Add bounded resource audiences and enforce browser scope grants
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 36s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06e87-e039-7ed2-b85c-20ad37f8a21b
This commit is contained in:
tegwick 2026-09-05 00:41:17 +02:00
parent b8dda4115a
commit 403904b901
22 changed files with 449 additions and 35 deletions

View file

@ -2,18 +2,16 @@
# Custodian Brief — key-cape # Custodian Brief — key-cape
**Domain:** infotech **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)* **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
## Active Workstreams ## 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 **Open tasks:**
- ! Provision and prove live registrations `607897c5`
**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`
--- ---
## MCP Orientation (when available) ## MCP Orientation (when available)

3
.gitignore vendored
View file

@ -174,3 +174,6 @@ cython_debug/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
# Generated workstation repository index
.repo-manager/

View file

@ -1,19 +1,15 @@
# INTENT # INTENT
> **NetKingdom layering review — 2026-08-28.** This repository's role was reviewed KeyCape declares its layer as **Tooling** under the NetKingdom security layer
> against the NetKingdom IT-security layer model: **Taxonomy → Tooling → Engines → model v0.4. It provides deterministic authentication and identity claims through
> Staff**, layered by determinism and by the kind of artifact each layer produces. the versioned IAM profile, packaging Authelia, LLDAP, and privacyIDEA.
> Findings and the argument behind them: Authorization decisions belong to access-engine; KeyCape is not an authorization
> `gate-house/history/2026-08-28-security-layer-model-and-gate-house-recut.md`. decision point. Issuing a role or scope claim does not decide access to a resource.
> 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. These backend components are implementation dependencies of KeyCape's tooling
> boundary. Separate catalog entries are unnecessary until they expose independently
> The layer rule that binds every repository: **Staff never touches tooling owned estate capabilities. Staff integration follows engine APIs as required by
> directly. It acts only through engine APIs.** the layer model; this declaration does not certify every existing caller path.
>
> **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.*
## Purpose ## Purpose

View file

@ -8,8 +8,9 @@
| Kind | ID | Status | Lane | Source | | Kind | ID | Status | Lane | Source |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| workplan | KEY-WP-0001 | done | — | workplans/KEY-WP-0001-keycape-implementation.md | | workplan | ADHOC-2026-09-05 | finished | — | workplans/ADHOC-2026-09-05.md |
| workplan | KEY-WP-0002 | done | — | workplans/KEY-WP-0002-container-image-gitea.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-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-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 | | 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-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-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-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-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-T02 | done | — | workplans/KEY-WP-0001-keycape-implementation.md |
| task | KEY-WP-0001-T03 | 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-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-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 | | 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 |

View file

@ -1,5 +1,4 @@
# Non-secret static registration fragments for KeyCape's bounded OpenBao JWT # Non-secret static registration fragments for KeyCape's bounded JWT consumers. Merge these entries into the deployment-owned KeyCape config.
# consumers. Merge these entries into the deployment-owned KeyCape config.
# The named environment variables must be materialized by an approved # The named environment variables must be materialized by an approved
# out-of-repository custody path; this file never contains their values. # out-of-repository custody path; this file never contains their values.
clients: clients:
@ -24,3 +23,27 @@ clients:
tenant: "tenant:coulomb" tenant: "tenant:coulomb"
roles: ["secrets-engine"] roles: ["secrets-engine"]
tokenLifetime: "15m" 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"

View file

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

View file

@ -7,7 +7,7 @@ id: KEY-IN-0001
kind: intake kind: intake
title: 'Declaration requested: state this repository''s layer in INTENT.md (security title: 'Declaration requested: state this repository''s layer in INTENT.md (security
layer model §11)' layer model §11)'
status: open status: closed
origin: cross-repo origin: cross-repo
origin_ref: net-kingdom security-layer-model_v0.4 §11 origin_ref: net-kingdom security-layer-model_v0.4 §11
priority: low 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. 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.' Standard: net-kingdom/canon/standards/security-layer-model_v0.4.md.'
created: '2026-08-28T21:01:53.604723Z' 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"
``` ```

View file

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

View file

@ -290,6 +290,7 @@ func buildClientRegistry(cfgClients []config.ClientConfig) (map[string]*domain.C
ClientType: c.ClientType, ClientType: c.ClientType,
SecretRef: c.SecretRef, SecretRef: c.SecretRef,
ClientSecret: clientSecret, ClientSecret: clientSecret,
Audience: c.Audience,
ServiceSubject: c.ServiceSubject, ServiceSubject: c.ServiceSubject,
Tenant: c.Tenant, Tenant: c.Tenant,
Roles: c.Roles, Roles: c.Roles,

View file

@ -30,6 +30,7 @@ type Config struct {
// ClientConfig is a static OIDC client registration. // ClientConfig is a static OIDC client registration.
type ClientConfig struct { type ClientConfig struct {
ClientID string `yaml:"clientId"` ClientID string `yaml:"clientId"`
Audience string `yaml:"audience,omitempty"`
DisplayName string `yaml:"displayName"` DisplayName string `yaml:"displayName"`
RedirectURIs []string `yaml:"redirectUris"` RedirectURIs []string `yaml:"redirectUris"`
AllowedScopes []string `yaml:"allowedScopes"` AllowedScopes []string `yaml:"allowedScopes"`

View file

@ -385,8 +385,8 @@ func TestServiceClientExampleContracts(t *testing.T) {
if errs := config.ValidateConfig(cfg); len(errs) != 0 { if errs := config.ValidateConfig(cfg); len(errs) != 0 {
t.Fatalf("service client examples must validate: %v", errs) t.Fatalf("service client examples must validate: %v", errs)
} }
if len(cfg.Clients) != 2 { if len(cfg.Clients) != 4 {
t.Fatalf("service client examples: want 2, got %d", len(cfg.Clients)) t.Fatalf("service client examples: want 4, got %d", len(cfg.Clients))
} }
codingAgent := cfg.Clients[0] codingAgent := cfg.Clients[0]
@ -479,3 +479,20 @@ func containsErr(errs []string, substring string) bool {
} }
return false 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)
}
}
}

View file

@ -42,6 +42,9 @@ func ValidateConfig(cfg *Config) []string {
prefix = fmt.Sprintf("clients[%d]", i) prefix = fmt.Sprintf("clients[%d]", i)
errs = append(errs, prefix+": clientId must not be empty") 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") hasAuthorizationCode := contains(c.GrantTypes, "authorization_code")
hasClientCredentials := contains(c.GrantTypes, "client_credentials") hasClientCredentials := contains(c.GrantTypes, "client_credentials")
if (hasAuthorizationCode || !hasClientCredentials) && len(c.RedirectURIs) == 0 { if (hasAuthorizationCode || !hasClientCredentials) && len(c.RedirectURIs) == 0 {

View file

@ -50,6 +50,7 @@ type Client struct {
ClientType string `yaml:"clientType" json:"clientType"` // "confidential" | "public" ClientType string `yaml:"clientType" json:"clientType"` // "confidential" | "public"
SecretRef string `yaml:"secretRef,omitempty" json:"secretRef,omitempty"` SecretRef string `yaml:"secretRef,omitempty" json:"secretRef,omitempty"`
ClientSecret string `yaml:"-" json:"-"` ClientSecret string `yaml:"-" json:"-"`
Audience string `yaml:"audience,omitempty" json:"audience,omitempty"`
ServiceSubject string `yaml:"serviceSubject,omitempty" json:"serviceSubject,omitempty"` ServiceSubject string `yaml:"serviceSubject,omitempty" json:"serviceSubject,omitempty"`
Tenant string `yaml:"tenant,omitempty" json:"tenant,omitempty"` Tenant string `yaml:"tenant,omitempty" json:"tenant,omitempty"`
Roles []string `yaml:"roles,omitempty" json:"roles,omitempty"` Roles []string `yaml:"roles,omitempty" json:"roles,omitempty"`

View file

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

View file

@ -190,6 +190,13 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
return 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. // 5. Validate code_challenge is present.
if codeChallenge == "" { if codeChallenge == "" {
profileerrors.InvalidProfileUsage( profileerrors.InvalidProfileUsage(

View file

@ -94,7 +94,7 @@ func testClient() map[string]*domain.Client {
ClientID: "test-client", ClientID: "test-client",
DisplayName: "Test Client", DisplayName: "Test Client",
RedirectURIs: []string{"https://app.example.com/callback"}, RedirectURIs: []string{"https://app.example.com/callback"},
AllowedScopes: []string{"openid", "profile", "email"}, AllowedScopes: []string{"openid", "profile", "email", "groups"},
ClientType: "public", ClientType: "public",
}, },
"netkingdom-bootstrap-console": { "netkingdom-bootstrap-console": {

View file

@ -93,6 +93,14 @@ func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return 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. // 4. Verify PKCE code_verifier.
if !verifyPKCE(codeVerifier, sess.PKCEChallenge) { if !verifyPKCE(codeVerifier, sess.PKCEChallenge) {
profileerrors.InvalidProfileUsage( profileerrors.InvalidProfileUsage(
@ -169,12 +177,24 @@ func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return 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). // 8. Delete used PKCE session (prevent replay).
h.Sessions.Delete(code) h.Sessions.Delete(code)
// 9. Build response. // 9. Build response.
resp := tokenResponse{ resp := tokenResponse{
AccessToken: jwtToken, AccessToken: accessToken,
TokenType: "Bearer", TokenType: "Bearer",
ExpiresIn: int(h.TokenLifetime.Seconds()), ExpiresIn: int(h.TokenLifetime.Seconds()),
IDToken: jwtToken, IDToken: jwtToken,
@ -239,7 +259,7 @@ func (h *TokenHandler) serveClientCredentials(w http.ResponseWriter, r *http.Req
claims := map[string]interface{}{ claims := map[string]interface{}{
"iss": h.Issuer, "iss": h.Issuer,
"sub": client.ServiceSubject, "sub": client.ServiceSubject,
"aud": clientID, "aud": accessAudience(client),
"exp": now.Add(tokenLifetime).Unix(), "exp": now.Add(tokenLifetime).Unix(),
"iat": now.Unix(), "iat": now.Unix(),
"tenant": client.Tenant, "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 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
}

View file

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

View file

@ -4,7 +4,7 @@ type: workplan
title: "KeyCape Implementation — Lightweight IAM Profile" title: "KeyCape Implementation — Lightweight IAM Profile"
domain: infotech domain: infotech
repo: key-cape repo: key-cape
status: done status: finished
owner: Bernd owner: Bernd
topic_slug: netkingdom topic_slug: netkingdom
topic_id: a6c6e745-bf54-4465-9340-1534a2be493e topic_id: a6c6e745-bf54-4465-9340-1534a2be493e

View file

@ -4,7 +4,7 @@ type: workplan
title: "KeyCape Container Image — Build & Publish to Gitea OCI Registry" title: "KeyCape Container Image — Build & Publish to Gitea OCI Registry"
domain: infotech domain: infotech
repo: key-cape repo: key-cape
status: done status: finished
owner: netkingdom owner: netkingdom
topic_slug: netkingdom topic_slug: netkingdom
created: "2026-03-22" created: "2026-03-22"

View file

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

View file

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