Files
silo-server/internal/audiobooks/cred_validator.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

85 lines
2.7 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/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
}