feat: logout callback with 'take me to I was' functionality
This commit is contained in:
15
src/index.ts
15
src/index.ts
@@ -14,7 +14,12 @@ export type OidcIntegrationOptions = {
|
||||
issuer: EnvString;
|
||||
clientId: EnvString;
|
||||
scopes?: string; // default "openid email profile"
|
||||
routes?: { login?: string; callback?: string; logout?: string }; // defaults: /login, /oidc/callback, /logout
|
||||
routes?: {
|
||||
login?: string;
|
||||
callback?: string;
|
||||
logout?: string;
|
||||
logoutCallback?: string;
|
||||
}; // defaults: /login, /oidc/callback, /logout, /logout/callback
|
||||
redirectUri?: { mode: "infer-from-request" } | { absolute: string };
|
||||
cookie: {
|
||||
name?: string;
|
||||
@@ -34,7 +39,7 @@ export type OidcInjectedOptions = {
|
||||
clientIdEnv: string;
|
||||
clientIdFallback?: string;
|
||||
scopes: string;
|
||||
routes: { login: string; callback: string; logout: string };
|
||||
routes: { login: string; callback: string; logout: string; logoutCallback: string };
|
||||
redirectUri: { mode: "infer-from-request" } | { absolute: string };
|
||||
cookie: {
|
||||
name: string;
|
||||
@@ -59,6 +64,8 @@ function resolveOptions(user: OidcIntegrationOptions): OidcInjectedOptions {
|
||||
login: user.routes?.login ?? "/login",
|
||||
callback: user.routes?.callback ?? "/oidc/callback",
|
||||
logout: user.routes?.logout ?? "/logout",
|
||||
logoutCallback:
|
||||
user.routes?.logoutCallback ?? `${user.routes?.logout ?? "/logout"}/callback`,
|
||||
};
|
||||
const cookie = {
|
||||
name: user.cookie?.name ?? "oidc_session",
|
||||
@@ -126,6 +133,10 @@ export default function resuelyOidc(
|
||||
pattern: resolved.routes.logout,
|
||||
entrypoint: fileURLToPath(new URL("./routes/logout.js", import.meta.url)),
|
||||
});
|
||||
injectRoute({
|
||||
pattern: resolved.routes.logoutCallback,
|
||||
entrypoint: fileURLToPath(new URL("./routes/logout.js", import.meta.url)),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIContext } from "astro";
|
||||
import { getOptions } from "../runtime.js";
|
||||
import { clearCookie } from "../lib/cookies.js";
|
||||
import { clearCookie, serializeCookie } from "../lib/cookies.js";
|
||||
import { generateState } from "../lib/pkce.js";
|
||||
|
||||
async function discover(issuer: string) {
|
||||
const res = await fetch(
|
||||
@@ -12,38 +13,134 @@ async function discover(issuer: string) {
|
||||
};
|
||||
}
|
||||
|
||||
type LogoutCookie = {
|
||||
state: string;
|
||||
returnTo?: string;
|
||||
};
|
||||
|
||||
function parseCookies(cookieHeader: string | null): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!cookieHeader) return out;
|
||||
for (const part of cookieHeader.split(/;\s*/)) {
|
||||
const idx = part.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
const name = decodeURIComponent(part.slice(0, idx));
|
||||
const val = decodeURIComponent(part.slice(idx + 1));
|
||||
out[name] = val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function safeReturnTo(reqUrl: URL, returnTo: string | undefined): string {
|
||||
if (!returnTo) return "/";
|
||||
try {
|
||||
const target = new URL(returnTo, reqUrl);
|
||||
if (target.origin !== reqUrl.origin) return "/";
|
||||
return target.toString();
|
||||
} catch {
|
||||
return "/";
|
||||
}
|
||||
}
|
||||
|
||||
function inferPostLogoutRedirectUri(
|
||||
options: ReturnType<typeof getOptions>,
|
||||
reqUrl: URL,
|
||||
): string {
|
||||
if ("absolute" in options.redirectUri) return options.redirectUri.absolute;
|
||||
// Default to site root
|
||||
const u = new URL("/", reqUrl);
|
||||
const u = new URL(options.routes.logoutCallback, reqUrl);
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
function parseLogoutCookie(raw: string): LogoutCookie | null {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(decoded);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
if (!("state" in parsed) || typeof parsed.state !== "string") return null;
|
||||
const returnTo =
|
||||
"returnTo" in parsed && typeof parsed.returnTo === "string"
|
||||
? parsed.returnTo
|
||||
: undefined;
|
||||
return { state: parsed.state, returnTo };
|
||||
}
|
||||
|
||||
export async function GET(ctx: APIContext) {
|
||||
const options = getOptions();
|
||||
const { url } = ctx;
|
||||
|
||||
const isCallback = url.pathname === options.routes.logoutCallback;
|
||||
const logoutCookieName = `${options.cookie.name}_logout`;
|
||||
|
||||
if (isCallback) {
|
||||
const state = url.searchParams.get("state") || "";
|
||||
const cookies = parseCookies(ctx.request.headers.get("cookie"));
|
||||
const cookie = cookies[logoutCookieName];
|
||||
const parsed = cookie ? parseLogoutCookie(cookie) : null;
|
||||
|
||||
const ok = parsed && parsed.state === state;
|
||||
const location = ok ? safeReturnTo(url, parsed.returnTo) : "/";
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set("Location", location);
|
||||
headers.append(
|
||||
"Set-Cookie",
|
||||
clearCookie(options.cookie.name, {
|
||||
path: options.cookie.path,
|
||||
domain: options.cookie.domain,
|
||||
}),
|
||||
);
|
||||
headers.append(
|
||||
"Set-Cookie",
|
||||
clearCookie(logoutCookieName, {
|
||||
path: options.cookie.path,
|
||||
domain: options.cookie.domain,
|
||||
}),
|
||||
);
|
||||
|
||||
return new Response(null, { status: 303, headers });
|
||||
}
|
||||
|
||||
const disco = await discover(options.issuer);
|
||||
const endSession =
|
||||
disco.end_session_endpoint || new URL("/logout", options.issuer).toString();
|
||||
|
||||
const state = generateState();
|
||||
const returnTo = url.searchParams.get("return_to") || undefined;
|
||||
const cookieValue = JSON.stringify({ state, returnTo });
|
||||
const setLogout = serializeCookie(logoutCookieName, cookieValue, {
|
||||
path: options.cookie.path,
|
||||
domain: options.cookie.domain,
|
||||
sameSite: "Lax",
|
||||
secure: options.cookie.secure,
|
||||
httpOnly: true,
|
||||
maxAge: 5 * 60,
|
||||
});
|
||||
|
||||
const postLogout = inferPostLogoutRedirectUri(options, url);
|
||||
const redirect = new URL(endSession);
|
||||
redirect.searchParams.set("client_id", options.clientId);
|
||||
redirect.searchParams.set("post_logout_redirect_uri", postLogout);
|
||||
redirect.searchParams.set("state", state);
|
||||
|
||||
const clear = clearCookie(options.cookie.name, {
|
||||
path: options.cookie.path,
|
||||
domain: options.cookie.domain,
|
||||
});
|
||||
|
||||
return new Response(null, {
|
||||
status: 303,
|
||||
headers: {
|
||||
Location: redirect.toString(),
|
||||
"Set-Cookie": clear,
|
||||
},
|
||||
});
|
||||
const headers = new Headers();
|
||||
headers.set("Location", redirect.toString());
|
||||
headers.append("Set-Cookie", clear);
|
||||
headers.append("Set-Cookie", setLogout);
|
||||
|
||||
return new Response(null, { status: 303, headers });
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export type OidcRuntimeOptions = {
|
||||
issuer: string;
|
||||
clientId: string;
|
||||
scopes: string;
|
||||
routes: { login: string; callback: string; logout: string };
|
||||
routes: { login: string; callback: string; logout: string; logoutCallback: string };
|
||||
redirectUri: { mode: "infer-from-request" } | { absolute: string };
|
||||
cookie: {
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user