Establish the live state and find a rollout precondition for G10
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 45s

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 <noreply@anthropic.com>
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
This commit is contained in:
tegwick 2026-09-08 11:41:42 +02:00
parent 64942639ad
commit 7dda967c27
6 changed files with 293 additions and 7 deletions

View file

@ -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

View file

@ -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.

View file

@ -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
}

View file

@ -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

View file

@ -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)
}
})
}
}

View file

@ -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.