From 7619e84c8d0dcdb8f774d03df794a81bf6e79721 Mon Sep 17 00:00:00 2001 From: RXWatcher <14085001+RXWatcher@users.noreply.github.com> Date: Sun, 24 May 2026 15:17:48 +0200 Subject: [PATCH] feat(audiobooks): port ABS auth (JWT + login bridge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 of the ABS-compat port. Adds the handleLogin handler issuing JWT access + refresh tokens, a SiloCredValidator adapter that calls silo's auth.Service directly (in-process — the plugin previously HTTP-POSTed to /api/v1/auth/login), and an ABSSessionStore adapter backed by the abs_sessions table from migration 139. POST /abs/login and POST /login are mounted but not yet reachable until Stage 7 wires the audiobook service into the main router. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/audiobooks/abs/handler.go | 8 +- internal/audiobooks/abs/login.go | 214 +++++++++++++++++++++++ internal/audiobooks/abs_session_store.go | 98 +++++++++++ internal/audiobooks/cred_validator.go | 84 +++++++++ 4 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 internal/audiobooks/abs/login.go create mode 100644 internal/audiobooks/abs_session_store.go create mode 100644 internal/audiobooks/cred_validator.go diff --git a/internal/audiobooks/abs/handler.go b/internal/audiobooks/abs/handler.go index d0ee2512..6fea5a33 100644 --- a/internal/audiobooks/abs/handler.go +++ b/internal/audiobooks/abs/handler.go @@ -148,8 +148,12 @@ func (h *Handler) Mount(parent chi.Router) { }) } -func (h *Handler) mountRoutes(_ chi.Router) { - // TODO Stage 2: auth routes (login, logout, refresh, authorize, me, ping, status, init) +func (h *Handler) mountRoutes(r chi.Router) { + // Stage 2: login (body-creds + host-proxied paths). + r.Post("/login", h.handleLogin) + r.Post("/abs/api/login", h.handleLogin) + + // TODO Stage 2 (remaining): logout, refresh, authorize, me, ping, status, init // TODO Stage 3: library browse routes (libraries, items, item detail, cover, authors, series, search, personalized) // TODO Stage 4: playback session + file routes (play, file/download, public/track) // TODO Stage 5: progress routes (me/progress/*, me/items-in-progress, me/listening-stats, me/stats/year/*) diff --git a/internal/audiobooks/abs/login.go b/internal/audiobooks/abs/login.go new file mode 100644 index 00000000..efc5990b --- /dev/null +++ b/internal/audiobooks/abs/login.go @@ -0,0 +1,214 @@ +package abs + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "time" + + "github.com/oklog/ulid/v2" + + "github.com/Silo-Server/silo-server/internal/auth" +) + +// ErrNotFound is returned by TokenStore when a JTI is absent. +var ErrNotFound = errors.New("abs token not found") + +// handleLogin mints ABS access + refresh JWTs for the caller. +// +// Two paths: +// +// 1. Host-proxied: X-Silo-User-Id header is present. The silo host validated +// the session before forwarding, so the header is trusted unconditionally. +// ProfileID comes from X-Silo-Profile-Id (may be empty = primary profile). +// +// 2. Standalone body-creds: no host header. The handler delegates to +// handleStandaloneLogin, which calls CredValidator.Validate and applies +// rate limiting. +func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { + if userID := r.Header.Get("X-Silo-User-Id"); userID != "" { + profileID := r.Header.Get("X-Silo-Profile-Id") + name := r.Header.Get("X-Silo-Profile-Name") + if name == "" { + name = r.Header.Get("X-Silo-User-Name") + } + h.completeLogin(w, r, userID, profileID, name) + return + } + h.handleStandaloneLogin(w, r) +} + +// handleStandaloneLogin validates body-credential login when no host-proxy +// header is present. It checks whether standalone login is enabled, enforces +// the per-IP rate limit, decodes the JSON body, and calls CredValidator. +func (h *Handler) handleStandaloneLogin(w http.ResponseWriter, r *http.Request) { + if h.deps.Config == nil { + http.Error(w, "config not available", http.StatusServiceUnavailable) + return + } + enabled, err := h.deps.Config.StandaloneLoginEnabled(r.Context()) + if err != nil { + http.Error(w, "config unavailable", http.StatusInternalServerError) + return + } + if !enabled { + http.Error(w, "standalone login is disabled on this server", http.StatusUnauthorized) + return + } + if h.deps.CredValidator == nil { + http.Error(w, "standalone login is unavailable in this deployment", http.StatusServiceUnavailable) + return + } + + ip := clientIP(r) + if !h.deps.LoginLimiter.allow(ip) { + w.Header().Set("Retry-After", "60") + http.Error(w, "too many login attempts; try again shortly", http.StatusTooManyRequests) + return + } + + var body struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if strings.TrimSpace(body.Username) == "" || body.Password == "" { + http.Error(w, "username and password are required", http.StatusUnauthorized) + return + } + + userID, profileID, displayName, err := h.deps.CredValidator.Validate(r.Context(), body.Username, body.Password) + if err != nil { + if errors.Is(err, auth.ErrInvalidCredentials) || errors.Is(err, auth.ErrUserDisabled) { + http.Error(w, "invalid username or password", http.StatusUnauthorized) + } else { + http.Error(w, "login service unavailable", http.StatusServiceUnavailable) + } + return + } + + // If the validator didn't return a displayName, derive it from the username + // (the profile portion after '#', or the whole username). + if displayName == "" { + displayName = body.Username + if i := strings.LastIndexByte(displayName, '#'); i >= 0 && i < len(displayName)-1 { + displayName = displayName[i+1:] + } + } + + h.completeLogin(w, r, userID, profileID, displayName) +} + +// completeLogin mints ABS access + refresh JWTs for the validated user and +// writes the login response. Shared by the host-proxied and body-creds paths +// so both return the identical response envelope. +func (h *Handler) completeLogin(w http.ResponseWriter, r *http.Request, userID, profileID, displayName string) { + if h.deps.Config == nil { + http.Error(w, "config not available", http.StatusServiceUnavailable) + return + } + if h.deps.TokenStore == nil { + http.Error(w, "token store not available", http.StatusServiceUnavailable) + return + } + + secret, err := h.deps.Config.JWTSecret(r.Context()) + if err != nil { + http.Error(w, "jwt secret unavailable", http.StatusInternalServerError) + return + } + + accessTTL, err := h.deps.Config.AccessTTL(r.Context()) + if err != nil || accessTTL == 0 { + accessTTL = 24 * time.Hour + } + refreshTTL, err := h.deps.Config.RefreshTTL(r.Context()) + if err != nil || refreshTTL == 0 { + refreshTTL = 30 * 24 * time.Hour + } + + accessJTI := ulid.Make().String() + refreshJTI := ulid.Make().String() + + access, err := IssueAccessToken(secret, userID, profileID, accessJTI, accessTTL) + if err != nil { + http.Error(w, "mint access token: "+err.Error(), http.StatusInternalServerError) + return + } + refresh, err := IssueRefreshToken(secret, userID, profileID, refreshJTI, refreshTTL) + if err != nil { + http.Error(w, "mint refresh token: "+err.Error(), http.StatusInternalServerError) + return + } + + now := time.Now() + if err := h.deps.TokenStore.InsertToken(r.Context(), ABSToken{ + ID: accessJTI, + UserID: userID, + ProfileID: profileID, + JTI: accessJTI, + ExpiresAt: now.Add(accessTTL), + }); err != nil { + http.Error(w, "persist access token: "+err.Error(), http.StatusInternalServerError) + return + } + // Refresh-token insert must also succeed: a client that receives a refresh + // token whose JTI isn't in the store will fail on its first use (the + // bearer middleware looks up by JTI), forcing an interactive re-login. + if err := h.deps.TokenStore.InsertToken(r.Context(), ABSToken{ + ID: refreshJTI, + UserID: userID, + ProfileID: profileID, + JTI: refreshJTI, + ExpiresAt: now.Add(refreshTTL), + }); err != nil { + http.Error(w, "persist refresh token: "+err.Error(), http.StatusInternalServerError) + return + } + + // Build user object. displayName falls back to userID when empty. + name := displayName + if name == "" { + name = userID + } + + user := map[string]any{ + "id": userID, + "username": name, + "type": "user", + "permissions": map[string]any{ + "update": true, + "delete": true, + "download": true, + "accessExplicitContent": true, + }, + // Legacy field for 2.17- clients. + "token": access, + } + + // x-return-tokens opt-in: when set, embed token pair on user object too + // (some clients read from the user envelope, others from the top level). + if strings.EqualFold(r.Header.Get("x-return-tokens"), "true") { + user["accessToken"] = access + user["refreshToken"] = refresh + } + + writeJSON(w, http.StatusOK, map[string]any{ + "user": user, + "serverSettings": map[string]any{ + "id": "server-settings", + "version": "2.35.0", + "language": "en-us", + "buildNumber": 1, + "authActiveAuthMethods": []string{"local"}, + }, + "ereaderDevices": []any{}, + "accessToken": access, + "refreshToken": refresh, + }) +} diff --git a/internal/audiobooks/abs_session_store.go b/internal/audiobooks/abs_session_store.go new file mode 100644 index 00000000..08966f73 --- /dev/null +++ b/internal/audiobooks/abs_session_store.go @@ -0,0 +1,98 @@ +package audiobooks + +import ( + "context" + "errors" + "fmt" + "strconv" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/audiobooks/abs" +) + +// ABSSessionStore implements abs.TokenStore on the abs_sessions table +// (migration 139). Each JTI is stored as the `token` column; revocation sets +// `revoked_at`. The table stores integer user_id, so we parse the string +// UserID from ABSToken back to int. +type ABSSessionStore struct { + Pool *pgxpool.Pool +} + +// InsertToken inserts a newly minted JTI into abs_sessions. +// Duplicate tokens are silently ignored (ON CONFLICT DO NOTHING) so +// concurrent requests using the same JTI are idempotent. +func (s *ABSSessionStore) InsertToken(ctx context.Context, tok abs.ABSToken) error { + uid, err := strconv.Atoi(tok.UserID) + if err != nil { + return fmt.Errorf("abs_session_store: invalid user id %q: %w", tok.UserID, err) + } + _, err = s.Pool.Exec(ctx, ` + INSERT INTO abs_sessions + (user_id, token, device_id, device_name, client_name, client_version, last_seen_at) + VALUES ($1, $2, $3, $4, $5, $6, now()) + ON CONFLICT (token) DO NOTHING`, + uid, + tok.JTI, + tok.JTI, // device_id: use the JTI as a stable device key + "", // device_name: unknown at login time + "abs-compat", + "", + ) + if err != nil { + return fmt.Errorf("abs_session_store: insert token: %w", err) + } + return nil +} + +// GetTokenByJTI looks up an abs_sessions row by its JTI (stored in `token`). +// Returns abs.ErrNotFound when the row doesn't exist. +func (s *ABSSessionStore) GetTokenByJTI(ctx context.Context, jti string) (abs.ABSToken, error) { + var ( + uid int + profileID *string + tok abs.ABSToken + ) + row := s.Pool.QueryRow(ctx, ` + SELECT user_id, token, revoked_at + FROM abs_sessions + WHERE token = $1`, jti) + err := row.Scan(&uid, &tok.JTI, &tok.RevokedAt) + if errors.Is(err, pgx.ErrNoRows) { + return abs.ABSToken{}, abs.ErrNotFound + } + if err != nil { + return abs.ABSToken{}, fmt.Errorf("abs_session_store: get token: %w", err) + } + tok.ID = tok.JTI + tok.UserID = strconv.Itoa(uid) + if profileID != nil { + tok.ProfileID = *profileID + } + return tok, nil +} + +// RevokeTokenByJTI sets revoked_at to now() for the given JTI. +// Idempotent: revoking an already-revoked token is a no-op. +func (s *ABSSessionStore) RevokeTokenByJTI(ctx context.Context, jti string) error { + _, err := s.Pool.Exec(ctx, ` + UPDATE abs_sessions SET revoked_at = now() + WHERE token = $1 AND revoked_at IS NULL`, jti) + if err != nil { + return fmt.Errorf("abs_session_store: revoke token: %w", err) + } + return nil +} + +// TouchToken bumps last_seen_at for active-session bookkeeping. +// Errors are logged by the caller; we never gate a valid request on this. +func (s *ABSSessionStore) TouchToken(ctx context.Context, jti string) error { + _, err := s.Pool.Exec(ctx, ` + UPDATE abs_sessions SET last_seen_at = now() + WHERE token = $1`, jti) + if err != nil { + return fmt.Errorf("abs_session_store: touch token: %w", err) + } + return nil +} diff --git a/internal/audiobooks/cred_validator.go b/internal/audiobooks/cred_validator.go new file mode 100644 index 00000000..9819840f --- /dev/null +++ b/internal/audiobooks/cred_validator.go @@ -0,0 +1,84 @@ +package audiobooks + +import ( + "context" + "errors" + "fmt" + "strconv" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/auth" +) + +// SiloCredValidator implements abs.ProfileCredentialValidator using silo's +// existing auth.Service. In-process validation avoids the HTTP round-trip +// the plugin previously made to POST /api/v1/auth/login. +type SiloCredValidator struct { + Auth *auth.Service + Pool *pgxpool.Pool +} + +// Validate checks (username, password) against silo's local auth provider and +// returns the string user ID, primary profile ID, and display name. +// +// The ABS compat layer represents user/profile IDs as strings; we format the +// integer user ID and UUID profile ID accordingly. If the user has no profiles +// yet, profileID is returned empty and the ABS handler treats the missing +// profile as "primary". +func (v *SiloCredValidator) Validate( + ctx context.Context, + username, password string, +) (userID, profileID, displayName string, err error) { + if v.Auth == nil { + return "", "", "", errors.New("auth service not configured") + } + + // DeviceName and IP are informational for session bookkeeping only. + _, user, err := v.Auth.Login(ctx, username, password, "abs-compat", "") + if err != nil { + // Propagate auth sentinel errors as-is so callers can distinguish + // "bad credentials" (ErrInvalidCredentials) from "service down". + return "", "", "", err + } + + userIDStr := strconv.Itoa(user.ID) + displayName = user.Username + + // Resolve the primary profile for this user from the central + // user_profiles table. Primary is the first profile created per user + // (is_primary = true). If none exists, return empty profileID. + pid, pname, pidErr := primaryProfileForUser(ctx, v.Pool, user.ID) + if pidErr == nil && pid != "" { + profileID = pid + if pname != "" { + displayName = pname + } + } + + return userIDStr, profileID, displayName, nil +} + +// primaryProfileForUser returns the (id, name) of the primary profile for +// the given user from the user_profiles table. Returns ("", "", nil) when no +// profiles exist (newly created user, pre-profile-setup). +func primaryProfileForUser(ctx context.Context, pool *pgxpool.Pool, userID int) (id, name string, err error) { + if pool == nil { + return "", "", fmt.Errorf("no pgx pool available") + } + row := pool.QueryRow(ctx, + `SELECT id, name FROM user_profiles + WHERE user_id = $1 AND is_primary = TRUE + LIMIT 1`, + userID, + ) + err = row.Scan(&id, &name) + if errors.Is(err, pgx.ErrNoRows) { + // No primary profile — acceptable for a brand-new user or a user who + // hasn't set up profiles yet. Return empty strings; the ABS handler + // interprets empty profileID as "use primary / no profile". + return "", "", nil + } + return id, name, err +}