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
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:
parent
b8dda4115a
commit
403904b901
22 changed files with 449 additions and 35 deletions
|
|
@ -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)
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -174,3 +174,6 @@ cython_debug/
|
|||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
|
||||
# Generated workstation repository index
|
||||
.repo-manager/
|
||||
|
|
|
|||
24
INTENT.md
24
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
31
docs/approval-engine-auth-contract.md
Normal file
31
docs/approval-engine-auth-contract.md
Normal 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.
|
||||
|
|
@ -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"
|
||||
```
|
||||
|
|
|
|||
38
src/cmd/keycape/clients_test.go
Normal file
38
src/cmd/keycape/clients_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
134
src/internal/server/oidc/audience_test.go
Normal file
134
src/internal/server/oidc/audience_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
27
workplans/ADHOC-2026-09-05.md
Normal file
27
workplans/ADHOC-2026-09-05.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
47
workplans/KEY-WP-0013-approval-engine-resource-audience.md
Normal file
47
workplans/KEY-WP-0013-approval-engine-resource-audience.md
Normal 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.
|
||||
45
workplans/KEY-WP-0014-native-credential-lane-handoff.md
Normal file
45
workplans/KEY-WP-0014-native-credential-lane-handoff.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue