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:
Michilis
2026-07-29 19:07:04 +00:00
co-authored by Claude Opus 5
parent 4afa5d6fa0
commit 733d2459df
47 changed files with 2430 additions and 1585 deletions
+57 -49
View File
@@ -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
}