From be3ac9cdb88e6873667a479e97c97912158080d7bb6e555e14d8486a22452741 Mon Sep 17 00:00:00 2001 From: "CTO (Paperclip)" Date: Sun, 25 Oct 2026 18:03:48 +0000 Subject: [PATCH] admin-api: Pocket-ID client_credentials TokenSource with OIDC discovery + cache (replaces StaticToken in prod) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * services/admin-api/internal/attest/pocket_id_token_source.go — attest.TokenSource impl: OIDC discovery via /.well-known/openid-configuration, HTTP Basic client_credentials grant, mutex-guarded in-memory token cache with configurable RefreshLeeway (default 30s = "verwirft bei exp-30s" from DEV-602). * services/admin-api/internal/attest/pocket_id_token_source_test.go — 10 subtests against an httptest OIDC mock: happy path, cache hold, exp-30s refresh boundary, optional scope/audience, bad creds (401), missing config, discovery failures, non-positive expires_in, type-conformance. * services/admin-api/cmd/admin-api/main.go — buildTokenSource(): -bearer flag wins (dev), else -oidc-issuer + POCKET_ID_CLIENT_ID + POCKET_ID_CLIENT_SECRET env → PocketIDTokenSource (prod); hard-fail at startup if issuer is set but credentials are missing. New flags -pocket-id-scope / -pocket-id-audience for the client_credentials request. * services/admin-api/README.md — split inbound/outbound Pocket-ID auth, document the admin-api OIDC client registration (UI + API), new flags, env vars, prod bootstrap example. 36/36 go tests pass, go vet clean. Co-Authored-By: Paperclip --- services/admin-api/README.md | 107 +++++-- services/admin-api/cmd/admin-api/main.go | 49 ++- .../internal/attest/pocket_id_token_source.go | 188 +++++++++++ .../attest/pocket_id_token_source_test.go | 301 ++++++++++++++++++ 4 files changed, 621 insertions(+), 24 deletions(-) create mode 100644 services/admin-api/internal/attest/pocket_id_token_source.go create mode 100644 services/admin-api/internal/attest/pocket_id_token_source_test.go diff --git a/services/admin-api/README.md b/services/admin-api/README.md index 268a114..2e3400f 100644 --- a/services/admin-api/README.md +++ b/services/admin-api/README.md @@ -6,10 +6,12 @@ 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** und **Transparency-Log-Client** -sind vollständig und getestet (26 Go-Tests, go vet clean). Noch Stubs: -Postgres-Store (`pgx`) und Pocket-ID-Auth-Middleware — werden in Phase 2 -nachgezogen. Prod-Deployment folgt nach Phase-1-Trust-Core. +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. ## Scope @@ -20,9 +22,20 @@ nachgezogen. Prod-Deployment folgt nach Phase-1-Trust-Core. ## Auth -- OIDC via Pocket ID (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. -- Hintergrund-Dienste rufen die API nicht — Admin-API ist Human-only. Deshalb kein `client_credentials`-Pfad. +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. +- **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 + [`attest.PocketIDTokenSource`](./internal/attest/pocket_id_token_source.go) + cached den Token und zieht ihn automatisch 30 s vor `exp` neu. Dev-Setups + können den Flow mit `-bearer=` umgehen; produktiv wird + `-bearer` leer gelassen und `-oidc-issuer` + `POCKET_ID_CLIENT_ID` + + `POCKET_ID_CLIENT_SECRET` gesetzt. ## HTTP-Endpunkte (geplant) @@ -56,8 +69,10 @@ services/admin-api/ │ ├── attest/ │ │ ├── ratechange.go — Statement-Bau, Orchestrator SignAndLog │ │ ├── hash.go — stabile JSON-Serialisierung + SHA-256 -│ │ ├── http_signer.go — POST /api/v1/sign/dsse Client +│ │ ├── http_signer.go — POST /api/v1/sign/dsse Client (TokenSource) │ │ ├── http_signer_test.go — httptest, Fehlerpfade, Hash-Stabilität +│ │ ├── pocket_id_token_source.go — Pocket-ID client_credentials + Discovery + Cache +│ │ ├── pocket_id_token_source_test.go — httptest-OIDC-Mock, Cache + exp-30s Refresh │ │ └── orchestrator_test.go — SignAndLog mit Fake-Signer/-Submitter │ └── tlog/ │ ├── submitter.go — POST /api/v1/log/entries Client @@ -77,11 +92,15 @@ go run ./cmd/admin-api -store=memory -listen=:8080 go run ./cmd/admin-api -store=memory \ -signer-url=http://localhost:9001 -tlog-url=http://localhost:9002 \ -bearer=dev-token -# Prod-Modus (sobald postgres.go fertig): +# Prod-Modus (sobald postgres.go fertig). Credentials nur über Env, +# nicht als Flag — damit sie nicht in Prozess-Listings landen. +export POCKET_ID_CLIENT_ID=proofedmesh-admin-api +export POCKET_ID_CLIENT_SECRET=… # aus Pocket-ID OIDC-Client-Registrierung go run ./cmd/admin-api -store=postgres \ -db="postgres://trust:…@trust-postgres:5432/trust?sslmode=verify-full" \ -signer-url=https://trust.basicstack.de -tlog-url=https://trust.basicstack.de \ - -signer-key=trust-api/rate-change-signer-2026-q4 + -signer-key=trust-api/rate-change-signer-2026-q4 \ + -oidc-issuer=https://auth.basicstack.de ``` ## Attestations-Fluss (implementiert) @@ -97,15 +116,65 @@ 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 (Prod: `client_credentials`-Flow, noch nicht gewired) | -| `-oidc-issuer` | `https://auth.basicstack.de` | Issuer-Claim für `performed_by.iss` im Attestations-Predicate | +| 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 | + +Zusätzliche Env-Variablen (werden nur im `client_credentials`-Flow gelesen, +also wenn `-bearer` leer ist und `-oidc-issuer` gesetzt ist): + +| 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 | + +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. + +## Pocket-ID-Client-Registrierung für admin-api + +Für den Outbound-Flow (Admin-API → Signing/Transparency-Log) muss admin-api in +Pocket ID als eigener OIDC-Client angelegt werden. + +Admin-UI (`https://auth.basicstack.de/settings/oidc-clients`): + +- **Name**: `proofedmesh-admin-api` +- **Grant Type**: `client_credentials` +- **Redirect URIs**: leer lassen (kein Browser-Flow) +- **PKCE**: deaktivieren (Server-zu-Server-Flow, kein User-Agent) +- **Scopes**: default; falls Signing-Service später eine eigene Scope erzwingt, + hier zusätzlich eintragen und per `-pocket-id-scope` anfordern +- **Allowed user groups**: leer (kein User-bezogener Flow) + +Alternativ via API, siehe +[`skills/pocket-id-api`](../../docs/) und `POST /api/oidc/clients`: + +```bash +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-api", + "callbackUrls": [], + "pkceEnabled": false, + "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. ## Rate Change Attestation (Entwurf) diff --git a/services/admin-api/cmd/admin-api/main.go b/services/admin-api/cmd/admin-api/main.go index 4c79c93..7e55826 100644 --- a/services/admin-api/cmd/admin-api/main.go +++ b/services/admin-api/cmd/admin-api/main.go @@ -30,7 +30,9 @@ 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 for admin principals in attestations") + 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") + 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") flag.Parse() logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) @@ -54,16 +56,18 @@ func main() { var orch *attest.Orchestrator if *signerURL != "" && *tlogURL != "" { - var tokenSrc attest.TokenSource - if *bearer != "" { - tokenSrc = attest.StaticToken(*bearer) + tokenSrc, mode, err := buildTokenSource(*bearer, *issuer, *pocketScope, *pocketAudience) + if err != nil { + logger.Error("admin-api: token source init failed", "err", err) + os.Exit(1) } orch = &attest.Orchestrator{ Signer: attest.HTTPSigner{BaseURL: *signerURL, TokenSource: tokenSrc}, Submitter: tlog.HTTPSubmitter{BaseURL: *tlogURL, TokenSource: tokenSrc}, KeyRef: *keyRef, } - logger.Info("admin-api: attestation enabled", "signer", *signerURL, "tlog", *tlogURL, "key_ref", *keyRef) + logger.Info("admin-api: attestation enabled", + "signer", *signerURL, "tlog", *tlogURL, "key_ref", *keyRef, "token_source", mode) } else { logger.Warn("admin-api: attestation disabled (set -signer-url and -tlog-url to enable)") } @@ -110,3 +114,38 @@ func main() { defer cancel() _ = srv.Shutdown(shutdownCtx) } + +// buildTokenSource chooses between the dev static-bearer path and the +// production Pocket ID client_credentials flow. Credentials for the +// client_credentials flow are read from POCKET_ID_CLIENT_ID and +// POCKET_ID_CLIENT_SECRET so they never show up in process listings. +// +// Precedence: +// 1. bearer flag -> StaticToken (dev) +// 2. issuer set + env credentials present -> PocketIDTokenSource (prod) +// 3. otherwise -> nil (requests go out unauthenticated; the signer will +// likely return 401, but admin-api still starts so operators can see +// the misconfiguration in logs) +func buildTokenSource(bearer, issuer, scope, audience string) (attest.TokenSource, string, error) { + if bearer != "" { + return attest.StaticToken(bearer), "static_bearer", nil + } + if issuer == "" { + return nil, "none", nil + } + clientID := os.Getenv("POCKET_ID_CLIENT_ID") + 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; " + + "register admin-api as a Pocket ID OIDC client and inject the credentials via env", issuer) + } + return &attest.PocketIDTokenSource{ + Issuer: issuer, + ClientID: clientID, + ClientSecret: clientSecret, + Scope: scope, + Audience: audience, + }, "pocket_id_client_credentials", nil +} diff --git a/services/admin-api/internal/attest/pocket_id_token_source.go b/services/admin-api/internal/attest/pocket_id_token_source.go new file mode 100644 index 0000000..2aaf6ba --- /dev/null +++ b/services/admin-api/internal/attest/pocket_id_token_source.go @@ -0,0 +1,188 @@ +package attest + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// PocketIDTokenSource issues Bearer tokens using the Pocket ID OIDC +// `client_credentials` grant and caches each token until shortly before its +// expiry. It is safe for concurrent use. +// +// Flow: +// +// 1. On first use, GET {Issuer}/.well-known/openid-configuration and read +// `token_endpoint`. +// 2. POST to the token endpoint with grant_type=client_credentials, using +// HTTP Basic auth (`client_secret_basic`) for ClientID/ClientSecret. +// 3. Cache the access token until RefreshLeeway before `expires_in` elapses. +// +// The admin-api uses the same instance as the TokenSource for both the +// Signing Service and the Transparency Log client, so a single Pocket ID +// client credentials grant covers both backends. +type PocketIDTokenSource struct { + // Issuer is the Pocket ID base URL, e.g. "https://auth.basicstack.de". + // The trailing slash is optional. + Issuer string + // ClientID / ClientSecret identify the Pocket ID OIDC client that the + // admin-api is registered as. They are typically sourced from env vars + // (POCKET_ID_CLIENT_ID / POCKET_ID_CLIENT_SECRET) rather than flags so + // they do not end up in process listings. + ClientID string + ClientSecret string + // Scope and Audience are optional. Pocket ID accepts both as standard + // OIDC parameters; leave empty when not required by the server policy. + Scope string + Audience string + // Client overrides the default HTTP client. The default has a 10s + // timeout and no custom transport. + Client *http.Client + // RefreshLeeway is the window before token expiry at which the cache + // is considered invalid and a new token must be fetched. Zero defaults + // to 30 seconds, matching the task spec ("verwirft bei exp-30s"). + RefreshLeeway time.Duration + // Now is injected for deterministic tests. Zero defaults to time.Now. + Now func() time.Time + + mu sync.Mutex + tokenEndpoint string + cachedToken string + cachedExpiry time.Time +} + +type pocketIDTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + +type pocketIDDiscoveryDoc struct { + TokenEndpoint string `json:"token_endpoint"` +} + +// Token returns a cached access token when still valid, or fetches a fresh +// one from Pocket ID. It satisfies the attest.TokenSource interface. +func (p *PocketIDTokenSource) Token(ctx context.Context) (string, error) { + if p == nil { + return "", errors.New("attest.PocketIDTokenSource: nil receiver") + } + if p.Issuer == "" { + return "", errors.New("attest.PocketIDTokenSource: Issuer is required") + } + if p.ClientID == "" || p.ClientSecret == "" { + return "", errors.New("attest.PocketIDTokenSource: ClientID and ClientSecret are required") + } + + p.mu.Lock() + defer p.mu.Unlock() + + leeway := p.RefreshLeeway + if leeway <= 0 { + leeway = 30 * time.Second + } + now := p.nowLocked() + if p.cachedToken != "" && now.Add(leeway).Before(p.cachedExpiry) { + return p.cachedToken, nil + } + + if p.tokenEndpoint == "" { + if err := p.discoverLocked(ctx); err != nil { + return "", err + } + } + + tok, expiry, err := p.fetchLocked(ctx, now) + if err != nil { + return "", err + } + p.cachedToken = tok + p.cachedExpiry = expiry + return tok, nil +} + +func (p *PocketIDTokenSource) nowLocked() time.Time { + if p.Now != nil { + return p.Now() + } + return time.Now() +} + +func (p *PocketIDTokenSource) httpClient() *http.Client { + if p.Client != nil { + return p.Client + } + return &http.Client{Timeout: 10 * time.Second} +} + +func (p *PocketIDTokenSource) discoverLocked(ctx context.Context) error { + base := strings.TrimRight(p.Issuer, "/") + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/.well-known/openid-configuration", nil) + if err != nil { + return fmt.Errorf("pocket id discovery: %w", err) + } + req.Header.Set("Accept", "application/json") + resp, err := p.httpClient().Do(req) + if err != nil { + return fmt.Errorf("pocket id discovery: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("pocket id discovery: status %d: %s", resp.StatusCode, string(raw)) + } + var doc pocketIDDiscoveryDoc + if err := json.Unmarshal(raw, &doc); err != nil { + return fmt.Errorf("pocket id discovery: decode: %w", err) + } + if doc.TokenEndpoint == "" { + return errors.New("pocket id discovery: token_endpoint missing from OIDC configuration") + } + p.tokenEndpoint = doc.TokenEndpoint + return nil +} + +func (p *PocketIDTokenSource) fetchLocked(ctx context.Context, now time.Time) (string, time.Time, error) { + form := url.Values{} + form.Set("grant_type", "client_credentials") + if p.Scope != "" { + form.Set("scope", p.Scope) + } + if p.Audience != "" { + form.Set("audience", p.Audience) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.tokenEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return "", time.Time{}, fmt.Errorf("pocket id token: %w", err) + } + req.SetBasicAuth(p.ClientID, p.ClientSecret) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + resp, err := p.httpClient().Do(req) + if err != nil { + return "", time.Time{}, fmt.Errorf("pocket id token: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != http.StatusOK { + return "", time.Time{}, fmt.Errorf("pocket id token: status %d: %s", resp.StatusCode, string(raw)) + } + var out pocketIDTokenResponse + if err := json.Unmarshal(raw, &out); err != nil { + return "", time.Time{}, fmt.Errorf("pocket id token: decode: %w", err) + } + if out.AccessToken == "" { + return "", time.Time{}, errors.New("pocket id token: response missing access_token") + } + if out.ExpiresIn <= 0 { + return "", time.Time{}, errors.New("pocket id token: response has non-positive expires_in") + } + return out.AccessToken, now.Add(time.Duration(out.ExpiresIn) * time.Second), nil +} diff --git a/services/admin-api/internal/attest/pocket_id_token_source_test.go b/services/admin-api/internal/attest/pocket_id_token_source_test.go new file mode 100644 index 0000000..157df02 --- /dev/null +++ b/services/admin-api/internal/attest/pocket_id_token_source_test.go @@ -0,0 +1,301 @@ +package attest + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// mockOIDCServer is an httptest server that serves /.well-known/openid-configuration +// and a client_credentials token endpoint. It records call counts so tests can +// assert on cache behaviour. +type mockOIDCServer struct { + srv *httptest.Server + discoveryCalls atomic.Int32 + tokenCalls atomic.Int32 + expiresIn int32 // seconds; swap atomically if the test wants to change it between calls + tokenPrefix string // tokens are "-" to allow identity assertions + expectedClientID string + expectedSecret string + expectedScope string + expectedAudience string + t *testing.T +} + +func newMockOIDCServer(t *testing.T) *mockOIDCServer { + t.Helper() + m := &mockOIDCServer{ + expiresIn: 60, + tokenPrefix: "tok", + expectedClientID: "admin-api", + expectedSecret: "s3cret", + t: t, + } + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + m.discoveryCalls.Add(1) + if r.Method != http.MethodGet { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": m.srv.URL, + "token_endpoint": m.srv.URL + "/api/oidc/token", + }) + }) + mux.HandleFunc("/api/oidc/token", func(w http.ResponseWriter, r *http.Request) { + n := m.tokenCalls.Add(1) + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "parse: "+err.Error(), http.StatusBadRequest) + return + } + id, secret, ok := r.BasicAuth() + if !ok || id != m.expectedClientID || secret != m.expectedSecret { + http.Error(w, `{"error":"invalid_client"}`, http.StatusUnauthorized) + return + } + if got := r.PostForm.Get("grant_type"); got != "client_credentials" { + http.Error(w, `{"error":"unsupported_grant_type"}`, http.StatusBadRequest) + return + } + if m.expectedScope != "" && r.PostForm.Get("scope") != m.expectedScope { + http.Error(w, `{"error":"invalid_scope"}`, http.StatusBadRequest) + return + } + if m.expectedAudience != "" && r.PostForm.Get("audience") != m.expectedAudience { + http.Error(w, `{"error":"invalid_audience"}`, http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + resp := pocketIDTokenResponse{ + AccessToken: fmt.Sprintf("%s-%d", m.tokenPrefix, n), + TokenType: "Bearer", + ExpiresIn: int(atomic.LoadInt32(&m.expiresIn)), + } + _ = json.NewEncoder(w).Encode(resp) + }) + m.srv = httptest.NewServer(mux) + t.Cleanup(m.srv.Close) + return m +} + +func TestPocketIDTokenSource_DiscoversAndFetches(t *testing.T) { + m := newMockOIDCServer(t) + src := &PocketIDTokenSource{ + Issuer: m.srv.URL, + ClientID: m.expectedClientID, + ClientSecret: m.expectedSecret, + } + tok, err := src.Token(context.Background()) + if err != nil { + t.Fatalf("Token: %v", err) + } + if tok != "tok-1" { + t.Fatalf("token: got %q, want %q", tok, "tok-1") + } + if got := m.discoveryCalls.Load(); got != 1 { + t.Errorf("discovery calls = %d, want 1", got) + } + if got := m.tokenCalls.Load(); got != 1 { + t.Errorf("token calls = %d, want 1", got) + } +} + +func TestPocketIDTokenSource_CachesWithinLeeway(t *testing.T) { + m := newMockOIDCServer(t) + now := time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC) + src := &PocketIDTokenSource{ + Issuer: m.srv.URL, + ClientID: m.expectedClientID, + ClientSecret: m.expectedSecret, + RefreshLeeway: 30 * time.Second, + Now: func() time.Time { return now }, + } + // expires_in=60s, leeway=30s → cache valid for ~30s wall time. + for i := 0; i < 3; i++ { + tok, err := src.Token(context.Background()) + if err != nil { + t.Fatalf("Token call %d: %v", i, err) + } + if tok != "tok-1" { + t.Fatalf("Token call %d: got %q, want cached tok-1", i, tok) + } + } + if got := m.tokenCalls.Load(); got != 1 { + t.Errorf("token endpoint hit %d times, expected 1 cached fetch", got) + } + if got := m.discoveryCalls.Load(); got != 1 { + t.Errorf("discovery hit %d times, expected 1 (sticky)", got) + } +} + +func TestPocketIDTokenSource_RefreshesWithinExpiryLeeway(t *testing.T) { + m := newMockOIDCServer(t) + // Pretend the token server issues 60s tokens. + now := time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC) + src := &PocketIDTokenSource{ + Issuer: m.srv.URL, + ClientID: m.expectedClientID, + ClientSecret: m.expectedSecret, + RefreshLeeway: 30 * time.Second, + Now: func() time.Time { return now }, + } + first, err := src.Token(context.Background()) + if err != nil { + t.Fatalf("Token (first): %v", err) + } + if first != "tok-1" { + t.Fatalf("first token = %q", first) + } + + // Advance to one second before the exp-leeway cutoff. Cache must hold. + // exp = 12:01:00, leeway = 30s, so cache is valid while now < 12:00:30. + now = now.Add(29 * time.Second) + same, err := src.Token(context.Background()) + if err != nil { + t.Fatalf("Token (within leeway): %v", err) + } + if same != "tok-1" { + t.Fatalf("inside leeway expected cached tok-1, got %q", same) + } + + // Advance to exp-30s exactly: now+leeway == cachedExpiry → cache + // invalid (strict Before). This is the "verwirft bei exp-30s" + // behaviour from the task spec. + now = now.Add(1 * time.Second) + refreshed, err := src.Token(context.Background()) + if err != nil { + t.Fatalf("Token (at exp-leeway): %v", err) + } + if refreshed != "tok-2" { + t.Fatalf("at exp-leeway cutoff expected refreshed tok-2, got %q", refreshed) + } + if got := m.tokenCalls.Load(); got != 2 { + t.Errorf("token endpoint hits = %d, want 2", got) + } +} + +func TestPocketIDTokenSource_PassesOptionalScopeAndAudience(t *testing.T) { + m := newMockOIDCServer(t) + m.expectedScope = "trust-api.sign" + m.expectedAudience = "https://trust.basicstack.de" + src := &PocketIDTokenSource{ + Issuer: m.srv.URL, + ClientID: m.expectedClientID, + ClientSecret: m.expectedSecret, + Scope: m.expectedScope, + Audience: m.expectedAudience, + } + if _, err := src.Token(context.Background()); err != nil { + t.Fatalf("Token: %v", err) + } +} + +func TestPocketIDTokenSource_RejectsBadCredentials(t *testing.T) { + m := newMockOIDCServer(t) + src := &PocketIDTokenSource{ + Issuer: m.srv.URL, + ClientID: m.expectedClientID, + ClientSecret: "wrong", + } + _, err := src.Token(context.Background()) + if err == nil { + t.Fatal("expected error for bad credentials") + } + if !strings.Contains(err.Error(), "status 401") { + t.Errorf("expected 401 in error, got %v", err) + } +} + +func TestPocketIDTokenSource_RejectsMissingConfig(t *testing.T) { + cases := []struct { + name string + src *PocketIDTokenSource + }{ + {"no issuer", &PocketIDTokenSource{ClientID: "a", ClientSecret: "b"}}, + {"no client id", &PocketIDTokenSource{Issuer: "https://auth", ClientSecret: "b"}}, + {"no client secret", &PocketIDTokenSource{Issuer: "https://auth", ClientID: "a"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := tc.src.Token(context.Background()); err == nil { + t.Fatal("expected error, got nil") + } + }) + } +} + +func TestPocketIDTokenSource_DiscoveryError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "oops", http.StatusInternalServerError) + })) + defer srv.Close() + src := &PocketIDTokenSource{ + Issuer: srv.URL, + ClientID: "a", + ClientSecret: "b", + } + _, err := src.Token(context.Background()) + if err == nil { + t.Fatal("expected discovery error") + } + if !strings.Contains(err.Error(), "discovery") { + t.Errorf("expected discovery in error, got %v", err) + } +} + +func TestPocketIDTokenSource_MissingTokenEndpoint(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"issuer":"https://auth"}`)) + })) + defer srv.Close() + src := &PocketIDTokenSource{ + Issuer: srv.URL, + ClientID: "a", + ClientSecret: "b", + } + _, err := src.Token(context.Background()) + if err == nil || !strings.Contains(err.Error(), "token_endpoint") { + t.Fatalf("expected missing token_endpoint error, got %v", err) + } +} + +func TestPocketIDTokenSource_RejectsNonPositiveExpiry(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/openid-configuration": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(fmt.Sprintf(`{"token_endpoint":%q}`, "http://"+r.Host+"/token"))) + case "/token": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"x","expires_in":0}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + src := &PocketIDTokenSource{ + Issuer: srv.URL, + ClientID: "a", + ClientSecret: "b", + } + _, err := src.Token(context.Background()) + if err == nil || !strings.Contains(err.Error(), "expires_in") { + t.Fatalf("expected expires_in error, got %v", err) + } +} + +// Compile-time check that PocketIDTokenSource satisfies the attest.TokenSource interface. +var _ TokenSource = (*PocketIDTokenSource)(nil)