Verify issuer-owned current token status and scoped revocation source
This commit is contained in:
parent
83078392c0
commit
90e29dccfc
77 changed files with 16241 additions and 0 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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))
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"status": "passed", "checks": 26, "all_owned_processes_terminal": true}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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']}))
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}))
|
||||
|
|
@ -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"
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WARN: git commit failed for writeback: Generated-only commits rejected: staged
|
||||
|
|
@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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]
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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...)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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]
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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...)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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))
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"full_race_test_source_matches_built_and_binary_test_source": true,
|
||||
"source_files": 115,
|
||||
"binary_sha256": "sha256:bc1369cf122133642180a9fa3cf92b93e0332f639c4e218d5041af959c77c92b"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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}))
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue