// Package auth provides JWT issuance + verification for the BFF. // // Tokens carry these claims: // // sub user id (UUID string) // tv token_version snapshot — middleware rejects if it doesn't match the // current value on the user row. Bumping the user's token_version // invalidates every JWT ever issued to that user (used on password // change and forced logout). // iat issued-at // exp expiry package auth import ( "errors" "fmt" "time" "github.com/golang-jwt/jwt/v5" ) // Claims is the JWT payload we sign + verify. type Claims struct { UserID string `json:"sub"` TokenVersion int `json:"tv"` jwt.RegisteredClaims } // Issuer issues and verifies tokens with a single shared HMAC secret. type Issuer struct { secret []byte ttl time.Duration } // NewIssuer returns an Issuer configured with the given HMAC secret and TTL. // The TTL is applied to every token issued via Issue(). func NewIssuer(secret string, ttl time.Duration) *Issuer { return &Issuer{secret: []byte(secret), ttl: ttl} } // Issue mints a signed JWT for the given user. func (i *Issuer) Issue(userID string, tokenVersion int) (string, time.Time, error) { exp := time.Now().Add(i.ttl) claims := &Claims{ UserID: userID, TokenVersion: tokenVersion, RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(exp), }, } tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) signed, err := tok.SignedString(i.secret) if err != nil { return "", time.Time{}, fmt.Errorf("sign jwt: %w", err) } return signed, exp, nil } // ErrInvalidToken is returned by Parse for any verification failure. var ErrInvalidToken = errors.New("invalid or expired token") // Parse validates the token's signature + expiry and returns the claims. // Callers must additionally compare claims.TokenVersion against the user's // current token_version (the JWT alone can't tell us whether the user has // since bumped their version). func (i *Issuer) Parse(raw string) (*Claims, error) { claims := &Claims{} tok, err := jwt.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } return i.secret, nil }) if err != nil || !tok.Valid { return nil, ErrInvalidToken } return claims, nil } // TTL returns the configured token lifetime. Handlers use it to set cookie // Max-Age so the cookie and the JWT expire in lockstep. func (i *Issuer) TTL() time.Duration { return i.ttl }