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
|
# 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
3
.gitignore
vendored
|
|
@ -174,3 +174,6 @@ cython_debug/
|
||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
|
|
||||||
|
# Generated workstation repository index
|
||||||
|
.repo-manager/
|
||||||
|
|
|
||||||
24
INTENT.md
24
INTENT.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
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
|
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"
|
||||||
```
|
```
|
||||||
|
|
|
||||||
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,
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
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
|
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(
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
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"
|
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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
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