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:
@@ -1013,3 +1013,72 @@ func TestSessionLogout_PostLogoutRedirectUsesFrontendURL(t *testing.T) {
|
||||
t.Fatalf("status = %d, Location = %q, want post_logout_redirect_uri = %q", rec.Code, rec.Header().Get("Location"), testSessionConfig.FrontendURL+"/")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionLogout_PassesIDTokenHintFromSessionCookie confirms the raw ID
|
||||
// token carried in the session cookie (minted at callback time) is handed
|
||||
// back to EndSessionURL on logout, so Keycloak can skip its own
|
||||
// logout-confirmation prompt instead of leaving the user a chance to cancel
|
||||
// out of it after their geniusrun account is already deleted.
|
||||
func TestSessionLogout_PassesIDTokenHintFromSessionCookie(t *testing.T) {
|
||||
verifier := &authmock.Verifier{EndSessionResult: "https://keycloak.example.com/logout"}
|
||||
s, _ := newTestServerWithAuth(t, verifier)
|
||||
|
||||
cookie, err := auth.MintSessionCookie(
|
||||
auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com", IDToken: "raw-id-token-jwt"},
|
||||
testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("mint session cookie: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/session/logout", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
s.Router().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, want 302, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if verifier.LastIDTokenHint != "raw-id-token-jwt" {
|
||||
t.Errorf("LastIDTokenHint = %q, want %q", verifier.LastIDTokenHint, "raw-id-token-jwt")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionCallback_MintsSessionCookieCarryingIDToken confirms the raw ID
|
||||
// token from a completed OIDC callback ends up in the session cookie (not
|
||||
// just Sub/Name/Email), since that's the only place logout can later read
|
||||
// it back from to build id_token_hint.
|
||||
func TestSessionCallback_MintsSessionCookieCarryingIDToken(t *testing.T) {
|
||||
verifier := &authmock.Verifier{
|
||||
CallbackResult: auth.LoginResult{
|
||||
Claims: auth.Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com", IDToken: "raw-id-token-jwt"},
|
||||
Authorized: true,
|
||||
},
|
||||
}
|
||||
s, _ := newTestServerWithAuth(t, verifier)
|
||||
|
||||
txnCookie, err := auth.MintTxnCookie(auth.TxnState{State: "s1", CodeVerifier: "v1"}, testSessionConfig.Secret, false)
|
||||
if err != nil {
|
||||
t.Fatalf("mint txn cookie: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/session/callback?code=abc&state=s1", nil)
|
||||
req.AddCookie(txnCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
s.Router().ServeHTTP(rec, req)
|
||||
|
||||
var sessionCookie *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == auth.SessionCookieName {
|
||||
sessionCookie = c
|
||||
}
|
||||
}
|
||||
if sessionCookie == nil {
|
||||
t.Fatal("expected a session cookie to be set")
|
||||
}
|
||||
claims, err := auth.ParseSessionCookie(sessionCookie, testSessionConfig.Secret)
|
||||
if err != nil {
|
||||
t.Fatalf("parse session cookie: %v", err)
|
||||
}
|
||||
if claims.IDToken != "raw-id-token-jwt" {
|
||||
t.Errorf("claims.IDToken = %q, want %q", claims.IDToken, "raw-id-token-jwt")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user