// 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 import ( "errors" "net/http" "strings" "github.com/golang-jwt/jwt/v5" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" ) const ( issuer = "spanglish" audience = "spanglish-app" ) var ( ErrNoToken = errors.New("no bearer token") ErrInvalidToken = errors.New("invalid token") ) // 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 claims struct { Email string `json:"email"` Role string `json:"role"` TokenVersion *int `json:"tokenVersion"` jwt.RegisteredClaims } type Verifier struct { secret []byte db *store.DB } func NewVerifier(secret string, db *store.DB) *Verifier { return &Verifier{secret: []byte(secret), db: db} } // FromRequest returns the authenticated user, ErrNoToken when no // Authorization header is present, or ErrInvalidToken for anything bad. func (v *Verifier) FromRequest(r *http.Request) (User, error) { header := r.Header.Get("Authorization") if header == "" { 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) if err != nil { return User{}, ErrInvalidToken } if ua.AccountStatus != "active" { return User{}, ErrInvalidToken } if c.TokenVersion != nil && *c.TokenVersion != ua.TokenVersion { 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 }