feat(audiobooks): port ABS auth (JWT + login bridge)

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) <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-05-24 15:17:48 +02:00
co-authored by Claude Opus 4.7
parent 226d721ea3
commit 7619e84c8d
4 changed files with 402 additions and 2 deletions
+6 -2
View File
@@ -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/*)
+214
View File
@@ -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,
})
}
+98
View File
@@ -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
}
+84
View File
@@ -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
}