All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 29s
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
231 lines
7.3 KiB
Go
231 lines
7.3 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",
|
|
ErrorType: "id_token_verification_error",
|
|
})
|
|
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
|
|
}
|