From 403904b901efe6dd48900a1102b7d4cafb4a041d Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 5 Sep 2026 00:41:17 +0200 Subject: [PATCH] Add bounded resource audiences and enforce browser scope grants Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a06e87-e039-7ed2-b85c-20ad37f8a21b --- .custodian-brief.md | 12 +- .gitignore | 3 + INTENT.md | 24 ++-- WORK-RECORDS.md | 18 ++- config/service-clients.example.yaml | 27 +++- docs/approval-engine-auth-contract.md | 31 ++++ intakes/intakes.md | 6 +- src/cmd/keycape/clients_test.go | 38 +++++ src/cmd/keycape/main.go | 1 + src/internal/config/config.go | 1 + src/internal/config/config_test.go | 21 ++- src/internal/config/validate.go | 3 + src/internal/domain/model.go | 1 + src/internal/server/oidc/audience_test.go | 134 ++++++++++++++++++ src/internal/server/oidc/authorize.go | 7 + src/internal/server/oidc/authorize_test.go | 2 +- src/internal/server/oidc/token.go | 32 ++++- workplans/ADHOC-2026-09-05.md | 27 ++++ .../KEY-WP-0001-keycape-implementation.md | 2 +- .../KEY-WP-0002-container-image-gitea.md | 2 +- ...-0013-approval-engine-resource-audience.md | 47 ++++++ ...-WP-0014-native-credential-lane-handoff.md | 45 ++++++ 22 files changed, 449 insertions(+), 35 deletions(-) create mode 100644 docs/approval-engine-auth-contract.md create mode 100644 src/cmd/keycape/clients_test.go create mode 100644 src/internal/server/oidc/audience_test.go create mode 100644 workplans/ADHOC-2026-09-05.md create mode 100644 workplans/KEY-WP-0013-approval-engine-resource-audience.md create mode 100644 workplans/KEY-WP-0014-native-credential-lane-handoff.md diff --git a/.custodian-brief.md b/.custodian-brief.md index 0281d4b..cbfc046 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,18 +2,16 @@ # Custodian Brief — key-cape **Domain:** infotech -**Last synced:** 2026-08-25 17:53 UTC +**Last synced:** 2026-09-04 22:33 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams -*(none — repo may need first-session setup)* +### Approval-engine resource audience and client registrations +Progress: 1/2 done | workplan_id: `6e815d88-b0e3-5ce0-be5d-13ab15917f7f` -## Inbox Hygiene - -**Stale unread:** 1 message(s) older than 3 day(s) — triage at session start. -**Missing thread_id:** 1 unread message(s) lack supersession chains. -- ! state-hub: [BREAKING] Task status canon: wait/todo/progress/done/cancel `8dbd52c2` +**Open tasks:** +- ! Provision and prove live registrations `607897c5` --- ## MCP Orientation (when available) diff --git a/.gitignore b/.gitignore index 36b13f1..13ca50c 100644 --- a/.gitignore +++ b/.gitignore @@ -174,3 +174,6 @@ cython_debug/ # PyPI configuration file .pypirc + +# Generated workstation repository index +.repo-manager/ diff --git a/INTENT.md b/INTENT.md index 24a06c4..b3cff8e 100644 --- a/INTENT.md +++ b/INTENT.md @@ -1,19 +1,15 @@ # INTENT -> **NetKingdom layering review — 2026-08-28.** This repository's role was reviewed -> against the NetKingdom IT-security layer model: **Taxonomy → Tooling → Engines → -> Staff**, layered by determinism and by the kind of artifact each layer produces. -> Findings and the argument behind them: -> `gate-house/history/2026-08-28-security-layer-model-and-gate-house-recut.md`. -> The model is `net-kingdom/canon/standards/security-layer-model_v0.1.md` (proposed), -> ratified by `gate-house/decisions/decisions.md` GH-DEC-2026-001. -> -> The layer rule that binds every repository: **Staff never touches tooling -> directly. It acts only through engine APIs.** -> -> **This repository is Tooling — deterministic infrastructure and state.** State the layer: key-cape packages the third-party identity tooling (authelia, lldap, privacy-idea) behind a versioned IAM profile. It owns authentication and identity claims only. Because "auth" is ambiguous across the estate, this INTENT should say explicitly that key-cape is **not** an authorization decision point — that is access-engine's, and it stays the only one. -> -> *This note records what should change. The body below is not yet adapted.* +KeyCape declares its layer as **Tooling** under the NetKingdom security layer +model v0.4. It provides deterministic authentication and identity claims through +the versioned IAM profile, packaging Authelia, LLDAP, and privacyIDEA. +Authorization decisions belong to access-engine; KeyCape is not an authorization +decision point. Issuing a role or scope claim does not decide access to a resource. + +These backend components are implementation dependencies of KeyCape's tooling +boundary. Separate catalog entries are unnecessary until they expose independently +owned estate capabilities. Staff integration follows engine APIs as required by +the layer model; this declaration does not certify every existing caller path. ## Purpose diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index cf75086..e31eb72 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -8,8 +8,9 @@ | Kind | ID | Status | Lane | Source | | --- | --- | --- | --- | --- | -| workplan | KEY-WP-0001 | done | — | workplans/KEY-WP-0001-keycape-implementation.md | -| workplan | KEY-WP-0002 | done | — | workplans/KEY-WP-0002-container-image-gitea.md | +| workplan | ADHOC-2026-09-05 | finished | — | workplans/ADHOC-2026-09-05.md | +| workplan | KEY-WP-0001 | finished | — | workplans/KEY-WP-0001-keycape-implementation.md | +| workplan | KEY-WP-0002 | finished | — | workplans/KEY-WP-0002-container-image-gitea.md | | workplan | KEY-WP-0003 | finished | — | workplans/KEY-WP-0003-bootstrap-console-oidc-mfa-login.md | | workplan | KEY-WP-0004 | finished | — | workplans/KEY-WP-0004-binky-hedgehog-tenant-onboarding.md | | workplan | KEY-WP-0005 | finished | — | workplans/KEY-WP-0005-iam-profile-core-claims.md | @@ -19,6 +20,10 @@ | workplan | KEY-WP-0009 | finished | — | workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md | | workplan | KEY-WP-0010 | finished | — | workplans/KEY-WP-0010-openbao-operator-loopback-callback.md | | workplan | KEY-WP-0011 | finished | — | workplans/KEY-WP-0011-live-secret-exposure-recovery.md | +| workplan | KEY-WP-0012 | finished | — | workplans/KEY-WP-0012-userinfo-canonical-subject-resolution.md | +| workplan | KEY-WP-0013 | blocked | — | workplans/KEY-WP-0013-approval-engine-resource-audience.md | +| workplan | KEY-WP-0014 | proposed | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md | +| task | ADHOC-2026-09-05-T01 | done | — | workplans/ADHOC-2026-09-05.md | | task | KEY-WP-0001-T01 | done | — | workplans/KEY-WP-0001-keycape-implementation.md | | task | KEY-WP-0001-T02 | done | — | workplans/KEY-WP-0001-keycape-implementation.md | | task | KEY-WP-0001-T03 | done | — | workplans/KEY-WP-0001-keycape-implementation.md | @@ -83,4 +88,11 @@ | task | KEY-WP-0011-T01 | done | — | workplans/KEY-WP-0011-live-secret-exposure-recovery.md | | task | KEY-WP-0011-T02 | done | — | workplans/KEY-WP-0011-live-secret-exposure-recovery.md | | task | KEY-WP-0011-T03 | done | — | workplans/KEY-WP-0011-live-secret-exposure-recovery.md | -| intake | KEY-IN-0001 | open | — | intakes/intakes.md | +| task | KEY-WP-0012-T01 | done | — | workplans/KEY-WP-0012-userinfo-canonical-subject-resolution.md | +| task | KEY-WP-0012-T02 | done | — | workplans/KEY-WP-0012-userinfo-canonical-subject-resolution.md | +| task | KEY-WP-0012-T03 | done | — | workplans/KEY-WP-0012-userinfo-canonical-subject-resolution.md | +| task | KEY-WP-0013-T01 | done | — | workplans/KEY-WP-0013-approval-engine-resource-audience.md | +| task | KEY-WP-0013-T02 | wait | — | workplans/KEY-WP-0013-approval-engine-resource-audience.md | +| task | KEY-WP-0014-T01 | todo | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md | +| task | KEY-WP-0014-T02 | todo | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md | +| intake | KEY-IN-0001 | closed | — | intakes/intakes.md | diff --git a/config/service-clients.example.yaml b/config/service-clients.example.yaml index 1eb6efc..29c739d 100644 --- a/config/service-clients.example.yaml +++ b/config/service-clients.example.yaml @@ -1,5 +1,4 @@ -# Non-secret static registration fragments for KeyCape's bounded OpenBao JWT -# consumers. Merge these entries into the deployment-owned KeyCape config. +# Non-secret static registration fragments for KeyCape's bounded JWT consumers. Merge these entries into the deployment-owned KeyCape config. # The named environment variables must be materialized by an approved # out-of-repository custody path; this file never contains their values. clients: @@ -24,3 +23,27 @@ clients: tenant: "tenant:coulomb" roles: ["secrets-engine"] tokenLifetime: "15m" + + - clientId: "secrets-engine-approval" + displayName: "secrets-engine approval consume client" + audience: "approval-engine" + allowedScopes: ["approval:read", "approval:consume"] + grantTypes: ["client_credentials"] + clientType: "confidential" + secretRef: "env:KEYCAPE_SECRETS_ENGINE_APPROVAL_CLIENT_SECRET" + serviceSubject: "service:secrets-engine" + tenant: "tenant:coulomb" + roles: ["secrets-engine"] + tokenLifetime: "15m" + + - clientId: "approval-engine-operator" + displayName: "approval-engine lifecycle operator" + audience: "approval-engine" + allowedScopes: ["approval:create", "approval:read", "approval:approve", "approval:revoke", "approval:supersede", "approval:observe", "approval:emit"] + grantTypes: ["client_credentials"] + clientType: "confidential" + secretRef: "env:KEYCAPE_APPROVAL_ENGINE_OPERATOR_CLIENT_SECRET" + serviceSubject: "service:approval-engine-operator" + tenant: "tenant:coulomb" + roles: ["approval-operator"] + tokenLifetime: "15m" diff --git a/docs/approval-engine-auth-contract.md b/docs/approval-engine-auth-contract.md new file mode 100644 index 0000000..ead4d1c --- /dev/null +++ b/docs/approval-engine-auth-contract.md @@ -0,0 +1,31 @@ +# Approval-engine token contract + +Static client registrations may set `audience: approval-engine`. This selects +only the access-token audience; OIDC ID tokens retain `aud=clientId`. Omitting +`audience` preserves the existing client-ID access audience, including OpenBao +consumers. Request `audience` and `resource` parameters cannot override it. +Access tokens contain the granted `scope` string for both supported grant types. + +`config/service-clients.example.yaml` provides the two requested confidential +clients: secrets-engine-approval gets read/consume, and approval-engine-operator +gets lifecycle/observation scopes without consume. Service tokens contain +`principal_type=service`, tenant, roles, scope, assurance, issuer, subject, +audience, issue time and expiry; the lifetime is 15 minutes. The issuer signs +with RS256 and publishes its public key through `/jwks`. + +Human approvers need a separate authorization-code/PKCE registration with an +exact deployment-owned callback, `audience: approval-engine`, +`allowedScopes: [openid, approval:approve]`, and `mfaRequired: true`. Do not add +consume or other approval grants to that client. No callback is invented here. +The ID token is for the login client; present the access token to approval-engine. + +These fragments are not live registrations. Deployment requires custody-managed +values for the named environment references, the exact human callback, and a +rollout of this version. Never log the token or secret. Verify the resulting +access token against the deployed issuer's `/jwks`, checking issuer, audience, +expiry, subject, principal type, tenant, roles, scope and assurance. Verify that +operator consume and human consume requests are rejected. Local tests verify +signatures against the JWKS handler; they do not constitute live issuance proof. + +KeyCape owns issuance and client grants/disablement. OpenBao and the deployment +operator own credential custody; approval-engine enforces its resource policy. diff --git a/intakes/intakes.md b/intakes/intakes.md index 04d62a2..845c302 100644 --- a/intakes/intakes.md +++ b/intakes/intakes.md @@ -7,7 +7,7 @@ id: KEY-IN-0001 kind: intake title: 'Declaration requested: state this repository''s layer in INTENT.md (security layer model §11)' -status: open +status: closed origin: cross-repo origin_ref: net-kingdom security-layer-model_v0.4 §11 priority: low @@ -34,5 +34,7 @@ description: 'A conformance sweep on 2026-08-28 found this repository has no lay actually does, that is more useful to us than a label added to close a checkbox. Standard: net-kingdom/canon/standards/security-layer-model_v0.4.md.' created: '2026-08-28T21:01:53.604723Z' -updated: '2026-08-28T21:01:53.604723Z' +updated: '2026-09-05' +resolution: 'Declared Tooling ownership and authorization boundary in INTENT.md; backend components remain implementation dependencies.' +state_hub_intake_id: "01a06e8d-5b4b-7202-96e3-c7304640c3a6" ``` diff --git a/src/cmd/keycape/clients_test.go b/src/cmd/keycape/clients_test.go new file mode 100644 index 0000000..d06ee99 --- /dev/null +++ b/src/cmd/keycape/clients_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "keycape/internal/config" + "testing" + "time" +) + +func TestServiceRegistrationAudienceAndScopeIsolation(t *testing.T) { + cfg, err := config.Load("../../../config/service-clients.example.yaml") + if err != nil { + t.Fatal(err) + } + for _, c := range cfg.Clients { + t.Setenv(c.SecretRef[4:], "test-only-secret") + } + registry, err := buildClientRegistry(cfg.Clients) + if err != nil { + t.Fatal(err) + } + for _, id := range []string{"secrets-engine-approval", "approval-engine-operator"} { + c := registry[id] + if c == nil || c.Audience != "approval-engine" || c.TokenLifetime != 15*time.Minute { + t.Fatalf("invalid registration for %s", id) + } + for _, scope := range c.AllowedScopes { + if id == "approval-engine-operator" && scope == "approval:consume" { + t.Fatal("operator may not consume") + } + if id == "secrets-engine-approval" && scope != "approval:read" && scope != "approval:consume" { + t.Fatal("excess PEP scope") + } + } + } + if registry["secrets-engine-openbao"].Audience != "" { + t.Fatal("OpenBao audience default changed") + } +} diff --git a/src/cmd/keycape/main.go b/src/cmd/keycape/main.go index 138de88..050fa3e 100644 --- a/src/cmd/keycape/main.go +++ b/src/cmd/keycape/main.go @@ -290,6 +290,7 @@ func buildClientRegistry(cfgClients []config.ClientConfig) (map[string]*domain.C ClientType: c.ClientType, SecretRef: c.SecretRef, ClientSecret: clientSecret, + Audience: c.Audience, ServiceSubject: c.ServiceSubject, Tenant: c.Tenant, Roles: c.Roles, diff --git a/src/internal/config/config.go b/src/internal/config/config.go index e10fcb1..fab257d 100644 --- a/src/internal/config/config.go +++ b/src/internal/config/config.go @@ -30,6 +30,7 @@ type Config struct { // ClientConfig is a static OIDC client registration. type ClientConfig struct { ClientID string `yaml:"clientId"` + Audience string `yaml:"audience,omitempty"` DisplayName string `yaml:"displayName"` RedirectURIs []string `yaml:"redirectUris"` AllowedScopes []string `yaml:"allowedScopes"` diff --git a/src/internal/config/config_test.go b/src/internal/config/config_test.go index fbb167e..2d94f11 100644 --- a/src/internal/config/config_test.go +++ b/src/internal/config/config_test.go @@ -385,8 +385,8 @@ func TestServiceClientExampleContracts(t *testing.T) { if errs := config.ValidateConfig(cfg); len(errs) != 0 { t.Fatalf("service client examples must validate: %v", errs) } - if len(cfg.Clients) != 2 { - t.Fatalf("service client examples: want 2, got %d", len(cfg.Clients)) + if len(cfg.Clients) != 4 { + t.Fatalf("service client examples: want 4, got %d", len(cfg.Clients)) } codingAgent := cfg.Clients[0] @@ -479,3 +479,20 @@ func containsErr(errs []string, substring string) bool { } return false } + +func TestValidateConfigAudience(t *testing.T) { + for _, audience := range []string{"", "approval-engine", "https://api.example.com"} { + cfg := validConfig("key.pem") + cfg.Clients[0].Audience = audience + if errs := config.ValidateConfig(cfg); len(errs) != 0 { + t.Fatalf("valid audience %q: %v", audience, errs) + } + } + for _, audience := range []string{" ", "approval-engine other", "approval-engine\n"} { + cfg := validConfig("key.pem") + cfg.Clients[0].Audience = audience + if errs := config.ValidateConfig(cfg); len(errs) == 0 { + t.Fatalf("accepted audience %q", audience) + } + } +} diff --git a/src/internal/config/validate.go b/src/internal/config/validate.go index 331fdac..d09138e 100644 --- a/src/internal/config/validate.go +++ b/src/internal/config/validate.go @@ -42,6 +42,9 @@ func ValidateConfig(cfg *Config) []string { prefix = fmt.Sprintf("clients[%d]", i) errs = append(errs, prefix+": clientId must not be empty") } + if strings.TrimSpace(c.Audience) != c.Audience || strings.ContainsAny(c.Audience, " \t\r\n") { + errs = append(errs, prefix+": audience must be a single non-whitespace identifier") + } hasAuthorizationCode := contains(c.GrantTypes, "authorization_code") hasClientCredentials := contains(c.GrantTypes, "client_credentials") if (hasAuthorizationCode || !hasClientCredentials) && len(c.RedirectURIs) == 0 { diff --git a/src/internal/domain/model.go b/src/internal/domain/model.go index f4846b4..71b1b84 100644 --- a/src/internal/domain/model.go +++ b/src/internal/domain/model.go @@ -50,6 +50,7 @@ type Client struct { ClientType string `yaml:"clientType" json:"clientType"` // "confidential" | "public" SecretRef string `yaml:"secretRef,omitempty" json:"secretRef,omitempty"` ClientSecret string `yaml:"-" json:"-"` + Audience string `yaml:"audience,omitempty" json:"audience,omitempty"` ServiceSubject string `yaml:"serviceSubject,omitempty" json:"serviceSubject,omitempty"` Tenant string `yaml:"tenant,omitempty" json:"tenant,omitempty"` Roles []string `yaml:"roles,omitempty" json:"roles,omitempty"` diff --git a/src/internal/server/oidc/audience_test.go b/src/internal/server/oidc/audience_test.go new file mode 100644 index 0000000..fb5b1f4 --- /dev/null +++ b/src/internal/server/oidc/audience_test.go @@ -0,0 +1,134 @@ +package oidc_test + +import ( + "crypto" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "keycape/internal/domain" + "keycape/internal/server/oidc" + "keycape/internal/server/telemetry" +) + +func verifyWithJWKS(t *testing.T, h *oidc.TokenHandler, token string) { + t.Helper() + keys := oidc.NewKeySet() + keys.AddKey("key-1", &h.SigningKey.PublicKey) + w := httptest.NewRecorder() + oidc.NewJWKSHandler(keys).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/jwks", nil)) + var response struct { + Keys []oidc.JWK `json:"keys"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if len(response.Keys) != 1 { + t.Fatal("missing signing key") + } + key := response.Keys[0] + n, err := base64.RawURLEncoding.DecodeString(key.N) + if err != nil { + t.Fatal(err) + } + e, err := base64.RawURLEncoding.DecodeString(key.E) + if err != nil { + t.Fatal(err) + } + parts := strings.Split(token, ".") + if len(parts) != 3 { + t.Fatal("invalid JWT") + } + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + pub := rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(new(big.Int).SetBytes(e).Int64())} + if err := rsa.VerifyPKCS1v15(&pub, crypto.SHA256, digest[:], sig); err != nil { + t.Fatal(err) + } +} + +func TestServiceAudienceIsStaticAndDefaultsToClientID(t *testing.T) { + for _, audience := range []string{"", "approval-engine"} { + t.Run(audience, func(t *testing.T) { + h := serviceTokenHandler(t) + h.ClientConfig["rapp-qonto"].Audience = audience + req := tokenRequest(url.Values{"grant_type": {"client_credentials"}, "audience": {"attacker"}, "resource": {"attacker"}}) + req.SetBasicAuth("rapp-qonto", "test-service-secret") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("status %d", w.Code) + } + token := decodeTokenResponse(t, w.Body.String())["access_token"].(string) + want := audience + if want == "" { + want = "rapp-qonto" + } + if parseJWTPayload(t, token)["aud"] != want { + t.Fatal("wrong audience") + } + verifyWithJWKS(t, h, token) + }) + } +} + +func TestHumanResourceAudiencePreservesIDTokenAudience(t *testing.T) { + sessions := oidc.NewSessionStore() + h, _ := newTokenHandler(t, sessions, &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}) + h.ClientConfig["test-client"].Audience = "approval-engine" + h.ClientConfig["test-client"].AllowedScopes = []string{"openid", "approval:approve"} + verifier := "test-verifier" + code := seededSession(sessions, verifier) + sess, _ := sessions.Get(code) + sess.Scopes = []string{"openid", "approval:approve"} + w := httptest.NewRecorder() + h.ServeHTTP(w, tokenRequest(url.Values{"grant_type": {"authorization_code"}, "client_id": {"test-client"}, "code": {code}, "code_verifier": {verifier}})) + if w.Code != 200 { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + response := decodeTokenResponse(t, w.Body.String()) + access := response["access_token"].(string) + id := response["id_token"].(string) + claims := parseJWTPayload(t, access) + if claims["aud"] != "approval-engine" || claims["scope"] != "openid approval:approve" || claims["principal_type"] != "human" { + t.Fatalf("wrong access claims: %v", claims) + } + if parseJWTPayload(t, id)["aud"] != "test-client" { + t.Fatal("ID token audience changed") + } + verifyWithJWKS(t, h, access) + verifyWithJWKS(t, h, id) +} + +func TestHumanExcessScopeRejectedBeforeAuthentication(t *testing.T) { + h := newAuthorizeHandler(nil, nil, telemetry.NoopEmitter{}) + params := validAuthorizeParams() + params.Set("scope", "openid approval:consume") + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/authorize?"+params.Encode(), nil)) + if w.Code != http.StatusBadRequest { + t.Fatalf("status %d", w.Code) + } +} + +func TestTokenRejectsScopeRemovedAfterAuthorization(t *testing.T) { + sessions := oidc.NewSessionStore() + h, _ := newTokenHandler(t, sessions, &mockUserRepo{}) + code := seededSession(sessions, "verifier") + h.ClientConfig["test-client"].AllowedScopes = []string{"openid"} + w := httptest.NewRecorder() + h.ServeHTTP(w, tokenRequest(url.Values{"grant_type": {"authorization_code"}, "client_id": {"test-client"}, "code": {code}, "code_verifier": {"verifier"}})) + if w.Code != http.StatusBadRequest { + t.Fatalf("status %d", w.Code) + } +} diff --git a/src/internal/server/oidc/authorize.go b/src/internal/server/oidc/authorize.go index 5eced25..1fe5a37 100644 --- a/src/internal/server/oidc/authorize.go +++ b/src/internal/server/oidc/authorize.go @@ -190,6 +190,13 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request return } + for _, requestedScope := range strings.Fields(scope) { + if !containsString(client.AllowedScopes, requestedScope) { + profileerrors.InvalidProfileUsage("requested scope is not allowed", "scope").Write(w, http.StatusBadRequest) + return + } + } + // 5. Validate code_challenge is present. if codeChallenge == "" { profileerrors.InvalidProfileUsage( diff --git a/src/internal/server/oidc/authorize_test.go b/src/internal/server/oidc/authorize_test.go index a01e91b..6900645 100644 --- a/src/internal/server/oidc/authorize_test.go +++ b/src/internal/server/oidc/authorize_test.go @@ -94,7 +94,7 @@ func testClient() map[string]*domain.Client { ClientID: "test-client", DisplayName: "Test Client", RedirectURIs: []string{"https://app.example.com/callback"}, - AllowedScopes: []string{"openid", "profile", "email"}, + AllowedScopes: []string{"openid", "profile", "email", "groups"}, ClientType: "public", }, "netkingdom-bootstrap-console": { diff --git a/src/internal/server/oidc/token.go b/src/internal/server/oidc/token.go index 32c779d..05c7e37 100644 --- a/src/internal/server/oidc/token.go +++ b/src/internal/server/oidc/token.go @@ -93,6 +93,14 @@ func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // Recheck grants in case the client registration changed after authorization. + for _, scope := range sess.Scopes { + if !containsString(h.ClientConfig[clientID].AllowedScopes, scope) { + profileerrors.InvalidProfileUsage("requested scope is not allowed", "scope").Write(w, http.StatusBadRequest) + return + } + } + // 4. Verify PKCE code_verifier. if !verifyPKCE(codeVerifier, sess.PKCEChallenge) { profileerrors.InvalidProfileUsage( @@ -169,12 +177,24 @@ func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // Access tokens target the statically registered resource server. ID tokens + // remain bound to the OIDC relying party. + if audience := h.ClientConfig[clientID].Audience; audience != "" { + claims["aud"] = audience + } + claims["scope"] = strings.Join(sess.Scopes, " ") + accessToken, err := buildJWT(claims, kid, h.SigningKey) + if err != nil { + http.Error(w, "failed to build JWT", http.StatusInternalServerError) + return + } + // 8. Delete used PKCE session (prevent replay). h.Sessions.Delete(code) // 9. Build response. resp := tokenResponse{ - AccessToken: jwtToken, + AccessToken: accessToken, TokenType: "Bearer", ExpiresIn: int(h.TokenLifetime.Seconds()), IDToken: jwtToken, @@ -239,7 +259,7 @@ func (h *TokenHandler) serveClientCredentials(w http.ResponseWriter, r *http.Req claims := map[string]interface{}{ "iss": h.Issuer, "sub": client.ServiceSubject, - "aud": clientID, + "aud": accessAudience(client), "exp": now.Add(tokenLifetime).Unix(), "iat": now.Unix(), "tenant": client.Tenant, @@ -393,3 +413,11 @@ func buildJWT(claims map[string]interface{}, kid string, key *rsa.PrivateKey) (s return strings.Join([]string{hdrB64, payloadB64, sigB64}, "."), nil } + +// accessAudience is configured by the issuer, never selected by request input. +func accessAudience(client *domain.Client) string { + if client.Audience != "" { + return client.Audience + } + return client.ClientID +} diff --git a/workplans/ADHOC-2026-09-05.md b/workplans/ADHOC-2026-09-05.md new file mode 100644 index 0000000..f7e7867 --- /dev/null +++ b/workplans/ADHOC-2026-09-05.md @@ -0,0 +1,27 @@ +--- +id: ADHOC-2026-09-05 +type: workplan +title: "Repository review and layer declaration" +domain: infotech +repo: key-cape +status: finished +owner: codex +created: "2026-09-05" +updated: "2026-09-05" +state_hub_workstream_id: "6f5a12f0-5dac-5e02-bbd9-9ab2263264ba" +--- + +## Resolve declaration intake and normalize repository records + +```task +id: ADHOC-2026-09-05-T01 +status: done +priority: low +state_hub_task_id: "54c89abe-2539-550d-bc3a-649af8d4580a" +``` + +Reviewed KEY-IN-0001 against local security-layer-model_v0.4. Declared Tooling +and the authentication/authorization boundary in INTENT.md and closed the intake. +Normalized two legacy completed workplan frontmatter statuses to finished. +Ignored the generated .repo-manager workstation cache. Existing task statuses +already conform to the State Hub task lifecycle canon. diff --git a/workplans/KEY-WP-0001-keycape-implementation.md b/workplans/KEY-WP-0001-keycape-implementation.md index e42fbc5..7433441 100644 --- a/workplans/KEY-WP-0001-keycape-implementation.md +++ b/workplans/KEY-WP-0001-keycape-implementation.md @@ -4,7 +4,7 @@ type: workplan title: "KeyCape Implementation — Lightweight IAM Profile" domain: infotech repo: key-cape -status: done +status: finished owner: Bernd topic_slug: netkingdom topic_id: a6c6e745-bf54-4465-9340-1534a2be493e diff --git a/workplans/KEY-WP-0002-container-image-gitea.md b/workplans/KEY-WP-0002-container-image-gitea.md index 983d2e0..418f477 100644 --- a/workplans/KEY-WP-0002-container-image-gitea.md +++ b/workplans/KEY-WP-0002-container-image-gitea.md @@ -4,7 +4,7 @@ type: workplan title: "KeyCape Container Image — Build & Publish to Gitea OCI Registry" domain: infotech repo: key-cape -status: done +status: finished owner: netkingdom topic_slug: netkingdom created: "2026-03-22" diff --git a/workplans/KEY-WP-0013-approval-engine-resource-audience.md b/workplans/KEY-WP-0013-approval-engine-resource-audience.md new file mode 100644 index 0000000..5476029 --- /dev/null +++ b/workplans/KEY-WP-0013-approval-engine-resource-audience.md @@ -0,0 +1,47 @@ +--- +id: KEY-WP-0013 +type: workplan +title: "Approval-engine resource audience and client registrations" +domain: infotech +repo: key-cape +status: blocked +owner: codex +topic_slug: approval-engine-resource-audience +created: "2026-09-05" +updated: "2026-09-05" +state_hub_workstream_id: "6e815d88-b0e3-5ce0-be5d-13ab15917f7f" +--- + +Source: approval-engine inbox request 5583e896-52f2-45bd-895f-02f227b7e836, +reviewed against its local registration and caller-authentication contracts. + +## Implement static resource audiences + +```task +id: KEY-WP-0013-T01 +status: done +priority: high +state_hub_task_id: "a92432a2-4e92-5b90-be2b-d82b784bf8f0" +``` + +Added optional static audience configuration for access tokens in both grants; +ID tokens retain the relying-party audience. Added human access-token scope. +Published bounded approval service fragments and the human registration contract. +Regression tests cover the default audience, request override resistance, JWKS +signature validation, ID-token separation and service registration scope isolation. +Browser requests and token exchanges now enforce the client scope allow-list, +including grants removed after authorization. + +## Provision and prove live registrations + +```task +id: KEY-WP-0013-T02 +status: wait +priority: high +state_hub_task_id: "607897c5-bad9-55e5-86df-7802f592d6e8" +``` + +Needs deployment-owned custody for both new secret references and an exact human +callback registration. Deploy the implementation and registrations together, +then prove live JWKS verification and denied excess scopes without logging values. +Local signature proof is not live rollout evidence. See docs/approval-engine-auth-contract.md. diff --git a/workplans/KEY-WP-0014-native-credential-lane-handoff.md b/workplans/KEY-WP-0014-native-credential-lane-handoff.md new file mode 100644 index 0000000..c241001 --- /dev/null +++ b/workplans/KEY-WP-0014-native-credential-lane-handoff.md @@ -0,0 +1,45 @@ +--- +id: KEY-WP-0014 +type: workplan +title: "Review native login and client credential lane handoffs" +domain: infotech +repo: key-cape +status: proposed +owner: codex +topic_slug: native-credential-lane-handoff +created: "2026-09-05" +updated: "2026-09-05" +state_hub_workstream_id: "0d003df3-f7d3-5063-8ca0-e1e33f7df74a" +--- + +Source: ops-warden inbox message 0dd9c7bd-0ecd-42d1-806f-7cc4ba9730ed. +The issuer supports client_secret_basic exchange, but has no native exchange or +rotation CLI. Existing machine issuance ownership does not complete this handoff. + +## Design owner command and custody boundaries + +```task +id: KEY-WP-0014-T01 +status: todo +priority: medium +state_hub_task_id: "0c0a0b61-c19e-5631-9cda-8b2dc0f47d8f" +``` + +Review ops-warden's existing key-cape-oidc-login proxy and +rapp-qonto-keycape-client route contracts. Specify the native interactive login +and bounded exchange commands, token delivery, renewal and custody-mediated +rotation before implementation. Keep secret custody with OpenBao and avoid +retiring the proxy until replacement commands have equivalent verification. + +## Verify handoff delivery evidence + +```task +id: KEY-WP-0014-T02 +status: todo +priority: low +state_hub_task_id: "d9a5de97-b7d5-5599-98c2-eaab32f51495" +``` + +Ops-warden reports KEY-WP-0009-T04's claimed reply did not arrive. Verify prior +receipts for all four named recipients before claiming successful notification. +No outbound coordination messages were sent during the 2026-09-05 repo review.