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 } // 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 } return &AutheliaAdapter{cfg: cfg, client: httpClient} } // --------------------------------------------------------------------------- // domain.AuthProvider implementation // --------------------------------------------------------------------------- // AuthorizeURL builds the Authelia OIDC authorization URL to which the user // should be redirected. func (a *AutheliaAdapter) AuthorizeURL(_ context.Context, req domain.AuthRequest) (string, error) { base := strings.TrimRight(a.cfg.BaseURL, "/") + "/api/oidc/authorization" q := url.Values{} q.Set("client_id", req.ClientID) q.Set("redirect_uri", req.RedirectURI) q.Set("response_type", "code") q.Set("state", req.State) if req.Nonce != "" { q.Set("nonce", req.Nonce) } if len(req.Scopes) > 0 { q.Set("scope", strings.Join(req.Scopes, " ")) } else { q.Set("scope", "openid profile") } if req.PKCEChallenge != "" { q.Set("code_challenge", req.PKCEChallenge) q.Set("code_challenge_method", req.PKCEChallengeMethod) } 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 } // Parse the ID token claims (no signature verification — internal service boundary). claims, err := parseIDTokenClaims(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_parse_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.cfg.BaseURL, "/") + "/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) body.Set("client_secret", a.cfg.ClientSecret) 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") 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 } // parseIDTokenClaims extracts the JWT payload claims without verifying the // signature. This is intentional — the token is received directly from the // upstream OIDC provider over a server-to-server TLS connection. 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 }