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).
|
(siehe `docs/architecture/v1.1.md` §6.2 und ADR-0003).
|
||||||
|
|
||||||
**Status: in-progress.** HTTP-Handler, HTML-Templates, Modell-Typen,
|
**Status: in-progress.** HTTP-Handler, HTML-Templates, Modell-Typen,
|
||||||
Store-Interface, **Signing-Service-Client**, **Transparency-Log-Client** und
|
Store-Interface, **Signing-Service-Client**, **Transparency-Log-Client**,
|
||||||
**Pocket-ID-TokenSource** (`client_credentials` mit Discovery + Cache)
|
**outbound Pocket-ID-TokenSource** (`client_credentials` mit Discovery + Cache)
|
||||||
sind vollständig und getestet (36 Go-Tests, go vet clean). Noch Stubs:
|
und **inbound Pocket-ID-Session-Cookie-Verifier** (Authorization-Code + PKCE,
|
||||||
Postgres-Store (`pgx`) und die inbound Pocket-ID-Auth-Middleware
|
JWKS-Signatur-Prüfung, Erzwingung `proofedmesh-admin`-Gruppenmitgliedschaft)
|
||||||
(Admin-Session-Cookie) — werden in Phase 2 nachgezogen. Prod-Deployment
|
sind vollständig und getestet (46 Go-Tests, go vet clean). Noch Stub:
|
||||||
folgt nach Phase-1-Trust-Core.
|
Postgres-Store (`pgx`) — wird in Phase 2 nachgezogen. Prod-Deployment folgt
|
||||||
|
nach Phase-1-Trust-Core.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
|
|
@ -24,10 +25,20 @@ folgt nach Phase-1-Trust-Core.
|
||||||
|
|
||||||
Admin-API hat zwei Auth-Richtungen und beide laufen über Pocket ID:
|
Admin-API hat zwei Auth-Richtungen und beide laufen über Pocket ID:
|
||||||
|
|
||||||
- **Inbound (Admins → Admin-API)**: OIDC Authorization-Code-Flow + Session-Cookie.
|
- **Inbound (Admins → Admin-API)**: OIDC Authorization-Code-Flow mit PKCE (S256),
|
||||||
Autorisierung: Gruppenmitgliedschaft `proofedmesh-admin` wird aus dem ID-Token
|
Session-Cookie. Discovery via `/.well-known/openid-configuration`, Signatur-
|
||||||
gelesen und ist Pflicht für alle Endpunkte außer Login. Admin-API ist
|
Verifikation gegen das vom OP veröffentlichte JWKS (RS256). Session-Zustand
|
||||||
human-only — Hintergrund-Dienste rufen die Admin-API nicht an.
|
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
|
- **Outbound (Admin-API → Signing/Transparency-Log)**: Pocket ID
|
||||||
`client_credentials`-Grant. Admin-API ist dort als eigener OIDC-Client
|
`client_credentials`-Grant. Admin-API ist dort als eigener OIDC-Client
|
||||||
registriert und erhält kurzlebige Bearer-Token. Der zentrale
|
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.go — in-memory impl (für Tests + Dev)
|
||||||
│ │ ├── memory_test.go
|
│ │ ├── memory_test.go
|
||||||
│ │ └── postgres.go — pgx impl, derzeit Stub
|
│ │ └── 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/
|
│ ├── httpapi/
|
||||||
│ │ ├── rates.go — Handler (JSON + HTML, Signer-Orchestrierung)
|
│ │ ├── rates.go — Handler (JSON + HTML, Signer-Orchestrierung)
|
||||||
│ │ ├── rates_test.go — Handler-Tests gegen in-memory store
|
│ │ ├── rates_test.go — Handler-Tests gegen in-memory store
|
||||||
|
|
@ -116,29 +131,39 @@ Jede erfolgreiche Create/Retire-Operation ruft intern `attest.Orchestrator.SignA
|
||||||
|
|
||||||
## Konfigurations-Flags
|
## Konfigurations-Flags
|
||||||
|
|
||||||
| Flag | Default | Beschreibung |
|
| Flag | Default | Beschreibung |
|
||||||
| ---------------------- | ------------------------------------------- | ------------ |
|
| ----------------------------- | ------------------------------------------- | ------------ |
|
||||||
| `-listen` | `:8080` | HTTP-Bind |
|
| `-listen` | `:8080` | HTTP-Bind |
|
||||||
| `-store` | `memory` | `memory` oder `postgres` |
|
| `-store` | `memory` | `memory` oder `postgres` |
|
||||||
| `-signer-url` | *(leer)* | Signing-Service-Base-URL — leer → Attestation deaktiviert |
|
| `-signer-url` | *(leer)* | Signing-Service-Base-URL — leer → Attestation deaktiviert |
|
||||||
| `-tlog-url` | *(leer)* | Transparency-Log-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) |
|
| `-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 |
|
| `-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` |
|
| `-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 `client_credentials`-Request |
|
| `-pocket-id-scope` | *(leer)* | Optionaler `scope` im Outbound `client_credentials`-Request |
|
||||||
| `-pocket-id-audience` | *(leer)* | Optionale `audience` im `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,
|
Zusätzliche Env-Variablen (werden gelesen, aber nie als Flag akzeptiert, damit
|
||||||
also wenn `-bearer` leer ist und `-oidc-issuer` gesetzt ist):
|
sie nicht in Prozess-Listings landen):
|
||||||
|
|
||||||
| Env | Beschreibung |
|
| Env | Beschreibung |
|
||||||
| -------------------------- | ------------ |
|
| ----------------------------- | ------------ |
|
||||||
| `POCKET_ID_CLIENT_ID` | OIDC-Client-ID aus der admin-api-Registrierung in Pocket ID |
|
| `POCKET_ID_CLIENT_ID` | Outbound-OIDC-Client-ID (admin-api → Signing/TLog) |
|
||||||
| `POCKET_ID_CLIENT_SECRET` | OIDC-Client-Secret aus der admin-api-Registrierung in Pocket ID |
|
| `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,
|
Fehlen beim Outbound `POCKET_ID_CLIENT_ID`/`POCKET_ID_CLIENT_SECRET`, aber
|
||||||
bricht admin-api direkt beim Start ab — kein stilles Weiterlaufen mit nicht
|
`-oidc-issuer` ist gesetzt und `-bearer` ist leer, bricht admin-api direkt
|
||||||
authentifizierten Signatur-Requests.
|
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
|
## 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
|
hier zusätzlich eintragen und per `-pocket-id-scope` anfordern
|
||||||
- **Allowed user groups**: leer (kein User-bezogener Flow)
|
- **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
|
Alternativ via API, siehe
|
||||||
[`skills/pocket-id-api`](../../docs/) und `POST /api/oidc/clients`:
|
[`skills/pocket-id-api`](../../docs/) und `POST /api/oidc/clients`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Outbound (admin-api als Service-Client)
|
||||||
curl -X POST "https://auth.basicstack.de/api/oidc/clients" \
|
curl -X POST "https://auth.basicstack.de/api/oidc/clients" \
|
||||||
-H "X-API-Key: $POCKET_ID_API_KEY" \
|
-H "X-API-Key: $POCKET_ID_API_KEY" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
|
|
@ -169,12 +206,26 @@ curl -X POST "https://auth.basicstack.de/api/oidc/clients" \
|
||||||
"isPublic": false,
|
"isPublic": false,
|
||||||
"credentialsConfidentialClient": true
|
"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
|
Die generierten `clientId` + `clientSecret` landen danach als Secrets in der
|
||||||
in der Deployment-Pipeline und werden dem admin-api-Container als
|
Deployment-Pipeline und werden dem admin-api-Container als
|
||||||
`POCKET_ID_CLIENT_ID` / `POCKET_ID_CLIENT_SECRET` injiziert. Die Felder sind
|
`POCKET_ID_CLIENT_ID` / `POCKET_ID_CLIENT_SECRET` (outbound) bzw. als
|
||||||
NICHT in `-flags` konfigurierbar, damit sie nicht in Prozess-Listings landen.
|
`-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)
|
## Rate Change Attestation (Entwurf)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,26 @@
|
||||||
// Command admin-api is the HTTP entry point for the per-diem-rates admin
|
// 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
|
// surface and future admin flows. Signing + transparency-log wiring are real
|
||||||
// that stamps a hard-coded principal into the request context; signing +
|
// HTTP clients but default to disabled so local dev with `-store=memory` has
|
||||||
// transparency-log wiring are real HTTP clients but default to disabled so
|
// no external dependencies.
|
||||||
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
|
@ -18,6 +32,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"basicstack.de/proofedmesh/services/admin-api/internal/attest"
|
"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/httpapi"
|
||||||
"basicstack.de/proofedmesh/services/admin-api/internal/store"
|
"basicstack.de/proofedmesh/services/admin-api/internal/store"
|
||||||
"basicstack.de/proofedmesh/services/admin-api/internal/tlog"
|
"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")
|
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")
|
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")
|
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")
|
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")
|
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()
|
flag.Parse()
|
||||||
|
|
||||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
|
@ -84,14 +103,44 @@ func main() {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
httpapi.Mount(mux, deps)
|
httpapi.Mount(mux, deps)
|
||||||
|
|
||||||
// Admin-only auth middleware is a stub that attaches a hardcoded principal
|
// Wire the inbound Pocket-ID OIDC middleware when fully configured.
|
||||||
// in the skeleton. Production wiring will replace this with a Pocket ID
|
// Otherwise fall back to the historic hardcoded-principal stub so unit
|
||||||
// session-cookie verifier that pulls `sub` from a validated ID token and
|
// tests and bare dev runs do not need an IdP.
|
||||||
// enforces group membership in `proofedmesh-admin`.
|
var handler http.Handler = mux
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
adminClientSecret := os.Getenv("ADMIN_OIDC_CLIENT_SECRET")
|
||||||
ctx := httpapi.WithPrincipal(r.Context(), "stub-admin")
|
adminSessionSecretRaw := os.Getenv("ADMIN_SESSION_COOKIE_SECRET")
|
||||||
mux.ServeHTTP(w, r.WithContext(ctx))
|
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{
|
srv := &http.Server{
|
||||||
Addr: *listen,
|
Addr: *listen,
|
||||||
|
|
@ -137,8 +186,8 @@ func buildTokenSource(bearer, issuer, scope, audience string) (attest.TokenSourc
|
||||||
clientSecret := os.Getenv("POCKET_ID_CLIENT_SECRET")
|
clientSecret := os.Getenv("POCKET_ID_CLIENT_SECRET")
|
||||||
if clientID == "" || clientSecret == "" {
|
if clientID == "" || clientSecret == "" {
|
||||||
return nil, "none", fmt.Errorf(
|
return nil, "none", fmt.Errorf(
|
||||||
"-bearer is empty and -oidc-issuer=%q is set, " +
|
"-bearer is empty and -oidc-issuer=%q is set, "+
|
||||||
"but POCKET_ID_CLIENT_ID / POCKET_ID_CLIENT_SECRET are not; " +
|
"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)
|
"register admin-api as a Pocket ID OIDC client and inject the credentials via env", issuer)
|
||||||
}
|
}
|
||||||
return &attest.PocketIDTokenSource{
|
return &attest.PocketIDTokenSource{
|
||||||
|
|
@ -149,3 +198,22 @@ func buildTokenSource(bearer, issuer, scope, audience string) (attest.TokenSourc
|
||||||
Audience: audience,
|
Audience: audience,
|
||||||
}, "pocket_id_client_credentials", nil
|
}, "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"
|
"time"
|
||||||
|
|
||||||
"basicstack.de/proofedmesh/services/admin-api/internal/attest"
|
"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/model"
|
||||||
"basicstack.de/proofedmesh/services/admin-api/internal/store"
|
"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()})
|
_ = json.NewEncoder(w).Encode(map[string]any{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
type principalKey struct{}
|
// WithPrincipal re-exports auth.WithPrincipal so existing callers and tests
|
||||||
type sessionKey struct{}
|
// keep working after the context accessors moved into internal/auth.
|
||||||
|
|
||||||
// WithPrincipal attaches the authenticated admin user to the request context.
|
|
||||||
// Called by the auth middleware wired in main.
|
|
||||||
func WithPrincipal(ctx context.Context, sub string) context.Context {
|
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.
|
// WithSession re-exports auth.WithSession for the same reason.
|
||||||
// Carried into rate_change attestations so audits show which session triggered
|
|
||||||
// the change.
|
|
||||||
func WithSession(ctx context.Context, sessionID string) context.Context {
|
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 {
|
func principalFrom(ctx context.Context) string { return auth.PrincipalFrom(ctx) }
|
||||||
if v, ok := ctx.Value(principalKey{}).(string); ok {
|
func sessionFrom(ctx context.Context) string { return auth.SessionFrom(ctx) }
|
||||||
return v
|
|
||||||
}
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
func sessionFrom(ctx context.Context) string {
|
|
||||||
if v, ok := ctx.Value(sessionKey{}).(string); ok {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue