admin-api: Pocket-ID client_credentials TokenSource with OIDC discovery + cache (replaces StaticToken in prod)
All checks were successful
validate / JSON Schema (ajv) (push) Successful in 20s
validate / Go (vet + test) (push) Successful in 19s
validate / Rego (opa fmt + opa test) (push) Successful in 13s

* 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 <noreply@paperclip.ing>
This commit is contained in:
CTO (Paperclip) 2026-10-25 18:03:48 +00:00
parent d9fa1c55b9
commit be3ac9cdb8
4 changed files with 621 additions and 24 deletions

View file

@ -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=<static token>` 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)
@ -98,14 +117,64 @@ 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 |
| `-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)

View file

@ -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
}

View file

@ -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
}

View file

@ -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 "<prefix>-<N>" 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)