// Package apitoken issues and verifies the tokens non-browser clients use. // // Jellyfin clients send `X-Emby-Token` (or `api_key=`); Subsonic clients send // `t=md5(key+salt)`. Neither speaks the web app's session cookie, so these // tokens are a second, revocable way to be a user. They do not weaken the cookie // model: a token resolves to the same auth.User and every caller still goes // through the same grant chokepoint. // // Two storage shapes, deliberately: // // - A Jellyfin token is a bearer value, so only its hash is stored — the same // reasoning as session tokens. A stolen database yields nothing usable. // - A Subsonic key must be recoverable, because verifying `t` means computing // md5(key + salt) server-side. So Subsonic keys are stored in the clear. // That is the Navidrome approach, and it is why the key is a generated // random secret rather than anything derived from the user's password. package apitoken import ( "context" "crypto/rand" "crypto/sha256" "database/sql" "encoding/hex" "errors" "fmt" "github.com/mithral/vellum/internal/core" ) // Kinds of token. A client family, not a permission. const ( KindJellyfin = "jellyfin" KindSubsonic = "subsonic" ) // ErrNotFound means no token matched. var ErrNotFound = errors.New("apitoken: not found") // Token is one issued credential. It never carries the secret itself. type Token struct { ID int64 UserID int64 Kind string Label string CreatedAt string LastUsedAt string ExpiresAt string } // Mint issues a new token for a user and returns the secret exactly once. // // The caller must show it to the user immediately; it is not recoverable for // Jellyfin afterwards (only its hash is kept). For Subsonic it is recoverable, // because that protocol requires it — see the package comment. func Mint(ctx context.Context, db *sql.DB, userID int64, kind, label string) (string, error) { if kind != KindJellyfin && kind != KindSubsonic { return "", fmt.Errorf("apitoken: unknown kind %q", kind) } var raw [32]byte if _, err := rand.Read(raw[:]); err != nil { return "", fmt.Errorf("apitoken: read random: %w", err) } secret := hex.EncodeToString(raw[:]) plain := "" if kind == KindSubsonic { // Recoverable by necessity: verifying `t` needs the key itself. plain = secret } _, err := db.ExecContext(ctx, ` INSERT INTO api_tokens (user_id, kind, label, token_hash, token_plain, created_at) VALUES (?, ?, ?, ?, ?, ?)`, userID, kind, label, Hash(secret), plain, core.Now()) if err != nil { return "", fmt.Errorf("apitoken: insert: %w", err) } return secret, nil } // Hash is the lookup digest for a token. Exported so the Jellyfin layer can // resolve a bearer value without duplicating the scheme. func Hash(secret string) string { sum := sha256.Sum256([]byte(secret)) return hex.EncodeToString(sum[:]) } // Resolve finds the user a token belongs to, and records the use. // // An empty or unknown token is ErrNotFound; the caller turns that into a 401. // An expired token is also ErrNotFound, so callers cannot accidentally treat an // expired credential as valid by checking only the error's absence. func Resolve(ctx context.Context, db *sql.DB, secret string) (int64, error) { if secret == "" { return 0, ErrNotFound } var ( id int64 userID int64 expires string ) err := db.QueryRowContext(ctx, `SELECT id, user_id, expires_at FROM api_tokens WHERE token_hash = ?`, Hash(secret)).Scan(&id, &userID, &expires) if errors.Is(err, sql.ErrNoRows) { return 0, ErrNotFound } if err != nil { return 0, fmt.Errorf("apitoken: resolve: %w", err) } if expires != "" && expires < core.Now() { return 0, ErrNotFound } // Best-effort: a failed touch must not fail the request. _, _ = db.ExecContext(ctx, `UPDATE api_tokens SET last_used_at = ? WHERE id = ?`, core.Now(), id) return userID, nil } // Plain returns the recoverable secret for a Subsonic token. Jellyfin tokens // have none by design, so this returns ErrNotFound for them. func Plain(ctx context.Context, db *sql.DB, userID int64) (string, error) { var plain string err := db.QueryRowContext(ctx, `SELECT token_plain FROM api_tokens WHERE user_id = ? AND kind = ? AND token_plain != '' ORDER BY id LIMIT 1`, userID, KindSubsonic).Scan(&plain) if errors.Is(err, sql.ErrNoRows) { return "", ErrNotFound } if err != nil { return "", fmt.Errorf("apitoken: plain: %w", err) } return plain, nil } // ForUser lists a user's tokens, newest first. func ForUser(ctx context.Context, db *sql.DB, userID int64) ([]Token, error) { rows, err := db.QueryContext(ctx, ` SELECT id, user_id, kind, label, created_at, last_used_at, expires_at FROM api_tokens WHERE user_id = ? ORDER BY id DESC`, userID) if err != nil { return nil, fmt.Errorf("apitoken: list: %w", err) } defer rows.Close() out := []Token{} for rows.Next() { var t Token if err := rows.Scan(&t.ID, &t.UserID, &t.Kind, &t.Label, &t.CreatedAt, &t.LastUsedAt, &t.ExpiresAt); err != nil { return nil, err } out = append(out, t) } return out, rows.Err() } // Revoke deletes a token, scoped to its owner so one user cannot revoke // another's. Reports whether anything was removed. func Revoke(ctx context.Context, db *sql.DB, userID, tokenID int64) (bool, error) { res, err := db.ExecContext(ctx, `DELETE FROM api_tokens WHERE id = ? AND user_id = ?`, tokenID, userID) if err != nil { return false, fmt.Errorf("apitoken: revoke: %w", err) } n, err := res.RowsAffected() return n > 0, err }