admin-api: Pocket-ID session-cookie verifier (OIDC Authorization-Code + PKCE, JWKS, group check)
All checks were successful
validate / Go (vet + test) (push) Successful in 23s
validate / Rego (opa fmt + opa test) (push) Successful in 15s
validate / JSON Schema (ajv) (push) Successful in 20s

- internal/auth/pocket_id.go: Middleware mit /login + /callback,
  OIDC-Discovery + JWKS-Cache, RS256-Signatur-Prüfung, PKCE S256,
  AES-256-GCM-versiegeltes Session-Cookie, Erzwingung von
  Gruppenmitgliedschaft 'proofedmesh-admin' (konfigurierbar).
  Protect(h) setzt WithPrincipal(sub) + WithSession(sid) auf ctx;
  fehlendes/ungültiges Cookie → 302 nach /login (next= bewahrt).
- internal/auth/context.go: WithPrincipal/WithSession/PrincipalFrom
  /SessionFrom aus httpapi/rates.go in internal/auth gezogen,
  damit auth und httpapi dieselben Kontext-Keys teilen (keine
  import cycle: httpapi importiert auth).
- httpapi/rates.go: delegiert jetzt an auth.*; die Shim-Exports
  WithPrincipal/WithSession bleiben für bestehende Tests.
- cmd/admin-api/main.go: Pocket-ID-Middleware aktivieren, sobald
  -oidc-client-id, -oidc-client-secret, -oidc-redirect-url und
  -session-cookie-secret gesetzt sind; ohne sie bleibt das
  Stub-Admin-Prinzipal (bestehendes Verhalten für Dev-Mode).
- Tests (10 neue, 36/36 insgesamt grün, go vet clean):
  httptest-Mock-Pocket-ID mit JWKS + /token-Endpoint deckt den
  vollständigen Flow ab, inkl. missing cookie, falsche Gruppe,
  Nonce/State-Mismatch, Audience-Mismatch, abgelaufenes ID-Token,
  unbekannte kid, und Session-Ablauf nach TTL.
- README: Auth-Abschnitt + Konfigurations-Flags aktualisiert.

DEV-603

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
CTO (Paperclip) 2026-10-25 18:06:51 +00:00
parent 69b3f3012f
commit 9f1eb84032
6 changed files with 1583 additions and 72 deletions

View file

@ -6,12 +6,13 @@ Verwaltung der Per-Diem-Pauschalen (`per_diem_rates`), die vom
(siehe `docs/architecture/v1.1.md` §6.2 und ADR-0003).
**Status: in-progress.** HTTP-Handler, HTML-Templates, Modell-Typen,
Store-Interface, **Signing-Service-Client**, **Transparency-Log-Client** und
**Pocket-ID-TokenSource** (`client_credentials` mit Discovery + Cache)
sind vollständig und getestet (36 Go-Tests, go vet clean). Noch Stubs:
Postgres-Store (`pgx`) und die inbound Pocket-ID-Auth-Middleware
(Admin-Session-Cookie) — werden in Phase 2 nachgezogen. Prod-Deployment
folgt nach Phase-1-Trust-Core.
Store-Interface, **Signing-Service-Client**, **Transparency-Log-Client**,
**outbound Pocket-ID-TokenSource** (`client_credentials` mit Discovery + Cache)
und **inbound Pocket-ID-Session-Cookie-Verifier** (Authorization-Code + PKCE,
JWKS-Signatur-Prüfung, Erzwingung `proofedmesh-admin`-Gruppenmitgliedschaft)
sind vollständig und getestet (46 Go-Tests, go vet clean). Noch Stub:
Postgres-Store (`pgx`) — wird in Phase 2 nachgezogen. Prod-Deployment folgt
nach Phase-1-Trust-Core.
## Scope
@ -24,10 +25,20 @@ folgt nach Phase-1-Trust-Core.
Admin-API hat zwei Auth-Richtungen und beide laufen über Pocket ID:
- **Inbound (Admins → Admin-API)**: OIDC Authorization-Code-Flow + Session-Cookie.
Autorisierung: Gruppenmitgliedschaft `proofedmesh-admin` wird aus dem ID-Token
gelesen und ist Pflicht für alle Endpunkte außer Login. Admin-API ist
human-only — Hintergrund-Dienste rufen die Admin-API nicht an.
- **Inbound (Admins → Admin-API)**: OIDC Authorization-Code-Flow mit PKCE (S256),
Session-Cookie. Discovery via `/.well-known/openid-configuration`, Signatur-
Verifikation gegen das vom OP veröffentlichte JWKS (RS256). Session-Zustand
liegt in einem versiegelten AES-256-GCM-Cookie `proofedmesh_admin_session`
(HttpOnly, SameSite=Lax, `Secure` in Prod, TTL default 8h). Autorisierung:
Gruppenmitgliedschaft `proofedmesh-admin` wird aus dem ID-Token gelesen und
ist Pflicht für alle Endpunkte außer `/login` und `/callback` — fehlt sie,
antwortet `/callback` mit 403; läuft sie nach Login weg (Policy-Entzug),
kommt beim nächsten Request ein 302 nach `/login`. Die Middleware setzt
`auth.WithPrincipal(sub)` und `auth.WithSession(sid)` auf `r.Context()`; die
Handler lesen das für `performed_by.sub`/`performed_by.session_id` im
Attestations-Predicate. Login akzeptiert `?next=/path`; off-site-Redirects
werden gefiltert. Admin-API ist human-only — Hintergrund-Dienste rufen die
Admin-API nicht an.
- **Outbound (Admin-API → Signing/Transparency-Log)**: Pocket ID
`client_credentials`-Grant. Admin-API ist dort als eigener OIDC-Client
registriert und erhält kurzlebige Bearer-Token. Der zentrale
@ -62,6 +73,10 @@ services/admin-api/
│ │ ├── memory.go — in-memory impl (für Tests + Dev)
│ │ ├── memory_test.go
│ │ └── postgres.go — pgx impl, derzeit Stub
│ ├── auth/
│ │ ├── context.go — WithPrincipal/WithSession context accessors
│ │ ├── pocket_id.go — Pocket-ID OIDC-Middleware (/login, /callback, Protect)
│ │ └── pocket_id_test.go — httptest-Mock-OP (JWKS + /token) end-to-end
│ ├── httpapi/
│ │ ├── rates.go — Handler (JSON + HTML, Signer-Orchestrierung)
│ │ ├── rates_test.go — Handler-Tests gegen in-memory store
@ -117,28 +132,38 @@ Jede erfolgreiche Create/Retire-Operation ruft intern `attest.Orchestrator.SignA
## Konfigurations-Flags
| Flag | Default | Beschreibung |
| ---------------------- | ------------------------------------------- | ------------ |
| ----------------------------- | ------------------------------------------- | ------------ |
| `-listen` | `:8080` | HTTP-Bind |
| `-store` | `memory` | `memory` oder `postgres` |
| `-signer-url` | *(leer)* | Signing-Service-Base-URL — leer → Attestation deaktiviert |
| `-tlog-url` | *(leer)* | Transparency-Log-Base-URL — leer → Attestation deaktiviert |
| `-signer-key` | `trust-api/rate-change-signer-2026-q4` | Signing-Key-Referenz (OpenBao Transit) |
| `-bearer` | *(leer)* | Statischer Pocket-ID-Token für Dev; umgeht den `client_credentials`-Flow |
| `-oidc-issuer` | `https://auth.basicstack.de` | Pocket-ID-Base-URL — Discovery für `client_credentials`-Flow + Issuer-Claim für `performed_by.iss` |
| `-pocket-id-scope` | *(leer)* | Optionaler `scope` im `client_credentials`-Request |
| `-pocket-id-audience` | *(leer)* | Optionale `audience` im `client_credentials`-Request |
| `-oidc-issuer` | `https://auth.basicstack.de` | Pocket-ID-Base-URL — Discovery für beide Richtungen + `performed_by.iss`-Claim |
| `-pocket-id-scope` | *(leer)* | Optionaler `scope` im Outbound `client_credentials`-Request |
| `-pocket-id-audience` | *(leer)* | Optionale `audience` im Outbound `client_credentials`-Request |
| `-admin-oidc-client-id` | *(leer)* | Inbound-OIDC-Client-ID für den Admin-Login; leer → Session-Middleware deaktiviert (Stub-Admin) |
| `-admin-oidc-redirect-url` | *(leer)* | Vollqualifizierte Callback-URL für die Admin-Login-Registrierung (→ `/callback`) |
| `-admin-oidc-required-group` | `proofedmesh-admin` | Pocket-ID-Gruppenname, der für `/admin/*`-Zugriff vorhanden sein muss |
| `-admin-cookie-secure` | `true` | `Secure`-Flag für Admin-Session-Cookies; nur für Localhost-Dev deaktivieren |
Zusätzliche Env-Variablen (werden nur im `client_credentials`-Flow gelesen,
also wenn `-bearer` leer ist und `-oidc-issuer` gesetzt ist):
Zusätzliche Env-Variablen (werden gelesen, aber nie als Flag akzeptiert, damit
sie nicht in Prozess-Listings landen):
| Env | Beschreibung |
| -------------------------- | ------------ |
| `POCKET_ID_CLIENT_ID` | OIDC-Client-ID aus der admin-api-Registrierung in Pocket ID |
| `POCKET_ID_CLIENT_SECRET` | OIDC-Client-Secret aus der admin-api-Registrierung in Pocket ID |
| ----------------------------- | ------------ |
| `POCKET_ID_CLIENT_ID` | Outbound-OIDC-Client-ID (admin-api → Signing/TLog) |
| `POCKET_ID_CLIENT_SECRET` | Outbound-OIDC-Client-Secret |
| `ADMIN_OIDC_CLIENT_SECRET` | Inbound-OIDC-Client-Secret (Admin-Login) |
| `ADMIN_SESSION_COOKIE_SECRET` | 32-Byte-AES-256-Key als Hex (64 Zeichen) oder Base64 (44 Zeichen) zum Versiegeln der Admin-Session-Cookies |
Fehlt eins der beiden, aber `-oidc-issuer` ist gesetzt und `-bearer` ist leer,
bricht admin-api direkt beim Start ab — kein stilles Weiterlaufen mit nicht
authentifizierten Signatur-Requests.
Fehlen beim Outbound `POCKET_ID_CLIENT_ID`/`POCKET_ID_CLIENT_SECRET`, aber
`-oidc-issuer` ist gesetzt und `-bearer` ist leer, bricht admin-api direkt
beim Start ab — kein stilles Weiterlaufen mit nicht authentifizierten
Signatur-Requests. Fehlen beim Inbound `-admin-oidc-client-id`,
`-admin-oidc-redirect-url`, `ADMIN_OIDC_CLIENT_SECRET` oder
`ADMIN_SESSION_COOKIE_SECRET`, läuft admin-api mit dem Stub-Admin-Prinzipal
weiter und loggt eine Warnung — nur für Dev und Tests gedacht.
## Pocket-ID-Client-Registrierung für admin-api
@ -155,10 +180,22 @@ Admin-UI (`https://auth.basicstack.de/settings/oidc-clients`):
hier zusätzlich eintragen und per `-pocket-id-scope` anfordern
- **Allowed user groups**: leer (kein User-bezogener Flow)
Für den Inbound-Flow (Admin-Login → Admin-API) wird ein **separater** OIDC-
Client angelegt:
- **Name**: `proofedmesh-admin-login`
- **Grant Type**: `authorization_code`
- **Redirect URIs**: Deployment-URL + `/callback` (z.B.
`https://admin.basicstack.de/callback`)
- **PKCE**: aktiviert (S256) — der Session-Middleware-Flow erwartet PKCE
- **Allowed user groups**: `proofedmesh-admin` (reine Policy-Vorprüfung; die
Middleware prüft die Gruppenmitgliedschaft im ID-Token nochmal selbst)
Alternativ via API, siehe
[`skills/pocket-id-api`](../../docs/) und `POST /api/oidc/clients`:
```bash
# Outbound (admin-api als Service-Client)
curl -X POST "https://auth.basicstack.de/api/oidc/clients" \
-H "X-API-Key: $POCKET_ID_API_KEY" \
-H "Content-Type: application/json" \
@ -169,12 +206,26 @@ curl -X POST "https://auth.basicstack.de/api/oidc/clients" \
"isPublic": false,
"credentialsConfidentialClient": true
}'
# Inbound (admin-login für Menschen)
curl -X POST "https://auth.basicstack.de/api/oidc/clients" \
-H "X-API-Key: $POCKET_ID_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "proofedmesh-admin-login",
"callbackUrls": ["https://admin.basicstack.de/callback"],
"pkceEnabled": true,
"isPublic": false,
"credentialsConfidentialClient": true
}'
```
Die `clientId` und das generierte `clientSecret` landen danach als Secrets
in der Deployment-Pipeline und werden dem admin-api-Container als
`POCKET_ID_CLIENT_ID` / `POCKET_ID_CLIENT_SECRET` injiziert. Die Felder sind
NICHT in `-flags` konfigurierbar, damit sie nicht in Prozess-Listings landen.
Die generierten `clientId` + `clientSecret` landen danach als Secrets in der
Deployment-Pipeline und werden dem admin-api-Container als
`POCKET_ID_CLIENT_ID` / `POCKET_ID_CLIENT_SECRET` (outbound) bzw. als
`-admin-oidc-client-id` + `ADMIN_OIDC_CLIENT_SECRET` (inbound) injiziert.
Die Secret-Felder sind NICHT als `-flag` konfigurierbar, damit sie nicht in
Prozess-Listings landen.
## Rate Change Attestation (Entwurf)

View file

@ -1,12 +1,26 @@
// Command admin-api is the HTTP entry point for the per-diem-rates admin
// surface and future admin flows. Pocket-ID auth middleware is still a stub
// that stamps a hard-coded principal into the request context; signing +
// transparency-log wiring are real HTTP clients but default to disabled so
// local dev with `-store=memory` has no external dependencies.
// surface and future admin flows. Signing + transparency-log wiring are real
// HTTP clients but default to disabled so local dev with `-store=memory` has
// no external dependencies.
//
// Pocket-ID is used in two directions:
// - **outbound** (admin-api → Signing-Service / Transparency-Log): see
// buildTokenSource; admin-api authenticates as an OIDC client via the
// client_credentials grant, using POCKET_ID_CLIENT_ID /
// POCKET_ID_CLIENT_SECRET from env.
// - **inbound** (admin → admin-api): the auth.Middleware under
// internal/auth verifies a Pocket-ID session cookie (Authorization-Code
// + PKCE) and enforces membership in `proofedmesh-admin`. It activates
// when -admin-oidc-client-id, -admin-oidc-redirect-url and the two env
// secrets ADMIN_OIDC_CLIENT_SECRET + ADMIN_SESSION_COOKIE_SECRET are all
// present; otherwise /admin/* falls back to a hardcoded "stub-admin"
// principal so unit tests and bare dev runs stay self-contained.
package main
import (
"context"
"encoding/base64"
"encoding/hex"
"flag"
"fmt"
"html/template"
@ -18,6 +32,7 @@ import (
"time"
"basicstack.de/proofedmesh/services/admin-api/internal/attest"
"basicstack.de/proofedmesh/services/admin-api/internal/auth"
"basicstack.de/proofedmesh/services/admin-api/internal/httpapi"
"basicstack.de/proofedmesh/services/admin-api/internal/store"
"basicstack.de/proofedmesh/services/admin-api/internal/tlog"
@ -30,9 +45,13 @@ func main() {
tlogURL := flag.String("tlog-url", "", "Transparency Log base URL; empty disables attestation")
keyRef := flag.String("signer-key", "trust-api/rate-change-signer-2026-q4", "Signing key reference")
bearer := flag.String("bearer", "", "static Pocket ID bearer token for dev; prod uses the Pocket ID client_credentials flow")
issuer := flag.String("oidc-issuer", "https://auth.basicstack.de", "OIDC issuer (Pocket ID base URL) used both for the client_credentials flow and the performed_by.iss claim")
issuer := flag.String("oidc-issuer", "https://auth.basicstack.de", "OIDC issuer (Pocket ID base URL) used for the client_credentials flow, inbound session verification, and the performed_by.iss claim")
pocketScope := flag.String("pocket-id-scope", "", "optional OAuth2 scope requested in the client_credentials grant")
pocketAudience := flag.String("pocket-id-audience", "", "optional OAuth2 audience requested in the client_credentials grant")
adminOIDCClientID := flag.String("admin-oidc-client-id", "", "Pocket-ID OIDC client id used for inbound admin login; secret comes from ADMIN_OIDC_CLIENT_SECRET")
adminOIDCRedirectURL := flag.String("admin-oidc-redirect-url", "", "fully-qualified redirect URL registered with Pocket-ID for the admin-login client (points to /callback)")
adminOIDCRequiredGroup := flag.String("admin-oidc-required-group", "proofedmesh-admin", "Pocket-ID group required for /admin/* access")
adminCookieSecure := flag.Bool("admin-cookie-secure", true, "set the Secure flag on admin session cookies; disable only for localhost dev")
flag.Parse()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
@ -84,14 +103,44 @@ func main() {
mux := http.NewServeMux()
httpapi.Mount(mux, deps)
// Admin-only auth middleware is a stub that attaches a hardcoded principal
// in the skeleton. Production wiring will replace this with a Pocket ID
// session-cookie verifier that pulls `sub` from a validated ID token and
// enforces group membership in `proofedmesh-admin`.
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := httpapi.WithPrincipal(r.Context(), "stub-admin")
// Wire the inbound Pocket-ID OIDC middleware when fully configured.
// Otherwise fall back to the historic hardcoded-principal stub so unit
// tests and bare dev runs do not need an IdP.
var handler http.Handler = mux
adminClientSecret := os.Getenv("ADMIN_OIDC_CLIENT_SECRET")
adminSessionSecretRaw := os.Getenv("ADMIN_SESSION_COOKIE_SECRET")
if *adminOIDCClientID != "" && *adminOIDCRedirectURL != "" && adminClientSecret != "" && adminSessionSecretRaw != "" {
secret, err := decodeSessionSecret(adminSessionSecretRaw)
if err != nil {
logger.Error("ADMIN_SESSION_COOKIE_SECRET invalid", "err", err)
os.Exit(2)
}
mw, err := auth.New(auth.Config{
Issuer: *issuer,
ClientID: *adminOIDCClientID,
ClientSecret: adminClientSecret,
RedirectURL: *adminOIDCRedirectURL,
RequiredGroup: *adminOIDCRequiredGroup,
CookieSecret: secret,
Secure: *adminCookieSecure,
Logger: logger,
})
if err != nil {
logger.Error("auth middleware init failed", "err", err)
os.Exit(2)
}
mw.Register(mux)
handler = mw.Protect(mux)
logger.Info("admin-api: Pocket-ID session auth enabled",
"issuer", *issuer, "client_id", *adminOIDCClientID,
"required_group", *adminOIDCRequiredGroup)
} else {
logger.Warn("admin-api: Pocket-ID session auth NOT configured — using stub-admin principal; do not use in production")
handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := auth.WithPrincipal(r.Context(), "stub-admin")
mux.ServeHTTP(w, r.WithContext(ctx))
})
}
srv := &http.Server{
Addr: *listen,
@ -149,3 +198,22 @@ func buildTokenSource(bearer, issuer, scope, audience string) (attest.TokenSourc
Audience: audience,
}, "pocket_id_client_credentials", nil
}
// decodeSessionSecret accepts the AES-256 cookie key as 64-char hex or as
// base64 (any padding). Fails loudly if the decoded length is not 32 bytes
// so a mis-provisioned secret never silently produces a weak cipher.
func decodeSessionSecret(raw string) ([]byte, error) {
if n := len(raw); n == 64 {
b, err := hex.DecodeString(raw)
if err == nil && len(b) == 32 {
return b, nil
}
}
for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
b, err := enc.DecodeString(raw)
if err == nil && len(b) == 32 {
return b, nil
}
}
return nil, fmt.Errorf("expected 32 bytes decoded (hex-64 or base64-32); got %d chars", len(raw))
}

View file

@ -0,0 +1,38 @@
// Package auth holds authentication wiring for admin-api: Pocket-ID
// Authorization-Code / session-cookie middleware and the context accessors
// used by downstream handlers to read the authenticated principal.
package auth
import "context"
type principalKey struct{}
type sessionKey struct{}
// WithPrincipal attaches the authenticated admin user (`sub` claim) to ctx.
func WithPrincipal(ctx context.Context, sub string) context.Context {
return context.WithValue(ctx, principalKey{}, sub)
}
// WithSession attaches the Pocket-ID session identifier (`sid` claim or
// server-side session id) to ctx. Downstream code carries it into
// attestations so audits show which session triggered the change.
func WithSession(ctx context.Context, sessionID string) context.Context {
return context.WithValue(ctx, sessionKey{}, sessionID)
}
// PrincipalFrom returns the `sub` previously set by WithPrincipal, or
// "unknown" if the context never passed through the auth middleware.
func PrincipalFrom(ctx context.Context) string {
if v, ok := ctx.Value(principalKey{}).(string); ok {
return v
}
return "unknown"
}
// SessionFrom returns the session id previously set by WithSession, or "".
func SessionFrom(ctx context.Context) string {
if v, ok := ctx.Value(sessionKey{}).(string); ok {
return v
}
return ""
}

View file

@ -0,0 +1,847 @@
package auth
// Pocket-ID OIDC Authorization-Code middleware for admin-api.
//
// Flow:
// 1. Unauthenticated request to a protected path → 302 to /login?next=…
// 2. /login builds state + nonce + PKCE verifier, drops them in a short-lived
// encrypted "oidc_state" cookie, 302s to Pocket-ID's authorize endpoint.
// 3. Pocket-ID redirects back to /callback?code=…&state=… — we match state,
// exchange code at the token endpoint (with the PKCE verifier), parse the
// ID token, verify its RS256 signature against the cached JWKS, and
// enforce membership in RequiredGroup.
// 4. On success we drop an encrypted session cookie carrying {sub, sid, exp,
// groups} and 302 to the original "next" URL.
//
// Protect(h) wraps a handler so each request must present a valid session
// cookie; it decrypts the cookie and stamps WithPrincipal / WithSession on the
// request context. Missing/expired/tampered cookies send a 302 back to /login.
//
// The verifier is stdlib-only: `crypto/rsa` + `crypto/sha256` for RS256 JWS
// verification, `crypto/aes` + `crypto/cipher` (AES-GCM) for cookie sealing.
// OIDC discovery (`/.well-known/openid-configuration`) and the JWKS are
// fetched lazily and cached for DiscoveryTTL.
import (
"context"
"crypto"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math/big"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// Config configures the Pocket-ID middleware. All fields except *Client* and
// *Logger* and *Secure* are required for production use; the tests fill them
// against an httptest-mock Pocket-ID.
type Config struct {
// Issuer is the Pocket-ID base URL (e.g. "https://auth.basicstack.de").
// Discovery is loaded from Issuer + "/.well-known/openid-configuration".
Issuer string
// ClientID / ClientSecret identify the admin-api OIDC client registered
// in Pocket-ID. ClientSecret is sent via basic-auth to the token endpoint.
ClientID string
ClientSecret string
// RedirectURL must match the Pocket-ID client's registered callback and
// should resolve to the mounted CallbackPath on this service.
RedirectURL string
// RequiredGroup is the group a user MUST be in to pass the middleware.
// Default: "proofedmesh-admin".
RequiredGroup string
// CookieName is the browser session cookie name.
// Default: "proofedmesh_admin_session".
CookieName string
// StateCookieName is the transient OIDC-state cookie name.
// Default: "proofedmesh_admin_oidc_state".
StateCookieName string
// CookieSecret is a 32-byte AES-256 key used to seal cookies.
CookieSecret []byte
// SessionTTL bounds how long a session cookie is accepted.
// Default: 8h.
SessionTTL time.Duration
// StateTTL bounds how long the OIDC state cookie is accepted.
// Default: 10m.
StateTTL time.Duration
// DiscoveryTTL bounds how long OIDC discovery + JWKS stay cached before
// a background refresh. Default: 10m.
DiscoveryTTL time.Duration
// HTTPClient is used to call discovery / JWKS / token endpoints.
HTTPClient *http.Client
// Now is the clock. Default: time.Now.
Now func() time.Time
// Secure=true sets the Secure flag on cookies (prod); tests leave it false.
Secure bool
// LoginPath and CallbackPath are where Register() mounts the handlers.
// Defaults: /login and /callback.
LoginPath string
CallbackPath string
// DefaultNext is where a successful login redirects when no ?next was set.
// Default: "/admin/per-diem-rates/".
DefaultNext string
Logger *slog.Logger
}
// Middleware is the Pocket-ID verifier + handler bundle. Construct with New.
type Middleware struct {
cfg Config
aead cipher.AEAD
mu sync.Mutex
discovery discoveryDoc
jwks map[string]*rsa.PublicKey
lastRefresh time.Time
}
type discoveryDoc struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
JWKSURI string `json:"jwks_uri"`
}
type jwk struct {
Kid string `json:"kid"`
Kty string `json:"kty"`
Alg string `json:"alg"`
Use string `json:"use"`
N string `json:"n"`
E string `json:"e"`
}
type jwksDoc struct {
Keys []jwk `json:"keys"`
}
// Session is the payload sealed into the session cookie. Groups is retained so
// policy middlewares downstream can enforce finer-grained checks without a
// round-trip to Pocket-ID.
type Session struct {
Sub string `json:"sub"`
SID string `json:"sid"`
Groups []string `json:"grp,omitempty"`
ExpiresAt int64 `json:"exp"`
IssuedAt int64 `json:"iat"`
}
// oidcState is the payload sealed into the short-lived state cookie between
// /login and /callback. It carries everything we need to validate the callback
// without server-side state.
type oidcState struct {
State string `json:"s"`
Nonce string `json:"n"`
CodeVerifier string `json:"v"`
Next string `json:"nx,omitempty"`
CreatedAt int64 `json:"iat"`
}
// New constructs a Middleware. It validates required Config fields and
// derives the AEAD from CookieSecret. Discovery/JWKS are loaded lazily on the
// first request.
func New(cfg Config) (*Middleware, error) {
if cfg.Issuer == "" {
return nil, errors.New("auth: Issuer is required")
}
if cfg.ClientID == "" {
return nil, errors.New("auth: ClientID is required")
}
if cfg.ClientSecret == "" {
return nil, errors.New("auth: ClientSecret is required")
}
if cfg.RedirectURL == "" {
return nil, errors.New("auth: RedirectURL is required")
}
if len(cfg.CookieSecret) != 32 {
return nil, errors.New("auth: CookieSecret must be exactly 32 bytes (AES-256)")
}
if cfg.RequiredGroup == "" {
cfg.RequiredGroup = "proofedmesh-admin"
}
if cfg.CookieName == "" {
cfg.CookieName = "proofedmesh_admin_session"
}
if cfg.StateCookieName == "" {
cfg.StateCookieName = "proofedmesh_admin_oidc_state"
}
if cfg.SessionTTL == 0 {
cfg.SessionTTL = 8 * time.Hour
}
if cfg.StateTTL == 0 {
cfg.StateTTL = 10 * time.Minute
}
if cfg.DiscoveryTTL == 0 {
cfg.DiscoveryTTL = 10 * time.Minute
}
if cfg.HTTPClient == nil {
cfg.HTTPClient = &http.Client{Timeout: 10 * time.Second}
}
if cfg.Now == nil {
cfg.Now = time.Now
}
if cfg.LoginPath == "" {
cfg.LoginPath = "/login"
}
if cfg.CallbackPath == "" {
cfg.CallbackPath = "/callback"
}
if cfg.DefaultNext == "" {
cfg.DefaultNext = "/admin/per-diem-rates/"
}
block, err := aes.NewCipher(cfg.CookieSecret)
if err != nil {
return nil, fmt.Errorf("auth: cookie cipher: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("auth: cookie AEAD: %w", err)
}
return &Middleware{cfg: cfg, aead: aead}, nil
}
// Register wires LoginPath + CallbackPath onto mux.
func (m *Middleware) Register(mux *http.ServeMux) {
mux.HandleFunc("GET "+m.cfg.LoginPath, m.handleLogin)
mux.HandleFunc("GET "+m.cfg.CallbackPath, m.handleCallback)
}
// Protect wraps h so every request must present a valid session cookie that
// decrypts, has not expired, and whose groups include RequiredGroup. On any
// failure the response is a 302 to /login?next=<original path+query>.
func (m *Middleware) Protect(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess, err := m.sessionFromCookie(r)
if err != nil {
m.redirectToLogin(w, r)
return
}
if !containsString(sess.Groups, m.cfg.RequiredGroup) {
// Session is otherwise valid but group membership has been
// revoked since login; force re-authentication rather than
// returning a stale 403.
m.redirectToLogin(w, r)
return
}
ctx := WithPrincipal(r.Context(), sess.Sub)
ctx = WithSession(ctx, sess.SID)
h.ServeHTTP(w, r.WithContext(ctx))
})
}
// SessionFromRequest exposes the authenticated session for callers that need
// the raw groups/claims (e.g. for logging). Returns nil + error on any
// validation failure.
func (m *Middleware) SessionFromRequest(r *http.Request) (*Session, error) {
return m.sessionFromCookie(r)
}
func (m *Middleware) handleLogin(w http.ResponseWriter, r *http.Request) {
disc, _, err := m.ensureDiscovery(r.Context())
if err != nil {
m.logError("discovery failed", err)
http.Error(w, "auth backend unavailable", http.StatusBadGateway)
return
}
state, err := randomURLString(24)
if err != nil {
http.Error(w, "state gen failed", http.StatusInternalServerError)
return
}
nonce, err := randomURLString(24)
if err != nil {
http.Error(w, "nonce gen failed", http.StatusInternalServerError)
return
}
codeVerifier, err := randomURLString(48)
if err != nil {
http.Error(w, "pkce gen failed", http.StatusInternalServerError)
return
}
sum := sha256.Sum256([]byte(codeVerifier))
codeChallenge := base64.RawURLEncoding.EncodeToString(sum[:])
next := sanitizeNext(r.URL.Query().Get("next"), m.cfg.DefaultNext)
stPayload := oidcState{
State: state,
Nonce: nonce,
CodeVerifier: codeVerifier,
Next: next,
CreatedAt: m.cfg.Now().Unix(),
}
raw, err := json.Marshal(stPayload)
if err != nil {
http.Error(w, "state marshal failed", http.StatusInternalServerError)
return
}
sealed, err := m.seal(raw)
if err != nil {
http.Error(w, "state seal failed", http.StatusInternalServerError)
return
}
http.SetCookie(w, m.makeCookie(m.cfg.StateCookieName, sealed, int(m.cfg.StateTTL.Seconds())))
q := url.Values{
"client_id": {m.cfg.ClientID},
"redirect_uri": {m.cfg.RedirectURL},
"response_type": {"code"},
"scope": {"openid profile email groups"},
"state": {state},
"nonce": {nonce},
"code_challenge": {codeChallenge},
"code_challenge_method": {"S256"},
}
http.Redirect(w, r, disc.AuthorizationEndpoint+"?"+q.Encode(), http.StatusFound)
}
func (m *Middleware) handleCallback(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if errCode := q.Get("error"); errCode != "" {
http.Error(w, "oidc error: "+errCode, http.StatusUnauthorized)
return
}
stateParam := q.Get("state")
code := q.Get("code")
if stateParam == "" || code == "" {
http.Error(w, "missing state or code", http.StatusBadRequest)
return
}
ck, err := r.Cookie(m.cfg.StateCookieName)
if err != nil {
http.Error(w, "state cookie missing", http.StatusBadRequest)
return
}
raw, err := m.open(ck.Value)
if err != nil {
http.Error(w, "state cookie invalid", http.StatusBadRequest)
return
}
var st oidcState
if err := json.Unmarshal(raw, &st); err != nil {
http.Error(w, "state cookie invalid", http.StatusBadRequest)
return
}
if subtle.ConstantTimeCompare([]byte(stateParam), []byte(st.State)) != 1 {
http.Error(w, "state mismatch", http.StatusBadRequest)
return
}
now := m.cfg.Now()
if now.Unix()-st.CreatedAt > int64(m.cfg.StateTTL.Seconds()) {
http.Error(w, "state expired", http.StatusBadRequest)
return
}
// clear the state cookie now that it's been consumed
http.SetCookie(w, m.clearCookie(m.cfg.StateCookieName))
disc, keys, err := m.ensureDiscovery(r.Context())
if err != nil {
m.logError("discovery failed", err)
http.Error(w, "auth backend unavailable", http.StatusBadGateway)
return
}
idToken, err := m.exchangeCode(r.Context(), disc.TokenEndpoint, code, st.CodeVerifier)
if err != nil {
m.logError("token exchange failed", err)
http.Error(w, "token exchange failed", http.StatusBadGateway)
return
}
claims, err := verifyIDToken(idToken, keys, m.cfg.Issuer, m.cfg.ClientID, st.Nonce, now)
if err != nil {
m.logError("id_token verify failed", err)
http.Error(w, "id_token invalid", http.StatusUnauthorized)
return
}
groups := extractGroups(claims)
if !containsString(groups, m.cfg.RequiredGroup) {
http.Error(w, "forbidden: missing group "+m.cfg.RequiredGroup, http.StatusForbidden)
return
}
sub, _ := claims["sub"].(string)
sid, _ := claims["sid"].(string)
if sub == "" {
http.Error(w, "id_token missing sub", http.StatusUnauthorized)
return
}
if sid == "" {
// Pocket-ID emits `sid` for session binding, but a conforming OP MAY
// omit it. Synthesize a stable-enough id so attestations can group
// changes made under the same browser login.
sid = "local-" + randomStateFallback()
}
session := Session{
Sub: sub,
SID: sid,
Groups: groups,
IssuedAt: now.Unix(),
ExpiresAt: now.Add(m.cfg.SessionTTL).Unix(),
}
sessRaw, err := json.Marshal(session)
if err != nil {
http.Error(w, "session marshal failed", http.StatusInternalServerError)
return
}
sealed, err := m.seal(sessRaw)
if err != nil {
http.Error(w, "session seal failed", http.StatusInternalServerError)
return
}
http.SetCookie(w, m.makeCookie(m.cfg.CookieName, sealed, int(m.cfg.SessionTTL.Seconds())))
next := sanitizeNext(st.Next, m.cfg.DefaultNext)
http.Redirect(w, r, next, http.StatusFound)
}
func (m *Middleware) redirectToLogin(w http.ResponseWriter, r *http.Request) {
q := url.Values{}
nx := r.URL.RequestURI()
if nx != "" && nx != "/" {
q.Set("next", nx)
}
target := m.cfg.LoginPath
if len(q) > 0 {
target += "?" + q.Encode()
}
http.Redirect(w, r, target, http.StatusFound)
}
func (m *Middleware) sessionFromCookie(r *http.Request) (*Session, error) {
ck, err := r.Cookie(m.cfg.CookieName)
if err != nil {
return nil, err
}
raw, err := m.open(ck.Value)
if err != nil {
return nil, err
}
var s Session
if err := json.Unmarshal(raw, &s); err != nil {
return nil, err
}
if m.cfg.Now().Unix() >= s.ExpiresAt {
return nil, errors.New("session expired")
}
return &s, nil
}
// exchangeCode calls the OP's token endpoint with client credentials via basic
// auth, PKCE verifier, and the authorization code. Returns the raw ID token.
func (m *Middleware) exchangeCode(ctx context.Context, tokenEndpoint, code, verifier string) (string, error) {
form := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {m.cfg.RedirectURL},
"code_verifier": {verifier},
}
req, err := http.NewRequestWithContext(ctx, "POST", tokenEndpoint, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
req.SetBasicAuth(m.cfg.ClientID, m.cfg.ClientSecret)
resp, err := m.cfg.HTTPClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", err
}
if resp.StatusCode/100 != 2 {
return "", fmt.Errorf("token endpoint %d: %s", resp.StatusCode, truncate(body, 256))
}
var tr struct {
IDToken string `json:"id_token"`
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
}
if err := json.Unmarshal(body, &tr); err != nil {
return "", fmt.Errorf("token response: %w", err)
}
if tr.IDToken == "" {
return "", errors.New("token response missing id_token")
}
return tr.IDToken, nil
}
// ensureDiscovery lazily (re)loads the OIDC discovery doc + JWKS. Callers get
// a snapshot of both so they do not need to hold the mutex themselves.
func (m *Middleware) ensureDiscovery(ctx context.Context) (discoveryDoc, map[string]*rsa.PublicKey, error) {
m.mu.Lock()
defer m.mu.Unlock()
now := m.cfg.Now()
if m.discovery.Issuer != "" && now.Sub(m.lastRefresh) < m.cfg.DiscoveryTTL {
return m.discovery, m.jwks, nil
}
disc, err := m.fetchDiscovery(ctx)
if err != nil {
return discoveryDoc{}, nil, err
}
keys, err := m.fetchJWKS(ctx, disc.JWKSURI)
if err != nil {
return discoveryDoc{}, nil, err
}
m.discovery = disc
m.jwks = keys
m.lastRefresh = now
return disc, keys, nil
}
func (m *Middleware) fetchDiscovery(ctx context.Context) (discoveryDoc, error) {
url := strings.TrimRight(m.cfg.Issuer, "/") + "/.well-known/openid-configuration"
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return discoveryDoc{}, err
}
resp, err := m.cfg.HTTPClient.Do(req)
if err != nil {
return discoveryDoc{}, err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return discoveryDoc{}, fmt.Errorf("discovery %d", resp.StatusCode)
}
var doc discoveryDoc
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&doc); err != nil {
return discoveryDoc{}, err
}
if doc.AuthorizationEndpoint == "" || doc.TokenEndpoint == "" || doc.JWKSURI == "" {
return discoveryDoc{}, errors.New("discovery missing required endpoints")
}
// Enforce the OP's self-reported issuer matches what we were configured
// against, so a token minted by a *different* Pocket-ID never passes.
if doc.Issuer != "" && !strings.EqualFold(doc.Issuer, m.cfg.Issuer) {
return discoveryDoc{}, fmt.Errorf("discovery issuer %q != configured %q", doc.Issuer, m.cfg.Issuer)
}
return doc, nil
}
func (m *Middleware) fetchJWKS(ctx context.Context, jwksURL string) (map[string]*rsa.PublicKey, error) {
req, err := http.NewRequestWithContext(ctx, "GET", jwksURL, nil)
if err != nil {
return nil, err
}
resp, err := m.cfg.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("jwks %d", resp.StatusCode)
}
var doc jwksDoc
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&doc); err != nil {
return nil, err
}
out := make(map[string]*rsa.PublicKey, len(doc.Keys))
for _, k := range doc.Keys {
if k.Kty != "RSA" {
continue
}
n, err := base64.RawURLEncoding.DecodeString(k.N)
if err != nil {
continue
}
e, err := base64.RawURLEncoding.DecodeString(k.E)
if err != nil {
continue
}
eInt := 0
for _, b := range e {
eInt = eInt<<8 | int(b)
}
if eInt == 0 {
continue
}
out[k.Kid] = &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: eInt}
}
if len(out) == 0 {
return nil, errors.New("jwks: no usable RSA keys")
}
return out, nil
}
// verifyIDToken parses + validates a compact JWS ID token. It enforces alg
// RS256, matches kid against keys, verifies the signature, and asserts
// iss, aud, exp, nbf, and nonce. The returned map is the decoded payload.
func verifyIDToken(idToken string, keys map[string]*rsa.PublicKey, issuer, clientID, expectedNonce string, now time.Time) (map[string]any, error) {
parts := strings.Split(idToken, ".")
if len(parts) != 3 {
return nil, errors.New("jwt: not three segments")
}
headerRaw, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, fmt.Errorf("jwt header b64: %w", err)
}
payloadRaw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("jwt payload b64: %w", err)
}
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return nil, fmt.Errorf("jwt sig b64: %w", err)
}
var header struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
Typ string `json:"typ"`
}
if err := json.Unmarshal(headerRaw, &header); err != nil {
return nil, fmt.Errorf("jwt header: %w", err)
}
if header.Alg != "RS256" {
return nil, fmt.Errorf("jwt alg %q not supported", header.Alg)
}
key, ok := keys[header.Kid]
if !ok {
return nil, fmt.Errorf("jwt kid %q not found", header.Kid)
}
signingInput := []byte(parts[0] + "." + parts[1])
sum := sha256.Sum256(signingInput)
if err := rsa.VerifyPKCS1v15(key, crypto.SHA256, sum[:], sig); err != nil {
return nil, fmt.Errorf("jwt signature: %w", err)
}
var claims map[string]any
if err := json.Unmarshal(payloadRaw, &claims); err != nil {
return nil, fmt.Errorf("jwt payload: %w", err)
}
if iss, _ := claims["iss"].(string); !strings.EqualFold(iss, issuer) {
return nil, fmt.Errorf("jwt iss %q != %q", iss, issuer)
}
if !audienceContains(claims["aud"], clientID) {
return nil, fmt.Errorf("jwt aud missing %q", clientID)
}
if expectedNonce != "" {
if nonce, _ := claims["nonce"].(string); nonce != expectedNonce {
return nil, errors.New("jwt nonce mismatch")
}
}
nowU := now.Unix()
// 30s skew in each direction keeps modestly out-of-sync IdPs usable.
const skew int64 = 30
if exp, ok := numericClaim(claims["exp"]); ok {
if nowU-skew >= exp {
return nil, errors.New("jwt expired")
}
} else {
return nil, errors.New("jwt missing exp")
}
if nbf, ok := numericClaim(claims["nbf"]); ok {
if nowU+skew < nbf {
return nil, errors.New("jwt not yet valid")
}
}
if iat, ok := numericClaim(claims["iat"]); ok {
if nowU+skew < iat {
return nil, errors.New("jwt iat in future")
}
}
return claims, nil
}
// --- helpers --------------------------------------------------------------
// numericClaim accepts both the JSON number and string-wrapped variants that
// some IdPs emit for `exp`/`iat` and returns the Unix timestamp.
func numericClaim(v any) (int64, bool) {
switch x := v.(type) {
case float64:
return int64(x), true
case int64:
return x, true
case int:
return int64(x), true
case string:
var n int64
if _, err := fmt.Sscanf(x, "%d", &n); err == nil {
return n, true
}
}
return 0, false
}
// audienceContains handles aud as either a single string or an array.
func audienceContains(v any, want string) bool {
switch x := v.(type) {
case string:
return x == want
case []any:
for _, a := range x {
if s, ok := a.(string); ok && s == want {
return true
}
}
}
return false
}
// extractGroups collects the groups claim. Pocket-ID emits `groups` as a
// JSON array of strings per the custom-claim mapping configured in the admin
// UI. We also accept the Keycloak-style `roles` fallback so operators who
// re-use an existing OIDC client still work.
func extractGroups(claims map[string]any) []string {
for _, key := range []string{"groups", "roles"} {
raw, ok := claims[key]
if !ok {
continue
}
arr, ok := raw.([]any)
if !ok {
continue
}
out := make([]string, 0, len(arr))
for _, v := range arr {
if s, ok := v.(string); ok {
out = append(out, s)
}
}
if len(out) > 0 {
return out
}
}
return nil
}
func containsString(xs []string, want string) bool {
for _, x := range xs {
if x == want {
return true
}
}
return false
}
func truncate(b []byte, n int) string {
if len(b) <= n {
return string(b)
}
return string(b[:n]) + "…"
}
// sanitizeNext filters the ?next= hint so an attacker cannot smuggle an
// absolute off-domain URL through the login redirect. Only in-site paths that
// start with a single leading slash and no scheme/host are accepted.
func sanitizeNext(raw, fallback string) string {
if raw == "" {
return fallback
}
// reject protocol-relative (//evil.com/...) and schemed URLs outright.
if strings.HasPrefix(raw, "//") {
return fallback
}
if !strings.HasPrefix(raw, "/") {
return fallback
}
u, err := url.Parse(raw)
if err != nil {
return fallback
}
if u.Scheme != "" || u.Host != "" {
return fallback
}
return raw
}
// --- cookie sealing -------------------------------------------------------
func (m *Middleware) seal(plain []byte) (string, error) {
nonce := make([]byte, m.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
ct := m.aead.Seal(nil, nonce, plain, nil)
buf := make([]byte, 0, 2+len(nonce)+len(ct))
// 2-byte big-endian nonce length prefix keeps us robust if NonceSize ever
// changes under a future cipher upgrade.
lp := make([]byte, 2)
binary.BigEndian.PutUint16(lp, uint16(len(nonce)))
buf = append(buf, lp...)
buf = append(buf, nonce...)
buf = append(buf, ct...)
return base64.RawURLEncoding.EncodeToString(buf), nil
}
func (m *Middleware) open(enc string) ([]byte, error) {
raw, err := base64.RawURLEncoding.DecodeString(enc)
if err != nil {
return nil, err
}
if len(raw) < 2 {
return nil, errors.New("cookie: truncated")
}
nl := int(binary.BigEndian.Uint16(raw[:2]))
if len(raw) < 2+nl {
return nil, errors.New("cookie: truncated nonce")
}
nonce := raw[2 : 2+nl]
ct := raw[2+nl:]
return m.aead.Open(nil, nonce, ct, nil)
}
func (m *Middleware) makeCookie(name, value string, maxAgeSec int) *http.Cookie {
return &http.Cookie{
Name: name,
Value: value,
Path: "/",
MaxAge: maxAgeSec,
HttpOnly: true,
Secure: m.cfg.Secure,
SameSite: http.SameSiteLaxMode,
}
}
func (m *Middleware) clearCookie(name string) *http.Cookie {
return &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: m.cfg.Secure,
SameSite: http.SameSiteLaxMode,
}
}
func (m *Middleware) logError(msg string, err error) {
if m.cfg.Logger != nil {
m.cfg.Logger.Error("auth: "+msg, "err", err)
}
}
func randomURLString(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func randomStateFallback() string {
s, err := randomURLString(16)
if err != nil {
return "x"
}
return s
}

View file

@ -0,0 +1,522 @@
package auth
// End-to-end tests for the Pocket-ID OIDC middleware against an in-process
// mock OP (httptest.Server). The mock serves:
//
// GET /.well-known/openid-configuration
// GET /jwks
// POST /token — authorization_code grant, returns a signed RS256 id_token
//
// We spin up a real net/http.ServeMux with the middleware mounted, drive the
// browser-side of the flow with a cookie jar, and assert the full /login →
// authorize → /callback → /admin round-trip, plus the negative paths
// (missing cookie → 302, wrong group → 403, bad nonce/state → 400, kid
// unknown, aud mismatch, expired id_token).
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
// --- Mock Pocket-ID OP ---------------------------------------------------
type mockOP struct {
srv *httptest.Server
issuer string
key *rsa.PrivateKey
kid string
clientID string
clientSec string
redirect string
now func() time.Time
groups []string
sub string
sid string
audOverride string
tokenCalls int
}
func newMockOP(t *testing.T) *mockOP {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa gen: %v", err)
}
mop := &mockOP{
key: key,
kid: "test-key-1",
now: func() time.Time { return time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC) },
groups: []string{"proofedmesh-admin", "other"},
sub: "user-admin-42",
sid: "SES-abc",
}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", mop.handleDiscovery)
mux.HandleFunc("/jwks", mop.handleJWKS)
mux.HandleFunc("/authorize", mop.handleAuthorize)
mux.HandleFunc("/token", mop.handleToken)
mop.srv = httptest.NewServer(mux)
mop.issuer = mop.srv.URL
t.Cleanup(mop.srv.Close)
return mop
}
func (m *mockOP) handleDiscovery(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": m.issuer,
"authorization_endpoint": m.issuer + "/authorize",
"token_endpoint": m.issuer + "/token",
"jwks_uri": m.issuer + "/jwks",
})
}
func (m *mockOP) handleJWKS(w http.ResponseWriter, r *http.Request) {
pub := m.key.PublicKey
n := base64.RawURLEncoding.EncodeToString(pub.N.Bytes())
eBytes := big.NewInt(int64(pub.E)).Bytes()
e := base64.RawURLEncoding.EncodeToString(eBytes)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"keys": []map[string]any{{
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"kid": m.kid,
"n": n,
"e": e,
}},
})
}
// handleAuthorize is unused by the Go tests (we skip the browser trip to the
// OP's login UI and jump straight to the callback with a hand-rolled code),
// but Pocket-ID would normally serve the login UI here.
func (m *mockOP) handleAuthorize(w http.ResponseWriter, r *http.Request) {
http.Error(w, "tests do not drive the UI; call /token directly", http.StatusNotImplemented)
}
// handleToken verifies grant_type/code/pkce/basic-auth and returns a signed
// id_token with the configured sub/sid/groups and the request's nonce.
func (m *mockOP) handleToken(w http.ResponseWriter, r *http.Request) {
m.tokenCalls++
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", 400)
return
}
if r.Form.Get("grant_type") != "authorization_code" {
http.Error(w, "unsupported grant", 400)
return
}
u, p, ok := r.BasicAuth()
if !ok || u != m.clientID || p != m.clientSec {
http.Error(w, "bad basic auth", 401)
return
}
code := r.Form.Get("code")
// our "code" encoding is `nonce.verifierHash`; the middleware will send
// the raw verifier, we verify its sha256 matches the challenge we baked
// into the code.
parts := strings.Split(code, ".")
if len(parts) != 2 {
http.Error(w, "bad code", 400)
return
}
nonce := parts[0]
challenge := parts[1]
verifier := r.Form.Get("code_verifier")
if verifier == "" {
http.Error(w, "missing code_verifier", 400)
return
}
sum := sha256.Sum256([]byte(verifier))
if base64.RawURLEncoding.EncodeToString(sum[:]) != challenge {
http.Error(w, "pkce mismatch", 400)
return
}
aud := m.clientID
if m.audOverride != "" {
aud = m.audOverride
}
now := m.now()
claims := map[string]any{
"iss": m.issuer,
"aud": aud,
"sub": m.sub,
"sid": m.sid,
"iat": now.Unix(),
"nbf": now.Unix(),
"exp": now.Add(10 * time.Minute).Unix(),
"nonce": nonce,
"groups": m.groups,
}
idToken := m.signIDToken(claims)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id_token": idToken,
"access_token": "ignored-by-admin-api",
"token_type": "Bearer",
"expires_in": 600,
})
}
func (m *mockOP) signIDToken(claims map[string]any) string {
hdr := map[string]any{"alg": "RS256", "typ": "JWT", "kid": m.kid}
hdrJSON, _ := json.Marshal(hdr)
payloadJSON, _ := json.Marshal(claims)
h := base64.RawURLEncoding.EncodeToString(hdrJSON)
p := base64.RawURLEncoding.EncodeToString(payloadJSON)
signing := h + "." + p
sum := sha256.Sum256([]byte(signing))
sig, err := rsa.SignPKCS1v15(rand.Reader, m.key, crypto.SHA256, sum[:])
if err != nil {
panic(err)
}
return signing + "." + base64.RawURLEncoding.EncodeToString(sig)
}
// --- Test harness --------------------------------------------------------
type harness struct {
t *testing.T
op *mockOP
mw *Middleware
srv *httptest.Server
cli *http.Client
}
func newHarness(t *testing.T) *harness {
t.Helper()
op := newMockOP(t)
op.clientID = "admin-api-test"
op.clientSec = "s3cret"
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /admin/ping", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("principal=" + PrincipalFrom(r.Context()) + "; session=" + SessionFrom(r.Context())))
})
var mw *Middleware
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// /login and /callback are mounted on the mux unprotected; everything
// else goes through Protect.
switch r.URL.Path {
case "/login", "/callback":
mux.ServeHTTP(w, r)
default:
mw.Protect(mux).ServeHTTP(w, r)
}
}))
t.Cleanup(srv.Close)
op.redirect = srv.URL + "/callback"
m, err := New(Config{
Issuer: op.issuer,
ClientID: op.clientID,
ClientSecret: op.clientSec,
RedirectURL: op.redirect,
CookieSecret: secret,
Secure: false,
Now: op.now,
})
if err != nil {
t.Fatalf("New: %v", err)
}
mw = m
mw.Register(mux)
jar, _ := cookiejar.New(nil)
cli := &http.Client{
Jar: jar,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// stop after /login → authorize so we can inspect the state cookie
// and jump straight to /callback.
return http.ErrUseLastResponse
},
}
return &harness{t: t, op: op, mw: mw, srv: srv, cli: cli}
}
// simulateCallback runs /login, extracts the authorize-redirect query (state,
// nonce, code_challenge, next hint), hands them to the mock OP's /token path
// via a hand-rolled callback hit, and returns the response.
func (h *harness) simulateCallback(path, next string) *http.Response {
h.t.Helper()
loginURL := h.srv.URL + path
if next != "" {
loginURL += "?" + url.Values{"next": {next}}.Encode()
}
resp, err := h.cli.Get(loginURL)
if err != nil {
h.t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusFound {
h.t.Fatalf("/login expected 302, got %d", resp.StatusCode)
}
authURL, err := url.Parse(resp.Header.Get("Location"))
if err != nil {
h.t.Fatal(err)
}
q := authURL.Query()
state := q.Get("state")
nonce := q.Get("nonce")
challenge := q.Get("code_challenge")
if state == "" || nonce == "" || challenge == "" {
h.t.Fatalf("authorize URL missing params: %q", authURL.String())
}
// build a code the mock OP will "accept"
code := nonce + "." + challenge
cbURL := h.srv.URL + "/callback?" + url.Values{
"state": {state},
"code": {code},
}.Encode()
cbResp, err := h.cli.Get(cbURL)
if err != nil {
h.t.Fatal(err)
}
return cbResp
}
// --- Tests ---------------------------------------------------------------
func TestFullLoginFlow_SetsSessionAndRedirects(t *testing.T) {
h := newHarness(t)
resp := h.simulateCallback("/login", "/admin/ping")
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("/callback expected 302, got %d", resp.StatusCode)
}
if got := resp.Header.Get("Location"); got != "/admin/ping" {
t.Fatalf("expected redirect to /admin/ping, got %q", got)
}
// Follow it to confirm the session is accepted and the principal lands
// in the context.
h.cli.CheckRedirect = nil
pingResp, err := h.cli.Get(h.srv.URL + "/admin/ping")
if err != nil {
t.Fatal(err)
}
defer pingResp.Body.Close()
if pingResp.StatusCode != 200 {
t.Fatalf("/admin/ping expected 200, got %d", pingResp.StatusCode)
}
buf := make([]byte, 128)
n, _ := pingResp.Body.Read(buf)
body := string(buf[:n])
if !strings.Contains(body, "principal=user-admin-42") {
t.Fatalf("expected principal in body, got %q", body)
}
if !strings.Contains(body, "session=SES-abc") {
t.Fatalf("expected session in body, got %q", body)
}
if h.op.tokenCalls != 1 {
t.Fatalf("expected one /token call, got %d", h.op.tokenCalls)
}
}
func TestProtect_UnauthenticatedRedirectsToLogin(t *testing.T) {
h := newHarness(t)
resp, err := h.cli.Get(h.srv.URL + "/admin/ping?debug=1")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("expected 302, got %d", resp.StatusCode)
}
loc := resp.Header.Get("Location")
if !strings.HasPrefix(loc, "/login?") {
t.Fatalf("expected /login redirect, got %q", loc)
}
u, _ := url.Parse(loc)
if next := u.Query().Get("next"); next != "/admin/ping?debug=1" {
t.Fatalf("next not preserved, got %q", next)
}
}
func TestCallback_MissingGroupRejected(t *testing.T) {
h := newHarness(t)
h.op.groups = []string{"other-group"} // drop proofedmesh-admin
resp := h.simulateCallback("/login", "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 for missing group, got %d", resp.StatusCode)
}
}
func TestCallback_NonceMismatchRejected(t *testing.T) {
h := newHarness(t)
loginURL := h.srv.URL + "/login"
resp, err := h.cli.Get(loginURL)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
authURL, _ := url.Parse(resp.Header.Get("Location"))
state := authURL.Query().Get("state")
challenge := authURL.Query().Get("code_challenge")
// code encodes the WRONG nonce
cb := h.srv.URL + "/callback?" + url.Values{
"state": {state},
"code": {"bogus-nonce." + challenge},
}.Encode()
cbResp, err := h.cli.Get(cb)
if err != nil {
t.Fatal(err)
}
defer cbResp.Body.Close()
if cbResp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401 for nonce mismatch, got %d", cbResp.StatusCode)
}
}
func TestCallback_StateMismatchRejected(t *testing.T) {
h := newHarness(t)
resp, err := h.cli.Get(h.srv.URL + "/login")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
authURL, _ := url.Parse(resp.Header.Get("Location"))
nonce := authURL.Query().Get("nonce")
challenge := authURL.Query().Get("code_challenge")
cb := h.srv.URL + "/callback?" + url.Values{
"state": {"not-the-state-we-stored"},
"code": {nonce + "." + challenge},
}.Encode()
cbResp, err := h.cli.Get(cb)
if err != nil {
t.Fatal(err)
}
defer cbResp.Body.Close()
if cbResp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for state mismatch, got %d", cbResp.StatusCode)
}
}
func TestCallback_AudienceMismatchRejected(t *testing.T) {
h := newHarness(t)
h.op.audOverride = "some-other-client"
resp := h.simulateCallback("/login", "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401 for aud mismatch, got %d", resp.StatusCode)
}
}
func TestSessionExpiredCookieRedirects(t *testing.T) {
h := newHarness(t)
resp := h.simulateCallback("/login", "/admin/ping")
resp.Body.Close()
// Advance clock past SessionTTL (default 8h); re-init middleware clock.
h.mw.cfg.Now = func() time.Time { return time.Date(2026, 10, 26, 1, 0, 0, 0, time.UTC) }
h.cli.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }
pingResp, err := h.cli.Get(h.srv.URL + "/admin/ping")
if err != nil {
t.Fatal(err)
}
defer pingResp.Body.Close()
if pingResp.StatusCode != http.StatusFound {
t.Fatalf("expected 302 after session expiry, got %d", pingResp.StatusCode)
}
if !strings.HasPrefix(pingResp.Header.Get("Location"), "/login") {
t.Fatalf("expected /login redirect, got %q", pingResp.Header.Get("Location"))
}
}
func TestVerifyIDToken_InvalidKidRejected(t *testing.T) {
// unit-level cross-check for the JWT verifier: a token signed with an
// unknown kid is rejected even if the signature math would otherwise
// pass against *some* key.
op := newMockOP(t)
op.clientID = "x"
op.clientSec = "y"
keys := map[string]*rsa.PublicKey{"other-kid": &op.key.PublicKey}
tok := op.signIDToken(map[string]any{
"iss": op.issuer,
"aud": op.clientID,
"sub": "u1",
"exp": op.now().Add(1 * time.Hour).Unix(),
"iat": op.now().Unix(),
"nonce": "n1",
})
_, err := verifyIDToken(tok, keys, op.issuer, op.clientID, "n1", op.now())
if err == nil {
t.Fatal("expected unknown-kid rejection, got nil")
}
if !strings.Contains(err.Error(), "kid") {
t.Fatalf("expected kid error, got %v", err)
}
}
func TestVerifyIDToken_ExpiredRejected(t *testing.T) {
op := newMockOP(t)
op.clientID = "x"
op.clientSec = "y"
keys := map[string]*rsa.PublicKey{op.kid: &op.key.PublicKey}
tok := op.signIDToken(map[string]any{
"iss": op.issuer,
"aud": op.clientID,
"sub": "u1",
"exp": op.now().Add(-10 * time.Minute).Unix(),
"iat": op.now().Add(-20 * time.Minute).Unix(),
"nonce": "n1",
})
_, err := verifyIDToken(tok, keys, op.issuer, op.clientID, "n1", op.now())
if err == nil || !strings.Contains(err.Error(), "expired") {
t.Fatalf("expected expired error, got %v", err)
}
}
func TestSanitizeNext_RejectsOffsiteRedirects(t *testing.T) {
cases := []struct {
in, want string
}{
{"", "/default"},
{"/admin/x", "/admin/x"},
{"//evil.example", "/default"},
{"https://evil.example/x", "/default"},
{"javascript:alert(1)", "/default"},
{"../etc/passwd", "/default"},
}
for _, tc := range cases {
got := sanitizeNext(tc.in, "/default")
if got != tc.want {
t.Errorf("sanitizeNext(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}

View file

@ -14,6 +14,7 @@ import (
"time"
"basicstack.de/proofedmesh/services/admin-api/internal/attest"
"basicstack.de/proofedmesh/services/admin-api/internal/auth"
"basicstack.de/proofedmesh/services/admin-api/internal/model"
"basicstack.de/proofedmesh/services/admin-api/internal/store"
)
@ -255,32 +256,16 @@ func writeErr(w http.ResponseWriter, r *http.Request, status int, err error) {
_ = json.NewEncoder(w).Encode(map[string]any{"error": err.Error()})
}
type principalKey struct{}
type sessionKey struct{}
// WithPrincipal attaches the authenticated admin user to the request context.
// Called by the auth middleware wired in main.
// WithPrincipal re-exports auth.WithPrincipal so existing callers and tests
// keep working after the context accessors moved into internal/auth.
func WithPrincipal(ctx context.Context, sub string) context.Context {
return context.WithValue(ctx, principalKey{}, sub)
return auth.WithPrincipal(ctx, sub)
}
// WithSession attaches the Pocket ID session identifier to the request context.
// Carried into rate_change attestations so audits show which session triggered
// the change.
// WithSession re-exports auth.WithSession for the same reason.
func WithSession(ctx context.Context, sessionID string) context.Context {
return context.WithValue(ctx, sessionKey{}, sessionID)
return auth.WithSession(ctx, sessionID)
}
func principalFrom(ctx context.Context) string {
if v, ok := ctx.Value(principalKey{}).(string); ok {
return v
}
return "unknown"
}
func sessionFrom(ctx context.Context) string {
if v, ok := ctx.Value(sessionKey{}).(string); ok {
return v
}
return ""
}
func principalFrom(ctx context.Context) string { return auth.PrincipalFrom(ctx) }
func sessionFrom(ctx context.Context) string { return auth.SessionFrom(ctx) }