feat(auth): pass id_token_hint on Keycloak logout

Carries the raw ID token in the session cookie so logout can hand it back
to Keycloak as id_token_hint, letting it skip its own logout-confirmation
prompt -- otherwise a user could cancel out of it and land back in the app
with a Keycloak SSO session but no geniusrun profile (e.g. right after
deleting their account).
This commit is contained in:
2026-07-26 11:55:13 +02:00
parent d7202eb9bb
commit 8353cd148b
7 changed files with 191 additions and 26 deletions

View File

@@ -1,9 +1,15 @@
// Package auth implements geniusrun's own login gate: an OIDC Authorization
// Code flow against an existing Keycloak realm (see oidc.go), backed by a
// signed session cookie geniusrun mints itself (this file) and a chi
// middleware that checks it (middleware.go). Keycloak's own tokens are never
// stored or refreshed -- once HandleCallback verifies the ID token and role,
// only this package's own cookie matters for subsequent requests.
// middleware that checks it (middleware.go). Keycloak's own access/refresh
// tokens are never stored or refreshed -- once HandleCallback verifies the
// ID token and role, only this package's own cookie matters for subsequent
// requests. The one exception is the raw ID token itself, carried opaquely
// inside the signed session cookie (Claims.IDToken) solely so a later
// logout can pass it back to Keycloak as id_token_hint -- letting Keycloak
// skip its own logout-confirmation prompt for a session it can positively
// identify, rather than leaving the user a chance to cancel out of it after
// their geniusrun account (and profile) is already gone.
package auth
import (
@@ -26,17 +32,21 @@ const (
)
// Claims identifies the authenticated user, carried in the signed session
// cookie.
// cookie. IDToken is the raw Keycloak ID token JWT from the OIDC callback,
// carried opaquely so a later logout can pass it back to Keycloak as
// id_token_hint (see EndSessionURL) -- geniusrun never inspects it itself.
type Claims struct {
Sub string
Name string
Email string
Sub string
Name string
Email string
IDToken string
}
type sessionClaims struct {
Sub string `json:"sub"`
Name string `json:"name"`
Email string `json:"email"`
Sub string `json:"sub"`
Name string `json:"name"`
Email string `json:"email"`
IDToken string `json:"id_token,omitempty"`
jwt.RegisteredClaims
}
@@ -58,9 +68,10 @@ type txnClaims struct {
func MintSessionCookie(claims Claims, secret []byte, duration time.Duration, secure bool) (*http.Cookie, error) {
now := time.Now()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, sessionClaims{
Sub: claims.Sub,
Name: claims.Name,
Email: claims.Email,
Sub: claims.Sub,
Name: claims.Name,
Email: claims.Email,
IDToken: claims.IDToken,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(duration)),
@@ -91,7 +102,7 @@ func ParseSessionCookie(cookie *http.Cookie, secret []byte) (Claims, error) {
if _, err := jwt.ParseWithClaims(cookie.Value, &sc, keyfunc(secret), jwt.WithValidMethods([]string{"HS256"})); err != nil {
return Claims{}, fmt.Errorf("parse session token: %w", err)
}
return Claims{Sub: sc.Sub, Name: sc.Name, Email: sc.Email}, nil
return Claims{Sub: sc.Sub, Name: sc.Name, Email: sc.Email, IDToken: sc.IDToken}, nil
}
// MintTxnCookie signs an OIDC login transaction into a short-lived cookie.