// Package auth validates the bearer tokens issued by the go-loose SSO service // and identifies the caller. package auth import ( "context" "errors" "fmt" "strings" "github.com/golang-jwt/jwt/v5" "go-places-api/internal/domain" ) // Errors returned by verifiers. They are deliberately coarse: a caller learns // that a token is unacceptable, not why, so probing cannot distinguish an // expired token from a forged one. var ( // ErrMissingToken means no credential was presented. ErrMissingToken = errors.New("auth: no token presented") // ErrInvalidToken means the token failed verification for any reason. ErrInvalidToken = errors.New("auth: invalid token") ) // Claims are the fields the application reads from a go-loose token. type Claims struct { jwt.RegisteredClaims // Username and Email are copied from the identity provider. They are // optional because a token may only assert the subject. Username string `json:"username,omitempty"` Email string `json:"email,omitempty"` } // User is the identity resolved from a token. type User struct { domain.User } // Verifier turns a bearer token into an identity. type Verifier interface { // Verify returns the identity the token asserts. It returns // ErrMissingToken or ErrInvalidToken for unusable credentials. Verify(ctx context.Context, token string) (User, error) // Name identifies the verification strategy, for logging. Name() string } // JWTVerifier verifies HS256-signed tokens issued by go-loose. // // Only HS256 is accepted. The algorithm is pinned rather than read from the // token header so that a token cannot select "none" or an asymmetric algorithm // and slip past verification. type JWTVerifier struct { secret []byte audience string issuer string } var _ Verifier = (*JWTVerifier)(nil) // NewJWTVerifier returns a verifier for tokens signed with secret. audience may // be empty to skip the audience check; issuer likewise. func NewJWTVerifier(secret, audience, issuer string) *JWTVerifier { return &JWTVerifier{secret: []byte(secret), audience: audience, issuer: issuer} } // Name implements Verifier. func (v *JWTVerifier) Name() string { return "jwt" } // Verify parses and validates the token, then extracts the identity. func (v *JWTVerifier) Verify(ctx context.Context, token string) (User, error) { if err := ctx.Err(); err != nil { return User{}, err } if token == "" { return User{}, ErrMissingToken } if len(v.secret) == 0 { return User{}, fmt.Errorf("%w: no signing secret configured", ErrInvalidToken) } opts := []jwt.ParserOption{ jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithExpirationRequired(), } if v.audience != "" { opts = append(opts, jwt.WithAudience(v.audience)) } if v.issuer != "" { opts = append(opts, jwt.WithIssuer(v.issuer)) } var claims Claims parsed, err := jwt.ParseWithClaims(token, &claims, keyFunc(v.secret), opts...) if err != nil { // The underlying reason stays unexported so the caller cannot use the // error text to learn why a token was rejected. return User{}, ErrInvalidToken } if !parsed.Valid { return User{}, ErrInvalidToken } if claims.Subject == "" { // Without a subject there is nobody to attribute the request to. return User{}, ErrInvalidToken } return User{domain.User{ ID: claims.Subject, Username: firstNonEmpty(claims.Username, claims.Subject), Email: claims.Email, }}, nil } // keyFunc supplies the verification key. The token's own "alg" header is // deliberately ignored because the parser is already pinned to HS256. func keyFunc(secret []byte) jwt.Keyfunc { return func(t *jwt.Token) (any, error) { return secret, nil } } // DevVerifier accepts a small set of fixed tokens for local development. It is // only wired up when AUTH_MODE=dev, and refuses to start in production. type DevVerifier struct { tokens map[string]domain.User } var _ Verifier = (*DevVerifier)(nil) // DefaultDevUser is the identity assumed by the "valid_token" dev token. var DefaultDevUser = domain.User{ ID: "dev-user", Username: "dev", Email: "dev@localhost", } // NewDevVerifier returns a verifier accepting the given token-to-user mapping. func NewDevVerifier(tokens map[string]domain.User) *DevVerifier { if tokens == nil { tokens = map[string]domain.User{"valid_token": DefaultDevUser} } return &DevVerifier{tokens: tokens} } // Name implements Verifier. func (v *DevVerifier) Name() string { return "dev" } // Verify looks the token up in the fixed table. func (v *DevVerifier) Verify(ctx context.Context, token string) (User, error) { if err := ctx.Err(); err != nil { return User{}, err } if token == "" { return User{}, ErrMissingToken } u, ok := v.tokens[token] if !ok { return User{}, ErrInvalidToken } return User{u}, nil } // BearerToken extracts the credential from an Authorization header value. // Both "Bearer " and a bare token are accepted, since some clients send // the latter. func BearerToken(header string) (string, error) { if strings.TrimSpace(header) == "" { return "", ErrMissingToken } // The scheme is split before trimming the credential, so "Bearer " with // nothing after it is reported as a missing token rather than read as a // bare token whose value is the word "Bearer". scheme, rest, hasSpace := strings.Cut(header, " ") if strings.EqualFold(scheme, "bearer") { token := strings.TrimSpace(rest) if !hasSpace || token == "" { return "", ErrMissingToken } return token, nil } header = strings.TrimSpace(header) // A bare token is accepted, but reject anything shaped like another scheme. if i := strings.IndexByte(header, ' '); i > 0 { return "", ErrInvalidToken } return header, nil } func firstNonEmpty(vals ...string) string { for _, v := range vals { if v != "" { return v } } return "" }