go-bananas/internal/auth/auth.go
Daniel Noulet 951dd8d26b
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
initial commit
2026-10-07 15:11:35 +02:00

715 lines
24 KiB
Go

// Package auth authenticates callers and authorises their actions.
//
// Three mechanisms are supported and are resolved by Authenticator in a fixed
// order, most specific first:
//
// 1. An API key, presented as a bearer token. Machine access.
// 2. An OIDC session, carried in a signed cookie. Human access to the admin
// API, with a full login redirect flow.
// 3. A development bypass, available only when the configuration explicitly
// enables it and the process is running in a development environment.
//
// The development bypass refuses to initialise outside development. That check
// is the single most important line of defence in this package: an accidentally
// deployed dev mode is a total authentication bypass, so it is made impossible
// to construct rather than merely documented as unsupported.
package auth
import (
"context"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/urpi/go-bananas/internal/config"
"github.com/urpi/go-bananas/internal/domain"
)
// Errors returned by this package. They wrap the domain sentinels so the HTTP
// layer can map them to status codes without importing this package's internals.
var (
ErrNoSession = fmt.Errorf("%w: no session", domain.ErrUnauthorized)
ErrSessionExpired = fmt.Errorf("%w: session expired", domain.ErrUnauthorized)
ErrSessionInvalid = fmt.Errorf("%w: session is invalid", domain.ErrUnauthorized)
ErrDevBypassDenied = fmt.Errorf("%w: development bypass is not available", domain.ErrUnauthorized)
)
// Identity, Role and the role constants are re-exported so that callers of this
// package need not also import the domain package.
type (
// Identity is the authenticated caller.
Identity = domain.Identity
// Role is a coarse authorisation level.
Role = domain.Role
)
// Role constants, re-exported for the same reason.
const (
// RoleViewer may read published themes.
RoleViewer = domain.RoleViewer
// RoleEditor may create, edit and publish themes.
RoleEditor = domain.RoleEditor
// RoleAdmin may additionally manage users, API keys and settings.
RoleAdmin = domain.RoleAdmin
)
// Resolver is the identity-provider half of the interface. It is an interface so
// that the session store can be tested without a live provider, and so that a
// future provider (SAML, a service mesh) can be added without touching the HTTP
// layer.
// Login is the outcome of a completed login.
type Login struct {
// Identity is the authenticated caller.
Identity *Identity
// ReturnTo is the sanitised local path to send the browser to. It is always
// a path on this service, never an absolute URL.
ReturnTo string
}
// SafeReturnTo restricts a post-login redirect to a local path.
//
// Without this check an attacker could craft a login link that, after a
// successful authentication, bounces the user to a site of their choosing with the
// session cookie freshly minted: an open redirect that also primes a phishing flow.
// Absolute URLs, protocol-relative URLs, backslashes and control characters are all
// rejected, because browsers normalise several of those into an origin.
func SafeReturnTo(returnTo string) string {
trimmed := strings.TrimSpace(returnTo)
if trimmed == "" {
return "/"
}
if strings.ContainsAny(trimmed, "\\\r\n\t") ||
strings.Contains(trimmed, "://") || strings.HasPrefix(trimmed, "//") {
return "/"
}
if !strings.HasPrefix(trimmed, "/") {
return "/"
}
return trimmed
}
type Resolver interface {
// Begin starts a login by redirecting the browser to the provider.
Begin(ctx context.Context, req *http.Request, w http.ResponseWriter, returnTo string) error
// Complete finishes a login, returning the identity and where to send the
// browser next.
Complete(ctx context.Context, r *http.Request) (*Login, error)
// End starts a logout.
End(ctx context.Context, redirect string) string
// Enabled reports whether the resolver can authenticate at all.
Enabled() bool
// ClearState discards any pending login transaction.
ClearState(w http.ResponseWriter)
// LogoutURL returns the provider's end-session endpoint.
LogoutURL(redirect string) string
}
// KeyStore is the persistence the authenticator needs beyond sessions: recording
// the users it has seen, so that an operator can see who has access.
//
// It was once also the API-key store. Machine credentials were removed, so a bearer
// token is no longer a credential: the runtime API is public and the admin API is
// for people. A request carrying a bearer token is now simply unauthenticated,
// rather than being given editor rights by whatever it presents.
type KeyStore interface {
UpsertUser(ctx context.Context, identity *Identity) error
}
// Authenticator authenticates requests and issues sessions.
type Authenticator struct {
config config.Config
resolver Resolver
keys KeyStore
sessions *SessionStore
// now is the clock, overridable in tests.
now func() time.Time
}
// New builds an Authenticator. resolver may be nil when authentication is
// disabled; sessions are always constructed because the dev bypass and API keys
// still benefit from a consistent identity shape.
func New(cfg config.Config, resolver Resolver, keys KeyStore) (*Authenticator, error) {
if cfg.AuthMode == config.AuthModeDev && !cfg.IsDevelopment() {
return nil, fmt.Errorf("refusing to start: AUTH_MODE=dev outside a development environment")
}
if cfg.AuthMode == config.AuthModeNone && !cfg.IsDevelopment() {
return nil, fmt.Errorf("refusing to start: AUTH_MODE=none outside a development environment")
}
sessionStore, err := NewSessionStore(cfg.SessionSecret, cfg.SessionTTL, cfg.IsDevelopment())
if err != nil {
return nil, err
}
if cfg.AuthMode == config.AuthModeOIDC && (resolver == nil || !resolver.Enabled()) {
return nil, fmt.Errorf("AUTH_MODE=oidc but the identity provider is unavailable")
}
return &Authenticator{
config: cfg,
resolver: resolver,
keys: keys,
sessions: sessionStore,
now: time.Now,
}, nil
}
// Sessions exposes the session store so the HTTP layer can set and clear
// cookies without re-deriving the cookie attributes.
func (a *Authenticator) Sessions() *SessionStore { return a.sessions }
// Config returns the configuration the authenticator was built with.
func (a *Authenticator) Config() config.Config { return a.config }
// Mode returns the active authentication mode.
func (a *Authenticator) Mode() string { return a.config.AuthMode }
// DevBypassEnabled reports whether the development bypass is available.
func (a *Authenticator) DevBypassEnabled() bool {
return a.config.AuthMode == config.AuthModeDev && a.config.IsDevelopment()
}
// Authenticate resolves the caller from a request.
//
// The session cookie is the only credential. There is no bearer-token path: a
// request with an Authorization header is treated exactly like one without, so a
// stale or forged token cannot grant anything.
func (a *Authenticator) Authenticate(r *http.Request) (*Identity, error) {
if identity, err := a.sessions.Read(r); err == nil {
return identity, nil
} else if errors.Is(err, ErrSessionExpired) {
return nil, ErrSessionExpired
}
if a.DevBypassEnabled() {
if identity := a.devIdentity(r); identity != nil {
return identity, nil
}
}
if a.config.AuthMode == config.AuthModeNone {
return &Identity{
Subject: "anonymous",
Name: "anonymous",
Roles: []Role{RoleAdmin},
Method: "none",
}, nil
}
return nil, ErrNoSession
}
// devIdentity builds an identity for the development bypass.
//
// The caller may name themselves with ?dev-user=email, which lets a developer
// exercise the role model without a running identity provider. It is only
// reachable when DevBypassEnabled is true, which config.Validate refuses to
// allow outside development.
func (a *Authenticator) devIdentity(r *http.Request) *Identity {
email := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("dev-user")))
if email == "" {
email = "dev@go-bananas.local"
}
role := a.config.DevFallbackRole
if mapped, ok := a.config.DevUsers[email]; ok {
role = mapped
}
identity := &Identity{
Subject: "dev:" + email,
Email: email,
Name: displayNameFor(email),
Roles: []Role{domain.ParseRole(role)},
Method: "dev",
}
return identity
}
// displayNameFor turns an email address into a plausible display name.
func displayNameFor(email string) string {
local, _, _ := strings.Cut(email, "@")
if local == "" {
return email
}
// Capitalise the first letter of each hyphen-separated segment.
parts := strings.Split(local, "-")
for i, p := range parts {
if p == "" {
continue
}
parts[i] = strings.ToUpper(p[:1]) + p[1:]
}
return strings.Join(parts, " ")
}
// IssueSession records a successful login and writes the session cookie.
func (a *Authenticator) IssueSession(ctx context.Context, w http.ResponseWriter, identity *Identity) error {
if identity == nil {
return fmt.Errorf("%w: cannot issue a session for a nil identity", domain.ErrInvalid)
}
if a.keys != nil && identity.Method != "dev" {
// Recording the login lets an administrator see who has used the system.
if err := a.keys.UpsertUser(ctx, identity); err != nil {
// A login that cannot be recorded should still succeed; losing the
// directory entry is far less harmful than locking the user out.
_ = err
}
}
return a.sessions.Issue(w, identity)
}
// ClearSession removes the session cookie.
func (a *Authenticator) ClearSession(w http.ResponseWriter) {
a.sessions.Clear(w)
}
// ModeConfigured reports whether interactive login is available at all.
func (a *Authenticator) ModeConfigured() bool {
return a != nil && a.config.AuthMode != config.AuthModeNone
}
// ProviderEnabled reports whether an OIDC provider is wired up.
func (a *Authenticator) ProviderEnabled() bool {
return a != nil && a.resolver != nil && a.resolver.Enabled()
}
// Begin starts an interactive login.
func (a *Authenticator) Begin(ctx context.Context, req *http.Request, w http.ResponseWriter, returnTo string) error {
if a == nil || a.resolver == nil || !a.resolver.Enabled() {
return fmt.Errorf("%w: no identity provider is configured", domain.ErrNotConfigured)
}
return a.resolver.Begin(ctx, req, w, returnTo)
}
// Complete finishes an interactive login.
func (a *Authenticator) Complete(ctx context.Context, r *http.Request) (*Login, error) {
if a == nil || a.resolver == nil || !a.resolver.Enabled() {
return nil, fmt.Errorf("%w: no identity provider is configured", domain.ErrNotConfigured)
}
return a.resolver.Complete(ctx, r)
}
// ClearState discards a pending login transaction.
func (a *Authenticator) ClearState(w http.ResponseWriter) {
if a != nil && a.resolver != nil {
a.resolver.ClearState(w)
}
}
// LogoutURL returns the provider logout URL.
func (a *Authenticator) LogoutURL(redirect string) string {
if a.resolver == nil || !a.resolver.Enabled() {
return ""
}
return a.resolver.LogoutURL(redirect)
}
// ---------------------------------------------------------------- sessions --
// SessionStore issues and verifies stateless signed session cookies.
//
// The session is a signed, encrypted-free JSON payload in a cookie: there is no
// server-side session table to migrate, scale or invalidate. The trade is that
// revoking a session before its expiry requires rotating the signing key, which
// is acceptable for a session that lives eight hours and is documented as such.
//
// The signature is HMAC-SHA256 over the payload with a constant-time
// comparison, which is what makes a forged cookie infeasible.
type SessionStore struct {
secret []byte
ttl time.Duration
secure bool
name string
sameSite http.SameSite
now func() time.Time
// inDevelopment relaxes cookie behaviour so that a local run over plain
// HTTP still receives a cookie.
inDevelopment bool
}
// NewSessionStore builds a session store.
func NewSessionStore(secret string, ttl time.Duration, inDevelopment bool) (*SessionStore, error) {
if len(secret) < 32 {
return nil, fmt.Errorf("%w: session secret must be at least 32 bytes, got %d", domain.ErrInvalid, len(secret))
}
if ttl <= 0 {
ttl = 8 * time.Hour
}
switch strings.ToLower(stringValue(sameSiteFromString("lax"))) {
case "lax", "strict", "none":
default:
return nil, fmt.Errorf("%w: unknown SameSite value", domain.ErrInvalid)
}
return &SessionStore{
secret: []byte(secret),
ttl: ttl,
secure: true,
name: "go-bananas-session",
sameSite: http.SameSiteLaxMode,
now: time.Now,
inDevelopment: inDevelopment,
}, nil
}
// WithCookie configures the cookie attributes. It exists so that the
// configuration can be applied in one place after construction.
func (s *SessionStore) WithCookie(name string, secure bool, sameSite http.SameSite) *SessionStore {
if name != "" {
s.name = name
}
s.secure = secure
s.sameSite = sameSite
return s
}
// WithClock overrides the clock, for tests.
func (s *SessionStore) WithClock(now func() time.Time) *SessionStore {
s.now = now
return s
}
// CookieName returns the configured cookie name.
func (s *SessionStore) CookieName() string { return s.name }
// sessionPayload is the signed cookie body.
type sessionPayload struct {
Subject string `json:"sub"`
Email string `json:"email,omitempty"`
Name string `json:"name,omitempty"`
Roles []string `json:"roles"`
Method string `json:"method"`
IssuedAt int64 `json:"iat"`
ExpiresAt int64 `json:"exp"`
// Nonce lets a future rotation invalidate sessions issued before a cutover.
Nonce string `json:"jti,omitempty"`
}
// Issue writes a signed session cookie.
func (s *SessionStore) Issue(w http.ResponseWriter, identity *Identity) error {
now := s.now().UTC()
roles := make([]string, 0, len(identity.Roles))
for _, r := range identity.Roles {
roles = append(roles, string(r))
}
payload := sessionPayload{
Subject: identity.Subject,
Email: identity.Email,
Name: identity.Name,
Roles: roles,
Method: identity.Method,
IssuedAt: now.Unix(),
ExpiresAt: now.Add(s.ttl).Unix(),
}
encoded, err := s.encode(payload)
if err != nil {
return err
}
//nolint:gosec // G124: HttpOnly, Secure and SameSite are all set, from the
// configured values rather than literals. gosec only recognises a literal
// attribute, so a configurable cookie reads as an unprotected one.
http.SetCookie(w, &http.Cookie{
Name: s.name,
Value: encoded,
Path: "/",
Expires: now.Add(s.ttl),
MaxAge: int(s.ttl.Seconds()),
HttpOnly: true,
Secure: s.secure,
SameSite: s.sameSite,
})
return nil
}
// Clear expires the session cookie.
func (s *SessionStore) Clear(w http.ResponseWriter) {
//nolint:gosec // G124: as above. Clearing a cookie needs the same attribute
// triple as setting it, or a browser will not remove it.
http.SetCookie(w, &http.Cookie{
Name: s.name,
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
MaxAge: -1,
HttpOnly: true,
Secure: s.secure,
SameSite: s.sameSite,
})
}
// Read verifies and decodes the session cookie.
func (s *SessionStore) Read(r *http.Request) (*Identity, error) {
cookie, err := r.Cookie(s.name)
if err != nil || cookie.Value == "" {
return nil, ErrNoSession
}
payload, err := s.decode(cookie.Value)
if err != nil {
return nil, err
}
now := s.now().UTC().Unix()
if payload.ExpiresAt > 0 && now > payload.ExpiresAt {
return nil, ErrSessionExpired
}
identity := &Identity{
Subject: payload.Subject,
Email: payload.Email,
Name: payload.Name,
Method: payload.Method,
}
// Left nil when the payload carries no expiry, rather than becoming the Unix
// epoch: a session without an expiry is a valid thing to represent, and 1970 is
// a date every client would read as "already expired".
if payload.ExpiresAt > 0 {
identity.ExpiresAt = timePtr(time.Unix(payload.ExpiresAt, 0).UTC())
}
for _, r := range payload.Roles {
identity.Roles = append(identity.Roles, Role(r))
}
if len(identity.Roles) == 0 {
identity.Roles = []Role{RoleViewer}
}
return identity, nil
}
// encode serialises and signs a payload.
func (s *SessionStore) encode(payload sessionPayload) (string, error) {
body, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("encode session: %w", err)
}
bodyEncoded := base64.RawURLEncoding.EncodeToString(body)
mac := s.sign(bodyEncoded)
return bodyEncoded + "." + base64.RawURLEncoding.EncodeToString(mac), nil
}
// decode verifies and parses a payload.
func (s *SessionStore) decode(value string) (sessionPayload, error) {
var payload sessionPayload
bodyEncoded, signatureEncoded, ok := strings.Cut(value, ".")
if !ok {
return payload, ErrSessionInvalid
}
signature, err := base64.RawURLEncoding.DecodeString(signatureEncoded)
if err != nil {
return payload, ErrSessionInvalid
}
// Constant-time comparison: a byte-by-byte early return would leak the
// correct prefix length to an attacker measuring responses.
expected := s.sign(bodyEncoded)
if subtle.ConstantTimeCompare(signature, expected) != 1 {
return payload, ErrSessionInvalid
}
body, err := base64.RawURLEncoding.DecodeString(bodyEncoded)
if err != nil {
return payload, ErrSessionInvalid
}
if err := json.Unmarshal(body, &payload); err != nil {
return payload, ErrSessionInvalid
}
if payload.Subject == "" {
return payload, ErrSessionInvalid
}
return payload, nil
}
// sign computes the HMAC over a value.
func (s *SessionStore) sign(value string) []byte {
mac := hmacSHA256(s.secret, []byte(value))
return mac[:]
}
// sameSiteFromString maps a configuration value to an http.SameSite.
func sameSiteFromString(raw string) http.SameSite {
switch strings.ToLower(raw) {
case "strict":
return http.SameSiteStrictMode
case "none":
return http.SameSiteNoneMode
default:
return http.SameSiteLaxMode
}
}
// stringValue renders a SameSite for logging.
func stringValue(v http.SameSite) string {
switch v {
case http.SameSiteStrictMode:
return "strict"
case http.SameSiteNoneMode:
return "none"
default:
return "lax"
}
}
// ------------------------------------------------------------------ state --
// StateStore keeps short-lived OIDC transaction state (the PKCE verifier and
// the post-login return path) in a signed cookie.
//
// Keeping it in the cookie rather than a server-side map is what lets go-bananas
// run multiple replicas behind a load balancer with no shared session storage.
type StateStore struct {
secret []byte
name string
ttl time.Duration
secure bool
sameSite http.SameSite
now func() time.Time
}
// oauthState is the transaction cookie body.
//
// State is stored alongside the verifier and nonce so that the callback can be
// bound to the browser that started the flow. Without it the state parameter is
// merely present, not checked, and an attacker-supplied authorization code could
// be redeemed into a session in someone else's browser (a login CSRF).
type oauthState struct {
Verifier string `json:"v"`
ReturnTo string `json:"r"`
Nonce string `json:"n"`
State string `json:"s"`
IssuedAt int64 `json:"iat"`
}
// Transaction is a verified login transaction read back from the state cookie.
type Transaction struct {
// Verifier is the PKCE code verifier for the exchange.
Verifier string
// ReturnTo is the sanitised local path the browser should land on.
ReturnTo string
// Nonce must match the nonce in the returned ID token.
Nonce string
// State must match the state parameter the provider echoes back.
State string
}
// NewStateStore builds the transaction cookie store.
func NewStateStore(secret, name string, secure bool, sameSite http.SameSite, ttl time.Duration) *StateStore {
if ttl <= 0 {
ttl = 10 * time.Minute
}
return &StateStore{
secret: []byte(secret),
name: name,
secure: secure,
sameSite: sameSite,
ttl: ttl,
now: time.Now,
}
}
// Name returns the state cookie name.
func (s *StateStore) Name() string { return s.name }
// Put writes the transaction state cookie. It fails when any part of the
// transaction is empty: a transaction missing its state or verifier cannot be
// validated at the callback, so storing it would only defer the failure to a point
// where the user is already mid-login.
func (s *StateStore) Put(w http.ResponseWriter, verifier, returnTo, nonce, state string) error {
if verifier == "" || nonce == "" || state == "" {
return fmt.Errorf("%w: incomplete oauth transaction", domain.ErrInvalid)
}
now := s.now().UTC()
body, err := json.Marshal(oauthState{
Verifier: verifier, ReturnTo: returnTo, Nonce: nonce, State: state,
IssuedAt: now.Unix(),
})
if err != nil {
return fmt.Errorf("encode oauth state: %w", err)
}
encoded := base64.RawURLEncoding.EncodeToString(body)
mac := hmacSHA256(s.secret, []byte(encoded))
//nolint:gosec // G124: as above.
http.SetCookie(w, &http.Cookie{
Name: s.name,
Value: encoded + "." + base64.RawURLEncoding.EncodeToString(mac[:]),
Path: "/",
Expires: now.Add(s.ttl),
MaxAge: int(s.ttl.Seconds()),
HttpOnly: true,
Secure: s.secure,
SameSite: http.SameSiteLaxMode,
})
return nil
}
// Get verifies the transaction state cookie and returns what it holds.
//
// "Consume" is the caller's job: the cookie is single-use, but only because the
// HTTP layer expires it once the login has succeeded. Clearing it here instead
// would expire it before the code exchange, and a user who mistyped their password
// once at the provider would arrive back with no transaction to finish.
func (s *StateStore) Get(r *http.Request) (Transaction, error) {
cookie, err := r.Cookie(s.name)
if err != nil {
return Transaction{}, fmt.Errorf("%w: missing oauth state", domain.ErrUnauthorized)
}
encoded, signatureEncoded, ok := strings.Cut(cookie.Value, ".")
if !ok {
return Transaction{}, fmt.Errorf("%w: malformed oauth state", domain.ErrUnauthorized)
}
signature, decodeErr := base64.RawURLEncoding.DecodeString(signatureEncoded)
if decodeErr != nil {
return Transaction{}, fmt.Errorf("%w: malformed oauth state", domain.ErrUnauthorized)
}
expected := hmacSHA256(s.secret, []byte(encoded))
if subtle.ConstantTimeCompare(signature, expected[:]) != 1 {
return Transaction{}, fmt.Errorf("%w: oauth state signature mismatch", domain.ErrUnauthorized)
}
body, decodeErr := base64.RawURLEncoding.DecodeString(encoded)
if decodeErr != nil {
return Transaction{}, fmt.Errorf("%w: malformed oauth state", domain.ErrUnauthorized)
}
var state oauthState
if json.Unmarshal(body, &state) != nil {
return Transaction{}, fmt.Errorf("%w: malformed oauth state", domain.ErrUnauthorized)
}
if s.now().UTC().Sub(time.Unix(state.IssuedAt, 0).UTC()) > s.ttl {
return Transaction{}, fmt.Errorf("%w: oauth state expired", domain.ErrUnauthorized)
}
if state.Verifier == "" || state.Nonce == "" || state.State == "" {
return Transaction{}, fmt.Errorf("%w: incomplete oauth state", domain.ErrUnauthorized)
}
return Transaction{
Verifier: state.Verifier,
ReturnTo: SafeReturnTo(state.ReturnTo),
Nonce: state.Nonce,
State: state.State,
}, nil
}
// Clear expires the state cookie.
func (s *StateStore) Clear(w http.ResponseWriter) {
//nolint:gosec // G124: as above.
http.SetCookie(w, &http.Cookie{
Name: s.name, Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: s.secure, SameSite: http.SameSiteLaxMode,
})
}
// hmacSHA256 computes a signature. It is a free function so the session store
// and the OIDC state store share one implementation and cannot diverge.
func hmacSHA256(key, body []byte) [32]byte {
return sha256HMAC(key, body)
}
// timePtr returns a pointer to t. It exists because the session and identity types
// carry an optional expiry, and taking the address of a conversion inline is not
// something Go will let you write.
func timePtr(t time.Time) *time.Time { return &t }