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

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