Verify issuer-owned current token status and scoped revocation source
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Reject generated-only commits / generated-commit-guard (push) Successful in 10s

This commit is contained in:
codex 2026-10-18 10:21:40 +02:00
parent 83078392c0
commit 90e29dccfc
77 changed files with 16241 additions and 0 deletions

View file

@ -0,0 +1,227 @@
{
"objective": "Finish what you found missing and finish CUST-WP-0080",
"status": "active",
"observed_at": "2026-10-18T01:51:53.783602+00:00",
"full_scope_baseline": {
"path": "docs/changes/netkingdom-target-2026-10-18/cust80-completion-r1/completion-audit.json",
"sha256": "sha256:9bdee9a790db598956fe35d5954137bb4582c9f3810d2392f7ef3236c369b065",
"all_requirements_retained": true
},
"normative_architecture": {
"path": "docs/netkingdom-target-security-architecture.md",
"sha256": "ec2079be18c7cdb8a1e4fdbd22131c08c83721d7a1bf18b4802bc5e229520f35"
},
"acceptance_gates": [
{
"id": "TSA-01",
"required_outcome": "Every inventoried privileged PEP authenticates its decision",
"required_evidence": "PEP denominator; admitted signer/channel; forged, altered, wrong-key/pin, unsigned/downgrade, expired and rotation refusals. Start SECRETS-WP-0014.",
"status": "incomplete",
"completion_evidence": []
},
{
"id": "TSA-02",
"required_outcome": "Exact approval produces one bounded protected operation",
"required_evidence": "SECRETS-WP-0013/RPF-WP-0035-T12 native effect; Approval T01/T05 issuer/tenant provenance; wrong actor/tenant/body, expiry, replay and unavailable refusal.",
"status": "incomplete",
"completion_evidence": []
},
{
"id": "TSA-03",
"required_outcome": "Crashes preserve uncertainty and cleanup",
"required_evidence": "Actual ledger restart/concurrency, consumed-before-effect failure, target reconciliation, scoped cleanup and revocation evidence; no blind replay.",
"status": "incomplete",
"completion_evidence": []
},
{
"id": "TSA-04",
"required_outcome": "First reusable browser case is accepted",
"required_evidence": "NK-WP-0048 and named Mason/Warden/KeyCape adapters; independent native structure, sign-in/denial, delivery, revocation and rollback.",
"status": "incomplete",
"completion_evidence": []
},
{
"id": "TSA-05",
"required_outcome": "Human and browser consumers can use the delivered identity",
"required_evidence": "KEY-WP-0040/U08 real-factor recovery; User Engine journey; NK-WP-0050 and CUST-WP-0080-T05 human/agent Playwright acceptance.",
"status": "incomplete",
"completion_evidence": []
},
{
"id": "TSA-06",
"required_outcome": "Actual agent and runner authority is bounded",
"required_evidence": "CUST-WP-0073/GLAS-WP-0012 admitted runtime read-denial/tool-effect proof; Forge/Platform per-job credential and repository-workflow abuse tests.",
"status": "incomplete",
"completion_evidence": []
},
{
"id": "TSA-07",
"required_outcome": "Credential lifecycle is inventoried and simplified",
"required_evidence": "Current owner/consumer denominator, unknown/orphan findings, native-identity elimination candidates; sampled replacement/adoption/old-refusal and deletion semantics. G04/G09/G12.",
"status": "incomplete",
"completion_evidence": []
},
{
"id": "TSA-08",
"required_outcome": "Recovery is independent and measured",
"required_evidence": "NK-WP-0049 plus platform/root owners; isolated recovery with ordinary issuer unavailable, restore/reconnect/audit continuity and measured RTO/RPO. G08/G11.",
"status": "incomplete",
"completion_evidence": []
},
{
"id": "TSA-09",
"required_outcome": "Security effects have independently reconciled evidence",
"required_evidence": "Admitted source/class inventory, atomic outbox, receiver readback, lag/duplicate/boundary accounting and missing-class findings. Approval/Audit/Tenant/Secrets owners.",
"status": "incomplete",
"completion_evidence": []
},
{
"id": "TSA-10",
"required_outcome": "Source and delivery controls cover declared entry paths",
"required_evidence": "FORGE-WP-0006/RMGR-WP-0021: denominator, SSH/HTTP/UI/new repo/import/mirror coverage, required checks/provenance, private synthetic finding through resolution.",
"status": "incomplete",
"completion_evidence": []
},
{
"id": "TSA-11",
"required_outcome": "Discovery remains accurate",
"required_evidence": "All reviewed IDs exactly once, owner/index parity, usable source links, current limits/version aliases, drift checks and package-family disposition. Reuse/Repo Manager/provider owners.",
"status": "incomplete",
"completion_evidence": []
},
{
"id": "TSA-12",
"required_outcome": "Improvement operates without repeated manual polling",
"required_evidence": "CUST-WP-0079/Activity accepted schedule, unassisted firing, checkpoint and changed-state notifications; adopted cadence runtime/receiver receipts; owner change and native remeasurement.",
"status": "incomplete",
"completion_evidence": []
},
{
"id": "TSA-13",
"required_outcome": "Shared adapters reduce per-consumer implementation",
"required_evidence": "Producer-format cross-language fixtures, two real consumers, version compatibility, denied-path parity, upgrades and rollback; no second PDP.",
"status": "incomplete",
"completion_evidence": []
},
{
"id": "TSA-14",
"required_outcome": "Minimal profiles are independently reusable",
"required_evidence": "Version/provider lock, dependency and optional-provider declaration, provider adapters, second clean installation, complete positive/negative journeys and upgrade/recovery instructions.",
"status": "incomplete",
"completion_evidence": []
},
{
"id": "TSA-15",
"required_outcome": "Optimization improves measured operations",
"required_evidence": "Baseline and repeat measurements of onboarding time/touches, p50/p95 decision/effect latency, scanner throughput/failure, resources/headroom and owner effort with unchanged control coverage.",
"status": "incomplete",
"completion_evidence": []
},
{
"id": "TSA-16",
"required_outcome": "Governance and exceptions remain truthful",
"required_evidence": "Existing SEC-DSM ledger and close-down controls reconciled to exact evidence; real authority for changes, explicit residual promotions, preserved deferrals and no invented owner assent.",
"status": "incomplete",
"completion_evidence": []
}
],
"sec_dsm_requirements": {
"path": "docs/evidence/2026-10-12-netkingdom-security/sec-dsm-requirement-ledger.json",
"sha256": "sha256:ad81f7186ada04aa1cc410932fc8669d69dc2232f3c0be00657b17f3aa3a8184",
"count": 116,
"status_counts": {
"unverified": 84,
"incomplete": 12,
"reference": 20
},
"closure_status_unchanged": true
},
"gap_ids": [
"G01",
"G02",
"G03",
"G04",
"G05",
"G06",
"G07",
"G08",
"G09",
"G10",
"G11",
"G12",
"G13"
],
"separate_required_deliverables": [
"G01-G13 current closure evidence or already applicable approved exceptions",
"SEC-DSM requirement ledger reconciled with exact evidence",
"T05 real human and agent Playwright acceptance and supported discovery",
"Second real consumer and clean installation proves reuse",
"Residual handoffs before workplan finish; no required work left only in prose"
],
"source_progress": {
"packet": "docs/changes/netkingdom-target-2026-10-18/forgejo-postgres-source-r1",
"producer_source_commit": "git:ca0cb75729a63c9288b7333382d0fa3eb1e8c9a3",
"consumer_owner_commit": "git:308e83fcfbf9ec98dbe6b0286cbe8ec7be8899b3",
"consumer_paths": 40,
"patch_sha256": "sha256:feb1079399a43989742ae0a946c1ada50113d1821e6541f97f0b5592ee1fc20c",
"postgreSQL_test_subtest_passes": 37,
"database_schema_profiles": [
"default",
"explicit-public",
"named"
],
"separate_client_crash_recovery": true,
"actual_database_server_restart_recovery": true,
"controlled_missing_lock_and_private_marker_counterexamples": true,
"following_query_hook_context_panic_corrected": true,
"selected_composed_passes_including_subtests": 94,
"source_skips": 0,
"release": {
"frontend_and_static_backend": "passed",
"release_version": "11.0.3+cust80.feb10793",
"binary_sha256": "sha256:92efb26614d3fca22c400920d92c4cd022e32ab86c1f562f60699ab5f750cd28",
"embedded_assets_exact": 2234,
"actual_release_CLI_checks": 48,
"exact_local_OCI_exports": 2,
"manifest": "sha256:9d127b0a75d3d53b1598e7f380dce29f23f42c2e69056fdfc9a42122d98c41c7",
"native_admission": false
},
"independent_owner_assent": false,
"production_database": false,
"native_gates_closed": 0
},
"current_work": "Independent issuer/profile review and admission of the issuer-owned online token-status extension; current authenticated operation/inverse and exact effect/approval/cleanup integration in existing NK48/Apps owners; exclusion of every release writer; private journal custody/retention; ongoing HTTP/API/Git/SSH decisions; all sixteen gates, G01-G13 and 116 ledger entries retained.",
"workplan_finished": false,
"goal_complete": false,
"preceding_audit": {
"path": "docs/changes/netkingdom-target-2026-10-18/cust80-completion-r3/completion-audit.json",
"sha256": "sha256:6df13fcbda73baed5333f6344208939676cbee6fa9482a3e6de908abd17aa070"
},
"issuer_source_progress": {
"packet": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1",
"base_revision": "git:d803e9282a47b49daf930fb905312fa5104c0ace",
"scope": "proposed opt-in extension in the existing issuer",
"source_delta_paths": 18,
"complete_race_passes_including_subtests": 659,
"existing_native_external_skips": 6,
"new_token_status_passes_including_subtests": 43,
"actual_built_server_process_checks": 26,
"actual_process_restart": true,
"actual_handler_HTTPS_PKCE_status_revocation": true,
"controlled_revocation_guard_omission_failed_checks": 7,
"independent_owner_assent": false,
"native_admission": false,
"gates_closed": 0,
"owner_identifier_reconciliation": "existing exact historical reference restored using official consistency injection helper; new canonical records assigned by normal Repo Manager; no replacement existing UUID",
"consumer_owner_commit": "git:67a0869c1ee40c9e00a60cb7c1d828734d5d3d32",
"owner_primary_projection_verified": true,
"owner_workplan": "KEY-WP-0042",
"owner_workplan_status": "proposed",
"owner_tasks": {
"T01": "progress",
"T02": "done",
"T03": "todo"
},
"source_task_complete_only": true
}
}

View file

@ -0,0 +1,301 @@
{
"exit_code": 1,
"commit": "83078392c076acd1e30425efd81115910f97f006",
"base": "83078392c076acd1e30425efd81115910f97f006",
"files": [
{
"path": "docs/changes/netkingdom-target-2026-10-18/cust80-completion-r4/completion-audit.json",
"sha256": "8a4e3e61f3639a31d9d5df7803d3cb420eb391b191894e209d62b9e3100d5127"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/README.md",
"sha256": "48a160d8000c3473a574f6ae8572a736584d97c3369e557dc73a95d245dfd7d2"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/apply_owner.py",
"sha256": "82f027e306b0217c5260eabd426161702732c18cb108e80874dececf5d5b373b"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/baseline.json",
"sha256": "fd1bb4074032d34706297723b499ff103be02912d9a08957521eb28fd0ae8114"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/binary-attempt1.json",
"sha256": "a1feec82fe8cc84c60e545071143acc354dbd5612e823a23884af3c9ff83d858"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/binary-attempt1.jsonl",
"sha256": "8047cd4f4fbd2e1ef04112f119ac2d80a75673a9c78fdbabb36a0ef876e94bef"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/binary-fixture-receipt.json",
"sha256": "61d2243797160e73e2a9fffe7747857dc84694c7653920c3c505eff4123432a9"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/binary_fixture.py",
"sha256": "c50b7812a2145de26637ed308d8994283fa96da38b1df767e4c703afa1a09d5c"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/build-attempt1.json",
"sha256": "46fc022cc329c20696412a247c107cbe42a75c8d24b9662a8012ee7101c5adf2"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/build-attempt1.jsonl",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/committed-source-equivalence.json",
"sha256": "f51bd2e5e90c81d403069a0130cae1d732e3d2c6f1f1ea883b1166c98167377e"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/controlled-revocation-omission.json",
"sha256": "1134048b198312bc75e40ad256ecd68145853dc9a7db013e8f813c471c215497"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/focused-attempt1.json",
"sha256": "47e230347c930b01dae9e172b0752ab3d096dab81fb02781f8babfbce20305e2"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/focused-attempt1.jsonl",
"sha256": "a532f528ee71da2cac50a3c488fbf6d94d2d587b6240f41f335a000a5dd7b30b"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/focused-attempt2.json",
"sha256": "b2792cd084fe03ac3322fb4266caee66480b259f1b8210427c2ca76a8c5bbe09"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/focused-attempt2.jsonl",
"sha256": "014a3e1d5747b44b37b51ab2a04731813821ba305404da68d6c2edf2539a7f84"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/freeze_candidate.py",
"sha256": "4b3fdad177292a1fde2d02fd74962130f905ec31bab8f936554449606c5ce17e"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/full-attempt1.json",
"sha256": "9afdcf762abaf253e3c44ed250188923e71649f7dc58a680c11a444447e47ff0"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/full-attempt1.jsonl",
"sha256": "55754790c7bb6839ca607956f64d434bcd05af0dbd4a8570f60c4512bf5e536d"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/full-source-inventory.json",
"sha256": "31dad565976de6ba7ed447c4a69b96c86d4c2d9e11033e30d9bc15f93c3399bb"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/identifier-assignment.json",
"sha256": "f0a4b3e160951f7e49aa01c6626a52830af5740e8697a416537df5a36494d59a"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/identifier-preflight-refusal.json",
"sha256": "0867d02dbc4751c3642627ffde364a87b2a530283221d7e70eea55d44c8c0ce2"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/identifier-preview.json",
"sha256": "53b35217e4ad315aabfe9d57e04459e41a3498032a486f810ea62544c7ca1250"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/owner-application.json",
"sha256": "61d748eb7012346f7a72ed688e925d3bf0f86bf538a601e199a0e9196b63a038"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/owner-consistency/before.json",
"sha256": "b2b460e0064e040d86dff2cba55e8d496f2f298908980c03f9215fbdd52f6396"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/owner-consistency/consistency-result-summary.json",
"sha256": "e7f4aa5832078e5ede03532555b2045728885775ecf364629a3866da6e2ebb1f"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/owner-consistency/existing-binding-restoration-proposal.json",
"sha256": "8a2ef6e3e97c9143045feabf962aa61fe46f1b886ed0b854e1725f8bad7c2611"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/owner-consistency/existing-binding.json",
"sha256": "fb1abb81f5b20a2b20cdad349845e6603728e262b8dedcd784845a8740a98627"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/owner-generated-cleanup.json",
"sha256": "e624fc13d4097733a0afd87c70b3bed17ce6dc853f5719574ec33961d62be7e8"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/owner-publication/consistency.json",
"sha256": "fed55936ef8e85acb1f17ae2a014ef9021da87168777650d38b9cbb0053544a8"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/owner-publication/consistency.stderr.txt",
"sha256": "f4a6aa1609a13600a0b97cbb08bbc03b3e247b619ff37912d37f7e109ce798ba"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/owner-publication/projection.json",
"sha256": "4d57eb5ffe84416571b4303e72a56bbbe5abb7b94d164e49c94afc3a74adbbf6"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/owner-publication/projection.stderr.txt",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/owner-publication/publication-commit.json",
"sha256": "c0c30cd31ecc5b4a1fffd95db1c1a8e5581ec9c0fc29354fe02d766b5e3af40c"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/owner-publication/publication-inputs.json",
"sha256": "c6256e6c8601e5c64ac99b23799158835a059c987ff896febaacace346d9b310"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/owner-readback.json",
"sha256": "80fb941c71831045de82d7aa71ea73859503d0cb64d9bd2c335b2bc3d9b9bbb4"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/owner-source.patch",
"sha256": "e3d3bc375a8a0a49eff434483050ae338935391b5b361e64f0880ed992f4c85c"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/postimages/SCOPE.md",
"sha256": "a80e80ba2e5279ba50232e6b3f57a5bdd14866e584d2d055dfb09e98a4c24ce0"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/postimages/docs/online-token-status.md",
"sha256": "f5e41b47500b15c80b2da6cbe37a9e64bcbde1c072750934cf74464a19488925"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/postimages/docs/operations.md",
"sha256": "3659362e3c7f8720e52cc8dc7c67a6295ac26462f383c68c06c120fac6e6e472"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/postimages/src/cmd/keycape/main.go",
"sha256": "2f81ea0b0b33f38298e028d5610625e3834fdf1aa12f6f392579d6207bcf6f1d"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/postimages/src/internal/config/config.go",
"sha256": "19cd234757f8908880d28b8b40939367dfab1322e0401e4e563d56cd3af06683"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/postimages/src/internal/config/token_status_test.go",
"sha256": "b2b2ec13215c9661d79142ea698cb8b080f6e017de050bedebf3eb8d16df2ad5"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/postimages/src/internal/config/validate.go",
"sha256": "2b1766979b817802f43bdd0f999ac61452d86cdfafc1539a5d6be896b900a405"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/postimages/src/internal/server/oidc/discovery.go",
"sha256": "00e4c831a2b4d29aecf3597a09c943315b87c5834dbcd4912ba5d8d31ab28cd5"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/postimages/src/internal/server/oidc/token.go",
"sha256": "304fa78d79a5436b9174d88991c19c0834356cb5425c6ed369accb85467a402f"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/postimages/src/internal/server/oidc/token_status.go",
"sha256": "d9c22a71bb2efad7ea240f2689a98f14768ce3c1519d1d8b4e78ee7af2dc70b3"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/postimages/src/internal/server/oidc/token_status_discovery_test.go",
"sha256": "27adb5797afba46ca2b1f16018721f055f82760498088b58b993730be1fa2031"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/postimages/src/internal/server/oidc/token_status_http.go",
"sha256": "a7202d3176454f8ab42c090426e2debcb5cc2c1fc089e79746bb78e5c455af97"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/postimages/src/internal/server/oidc/token_status_internal_test.go",
"sha256": "3d3ab41987c7586bd53af8b5c36ff6667b5b2a32d59789915eb9a36b7ceeba53"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/postimages/src/internal/server/oidc/token_status_test.go",
"sha256": "e2613d44deefcbfbd0ce1db5ce2588849fb4be9b8bed33cb538171cc29f63e90"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/postimages/src/internal/server/oidc/token_status_transport_test.go",
"sha256": "3a9945f0292125e86d0014b5245a20c0475d6c518eccff0f9b936356d8e0b347"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/postimages/src/internal/server/oidc/userinfo.go",
"sha256": "f9c986f7a2c79f27a9970a296e6b493014fed77af2f22ba96c4a457d049c916f"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/postimages/workplans/ADHOC-2026-09-27.md",
"sha256": "f6220f17fc5b6e7300d0a55ac29f78f385ad89db276ccaa21693428fffa5919b"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/postimages/workplans/KEY-WP-0042-online-access-token-status.md",
"sha256": "6d7ff6dea31b251e261195fff97a2061d56579b5786629d95014f6d1cbe0f55e"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/preceding-proposal-manifest.json",
"sha256": "c1a0420b5a497b8d6d913c0dd43f61eb164b832af179d0a7f7ceb457128802a1"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/preceding-proposal.patch",
"sha256": "926f36443c36c6568ba4fb5f7db26697bcab01f8ce0beaa57ba227329f156a13"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/preimages/SCOPE.md",
"sha256": "20fbadf3e33339bca4b541cdfa33a0de00095c968d51006dc4d0a46735cc834f"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/preimages/docs/operations.md",
"sha256": "e68ec32073b7731214308ccd2fceb522d934b00f1d76e6d438e2870030497cfc"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/preimages/src/cmd/keycape/main.go",
"sha256": "ff1ee4f5d2eb86a1e4184374d5244e24704d9d5fd780e173062af07112344afd"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/preimages/src/internal/config/config.go",
"sha256": "afd465acd9b6879493b888acc6fc54b9a71e032892701a81f1072986d2c30ac7"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/preimages/src/internal/config/validate.go",
"sha256": "8189ee82e7df74a2a4a1f53e213b1be0a5c737fd76d8f283f7ec9f8ed38c3a65"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/preimages/src/internal/server/oidc/discovery.go",
"sha256": "5ff9ddd9e2f4304b562e116100ca31fe7eabf8315d7d73998d3e6c5bea53956d"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/preimages/src/internal/server/oidc/token.go",
"sha256": "8422cc9dbd39c88dfe835b43f6262cb120bc0d2e5b08898a151167b7555368d3"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/preimages/src/internal/server/oidc/userinfo.go",
"sha256": "bbd5fa9ebd0726371c90d6938536c1ce5fcfacbbe853a80833e3587fc2115a0b"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/preimages/workplans/ADHOC-2026-09-27.md",
"sha256": "2794d1940fc3c5e4a2df57de9d4b742fa6aa8602b67f1d3177c0d96bc5aaf314"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/restore_owner_generated_brief.py",
"sha256": "d3e84fd323cb8c3283d1bb09108cc3bbf8cbc46f504e3bebad1a61d04d5a3dc6"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/run_candidate.py",
"sha256": "df249e1946a3661b47bf4d8515e4faf8d0cdd91d0ebbda2304630aeedafdd214"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/source-check-equivalence.json",
"sha256": "ad214d41e3ad926eff17784fd2e13ce1b6ca2a30e3b6e5d5efcdd93b5d53ce63"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/source-decision-receipt.json",
"sha256": "51953a52a52f314ebbd1b00b4ba15ef7eb8845a7e0b000c5b1cfb2b0db0a209f"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/source-decision-request.json",
"sha256": "c9f3857a28a631899d31493d7d75f7cba5391437f4e9a4e4e1d68383d8dfc53c"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/source-manifest.json",
"sha256": "a133e0de7bbdaf5cd5a519ddaccd3d314b47da6a3bbd9a883162b9238dda7bea"
},
{
"path": "workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md",
"sha256": "b78a7089191c0f90de6fdd2ee7e4f901b567da0545a4fc2facad9d8c4b1be4e4"
}
],
"stdout": "",
"stderr": "{\"blocked_blob_ids\": [\"1344d4edea99df8f85c0884df2650361471597e8\", \"35e8d505c22c8b192017748124b883e69532deb6\", \"8f80cab62acdff2319be10202ca35388a38943e0\"], \"scanned_blobs\": 72, \"scanner_sha256\": \"88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509\", \"scanner_version\": \"8.30.1\", \"scope\": \"changed-staged-blobs\", \"status\": \"blocked\"}\n"
}

View file

@ -0,0 +1,19 @@
{
"status": "blocked",
"source": "unchanged normal hook",
"rows": [
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/binary-attempt1.json",
"oid": "git:35e8d505c22c8b192017748124b883e69532deb6"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/focused-attempt2.json",
"oid": "git:8f80cab62acdff2319be10202ca35388a38943e0"
},
{
"path": "docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/full-source-inventory.json",
"oid": "git:1344d4edea99df8f85c0884df2650361471597e8"
}
],
"recovery": "inspect exact finding and prove any public-metadata derivation; do not bypass scanner or alter its rules"
}

View file

@ -0,0 +1,122 @@
# Issuer-owned online access-token status source proposal
This packet prepares the missing issuer facility identified while implementing
current operation/inverse sessions for CUST-WP-0080. KeyCape's existing
KEY-WP-0025 correctly documents that its legacy JWTs remain usable until expiry;
Warden's injected current-session evidence verifier does not implement the
missing producer. The selected extension lives in the existing KeyCape issuer.
It introduces no Forgejo-local token registry, broker, PDP or approval authority.
The original base is KeyCape `d803e928`. `owner-source.patch` and
`source-manifest.json` retain the exact 18-path source/publication delta, and all postimages
replay from that published source archive with fuzz zero. Unrelated source is
unchanged. Independent issuer/profile/consumer assent and native activation
remain open. See the owner publication/consistency receipts for subsequent
metadata reconciliation and the published source revision; a local source
proposal alone is not an owner-reviewed runtime capability.
## Implemented source behavior
An explicit `tokenStatus.enabled` profile connects one issuer-owned bounded
hash-only store to actual human/service issuance, UserInfo, RFC 7662
introspection, RFC 7009 revocation, configuration validation and discovery.
Access tokens gain a unique random jti; ID-token claims and the disabled legacy
profile are preserved. A later grant cannot remint or reactivate a revoked token.
Current registration commitments include the omitted private-secret digest and
internal token lifetime. Introspection authenticates the registered issuing
confidential client; public clients can revoke their own bearer but cannot read
token status. There is no implicit cross-client resource-server delegation.
Online checks reject unknown/revoked/expired tokens, registration drift, missing
or disabled/suspended humans and unavailable directory lookup. They recheck
status after lookup so a delayed query cannot resurrect revoked authority.
Unknown and other-client tokens disclose no existence information. Requests
and private cache behavior are bounded and reject ambiguity without echoing
values. Live records/tombstones are never evicted to make room for issuance.
The profile retains the issuer's supported single-instance topology: process
restart loses online status and refuses every earlier token until fresh
authentication. Offline consumers keep their existing JWT lifetime. The full
tradeoff, HTTP/HTTPS topology, capacity/availability limits and rollout contract
are explicit in `postimages/docs/online-token-status.md` and the proposed
`postimages/workplans/KEY-WP-0042-online-access-token-status.md`.
## Verification
The initial focused race run passed 40 test/subtest results. The final complete
actual source race suite passed **659**, with six existing native/external skips;
all 43 new token-status test/subtest results passed without skips. These include
an actual HTTPS human PKCE/introspection/revocation/UserInfo flow, both grant
types, public/confidential isolation, capacity/expiry/tombstones, changed current
registration/account disposition, malformed/oversize/ambiguous input, concurrent
store use and revocation during both UserInfo and introspection directory lookup.
The initial focused receipt is a preceding source revision, not another final
suite success. The six skipped tests are listed in the complete receipt.
The standalone server builds. Its actual main/router/config/grant/store paths
pass **26** isolated process-level checks, including real revocation, predecessor
refusal after an actual process restart, fresh issuance, capacity refusal and
disabled-profile discovery/routes/claims. This process fixture uses local HTTP
inside a private network namespace; the separate actual-handler flow uses HTTPS.
Neither is production ingress, a real user session, owner custody or an admitted
deployment. Synthetic private keys, client credentials and JWTs stay in private
fixture memory/tmpfs; actual daemon logs contain none of them. All owned daemon
processes exit normally; the fixture tmpfs disappears with its sandbox.
An isolated control omitting only `entry.revoked` from the online predicate fails
seven intended checks, including real HTTPS and delayed-directory paths. Its 36
passing checks are controls, not a successful suite. Canonical candidate source
is not weakened. `controlled-revocation-omission.json` retains the counterexample.
The full race source, build source and actual-process source match exactly;
`source-check-equivalence.json` records the standalone binary digest.
`run_candidate.py` exposes `focused`, `full`, `build` and `binary` modes. It uses
an empty HOME, allowlisted environment, read-only public module/toolchain mounts
and private PID/network namespaces. No host credentials or real backend are
available. Receipts/logs refuse overwrite. `freeze_candidate.py` verifies exact
base/replay; the controlled source's sole omitted predicate is explicitly retained.
## Publication and remaining work
The first ordinary identifier preview refused an unrelated completed historical
task with a missing file UUID. The existing Hub already has an exact matching
task/binding. The normal consistency audit deliberately skips creation in closed workstreams.
After exact parent/record/title/status/description comparison, its existing
block-injection helper restores that already-recorded UUID in the reviewable
candidate. Repo Manager then assigns the four new canonical proposal records.
No replacement identity or historical status change occurs. The refusal and
existing binding remain in `identifier-preflight-refusal.json` and
`owner-consistency/`. This is separate from runtime source verification.
The new owner workplan is an explicit **proposed extension**, not a claim that
the existing expiry-only scope already supplied online status. Its source task
is complete; independent issuer/profile review and native lifecycle admission
remain open. Consumer exact-operation/inverse binding and revocation on every
exit stay in existing NK48-T34/T42/T43 and RAPPS16-T08/T09. This issuer facility
does not implement their descriptors, approvals, effects or writer exclusion.
All sixteen architecture gates, G01–G13 and 116 SEC-DSM ledger entries remain in
the full goal. No native gate is closed by this packet. Required ongoing
HTTP/API/Git/SSH authorization, private journal custody/retention, release-wide
writer exclusion, exact package/type/trust admission and independent owner/native
evidence remain open.
KeyCape source commit `67a0869c` contains the 17-path feature and one historical
reference restoration. Normal source commit hooks passed; primary projection
verification is retained by `owner-publication/`. The preceding 17-path proposal
patch/manifest remain separate, before historical-reference restoration and
normal deterministic UUID assignment.
Final primary readback verifies KEY-WP-0042 as proposed, T01 progress, T02 done
(source only), and T03 todo, with all three exact source UUID/status bindings.
The existing historical task UUID/status is unchanged. The tool-generated
KeyCape brief/index is restored to its original state after publication; its
tracked worktree is clean. This does not change the Custodian task/gate counts.
The unchanged repository hook initially refused three untyped public source
inventories. Every one of their 345 digests was checked against the exact
positive/control source and published with an explicit `sha256:` type. Removing
that type and serializing the retained JSON restores each original receipt byte
for byte; `typed-source-digest-derivation.json` retains original/published hashes.
No runtime/test code, private value or scanner rule changed. The first refusal
is retained by the root publication receipt.

View file

@ -0,0 +1,46 @@
"""Apply exactly the verified issuer source and existing-binding restoration."""
from pathlib import Path
import hashlib
import json
import subprocess
import urllib.request
HERE = Path(__file__).resolve().parent
ROOT = Path('/home/worsch/key-cape')
manifest = json.loads((HERE / 'source-manifest.json').read_text())
sha = lambda raw: 'sha256:' + hashlib.sha256(raw).hexdigest()
def git(*args):
return subprocess.check_output(['git', '-C', str(ROOT), *args], text=True).strip()
base = manifest['base_revision'].removeprefix('git:')
assert git('rev-parse', 'HEAD') == base
assert git('ls-remote', 'origin', 'refs/heads/main').split()[0] == base
assert set(git('diff', '--name-only', 'HEAD').splitlines()) <= {'.custodian-brief.md', 'WORK-RECORDS.md'}
restoration = json.loads((HERE / 'owner-consistency/existing-binding-restoration-proposal.json').read_text())
with urllib.request.urlopen('http://127.0.0.1:8000/tasks/' + restoration['existing_hub_task_id'], timeout=20) as response:
task = json.load(response)
assert task['record_id'] == restoration['task_record_id'] and task['workplan_id'] == restoration['parent'] and task['status'] == 'done'
paths = {row['path'] for row in manifest['files']}
protected = {name: sha((ROOT / name).read_bytes()) for name in git('ls-files').splitlines()
if name not in paths and (ROOT / name).is_file()}
original_index = git('ls-files', '--stage', '--', '.custodian-brief.md', 'WORK-RECORDS.md')
for row in manifest['files']:
file = ROOT / row['path']
assert (not file.exists()) if row['before_sha256'] is None else sha(file.read_bytes()) == row['before_sha256'], row['path']
assert sha((HERE / 'postimages' / row['path']).read_bytes()) == row['sha256'], row['path']
for row in manifest['files']:
target = ROOT / row['path']
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes((HERE / 'postimages' / row['path']).read_bytes())
assert all(sha((ROOT / name).read_bytes()) == digest for name, digest in protected.items())
assert git('ls-files', '--stage', '--', '.custodian-brief.md', 'WORK-RECORDS.md') == original_index
receipt = {'status': 'source-applied', 'owner': 'key-cape', 'base': 'git:' + base, 'files': len(paths),
'other_tracked_files_preserved': len(protected), 'existing_binding_restored': restoration['existing_hub_task_id'],
'new_identity_for_historical_task': False, 'independent_owner_assent': False, 'native_actions': False}
(HERE / 'owner-application.json').write_text(json.dumps(receipt, indent=2) + '\n')
publication = {'owner': 'key-cape', 'base_revision': base,
'message': 'Add opt-in issuer-owned access-token status and scoped revocation',
'files': [{'path': row['path'], 'sha256': row['sha256'].removeprefix('sha256:')} for row in manifest['files']]}
owner = HERE / 'owner-publication'
owner.mkdir(exist_ok=False)
(owner / 'publication-inputs.json').write_text(json.dumps(publication, indent=2) + '\n')
print(json.dumps(receipt))

View file

@ -0,0 +1,8 @@
{
"repo": "key-cape",
"commit": "d803e9282a47b49daf930fb905312fa5104c0ace",
"archive_sha256": "sha256:e6170806640398b325cee8cbf9c8b6f02a1ba5baa94022df6446c69b3fca89c2",
"workspace": "/tmp/custodian-keycape-token-status-r1/source",
"status": "isolated-source-proposal",
"live_authority": false
}

View file

@ -0,0 +1,203 @@
{
"returncode": 0,
"seconds": 0.53,
"argv": [
"bwrap",
"--unshare-net",
"--unshare-pid",
"--die-with-parent",
"--new-session",
"--ro-bind",
"/usr",
"/usr",
"--ro-bind",
"/lib",
"/lib",
"--ro-bind",
"/lib64",
"/lib64",
"--symlink",
"usr/bin",
"/bin",
"--proc",
"/proc",
"--dev",
"/dev",
"--tmpfs",
"/tmp",
"--bind",
"/tmp/custodian-keycape-token-status-r1",
"/work",
"--ro-bind",
"/home/worsch/go/pkg/mod",
"/modules",
"--ro-bind",
"/home/worsch/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.26.7.linux-amd64",
"/toolchain",
"--chdir",
"/work/source/src",
"--clearenv",
"--ro-bind",
"/home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/binary_fixture.py",
"/fixture.py",
"--setenv",
"PATH",
"/toolchain/bin:/usr/bin:/bin",
"--setenv",
"HOME",
"/work/home",
"--setenv",
"GOTOOLCHAIN",
"local",
"--setenv",
"GOPROXY",
"off",
"--setenv",
"GOSUMDB",
"off",
"--setenv",
"GOMODCACHE",
"/modules",
"--setenv",
"GOCACHE",
"/work/gocache",
"--setenv",
"GOMAXPROCS",
"4",
"--setenv",
"CGO_ENABLED",
"1",
"--",
"python3",
"/fixture.py"
],
"source_sha256_at_start": {
"src/go.mod": "sha256:ab299ad4f94feb78e217e2228a4aa161b166460aa28df0c5009451ac1a337456",
"src/Makefile": "sha256:6cdd3aa6e1cccaf4e028278acafe3e6a8d28170de89ff3462984d4f6eb56bd96",
"src/go.sum": "sha256:b10ad54277fb2bd927d3af727fc4dbb9ebe91a038a75be0469d0253ca699226d",
"src/tests/negative/negative_test.go": "sha256:757485bf1967ed95e872d4a1987b7fd985fbffccd05cf41270b9f4061431de3f",
"src/tests/conformance/external_test.go": "sha256:b26bd465e31086f957c9e374b6bee1f0f077b7481eda9daa29cf63816bb87fdb",
"src/tests/migration/scenario_c_test.go": "sha256:9722146ac224768aff85476acbd83f3d478c1e060e5a3c6f99b1786858879b80",
"src/tests/migration/fixtures_test.go": "sha256:3fbcb8f531fa206225f719a67b744b2ecb908095fbae0538432796697971e6de",
"src/tests/migration/scenario_b_test.go": "sha256:40ae7239d116f3e284c767570d39e1f32d44fc29b6fa37ea8835987027942ac0",
"src/tests/profile/profile_test.go": "sha256:e5ab4568b15f681a13c9295b17907ca3872ee33b702bb56aea749f17758d29e6",
"src/internal/domain/assurance_test.go": "sha256:87680e7afd47294dadc13092ca2bb7c47b9625c80e48a701765c00df3293d755",
"src/internal/domain/conformance_test.go": "sha256:6420ec21d4e7a97f60902e1f0debbaf20df91aeac6ab110629b82a67f4c5c9a1",
"src/internal/domain/repository.go": "sha256:28f6d8f7f3d822e73f164af5e877f252db7dc6faa97f0ebf75d76aad64078011",
"src/internal/domain/auth.go": "sha256:ae800c5b1f58408147dd28a5c87ab2603b1537500a289397e711a4cb0e132cee",
"src/internal/domain/model.go": "sha256:ce4309fc7426c66e4ebc18022d2f704c10ebbec243cc98e657d1de765ad09029",
"src/internal/domain/assurance.go": "sha256:d3a29ece7c4fa4d9daa8b5fda89cbe0dd1ffef2ee2ecae0051ee68010edb8fd7",
"src/internal/domain/mfa.go": "sha256:1b7875b08a0f280a20a14a4fa4fdb4fc84c3942795b1d1417943d32634121b87",
"src/internal/authclient/cli.go": "sha256:376a8e0d1d60825a8b3ef21d371e3fc353c0a34e461712a6899e264278edbe7f",
"src/internal/authclient/client.go": "sha256:61fb150349c5d3714450a90834c59af9a579a8a649c4149297a402cfcf6e39c5",
"src/internal/authclient/verify.go": "sha256:804735be927ba7d0228ac378056730040d3ccf2ed020b4da0714085bae972f25",
"src/internal/authclient/client_test.go": "sha256:23642488fcff45e369d22ba0000c47e0e450c5f92560e643ab96303e2a2f43aa",
"src/internal/authclient/verify_test.go": "sha256:7febc6698c87eac5fd8d2520c4f77c68e82f18a2aafa448cfa4990b96fdc46fc",
"src/internal/validator/report.go": "sha256:6bc4d31f932ea9a32020cf33203978906730dfe3601350ded0206ff6c8c297a8",
"src/internal/validator/validator_test.go": "sha256:8216027a464eab4167d8e87b4d27d130854d7b9cd4cc8a1b6f1a47ff1f9a2276",
"src/internal/validator/validator.go": "sha256:3d0a8bf8ebbe54e21e162c8f7df6e4a4d69ab997a01969b74dcbd53decdf559f",
"src/internal/config/account_test.go": "sha256:c6b75097d14dd42415c836407fa2a509857cdd2b771fb7e53d4507cbad2f4b1d",
"src/internal/config/validate.go": "sha256:2b1766979b817802f43bdd0f999ac61452d86cdfafc1539a5d6be896b900a405",
"src/internal/config/optional_mfa_test.go": "sha256:03a00bc309943adbed68bdb9bf47facb6b3b50ef380fd49143b5ed54d684f63e",
"src/internal/config/config_test.go": "sha256:af8602010985a0a5a6b4b2bf779f824e7eb5141e6bc2e8d8fcf600b964955100",
"src/internal/config/token_status_test.go": "sha256:b2b2ec13215c9661d79142ea698cb8b080f6e017de050bedebf3eb8d16df2ad5",
"src/internal/config/config.go": "sha256:19cd234757f8908880d28b8b40939367dfab1322e0401e4e563d56cd3af06683",
"src/internal/errors/taxonomy.go": "sha256:7d60f30a4ff0c9a21a3942451eb0292320a6346d3d81508e414a7dfbdc3c666c",
"src/internal/errors/taxonomy_test.go": "sha256:6c0bdf1f8e90ea343d88f57f80f6c916c92428a9ac2191a518ee7b70df0eed45",
"src/internal/jose/jose.go": "sha256:bcfc30d2175735f37e7ea6f58451e122fbdfe02c562805e5a018c863fb677fab",
"src/internal/jose/jose_test.go": "sha256:cbbcbac620b27c8e93d9dc3fefc3ca4fcd20ab58c6bc8f79eb041b980aaa9a43",
"src/internal/server/policy/policy_test.go": "sha256:25644557ca532b608dff4e02b202c5c3b7c423fb31e98ad0609febf3660183a2",
"src/internal/server/policy/policy.go": "sha256:015d0b42419c501a46171d09a8f7d588dd517d4c98ad14344ff52113294e0fe7",
"src/internal/server/errors/enforcement.go": "sha256:f644546b1423992518fc4078ab561ab68fea1fcff00b734795af6652ae3dd4e2",
"src/internal/server/errors/enforcement_test.go": "sha256:b4ea0c9a3aaebd66b0b0fcc73f544c76d5a377ac0c9561ccc2638a236844374c",
"src/internal/server/telemetry/emitter.go": "sha256:e92f008b6a487a47b8ee2b98011a3a5b821838a61be9c4db4d56cda600e6fda0",
"src/internal/server/telemetry/emitter_test.go": "sha256:29fae2460deb7219eb8edc725b3701a02d2b341fee62bffb2661a520f5a7381a",
"src/internal/server/telemetry/events.go": "sha256:3f9df9822649ae2701c3bbf475c8e6d600bc60b7ccdea4d0e54193162d5b0167",
"src/internal/server/readiness/readiness_test.go": "sha256:10163e0b1d2f41f336d61782c3bfffdd1116a8459247cc933ffb40be645184c6",
"src/internal/server/readiness/readiness.go": "sha256:ffe22b99633b1aa50fc299371398ec0f3fc090c9c8a5f4957c6bfa76b001d073",
"src/internal/server/oidc/discovery_test.go": "sha256:2d0dc24d59497aa5a9aa581f8fd3d9cca300d7e0cb4962907630570d60e7e7f0",
"src/internal/server/oidc/tenant_precondition_test.go": "sha256:2f2c0d90d26da4e3a6c9145aec8c9d7121c791b6b993c2fe8893a763a30fa72f",
"src/internal/server/oidc/userinfo.go": "sha256:f9c986f7a2c79f27a9970a296e6b493014fed77af2f22ba96c4a457d049c916f",
"src/internal/server/oidc/account.go": "sha256:b0e36d8cb5f9b1bf4cee573e46b204800c0e3162a912a122b271def4712ab72a",
"src/internal/server/oidc/policy_runtime_test.go": "sha256:856146013d2aaa60836e669c297024923ed8e2856d31a89744a99baadae4a74a",
"src/internal/server/oidc/handoff_test.go": "sha256:23268b7f9f103f8a8798a168004580bc8fdb3a175161220dac26480a89f28e02",
"src/internal/server/oidc/human_tenant_test.go": "sha256:459664f90d76224958b3dd807d95d7bfcf0fce5f113498bd9886a41be19903a5",
"src/internal/server/oidc/discovery.go": "sha256:00e4c831a2b4d29aecf3597a09c943315b87c5834dbcd4912ba5d8d31ab28cd5",
"src/internal/server/oidc/jwks.go": "sha256:426d6596979ed6d097d6aaae66b6475d1c97687888c6ab1050c05cb14653ec60",
"src/internal/server/oidc/login_session.go": "sha256:efb0fcd066d81d753b7043c9c3d9e74e35cd510ca4304d21a51ba29dec833e1f",
"src/internal/server/oidc/hardening_test.go": "sha256:b41d6a987d5507d5160d970d0f2110ce681851fca1f4f7a3a9573163e8cd6f3e",
"src/internal/server/oidc/jwks_test.go": "sha256:9100e83e58ce2cc2f6d2f47804ee0ce035ca424b209597a12c4ef1a8896163aa",
"src/internal/server/oidc/account_test.go": "sha256:db1f7b8a2989b1d0deffee205ec9f4f35c7472135dcb8a10b5498893d881d763",
"src/internal/server/oidc/token_status_discovery_test.go": "sha256:27adb5797afba46ca2b1f16018721f055f82760498088b58b993730be1fa2031",
"src/internal/server/oidc/principal_type_guard_test.go": "sha256:b3fa097691f07594616e09e8775512d586a019ccdf8302bf594c1ac94f5f3f40",
"src/internal/server/oidc/token_status_http.go": "sha256:a7202d3176454f8ab42c090426e2debcb5cc2c1fc089e79746bb78e5c455af97",
"src/internal/server/oidc/native_optional_test.go": "sha256:329907bf7089d8f3ea05baad919d05677b8c3aaa201e6f233795ae9f69c003b5",
"src/internal/server/oidc/authorize_test.go": "sha256:61042d55e20a11fb6e1e9e6c054e50c0f629b702ed6d8da281d52529e5c29b0d",
"src/internal/server/oidc/session.go": "sha256:48473ebe123285ed21e7738834d9d9168dfc855d911cb9e7ce7e87b069a23a3c",
"src/internal/server/oidc/logout.go": "sha256:d4de80f8056e7a65c7a41f244dd38e447a4e04df729760a8a59cb6bb4e063893",
"src/internal/server/oidc/optional_mfa_test.go": "sha256:d758848d845c181cf1b18710e6ca24977f6b7576320dcad1f7293a5433c3b529",
"src/internal/server/oidc/token.go": "sha256:304fa78d79a5436b9174d88991c19c0834356cb5425c6ed369accb85467a402f",
"src/internal/server/oidc/token_status.go": "sha256:d9c22a71bb2efad7ea240f2689a98f14768ce3c1519d1d8b4e78ee7af2dc70b3",
"src/internal/server/oidc/userinfo_test.go": "sha256:e88c42c7cdf50d5953c9f594f0b99b4f87f5e223ba1cb44b70d696972016da80",
"src/internal/server/oidc/signin_diagnosis_test.go": "sha256:97e80be67c480dfe8eef0683f25868a91a0fdc96472b5296b734b56274270b3e",
"src/internal/server/oidc/policy_isolation_test.go": "sha256:35cb9870f5942c61c0e8c6ca875e9080bbd71c46c7b20fb18da01be88e0fa077",
"src/internal/server/oidc/token_test.go": "sha256:5721617c6e5eb79a3b5e86efa63e9278a8d81a0d7c01bf7b184255c102848e33",
"src/internal/server/oidc/audience_test.go": "sha256:1b751e1aafb5dbdcb751b42b001e32ddfaccfa3d5ba9d5af67b44148d9802ae3",
"src/internal/server/oidc/token_status_transport_test.go": "sha256:3a9945f0292125e86d0014b5245a20c0475d6c518eccff0f9b936356d8e0b347",
"src/internal/server/oidc/tenant_test.go": "sha256:8a3879789589f0c3d6e473ba56dd7d7c86ec6fbdddaafa4b13354f82d5f88713",
"src/internal/server/oidc/assurance_time_test.go": "sha256:911f65e8cea87ef867d6bf98199ed55abd29427b9f8d0838977d228e6186b08a",
"src/internal/server/oidc/signin_diagnosis.go": "sha256:b7976a1cfe1d8d1dca890cf0762cc82d08978a2e7537e45988b147ed0222042f",
"src/internal/server/oidc/authorize.go": "sha256:04f9bfb6b146ecf869161b7a0eb00b6486f424710c2c867e4f4b70af5f44ff5e",
"src/internal/server/oidc/handoff.go": "sha256:a2ffede12c9140a6c7d14632122a39ffbd0e707cd357dcc11d3ad885dc2a7fa4",
"src/internal/server/oidc/token_status_internal_test.go": "sha256:3d3ab41987c7586bd53af8b5c36ff6667b5b2a32d59789915eb9a36b7ceeba53",
"src/internal/server/oidc/token_status_test.go": "sha256:e2613d44deefcbfbd0ce1db5ce2588849fb4be9b8bed33cb538171cc29f63e90",
"src/internal/migration/lldapexport/exporter.go": "sha256:cb041827c9c0edf9da1338e966e7722e3765065582933b8c17b0d9008d44d103",
"src/internal/migration/lldapexport/exporter_test.go": "sha256:6f5cf0e43ea8aea9ceeb6d4d3ae0bfd9f3a5d18636dd49bea0c574c5253a44c2",
"src/internal/migration/tokeycloak/transformer_test.go": "sha256:5c3f5145a37079262db805f5bedaabe1d42edf2ccec1788c09c02f5b8336639b",
"src/internal/migration/tokeycloak/transformer.go": "sha256:856e26fd2075f020ef02373b5b7e9e5a1e98473b5caca95b60b7983287fc10aa",
"src/internal/migration/toldap/generator.go": "sha256:e59ba05203145f52818b3f4e34ab62126481faa9dde697f643fc1cda0638bb46",
"src/internal/migration/toldap/generator_test.go": "sha256:73b0969ce2e2cb018aa5734714c86152d1695f0099a5caf7dd96b773717fc2cc",
"src/internal/adapters/tenantengine/adapter.go": "sha256:a54ef12fafd2efc24c547d562e0a558a7ecef586394e79f7de345dc8cbbbbef3",
"src/internal/adapters/tenantengine/adapter_test.go": "sha256:b9c2db04bc187e6e6e56d4a42d4e657b9e56fef06843500fb4640477dee553e0",
"src/internal/adapters/lldap/adapter.go": "sha256:e7df7a159e9dd33a97932fd8cbde49a61091b8722796bf54ae4be887b4a3f5ef",
"src/internal/adapters/lldap/railiance_admin_test.go": "sha256:63db481bb6e592e91bf4a555e75065fbd3bd8cd408a7967a4b7786e12bef5d5f",
"src/internal/adapters/lldap/adapter_test.go": "sha256:650394ccc25d54c73c03746977a1880b1ed905634da8382da0ecf35843454081",
"src/internal/adapters/lldap/config_internal_test.go": "sha256:cbc71ce9a795b73c40b2714153eb2ee4d2e4007f7f56a7882eb862c23868cb68",
"src/internal/adapters/lldap/config.go": "sha256:33059a27418b00e1987b7ced0b21dd39c1765338bf575fe2333ce3e606e5546b",
"src/internal/adapters/privacyidea/credential_file_test.go": "sha256:9db15562c6fdf01e0806bbc478915413b17adb63c7e9ba213acfc4f9b2b2f0b6",
"src/internal/adapters/privacyidea/read_scope_test.go": "sha256:4685e1b84dad044db12708ecd231d4f91ec22ac45901ed2b5177e3be17d8cdc3",
"src/internal/adapters/privacyidea/adapter.go": "sha256:2559c68b5aa22bc5f61961e47d696b376f8bede533be03715ffc80c0d89ed904",
"src/internal/adapters/privacyidea/adapter_test.go": "sha256:6257489b9c51270da2c96f165c0efd1750b273218df51dca907d29c64c98b349",
"src/internal/adapters/privacyidea/optional_mfa_test.go": "sha256:e2e415c671d1d0e7f30a0ce22f5ec58914d8e366b12e445e13b1d645a9ca6a65",
"src/internal/adapters/privacyidea/enrollment_state_test.go": "sha256:dcf24c486629fa4835cddaa786af31ec78c05907694d4d93bce0f32876806377",
"src/internal/adapters/privacyidea/config.go": "sha256:8dd769b18c04c562714794484630acdbda81b34ab1cfaf9cc7445513207e5021",
"src/internal/adapters/authelia/verify.go": "sha256:189f9bc18b8d4319d2d88ee76a76e3b86468ebafde85ef460a4bafb7b350b4c4",
"src/internal/adapters/authelia/adapter.go": "sha256:2887bd546f43a16338547dab5ce4a90850c670ed6f6c0d610a5d08c72c7f3f45",
"src/internal/adapters/authelia/adapter_test.go": "sha256:d450b37e2b2405c189d444e50f0612e64177d6698cb4665a06ad967ae2224f93",
"src/internal/adapters/authelia/issuer_probe.go": "sha256:5846e2b514077a572611b898f449c04b096b086aeb724da055b2416994a7a7bb",
"src/internal/adapters/authelia/issuer_probe_test.go": "sha256:a85e02399f8a5c81401dbf159e8054420bff56e08c8a98e8026cad8d31fcecec",
"src/internal/adapters/authelia/config.go": "sha256:1b85ea3ffd324bced1ba108492ec780af8ba2b015d22cd51b294afaf36f2ec55",
"src/internal/adapters/authelia/verify_test.go": "sha256:f1570604d5038e49c14791f4d1aa278ad76a7e90294e82813d2c21e6a80a5511",
"src/cmd/keycape-to-keycloak/main.go": "sha256:6141d3f86bbe3df2a814a0c9995e537d32bf7b19f5549e89d40974eb3270faf7",
"src/cmd/lldap-to-ldap/main.go": "sha256:adcc9481d009cad909bd55a71d95c17ce30e29ca63e88a7c9e8101b92d4b872e",
"src/cmd/keycape/clients_test.go": "sha256:6a3982c1b2bd9b15c387ba64d4c87b37304dc6f615ed2ab22eec888f620cb1da",
"src/cmd/keycape/checkconfig.go": "sha256:2c47c9aedf3457627fdec991703e3be50e5ef554e4104cf3741f8347687279f2",
"src/cmd/keycape/main.go": "sha256:2f81ea0b0b33f38298e028d5610625e3834fdf1aa12f6f392579d6207bcf6f1d",
"src/cmd/keycape/checkconfig_test.go": "sha256:06955c996774109b26e4751ab20787f12718a1de96e3483a775fb19688d90e66",
"src/cmd/validator/main.go": "sha256:e53b0a7860537dfb1cec69a00cdafe7073b35802421b5977f2fece4de01b9c1f",
"src/cmd/lldap-export/main_test.go": "sha256:f105e490c717a414a5a3d1e65ceb4edcf74341ad7713b0accf8117a9b1ee02d2",
"src/cmd/lldap-export/main.go": "sha256:f019a6dbb0849d06d5c3729874293d520478ca2887929a0595b59a324097ae75"
},
"counts_including_subtests": {
"pass": 0,
"fail": 0,
"skip": 0
},
"failures": [],
"skips": [],
"source": "/tmp/custodian-keycape-token-status-r1/source",
"native_acceptance": false,
"credentials_accessed": false,
"network": "private empty namespace; fixture loopback only",
"home": "new empty sandbox HOME"
}

View file

@ -0,0 +1 @@
{"status": "passed", "checks": 26, "all_owned_processes_terminal": true}

View file

@ -0,0 +1,115 @@
{
"binary_sha256": "sha256:bc1369cf122133642180a9fa3cf92b93e0332f639c4e218d5041af959c77c92b",
"checks": [
{
"check": "enabled_discovery",
"passed": true
},
{
"check": "actual_service_grant",
"passed": true
},
{
"check": "distinct_service_grants",
"passed": true
},
{
"check": "introspection_private_cache_control",
"passed": true
},
{
"check": "current_service_metadata",
"passed": true
},
{
"check": "capacity_refuses_without_token",
"passed": true
},
{
"check": "actual_revocation",
"passed": true
},
{
"check": "introspection_private_cache_control",
"passed": true
},
{
"check": "revoked_token_inactive",
"passed": true
},
{
"check": "introspection_private_cache_control",
"passed": true
},
{
"check": "other_grant_still_active",
"passed": true
},
{
"check": "tombstone_not_evicted",
"passed": true
},
{
"check": "unauthenticated_introspection_refused",
"passed": true
},
{
"check": "graceful_process_exit",
"passed": true
},
{
"check": "introspection_private_cache_control",
"passed": true
},
{
"check": "actual_process_restart_refuses_predecessor",
"passed": true
},
{
"check": "introspection_private_cache_control",
"passed": true
},
{
"check": "fresh_post_restart_grant",
"passed": true
},
{
"check": "introspection_private_cache_control",
"passed": true
},
{
"check": "new_grant_does_not_resurrect_predecessor",
"passed": true
},
{
"check": "graceful_process_exit",
"passed": true
},
{
"check": "disabled_discovery_omits_endpoints",
"passed": true
},
{
"check": "disabled_router_has_no_endpoint",
"passed": true
},
{
"check": "legacy_service_grant_has_no_new_claim",
"passed": true
},
{
"check": "graceful_process_exit",
"passed": true
},
{
"check": "actual_process_diagnostics_omit_private_material",
"passed": true
}
],
"real_credentials": false,
"native_acceptance": false,
"transport": "isolated fixture HTTP backend; separate handler test proves fixture HTTPS",
"source": "standalone built KeyCape executable with actual main router/config/grants/store",
"status": "passed",
"all_owned_processes_terminal": true
}

View file

@ -0,0 +1,153 @@
"""Exercise the built server in the runner's private network with synthetic input."""
import base64
import hashlib
import http.client
import json
import os
from pathlib import Path
import secrets
import socket
import subprocess
import tempfile
import time
import urllib.parse
workspace = Path('/work')
binary = workspace / 'keycape-candidate'
assert binary.is_file()
private = Path(tempfile.mkdtemp(prefix='token-status-private-'))
key = private / 'issuer.pem'
with key.open('xb') as output:
os.chmod(key, 0o600)
subprocess.run(['openssl', 'genpkey', '-algorithm', 'RSA', '-pkeyopt', 'rsa_keygen_bits:2048'],
stdout=output, stderr=subprocess.DEVNULL, check=True)
secret = secrets.token_urlsafe(24)
with socket.socket() as listener:
listener.bind(('127.0.0.1', 0))
port = listener.getsockname()[1]
cfg = {'issuer': 'https://issuer.fixture.invalid', 'port': port, 'tokenLifetime': '15m',
'privateKeyPem': str(key), 'environment': 'dev', 'tokenStatus': {'enabled': True, 'maxEntries': 2},
'clients': [{'clientId': 'fixture-client', 'clientType': 'confidential',
'grantTypes': ['client_credentials'], 'allowedScopes': ['fixture.read'],
'secretRef': 'env:FIXTURE_ONLY_CLIENT_SECRET', 'serviceSubject': 'service:fixture',
'tenant': 'tenant:fixture', 'audience': 'resource:fixture', 'tokenLifetime': '1m'}]}
config = private / 'config.json'
config.write_text(json.dumps(cfg))
os.chmod(config, 0o600)
auth = 'Basic ' + base64.b64encode(('fixture-client:' + urllib.parse.quote_plus(secret)).encode()).decode()
tokens, checks, logs, processes = [], [], [], []
def request(path, form=None, authenticated=True):
conn = http.client.HTTPConnection('127.0.0.1', port, timeout=3)
headers = {}
if authenticated:
headers['Authorization'] = auth
body = None if form is None else urllib.parse.urlencode(form)
if body is not None:
headers['Content-Type'] = 'application/x-www-form-urlencoded'
try:
conn.request('GET' if form is None else 'POST', path, body, headers)
response = conn.getresponse()
return response.status, dict(response.getheaders()), response.read(65537)
finally:
conn.close()
def check(label, condition):
checks.append({'check': label, 'passed': bool(condition)})
if not condition:
raise AssertionError(label)
def start(label):
log = private / (label + '.log')
output = log.open('xb')
process = subprocess.Popen([str(binary), '--config', str(config)], stdout=output, stderr=subprocess.STDOUT,
env={**os.environ, 'FIXTURE_ONLY_CLIENT_SECRET': secret})
output.close()
logs.append(log)
processes.append(process)
for _ in range(100):
if process.poll() is not None:
raise AssertionError('fixture process exited before readiness')
try:
if request('/healthz', authenticated=False)[0] == 200:
return process
except OSError:
pass
time.sleep(0.02)
raise AssertionError('fixture startup timed out')
def stop(process):
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=3)
raise AssertionError('fixture required forced termination')
check('graceful_process_exit', process.returncode == 0)
def issue():
status, _, body = request('/token', {'grant_type': 'client_credentials'})
if status != 200:
return status, None
token = json.loads(body)['access_token']
tokens.append(token)
return status, token
def introspect(raw):
status, headers, body = request('/introspect', {'token': raw})
check('introspection_private_cache_control', headers.get('Cache-Control') == 'no-store')
return status, json.loads(body)
report = {'binary_sha256': 'sha256:' + hashlib.sha256(binary.read_bytes()).hexdigest(),
'checks': checks, 'real_credentials': False, 'native_acceptance': False,
'transport': 'isolated fixture HTTP backend; separate handler test proves fixture HTTPS',
'source': 'standalone built KeyCape executable with actual main router/config/grants/store'}
try:
process = start('enabled')
status, _, body = request('/.well-known/openid-configuration', authenticated=False)
discovery = json.loads(body)
check('enabled_discovery', status == 200 and discovery['introspection_endpoint'].endswith('/introspect') and discovery['revocation_endpoint'].endswith('/revoke'))
status, first = issue()
check('actual_service_grant', status == 200 and bool(first))
status, second = issue()
check('distinct_service_grants', status == 200 and first != second)
status, metadata = introspect(first)
check('current_service_metadata', status == 200 and metadata.get('active') is True and metadata.get('aud') == 'resource:fixture' and metadata.get('client_id') == 'fixture-client')
check('capacity_refuses_without_token', issue() == (503, None))
status, _, body = request('/revoke', {'token': first})
check('actual_revocation', status == 200 and body == b'')
check('revoked_token_inactive', introspect(first)[1] == {'active': False})
check('other_grant_still_active', introspect(second)[1].get('active') is True)
check('tombstone_not_evicted', issue() == (503, None))
check('unauthenticated_introspection_refused', request('/introspect', {'token': second}, authenticated=False)[0] == 401)
stop(process)
process = start('restarted')
check('actual_process_restart_refuses_predecessor', introspect(second)[1] == {'active': False})
status, third = issue()
check('fresh_post_restart_grant', status == 200 and third not in (first, second) and introspect(third)[1].get('active') is True)
check('new_grant_does_not_resurrect_predecessor', introspect(first)[1] == {'active': False})
stop(process)
cfg['tokenStatus'] = {'enabled': False}
config.write_text(json.dumps(cfg))
process = start('legacy')
_, _, body = request('/.well-known/openid-configuration', authenticated=False)
discovery = json.loads(body)
check('disabled_discovery_omits_endpoints', 'introspection_endpoint' not in discovery and 'revocation_endpoint' not in discovery)
check('disabled_router_has_no_endpoint', request('/introspect', {'token': second})[0] == 404)
status, fourth = issue()
payload = fourth.split('.')[1]
claims = json.loads(base64.urlsafe_b64decode(payload + '=' * (-len(payload) % 4)))
check('legacy_service_grant_has_no_new_claim', status == 200 and 'jti' not in claims)
stop(process)
combined = b''.join(path.read_bytes() for path in logs)
needles = [secret.encode(), key.read_bytes(), *(token.encode() for token in tokens)]
check('actual_process_diagnostics_omit_private_material', all(value not in combined for value in needles))
report['status'] = 'passed'
finally:
for process in processes:
if process.poll() is None:
stop(process)
report['all_owned_processes_terminal'] = all(p.poll() is not None for p in processes)
(workspace / 'binary-fixture-receipt.json').write_text(json.dumps(report, indent=2) + '\n')
print(json.dumps({'status': report['status'], 'checks': len(checks), 'all_owned_processes_terminal': report['all_owned_processes_terminal']}))

View file

@ -0,0 +1,86 @@
{
"returncode": 0,
"seconds": 22.93,
"argv": [
"bwrap",
"--unshare-net",
"--unshare-pid",
"--die-with-parent",
"--new-session",
"--ro-bind",
"/usr",
"/usr",
"--ro-bind",
"/lib",
"/lib",
"--ro-bind",
"/lib64",
"/lib64",
"--symlink",
"usr/bin",
"/bin",
"--proc",
"/proc",
"--dev",
"/dev",
"--tmpfs",
"/tmp",
"--bind",
"/tmp/custodian-keycape-token-status-r1",
"/work",
"--ro-bind",
"/home/worsch/go/pkg/mod",
"/modules",
"--ro-bind",
"/home/worsch/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.26.7.linux-amd64",
"/toolchain",
"--chdir",
"/work/source/src",
"--clearenv",
"--setenv",
"PATH",
"/toolchain/bin:/usr/bin:/bin",
"--setenv",
"HOME",
"/work/home",
"--setenv",
"GOTOOLCHAIN",
"local",
"--setenv",
"GOPROXY",
"off",
"--setenv",
"GOSUMDB",
"off",
"--setenv",
"GOMODCACHE",
"/modules",
"--setenv",
"GOCACHE",
"/work/gocache",
"--setenv",
"GOMAXPROCS",
"4",
"--setenv",
"CGO_ENABLED",
"1",
"--",
"go",
"build",
"-o",
"/work/keycape-candidate",
"./cmd/keycape"
],
"counts_including_subtests": {
"pass": 0,
"fail": 0,
"skip": 0
},
"failures": [],
"skips": [],
"source": "/tmp/custodian-keycape-token-status-r1/source",
"native_acceptance": false,
"credentials_accessed": false,
"network": "private empty namespace; fixture loopback only",
"home": "new empty sandbox HOME"
}

View file

@ -0,0 +1,8 @@
{
"commit": "git:67a0869c1ee40c9e00a60cb7c1d828734d5d3d32",
"source_files": 115,
"committed_runtime_test_and_build_source_exact": true,
"commit_paths": 18,
"exact_frozen_delta": true,
"production_deployed": false
}

View file

@ -0,0 +1,24 @@
{
"control_source": "/tmp/custodian-keycape-token-status-control-r1/source",
"changed_only": "src/internal/server/oidc/token_status.go",
"production_file_sha256": "sha256:d9c22a71bb2efad7ea240f2689a98f14768ce3c1519d1d8b4e78ee7af2dc70b3",
"control_file_sha256": "sha256:895cffaee454023a6e90603b34a4f90796b5a38714129db103d4020380fdf788",
"omitted": "entry.revoked in online active predicate",
"expected_result": "focused revocation tests fail",
"canonical_source_weakened": false,
"actual_result": {
"pass": 36,
"fail": 7,
"skip": 0
},
"expected_counterexample_reproduced": true,
"failed_tests": [
"keycape/internal/server/oidc:TestTokenStatusConcurrentIssueReadAndRevoke",
"keycape/internal/server/oidc:TestTokenStatusActualIssueRevokeAndFreshGrant",
"keycape/internal/server/oidc:TestTokenStatusPublicClientMayRevokeButNotIntrospect",
"keycape/internal/server/oidc:TestTokenStatusServiceGrantIsCurrentAndDistinct",
"keycape/internal/server/oidc:TestTokenStatusRevocationDuringDirectoryLookupRefuses",
"keycape/internal/server/oidc:TestTokenStatusActualHTTPSFlowAndAuthenticationEncoding",
"keycape/internal/server/oidc:TestTokenStatusRevocationDuringUserInfoLookupRefuses"
]
}

View file

@ -0,0 +1,92 @@
{
"returncode": 0,
"seconds": 41.81,
"argv": [
"bwrap",
"--unshare-net",
"--unshare-pid",
"--die-with-parent",
"--new-session",
"--ro-bind",
"/usr",
"/usr",
"--ro-bind",
"/lib",
"/lib",
"--ro-bind",
"/lib64",
"/lib64",
"--symlink",
"usr/bin",
"/bin",
"--proc",
"/proc",
"--dev",
"/dev",
"--tmpfs",
"/tmp",
"--bind",
"/tmp/custodian-keycape-token-status-r1",
"/work",
"--ro-bind",
"/home/worsch/go/pkg/mod",
"/modules",
"--ro-bind",
"/home/worsch/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.26.7.linux-amd64",
"/toolchain",
"--chdir",
"/work/source/src",
"--clearenv",
"--setenv",
"PATH",
"/toolchain/bin:/usr/bin:/bin",
"--setenv",
"HOME",
"/work/home",
"--setenv",
"GOTOOLCHAIN",
"local",
"--setenv",
"GOPROXY",
"off",
"--setenv",
"GOSUMDB",
"off",
"--setenv",
"GOMODCACHE",
"/modules",
"--setenv",
"GOCACHE",
"/work/gocache",
"--setenv",
"GOMAXPROCS",
"4",
"--setenv",
"CGO_ENABLED",
"1",
"--",
"go",
"test",
"-p",
"4",
"-race",
"-json",
"-count=1",
"-run",
"^TestTokenStatus",
"./internal/server/oidc",
"./internal/config"
],
"counts_including_subtests": {
"pass": 40,
"fail": 0,
"skip": 0
},
"failures": [],
"skips": [],
"source": "/tmp/custodian-keycape-token-status-r1/source",
"native_acceptance": false,
"credentials_accessed": false,
"network": "private empty namespace; fixture loopback only",
"home": "new empty sandbox HOME"
}

View file

@ -0,0 +1,168 @@
{"Time":"2026-10-18T01:44:05.869783474Z","Action":"start","Package":"keycape/internal/server/oidc"}
{"Time":"2026-10-18T01:44:05.870004688Z","Action":"start","Package":"keycape/internal/config"}
{"Time":"2026-10-18T01:44:05.888599214Z","Action":"run","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS"}
{"Time":"2026-10-18T01:44:05.888664003Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS","Output":"=== RUN TestTokenStatusConfigIsExplicitBoundedAndHTTPS\n"}
{"Time":"2026-10-18T01:44:05.888786709Z","Action":"run","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/legacy"}
{"Time":"2026-10-18T01:44:05.888798946Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/legacy","Output":"=== RUN TestTokenStatusConfigIsExplicitBoundedAndHTTPS/legacy\n"}
{"Time":"2026-10-18T01:44:05.888820948Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/legacy","Output":"--- PASS: TestTokenStatusConfigIsExplicitBoundedAndHTTPS/legacy (0.00s)\n"}
{"Time":"2026-10-18T01:44:05.888850596Z","Action":"pass","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/legacy","Elapsed":0}
{"Time":"2026-10-18T01:44:05.888958821Z","Action":"run","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/enabled-default"}
{"Time":"2026-10-18T01:44:05.888973348Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/enabled-default","Output":"=== RUN TestTokenStatusConfigIsExplicitBoundedAndHTTPS/enabled-default\n"}
{"Time":"2026-10-18T01:44:05.889148206Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/enabled-default","Output":"--- PASS: TestTokenStatusConfigIsExplicitBoundedAndHTTPS/enabled-default (0.00s)\n"}
{"Time":"2026-10-18T01:44:05.889169423Z","Action":"pass","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/enabled-default","Elapsed":0}
{"Time":"2026-10-18T01:44:05.889270584Z","Action":"run","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/enabled-bounded"}
{"Time":"2026-10-18T01:44:05.889293848Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/enabled-bounded","Output":"=== RUN TestTokenStatusConfigIsExplicitBoundedAndHTTPS/enabled-bounded\n"}
{"Time":"2026-10-18T01:44:05.889433249Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/enabled-bounded","Output":"--- PASS: TestTokenStatusConfigIsExplicitBoundedAndHTTPS/enabled-bounded (0.00s)\n"}
{"Time":"2026-10-18T01:44:05.889459607Z","Action":"pass","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/enabled-bounded","Elapsed":0}
{"Time":"2026-10-18T01:44:05.889469922Z","Action":"run","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/http"}
{"Time":"2026-10-18T01:44:05.889474936Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/http","Output":"=== RUN TestTokenStatusConfigIsExplicitBoundedAndHTTPS/http\n"}
{"Time":"2026-10-18T01:44:05.889659657Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/http","Output":"--- PASS: TestTokenStatusConfigIsExplicitBoundedAndHTTPS/http (0.00s)\n"}
{"Time":"2026-10-18T01:44:05.889682507Z","Action":"pass","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/http","Elapsed":0}
{"Time":"2026-10-18T01:44:05.889694013Z","Action":"run","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/userinfo-url"}
{"Time":"2026-10-18T01:44:05.889700776Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/userinfo-url","Output":"=== RUN TestTokenStatusConfigIsExplicitBoundedAndHTTPS/userinfo-url\n"}
{"Time":"2026-10-18T01:44:05.889937963Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/userinfo-url","Output":"--- PASS: TestTokenStatusConfigIsExplicitBoundedAndHTTPS/userinfo-url (0.00s)\n"}
{"Time":"2026-10-18T01:44:05.889962165Z","Action":"pass","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/userinfo-url","Elapsed":0}
{"Time":"2026-10-18T01:44:05.889975601Z","Action":"run","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/query"}
{"Time":"2026-10-18T01:44:05.889987612Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/query","Output":"=== RUN TestTokenStatusConfigIsExplicitBoundedAndHTTPS/query\n"}
{"Time":"2026-10-18T01:44:05.89015636Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/query","Output":"--- PASS: TestTokenStatusConfigIsExplicitBoundedAndHTTPS/query (0.00s)\n"}
{"Time":"2026-10-18T01:44:05.890183719Z","Action":"pass","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/query","Elapsed":0}
{"Time":"2026-10-18T01:44:05.890294775Z","Action":"run","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/too-large"}
{"Time":"2026-10-18T01:44:05.890309879Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/too-large","Output":"=== RUN TestTokenStatusConfigIsExplicitBoundedAndHTTPS/too-large\n"}
{"Time":"2026-10-18T01:44:05.890418131Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/too-large","Output":"--- PASS: TestTokenStatusConfigIsExplicitBoundedAndHTTPS/too-large (0.00s)\n"}
{"Time":"2026-10-18T01:44:05.890444398Z","Action":"pass","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/too-large","Elapsed":0}
{"Time":"2026-10-18T01:44:05.89047136Z","Action":"run","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/negative"}
{"Time":"2026-10-18T01:44:05.890477879Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/negative","Output":"=== RUN TestTokenStatusConfigIsExplicitBoundedAndHTTPS/negative\n"}
{"Time":"2026-10-18T01:44:05.8907124Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/negative","Output":"--- PASS: TestTokenStatusConfigIsExplicitBoundedAndHTTPS/negative (0.00s)\n"}
{"Time":"2026-10-18T01:44:05.890737883Z","Action":"pass","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/negative","Elapsed":0}
{"Time":"2026-10-18T01:44:05.8907513Z","Action":"run","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/disabled-capacity"}
{"Time":"2026-10-18T01:44:05.89075754Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/disabled-capacity","Output":"=== RUN TestTokenStatusConfigIsExplicitBoundedAndHTTPS/disabled-capacity\n"}
{"Time":"2026-10-18T01:44:05.89097219Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/disabled-capacity","Output":"--- PASS: TestTokenStatusConfigIsExplicitBoundedAndHTTPS/disabled-capacity (0.00s)\n"}
{"Time":"2026-10-18T01:44:05.891008602Z","Action":"pass","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/disabled-capacity","Elapsed":0}
{"Time":"2026-10-18T01:44:05.891021523Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS","Output":"--- PASS: TestTokenStatusConfigIsExplicitBoundedAndHTTPS (0.00s)\n"}
{"Time":"2026-10-18T01:44:05.891053598Z","Action":"pass","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS","Elapsed":0}
{"Time":"2026-10-18T01:44:05.891065041Z","Action":"output","Package":"keycape/internal/config","Output":"PASS\n"}
{"Time":"2026-10-18T01:44:05.89151663Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusExactExpiryCapacityAndTombstones"}
{"Time":"2026-10-18T01:44:05.891557334Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusExactExpiryCapacityAndTombstones","Output":"=== RUN TestTokenStatusExactExpiryCapacityAndTombstones\n"}
{"Time":"2026-10-18T01:44:05.892793338Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusExactExpiryCapacityAndTombstones","Output":"--- PASS: TestTokenStatusExactExpiryCapacityAndTombstones (0.00s)\n"}
{"Time":"2026-10-18T01:44:05.892909156Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusExactExpiryCapacityAndTombstones","Elapsed":0}
{"Time":"2026-10-18T01:44:05.892991853Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke"}
{"Time":"2026-10-18T01:44:05.893000203Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":"=== RUN TestTokenStatusConcurrentIssueReadAndRevoke\n"}
{"Time":"2026-10-18T01:44:05.912013424Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":"--- PASS: TestTokenStatusConcurrentIssueReadAndRevoke (0.02s)\n"}
{"Time":"2026-10-18T01:44:05.912065174Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Elapsed":0.02}
{"Time":"2026-10-18T01:44:05.912536595Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusDiscoveryOnlyAdvertisesEnabledProfile"}
{"Time":"2026-10-18T01:44:05.912548534Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusDiscoveryOnlyAdvertisesEnabledProfile","Output":"=== RUN TestTokenStatusDiscoveryOnlyAdvertisesEnabledProfile\n"}
{"Time":"2026-10-18T01:44:05.914026065Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusDiscoveryOnlyAdvertisesEnabledProfile","Output":"--- PASS: TestTokenStatusDiscoveryOnlyAdvertisesEnabledProfile (0.00s)\n"}
{"Time":"2026-10-18T01:44:05.914051989Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusDiscoveryOnlyAdvertisesEnabledProfile","Elapsed":0}
{"Time":"2026-10-18T01:44:05.914139592Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusActualIssueRevokeAndFreshGrant"}
{"Time":"2026-10-18T01:44:05.914150016Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusActualIssueRevokeAndFreshGrant","Output":"=== RUN TestTokenStatusActualIssueRevokeAndFreshGrant\n"}
{"Time":"2026-10-18T01:44:06.013925253Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusActualIssueRevokeAndFreshGrant","Output":"--- PASS: TestTokenStatusActualIssueRevokeAndFreshGrant (0.10s)\n"}
{"Time":"2026-10-18T01:44:06.013986759Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusActualIssueRevokeAndFreshGrant","Elapsed":0.1}
{"Time":"2026-10-18T01:44:06.014002657Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusNoCrossClientOrIDTokenAuthority"}
{"Time":"2026-10-18T01:44:06.014008536Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusNoCrossClientOrIDTokenAuthority","Output":"=== RUN TestTokenStatusNoCrossClientOrIDTokenAuthority\n"}
{"Time":"2026-10-18T01:44:06.181997808Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusNoCrossClientOrIDTokenAuthority","Output":"--- PASS: TestTokenStatusNoCrossClientOrIDTokenAuthority (0.17s)\n"}
{"Time":"2026-10-18T01:44:06.182074275Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusNoCrossClientOrIDTokenAuthority","Elapsed":0.17}
{"Time":"2026-10-18T01:44:06.182087133Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusCapacityFailsIssuanceWithoutEvictingLiveToken"}
{"Time":"2026-10-18T01:44:06.182093599Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusCapacityFailsIssuanceWithoutEvictingLiveToken","Output":"=== RUN TestTokenStatusCapacityFailsIssuanceWithoutEvictingLiveToken\n"}
{"Time":"2026-10-18T01:44:06.265364258Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusCapacityFailsIssuanceWithoutEvictingLiveToken","Output":"--- PASS: TestTokenStatusCapacityFailsIssuanceWithoutEvictingLiveToken (0.08s)\n"}
{"Time":"2026-10-18T01:44:06.26545214Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusCapacityFailsIssuanceWithoutEvictingLiveToken","Elapsed":0.08}
{"Time":"2026-10-18T01:44:06.265490129Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRestartAndUnavailableStoreRefuse"}
{"Time":"2026-10-18T01:44:06.265501446Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRestartAndUnavailableStoreRefuse","Output":"=== RUN TestTokenStatusRestartAndUnavailableStoreRefuse\n"}
{"Time":"2026-10-18T01:44:06.4996983Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRestartAndUnavailableStoreRefuse","Output":"--- PASS: TestTokenStatusRestartAndUnavailableStoreRefuse (0.23s)\n"}
{"Time":"2026-10-18T01:44:06.499751267Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRestartAndUnavailableStoreRefuse","Elapsed":0.23}
{"Time":"2026-10-18T01:44:06.49977317Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition"}
{"Time":"2026-10-18T01:44:06.499779816Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition","Output":"=== RUN TestTokenStatusRegistrationDriftAndDirectoryDisposition\n"}
{"Time":"2026-10-18T01:44:06.499795803Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/removed"}
{"Time":"2026-10-18T01:44:06.499802106Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/removed","Output":"=== RUN TestTokenStatusRegistrationDriftAndDirectoryDisposition/removed\n"}
{"Time":"2026-10-18T01:44:06.796536412Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/removed","Output":"--- PASS: TestTokenStatusRegistrationDriftAndDirectoryDisposition/removed (0.30s)\n"}
{"Time":"2026-10-18T01:44:06.796593212Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/removed","Elapsed":0.3}
{"Time":"2026-10-18T01:44:06.796607738Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/audience"}
{"Time":"2026-10-18T01:44:06.796614934Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/audience","Output":"=== RUN TestTokenStatusRegistrationDriftAndDirectoryDisposition/audience\n"}
{"Time":"2026-10-18T01:44:06.895673725Z","Action":"output","Package":"keycape/internal/config","Output":"ok \tkeycape/internal/config\t1.025s\n"}
{"Time":"2026-10-18T01:44:06.895748009Z","Action":"pass","Package":"keycape/internal/config","Elapsed":1.026}
{"Time":"2026-10-18T01:44:06.914170247Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/audience","Output":"--- PASS: TestTokenStatusRegistrationDriftAndDirectoryDisposition/audience (0.12s)\n"}
{"Time":"2026-10-18T01:44:06.914251853Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/audience","Elapsed":0.12}
{"Time":"2026-10-18T01:44:06.914263007Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/secret"}
{"Time":"2026-10-18T01:44:06.914269337Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/secret","Output":"=== RUN TestTokenStatusRegistrationDriftAndDirectoryDisposition/secret\n"}
{"Time":"2026-10-18T01:44:07.087510198Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/secret","Output":"--- PASS: TestTokenStatusRegistrationDriftAndDirectoryDisposition/secret (0.17s)\n"}
{"Time":"2026-10-18T01:44:07.087570171Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/secret","Elapsed":0.17}
{"Time":"2026-10-18T01:44:07.087586312Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/scopes"}
{"Time":"2026-10-18T01:44:07.087594419Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/scopes","Output":"=== RUN TestTokenStatusRegistrationDriftAndDirectoryDisposition/scopes\n"}
{"Time":"2026-10-18T01:44:07.411672738Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/scopes","Output":"--- PASS: TestTokenStatusRegistrationDriftAndDirectoryDisposition/scopes (0.32s)\n"}
{"Time":"2026-10-18T01:44:07.411745147Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/scopes","Elapsed":0.32}
{"Time":"2026-10-18T01:44:07.411764173Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/lifetime"}
{"Time":"2026-10-18T01:44:07.411771297Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/lifetime","Output":"=== RUN TestTokenStatusRegistrationDriftAndDirectoryDisposition/lifetime\n"}
{"Time":"2026-10-18T01:44:07.452142752Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/lifetime","Output":"--- PASS: TestTokenStatusRegistrationDriftAndDirectoryDisposition/lifetime (0.04s)\n"}
{"Time":"2026-10-18T01:44:07.452209615Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/lifetime","Elapsed":0.04}
{"Time":"2026-10-18T01:44:07.452224133Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/suspended"}
{"Time":"2026-10-18T01:44:07.452233015Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/suspended","Output":"=== RUN TestTokenStatusRegistrationDriftAndDirectoryDisposition/suspended\n"}
{"Time":"2026-10-18T01:44:07.561804091Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/suspended","Output":"--- PASS: TestTokenStatusRegistrationDriftAndDirectoryDisposition/suspended (0.11s)\n"}
{"Time":"2026-10-18T01:44:07.561865643Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/suspended","Elapsed":0.11}
{"Time":"2026-10-18T01:44:07.56187778Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/disabled"}
{"Time":"2026-10-18T01:44:07.561885734Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/disabled","Output":"=== RUN TestTokenStatusRegistrationDriftAndDirectoryDisposition/disabled\n"}
{"Time":"2026-10-18T01:44:07.648057558Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/disabled","Output":"--- PASS: TestTokenStatusRegistrationDriftAndDirectoryDisposition/disabled (0.09s)\n"}
{"Time":"2026-10-18T01:44:07.648112969Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/disabled","Elapsed":0.09}
{"Time":"2026-10-18T01:44:07.648126243Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/user-removed"}
{"Time":"2026-10-18T01:44:07.648131563Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/user-removed","Output":"=== RUN TestTokenStatusRegistrationDriftAndDirectoryDisposition/user-removed\n"}
{"Time":"2026-10-18T01:44:07.731499528Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/user-removed","Output":"--- PASS: TestTokenStatusRegistrationDriftAndDirectoryDisposition/user-removed (0.08s)\n"}
{"Time":"2026-10-18T01:44:07.731566598Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/user-removed","Elapsed":0.08}
{"Time":"2026-10-18T01:44:07.731578618Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition","Output":"--- PASS: TestTokenStatusRegistrationDriftAndDirectoryDisposition (1.23s)\n"}
{"Time":"2026-10-18T01:44:07.731591188Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition","Elapsed":1.23}
{"Time":"2026-10-18T01:44:07.731597635Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusPublicClientMayRevokeButNotIntrospect"}
{"Time":"2026-10-18T01:44:07.731603497Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusPublicClientMayRevokeButNotIntrospect","Output":"=== RUN TestTokenStatusPublicClientMayRevokeButNotIntrospect\n"}
{"Time":"2026-10-18T01:44:07.88603636Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusPublicClientMayRevokeButNotIntrospect","Output":"--- PASS: TestTokenStatusPublicClientMayRevokeButNotIntrospect (0.15s)\n"}
{"Time":"2026-10-18T01:44:07.886092438Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusPublicClientMayRevokeButNotIntrospect","Elapsed":0.15}
{"Time":"2026-10-18T01:44:07.88610747Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusServiceGrantIsCurrentAndDistinct"}
{"Time":"2026-10-18T01:44:07.886114972Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusServiceGrantIsCurrentAndDistinct","Output":"=== RUN TestTokenStatusServiceGrantIsCurrentAndDistinct\n"}
{"Time":"2026-10-18T01:44:07.986961214Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusServiceGrantIsCurrentAndDistinct","Output":"--- PASS: TestTokenStatusServiceGrantIsCurrentAndDistinct (0.10s)\n"}
{"Time":"2026-10-18T01:44:07.987021386Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusServiceGrantIsCurrentAndDistinct","Elapsed":0.1}
{"Time":"2026-10-18T01:44:07.987032442Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests"}
{"Time":"2026-10-18T01:44:07.987038709Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests","Output":"=== RUN TestTokenStatusRejectsMalformedOrUnauthenticatedRequests\n"}
{"Time":"2026-10-18T01:44:07.987050079Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/get"}
{"Time":"2026-10-18T01:44:07.987056184Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/get","Output":"=== RUN TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/get\n"}
{"Time":"2026-10-18T01:44:08.036241913Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/get","Output":"--- PASS: TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/get (0.05s)\n"}
{"Time":"2026-10-18T01:44:08.036336117Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/get","Elapsed":0.05}
{"Time":"2026-10-18T01:44:08.036368065Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/query"}
{"Time":"2026-10-18T01:44:08.036382718Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/query","Output":"=== RUN TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/query\n"}
{"Time":"2026-10-18T01:44:08.199358535Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/query","Output":"--- PASS: TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/query (0.16s)\n"}
{"Time":"2026-10-18T01:44:08.199414424Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/query","Elapsed":0.16}
{"Time":"2026-10-18T01:44:08.199426444Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/json"}
{"Time":"2026-10-18T01:44:08.199433946Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/json","Output":"=== RUN TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/json\n"}
{"Time":"2026-10-18T01:44:08.222687997Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/json","Output":"--- PASS: TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/json (0.02s)\n"}
{"Time":"2026-10-18T01:44:08.222780208Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/json","Elapsed":0.02}
{"Time":"2026-10-18T01:44:08.222809712Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/duplicate-token"}
{"Time":"2026-10-18T01:44:08.222819469Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/duplicate-token","Output":"=== RUN TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/duplicate-token\n"}
{"Time":"2026-10-18T01:44:08.362536745Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/duplicate-token","Output":"--- PASS: TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/duplicate-token (0.14s)\n"}
{"Time":"2026-10-18T01:44:08.36261863Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/duplicate-token","Elapsed":0.14}
{"Time":"2026-10-18T01:44:08.362639424Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/oversize"}
{"Time":"2026-10-18T01:44:08.362647404Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/oversize","Output":"=== RUN TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/oversize\n"}
{"Time":"2026-10-18T01:44:08.490557458Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/oversize","Output":"--- PASS: TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/oversize (0.13s)\n"}
{"Time":"2026-10-18T01:44:08.490632446Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/oversize","Elapsed":0.13}
{"Time":"2026-10-18T01:44:08.490646116Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/wrong-secret"}
{"Time":"2026-10-18T01:44:08.490656639Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/wrong-secret","Output":"=== RUN TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/wrong-secret\n"}
{"Time":"2026-10-18T01:44:08.604489392Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/wrong-secret","Output":"--- PASS: TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/wrong-secret (0.11s)\n"}
{"Time":"2026-10-18T01:44:08.604592233Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/wrong-secret","Elapsed":0.11}
{"Time":"2026-10-18T01:44:08.604613063Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/missing-auth"}
{"Time":"2026-10-18T01:44:08.604619916Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/missing-auth","Output":"=== RUN TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/missing-auth\n"}
{"Time":"2026-10-18T01:44:08.767525032Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/missing-auth","Output":"--- PASS: TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/missing-auth (0.16s)\n"}
{"Time":"2026-10-18T01:44:08.767591787Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/missing-auth","Elapsed":0.16}
{"Time":"2026-10-18T01:44:08.767600407Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/duplicate-auth"}
{"Time":"2026-10-18T01:44:08.767604078Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/duplicate-auth","Output":"=== RUN TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/duplicate-auth\n"}
{"Time":"2026-10-18T01:44:08.831033225Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/duplicate-auth","Output":"--- PASS: TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/duplicate-auth (0.06s)\n"}
{"Time":"2026-10-18T01:44:08.831104749Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/duplicate-auth","Elapsed":0.06}
{"Time":"2026-10-18T01:44:08.831125191Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/body-secret"}
{"Time":"2026-10-18T01:44:08.831136138Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/body-secret","Output":"=== RUN TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/body-secret\n"}
{"Time":"2026-10-18T01:44:08.93524315Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/body-secret","Output":"--- PASS: TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/body-secret (0.10s)\n"}
{"Time":"2026-10-18T01:44:08.935298984Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/body-secret","Elapsed":0.1}
{"Time":"2026-10-18T01:44:08.935312303Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/mismatched-client"}
{"Time":"2026-10-18T01:44:08.935317488Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/mismatched-client","Output":"=== RUN TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/mismatched-client\n"}
{"Time":"2026-10-18T01:44:09.102033666Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/mismatched-client","Output":"--- PASS: TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/mismatched-client (0.17s)\n"}
{"Time":"2026-10-18T01:44:09.10209969Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/mismatched-client","Elapsed":0.17}
{"Time":"2026-10-18T01:44:09.102112828Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests","Output":"--- PASS: TestTokenStatusRejectsMalformedOrUnauthenticatedRequests (1.12s)\n"}
{"Time":"2026-10-18T01:44:09.102124803Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests","Elapsed":1.12}
{"Time":"2026-10-18T01:44:09.102133099Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRevocationDuringDirectoryLookupRefuses"}
{"Time":"2026-10-18T01:44:09.102140142Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRevocationDuringDirectoryLookupRefuses","Output":"=== RUN TestTokenStatusRevocationDuringDirectoryLookupRefuses\n"}
{"Time":"2026-10-18T01:44:09.319280013Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRevocationDuringDirectoryLookupRefuses","Output":"--- PASS: TestTokenStatusRevocationDuringDirectoryLookupRefuses (0.22s)\n"}
{"Time":"2026-10-18T01:44:09.319336497Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRevocationDuringDirectoryLookupRefuses","Elapsed":0.22}
{"Time":"2026-10-18T01:44:09.319352061Z","Action":"output","Package":"keycape/internal/server/oidc","Output":"PASS\n"}
{"Time":"2026-10-18T01:44:10.326829746Z","Action":"output","Package":"keycape/internal/server/oidc","Output":"ok \tkeycape/internal/server/oidc\t4.457s\n"}
{"Time":"2026-10-18T01:44:10.326880134Z","Action":"pass","Package":"keycape/internal/server/oidc","Elapsed":4.457}

View file

@ -0,0 +1,220 @@
{
"returncode": 1,
"seconds": 7.3,
"argv": [
"bwrap",
"--unshare-net",
"--unshare-pid",
"--die-with-parent",
"--new-session",
"--ro-bind",
"/usr",
"/usr",
"--ro-bind",
"/lib",
"/lib",
"--ro-bind",
"/lib64",
"/lib64",
"--symlink",
"usr/bin",
"/bin",
"--proc",
"/proc",
"--dev",
"/dev",
"--tmpfs",
"/tmp",
"--bind",
"/tmp/custodian-keycape-token-status-control-r1",
"/work",
"--ro-bind",
"/home/worsch/go/pkg/mod",
"/modules",
"--ro-bind",
"/home/worsch/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.26.7.linux-amd64",
"/toolchain",
"--chdir",
"/work/source/src",
"--clearenv",
"--bind",
"/tmp/custodian-keycape-token-status-r1/gocache",
"/work/gocache",
"--setenv",
"PATH",
"/toolchain/bin:/usr/bin:/bin",
"--setenv",
"HOME",
"/work/home",
"--setenv",
"GOTOOLCHAIN",
"local",
"--setenv",
"GOPROXY",
"off",
"--setenv",
"GOSUMDB",
"off",
"--setenv",
"GOMODCACHE",
"/modules",
"--setenv",
"GOCACHE",
"/work/gocache",
"--setenv",
"GOMAXPROCS",
"4",
"--setenv",
"CGO_ENABLED",
"1",
"--",
"go",
"test",
"-p",
"4",
"-race",
"-json",
"-count=1",
"-run",
"^TestTokenStatus",
"./internal/server/oidc",
"./internal/config"
],
"source_sha256_at_start": {
"src/go.mod": "sha256:ab299ad4f94feb78e217e2228a4aa161b166460aa28df0c5009451ac1a337456",
"src/Makefile": "sha256:6cdd3aa6e1cccaf4e028278acafe3e6a8d28170de89ff3462984d4f6eb56bd96",
"src/go.sum": "sha256:b10ad54277fb2bd927d3af727fc4dbb9ebe91a038a75be0469d0253ca699226d",
"src/tests/negative/negative_test.go": "sha256:757485bf1967ed95e872d4a1987b7fd985fbffccd05cf41270b9f4061431de3f",
"src/tests/conformance/external_test.go": "sha256:b26bd465e31086f957c9e374b6bee1f0f077b7481eda9daa29cf63816bb87fdb",
"src/tests/migration/scenario_c_test.go": "sha256:9722146ac224768aff85476acbd83f3d478c1e060e5a3c6f99b1786858879b80",
"src/tests/migration/fixtures_test.go": "sha256:3fbcb8f531fa206225f719a67b744b2ecb908095fbae0538432796697971e6de",
"src/tests/migration/scenario_b_test.go": "sha256:40ae7239d116f3e284c767570d39e1f32d44fc29b6fa37ea8835987027942ac0",
"src/tests/profile/profile_test.go": "sha256:e5ab4568b15f681a13c9295b17907ca3872ee33b702bb56aea749f17758d29e6",
"src/internal/domain/assurance_test.go": "sha256:87680e7afd47294dadc13092ca2bb7c47b9625c80e48a701765c00df3293d755",
"src/internal/domain/conformance_test.go": "sha256:6420ec21d4e7a97f60902e1f0debbaf20df91aeac6ab110629b82a67f4c5c9a1",
"src/internal/domain/repository.go": "sha256:28f6d8f7f3d822e73f164af5e877f252db7dc6faa97f0ebf75d76aad64078011",
"src/internal/domain/auth.go": "sha256:ae800c5b1f58408147dd28a5c87ab2603b1537500a289397e711a4cb0e132cee",
"src/internal/domain/model.go": "sha256:ce4309fc7426c66e4ebc18022d2f704c10ebbec243cc98e657d1de765ad09029",
"src/internal/domain/assurance.go": "sha256:d3a29ece7c4fa4d9daa8b5fda89cbe0dd1ffef2ee2ecae0051ee68010edb8fd7",
"src/internal/domain/mfa.go": "sha256:1b7875b08a0f280a20a14a4fa4fdb4fc84c3942795b1d1417943d32634121b87",
"src/internal/authclient/cli.go": "sha256:376a8e0d1d60825a8b3ef21d371e3fc353c0a34e461712a6899e264278edbe7f",
"src/internal/authclient/client.go": "sha256:61fb150349c5d3714450a90834c59af9a579a8a649c4149297a402cfcf6e39c5",
"src/internal/authclient/verify.go": "sha256:804735be927ba7d0228ac378056730040d3ccf2ed020b4da0714085bae972f25",
"src/internal/authclient/client_test.go": "sha256:23642488fcff45e369d22ba0000c47e0e450c5f92560e643ab96303e2a2f43aa",
"src/internal/authclient/verify_test.go": "sha256:7febc6698c87eac5fd8d2520c4f77c68e82f18a2aafa448cfa4990b96fdc46fc",
"src/internal/validator/report.go": "sha256:6bc4d31f932ea9a32020cf33203978906730dfe3601350ded0206ff6c8c297a8",
"src/internal/validator/validator_test.go": "sha256:8216027a464eab4167d8e87b4d27d130854d7b9cd4cc8a1b6f1a47ff1f9a2276",
"src/internal/validator/validator.go": "sha256:3d0a8bf8ebbe54e21e162c8f7df6e4a4d69ab997a01969b74dcbd53decdf559f",
"src/internal/config/account_test.go": "sha256:c6b75097d14dd42415c836407fa2a509857cdd2b771fb7e53d4507cbad2f4b1d",
"src/internal/config/validate.go": "sha256:2b1766979b817802f43bdd0f999ac61452d86cdfafc1539a5d6be896b900a405",
"src/internal/config/optional_mfa_test.go": "sha256:03a00bc309943adbed68bdb9bf47facb6b3b50ef380fd49143b5ed54d684f63e",
"src/internal/config/config_test.go": "sha256:af8602010985a0a5a6b4b2bf779f824e7eb5141e6bc2e8d8fcf600b964955100",
"src/internal/config/token_status_test.go": "sha256:b2b2ec13215c9661d79142ea698cb8b080f6e017de050bedebf3eb8d16df2ad5",
"src/internal/config/config.go": "sha256:19cd234757f8908880d28b8b40939367dfab1322e0401e4e563d56cd3af06683",
"src/internal/errors/taxonomy.go": "sha256:7d60f30a4ff0c9a21a3942451eb0292320a6346d3d81508e414a7dfbdc3c666c",
"src/internal/errors/taxonomy_test.go": "sha256:6c0bdf1f8e90ea343d88f57f80f6c916c92428a9ac2191a518ee7b70df0eed45",
"src/internal/jose/jose.go": "sha256:bcfc30d2175735f37e7ea6f58451e122fbdfe02c562805e5a018c863fb677fab",
"src/internal/jose/jose_test.go": "sha256:cbbcbac620b27c8e93d9dc3fefc3ca4fcd20ab58c6bc8f79eb041b980aaa9a43",
"src/internal/server/policy/policy_test.go": "sha256:25644557ca532b608dff4e02b202c5c3b7c423fb31e98ad0609febf3660183a2",
"src/internal/server/policy/policy.go": "sha256:015d0b42419c501a46171d09a8f7d588dd517d4c98ad14344ff52113294e0fe7",
"src/internal/server/errors/enforcement.go": "sha256:f644546b1423992518fc4078ab561ab68fea1fcff00b734795af6652ae3dd4e2",
"src/internal/server/errors/enforcement_test.go": "sha256:b4ea0c9a3aaebd66b0b0fcc73f544c76d5a377ac0c9561ccc2638a236844374c",
"src/internal/server/telemetry/emitter.go": "sha256:e92f008b6a487a47b8ee2b98011a3a5b821838a61be9c4db4d56cda600e6fda0",
"src/internal/server/telemetry/emitter_test.go": "sha256:29fae2460deb7219eb8edc725b3701a02d2b341fee62bffb2661a520f5a7381a",
"src/internal/server/telemetry/events.go": "sha256:3f9df9822649ae2701c3bbf475c8e6d600bc60b7ccdea4d0e54193162d5b0167",
"src/internal/server/readiness/readiness_test.go": "sha256:10163e0b1d2f41f336d61782c3bfffdd1116a8459247cc933ffb40be645184c6",
"src/internal/server/readiness/readiness.go": "sha256:ffe22b99633b1aa50fc299371398ec0f3fc090c9c8a5f4957c6bfa76b001d073",
"src/internal/server/oidc/discovery_test.go": "sha256:2d0dc24d59497aa5a9aa581f8fd3d9cca300d7e0cb4962907630570d60e7e7f0",
"src/internal/server/oidc/tenant_precondition_test.go": "sha256:2f2c0d90d26da4e3a6c9145aec8c9d7121c791b6b993c2fe8893a763a30fa72f",
"src/internal/server/oidc/userinfo.go": "sha256:f9c986f7a2c79f27a9970a296e6b493014fed77af2f22ba96c4a457d049c916f",
"src/internal/server/oidc/account.go": "sha256:b0e36d8cb5f9b1bf4cee573e46b204800c0e3162a912a122b271def4712ab72a",
"src/internal/server/oidc/policy_runtime_test.go": "sha256:856146013d2aaa60836e669c297024923ed8e2856d31a89744a99baadae4a74a",
"src/internal/server/oidc/handoff_test.go": "sha256:23268b7f9f103f8a8798a168004580bc8fdb3a175161220dac26480a89f28e02",
"src/internal/server/oidc/human_tenant_test.go": "sha256:459664f90d76224958b3dd807d95d7bfcf0fce5f113498bd9886a41be19903a5",
"src/internal/server/oidc/discovery.go": "sha256:00e4c831a2b4d29aecf3597a09c943315b87c5834dbcd4912ba5d8d31ab28cd5",
"src/internal/server/oidc/jwks.go": "sha256:426d6596979ed6d097d6aaae66b6475d1c97687888c6ab1050c05cb14653ec60",
"src/internal/server/oidc/login_session.go": "sha256:efb0fcd066d81d753b7043c9c3d9e74e35cd510ca4304d21a51ba29dec833e1f",
"src/internal/server/oidc/hardening_test.go": "sha256:b41d6a987d5507d5160d970d0f2110ce681851fca1f4f7a3a9573163e8cd6f3e",
"src/internal/server/oidc/jwks_test.go": "sha256:9100e83e58ce2cc2f6d2f47804ee0ce035ca424b209597a12c4ef1a8896163aa",
"src/internal/server/oidc/account_test.go": "sha256:db1f7b8a2989b1d0deffee205ec9f4f35c7472135dcb8a10b5498893d881d763",
"src/internal/server/oidc/token_status_discovery_test.go": "sha256:27adb5797afba46ca2b1f16018721f055f82760498088b58b993730be1fa2031",
"src/internal/server/oidc/principal_type_guard_test.go": "sha256:b3fa097691f07594616e09e8775512d586a019ccdf8302bf594c1ac94f5f3f40",
"src/internal/server/oidc/token_status_http.go": "sha256:a7202d3176454f8ab42c090426e2debcb5cc2c1fc089e79746bb78e5c455af97",
"src/internal/server/oidc/native_optional_test.go": "sha256:329907bf7089d8f3ea05baad919d05677b8c3aaa201e6f233795ae9f69c003b5",
"src/internal/server/oidc/authorize_test.go": "sha256:61042d55e20a11fb6e1e9e6c054e50c0f629b702ed6d8da281d52529e5c29b0d",
"src/internal/server/oidc/session.go": "sha256:48473ebe123285ed21e7738834d9d9168dfc855d911cb9e7ce7e87b069a23a3c",
"src/internal/server/oidc/logout.go": "sha256:d4de80f8056e7a65c7a41f244dd38e447a4e04df729760a8a59cb6bb4e063893",
"src/internal/server/oidc/optional_mfa_test.go": "sha256:d758848d845c181cf1b18710e6ca24977f6b7576320dcad1f7293a5433c3b529",
"src/internal/server/oidc/token.go": "sha256:304fa78d79a5436b9174d88991c19c0834356cb5425c6ed369accb85467a402f",
"src/internal/server/oidc/token_status.go": "sha256:895cffaee454023a6e90603b34a4f90796b5a38714129db103d4020380fdf788",
"src/internal/server/oidc/userinfo_test.go": "sha256:e88c42c7cdf50d5953c9f594f0b99b4f87f5e223ba1cb44b70d696972016da80",
"src/internal/server/oidc/signin_diagnosis_test.go": "sha256:97e80be67c480dfe8eef0683f25868a91a0fdc96472b5296b734b56274270b3e",
"src/internal/server/oidc/policy_isolation_test.go": "sha256:35cb9870f5942c61c0e8c6ca875e9080bbd71c46c7b20fb18da01be88e0fa077",
"src/internal/server/oidc/token_test.go": "sha256:5721617c6e5eb79a3b5e86efa63e9278a8d81a0d7c01bf7b184255c102848e33",
"src/internal/server/oidc/audience_test.go": "sha256:1b751e1aafb5dbdcb751b42b001e32ddfaccfa3d5ba9d5af67b44148d9802ae3",
"src/internal/server/oidc/token_status_transport_test.go": "sha256:3a9945f0292125e86d0014b5245a20c0475d6c518eccff0f9b936356d8e0b347",
"src/internal/server/oidc/tenant_test.go": "sha256:8a3879789589f0c3d6e473ba56dd7d7c86ec6fbdddaafa4b13354f82d5f88713",
"src/internal/server/oidc/assurance_time_test.go": "sha256:911f65e8cea87ef867d6bf98199ed55abd29427b9f8d0838977d228e6186b08a",
"src/internal/server/oidc/signin_diagnosis.go": "sha256:b7976a1cfe1d8d1dca890cf0762cc82d08978a2e7537e45988b147ed0222042f",
"src/internal/server/oidc/authorize.go": "sha256:04f9bfb6b146ecf869161b7a0eb00b6486f424710c2c867e4f4b70af5f44ff5e",
"src/internal/server/oidc/handoff.go": "sha256:a2ffede12c9140a6c7d14632122a39ffbd0e707cd357dcc11d3ad885dc2a7fa4",
"src/internal/server/oidc/token_status_internal_test.go": "sha256:3d3ab41987c7586bd53af8b5c36ff6667b5b2a32d59789915eb9a36b7ceeba53",
"src/internal/server/oidc/token_status_test.go": "sha256:e2613d44deefcbfbd0ce1db5ce2588849fb4be9b8bed33cb538171cc29f63e90",
"src/internal/migration/lldapexport/exporter.go": "sha256:cb041827c9c0edf9da1338e966e7722e3765065582933b8c17b0d9008d44d103",
"src/internal/migration/lldapexport/exporter_test.go": "sha256:6f5cf0e43ea8aea9ceeb6d4d3ae0bfd9f3a5d18636dd49bea0c574c5253a44c2",
"src/internal/migration/tokeycloak/transformer_test.go": "sha256:5c3f5145a37079262db805f5bedaabe1d42edf2ccec1788c09c02f5b8336639b",
"src/internal/migration/tokeycloak/transformer.go": "sha256:856e26fd2075f020ef02373b5b7e9e5a1e98473b5caca95b60b7983287fc10aa",
"src/internal/migration/toldap/generator.go": "sha256:e59ba05203145f52818b3f4e34ab62126481faa9dde697f643fc1cda0638bb46",
"src/internal/migration/toldap/generator_test.go": "sha256:73b0969ce2e2cb018aa5734714c86152d1695f0099a5caf7dd96b773717fc2cc",
"src/internal/adapters/tenantengine/adapter.go": "sha256:a54ef12fafd2efc24c547d562e0a558a7ecef586394e79f7de345dc8cbbbbef3",
"src/internal/adapters/tenantengine/adapter_test.go": "sha256:b9c2db04bc187e6e6e56d4a42d4e657b9e56fef06843500fb4640477dee553e0",
"src/internal/adapters/lldap/adapter.go": "sha256:e7df7a159e9dd33a97932fd8cbde49a61091b8722796bf54ae4be887b4a3f5ef",
"src/internal/adapters/lldap/railiance_admin_test.go": "sha256:63db481bb6e592e91bf4a555e75065fbd3bd8cd408a7967a4b7786e12bef5d5f",
"src/internal/adapters/lldap/adapter_test.go": "sha256:650394ccc25d54c73c03746977a1880b1ed905634da8382da0ecf35843454081",
"src/internal/adapters/lldap/config_internal_test.go": "sha256:cbc71ce9a795b73c40b2714153eb2ee4d2e4007f7f56a7882eb862c23868cb68",
"src/internal/adapters/lldap/config.go": "sha256:33059a27418b00e1987b7ced0b21dd39c1765338bf575fe2333ce3e606e5546b",
"src/internal/adapters/privacyidea/credential_file_test.go": "sha256:9db15562c6fdf01e0806bbc478915413b17adb63c7e9ba213acfc4f9b2b2f0b6",
"src/internal/adapters/privacyidea/read_scope_test.go": "sha256:4685e1b84dad044db12708ecd231d4f91ec22ac45901ed2b5177e3be17d8cdc3",
"src/internal/adapters/privacyidea/adapter.go": "sha256:2559c68b5aa22bc5f61961e47d696b376f8bede533be03715ffc80c0d89ed904",
"src/internal/adapters/privacyidea/adapter_test.go": "sha256:6257489b9c51270da2c96f165c0efd1750b273218df51dca907d29c64c98b349",
"src/internal/adapters/privacyidea/optional_mfa_test.go": "sha256:e2e415c671d1d0e7f30a0ce22f5ec58914d8e366b12e445e13b1d645a9ca6a65",
"src/internal/adapters/privacyidea/enrollment_state_test.go": "sha256:dcf24c486629fa4835cddaa786af31ec78c05907694d4d93bce0f32876806377",
"src/internal/adapters/privacyidea/config.go": "sha256:8dd769b18c04c562714794484630acdbda81b34ab1cfaf9cc7445513207e5021",
"src/internal/adapters/authelia/verify.go": "sha256:189f9bc18b8d4319d2d88ee76a76e3b86468ebafde85ef460a4bafb7b350b4c4",
"src/internal/adapters/authelia/adapter.go": "sha256:2887bd546f43a16338547dab5ce4a90850c670ed6f6c0d610a5d08c72c7f3f45",
"src/internal/adapters/authelia/adapter_test.go": "sha256:d450b37e2b2405c189d444e50f0612e64177d6698cb4665a06ad967ae2224f93",
"src/internal/adapters/authelia/issuer_probe.go": "sha256:5846e2b514077a572611b898f449c04b096b086aeb724da055b2416994a7a7bb",
"src/internal/adapters/authelia/issuer_probe_test.go": "sha256:a85e02399f8a5c81401dbf159e8054420bff56e08c8a98e8026cad8d31fcecec",
"src/internal/adapters/authelia/config.go": "sha256:1b85ea3ffd324bced1ba108492ec780af8ba2b015d22cd51b294afaf36f2ec55",
"src/internal/adapters/authelia/verify_test.go": "sha256:f1570604d5038e49c14791f4d1aa278ad76a7e90294e82813d2c21e6a80a5511",
"src/cmd/keycape-to-keycloak/main.go": "sha256:6141d3f86bbe3df2a814a0c9995e537d32bf7b19f5549e89d40974eb3270faf7",
"src/cmd/lldap-to-ldap/main.go": "sha256:adcc9481d009cad909bd55a71d95c17ce30e29ca63e88a7c9e8101b92d4b872e",
"src/cmd/keycape/clients_test.go": "sha256:6a3982c1b2bd9b15c387ba64d4c87b37304dc6f615ed2ab22eec888f620cb1da",
"src/cmd/keycape/checkconfig.go": "sha256:2c47c9aedf3457627fdec991703e3be50e5ef554e4104cf3741f8347687279f2",
"src/cmd/keycape/main.go": "sha256:2f81ea0b0b33f38298e028d5610625e3834fdf1aa12f6f392579d6207bcf6f1d",
"src/cmd/keycape/checkconfig_test.go": "sha256:06955c996774109b26e4751ab20787f12718a1de96e3483a775fb19688d90e66",
"src/cmd/validator/main.go": "sha256:e53b0a7860537dfb1cec69a00cdafe7073b35802421b5977f2fece4de01b9c1f",
"src/cmd/lldap-export/main_test.go": "sha256:f105e490c717a414a5a3d1e65ceb4edcf74341ad7713b0accf8117a9b1ee02d2",
"src/cmd/lldap-export/main.go": "sha256:f019a6dbb0849d06d5c3729874293d520478ca2887929a0595b59a324097ae75"
},
"counts_including_subtests": {
"pass": 36,
"fail": 7,
"skip": 0
},
"failures": [
"keycape/internal/server/oidc:TestTokenStatusConcurrentIssueReadAndRevoke",
"keycape/internal/server/oidc:TestTokenStatusActualIssueRevokeAndFreshGrant",
"keycape/internal/server/oidc:TestTokenStatusPublicClientMayRevokeButNotIntrospect",
"keycape/internal/server/oidc:TestTokenStatusServiceGrantIsCurrentAndDistinct",
"keycape/internal/server/oidc:TestTokenStatusRevocationDuringDirectoryLookupRefuses",
"keycape/internal/server/oidc:TestTokenStatusActualHTTPSFlowAndAuthenticationEncoding",
"keycape/internal/server/oidc:TestTokenStatusRevocationDuringUserInfoLookupRefuses"
],
"skips": [],
"source": "/tmp/custodian-keycape-token-status-control-r1/source",
"native_acceptance": false,
"credentials_accessed": false,
"network": "private empty namespace; fixture loopback only",
"home": "new empty sandbox HOME"
}

View file

@ -0,0 +1,250 @@
{"Time":"2026-10-18T01:48:40.055464485Z","Action":"start","Package":"keycape/internal/server/oidc"}
{"Time":"2026-10-18T01:48:40.056054823Z","Action":"start","Package":"keycape/internal/config"}
{"Time":"2026-10-18T01:48:40.075378651Z","Action":"run","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS"}
{"Time":"2026-10-18T01:48:40.075440212Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS","Output":"=== RUN TestTokenStatusConfigIsExplicitBoundedAndHTTPS\n"}
{"Time":"2026-10-18T01:48:40.075514847Z","Action":"run","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/legacy"}
{"Time":"2026-10-18T01:48:40.075528283Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/legacy","Output":"=== RUN TestTokenStatusConfigIsExplicitBoundedAndHTTPS/legacy\n"}
{"Time":"2026-10-18T01:48:40.075549879Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/legacy","Output":"--- PASS: TestTokenStatusConfigIsExplicitBoundedAndHTTPS/legacy (0.00s)\n"}
{"Time":"2026-10-18T01:48:40.07570723Z","Action":"pass","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/legacy","Elapsed":0}
{"Time":"2026-10-18T01:48:40.075729846Z","Action":"run","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/enabled-default"}
{"Time":"2026-10-18T01:48:40.075736744Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/enabled-default","Output":"=== RUN TestTokenStatusConfigIsExplicitBoundedAndHTTPS/enabled-default\n"}
{"Time":"2026-10-18T01:48:40.07578101Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusExactExpiryCapacityAndTombstones"}
{"Time":"2026-10-18T01:48:40.075793715Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusExactExpiryCapacityAndTombstones","Output":"=== RUN TestTokenStatusExactExpiryCapacityAndTombstones\n"}
{"Time":"2026-10-18T01:48:40.075997568Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/enabled-default","Output":"--- PASS: TestTokenStatusConfigIsExplicitBoundedAndHTTPS/enabled-default (0.00s)\n"}
{"Time":"2026-10-18T01:48:40.076030337Z","Action":"pass","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/enabled-default","Elapsed":0}
{"Time":"2026-10-18T01:48:40.076051536Z","Action":"run","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/enabled-bounded"}
{"Time":"2026-10-18T01:48:40.076058515Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/enabled-bounded","Output":"=== RUN TestTokenStatusConfigIsExplicitBoundedAndHTTPS/enabled-bounded\n"}
{"Time":"2026-10-18T01:48:40.076683108Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/enabled-bounded","Output":"--- PASS: TestTokenStatusConfigIsExplicitBoundedAndHTTPS/enabled-bounded (0.00s)\n"}
{"Time":"2026-10-18T01:48:40.076748852Z","Action":"pass","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/enabled-bounded","Elapsed":0}
{"Time":"2026-10-18T01:48:40.077033548Z","Action":"run","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/http"}
{"Time":"2026-10-18T01:48:40.077070077Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/http","Output":"=== RUN TestTokenStatusConfigIsExplicitBoundedAndHTTPS/http\n"}
{"Time":"2026-10-18T01:48:40.077340513Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/http","Output":"--- PASS: TestTokenStatusConfigIsExplicitBoundedAndHTTPS/http (0.00s)\n"}
{"Time":"2026-10-18T01:48:40.077452102Z","Action":"pass","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/http","Elapsed":0}
{"Time":"2026-10-18T01:48:40.077573149Z","Action":"run","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/userinfo-url"}
{"Time":"2026-10-18T01:48:40.077596143Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/userinfo-url","Output":"=== RUN TestTokenStatusConfigIsExplicitBoundedAndHTTPS/userinfo-url\n"}
{"Time":"2026-10-18T01:48:40.077929222Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/userinfo-url","Output":"--- PASS: TestTokenStatusConfigIsExplicitBoundedAndHTTPS/userinfo-url (0.00s)\n"}
{"Time":"2026-10-18T01:48:40.07797549Z","Action":"pass","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/userinfo-url","Elapsed":0}
{"Time":"2026-10-18T01:48:40.077991315Z","Action":"run","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/query"}
{"Time":"2026-10-18T01:48:40.07799714Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/query","Output":"=== RUN TestTokenStatusConfigIsExplicitBoundedAndHTTPS/query\n"}
{"Time":"2026-10-18T01:48:40.077916853Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusExactExpiryCapacityAndTombstones","Output":"--- PASS: TestTokenStatusExactExpiryCapacityAndTombstones (0.00s)\n"}
{"Time":"2026-10-18T01:48:40.078184136Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusExactExpiryCapacityAndTombstones","Elapsed":0}
{"Time":"2026-10-18T01:48:40.078223866Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/query","Output":"--- PASS: TestTokenStatusConfigIsExplicitBoundedAndHTTPS/query (0.00s)\n"}
{"Time":"2026-10-18T01:48:40.07846276Z","Action":"pass","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/query","Elapsed":0}
{"Time":"2026-10-18T01:48:40.078496088Z","Action":"run","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/too-large"}
{"Time":"2026-10-18T01:48:40.078529605Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/too-large","Output":"=== RUN TestTokenStatusConfigIsExplicitBoundedAndHTTPS/too-large\n"}
{"Time":"2026-10-18T01:48:40.078640662Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke"}
{"Time":"2026-10-18T01:48:40.078662042Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":"=== RUN TestTokenStatusConcurrentIssueReadAndRevoke\n"}
{"Time":"2026-10-18T01:48:40.078642797Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/too-large","Output":"--- PASS: TestTokenStatusConfigIsExplicitBoundedAndHTTPS/too-large (0.00s)\n"}
{"Time":"2026-10-18T01:48:40.078712647Z","Action":"pass","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/too-large","Elapsed":0}
{"Time":"2026-10-18T01:48:40.078825471Z","Action":"run","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/negative"}
{"Time":"2026-10-18T01:48:40.078839682Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/negative","Output":"=== RUN TestTokenStatusConfigIsExplicitBoundedAndHTTPS/negative\n"}
{"Time":"2026-10-18T01:48:40.079148385Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/negative","Output":"--- PASS: TestTokenStatusConfigIsExplicitBoundedAndHTTPS/negative (0.00s)\n"}
{"Time":"2026-10-18T01:48:40.079189053Z","Action":"pass","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/negative","Elapsed":0}
{"Time":"2026-10-18T01:48:40.079214698Z","Action":"run","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/disabled-capacity"}
{"Time":"2026-10-18T01:48:40.079219622Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/disabled-capacity","Output":"=== RUN TestTokenStatusConfigIsExplicitBoundedAndHTTPS/disabled-capacity\n"}
{"Time":"2026-10-18T01:48:40.079497663Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/disabled-capacity","Output":"--- PASS: TestTokenStatusConfigIsExplicitBoundedAndHTTPS/disabled-capacity (0.00s)\n"}
{"Time":"2026-10-18T01:48:40.079530738Z","Action":"pass","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS/disabled-capacity","Elapsed":0}
{"Time":"2026-10-18T01:48:40.079584887Z","Action":"output","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS","Output":"--- PASS: TestTokenStatusConfigIsExplicitBoundedAndHTTPS (0.00s)\n"}
{"Time":"2026-10-18T01:48:40.079698812Z","Action":"pass","Package":"keycape/internal/config","Test":"TestTokenStatusConfigIsExplicitBoundedAndHTTPS","Elapsed":0}
{"Time":"2026-10-18T01:48:40.079826776Z","Action":"output","Package":"keycape/internal/config","Output":"PASS\n"}
{"Time":"2026-10-18T01:48:40.080172046Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.080547044Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.081330785Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.082155552Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.095238697Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.095364136Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.095786783Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.095815999Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.096186112Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.096213317Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.096460354Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.096570878Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.096787513Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.096929967Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.097216601Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.097264248Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.097638466Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.097712597Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.097854253Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.097910323Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.098212189Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.098478017Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.098532382Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.098631536Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.098934714Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.099259612Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.099307908Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.099388594Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.099421246Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.099439912Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.099527181Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.09954343Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.099567298Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.099656262Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.099888907Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.099916022Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.099925427Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.100073917Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.100196381Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.100302829Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.100499125Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.100532796Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.100778065Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.100812249Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.101080873Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.101116455Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.101299076Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.101327363Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.101565689Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.101593363Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.101753049Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.102056515Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.102085596Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.102268592Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.102287934Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.102519984Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.102556315Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.102836016Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.102868668Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.103046183Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.103064713Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.103280451Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.103365871Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.10339488Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":" token_status_internal_test.go:61: concurrent revoked token accepted\n"}
{"Time":"2026-10-18T01:48:40.103600157Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Output":"--- FAIL: TestTokenStatusConcurrentIssueReadAndRevoke (0.03s)\n"}
{"Time":"2026-10-18T01:48:40.103634684Z","Action":"fail","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusConcurrentIssueReadAndRevoke","Elapsed":0.03}
{"Time":"2026-10-18T01:48:40.104075815Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusDiscoveryOnlyAdvertisesEnabledProfile"}
{"Time":"2026-10-18T01:48:40.104105292Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusDiscoveryOnlyAdvertisesEnabledProfile","Output":"=== RUN TestTokenStatusDiscoveryOnlyAdvertisesEnabledProfile\n"}
{"Time":"2026-10-18T01:48:40.105277418Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusDiscoveryOnlyAdvertisesEnabledProfile","Output":"--- PASS: TestTokenStatusDiscoveryOnlyAdvertisesEnabledProfile (0.00s)\n"}
{"Time":"2026-10-18T01:48:40.105332828Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusDiscoveryOnlyAdvertisesEnabledProfile","Elapsed":0}
{"Time":"2026-10-18T01:48:40.105350818Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusActualIssueRevokeAndFreshGrant"}
{"Time":"2026-10-18T01:48:40.105355976Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusActualIssueRevokeAndFreshGrant","Output":"=== RUN TestTokenStatusActualIssueRevokeAndFreshGrant\n"}
{"Time":"2026-10-18T01:48:40.397837632Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusActualIssueRevokeAndFreshGrant","Output":" token_status_test.go:139: revoked metadata leaked or remained active\n"}
{"Time":"2026-10-18T01:48:40.397912854Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusActualIssueRevokeAndFreshGrant","Output":"--- FAIL: TestTokenStatusActualIssueRevokeAndFreshGrant (0.29s)\n"}
{"Time":"2026-10-18T01:48:40.397927264Z","Action":"fail","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusActualIssueRevokeAndFreshGrant","Elapsed":0.29}
{"Time":"2026-10-18T01:48:40.397935821Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusNoCrossClientOrIDTokenAuthority"}
{"Time":"2026-10-18T01:48:40.397944451Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusNoCrossClientOrIDTokenAuthority","Output":"=== RUN TestTokenStatusNoCrossClientOrIDTokenAuthority\n"}
{"Time":"2026-10-18T01:48:40.565453191Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusNoCrossClientOrIDTokenAuthority","Output":"--- PASS: TestTokenStatusNoCrossClientOrIDTokenAuthority (0.17s)\n"}
{"Time":"2026-10-18T01:48:40.565506952Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusNoCrossClientOrIDTokenAuthority","Elapsed":0.17}
{"Time":"2026-10-18T01:48:40.56551927Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusCapacityFailsIssuanceWithoutEvictingLiveToken"}
{"Time":"2026-10-18T01:48:40.565527196Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusCapacityFailsIssuanceWithoutEvictingLiveToken","Output":"=== RUN TestTokenStatusCapacityFailsIssuanceWithoutEvictingLiveToken\n"}
{"Time":"2026-10-18T01:48:40.680590916Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusCapacityFailsIssuanceWithoutEvictingLiveToken","Output":"--- PASS: TestTokenStatusCapacityFailsIssuanceWithoutEvictingLiveToken (0.11s)\n"}
{"Time":"2026-10-18T01:48:40.680729592Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusCapacityFailsIssuanceWithoutEvictingLiveToken","Elapsed":0.11}
{"Time":"2026-10-18T01:48:40.680751946Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRestartAndUnavailableStoreRefuse"}
{"Time":"2026-10-18T01:48:40.680760386Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRestartAndUnavailableStoreRefuse","Output":"=== RUN TestTokenStatusRestartAndUnavailableStoreRefuse\n"}
{"Time":"2026-10-18T01:48:40.753639889Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRestartAndUnavailableStoreRefuse","Output":"--- PASS: TestTokenStatusRestartAndUnavailableStoreRefuse (0.07s)\n"}
{"Time":"2026-10-18T01:48:40.753710756Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRestartAndUnavailableStoreRefuse","Elapsed":0.07}
{"Time":"2026-10-18T01:48:40.753728628Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition"}
{"Time":"2026-10-18T01:48:40.753738375Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition","Output":"=== RUN TestTokenStatusRegistrationDriftAndDirectoryDisposition\n"}
{"Time":"2026-10-18T01:48:40.753864022Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/removed"}
{"Time":"2026-10-18T01:48:40.753878756Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/removed","Output":"=== RUN TestTokenStatusRegistrationDriftAndDirectoryDisposition/removed\n"}
{"Time":"2026-10-18T01:48:40.866976317Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/removed","Output":"--- PASS: TestTokenStatusRegistrationDriftAndDirectoryDisposition/removed (0.11s)\n"}
{"Time":"2026-10-18T01:48:40.867046859Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/removed","Elapsed":0.11}
{"Time":"2026-10-18T01:48:40.867059835Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/audience"}
{"Time":"2026-10-18T01:48:40.867073117Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/audience","Output":"=== RUN TestTokenStatusRegistrationDriftAndDirectoryDisposition/audience\n"}
{"Time":"2026-10-18T01:48:40.932707099Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/audience","Output":"--- PASS: TestTokenStatusRegistrationDriftAndDirectoryDisposition/audience (0.07s)\n"}
{"Time":"2026-10-18T01:48:40.932767632Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/audience","Elapsed":0.07}
{"Time":"2026-10-18T01:48:40.932779561Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/secret"}
{"Time":"2026-10-18T01:48:40.932786523Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/secret","Output":"=== RUN TestTokenStatusRegistrationDriftAndDirectoryDisposition/secret\n"}
{"Time":"2026-10-18T01:48:41.022808976Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/secret","Output":"--- PASS: TestTokenStatusRegistrationDriftAndDirectoryDisposition/secret (0.09s)\n"}
{"Time":"2026-10-18T01:48:41.022890528Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/secret","Elapsed":0.09}
{"Time":"2026-10-18T01:48:41.022913107Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/scopes"}
{"Time":"2026-10-18T01:48:41.022962973Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/scopes","Output":"=== RUN TestTokenStatusRegistrationDriftAndDirectoryDisposition/scopes\n"}
{"Time":"2026-10-18T01:48:41.083654782Z","Action":"output","Package":"keycape/internal/config","Output":"ok \tkeycape/internal/config\t1.027s\n"}
{"Time":"2026-10-18T01:48:41.083707533Z","Action":"pass","Package":"keycape/internal/config","Elapsed":1.028}
{"Time":"2026-10-18T01:48:41.127075013Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/scopes","Output":"--- PASS: TestTokenStatusRegistrationDriftAndDirectoryDisposition/scopes (0.10s)\n"}
{"Time":"2026-10-18T01:48:41.1271311Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/scopes","Elapsed":0.1}
{"Time":"2026-10-18T01:48:41.127148973Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/lifetime"}
{"Time":"2026-10-18T01:48:41.127153247Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/lifetime","Output":"=== RUN TestTokenStatusRegistrationDriftAndDirectoryDisposition/lifetime\n"}
{"Time":"2026-10-18T01:48:41.181026212Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/lifetime","Output":"--- PASS: TestTokenStatusRegistrationDriftAndDirectoryDisposition/lifetime (0.05s)\n"}
{"Time":"2026-10-18T01:48:41.181109441Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/lifetime","Elapsed":0.05}
{"Time":"2026-10-18T01:48:41.181133138Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/suspended"}
{"Time":"2026-10-18T01:48:41.181140794Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/suspended","Output":"=== RUN TestTokenStatusRegistrationDriftAndDirectoryDisposition/suspended\n"}
{"Time":"2026-10-18T01:48:41.42974976Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/suspended","Output":"--- PASS: TestTokenStatusRegistrationDriftAndDirectoryDisposition/suspended (0.25s)\n"}
{"Time":"2026-10-18T01:48:41.429804855Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/suspended","Elapsed":0.25}
{"Time":"2026-10-18T01:48:41.429821943Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/disabled"}
{"Time":"2026-10-18T01:48:41.429827092Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/disabled","Output":"=== RUN TestTokenStatusRegistrationDriftAndDirectoryDisposition/disabled\n"}
{"Time":"2026-10-18T01:48:41.506139609Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/disabled","Output":"--- PASS: TestTokenStatusRegistrationDriftAndDirectoryDisposition/disabled (0.08s)\n"}
{"Time":"2026-10-18T01:48:41.506205317Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/disabled","Elapsed":0.08}
{"Time":"2026-10-18T01:48:41.506224668Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/user-removed"}
{"Time":"2026-10-18T01:48:41.506232117Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/user-removed","Output":"=== RUN TestTokenStatusRegistrationDriftAndDirectoryDisposition/user-removed\n"}
{"Time":"2026-10-18T01:48:41.633160438Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/user-removed","Output":"--- PASS: TestTokenStatusRegistrationDriftAndDirectoryDisposition/user-removed (0.13s)\n"}
{"Time":"2026-10-18T01:48:41.633222324Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition/user-removed","Elapsed":0.13}
{"Time":"2026-10-18T01:48:41.633235904Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition","Output":"--- PASS: TestTokenStatusRegistrationDriftAndDirectoryDisposition (0.88s)\n"}
{"Time":"2026-10-18T01:48:41.633253172Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRegistrationDriftAndDirectoryDisposition","Elapsed":0.88}
{"Time":"2026-10-18T01:48:41.633261098Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusPublicClientMayRevokeButNotIntrospect"}
{"Time":"2026-10-18T01:48:41.633268753Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusPublicClientMayRevokeButNotIntrospect","Output":"=== RUN TestTokenStatusPublicClientMayRevokeButNotIntrospect\n"}
{"Time":"2026-10-18T01:48:41.761459379Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusPublicClientMayRevokeButNotIntrospect","Output":" token_status_test.go:265: public revocation not enforced\n"}
{"Time":"2026-10-18T01:48:41.76153626Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusPublicClientMayRevokeButNotIntrospect","Output":"--- FAIL: TestTokenStatusPublicClientMayRevokeButNotIntrospect (0.13s)\n"}
{"Time":"2026-10-18T01:48:41.761547162Z","Action":"fail","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusPublicClientMayRevokeButNotIntrospect","Elapsed":0.13}
{"Time":"2026-10-18T01:48:41.76155911Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusServiceGrantIsCurrentAndDistinct"}
{"Time":"2026-10-18T01:48:41.761567135Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusServiceGrantIsCurrentAndDistinct","Output":"=== RUN TestTokenStatusServiceGrantIsCurrentAndDistinct\n"}
{"Time":"2026-10-18T01:48:41.900042565Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusServiceGrantIsCurrentAndDistinct","Output":" token_status_test.go:295: service revocation not token-scoped\n"}
{"Time":"2026-10-18T01:48:41.900152269Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusServiceGrantIsCurrentAndDistinct","Output":"--- FAIL: TestTokenStatusServiceGrantIsCurrentAndDistinct (0.14s)\n"}
{"Time":"2026-10-18T01:48:41.900164875Z","Action":"fail","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusServiceGrantIsCurrentAndDistinct","Elapsed":0.14}
{"Time":"2026-10-18T01:48:41.900178572Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests"}
{"Time":"2026-10-18T01:48:41.900183919Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests","Output":"=== RUN TestTokenStatusRejectsMalformedOrUnauthenticatedRequests\n"}
{"Time":"2026-10-18T01:48:41.90019134Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/get"}
{"Time":"2026-10-18T01:48:41.900196471Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/get","Output":"=== RUN TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/get\n"}
{"Time":"2026-10-18T01:48:41.93630134Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/get","Output":"--- PASS: TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/get (0.04s)\n"}
{"Time":"2026-10-18T01:48:41.936392279Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/get","Elapsed":0.04}
{"Time":"2026-10-18T01:48:41.936975764Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/query"}
{"Time":"2026-10-18T01:48:41.937015819Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/query","Output":"=== RUN TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/query\n"}
{"Time":"2026-10-18T01:48:42.056450011Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/query","Output":"--- PASS: TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/query (0.12s)\n"}
{"Time":"2026-10-18T01:48:42.056512735Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/query","Elapsed":0.12}
{"Time":"2026-10-18T01:48:42.056525756Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/json"}
{"Time":"2026-10-18T01:48:42.056531202Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/json","Output":"=== RUN TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/json\n"}
{"Time":"2026-10-18T01:48:42.228757364Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/json","Output":"--- PASS: TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/json (0.17s)\n"}
{"Time":"2026-10-18T01:48:42.228833993Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/json","Elapsed":0.17}
{"Time":"2026-10-18T01:48:42.228879566Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/duplicate-token"}
{"Time":"2026-10-18T01:48:42.228891198Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/duplicate-token","Output":"=== RUN TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/duplicate-token\n"}
{"Time":"2026-10-18T01:48:42.511281739Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/duplicate-token","Output":"--- PASS: TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/duplicate-token (0.28s)\n"}
{"Time":"2026-10-18T01:48:42.511399225Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/duplicate-token","Elapsed":0.28}
{"Time":"2026-10-18T01:48:42.511442796Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/oversize"}
{"Time":"2026-10-18T01:48:42.511453328Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/oversize","Output":"=== RUN TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/oversize\n"}
{"Time":"2026-10-18T01:48:42.624039379Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/oversize","Output":"--- PASS: TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/oversize (0.11s)\n"}
{"Time":"2026-10-18T01:48:42.624099776Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/oversize","Elapsed":0.11}
{"Time":"2026-10-18T01:48:42.624110714Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/wrong-secret"}
{"Time":"2026-10-18T01:48:42.624137712Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/wrong-secret","Output":"=== RUN TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/wrong-secret\n"}
{"Time":"2026-10-18T01:48:42.720921231Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/wrong-secret","Output":"--- PASS: TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/wrong-secret (0.10s)\n"}
{"Time":"2026-10-18T01:48:42.720980303Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/wrong-secret","Elapsed":0.1}
{"Time":"2026-10-18T01:48:42.720991268Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/missing-auth"}
{"Time":"2026-10-18T01:48:42.720998978Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/missing-auth","Output":"=== RUN TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/missing-auth\n"}
{"Time":"2026-10-18T01:48:42.795231777Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/missing-auth","Output":"--- PASS: TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/missing-auth (0.07s)\n"}
{"Time":"2026-10-18T01:48:42.795283058Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/missing-auth","Elapsed":0.07}
{"Time":"2026-10-18T01:48:42.795297738Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/duplicate-auth"}
{"Time":"2026-10-18T01:48:42.795302779Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/duplicate-auth","Output":"=== RUN TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/duplicate-auth\n"}
{"Time":"2026-10-18T01:48:42.930326295Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/duplicate-auth","Output":"--- PASS: TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/duplicate-auth (0.13s)\n"}
{"Time":"2026-10-18T01:48:42.930382716Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/duplicate-auth","Elapsed":0.13}
{"Time":"2026-10-18T01:48:42.930393419Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/body-secret"}
{"Time":"2026-10-18T01:48:42.930400336Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/body-secret","Output":"=== RUN TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/body-secret\n"}
{"Time":"2026-10-18T01:48:43.13867242Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/body-secret","Output":"--- PASS: TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/body-secret (0.21s)\n"}
{"Time":"2026-10-18T01:48:43.138731645Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/body-secret","Elapsed":0.21}
{"Time":"2026-10-18T01:48:43.138743846Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/mismatched-client"}
{"Time":"2026-10-18T01:48:43.13875087Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/mismatched-client","Output":"=== RUN TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/mismatched-client\n"}
{"Time":"2026-10-18T01:48:43.239401537Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/mismatched-client","Output":"--- PASS: TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/mismatched-client (0.10s)\n"}
{"Time":"2026-10-18T01:48:43.239482548Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests/mismatched-client","Elapsed":0.1}
{"Time":"2026-10-18T01:48:43.239495434Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests","Output":"--- PASS: TestTokenStatusRejectsMalformedOrUnauthenticatedRequests (1.34s)\n"}
{"Time":"2026-10-18T01:48:43.239503459Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRejectsMalformedOrUnauthenticatedRequests","Elapsed":1.34}
{"Time":"2026-10-18T01:48:43.239514848Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRevocationDuringDirectoryLookupRefuses"}
{"Time":"2026-10-18T01:48:43.239522323Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRevocationDuringDirectoryLookupRefuses","Output":"=== RUN TestTokenStatusRevocationDuringDirectoryLookupRefuses\n"}
{"Time":"2026-10-18T01:48:43.464899947Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRevocationDuringDirectoryLookupRefuses","Output":" token_status_test.go:374: delayed lookup resurrected revoked token\n"}
{"Time":"2026-10-18T01:48:43.464967766Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRevocationDuringDirectoryLookupRefuses","Output":"--- FAIL: TestTokenStatusRevocationDuringDirectoryLookupRefuses (0.23s)\n"}
{"Time":"2026-10-18T01:48:43.464980741Z","Action":"fail","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRevocationDuringDirectoryLookupRefuses","Elapsed":0.23}
{"Time":"2026-10-18T01:48:43.465176154Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusActualHTTPSFlowAndAuthenticationEncoding"}
{"Time":"2026-10-18T01:48:43.465194757Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusActualHTTPSFlowAndAuthenticationEncoding","Output":"=== RUN TestTokenStatusActualHTTPSFlowAndAuthenticationEncoding\n"}
{"Time":"2026-10-18T01:48:43.777733468Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusActualHTTPSFlowAndAuthenticationEncoding","Output":" token_status_transport_test.go:64: revocation did not affect HTTPS introspection\n"}
{"Time":"2026-10-18T01:48:43.778231381Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusActualHTTPSFlowAndAuthenticationEncoding","Output":"--- FAIL: TestTokenStatusActualHTTPSFlowAndAuthenticationEncoding (0.31s)\n"}
{"Time":"2026-10-18T01:48:43.778261815Z","Action":"fail","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusActualHTTPSFlowAndAuthenticationEncoding","Elapsed":0.31}
{"Time":"2026-10-18T01:48:43.778380276Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRevocationDuringUserInfoLookupRefuses"}
{"Time":"2026-10-18T01:48:43.778396849Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRevocationDuringUserInfoLookupRefuses","Output":"=== RUN TestTokenStatusRevocationDuringUserInfoLookupRefuses\n"}
{"Time":"2026-10-18T01:48:43.860160997Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRevocationDuringUserInfoLookupRefuses","Output":" token_status_transport_test.go:95: delayed UserInfo lookup resurrected revoked token\n"}
{"Time":"2026-10-18T01:48:43.860223531Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRevocationDuringUserInfoLookupRefuses","Output":"--- FAIL: TestTokenStatusRevocationDuringUserInfoLookupRefuses (0.08s)\n"}
{"Time":"2026-10-18T01:48:43.860232963Z","Action":"fail","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusRevocationDuringUserInfoLookupRefuses","Elapsed":0.08}
{"Time":"2026-10-18T01:48:43.860244037Z","Action":"run","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusLegacyGrantHasNoStatusClaim"}
{"Time":"2026-10-18T01:48:43.860270286Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusLegacyGrantHasNoStatusClaim","Output":"=== RUN TestTokenStatusLegacyGrantHasNoStatusClaim\n"}
{"Time":"2026-10-18T01:48:44.043210266Z","Action":"output","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusLegacyGrantHasNoStatusClaim","Output":"--- PASS: TestTokenStatusLegacyGrantHasNoStatusClaim (0.18s)\n"}
{"Time":"2026-10-18T01:48:44.043267895Z","Action":"pass","Package":"keycape/internal/server/oidc","Test":"TestTokenStatusLegacyGrantHasNoStatusClaim","Elapsed":0.18}
{"Time":"2026-10-18T01:48:44.04328363Z","Action":"output","Package":"keycape/internal/server/oidc","Output":"FAIL\n"}
{"Time":"2026-10-18T01:48:44.049374Z","Action":"output","Package":"keycape/internal/server/oidc","Output":"FAIL\tkeycape/internal/server/oidc\t3.993s\n"}
{"Time":"2026-10-18T01:48:44.04943493Z","Action":"fail","Package":"keycape/internal/server/oidc","Elapsed":3.9939999999999998}

View file

@ -0,0 +1,55 @@
"""Freeze exact owner delta and verify replay from its published Git base."""
import difflib
import hashlib
import io
import json
from pathlib import Path
import subprocess
import tarfile
HERE = Path(__file__).resolve().parent
SOURCE = Path('/tmp/custodian-keycape-token-status-r1/source')
OWNER = Path('/home/worsch/key-cape')
baseline = json.loads((HERE / 'baseline.json').read_text())
commit = baseline['commit']
assert subprocess.check_output(['git', '-C', str(OWNER), 'rev-parse', 'HEAD'], text=True).strip() == commit
archive = subprocess.check_output(['git', '-C', str(OWNER), 'archive', commit])
assert 'sha256:' + hashlib.sha256(archive).hexdigest() == baseline['archive_sha256']
with tarfile.open(fileobj=io.BytesIO(archive)) as tar:
original = {m.name: tar.extractfile(m).read() for m in tar.getmembers() if m.isfile()}
current = {str(p.relative_to(SOURCE)): p.read_bytes() for p in SOURCE.rglob('*') if p.is_file()}
assert not original.keys() - current.keys(), 'unexpected deletion'
rows, patch = [], []
for name, raw in sorted(current.items()):
before = original.get(name)
if before == raw:
continue
before_path = HERE / 'preimages' / name
after_path = HERE / 'postimages' / name
if before is not None:
before_path.parent.mkdir(parents=True, exist_ok=True)
before_path.write_bytes(before)
after_path.parent.mkdir(parents=True, exist_ok=True)
after_path.write_bytes(raw)
rows.append({'path': name, 'before_sha256': None if before is None else 'sha256:' + hashlib.sha256(before).hexdigest(),
'sha256': 'sha256:' + hashlib.sha256(raw).hexdigest()})
patch.append(f'diff --git a/{name} b/{name}\n')
patch.extend(difflib.unified_diff((before or b'').decode().splitlines(keepends=True), raw.decode().splitlines(keepends=True),
fromfile='/dev/null' if before is None else 'a/' + name, tofile='b/' + name))
assert len(rows) == 18, [row['path'] for row in rows]
(HERE / 'owner-source.patch').write_text(''.join(patch))
replay = Path('/tmp/custodian-keycape-token-status-r1-replay-r2')
replay.mkdir(exist_ok=False)
with tarfile.open(fileobj=io.BytesIO(archive)) as tar:
tar.extractall(replay, filter='data')
result = subprocess.run(['patch', '-p1', '--batch', '--fuzz=0', '-i', str(HERE / 'owner-source.patch')], cwd=replay,
capture_output=True, text=True, check=True)
assert all((replay / row['path']).read_bytes() == (SOURCE / row['path']).read_bytes() for row in rows)
assert all((replay / name).read_bytes() == raw for name, raw in current.items())
receipt = {'owner': 'key-cape', 'base_revision': 'git:' + commit, 'files': rows,
'patch_sha256': 'sha256:' + hashlib.sha256((HERE / 'owner-source.patch').read_bytes()).hexdigest(),
'replay': {'fuzz': 0, 'all_postimages_exact': True, 'all_unrelated_archive_files_unchanged': True,
'stdout': result.stdout, 'stderr': result.stderr},
'canonical_owner_modified': False, 'independent_owner_assent': False, 'native_admission': False}
(HERE / 'source-manifest.json').write_text(json.dumps(receipt, indent=2) + '\n')
print(json.dumps({'files': len(rows), 'exact_replay': True, 'owner_modified': False}))

View file

@ -0,0 +1,96 @@
{
"returncode": 0,
"seconds": 25.7,
"argv": [
"bwrap",
"--unshare-net",
"--unshare-pid",
"--die-with-parent",
"--new-session",
"--ro-bind",
"/usr",
"/usr",
"--ro-bind",
"/lib",
"/lib",
"--ro-bind",
"/lib64",
"/lib64",
"--symlink",
"usr/bin",
"/bin",
"--proc",
"/proc",
"--dev",
"/dev",
"--tmpfs",
"/tmp",
"--bind",
"/tmp/custodian-keycape-token-status-r1",
"/work",
"--ro-bind",
"/home/worsch/go/pkg/mod",
"/modules",
"--ro-bind",
"/home/worsch/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.26.7.linux-amd64",
"/toolchain",
"--chdir",
"/work/source/src",
"--clearenv",
"--setenv",
"PATH",
"/toolchain/bin:/usr/bin:/bin",
"--setenv",
"HOME",
"/work/home",
"--setenv",
"GOTOOLCHAIN",
"local",
"--setenv",
"GOPROXY",
"off",
"--setenv",
"GOSUMDB",
"off",
"--setenv",
"GOMODCACHE",
"/modules",
"--setenv",
"GOCACHE",
"/work/gocache",
"--setenv",
"GOMAXPROCS",
"4",
"--setenv",
"CGO_ENABLED",
"1",
"--",
"go",
"test",
"-p",
"4",
"-race",
"-json",
"-count=1",
"./..."
],
"counts_including_subtests": {
"pass": 659,
"fail": 0,
"skip": 6
},
"failures": [],
"skips": [
"keycape/tests/conformance:TestIssuerAdvertisesItself",
"keycape/tests/conformance:TestProfileAuthorizationSurface",
"keycape/tests/conformance:TestPublishedKeysAreUsable",
"keycape/tests/conformance:TestExcludedGrantsAreNotAdvertised",
"keycape/tests/conformance:TestServiceTokenVerifiesAgainstPublishedKeys",
"keycape/internal/server/oidc:TestNativeOptionalEnrollmentAndOldSession"
],
"source": "/tmp/custodian-keycape-token-status-r1/source",
"native_acceptance": false,
"credentials_accessed": false,
"network": "private empty namespace; fixture loopback only",
"home": "new empty sandbox HOME"
}

View file

@ -0,0 +1,122 @@
{
"base": "d803e9282a47b49daf930fb905312fa5104c0ace",
"source": "/tmp/custodian-keycape-token-status-r1/source",
"sha256": {
"src/go.mod": "sha256:ab299ad4f94feb78e217e2228a4aa161b166460aa28df0c5009451ac1a337456",
"src/Makefile": "sha256:6cdd3aa6e1cccaf4e028278acafe3e6a8d28170de89ff3462984d4f6eb56bd96",
"src/go.sum": "sha256:b10ad54277fb2bd927d3af727fc4dbb9ebe91a038a75be0469d0253ca699226d",
"src/tests/negative/negative_test.go": "sha256:757485bf1967ed95e872d4a1987b7fd985fbffccd05cf41270b9f4061431de3f",
"src/tests/conformance/external_test.go": "sha256:b26bd465e31086f957c9e374b6bee1f0f077b7481eda9daa29cf63816bb87fdb",
"src/tests/migration/scenario_c_test.go": "sha256:9722146ac224768aff85476acbd83f3d478c1e060e5a3c6f99b1786858879b80",
"src/tests/migration/fixtures_test.go": "sha256:3fbcb8f531fa206225f719a67b744b2ecb908095fbae0538432796697971e6de",
"src/tests/migration/scenario_b_test.go": "sha256:40ae7239d116f3e284c767570d39e1f32d44fc29b6fa37ea8835987027942ac0",
"src/tests/profile/profile_test.go": "sha256:e5ab4568b15f681a13c9295b17907ca3872ee33b702bb56aea749f17758d29e6",
"src/internal/domain/assurance_test.go": "sha256:87680e7afd47294dadc13092ca2bb7c47b9625c80e48a701765c00df3293d755",
"src/internal/domain/conformance_test.go": "sha256:6420ec21d4e7a97f60902e1f0debbaf20df91aeac6ab110629b82a67f4c5c9a1",
"src/internal/domain/repository.go": "sha256:28f6d8f7f3d822e73f164af5e877f252db7dc6faa97f0ebf75d76aad64078011",
"src/internal/domain/auth.go": "sha256:ae800c5b1f58408147dd28a5c87ab2603b1537500a289397e711a4cb0e132cee",
"src/internal/domain/model.go": "sha256:ce4309fc7426c66e4ebc18022d2f704c10ebbec243cc98e657d1de765ad09029",
"src/internal/domain/assurance.go": "sha256:d3a29ece7c4fa4d9daa8b5fda89cbe0dd1ffef2ee2ecae0051ee68010edb8fd7",
"src/internal/domain/mfa.go": "sha256:1b7875b08a0f280a20a14a4fa4fdb4fc84c3942795b1d1417943d32634121b87",
"src/internal/authclient/cli.go": "sha256:376a8e0d1d60825a8b3ef21d371e3fc353c0a34e461712a6899e264278edbe7f",
"src/internal/authclient/client.go": "sha256:61fb150349c5d3714450a90834c59af9a579a8a649c4149297a402cfcf6e39c5",
"src/internal/authclient/verify.go": "sha256:804735be927ba7d0228ac378056730040d3ccf2ed020b4da0714085bae972f25",
"src/internal/authclient/client_test.go": "sha256:23642488fcff45e369d22ba0000c47e0e450c5f92560e643ab96303e2a2f43aa",
"src/internal/authclient/verify_test.go": "sha256:7febc6698c87eac5fd8d2520c4f77c68e82f18a2aafa448cfa4990b96fdc46fc",
"src/internal/validator/report.go": "sha256:6bc4d31f932ea9a32020cf33203978906730dfe3601350ded0206ff6c8c297a8",
"src/internal/validator/validator_test.go": "sha256:8216027a464eab4167d8e87b4d27d130854d7b9cd4cc8a1b6f1a47ff1f9a2276",
"src/internal/validator/validator.go": "sha256:3d0a8bf8ebbe54e21e162c8f7df6e4a4d69ab997a01969b74dcbd53decdf559f",
"src/internal/config/account_test.go": "sha256:c6b75097d14dd42415c836407fa2a509857cdd2b771fb7e53d4507cbad2f4b1d",
"src/internal/config/validate.go": "sha256:2b1766979b817802f43bdd0f999ac61452d86cdfafc1539a5d6be896b900a405",
"src/internal/config/optional_mfa_test.go": "sha256:03a00bc309943adbed68bdb9bf47facb6b3b50ef380fd49143b5ed54d684f63e",
"src/internal/config/config_test.go": "sha256:af8602010985a0a5a6b4b2bf779f824e7eb5141e6bc2e8d8fcf600b964955100",
"src/internal/config/token_status_test.go": "sha256:b2b2ec13215c9661d79142ea698cb8b080f6e017de050bedebf3eb8d16df2ad5",
"src/internal/config/config.go": "sha256:19cd234757f8908880d28b8b40939367dfab1322e0401e4e563d56cd3af06683",
"src/internal/errors/taxonomy.go": "sha256:7d60f30a4ff0c9a21a3942451eb0292320a6346d3d81508e414a7dfbdc3c666c",
"src/internal/errors/taxonomy_test.go": "sha256:6c0bdf1f8e90ea343d88f57f80f6c916c92428a9ac2191a518ee7b70df0eed45",
"src/internal/jose/jose.go": "sha256:bcfc30d2175735f37e7ea6f58451e122fbdfe02c562805e5a018c863fb677fab",
"src/internal/jose/jose_test.go": "sha256:cbbcbac620b27c8e93d9dc3fefc3ca4fcd20ab58c6bc8f79eb041b980aaa9a43",
"src/internal/server/policy/policy_test.go": "sha256:25644557ca532b608dff4e02b202c5c3b7c423fb31e98ad0609febf3660183a2",
"src/internal/server/policy/policy.go": "sha256:015d0b42419c501a46171d09a8f7d588dd517d4c98ad14344ff52113294e0fe7",
"src/internal/server/errors/enforcement.go": "sha256:f644546b1423992518fc4078ab561ab68fea1fcff00b734795af6652ae3dd4e2",
"src/internal/server/errors/enforcement_test.go": "sha256:b4ea0c9a3aaebd66b0b0fcc73f544c76d5a377ac0c9561ccc2638a236844374c",
"src/internal/server/telemetry/emitter.go": "sha256:e92f008b6a487a47b8ee2b98011a3a5b821838a61be9c4db4d56cda600e6fda0",
"src/internal/server/telemetry/emitter_test.go": "sha256:29fae2460deb7219eb8edc725b3701a02d2b341fee62bffb2661a520f5a7381a",
"src/internal/server/telemetry/events.go": "sha256:3f9df9822649ae2701c3bbf475c8e6d600bc60b7ccdea4d0e54193162d5b0167",
"src/internal/server/readiness/readiness_test.go": "sha256:10163e0b1d2f41f336d61782c3bfffdd1116a8459247cc933ffb40be645184c6",
"src/internal/server/readiness/readiness.go": "sha256:ffe22b99633b1aa50fc299371398ec0f3fc090c9c8a5f4957c6bfa76b001d073",
"src/internal/server/oidc/discovery_test.go": "sha256:2d0dc24d59497aa5a9aa581f8fd3d9cca300d7e0cb4962907630570d60e7e7f0",
"src/internal/server/oidc/tenant_precondition_test.go": "sha256:2f2c0d90d26da4e3a6c9145aec8c9d7121c791b6b993c2fe8893a763a30fa72f",
"src/internal/server/oidc/userinfo.go": "sha256:f9c986f7a2c79f27a9970a296e6b493014fed77af2f22ba96c4a457d049c916f",
"src/internal/server/oidc/account.go": "sha256:b0e36d8cb5f9b1bf4cee573e46b204800c0e3162a912a122b271def4712ab72a",
"src/internal/server/oidc/policy_runtime_test.go": "sha256:856146013d2aaa60836e669c297024923ed8e2856d31a89744a99baadae4a74a",
"src/internal/server/oidc/handoff_test.go": "sha256:23268b7f9f103f8a8798a168004580bc8fdb3a175161220dac26480a89f28e02",
"src/internal/server/oidc/human_tenant_test.go": "sha256:459664f90d76224958b3dd807d95d7bfcf0fce5f113498bd9886a41be19903a5",
"src/internal/server/oidc/discovery.go": "sha256:00e4c831a2b4d29aecf3597a09c943315b87c5834dbcd4912ba5d8d31ab28cd5",
"src/internal/server/oidc/jwks.go": "sha256:426d6596979ed6d097d6aaae66b6475d1c97687888c6ab1050c05cb14653ec60",
"src/internal/server/oidc/login_session.go": "sha256:efb0fcd066d81d753b7043c9c3d9e74e35cd510ca4304d21a51ba29dec833e1f",
"src/internal/server/oidc/hardening_test.go": "sha256:b41d6a987d5507d5160d970d0f2110ce681851fca1f4f7a3a9573163e8cd6f3e",
"src/internal/server/oidc/jwks_test.go": "sha256:9100e83e58ce2cc2f6d2f47804ee0ce035ca424b209597a12c4ef1a8896163aa",
"src/internal/server/oidc/account_test.go": "sha256:db1f7b8a2989b1d0deffee205ec9f4f35c7472135dcb8a10b5498893d881d763",
"src/internal/server/oidc/token_status_discovery_test.go": "sha256:27adb5797afba46ca2b1f16018721f055f82760498088b58b993730be1fa2031",
"src/internal/server/oidc/principal_type_guard_test.go": "sha256:b3fa097691f07594616e09e8775512d586a019ccdf8302bf594c1ac94f5f3f40",
"src/internal/server/oidc/token_status_http.go": "sha256:a7202d3176454f8ab42c090426e2debcb5cc2c1fc089e79746bb78e5c455af97",
"src/internal/server/oidc/native_optional_test.go": "sha256:329907bf7089d8f3ea05baad919d05677b8c3aaa201e6f233795ae9f69c003b5",
"src/internal/server/oidc/authorize_test.go": "sha256:61042d55e20a11fb6e1e9e6c054e50c0f629b702ed6d8da281d52529e5c29b0d",
"src/internal/server/oidc/session.go": "sha256:48473ebe123285ed21e7738834d9d9168dfc855d911cb9e7ce7e87b069a23a3c",
"src/internal/server/oidc/logout.go": "sha256:d4de80f8056e7a65c7a41f244dd38e447a4e04df729760a8a59cb6bb4e063893",
"src/internal/server/oidc/optional_mfa_test.go": "sha256:d758848d845c181cf1b18710e6ca24977f6b7576320dcad1f7293a5433c3b529",
"src/internal/server/oidc/token.go": "sha256:304fa78d79a5436b9174d88991c19c0834356cb5425c6ed369accb85467a402f",
"src/internal/server/oidc/token_status.go": "sha256:d9c22a71bb2efad7ea240f2689a98f14768ce3c1519d1d8b4e78ee7af2dc70b3",
"src/internal/server/oidc/userinfo_test.go": "sha256:e88c42c7cdf50d5953c9f594f0b99b4f87f5e223ba1cb44b70d696972016da80",
"src/internal/server/oidc/signin_diagnosis_test.go": "sha256:97e80be67c480dfe8eef0683f25868a91a0fdc96472b5296b734b56274270b3e",
"src/internal/server/oidc/policy_isolation_test.go": "sha256:35cb9870f5942c61c0e8c6ca875e9080bbd71c46c7b20fb18da01be88e0fa077",
"src/internal/server/oidc/token_test.go": "sha256:5721617c6e5eb79a3b5e86efa63e9278a8d81a0d7c01bf7b184255c102848e33",
"src/internal/server/oidc/audience_test.go": "sha256:1b751e1aafb5dbdcb751b42b001e32ddfaccfa3d5ba9d5af67b44148d9802ae3",
"src/internal/server/oidc/token_status_transport_test.go": "sha256:3a9945f0292125e86d0014b5245a20c0475d6c518eccff0f9b936356d8e0b347",
"src/internal/server/oidc/tenant_test.go": "sha256:8a3879789589f0c3d6e473ba56dd7d7c86ec6fbdddaafa4b13354f82d5f88713",
"src/internal/server/oidc/assurance_time_test.go": "sha256:911f65e8cea87ef867d6bf98199ed55abd29427b9f8d0838977d228e6186b08a",
"src/internal/server/oidc/signin_diagnosis.go": "sha256:b7976a1cfe1d8d1dca890cf0762cc82d08978a2e7537e45988b147ed0222042f",
"src/internal/server/oidc/authorize.go": "sha256:04f9bfb6b146ecf869161b7a0eb00b6486f424710c2c867e4f4b70af5f44ff5e",
"src/internal/server/oidc/handoff.go": "sha256:a2ffede12c9140a6c7d14632122a39ffbd0e707cd357dcc11d3ad885dc2a7fa4",
"src/internal/server/oidc/token_status_internal_test.go": "sha256:3d3ab41987c7586bd53af8b5c36ff6667b5b2a32d59789915eb9a36b7ceeba53",
"src/internal/server/oidc/token_status_test.go": "sha256:e2613d44deefcbfbd0ce1db5ce2588849fb4be9b8bed33cb538171cc29f63e90",
"src/internal/migration/lldapexport/exporter.go": "sha256:cb041827c9c0edf9da1338e966e7722e3765065582933b8c17b0d9008d44d103",
"src/internal/migration/lldapexport/exporter_test.go": "sha256:6f5cf0e43ea8aea9ceeb6d4d3ae0bfd9f3a5d18636dd49bea0c574c5253a44c2",
"src/internal/migration/tokeycloak/transformer_test.go": "sha256:5c3f5145a37079262db805f5bedaabe1d42edf2ccec1788c09c02f5b8336639b",
"src/internal/migration/tokeycloak/transformer.go": "sha256:856e26fd2075f020ef02373b5b7e9e5a1e98473b5caca95b60b7983287fc10aa",
"src/internal/migration/toldap/generator.go": "sha256:e59ba05203145f52818b3f4e34ab62126481faa9dde697f643fc1cda0638bb46",
"src/internal/migration/toldap/generator_test.go": "sha256:73b0969ce2e2cb018aa5734714c86152d1695f0099a5caf7dd96b773717fc2cc",
"src/internal/adapters/tenantengine/adapter.go": "sha256:a54ef12fafd2efc24c547d562e0a558a7ecef586394e79f7de345dc8cbbbbef3",
"src/internal/adapters/tenantengine/adapter_test.go": "sha256:b9c2db04bc187e6e6e56d4a42d4e657b9e56fef06843500fb4640477dee553e0",
"src/internal/adapters/lldap/adapter.go": "sha256:e7df7a159e9dd33a97932fd8cbde49a61091b8722796bf54ae4be887b4a3f5ef",
"src/internal/adapters/lldap/railiance_admin_test.go": "sha256:63db481bb6e592e91bf4a555e75065fbd3bd8cd408a7967a4b7786e12bef5d5f",
"src/internal/adapters/lldap/adapter_test.go": "sha256:650394ccc25d54c73c03746977a1880b1ed905634da8382da0ecf35843454081",
"src/internal/adapters/lldap/config_internal_test.go": "sha256:cbc71ce9a795b73c40b2714153eb2ee4d2e4007f7f56a7882eb862c23868cb68",
"src/internal/adapters/lldap/config.go": "sha256:33059a27418b00e1987b7ced0b21dd39c1765338bf575fe2333ce3e606e5546b",
"src/internal/adapters/privacyidea/credential_file_test.go": "sha256:9db15562c6fdf01e0806bbc478915413b17adb63c7e9ba213acfc4f9b2b2f0b6",
"src/internal/adapters/privacyidea/read_scope_test.go": "sha256:4685e1b84dad044db12708ecd231d4f91ec22ac45901ed2b5177e3be17d8cdc3",
"src/internal/adapters/privacyidea/adapter.go": "sha256:2559c68b5aa22bc5f61961e47d696b376f8bede533be03715ffc80c0d89ed904",
"src/internal/adapters/privacyidea/adapter_test.go": "sha256:6257489b9c51270da2c96f165c0efd1750b273218df51dca907d29c64c98b349",
"src/internal/adapters/privacyidea/optional_mfa_test.go": "sha256:e2e415c671d1d0e7f30a0ce22f5ec58914d8e366b12e445e13b1d645a9ca6a65",
"src/internal/adapters/privacyidea/enrollment_state_test.go": "sha256:dcf24c486629fa4835cddaa786af31ec78c05907694d4d93bce0f32876806377",
"src/internal/adapters/privacyidea/config.go": "sha256:8dd769b18c04c562714794484630acdbda81b34ab1cfaf9cc7445513207e5021",
"src/internal/adapters/authelia/verify.go": "sha256:189f9bc18b8d4319d2d88ee76a76e3b86468ebafde85ef460a4bafb7b350b4c4",
"src/internal/adapters/authelia/adapter.go": "sha256:2887bd546f43a16338547dab5ce4a90850c670ed6f6c0d610a5d08c72c7f3f45",
"src/internal/adapters/authelia/adapter_test.go": "sha256:d450b37e2b2405c189d444e50f0612e64177d6698cb4665a06ad967ae2224f93",
"src/internal/adapters/authelia/issuer_probe.go": "sha256:5846e2b514077a572611b898f449c04b096b086aeb724da055b2416994a7a7bb",
"src/internal/adapters/authelia/issuer_probe_test.go": "sha256:a85e02399f8a5c81401dbf159e8054420bff56e08c8a98e8026cad8d31fcecec",
"src/internal/adapters/authelia/config.go": "sha256:1b85ea3ffd324bced1ba108492ec780af8ba2b015d22cd51b294afaf36f2ec55",
"src/internal/adapters/authelia/verify_test.go": "sha256:f1570604d5038e49c14791f4d1aa278ad76a7e90294e82813d2c21e6a80a5511",
"src/cmd/keycape-to-keycloak/main.go": "sha256:6141d3f86bbe3df2a814a0c9995e537d32bf7b19f5549e89d40974eb3270faf7",
"src/cmd/lldap-to-ldap/main.go": "sha256:adcc9481d009cad909bd55a71d95c17ce30e29ca63e88a7c9e8101b92d4b872e",
"src/cmd/keycape/clients_test.go": "sha256:6a3982c1b2bd9b15c387ba64d4c87b37304dc6f615ed2ab22eec888f620cb1da",
"src/cmd/keycape/checkconfig.go": "sha256:2c47c9aedf3457627fdec991703e3be50e5ef554e4104cf3741f8347687279f2",
"src/cmd/keycape/main.go": "sha256:2f81ea0b0b33f38298e028d5610625e3834fdf1aa12f6f392579d6207bcf6f1d",
"src/cmd/keycape/checkconfig_test.go": "sha256:06955c996774109b26e4751ab20787f12718a1de96e3483a775fb19688d90e66",
"src/cmd/validator/main.go": "sha256:e53b0a7860537dfb1cec69a00cdafe7073b35802421b5977f2fece4de01b9c1f",
"src/cmd/lldap-export/main_test.go": "sha256:f105e490c717a414a5a3d1e65ceb4edcf74341ad7713b0accf8117a9b1ee02d2",
"src/cmd/lldap-export/main.go": "sha256:f019a6dbb0849d06d5c3729874293d520478ca2887929a0595b59a324097ae75"
},
"source_files": 115
}

View file

@ -0,0 +1,38 @@
{
"schema": "repo-manager.identifier-assignment.v1",
"ok": true,
"executed": true,
"repo": "source",
"namespace": "helixforge",
"assignments": [
{
"kind": "workplan",
"record_id": "KEY-WP-0042",
"uuid": "e2eecd1c-45c7-5a7c-bd1c-d31c845e3d38",
"path": "workplans/KEY-WP-0042-online-access-token-status.md"
},
{
"kind": "task",
"record_id": "KEY-WP-0042-T01",
"uuid": "d8af8e77-f6e5-5d0d-8b44-794af3dee5cf",
"path": "workplans/KEY-WP-0042-online-access-token-status.md"
},
{
"kind": "task",
"record_id": "KEY-WP-0042-T02",
"uuid": "2cade51f-d25a-5719-96b4-b57d50c1b97d",
"path": "workplans/KEY-WP-0042-online-access-token-status.md"
},
{
"kind": "task",
"record_id": "KEY-WP-0042-T03",
"uuid": "a5539bb0-2501-5774-a7d8-acddfb0938ab",
"path": "workplans/KEY-WP-0042-online-access-token-status.md"
}
],
"historical_invalid_identifiers": [],
"historical_identity_collisions": [],
"files_changed": [
"workplans/KEY-WP-0042-online-access-token-status.md"
]
}

View file

@ -0,0 +1,10 @@
{
"status": "refused",
"phase": "official repo-manager missing-identifier preview on isolated full owner archive",
"reason": "missing UUIDs on grandfathered or noncanonical records require an explicit identity decision: ADHOC-2026-09-27-T02 in workplans/ADHOC-2026-09-27.md",
"historic_source_sha256": "sha256:2794d1940fc3c5e4a2df57de9d4b742fa6aa8602b67f1d3177c0d96bc5aaf314",
"canonical_owner_changed": false,
"identifiers_assigned": false,
"new_plan": "proposed KEY-WP-0042 source draft; not registered owner work",
"next_action": "Reconcile the existing historical task binding through its owner identity path; do not invent a UUID or bypass ordinary publication"
}

View file

@ -0,0 +1,38 @@
{
"schema": "repo-manager.identifier-assignment.v1",
"ok": true,
"executed": false,
"repo": "source",
"namespace": "helixforge",
"assignments": [
{
"kind": "workplan",
"record_id": "KEY-WP-0042",
"uuid": "e2eecd1c-45c7-5a7c-bd1c-d31c845e3d38",
"path": "workplans/KEY-WP-0042-online-access-token-status.md"
},
{
"kind": "task",
"record_id": "KEY-WP-0042-T01",
"uuid": "d8af8e77-f6e5-5d0d-8b44-794af3dee5cf",
"path": "workplans/KEY-WP-0042-online-access-token-status.md"
},
{
"kind": "task",
"record_id": "KEY-WP-0042-T02",
"uuid": "2cade51f-d25a-5719-96b4-b57d50c1b97d",
"path": "workplans/KEY-WP-0042-online-access-token-status.md"
},
{
"kind": "task",
"record_id": "KEY-WP-0042-T03",
"uuid": "a5539bb0-2501-5774-a7d8-acddfb0938ab",
"path": "workplans/KEY-WP-0042-online-access-token-status.md"
}
],
"historical_invalid_identifiers": [],
"historical_identity_collisions": [],
"files_changed": [
"workplans/KEY-WP-0042-online-access-token-status.md"
]
}

View file

@ -0,0 +1,11 @@
{
"status": "source-applied",
"owner": "key-cape",
"base": "git:d803e9282a47b49daf930fb905312fa5104c0ace",
"files": 18,
"other_tracked_files_preserved": 271,
"existing_binding_restored": "7fa0bfc7-0772-57c7-8d6f-641615cab82a",
"new_identity_for_historical_task": false,
"independent_owner_assent": false,
"native_actions": false
}

View file

@ -0,0 +1,27 @@
{
"command": [
"statehub",
"fix-consistency",
"--repo",
"key-cape",
"--json"
],
"source_revision": "d803e9282a47b49daf930fb905312fa5104c0ace",
"exit_code": 0,
"result": "warn",
"summary": {
"fail": 0,
"automation_error": 0,
"warn": 8,
"info": 1
},
"relevant_fixes_applied": [
"bindings: synced 44/44",
"C-11 skipped: task 'ADHOC-2026-09-27-T02' in finished workstream \u2014 not created",
"brief: .custodian-brief.md updated",
"push: pushed"
],
"writeback_commit_warning": "Generated-only commits rejected: staged",
"interpretation": "No historical task created or missing reference repaired by the normal closed-workstream branch. Retained initial refusal; exact existing binding subsequently restored through official injection helper in candidate, then ordinary source publication. No hook bypass.",
"receipt_scope": "selected exact terminal command fields; full command output retained in tool transcript"
}

View file

@ -0,0 +1,14 @@
{
"source": "workplans/ADHOC-2026-09-27.md",
"task_record_id": "ADHOC-2026-09-27-T02",
"existing_hub_task_id": "7fa0bfc7-0772-57c7-8d6f-641615cab82a",
"parent": "5217dd06-73cb-50f8-ba64-528e421b29de",
"title_status_description_exact_match": true,
"same_all_other_task_fields": true,
"tool": "State Hub consistency_check._inject_task_id_into_block",
"new_task_or_uuid_created": false,
"canonical_owner_changed": false,
"reason": "normal C-11 deliberately skips closed workstreams; restore only an already-existing exact source/cached binding",
"source_before_sha256": "sha256:2794d1940fc3c5e4a2df57de9d4b742fa6aa8602b67f1d3177c0d96bc5aaf314",
"source_after_sha256": "sha256:f6220f17fc5b6e7300d0a55ac29f78f385ad89db276ccaa21693428fffa5919b"
}

View file

@ -0,0 +1,23 @@
{
"workplan_id": "5217dd06-73cb-50f8-ba64-528e421b29de",
"id": "7fa0bfc7-0772-57c7-8d6f-641615cab82a",
"record_id": "ADHOC-2026-09-27-T02",
"flavor": null,
"title": "Discovery/JWKS fetch was missing the split-horizon origin headers",
"description": "Rolling out T01 (image digest `sha256:248e449a...`) broke every KeyCape login,\nincluding unrelated clients (`user-engine-portal`), with `auth_failure` /\n`provider_metadata_unavailable` at `/api/oidc/token` on the KeyCape side and\nAuthelia logging `invalid X-Forwarded-Proto header value 'http'` on\n`GET /.well-known/openid-configuration` from KeyCape's pod IP.\n\nRoot cause, unrelated to T01's change: `AutheliaAdapter.setPublicOriginHeaders`\n(the NK-WP-0041-T02/path-B fix, key-cape@3b0446e) annotates only the token\nexchange (`newTokenRequest`) with `X-Forwarded-Proto`/`X-Forwarded-Host` so\nAuthelia can derive the public issuer for a split-horizon (in-cluster HTTP,\npublic HTTPS) deployment. `idTokenVerifier.get` in `verify.go` \u2014 used for the\nprovider-metadata and JWKS fetches that verify every upstream ID token \u2014 never\ngot the same treatment; it built a bare request with only an `Accept` header.\nAuthelia 4.38 fails closed on the resulting unmarked in-cluster request. This\ngap predates today's session (present since 3b0446e); it surfaced now because\nthis was the first login attempted since that image went live.\n\nFix: extracted `setPublicOriginHeaders` from an `AutheliaAdapter` method to a\npackage-level function taking the target public base URL explicitly, and gave\n`idTokenVerifier` its own resolved `publicBaseURL` (same\n`BrowserBaseURL`-else-`BaseURL` fallback as the adapter) so `get()` applies\nthe same headers. Added `TestDiscoveryFetch_CarriesPublicOriginHeaders`\n(confirmed it fails without the fix, passes with it). Full suite green.\n\nNew image `key-cape@<pending>` still needs a rollout to `sso-mfa/k8s/keycape/deployment.yaml`\nbefore platform-root can sign in again.",
"status": "done",
"priority": "high",
"assignee": null,
"due_date": null,
"blocking_reason": null,
"needs_human": false,
"intervention_note": null,
"decision_id": null,
"blocked_on": null,
"wait_until": null,
"parent_task_id": null,
"wait_kind": null,
"created_at": "2026-10-17T06:49:57.662339Z",
"updated_at": "2026-10-17T06:49:57.662339Z",
"workstream_id": "5217dd06-73cb-50f8-ba64-528e421b29de"
}

View file

@ -0,0 +1,7 @@
{
"restored": ".custodian-brief.md",
"scope": "only this turn generated it; initial owner tracked state was clean",
"original_owner_worktree_and_index_preserved": true,
"source_commit_unchanged": "67a0869c1ee40c9e00a60cb7c1d828734d5d3d32",
"tracked_status": ""
}

View file

@ -0,0 +1,126 @@
{
"repo_slug": "key-cape",
"repo_path": "/home/worsch/key-cape",
"source_revision": "67a0869c1ee40c9e00a60cb7c1d828734d5d3d32",
"source_revision_reason": null,
"issues": [
{
"severity": "WARN",
"check_id": "C-24",
"message": "Repo classification advisory: capability_tag 'security' is not a recommended family tag (allowed, check for synonym)",
"file_path": "",
"db_id": "",
"file_value": "",
"db_value": "",
"fixable": false
},
{
"severity": "WARN",
"check_id": "C-33",
"message": "WORK-RECORDS.md is missing or stale \u2014 regenerate via fix-consistency (CUST-WP-0061-T04 generated work-record index)",
"file_path": "WORK-RECORDS.md",
"db_id": "",
"file_value": "",
"db_value": "",
"fixable": true
},
{
"severity": "WARN",
"check_id": "C-20",
"message": "Workplan dependency target 'USER-WP-0020' is not linked to State Hub",
"file_path": "workplans/KEY-WP-0007-user-engine-portal-oidc-client.md",
"db_id": "",
"file_value": "USER-WP-0020",
"db_value": "",
"fixable": false
},
{
"severity": "WARN",
"check_id": "C-20",
"message": "Workplan dependency target 'NK-WP-0025' is not linked to State Hub",
"file_path": "workplans/KEY-WP-0008-registration-handoff-and-client-mfa-policy.md",
"db_id": "",
"file_value": "NK-WP-0025",
"db_value": "",
"fixable": false
},
{
"severity": "WARN",
"check_id": "C-20",
"message": "Workplan dependency target 'NK-WP-0030' is not linked to State Hub",
"file_path": "workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md",
"db_id": "",
"file_value": "NK-WP-0030",
"db_value": "",
"fixable": false
},
{
"severity": "WARN",
"check_id": "C-20",
"message": "Workplan dependency target 'RAILIANCE-WP-0027' is not linked to State Hub",
"file_path": "workplans/KEY-WP-0010-openbao-operator-loopback-callback.md",
"db_id": "",
"file_value": "RAILIANCE-WP-0027",
"db_value": "",
"fixable": false
},
{
"severity": "WARN",
"check_id": "C-06",
"message": "Authoritative workplan e2eecd1c\u2026 is absent from this projection",
"file_path": "workplans/KEY-WP-0042-online-access-token-status.md",
"db_id": "e2eecd1c-45c7-5a7c-bd1c-d31c845e3d38",
"file_value": "",
"db_value": "",
"fixable": true
},
{
"severity": "INFO",
"check_id": "C-08",
"message": "Retired DB workstream 'keycape-implementation@retired-20260826' (id=2c9caad8\u2026, status=archived) has no backing workplan file \u2014 projection tombstone, not live work",
"file_path": "",
"db_id": "2c9caad8-2ced-492d-9d63-376387b4b9b0",
"file_value": "",
"db_value": "",
"fixable": false
},
{
"severity": "WARN",
"check_id": "C-28",
"message": "2 unread inbox message(s) older than 3 day(s): the-custodian:23172cb2, the-custodian:51e9eace",
"file_path": "",
"db_id": "",
"file_value": "",
"db_value": "",
"fixable": false
},
{
"severity": "WARN",
"check_id": "C-28",
"message": "2 unread message(s) lack thread_id for supersession tracking: the-custodian:23172cb2, the-custodian:51e9eace",
"file_path": "",
"db_id": "",
"file_value": "",
"db_value": "",
"fixable": false
}
],
"fixes_applied": [
"bindings: synced 44/45",
"C-17 cleared: pushed 1 backlogged commit(s)",
"C-33 fixed: regenerated WORK-RECORDS.md",
"C-06 fixed: created workstream e2eecd1c\u2026 for KEY-WP-0042, wrote ID to KEY-WP-0042-online-access-token-status.md",
" + task KEY-WP-0042-T01 \u2192 d8af8e77\u2026",
" + task KEY-WP-0042-T02 \u2192 2cade51f\u2026",
" + task KEY-WP-0042-T03 \u2192 a5539bb0\u2026",
"brief: .custodian-brief.md updated",
"push: pushed"
],
"summary": {
"fail": 0,
"automation_error": 0,
"warn": 9,
"info": 1
},
"result": "warn"
}

View file

@ -0,0 +1 @@
WARN: git commit failed for writeback: Generated-only commits rejected: staged

View file

@ -0,0 +1,202 @@
{
"ok": true,
"status": "applied",
"repo_slug": "key-cape",
"commit": "67a0869c1ee40c9e00a60cb7c1d828734d5d3d32",
"requests": 2,
"elapsed_ms": 8983.3,
"git": {
"upstream": "origin/main",
"behind": 0,
"ahead": 0
},
"identifiers": {
"schema": "repo-manager.identifier-assignment.v1",
"ok": true,
"executed": true,
"repo": "key-cape",
"namespace": "helixforge",
"assignments": [],
"historical_invalid_identifiers": [],
"historical_identity_collisions": [],
"files_changed": []
},
"receipt": {
"schema": "state-hub.repository-projection-reconcile.v1",
"instance_role": "primary",
"instance_label": "railiance01",
"expected_commit": "67a0869c1ee40c9e00a60cb7c1d828734d5d3d32",
"derived_commit": "67a0869c1ee40c9e00a60cb7c1d828734d5d3d32",
"outcome": {
"schema": "state-hub.projection-reset.v1",
"repo_slug": "key-cape",
"commit": "67a0869c1ee40c9e00a60cb7c1d828734d5d3d32",
"status": "applied",
"counts": {
"created": 0,
"updated": 45,
"retired": 0,
"refused": 0,
"released": 0,
"created_tasks": 0,
"updated_tasks": 3,
"cancelled_tasks": 0,
"created_edges": 0,
"removed_edges": 0,
"unresolved_edges": 1,
"external_edges": 8
},
"created": [],
"updated": [
"ADHOC-2026-09-05",
"ADHOC-2026-09-07",
"ADHOC-2026-09-27",
"KEY-WP-0001",
"KEY-WP-0002",
"KEY-WP-0003",
"KEY-WP-0004",
"KEY-WP-0005",
"KEY-WP-0006",
"KEY-WP-0007",
"KEY-WP-0008",
"KEY-WP-0009",
"KEY-WP-0010",
"KEY-WP-0011",
"KEY-WP-0012",
"KEY-WP-0013",
"KEY-WP-0014",
"KEY-WP-0015",
"KEY-WP-0016",
"KEY-WP-0017",
"KEY-WP-0018",
"KEY-WP-0019",
"KEY-WP-0020",
"KEY-WP-0021",
"KEY-WP-0022",
"KEY-WP-0023",
"KEY-WP-0024",
"KEY-WP-0025",
"KEY-WP-0026",
"KEY-WP-0027",
"KEY-WP-0028",
"KEY-WP-0029",
"KEY-WP-0030",
"KEY-WP-0031",
"KEY-WP-0032",
"KEY-WP-0033",
"KEY-WP-0034",
"KEY-WP-0035",
"KEY-WP-0036",
"KEY-WP-0037",
"KEY-WP-0038",
"KEY-WP-0039",
"KEY-WP-0040",
"KEY-WP-0041",
"KEY-WP-0042"
],
"retired": [],
"released": [],
"created_tasks": [],
"updated_tasks": [
"KEY-WP-0042-T01",
"KEY-WP-0042-T02",
"KEY-WP-0042-T03"
],
"cancelled_tasks": [],
"created_edges": [],
"removed_edges": [],
"unresolved_edges": [
{
"workplan": "KEY-WP-0010",
"from": "KEY-WP-0010",
"target": "RAILIANCE-WP-0027",
"reason": "missing"
}
],
"external_edges": [
{
"workplan": "KEY-WP-0038",
"from": "KEY-WP-0038-T03",
"target": "KEY-WP-0038-T02",
"relationship_type": "starts_after",
"from_workplan_id": "098e3e2c-a138-560e-b2d3-3a15eb49d8a9",
"from_task_id": "40ddaa29-7245-508c-ad48-ddc063e25115",
"to_workplan_id": null,
"to_task_id": "ddcfb482-8f33-5c81-9ce8-84be8fa5dc30"
},
{
"workplan": "KEY-WP-0038",
"from": "KEY-WP-0038-T02",
"target": "KEY-WP-0038-T01",
"relationship_type": "starts_after",
"from_workplan_id": "098e3e2c-a138-560e-b2d3-3a15eb49d8a9",
"from_task_id": "ddcfb482-8f33-5c81-9ce8-84be8fa5dc30",
"to_workplan_id": null,
"to_task_id": "5f019d13-1fa3-5b88-a27a-75a3339ca96e"
},
{
"workplan": "KEY-WP-0038",
"from": "KEY-WP-0038-T02",
"target": "NK-WP-0048-T06",
"relationship_type": "starts_after",
"from_workplan_id": "098e3e2c-a138-560e-b2d3-3a15eb49d8a9",
"from_task_id": "ddcfb482-8f33-5c81-9ce8-84be8fa5dc30",
"to_workplan_id": null,
"to_task_id": "d9b26bcf-a7c6-59a2-92a0-f913d76a203c"
},
{
"workplan": "KEY-WP-0039",
"from": "KEY-WP-0039-T02",
"target": "KEY-WP-0038-T03",
"relationship_type": "starts_after",
"from_workplan_id": "09c3bddf-6058-5603-8488-cccdb7be4f86",
"from_task_id": "03da4c38-1dac-53b1-8b0b-e697df48e9d3",
"to_workplan_id": null,
"to_task_id": "40ddaa29-7245-508c-ad48-ddc063e25115"
},
{
"workplan": "KEY-WP-0039",
"from": "KEY-WP-0039-T02",
"target": "KEY-WP-0039-T01",
"relationship_type": "starts_after",
"from_workplan_id": "09c3bddf-6058-5603-8488-cccdb7be4f86",
"from_task_id": "03da4c38-1dac-53b1-8b0b-e697df48e9d3",
"to_workplan_id": null,
"to_task_id": "4d06b059-3768-53bb-83a4-a9a71c0850b8"
},
{
"workplan": "KEY-WP-0039",
"from": "KEY-WP-0039-T02",
"target": "KEY-WP-0038-T02",
"relationship_type": "starts_after",
"from_workplan_id": "09c3bddf-6058-5603-8488-cccdb7be4f86",
"from_task_id": "03da4c38-1dac-53b1-8b0b-e697df48e9d3",
"to_workplan_id": null,
"to_task_id": "ddcfb482-8f33-5c81-9ce8-84be8fa5dc30"
},
{
"workplan": "KEY-WP-0007",
"from": "KEY-WP-0007",
"target": "KEY-WP-0005",
"relationship_type": "blocks",
"from_workplan_id": "24db4178-705c-5b09-b7db-ee7a345d71a0",
"from_task_id": null,
"to_workplan_id": "711a8303-69aa-5db6-9043-92530c6dc81b",
"to_task_id": null
},
{
"workplan": "KEY-WP-0009",
"from": "KEY-WP-0009",
"target": "KEY-WP-0006",
"relationship_type": "blocks",
"from_workplan_id": "c1a9b1cc-2ff0-566a-b544-1a2ef967fc0d",
"from_task_id": null,
"to_workplan_id": "d8d20d8f-61e0-54e2-9a88-a3958f0b6f2d",
"to_task_id": null
}
],
"refused": [],
"notes": []
}
}
}

View file

@ -0,0 +1,81 @@
{
"exit_code": 0,
"commit": "67a0869c1ee40c9e00a60cb7c1d828734d5d3d32",
"base": "d803e9282a47b49daf930fb905312fa5104c0ace",
"files": [
{
"path": "SCOPE.md",
"sha256": "a80e80ba2e5279ba50232e6b3f57a5bdd14866e584d2d055dfb09e98a4c24ce0"
},
{
"path": "docs/online-token-status.md",
"sha256": "f5e41b47500b15c80b2da6cbe37a9e64bcbde1c072750934cf74464a19488925"
},
{
"path": "docs/operations.md",
"sha256": "3659362e3c7f8720e52cc8dc7c67a6295ac26462f383c68c06c120fac6e6e472"
},
{
"path": "src/cmd/keycape/main.go",
"sha256": "2f81ea0b0b33f38298e028d5610625e3834fdf1aa12f6f392579d6207bcf6f1d"
},
{
"path": "src/internal/config/config.go",
"sha256": "19cd234757f8908880d28b8b40939367dfab1322e0401e4e563d56cd3af06683"
},
{
"path": "src/internal/config/token_status_test.go",
"sha256": "b2b2ec13215c9661d79142ea698cb8b080f6e017de050bedebf3eb8d16df2ad5"
},
{
"path": "src/internal/config/validate.go",
"sha256": "2b1766979b817802f43bdd0f999ac61452d86cdfafc1539a5d6be896b900a405"
},
{
"path": "src/internal/server/oidc/discovery.go",
"sha256": "00e4c831a2b4d29aecf3597a09c943315b87c5834dbcd4912ba5d8d31ab28cd5"
},
{
"path": "src/internal/server/oidc/token.go",
"sha256": "304fa78d79a5436b9174d88991c19c0834356cb5425c6ed369accb85467a402f"
},
{
"path": "src/internal/server/oidc/token_status.go",
"sha256": "d9c22a71bb2efad7ea240f2689a98f14768ce3c1519d1d8b4e78ee7af2dc70b3"
},
{
"path": "src/internal/server/oidc/token_status_discovery_test.go",
"sha256": "27adb5797afba46ca2b1f16018721f055f82760498088b58b993730be1fa2031"
},
{
"path": "src/internal/server/oidc/token_status_http.go",
"sha256": "a7202d3176454f8ab42c090426e2debcb5cc2c1fc089e79746bb78e5c455af97"
},
{
"path": "src/internal/server/oidc/token_status_internal_test.go",
"sha256": "3d3ab41987c7586bd53af8b5c36ff6667b5b2a32d59789915eb9a36b7ceeba53"
},
{
"path": "src/internal/server/oidc/token_status_test.go",
"sha256": "e2613d44deefcbfbd0ce1db5ce2588849fb4be9b8bed33cb538171cc29f63e90"
},
{
"path": "src/internal/server/oidc/token_status_transport_test.go",
"sha256": "3a9945f0292125e86d0014b5245a20c0475d6c518eccff0f9b936356d8e0b347"
},
{
"path": "src/internal/server/oidc/userinfo.go",
"sha256": "f9c986f7a2c79f27a9970a296e6b493014fed77af2f22ba96c4a457d049c916f"
},
{
"path": "workplans/ADHOC-2026-09-27.md",
"sha256": "f6220f17fc5b6e7300d0a55ac29f78f385ad89db276ccaa21693428fffa5919b"
},
{
"path": "workplans/KEY-WP-0042-online-access-token-status.md",
"sha256": "6d7ff6dea31b251e261195fff97a2061d56579b5786629d95014f6d1cbe0f55e"
}
],
"stdout": "[main 67a0869] Add opt-in issuer-owned access-token status and scoped revocation\n 18 files changed, 1230 insertions(+), 28 deletions(-)\n create mode 100644 docs/online-token-status.md\n create mode 100644 src/internal/config/token_status_test.go\n create mode 100644 src/internal/server/oidc/token_status.go\n create mode 100644 src/internal/server/oidc/token_status_discovery_test.go\n create mode 100644 src/internal/server/oidc/token_status_http.go\n create mode 100644 src/internal/server/oidc/token_status_internal_test.go\n create mode 100644 src/internal/server/oidc/token_status_test.go\n create mode 100644 src/internal/server/oidc/token_status_transport_test.go\n create mode 100644 workplans/KEY-WP-0042-online-access-token-status.md\n",
"stderr": "{\"blocked_blob_ids\": [], \"scanned_blobs\": 18, \"scanner_sha256\": \"88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509\", \"scanner_version\": \"8.30.1\", \"scope\": \"changed-staged-blobs\", \"status\": \"clean\"}\n"
}

View file

@ -0,0 +1,79 @@
{
"owner": "key-cape",
"base_revision": "d803e9282a47b49daf930fb905312fa5104c0ace",
"message": "Add opt-in issuer-owned access-token status and scoped revocation",
"files": [
{
"path": "SCOPE.md",
"sha256": "a80e80ba2e5279ba50232e6b3f57a5bdd14866e584d2d055dfb09e98a4c24ce0"
},
{
"path": "docs/online-token-status.md",
"sha256": "f5e41b47500b15c80b2da6cbe37a9e64bcbde1c072750934cf74464a19488925"
},
{
"path": "docs/operations.md",
"sha256": "3659362e3c7f8720e52cc8dc7c67a6295ac26462f383c68c06c120fac6e6e472"
},
{
"path": "src/cmd/keycape/main.go",
"sha256": "2f81ea0b0b33f38298e028d5610625e3834fdf1aa12f6f392579d6207bcf6f1d"
},
{
"path": "src/internal/config/config.go",
"sha256": "19cd234757f8908880d28b8b40939367dfab1322e0401e4e563d56cd3af06683"
},
{
"path": "src/internal/config/token_status_test.go",
"sha256": "b2b2ec13215c9661d79142ea698cb8b080f6e017de050bedebf3eb8d16df2ad5"
},
{
"path": "src/internal/config/validate.go",
"sha256": "2b1766979b817802f43bdd0f999ac61452d86cdfafc1539a5d6be896b900a405"
},
{
"path": "src/internal/server/oidc/discovery.go",
"sha256": "00e4c831a2b4d29aecf3597a09c943315b87c5834dbcd4912ba5d8d31ab28cd5"
},
{
"path": "src/internal/server/oidc/token.go",
"sha256": "304fa78d79a5436b9174d88991c19c0834356cb5425c6ed369accb85467a402f"
},
{
"path": "src/internal/server/oidc/token_status.go",
"sha256": "d9c22a71bb2efad7ea240f2689a98f14768ce3c1519d1d8b4e78ee7af2dc70b3"
},
{
"path": "src/internal/server/oidc/token_status_discovery_test.go",
"sha256": "27adb5797afba46ca2b1f16018721f055f82760498088b58b993730be1fa2031"
},
{
"path": "src/internal/server/oidc/token_status_http.go",
"sha256": "a7202d3176454f8ab42c090426e2debcb5cc2c1fc089e79746bb78e5c455af97"
},
{
"path": "src/internal/server/oidc/token_status_internal_test.go",
"sha256": "3d3ab41987c7586bd53af8b5c36ff6667b5b2a32d59789915eb9a36b7ceeba53"
},
{
"path": "src/internal/server/oidc/token_status_test.go",
"sha256": "e2613d44deefcbfbd0ce1db5ce2588849fb4be9b8bed33cb538171cc29f63e90"
},
{
"path": "src/internal/server/oidc/token_status_transport_test.go",
"sha256": "3a9945f0292125e86d0014b5245a20c0475d6c518eccff0f9b936356d8e0b347"
},
{
"path": "src/internal/server/oidc/userinfo.go",
"sha256": "f9c986f7a2c79f27a9970a296e6b493014fed77af2f22ba96c4a457d049c916f"
},
{
"path": "workplans/ADHOC-2026-09-27.md",
"sha256": "f6220f17fc5b6e7300d0a55ac29f78f385ad89db276ccaa21693428fffa5919b"
},
{
"path": "workplans/KEY-WP-0042-online-access-token-status.md",
"sha256": "6d7ff6dea31b251e261195fff97a2061d56579b5786629d95014f6d1cbe0f55e"
}
]
}

View file

@ -0,0 +1,30 @@
{
"workplan": {
"record_id": "KEY-WP-0042",
"uuid": "e2eecd1c-45c7-5a7c-bd1c-d31c845e3d38",
"status": "proposed"
},
"tasks": [
{
"record_id": "KEY-WP-0042-T01",
"uuid": "d8af8e77-f6e5-5d0d-8b44-794af3dee5cf",
"status": "progress"
},
{
"record_id": "KEY-WP-0042-T02",
"uuid": "2cade51f-d25a-5719-96b4-b57d50c1b97d",
"status": "done"
},
{
"record_id": "KEY-WP-0042-T03",
"uuid": "a5539bb0-2501-5774-a7d8-acddfb0938ab",
"status": "todo"
}
],
"task_rows_exact": true,
"existing_historical_binding_preserved": "7fa0bfc7-0772-57c7-8d6f-641615cab82a",
"existing_historical_status": "done",
"primary_projection_commit": "git:67a0869c1ee40c9e00a60cb7c1d828734d5d3d32",
"independent_owner_assent": false,
"native_admission": false
}

View file

@ -0,0 +1,224 @@
# SCOPE
Reviewed 2026-09-10 against source revision `f247d35`.
Review lineage, because "what changed since?" should have a usable answer. The
full capability review was 2026-09-09 against `5ae80d3`
([reassessment](history/2026-09-09-scope-reassessment.md)), superseding
2026-09-05 against `b989de4`. `7a73352` followed it by a minute — documentation
and workplans only — and was confirmed by reading the commit rather than by
report. Since then the tenant claim gained `tenant_source`, the approver client
was registered, and two guards were added that pin properties rather than
behaviours; each is described below. Read versus told, and reviewed versus merely
later, are distinctions this header is meant to preserve.
## Purpose and boundary
KeyCape is Go identity **Tooling** for NetKingdom's lightweight IAM deployment.
It runs its own OIDC-style issuer, uses Authelia for upstream browser
authentication, reads identities from LLDAP, and delegates factor checks to
privacyIDEA. It also provides caller authentication commands, directory snapshot
validation, and migration artifact generators.
It issues identity, role, scope and assurance claims. Resource authorization
belongs to access-engine and consuming services; secret custody and OpenBao
policy/token enforcement belong to their platform owners. The implemented subset
supports NetKingdom integrations, but complete profile conformance and drop-in
Keycloak interchangeability are not established.
## Implemented capabilities
| Surface | What the current source provides |
| --- | --- |
| Issuer HTTP API | Discovery, `/authorize` and its callback/registration-return routes, `/token`, `/jwks`, `/userinfo`, local `/logout`, `/healthz` (liveness, probes nothing) and `/readyz` (probes LLDAP by bind, Authelia and privacyIDEA). |
| Human authentication | Authorization code with S256 PKCE, exact registered redirects, scope allow-lists, Authelia login, privacyIDEA MFA challenges, client-specific assurance requirements, browser session reuse, step-up/fresh-login handling, and configured registration/enrollment handoffs. The exchange is bound to the redirect URI the code was issued for, refused for clients not registered for the grant, authenticated for confidential clients, and the code is consumed atomically so exactly one exchange can succeed. Upstream Authelia ID tokens are verified — signature against the provider's published keys, expected issuer, own client ID in the audience, validity window — failing closed and independently of transport. |
| Service authentication | Static confidential `client_credentials` clients authenticated with form-encoded `client_secret_basic`; configured subject, tenant, roles, scopes and per-client token lifetime. Secrets are resolved from environment references at startup. |
| Tokens and identity | Locally signed RS256 JWTs; configurable access-token resource audience while ID tokens retain the client audience; human/service principal types, tenant, groups, roles, scope and assurance claims. A service tenant is bound at registration; a human tenant comes from the directory, or from a client-declared zone when the directory has placed the user nowhere, and issuance is refused when the two conflict. `assurance.at` is the authentication time, not the mint time. UserInfo verifies algorithm, issuer and access-token purpose, resolves canonical directory subjects, and filters profile/email/groups by scope. |
| Caller commands | `keycape login` for public-client browser PKCE login, `keycape service-token` for service exchange, and `keycape verify-client` to check a live registration's subject, tenant, roles, lifetime and scope refusals without emitting values. HTTPS discovery/JWKS verification and private JSON token-file delivery outside Git; no token output on stdout. |
| Validation and migration | Canonical snapshot checks; deterministic LLDAP user/group/membership export that records whether it enumerated the whole directory; basic Keycloak realm JSON; LDIF generation for OpenLDAP, 389 Directory Server and AD targets. These generate artifacts rather than execute a complete migration. |
| Diagnostics and packaging | Structured authentication/enforcement/migration events, liveness and dependency-probing readiness endpoints, Go build/test/vet targets, a container packaging the issuer and all four migration/validation binaries, and development/CI scaffolding. |
## Material limits
- Client registrations, service secrets and the signing key are loaded at startup.
Login, authorization and handoff state are process-local. There is no shared
session store, general refresh-token flow, or automatic signing-key/client-secret
rotation service. The default profile remains expiry-only. Proposed KEY-WP-0042
adds an explicitly enabled, singleton, hash-only online access-token status
profile; see `docs/online-token-status.md` for its scope and admission limits. Logout clears the
local KeyCape session, not every upstream or downstream session/token.
- The optional tenant-engine `tenant_roles` adapter is wired through a
`tenantEngine` config block and is off unless `baseURL` is set (KEY-WP-0024).
When enabled it fails open: an unreachable source omits the claim rather than
failing issuance, so `tenant_roles` is a cache and must not be trusted for
privileged decisions.
- The LLDAP export enumerates the group subtree directly, so groups with no
members are present, and every snapshot carries a `groupEnumeration` field
saying whether that enumeration ran or the membership-derived fallback did
(KEY-WP-0018). A failed enumeration aborts the export rather than writing a
smaller snapshot, and read failures are reported rather than skipped. Users,
groups and memberships are sorted, so an unchanged directory exports
identically. Completeness beyond users, groups and memberships — passwords and
MFA credentials in particular — is still not covered.
- The Keycloak CLI takes a `-clients` config and migrates client registrations:
flows follow the declared grants, and audience, tenant, service subject and
roles are carried as protocol mappers with per-client lifetime, handoff URLs
and the secret *reference* as attributes (KEY-WP-0020). What it cannot carry it
names, in a report separate from the consistency check: secret values, MFA
policy enforcement, passwords and factor enrolment, and subject continuity —
Keycloak mints its own `sub`, so the LLDAP canonical ID survives only as an
attribute. The output is a reviewed artifact, not a proven migration; that
proof needs a live provider swap and is not established.
- Snapshot validation is a limited Go rule set, not full machine-readable schema
enforcement. Raw LDAP attribute keys are checked for descriptor validity,
canonical-mapping shadowing and case-only duplicates (KEY-WP-0021); attribute
values are not validated against a directory schema, and no allow-list of
permitted attribute names exists, since that field carries what the canonical
model does not name. The canonical YAML model and discovery metadata now match the
runtime client-registration surface and are held there by a conformance check
(KEY-WP-0017); the Go model is the runtime authority and the YAML the reviewed
contract. That is narrower than schema enforcement in general.
- The authorization-code grant now binds the redirect URI,
enforces grant-type eligibility, authenticates confidential clients and
consumes codes atomically, and UserInfo enforces algorithm, issuer and
access-token purpose (KEY-WP-0016). Upstream Authelia ID tokens are verified
before any claim is trusted — signature against the provider's published keys,
advertised issuer, own client ID in the audience, validity window — failing
closed and independently of transport (KEY-WP-0019). KeyCape deliberately does
not check or gate on the upstream transport. Enforcing these bindings
is not complete profile conformance. Relying parties must repeat `redirect_uri`
on the token exchange and present the access token, not the ID token, to
`/userinfo`; see [authorization-code bindings](docs/authorization-code-bindings.md).
See the assessment below.
- Tests cover local handlers, adapters, transformations and CLI protocol behavior.
They do not establish complete replacement against a running Keycloak/full-LDAP
stack. The Scenario B/C harnesses now run, and `src/tests/conformance` targets a
live issuer named by `KEYCAPE_TEST_ISSUER`, skipping when it is unset
(KEY-WP-0022). Run against Keycloak 26.0 it verified discovery, the
authorization surface, the published keys and a real token exchange — and
showed that stock Keycloak advertises the excluded `implicit` and `password`
grants, so it is not a drop-in for this profile. Directory migration is proved
end to end into a real OpenLDAP, with every migrated membership resolving, and a
realm built from a live export imports into Keycloak and serves discovery
(KEY-WP-0023). Relying-party behaviour and MFA against a migrated realm remain
unexercised, and credential/MFA migration is not supplied at all, so no harness
can establish it. Subject continuity is explicitly not preserved: the canonical
ID survives as an attribute while Keycloak mints its own `sub`.
- **All ten gaps from the 2026-09-05 assessment are now closed**, G10 last, on
2026-09-10. Closure means the gap each named no longer holds; it does not mean
nothing is outstanding. The approver registration is published but no human has
logged in through it, the deployed image predates most of this revision, and the
Qonto rotation is deferred by owner decision on evidence. Those are open items
with owners and triggers, which is a different thing from a gap between what
this document claims and what is true.
- **Source behaviour and deployed behaviour are not the same claim, and this
document describes source.** Everything above is what the code in this revision
does, established by 394 Go tests across 23 packages plus the harness
runs named. What has been demonstrated against the live deployment is narrower and is
recorded as committed receipts in `docs/evidence/`: the upstream issuer was
probed and then pinned in `sso/keycape-config`
(`2026-09-09-upstream-issuer-pin.json` — the pre-pin state did **not** match, so
this was a real fix), and both approval service clients were verified live in an
attended owner process, showing JWKS signature verification, exact claims,
excess-scope and wrong-secret denial at a 900s lifetime
(`2026-09-09-keycape-verifier-admission.json`), independently confirmed by the
custody owner. That is live evidence for those two clients specifically —
more than a source claim, less than a general one. On the custody boundary:
client-side retrieval of those secrets is unadmitted and stays so, but the
attended operator path is not a client-side read and never required one;
conflating the two is what left this work recorded as waiting on a run that had
already happened. Those receipts also decline what they did not test: no real
predecessor rotation and no observed wall-clock expiry. Those name a **third
state** this document otherwise has no words for — *implemented and unproven*,
distinct from both missing and done. `verify-client`'s predecessor rejection is
written and unit-tested but has never run against a genuinely distinct
predecessor, so this repository holds a receipt that reads like rotation
evidence and is not. Both owners state that limit independently. Read "there is
a test" and "it has been exercised against the real thing" as separate claims
wherever this document makes one. The deployed image is older than this revision, so the
authorization-code bindings, upstream verification, readiness, graceful
shutdown and the two tenant-related changes are **not yet running anywhere** —
see [operations](docs/operations.md), "Unreleased fail-closed startup changes".
Read `docs/evidence/` before describing any of this as proven live.
- The server listens on HTTP; HTTPS termination is deployment-owned.
`/healthz` is liveness and probes nothing; `/readyz` probes LLDAP, Authelia and
privacyIDEA and gates traffic. `SIGTERM` drains in-flight requests for 15s.
The supported topology is a single replica, since login and authorization state
is process-local — see [operations](docs/operations.md) for that and for the
key-rotation, client-removal and logout limits. Development
Compose needs key and Authelia material absent from the checkout by design;
`scripts/bootstrap-dev.sh` generates it locally and git-ignores it
(KEY-WP-0026). Production
deployment and custody are external; a source implementation or example client
fragment does not prove live registration or consumer cutover.
- Notification receipt closure is done: KEY-WP-0009 is finished, its four handoffs
delivered with receipts readable via `GET /messages/?from_agent=key-cape`.
Approval-client provisioning (KEY-WP-0013-T02) and coordinated Qonto rotation
plus the login-proxy consumer split (KEY-WP-0014-T04) remain open. Both requests
are now delivered and both wait on owners outside this repository —
railiance-platform for custody admission, approval-engine for the exact human
callback, ops-warden for the route contract and a rotation execution/rollback
authority. No client is materialized in the deployed image and no route changed.
## Deliberate exclusions
General-purpose IAM, dynamic client registration, implicit/password grants,
wildcard redirects, arbitrary identity brokering, resource authorization policy,
OpenBao credential custody, and Keycloak operations are outside this repository's
ownership. Registration/enrollment handoffs do not implement user provisioning
or factor enrollment themselves.
## Entry points and verification
Run the issuer with `keycape --config PATH`; `keycape server` and
`keycape migrate` are not implemented subcommands. Caller commands are
`keycape login`, `keycape service-token` and `keycape verify-client`.
`keycape check-config` validates a rendered configuration offline, before a
restart (see [operations](docs/operations.md)).
Separate binaries under `src/cmd/` are `validator`, `lldap-export`,
`keycape-to-keycloak`, and `lldap-to-ldap`. `make -C src build` places binaries in
root `bin/` — the root `make build` only compiles and installs nothing. The
container packages all five binaries with the issuer as its entrypoint
(KEY-WP-0026). Tests live under `src/tests/` and alongside packages in
`src/internal/`.
`make test`, `make lint` (Go vet), `make build`, and `make contract-test` are the
root checks. The capability-contract check requires the sibling NetKingdom
validator. These checks passed for the reviewed revision in the preceding
implementation session and were rerun for this one; `src/tests/conformance` is
additionally runnable against a live issuer via `KEYCAPE_TEST_ISSUER` and skips
when it is unset. The scope assessment is a source review: it establishes what
the code does, not what any deployment is currently running.
Use this repo for its bounded issuer, caller JWT acquisition, identity adapters
and migration preparation. Consult [INTENT.md](INTENT.md) for the architectural
target, [native authentication](docs/native-authentication.md) for caller
constraints, [operations](docs/operations.md) for the supported topology and the
unreleased fail-closed changes, `docs/evidence/` for what has been proven against
the live deployment, and the [reassessment](history/2026-09-09-scope-reassessment.md)
— with the [2026-09-05 assessment](history/2026-09-05-011726-scope-intent-assessment.md)
it supersedes — for evidence, priorities and remaining gaps.
## Provided capabilities
```capability
type: security
title: Bounded OIDC and service-token issuance
description: Implements static-client browser PKCE and client-credentials authentication with RS256 identity claims through Authelia, LLDAP and privacyIDEA; complete Keycloak interchangeability remains unproven.
keywords: [oidc, pkce, authentication, iam, sso, mfa, identity, service-token]
```
```capability
type: security
title: Verified caller JWT acquisition
description: Provides native browser login and service-token exchange commands with HTTPS discovery, JWKS verification and private token-file delivery.
keywords: [oidc, pkce, jwt, cli, authentication, jwks]
```
```capability
type: security
title: Directory validation and migration artifact generation
description: Checks canonical snapshots and produces LLDAP exports, basic Keycloak realm JSON and target-specific LDIF; does not perform or prove a complete live migration.
keywords: [migration, identity, lldap, keycloak, ldif, validation]
```

View file

@ -0,0 +1,119 @@
# Optional online access-token status
Source proposal under KEY-WP-0042, driven by CUST-WP-0080 and existing
NK-WP-0048-T34/T42/T43 and RAPPS-WP-0016-T08/T09. Independent issuer/consumer
contract review and native admission remain required. Existing deployments are
unchanged. This extends the bounded issuer; it does not introduce a broker,
approval store, PDP, or a second application-local token registry.
The legacy profile authenticates self-contained JWTs until expiry. That does
not establish that an operation's initiating token is still unrevoked. The
optional profile adds issuer-owned current access-token status using
[RFC 7662](https://www.rfc-editor.org/rfc/rfc7662.html) introspection and
[RFC 7009](https://www.rfc-editor.org/rfc/rfc7009.html) revocation. It supports
access tokens only, without refresh tokens or token exchange.
## Enablement and lifecycle
Owner-admitted configuration opts in explicitly:
```yaml
tokenStatus:
enabled: true
maxEntries: 10000
```
The issuer must use HTTPS at its existing trusted ingress. The server's internal
HTTP listener and reverse-proxy topology are unchanged; direct HTTP exposure is
not admitted by an HTTPS issuer string. Omitted configuration keeps the existing
expiry-only token/UserInfo contract and advertises no new endpoints. Enabling
status registers `/introspect` and `/revoke`, advertises their implemented client
authentication methods, and connects the same store to issuance and UserInfo.
Human PKCE and service grants add a cryptographically random `jti` to each access
token. ID-token claims and existing assurance/tenant provenance remain unchanged.
Distinct grants in the same second have distinct bearer tokens: a new grant can
never remint a revoked token. A successful response is emitted only after the
token hash, expiry and current registration commitment have been recorded. No
bearer, client secret or identity/group snapshot is retained in the status store.
The store is bounded to the configured count (default 10000, maximum 1000000).
Live records and revocation tombstones are never evicted to admit another grant;
capacity exhaustion refuses issuance with a fixed 503. Expired entries are
collected when admitting a new token. Capacity and per-client ingress rate limits
need workload-representative admission: this is not a throughput/availability
claim or protection against an authorized client filling the bound.
This profile uses KeyCape's existing **single-instance topology**. Status is
process-local. Restart, deployment, rollback or store loss makes every earlier
token inactive at online endpoints, even if its JWT signature/expiry still
validates offline. Consumers must reauthenticate; there is no recovery replay
or token-state import that can resurrect revoked authority. Multiple replicas
are unsupported. Native admission must include this interruption and the
measured reauthentication/recovery procedure. A persisted/shared store is not
introduced here.
## Introspection and revocation
Both endpoints accept POST form bodies only, bound to 64 KiB; token values are
bound to 32 KiB. Query parameters, duplicate form or authentication fields,
unsupported content types and malformed authentication refuse without echoing
input. Responses use `Cache-Control: no-store` and `Pragma: no-cache`.
Introspection requires the existing registered confidential client's
`client_secret_basic` authentication. Only that client's own issued tokens can
be inspected. A separate resource audience is returned as `aud`, not confused
with the issuing `client_id`. Cross-client resource-server delegation needs a
separately reviewed authorization contract and is not implicitly granted by a
matching audience string. Public clients cannot enumerate token status.
An active response means the exact access token was issued in this process,
has not expired or been revoked, still matches its current complete registration,
and passes current issuer/signature/purpose validation. For humans, directory
lookup must succeed and the current account must be enabled and not suspended.
Status is checked again after directory lookup. Service subjects remain bound
to their current registration. Active output contains only standard access-token
metadata; inactive output contains only `active: false`. Unknown tokens, ID
tokens, altered tokens, another client's tokens and expired/revoked tokens all
return that same inactive response to an authenticated introspector.
Revocation authenticates confidential clients using the same method. A registered
public client may revoke its own token using `client_id` plus possession of that
bearer; public-client introspection remains unavailable. Revocation only affects
the exact token issued to that client. Unknown, other-client, expired and already
revoked tokens all produce the same empty 200. No token existence is disclosed.
Token-type hints do not bypass the actual access-token lookup. Removal or changes
to a registration invalidate online status; the commitment explicitly includes
the secret digest and internal token lifetime omitted from public Client JSON.
When enabled, actual UserInfo consults the same store before lookup and again
after it. Revocation therefore affects that existing endpoint immediately on its
next check. Ordinary login/logout and upstream Authelia sessions are distinct:
neither browser logout nor deleting a local cookie is represented as global
JWT revocation.
## Consumer contract and remaining acceptance
Online status establishes current access-token status, not permission
for an application resource or privileged effect. Consumers still independently
validate the actual signed token, admitted issuer/key/audience/type, subject,
tenant, principal type, assurance and freshness. The authenticated introspection
response must refer to that exact token/client. Obtain a new current response
before each protected operation; unavailable, malformed, inactive or ambiguous
responses refuse. A cached positive is not a current-session check.
Access Engine retains resource/action decisions, Approval Engine exact one-shot
approval, Warden admission and owner adapters execution. Bind their existing
descriptor, case, transaction and exact effect before dispatch. A separate new
operation/inverse token must be acquired and revoked on every terminal path;
renewed authentication cannot revive a consumed approval or replay a mutation.
This issuer capability alone implements none of those consumer bindings.
Native completion still requires independent issuer/profile review, exact
image/config/trust admission, bounded retained-token custody, real human/service
issuance, private transport, current-token/revocation integration in each owner,
all exit-path cleanup, restart/expiry/unavailable/refusal checks, ongoing
HTTP/API/Git/SSH authorization and audit evidence. Offline consumers which never
consult the endpoint retain their existing JWT validity; no estate-wide immediate
revocation claim follows from source implementation. Disabling the feature is
not an acceptable fallback for a consumer admitted under the online profile.

View file

@ -0,0 +1,294 @@
# Operating KeyCape
The supported deployment topology and the limits that come with it. These are
deliberate boundaries of the current implementation, not defects awaiting a fix:
where a limit is a consequence of a design choice, the choice is named.
## Topology: exactly one replica
**Run one instance per issuer.** Authorization codes, login sessions and
registration/enrollment handoffs are held in process memory. There is no shared
store, and no sticky-session configuration makes this safe: a browser that starts
a login on one replica and returns from Authelia to another finds no session and
must start again. Two replicas do not halve the failure rate, they roughly double
the login failure rate.
This is a documented exclusion rather than a missing feature. Adding a shared
session store is a real option if the profile ever requires horizontal scale; it
is not required today.
Consequences to plan for:
- **A restart drops in-flight logins.** Anyone mid-login gets an error and must
start again. In the default profile issued tokens are unaffected — they are
self-contained JWTs and
stay valid until they expire.
- **Rolling deployments are single-instance rollovers**, so expect a brief window
where new logins fail. Draining (below) protects requests already in flight, not
logins waiting on a human at Authelia's password prompt.
## Liveness and readiness
| Endpoint | Answers | Use it for |
| --- | --- | --- |
| `/healthz` | Is the process up? Probes nothing. | Liveness. |
| `/readyz` | Are LLDAP, Authelia and privacyIDEA reachable? | Readiness / traffic gating. |
Keep these distinct. Wiring liveness to `/readyz` means an orchestrator restarts
KeyCape whenever a dependency blinks, turning someone else's blip into an outage
of your own — and a restart also discards every in-flight login, making it worse
than the condition it reacted to.
`/readyz` returns 200 with `status: ready`, or 503 with `status: not_ready` and
the failing check named. It reports *which* check failed, never *why*: the
endpoint is unauthenticated and upstream error text carries hostnames and
occasionally credentials-in-URLs. The reason is in the server log.
Probes are reachability and credential checks, not functional tests. LLDAP is
probed with a bind, so a rotated or revoked service password is caught — that
leaves the port open and every lookup failing, which is exactly what readiness
should catch. Authelia and privacyIDEA are probed with a plain HTTP GET: any
response means something is listening and speaking HTTP.
Results are cached for 2 seconds and each probe is bounded at 3 seconds. The
cache is not an optimisation: the endpoint is necessarily unauthenticated, and
without it anyone able to reach it could drive one upstream request per
dependency per call.
## Shutdown
On `SIGTERM` or `SIGINT` the server stops accepting connections and gives
in-flight requests up to 15 seconds to finish. The grace period is deliberately
under the 30-second read/write timeouts, so a stuck request cannot outlive the
window an orchestrator typically allows before `SIGKILL`.
In-memory login and authorization state is not preserved across shutdown, by
design — see the topology section.
## Key and registration lifecycle
The signing key and all client registrations are read once at startup. Both are
changed by editing configuration and restarting; there is no reload signal and no
rotation service.
**Check a changed configuration before the restart.** KeyCape exits at startup on
a configuration it refuses, and with one replica that stops sign-in for every
client. `keycape check-config --config PATH [--env-names A,B] [--policy-store]`
runs every startup check that needs no secret value, signing key or upstream, and
prints a JSON report with no values in it. The checks cover validation (including
redirect URIs with `?` or `*`, and duplicate `clientId`s), building the client
registry, the global `tokenLifetime`, and the clients the policy store requires
when `--policy-store` is set. With `--env-names`, it also checks that every
`env:` secretRef is among the variables the Deployment provides. It exits non-zero
when the server would refuse to start. It does not check that the key file is
readable or that a secret is non-empty, because it never sees either.
**The key ID is the constant `key-1`.** Rotating the signing key while keeping
that identifier is a trap: a consumer caching JWKS by `kid` may keep the old key
and reject freshly issued tokens until its cache expires. Plan rotation as
"publish new keys, let consumers refetch, then issue with the new key", and treat
a same-`kid` swap as a breaking change for anyone caching aggressively.
**Removing a client registration does not revoke tokens already issued to it.**
In the default expiry-only profile there is no introspection or revocation
endpoint, so an issued token stays valid
until it expires — 15 minutes by default, or the client's `tokenLifetime`. To cut
off a compromised client, remove the registration *and* wait out the lifetime, or
rotate the signing key if you cannot.
**`/logout` clears the local KeyCape session only.** It does not end the Authelia
session and does not revoke any issued token. A user who logs out and back in may
not be prompted for credentials, because the upstream session is still valid.
## Transport
The server speaks plain HTTP. TLS termination belongs to the deployment. KeyCape
deliberately does not check or gate on the transport used to reach its upstream
providers either; upstream ID tokens are verified cryptographically instead, so
that assurance does not depend on the network being what we believe it is
(KEY-WP-0019).
## Upstream issuer pinning (read before rolling out KEY-WP-0019)
KeyCape verifies upstream ID tokens against the issuer the provider advertises,
discovered server-side from `authelia.tokenBaseURL`. Some providers — Authelia
among them — derive the advertised issuer from the **request Host**, so the value
KeyCape learns over an in-cluster service address is not the value minted into
tokens issued for the browser-facing host.
Where that is true, pin it explicitly:
```yaml
authelia:
tokenBaseURL: "http://authelia.sso.svc.cluster.local:9091"
issuer: "https://auth.coulomb.social" # exactly the iss claim in ID tokens
jwksUrl: "http://authelia.sso.svc.cluster.local:9091/jwks.json"
```
Verification fails closed, so a mismatch means **every human login fails** — and
it looks like a broken login rather than a configuration error. Check the
issuer before rolling out, not after. The failure is diagnosable: the
authentication failure event carries `error_type=id_token_issuer_mismatch`, as
distinct from `id_token_signature` or `provider_keys_unavailable`.
Confirm the value with the Host the provider will actually see:
```bash
curl -s -H "Host: auth.coulomb.social" \
http://authelia.sso.svc.cluster.local:9091/.well-known/openid-configuration \
| jq -r .issuer
```
## Before any live change
The estate-wide rules are in
`the-custodian/docs/agent-environment-orientation.md` (revision 2026-09-21);
read it first. The parts that apply to KeyCape's objects in `sso` and `mfa`:
- The founder's go-ahead comes first, recorded as
`ADMINISTER @ realm:kubernetes/railiance01`, `activation=APPROVED`. Run changes
as `ssh railiance01 '…'`. A harness block is a stop signal, not something to
route around.
- Check CPU headroom before a restart or rollout
(`kubectl describe node | grep -A4 "Allocated resources"`). A rolling update
starts the new pod before stopping the old one.
- Diff first: client-side `kubectl diff`, then `--dry-run=server`.
- **Never `kubectl apply` a Secret, and never read one's metadata.** `apply`
copies the whole Secret into the `last-applied-configuration` annotation, so
even a metadata read prints the signing key and client secrets. Write Secrets
with `kubectl replace`. To test for the annotation without printing it:
`kubectl get secret <n> -n sso -o go-template='{{ if index .metadata.annotations "kubectl.kubernetes.io/last-applied-configuration" }}HAS-ANNOTATION{{ else }}clean{{ end }}'`.
- **Never run any other go-template or jsonpath against a Secret.** When a
template fails, for example `len` on an absent field, kubectl prints the whole
raw object as debugging output, `.data` included. The presence check above is
the only tested template. This leaked `sso/keycape-config` into an agent
session on 2026-09-23 (see below).
**Open finding, 2026-09-23:** `sso/keycape-config`, `sso/authelia-secrets`,
`sso/lldap-secrets` and `mfa/privacyidea-config` all carry that annotation. The
earlier rotation script wrote them with `apply`; it now uses `replace`. Anyone
who can `get` these Secrets could read the data anyway, so this is not a new
reader. What it breaks is the assumption that a metadata read is safe. The fix
is a live change and waits for the founder's go-ahead:
`kubectl annotate secret <n> -n <ns> kubectl.kubernetes.io/last-applied-configuration-`
for each of the four. It prints no value and leaves `.data` untouched, so pods
need no restart. Re-check with the template above afterwards.
**Superseded the same day by an exposure.** While checking these Secrets' owners
before the cleanup, an agent ran an untested template. It failed and printed
`keycape-config` in full into the session transcript: the signing key, the LLDAP
bind password and the Authelia client secret. The other three Secrets were
probably printed as well. All four must be treated as exposed and rotated; the
annotation cleanup folds into that rotation.
**Rotation deferred (founder decision, 2026-09-24, builder mode).** Tracked as
the-custodian `CUST-WP-0073-T05` with event triggers: the first production
workload or customer data, any sign the transcript left the workstation, or a
planned key rotation. The same workplan removes the problem class by giving
agents a cluster identity that cannot read these objects at all.
## Current image (2026-10-15)
The live issuer is
`forgejo.coulomb.social/coulomb/key-cape@sha256:cdec9dd7c515f7e0a6169fc3fa560ee2cc47bf40d899755dec808c362f5ce010`,
source `8c6c071`. Over the previous live source `1164f65` it adds the
duplicate-clientId startup check (`c528daa`), the lost-authenticator help link
(`205a1c0`), and the opt-in sign-in diagnosis page with `uid`/`assurance` on
`auth_success` (`af3b1b0`). Founder-run on 2026-10-15: `keycape check-config`
on the live config was valid (13 clients, no errors), `kubectl diff` showed only
the image, server dry-run passed, Recreate rollout succeeded, pod ready with 0
restarts and discovery answering 200. The owner manifest is
`net-kingdom/sso-mfa/k8s/keycape/deployment.yaml`. Rollback is
`sha256:6f79a2af1c695d39480173fad013facd84d21e7732860366af519302a2c496b8`.
## Fail-closed startup changes from source 11ce29a
These remain in the current image. They first shipped in
`sha256:8dc35801d1d2b0905a43530c40b1fed0107fc179141fe5b2b614c52c01714fc9`,
Forgejo run 69, source `11ce29a`, confirmed running on 2026-09-25. They fail
closed. The 2026-09-24 Vergabe journey completed after one token-exchange
failure; see `docs/evidence/2026-09-24-fresh-login-and-account-switch.md`.
**1. Browser clients reject service-identity fields (`74b35b6`, KEY-WP-0028).**
Config validation now rejects `serviceSubject` or `roles` on a client whose
grant is not `client_credentials`; those fields were silently ignored there, so a
registration could look effective and do nothing. This is a **startup** error:
a stray field in the deployed config stops the process from booting rather than
degrading. The live `sso/keycape-config` was checked on 2026-09-09 and none of
its three browser clients carries either field, so it passes as deployed. The
custody owner confirmed independently that the config was last written during the
2026-09-09 activation (resource version 58747126) and has not been edited since,
so the inspected state is the state that will boot — and that they will re-check
the browser clients if anything writes that Secret before the window. `tenant` is deliberately not rejected;
see below.
**2. A conflicting tenant binding refuses issuance (`329e48f`, KEY-WP-0013-T05).**
A client registration may declare a `tenant`, which is supplied when the
directory has placed the user nowhere and enforced when it has. A declared zone
that **conflicts** with a directory assignment refuses the token with `403` and
`error_type: tenant_binding`. No deployed client declares a tenant on a browser
grant today, so nothing changes on rollout; the refusal only becomes reachable
once the approver client is registered. See
[the tenant contract](tenant-claim-contract.md).
**3. KeyCape enforces login freshness itself (`11ce29a`, KEY-WP-0033).**
`prompt=login` is no longer forwarded to Authelia, because 4.38 refuses it for
every real login. `prompt=login` and `max_age=0` go upstream as `max_age=10`,
and the callback refuses any upstream `auth_time` that misses the requirement,
or is absent (`stale_upstream_authentication`). This changes nothing for clients
that do not ask for freshness. The proof is a completed fresh-login journey,
not a redirect check.
The live boot is the image above. A further rollout is not required for these
three changes.
## What is not claimed
No resource-efficiency or throughput bounds are asserted here. Nothing in this
repository benchmarks KeyCape, so any figure would be invention. Measure it in
your own deployment before sizing against it.
## Account recovery and browser sign-out
Set `KEYCAPE_ACCOUNT_PORTAL_URL=https://users.coulomb.social` and
`KEYCAPE_BROWSER_LOGOUT_URL=https://auth.coulomb.social/logout` together to enable
central browser recovery. Both must be HTTPS, with no credentials, query or
fragment. They override the corresponding YAML accountPortalURL/browserLogoutURL.
Failed browser authorization and expired callbacks redirect to the fixed
`/access-recovery` page with no state, code, claimed user or browser return URL.
API authorization/token validation remains fail-closed.
`GET /account/logout` displays confirmation and creates a short-lived Secure,
HttpOnly host-only CSRF cookie. POST checks that cookie, the form nonce and exact
issuer Origin; only then does it invalidate the current KeyCape session and
redirect to Authelia's browser `/logout`, with a fixed portal `/logged-out` return.
Authelia owns and deletes its session cookie. Its v4.38 SignOut view validates the
return destination and performs the same-origin logout API call. Reference:
https://github.com/authelia/authelia/blob/v4.38.19/web/src/views/LoginPortal/SignOut/SignOut.tsx
Existing RP sessions and issued JWTs are not revoked. The original `/logout`
endpoint retains its local-only contract. Actual shared-session destruction
requires browser execution; a redirect-only smoke is not acceptance evidence.
## Sign-in diagnosis page (NK-WP-0051-T02)
A client with `signInDiagnosis: true` in its registration shows a page before
the redirect back to it: `uid`, `groups`, `assurance` and `client_id` as defined
in `net-kingdom/docs/sign-in-diagnosis.md`, with one continue link carrying the
unchanged code. Default is off. The repo enables it only for `openbao-admin` in
`config/dev-config.yaml`; the live flag is set on the OpenBao browser client
(`openbao-admin`) in `config.yaml` of the `sso/keycape-config` Secret, and takes
effect on the next issuer restart. It is live since 2026-10-15 (rv 75372162),
set with net-kingdom `sso-mfa/k8s/keycape/openbao-signin-diagnosis-rollout.py`;
`--operation remove` plus a restart is the rollback. A redirect URI that is not http(s)
skips the page. Every `auth_success` log line now carries `client_id`, `uid` and
`assurance`, and no token, code, email or one-time password.
## Proposed online token-status profile
KEY-WP-0042 adds an explicitly enabled singleton profile. It makes prior tokens
inactive at online endpoints after restart and keeps revoked-token tombstones
until expiry. It does not revoke offline consumers or upstream/browser sessions.
Read [the complete lifecycle and admission contract](online-token-status.md)
before proposing activation; no image/config rollout or consumer admission is
established by source tests. Existing deployed configuration remains unchanged.

View file

@ -0,0 +1,488 @@
// keycape is the main server binary for the KeyCape IAM profile service.
// It orchestrates Authelia, LLDAP, and privacyIDEA to implement the
// NetKingdom IAM Profile (OIDC/PKCE Authorization Code Flow).
package main
import (
"context"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"flag"
"fmt"
"keycape/internal/server/readiness"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/rs/zerolog"
"keycape/internal/adapters/authelia"
"keycape/internal/adapters/lldap"
"keycape/internal/adapters/privacyidea"
"keycape/internal/adapters/tenantengine"
"keycape/internal/authclient"
"keycape/internal/config"
"keycape/internal/domain"
servererrors "keycape/internal/server/errors"
"keycape/internal/server/oidc"
"keycape/internal/server/policy"
"keycape/internal/server/telemetry"
)
const version = "0.1.0"
func main() {
if len(os.Args) > 1 && os.Args[1] == "check-config" {
os.Exit(runCheckConfig(os.Args[2:], os.Stdout))
}
if len(os.Args) > 1 && os.Args[1] == "probe-upstream-issuer" {
if err := authelia.RunIssuerProbe(context.Background(), os.Args[2:], os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return
}
if len(os.Args) > 1 && (os.Args[1] == "login" || os.Args[1] == "service-token" || os.Args[1] == "verify-client") {
if err := authclient.Run(context.Background(), os.Args[1:], os.Stderr); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return
}
log := zerolog.New(os.Stdout).With().Timestamp().Logger()
// -----------------------------------------------------------------
// 1. Parse flags and load config.
// -----------------------------------------------------------------
var cfgPath string
flag.StringVar(&cfgPath, "config", "", "path to YAML config file (env: KEYCAPE_CONFIG)")
flag.Parse()
cfg, err := config.Load(cfgPath)
if err != nil {
log.Error().Err(err).Msg("failed to load config")
os.Exit(1)
}
// -----------------------------------------------------------------
// 2. Validate config.
// -----------------------------------------------------------------
errs := config.ValidateConfig(cfg)
if len(errs) > 0 {
log.Error().Strs("errors", errs).Msg("config validation failed")
os.Exit(1)
}
// -----------------------------------------------------------------
// 3. Load RSA private key.
// -----------------------------------------------------------------
privateKey, err := loadPrivateKey(cfg.PrivateKeyPEM)
if err != nil {
log.Error().Err(err).Str("path", cfg.PrivateKeyPEM).Msg("failed to load private key")
os.Exit(1)
}
// -----------------------------------------------------------------
// 4. Build JWKS from public key.
// -----------------------------------------------------------------
ks := oidc.NewKeySet()
ks.AddKey("key-1", &privateKey.PublicKey)
// -----------------------------------------------------------------
// 5. Build client registry.
// -----------------------------------------------------------------
clients, err := buildClientRegistry(cfg.Clients)
if err != nil {
log.Error().Err(err).Msg("failed to build client registry")
os.Exit(1)
}
// -----------------------------------------------------------------
// 6. Create adapters.
// -----------------------------------------------------------------
lldapAdapter := lldap.New(cfg.LLDAP)
autheliaAdapter := authelia.New(cfg.Authelia, nil)
privacyIDEAAdapter := privacyidea.New(cfg.PrivacyIDEA, nil)
// -----------------------------------------------------------------
// 7. Create telemetry emitter.
// -----------------------------------------------------------------
emitter := telemetry.NewLogEmitter(log)
// -----------------------------------------------------------------
// 8. Create enforcement registry.
// -----------------------------------------------------------------
enforcement := servererrors.DefaultRegistry()
// -----------------------------------------------------------------
// 9. Create session store.
// -----------------------------------------------------------------
sessions := oidc.NewSessionStore()
// -----------------------------------------------------------------
// 10. Parse token lifetime.
// -----------------------------------------------------------------
tokenLifetime := 15 * time.Minute
if cfg.TokenLifetime != "" {
d, err := time.ParseDuration(cfg.TokenLifetime)
if err != nil {
log.Error().Err(err).Str("tokenLifetime", cfg.TokenLifetime).Msg("invalid tokenLifetime")
os.Exit(1)
}
tokenLifetime = d
}
// -----------------------------------------------------------------
// 11. Build issuer base URL.
// -----------------------------------------------------------------
issuer := strings.TrimRight(cfg.Issuer, "/")
// -----------------------------------------------------------------
// 12. Register HTTP handlers.
// -----------------------------------------------------------------
mux := http.NewServeMux()
var tokenStatus *oidc.TokenStatusStore
var introspectionEndpoint, revocationEndpoint string
if cfg.TokenStatus.Enabled {
limit := cfg.TokenStatus.MaxEntries
if limit == 0 {
limit = 10000
}
var err error
tokenStatus, err = oidc.NewTokenStatusStore(limit)
if err != nil {
log.Error().Msg("invalid token-status configuration")
os.Exit(1)
}
status := &oidc.TokenStatusHandler{Tokens: tokenStatus, ClientConfig: clients, Users: lldapAdapter, SigningKey: &privateKey.PublicKey, Issuer: issuer}
mux.HandleFunc("/introspect", status.Introspect)
mux.HandleFunc("/revoke", status.Revoke)
introspectionEndpoint, revocationEndpoint = issuer+"/introspect", issuer+"/revoke"
}
// Discovery.
mux.Handle("/.well-known/openid-configuration", oidc.NewDiscoveryHandler(oidc.DiscoveryConfig{
Issuer: issuer,
AuthorizationEndpoint: issuer + "/authorize",
TokenEndpoint: issuer + "/token",
JWKSUri: issuer + "/jwks",
UserinfoEndpoint: issuer + "/userinfo",
EndSessionEndpoint: issuer + "/logout",
IntrospectionEndpoint: introspectionEndpoint,
RevocationEndpoint: revocationEndpoint,
Clients: clients,
}))
// JWKS.
mux.Handle("/jwks", oidc.NewJWKSHandler(ks))
// Authorize handler (with enforcement middleware).
logins := oidc.NewLoginSessionStore()
authorizeHandler := &oidc.AuthorizeHandler{
AccountPortalURL: cfg.AccountPortalURL,
ClientConfig: clients,
Auth: autheliaAdapter,
MFA: privacyIDEAAdapter,
Sessions: sessions,
Logins: logins,
Handoffs: oidc.NewHandoffStore(),
Issuer: issuer,
Emitter: emitter,
Users: lldapAdapter,
}
if path := os.Getenv("KEYCAPE_POLICY_PATH"); path != "" {
policies, err := policy.Open(path, clients)
if err != nil {
log.Error().Msg("authentication policy store unavailable")
os.Exit(1)
}
authorizeHandler.EffectivePolicy = policies.Effective
mux.Handle("/platform/authentication-policy", policy.Handler(policies, issuer, &privateKey.PublicKey))
}
mux.Handle("/authorize", enforcement.Middleware(authorizeHandler))
mux.Handle("/authorize/callback", authorizeHandler)
mux.Handle("/authorize/return", authorizeHandler)
mux.Handle("/authorize/register", authorizeHandler)
mux.Handle("/logout", &oidc.LogoutHandler{
ClientConfig: clients,
Logins: logins,
SecureCookie: strings.HasPrefix(strings.ToLower(issuer), "https://"),
})
if cfg.AccountPortalURL != "" {
mux.Handle("/account/logout", &oidc.AccountLogoutHandler{
PortalURL: cfg.AccountPortalURL, UpstreamLogoutURL: cfg.BrowserLogoutURL,
Logins: logins, Issuer: issuer,
})
}
// Token handler (with enforcement middleware).
tokenHandler := &oidc.TokenHandler{
ClientConfig: clients,
Sessions: sessions,
Users: lldapAdapter,
SigningKey: privateKey,
Issuer: issuer,
TokenLifetime: tokenLifetime,
Emitter: emitter,
// Opt-in: nil unless tenantEngine.baseURL is configured, which is what
// leaves tenant_roles off the stock server (KEY-WP-0024). The adapter
// fails open, so a configured-but-unreachable cache omits the claim
// rather than failing issuance.
TenantEngine: buildTenantEngine(cfg.TenantEngine),
Tokens: tokenStatus,
}
mux.Handle("/token", enforcement.Middleware(tokenHandler))
// Userinfo handler.
mux.Handle("/userinfo", &oidc.UserinfoHandler{
Users: lldapAdapter,
SigningKey: &privateKey.PublicKey,
Issuer: issuer,
Emitter: emitter,
Tokens: tokenStatus,
ClientConfig: clients,
})
// Healthz.
// /readyz gates traffic on the dependencies a login needs; /healthz stays a
// liveness signal (KEY-WP-0025). Restarting KeyCape because LLDAP blinked
// would convert a dependency blip into an outage of our own, so the two must
// not be the same endpoint.
mux.Handle("/readyz", readiness.New(2*time.Second, 3*time.Second,
readiness.Check{Name: "lldap", Probe: func(ctx context.Context) error {
return lldapAdapter.Ping(ctx)
}},
readiness.Check{Name: "authelia", Probe: httpReachable(cfg.Authelia.TokenBaseURL, cfg.Authelia.BaseURL)},
readiness.Check{Name: "privacyidea", Probe: httpReachable(cfg.PrivacyIDEA.BaseURL)},
readiness.Check{Name: "factor_reader", Probe: privacyIDEAAdapter.Probe},
))
// Liveness only: this says the process is up, and deliberately probes
// nothing. See /readyz.
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"version": version,
})
})
// Inject emitter into request context.
handler := withEmitter(mux, emitter)
// -----------------------------------------------------------------
// 13. Start HTTP server.
// -----------------------------------------------------------------
addr := fmt.Sprintf(":%d", cfg.Port)
if cfg.Port == 0 {
addr = ":8080"
}
log.Info().
Str("issuer", issuer).
Str("addr", addr).
Str("environment", cfg.Environment).
Str("version", version).
Msg("starting keycape server")
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
// Serve in the background so shutdown can be driven by a signal. Without
// this the process died on SIGTERM mid-request, so every rolling restart
// returned errors to whoever was logging in at that moment.
serveErr := make(chan error, 1)
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
serveErr <- err
return
}
serveErr <- nil
}()
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
select {
case err := <-serveErr:
if err != nil {
log.Error().Err(err).Msg("server error")
os.Exit(1)
}
case sig := <-signals:
log.Info().Str("signal", sig.String()).Msg("shutting down")
// In-flight requests get a bounded window to finish. In-memory login and
// authorization state does not survive this and is not meant to: a
// browser mid-login must start again after a restart. That is a
// documented limit of the single-replica topology, not a bug to paper
// over here -- see docs/operations.md.
ctx, cancel := context.WithTimeout(context.Background(), shutdownGrace)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Error().Err(err).Msg("graceful shutdown failed; exiting anyway")
os.Exit(1)
}
log.Info().Msg("shutdown complete")
}
}
// shutdownGrace bounds how long in-flight requests may finish after a signal.
// Kept under the 30s read/write timeouts so a stuck request cannot outlive the
// window an orchestrator typically allows before sending SIGKILL.
const shutdownGrace = 15 * time.Second
// httpReachable probes the first non-empty URL with a GET, reporting whether the
// dependency answered at all. It is a reachability check, not a functional one:
// any HTTP response means something is listening and speaking HTTP there.
func httpReachable(candidates ...string) func(context.Context) error {
target := ""
for _, candidate := range candidates {
if candidate != "" {
target = candidate
break
}
}
return func(ctx context.Context) error {
if target == "" {
return fmt.Errorf("no endpoint configured")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// loadPrivateKey reads a PEM file and parses the RSA private key.
// Supports both PKCS#1 ("RSA PRIVATE KEY") and PKCS#8 ("PRIVATE KEY") PEM blocks.
func loadPrivateKey(path string) (*rsa.PrivateKey, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read key file: %w", err)
}
block, _ := pem.Decode(data)
if block == nil {
return nil, fmt.Errorf("no PEM block found in %q", path)
}
switch block.Type {
case "RSA PRIVATE KEY":
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse PKCS1 private key: %w", err)
}
return key, nil
case "PRIVATE KEY":
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse PKCS8 private key: %w", err)
}
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("private key is not an RSA key")
}
return rsaKey, nil
default:
return nil, fmt.Errorf("unexpected PEM block type %q; expected RSA PRIVATE KEY or PRIVATE KEY", block.Type)
}
}
// buildClientRegistry converts []ClientConfig into the map used by handlers.
func buildClientRegistry(cfgClients []config.ClientConfig) (map[string]*domain.Client, error) {
return buildClientRegistryWith(cfgClients, os.Getenv)
}
// buildClientRegistryWith resolves env: secretRefs through getenv, so
// check-config can build the registry without any secret value.
func buildClientRegistryWith(cfgClients []config.ClientConfig, getenv func(string) string) (map[string]*domain.Client, error) {
m := make(map[string]*domain.Client, len(cfgClients))
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 = getenv(envName)
if clientSecret == "" {
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,
Audience: c.Audience,
ServiceSubject: c.ServiceSubject,
Tenant: c.Tenant,
Roles: c.Roles,
TokenLifetime: clientTokenLifetime,
MFARequired: c.MFARequired,
MFAOptional: c.MFAOptional,
RegistrationURL: c.RegistrationURL,
EnrollmentURL: c.EnrollmentURL,
SignInDiagnosis: c.SignInDiagnosis,
}
}
return m, nil
}
// withEmitter wraps a handler to inject the telemetry emitter into every request context.
func withEmitter(next http.Handler, e telemetry.Emitter) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := telemetry.WithEmitter(context.Background(), e)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// buildTenantEngine returns the tenant_roles cache client, or nil when the
// claim is not configured. Validation has already accepted the URL and timeout.
func buildTenantEngine(cfg config.TenantEngineConfig) *tenantengine.Client {
if cfg.BaseURL == "" {
return nil
}
var httpClient *http.Client
if cfg.Timeout != "" {
if timeout, err := time.ParseDuration(cfg.Timeout); err == nil {
httpClient = &http.Client{Timeout: timeout}
}
}
return tenantengine.New(cfg.BaseURL, httpClient)
}

View file

@ -0,0 +1,152 @@
// Package config handles loading and validating the KeyCape server configuration
// from a YAML file. The config path is resolved from the --config flag or the
// KEYCAPE_CONFIG environment variable.
package config
import (
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
"keycape/internal/adapters/authelia"
"keycape/internal/adapters/lldap"
"keycape/internal/adapters/privacyidea"
"keycape/internal/domain"
)
// Config is the top-level server configuration.
type Config struct {
AccountPortalURL string `yaml:"accountPortalURL,omitempty"`
BrowserLogoutURL string `yaml:"browserLogoutURL,omitempty"`
Issuer string `yaml:"issuer"`
Port int `yaml:"port"`
TokenLifetime string `yaml:"tokenLifetime"`
PrivateKeyPEM string `yaml:"privateKeyPem"`
LLDAP lldap.Config `yaml:"lldap"`
Authelia authelia.Config `yaml:"authelia"`
PrivacyIDEA privacyidea.Config `yaml:"privacyidea"`
Clients []ClientConfig `yaml:"clients"`
Environment string `yaml:"environment"`
TenantEngine TenantEngineConfig `yaml:"tenantEngine,omitempty"`
TokenStatus TokenStatusConfig `yaml:"tokenStatus,omitempty"`
}
// TokenStatusConfig opts a singleton issuer into online access-token status.
// Existing deployment configuration remains expiry-only until owner admission.
type TokenStatusConfig struct {
Enabled bool `yaml:"enabled"`
MaxEntries int `yaml:"maxEntries,omitempty"`
}
// TenantEngineConfig configures the optional tenant_roles cache claim.
//
// Opt-in by design: an empty baseURL leaves the claim off entirely, which is
// what the stock server does. tenant_roles is a cache callers must not trust for
// privileged decisions, and the adapter fails open, so enabling it is a
// performance choice rather than a security one (KEY-WP-0024).
type TenantEngineConfig struct {
// BaseURL is tenant-engine's cache-read endpoint. Empty disables the claim.
BaseURL string `yaml:"baseURL,omitempty"`
// Timeout bounds the lookup, which sits on the synchronous token-issuance
// path. Empty uses the adapter's own short default.
Timeout string `yaml:"timeout,omitempty"`
}
// 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"`
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"`
MFAOptional bool `yaml:"mfaOptional,omitempty"`
MFARequired *bool `yaml:"mfaRequired,omitempty"`
RegistrationURL string `yaml:"registrationUrl,omitempty"`
EnrollmentURL string `yaml:"enrollmentUrl,omitempty"`
// SignInDiagnosis opts the client into the pre-redirect sign-in
// diagnosis page (NK-WP-0051-T02). Default off.
SignInDiagnosis bool `yaml:"signInDiagnosis,omitempty"`
}
// Load reads and parses the YAML config file at path.
// If path is empty, it falls back to the KEYCAPE_CONFIG environment variable.
// Returns an error if the file cannot be read or parsed.
func Load(path string) (*Config, error) {
if path == "" {
path = os.Getenv("KEYCAPE_CONFIG")
}
if path == "" {
return nil, fmt.Errorf("config: no config path specified (use --config or KEYCAPE_CONFIG)")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("config: read %q: %w", path, err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("config: parse %q: %w", path, err)
}
if value := os.Getenv("KEYCAPE_ACCOUNT_PORTAL_URL"); value != "" {
cfg.AccountPortalURL = value
}
if value := os.Getenv("KEYCAPE_BROWSER_LOGOUT_URL"); value != "" {
cfg.BrowserLogoutURL = value
}
return &cfg, nil
}
// Registrations converts the configured clients to domain registrations without
// resolving any secret. Migration and inspection tooling needs the registration
// contract — grants, audience, tenant, roles, lifetime, MFA and handoff policy —
// but must never load secret material, so ClientSecret is deliberately left
// empty here. The server has its own conversion that does resolve secrets.
//
// An unparseable tokenLifetime is an error rather than a silent zero: a
// migration that quietly drops a per-client lifetime is the class of defect this
// exists to avoid.
func (c *Config) Registrations() ([]domain.Client, error) {
clients := make([]domain.Client, 0, len(c.Clients))
for _, cc := range c.Clients {
var lifetime time.Duration
if cc.TokenLifetime != "" {
parsed, err := time.ParseDuration(cc.TokenLifetime)
if err != nil {
return nil, fmt.Errorf("config: client %q tokenLifetime is invalid: %w", cc.ClientID, err)
}
lifetime = parsed
}
clients = append(clients, domain.Client{
ClientID: cc.ClientID,
DisplayName: cc.DisplayName,
RedirectURIs: cc.RedirectURIs,
AllowedScopes: cc.AllowedScopes,
GrantTypes: cc.GrantTypes,
ClientType: cc.ClientType,
SecretRef: cc.SecretRef,
Audience: cc.Audience,
ServiceSubject: cc.ServiceSubject,
Tenant: cc.Tenant,
Roles: cc.Roles,
TokenLifetime: lifetime,
MFARequired: cc.MFARequired,
MFAOptional: cc.MFAOptional,
RegistrationURL: cc.RegistrationURL,
EnrollmentURL: cc.EnrollmentURL,
SignInDiagnosis: cc.SignInDiagnosis,
})
}
return clients, nil
}

View file

@ -0,0 +1,41 @@
package config_test
import (
"strings"
"testing"
"keycape/internal/config"
)
func TestTokenStatusConfigIsExplicitBoundedAndHTTPS(t *testing.T) {
for _, kind := range []string{"legacy", "enabled-default", "enabled-bounded", "http", "userinfo-url", "query", "too-large", "negative", "disabled-capacity"} {
t.Run(kind, func(t *testing.T) {
cfg := validConfig("unused-public-test-key-path")
cfg.TokenStatus.Enabled = kind != "legacy" && kind != "disabled-capacity"
switch kind {
case "enabled-bounded":
cfg.TokenStatus.MaxEntries = 128
case "http":
cfg.Issuer = "http://auth.example.com"
case "userinfo-url":
cfg.Issuer = "https://name@auth.example.com"
case "query":
cfg.Issuer = "https://auth.example.com?query=present"
case "too-large":
cfg.TokenStatus.MaxEntries = 1000001
case "negative":
cfg.TokenStatus.MaxEntries = -1
case "disabled-capacity":
cfg.TokenStatus.MaxEntries = 128
}
var statusError bool
for _, err := range config.ValidateConfig(cfg) {
statusError = statusError || strings.HasPrefix(err, "tokenStatus")
}
expected := kind != "legacy" && kind != "enabled-default" && kind != "enabled-bounded"
if statusError != expected {
t.Fatal("token-status configuration acceptance mismatch")
}
})
}
}

View file

@ -0,0 +1,199 @@
package config
import (
"fmt"
"net/url"
"strings"
"time"
)
// ValidateConfig validates a loaded Config and returns a list of human-readable
// error messages. An empty slice means the config is valid.
// Called at startup — the server must exit 1 if any errors are returned.
func ValidateConfig(cfg *Config) []string {
var errs []string
if cfg.TokenStatus.Enabled {
u, err := url.Parse(cfg.Issuer)
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
errs = append(errs, "tokenStatus: requires an HTTPS issuer without credentials, query or fragment")
}
if cfg.TokenStatus.MaxEntries < 0 || cfg.TokenStatus.MaxEntries > 1000000 {
errs = append(errs, "tokenStatus.maxEntries: must be between 1 and 1000000, or omitted for 10000")
}
} else if cfg.TokenStatus.MaxEntries != 0 {
errs = append(errs, "tokenStatus.maxEntries: set while token status is disabled")
}
for name, raw := range map[string]string{"accountPortalURL": cfg.AccountPortalURL, "browserLogoutURL": cfg.BrowserLogoutURL} {
if raw == "" {
continue
}
u, err := url.Parse(raw)
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
errs = append(errs, name+": must be an HTTPS URL without credentials, query or fragment")
}
}
if (cfg.AccountPortalURL == "") != (cfg.BrowserLogoutURL == "") {
errs = append(errs, "accountPortalURL and browserLogoutURL must be configured together")
}
// Issuer must be a valid URL with an http(s) scheme.
if cfg.Issuer == "" {
errs = append(errs, "issuer: must not be empty")
} else {
u, err := url.Parse(cfg.Issuer)
if err != nil || u.Scheme == "" || u.Host == "" {
errs = append(errs, fmt.Sprintf("issuer: %q is not a valid URL (must include scheme and host)", cfg.Issuer))
} else if u.Scheme != "http" && u.Scheme != "https" {
errs = append(errs, fmt.Sprintf("issuer: scheme must be http or https, got %q", u.Scheme))
}
}
// Port must be in the valid TCP range.
if cfg.Port < 1 || cfg.Port > 65535 {
errs = append(errs, fmt.Sprintf("port: must be between 1 and 65535, got %d", cfg.Port))
}
// tenant_roles is opt-in: an empty baseURL disables it. A configured one
// must be usable, since a misconfigured cache source on the token path is
// worse than no cache source (KEY-WP-0024).
if cfg.TenantEngine.BaseURL != "" {
u, err := url.Parse(cfg.TenantEngine.BaseURL)
if err != nil || u.Scheme == "" || u.Host == "" {
errs = append(errs, fmt.Sprintf("tenantEngine.baseURL: %q is not a valid URL (must include scheme and host)", cfg.TenantEngine.BaseURL))
} else if u.Scheme != "http" && u.Scheme != "https" {
errs = append(errs, fmt.Sprintf("tenantEngine.baseURL: scheme must be http or https, got %q", u.Scheme))
}
}
if cfg.TenantEngine.Timeout != "" {
if cfg.TenantEngine.BaseURL == "" {
errs = append(errs, "tenantEngine.timeout: set without a baseURL, so tenant_roles stays disabled")
} else if timeout, err := time.ParseDuration(cfg.TenantEngine.Timeout); err != nil {
errs = append(errs, "tenantEngine.timeout: must be a valid duration")
} else if timeout <= 0 || timeout > 10*time.Second {
errs = append(errs, "tenantEngine.timeout: must be greater than 0 and at most 10s; it sits on the token-issuance path")
}
}
// At least one client must be registered.
if len(cfg.Clients) == 0 {
errs = append(errs, "clients: at least one client must be defined")
}
// The registry is keyed by clientId, so a duplicate would silently replace
// the earlier registration instead of failing.
seen := map[string]bool{}
// Each client must have at least one redirect URI and a non-empty clientId.
for i, c := range cfg.Clients {
prefix := fmt.Sprintf("clients[%d] (%s)", i, c.ClientID)
if c.ClientID == "" {
prefix = fmt.Sprintf("clients[%d]", i)
errs = append(errs, prefix+": clientId must not be empty")
} else if seen[c.ClientID] {
errs = append(errs, prefix+": clientId is registered more than once")
}
seen[c.ClientID] = true
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")
}
if c.MFAOptional && c.MFARequired != nil {
errs = append(errs, prefix+": mfaOptional cannot be combined with mfaRequired")
}
if c.MFAOptional && contains(c.GrantTypes, "client_credentials") {
errs = append(errs, prefix+": mfaOptional is only supported for browser clients")
}
hasAuthorizationCode := contains(c.GrantTypes, "authorization_code")
hasClientCredentials := contains(c.GrantTypes, "client_credentials")
if (hasAuthorizationCode || !hasClientCredentials) && len(c.RedirectURIs) == 0 {
errs = append(errs, prefix+": redirect_uri: at least one redirectUri must be registered")
}
if hasClientCredentials {
if c.ClientType != "confidential" {
errs = append(errs, prefix+": client_credentials requires clientType confidential")
}
if !strings.HasPrefix(c.SecretRef, "env:") {
errs = append(errs, prefix+": client_credentials requires an env: secretRef")
}
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")
}
// serviceSubject and roles are read only on the client_credentials
// path, so on a browser client they are silently ignored: the
// subject and roles come from the directory user. Rejecting them
// turns a registration that looks effective into a startup error
// (KEY-WP-0028).
//
// tenant is deliberately NOT in this list. A browser client may
// declare one, and humanTenant resolves it against the directory --
// see docs/tenant-claim-contract.md, "How a human token's tenant is
// resolved".
if c.ServiceSubject != "" {
errs = append(errs, prefix+": serviceSubject is only read for client_credentials clients; a browser client's subject is the directory user")
}
if len(c.Roles) > 0 {
errs = append(errs, prefix+": roles is only read for client_credentials clients; a browser client's roles come from the directory user")
}
}
// Warn about wildcard redirect URIs (they are blocked at runtime anyway).
for _, uri := range c.RedirectURIs {
if strings.ContainsAny(uri, "*?") {
errs = append(errs, prefix+fmt.Sprintf(": redirect_uri %q must not contain wildcards", uri))
}
}
if c.RegistrationURL != "" {
if err := validateHandoffURL(c.RegistrationURL); err != nil {
errs = append(errs, prefix+": registrationUrl: "+err.Error())
}
}
if c.EnrollmentURL != "" {
if err := validateHandoffURL(c.EnrollmentURL); err != nil {
errs = append(errs, prefix+": enrollmentUrl: "+err.Error())
}
}
}
// Private key PEM path must be provided (existence is checked at startup).
if cfg.PrivateKeyPEM == "" {
errs = append(errs, "privateKeyPem: path must not be empty")
}
return errs
}
func contains(values []string, wanted string) bool {
for _, value := range values {
if value == wanted {
return true
}
}
return false
}
func validateHandoffURL(raw string) error {
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("%q is not an absolute URL", raw)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("%q scheme must be http or https", raw)
}
if u.User != nil {
return fmt.Errorf("%q must not contain userinfo", raw)
}
if strings.ContainsAny(u.Host, "*?") || strings.ContainsAny(u.Path, "*") {
return fmt.Errorf("%q must not contain wildcards", raw)
}
return nil
}

View file

@ -0,0 +1,142 @@
// Package oidc implements OIDC profile endpoints for KeyCape.
// Only profile-supported features are advertised — no implicit flow,
// no dynamic registration, no request objects.
package oidc
import (
"encoding/json"
"net/http"
"sort"
"keycape/internal/domain"
)
// DiscoveryConfig holds the issuer and endpoint URLs for the discovery document.
// UserinfoEndpoint is optional; if empty it is omitted from the document.
type DiscoveryConfig struct {
Issuer string // e.g. "https://auth.netkingdom.local"
AuthorizationEndpoint string
TokenEndpoint string
JWKSUri string
UserinfoEndpoint string // optional, empty = not advertised
EndSessionEndpoint string // optional, empty = not advertised
IntrospectionEndpoint string // opt-in online access-token status
RevocationEndpoint string // opt-in access-token revocation
// Clients supplies the registered clients so scopes_supported reflects the
// scopes this deployment can actually grant, rather than a fixed list that
// omits every configured resource scope (KEY-WP-0017-T03). Nil or empty
// leaves only the baseline OIDC scopes advertised.
Clients map[string]*domain.Client
}
// discoveryDocument is the JSON shape of /.well-known/openid-configuration.
// Fields are ordered to match common OIDC implementations for readability.
// registration_endpoint is intentionally absent — no dynamic client registration.
type discoveryDocument struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
JWKSUri string `json:"jwks_uri"`
UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"`
EndSessionEndpoint string `json:"end_session_endpoint,omitempty"`
IntrospectionEndpoint string `json:"introspection_endpoint,omitempty"`
RevocationEndpoint string `json:"revocation_endpoint,omitempty"`
IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported,omitempty"`
RevocationEndpointAuthMethodsSupported []string `json:"revocation_endpoint_auth_methods_supported,omitempty"`
ResponseTypesSupported []string `json:"response_types_supported"`
GrantTypesSupported []string `json:"grant_types_supported"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
ScopesSupported []string `json:"scopes_supported"`
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
ClaimsSupported []string `json:"claims_supported"`
SubjectTypesSupported []string `json:"subject_types_supported"`
RequestParameterSupported bool `json:"request_parameter_supported"`
ClaimsParameterSupported bool `json:"claims_parameter_supported"`
}
// discoveryHandler implements http.Handler for GET /.well-known/openid-configuration.
type discoveryHandler struct {
doc []byte
}
// NewDiscoveryHandler returns an http.Handler that serves the OIDC discovery document.
// The document is pre-serialised at construction time so every request is a cheap copy.
func NewDiscoveryHandler(cfg DiscoveryConfig) http.Handler {
d := discoveryDocument{
Issuer: cfg.Issuer,
AuthorizationEndpoint: cfg.AuthorizationEndpoint,
TokenEndpoint: cfg.TokenEndpoint,
JWKSUri: cfg.JWKSUri,
UserinfoEndpoint: cfg.UserinfoEndpoint,
EndSessionEndpoint: cfg.EndSessionEndpoint,
// Profile-locked values — not negotiable.
ResponseTypesSupported: []string{"code"},
GrantTypesSupported: []string{"authorization_code", "client_credentials"},
CodeChallengeMethodsSupported: []string{"S256"},
IDTokenSigningAlgValuesSupported: []string{"RS256"},
ScopesSupported: supportedScopes(cfg.Clients),
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "none"},
// Core profile claims are emitted on every token, so omitting one here
// is a contract defect rather than missing optional metadata. The
// scope-gated human claims and the optional cached tenant_roles claim
// follow them.
ClaimsSupported: []string{
"sub", "iss", "aud", "exp", "iat",
"tenant", "tenant_source", "principal_type", "roles", "groups", "assurance", "scope",
"nonce", "preferred_username", "email", "name", "tenant_roles",
},
SubjectTypesSupported: []string{"public"},
RequestParameterSupported: false,
ClaimsParameterSupported: false,
}
if cfg.IntrospectionEndpoint != "" {
d.IntrospectionEndpoint = cfg.IntrospectionEndpoint
d.IntrospectionEndpointAuthMethodsSupported = []string{"client_secret_basic"}
d.ClaimsSupported = append(d.ClaimsSupported, "jti")
}
if cfg.RevocationEndpoint != "" {
d.RevocationEndpoint = cfg.RevocationEndpoint
d.RevocationEndpointAuthMethodsSupported = []string{"client_secret_basic", "none"}
}
b, err := json.Marshal(d)
if err != nil {
// This can only fail if the struct contains un-marshallable types, which it does not.
panic("oidc: failed to marshal discovery document: " + err.Error())
}
return &discoveryHandler{doc: b}
}
func (h *discoveryHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "max-age=3600")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(h.doc)
}
// supportedScopes returns the baseline OIDC scopes plus every scope any
// registered client is allowed to request, sorted for a stable document. The
// baseline is advertised even with no clients configured, since it describes the
// profile rather than a particular deployment.
func supportedScopes(clients map[string]*domain.Client) []string {
seen := map[string]bool{}
scopes := []string{}
for _, scope := range []string{"openid", "profile", "email", "groups"} {
seen[scope] = true
scopes = append(scopes, scope)
}
extra := []string{}
for _, client := range clients {
for _, scope := range client.AllowedScopes {
if scope == "" || seen[scope] {
continue
}
seen[scope] = true
extra = append(extra, scope)
}
}
sort.Strings(extra)
return append(scopes, extra...)
}

View file

@ -0,0 +1,625 @@
package oidc
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"keycape/internal/adapters/tenantengine"
"keycape/internal/domain"
profileerrors "keycape/internal/errors"
"keycape/internal/server/telemetry"
)
// TokenHandler implements POST /token.
type TokenHandler struct {
ClientConfig map[string]*domain.Client
Sessions *SessionStore
Users domain.UserRepository
SigningKey *rsa.PrivateKey
Issuer string
TokenLifetime time.Duration
Emitter telemetry.Emitter
// TenantEngine sources the optional tenant_roles claim (KEY-WP-0005-T02).
// Nil disables it entirely -- token issuance never depends on it.
TenantEngine *tenantengine.Client
// Tokens enables issuer-owned online token status; nil retains the legacy
// expiry-only profile. Enabling it requires explicit deployment admission.
Tokens *TokenStatusStore
}
// tokenResponse is the JSON body returned on a successful token exchange.
type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
IDToken string `json:"id_token,omitempty"`
}
// ServeHTTP handles POST /token.
func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form body", http.StatusBadRequest)
return
}
grantType := r.FormValue("grant_type")
if grantType == "client_credentials" {
h.serveClientCredentials(w, r)
return
}
clientID := r.FormValue("client_id")
code := r.FormValue("code")
codeVerifier := r.FormValue("code_verifier")
// 1. Validate grant_type.
if grantType != "authorization_code" {
profileerrors.FeatureNotSupported(
"only grant_type=authorization_code is supported",
"grant_type="+grantType,
).Write(w, http.StatusBadRequest)
return
}
// 2. Validate client exists and may use this grant.
client, ok := h.ClientConfig[clientID]
if !ok {
profileerrors.InvalidProfileUsage("unknown client_id", "client_id").
Write(w, http.StatusBadRequest)
return
}
// Grant-type eligibility, enforced equivalently to the service path
// (KEY-WP-0016-T02). An empty grantTypes is an implicit authorization-code
// client, matching config validation; a client_credentials-only client must
// not reach the browser path.
if len(client.GrantTypes) > 0 && !containsString(client.GrantTypes, "authorization_code") {
profileerrors.InvalidProfileUsage(
"client is not registered for grant_type=authorization_code",
"grant_type",
).Write(w, http.StatusBadRequest)
return
}
// Confidential authorization-code clients authenticate with their secret,
// using the same credential sources as the service grant. A public client
// must not be able to present a secret and be treated as authenticated.
if client.ClientType == "confidential" {
presentedID, secret, ok := basicClientCredentials(r)
if !ok || presentedID != clientID || client.ClientSecret == "" ||
!secretsEqual(secret, client.ClientSecret) {
profileerrors.InvalidProfileUsage(
"client authentication failed",
"Authorization",
).Write(w, http.StatusUnauthorized)
return
}
}
// 3. Consume the PKCE session. Single-use and atomic: see
// SessionStore.Consume (KEY-WP-0016-T01).
sess, ok := h.Sessions.Consume(code)
if !ok {
profileerrors.InvalidProfileUsage(
"authorization code not found or expired",
"code",
).Write(w, http.StatusBadRequest)
return
}
// Verify client_id matches the session.
if sess.ClientID != clientID {
profileerrors.InvalidProfileUsage(
"client_id does not match the authorization code",
"client_id",
).Write(w, http.StatusBadRequest)
return
}
// Bind the exchange to the redirect URI the code was issued for
// (RFC 6749 section 4.1.3, KEY-WP-0016-T02). /authorize always records an
// exactly-matched registered redirect, so the parameter is always required
// here and must be identical.
if redirectURI := r.FormValue("redirect_uri"); redirectURI != sess.RedirectURI {
profileerrors.InvalidProfileUsage(
"redirect_uri does not match the authorization request",
"redirect_uri",
).Write(w, http.StatusBadRequest)
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(
"code_verifier does not match code_challenge",
"code_verifier",
).Write(w, http.StatusBadRequest)
return
}
// 5. Look up user.
user, err := h.Users.LookupUser(ctx, sess.Username)
if err != nil {
http.Error(w, "user not found", http.StatusInternalServerError)
return
}
if isSuspended(user) {
profileerrors.RejectedForSafety(
"account is suspended",
"account_lifecycle",
).Write(w, http.StatusForbidden)
return
}
// 6. Build JWT claims.
now := time.Now()
exp := now.Add(h.TokenLifetime)
claims := map[string]interface{}{
"iss": h.Issuer,
"sub": user.ID,
"aud": clientID,
"exp": exp.Unix(),
"iat": now.Unix(),
}
if sess.Nonce != "" {
claims["nonce"] = sess.Nonce
}
scopeSet := make(map[string]bool)
for _, s := range sess.Scopes {
scopeSet[s] = true
}
if scopeSet["profile"] {
claims["preferred_username"] = user.Username
}
if scopeSet["email"] {
claims["email"] = user.Email
}
// Core claims required by net-kingdom/canon/standards/iam-profile_v0.3.md
// for every production token -- not scope-gated, unlike the recommended
// human claims above (KEY-WP-0005-T01).
tenant, tenantSource, err := humanTenant(h.ClientConfig[clientID], user)
if err != nil {
profileerrors.RejectedForSafety(
"tenant binding conflict",
"tenant_binding",
).Write(w, http.StatusForbidden)
return
}
claims["tenant"] = tenant
claims["tenant_source"] = tenantSource
claims["principal_type"] = "human"
claims["groups"] = nonNilStrings(user.Groups)
claims["roles"] = nonNilStrings(user.Roles)
claims["assurance"] = assuranceClaim(sess.MFAVerified, sess.AuthTime, now)
// Optional cached tenant_roles claim (KEY-WP-0005-T02). Fails open --
// see internal/adapters/tenantengine's package doc for why this is the
// one place in the whole tenant_roles design where that's correct.
if roles, ok := h.TenantEngine.Roles(ctx, tenant); ok {
claims["tenant_roles"] = roles
}
// 7. Sign JWT with RSA-SHA256.
kid := "key-1" // static kid for v0.1
jwtToken, err := buildJWT(claims, kid, h.SigningKey)
if err != nil {
http.Error(w, "failed to build JWT", http.StatusInternalServerError)
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, " ")
if h.Tokens != nil {
id := make([]byte, 32)
if _, err := rand.Read(id); err != nil {
http.Error(w, "token status unavailable", http.StatusServiceUnavailable)
return
}
claims["jti"] = base64.RawURLEncoding.EncodeToString(id)
}
accessToken, err := buildJWT(claims, kid, h.SigningKey)
if err != nil {
http.Error(w, "failed to build JWT", http.StatusInternalServerError)
return
}
if h.Tokens != nil {
if err := h.Tokens.register(accessToken, client, time.Unix(exp.Unix(), 0), now); err != nil {
http.Error(w, "token status unavailable", http.StatusServiceUnavailable)
return
}
}
// 8. Build response. The session was already consumed at lookup, so no
// separate replay-prevention delete is needed here.
resp := tokenResponse{
AccessToken: accessToken,
TokenType: "Bearer",
ExpiresIn: int(h.TokenLifetime.Seconds()),
IDToken: jwtToken,
}
// 10. Emit token_issued telemetry.
h.Emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now(),
EventType: telemetry.EventTokenIssued,
ClientID: clientID,
Endpoint: "/token",
Result: "success",
Scopes: sess.Scopes,
GrantType: grantType,
})
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(resp)
}
// basicClientCredentials reads client_secret_basic credentials, applying the
// form-encoding decode RFC 6749 appendix B requires of both halves. Shared by
// the service grant and confidential authorization-code client authentication.
func basicClientCredentials(r *http.Request) (clientID, clientSecret string, ok bool) {
clientID, clientSecret, ok = r.BasicAuth()
if !ok {
return "", "", false
}
decodedID, idErr := url.QueryUnescape(clientID)
decodedSecret, secretErr := url.QueryUnescape(clientSecret)
if idErr != nil || secretErr != nil {
return "", "", false
}
return decodedID, decodedSecret, true
}
// secretsEqual compares two secrets in constant time. Digesting first keeps the
// comparison length-independent, so a wrong-length secret is indistinguishable
// from a wrong-value one.
func secretsEqual(presented, expected string) bool {
presentedDigest := sha256.Sum256([]byte(presented))
expectedDigest := sha256.Sum256([]byte(expected))
return subtle.ConstantTimeCompare(presentedDigest[:], expectedDigest[:]) == 1
}
func (h *TokenHandler) serveClientCredentials(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
clientID, clientSecret, ok := r.BasicAuth()
if !ok {
profileerrors.InvalidProfileUsage("client_secret_basic authentication required", "Authorization").
Write(w, http.StatusUnauthorized)
return
}
clientID, idErr := url.QueryUnescape(clientID)
clientSecret, secretErr := url.QueryUnescape(clientSecret)
if idErr != nil || secretErr != nil {
profileerrors.InvalidProfileUsage("invalid client authentication encoding", "Authorization").Write(w, http.StatusUnauthorized)
return
}
client, ok := h.ClientConfig[clientID]
if !ok || client.ClientType != "confidential" || !containsString(client.GrantTypes, "client_credentials") {
profileerrors.InvalidProfileUsage("invalid confidential client", "client_id").
Write(w, http.StatusUnauthorized)
return
}
presentedDigest := sha256.Sum256([]byte(clientSecret))
expectedDigest := sha256.Sum256([]byte(client.ClientSecret))
if client.ClientSecret == "" ||
subtle.ConstantTimeCompare(presentedDigest[:], expectedDigest[:]) != 1 {
profileerrors.InvalidProfileUsage("invalid client authentication", "Authorization").
Write(w, http.StatusUnauthorized)
return
}
requestedScopes := strings.Fields(r.FormValue("scope"))
if len(requestedScopes) == 0 {
requestedScopes = append([]string(nil), client.AllowedScopes...)
}
for _, scope := range requestedScopes {
if !containsString(client.AllowedScopes, scope) {
profileerrors.InvalidProfileUsage("requested scope is not allowed", "scope").
Write(w, http.StatusBadRequest)
return
}
}
now := time.Now()
tokenLifetime := h.TokenLifetime
if client.TokenLifetime > 0 {
tokenLifetime = client.TokenLifetime
}
claims := map[string]interface{}{
"iss": h.Issuer,
"sub": client.ServiceSubject,
"aud": accessAudience(client),
"exp": now.Add(tokenLifetime).Unix(),
"iat": now.Unix(),
"tenant": client.Tenant,
// A service client's tenant is always registration-supplied: there is no
// directory principal behind it to assert one (GH-DEC-2026-013 §5).
"tenant_source": TenantSourceRegistration,
"principal_type": "service",
"groups": []string{},
"roles": nonNilStrings(client.Roles),
"scope": strings.Join(requestedScopes, " "),
"assurance": map[string]interface{}{
"level": "aal1", "methods": []string{"client_secret"},
"mfa": false, "source": "key-cape", "at": now.Unix(),
},
}
if roles, ok := h.TenantEngine.Roles(ctx, client.Tenant); ok {
claims["tenant_roles"] = roles
}
if h.Tokens != nil {
id := make([]byte, 32)
if _, err := rand.Read(id); err != nil {
http.Error(w, "token status unavailable", http.StatusServiceUnavailable)
return
}
claims["jti"] = base64.RawURLEncoding.EncodeToString(id)
}
jwtToken, err := buildJWT(claims, "key-1", h.SigningKey)
if err != nil {
http.Error(w, "failed to build JWT", http.StatusInternalServerError)
return
}
if h.Tokens != nil {
if err := h.Tokens.register(jwtToken, client, time.Unix(now.Add(tokenLifetime).Unix(), 0), now); err != nil {
http.Error(w, "token status unavailable", http.StatusServiceUnavailable)
return
}
}
h.Emitter.Emit(ctx, telemetry.Event{
Timestamp: now, EventType: telemetry.EventTokenIssued, ClientID: clientID,
Endpoint: "/token", Result: "success", Scopes: requestedScopes,
GrantType: "client_credentials",
})
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(tokenResponse{
AccessToken: jwtToken, TokenType: "Bearer",
ExpiresIn: int(tokenLifetime.Seconds()),
})
}
func containsString(values []string, wanted string) bool {
for _, value := range values {
if value == wanted {
return true
}
}
return false
}
func isSuspended(user *domain.User) bool {
return containsString(user.Groups, "netkingdom-suspended")
}
// ---------------------------------------------------------------------------
// IAM Profile core claims (KEY-WP-0005-T01)
// ---------------------------------------------------------------------------
// defaultTenant is the fallback tenant claim for users with no explicit
// Tenant assignment yet. This workstation currently operates a single
// tenant (tenant:coulomb, ADR-0006); later tenants (e.g. tenant:friendly:binky,
// ADR-0013) require an explicit domain.User.Tenant value -- this default
// never silently assigns a user to a tenant other than the platform's
// original one.
const defaultTenant = "tenant:coulomb"
// effectiveTenant resolves the tenant claim for a user, falling back to
// defaultTenant when the user has no explicit tenant assignment. The IAM
// Profile requires a non-empty tenant claim on every token.
func effectiveTenant(user *domain.User) string {
if user.Tenant != "" {
return user.Tenant
}
return defaultTenant
}
// humanTenant resolves the tenant claim for a human token (KEY-WP-0013-T05).
//
// A human's tenant is normally a property of the person, read from the
// directory. But the approval chain is bound to the landlord zone by decision
// 5ed3fb35-eca9-413a-82b9-95171ba85bf6, and approval-engine compares the claim
// by exact string equality, so an approver client has to be able to state the
// zone it issues into. Without this every human token fell back to
// defaultTenant and would have been refused downstream -- as a failed approval
// rather than as a registration defect.
//
// The rule is deliberately not an override:
//
// - no client tenant declared -> the directory answer, unchanged;
// - declared, user unassigned -> the declared zone;
// - declared and equal -> agreement, no ambiguity;
// - declared and different -> refuse to issue.
//
// So a registration can bind a zone for users the directory has not placed, and
// can never relabel a user the directory HAS placed into a different one. That
// last case fails closed rather than picking a winner, because either answer
// would be a silent cross-tenant assertion. It also means this stays correct if
// the directory later populates Tenant: the same code turns from supplying the
// zone into enforcing agreement with it, with no second migration.
//
// This is only safe because client registrations are static and
// deployment-owned; KeyCape excludes dynamic client registration by design. A
// self-service client that could name its users' tenant would be an escalation.
func humanTenant(client *domain.Client, user *domain.User) (string, string, error) {
if client == nil || client.Tenant == "" {
if user.Tenant != "" {
return user.Tenant, TenantSourceDirectory, nil
}
return defaultTenant, TenantSourceDefault, nil
}
if user.Tenant != "" {
if user.Tenant != client.Tenant {
return "", "", fmt.Errorf("client %q binds tenant %q but the directory assigns this user a different tenant", client.ClientID, client.Tenant)
}
// Agreement: the directory did assert this about the person, so the
// stronger provenance is the true one.
return user.Tenant, TenantSourceDirectory, nil
}
return client.Tenant, TenantSourceRegistration, nil
}
// Tenant provenance values for the tenant_source claim (GH-DEC-2026-013 §5).
//
// A bare tenant string cannot tell a consumer whether the identity layer
// asserted the zone about this PERSON or a registration supplied it about the
// CLIENT they came through. approval-engine admits an approver by exact-matching
// that string while its contract reads as though it relies on the first, so the
// check is sound and the property a reader infers from it is absent. The ruling
// requires the claim to carry its provenance and forbids a consumer treating the
// two as equivalent for any decision turning on a fact about the person. It names
// the property; the field is ours.
//
// Three values, not the two the ruling names, and the third is the point. A
// tenant nobody asserted -- neither directory nor registration, just the
// profile's non-empty default -- is not directory-asserted, and labelling it so
// would reintroduce the same defect one level down: a consumer would read
// "directory" for a fact the directory never stated. That is the unknown-versus-
// absent distinction the ruling cites from GH-DEC-2026-011 §3, applied to our own
// fallback rather than only to the case we were asked about.
const (
// TenantSourceDirectory: the identity layer asserted this zone about this
// person. Includes the agreement case, where a registration declared the
// same zone the directory did -- the directory still asserted it.
TenantSourceDirectory = "directory"
// TenantSourceRegistration: supplied by the client registration for a person
// the directory has placed nowhere. A fact about the client, not the person.
TenantSourceRegistration = "registration"
// TenantSourceDefault: nobody asserted a zone; this is the profile default,
// emitted because the profile requires a non-empty tenant. Weaker than both.
TenantSourceDefault = "default"
)
// nonNilStrings returns s, or an empty (non-nil) slice if s is nil, so the
// claim always serializes as `[]`, never `null` -- the profile requires
// groups/roles to be present, "possibly empty", not absent.
func nonNilStrings(s []string) []string {
if s == nil {
return []string{}
}
return s
}
// assuranceClaim builds the profile's `assurance` object from whether MFA
// was actually verified during this authorization (session.MFAVerified),
// not from static enrollment state -- a user who has MFA enrolled but
// wasn't challenged for it in this particular flow gets aal1, not aal2.
//
// `at` is the time the user authenticated, not the time this token was
// minted. Those differ whenever a browser session is reused, and the gap is
// the whole point: approval-engine stores this object verbatim as the only
// downstream record that MFA occurred (KEY-WP-0013-T05), so mint time would
// overstate how recently the person proved anything. issuedAt is the
// fallback for a session predating this field.
func assuranceClaim(mfaVerified bool, authTime, issuedAt time.Time) map[string]interface{} {
at := authTime
if at.IsZero() {
at = issuedAt
}
level := "aal1"
methods := []string{"pwd"}
if mfaVerified {
level = "aal2"
methods = append(methods, "otp")
}
return map[string]interface{}{
"level": level,
"methods": methods,
"mfa": mfaVerified,
"source": "key-cape",
"at": at.Unix(),
}
}
// ---------------------------------------------------------------------------
// PKCE verification
// ---------------------------------------------------------------------------
// verifyPKCE checks BASE64URL(SHA256(verifier)) == challenge (S256 method).
func verifyPKCE(verifier, challenge string) bool {
h := sha256.New()
h.Write([]byte(verifier))
computed := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
return computed == challenge
}
// ---------------------------------------------------------------------------
// JWT construction (stdlib only — no external JWT library)
// ---------------------------------------------------------------------------
type jwtHeader struct {
Alg string `json:"alg"`
Typ string `json:"typ"`
Kid string `json:"kid"`
}
// buildJWT constructs and signs a JWT using RSA-SHA256 with the standard library.
// Format: base64url(header) + "." + base64url(payload) + "." + base64url(signature)
func buildJWT(claims map[string]interface{}, kid string, key *rsa.PrivateKey) (string, error) {
// Header.
hdr := jwtHeader{Alg: "RS256", Typ: "JWT", Kid: kid}
hdrJSON, err := json.Marshal(hdr)
if err != nil {
return "", err
}
hdrB64 := base64.RawURLEncoding.EncodeToString(hdrJSON)
// Payload.
payloadJSON, err := json.Marshal(claims)
if err != nil {
return "", err
}
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON)
// Signing input.
signingInput := hdrB64 + "." + payloadB64
// Digest.
digest := sha256.Sum256([]byte(signingInput))
// Sign with PKCS1v15 / SHA256.
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
if err != nil {
return "", err
}
sigB64 := base64.RawURLEncoding.EncodeToString(sig)
return strings.Join([]string{hdrB64, payloadB64, sigB64}, "."), nil
}
// accessAudience is configured by the issuer, never selected by request input.
func accessAudience(client *domain.Client) string {
if client.Audience != "" {
return client.Audience
}
return client.ClientID
}

View file

@ -0,0 +1,97 @@
package oidc
import (
"crypto/sha256"
"encoding/json"
"errors"
"sync"
"time"
"keycape/internal/domain"
)
// TokenStatusStore is the optional single-process online access-token ledger.
// It holds only token hashes and registration commitments, never bearer values.
// Losing this store makes every previously issued token inactive to online
// checks. It is not a browser session, approval, or operation authority store.
type TokenStatusStore struct {
mu sync.Mutex
entries map[[32]byte]tokenStatus
limit int
}
type tokenStatus struct {
clientID string
registration [32]byte
expires time.Time
revoked bool
}
func NewTokenStatusStore(limit int) (*TokenStatusStore, error) {
if limit < 1 || limit > 1000000 {
return nil, errors.New("invalid token-status capacity")
}
return &TokenStatusStore{entries: make(map[[32]byte]tokenStatus), limit: limit}, nil
}
func registrationCommitment(client *domain.Client) [32]byte {
// ClientSecret and TokenLifetime are deliberately omitted from Client JSON.
// Bind their commitments explicitly as well as all public registration data.
secret := sha256.Sum256([]byte(client.ClientSecret))
raw, _ := json.Marshal(struct {
Client *domain.Client
Secret [32]byte
Lifetime time.Duration
}{client, secret, client.TokenLifetime})
return sha256.Sum256(raw)
}
func (s *TokenStatusStore) register(raw string, client *domain.Client, expires, now time.Time) error {
if s == nil || client == nil || raw == "" || len(raw) > 32768 || !now.Before(expires) {
return errors.New("token-status registration refused")
}
s.mu.Lock()
defer s.mu.Unlock()
for key, entry := range s.entries {
if !now.Before(entry.expires) {
delete(s.entries, key)
}
}
key := sha256.Sum256([]byte(raw))
if _, exists := s.entries[key]; exists || len(s.entries) >= s.limit {
return errors.New("token-status registration refused")
}
s.entries[key] = tokenStatus{clientID: client.ClientID, registration: registrationCommitment(client), expires: expires}
return nil
}
func (s *TokenStatusStore) active(raw string, clients map[string]*domain.Client, now time.Time) (string, bool) {
if s == nil {
return "", false
}
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.entries[sha256.Sum256([]byte(raw))]
if !ok || entry.revoked || !now.Before(entry.expires) {
return "", false
}
client := clients[entry.clientID]
if client == nil || client.ClientID != entry.clientID || registrationCommitment(client) != entry.registration {
return "", false
}
return entry.clientID, true
}
func (s *TokenStatusStore) revoke(raw, clientID string) {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
key := sha256.Sum256([]byte(raw))
if entry, ok := s.entries[key]; ok && entry.clientID == clientID {
// Retain the tombstone until expiry: a retry can never reactivate it.
entry.revoked = true
s.entries[key] = entry
}
}

View file

@ -0,0 +1,30 @@
package oidc_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"keycape/internal/server/oidc"
)
func TestTokenStatusDiscoveryOnlyAdvertisesEnabledProfile(t *testing.T) {
for _, enabled := range []bool{false, true} {
cfg := oidc.DiscoveryConfig{Issuer: "https://issuer.example"}
if enabled {
cfg.IntrospectionEndpoint, cfg.RevocationEndpoint = cfg.Issuer+"/introspect", cfg.Issuer+"/revoke"
}
w := httptest.NewRecorder()
oidc.NewDiscoveryHandler(cfg).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/.well-known/openid-configuration", nil))
var doc map[string]interface{}
if json.Unmarshal(w.Body.Bytes(), &doc) != nil {
t.Fatal("discovery is invalid JSON")
}
for _, name := range []string{"introspection_endpoint", "revocation_endpoint", "introspection_endpoint_auth_methods_supported", "revocation_endpoint_auth_methods_supported"} {
if _, exists := doc[name]; exists != enabled {
t.Fatal("unimplemented token status advertised")
}
}
}
}

View file

@ -0,0 +1,145 @@
package oidc
import (
"crypto/rsa"
"encoding/json"
"mime"
"net/http"
"time"
"keycape/internal/domain"
)
// TokenStatusHandler serves the optional issuer-owned RFC 7662 introspection
// and RFC 7009 revocation endpoints. Introspection authenticates the issuing
// confidential client; this profile does not delegate cross-client token reads.
type TokenStatusHandler struct {
Tokens *TokenStatusStore
ClientConfig map[string]*domain.Client
Users domain.UserRepository
SigningKey *rsa.PublicKey
Issuer string
}
func statusError(w http.ResponseWriter, status int, code string) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": code})
}
func (h *TokenStatusHandler) parse(w http.ResponseWriter, r *http.Request, introspection bool) (*domain.Client, string, bool) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
statusError(w, http.StatusMethodNotAllowed, "invalid_request")
return nil, "", false
}
if h.Tokens == nil || h.SigningKey == nil || h.Issuer == "" {
statusError(w, http.StatusServiceUnavailable, "temporarily_unavailable")
return nil, "", false
}
media, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || media != "application/x-www-form-urlencoded" || r.URL.RawQuery != "" || len(r.Header.Values("Authorization")) > 1 {
statusError(w, http.StatusBadRequest, "invalid_request")
return nil, "", false
}
r.Body = http.MaxBytesReader(w, r.Body, 65536)
if err := r.ParseForm(); err != nil {
statusError(w, http.StatusBadRequest, "invalid_request")
return nil, "", false
}
for _, values := range r.PostForm {
if len(values) != 1 {
statusError(w, http.StatusBadRequest, "invalid_request")
return nil, "", false
}
}
raw := r.PostForm.Get("token")
if raw == "" || len(raw) > 32768 {
statusError(w, http.StatusBadRequest, "invalid_request")
return nil, "", false
}
id, secret, basic := basicClientCredentials(r)
formID := r.PostForm.Get("client_id")
if !basic {
id = formID
}
client := h.ClientConfig[id]
valid := client != nil && client.ClientID == id && !r.PostForm.Has("client_secret")
if valid && client.ClientType == "confidential" {
valid = basic && (formID == "" || formID == id) && client.ClientSecret != "" && secretsEqual(secret, client.ClientSecret)
} else {
// Public-client revocation requires possession of its own bearer token.
// Public clients can never enumerate token status via introspection.
valid = valid && !introspection && client.ClientType == "public" && !basic && r.Header.Get("Authorization") == ""
}
if !valid {
w.Header().Set("WWW-Authenticate", `Basic realm="keycape-token-status"`)
statusError(w, http.StatusUnauthorized, "invalid_client")
return nil, "", false
}
return client, raw, true
}
func (h *TokenStatusHandler) Introspect(w http.ResponseWriter, r *http.Request) {
client, raw, ok := h.parse(w, r, true)
if !ok {
return
}
response := map[string]interface{}{"active": false}
defer func() {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
}()
id, active := h.Tokens.active(raw, h.ClientConfig, time.Now())
if !active || id != client.ClientID {
return
}
claims, err := validateAccessToken(raw, h.SigningKey, h.Issuer)
if err != nil {
return
}
sub, _ := claims["sub"].(string)
switch claims["principal_type"] {
case "human":
if h.Users == nil {
return
}
user, err := lookupUserBySubject(r.Context(), h.Users, claims, sub)
if err != nil || !user.Enabled || isSuspended(user) {
return
}
case "service":
if sub == "" || sub != client.ServiceSubject {
return
}
default:
return
}
// Directory lookup may block. Recheck revocation and registration after it.
if current, active := h.Tokens.active(raw, h.ClientConfig, time.Now()); !active || current != id {
return
}
response["active"] = true
response["client_id"] = id
response["token_type"] = "Bearer"
for _, field := range []string{"iss", "sub", "aud", "scope", "iat", "exp", "jti"} {
if value, present := claims[field]; present {
response[field] = value
}
}
}
func (h *TokenStatusHandler) Revoke(w http.ResponseWriter, r *http.Request) {
client, raw, ok := h.parse(w, r, false)
if !ok {
return
}
// Unknown, expired, ID and other-client tokens receive the same empty
// success as an already-revoked token; no existence information leaks.
h.Tokens.revoke(raw, client.ClientID)
w.WriteHeader(http.StatusOK)
}

View file

@ -0,0 +1,66 @@
package oidc
import (
"fmt"
"sync"
"testing"
"time"
"keycape/internal/domain"
)
func TestTokenStatusExactExpiryCapacityAndTombstones(t *testing.T) {
client := &domain.Client{ClientID: "synthetic-client", ClientType: "public"}
clients := map[string]*domain.Client{client.ClientID: client}
store, _ := NewTokenStatusStore(1)
now := time.Unix(1700000000, 0)
end := now.Add(time.Second)
if store.register("synthetic-first", client, end, now) != nil {
t.Fatal("register refused")
}
if _, active := store.active("synthetic-first", clients, end); active {
t.Fatal("expiry equality accepted")
}
store.revoke("synthetic-first", client.ClientID)
if store.register("synthetic-first", client, end, now) == nil {
t.Fatal("revoked token reactivated")
}
if store.register("synthetic-second", client, end.Add(time.Second), end) != nil {
t.Fatal("expired tombstone not collected")
}
if _, active := store.active("synthetic-second", clients, end); !active {
t.Fatal("fresh token refused")
}
for _, capacity := range []int{-1, 0, 1000001} {
if _, err := NewTokenStatusStore(capacity); err == nil {
t.Fatal("unbounded capacity accepted")
}
}
}
func TestTokenStatusConcurrentIssueReadAndRevoke(t *testing.T) {
store, _ := NewTokenStatusStore(128)
client := &domain.Client{ClientID: "synthetic-client", ClientType: "public"}
clients := map[string]*domain.Client{client.ClientID: client}
now := time.Now()
var wg sync.WaitGroup
for i := 0; i < 64; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
raw := fmt.Sprintf("synthetic-token-%d", i)
if store.register(raw, client, now.Add(time.Minute), now) != nil {
t.Error("concurrent registration failed")
return
}
if _, active := store.active(raw, clients, now); !active {
t.Error("concurrent current token refused")
}
store.revoke(raw, client.ClientID)
if _, active := store.active(raw, clients, now); active {
t.Error("concurrent revoked token accepted")
}
}(i)
}
wg.Wait()
}

View file

@ -0,0 +1,379 @@
package oidc_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"keycape/internal/domain"
"keycape/internal/server/oidc"
"keycape/internal/server/telemetry"
)
type statusFixture struct {
token *oidc.TokenHandler
status *oidc.TokenStatusHandler
userinfo *oidc.UserinfoHandler
users *mockUserRepo
}
func newStatusFixture(t *testing.T, capacity int) *statusFixture {
t.Helper()
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
tokens, err := oidc.NewTokenStatusStore(capacity)
if err != nil {
t.Fatal("store creation failed")
}
h, key := newTokenHandler(t, oidc.NewSessionStore(), users)
h.Tokens = tokens
h.ClientConfig["test-client"].ClientType = "confidential"
h.ClientConfig["test-client"].ClientSecret = "synthetic-status-only"
h.ClientConfig["other"] = &domain.Client{ClientID: "other", ClientType: "confidential", ClientSecret: "synthetic-other-only"}
return &statusFixture{token: h, users: users,
status: &oidc.TokenStatusHandler{Tokens: tokens, ClientConfig: h.ClientConfig, Users: users, SigningKey: &key.PublicKey, Issuer: h.Issuer},
userinfo: &oidc.UserinfoHandler{Tokens: tokens, ClientConfig: h.ClientConfig, Users: users, SigningKey: &key.PublicKey, Issuer: h.Issuer, Emitter: telemetry.NoopEmitter{}},
}
}
func (f *statusFixture) issue(t *testing.T) (string, string, int) {
t.Helper()
verifier, _ := makeVerifierAndChallenge()
code := seededSession(f.token.Sessions, verifier)
req := tokenRequest(url.Values{"grant_type": {"authorization_code"}, "client_id": {"test-client"}, "code": {code}, "code_verifier": {verifier}})
if f.token.ClientConfig["test-client"].ClientType == "confidential" {
req.SetBasicAuth(url.QueryEscape("test-client"), url.QueryEscape(f.token.ClientConfig["test-client"].ClientSecret))
}
w := httptest.NewRecorder()
f.token.ServeHTTP(w, req)
if w.Code != http.StatusOK {
return "", "", w.Code
}
var body struct {
Access string `json:"access_token"`
ID string `json:"id_token"`
}
if json.Unmarshal(w.Body.Bytes(), &body) != nil || body.Access == "" || body.ID == "" {
t.Fatal("invalid token response")
}
return body.Access, body.ID, w.Code
}
func (f *statusFixture) request(t *testing.T, raw, client, secret string, revoke bool, mutate func(*http.Request)) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{"token": {raw}}
if secret == "" {
form.Set("client_id", client)
}
r := httptest.NewRequest(http.MethodPost, "/introspect", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if secret != "" {
r.SetBasicAuth(url.QueryEscape(client), url.QueryEscape(secret))
}
if mutate != nil {
mutate(r)
}
w := httptest.NewRecorder()
if revoke {
f.status.Revoke(w, r)
} else {
f.status.Introspect(w, r)
}
if w.Header().Get("Cache-Control") != "no-store" || w.Header().Get("Pragma") != "no-cache" {
t.Fatal("missing private response controls")
}
return w
}
func (f *statusFixture) introspect(t *testing.T, raw string) map[string]interface{} {
t.Helper()
w := f.request(t, raw, "test-client", f.token.ClientConfig["test-client"].ClientSecret, false, nil)
if w.Code != http.StatusOK {
t.Fatalf("introspection status %d", w.Code)
}
var body map[string]interface{}
if json.Unmarshal(w.Body.Bytes(), &body) != nil {
t.Fatal("invalid introspection response")
}
return body
}
func (f *statusFixture) userinfoStatus(raw string) int {
w := httptest.NewRecorder()
f.userinfo.ServeHTTP(w, userinfoRequest(raw))
return w.Code
}
func TestTokenStatusActualIssueRevokeAndFreshGrant(t *testing.T) {
f := newStatusFixture(t, 8)
first, id, code := f.issue(t)
if code != http.StatusOK {
t.Fatalf("issue status %d", code)
}
active := f.introspect(t, first)
if active["active"] != true || active["sub"] != "user-alice" || active["client_id"] != "test-client" || active["token_type"] != "Bearer" {
t.Fatal("wrong active-token metadata")
}
if _, present := parseJWTPayload(t, id)["jti"]; present {
t.Fatal("access-token lifecycle changed ID token")
}
if f.userinfoStatus(first) != http.StatusOK {
t.Fatal("issued token refused")
}
second, _, code := f.issue(t)
if code != http.StatusOK || first == second || active["jti"] == f.introspect(t, second)["jti"] {
t.Fatal("fresh grants must be distinct")
}
for i := 0; i < 2; i++ {
w := f.request(t, first, "test-client", "synthetic-status-only", true, nil)
if w.Code != http.StatusOK || w.Body.Len() != 0 {
t.Fatal("revocation must be empty idempotent success")
}
}
if got := f.introspect(t, first); got["active"] != false || len(got) != 1 {
t.Fatal("revoked metadata leaked or remained active")
}
if f.userinfoStatus(first) != http.StatusUnauthorized {
t.Fatal("revoked token still accepted by real UserInfo")
}
if f.introspect(t, second)["active"] != true || f.userinfoStatus(second) != http.StatusOK {
t.Fatal("revoking one grant affected another")
}
third, _, code := f.issue(t)
if code != http.StatusOK || f.introspect(t, third)["active"] != true || f.introspect(t, first)["active"] != false {
t.Fatal("fresh issuance reactivated a revoked grant")
}
}
func TestTokenStatusNoCrossClientOrIDTokenAuthority(t *testing.T) {
f := newStatusFixture(t, 8)
raw, id, _ := f.issue(t)
for _, invalid := range []string{id, "unknown.synthetic.token", raw + "changed"} {
got := f.introspect(t, invalid)
if len(got) != 1 || got["active"] != false {
t.Fatal("non-access token disclosed metadata")
}
w := f.request(t, invalid, "test-client", "synthetic-status-only", true, nil)
if w.Code != http.StatusOK || w.Body.Len() != 0 {
t.Fatal("unknown revocation disclosed state")
}
}
w := f.request(t, raw, "other", "synthetic-other-only", false, nil)
if w.Code != http.StatusOK || strings.TrimSpace(w.Body.String()) != `{"active":false}` {
t.Fatal("other client read token")
}
w = f.request(t, raw, "other", "synthetic-other-only", true, nil)
if w.Code != http.StatusOK || f.introspect(t, raw)["active"] != true {
t.Fatal("other client revoked token")
}
}
func TestTokenStatusCapacityFailsIssuanceWithoutEvictingLiveToken(t *testing.T) {
f := newStatusFixture(t, 1)
raw, _, code := f.issue(t)
if code != http.StatusOK {
t.Fatal("first issuance failed")
}
if _, _, code = f.issue(t); code != http.StatusServiceUnavailable {
t.Fatalf("full store issue status %d", code)
}
if f.introspect(t, raw)["active"] != true {
t.Fatal("capacity evicted live authority")
}
f.request(t, raw, "test-client", "synthetic-status-only", true, nil)
if _, _, code = f.issue(t); code != http.StatusServiceUnavailable {
t.Fatal("revocation tombstone was evicted before expiry")
}
}
func TestTokenStatusRestartAndUnavailableStoreRefuse(t *testing.T) {
f := newStatusFixture(t, 8)
raw, _, _ := f.issue(t)
empty, err := oidc.NewTokenStatusStore(8)
if err != nil {
t.Fatal(err)
}
f.status.Tokens, f.userinfo.Tokens = empty, empty
if f.introspect(t, raw)["active"] != false || f.userinfoStatus(raw) != http.StatusUnauthorized {
t.Fatal("restart resurrected unknown token")
}
f.status.Tokens = nil
if w := f.request(t, raw, "test-client", "synthetic-status-only", false, nil); w.Code != http.StatusServiceUnavailable {
t.Fatal("missing store allowed online status")
}
// Offline JWT validity remains a separate legacy contract. Do not claim
// that adding an endpoint revokes tokens in consumers that never consult it.
f.userinfo.Tokens = nil
if f.userinfoStatus(raw) != http.StatusOK {
t.Fatal("legacy profile changed")
}
}
func TestTokenStatusRegistrationDriftAndDirectoryDisposition(t *testing.T) {
for _, change := range []string{"removed", "audience", "secret", "scopes", "lifetime", "suspended", "disabled", "user-removed"} {
t.Run(change, func(t *testing.T) {
f := newStatusFixture(t, 8)
raw, _, _ := f.issue(t)
switch change {
case "removed":
delete(f.token.ClientConfig, "test-client")
case "audience":
f.token.ClientConfig["test-client"].Audience = "different-resource"
case "secret":
f.token.ClientConfig["test-client"].ClientSecret = "replacement-synthetic-only"
case "scopes":
f.token.ClientConfig["test-client"].AllowedScopes = []string{"openid"}
case "lifetime":
f.token.ClientConfig["test-client"].TokenLifetime = time.Minute
case "suspended":
f.users.users["alice"].Groups = []string{"netkingdom-suspended"}
case "disabled":
f.users.users["alice"].Enabled = false
case "user-removed":
delete(f.users.users, "alice")
}
if f.userinfoStatus(raw) != http.StatusUnauthorized {
t.Fatal("UserInfo accepted changed authority")
}
if change != "removed" && f.introspect(t, raw)["active"] != false {
t.Fatal("introspection accepted changed authority")
}
})
}
}
func TestTokenStatusPublicClientMayRevokeButNotIntrospect(t *testing.T) {
f := newStatusFixture(t, 8)
f.token.ClientConfig["test-client"].ClientType = "public"
f.token.ClientConfig["test-client"].ClientSecret = ""
raw, _, code := f.issue(t)
if code != http.StatusOK {
t.Fatal("public PKCE issue failed")
}
if w := f.request(t, raw, "test-client", "", false, nil); w.Code != http.StatusUnauthorized {
t.Fatal("public client enumerated status")
}
if w := f.request(t, raw, "test-client", "", true, nil); w.Code != http.StatusOK {
t.Fatal("public bearer revocation failed")
}
if f.userinfoStatus(raw) != http.StatusUnauthorized {
t.Fatal("public revocation not enforced")
}
}
func TestTokenStatusServiceGrantIsCurrentAndDistinct(t *testing.T) {
f := newStatusFixture(t, 8)
client := f.token.ClientConfig["test-client"]
client.GrantTypes, client.ServiceSubject, client.Tenant = []string{"client_credentials"}, "service:status-fixture", "tenant:fixture"
issue := func() string {
r := tokenRequest(url.Values{"grant_type": {"client_credentials"}})
r.SetBasicAuth(url.QueryEscape(client.ClientID), url.QueryEscape(client.ClientSecret))
w := httptest.NewRecorder()
f.token.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("service issue status %d", w.Code)
}
var body struct {
Access string `json:"access_token"`
}
if json.Unmarshal(w.Body.Bytes(), &body) != nil || body.Access == "" {
t.Fatal("service response invalid")
}
return body.Access
}
first, second := issue(), issue()
if first == second || f.introspect(t, first)["active"] != true {
t.Fatal("service grants not distinct/current")
}
f.request(t, first, client.ClientID, client.ClientSecret, true, nil)
if f.introspect(t, first)["active"] != false || f.introspect(t, second)["active"] != true {
t.Fatal("service revocation not token-scoped")
}
}
func TestTokenStatusRejectsMalformedOrUnauthenticatedRequests(t *testing.T) {
for _, kind := range []string{"get", "query", "json", "duplicate-token", "oversize", "wrong-secret", "missing-auth", "duplicate-auth", "body-secret", "mismatched-client"} {
t.Run(kind, func(t *testing.T) {
f := newStatusFixture(t, 8)
raw, _, _ := f.issue(t)
mutate := func(r *http.Request) {
body := func(form url.Values) { r.Body = io.NopCloser(strings.NewReader(form.Encode())) }
switch kind {
case "get":
r.Method = http.MethodGet
case "query":
r.URL.RawQuery = "token=must-never-use-url"
case "json":
r.Header.Set("Content-Type", "application/json")
case "duplicate-token":
body(url.Values{"token": {raw, raw}})
case "oversize":
body(url.Values{"token": {strings.Repeat("x", 65537)}})
case "wrong-secret":
r.SetBasicAuth("test-client", "wrong-synthetic-secret")
case "missing-auth":
r.Header.Del("Authorization")
case "duplicate-auth":
r.Header.Add("Authorization", "Basic extra")
case "body-secret":
body(url.Values{"token": {raw}, "client_secret": {"unsupported"}})
case "mismatched-client":
body(url.Values{"token": {raw}, "client_id": {"other"}})
}
}
for _, revoke := range []bool{false, true} {
w := f.request(t, raw, "test-client", "synthetic-status-only", revoke, mutate)
if w.Code < 400 {
t.Fatal("malformed request accepted")
}
}
if f.introspect(t, raw)["active"] != true {
t.Fatal("refused request changed token")
}
})
}
}
type blockingStatusUsers struct {
*mockUserRepo
entered, release chan struct{}
}
func (u *blockingStatusUsers) LookupUser(ctx context.Context, name string) (*domain.User, error) {
close(u.entered)
select {
case <-u.release:
return u.mockUserRepo.LookupUser(ctx, name)
case <-ctx.Done():
return nil, ctx.Err()
}
}
func TestTokenStatusRevocationDuringDirectoryLookupRefuses(t *testing.T) {
f := newStatusFixture(t, 8)
raw, _, _ := f.issue(t)
users := &blockingStatusUsers{mockUserRepo: f.users, entered: make(chan struct{}), release: make(chan struct{})}
f.status.Users = users
done := make(chan *httptest.ResponseRecorder, 1)
go func() { done <- f.request(t, raw, "test-client", "synthetic-status-only", false, nil) }()
select {
case <-users.entered:
case <-time.After(3 * time.Second):
t.Fatal("directory lookup not entered")
}
f.request(t, raw, "test-client", "synthetic-status-only", true, nil)
close(users.release)
select {
case w := <-done:
if strings.TrimSpace(w.Body.String()) != `{"active":false}` {
t.Fatal("delayed lookup resurrected revoked token")
}
case <-time.After(3 * time.Second):
t.Fatal("introspection did not finish")
}
}

View file

@ -0,0 +1,121 @@
package oidc_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
func TestTokenStatusActualHTTPSFlowAndAuthenticationEncoding(t *testing.T) {
f := newStatusFixture(t, 8)
f.token.ClientConfig["test-client"].ClientSecret = "synthetic + / colon: only"
mux := http.NewServeMux()
mux.Handle("/token", f.token)
mux.Handle("/userinfo", f.userinfo)
mux.HandleFunc("/introspect", f.status.Introspect)
mux.HandleFunc("/revoke", f.status.Revoke)
server := httptest.NewTLSServer(mux)
defer server.Close()
client := server.Client()
verifier, _ := makeVerifierAndChallenge()
code := seededSession(f.token.Sessions, verifier)
post := func(path string, form url.Values) (int, []byte) {
r, err := http.NewRequest(http.MethodPost, server.URL+path, strings.NewReader(form.Encode()))
if err != nil {
t.Fatal("request construction failed")
}
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.SetBasicAuth(url.QueryEscape("test-client"), url.QueryEscape(f.token.ClientConfig["test-client"].ClientSecret))
resp, err := client.Do(r)
if err != nil {
t.Fatal("HTTPS fixture transport failed")
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 65537))
if err != nil || len(body) > 65536 {
t.Fatal("response read failed")
}
return resp.StatusCode, body
}
status, body := post("/token", url.Values{"grant_type": {"authorization_code"}, "client_id": {"test-client"}, "code": {code}, "code_verifier": {verifier}, "redirect_uri": {seededRedirectURI}})
var tokens struct {
Access string `json:"access_token"`
}
if status != http.StatusOK || json.Unmarshal(body, &tokens) != nil || tokens.Access == "" {
t.Fatal("actual HTTPS PKCE exchange failed")
}
// A misleading type hint must not hide a supported access token.
form := url.Values{"token": {tokens.Access}, "token_type_hint": {"refresh_token"}}
status, body = post("/introspect", form)
var metadata map[string]interface{}
if status != http.StatusOK || json.Unmarshal(body, &metadata) != nil || metadata["active"] != true {
t.Fatal("actual HTTPS introspection failed")
}
if status, body = post("/revoke", form); status != http.StatusOK || len(body) != 0 {
t.Fatal("actual HTTPS revocation failed")
}
status, body = post("/introspect", form)
if status != http.StatusOK || strings.TrimSpace(string(body)) != `{"active":false}` {
t.Fatal("revocation did not affect HTTPS introspection")
}
r, _ := http.NewRequest(http.MethodGet, server.URL+"/userinfo", nil)
r.Header.Set("Authorization", "Bearer "+tokens.Access)
resp, err := client.Do(r)
if err != nil {
t.Fatal("HTTPS UserInfo failed")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized || resp.Header.Get("Cache-Control") != "no-store" {
t.Fatal("real UserInfo accepted or cached revoked bearer")
}
}
func TestTokenStatusRevocationDuringUserInfoLookupRefuses(t *testing.T) {
f := newStatusFixture(t, 8)
raw, _, _ := f.issue(t)
users := &blockingStatusUsers{mockUserRepo: f.users, entered: make(chan struct{}), release: make(chan struct{})}
f.userinfo.Users = users
done := make(chan int, 1)
go func() { done <- f.userinfoStatus(raw) }()
select {
case <-users.entered:
case <-time.After(3 * time.Second):
t.Fatal("UserInfo did not enter directory lookup")
}
f.request(t, raw, "test-client", "synthetic-status-only", true, nil)
close(users.release)
select {
case code := <-done:
if code != http.StatusUnauthorized {
t.Fatal("delayed UserInfo lookup resurrected revoked token")
}
case <-time.After(3 * time.Second):
t.Fatal("UserInfo did not finish")
}
}
func TestTokenStatusLegacyGrantHasNoStatusClaim(t *testing.T) {
f := newStatusFixture(t, 8)
f.token.Tokens = nil
raw, id, code := f.issue(t)
if code != http.StatusOK {
t.Fatal("legacy issue failed")
}
for _, token := range []string{raw, id} {
if _, present := parseJWTPayload(t, token)["jti"]; present {
t.Fatal("disabled token contract changed")
}
}
if f.introspect(t, raw)["active"] != false || f.userinfoStatus(raw) != http.StatusUnauthorized {
t.Fatal("unregistered legacy token gained online authority")
}
f.userinfo.Tokens = nil
if f.userinfoStatus(raw) != http.StatusOK {
t.Fatal("legacy token no longer accepted in legacy mode")
}
}

View file

@ -0,0 +1,280 @@
package oidc
import (
"context"
"crypto"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"keycape/internal/domain"
"keycape/internal/server/telemetry"
)
// UserinfoHandler implements GET /userinfo (OIDC Core §5.3).
//
// The endpoint validates the Bearer token, extracts the subject, looks up
// the user, and returns claims that are consistent with those in the ID token
// for the same scope set.
type UserinfoHandler struct {
Users domain.UserRepository
SigningKey *rsa.PublicKey // used to verify the incoming access token
Issuer string
Emitter telemetry.Emitter
Tokens *TokenStatusStore
ClientConfig map[string]*domain.Client
}
// ServeHTTP handles GET /userinfo.
func (h *UserinfoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if h.Tokens != nil {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
}
// 1. Extract Bearer token.
tokenStr, ok := bearerToken(r)
if !ok {
http.Error(w, `{"error":"missing_token","description":"Authorization: Bearer <token> required"}`, http.StatusUnauthorized)
return
}
// 2. Validate token (algorithm, signature, expiry, issuer, purpose) and
// extract claims.
claims, err := validateAccessToken(tokenStr, h.SigningKey, h.Issuer)
if err != nil {
http.Error(w, `{"error":"invalid_token","description":"token validation failed"}`, http.StatusUnauthorized)
return
}
if h.Tokens != nil {
if _, active := h.Tokens.active(tokenStr, h.ClientConfig, time.Now()); !active {
http.Error(w, `{"error":"invalid_token"}`, http.StatusUnauthorized)
return
}
}
// 3. Extract sub claim (which is the username in our model).
sub, _ := claims["sub"].(string)
if sub == "" {
http.Error(w, `{"error":"invalid_token","description":"missing sub claim"}`, http.StatusUnauthorized)
return
}
// 4. Resolve the canonical subject. Human tokens use the stable directory
// ID (the LDAP DN) as sub, while LookupUser accepts an LDAP uid. Prefer the
// scoped username hint but require it to resolve back to the signed subject;
// fall back to an ID scan for openid-only tokens and renamed users.
user, err := lookupUserBySubject(ctx, h.Users, claims, sub)
if err != nil {
// User referenced in token but not found → treat as invalid token.
http.Error(w, `{"error":"invalid_token","description":"subject not found"}`, http.StatusUnauthorized)
return
}
if isSuspended(user) {
http.Error(w, `{"error":"invalid_token","description":"subject is suspended"}`, http.StatusUnauthorized)
return
}
if h.Tokens != nil {
if _, active := h.Tokens.active(tokenStr, h.ClientConfig, time.Now()); !user.Enabled || !active {
http.Error(w, `{"error":"invalid_token"}`, http.StatusUnauthorized)
return
}
}
// 5. Build response claims filtered by the scopes embedded in the token.
scopeStr, _ := claims["scope"].(string)
scopeSet := parseScopeSet(scopeStr)
resp := map[string]interface{}{
"sub": sub,
}
if scopeSet["profile"] {
resp["preferred_username"] = user.Username
resp["name"] = user.DisplayName
}
if scopeSet["email"] {
resp["email"] = user.Email
}
if scopeSet["groups"] {
resp["groups"] = user.Groups
}
// 6. Emit telemetry.
h.Emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now(),
EventType: telemetry.EventAuthSuccess,
Endpoint: "/userinfo",
Result: "success",
})
// 7. Write JSON response.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(resp)
}
func lookupUserBySubject(
ctx context.Context,
users domain.UserRepository,
claims map[string]interface{},
sub string,
) (*domain.User, error) {
if username, _ := claims["preferred_username"].(string); username != "" {
if user, err := users.LookupUser(ctx, username); err == nil && user.ID == sub {
return user, nil
}
}
// Retain compatibility with older signed tokens whose subject was the uid.
if user, err := users.LookupUser(ctx, sub); err == nil &&
(user.ID == sub || user.Username == sub) {
return user, nil
}
all, err := users.ListUsers(ctx)
if err != nil {
return nil, err
}
for i := range all {
if all[i].ID == sub {
return &all[i], nil
}
}
return nil, domain.ErrUserNotFound
}
// ---------------------------------------------------------------------------
// JWT validation (stdlib only — no external JWT library)
// ---------------------------------------------------------------------------
// validateAccessToken parses and validates a KeyCape-issued access token.
//
// Beyond signature and expiry it enforces the bindings the caller CLI already
// requires (KEY-WP-0016-T03): the JOSE header algorithm must be exactly RS256,
// so an "alg":"none" or HMAC-shaped token can never bypass the RSA check; the
// issuer claim must equal this issuer, so a correctly-signed token from another
// deployment is refused; and the token must be an access token.
//
// Purpose is decided on the presence of the `scope` claim, which the token
// endpoint sets on access tokens and never on ID tokens. That keeps the check
// verification-side: it does not add a claim to the issued token contract, which
// consumers pin exactly.
func validateAccessToken(tokenStr string, pubKey *rsa.PublicKey, issuer string) (map[string]interface{}, error) {
parts := strings.Split(tokenStr, ".")
if len(parts) != 3 {
return nil, errors.New("malformed JWT: expected 3 parts")
}
// Verify the JOSE header algorithm before trusting the signature check.
headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, errors.New("malformed JWT: invalid header encoding")
}
var header struct {
Alg string `json:"alg"`
}
if err := json.Unmarshal(headerJSON, &header); err != nil {
return nil, errors.New("malformed JWT: header is not valid JSON")
}
if header.Alg != "RS256" {
return nil, errors.New("unsupported JWT algorithm")
}
// Verify signature over header.payload.
signingInput := parts[0] + "." + parts[1]
digest := sha256.Sum256([]byte(signingInput))
sigBytes, decodeErr := base64.RawURLEncoding.DecodeString(parts[2])
if decodeErr != nil {
return nil, errors.New("malformed JWT: invalid signature encoding")
}
if err := rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, digest[:], sigBytes); err != nil {
return nil, errors.New("JWT signature verification failed")
}
// Decode payload.
payloadJSON, payloadErr := base64.RawURLEncoding.DecodeString(parts[1])
if payloadErr != nil {
return nil, errors.New("malformed JWT: invalid payload encoding")
}
var claims map[string]interface{}
if err := json.Unmarshal(payloadJSON, &claims); err != nil {
return nil, errors.New("malformed JWT: payload is not valid JSON")
}
// Check exp claim.
exp, ok := claims["exp"].(float64)
if !ok {
return nil, errors.New("JWT missing exp claim")
}
if time.Now().Unix() > int64(exp) {
return nil, errors.New("JWT has expired")
}
// Require this issuer. An empty configured issuer would make the check
// vacuous, so treat that as a misconfiguration rather than skipping it.
if issuer == "" {
return nil, errors.New("issuer is not configured")
}
if tokenIssuer, _ := claims["iss"].(string); tokenIssuer != issuer {
return nil, errors.New("JWT issuer mismatch")
}
// Require an access token. ID tokens carry no scope claim.
if _, ok := claims["scope"]; !ok {
return nil, errors.New("JWT is not an access token")
}
return claims, nil
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// bearerToken extracts the token from the Authorization header.
// Returns ("", false) when the header is missing or not a Bearer token.
func bearerToken(r *http.Request) (string, bool) {
hdr := r.Header.Get("Authorization")
if hdr == "" {
return "", false
}
const prefix = "Bearer "
if !strings.HasPrefix(hdr, prefix) {
return "", false
}
tok := strings.TrimSpace(hdr[len(prefix):])
if tok == "" {
return "", false
}
return tok, true
}
// parseScopeSet converts a space-separated scope string to a set.
func parseScopeSet(scope string) map[string]bool {
set := make(map[string]bool)
for _, s := range strings.Fields(scope) {
set[s] = true
}
return set
}
// ---------------------------------------------------------------------------
// BuildJWT — exported for test helpers
// ---------------------------------------------------------------------------
// BuildJWT is an exported wrapper around the internal buildJWT function so
// that tests in the oidc_test package can construct valid tokens for the
// UserinfoHandler without importing an external JWT library.
func BuildJWT(claims map[string]interface{}, kid string, key *rsa.PrivateKey) (string, error) {
return buildJWT(claims, kid, key)
}

View file

@ -0,0 +1,101 @@
---
id: ADHOC-2026-09-27
type: workplan
title: "Fresh MFA no longer backdated to a stale kc_login cookie (INFD-IN-0005)"
domain: infotech
repo: key-cape
status: finished
owner: claude
created: "2026-09-27"
updated: "2026-09-27"
state_hub_workstream_id: "5217dd06-73cb-50f8-ba64-528e421b29de"
---
## Stop completeAuthorization from reusing a stale prior session's IssuedAt after real MFA
```task
id: ADHOC-2026-09-27-T01
status: done
priority: high
state_hub_task_id: "ce98e4af-cfaa-5620-8f21-15ade18ee9e8"
```
`informed-decision` intake `INFD-IN-0005` (open, `origin_ref: SECRETS-WP-0010`):
platform-root's approval of `infd-20260927-b01` was refused by flex-auth's
`compact_sitting` policy with `policy_denied`, despite platform-root having just
completed a fresh key-cape MFA sign-in. flex-auth's 900-second review-freshness
check reads `assurance.at`, which key-cape derives from the PKCE session's
`AuthTime`.
Root cause in `src/internal/server/oidc/authorize.go`,
`AuthorizeHandler.completeAuthorization`: whenever a `kc_login` cookie already
existed for the same username, its `IssuedAt` was unconditionally reused as
`authTime` — even on the two call sites that had just called
`MFA.ValidateMFAToken` successfully in this exact request (the callback's
`mfaToken != ""` branch and `serveMFASubmission`). A user who had an older
browser session and then stepped up with a fresh MFA challenge got the *old*
session's timestamp stamped onto the *new*, just-proven authentication —
understating freshness for every downstream binding-grade check, exactly as
`INFD-IN-0005` describes ("completeAuthorization currently copies a prior
timestamp... only after actual reauthentication").
The reuse itself is correct when MFA was **not** just performed in this
request (`decision.MFAVerified` carried forward from an existing AAL2 session,
via `domain.DecideAssurance`) — reporting mint time there would overstate
freshness (`TestAssuranceReportsAuthenticationTimeNotMintTime`). The two cases
were conflated under a single `mfaVerified` bool.
Fix: added a `freshMFA bool` parameter to `completeAuthorization`, `true` only
at the two call sites that just validated an MFA token in this request, `false`
at the carried-forward-assurance call site. `authTime` now falls back to the
prior session's `IssuedAt` only when `!freshMFA`.
Added `TestAuthorizeCallback_MFASubmission_FreshEvenWithStalePriorLoginCookie`,
which seeds a 4-hour-old `kc_login` cookie for the same user, then submits a
valid MFA token and asserts the resulting `AuthTime` is within a minute of now.
Confirmed it fails against the pre-fix code (`AuthTime` ~4h old) and passes
after. Full suite (`go test ./...`) green, no other test needed adjustment —
the carried-forward-assurance case (`TestPolicy_...`, `TestLogout_...`,
`assurance_time_test.go`) is unaffected since none of it exercises a fresh MFA
submission with a pre-existing cookie for the same user.
Does not touch flex-auth (its 900s window is correct; it was consuming a bad
timestamp) or informed-decision (correctly refuses to interpret the PDP's
denial). `INFD-IN-0005` can close once informed-decision confirms platform-root
can approve after a fresh sign-in against this build.
## Discovery/JWKS fetch was missing the split-horizon origin headers
```task
id: ADHOC-2026-09-27-T02
status: done
priority: high
state_hub_task_id: "7fa0bfc7-0772-57c7-8d6f-641615cab82a"
```
Rolling out T01 (image digest `sha256:248e449a...`) broke every KeyCape login,
including unrelated clients (`user-engine-portal`), with `auth_failure` /
`provider_metadata_unavailable` at `/api/oidc/token` on the KeyCape side and
Authelia logging `invalid X-Forwarded-Proto header value 'http'` on
`GET /.well-known/openid-configuration` from KeyCape's pod IP.
Root cause, unrelated to T01's change: `AutheliaAdapter.setPublicOriginHeaders`
(the NK-WP-0041-T02/path-B fix, key-cape@3b0446e) annotates only the token
exchange (`newTokenRequest`) with `X-Forwarded-Proto`/`X-Forwarded-Host` so
Authelia can derive the public issuer for a split-horizon (in-cluster HTTP,
public HTTPS) deployment. `idTokenVerifier.get` in `verify.go` — used for the
provider-metadata and JWKS fetches that verify every upstream ID token — never
got the same treatment; it built a bare request with only an `Accept` header.
Authelia 4.38 fails closed on the resulting unmarked in-cluster request. This
gap predates today's session (present since 3b0446e); it surfaced now because
this was the first login attempted since that image went live.
Fix: extracted `setPublicOriginHeaders` from an `AutheliaAdapter` method to a
package-level function taking the target public base URL explicitly, and gave
`idTokenVerifier` its own resolved `publicBaseURL` (same
`BrowserBaseURL`-else-`BaseURL` fallback as the adapter) so `get()` applies
the same headers. Added `TestDiscoveryFetch_CarriesPublicOriginHeaders`
(confirmed it fails without the fix, passes with it). Full suite green.
New image `key-cape@<pending>` still needs a rollout to `sso-mfa/k8s/keycape/deployment.yaml`
before platform-root can sign in again.

View file

@ -0,0 +1,84 @@
---
id: KEY-WP-0042
type: workplan
title: "Provide issuer-owned current access-token status and scoped revocation"
domain: infotech
repo: key-cape
status: proposed
owner: codex
topic_slug: custodian
created: "2026-10-18"
updated: "2026-10-18"
flavor: extension
depends_on: []
state_hub_workstream_id: "e2eecd1c-45c7-5a7c-bd1c-d31c845e3d38"
---
CUST-WP-0080's current-operation/inverse requirement exposed a concrete missing
issuer facility. KEY-WP-0025-T03 truthfully documents the expiry-only limit;
KEY-WP-0038 owns Forgejo's existing client, not a token-liveness API. This is an
explicit proposed scope extension in the existing issuer, not a silent change
to that completed limit record. No new service or duplicate session authority.
Consumer binding/authorization and native case acceptance stay in
NK-WP-0048-T34/T42/T43 and RAPPS-WP-0016-T08/T09; they are not copied here.
## Review the bounded issuer/profile extension
```task
id: KEY-WP-0042-T01
status: progress
priority: high
state_hub_task_id: "d8af8e77-f6e5-5d0d-8b44-794af3dee5cf"
```
The reviewable contract is `docs/online-token-status.md`: opt-in RFC 7662/7009
access-token status; registered owning-client authentication; private hash-only
bounded state; unique access-token jti; no ID-token change; singleton store-loss
refusal; disabled legacy behavior explicit. Source preparation is authorized by
the full Custodian implementation goal. Independent issuer/profile/consumer
assent remains required before admission; no such assent is inferred here.
## Implement and verify the actual issuer paths
```task
id: KEY-WP-0042-T02
status: done
priority: high
state_hub_task_id: "2cade51f-d25a-5719-96b4-b57d50c1b97d"
```
Wire the same issuer store to both actual grant types, UserInfo, introspection,
revocation, configuration validation and implemented discovery. Verify distinct
grants, exact expiry, replay/tombstones, capacity, malformed/unauthenticated and
cross-client refusals, current registration and human disposition, revocation
during directory lookup, process-store loss and absence of secret output. Keep
the complete existing source suite, disabled profile and build passing.
## Admit and accept the issuer lifecycle at native scope
```task
id: KEY-WP-0042-T03
status: todo
priority: high
state_hub_task_id: "a5539bb0-2501-5774-a7d8-acddfb0938ab"
```
After independent contract review, admit exact issuer image/config/profile,
HTTPS ingress/client custody and measured capacity/headroom. Exercise real
human PKCE and service tokens, actual endpoint authentication, revoked-token
UserInfo refusal, expiry, unavailable dependency, restart/store loss and fresh
reauthentication. Preserve old offline-token semantics and document every
consumer's selected mode. Review disabling/rollback explicitly; never silently
fall back to offline acceptance for online-profile consumers. Provide owner
receipts to existing NK48/Apps consumer tasks and Audit source admission.
2026-10-18 source verification: all 659 test/subtest results in the complete
race suite pass, with six preexisting native/external skips. The standalone
binary passes 26 isolated process checks, including actual main/router/config,
service issuance, capacity, revocation, process restart, fresh issuance and
disabled-profile behavior; fixture diagnostics omit synthetic private inputs.
The HTTPS human PKCE flow and revocation during directory lookups also pass.
An isolated control omitting the revoked-state predicate fails seven intended
checks. This closes source task T02 only. T01 independent issuer/profile review,
T03 native admission and all existing consumer operation/effect bindings remain
open. Evidence is retained by CUST-WP-0080 under keycape-token-status-r1.

View file

@ -0,0 +1,102 @@
{
"owner": "key-cape",
"base_revision": "git:d803e9282a47b49daf930fb905312fa5104c0ace",
"files": [
{
"path": "SCOPE.md",
"before_sha256": "sha256:20fbadf3e33339bca4b541cdfa33a0de00095c968d51006dc4d0a46735cc834f",
"sha256": "sha256:a80e80ba2e5279ba50232e6b3f57a5bdd14866e584d2d055dfb09e98a4c24ce0"
},
{
"path": "docs/online-token-status.md",
"before_sha256": null,
"sha256": "sha256:f5e41b47500b15c80b2da6cbe37a9e64bcbde1c072750934cf74464a19488925"
},
{
"path": "docs/operations.md",
"before_sha256": "sha256:e68ec32073b7731214308ccd2fceb522d934b00f1d76e6d438e2870030497cfc",
"sha256": "sha256:3659362e3c7f8720e52cc8dc7c67a6295ac26462f383c68c06c120fac6e6e472"
},
{
"path": "src/cmd/keycape/main.go",
"before_sha256": "sha256:ff1ee4f5d2eb86a1e4184374d5244e24704d9d5fd780e173062af07112344afd",
"sha256": "sha256:2f81ea0b0b33f38298e028d5610625e3834fdf1aa12f6f392579d6207bcf6f1d"
},
{
"path": "src/internal/config/config.go",
"before_sha256": "sha256:afd465acd9b6879493b888acc6fc54b9a71e032892701a81f1072986d2c30ac7",
"sha256": "sha256:19cd234757f8908880d28b8b40939367dfab1322e0401e4e563d56cd3af06683"
},
{
"path": "src/internal/config/token_status_test.go",
"before_sha256": null,
"sha256": "sha256:b2b2ec13215c9661d79142ea698cb8b080f6e017de050bedebf3eb8d16df2ad5"
},
{
"path": "src/internal/config/validate.go",
"before_sha256": "sha256:8189ee82e7df74a2a4a1f53e213b1be0a5c737fd76d8f283f7ec9f8ed38c3a65",
"sha256": "sha256:2b1766979b817802f43bdd0f999ac61452d86cdfafc1539a5d6be896b900a405"
},
{
"path": "src/internal/server/oidc/discovery.go",
"before_sha256": "sha256:5ff9ddd9e2f4304b562e116100ca31fe7eabf8315d7d73998d3e6c5bea53956d",
"sha256": "sha256:00e4c831a2b4d29aecf3597a09c943315b87c5834dbcd4912ba5d8d31ab28cd5"
},
{
"path": "src/internal/server/oidc/token.go",
"before_sha256": "sha256:8422cc9dbd39c88dfe835b43f6262cb120bc0d2e5b08898a151167b7555368d3",
"sha256": "sha256:304fa78d79a5436b9174d88991c19c0834356cb5425c6ed369accb85467a402f"
},
{
"path": "src/internal/server/oidc/token_status.go",
"before_sha256": null,
"sha256": "sha256:d9c22a71bb2efad7ea240f2689a98f14768ce3c1519d1d8b4e78ee7af2dc70b3"
},
{
"path": "src/internal/server/oidc/token_status_discovery_test.go",
"before_sha256": null,
"sha256": "sha256:27adb5797afba46ca2b1f16018721f055f82760498088b58b993730be1fa2031"
},
{
"path": "src/internal/server/oidc/token_status_http.go",
"before_sha256": null,
"sha256": "sha256:a7202d3176454f8ab42c090426e2debcb5cc2c1fc089e79746bb78e5c455af97"
},
{
"path": "src/internal/server/oidc/token_status_internal_test.go",
"before_sha256": null,
"sha256": "sha256:3d3ab41987c7586bd53af8b5c36ff6667b5b2a32d59789915eb9a36b7ceeba53"
},
{
"path": "src/internal/server/oidc/token_status_test.go",
"before_sha256": null,
"sha256": "sha256:e2613d44deefcbfbd0ce1db5ce2588849fb4be9b8bed33cb538171cc29f63e90"
},
{
"path": "src/internal/server/oidc/token_status_transport_test.go",
"before_sha256": null,
"sha256": "sha256:3a9945f0292125e86d0014b5245a20c0475d6c518eccff0f9b936356d8e0b347"
},
{
"path": "src/internal/server/oidc/userinfo.go",
"before_sha256": "sha256:bbd5fa9ebd0726371c90d6938536c1ce5fcfacbbe853a80833e3587fc2115a0b",
"sha256": "sha256:f9c986f7a2c79f27a9970a296e6b493014fed77af2f22ba96c4a457d049c916f"
},
{
"path": "workplans/KEY-WP-0042-online-access-token-status.md",
"before_sha256": null,
"sha256": "sha256:a8901d5aed2cc2e653750852d2431e9a03c3eff766f09afac2f7a4301a06b4f6"
}
],
"patch_sha256": "sha256:926f36443c36c6568ba4fb5f7db26697bcab01f8ce0beaa57ba227329f156a13",
"replay": {
"fuzz": 0,
"all_postimages_exact": true,
"all_unrelated_archive_files_unchanged": true,
"stdout": "patching file SCOPE.md\npatching file docs/online-token-status.md\npatching file docs/operations.md\npatching file src/cmd/keycape/main.go\npatching file src/internal/config/config.go\npatching file src/internal/config/token_status_test.go\npatching file src/internal/config/validate.go\npatching file src/internal/server/oidc/discovery.go\npatching file src/internal/server/oidc/token.go\npatching file src/internal/server/oidc/token_status.go\npatching file src/internal/server/oidc/token_status_discovery_test.go\npatching file src/internal/server/oidc/token_status_http.go\npatching file src/internal/server/oidc/token_status_internal_test.go\npatching file src/internal/server/oidc/token_status_test.go\npatching file src/internal/server/oidc/token_status_transport_test.go\npatching file src/internal/server/oidc/userinfo.go\npatching file workplans/KEY-WP-0042-online-access-token-status.md\n",
"stderr": ""
},
"canonical_owner_modified": false,
"independent_owner_assent": false,
"native_admission": false
}

View file

@ -0,0 +1,222 @@
# SCOPE
Reviewed 2026-09-10 against source revision `f247d35`.
Review lineage, because "what changed since?" should have a usable answer. The
full capability review was 2026-09-09 against `5ae80d3`
([reassessment](history/2026-09-09-scope-reassessment.md)), superseding
2026-09-05 against `b989de4`. `7a73352` followed it by a minute — documentation
and workplans only — and was confirmed by reading the commit rather than by
report. Since then the tenant claim gained `tenant_source`, the approver client
was registered, and two guards were added that pin properties rather than
behaviours; each is described below. Read versus told, and reviewed versus merely
later, are distinctions this header is meant to preserve.
## Purpose and boundary
KeyCape is Go identity **Tooling** for NetKingdom's lightweight IAM deployment.
It runs its own OIDC-style issuer, uses Authelia for upstream browser
authentication, reads identities from LLDAP, and delegates factor checks to
privacyIDEA. It also provides caller authentication commands, directory snapshot
validation, and migration artifact generators.
It issues identity, role, scope and assurance claims. Resource authorization
belongs to access-engine and consuming services; secret custody and OpenBao
policy/token enforcement belong to their platform owners. The implemented subset
supports NetKingdom integrations, but complete profile conformance and drop-in
Keycloak interchangeability are not established.
## Implemented capabilities
| Surface | What the current source provides |
| --- | --- |
| Issuer HTTP API | Discovery, `/authorize` and its callback/registration-return routes, `/token`, `/jwks`, `/userinfo`, local `/logout`, `/healthz` (liveness, probes nothing) and `/readyz` (probes LLDAP by bind, Authelia and privacyIDEA). |
| Human authentication | Authorization code with S256 PKCE, exact registered redirects, scope allow-lists, Authelia login, privacyIDEA MFA challenges, client-specific assurance requirements, browser session reuse, step-up/fresh-login handling, and configured registration/enrollment handoffs. The exchange is bound to the redirect URI the code was issued for, refused for clients not registered for the grant, authenticated for confidential clients, and the code is consumed atomically so exactly one exchange can succeed. Upstream Authelia ID tokens are verified — signature against the provider's published keys, expected issuer, own client ID in the audience, validity window — failing closed and independently of transport. |
| Service authentication | Static confidential `client_credentials` clients authenticated with form-encoded `client_secret_basic`; configured subject, tenant, roles, scopes and per-client token lifetime. Secrets are resolved from environment references at startup. |
| Tokens and identity | Locally signed RS256 JWTs; configurable access-token resource audience while ID tokens retain the client audience; human/service principal types, tenant, groups, roles, scope and assurance claims. A service tenant is bound at registration; a human tenant comes from the directory, or from a client-declared zone when the directory has placed the user nowhere, and issuance is refused when the two conflict. `assurance.at` is the authentication time, not the mint time. UserInfo verifies algorithm, issuer and access-token purpose, resolves canonical directory subjects, and filters profile/email/groups by scope. |
| Caller commands | `keycape login` for public-client browser PKCE login, `keycape service-token` for service exchange, and `keycape verify-client` to check a live registration's subject, tenant, roles, lifetime and scope refusals without emitting values. HTTPS discovery/JWKS verification and private JSON token-file delivery outside Git; no token output on stdout. |
| Validation and migration | Canonical snapshot checks; deterministic LLDAP user/group/membership export that records whether it enumerated the whole directory; basic Keycloak realm JSON; LDIF generation for OpenLDAP, 389 Directory Server and AD targets. These generate artifacts rather than execute a complete migration. |
| Diagnostics and packaging | Structured authentication/enforcement/migration events, liveness and dependency-probing readiness endpoints, Go build/test/vet targets, a container packaging the issuer and all four migration/validation binaries, and development/CI scaffolding. |
## Material limits
- Client registrations, service secrets and the signing key are loaded at startup.
Login, authorization and handoff state are process-local. There is no shared
session store, general refresh-token flow, token introspection/revocation API,
or automatic signing-key/client-secret rotation service. Logout clears the
local KeyCape session, not every upstream or downstream session/token.
- The optional tenant-engine `tenant_roles` adapter is wired through a
`tenantEngine` config block and is off unless `baseURL` is set (KEY-WP-0024).
When enabled it fails open: an unreachable source omits the claim rather than
failing issuance, so `tenant_roles` is a cache and must not be trusted for
privileged decisions.
- The LLDAP export enumerates the group subtree directly, so groups with no
members are present, and every snapshot carries a `groupEnumeration` field
saying whether that enumeration ran or the membership-derived fallback did
(KEY-WP-0018). A failed enumeration aborts the export rather than writing a
smaller snapshot, and read failures are reported rather than skipped. Users,
groups and memberships are sorted, so an unchanged directory exports
identically. Completeness beyond users, groups and memberships — passwords and
MFA credentials in particular — is still not covered.
- The Keycloak CLI takes a `-clients` config and migrates client registrations:
flows follow the declared grants, and audience, tenant, service subject and
roles are carried as protocol mappers with per-client lifetime, handoff URLs
and the secret *reference* as attributes (KEY-WP-0020). What it cannot carry it
names, in a report separate from the consistency check: secret values, MFA
policy enforcement, passwords and factor enrolment, and subject continuity —
Keycloak mints its own `sub`, so the LLDAP canonical ID survives only as an
attribute. The output is a reviewed artifact, not a proven migration; that
proof needs a live provider swap and is not established.
- Snapshot validation is a limited Go rule set, not full machine-readable schema
enforcement. Raw LDAP attribute keys are checked for descriptor validity,
canonical-mapping shadowing and case-only duplicates (KEY-WP-0021); attribute
values are not validated against a directory schema, and no allow-list of
permitted attribute names exists, since that field carries what the canonical
model does not name. The canonical YAML model and discovery metadata now match the
runtime client-registration surface and are held there by a conformance check
(KEY-WP-0017); the Go model is the runtime authority and the YAML the reviewed
contract. That is narrower than schema enforcement in general.
- The authorization-code grant now binds the redirect URI,
enforces grant-type eligibility, authenticates confidential clients and
consumes codes atomically, and UserInfo enforces algorithm, issuer and
access-token purpose (KEY-WP-0016). Upstream Authelia ID tokens are verified
before any claim is trusted — signature against the provider's published keys,
advertised issuer, own client ID in the audience, validity window — failing
closed and independently of transport (KEY-WP-0019). KeyCape deliberately does
not check or gate on the upstream transport. Enforcing these bindings
is not complete profile conformance. Relying parties must repeat `redirect_uri`
on the token exchange and present the access token, not the ID token, to
`/userinfo`; see [authorization-code bindings](docs/authorization-code-bindings.md).
See the assessment below.
- Tests cover local handlers, adapters, transformations and CLI protocol behavior.
They do not establish complete replacement against a running Keycloak/full-LDAP
stack. The Scenario B/C harnesses now run, and `src/tests/conformance` targets a
live issuer named by `KEYCAPE_TEST_ISSUER`, skipping when it is unset
(KEY-WP-0022). Run against Keycloak 26.0 it verified discovery, the
authorization surface, the published keys and a real token exchange — and
showed that stock Keycloak advertises the excluded `implicit` and `password`
grants, so it is not a drop-in for this profile. Directory migration is proved
end to end into a real OpenLDAP, with every migrated membership resolving, and a
realm built from a live export imports into Keycloak and serves discovery
(KEY-WP-0023). Relying-party behaviour and MFA against a migrated realm remain
unexercised, and credential/MFA migration is not supplied at all, so no harness
can establish it. Subject continuity is explicitly not preserved: the canonical
ID survives as an attribute while Keycloak mints its own `sub`.
- **All ten gaps from the 2026-09-05 assessment are now closed**, G10 last, on
2026-09-10. Closure means the gap each named no longer holds; it does not mean
nothing is outstanding. The approver registration is published but no human has
logged in through it, the deployed image predates most of this revision, and the
Qonto rotation is deferred by owner decision on evidence. Those are open items
with owners and triggers, which is a different thing from a gap between what
this document claims and what is true.
- **Source behaviour and deployed behaviour are not the same claim, and this
document describes source.** Everything above is what the code in this revision
does, established by 394 Go tests across 23 packages plus the harness
runs named. What has been demonstrated against the live deployment is narrower and is
recorded as committed receipts in `docs/evidence/`: the upstream issuer was
probed and then pinned in `sso/keycape-config`
(`2026-09-09-upstream-issuer-pin.json` — the pre-pin state did **not** match, so
this was a real fix), and both approval service clients were verified live in an
attended owner process, showing JWKS signature verification, exact claims,
excess-scope and wrong-secret denial at a 900s lifetime
(`2026-09-09-keycape-verifier-admission.json`), independently confirmed by the
custody owner. That is live evidence for those two clients specifically —
more than a source claim, less than a general one. On the custody boundary:
client-side retrieval of those secrets is unadmitted and stays so, but the
attended operator path is not a client-side read and never required one;
conflating the two is what left this work recorded as waiting on a run that had
already happened. Those receipts also decline what they did not test: no real
predecessor rotation and no observed wall-clock expiry. Those name a **third
state** this document otherwise has no words for — *implemented and unproven*,
distinct from both missing and done. `verify-client`'s predecessor rejection is
written and unit-tested but has never run against a genuinely distinct
predecessor, so this repository holds a receipt that reads like rotation
evidence and is not. Both owners state that limit independently. Read "there is
a test" and "it has been exercised against the real thing" as separate claims
wherever this document makes one. The deployed image is older than this revision, so the
authorization-code bindings, upstream verification, readiness, graceful
shutdown and the two tenant-related changes are **not yet running anywhere** —
see [operations](docs/operations.md), "Unreleased fail-closed startup changes".
Read `docs/evidence/` before describing any of this as proven live.
- The server listens on HTTP; HTTPS termination is deployment-owned.
`/healthz` is liveness and probes nothing; `/readyz` probes LLDAP, Authelia and
privacyIDEA and gates traffic. `SIGTERM` drains in-flight requests for 15s.
The supported topology is a single replica, since login and authorization state
is process-local — see [operations](docs/operations.md) for that and for the
key-rotation, client-removal and logout limits. Development
Compose needs key and Authelia material absent from the checkout by design;
`scripts/bootstrap-dev.sh` generates it locally and git-ignores it
(KEY-WP-0026). Production
deployment and custody are external; a source implementation or example client
fragment does not prove live registration or consumer cutover.
- Notification receipt closure is done: KEY-WP-0009 is finished, its four handoffs
delivered with receipts readable via `GET /messages/?from_agent=key-cape`.
Approval-client provisioning (KEY-WP-0013-T02) and coordinated Qonto rotation
plus the login-proxy consumer split (KEY-WP-0014-T04) remain open. Both requests
are now delivered and both wait on owners outside this repository —
railiance-platform for custody admission, approval-engine for the exact human
callback, ops-warden for the route contract and a rotation execution/rollback
authority. No client is materialized in the deployed image and no route changed.
## Deliberate exclusions
General-purpose IAM, dynamic client registration, implicit/password grants,
wildcard redirects, arbitrary identity brokering, resource authorization policy,
OpenBao credential custody, and Keycloak operations are outside this repository's
ownership. Registration/enrollment handoffs do not implement user provisioning
or factor enrollment themselves.
## Entry points and verification
Run the issuer with `keycape --config PATH`; `keycape server` and
`keycape migrate` are not implemented subcommands. Caller commands are
`keycape login`, `keycape service-token` and `keycape verify-client`.
`keycape check-config` validates a rendered configuration offline, before a
restart (see [operations](docs/operations.md)).
Separate binaries under `src/cmd/` are `validator`, `lldap-export`,
`keycape-to-keycloak`, and `lldap-to-ldap`. `make -C src build` places binaries in
root `bin/` — the root `make build` only compiles and installs nothing. The
container packages all five binaries with the issuer as its entrypoint
(KEY-WP-0026). Tests live under `src/tests/` and alongside packages in
`src/internal/`.
`make test`, `make lint` (Go vet), `make build`, and `make contract-test` are the
root checks. The capability-contract check requires the sibling NetKingdom
validator. These checks passed for the reviewed revision in the preceding
implementation session and were rerun for this one; `src/tests/conformance` is
additionally runnable against a live issuer via `KEYCAPE_TEST_ISSUER` and skips
when it is unset. The scope assessment is a source review: it establishes what
the code does, not what any deployment is currently running.
Use this repo for its bounded issuer, caller JWT acquisition, identity adapters
and migration preparation. Consult [INTENT.md](INTENT.md) for the architectural
target, [native authentication](docs/native-authentication.md) for caller
constraints, [operations](docs/operations.md) for the supported topology and the
unreleased fail-closed changes, `docs/evidence/` for what has been proven against
the live deployment, and the [reassessment](history/2026-09-09-scope-reassessment.md)
— with the [2026-09-05 assessment](history/2026-09-05-011726-scope-intent-assessment.md)
it supersedes — for evidence, priorities and remaining gaps.
## Provided capabilities
```capability
type: security
title: Bounded OIDC and service-token issuance
description: Implements static-client browser PKCE and client-credentials authentication with RS256 identity claims through Authelia, LLDAP and privacyIDEA; complete Keycloak interchangeability remains unproven.
keywords: [oidc, pkce, authentication, iam, sso, mfa, identity, service-token]
```
```capability
type: security
title: Verified caller JWT acquisition
description: Provides native browser login and service-token exchange commands with HTTPS discovery, JWKS verification and private token-file delivery.
keywords: [oidc, pkce, jwt, cli, authentication, jwks]
```
```capability
type: security
title: Directory validation and migration artifact generation
description: Checks canonical snapshots and produces LLDAP exports, basic Keycloak realm JSON and target-specific LDIF; does not perform or prove a complete live migration.
keywords: [migration, identity, lldap, keycloak, ldif, validation]
```

View file

@ -0,0 +1,283 @@
# Operating KeyCape
The supported deployment topology and the limits that come with it. These are
deliberate boundaries of the current implementation, not defects awaiting a fix:
where a limit is a consequence of a design choice, the choice is named.
## Topology: exactly one replica
**Run one instance per issuer.** Authorization codes, login sessions and
registration/enrollment handoffs are held in process memory. There is no shared
store, and no sticky-session configuration makes this safe: a browser that starts
a login on one replica and returns from Authelia to another finds no session and
must start again. Two replicas do not halve the failure rate, they roughly double
the login failure rate.
This is a documented exclusion rather than a missing feature. Adding a shared
session store is a real option if the profile ever requires horizontal scale; it
is not required today.
Consequences to plan for:
- **A restart drops in-flight logins.** Anyone mid-login gets an error and must
start again. Issued tokens are unaffected — they are self-contained JWTs and
stay valid until they expire.
- **Rolling deployments are single-instance rollovers**, so expect a brief window
where new logins fail. Draining (below) protects requests already in flight, not
logins waiting on a human at Authelia's password prompt.
## Liveness and readiness
| Endpoint | Answers | Use it for |
| --- | --- | --- |
| `/healthz` | Is the process up? Probes nothing. | Liveness. |
| `/readyz` | Are LLDAP, Authelia and privacyIDEA reachable? | Readiness / traffic gating. |
Keep these distinct. Wiring liveness to `/readyz` means an orchestrator restarts
KeyCape whenever a dependency blinks, turning someone else's blip into an outage
of your own — and a restart also discards every in-flight login, making it worse
than the condition it reacted to.
`/readyz` returns 200 with `status: ready`, or 503 with `status: not_ready` and
the failing check named. It reports *which* check failed, never *why*: the
endpoint is unauthenticated and upstream error text carries hostnames and
occasionally credentials-in-URLs. The reason is in the server log.
Probes are reachability and credential checks, not functional tests. LLDAP is
probed with a bind, so a rotated or revoked service password is caught — that
leaves the port open and every lookup failing, which is exactly what readiness
should catch. Authelia and privacyIDEA are probed with a plain HTTP GET: any
response means something is listening and speaking HTTP.
Results are cached for 2 seconds and each probe is bounded at 3 seconds. The
cache is not an optimisation: the endpoint is necessarily unauthenticated, and
without it anyone able to reach it could drive one upstream request per
dependency per call.
## Shutdown
On `SIGTERM` or `SIGINT` the server stops accepting connections and gives
in-flight requests up to 15 seconds to finish. The grace period is deliberately
under the 30-second read/write timeouts, so a stuck request cannot outlive the
window an orchestrator typically allows before `SIGKILL`.
In-memory login and authorization state is not preserved across shutdown, by
design — see the topology section.
## Key and registration lifecycle
The signing key and all client registrations are read once at startup. Both are
changed by editing configuration and restarting; there is no reload signal and no
rotation service.
**Check a changed configuration before the restart.** KeyCape exits at startup on
a configuration it refuses, and with one replica that stops sign-in for every
client. `keycape check-config --config PATH [--env-names A,B] [--policy-store]`
runs every startup check that needs no secret value, signing key or upstream, and
prints a JSON report with no values in it. The checks cover validation (including
redirect URIs with `?` or `*`, and duplicate `clientId`s), building the client
registry, the global `tokenLifetime`, and the clients the policy store requires
when `--policy-store` is set. With `--env-names`, it also checks that every
`env:` secretRef is among the variables the Deployment provides. It exits non-zero
when the server would refuse to start. It does not check that the key file is
readable or that a secret is non-empty, because it never sees either.
**The key ID is the constant `key-1`.** Rotating the signing key while keeping
that identifier is a trap: a consumer caching JWKS by `kid` may keep the old key
and reject freshly issued tokens until its cache expires. Plan rotation as
"publish new keys, let consumers refetch, then issue with the new key", and treat
a same-`kid` swap as a breaking change for anyone caching aggressively.
**Removing a client registration does not revoke tokens already issued to it.**
There is no introspection or revocation endpoint, so an issued token stays valid
until it expires — 15 minutes by default, or the client's `tokenLifetime`. To cut
off a compromised client, remove the registration *and* wait out the lifetime, or
rotate the signing key if you cannot.
**`/logout` clears the local KeyCape session only.** It does not end the Authelia
session and does not revoke any issued token. A user who logs out and back in may
not be prompted for credentials, because the upstream session is still valid.
## Transport
The server speaks plain HTTP. TLS termination belongs to the deployment. KeyCape
deliberately does not check or gate on the transport used to reach its upstream
providers either; upstream ID tokens are verified cryptographically instead, so
that assurance does not depend on the network being what we believe it is
(KEY-WP-0019).
## Upstream issuer pinning (read before rolling out KEY-WP-0019)
KeyCape verifies upstream ID tokens against the issuer the provider advertises,
discovered server-side from `authelia.tokenBaseURL`. Some providers — Authelia
among them — derive the advertised issuer from the **request Host**, so the value
KeyCape learns over an in-cluster service address is not the value minted into
tokens issued for the browser-facing host.
Where that is true, pin it explicitly:
```yaml
authelia:
tokenBaseURL: "http://authelia.sso.svc.cluster.local:9091"
issuer: "https://auth.coulomb.social" # exactly the iss claim in ID tokens
jwksUrl: "http://authelia.sso.svc.cluster.local:9091/jwks.json"
```
Verification fails closed, so a mismatch means **every human login fails** — and
it looks like a broken login rather than a configuration error. Check the
issuer before rolling out, not after. The failure is diagnosable: the
authentication failure event carries `error_type=id_token_issuer_mismatch`, as
distinct from `id_token_signature` or `provider_keys_unavailable`.
Confirm the value with the Host the provider will actually see:
```bash
curl -s -H "Host: auth.coulomb.social" \
http://authelia.sso.svc.cluster.local:9091/.well-known/openid-configuration \
| jq -r .issuer
```
## Before any live change
The estate-wide rules are in
`the-custodian/docs/agent-environment-orientation.md` (revision 2026-09-21);
read it first. The parts that apply to KeyCape's objects in `sso` and `mfa`:
- The founder's go-ahead comes first, recorded as
`ADMINISTER @ realm:kubernetes/railiance01`, `activation=APPROVED`. Run changes
as `ssh railiance01 '…'`. A harness block is a stop signal, not something to
route around.
- Check CPU headroom before a restart or rollout
(`kubectl describe node | grep -A4 "Allocated resources"`). A rolling update
starts the new pod before stopping the old one.
- Diff first: client-side `kubectl diff`, then `--dry-run=server`.
- **Never `kubectl apply` a Secret, and never read one's metadata.** `apply`
copies the whole Secret into the `last-applied-configuration` annotation, so
even a metadata read prints the signing key and client secrets. Write Secrets
with `kubectl replace`. To test for the annotation without printing it:
`kubectl get secret <n> -n sso -o go-template='{{ if index .metadata.annotations "kubectl.kubernetes.io/last-applied-configuration" }}HAS-ANNOTATION{{ else }}clean{{ end }}'`.
- **Never run any other go-template or jsonpath against a Secret.** When a
template fails, for example `len` on an absent field, kubectl prints the whole
raw object as debugging output, `.data` included. The presence check above is
the only tested template. This leaked `sso/keycape-config` into an agent
session on 2026-09-23 (see below).
**Open finding, 2026-09-23:** `sso/keycape-config`, `sso/authelia-secrets`,
`sso/lldap-secrets` and `mfa/privacyidea-config` all carry that annotation. The
earlier rotation script wrote them with `apply`; it now uses `replace`. Anyone
who can `get` these Secrets could read the data anyway, so this is not a new
reader. What it breaks is the assumption that a metadata read is safe. The fix
is a live change and waits for the founder's go-ahead:
`kubectl annotate secret <n> -n <ns> kubectl.kubernetes.io/last-applied-configuration-`
for each of the four. It prints no value and leaves `.data` untouched, so pods
need no restart. Re-check with the template above afterwards.
**Superseded the same day by an exposure.** While checking these Secrets' owners
before the cleanup, an agent ran an untested template. It failed and printed
`keycape-config` in full into the session transcript: the signing key, the LLDAP
bind password and the Authelia client secret. The other three Secrets were
probably printed as well. All four must be treated as exposed and rotated; the
annotation cleanup folds into that rotation.
**Rotation deferred (founder decision, 2026-09-24, builder mode).** Tracked as
the-custodian `CUST-WP-0073-T05` with event triggers: the first production
workload or customer data, any sign the transcript left the workstation, or a
planned key rotation. The same workplan removes the problem class by giving
agents a cluster identity that cannot read these objects at all.
## Current image (2026-10-15)
The live issuer is
`forgejo.coulomb.social/coulomb/key-cape@sha256:cdec9dd7c515f7e0a6169fc3fa560ee2cc47bf40d899755dec808c362f5ce010`,
source `8c6c071`. Over the previous live source `1164f65` it adds the
duplicate-clientId startup check (`c528daa`), the lost-authenticator help link
(`205a1c0`), and the opt-in sign-in diagnosis page with `uid`/`assurance` on
`auth_success` (`af3b1b0`). Founder-run on 2026-10-15: `keycape check-config`
on the live config was valid (13 clients, no errors), `kubectl diff` showed only
the image, server dry-run passed, Recreate rollout succeeded, pod ready with 0
restarts and discovery answering 200. The owner manifest is
`net-kingdom/sso-mfa/k8s/keycape/deployment.yaml`. Rollback is
`sha256:6f79a2af1c695d39480173fad013facd84d21e7732860366af519302a2c496b8`.
## Fail-closed startup changes from source 11ce29a
These remain in the current image. They first shipped in
`sha256:8dc35801d1d2b0905a43530c40b1fed0107fc179141fe5b2b614c52c01714fc9`,
Forgejo run 69, source `11ce29a`, confirmed running on 2026-09-25. They fail
closed. The 2026-09-24 Vergabe journey completed after one token-exchange
failure; see `docs/evidence/2026-09-24-fresh-login-and-account-switch.md`.
**1. Browser clients reject service-identity fields (`74b35b6`, KEY-WP-0028).**
Config validation now rejects `serviceSubject` or `roles` on a client whose
grant is not `client_credentials`; those fields were silently ignored there, so a
registration could look effective and do nothing. This is a **startup** error:
a stray field in the deployed config stops the process from booting rather than
degrading. The live `sso/keycape-config` was checked on 2026-09-09 and none of
its three browser clients carries either field, so it passes as deployed. The
custody owner confirmed independently that the config was last written during the
2026-09-09 activation (resource version 58747126) and has not been edited since,
so the inspected state is the state that will boot — and that they will re-check
the browser clients if anything writes that Secret before the window. `tenant` is deliberately not rejected;
see below.
**2. A conflicting tenant binding refuses issuance (`329e48f`, KEY-WP-0013-T05).**
A client registration may declare a `tenant`, which is supplied when the
directory has placed the user nowhere and enforced when it has. A declared zone
that **conflicts** with a directory assignment refuses the token with `403` and
`error_type: tenant_binding`. No deployed client declares a tenant on a browser
grant today, so nothing changes on rollout; the refusal only becomes reachable
once the approver client is registered. See
[the tenant contract](tenant-claim-contract.md).
**3. KeyCape enforces login freshness itself (`11ce29a`, KEY-WP-0033).**
`prompt=login` is no longer forwarded to Authelia, because 4.38 refuses it for
every real login. `prompt=login` and `max_age=0` go upstream as `max_age=10`,
and the callback refuses any upstream `auth_time` that misses the requirement,
or is absent (`stale_upstream_authentication`). This changes nothing for clients
that do not ask for freshness. The proof is a completed fresh-login journey,
not a redirect check.
The live boot is the image above. A further rollout is not required for these
three changes.
## What is not claimed
No resource-efficiency or throughput bounds are asserted here. Nothing in this
repository benchmarks KeyCape, so any figure would be invention. Measure it in
your own deployment before sizing against it.
## Account recovery and browser sign-out
Set `KEYCAPE_ACCOUNT_PORTAL_URL=https://users.coulomb.social` and
`KEYCAPE_BROWSER_LOGOUT_URL=https://auth.coulomb.social/logout` together to enable
central browser recovery. Both must be HTTPS, with no credentials, query or
fragment. They override the corresponding YAML accountPortalURL/browserLogoutURL.
Failed browser authorization and expired callbacks redirect to the fixed
`/access-recovery` page with no state, code, claimed user or browser return URL.
API authorization/token validation remains fail-closed.
`GET /account/logout` displays confirmation and creates a short-lived Secure,
HttpOnly host-only CSRF cookie. POST checks that cookie, the form nonce and exact
issuer Origin; only then does it invalidate the current KeyCape session and
redirect to Authelia's browser `/logout`, with a fixed portal `/logged-out` return.
Authelia owns and deletes its session cookie. Its v4.38 SignOut view validates the
return destination and performs the same-origin logout API call. Reference:
https://github.com/authelia/authelia/blob/v4.38.19/web/src/views/LoginPortal/SignOut/SignOut.tsx
Existing RP sessions and issued JWTs are not revoked. The original `/logout`
endpoint retains its local-only contract. Actual shared-session destruction
requires browser execution; a redirect-only smoke is not acceptance evidence.
## Sign-in diagnosis page (NK-WP-0051-T02)
A client with `signInDiagnosis: true` in its registration shows a page before
the redirect back to it: `uid`, `groups`, `assurance` and `client_id` as defined
in `net-kingdom/docs/sign-in-diagnosis.md`, with one continue link carrying the
unchanged code. Default is off. The repo enables it only for `openbao-admin` in
`config/dev-config.yaml`; the live flag is set on the OpenBao browser client
(`openbao-admin`) in `config.yaml` of the `sso/keycape-config` Secret, and takes
effect on the next issuer restart. It is live since 2026-10-15 (rv 75372162),
set with net-kingdom `sso-mfa/k8s/keycape/openbao-signin-diagnosis-rollout.py`;
`--operation remove` plus a restart is the rollback. A redirect URI that is not http(s)
skips the page. Every `auth_success` log line now carries `client_id`, `uid` and
`assurance`, and no token, code, email or one-time password.

View file

@ -0,0 +1,465 @@
// keycape is the main server binary for the KeyCape IAM profile service.
// It orchestrates Authelia, LLDAP, and privacyIDEA to implement the
// NetKingdom IAM Profile (OIDC/PKCE Authorization Code Flow).
package main
import (
"context"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"flag"
"fmt"
"keycape/internal/server/readiness"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/rs/zerolog"
"keycape/internal/adapters/authelia"
"keycape/internal/adapters/lldap"
"keycape/internal/adapters/privacyidea"
"keycape/internal/adapters/tenantengine"
"keycape/internal/authclient"
"keycape/internal/config"
"keycape/internal/domain"
servererrors "keycape/internal/server/errors"
"keycape/internal/server/oidc"
"keycape/internal/server/policy"
"keycape/internal/server/telemetry"
)
const version = "0.1.0"
func main() {
if len(os.Args) > 1 && os.Args[1] == "check-config" {
os.Exit(runCheckConfig(os.Args[2:], os.Stdout))
}
if len(os.Args) > 1 && os.Args[1] == "probe-upstream-issuer" {
if err := authelia.RunIssuerProbe(context.Background(), os.Args[2:], os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return
}
if len(os.Args) > 1 && (os.Args[1] == "login" || os.Args[1] == "service-token" || os.Args[1] == "verify-client") {
if err := authclient.Run(context.Background(), os.Args[1:], os.Stderr); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return
}
log := zerolog.New(os.Stdout).With().Timestamp().Logger()
// -----------------------------------------------------------------
// 1. Parse flags and load config.
// -----------------------------------------------------------------
var cfgPath string
flag.StringVar(&cfgPath, "config", "", "path to YAML config file (env: KEYCAPE_CONFIG)")
flag.Parse()
cfg, err := config.Load(cfgPath)
if err != nil {
log.Error().Err(err).Msg("failed to load config")
os.Exit(1)
}
// -----------------------------------------------------------------
// 2. Validate config.
// -----------------------------------------------------------------
errs := config.ValidateConfig(cfg)
if len(errs) > 0 {
log.Error().Strs("errors", errs).Msg("config validation failed")
os.Exit(1)
}
// -----------------------------------------------------------------
// 3. Load RSA private key.
// -----------------------------------------------------------------
privateKey, err := loadPrivateKey(cfg.PrivateKeyPEM)
if err != nil {
log.Error().Err(err).Str("path", cfg.PrivateKeyPEM).Msg("failed to load private key")
os.Exit(1)
}
// -----------------------------------------------------------------
// 4. Build JWKS from public key.
// -----------------------------------------------------------------
ks := oidc.NewKeySet()
ks.AddKey("key-1", &privateKey.PublicKey)
// -----------------------------------------------------------------
// 5. Build client registry.
// -----------------------------------------------------------------
clients, err := buildClientRegistry(cfg.Clients)
if err != nil {
log.Error().Err(err).Msg("failed to build client registry")
os.Exit(1)
}
// -----------------------------------------------------------------
// 6. Create adapters.
// -----------------------------------------------------------------
lldapAdapter := lldap.New(cfg.LLDAP)
autheliaAdapter := authelia.New(cfg.Authelia, nil)
privacyIDEAAdapter := privacyidea.New(cfg.PrivacyIDEA, nil)
// -----------------------------------------------------------------
// 7. Create telemetry emitter.
// -----------------------------------------------------------------
emitter := telemetry.NewLogEmitter(log)
// -----------------------------------------------------------------
// 8. Create enforcement registry.
// -----------------------------------------------------------------
enforcement := servererrors.DefaultRegistry()
// -----------------------------------------------------------------
// 9. Create session store.
// -----------------------------------------------------------------
sessions := oidc.NewSessionStore()
// -----------------------------------------------------------------
// 10. Parse token lifetime.
// -----------------------------------------------------------------
tokenLifetime := 15 * time.Minute
if cfg.TokenLifetime != "" {
d, err := time.ParseDuration(cfg.TokenLifetime)
if err != nil {
log.Error().Err(err).Str("tokenLifetime", cfg.TokenLifetime).Msg("invalid tokenLifetime")
os.Exit(1)
}
tokenLifetime = d
}
// -----------------------------------------------------------------
// 11. Build issuer base URL.
// -----------------------------------------------------------------
issuer := strings.TrimRight(cfg.Issuer, "/")
// -----------------------------------------------------------------
// 12. Register HTTP handlers.
// -----------------------------------------------------------------
mux := http.NewServeMux()
// Discovery.
mux.Handle("/.well-known/openid-configuration", oidc.NewDiscoveryHandler(oidc.DiscoveryConfig{
Issuer: issuer,
AuthorizationEndpoint: issuer + "/authorize",
TokenEndpoint: issuer + "/token",
JWKSUri: issuer + "/jwks",
UserinfoEndpoint: issuer + "/userinfo",
EndSessionEndpoint: issuer + "/logout",
Clients: clients,
}))
// JWKS.
mux.Handle("/jwks", oidc.NewJWKSHandler(ks))
// Authorize handler (with enforcement middleware).
logins := oidc.NewLoginSessionStore()
authorizeHandler := &oidc.AuthorizeHandler{
AccountPortalURL: cfg.AccountPortalURL,
ClientConfig: clients,
Auth: autheliaAdapter,
MFA: privacyIDEAAdapter,
Sessions: sessions,
Logins: logins,
Handoffs: oidc.NewHandoffStore(),
Issuer: issuer,
Emitter: emitter,
Users: lldapAdapter,
}
if path := os.Getenv("KEYCAPE_POLICY_PATH"); path != "" {
policies, err := policy.Open(path, clients)
if err != nil {
log.Error().Msg("authentication policy store unavailable")
os.Exit(1)
}
authorizeHandler.EffectivePolicy = policies.Effective
mux.Handle("/platform/authentication-policy", policy.Handler(policies, issuer, &privateKey.PublicKey))
}
mux.Handle("/authorize", enforcement.Middleware(authorizeHandler))
mux.Handle("/authorize/callback", authorizeHandler)
mux.Handle("/authorize/return", authorizeHandler)
mux.Handle("/authorize/register", authorizeHandler)
mux.Handle("/logout", &oidc.LogoutHandler{
ClientConfig: clients,
Logins: logins,
SecureCookie: strings.HasPrefix(strings.ToLower(issuer), "https://"),
})
if cfg.AccountPortalURL != "" {
mux.Handle("/account/logout", &oidc.AccountLogoutHandler{
PortalURL: cfg.AccountPortalURL, UpstreamLogoutURL: cfg.BrowserLogoutURL,
Logins: logins, Issuer: issuer,
})
}
// Token handler (with enforcement middleware).
tokenHandler := &oidc.TokenHandler{
ClientConfig: clients,
Sessions: sessions,
Users: lldapAdapter,
SigningKey: privateKey,
Issuer: issuer,
TokenLifetime: tokenLifetime,
Emitter: emitter,
// Opt-in: nil unless tenantEngine.baseURL is configured, which is what
// leaves tenant_roles off the stock server (KEY-WP-0024). The adapter
// fails open, so a configured-but-unreachable cache omits the claim
// rather than failing issuance.
TenantEngine: buildTenantEngine(cfg.TenantEngine),
}
mux.Handle("/token", enforcement.Middleware(tokenHandler))
// Userinfo handler.
mux.Handle("/userinfo", &oidc.UserinfoHandler{
Users: lldapAdapter,
SigningKey: &privateKey.PublicKey,
Issuer: issuer,
Emitter: emitter,
})
// Healthz.
// /readyz gates traffic on the dependencies a login needs; /healthz stays a
// liveness signal (KEY-WP-0025). Restarting KeyCape because LLDAP blinked
// would convert a dependency blip into an outage of our own, so the two must
// not be the same endpoint.
mux.Handle("/readyz", readiness.New(2*time.Second, 3*time.Second,
readiness.Check{Name: "lldap", Probe: func(ctx context.Context) error {
return lldapAdapter.Ping(ctx)
}},
readiness.Check{Name: "authelia", Probe: httpReachable(cfg.Authelia.TokenBaseURL, cfg.Authelia.BaseURL)},
readiness.Check{Name: "privacyidea", Probe: httpReachable(cfg.PrivacyIDEA.BaseURL)},
readiness.Check{Name: "factor_reader", Probe: privacyIDEAAdapter.Probe},
))
// Liveness only: this says the process is up, and deliberately probes
// nothing. See /readyz.
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"version": version,
})
})
// Inject emitter into request context.
handler := withEmitter(mux, emitter)
// -----------------------------------------------------------------
// 13. Start HTTP server.
// -----------------------------------------------------------------
addr := fmt.Sprintf(":%d", cfg.Port)
if cfg.Port == 0 {
addr = ":8080"
}
log.Info().
Str("issuer", issuer).
Str("addr", addr).
Str("environment", cfg.Environment).
Str("version", version).
Msg("starting keycape server")
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
// Serve in the background so shutdown can be driven by a signal. Without
// this the process died on SIGTERM mid-request, so every rolling restart
// returned errors to whoever was logging in at that moment.
serveErr := make(chan error, 1)
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
serveErr <- err
return
}
serveErr <- nil
}()
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
select {
case err := <-serveErr:
if err != nil {
log.Error().Err(err).Msg("server error")
os.Exit(1)
}
case sig := <-signals:
log.Info().Str("signal", sig.String()).Msg("shutting down")
// In-flight requests get a bounded window to finish. In-memory login and
// authorization state does not survive this and is not meant to: a
// browser mid-login must start again after a restart. That is a
// documented limit of the single-replica topology, not a bug to paper
// over here -- see docs/operations.md.
ctx, cancel := context.WithTimeout(context.Background(), shutdownGrace)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Error().Err(err).Msg("graceful shutdown failed; exiting anyway")
os.Exit(1)
}
log.Info().Msg("shutdown complete")
}
}
// shutdownGrace bounds how long in-flight requests may finish after a signal.
// Kept under the 30s read/write timeouts so a stuck request cannot outlive the
// window an orchestrator typically allows before sending SIGKILL.
const shutdownGrace = 15 * time.Second
// httpReachable probes the first non-empty URL with a GET, reporting whether the
// dependency answered at all. It is a reachability check, not a functional one:
// any HTTP response means something is listening and speaking HTTP there.
func httpReachable(candidates ...string) func(context.Context) error {
target := ""
for _, candidate := range candidates {
if candidate != "" {
target = candidate
break
}
}
return func(ctx context.Context) error {
if target == "" {
return fmt.Errorf("no endpoint configured")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// loadPrivateKey reads a PEM file and parses the RSA private key.
// Supports both PKCS#1 ("RSA PRIVATE KEY") and PKCS#8 ("PRIVATE KEY") PEM blocks.
func loadPrivateKey(path string) (*rsa.PrivateKey, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read key file: %w", err)
}
block, _ := pem.Decode(data)
if block == nil {
return nil, fmt.Errorf("no PEM block found in %q", path)
}
switch block.Type {
case "RSA PRIVATE KEY":
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse PKCS1 private key: %w", err)
}
return key, nil
case "PRIVATE KEY":
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse PKCS8 private key: %w", err)
}
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("private key is not an RSA key")
}
return rsaKey, nil
default:
return nil, fmt.Errorf("unexpected PEM block type %q; expected RSA PRIVATE KEY or PRIVATE KEY", block.Type)
}
}
// buildClientRegistry converts []ClientConfig into the map used by handlers.
func buildClientRegistry(cfgClients []config.ClientConfig) (map[string]*domain.Client, error) {
return buildClientRegistryWith(cfgClients, os.Getenv)
}
// buildClientRegistryWith resolves env: secretRefs through getenv, so
// check-config can build the registry without any secret value.
func buildClientRegistryWith(cfgClients []config.ClientConfig, getenv func(string) string) (map[string]*domain.Client, error) {
m := make(map[string]*domain.Client, len(cfgClients))
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 = getenv(envName)
if clientSecret == "" {
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,
Audience: c.Audience,
ServiceSubject: c.ServiceSubject,
Tenant: c.Tenant,
Roles: c.Roles,
TokenLifetime: clientTokenLifetime,
MFARequired: c.MFARequired,
MFAOptional: c.MFAOptional,
RegistrationURL: c.RegistrationURL,
EnrollmentURL: c.EnrollmentURL,
SignInDiagnosis: c.SignInDiagnosis,
}
}
return m, nil
}
// withEmitter wraps a handler to inject the telemetry emitter into every request context.
func withEmitter(next http.Handler, e telemetry.Emitter) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := telemetry.WithEmitter(context.Background(), e)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// buildTenantEngine returns the tenant_roles cache client, or nil when the
// claim is not configured. Validation has already accepted the URL and timeout.
func buildTenantEngine(cfg config.TenantEngineConfig) *tenantengine.Client {
if cfg.BaseURL == "" {
return nil
}
var httpClient *http.Client
if cfg.Timeout != "" {
if timeout, err := time.ParseDuration(cfg.Timeout); err == nil {
httpClient = &http.Client{Timeout: timeout}
}
}
return tenantengine.New(cfg.BaseURL, httpClient)
}

View file

@ -0,0 +1,144 @@
// Package config handles loading and validating the KeyCape server configuration
// from a YAML file. The config path is resolved from the --config flag or the
// KEYCAPE_CONFIG environment variable.
package config
import (
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
"keycape/internal/adapters/authelia"
"keycape/internal/adapters/lldap"
"keycape/internal/adapters/privacyidea"
"keycape/internal/domain"
)
// Config is the top-level server configuration.
type Config struct {
AccountPortalURL string `yaml:"accountPortalURL,omitempty"`
BrowserLogoutURL string `yaml:"browserLogoutURL,omitempty"`
Issuer string `yaml:"issuer"`
Port int `yaml:"port"`
TokenLifetime string `yaml:"tokenLifetime"`
PrivateKeyPEM string `yaml:"privateKeyPem"`
LLDAP lldap.Config `yaml:"lldap"`
Authelia authelia.Config `yaml:"authelia"`
PrivacyIDEA privacyidea.Config `yaml:"privacyidea"`
Clients []ClientConfig `yaml:"clients"`
Environment string `yaml:"environment"`
TenantEngine TenantEngineConfig `yaml:"tenantEngine,omitempty"`
}
// TenantEngineConfig configures the optional tenant_roles cache claim.
//
// Opt-in by design: an empty baseURL leaves the claim off entirely, which is
// what the stock server does. tenant_roles is a cache callers must not trust for
// privileged decisions, and the adapter fails open, so enabling it is a
// performance choice rather than a security one (KEY-WP-0024).
type TenantEngineConfig struct {
// BaseURL is tenant-engine's cache-read endpoint. Empty disables the claim.
BaseURL string `yaml:"baseURL,omitempty"`
// Timeout bounds the lookup, which sits on the synchronous token-issuance
// path. Empty uses the adapter's own short default.
Timeout string `yaml:"timeout,omitempty"`
}
// 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"`
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"`
MFAOptional bool `yaml:"mfaOptional,omitempty"`
MFARequired *bool `yaml:"mfaRequired,omitempty"`
RegistrationURL string `yaml:"registrationUrl,omitempty"`
EnrollmentURL string `yaml:"enrollmentUrl,omitempty"`
// SignInDiagnosis opts the client into the pre-redirect sign-in
// diagnosis page (NK-WP-0051-T02). Default off.
SignInDiagnosis bool `yaml:"signInDiagnosis,omitempty"`
}
// Load reads and parses the YAML config file at path.
// If path is empty, it falls back to the KEYCAPE_CONFIG environment variable.
// Returns an error if the file cannot be read or parsed.
func Load(path string) (*Config, error) {
if path == "" {
path = os.Getenv("KEYCAPE_CONFIG")
}
if path == "" {
return nil, fmt.Errorf("config: no config path specified (use --config or KEYCAPE_CONFIG)")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("config: read %q: %w", path, err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("config: parse %q: %w", path, err)
}
if value := os.Getenv("KEYCAPE_ACCOUNT_PORTAL_URL"); value != "" {
cfg.AccountPortalURL = value
}
if value := os.Getenv("KEYCAPE_BROWSER_LOGOUT_URL"); value != "" {
cfg.BrowserLogoutURL = value
}
return &cfg, nil
}
// Registrations converts the configured clients to domain registrations without
// resolving any secret. Migration and inspection tooling needs the registration
// contract — grants, audience, tenant, roles, lifetime, MFA and handoff policy —
// but must never load secret material, so ClientSecret is deliberately left
// empty here. The server has its own conversion that does resolve secrets.
//
// An unparseable tokenLifetime is an error rather than a silent zero: a
// migration that quietly drops a per-client lifetime is the class of defect this
// exists to avoid.
func (c *Config) Registrations() ([]domain.Client, error) {
clients := make([]domain.Client, 0, len(c.Clients))
for _, cc := range c.Clients {
var lifetime time.Duration
if cc.TokenLifetime != "" {
parsed, err := time.ParseDuration(cc.TokenLifetime)
if err != nil {
return nil, fmt.Errorf("config: client %q tokenLifetime is invalid: %w", cc.ClientID, err)
}
lifetime = parsed
}
clients = append(clients, domain.Client{
ClientID: cc.ClientID,
DisplayName: cc.DisplayName,
RedirectURIs: cc.RedirectURIs,
AllowedScopes: cc.AllowedScopes,
GrantTypes: cc.GrantTypes,
ClientType: cc.ClientType,
SecretRef: cc.SecretRef,
Audience: cc.Audience,
ServiceSubject: cc.ServiceSubject,
Tenant: cc.Tenant,
Roles: cc.Roles,
TokenLifetime: lifetime,
MFARequired: cc.MFARequired,
MFAOptional: cc.MFAOptional,
RegistrationURL: cc.RegistrationURL,
EnrollmentURL: cc.EnrollmentURL,
SignInDiagnosis: cc.SignInDiagnosis,
})
}
return clients, nil
}

View file

@ -0,0 +1,188 @@
package config
import (
"fmt"
"net/url"
"strings"
"time"
)
// ValidateConfig validates a loaded Config and returns a list of human-readable
// error messages. An empty slice means the config is valid.
// Called at startup — the server must exit 1 if any errors are returned.
func ValidateConfig(cfg *Config) []string {
var errs []string
for name, raw := range map[string]string{"accountPortalURL": cfg.AccountPortalURL, "browserLogoutURL": cfg.BrowserLogoutURL} {
if raw == "" {
continue
}
u, err := url.Parse(raw)
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
errs = append(errs, name+": must be an HTTPS URL without credentials, query or fragment")
}
}
if (cfg.AccountPortalURL == "") != (cfg.BrowserLogoutURL == "") {
errs = append(errs, "accountPortalURL and browserLogoutURL must be configured together")
}
// Issuer must be a valid URL with an http(s) scheme.
if cfg.Issuer == "" {
errs = append(errs, "issuer: must not be empty")
} else {
u, err := url.Parse(cfg.Issuer)
if err != nil || u.Scheme == "" || u.Host == "" {
errs = append(errs, fmt.Sprintf("issuer: %q is not a valid URL (must include scheme and host)", cfg.Issuer))
} else if u.Scheme != "http" && u.Scheme != "https" {
errs = append(errs, fmt.Sprintf("issuer: scheme must be http or https, got %q", u.Scheme))
}
}
// Port must be in the valid TCP range.
if cfg.Port < 1 || cfg.Port > 65535 {
errs = append(errs, fmt.Sprintf("port: must be between 1 and 65535, got %d", cfg.Port))
}
// tenant_roles is opt-in: an empty baseURL disables it. A configured one
// must be usable, since a misconfigured cache source on the token path is
// worse than no cache source (KEY-WP-0024).
if cfg.TenantEngine.BaseURL != "" {
u, err := url.Parse(cfg.TenantEngine.BaseURL)
if err != nil || u.Scheme == "" || u.Host == "" {
errs = append(errs, fmt.Sprintf("tenantEngine.baseURL: %q is not a valid URL (must include scheme and host)", cfg.TenantEngine.BaseURL))
} else if u.Scheme != "http" && u.Scheme != "https" {
errs = append(errs, fmt.Sprintf("tenantEngine.baseURL: scheme must be http or https, got %q", u.Scheme))
}
}
if cfg.TenantEngine.Timeout != "" {
if cfg.TenantEngine.BaseURL == "" {
errs = append(errs, "tenantEngine.timeout: set without a baseURL, so tenant_roles stays disabled")
} else if timeout, err := time.ParseDuration(cfg.TenantEngine.Timeout); err != nil {
errs = append(errs, "tenantEngine.timeout: must be a valid duration")
} else if timeout <= 0 || timeout > 10*time.Second {
errs = append(errs, "tenantEngine.timeout: must be greater than 0 and at most 10s; it sits on the token-issuance path")
}
}
// At least one client must be registered.
if len(cfg.Clients) == 0 {
errs = append(errs, "clients: at least one client must be defined")
}
// The registry is keyed by clientId, so a duplicate would silently replace
// the earlier registration instead of failing.
seen := map[string]bool{}
// Each client must have at least one redirect URI and a non-empty clientId.
for i, c := range cfg.Clients {
prefix := fmt.Sprintf("clients[%d] (%s)", i, c.ClientID)
if c.ClientID == "" {
prefix = fmt.Sprintf("clients[%d]", i)
errs = append(errs, prefix+": clientId must not be empty")
} else if seen[c.ClientID] {
errs = append(errs, prefix+": clientId is registered more than once")
}
seen[c.ClientID] = true
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")
}
if c.MFAOptional && c.MFARequired != nil {
errs = append(errs, prefix+": mfaOptional cannot be combined with mfaRequired")
}
if c.MFAOptional && contains(c.GrantTypes, "client_credentials") {
errs = append(errs, prefix+": mfaOptional is only supported for browser clients")
}
hasAuthorizationCode := contains(c.GrantTypes, "authorization_code")
hasClientCredentials := contains(c.GrantTypes, "client_credentials")
if (hasAuthorizationCode || !hasClientCredentials) && len(c.RedirectURIs) == 0 {
errs = append(errs, prefix+": redirect_uri: at least one redirectUri must be registered")
}
if hasClientCredentials {
if c.ClientType != "confidential" {
errs = append(errs, prefix+": client_credentials requires clientType confidential")
}
if !strings.HasPrefix(c.SecretRef, "env:") {
errs = append(errs, prefix+": client_credentials requires an env: secretRef")
}
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")
}
// serviceSubject and roles are read only on the client_credentials
// path, so on a browser client they are silently ignored: the
// subject and roles come from the directory user. Rejecting them
// turns a registration that looks effective into a startup error
// (KEY-WP-0028).
//
// tenant is deliberately NOT in this list. A browser client may
// declare one, and humanTenant resolves it against the directory --
// see docs/tenant-claim-contract.md, "How a human token's tenant is
// resolved".
if c.ServiceSubject != "" {
errs = append(errs, prefix+": serviceSubject is only read for client_credentials clients; a browser client's subject is the directory user")
}
if len(c.Roles) > 0 {
errs = append(errs, prefix+": roles is only read for client_credentials clients; a browser client's roles come from the directory user")
}
}
// Warn about wildcard redirect URIs (they are blocked at runtime anyway).
for _, uri := range c.RedirectURIs {
if strings.ContainsAny(uri, "*?") {
errs = append(errs, prefix+fmt.Sprintf(": redirect_uri %q must not contain wildcards", uri))
}
}
if c.RegistrationURL != "" {
if err := validateHandoffURL(c.RegistrationURL); err != nil {
errs = append(errs, prefix+": registrationUrl: "+err.Error())
}
}
if c.EnrollmentURL != "" {
if err := validateHandoffURL(c.EnrollmentURL); err != nil {
errs = append(errs, prefix+": enrollmentUrl: "+err.Error())
}
}
}
// Private key PEM path must be provided (existence is checked at startup).
if cfg.PrivateKeyPEM == "" {
errs = append(errs, "privateKeyPem: path must not be empty")
}
return errs
}
func contains(values []string, wanted string) bool {
for _, value := range values {
if value == wanted {
return true
}
}
return false
}
func validateHandoffURL(raw string) error {
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("%q is not an absolute URL", raw)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("%q scheme must be http or https", raw)
}
if u.User != nil {
return fmt.Errorf("%q must not contain userinfo", raw)
}
if strings.ContainsAny(u.Host, "*?") || strings.ContainsAny(u.Path, "*") {
return fmt.Errorf("%q must not contain wildcards", raw)
}
return nil
}

View file

@ -0,0 +1,127 @@
// Package oidc implements OIDC profile endpoints for KeyCape.
// Only profile-supported features are advertised — no implicit flow,
// no dynamic registration, no request objects.
package oidc
import (
"encoding/json"
"net/http"
"sort"
"keycape/internal/domain"
)
// DiscoveryConfig holds the issuer and endpoint URLs for the discovery document.
// UserinfoEndpoint is optional; if empty it is omitted from the document.
type DiscoveryConfig struct {
Issuer string // e.g. "https://auth.netkingdom.local"
AuthorizationEndpoint string
TokenEndpoint string
JWKSUri string
UserinfoEndpoint string // optional, empty = not advertised
EndSessionEndpoint string // optional, empty = not advertised
// Clients supplies the registered clients so scopes_supported reflects the
// scopes this deployment can actually grant, rather than a fixed list that
// omits every configured resource scope (KEY-WP-0017-T03). Nil or empty
// leaves only the baseline OIDC scopes advertised.
Clients map[string]*domain.Client
}
// discoveryDocument is the JSON shape of /.well-known/openid-configuration.
// Fields are ordered to match common OIDC implementations for readability.
// registration_endpoint is intentionally absent — no dynamic client registration.
type discoveryDocument struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
JWKSUri string `json:"jwks_uri"`
UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"`
EndSessionEndpoint string `json:"end_session_endpoint,omitempty"`
ResponseTypesSupported []string `json:"response_types_supported"`
GrantTypesSupported []string `json:"grant_types_supported"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
ScopesSupported []string `json:"scopes_supported"`
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
ClaimsSupported []string `json:"claims_supported"`
SubjectTypesSupported []string `json:"subject_types_supported"`
RequestParameterSupported bool `json:"request_parameter_supported"`
ClaimsParameterSupported bool `json:"claims_parameter_supported"`
}
// discoveryHandler implements http.Handler for GET /.well-known/openid-configuration.
type discoveryHandler struct {
doc []byte
}
// NewDiscoveryHandler returns an http.Handler that serves the OIDC discovery document.
// The document is pre-serialised at construction time so every request is a cheap copy.
func NewDiscoveryHandler(cfg DiscoveryConfig) http.Handler {
d := discoveryDocument{
Issuer: cfg.Issuer,
AuthorizationEndpoint: cfg.AuthorizationEndpoint,
TokenEndpoint: cfg.TokenEndpoint,
JWKSUri: cfg.JWKSUri,
UserinfoEndpoint: cfg.UserinfoEndpoint,
EndSessionEndpoint: cfg.EndSessionEndpoint,
// Profile-locked values — not negotiable.
ResponseTypesSupported: []string{"code"},
GrantTypesSupported: []string{"authorization_code", "client_credentials"},
CodeChallengeMethodsSupported: []string{"S256"},
IDTokenSigningAlgValuesSupported: []string{"RS256"},
ScopesSupported: supportedScopes(cfg.Clients),
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "none"},
// Core profile claims are emitted on every token, so omitting one here
// is a contract defect rather than missing optional metadata. The
// scope-gated human claims and the optional cached tenant_roles claim
// follow them.
ClaimsSupported: []string{
"sub", "iss", "aud", "exp", "iat",
"tenant", "tenant_source", "principal_type", "roles", "groups", "assurance", "scope",
"nonce", "preferred_username", "email", "name", "tenant_roles",
},
SubjectTypesSupported: []string{"public"},
RequestParameterSupported: false,
ClaimsParameterSupported: false,
}
b, err := json.Marshal(d)
if err != nil {
// This can only fail if the struct contains un-marshallable types, which it does not.
panic("oidc: failed to marshal discovery document: " + err.Error())
}
return &discoveryHandler{doc: b}
}
func (h *discoveryHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "max-age=3600")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(h.doc)
}
// supportedScopes returns the baseline OIDC scopes plus every scope any
// registered client is allowed to request, sorted for a stable document. The
// baseline is advertised even with no clients configured, since it describes the
// profile rather than a particular deployment.
func supportedScopes(clients map[string]*domain.Client) []string {
seen := map[string]bool{}
scopes := []string{}
for _, scope := range []string{"openid", "profile", "email", "groups"} {
seen[scope] = true
scopes = append(scopes, scope)
}
extra := []string{}
for _, client := range clients {
for _, scope := range client.AllowedScopes {
if scope == "" || seen[scope] {
continue
}
seen[scope] = true
extra = append(extra, scope)
}
}
sort.Strings(extra)
return append(scopes, extra...)
}

View file

@ -0,0 +1,594 @@
package oidc
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"keycape/internal/adapters/tenantengine"
"keycape/internal/domain"
profileerrors "keycape/internal/errors"
"keycape/internal/server/telemetry"
)
// TokenHandler implements POST /token.
type TokenHandler struct {
ClientConfig map[string]*domain.Client
Sessions *SessionStore
Users domain.UserRepository
SigningKey *rsa.PrivateKey
Issuer string
TokenLifetime time.Duration
Emitter telemetry.Emitter
// TenantEngine sources the optional tenant_roles claim (KEY-WP-0005-T02).
// Nil disables it entirely -- token issuance never depends on it.
TenantEngine *tenantengine.Client
}
// tokenResponse is the JSON body returned on a successful token exchange.
type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
IDToken string `json:"id_token,omitempty"`
}
// ServeHTTP handles POST /token.
func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form body", http.StatusBadRequest)
return
}
grantType := r.FormValue("grant_type")
if grantType == "client_credentials" {
h.serveClientCredentials(w, r)
return
}
clientID := r.FormValue("client_id")
code := r.FormValue("code")
codeVerifier := r.FormValue("code_verifier")
// 1. Validate grant_type.
if grantType != "authorization_code" {
profileerrors.FeatureNotSupported(
"only grant_type=authorization_code is supported",
"grant_type="+grantType,
).Write(w, http.StatusBadRequest)
return
}
// 2. Validate client exists and may use this grant.
client, ok := h.ClientConfig[clientID]
if !ok {
profileerrors.InvalidProfileUsage("unknown client_id", "client_id").
Write(w, http.StatusBadRequest)
return
}
// Grant-type eligibility, enforced equivalently to the service path
// (KEY-WP-0016-T02). An empty grantTypes is an implicit authorization-code
// client, matching config validation; a client_credentials-only client must
// not reach the browser path.
if len(client.GrantTypes) > 0 && !containsString(client.GrantTypes, "authorization_code") {
profileerrors.InvalidProfileUsage(
"client is not registered for grant_type=authorization_code",
"grant_type",
).Write(w, http.StatusBadRequest)
return
}
// Confidential authorization-code clients authenticate with their secret,
// using the same credential sources as the service grant. A public client
// must not be able to present a secret and be treated as authenticated.
if client.ClientType == "confidential" {
presentedID, secret, ok := basicClientCredentials(r)
if !ok || presentedID != clientID || client.ClientSecret == "" ||
!secretsEqual(secret, client.ClientSecret) {
profileerrors.InvalidProfileUsage(
"client authentication failed",
"Authorization",
).Write(w, http.StatusUnauthorized)
return
}
}
// 3. Consume the PKCE session. Single-use and atomic: see
// SessionStore.Consume (KEY-WP-0016-T01).
sess, ok := h.Sessions.Consume(code)
if !ok {
profileerrors.InvalidProfileUsage(
"authorization code not found or expired",
"code",
).Write(w, http.StatusBadRequest)
return
}
// Verify client_id matches the session.
if sess.ClientID != clientID {
profileerrors.InvalidProfileUsage(
"client_id does not match the authorization code",
"client_id",
).Write(w, http.StatusBadRequest)
return
}
// Bind the exchange to the redirect URI the code was issued for
// (RFC 6749 section 4.1.3, KEY-WP-0016-T02). /authorize always records an
// exactly-matched registered redirect, so the parameter is always required
// here and must be identical.
if redirectURI := r.FormValue("redirect_uri"); redirectURI != sess.RedirectURI {
profileerrors.InvalidProfileUsage(
"redirect_uri does not match the authorization request",
"redirect_uri",
).Write(w, http.StatusBadRequest)
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(
"code_verifier does not match code_challenge",
"code_verifier",
).Write(w, http.StatusBadRequest)
return
}
// 5. Look up user.
user, err := h.Users.LookupUser(ctx, sess.Username)
if err != nil {
http.Error(w, "user not found", http.StatusInternalServerError)
return
}
if isSuspended(user) {
profileerrors.RejectedForSafety(
"account is suspended",
"account_lifecycle",
).Write(w, http.StatusForbidden)
return
}
// 6. Build JWT claims.
now := time.Now()
exp := now.Add(h.TokenLifetime)
claims := map[string]interface{}{
"iss": h.Issuer,
"sub": user.ID,
"aud": clientID,
"exp": exp.Unix(),
"iat": now.Unix(),
}
if sess.Nonce != "" {
claims["nonce"] = sess.Nonce
}
scopeSet := make(map[string]bool)
for _, s := range sess.Scopes {
scopeSet[s] = true
}
if scopeSet["profile"] {
claims["preferred_username"] = user.Username
}
if scopeSet["email"] {
claims["email"] = user.Email
}
// Core claims required by net-kingdom/canon/standards/iam-profile_v0.3.md
// for every production token -- not scope-gated, unlike the recommended
// human claims above (KEY-WP-0005-T01).
tenant, tenantSource, err := humanTenant(h.ClientConfig[clientID], user)
if err != nil {
profileerrors.RejectedForSafety(
"tenant binding conflict",
"tenant_binding",
).Write(w, http.StatusForbidden)
return
}
claims["tenant"] = tenant
claims["tenant_source"] = tenantSource
claims["principal_type"] = "human"
claims["groups"] = nonNilStrings(user.Groups)
claims["roles"] = nonNilStrings(user.Roles)
claims["assurance"] = assuranceClaim(sess.MFAVerified, sess.AuthTime, now)
// Optional cached tenant_roles claim (KEY-WP-0005-T02). Fails open --
// see internal/adapters/tenantengine's package doc for why this is the
// one place in the whole tenant_roles design where that's correct.
if roles, ok := h.TenantEngine.Roles(ctx, tenant); ok {
claims["tenant_roles"] = roles
}
// 7. Sign JWT with RSA-SHA256.
kid := "key-1" // static kid for v0.1
jwtToken, err := buildJWT(claims, kid, h.SigningKey)
if err != nil {
http.Error(w, "failed to build JWT", http.StatusInternalServerError)
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. Build response. The session was already consumed at lookup, so no
// separate replay-prevention delete is needed here.
resp := tokenResponse{
AccessToken: accessToken,
TokenType: "Bearer",
ExpiresIn: int(h.TokenLifetime.Seconds()),
IDToken: jwtToken,
}
// 10. Emit token_issued telemetry.
h.Emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now(),
EventType: telemetry.EventTokenIssued,
ClientID: clientID,
Endpoint: "/token",
Result: "success",
Scopes: sess.Scopes,
GrantType: grantType,
})
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(resp)
}
// basicClientCredentials reads client_secret_basic credentials, applying the
// form-encoding decode RFC 6749 appendix B requires of both halves. Shared by
// the service grant and confidential authorization-code client authentication.
func basicClientCredentials(r *http.Request) (clientID, clientSecret string, ok bool) {
clientID, clientSecret, ok = r.BasicAuth()
if !ok {
return "", "", false
}
decodedID, idErr := url.QueryUnescape(clientID)
decodedSecret, secretErr := url.QueryUnescape(clientSecret)
if idErr != nil || secretErr != nil {
return "", "", false
}
return decodedID, decodedSecret, true
}
// secretsEqual compares two secrets in constant time. Digesting first keeps the
// comparison length-independent, so a wrong-length secret is indistinguishable
// from a wrong-value one.
func secretsEqual(presented, expected string) bool {
presentedDigest := sha256.Sum256([]byte(presented))
expectedDigest := sha256.Sum256([]byte(expected))
return subtle.ConstantTimeCompare(presentedDigest[:], expectedDigest[:]) == 1
}
func (h *TokenHandler) serveClientCredentials(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
clientID, clientSecret, ok := r.BasicAuth()
if !ok {
profileerrors.InvalidProfileUsage("client_secret_basic authentication required", "Authorization").
Write(w, http.StatusUnauthorized)
return
}
clientID, idErr := url.QueryUnescape(clientID)
clientSecret, secretErr := url.QueryUnescape(clientSecret)
if idErr != nil || secretErr != nil {
profileerrors.InvalidProfileUsage("invalid client authentication encoding", "Authorization").Write(w, http.StatusUnauthorized)
return
}
client, ok := h.ClientConfig[clientID]
if !ok || client.ClientType != "confidential" || !containsString(client.GrantTypes, "client_credentials") {
profileerrors.InvalidProfileUsage("invalid confidential client", "client_id").
Write(w, http.StatusUnauthorized)
return
}
presentedDigest := sha256.Sum256([]byte(clientSecret))
expectedDigest := sha256.Sum256([]byte(client.ClientSecret))
if client.ClientSecret == "" ||
subtle.ConstantTimeCompare(presentedDigest[:], expectedDigest[:]) != 1 {
profileerrors.InvalidProfileUsage("invalid client authentication", "Authorization").
Write(w, http.StatusUnauthorized)
return
}
requestedScopes := strings.Fields(r.FormValue("scope"))
if len(requestedScopes) == 0 {
requestedScopes = append([]string(nil), client.AllowedScopes...)
}
for _, scope := range requestedScopes {
if !containsString(client.AllowedScopes, scope) {
profileerrors.InvalidProfileUsage("requested scope is not allowed", "scope").
Write(w, http.StatusBadRequest)
return
}
}
now := time.Now()
tokenLifetime := h.TokenLifetime
if client.TokenLifetime > 0 {
tokenLifetime = client.TokenLifetime
}
claims := map[string]interface{}{
"iss": h.Issuer,
"sub": client.ServiceSubject,
"aud": accessAudience(client),
"exp": now.Add(tokenLifetime).Unix(),
"iat": now.Unix(),
"tenant": client.Tenant,
// A service client's tenant is always registration-supplied: there is no
// directory principal behind it to assert one (GH-DEC-2026-013 §5).
"tenant_source": TenantSourceRegistration,
"principal_type": "service",
"groups": []string{},
"roles": nonNilStrings(client.Roles),
"scope": strings.Join(requestedScopes, " "),
"assurance": map[string]interface{}{
"level": "aal1", "methods": []string{"client_secret"},
"mfa": false, "source": "key-cape", "at": now.Unix(),
},
}
if roles, ok := h.TenantEngine.Roles(ctx, client.Tenant); ok {
claims["tenant_roles"] = roles
}
jwtToken, err := buildJWT(claims, "key-1", h.SigningKey)
if err != nil {
http.Error(w, "failed to build JWT", http.StatusInternalServerError)
return
}
h.Emitter.Emit(ctx, telemetry.Event{
Timestamp: now, EventType: telemetry.EventTokenIssued, ClientID: clientID,
Endpoint: "/token", Result: "success", Scopes: requestedScopes,
GrantType: "client_credentials",
})
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(tokenResponse{
AccessToken: jwtToken, TokenType: "Bearer",
ExpiresIn: int(tokenLifetime.Seconds()),
})
}
func containsString(values []string, wanted string) bool {
for _, value := range values {
if value == wanted {
return true
}
}
return false
}
func isSuspended(user *domain.User) bool {
return containsString(user.Groups, "netkingdom-suspended")
}
// ---------------------------------------------------------------------------
// IAM Profile core claims (KEY-WP-0005-T01)
// ---------------------------------------------------------------------------
// defaultTenant is the fallback tenant claim for users with no explicit
// Tenant assignment yet. This workstation currently operates a single
// tenant (tenant:coulomb, ADR-0006); later tenants (e.g. tenant:friendly:binky,
// ADR-0013) require an explicit domain.User.Tenant value -- this default
// never silently assigns a user to a tenant other than the platform's
// original one.
const defaultTenant = "tenant:coulomb"
// effectiveTenant resolves the tenant claim for a user, falling back to
// defaultTenant when the user has no explicit tenant assignment. The IAM
// Profile requires a non-empty tenant claim on every token.
func effectiveTenant(user *domain.User) string {
if user.Tenant != "" {
return user.Tenant
}
return defaultTenant
}
// humanTenant resolves the tenant claim for a human token (KEY-WP-0013-T05).
//
// A human's tenant is normally a property of the person, read from the
// directory. But the approval chain is bound to the landlord zone by decision
// 5ed3fb35-eca9-413a-82b9-95171ba85bf6, and approval-engine compares the claim
// by exact string equality, so an approver client has to be able to state the
// zone it issues into. Without this every human token fell back to
// defaultTenant and would have been refused downstream -- as a failed approval
// rather than as a registration defect.
//
// The rule is deliberately not an override:
//
// - no client tenant declared -> the directory answer, unchanged;
// - declared, user unassigned -> the declared zone;
// - declared and equal -> agreement, no ambiguity;
// - declared and different -> refuse to issue.
//
// So a registration can bind a zone for users the directory has not placed, and
// can never relabel a user the directory HAS placed into a different one. That
// last case fails closed rather than picking a winner, because either answer
// would be a silent cross-tenant assertion. It also means this stays correct if
// the directory later populates Tenant: the same code turns from supplying the
// zone into enforcing agreement with it, with no second migration.
//
// This is only safe because client registrations are static and
// deployment-owned; KeyCape excludes dynamic client registration by design. A
// self-service client that could name its users' tenant would be an escalation.
func humanTenant(client *domain.Client, user *domain.User) (string, string, error) {
if client == nil || client.Tenant == "" {
if user.Tenant != "" {
return user.Tenant, TenantSourceDirectory, nil
}
return defaultTenant, TenantSourceDefault, nil
}
if user.Tenant != "" {
if user.Tenant != client.Tenant {
return "", "", fmt.Errorf("client %q binds tenant %q but the directory assigns this user a different tenant", client.ClientID, client.Tenant)
}
// Agreement: the directory did assert this about the person, so the
// stronger provenance is the true one.
return user.Tenant, TenantSourceDirectory, nil
}
return client.Tenant, TenantSourceRegistration, nil
}
// Tenant provenance values for the tenant_source claim (GH-DEC-2026-013 §5).
//
// A bare tenant string cannot tell a consumer whether the identity layer
// asserted the zone about this PERSON or a registration supplied it about the
// CLIENT they came through. approval-engine admits an approver by exact-matching
// that string while its contract reads as though it relies on the first, so the
// check is sound and the property a reader infers from it is absent. The ruling
// requires the claim to carry its provenance and forbids a consumer treating the
// two as equivalent for any decision turning on a fact about the person. It names
// the property; the field is ours.
//
// Three values, not the two the ruling names, and the third is the point. A
// tenant nobody asserted -- neither directory nor registration, just the
// profile's non-empty default -- is not directory-asserted, and labelling it so
// would reintroduce the same defect one level down: a consumer would read
// "directory" for a fact the directory never stated. That is the unknown-versus-
// absent distinction the ruling cites from GH-DEC-2026-011 §3, applied to our own
// fallback rather than only to the case we were asked about.
const (
// TenantSourceDirectory: the identity layer asserted this zone about this
// person. Includes the agreement case, where a registration declared the
// same zone the directory did -- the directory still asserted it.
TenantSourceDirectory = "directory"
// TenantSourceRegistration: supplied by the client registration for a person
// the directory has placed nowhere. A fact about the client, not the person.
TenantSourceRegistration = "registration"
// TenantSourceDefault: nobody asserted a zone; this is the profile default,
// emitted because the profile requires a non-empty tenant. Weaker than both.
TenantSourceDefault = "default"
)
// nonNilStrings returns s, or an empty (non-nil) slice if s is nil, so the
// claim always serializes as `[]`, never `null` -- the profile requires
// groups/roles to be present, "possibly empty", not absent.
func nonNilStrings(s []string) []string {
if s == nil {
return []string{}
}
return s
}
// assuranceClaim builds the profile's `assurance` object from whether MFA
// was actually verified during this authorization (session.MFAVerified),
// not from static enrollment state -- a user who has MFA enrolled but
// wasn't challenged for it in this particular flow gets aal1, not aal2.
//
// `at` is the time the user authenticated, not the time this token was
// minted. Those differ whenever a browser session is reused, and the gap is
// the whole point: approval-engine stores this object verbatim as the only
// downstream record that MFA occurred (KEY-WP-0013-T05), so mint time would
// overstate how recently the person proved anything. issuedAt is the
// fallback for a session predating this field.
func assuranceClaim(mfaVerified bool, authTime, issuedAt time.Time) map[string]interface{} {
at := authTime
if at.IsZero() {
at = issuedAt
}
level := "aal1"
methods := []string{"pwd"}
if mfaVerified {
level = "aal2"
methods = append(methods, "otp")
}
return map[string]interface{}{
"level": level,
"methods": methods,
"mfa": mfaVerified,
"source": "key-cape",
"at": at.Unix(),
}
}
// ---------------------------------------------------------------------------
// PKCE verification
// ---------------------------------------------------------------------------
// verifyPKCE checks BASE64URL(SHA256(verifier)) == challenge (S256 method).
func verifyPKCE(verifier, challenge string) bool {
h := sha256.New()
h.Write([]byte(verifier))
computed := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
return computed == challenge
}
// ---------------------------------------------------------------------------
// JWT construction (stdlib only — no external JWT library)
// ---------------------------------------------------------------------------
type jwtHeader struct {
Alg string `json:"alg"`
Typ string `json:"typ"`
Kid string `json:"kid"`
}
// buildJWT constructs and signs a JWT using RSA-SHA256 with the standard library.
// Format: base64url(header) + "." + base64url(payload) + "." + base64url(signature)
func buildJWT(claims map[string]interface{}, kid string, key *rsa.PrivateKey) (string, error) {
// Header.
hdr := jwtHeader{Alg: "RS256", Typ: "JWT", Kid: kid}
hdrJSON, err := json.Marshal(hdr)
if err != nil {
return "", err
}
hdrB64 := base64.RawURLEncoding.EncodeToString(hdrJSON)
// Payload.
payloadJSON, err := json.Marshal(claims)
if err != nil {
return "", err
}
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON)
// Signing input.
signingInput := hdrB64 + "." + payloadB64
// Digest.
digest := sha256.Sum256([]byte(signingInput))
// Sign with PKCS1v15 / SHA256.
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
if err != nil {
return "", err
}
sigB64 := base64.RawURLEncoding.EncodeToString(sig)
return strings.Join([]string{hdrB64, payloadB64, sigB64}, "."), nil
}
// accessAudience is configured by the issuer, never selected by request input.
func accessAudience(client *domain.Client) string {
if client.Audience != "" {
return client.Audience
}
return client.ClientID
}

View file

@ -0,0 +1,262 @@
package oidc
import (
"context"
"crypto"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"keycape/internal/domain"
"keycape/internal/server/telemetry"
)
// UserinfoHandler implements GET /userinfo (OIDC Core §5.3).
//
// The endpoint validates the Bearer token, extracts the subject, looks up
// the user, and returns claims that are consistent with those in the ID token
// for the same scope set.
type UserinfoHandler struct {
Users domain.UserRepository
SigningKey *rsa.PublicKey // used to verify the incoming access token
Issuer string
Emitter telemetry.Emitter
}
// ServeHTTP handles GET /userinfo.
func (h *UserinfoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// 1. Extract Bearer token.
tokenStr, ok := bearerToken(r)
if !ok {
http.Error(w, `{"error":"missing_token","description":"Authorization: Bearer <token> required"}`, http.StatusUnauthorized)
return
}
// 2. Validate token (algorithm, signature, expiry, issuer, purpose) and
// extract claims.
claims, err := validateAccessToken(tokenStr, h.SigningKey, h.Issuer)
if err != nil {
http.Error(w, `{"error":"invalid_token","description":"token validation failed"}`, http.StatusUnauthorized)
return
}
// 3. Extract sub claim (which is the username in our model).
sub, _ := claims["sub"].(string)
if sub == "" {
http.Error(w, `{"error":"invalid_token","description":"missing sub claim"}`, http.StatusUnauthorized)
return
}
// 4. Resolve the canonical subject. Human tokens use the stable directory
// ID (the LDAP DN) as sub, while LookupUser accepts an LDAP uid. Prefer the
// scoped username hint but require it to resolve back to the signed subject;
// fall back to an ID scan for openid-only tokens and renamed users.
user, err := lookupUserBySubject(ctx, h.Users, claims, sub)
if err != nil {
// User referenced in token but not found → treat as invalid token.
http.Error(w, `{"error":"invalid_token","description":"subject not found"}`, http.StatusUnauthorized)
return
}
if isSuspended(user) {
http.Error(w, `{"error":"invalid_token","description":"subject is suspended"}`, http.StatusUnauthorized)
return
}
// 5. Build response claims filtered by the scopes embedded in the token.
scopeStr, _ := claims["scope"].(string)
scopeSet := parseScopeSet(scopeStr)
resp := map[string]interface{}{
"sub": sub,
}
if scopeSet["profile"] {
resp["preferred_username"] = user.Username
resp["name"] = user.DisplayName
}
if scopeSet["email"] {
resp["email"] = user.Email
}
if scopeSet["groups"] {
resp["groups"] = user.Groups
}
// 6. Emit telemetry.
h.Emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now(),
EventType: telemetry.EventAuthSuccess,
Endpoint: "/userinfo",
Result: "success",
})
// 7. Write JSON response.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(resp)
}
func lookupUserBySubject(
ctx context.Context,
users domain.UserRepository,
claims map[string]interface{},
sub string,
) (*domain.User, error) {
if username, _ := claims["preferred_username"].(string); username != "" {
if user, err := users.LookupUser(ctx, username); err == nil && user.ID == sub {
return user, nil
}
}
// Retain compatibility with older signed tokens whose subject was the uid.
if user, err := users.LookupUser(ctx, sub); err == nil &&
(user.ID == sub || user.Username == sub) {
return user, nil
}
all, err := users.ListUsers(ctx)
if err != nil {
return nil, err
}
for i := range all {
if all[i].ID == sub {
return &all[i], nil
}
}
return nil, domain.ErrUserNotFound
}
// ---------------------------------------------------------------------------
// JWT validation (stdlib only — no external JWT library)
// ---------------------------------------------------------------------------
// validateAccessToken parses and validates a KeyCape-issued access token.
//
// Beyond signature and expiry it enforces the bindings the caller CLI already
// requires (KEY-WP-0016-T03): the JOSE header algorithm must be exactly RS256,
// so an "alg":"none" or HMAC-shaped token can never bypass the RSA check; the
// issuer claim must equal this issuer, so a correctly-signed token from another
// deployment is refused; and the token must be an access token.
//
// Purpose is decided on the presence of the `scope` claim, which the token
// endpoint sets on access tokens and never on ID tokens. That keeps the check
// verification-side: it does not add a claim to the issued token contract, which
// consumers pin exactly.
func validateAccessToken(tokenStr string, pubKey *rsa.PublicKey, issuer string) (map[string]interface{}, error) {
parts := strings.Split(tokenStr, ".")
if len(parts) != 3 {
return nil, errors.New("malformed JWT: expected 3 parts")
}
// Verify the JOSE header algorithm before trusting the signature check.
headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, errors.New("malformed JWT: invalid header encoding")
}
var header struct {
Alg string `json:"alg"`
}
if err := json.Unmarshal(headerJSON, &header); err != nil {
return nil, errors.New("malformed JWT: header is not valid JSON")
}
if header.Alg != "RS256" {
return nil, errors.New("unsupported JWT algorithm")
}
// Verify signature over header.payload.
signingInput := parts[0] + "." + parts[1]
digest := sha256.Sum256([]byte(signingInput))
sigBytes, decodeErr := base64.RawURLEncoding.DecodeString(parts[2])
if decodeErr != nil {
return nil, errors.New("malformed JWT: invalid signature encoding")
}
if err := rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, digest[:], sigBytes); err != nil {
return nil, errors.New("JWT signature verification failed")
}
// Decode payload.
payloadJSON, payloadErr := base64.RawURLEncoding.DecodeString(parts[1])
if payloadErr != nil {
return nil, errors.New("malformed JWT: invalid payload encoding")
}
var claims map[string]interface{}
if err := json.Unmarshal(payloadJSON, &claims); err != nil {
return nil, errors.New("malformed JWT: payload is not valid JSON")
}
// Check exp claim.
exp, ok := claims["exp"].(float64)
if !ok {
return nil, errors.New("JWT missing exp claim")
}
if time.Now().Unix() > int64(exp) {
return nil, errors.New("JWT has expired")
}
// Require this issuer. An empty configured issuer would make the check
// vacuous, so treat that as a misconfiguration rather than skipping it.
if issuer == "" {
return nil, errors.New("issuer is not configured")
}
if tokenIssuer, _ := claims["iss"].(string); tokenIssuer != issuer {
return nil, errors.New("JWT issuer mismatch")
}
// Require an access token. ID tokens carry no scope claim.
if _, ok := claims["scope"]; !ok {
return nil, errors.New("JWT is not an access token")
}
return claims, nil
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// bearerToken extracts the token from the Authorization header.
// Returns ("", false) when the header is missing or not a Bearer token.
func bearerToken(r *http.Request) (string, bool) {
hdr := r.Header.Get("Authorization")
if hdr == "" {
return "", false
}
const prefix = "Bearer "
if !strings.HasPrefix(hdr, prefix) {
return "", false
}
tok := strings.TrimSpace(hdr[len(prefix):])
if tok == "" {
return "", false
}
return tok, true
}
// parseScopeSet converts a space-separated scope string to a set.
func parseScopeSet(scope string) map[string]bool {
set := make(map[string]bool)
for _, s := range strings.Fields(scope) {
set[s] = true
}
return set
}
// ---------------------------------------------------------------------------
// BuildJWT — exported for test helpers
// ---------------------------------------------------------------------------
// BuildJWT is an exported wrapper around the internal buildJWT function so
// that tests in the oidc_test package can construct valid tokens for the
// UserinfoHandler without importing an external JWT library.
func BuildJWT(claims map[string]interface{}, kid string, key *rsa.PrivateKey) (string, error) {
return buildJWT(claims, kid, key)
}

View file

@ -0,0 +1,100 @@
---
id: ADHOC-2026-09-27
type: workplan
title: "Fresh MFA no longer backdated to a stale kc_login cookie (INFD-IN-0005)"
domain: infotech
repo: key-cape
status: finished
owner: claude
created: "2026-09-27"
updated: "2026-09-27"
state_hub_workstream_id: "5217dd06-73cb-50f8-ba64-528e421b29de"
---
## Stop completeAuthorization from reusing a stale prior session's IssuedAt after real MFA
```task
id: ADHOC-2026-09-27-T01
status: done
priority: high
state_hub_task_id: "ce98e4af-cfaa-5620-8f21-15ade18ee9e8"
```
`informed-decision` intake `INFD-IN-0005` (open, `origin_ref: SECRETS-WP-0010`):
platform-root's approval of `infd-20260927-b01` was refused by flex-auth's
`compact_sitting` policy with `policy_denied`, despite platform-root having just
completed a fresh key-cape MFA sign-in. flex-auth's 900-second review-freshness
check reads `assurance.at`, which key-cape derives from the PKCE session's
`AuthTime`.
Root cause in `src/internal/server/oidc/authorize.go`,
`AuthorizeHandler.completeAuthorization`: whenever a `kc_login` cookie already
existed for the same username, its `IssuedAt` was unconditionally reused as
`authTime` — even on the two call sites that had just called
`MFA.ValidateMFAToken` successfully in this exact request (the callback's
`mfaToken != ""` branch and `serveMFASubmission`). A user who had an older
browser session and then stepped up with a fresh MFA challenge got the *old*
session's timestamp stamped onto the *new*, just-proven authentication —
understating freshness for every downstream binding-grade check, exactly as
`INFD-IN-0005` describes ("completeAuthorization currently copies a prior
timestamp... only after actual reauthentication").
The reuse itself is correct when MFA was **not** just performed in this
request (`decision.MFAVerified` carried forward from an existing AAL2 session,
via `domain.DecideAssurance`) — reporting mint time there would overstate
freshness (`TestAssuranceReportsAuthenticationTimeNotMintTime`). The two cases
were conflated under a single `mfaVerified` bool.
Fix: added a `freshMFA bool` parameter to `completeAuthorization`, `true` only
at the two call sites that just validated an MFA token in this request, `false`
at the carried-forward-assurance call site. `authTime` now falls back to the
prior session's `IssuedAt` only when `!freshMFA`.
Added `TestAuthorizeCallback_MFASubmission_FreshEvenWithStalePriorLoginCookie`,
which seeds a 4-hour-old `kc_login` cookie for the same user, then submits a
valid MFA token and asserts the resulting `AuthTime` is within a minute of now.
Confirmed it fails against the pre-fix code (`AuthTime` ~4h old) and passes
after. Full suite (`go test ./...`) green, no other test needed adjustment —
the carried-forward-assurance case (`TestPolicy_...`, `TestLogout_...`,
`assurance_time_test.go`) is unaffected since none of it exercises a fresh MFA
submission with a pre-existing cookie for the same user.
Does not touch flex-auth (its 900s window is correct; it was consuming a bad
timestamp) or informed-decision (correctly refuses to interpret the PDP's
denial). `INFD-IN-0005` can close once informed-decision confirms platform-root
can approve after a fresh sign-in against this build.
## Discovery/JWKS fetch was missing the split-horizon origin headers
```task
id: ADHOC-2026-09-27-T02
status: done
priority: high
```
Rolling out T01 (image digest `sha256:248e449a...`) broke every KeyCape login,
including unrelated clients (`user-engine-portal`), with `auth_failure` /
`provider_metadata_unavailable` at `/api/oidc/token` on the KeyCape side and
Authelia logging `invalid X-Forwarded-Proto header value 'http'` on
`GET /.well-known/openid-configuration` from KeyCape's pod IP.
Root cause, unrelated to T01's change: `AutheliaAdapter.setPublicOriginHeaders`
(the NK-WP-0041-T02/path-B fix, key-cape@3b0446e) annotates only the token
exchange (`newTokenRequest`) with `X-Forwarded-Proto`/`X-Forwarded-Host` so
Authelia can derive the public issuer for a split-horizon (in-cluster HTTP,
public HTTPS) deployment. `idTokenVerifier.get` in `verify.go` — used for the
provider-metadata and JWKS fetches that verify every upstream ID token — never
got the same treatment; it built a bare request with only an `Accept` header.
Authelia 4.38 fails closed on the resulting unmarked in-cluster request. This
gap predates today's session (present since 3b0446e); it surfaced now because
this was the first login attempted since that image went live.
Fix: extracted `setPublicOriginHeaders` from an `AutheliaAdapter` method to a
package-level function taking the target public base URL explicitly, and gave
`idTokenVerifier` its own resolved `publicBaseURL` (same
`BrowserBaseURL`-else-`BaseURL` fallback as the adapter) so `get()` applies
the same headers. Added `TestDiscoveryFetch_CarriesPublicOriginHeaders`
(confirmed it fails without the fix, passes with it). Full suite green.
New image `key-cape@<pending>` still needs a rollout to `sso-mfa/k8s/keycape/deployment.yaml`
before platform-root can sign in again.

View file

@ -0,0 +1,30 @@
"""Restore only this turn's generated staged brief after successful sync."""
from pathlib import Path
import hashlib
import json
import subprocess
HERE = Path(__file__).resolve().parent
ROOT = Path('/home/worsch/key-cape')
before = json.loads((HERE / 'owner-consistency/before.json').read_text())
backup = HERE / 'owner-publication/publication-backup'
row = next(item for item in json.loads((backup / 'originals.json').read_text()) if item['path'] == '.custodian-brief.md')
def git(*args):
return subprocess.check_output(['git', '-C', str(ROOT), *args], text=True).strip()
assert json.loads((HERE / 'owner-publication/projection.json').read_text())['status'] == 'applied'
assert (ROOT / row['path']).read_bytes() == (backup / (row['path'] + '.worktree')).read_bytes()
assert git('ls-files', '--stage', '--', row['path']) == row['index']
original = subprocess.check_output(['git', '-C', str(ROOT), 'show', before['head'] + ':' + row['path']])
initial = before['tracked'][row['path']]
assert 'sha256:' + hashlib.sha256(original).hexdigest() == initial['sha256']
mode, oid, stage, name = initial['index'].split()
assert stage == '0' and name == row['path'] and not before['tracked_status']
(ROOT / row['path']).write_bytes(original)
subprocess.run(['git', '-C', str(ROOT), 'update-index', '--cacheinfo', mode, oid, name], check=True)
assert git('ls-files', '--stage', '--', name) == initial['index']
assert 'sha256:' + hashlib.sha256((ROOT / name).read_bytes()).hexdigest() == initial['sha256']
receipt = {'restored': name, 'scope': 'only this turn generated it; initial owner tracked state was clean',
'original_owner_worktree_and_index_preserved': True, 'source_commit_unchanged': git('rev-parse', 'HEAD'),
'tracked_status': git('status', '--porcelain=v1', '--untracked-files=no')}
(HERE / 'owner-generated-cleanup.json').write_text(json.dumps(receipt, indent=2) + '\n')
print(json.dumps(receipt))

View file

@ -0,0 +1,72 @@
"""Run issuer source checks with no host network, HOME, or credentials."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import subprocess
import time
HERE = Path(__file__).resolve().parent
parser = argparse.ArgumentParser()
parser.add_argument("mode", choices=["focused", "full", "build", "binary"])
parser.add_argument("--attempt", type=int, default=1)
parser.add_argument("--source", type=Path, default=Path("/tmp/custodian-keycape-token-status-r1/source"))
parser.add_argument("--cache-from", type=Path)
args = parser.parse_args()
source = args.source.resolve()
assert source.is_relative_to("/tmp") and (source / "src/go.mod").is_file()
workspace = source.parent
(workspace / "home").mkdir(exist_ok=True)
toolchain = Path("/home/worsch/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.26.7.linux-amd64")
argv = ["bwrap", "--unshare-net", "--unshare-pid", "--die-with-parent", "--new-session"]
for name in ["/usr", "/lib", "/lib64"]:
argv += ["--ro-bind", name, name]
argv += ["--symlink", "usr/bin", "/bin", "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp",
"--bind", str(workspace), "/work", "--ro-bind", "/home/worsch/go/pkg/mod", "/modules",
"--ro-bind", str(toolchain), "/toolchain", "--chdir", "/work/" + source.name + "/src", "--clearenv"]
if args.mode == "binary":
argv += ["--ro-bind", str(HERE / 'binary_fixture.py'), "/fixture.py"]
if args.cache_from:
cache = args.cache_from.resolve()
assert cache.is_relative_to('/tmp') and cache.is_dir()
argv += ['--bind', str(cache), '/work/gocache']
for key, value in {"PATH": "/toolchain/bin:/usr/bin:/bin", "HOME": "/work/home", "GOTOOLCHAIN": "local",
"GOPROXY": "off", "GOSUMDB": "off", "GOMODCACHE": "/modules", "GOCACHE": "/work/gocache",
"GOMAXPROCS": "4", "CGO_ENABLED": "1"}.items():
argv += ["--setenv", key, value]
if args.mode == "binary":
command = ["python3", "/fixture.py"]
elif args.mode == "build":
command = ["go", "build", "-o", "/work/keycape-candidate", "./cmd/keycape"]
else:
command = ["go", "test", "-p", "4", "-race", "-json", "-count=1"]
command += ["-run", "^TestTokenStatus", "./internal/server/oidc", "./internal/config"] if args.mode == "focused" else ["./..."]
argv += ["--", *command]
prefix = HERE / f"{args.mode}-attempt{args.attempt}"
assert not prefix.with_suffix(".json").exists()
start = time.monotonic()
source_inventory = {str(p.relative_to(source)): hashlib.sha256(p.read_bytes()).hexdigest()
for p in (source / 'src').rglob('*') if p.is_file()}
with prefix.with_suffix(".jsonl").open("xb") as output:
result = subprocess.run(argv, stdout=output, stderr=subprocess.STDOUT, timeout=900)
counts = {"pass": 0, "fail": 0, "skip": 0}
failures, skips = [], []
for line in prefix.with_suffix(".jsonl").read_text().splitlines():
try:
event = json.loads(line)
except ValueError:
continue
if event.get("Test") and event.get("Action") in counts:
action = event["Action"]
counts[action] += 1
if action == "fail": failures.append(event["Package"] + ":" + event["Test"])
if action == "skip": skips.append(event["Package"] + ":" + event["Test"])
receipt = {"returncode": result.returncode, "seconds": round(time.monotonic() - start, 2), "argv": argv,
"source_sha256_at_start": source_inventory,
"counts_including_subtests": counts, "failures": failures, "skips": skips,
"source": str(source), "native_acceptance": False, "credentials_accessed": False,
"network": "private empty namespace; fixture loopback only", "home": "new empty sandbox HOME"}
prefix.with_suffix(".json").write_text(json.dumps(receipt, indent=2) + "\n")
print(json.dumps({k: receipt[k] for k in ["returncode", "seconds", "counts_including_subtests", "failures", "skips"]}))
raise SystemExit(result.returncode)

View file

@ -0,0 +1,5 @@
{
"full_race_test_source_matches_built_and_binary_test_source": true,
"source_files": 115,
"binary_sha256": "sha256:bc1369cf122133642180a9fa3cf92b93e0332f639c4e218d5041af959c77c92b"
}

View file

@ -0,0 +1,18 @@
{
"workplan_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52",
"id": "1a76c40a-bdfe-4bc4-b81d-526ee12105c9",
"topic_id": null,
"title": "Prepare online access-token status in the existing issuer; retain independent operational admission",
"description": "Source preparation under the existing full CUST-WP-0080 implementation goal extends KeyCape with an explicit opt-in RFC 7662/7009 profile, rather than duplicating a token registry in Forgejo/Warden. KEY-WP-0042 is proposed; issuer/profile owner assent and native admission remain open. Existing Access/Approval/Warden/Apps operation, effect and cleanup authority is unchanged. Restore the historical KeyCape task file reference only after exact parent/record/title/status/description correspondence with its already-existing Hub binding; create no replacement identity.",
"decision_type": "made",
"status": "resolved",
"rationale": "Actual owner contracts establish that legacy JWT expiry and an injected evidence callback do not supply current revocation. A single producer facility preserves ownership and standard transport contracts. Bounded source, race, HTTPS and actual-process proofs are retained; process-local restart invalidation and offline-consumer limits are explicit. This record authorizes no live credential, trust, package, policy, or deployment change.",
"decided_by": "codex (authorized source preparation only)",
"decided_at": "2026-10-18T08:16:18.615371Z",
"deadline": null,
"escalation_note": null,
"superseded_by": null,
"created_at": "2026-10-18T08:16:19.437517Z",
"updated_at": "2026-10-18T08:16:19.437517Z",
"workstream_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52"
}

View file

@ -0,0 +1,11 @@
{
"id": "1a76c40a-bdfe-4bc4-b81d-526ee12105c9",
"workplan_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52",
"title": "Prepare online access-token status in the existing issuer; retain independent operational admission",
"description": "Source preparation under the existing full CUST-WP-0080 implementation goal extends KeyCape with an explicit opt-in RFC 7662/7009 profile, rather than duplicating a token registry in Forgejo/Warden. KEY-WP-0042 is proposed; issuer/profile owner assent and native admission remain open. Existing Access/Approval/Warden/Apps operation, effect and cleanup authority is unchanged. Restore the historical KeyCape task file reference only after exact parent/record/title/status/description correspondence with its already-existing Hub binding; create no replacement identity.",
"decision_type": "made",
"status": "resolved",
"rationale": "Actual owner contracts establish that legacy JWT expiry and an injected evidence callback do not supply current revocation. A single producer facility preserves ownership and standard transport contracts. Bounded source, race, HTTPS and actual-process proofs are retained; process-local restart invalidation and offline-consumer limits are explicit. This record authorizes no live credential, trust, package, policy, or deployment change.",
"decided_by": "codex (authorized source preparation only)",
"decided_at": "2026-10-18T08:16:18.615371+00:00"
}

View file

@ -0,0 +1,107 @@
{
"owner": "key-cape",
"base_revision": "git:d803e9282a47b49daf930fb905312fa5104c0ace",
"files": [
{
"path": "SCOPE.md",
"before_sha256": "sha256:20fbadf3e33339bca4b541cdfa33a0de00095c968d51006dc4d0a46735cc834f",
"sha256": "sha256:a80e80ba2e5279ba50232e6b3f57a5bdd14866e584d2d055dfb09e98a4c24ce0"
},
{
"path": "docs/online-token-status.md",
"before_sha256": null,
"sha256": "sha256:f5e41b47500b15c80b2da6cbe37a9e64bcbde1c072750934cf74464a19488925"
},
{
"path": "docs/operations.md",
"before_sha256": "sha256:e68ec32073b7731214308ccd2fceb522d934b00f1d76e6d438e2870030497cfc",
"sha256": "sha256:3659362e3c7f8720e52cc8dc7c67a6295ac26462f383c68c06c120fac6e6e472"
},
{
"path": "src/cmd/keycape/main.go",
"before_sha256": "sha256:ff1ee4f5d2eb86a1e4184374d5244e24704d9d5fd780e173062af07112344afd",
"sha256": "sha256:2f81ea0b0b33f38298e028d5610625e3834fdf1aa12f6f392579d6207bcf6f1d"
},
{
"path": "src/internal/config/config.go",
"before_sha256": "sha256:afd465acd9b6879493b888acc6fc54b9a71e032892701a81f1072986d2c30ac7",
"sha256": "sha256:19cd234757f8908880d28b8b40939367dfab1322e0401e4e563d56cd3af06683"
},
{
"path": "src/internal/config/token_status_test.go",
"before_sha256": null,
"sha256": "sha256:b2b2ec13215c9661d79142ea698cb8b080f6e017de050bedebf3eb8d16df2ad5"
},
{
"path": "src/internal/config/validate.go",
"before_sha256": "sha256:8189ee82e7df74a2a4a1f53e213b1be0a5c737fd76d8f283f7ec9f8ed38c3a65",
"sha256": "sha256:2b1766979b817802f43bdd0f999ac61452d86cdfafc1539a5d6be896b900a405"
},
{
"path": "src/internal/server/oidc/discovery.go",
"before_sha256": "sha256:5ff9ddd9e2f4304b562e116100ca31fe7eabf8315d7d73998d3e6c5bea53956d",
"sha256": "sha256:00e4c831a2b4d29aecf3597a09c943315b87c5834dbcd4912ba5d8d31ab28cd5"
},
{
"path": "src/internal/server/oidc/token.go",
"before_sha256": "sha256:8422cc9dbd39c88dfe835b43f6262cb120bc0d2e5b08898a151167b7555368d3",
"sha256": "sha256:304fa78d79a5436b9174d88991c19c0834356cb5425c6ed369accb85467a402f"
},
{
"path": "src/internal/server/oidc/token_status.go",
"before_sha256": null,
"sha256": "sha256:d9c22a71bb2efad7ea240f2689a98f14768ce3c1519d1d8b4e78ee7af2dc70b3"
},
{
"path": "src/internal/server/oidc/token_status_discovery_test.go",
"before_sha256": null,
"sha256": "sha256:27adb5797afba46ca2b1f16018721f055f82760498088b58b993730be1fa2031"
},
{
"path": "src/internal/server/oidc/token_status_http.go",
"before_sha256": null,
"sha256": "sha256:a7202d3176454f8ab42c090426e2debcb5cc2c1fc089e79746bb78e5c455af97"
},
{
"path": "src/internal/server/oidc/token_status_internal_test.go",
"before_sha256": null,
"sha256": "sha256:3d3ab41987c7586bd53af8b5c36ff6667b5b2a32d59789915eb9a36b7ceeba53"
},
{
"path": "src/internal/server/oidc/token_status_test.go",
"before_sha256": null,
"sha256": "sha256:e2613d44deefcbfbd0ce1db5ce2588849fb4be9b8bed33cb538171cc29f63e90"
},
{
"path": "src/internal/server/oidc/token_status_transport_test.go",
"before_sha256": null,
"sha256": "sha256:3a9945f0292125e86d0014b5245a20c0475d6c518eccff0f9b936356d8e0b347"
},
{
"path": "src/internal/server/oidc/userinfo.go",
"before_sha256": "sha256:bbd5fa9ebd0726371c90d6938536c1ce5fcfacbbe853a80833e3587fc2115a0b",
"sha256": "sha256:f9c986f7a2c79f27a9970a296e6b493014fed77af2f22ba96c4a457d049c916f"
},
{
"path": "workplans/ADHOC-2026-09-27.md",
"before_sha256": "sha256:2794d1940fc3c5e4a2df57de9d4b742fa6aa8602b67f1d3177c0d96bc5aaf314",
"sha256": "sha256:f6220f17fc5b6e7300d0a55ac29f78f385ad89db276ccaa21693428fffa5919b"
},
{
"path": "workplans/KEY-WP-0042-online-access-token-status.md",
"before_sha256": null,
"sha256": "sha256:6d7ff6dea31b251e261195fff97a2061d56579b5786629d95014f6d1cbe0f55e"
}
],
"patch_sha256": "sha256:e3d3bc375a8a0a49eff434483050ae338935391b5b361e64f0880ed992f4c85c",
"replay": {
"fuzz": 0,
"all_postimages_exact": true,
"all_unrelated_archive_files_unchanged": true,
"stdout": "patching file SCOPE.md\npatching file docs/online-token-status.md\npatching file docs/operations.md\npatching file src/cmd/keycape/main.go\npatching file src/internal/config/config.go\npatching file src/internal/config/token_status_test.go\npatching file src/internal/config/validate.go\npatching file src/internal/server/oidc/discovery.go\npatching file src/internal/server/oidc/token.go\npatching file src/internal/server/oidc/token_status.go\npatching file src/internal/server/oidc/token_status_discovery_test.go\npatching file src/internal/server/oidc/token_status_http.go\npatching file src/internal/server/oidc/token_status_internal_test.go\npatching file src/internal/server/oidc/token_status_test.go\npatching file src/internal/server/oidc/token_status_transport_test.go\npatching file src/internal/server/oidc/userinfo.go\npatching file workplans/ADHOC-2026-09-27.md\npatching file workplans/KEY-WP-0042-online-access-token-status.md\n",
"stderr": ""
},
"canonical_owner_modified": false,
"independent_owner_assent": false,
"native_admission": false
}

View file

@ -0,0 +1,36 @@
"""Publish typed public source digests with an exact original-byte derivation."""
import hashlib
import json
from pathlib import Path
HERE = Path(__file__).resolve().parent
records = []
for name, field in [('binary-attempt1.json', 'source_sha256_at_start'),
('focused-attempt2.json', 'source_sha256_at_start'),
('full-source-inventory.json', 'sha256')]:
file = HERE / name
raw = file.read_bytes()
value = json.loads(raw)
source = Path(value['source']).resolve()
assert source.is_relative_to('/tmp') and source.is_dir()
original = dict(value[field])
for relative, digest in original.items():
assert len(digest) == 64 and all(c in '0123456789abcdef' for c in digest)
path = (source / relative).resolve()
assert path.is_relative_to(source)
assert hashlib.sha256(path.read_bytes()).hexdigest() == digest
value[field] = {relative: 'sha256:' + digest for relative, digest in original.items()}
published = (json.dumps(value, indent=2) + '\n').encode()
inverse = json.loads(published)
inverse[field] = {relative: digest.removeprefix('sha256:') for relative, digest in inverse[field].items()}
assert (json.dumps(inverse, indent=2) + '\n').encode() == raw
file.write_bytes(published)
records.append({'file': name, 'field': field, 'digests_checked_against_exact_source': len(original),
'original_bytes_sha256': 'sha256:' + hashlib.sha256(raw).hexdigest(),
'published_bytes_sha256': 'sha256:' + hashlib.sha256(published).hexdigest(),
'inverse_reconstructs_original_bytes': True})
receipt = {'purpose': 'explicit digest type on public source inventories; no value secrecy transformation',
'records': records, 'runtime_or_test_source_changed': False, 'scanner_configuration_changed': False}
(HERE / 'typed-source-digest-derivation.json').write_text(json.dumps(receipt, indent=2) + '\n')
print(json.dumps({'files': len(records), 'verified_source_digests': sum(r['digests_checked_against_exact_source'] for r in records),
'original_bytes_exactly_reconstructable': True}))

View file

@ -0,0 +1,31 @@
{
"purpose": "explicit digest type on public source inventories; no value secrecy transformation",
"records": [
{
"file": "binary-attempt1.json",
"field": "source_sha256_at_start",
"digests_checked_against_exact_source": 115,
"original_bytes_sha256": "sha256:a1feec82fe8cc84c60e545071143acc354dbd5612e823a23884af3c9ff83d858",
"published_bytes_sha256": "sha256:82e9eb100d1f0446d5412467e42833cd9da28278659fcb99078f6436a9707435",
"inverse_reconstructs_original_bytes": true
},
{
"file": "focused-attempt2.json",
"field": "source_sha256_at_start",
"digests_checked_against_exact_source": 115,
"original_bytes_sha256": "sha256:b2792cd084fe03ac3322fb4266caee66480b259f1b8210427c2ca76a8c5bbe09",
"published_bytes_sha256": "sha256:be0b26631fb7e5c595d27f751176abe6ea3f3279167bea6dfd77d37ba5ef7793",
"inverse_reconstructs_original_bytes": true
},
{
"file": "full-source-inventory.json",
"field": "sha256",
"digests_checked_against_exact_source": 115,
"original_bytes_sha256": "sha256:31dad565976de6ba7ed447c4a69b96c86d4c2d9e11033e30d9bc15f93c3399bb",
"published_bytes_sha256": "sha256:9bacac653f28000314e177a63a4a62dc08eda7ab957b5dd9b6b90ed60009ec7e",
"inverse_reconstructs_original_bytes": true
}
],
"runtime_or_test_source_changed": false,
"scanner_configuration_changed": false
}

View file

@ -78,6 +78,39 @@ bootstrap callers and approved exception evidence. Reuse owner work where it
already covers the finding. Record new concrete uncovered implementation work
before closing this task. Never populate secret values or invent owner approval.
2026-10-18 current-token producer continuation: actual owner contracts confirm
that legacy KeyCape JWT validity is expiry-only and Warden's injected session
verifier does not supply a current revocation source. Prepared an explicit,
proposed issuer scope extension in the existing KeyCape service rather than
an application-local token registry. One opt-in bounded hash-only store serves
actual human/service issuance, UserInfo, RFC 7662 introspection and RFC 7009
revocation. Unique access-token identifiers prevent same-second remint/revival;
ID-token and disabled-profile behavior remain unchanged. Online checks bind
current registration and human disposition and recheck after directory lookup.
All 659 complete source race test/subtest results pass, with six existing
native/external skips; all 43 new status results pass without skips. The built
standalone server passes 26 actual-process checks, including revocation, real
restart/predecessor refusal, fresh issuance, capacity and disabled routes. A
separate actual-handler HTTPS PKCE flow passes. Omitting the revoked-state
predicate in a controlled source copy fails seven intended checks. All 18 source/publication
postimages replay exactly from the published owner archive; production is
untouched. Process-local status loss deliberately requires reauthentication;
offline consumers retain their existing token lifetime. These limits are part
of the explicit proposed KEY-WP-0042 contract, not an implicit scope promotion.
Ordinary owner identifier preview exposed a preexisting closed historical task
without its file UUID. The Hub has an exact matching existing binding; normal
consistency audit skips creation in closed workstreams. Its existing helper
restores only that exact already-recorded reference after parent/record/title/
status/body comparison. Normal Repo Manager assigns the four new canonical
proposal records. KeyCape `67a0869c` publishes the 17-path feature and historical
reference restoration, without changing historical status or any existing UUID. Exact source, refusals and process receipts:
`docs/changes/netkingdom-target-2026-10-18/keycape-token-status-r1/`. Independent
issuer/profile assent, native issuer admission and existing NK48/Apps operation,
inverse, ongoing-resource and cleanup bindings remain open. All sixteen target
gates, thirteen gaps and 116 ledger entries remain in scope; none closes here.
2026-10-18 PostgreSQL continuation: the actual Forgejo engine/model schemas and
OAuth configuration types now pass 37 test/subtest results on disposable
PostgreSQL 16.14 across default, explicit-public and named schemas. Separate