From 7dda967c27e800d3b703096cba91ca6cffc6adda Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 8 Sep 2026 11:41:42 +0200 Subject: [PATCH] Establish the live state and find a rollout precondition for G10 G10 waits on custody and platform owners and cannot close from here. What was doable: verify the handoffs actually went out, replace a remembered live state with an observed one, and find out whether main is safe to deploy. The last question found a defect in this repository's own recent work. Handoffs verified independently rather than trusted: all seven messages are in the hub with receipt ids. This gap was reopened once for claimed-but-unsent delivery, so the claim deserved the same scrutiny. Live state read from the cluster read-only: image main-153258b, only the Qonto secret materialized so the approval clients remain unprovisioned, four registered clients, no tenantEngine block. That also corrects an earlier claim of mine -- the deployed config sets userOU explicitly, so the KEY-WP-0023 default fix was never a production issue. The precondition: KEY-WP-0019 discovers the expected issuer from authelia.tokenBaseURL, and the deployed Authelia derives its advertised issuer from the request Host, advertising the in-cluster address to KeyCape and the browser-facing one to browsers. Verification fails closed, so a mismatch breaks every human login and looks like a broken login rather than a misconfiguration. Which value the token carries needs a real login against production to settle and was not determined here. Two mitigations: docs/operations.md documents pinning authelia.issuer and jwksUrl, with the curl that reveals what the provider advertises for a given Host; and the authentication failure event now carries a specific reason, so id_token_issuer_mismatch is distinguishable from a signature failure or an unreachable key set. The browser still learns nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P Assistant: claude-code Assistant-Model: opus Assistant-Process: 713576@bnt-lap001 Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6 --- docs/operations.md | 31 +++++ ...26-09-05-011726-scope-intent-assessment.md | 25 ++++ src/internal/adapters/authelia/adapter.go | 5 +- src/internal/adapters/authelia/verify.go | 63 +++++++++- src/internal/adapters/authelia/verify_test.go | 64 ++++++++++ ...P-0027-rollout-readiness-and-live-state.md | 112 ++++++++++++++++++ 6 files changed, 293 insertions(+), 7 deletions(-) create mode 100644 workplans/KEY-WP-0027-rollout-readiness-and-live-state.md diff --git a/docs/operations.md b/docs/operations.md index 6fb6369..32c0b2b 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -94,6 +94,37 @@ 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 +``` + ## What is not claimed No resource-efficiency or throughput bounds are asserted here. Nothing in this diff --git a/history/2026-09-05-011726-scope-intent-assessment.md b/history/2026-09-05-011726-scope-intent-assessment.md index 315ed01..df886a2 100644 --- a/history/2026-09-05-011726-scope-intent-assessment.md +++ b/history/2026-09-05-011726-scope-intent-assessment.md @@ -464,6 +464,31 @@ register the real callback, deploy and verify the new contracts, reconcile token types at consumer boundaries, and retain handoff receipts. Repo-local source changes cannot alone establish these outcomes. +**Status 2026-09-08 (KEY-WP-0027): still open, and correctly so.** The handoff +hygiene half is discharged: all seven messages are present in the hub with +receipt ids, verified independently rather than taken on trust, since this gap +was reopened once for exactly that reason. No replies yet. + +Live state observed read-only rather than remembered: image `main-153258b`, +only the Qonto secret materialized (so the two approval clients remain +unprovisioned), four registered clients, no `tenantEngine` block. An earlier +claim of mine is corrected there: the deployed config sets `userOU: "ou=people"` +explicitly, so the KEY-WP-0023 default fix was never a production issue. + +**Rollout precondition found in this repository's own work.** Upstream +verification (KEY-WP-0019) discovers the expected issuer from +`authelia.tokenBaseURL`, and the deployed Authelia derives its advertised issuer +from the request Host — in-cluster it advertises +`http://authelia.sso.svc.cluster.local:9091`, browser-facing +`http://auth.coulomb.social`. Verification fails closed, so a mismatch breaks +every human login and presents as a broken login rather than a misconfiguration. +Which value the token carries was not settled, because doing so needs a real +login against production. `docs/operations.md` documents pinning +`authelia.issuer`/`jwksUrl`, and the failure now reports a specific +`id_token_issuer_mismatch` reason so it is diagnosable in seconds. + +The rest is owner work and stays open. + ## Deliberate exclusions are not defects INTENT excludes general-purpose IAM, weakened flows and expanded-mode operations. diff --git a/src/internal/adapters/authelia/adapter.go b/src/internal/adapters/authelia/adapter.go index 1ae1d81..be2ffef 100644 --- a/src/internal/adapters/authelia/adapter.go +++ b/src/internal/adapters/authelia/adapter.go @@ -101,7 +101,10 @@ func (a *AutheliaAdapter) HandleCallback(ctx context.Context, params domain.Call EventType: telemetry.EventAuthFailure, Endpoint: "/api/oidc/token", Result: "failure", - ErrorType: "id_token_verification_error", + // Name which check failed: an issuer mismatch from a provider that + // derives its issuer from the request Host is a misconfiguration, + // not an attack, and is indistinguishable from one without this. + ErrorType: FailureReason(err), }) return nil, domain.ErrAuthFailed } diff --git a/src/internal/adapters/authelia/verify.go b/src/internal/adapters/authelia/verify.go index a393012..93022a2 100644 --- a/src/internal/adapters/authelia/verify.go +++ b/src/internal/adapters/authelia/verify.go @@ -3,6 +3,7 @@ package authelia import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -39,6 +40,56 @@ type idTokenVerifier struct { keys jose.KeySet } +// Verification failure reasons, reported through telemetry so an operator can +// tell a misconfiguration from an attack. They never reach the browser. +// +// The issuer case is worth naming separately: a provider that derives its +// advertised issuer from the request Host advertises one value to KeyCape (which +// fetches server-side) and mints another into tokens (issued for the +// browser-facing host). That fails closed and looks exactly like a broken login +// unless the log says which check failed (KEY-WP-0027). +const ( + ReasonIssuerMismatch = "id_token_issuer_mismatch" + ReasonAudienceMismatch = "id_token_audience_mismatch" + ReasonExpired = "id_token_expired" + ReasonWindow = "id_token_validity_window" + ReasonSignature = "id_token_signature" + ReasonProviderMetadata = "provider_metadata_unavailable" + ReasonKeysUnavailable = "provider_keys_unavailable" + ReasonVerification = "id_token_verification_error" +) + +var ( + errIssuerMismatch = errors.New("authelia: id_token issuer mismatch") + errAudienceMismatch = errors.New("authelia: id_token audience does not include this client") + errExpired = errors.New("authelia: id_token expired") + errWindow = errors.New("authelia: id_token validity window is not sane") +) + +// FailureReason classifies a verification error for telemetry. +func FailureReason(err error) string { + switch { + case err == nil: + return "" + case errors.Is(err, errIssuerMismatch): + return ReasonIssuerMismatch + case errors.Is(err, errAudienceMismatch): + return ReasonAudienceMismatch + case errors.Is(err, errExpired): + return ReasonExpired + case errors.Is(err, errWindow): + return ReasonWindow + case errors.Is(err, jose.ErrVerification): + return ReasonSignature + case strings.Contains(err.Error(), "provider metadata"): + return ReasonProviderMetadata + case strings.Contains(err.Error(), "jwks"): + return ReasonKeysUnavailable + default: + return ReasonVerification + } +} + // leeway absorbs ordinary clock skew between KeyCape and the provider. It is // small on purpose: it is a tolerance for imperfect clocks, not a grace period. const leeway = 30 * time.Second @@ -87,26 +138,26 @@ func (v *idTokenVerifier) Verify(ctx context.Context, idToken string) (map[strin // for us, by the provider we expect, and now. func (v *idTokenVerifier) checkClaims(claims map[string]interface{}, issuer string) error { if got, _ := claims["iss"].(string); got != issuer { - return fmt.Errorf("authelia: id_token issuer mismatch") + return errIssuerMismatch } if !audienceContains(claims["aud"], v.clientID) { - return fmt.Errorf("authelia: id_token audience does not include this client") + return errAudienceMismatch } exp, hasExp := numericClaim(claims, "exp") iat, hasIat := numericClaim(claims, "iat") if !hasExp || !hasIat { - return fmt.Errorf("authelia: id_token missing exp or iat") + return fmt.Errorf("%w: missing exp or iat", errWindow) } now := time.Now() if now.After(time.Unix(int64(exp), 0).Add(leeway)) { - return fmt.Errorf("authelia: id_token expired") + return errExpired } if time.Unix(int64(iat), 0).After(now.Add(leeway)) || iat >= exp { - return fmt.Errorf("authelia: id_token validity window is not sane") + return errWindow } if nbf, hasNbf := numericClaim(claims, "nbf"); hasNbf { if time.Unix(int64(nbf), 0).After(now.Add(leeway)) { - return fmt.Errorf("authelia: id_token is not yet valid") + return fmt.Errorf("%w: not yet valid", errWindow) } } return nil diff --git a/src/internal/adapters/authelia/verify_test.go b/src/internal/adapters/authelia/verify_test.go index 0195cd3..d900368 100644 --- a/src/internal/adapters/authelia/verify_test.go +++ b/src/internal/adapters/authelia/verify_test.go @@ -16,6 +16,7 @@ import ( "keycape/internal/adapters/authelia" "keycape/internal/domain" + "keycape/internal/server/telemetry" ) // KEY-WP-0019-T03 — upstream ID tokens are verified independently of transport. @@ -210,3 +211,66 @@ func smallKeyJWKS(t *testing.T) string { base64.RawURLEncoding.EncodeToString(small.PublicKey.N.Bytes()), base64.RawURLEncoding.EncodeToString(big.NewInt(int64(small.PublicKey.E)).Bytes())) } + +// Telemetry must name which check failed. A provider that derives its advertised +// issuer from the request Host advertises one value to KeyCape and mints another +// into tokens; that fails closed and is indistinguishable from an attack unless +// the reason is recorded (KEY-WP-0027). +// captureEmitter records emitted telemetry events. +type captureEmitter struct{ events []telemetry.Event } + +func (c *captureEmitter) Emit(_ context.Context, ev telemetry.Event) { + c.events = append(c.events, ev) +} + +func TestFailureReasonsAreDistinguishable(t *testing.T) { + valid := map[string]interface{}{"preferred_username": "alice"} + cases := map[string]struct { + provider *provider + want string + }{ + "issuer mismatch": {&provider{ + jwks: testJWKS(nil), + idToken: signIDToken(map[string]interface{}{"preferred_username": "alice", "iss": "https://elsewhere.example"}, testKeyID, nil), + }, authelia.ReasonIssuerMismatch}, + "audience mismatch": {&provider{ + jwks: testJWKS(nil), + idToken: signIDToken(map[string]interface{}{"preferred_username": "alice", "aud": "another-client"}, testKeyID, nil), + }, authelia.ReasonAudienceMismatch}, + "expired": {&provider{ + jwks: testJWKS(nil), + idToken: signIDToken(map[string]interface{}{ + "preferred_username": "alice", + "iat": time.Now().Add(-2 * time.Hour).Unix(), + "exp": time.Now().Add(-time.Hour).Unix(), + }, testKeyID, nil), + }, authelia.ReasonExpired}, + "signature": {&provider{ + jwks: testJWKS(nil), + idToken: signIDToken(valid, "unpublished-key", nil), + }, authelia.ReasonSignature}, + "keys unavailable": {&provider{ + jwksErr: errors.New("connection refused"), + idToken: signIDToken(valid, testKeyID, nil), + }, authelia.ReasonKeysUnavailable}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + adapter := authelia.New(testConfig(), tc.provider) + emitter := &captureEmitter{} + ctx := telemetry.WithEmitter(context.Background(), emitter) + if _, err := adapter.HandleCallback(ctx, domain.CallbackParams{Code: "c", State: "s"}); !errors.Is(err, domain.ErrAuthFailed) { + t.Fatalf("expected ErrAuthFailed, got %v", err) + } + found := false + for _, ev := range emitter.events { + if ev.ErrorType == tc.want { + found = true + } + } + if !found { + t.Fatalf("no event with ErrorType %q; got %v", tc.want, emitter.events) + } + }) + } +} diff --git a/workplans/KEY-WP-0027-rollout-readiness-and-live-state.md b/workplans/KEY-WP-0027-rollout-readiness-and-live-state.md new file mode 100644 index 0000000..16ff584 --- /dev/null +++ b/workplans/KEY-WP-0027-rollout-readiness-and-live-state.md @@ -0,0 +1,112 @@ +--- +id: KEY-WP-0027 +type: workplan +title: "Establish the live state and the rollout gap for G10" +domain: infotech +repo: key-cape +status: finished +owner: claude +topic_slug: rollout-readiness-and-live-state +created: "2026-09-08" +updated: "2026-09-08" +--- + +G10 cannot be closed from this repository: it waits on named custody and platform +owners. What was doable was to stop guessing about the live state, verify the +handoffs actually went out, and find out whether main is safe to roll out. The +last question turned up a defect in this repository's own recent work. + +## Verify the handoff receipts independently + +```task +id: KEY-WP-0027-T01 +status: done +priority: medium +``` + +KEY-WP-0009 was reopened once because delivery was claimed without receipts, so +the claim that it has now been sent deserved the same scrutiny rather than being +taken on trust. `GET /messages/?from_agent=key-cape` shows all seven present and +dated 2026-09-08T08:21Z: the four KEY-WP-0009 handoffs (netkingdom, +secrets-engine, ops-warden, railiance-platform) and the three admission requests +(railiance-platform custody, approval-engine human callback, ops-warden token +split). No replies had arrived at the time of writing, which is expected hours +after sending and is not itself a blocker to record. + +## Record the live state as observed, not as remembered + +```task +id: KEY-WP-0027-T02 +status: done +priority: medium +``` + +Read-only cluster inspection, `sso` namespace, 2026-09-08: + +- image `forgejo.coulomb.social/coulomb/key-cape:main-153258b`, predating every + change from KEY-WP-0016 onward; +- environment carries `KEYCAPE_RAPP_QONTO_CLIENT_SECRET` only, so the two + approval clients are still not materialized — KEY-WP-0013-T02 remains correctly + blocked; +- four registered clients: `demo-app`, `netkingdom-bootstrap-console` and + `openbao-admin` (public, authorization_code) and `rapp-qonto-client` + (confidential, client_credentials); +- no `tenantEngine` block, so KEY-WP-0024 is inert on rollout. + +**Correction to an earlier claim of mine.** I reported the `ou=people` fix +(KEY-WP-0023) as a production-relevant correction to human login. The deployed +config sets `userOU: "ou=people"` explicitly, so production was never affected. +The fix matters for anyone relying on the default — the dev stack, the scenario +scripts, a fresh deployment — not for this one. + +## Find out whether main is safe to roll out + +```task +id: KEY-WP-0027-T03 +status: done +priority: high +``` + +**Defect found in KEY-WP-0019, before it shipped.** Upstream verification +discovers the expected issuer server-side from `authelia.tokenBaseURL`. The +deployed Authelia derives its advertised issuer from the request Host, confirmed +directly: + +| Host presented | issuer advertised | +| --- | --- | +| `authelia.sso.svc.cluster.local:9091` | `http://authelia.sso.svc.cluster.local:9091` | +| `auth.coulomb.social` | `http://auth.coulomb.social` | + +KeyCape fetches over the in-cluster address, so it would expect the first value +while tokens minted for the browser-facing host carry the second. Verification +fails closed, so if they differ **every human login fails after rollout**, and it +presents as a broken login rather than a configuration error. + +Which value the ID token actually carries could not be settled from here without +performing a real login against production Authelia, which was not done. The risk +is therefore recorded as a rollout precondition, not as a confirmed breakage. + +Two mitigations, both in this repository: + +- `docs/operations.md` documents pinning `authelia.issuer` and `authelia.jwksUrl` + explicitly, with the one-line `curl` that reveals the value the provider will + advertise for a given Host. The overrides already existed; nothing said when + they were needed. +- The failure is now diagnosable. The authentication failure event carries a + specific reason — `id_token_issuer_mismatch`, distinct from + `id_token_signature`, `provider_keys_unavailable` and the rest — so an operator + can tell a misconfiguration from an attack. Five cases cover the mapping. The + browser still learns nothing. + +## Record what each owner owes + +```task +id: KEY-WP-0027-T04 +status: done +priority: medium +``` + +G10's status now names the live state, the outstanding requests with their +receipt ids, and the issuer precondition that must be checked before main is +deployed. G10 stays open: nothing here admits custody, registers the human +callback, or reconciles the OpenBao-token boundary, and no repo-local change can.