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
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue