diff --git a/.env.test b/.env.test index eb012561a1..838abc2216 100644 --- a/.env.test +++ b/.env.test @@ -28,6 +28,7 @@ OIDC_CLIENT_SECRET=client-secret OIDC_AUTH_URI=http://localhost/authorize OIDC_TOKEN_URI=http://localhost/token OIDC_USERINFO_URI=http://localhost/userinfo +OIDC_LOGOUT_URI=http://localhost/logout IFRAMELY_API_KEY=123 diff --git a/app/scenes/Logout.tsx b/app/scenes/Logout.tsx index e82d109736..752602ded0 100644 --- a/app/scenes/Logout.tsx +++ b/app/scenes/Logout.tsx @@ -11,7 +11,7 @@ const Logout = () => { clearCache: true, }); - if (env.OIDC_LOGOUT_URI) { + if (env.OIDC_LOGOUT_URI || auth.lastSignedIn === "oidc") { return null; // user will be redirected to logout URI after logout } return ; diff --git a/app/stores/AuthStore.ts b/app/stores/AuthStore.ts index b6c2278362..8a120c162d 100644 --- a/app/stores/AuthStore.ts +++ b/app/stores/AuthStore.ts @@ -368,8 +368,13 @@ export default class AuthStore extends Store { }); } - if (userInitiated) { - this.logoutRedirectUri = env.OIDC_LOGOUT_URI; + if ( + userInitiated && + (env.OIDC_LOGOUT_URI || this.lastSignedIn === "oidc") + ) { + // Route through the server so it can build a spec-compliant RP-initiated + // logout URL (including the id_token_hint) for the OIDC provider. + this.logoutRedirectUri = "/auth/oidc.logout"; } if (clearCache) { diff --git a/plugins/oidc/server/auth/oidc.test.ts b/plugins/oidc/server/auth/oidc.test.ts index 72e37f3748..9ed31dc7f3 100644 --- a/plugins/oidc/server/auth/oidc.test.ts +++ b/plugins/oidc/server/auth/oidc.test.ts @@ -12,4 +12,40 @@ describe("oidc", () => { expect(res.status).toEqual(302); expect(redirectLocation.searchParams.get("myParam")).toEqual("someParam"); }); + + describe("logout", () => { + it("should redirect to the provider with a spec-compliant logout request", async () => { + const res = await server.get("/auth/oidc.logout", { + redirect: "manual", + }); + expect(res.status).toEqual(302); + const redirectLocation = new URL(res.headers.get("location")!); + expect(redirectLocation.origin + redirectLocation.pathname).toEqual( + "http://localhost/logout" + ); + expect(redirectLocation.searchParams.get("client_id")).toEqual( + "client-id" + ); + expect( + redirectLocation.searchParams.get("post_logout_redirect_uri") + ).toEqual("http://localhost:3000"); + }); + + it("should include the id_token_hint when present", async () => { + const res = await server.get("/auth/oidc.logout", { + redirect: "manual", + headers: { + Cookie: "oidcIdToken=fake-id-token", + }, + }); + expect(res.status).toEqual(302); + const redirectLocation = new URL(res.headers.get("location")!); + expect(redirectLocation.searchParams.get("id_token_hint")).toEqual( + "fake-id-token" + ); + expect(res.headers.get("set-cookie")).toContain( + "oidcIdToken=; path=/auth/oidc.logout;" + ); + }); + }); }); diff --git a/plugins/oidc/server/auth/oidcRouter.ts b/plugins/oidc/server/auth/oidcRouter.ts index 07aba58190..71df80e417 100644 --- a/plugins/oidc/server/auth/oidcRouter.ts +++ b/plugins/oidc/server/auth/oidcRouter.ts @@ -1,10 +1,11 @@ import passport from "@outlinewiki/koa-passport"; +import { addMonths, subMinutes } from "date-fns"; import JWT from "jsonwebtoken"; import type { Context } from "koa"; import type Router from "koa-router"; import { get } from "es-toolkit/compat"; import { toError } from "@shared/utils/error"; -import { slugifyDomain } from "@shared/utils/domains"; +import { getCookieDomain, slugifyDomain } from "@shared/utils/domains"; import { parseEmail } from "@shared/utils/email"; import { isBase64Url } from "@shared/utils/urls"; import accountProvisioner from "@server/commands/accountProvisioner"; @@ -30,6 +31,8 @@ import env from "../env"; import { OIDCStrategy } from "./OIDCStrategy"; import { createContext } from "@server/context"; +const OIDC_LOGOUT_PATH = "/auth/oidc.logout"; + export interface OIDCEndpoints { authorizationURL: string; tokenURL: string; @@ -232,6 +235,23 @@ export function createOIDCRouter( scopes: params.scope ? params.scope.split(" ") : scopes, }, }); + // Persist the id_token so a later RP-initiated logout can pass it as + // the `id_token_hint`, allowing the provider to scope the logout to + // this session rather than terminating its global SSO session. + if (endpoints.logoutURL && params.id_token) { + context.cookies.set("oidcIdToken", params.id_token, { + httpOnly: true, + sameSite: "lax", + secure: env.isProduction, + path: OIDC_LOGOUT_PATH, + domain: getCookieDomain( + context.request.hostname, + env.isCloudHosted + ), + expires: addMonths(new Date(), 3), + }); + } + return done(null, result.user, { ...result, client }); } catch (err) { return done(toError(err), null); @@ -243,4 +263,45 @@ export function createOIDCRouter( router.get(config.id, startOAuthFlow, passport.authenticate(config.id)); router.get(`${config.id}.callback`, passportMiddleware(config.id)); router.post(`${config.id}.callback`, passportMiddleware(config.id)); + + // Performs a spec-compliant RP-initiated logout against the provider's end + // session endpoint. Passing `id_token_hint` identifies the session being + // ended so the provider can scope the logout and skip a confirmation prompt, + // while `post_logout_redirect_uri` returns the user to Outline afterwards. + // https://openid.net/specs/openid-connect-rpinitiated-1_0.html + router.get(`${config.id}.logout`, (ctx: Context) => { + const idToken = ctx.cookies.get("oidcIdToken"); + + // Always discard our copy of the id_token, regardless of where we redirect. + ctx.cookies.set("oidcIdToken", "", { + httpOnly: true, + sameSite: "lax", + secure: env.isProduction, + path: OIDC_LOGOUT_PATH, + domain: getCookieDomain(ctx.request.hostname, env.isCloudHosted), + expires: subMinutes(new Date(), 1), + }); + + if (!endpoints.logoutURL) { + return ctx.redirect("/"); + } + + try { + const url = new URL(endpoints.logoutURL); + if (idToken) { + url.searchParams.set("id_token_hint", idToken); + } + if (env.OIDC_CLIENT_ID) { + url.searchParams.set("client_id", env.OIDC_CLIENT_ID); + } + url.searchParams.set("post_logout_redirect_uri", env.URL); + + return ctx.redirect(url.toString()); + } catch (err) { + Logger.warn("Invalid OIDC logout URL", { + error: toError(err).message, + }); + return ctx.redirect("/"); + } + }); }