From 743eff7a50c2a1d42ab9c8262b7fe8da847bbbbf Mon Sep 17 00:00:00 2001 From: Chris Danis Date: Sat, 20 Sep 2025 16:02:21 -0400 Subject: [PATCH 1/3] draft impl of frontchannel logout --- src/config.go | 2 ++ src/main.go | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/config.go b/src/config.go index 8d4b95f..59b54d5 100644 --- a/src/config.go +++ b/src/config.go @@ -48,6 +48,7 @@ type Config struct { LogoutUri string `json:"logout_uri"` PostLogoutRedirectUri string `json:"post_logout_redirect_uri"` ValidPostLogoutRedirectUris []string `json:"valid_post_logout_redirect_uris"` + FrontChannelLogoutUri string `json:"front_channel_logout_uri"` CookieNamePrefix string `json:"cookie_name_prefix"` SessionCookie *SessionCookieConfig `json:"session_cookie"` @@ -152,6 +153,7 @@ func CreateConfig() *Config { //Scopes: []string{"openid", "profile", "email"}, CallbackUri: "/oidc/callback", LogoutUri: "/logout", + FrontChannelLogoutUri: "/frontchannel-logout", PostLogoutRedirectUri: "/", CookieNamePrefix: "TraefikOidcAuth", SessionCookie: &SessionCookieConfig{ diff --git a/src/main.go b/src/main.go index caac2b8..dae20ca 100644 --- a/src/main.go +++ b/src/main.go @@ -144,6 +144,10 @@ func (toa *TraefikOidcAuth) ServeHTTP(rw http.ResponseWriter, req *http.Request) toa.handleLogout(rw, req, session) return } + if toa.Config.FrontChannelLogoutUri != "" && strings.HasPrefix(req.RequestURI, toa.Config.FrontChannelLogoutUri) { + toa.handleFrontchannelLogout(rw, req, claims) + return + } // If this request is using external authentication by using a header or custom cookie, // we need to validate the authorization on every request. @@ -426,6 +430,50 @@ func (toa *TraefikOidcAuth) handleLogout(rw http.ResponseWriter, req *http.Reque http.Redirect(rw, req, endSessionURL.String(), http.StatusFound) } +func (toa *TraefikOidcAuth) handleFrontchannelLogout(rw http.ResponseWriter, req *http.Request, claims map[string]interface{}) { + toa.logger.Log(logging.LevelInfo, "Handling frontchannel logout...") + // https://openid.net/specs/openid-connect-frontchannel-1_0.html + + // if exactly one of iss or sid is missing, we ignore the request + iss := req.URL.Query().Get("iss") + sid := req.URL.Query().Get("sid") + if (iss == "" && sid != "") || (iss != "" && sid == "") { + toa.logger.Log(logging.LevelWarn, "Ignoring frontchannel logout request: iss or sid is missing") + http.Error(rw, "iss or sid is missing", http.StatusBadRequest) + return + } + + if (iss == "" && sid == "") || (claims["iss"] == iss && claims["sid"] == sid) { + // If both are missing or the issuer is valid, we proceed + toa.logger.Log(logging.LevelInfo, "Proceeding with frontchannel logout") + + clearChunkedCookie(toa.Config, rw, req, getSessionCookieName(toa.Config)) + toa.writeSuccessfulLogout(rw, req) + return + } else { + toa.logger.Log(logging.LevelWarn, "Ignoring frontchannel logout request: sid or iss does not match") + http.Error(rw, "sid or iss does not match", http.StatusBadRequest) + return + } +} + +func (toa *TraefikOidcAuth) writeSuccessfulLogout(rw http.ResponseWriter, req *http.Request) { + rw.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + + data := make(map[string]interface{}) + data["statusCode"] = http.StatusOK + data["statusName"] = "Logged out" + data["description"] = "You have been logged out successfully." + + if toa.Config.LoginUri != "" { + data["primaryButtonText"] = "Log back in" + data["primaryButtonUrl"] = utils.EnsureAbsoluteUrl(req, toa.Config.LoginUri) + } + + errorPages.WriteError(toa.logger, &errorPages.ErrorPageConfig{}, rw, req, data) +} + + func (toa *TraefikOidcAuth) handleUnauthenticated(rw http.ResponseWriter, req *http.Request) { switch toa.Config.UnauthorizedBehavior { case "Challenge": From 45159e7c68b26b151fd537933c3758b92a5eede8 Mon Sep 17 00:00:00 2001 From: Chris Danis Date: Sat, 20 Sep 2025 16:06:49 -0400 Subject: [PATCH 2/3] frontchannel logout ok with no session --- src/main.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main.go b/src/main.go index dae20ca..b7f539b 100644 --- a/src/main.go +++ b/src/main.go @@ -184,6 +184,11 @@ func (toa *TraefikOidcAuth) ServeHTTP(rw http.ResponseWriter, req *http.Request) // Clear the session cookie clearChunkedCookie(toa.Config, rw, req, getSessionCookieName(toa.Config)) + // Don't display unauthenticated error for frontchannel-logout URI + if strings.HasPrefix(req.RequestURI, toa.Config.FrontChannelLogoutUri) { + toa.writeSuccessfulLogout(rw, req) + return + } toa.handleUnauthenticated(rw, req) } From b2f29face968e0c21826985b4c8271a1d2a10a2c Mon Sep 17 00:00:00 2001 From: Chris Danis Date: Sat, 20 Sep 2025 16:07:04 -0400 Subject: [PATCH 3/3] e2e tests for frontchannel logout --- e2e/tests/keycloak/tests.spec.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/e2e/tests/keycloak/tests.spec.ts b/e2e/tests/keycloak/tests.spec.ts index 466709b..7bc1a74 100644 --- a/e2e/tests/keycloak/tests.spec.ts +++ b/e2e/tests/keycloak/tests.spec.ts @@ -123,6 +123,24 @@ test("logout", async ({ page }) => { expect(logoutResponse?.url()).toMatch(/http:\/\/localhost:8000\/realms\/master\/protocol\/openid-connect\/auth.*/); }); +test("frontchannel logout", async ({ page }) => { + await expectGotoOkay(page, "http://localhost:9080"); + + const response = await login(page, "admin", "admin", "http://localhost:9080"); + + expect(response.status()).toBe(200); + + const logoutResponse = await page.goto("http://localhost:9080/frontchannel-logout"); + + expect(logoutResponse?.status()).toBe(200); +}); + +test("frontchannel logout doesn't fail if no session", async ({ page }) => { + const logoutResponse = await page.goto("http://localhost:9080/frontchannel-logout"); + + expect(logoutResponse?.status()).toBe(200); +}); + test("test two services is seamless", async ({ page }) => { await configureTraefik(` http: