key-cape/src/internal/adapters/authelia/adapter.go
tegwick 7dda967c27
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 45s
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 <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
2026-09-08 11:41:42 +02:00

234 lines
7.5 KiB
Go

package authelia
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"keycape/internal/domain"
"keycape/internal/server/telemetry"
)
// AutheliaAdapter implements domain.AuthProvider by delegating to Authelia's
// OIDC endpoints. All Authelia tokens and cookies are confined to this package.
type AutheliaAdapter struct {
cfg Config
client HTTPClient
verifier *idTokenVerifier
}
// New returns a production-ready AutheliaAdapter.
// If httpClient is nil the default net/http.Client is used.
func New(cfg Config, httpClient HTTPClient) *AutheliaAdapter {
if httpClient == nil {
httpClient = defaultHTTPClient
}
a := &AutheliaAdapter{cfg: cfg, client: httpClient}
a.verifier = newIDTokenVerifier(cfg, httpClient, a.tokenBaseURL())
return a
}
// ---------------------------------------------------------------------------
// domain.AuthProvider implementation
// ---------------------------------------------------------------------------
// AuthorizeURL builds the Authelia OIDC authorization URL to which the user
// should be redirected.
//
// KeyCape is a confidential OIDC client to Authelia. The adapter always uses
// its own registered client_id and redirect_uri — NOT the downstream client's
// values — and requests the full fixed scope set. PKCE is omitted because
// the confidential client_secret authenticates the token exchange instead.
func (a *AutheliaAdapter) AuthorizeURL(_ context.Context, req domain.AuthRequest) (string, error) {
base := strings.TrimRight(a.authorizeBaseURL(), "/") + "/api/oidc/authorization"
q := url.Values{}
q.Set("client_id", a.cfg.ClientID)
q.Set("redirect_uri", a.cfg.RedirectURI)
q.Set("response_type", "code")
q.Set("state", req.State)
q.Set("scope", "openid profile email groups")
return base + "?" + q.Encode(), nil
}
// HandleCallback exchanges the authorization code for tokens and extracts the
// authenticated user identity. Authelia tokens are never returned — only the
// normalized AuthResult is.
func (a *AutheliaAdapter) HandleCallback(ctx context.Context, params domain.CallbackParams) (*domain.AuthResult, error) {
emitter := telemetry.EmitterFromContext(ctx)
// Surface callback-level errors from Authelia immediately.
if params.Error != "" {
emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now().UTC(),
EventType: telemetry.EventAuthFailure,
Endpoint: "/api/oidc/token",
Result: "failure",
ErrorType: params.Error,
})
return nil, domain.ErrAuthFailed
}
// Exchange the authorization code for tokens.
tokenResp, err := a.exchangeCode(ctx, params.Code)
if err != nil {
emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now().UTC(),
EventType: telemetry.EventAuthFailure,
Endpoint: "/api/oidc/token",
Result: "failure",
ErrorType: "token_exchange_error",
})
return nil, domain.ErrAuthFailed
}
// Verify the ID token before trusting any claim in it: signature against
// Authelia's published keys, expected issuer, our own client ID in the
// audience, and a sane validity window (KEY-WP-0019). This fails closed --
// an unreachable or unparseable key set denies the login rather than
// falling back to unverified claims.
claims, err := a.verifier.Verify(ctx, tokenResp.IDToken)
if err != nil {
emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now().UTC(),
EventType: telemetry.EventAuthFailure,
Endpoint: "/api/oidc/token",
Result: "failure",
// 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
}
// Extract username: prefer preferred_username, fall back to sub.
username := stringClaim(claims, "preferred_username")
if username == "" {
username = stringClaim(claims, "sub")
}
if username == "" {
emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now().UTC(),
EventType: telemetry.EventAuthFailure,
Endpoint: "/api/oidc/token",
Result: "failure",
ErrorType: "missing_username_claim",
})
return nil, domain.ErrAuthFailed
}
// Security boundary: only the ID token claims are forwarded.
// The access_token and refresh_token remain within this adapter.
return &domain.AuthResult{
Username: username,
Claims: claims,
}, nil
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// tokenResponse is the subset of the Authelia token endpoint response that
// this adapter needs. Fields beyond IDToken are intentionally not forwarded.
type tokenResponse struct {
IDToken string `json:"id_token"`
}
// exchangeCode sends a POST to Authelia's token endpoint and returns the
// parsed token response. On any HTTP or status error it returns a non-nil error.
func (a *AutheliaAdapter) exchangeCode(_ context.Context, code string) (*tokenResponse, error) {
tokenURL := strings.TrimRight(a.tokenBaseURL(), "/") + "/api/oidc/token"
body := url.Values{}
body.Set("grant_type", "authorization_code")
body.Set("code", code)
body.Set("redirect_uri", a.cfg.RedirectURI)
body.Set("client_id", a.cfg.ClientID)
req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(body.Encode()))
if err != nil {
return nil, fmt.Errorf("authelia: build token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(a.cfg.ClientID, a.cfg.ClientSecret)
resp, err := a.client.Do(req)
if err != nil {
return nil, fmt.Errorf("authelia: token exchange: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("authelia: token endpoint returned %d", resp.StatusCode)
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("authelia: read token response: %w", err)
}
var tr tokenResponse
if err := json.Unmarshal(raw, &tr); err != nil {
return nil, fmt.Errorf("authelia: decode token response: %w", err)
}
return &tr, nil
}
func (a *AutheliaAdapter) authorizeBaseURL() string {
if a.cfg.BrowserBaseURL != "" {
return a.cfg.BrowserBaseURL
}
return a.cfg.BaseURL
}
func (a *AutheliaAdapter) tokenBaseURL() string {
if a.cfg.TokenBaseURL != "" {
return a.cfg.TokenBaseURL
}
return a.cfg.BaseURL
}
// parseIDTokenClaims extracts the JWT payload claims without verifying
// anything. It is NOT part of the authentication path: HandleCallback verifies
// through idTokenVerifier. Kept for tests and diagnostics that need to read a
// token's payload without asserting it is trustworthy.
func parseIDTokenClaims(idToken string) (map[string]interface{}, error) {
parts := strings.Split(idToken, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("authelia: malformed id_token: expected 3 parts, got %d", len(parts))
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("authelia: decode id_token payload: %w", err)
}
var claims map[string]interface{}
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, fmt.Errorf("authelia: unmarshal id_token claims: %w", err)
}
return claims, nil
}
// stringClaim extracts a string value from a claims map, returning "" if
// the key is absent or the value is not a string.
func stringClaim(claims map[string]interface{}, key string) string {
v, ok := claims[key]
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return s
}