Vellum now speaks two foreign protocols, so the clients people already own can
use it. Both are translation layers over queries that already exist, and both go
through the same grant chokepoint as the web app — a token is a second way to be
a user, never a second set of permissions.
Shared foundation (coordinator, written before dispatch)
- migrations/0012_api_tokens.sql + internal/apitoken. Both protocols need a
credential that is not the session cookie, and letting two agents each invent
a token table would have produced two incompatible ones.
- The table stores two shapes for a real reason: a Jellyfin token is a bearer
value so only its hash is kept, while a Subsonic key must be recoverable
because verifying `t` means computing md5(key + salt) server-side. token_plain
is therefore the one column in the schema holding a usable credential. It is a
generated random secret, never a user password, and it is revocable.
Wave 9 - Jellyfin
- /System/Info/Public, /Users/AuthenticateByName, /Users/Me, /Users/{id},
/Items, /Users/{id}/Items, /Items/{id}, /Items/{id}/Images/Primary,
/Items/{id}/PlaybackInfo, /Videos/{id}/stream, /Audio/{id}/stream,
/Sessions/Playing{,/Progress,/Stopped}.
- Auth accepts X-Emby-Token, X-MediaBrowser-Token, ?api_key=, and
Authorization: MediaBrowser Token="...". The ?api_key= form is the one that
matters: <video> elements cannot set headers.
- Honest about the one thing Vellum cannot do. PlaybackInfo reports
SupportsDirectPlay/SupportsDirectStream true and SupportsTranscoding FALSE,
with a DirectStreamUrl carrying the api_key. A client that disables both
direct modes is demanding a transcode and is refused with ErrorCode
"NoCompatibleStream" — rather than handed a TranscodingUrl that would never
resolve, which is how a client hangs forever.
Wave 10 - Subsonic
- /rest/{method} dispatch with ping, getLicense, getMusicFolders, getIndexes,
getArtists/getArtist/getAlbum, getMusicDirectory, getSong, search3, stream,
getCoverArt, getAlbumList2, getRandomSongs, scrobble, getUser, getPlaylists,
plus GET /api/v1/subsonic/credentials to hand a user their key.
- Auth via t=md5(key+salt), p=key, constant-time comparison. A bad credential
returns HTTP 200 with the envelope status "failed" and error code 40 — NOT an
HTTP 401. Subsonic clients read the envelope, and getting this wrong is the
single most common integration failure in that protocol.
- stream supports format=raw and never invokes ffmpeg.
Coordinator wired both into main.go (the line agents were told to leave alone)
and verified against real requests.
Verified end to end, 18 packages pass, gofmt/vet clean, svelte-check 0/0:
- Jellyfin: discovery, AuthenticateByName minting a token, /Items reporting 18,
PlaybackInfo showing SupportsTranscoding=false, and a Range request to
/Videos/{id}/stream using ONLY ?api_key= returning 206 with Content-Range.
- Subsonic: ping as JSON and XML, md5(key+salt) auth succeeding, and a bad token
returning the failed envelope with code 40.
- Scoping, both protocols, with a genuinely non-admin user: 0 items, 404 on an
admin-only item, 404 on its stream, 0 music folders; admin still sees 18.
- The SPA is not shadowed by the root-mounted compat paths: /, /healthz and
/api/v1/version all still answer correctly.
Both agents failed with an HTTP 400 at the REPORT stage, not during the work;
their files were complete and compile, so the work was salvaged and verified
here rather than redone.
172 lines
5.4 KiB
Go
172 lines
5.4 KiB
Go
// 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
|
|
}
|