200 lines
5.7 KiB
Go
200 lines
5.7 KiB
Go
// 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 ""
|
|
}
|