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("/");
+ }
+ });
}