// 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 }