admin-api: Pocket-ID client_credentials TokenSource with OIDC discovery + cache (replaces StaticToken in prod)
* 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:
parent
d9fa1c55b9
commit
be3ac9cdb8
4 changed files with 621 additions and 24 deletions
|
|
@ -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).
|
(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** und **Transparency-Log-Client**
|
Store-Interface, **Signing-Service-Client**, **Transparency-Log-Client** und
|
||||||
sind vollständig und getestet (26 Go-Tests, go vet clean). Noch Stubs:
|
**Pocket-ID-TokenSource** (`client_credentials` mit Discovery + Cache)
|
||||||
Postgres-Store (`pgx`) und Pocket-ID-Auth-Middleware — werden in Phase 2
|
sind vollständig und getestet (36 Go-Tests, go vet clean). Noch Stubs:
|
||||||
nachgezogen. Prod-Deployment folgt nach Phase-1-Trust-Core.
|
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
|
## Scope
|
||||||
|
|
||||||
|
|
@ -20,9 +22,20 @@ nachgezogen. Prod-Deployment folgt nach Phase-1-Trust-Core.
|
||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
|
|
||||||
- OIDC via Pocket ID (Authorization-Code-Flow, Session-Cookie).
|
Admin-API hat zwei Auth-Richtungen und beide laufen über Pocket ID:
|
||||||
- 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.
|
- **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)
|
## HTTP-Endpunkte (geplant)
|
||||||
|
|
||||||
|
|
@ -56,8 +69,10 @@ services/admin-api/
|
||||||
│ ├── attest/
|
│ ├── attest/
|
||||||
│ │ ├── ratechange.go — Statement-Bau, Orchestrator SignAndLog
|
│ │ ├── ratechange.go — Statement-Bau, Orchestrator SignAndLog
|
||||||
│ │ ├── hash.go — stabile JSON-Serialisierung + SHA-256
|
│ │ ├── 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
|
│ │ ├── 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
|
│ │ └── orchestrator_test.go — SignAndLog mit Fake-Signer/-Submitter
|
||||||
│ └── tlog/
|
│ └── tlog/
|
||||||
│ ├── submitter.go — POST /api/v1/log/entries Client
|
│ ├── 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 \
|
go run ./cmd/admin-api -store=memory \
|
||||||
-signer-url=http://localhost:9001 -tlog-url=http://localhost:9002 \
|
-signer-url=http://localhost:9001 -tlog-url=http://localhost:9002 \
|
||||||
-bearer=dev-token
|
-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 \
|
go run ./cmd/admin-api -store=postgres \
|
||||||
-db="postgres://trust:…@trust-postgres:5432/trust?sslmode=verify-full" \
|
-db="postgres://trust:…@trust-postgres:5432/trust?sslmode=verify-full" \
|
||||||
-signer-url=https://trust.basicstack.de -tlog-url=https://trust.basicstack.de \
|
-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)
|
## Attestations-Fluss (implementiert)
|
||||||
|
|
@ -97,15 +116,65 @@ 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 (Prod: `client_credentials`-Flow, noch nicht gewired) |
|
| `-bearer` | *(leer)* | Statischer Pocket-ID-Token für Dev; umgeht den `client_credentials`-Flow |
|
||||||
| `-oidc-issuer` | `https://auth.basicstack.de` | Issuer-Claim für `performed_by.iss` im Attestations-Predicate |
|
| `-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)
|
## Rate Change Attestation (Entwurf)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,9 @@ 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 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()
|
flag.Parse()
|
||||||
|
|
||||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
|
@ -54,16 +56,18 @@ func main() {
|
||||||
|
|
||||||
var orch *attest.Orchestrator
|
var orch *attest.Orchestrator
|
||||||
if *signerURL != "" && *tlogURL != "" {
|
if *signerURL != "" && *tlogURL != "" {
|
||||||
var tokenSrc attest.TokenSource
|
tokenSrc, mode, err := buildTokenSource(*bearer, *issuer, *pocketScope, *pocketAudience)
|
||||||
if *bearer != "" {
|
if err != nil {
|
||||||
tokenSrc = attest.StaticToken(*bearer)
|
logger.Error("admin-api: token source init failed", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
orch = &attest.Orchestrator{
|
orch = &attest.Orchestrator{
|
||||||
Signer: attest.HTTPSigner{BaseURL: *signerURL, TokenSource: tokenSrc},
|
Signer: attest.HTTPSigner{BaseURL: *signerURL, TokenSource: tokenSrc},
|
||||||
Submitter: tlog.HTTPSubmitter{BaseURL: *tlogURL, TokenSource: tokenSrc},
|
Submitter: tlog.HTTPSubmitter{BaseURL: *tlogURL, TokenSource: tokenSrc},
|
||||||
KeyRef: *keyRef,
|
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 {
|
} else {
|
||||||
logger.Warn("admin-api: attestation disabled (set -signer-url and -tlog-url to enable)")
|
logger.Warn("admin-api: attestation disabled (set -signer-url and -tlog-url to enable)")
|
||||||
}
|
}
|
||||||
|
|
@ -110,3 +114,38 @@ func main() {
|
||||||
defer cancel()
|
defer cancel()
|
||||||
_ = srv.Shutdown(shutdownCtx)
|
_ = 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
|
||||||
|
}
|
||||||
|
|
|
||||||
188
services/admin-api/internal/attest/pocket_id_token_source.go
Normal file
188
services/admin-api/internal/attest/pocket_id_token_source.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
Loading…
Add table
Reference in a new issue