initial commit
This commit is contained in:
commit
1253955070
75 changed files with 13972 additions and 0 deletions
200
api/internal/auth/auth.go
Normal file
200
api/internal/auth/auth.go
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
// 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 ""
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue