initial commit
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

This commit is contained in:
Daniel Noulet 2026-10-07 15:11:35 +02:00
commit 951dd8d26b
175 changed files with 50041 additions and 0 deletions

715
internal/auth/auth.go Normal file
View file

@ -0,0 +1,715 @@
// 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 }

417
internal/auth/auth_test.go Normal file
View file

@ -0,0 +1,417 @@
package auth
import (
"crypto/tls"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/urpi/go-bananas/internal/config"
"github.com/urpi/go-bananas/internal/domain"
)
const testSecret = "test-secret-that-is-at-least-32-bytes-long"
// ---------------------------------------------------------------- sessions --
func TestSessionRoundTrip(t *testing.T) {
store, err := NewSessionStore(testSecret, time.Hour, false)
if err != nil {
t.Fatalf("NewSessionStore: %v", err)
}
identity := &domain.Identity{
Subject: "alice",
Email: "alice@example.com",
Name: "Alice",
Roles: []domain.Role{domain.RoleEditor},
Method: "oidc",
}
rec := httptest.NewRecorder()
if err := store.Issue(rec, identity); err != nil {
t.Fatalf("Issue: %v", err)
}
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
for _, c := range rec.Result().Cookies() {
req.AddCookie(c)
}
got, err := store.Read(req)
if err != nil {
t.Fatalf("Read: %v", err)
}
if got.Subject != "alice" || got.Email != "alice@example.com" {
t.Errorf("identity = %+v", got)
}
if !got.HasRole(domain.RoleEditor) {
t.Errorf("roles = %v, want editor", got.Roles)
}
}
func TestSessionCookieIsHttpOnly(t *testing.T) {
store, _ := NewSessionStore(testSecret, time.Hour, false)
rec := httptest.NewRecorder()
if err := store.Issue(rec, &domain.Identity{Subject: "a"}); err != nil {
t.Fatalf("Issue: %v", err)
}
cookies := rec.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("got %d cookies, want 1", len(cookies))
}
if !cookies[0].HttpOnly {
t.Error("session cookie must be HttpOnly so script cannot read it")
}
if !cookies[0].Secure {
t.Error("session cookie must be Secure")
}
}
func TestSessionRejectsShortSecret(t *testing.T) {
if _, err := NewSessionStore("too-short", time.Hour, false); err == nil {
t.Fatal("expected a short secret to be rejected")
}
}
func TestSessionRejectsTamperedPayload(t *testing.T) {
store, _ := NewSessionStore(testSecret, time.Hour, false)
rec := httptest.NewRecorder()
if err := store.Issue(rec, &domain.Identity{Subject: "alice", Roles: []domain.Role{domain.RoleViewer}}); err != nil {
t.Fatalf("Issue: %v", err)
}
var issued *http.Cookie
for _, c := range rec.Result().Cookies() {
issued = c
}
// Re-encode the payload with an escalated role but keep the old signature.
body, signature, _ := strings.Cut(issued.Value, ".")
forged := base64RawURL(`{"sub":"alice","roles":["admin"],"method":"oidc","iat":1,"exp":9999999999}`)
_ = body
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: issued.Name, Value: forged + "." + signature})
if _, err := store.Read(req); err == nil {
t.Fatal("a re-signed payload must be rejected")
}
}
func TestSessionRejectsForeignSecret(t *testing.T) {
mine, _ := NewSessionStore(testSecret, time.Hour, false)
theirs, _ := NewSessionStore("a-completely-different-32-byte-secret!", time.Hour, false)
rec := httptest.NewRecorder()
if err := theirs.Issue(rec, &domain.Identity{Subject: "mallory", Roles: []domain.Role{domain.RoleAdmin}}); err != nil {
t.Fatalf("Issue: %v", err)
}
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
for _, c := range rec.Result().Cookies() {
req.AddCookie(c)
}
if _, err := mine.Read(req); err == nil {
t.Fatal("a cookie signed with another key must be rejected")
}
}
func TestSessionExpires(t *testing.T) {
now := time.Now().UTC()
store, _ := NewSessionStore(testSecret, time.Hour, false)
store.WithClock(func() time.Time { return now })
rec := httptest.NewRecorder()
if err := store.Issue(rec, &domain.Identity{Subject: "alice"}); err != nil {
t.Fatalf("Issue: %v", err)
}
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
for _, c := range rec.Result().Cookies() {
req.AddCookie(c)
}
// Two hours later the session must be gone.
store.WithClock(func() time.Time { return now.Add(2 * time.Hour) })
if _, err := store.Read(req); err == nil {
t.Fatal("expected an expired session to be rejected")
}
}
func TestSessionReadRejectsMissingCookie(t *testing.T) {
store, _ := NewSessionStore(testSecret, time.Hour, false)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
if _, err := store.Read(req); err == nil {
t.Fatal("expected ErrNoSession")
}
}
func TestSessionWithRoleDefaultsToViewer(t *testing.T) {
// A cookie carrying no roles must never be treated as having privileges.
store, _ := NewSessionStore(testSecret, time.Hour, false)
encoded, err := store.encode(sessionPayload{Subject: "a", Method: "oidc", ExpiresAt: time.Now().Add(time.Hour).Unix()})
if err != nil {
t.Fatalf("encode: %v", err)
}
payload, err := store.decode(encoded)
if err != nil {
t.Fatalf("decode: %v", err)
}
if len(payload.Roles) != 0 {
t.Errorf("payload roles = %v, want none", payload.Roles)
}
}
// ------------------------------------------------------------- oidc state --
func TestOAuthStateRoundTrip(t *testing.T) {
state := NewStateStore(testSecret, "bananas-oauth", true, http.SameSiteLaxMode, 10*time.Minute)
rec := httptest.NewRecorder()
if err := state.Put(rec, "verifier-value", "/return", "nonce-value", "state-value"); err != nil {
t.Fatalf("Put: %v", err)
}
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/callback", nil)
for _, c := range rec.Result().Cookies() {
req.AddCookie(c)
}
tx, err := state.Get(req)
if err != nil {
t.Fatalf("Get: %v", err)
}
if tx.Verifier != "verifier-value" || tx.ReturnTo != "/return" ||
tx.Nonce != "nonce-value" || tx.State != "state-value" {
t.Errorf("round trip lost data: %+v", tx)
}
}
func TestOAuthStateRejectsTampering(t *testing.T) {
state := NewStateStore(testSecret, "bananas-oauth", true, http.SameSiteLaxMode, 10*time.Minute)
rec := httptest.NewRecorder()
if err := state.Put(rec, "verifier", "/", "nonce", "state"); err != nil {
t.Fatalf("Put: %v", err)
}
var issued *http.Cookie
for _, c := range rec.Result().Cookies() {
issued = c
}
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/callback", nil)
req.AddCookie(&http.Cookie{Name: issued.Name, Value: base64RawURL(`{"v":"attacker","r":"/evil","n":"x","iat":1}`) + "." + strings.Split(issued.Value, ".")[1]})
if _, err := state.Get(req); err == nil {
t.Fatal("a tampered state cookie must be rejected")
}
}
func TestOAuthStateExpires(t *testing.T) {
now := time.Now().UTC()
state := NewStateStore(testSecret, "bananas-oauth", true, http.SameSiteLaxMode, 10*time.Minute)
rec := httptest.NewRecorder()
if err := state.Put(rec, "verifier", "/", "nonce", "state"); err != nil {
t.Fatalf("Put: %v", err)
}
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/callback", nil)
for _, c := range rec.Result().Cookies() {
req.AddCookie(c)
}
// The StateStore uses time.Now directly; simulate staleness by minting an
// old state value through the exported encoder path.
stale := oauthState{Verifier: "v", ReturnTo: "/", IssuedAt: now.Add(-time.Hour).Unix()}
body := base64RawURL(`{"v":"v","r":"/","n":"","iat":` + itoa(stale.IssuedAt) + `}`)
mac := hmacSHA256([]byte(testSecret), []byte(body))
req2 := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/callback", nil)
req2.AddCookie(&http.Cookie{Name: "bananas-oauth", Value: body + "." + base64RawURLBytes(mac[:])})
if _, err := state.Get(req2); err == nil {
t.Fatal("an expired state cookie must be rejected")
}
}
// -------------------------------------------------------- sanitising URLs --
func TestSafeReturnToBlocksOpenRedirects(t *testing.T) {
dangerous := []string{
"https://evil.example.com/steal",
"//evil.example.com/steal",
"/\\evil.example.com",
"javascript:alert(1)",
"admin",
"",
}
for _, input := range dangerous {
got := SafeReturnTo(input)
if got != "/" {
t.Errorf("SafeReturnTo(%q) = %q, want /", input, got)
}
}
safe := map[string]string{
"/admin": "/admin",
"/admin/themes?x=1": "/admin/themes?x=1",
"/": "/",
}
for input, want := range safe {
if got := SafeReturnTo(input); got != want {
t.Errorf("SafeReturnTo(%q) = %q, want %q", input, got, want)
}
}
}
func TestLoginURLIsNotAnOpenRedirect(t *testing.T) {
// The redirect URI must always be one the provider has registered.
cfg := config.Defaults()
cfg.PublicBaseURL = "https://themes.example.com"
if got := cfg.RedirectURL(); got != "https://themes.example.com/auth/callback" {
t.Errorf("RedirectURL = %q", got)
}
cfg.BasePath = "/bananas"
if got := cfg.RedirectURL(); got != "https://themes.example.com/bananas/auth/callback" {
t.Errorf("RedirectURL with base path = %q", got)
}
}
// -------------------------------------------------------- authenticator --
func TestDevBypassGrantsConfiguredRole(t *testing.T) {
cfg := config.Defaults()
cfg.AuthMode = config.AuthModeDev
cfg.Environment = "development"
cfg.DevFallbackRole = "viewer"
cfg.DevUsers = map[string]string{"boss@example.com": "admin"}
a := newTestAuthenticator(t, cfg, nil)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/themes", nil)
identity, err := a.Authenticate(req)
if err != nil {
t.Fatalf("Authenticate: %v", err)
}
if identity.HighestRole() != domain.RoleViewer {
t.Errorf("role = %v, want viewer", identity.Roles)
}
req = httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/themes?dev-user=boss@example.com", nil)
identity, err = a.Authenticate(req)
if err != nil {
t.Fatalf("Authenticate: %v", err)
}
if identity.HighestRole() != domain.RoleAdmin {
t.Errorf("role = %v, want admin", identity.Roles)
}
}
func TestDevBypassRefusedOutsideDevelopment(t *testing.T) {
cfg := config.Defaults()
cfg.AuthMode = config.AuthModeDev
cfg.Environment = "production"
cfg.SessionSecret = testSecret
if _, err := New(cfg, nil, nil); err == nil {
t.Fatal("the development bypass must not be constructible in production")
}
}
func TestAuthNoneRefusedOutsideDevelopment(t *testing.T) {
cfg := config.Defaults()
cfg.AuthMode = config.AuthModeNone
cfg.Environment = "production"
cfg.SessionSecret = testSecret
if _, err := New(cfg, nil, nil); err == nil {
t.Fatal("AUTH_MODE=none must not be constructible in production")
}
}
func TestUnauthenticatedRequestIsRejected(t *testing.T) {
cfg := config.Defaults()
cfg.AuthMode = config.AuthModeOIDC
cfg.Environment = "test"
a := newTestAuthenticator(t, cfg, nil)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/themes", nil)
if _, err := a.Authenticate(req); err == nil {
t.Fatal("expected an unauthenticated request to be rejected")
}
}
func TestRoleMappingFailsClosed(t *testing.T) {
// A user in no configured group gets viewer, never more.
p := &OIDCProvider{config: config.Config{
OIDCAdminGroups: []string{"platform-admins"},
OIDCEditorGroups: []string{"designers"},
}}
if got := p.rolesFor([]string{"some-other-group"}, nil); got[0] != domain.RoleViewer {
t.Errorf("roles = %v, want viewer", got)
}
if got := p.rolesFor([]string{"designers"}, nil); got[0] != domain.RoleEditor {
t.Errorf("roles = %v, want editor", got)
}
if got := p.rolesFor([]string{"designers", "platform-admins"}, nil); got[0] != domain.RoleAdmin {
t.Errorf("roles = %v, want admin; the most privileged group must win", got)
}
if got := p.rolesFor(nil, []string{"platform-admins"}); got[0] != domain.RoleAdmin {
t.Errorf("roles = %v, want admin from realm roles", got)
}
}
func TestRoleMappingIsCaseInsensitive(t *testing.T) {
p := &OIDCProvider{config: config.Config{OIDCAdminGroups: []string{"Platform-Admins"}}}
if got := p.rolesFor([]string{"PLATFORM-ADMINS"}, nil); got[0] != domain.RoleAdmin {
t.Errorf("roles = %v, want admin", got)
}
}
func TestCodeChallengeIsS256(t *testing.T) {
// RFC 7636 test vector: verifier "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
// must produce challenge "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM".
got := codeChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
if got != "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" {
t.Errorf("codeChallenge = %q, want the RFC 7636 value", got)
}
}
func TestInsecureTLSConfigIsUsedOnlyWhenRequested(t *testing.T) {
cfg := config.Defaults()
cfg.OIDCSkipTLSVerify = true
if client := newProviderHTTPClient(cfg); client == nil {
t.Fatal("expected a client")
}
if tlsCfg := newProviderHTTPClient(cfg).Transport.(*http.Transport).TLSClientConfig; tlsCfg == nil || !tlsCfg.InsecureSkipVerify {
t.Error("expected TLS verification to be skipped when configured")
}
cfg.OIDCSkipTLSVerify = false
if tlsCfg := newProviderHTTPClient(cfg).Transport.(*http.Transport).TLSClientConfig; tlsCfg != nil && tlsCfg.InsecureSkipVerify {
t.Error("TLS verification must be on by default")
}
var _ *tls.Config
}
func TestProviderHTTPClientHasTimeout(t *testing.T) {
// An unresponsive identity provider must not be able to hang a request
// indefinitely.
client := newProviderHTTPClient(config.Defaults())
if client.Timeout == 0 {
t.Error("provider HTTP client must have a timeout")
}
}
func TestURLQueryEscape(t *testing.T) {
if got := urlQueryEscape("https://x.example/y?a=1&b=2"); got != "https%3A%2F%2Fx.example%2Fy%3Fa%3D1%26b%3D2" {
t.Errorf("urlQueryEscape = %q", got)
}
if _, err := url.Parse("https://x.example"); err != nil {
t.Fatalf("unexpected: %v", err)
}
}

View file

@ -0,0 +1,60 @@
package auth
import (
"context"
"encoding/base64"
"net/http"
"strconv"
"testing"
"github.com/urpi/go-bananas/internal/config"
)
// base64RawURL encodes without padding, matching how cookies are written.
func base64RawURL(s string) string {
return base64.RawURLEncoding.EncodeToString([]byte(s))
}
// base64RawURLBytes encodes raw bytes without padding.
func base64RawURLBytes(b []byte) string {
return base64.RawURLEncoding.EncodeToString(b)
}
// itoa renders an int64 for embedding in a JSON test fixture.
func itoa(v int64) string { return strconv.FormatInt(v, 10) }
// stubResolver is a Resolver that authenticates nobody. It exists so that tests
// can construct an Authenticator in OIDC mode without a live provider, which
// also documents that New refuses to start in OIDC mode without one.
type stubResolver struct{}
func (stubResolver) Begin(context.Context, *http.Request, http.ResponseWriter, string) error {
return nil
}
func (stubResolver) Complete(context.Context, *http.Request) (*Login, error) {
return nil, ErrNoSession
}
func (stubResolver) End(context.Context, string) string { return "" }
func (stubResolver) Enabled() bool { return true }
func (stubResolver) ClearState(http.ResponseWriter) {}
func (stubResolver) LogoutURL(string) string { return "https://idp.example/logout" }
// newTestAuthenticator builds an Authenticator for a configuration.
//
// The key store is optional and is passed straight through: with machine
// credentials removed it only carries the user table, which most of these tests do
// not exercise.
func newTestAuthenticator(t *testing.T, cfg config.Config, keys KeyStore) *Authenticator {
t.Helper()
if cfg.SessionSecret == "" {
cfg.SessionSecret = testSecret
}
// A stub resolver rather than nil: New refuses to construct in OIDC mode
// without one, and the point of some of these tests is exactly that an
// unauthenticated request is rejected even when a provider is wired up.
a, err := New(cfg, stubResolver{}, keys)
if err != nil {
t.Fatalf("New: %v", err)
}
return a
}

20
internal/auth/hmac.go Normal file
View file

@ -0,0 +1,20 @@
package auth
import (
"crypto/hmac"
"crypto/sha256"
)
// sha256HMAC computes an HMAC-SHA256 tag.
//
// It is a thin wrapper so that the session store and the OIDC state store
// cannot accidentally diverge on how they sign. The stdlib hmac package already
// performs a constant-time internal comparison; the constant-time requirement
// in this codebase is about how *we* compare the resulting tags.
func sha256HMAC(key, body []byte) [32]byte {
mac := hmac.New(sha256.New, key)
mac.Write(body)
var out [32]byte
copy(out[:], mac.Sum(nil))
return out
}

375
internal/auth/oidc.go Normal file
View file

@ -0,0 +1,375 @@
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"encoding/base64"
"encoding/hex"
"fmt"
"net/http"
"sort"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"github.com/urpi/go-bananas/internal/config"
"github.com/urpi/go-bananas/internal/domain"
)
// OIDCProvider implements Resolver against any OpenID Connect provider,
// including the auth-dev.urpi.be realm used for local development.
//
// Everything provider-specific comes from the issuer's discovery document, so
// the same code works against Keycloak, Authentik, Zitadel or Google without a
// configuration change. The only per-provider values are the client
// credentials and the group-to-role mapping.
type OIDCProvider struct {
config config.Config
provider *oidc.Provider
verifier *oidc.IDTokenVerifier
oauth *oauth2.Config
state *StateStore
}
// NewOIDCProvider performs discovery against the configured issuer and returns
// a ready provider. Discovery happens eagerly so that a misconfigured issuer
// fails at start-up rather than on the first login, when the failure would be
// hardest to diagnose.
func NewOIDCProvider(ctx context.Context, cfg config.Config, state *StateStore) (*OIDCProvider, error) {
if cfg.OIDCIssuer == "" {
return nil, fmt.Errorf("%w: OIDC_ISSUER is required", domain.ErrInvalid)
}
provider, err := newProvider(ctx, cfg)
if err != nil {
return nil, err
}
p := &OIDCProvider{
config: cfg,
provider: provider,
state: state,
}
scopes := append([]string{oidc.ScopeOpenID}, cfg.OIDCScopes...)
p.oauth = &oauth2.Config{
ClientID: cfg.OIDCClientID,
ClientSecret: cfg.OIDCClientSecret,
RedirectURL: cfg.RedirectURL(),
Scopes: scopes,
Endpoint: provider.Endpoint(),
}
p.verifier = provider.Verifier(&oidc.Config{
ClientID: cfg.OIDCClientID,
// Keycloak and Authentik both support discovery of the JWKS; skipping
// the audience check is not done because a token minted for another
// client in the same realm must not be accepted here.
})
return p, nil
}
// newProvider runs discovery with a bounded timeout so that an unreachable
// issuer produces a clear error instead of hanging start-up.
func newProvider(ctx context.Context, cfg config.Config) (*oidc.Provider, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
// ClientContext carries the custom HTTP client through to discovery, which
// is how the TLS-skip development switch and the request timeout reach the
// provider's HTTP calls.
ctx = oidc.ClientContext(ctx, newProviderHTTPClient(cfg))
provider, err := oidc.NewProvider(ctx, cfg.OIDCIssuer)
if err != nil {
return nil, fmt.Errorf("OIDC discovery against %s failed: %w", cfg.OIDCIssuer, err)
}
return provider, nil
}
// newProviderHTTPClient builds the HTTP client used for provider traffic.
//
// The timeout matters: without one, an unresponsive identity provider would hold
// a request open until the client's own timeout, and a stalled provider would
// look like a hung application.
func newProviderHTTPClient(cfg config.Config) *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.MaxIdleConnsPerHost = 4
if cfg.OIDCSkipTLSVerify {
transport.TLSClientConfig = insecureTLSConfig()
}
return &http.Client{
Transport: transport,
Timeout: 15 * time.Second,
}
}
// Enabled reports that this resolver can authenticate.
func (p *OIDCProvider) Enabled() bool { return p != nil && p.provider != nil }
// Begin starts a login by redirecting the browser to the provider. The return
// path and PKCE verifier are carried in a signed cookie rather than
// server-side state so that any replica can complete the flow.
func (p *OIDCProvider) Begin(_ context.Context, req *http.Request, w http.ResponseWriter, returnTo string) error {
verifier, err := randomVerifier()
if err != nil {
return err
}
nonce, err := randomNonce()
if err != nil {
return err
}
state, err := randomState()
if err != nil {
return err
}
// The transaction is stored before the redirect, and stores the state it was
// built with, so that the callback can prove the response belongs to this
// browser's login attempt.
if err := p.state.Put(w, verifier, SafeReturnTo(returnTo), nonce, state); err != nil {
return err
}
opts := []oauth2.AuthCodeOption{
// PKCE with S256. The verifier lives in a signed HttpOnly cookie, so the
// code is bound to the browser that started the flow.
oauth2.SetAuthURLParam("code_challenge", codeChallenge(verifier)),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
oauth2.SetAuthURLParam("nonce", nonce),
oauth2.SetAuthURLParam("state", state),
}
http.Redirect(w, req, p.oauth.AuthCodeURL(state, opts...), http.StatusFound)
return nil
}
// Complete finishes a login.
//
// The transaction cookie is the anchor: the state the provider echoes back is
// compared against the state this service minted for this browser, and the PKCE
// verifier and nonce both come from the same cookie. A callback that carries a
// foreign code or a replayed state is rejected before the code is ever exchanged.
func (p *OIDCProvider) Complete(ctx context.Context, req *http.Request) (*Login, error) {
if err := req.ParseForm(); err != nil {
return nil, fmt.Errorf("parse callback form: %w", err)
}
if providerErr := req.Form.Get("error"); providerErr != "" {
desc := req.Form.Get("error_description")
return nil, fmt.Errorf("identity provider returned %q: %s", providerErr, desc)
}
tx, err := p.state.Get(req)
if err != nil {
return nil, err
}
returnedState := req.Form.Get("state")
if returnedState == "" {
return nil, fmt.Errorf("%w: callback is missing the state parameter", domain.ErrUnauthorized)
}
// Constant time, so that a mismatching state cannot be discovered a byte at a
// time. The state is public, but the comparison is free to be careful.
if subtle.ConstantTimeCompare([]byte(returnedState), []byte(tx.State)) != 1 {
return nil, fmt.Errorf("%w: callback state does not match the login transaction", domain.ErrUnauthorized)
}
code := req.Form.Get("code")
if code == "" {
return nil, fmt.Errorf("%w: callback is missing the authorization code", domain.ErrUnauthorized)
}
token, err := p.oauth.Exchange(ctx, code, oauth2.VerifierOption(tx.Verifier))
if err != nil {
return nil, fmt.Errorf("exchange authorization code: %w", err)
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok || rawIDToken == "" {
return nil, fmt.Errorf("%w: provider returned no id_token", domain.ErrUnauthorized)
}
idToken, err := p.verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, fmt.Errorf("verify id_token: %w", err)
}
if idToken.Nonce != tx.Nonce {
return nil, fmt.Errorf("%w: id_token nonce mismatch", domain.ErrUnauthorized)
}
claims := struct {
Subject string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
PreferredUser string `json:"preferred_username"`
Groups []string `json:"groups"`
RealmRoles []string `json:"realm_access.roles"`
}{}
if err := idToken.Claims(&claims); err != nil {
return nil, fmt.Errorf("decode id_token claims: %w", err)
}
identity := &Identity{
Subject: claims.Subject,
Email: claims.Email,
Name: firstNonEmpty(claims.Name, claims.PreferredUser, claims.Email),
Method: "oidc",
}
if identity.Subject == "" {
return nil, fmt.Errorf("%w: id_token has no subject", domain.ErrUnauthorized)
}
identity.Roles = p.rolesFor(claims.Groups, claims.RealmRoles)
return &Login{Identity: identity, ReturnTo: tx.ReturnTo}, nil
}
// rolesFor maps provider groups onto application roles.
//
// The most privileged matching group wins, and a user in no configured group
// gets viewer. The default is deliberately the least privilege: a group mapping
// mistake must fail closed.
func (p *OIDCProvider) rolesFor(groups, realmRoles []string) []domain.Role {
all := make(map[string]struct{}, len(groups)+len(realmRoles))
for _, g := range groups {
all[strings.ToLower(strings.TrimSpace(g))] = struct{}{}
}
for _, r := range realmRoles {
all[strings.ToLower(strings.TrimSpace(r))] = struct{}{}
}
matches := func(configured []string) bool {
for _, want := range configured {
if _, ok := all[strings.ToLower(strings.TrimSpace(want))]; ok {
return true
}
}
return false
}
switch {
case matches(p.config.OIDCAdminGroups):
return []domain.Role{domain.RoleAdmin}
case matches(p.config.OIDCEditorGroups):
return []domain.Role{domain.RoleEditor}
case matches(p.config.OIDCViewerGroups):
return []domain.Role{domain.RoleViewer}
default:
return []domain.Role{domain.RoleViewer}
}
}
// End returns the provider's end-session URL.
func (p *OIDCProvider) End(_ context.Context, redirect string) string {
return p.LogoutURL(redirect)
}
// LogoutURL builds the provider end-session URL.
func (p *OIDCProvider) LogoutURL(redirect string) string {
if redirect == "" {
return ""
}
endSession := p.provider.Endpoint()
// Keycloak, Authentik and Zitadel all accept the post_logout_redirect_uri
// parameter on the end-session endpoint. A provider that does not
// implement it ignores the parameter and lands the user on its own logout
// confirmation page, which is a safe failure mode.
if !strings.Contains(endSession.AuthURL, "/end-session") {
return endSession.AuthURL
}
sep := "?"
if strings.Contains(endSession.AuthURL, "?") {
sep = "&"
}
return endSession.AuthURL + sep + "post_logout_redirect_uri=" + urlQueryEscape(redirect) +
"&client_id=" + urlQueryEscape(p.config.OIDCClientID)
}
// Issuer returns the configured issuer, used by the admin UI to show which
// provider is in use.
func (p *OIDCProvider) Issuer() string { return p.config.OIDCIssuer }
// RedirectURI returns the configured callback URL.
func (p *OIDCProvider) RedirectURI() string { return p.config.RedirectURL() }
// Claims returns the claims supported by this provider, for documentation.
func (p *OIDCProvider) Claims() []string {
list := []string{"sub", "email", "name", "preferred_username", "groups", "realm_access.roles"}
sort.Strings(list)
return list
}
// ClearState expires the transaction cookie. The HTTP layer calls it after
// Complete so that a replayed callback cannot reuse a consumed verifier.
func (p *OIDCProvider) ClearState(w http.ResponseWriter) { p.state.Clear(w) }
// ---------------------------------------------------------------- helpers --
// randomVerifier mints a PKCE verifier.
func randomVerifier() (string, error) {
return randomToken(32)
}
// randomState mints an anti-forgery state value.
func randomState() (string, error) {
return randomToken(24)
}
// randomNonce mints an ID token nonce.
func randomNonce() (string, error) {
return randomToken(16)
}
// randomToken returns a URL-safe random string of n bytes.
func randomToken(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("read random: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// codeChallenge returns the S256 PKCE challenge for a verifier.
func codeChallenge(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
// firstNonEmpty returns the first non-empty string.
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// urlQueryEscape percent-encodes a query parameter value.
func urlQueryEscape(s string) string {
var b strings.Builder
for _, b0 := range []byte(s) {
switch {
case b0 >= 'a' && b0 <= 'z', b0 >= 'A' && b0 <= 'Z', b0 >= '0' && b0 <= '9',
b0 == '-', b0 == '_', b0 == '.', b0 == '~':
b.WriteByte(b0)
default:
b.WriteByte('%')
b.WriteString(strings.ToUpper(hex.EncodeToString([]byte{b0})))
}
}
return b.String()
}
// insecureTLSConfig returns a TLS configuration that skips verification.
//
// It exists so that a development-only identity provider presenting a
// self-signed certificate can be used without wiring a CA into the image.
// config.Validate refuses to start when this is enabled outside development.
func insecureTLSConfig() *tls.Config {
//nolint:gosec // InsecureSkipVerify is intentional and gated by config.Validate.
return &tls.Config{InsecureSkipVerify: true}
}