Files
geniusrun/backend/internal/auth/oidc.go
Christophe Vila 8c9285f33c fix: IDEAS.md quickfixes — idle-timeout app config, id_token out of Claims, FormEvent import
session.idle_timeout (minutes, default 15) joins the app-config registry
and drives the onboarding Garmin session eviction, distinct from
session.duration (the login cookie lifetime in hours). The raw Keycloak
ID token no longer rides in auth.Claims through every request context:
it's minted into the session cookie separately and read back only by the
logout handler via IDTokenFromSessionCookie. OnboardingWizard uses the
type-imported FormEvent<HTMLFormElement> instead of the React.FormEvent
namespace alias.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 17:44:37 +02:00

168 lines
5.5 KiB
Go

package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"net/url"
"slices"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
// Verifier is the seam between internal/api and this package's real
// Keycloak-backed OIDC client, so tests can inject internal/auth/mock's fake
// instead of talking to a real identity provider.
type Verifier interface {
// BeginLogin builds a Keycloak authorization URL and the transaction
// state that must be round-tripped (via a TxnCookie) to HandleCallback.
BeginLogin() (authURL string, txn TxnState, err error)
// HandleCallback validates the callback query against txn, exchanges the
// code, verifies the ID token, and reports whether the required role was
// present.
HandleCallback(ctx context.Context, txn TxnState, query url.Values) (LoginResult, error)
// EndSessionURL builds the identity provider's logout URL, redirecting
// back to postLogoutRedirectURL once Keycloak's own session is cleared.
// idTokenHint, if non-empty, is passed as id_token_hint so Keycloak can
// positively identify the session being ended and skip its own
// logout-confirmation prompt (which would otherwise let the user cancel
// out of logout after their geniusrun account is already deleted).
EndSessionURL(postLogoutRedirectURL, idTokenHint string) string
}
// LoginResult is what a completed callback exchange resolves to.
type LoginResult struct {
Claims Claims
Authorized bool
// IDToken is the raw Keycloak ID token JWT, kept apart from Claims:
// it's only ever needed once more, at logout (id_token_hint), so it
// rides in the session cookie but never in the per-request Claims.
IDToken string
}
// OIDCConfig configures NewOIDCVerifier. RequiredRole is checked against the
// ID token's realm_access.roles.
type OIDCConfig struct {
IssuerURL string
ClientID string
ClientSecret string
RedirectURL string
RequiredRole string
}
// idTokenClaims mirrors the subset of a Keycloak ID token's claims geniusrun
// cares about.
type idTokenClaims struct {
Sub string `json:"sub"`
Name string `json:"name"`
Email string `json:"email"`
RealmAccess struct {
Roles []string `json:"roles"`
} `json:"realm_access"`
}
func (c idTokenClaims) hasRole(required string) bool {
return slices.Contains(c.RealmAccess.Roles, required)
}
type oidcVerifier struct {
provider *oidc.Provider
idTokenVerif *oidc.IDTokenVerifier
oauth2Config oauth2.Config
requiredRole string
}
// NewOIDCVerifier performs OIDC discovery against cfg.IssuerURL (once, at
// startup -- go-oidc caches the discovery document internally) and returns a
// Verifier backed by the real Keycloak realm.
func NewOIDCVerifier(ctx context.Context, cfg OIDCConfig) (Verifier, error) {
provider, err := oidc.NewProvider(ctx, cfg.IssuerURL)
if err != nil {
return nil, fmt.Errorf("oidc discovery against %s: %w", cfg.IssuerURL, err)
}
return &oidcVerifier{
provider: provider,
idTokenVerif: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
oauth2Config: oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: cfg.RedirectURL,
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
},
requiredRole: cfg.RequiredRole,
}, nil
}
func randomString(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate random state: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func (v *oidcVerifier) BeginLogin() (string, TxnState, error) {
state, err := randomString(24)
if err != nil {
return "", TxnState{}, err
}
verifier := oauth2.GenerateVerifier()
authURL := v.oauth2Config.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier))
return authURL, TxnState{State: state, CodeVerifier: verifier}, nil
}
func (v *oidcVerifier) HandleCallback(ctx context.Context, txn TxnState, query url.Values) (LoginResult, error) {
if got := query.Get("state"); got == "" || got != txn.State {
return LoginResult{}, fmt.Errorf("state mismatch")
}
code := query.Get("code")
if code == "" {
return LoginResult{}, fmt.Errorf("callback missing code")
}
token, err := v.oauth2Config.Exchange(ctx, code, oauth2.VerifierOption(txn.CodeVerifier))
if err != nil {
return LoginResult{}, fmt.Errorf("exchange code: %w", err)
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok || rawIDToken == "" {
return LoginResult{}, fmt.Errorf("token response missing id_token")
}
idToken, err := v.idTokenVerif.Verify(ctx, rawIDToken)
if err != nil {
return LoginResult{}, fmt.Errorf("verify id_token: %w", err)
}
var claims idTokenClaims
if err := idToken.Claims(&claims); err != nil {
return LoginResult{}, fmt.Errorf("decode id_token claims: %w", err)
}
return LoginResult{
Claims: Claims{Sub: claims.Sub, Name: claims.Name, Email: claims.Email},
Authorized: claims.hasRole(v.requiredRole),
IDToken: rawIDToken,
}, nil
}
func (v *oidcVerifier) EndSessionURL(postLogoutRedirectURL, idTokenHint string) string {
var discovery struct {
EndSessionEndpoint string `json:"end_session_endpoint"`
}
if err := v.provider.Claims(&discovery); err != nil || discovery.EndSessionEndpoint == "" {
return postLogoutRedirectURL
}
u, err := url.Parse(discovery.EndSessionEndpoint)
if err != nil {
return postLogoutRedirectURL
}
q := u.Query()
q.Set("client_id", v.oauth2Config.ClientID)
q.Set("post_logout_redirect_uri", postLogoutRedirectURL)
if idTokenHint != "" {
q.Set("id_token_hint", idTokenHint)
}
u.RawQuery = q.Encode()
return u.String()
}