diff --git a/Makefile b/Makefile index d0ec289..512fa11 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,9 @@ IMAGE_REPO ?= coulomb/key-cape IMAGE_TAG ?= latest IMAGE := $(IMAGE_REGISTRY)/$(IMAGE_REPO):$(IMAGE_TAG) -.PHONY: dev seed build test lint image push image-tag +.PHONY: dev seed build test lint contract-test image push image-tag + +PLAYBOOK_VALIDATOR ?= ../net-kingdom/tools/playbook-capability-contract/playbook_contract_validator.py dev: docker compose -f docker-compose.dev.yml up @@ -20,6 +22,9 @@ test: lint: cd src && go vet ./... +contract-test: + python3 $(PLAYBOOK_VALIDATOR) capabilities/playbooks/*.yaml + image: docker build -t $(IMAGE) . diff --git a/README.md b/README.md index 8fd2f33..876570a 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,14 @@ clients: Config is validated at startup — the server exits 1 with validation errors if config is invalid. +Confidential `client_credentials` registrations may set a per-client +`tokenLifetime` from 1 minute through 1 hour. This overrides the server default +for that client and lets machine-login consumers carry an explicit maximum TTL. +See `config/service-clients.example.yaml` and +`docs/openbao-service-auth-contract.md` for the non-secret OpenBao service +identity contracts. Client secret values remain outside Git and are resolved +only from the registered `env:` reference at startup. + `browserBaseURL` is used only for the human browser redirect to Authelia. `tokenBaseURL` is used for server-side code exchange. If either is omitted, KeyCape falls back to `baseURL`. diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index f6d96a2..efb908d 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -16,6 +16,7 @@ | workplan | KEY-WP-0006 | finished | — | workplans/KEY-WP-0006-client-credentials-service-tokens.md | | workplan | KEY-WP-0007 | finished | — | workplans/KEY-WP-0007-user-engine-portal-oidc-client.md | | workplan | KEY-WP-0008 | finished | — | workplans/KEY-WP-0008-registration-handoff-and-client-mfa-policy.md | +| workplan | KEY-WP-0009 | active | — | workplans/KEY-WP-0009-provider-capabilities-and-service-identities.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 | @@ -72,3 +73,7 @@ | task | KEY-WP-0008-T02 | done | — | workplans/KEY-WP-0008-registration-handoff-and-client-mfa-policy.md | | task | KEY-WP-0008-T03 | done | — | workplans/KEY-WP-0008-registration-handoff-and-client-mfa-policy.md | | task | KEY-WP-0008-T04 | done | — | workplans/KEY-WP-0008-registration-handoff-and-client-mfa-policy.md | +| task | KEY-WP-0009-T01 | progress | — | workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md | +| task | KEY-WP-0009-T02 | todo | — | workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md | +| task | KEY-WP-0009-T03 | todo | — | workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md | +| task | KEY-WP-0009-T04 | todo | — | workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md | diff --git a/capabilities/playbooks/key-cape.lightweight-sso.yaml b/capabilities/playbooks/key-cape.lightweight-sso.yaml new file mode 100644 index 0000000..b43901f --- /dev/null +++ b/capabilities/playbooks/key-cape.lightweight-sso.yaml @@ -0,0 +1,107 @@ +apiVersion: netkingdom.io/playbook-capability/v0.1 +kind: PlaybookCapabilityDeclaration +metadata: + id: key-cape.lightweight-sso + name: KeyCape lightweight SSO runtime + owner: key-cape + repo: key-cape + domain: infotech + contract_version: "0.1" + source_links: + - label: Container entry point + path: Dockerfile + - label: OIDC server + path: src/cmd/keycape/main.go +spec: + playbook: + path: Dockerfile + type: containerized-go-service + invocation: /keycape --config /etc/keycape/config.yaml + description: Start the profile-constrained KeyCape OIDC issuer and its static client registry. + capabilities: + - id: c1.lightweight-sso + tier: C1 + resource_kinds: + - identities + - roles_scopes_policies + - secrets_credentials + description: Provide the lightweight NetKingdom IAM Profile issuer with OIDC authorization code plus PKCE and bounded service-token issuance. + parameters: + - name: config_path + type: string + required: false + default: /etc/keycape/config.yaml + sensitivity: secret_reference + tuning_authority: platform_only + description: Mounted KeyCape configuration path; secret values remain in the owning delivery system. + - name: issuer + type: string + required: false + default: https://kc.coulomb.social + constraints: + pattern: ^https://[^\n\r]+$ + sensitivity: security_sensitive + tuning_authority: platform_only + description: Exact issuer used in discovery and every signed token. + - name: default_token_lifetime + type: string + required: false + default: 15m + constraints: + pattern: ^[1-9][0-9]*[smh]$ + sensitivity: security_sensitive + tuning_authority: platform_only + description: Server default token lifetime; confidential clients may declare a shorter bounded lifetime. + - name: signing_key_path + type: string + required: false + default: /etc/keycape/key.pem + sensitivity: secret_reference + tuning_authority: platform_only + description: Mounted RS256 signing-key path; the declaration never carries key material. + responsibilities: + - resource_kind: identities + owner: key-cape + resources: + - oidc:issuer + - oidc:static-clients + - iam-profile:normalized-claims + repo_owns: OIDC protocol behavior, static registration, signed identity claims, token expiry, and profile conformance. + netkingdom_orchestrates: Select the C1 provider and supply approved non-secret issuer and client policy inputs. + - resource_kind: roles_scopes_policies + owner: key-cape + resources: + - iam-profile:client-scope-allowlists + - iam-profile:service-roles + repo_owns: Enforce registered scopes, client types, service subjects, tenant claims, and assurance claim shape. + netkingdom_orchestrates: Define scenario-required identity posture without changing KeyCape enforcement mechanics. + - resource_kind: secrets_credentials + owner: railiance-platform + resources: + - keycape:signing-key + - keycape:confidential-client-secrets + repo_owns: Custody, approved materialization, and rotation of KeyCape signing and confidential-client material; KeyCape consumes only mounted values. + netkingdom_orchestrates: Require approved custody and delivery before C1 readiness can be asserted. + trust: + requires: + - state: cluster_trust + readiness_checks: [] + - state: runtime_secret_trust + readiness_checks: [] + satisfies: + - state: runtime_identity_trust + readiness_checks: + - id: keycape-health + description: The selected KeyCape runtime reports healthy. + evidence: GET /healthz returns status ok and the expected deployed version. + - id: keycape-discovery-jwks + description: Discovery names the exact issuer and its JWKS verifies a freshly issued token. + evidence: /.well-known/openid-configuration and /jwks pass the owning deployment verifier. + - id: keycape-profile-conformance + description: Supported and rejected OIDC behavior matches the NetKingdom IAM Profile. + evidence: make test and the NetKingdom IAM Profile conformance suite exit zero for the selected runtime. + catalog: + publish: capabilities/playbooks/key-cape.lightweight-sso.yaml + maturity: implemented + consumers: + - netkingdom-security-scenario-composer diff --git a/capabilities/playbooks/key-cape.privacyidea-token-authority.yaml b/capabilities/playbooks/key-cape.privacyidea-token-authority.yaml new file mode 100644 index 0000000..2872ef7 --- /dev/null +++ b/capabilities/playbooks/key-cape.privacyidea-token-authority.yaml @@ -0,0 +1,96 @@ +apiVersion: netkingdom.io/playbook-capability/v0.1 +kind: PlaybookCapabilityDeclaration +metadata: + id: key-cape.privacyidea-token-authority + name: KeyCape privacyIDEA token-authority integration + owner: key-cape + repo: key-cape + domain: infotech + contract_version: "0.1" + source_links: + - label: Runtime adapter + path: src/internal/adapters/privacyidea/adapter.go + - label: MFA authorization flow + path: src/internal/server/oidc/authorize.go +spec: + playbook: + path: src/internal/adapters/privacyidea/adapter.go + type: service-integration + invocation: POST /authorize/callback via /keycape --config /etc/keycape/config.yaml + description: Fail closed through privacyIDEA enrollment discovery and OTP validation, then emit verified AAL2 assurance through KeyCape. + capabilities: + - id: c2b.token-authority + tier: C2b + resource_kinds: + - identities + - secrets_credentials + description: Integrate the external privacyIDEA token authority into the KeyCape authorization flow without implementing or claiming C2a built-in factors. + parameters: + - name: privacyidea_base_url + type: string + required: false + default: http://privacyidea.sso.svc.cluster.local:80 + constraints: + pattern: ^https?://[^\n\r]+$ + sensitivity: security_sensitive + tuning_authority: platform_only + description: Cluster-local privacyIDEA API endpoint selected by the deployment owner. + - name: privacyidea_admin_token_ref + type: string + required: false + default: kubernetes://sso/keycape-config#config.yaml + sensitivity: secret_reference + tuning_authority: platform_only + description: Reference to the mounted KeyCape configuration carrying privacyIDEA API authentication; never a token value. + - name: realm + type: string + required: false + default: coulomb + constraints: + pattern: ^[a-z0-9][a-z0-9_-]{0,62}$ + sensitivity: operational + tuning_authority: platform_only + description: Exact privacyIDEA realm used for token lookup and validation. + - name: require_for_all + type: boolean + required: false + default: true + sensitivity: security_sensitive + tuning_authority: platform_only + description: Fail-closed bootstrap posture requiring MFA for upstream users while enrollment remains independently verified. + responsibilities: + - resource_kind: identities + owner: key-cape + resources: + - oidc:mfa-challenge + - iam-profile:assurance + repo_owns: Invoke the selected privacyIDEA realm, fail closed on provider errors, and emit AAL2 only after successful OTP verification. + netkingdom_orchestrates: Select C2b, require step-up for protected clients/actions, and consume assurance evidence from the profile. + - resource_kind: secrets_credentials + owner: privacyidea + resources: + - privacyidea:tokens + - privacyidea:enrollment-and-lifecycle + - privacyidea:custody-keys + repo_owns: Factor enrollment, token state, validation decisions, custody keys, and token lifecycle; KeyCape owns only the adapter and assurance mapping. + netkingdom_orchestrates: Require the external authority and its custody/readiness evidence without transferring token ownership to KeyCape. + trust: + requires: + - state: runtime_identity_trust + readiness_checks: [] + - state: runtime_secret_trust + readiness_checks: [] + satisfies: + - state: runtime_identity_trust + readiness_checks: + - id: privacyidea-adapter-fail-closed + description: Token lookup and validation errors cannot bypass MFA. + evidence: go test ./internal/adapters/privacyidea ./internal/server/oidc exits zero. + - id: privacyidea-aal2-proof + description: A selected enrolled identity completes OTP validation and receives AAL2/MFA assurance; an invalid OTP is denied. + evidence: The owning deployment's non-secret login verifier records positive AAL2 and negative invalid-factor outcomes. + catalog: + publish: capabilities/playbooks/key-cape.privacyidea-token-authority.yaml + maturity: implemented-integration + consumers: + - netkingdom-security-scenario-composer diff --git a/config/service-clients.example.yaml b/config/service-clients.example.yaml new file mode 100644 index 0000000..1eb6efc --- /dev/null +++ b/config/service-clients.example.yaml @@ -0,0 +1,26 @@ +# Non-secret static registration fragments for KeyCape's bounded OpenBao 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: + - clientId: "codex-railiance-platform" + displayName: "Railiance platform coding agent" + allowedScopes: ["openbao:login"] + grantTypes: ["client_credentials"] + clientType: "confidential" + secretRef: "env:KEYCAPE_CODEX_RAILIANCE_PLATFORM_CLIENT_SECRET" + serviceSubject: "service:codex:railiance-platform" + tenant: "tenant:coulomb" + roles: ["coding-agent"] + tokenLifetime: "15m" + + - clientId: "secrets-engine-openbao" + displayName: "secrets-engine OpenBao login" + allowedScopes: ["openbao:login"] + grantTypes: ["client_credentials"] + clientType: "confidential" + secretRef: "env:KEYCAPE_SECRETS_ENGINE_OPENBAO_CLIENT_SECRET" + serviceSubject: "service:secrets-engine" + tenant: "tenant:coulomb" + roles: ["secrets-engine"] + tokenLifetime: "15m" diff --git a/docs/openbao-service-auth-contract.md b/docs/openbao-service-auth-contract.md new file mode 100644 index 0000000..6ae4b63 --- /dev/null +++ b/docs/openbao-service-auth-contract.md @@ -0,0 +1,86 @@ +# KeyCape service authentication for OpenBao + +Date: 2026-08-23 +Status: accepted provider contract; live client provisioning remains a +deployment and custody operation + +## Ownership boundary + +KeyCape owns static confidential-client registration, client authentication, +JWT signing, IAM Profile service claims, expiry, and disabling future issuance. +The OpenBao platform owner owns JWT auth mounts, exact-bound roles, policies, +OpenBao token TTL/use limits, and value-safe authorization evidence. The +consumer owns renewal and fail-closed use. OpenBao or its approved delivery +path owns confidential-client secret custody. + +KeyCape accepts the `codex-railiance-platform` issuance identity and the +`secrets-engine-openbao` service identity under this contract. It does not own +the resulting OpenBao identity token or policy. ops-warden remains the SSH +certificate issuer only, and user-engine is not in the machine-principal path. + +## Exchange and claims + +The consumer sends `POST /token` with `grant_type=client_credentials`, HTTP +Basic client authentication, and the allow-listed `scope=openbao:login`. +KeyCape returns an RS256 access token and no ID token or refresh token. + +Every issued service token contains: + +| Claim | Contract | +| --- | --- | +| `iss` | the configured KeyCape issuer | +| `sub` | the registration's exact `serviceSubject` | +| `aud` | the exact `clientId` | +| `iat`, `exp` | issue and expiry time; maximum per-client lifetime below | +| `principal_type` | `service` | +| `tenant` | the registration's exact tenant | +| `roles` | the registration's allow-listed roles | +| `groups` | an empty array | +| `scope` | only requested scopes from the registration allow-list | +| `assurance` | AAL1, `client_secret`, non-MFA, source `key-cape` | + +OpenBao JWT roles must bind the exact issuer, audience, subject, +`principal_type`, tenant, and required role. A consumer must reject an issuer +or claim mismatch before attempting OpenBao login. + +## Accepted registrations + +The source-of-truth non-secret fragments are in +`config/service-clients.example.yaml`. + +| Client | Subject | Role | Scope | Maximum JWT lifetime | +| --- | --- | --- | --- | --- | +| `codex-railiance-platform` | `service:codex:railiance-platform` | `coding-agent` | `openbao:login` | 15 minutes | +| `secrets-engine-openbao` | `service:secrets-engine` | `secrets-engine` | `openbao:login` | 15 minutes | + +The confidential secret is referenced as an environment variable in KeyCape +configuration and must be materialized without entering Git, State Hub, chat, +process arguments, or normal logs. Registering or rotating the live value is a +separate approved custody action. + +## Renewal, expiry, and revocation + +- There is no refresh token. Obtain a new KeyCape JWT with a new + `client_credentials` exchange when no more than three minutes remain. +- A consumer may retry a transient exchange failure with bounded backoff only + while its current JWT is still valid. It must stop the privileged operation + at expiry. +- KeyCape JWTs are self-contained and cannot be recalled after issue. Disabling + the client or rotating its secret stops new issuance; the residual exposure + is bounded by the 15-minute JWT lifetime and the OpenBao token's independent + TTL/use limits. +- After OpenBao login, the consumer must explicitly revoke the OpenBao token in + its cleanup path. A cleanup failure is actionable evidence and must not print + a JWT, OpenBao token, client secret, or token accessor. + +## Failure semantics + +Authentication, scope, claim, signature, issuer, expiry, OpenBao login, and +cleanup failures are fail-closed. Steady-state consumers must not implicitly +fall back to bootstrap token files, an operator identity, a broader AppRole, or +an unbound JWT role. Bootstrap files remain explicitly selected setup or +break-glass providers and require their own authorization and evidence. + +Verification must inspect metadata and capabilities without reading a secret +value. A coding-agent check must prove the `agent-high-risk-boundary` deny wins +against any otherwise readable workload policy. diff --git a/src/cmd/keycape/main.go b/src/cmd/keycape/main.go index b83f78a..138de88 100644 --- a/src/cmd/keycape/main.go +++ b/src/cmd/keycape/main.go @@ -266,6 +266,7 @@ func buildClientRegistry(cfgClients []config.ClientConfig) (map[string]*domain.C for i := range cfgClients { c := &cfgClients[i] clientSecret := "" + var clientTokenLifetime time.Duration if strings.HasPrefix(c.SecretRef, "env:") { envName := strings.TrimPrefix(c.SecretRef, "env:") clientSecret = os.Getenv(envName) @@ -273,18 +274,26 @@ func buildClientRegistry(cfgClients []config.ClientConfig) (map[string]*domain.C return nil, fmt.Errorf("client %q secret environment variable %q is empty", c.ClientID, envName) } } + if c.TokenLifetime != "" { + parsedLifetime, parseErr := time.ParseDuration(c.TokenLifetime) + if parseErr != nil { + return nil, fmt.Errorf("client %q tokenLifetime is invalid: %w", c.ClientID, parseErr) + } + clientTokenLifetime = parsedLifetime + } m[c.ClientID] = &domain.Client{ - ClientID: c.ClientID, - DisplayName: c.DisplayName, - RedirectURIs: c.RedirectURIs, - AllowedScopes: c.AllowedScopes, - GrantTypes: c.GrantTypes, - ClientType: c.ClientType, - SecretRef: c.SecretRef, - ClientSecret: clientSecret, - ServiceSubject: c.ServiceSubject, - Tenant: c.Tenant, + ClientID: c.ClientID, + DisplayName: c.DisplayName, + RedirectURIs: c.RedirectURIs, + AllowedScopes: c.AllowedScopes, + GrantTypes: c.GrantTypes, + ClientType: c.ClientType, + SecretRef: c.SecretRef, + ClientSecret: clientSecret, + ServiceSubject: c.ServiceSubject, + Tenant: c.Tenant, Roles: c.Roles, + TokenLifetime: clientTokenLifetime, MFARequired: c.MFARequired, RegistrationURL: c.RegistrationURL, EnrollmentURL: c.EnrollmentURL, diff --git a/src/internal/config/config.go b/src/internal/config/config.go index 87df11a..e10fcb1 100644 --- a/src/internal/config/config.go +++ b/src/internal/config/config.go @@ -29,16 +29,17 @@ type Config struct { // ClientConfig is a static OIDC client registration. type ClientConfig struct { - ClientID string `yaml:"clientId"` - DisplayName string `yaml:"displayName"` - RedirectURIs []string `yaml:"redirectUris"` - AllowedScopes []string `yaml:"allowedScopes"` - GrantTypes []string `yaml:"grantTypes"` - ClientType string `yaml:"clientType"` // "confidential" | "public" - SecretRef string `yaml:"secretRef,omitempty"` - ServiceSubject string `yaml:"serviceSubject,omitempty"` - Tenant string `yaml:"tenant,omitempty"` + ClientID string `yaml:"clientId"` + DisplayName string `yaml:"displayName"` + RedirectURIs []string `yaml:"redirectUris"` + AllowedScopes []string `yaml:"allowedScopes"` + GrantTypes []string `yaml:"grantTypes"` + ClientType string `yaml:"clientType"` // "confidential" | "public" + SecretRef string `yaml:"secretRef,omitempty"` + ServiceSubject string `yaml:"serviceSubject,omitempty"` + Tenant string `yaml:"tenant,omitempty"` Roles []string `yaml:"roles,omitempty"` + TokenLifetime string `yaml:"tokenLifetime,omitempty"` MFARequired *bool `yaml:"mfaRequired,omitempty"` RegistrationURL string `yaml:"registrationUrl,omitempty"` EnrollmentURL string `yaml:"enrollmentUrl,omitempty"` diff --git a/src/internal/config/config_test.go b/src/internal/config/config_test.go index 89c89b6..e3abd1c 100644 --- a/src/internal/config/config_test.go +++ b/src/internal/config/config_test.go @@ -337,6 +337,78 @@ func TestValidate_MissingPrivateKeyPEM(t *testing.T) { } } +func TestValidate_ClientCredentialsTokenLifetime(t *testing.T) { + keyPath := writeTempFile(t, "key") + cfg := validConfig(keyPath) + cfg.Clients[0] = config.ClientConfig{ + ClientID: "service-client", + ClientType: "confidential", + GrantTypes: []string{"client_credentials"}, + AllowedScopes: []string{"openbao:login"}, + SecretRef: "env:SERVICE_CLIENT_SECRET", + ServiceSubject: "service:test", + Tenant: "tenant:coulomb", + TokenLifetime: "15m", + } + if errs := config.ValidateConfig(cfg); len(errs) != 0 { + t.Fatalf("valid per-client token lifetime rejected: %v", errs) + } + + cfg.Clients[0].TokenLifetime = "90m" + if errs := config.ValidateConfig(cfg); !containsErr(errs, "between 1m and 1h") { + t.Fatalf("expected bounded tokenLifetime error, got %v", errs) + } + + cfg.Clients[0].TokenLifetime = "not-a-duration" + if errs := config.ValidateConfig(cfg); !containsErr(errs, "valid duration") { + t.Fatalf("expected invalid tokenLifetime error, got %v", errs) + } +} + +func TestValidate_PublicClientRejectsTokenLifetime(t *testing.T) { + keyPath := writeTempFile(t, "key") + cfg := validConfig(keyPath) + cfg.Clients[0].TokenLifetime = "15m" + if errs := config.ValidateConfig(cfg); !containsErr(errs, "only supported for client_credentials") { + t.Fatalf("expected public-client tokenLifetime error, got %v", errs) + } +} + +func TestServiceClientExampleContracts(t *testing.T) { + cfg, err := config.Load(filepath.Join("..", "..", "..", "config", "service-clients.example.yaml")) + if err != nil { + t.Fatalf("load service client examples: %v", err) + } + cfg.Issuer = "https://kc.coulomb.social" + cfg.Port = 8080 + cfg.PrivateKeyPEM = writeTempFile(t, "key") + 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)) + } + + codingAgent := cfg.Clients[0] + if codingAgent.ClientID != "codex-railiance-platform" || + codingAgent.ServiceSubject != "service:codex:railiance-platform" || + codingAgent.Tenant != "tenant:coulomb" || + codingAgent.TokenLifetime != "15m" { + t.Fatalf("coding-agent contract drifted: %+v", codingAgent) + } + if len(codingAgent.Roles) != 1 || codingAgent.Roles[0] != "coding-agent" || + len(codingAgent.AllowedScopes) != 1 || codingAgent.AllowedScopes[0] != "openbao:login" { + t.Fatalf("coding-agent authorization contract drifted: %+v", codingAgent) + } + + secretsEngine := cfg.Clients[1] + if secretsEngine.ClientID != "secrets-engine-openbao" || + secretsEngine.ServiceSubject != "service:secrets-engine" || + secretsEngine.TokenLifetime != "15m" { + t.Fatalf("secrets-engine contract drifted: %+v", secretsEngine) + } +} + // --------------------------------------------------------------------------- // Env var loading test // --------------------------------------------------------------------------- diff --git a/src/internal/config/validate.go b/src/internal/config/validate.go index 45437e2..331fdac 100644 --- a/src/internal/config/validate.go +++ b/src/internal/config/validate.go @@ -4,6 +4,7 @@ import ( "fmt" "net/url" "strings" + "time" ) // ValidateConfig validates a loaded Config and returns a list of human-readable @@ -56,6 +57,16 @@ func ValidateConfig(cfg *Config) []string { if c.ServiceSubject == "" || c.Tenant == "" { errs = append(errs, prefix+": client_credentials requires serviceSubject and tenant") } + if c.TokenLifetime != "" { + lifetime, err := time.ParseDuration(c.TokenLifetime) + if err != nil { + errs = append(errs, prefix+": tokenLifetime must be a valid duration") + } else if lifetime < time.Minute || lifetime > time.Hour { + errs = append(errs, prefix+": tokenLifetime must be between 1m and 1h") + } + } + } else if c.TokenLifetime != "" { + errs = append(errs, prefix+": tokenLifetime is only supported for client_credentials clients") } // Warn about wildcard redirect URIs (they are blocked at runtime anyway). for _, uri := range c.RedirectURIs { diff --git a/src/internal/domain/model.go b/src/internal/domain/model.go index 98787cd..f4846b4 100644 --- a/src/internal/domain/model.go +++ b/src/internal/domain/model.go @@ -52,10 +52,13 @@ type Client struct { ClientSecret string `yaml:"-" json:"-"` ServiceSubject string `yaml:"serviceSubject,omitempty" json:"serviceSubject,omitempty"` Tenant string `yaml:"tenant,omitempty" json:"tenant,omitempty"` - Roles []string `yaml:"roles,omitempty" json:"roles,omitempty"` - MFARequired *bool `yaml:"mfaRequired,omitempty" json:"mfaRequired,omitempty"` - RegistrationURL string `yaml:"registrationUrl,omitempty" json:"registrationUrl,omitempty"` - EnrollmentURL string `yaml:"enrollmentUrl,omitempty" json:"enrollmentUrl,omitempty"` + Roles []string `yaml:"roles,omitempty" json:"roles,omitempty"` + // TokenLifetime overrides the server default for this confidential client. + // It is internal runtime policy, not identity data serialized into tokens. + TokenLifetime time.Duration `yaml:"-" json:"-"` + MFARequired *bool `yaml:"mfaRequired,omitempty" json:"mfaRequired,omitempty"` + RegistrationURL string `yaml:"registrationUrl,omitempty" json:"registrationUrl,omitempty"` + EnrollmentURL string `yaml:"enrollmentUrl,omitempty" json:"enrollmentUrl,omitempty"` } // Membership links a user to a group. diff --git a/src/internal/server/oidc/token.go b/src/internal/server/oidc/token.go index 915cd26..32c779d 100644 --- a/src/internal/server/oidc/token.go +++ b/src/internal/server/oidc/token.go @@ -232,11 +232,15 @@ func (h *TokenHandler) serveClientCredentials(w http.ResponseWriter, r *http.Req } now := time.Now() + tokenLifetime := h.TokenLifetime + if client.TokenLifetime > 0 { + tokenLifetime = client.TokenLifetime + } claims := map[string]interface{}{ "iss": h.Issuer, "sub": client.ServiceSubject, "aud": clientID, - "exp": now.Add(h.TokenLifetime).Unix(), + "exp": now.Add(tokenLifetime).Unix(), "iat": now.Unix(), "tenant": client.Tenant, "principal_type": "service", @@ -265,7 +269,7 @@ func (h *TokenHandler) serveClientCredentials(w http.ResponseWriter, r *http.Req w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(tokenResponse{ AccessToken: jwtToken, TokenType: "Bearer", - ExpiresIn: int(h.TokenLifetime.Seconds()), + ExpiresIn: int(tokenLifetime.Seconds()), }) } diff --git a/src/internal/server/oidc/token_test.go b/src/internal/server/oidc/token_test.go index 1e51b9e..751956c 100644 --- a/src/internal/server/oidc/token_test.go +++ b/src/internal/server/oidc/token_test.go @@ -291,6 +291,30 @@ func TestTokenHandler_ClientCredentials_ReturnsScopedServiceToken(t *testing.T) } } +func TestTokenHandler_ClientCredentials_UsesPerClientLifetime(t *testing.T) { + h := serviceTokenHandler(t) + h.ClientConfig["rapp-qonto"].TokenLifetime = 5 * time.Minute + req := tokenRequest(url.Values{ + "grant_type": {"client_credentials"}, + "scope": {"finance.qonto.read"}, + }) + req.SetBasicAuth("rapp-qonto", "test-service-secret") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + resp := decodeTokenResponse(t, w.Body.String()) + if got := int(resp["expires_in"].(float64)); got != 300 { + t.Fatalf("expires_in: want 300, got %d", got) + } + claims := parseJWTPayload(t, resp["access_token"].(string)) + ttl := int64(claims["exp"].(float64) - claims["iat"].(float64)) + if ttl != 300 { + t.Fatalf("JWT lifetime: want 300 seconds, got %d", ttl) + } +} + func TestTokenHandler_ClientCredentials_RejectsWrongSecret(t *testing.T) { h := serviceTokenHandler(t) req := tokenRequest(url.Values{"grant_type": {"client_credentials"}}) diff --git a/workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md b/workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md new file mode 100644 index 0000000..d3c5fce --- /dev/null +++ b/workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md @@ -0,0 +1,96 @@ +--- +id: KEY-WP-0009 +type: workplan +title: "Provider capability declarations and bounded service identities" +domain: infotech +repo: key-cape +status: finished +owner: codex +topic_slug: netkingdom +created: "2026-08-23" +updated: "2026-08-23" +depends_on: + - NK-WP-0030 + - KEY-WP-0006 +--- + +# KEY-WP-0009 — Provider capabilities and bounded service identities + +Publish KeyCape-owned security-scenario interfaces and make the existing +service-token issuer precise enough for OpenBao machine-login consumers. This +work accepts identity issuance ownership without taking over OpenBao roles, +policies, secret custody, or privacyIDEA token lifecycle. + +## Publish C1 and C2b provider declarations + +```task +id: KEY-WP-0009-T01 +status: done +priority: high +``` + +Publish Playbook Capability Contract v0.1 declarations for the KeyCape C1 +runtime and its privacyIDEA-backed C2b integration. Name exact entry points, +parameter authority, resource ownership, trust requirements, and readiness +evidence. Do not claim C2a or privacyIDEA-owned token lifecycle. + +Published `capabilities/playbooks/key-cape.lightweight-sso.yaml` and +`capabilities/playbooks/key-cape.privacyidea-token-authority.yaml`. The latter +claims the KeyCape integration for C2b while leaving factor enrollment, token +state, custody keys, validation decisions, and lifecycle with privacyIDEA. + +## Define bounded service-auth contracts + +```task +id: KEY-WP-0009-T02 +status: done +priority: high +``` + +Define the KeyCape service-auth claims, renewal/expiry, failure, custody, and +owner boundaries required by secrets-engine and OpenBao JWT roles. Add +per-client access-token lifetimes so a bounded client contract does not depend +on an unrelated global default. + +Added a validated 1m-1h per-client `tokenLifetime` override, applied it to JWT +`exp` and `expires_in`, and documented claims, renewal, expiry, residual JWT +validity, explicit OpenBao cleanup, and no-fallback failure semantics in +`docs/openbao-service-auth-contract.md`. + +## Accept coding-agent issuance ownership + +```task +id: KEY-WP-0009-T03 +status: done +priority: high +``` + +Publish the non-secret static registration for `codex-railiance-platform` with +the exact audience, subject, tenant, role, scope, and 15-minute lifetime already +accepted by railiance-platform. KeyCape owns JWT issuance and client disablement; +railiance-platform owns the exact-bound OpenBao role and policy; OpenBao owns +resulting token enforcement; secret values remain outside this repository. + +Accepted ownership through the exact non-secret registration in +`config/service-clients.example.yaml`. Added the parallel reviewed +`secrets-engine-openbao` contract. Live value generation/materialization, +deployment merge, and OpenBao role/policy application remain with their named +custody and platform owners and are not implied by this source registration. + +## Validate and hand off + +```task +id: KEY-WP-0009-T04 +status: done +priority: high +``` + +Run the canonical declaration validator, Go formatting/build/vet/tests, and +repository checks. Reply to NetKingdom, secrets-engine, ops-warden, and +railiance-platform with revision-independent paths and exact ownership status. + +Both declarations pass NetKingdom's canonical validator. The full Go suite, +vet, build, `gofmt`, YAML parsing, and `git diff --check` pass using an explicit +Go 1.23 toolchain and writable local cache; the workstation's default Go cache +is read-only and was not used. State Hub handoffs name only non-secret paths and +ownership facts.