// Package auth validates the Better Auth sessions issued by the backend. // The session cookie value is "." (HMAC-signed with the // backend's BETTER_AUTH_SECRET); this service resolves the token part against // the shared auth_sessions table, which is the authoritative validity check // (the token carries 32+ characters of entropy, and a DB hit is required // anyway for expiry/ban/role, so revocations apply here instantly). package auth import ( "errors" "net/http" "net/url" "strings" "time" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" ) // Cookie names set by the backend: advanced.cookiePrefix "spanglish", with // the __Secure- prefix added in production (advanced.useSecureCookies). var sessionCookieNames = []string{ "__Secure-spanglish.session_token", "spanglish.session_token", } var ( ErrNoToken = errors.New("no session token") ErrInvalidToken = errors.New("invalid session") ) // AdminRoles mirrors backend/src/routes/media.ts: photo administration is // admin/organizer only (review decision #6). var AdminRoles = []string{"admin", "organizer"} type User struct { ID string Email string Role string } func (u User) IsAdmin() bool { for _, r := range AdminRoles { if u.Role == r { return true } } return false } type Verifier struct { db *store.DB } func NewVerifier(db *store.DB) *Verifier { return &Verifier{db: db} } // FromRequest returns the authenticated user, ErrNoToken when no session // cookie or bearer token is present, or ErrInvalidToken for anything bad. func (v *Verifier) FromRequest(r *http.Request) (User, error) { token := sessionTokenFromRequest(r) if token == "" { return User{}, ErrNoToken } su, err := v.db.GetSessionUser(r.Context(), token) if err != nil { return User{}, ErrInvalidToken } if !su.ExpiresAt.After(time.Now()) { return User{}, ErrInvalidToken } // Same semantics as backend getAuthUser: suspended (banned) or unclaimed // accounts must not retain API access via a live session. if su.Banned || su.AccountStatus != "active" { return User{}, ErrInvalidToken } // Role comes from the users row, so demotions apply immediately. return User{ID: su.ID, Email: su.Email, Role: su.Role}, nil } // sessionTokenFromRequest extracts the raw session token from the Better Auth // cookie, or from an Authorization: Bearer header (tests and CLI tooling). func sessionTokenFromRequest(r *http.Request) string { for _, name := range sessionCookieNames { if c, err := r.Cookie(name); err == nil && c.Value != "" { return tokenPart(c.Value) } } if raw, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok && raw != "" { return tokenPart(raw) } return "" } // tokenPart strips the URL-encoding and HMAC signature from a cookie value: // "token.base64sig" -> "token". The token itself is alphanumeric, so the // first '.' always separates it from the signature. func tokenPart(v string) string { if dec, err := url.QueryUnescape(v); err == nil { v = dec } if i := strings.IndexByte(v, '.'); i >= 0 { return v[:i] } return v }