Migrate authentication to Better Auth
Replace the hand-rolled JWT auth with Better Auth 1.6.25 httpOnly cookie sessions, validated against the database on every request so revocation, bans and role changes take effect immediately. Backend: - betterAuth.ts wires the Drizzle adapter, magic links, Google sign-in and the admin plugin; auth-schema.ts maps Better Auth's models onto the existing `users` table so user IDs and their foreign keys survive intact. - routes/auth.ts is gone; Better Auth serves the standard endpoints and authExt.ts carries the flows it doesn't cover. - auth.ts shrinks to session resolution and helpers; sessions/revocation in dashboard.ts now read and delete `auth_sessions` rows directly. - Schema adds the Better Auth core + admin columns (email_verified, image, banned, ban_reason, ban_expires), with migrations and tests. - rateLimit.ts resolves client IPs spoof-resistantly: proxy headers are only honoured from loopback/RFC1918 peers plus TRUSTED_PROXIES. - passwordPolicy.ts centralises password validation. - Bump drizzle-orm, drizzle-kit and better-sqlite3 to versions compatible with Better Auth. Frontend: - auth-client.ts plus a reworked AuthContext and api/client.ts move to cookie-based sessions; no more bearer tokens in requests or middleware. photo-api: - Validate Better Auth session cookies against the shared auth_sessions table instead of verifying JWTs; JWT_SECRET is no longer needed for user auth, and PHOTO_VIEW_SECRET now signs gallery view tokens. BETTER_AUTH_SECRET and BETTER_AUTH_URL are required in production; the deprecated JWT_SECRET stays only as the photo-api view-token fallback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4afa5d6fa0
commit
733d2459df
@@ -1,27 +1,31 @@
|
||||
// Package auth validates the JWTs minted by backend/src/lib/auth.ts:
|
||||
// HS256 with the shared JWT_SECRET, issuer "spanglish", audience
|
||||
// "spanglish-app", plus the same DB-backed tokenVersion / account_status
|
||||
// revocation check the backend's getAuthUser performs.
|
||||
// Package auth validates the Better Auth sessions issued by the backend.
|
||||
// The session cookie value is "<token>.<signature>" (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"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"time"
|
||||
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
issuer = "spanglish"
|
||||
audience = "spanglish-app"
|
||||
)
|
||||
// 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 bearer token")
|
||||
ErrInvalidToken = errors.New("invalid token")
|
||||
ErrNoToken = errors.New("no session token")
|
||||
ErrInvalidToken = errors.New("invalid session")
|
||||
)
|
||||
|
||||
// AdminRoles mirrors backend/src/routes/media.ts: photo administration is
|
||||
@@ -43,57 +47,61 @@ func (u User) IsAdmin() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type claims struct {
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
TokenVersion *int `json:"tokenVersion"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type Verifier struct {
|
||||
secret []byte
|
||||
db *store.DB
|
||||
db *store.DB
|
||||
}
|
||||
|
||||
func NewVerifier(secret string, db *store.DB) *Verifier {
|
||||
return &Verifier{secret: []byte(secret), db: db}
|
||||
func NewVerifier(db *store.DB) *Verifier {
|
||||
return &Verifier{db: db}
|
||||
}
|
||||
|
||||
// FromRequest returns the authenticated user, ErrNoToken when no
|
||||
// Authorization header is present, or ErrInvalidToken for anything bad.
|
||||
// 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) {
|
||||
header := r.Header.Get("Authorization")
|
||||
if header == "" {
|
||||
token := sessionTokenFromRequest(r)
|
||||
if token == "" {
|
||||
return User{}, ErrNoToken
|
||||
}
|
||||
raw, ok := strings.CutPrefix(header, "Bearer ")
|
||||
if !ok {
|
||||
return User{}, ErrInvalidToken
|
||||
}
|
||||
|
||||
var c claims
|
||||
_, err := jwt.ParseWithClaims(raw, &c, func(*jwt.Token) (any, error) { return v.secret, nil },
|
||||
jwt.WithValidMethods([]string{"HS256"}),
|
||||
jwt.WithIssuer(issuer),
|
||||
jwt.WithAudience(audience),
|
||||
jwt.WithExpirationRequired(),
|
||||
)
|
||||
if err != nil || c.Subject == "" {
|
||||
return User{}, ErrInvalidToken
|
||||
}
|
||||
|
||||
// DB-backed revocation, same semantics as backend getAuthUser
|
||||
// (backend/src/lib/auth.ts:320-327).
|
||||
ua, err := v.db.GetUserAuth(r.Context(), c.Subject)
|
||||
su, err := v.db.GetSessionUser(r.Context(), token)
|
||||
if err != nil {
|
||||
return User{}, ErrInvalidToken
|
||||
}
|
||||
if ua.AccountStatus != "active" {
|
||||
if !su.ExpiresAt.After(time.Now()) {
|
||||
return User{}, ErrInvalidToken
|
||||
}
|
||||
if c.TokenVersion != nil && *c.TokenVersion != ua.TokenVersion {
|
||||
// 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 from the DB, not the token, so demotions apply immediately.
|
||||
return User{ID: ua.ID, Email: c.Email, Role: ua.Role}, nil
|
||||
// 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user