initial commit

This commit is contained in:
Daniel Noulet 2026-10-07 15:14:50 +02:00
commit 1253955070
75 changed files with 13972 additions and 0 deletions

200
api/internal/auth/auth.go Normal file
View 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 ""
}

View file

@ -0,0 +1,360 @@
package auth
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
const testSecret = "test-signing-secret"
// sign builds a token with the given claims and method.
func sign(t *testing.T, method jwt.SigningMethod, claims jwt.Claims) string {
t.Helper()
s, err := jwt.NewWithClaims(method, claims).SignedString([]byte(testSecret))
if err != nil {
t.Fatalf("SignedString: %v", err)
}
return s
}
// validClaims returns an unexpired claim set for the verifier under test.
func validClaims() Claims {
return Claims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: "user-42",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
},
Username: "ada",
Email: "ada@example.com",
}
}
func TestJWTVerifierAcceptsValidToken(t *testing.T) {
t.Parallel()
v := NewJWTVerifier(testSecret, "", "")
got, err := v.Verify(context.Background(), sign(t, jwt.SigningMethodHS256, validClaims()))
if err != nil {
t.Fatalf("Verify: %v", err)
}
if got.ID != "user-42" {
t.Errorf("ID = %q, want user-42", got.ID)
}
if got.Username != "ada" {
t.Errorf("Username = %q, want ada", got.Username)
}
if got.Email != "ada@example.com" {
t.Errorf("Email = %q", got.Email)
}
if v.Name() != "jwt" {
t.Errorf("Name = %q, want jwt", v.Name())
}
}
func TestJWTVerifierFallsBackToSubjectForUsername(t *testing.T) {
t.Parallel()
// A token that only asserts a subject still has to yield a usable identity,
// so the username defaults to the subject.
claims := Claims{RegisteredClaims: jwt.RegisteredClaims{
Subject: "user-7",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
}}
v := NewJWTVerifier(testSecret, "", "")
got, err := v.Verify(context.Background(), sign(t, jwt.SigningMethodHS256, claims))
if err != nil {
t.Fatalf("Verify: %v", err)
}
if got.Username != "user-7" {
t.Errorf("Username = %q, want the subject as fallback", got.Username)
}
}
func TestJWTVerifierRejections(t *testing.T) {
t.Parallel()
expired := Claims{RegisteredClaims: jwt.RegisteredClaims{
Subject: "user-42",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)),
}}
noExp := Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: "user-42"}}
noSub := Claims{RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
}}
tests := []struct {
name string
token string
}{
{"empty", ""},
{"expired", sign(t, jwt.SigningMethodHS256, expired)},
{"missing expiration", sign(t, jwt.SigningMethodHS256, noExp)},
{"missing subject", sign(t, jwt.SigningMethodHS256, noSub)},
{"wrong secret", signWithSecret(t, jwt.SigningMethodHS256, validClaims(), "other-secret")},
// The algorithm is pinned, so a token cannot pick "none" and skip
// signature verification entirely.
{"alg none", unsignedToken(t, validClaims())},
{"garbage", "not-a-token"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
v := NewJWTVerifier(testSecret, "", "")
if _, err := v.Verify(context.Background(), tc.token); err == nil {
t.Error("Verify accepted a token it should have rejected")
}
})
}
}
func TestJWTVerifierReportsMissingTokenDistinctly(t *testing.T) {
t.Parallel()
// The middleware needs to tell "you sent nothing" from "what you sent is
// bad" so it can word the challenge properly.
v := NewJWTVerifier(testSecret, "", "")
if _, err := v.Verify(context.Background(), ""); !errors.Is(err, ErrMissingToken) {
t.Errorf("err = %v, want ErrMissingToken", err)
}
if _, err := v.Verify(context.Background(), "garbage"); !errors.Is(err, ErrInvalidToken) {
t.Errorf("err = %v, want ErrInvalidToken", err)
}
}
func TestJWTVerifierRequiresConfiguredSecret(t *testing.T) {
t.Parallel()
// An empty secret must reject everything rather than accept a token signed
// with an empty key.
v := NewJWTVerifier("", "", "")
_, err := v.Verify(context.Background(), sign(t, jwt.SigningMethodHS256, validClaims()))
if !errors.Is(err, ErrInvalidToken) {
t.Errorf("err = %v, want ErrInvalidToken", err)
}
}
func TestJWTVerifierEnforcesAudienceAndIssuer(t *testing.T) {
t.Parallel()
claims := validClaims()
claims.Audience = jwt.ClaimStrings{"go-places"}
claims.Issuer = "go-loose"
token := sign(t, jwt.SigningMethodHS256, claims)
matching := NewJWTVerifier(testSecret, "go-places", "go-loose")
if _, err := matching.Verify(context.Background(), token); err != nil {
t.Errorf("Verify with matching aud/iss: %v", err)
}
// A token minted for a different client must not be accepted, or a token
// issued for another service in the same cluster would grant access.
wrongAudience := NewJWTVerifier(testSecret, "some-other-app", "go-loose")
if _, err := wrongAudience.Verify(context.Background(), token); err == nil {
t.Error("Verify accepted a token with the wrong audience")
}
wrongIssuer := NewJWTVerifier(testSecret, "go-places", "some-other-issuer")
if _, err := wrongIssuer.Verify(context.Background(), token); err == nil {
t.Error("Verify accepted a token from the wrong issuer")
}
// Skipping the checks is the documented behaviour of the empty arguments.
unpinned := NewJWTVerifier(testSecret, "", "")
if _, err := unpinned.Verify(context.Background(), token); err != nil {
t.Errorf("Verify with no aud/iss configured: %v", err)
}
}
func TestJWTVerifierHonoursContextCancellation(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
cancel()
v := NewJWTVerifier(testSecret, "", "")
_, err := v.Verify(ctx, sign(t, jwt.SigningMethodHS256, validClaims()))
if !errors.Is(err, context.Canceled) {
t.Errorf("err = %v, want context.Canceled", err)
}
}
func TestDevVerifierAcceptsOnlyConfiguredTokens(t *testing.T) {
t.Parallel()
v := NewDevVerifier(nil)
got, err := v.Verify(context.Background(), "valid_token")
if err != nil {
t.Fatalf("Verify: %v", err)
}
if got.ID != DefaultDevUser.ID {
t.Errorf("ID = %q, want %q", got.ID, DefaultDevUser.ID)
}
if v.Name() != "dev" {
t.Errorf("Name = %q, want dev", v.Name())
}
if _, err := v.Verify(context.Background(), "anything-else"); !errors.Is(err, ErrInvalidToken) {
t.Errorf("err = %v, want ErrInvalidToken", err)
}
if _, err := v.Verify(context.Background(), ""); !errors.Is(err, ErrMissingToken) {
t.Errorf("err = %v, want ErrMissingToken", err)
}
}
func TestBearerToken(t *testing.T) {
t.Parallel()
tests := []struct {
name string
header string
want string
wantErr error
}{
{name: "standard", header: "Bearer abc.def.ghi", want: "abc.def.ghi"},
{name: "lowercase scheme", header: "bearer abc", want: "abc"},
{name: "mixed case scheme", header: "BeArEr abc", want: "abc"},
{name: "extra spaces", header: "Bearer abc ", want: "abc"},
{name: "bare token", header: "abc.def.ghi", want: "abc.def.ghi"},
{name: "empty", header: "", wantErr: ErrMissingToken},
{name: "whitespace only", header: " ", wantErr: ErrMissingToken},
{name: "scheme without token", header: "Bearer ", wantErr: ErrMissingToken},
// A header naming some other scheme must not be silently read as a
// bearer token.
{name: "other scheme", header: "Basic dXNlcjpwYXNz", wantErr: ErrInvalidToken},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := BearerToken(tc.header)
if tc.wantErr != nil {
if !errors.Is(err, tc.wantErr) {
t.Fatalf("err = %v, want %v", err, tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Errorf("token = %q, want %q", got, tc.want)
}
})
}
}
func TestMiddlewareAttachesUserToContext(t *testing.T) {
t.Parallel()
m := NewMiddleware(NewDevVerifier(nil))
var seen User
var ok bool
h := m.WrapFunc(func(w http.ResponseWriter, r *http.Request) {
seen, ok = FromContext(r.Context())
w.WriteHeader(http.StatusNoContent)
})
req := httptest.NewRequest(http.MethodGet, "/places", nil)
req.Header.Set("Authorization", "Bearer valid_token")
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
}
if !ok {
t.Fatal("the handler could not read the identity from the context")
}
if seen.ID != DefaultDevUser.ID {
t.Errorf("user = %q, want %q", seen.ID, DefaultDevUser.ID)
}
}
func TestMiddlewareRejectsUnusableCredentials(t *testing.T) {
t.Parallel()
m := NewMiddleware(NewDevVerifier(nil))
tests := []struct {
name string
header string
body string
}{
{name: "no header", body: "a bearer token is required"},
{name: "bad token", header: "Bearer nope", body: "the token was rejected"},
{name: "other scheme", header: "Basic dXNlcjpwYXNz", body: "the token was rejected"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Local to the subtest: the subtests run in parallel, so a shared
// flag would be a data race.
called := false
h := m.WrapFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
})
req := httptest.NewRequest(http.MethodGet, "/places", nil)
if tc.header != "" {
req.Header.Set("Authorization", tc.header)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if called {
t.Error("the protected handler ran without a valid credential")
}
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec.Code)
}
if got := rec.Header().Get("WWW-Authenticate"); !strings.HasPrefix(got, "Bearer") {
t.Errorf("WWW-Authenticate = %q, want a Bearer challenge", got)
}
if body := rec.Body.String(); !strings.Contains(body, tc.body) {
t.Errorf("body = %q, want it to mention %q", body, tc.body)
}
})
}
}
func TestFromContextReportsAbsentIdentity(t *testing.T) {
t.Parallel()
if _, ok := FromContext(context.Background()); ok {
t.Error("FromContext found an identity on a bare context")
}
}
// signWithSecret signs with a different key to model a forged token.
func signWithSecret(t *testing.T, method jwt.SigningMethod, claims jwt.Claims, secret string) string {
t.Helper()
s, err := jwt.NewWithClaims(method, claims).SignedString([]byte(secret))
if err != nil {
t.Fatalf("SignedString: %v", err)
}
return s
}
// unsignedToken builds an alg=none token, the classic JWT downgrade attack.
func unsignedToken(t *testing.T, claims jwt.Claims) string {
t.Helper()
s, err := jwt.NewWithClaims(jwt.SigningMethodNone, claims).
SignedString(jwt.UnsafeAllowNoneSignatureType)
if err != nil {
t.Fatalf("SignedString: %v", err)
}
return s
}

View file

@ -0,0 +1,63 @@
package auth
import (
"context"
"errors"
"net/http"
)
type ctxKey struct{}
// WithUser stores the verified identity on the request context.
func WithUser(ctx context.Context, u User) context.Context {
return context.WithValue(ctx, ctxKey{}, u)
}
// FromContext returns the identity attached by the middleware.
func FromContext(ctx context.Context) (User, bool) {
u, ok := ctx.Value(ctxKey{}).(User)
return u, ok
}
// Middleware verifies the Authorization header and rejects requests that carry
// no usable credential.
type Middleware struct {
verifier Verifier
}
// NewMiddleware returns middleware backed by the verifier.
func NewMiddleware(v Verifier) *Middleware {
return &Middleware{verifier: v}
}
// Wrap protects h, making the verified identity available to it.
func (m *Middleware) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, err := BearerToken(r.Header.Get("Authorization"))
if err != nil {
unauthorized(w, err)
return
}
user, err := m.verifier.Verify(r.Context(), token)
if err != nil {
unauthorized(w, err)
return
}
next.ServeHTTP(w, r.WithContext(WithUser(r.Context(), user)))
})
}
// WrapFunc protects a handler function.
func (m *Middleware) WrapFunc(next http.HandlerFunc) http.HandlerFunc {
return m.Wrap(next).ServeHTTP
}
func unauthorized(w http.ResponseWriter, err error) {
w.Header().Set("WWW-Authenticate", `Bearer realm="go-places"`)
// The response deliberately does not echo the reason.
if errors.Is(err, ErrMissingToken) {
http.Error(w, "unauthorized: a bearer token is required", http.StatusUnauthorized)
return
}
http.Error(w, "unauthorized: the token was rejected", http.StatusUnauthorized)
}