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,10 +1,83 @@
package auth
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
)
// newTestOIDCVerifier spins up a fake OIDC discovery endpoint (just enough
// for oidc.NewProvider's discovery GET to succeed) and returns a real
// oidcVerifier backed by it, for tests that exercise EndSessionURL without
// a live Keycloak.
func newTestOIDCVerifier(t *testing.T) Verifier {
t.Helper()
var issuerURL string
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{
"issuer": %[1]q,
"authorization_endpoint": "%[1]s/auth",
"token_endpoint": "%[1]s/token",
"end_session_endpoint": "%[1]s/logout",
"jwks_uri": "%[1]s/certs"
}`, issuerURL)
})
mux.HandleFunc("/certs", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"keys":[]}`)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
issuerURL = srv.URL
verifier, err := NewOIDCVerifier(context.Background(), OIDCConfig{
IssuerURL: issuerURL, ClientID: "geniusrun", ClientSecret: "secret", RedirectURL: issuerURL + "/callback",
})
if err != nil {
t.Fatalf("NewOIDCVerifier: %v", err)
}
return verifier
}
func TestEndSessionURL_IncludesIDTokenHintWhenProvided(t *testing.T) {
verifier := newTestOIDCVerifier(t)
got := verifier.EndSessionURL("https://app.example.com/", "raw-id-token-jwt")
u, err := url.Parse(got)
if err != nil {
t.Fatalf("parse EndSessionURL result %q: %v", got, err)
}
q := u.Query()
if q.Get("id_token_hint") != "raw-id-token-jwt" {
t.Errorf("id_token_hint = %q, want %q", q.Get("id_token_hint"), "raw-id-token-jwt")
}
if q.Get("client_id") != "geniusrun" {
t.Errorf("client_id = %q, want geniusrun", q.Get("client_id"))
}
if q.Get("post_logout_redirect_uri") != "https://app.example.com/" {
t.Errorf("post_logout_redirect_uri = %q, want https://app.example.com/", q.Get("post_logout_redirect_uri"))
}
}
func TestEndSessionURL_OmitsIDTokenHintWhenEmpty(t *testing.T) {
verifier := newTestOIDCVerifier(t)
got := verifier.EndSessionURL("https://app.example.com/", "")
u, err := url.Parse(got)
if err != nil {
t.Fatalf("parse EndSessionURL result %q: %v", got, err)
}
if u.Query().Has("id_token_hint") {
t.Errorf("expected no id_token_hint param when idTokenHint is empty, got %q", got)
}
}
func TestIDTokenClaims_HasRole(t *testing.T) {
cases := []struct {
name string