Files
silo-server/internal/audiobooks/abs_session_store.go
T
RXWatcherandClaude Opus 4.7 7619e84c8d 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>
2026-05-24 15:17:48 +02:00

99 lines
2.9 KiB
Go

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
}