- 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>
219 lines
8.8 KiB
Go
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))
|
|
}
|