ProofedMesh/services/admin-api/cmd/admin-api/main.go
CTO (Paperclip) 9f1eb84032
All checks were successful
validate / Go (vet + test) (push) Successful in 23s
validate / Rego (opa fmt + opa test) (push) Successful in 15s
validate / JSON Schema (ajv) (push) Successful in 20s
admin-api: Pocket-ID session-cookie verifier (OIDC Authorization-Code + PKCE, JWKS, group check)
- internal/auth/pocket_id.go: Middleware mit /login + /callback,
  OIDC-Discovery + JWKS-Cache, RS256-Signatur-Prüfung, PKCE S256,
  AES-256-GCM-versiegeltes Session-Cookie, Erzwingung von
  Gruppenmitgliedschaft 'proofedmesh-admin' (konfigurierbar).
  Protect(h) setzt WithPrincipal(sub) + WithSession(sid) auf ctx;
  fehlendes/ungültiges Cookie → 302 nach /login (next= bewahrt).
- internal/auth/context.go: WithPrincipal/WithSession/PrincipalFrom
  /SessionFrom aus httpapi/rates.go in internal/auth gezogen,
  damit auth und httpapi dieselben Kontext-Keys teilen (keine
  import cycle: httpapi importiert auth).
- httpapi/rates.go: delegiert jetzt an auth.*; die Shim-Exports
  WithPrincipal/WithSession bleiben für bestehende Tests.
- cmd/admin-api/main.go: Pocket-ID-Middleware aktivieren, sobald
  -oidc-client-id, -oidc-client-secret, -oidc-redirect-url und
  -session-cookie-secret gesetzt sind; ohne sie bleibt das
  Stub-Admin-Prinzipal (bestehendes Verhalten für Dev-Mode).
- Tests (10 neue, 36/36 insgesamt grün, go vet clean):
  httptest-Mock-Pocket-ID mit JWKS + /token-Endpoint deckt den
  vollständigen Flow ab, inkl. missing cookie, falsche Gruppe,
  Nonce/State-Mismatch, Audience-Mismatch, abgelaufenes ID-Token,
  unbekannte kid, und Session-Ablauf nach TTL.
- README: Auth-Abschnitt + Konfigurations-Flags aktualisiert.

DEV-603

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 18:10:02 +00:00

219 lines
8.8 KiB
Go

// Command admin-api is the HTTP entry point for the per-diem-rates admin
// surface and future admin flows. Signing + transparency-log wiring are real
// HTTP clients but default to disabled so local dev with `-store=memory` has
// no external dependencies.
//
// Pocket-ID is used in two directions:
// - **outbound** (admin-api → Signing-Service / Transparency-Log): see
// buildTokenSource; admin-api authenticates as an OIDC client via the
// client_credentials grant, using POCKET_ID_CLIENT_ID /
// POCKET_ID_CLIENT_SECRET from env.
// - **inbound** (admin → admin-api): the auth.Middleware under
// internal/auth verifies a Pocket-ID session cookie (Authorization-Code
// + PKCE) and enforces membership in `proofedmesh-admin`. It activates
// when -admin-oidc-client-id, -admin-oidc-redirect-url and the two env
// secrets ADMIN_OIDC_CLIENT_SECRET + ADMIN_SESSION_COOKIE_SECRET are all
// present; otherwise /admin/* falls back to a hardcoded "stub-admin"
// principal so unit tests and bare dev runs stay self-contained.
package main
import (
"context"
"encoding/base64"
"encoding/hex"
"flag"
"fmt"
"html/template"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"basicstack.de/proofedmesh/services/admin-api/internal/attest"
"basicstack.de/proofedmesh/services/admin-api/internal/auth"
"basicstack.de/proofedmesh/services/admin-api/internal/httpapi"
"basicstack.de/proofedmesh/services/admin-api/internal/store"
"basicstack.de/proofedmesh/services/admin-api/internal/tlog"
)
func main() {
listen := flag.String("listen", ":8080", "HTTP listen address")
backend := flag.String("store", "memory", "storage backend: memory | postgres")
signerURL := flag.String("signer-url", "", "Signing Service base URL (e.g. https://trust.basicstack.de); 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")
bearer := flag.String("bearer", "", "static Pocket ID bearer token for dev; prod uses the Pocket ID client_credentials flow")
issuer := flag.String("oidc-issuer", "https://auth.basicstack.de", "OIDC issuer (Pocket ID base URL) used for the client_credentials flow, inbound session verification, and the performed_by.iss claim")
pocketScope := flag.String("pocket-id-scope", "", "optional OAuth2 scope requested in the client_credentials grant")
pocketAudience := flag.String("pocket-id-audience", "", "optional OAuth2 audience requested in the client_credentials grant")
adminOIDCClientID := flag.String("admin-oidc-client-id", "", "Pocket-ID OIDC client id used for inbound admin login; secret comes from ADMIN_OIDC_CLIENT_SECRET")
adminOIDCRedirectURL := flag.String("admin-oidc-redirect-url", "", "fully-qualified redirect URL registered with Pocket-ID for the admin-login client (points to /callback)")
adminOIDCRequiredGroup := flag.String("admin-oidc-required-group", "proofedmesh-admin", "Pocket-ID group required for /admin/* access")
adminCookieSecure := flag.Bool("admin-cookie-secure", true, "set the Secure flag on admin session cookies; disable only for localhost dev")
flag.Parse()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
var s store.RateStore
switch *backend {
case "memory":
logger.Info("admin-api: using in-memory store")
s = store.NewMemory()
case "postgres":
pg, err := store.NewPostgres()
if err != nil {
logger.Error("postgres store init failed", "err", err)
os.Exit(1)
}
s = pg
default:
fmt.Fprintln(os.Stderr, "unknown -store flag; expected memory|postgres")
os.Exit(2)
}
var orch *attest.Orchestrator
if *signerURL != "" && *tlogURL != "" {
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, "token_source", mode)
} else {
logger.Warn("admin-api: attestation disabled (set -signer-url and -tlog-url to enable)")
}
deps := httpapi.Dependencies{
Store: s,
Templates: template.New("admin"), // real templates loaded from web/templates in the full build
Now: time.Now,
Attest: orch,
Issuer: *issuer,
Logger: logger,
}
mux := http.NewServeMux()
httpapi.Mount(mux, deps)
// Wire the inbound Pocket-ID OIDC middleware when fully configured.
// Otherwise fall back to the historic hardcoded-principal stub so unit
// tests and bare dev runs do not need an IdP.
var handler http.Handler = mux
adminClientSecret := os.Getenv("ADMIN_OIDC_CLIENT_SECRET")
adminSessionSecretRaw := os.Getenv("ADMIN_SESSION_COOKIE_SECRET")
if *adminOIDCClientID != "" && *adminOIDCRedirectURL != "" && adminClientSecret != "" && adminSessionSecretRaw != "" {
secret, err := decodeSessionSecret(adminSessionSecretRaw)
if err != nil {
logger.Error("ADMIN_SESSION_COOKIE_SECRET invalid", "err", err)
os.Exit(2)
}
mw, err := auth.New(auth.Config{
Issuer: *issuer,
ClientID: *adminOIDCClientID,
ClientSecret: adminClientSecret,
RedirectURL: *adminOIDCRedirectURL,
RequiredGroup: *adminOIDCRequiredGroup,
CookieSecret: secret,
Secure: *adminCookieSecure,
Logger: logger,
})
if err != nil {
logger.Error("auth middleware init failed", "err", err)
os.Exit(2)
}
mw.Register(mux)
handler = mw.Protect(mux)
logger.Info("admin-api: Pocket-ID session auth enabled",
"issuer", *issuer, "client_id", *adminOIDCClientID,
"required_group", *adminOIDCRequiredGroup)
} else {
logger.Warn("admin-api: Pocket-ID session auth NOT configured — using stub-admin principal; do not use in production")
handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := auth.WithPrincipal(r.Context(), "stub-admin")
mux.ServeHTTP(w, r.WithContext(ctx))
})
}
srv := &http.Server{
Addr: *listen,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
logger.Info("admin-api listening", "addr", *listen, "store", *backend)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("http serve", "err", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
logger.Info("admin-api shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
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
}
// decodeSessionSecret accepts the AES-256 cookie key as 64-char hex or as
// base64 (any padding). Fails loudly if the decoded length is not 32 bytes
// so a mis-provisioned secret never silently produces a weak cipher.
func decodeSessionSecret(raw string) ([]byte, error) {
if n := len(raw); n == 64 {
b, err := hex.DecodeString(raw)
if err == nil && len(b) == 32 {
return b, nil
}
}
for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
b, err := enc.DecodeString(raw)
if err == nil && len(b) == 32 {
return b, nil
}
}
return nil, fmt.Errorf("expected 32 bytes decoded (hex-64 or base64-32); got %d chars", len(raw))
}