mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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()} />;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("/");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user