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
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:
parent
64942639ad
commit
7dda967c27
6 changed files with 293 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
112
workplans/KEY-WP-0027-rollout-readiness-and-live-state.md
Normal file
112
workplans/KEY-WP-0027-rollout-readiness-and-live-state.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue