Add spec-compliant OIDC logout (#12804)

* Add spec-compliant OIDC logout

* Scope OIDC logout token cookie

* Assert OIDC post logout redirect

* Handle invalid OIDC logout URLs
This commit is contained in:
Tom Moor
2026-06-24 17:54:14 -04:00
committed by GitHub
parent c3669a1f1a
commit 98a756154a
5 changed files with 107 additions and 4 deletions
+1
View File
@@ -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
+1 -1
View File
@@ -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 <Redirect to={logoutPath()} />;
+7 -2
View File
@@ -368,8 +368,13 @@ export default class AuthStore extends Store<Team> {
});
}
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) {
+36
View File
@@ -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;"
);
});
});
});
+62 -1
View File
@@ -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("/");
}
});
}