admin-api: Pocket-ID session-cookie verifier (OIDC Authorization-Code + PKCE, JWKS, group check)
- 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:
parent
69b3f3012f
commit
9f1eb84032
6 changed files with 1583 additions and 72 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -137,8 +186,8 @@ func buildTokenSource(bearer, issuer, scope, audience string) (attest.TokenSourc
|
|||
clientSecret := os.Getenv("POCKET_ID_CLIENT_SECRET")
|
||||
if clientID == "" || clientSecret == "" {
|
||||
return nil, "none", fmt.Errorf(
|
||||
"-bearer is empty and -oidc-issuer=%q is set, " +
|
||||
"but POCKET_ID_CLIENT_ID / POCKET_ID_CLIENT_SECRET are not; " +
|
||||
"-bearer is empty and -oidc-issuer=%q is set, "+
|
||||
"but POCKET_ID_CLIENT_ID / POCKET_ID_CLIENT_SECRET are not; "+
|
||||
"register admin-api as a Pocket ID OIDC client and inject the credentials via env", issuer)
|
||||
}
|
||||
return &attest.PocketIDTokenSource{
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
38
services/admin-api/internal/auth/context.go
Normal file
38
services/admin-api/internal/auth/context.go
Normal 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 ""
|
||||
}
|
||||
847
services/admin-api/internal/auth/pocket_id.go
Normal file
847
services/admin-api/internal/auth/pocket_id.go
Normal 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
|
||||
}
|
||||
522
services/admin-api/internal/auth/pocket_id_test.go
Normal file
522
services/admin-api/internal/auth/pocket_id_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue