generated from coulomb/repo-seed
feat: implement T11, T12 — Authelia adapter, privacyIDEA adapter
- T11: AutheliaAdapter delegating login UI and session; Authelia tokens never leak to profile layer - T12: PrivacyIDEAAdapter delegating MFA 100% — no MFA logic in KeyCape 21 adapter tests pass, vet clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
215
src/internal/adapters/authelia/adapter.go
Normal file
215
src/internal/adapters/authelia/adapter.go
Normal file
@@ -0,0 +1,215 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user