go-places/api/internal/auth/auth.go

201 lines
5.7 KiB
Go
Raw Permalink Normal View History

2026-10-07 15:14:50 +02:00
// 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 <token>" 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 ""
}