key-cape/src/internal/adapters/authelia/adapter.go

235 lines
7.5 KiB
Go
Raw Normal View History

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 {
Verify upstream Authelia ID tokens Closes the remaining half of gap G01. The adapter decoded upstream ID-token claims without verifying anything, justified in a comment by a server-to-server TLS boundary that nothing enforced. Operator decision: verify the token rather than police the transport. The hop is to be HTTPS as defence in depth, but KeyCape does not monitor, check or gate on that -- a transport check helps only when it is configured correctly, which is the assumption it was meant to remove. Verification holds regardless of how the token arrived, so no HTTPS validation or opt-in flag is added. HandleCallback now verifies the RS256 signature against Authelia's published keys, the issuer Authelia advertises, KeyCape's own client ID in the audience, and a sane validity window, before any claim is trusted. It fails closed: an unreachable or unparseable key set denies the login. The advertised jwks_uri path is rebased onto the server-side token base URL so split-horizon deployments resolve, with config overrides where that inference is wrong, and an unknown key id triggers one refresh so provider rotation needs no restart. The reusable half lives in internal/jose rather than being copied from authclient's verifier, since duplicated verification is how two copies drift and one misses a fix. Migrating authclient onto it is tracked as KEY-WP-0019-T05, kept separate so it does not destabilise a tested path in this change. Thirteen rejection cases plus algorithm and rotation coverage; with the unverified parse restored all fifteen fail, so they test the fix rather than merely passing. 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-07 08:51:42 +02:00
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
}
Verify upstream Authelia ID tokens Closes the remaining half of gap G01. The adapter decoded upstream ID-token claims without verifying anything, justified in a comment by a server-to-server TLS boundary that nothing enforced. Operator decision: verify the token rather than police the transport. The hop is to be HTTPS as defence in depth, but KeyCape does not monitor, check or gate on that -- a transport check helps only when it is configured correctly, which is the assumption it was meant to remove. Verification holds regardless of how the token arrived, so no HTTPS validation or opt-in flag is added. HandleCallback now verifies the RS256 signature against Authelia's published keys, the issuer Authelia advertises, KeyCape's own client ID in the audience, and a sane validity window, before any claim is trusted. It fails closed: an unreachable or unparseable key set denies the login. The advertised jwks_uri path is rebased onto the server-side token base URL so split-horizon deployments resolve, with config overrides where that inference is wrong, and an unknown key id triggers one refresh so provider rotation needs no restart. The reusable half lives in internal/jose rather than being copied from authclient's verifier, since duplicated verification is how two copies drift and one misses a fix. Migrating authclient onto it is tracked as KEY-WP-0019-T05, kept separate so it does not destabilise a tested path in this change. Thirteen rejection cases plus algorithm and rotation coverage; with the unverified parse restored all fifteen fail, so they test the fix rather than merely passing. 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-07 08:51:42 +02:00
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) {
2026-05-24 17:03:01 +02:00
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 upstream Authelia ID tokens Closes the remaining half of gap G01. The adapter decoded upstream ID-token claims without verifying anything, justified in a comment by a server-to-server TLS boundary that nothing enforced. Operator decision: verify the token rather than police the transport. The hop is to be HTTPS as defence in depth, but KeyCape does not monitor, check or gate on that -- a transport check helps only when it is configured correctly, which is the assumption it was meant to remove. Verification holds regardless of how the token arrived, so no HTTPS validation or opt-in flag is added. HandleCallback now verifies the RS256 signature against Authelia's published keys, the issuer Authelia advertises, KeyCape's own client ID in the audience, and a sane validity window, before any claim is trusted. It fails closed: an unreachable or unparseable key set denies the login. The advertised jwks_uri path is rebased onto the server-side token base URL so split-horizon deployments resolve, with config overrides where that inference is wrong, and an unknown key id triggers one refresh so provider rotation needs no restart. The reusable half lives in internal/jose rather than being copied from authclient's verifier, since duplicated verification is how two copies drift and one misses a fix. Migrating authclient onto it is tracked as KEY-WP-0019-T05, kept separate so it does not destabilise a tested path in this change. Thirteen rejection cases plus algorithm and rotation coverage; with the unverified parse restored all fifteen fail, so they test the fix rather than merely passing. 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-07 08:51:42 +02:00
// 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",
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
// 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) {
2026-05-24 17:03:01 +02:00
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
}
2026-05-24 17:03:01 +02:00
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
}
Verify upstream Authelia ID tokens Closes the remaining half of gap G01. The adapter decoded upstream ID-token claims without verifying anything, justified in a comment by a server-to-server TLS boundary that nothing enforced. Operator decision: verify the token rather than police the transport. The hop is to be HTTPS as defence in depth, but KeyCape does not monitor, check or gate on that -- a transport check helps only when it is configured correctly, which is the assumption it was meant to remove. Verification holds regardless of how the token arrived, so no HTTPS validation or opt-in flag is added. HandleCallback now verifies the RS256 signature against Authelia's published keys, the issuer Authelia advertises, KeyCape's own client ID in the audience, and a sane validity window, before any claim is trusted. It fails closed: an unreachable or unparseable key set denies the login. The advertised jwks_uri path is rebased onto the server-side token base URL so split-horizon deployments resolve, with config overrides where that inference is wrong, and an unknown key id triggers one refresh so provider rotation needs no restart. The reusable half lives in internal/jose rather than being copied from authclient's verifier, since duplicated verification is how two copies drift and one misses a fix. Migrating authclient onto it is tracked as KEY-WP-0019-T05, kept separate so it does not destabilise a tested path in this change. Thirteen rejection cases plus algorithm and rotation coverage; with the unverified parse restored all fifteen fail, so they test the fix rather than merely passing. 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-07 08:51:42 +02:00
// 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
}