From eceee79d0bbc720b969b243ef59ac86e9dfc992d Mon Sep 17 00:00:00 2001 From: Raul Lugo Date: Wed, 28 Jan 2026 11:55:57 +0100 Subject: [PATCH] feat: logout callback with 'take me to I was' functionality --- README.md | 81 ++++++++++++++++++++++------- src/index.ts | 15 +++++- src/routes/logout.ts | 119 +++++++++++++++++++++++++++++++++++++++---- src/runtime.ts | 2 +- 4 files changed, 184 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 8db818b..ce1ffbe 100644 --- a/README.md +++ b/README.md @@ -4,54 +4,97 @@ Astro integration that injects OIDC login/callback/logout routes, a middleware t ## Install +```sh npm install @resuely/astro-oidc-rp +``` ## Usage (astro.config.mjs) +```js import { defineConfig } from "astro/config"; import resuelyOidc from "@resuely/astro-oidc-rp"; export default defineConfig({ integrations: [ resuelyOidc({ - issuer: "https://your-idp", - clientId: "YOUR_CLIENT_ID", - cookie: { signingSecret: process.env.OIDC_SIGNING_SECRET! }, + issuer: { env: "OIDC_ISSUER", fallback: "https://your-idp" }, + clientId: { env: "OIDC_CLIENT_ID" }, + cookie: { signingSecret: { env: "OIDC_SIGNING_SECRET" } }, protected: ["/app/*", "/me"], }), ], }); +``` -- Injected routes: - - Login: /login - - Callback: /oidc/callback - - Logout: /logout +Injected routes (defaults): + +- Login: `/login` +- Callback: `/oidc/callback` +- Logout: `/logout` +- Logout callback: `/logout/callback` ## Options -- issuer: string (required) -- clientId: string (required) -- scopes?: string (default: "openid email profile") -- routes?: { login?: string; callback?: string; logout?: string } -- redirectUri?: { mode: "infer-from-request" } | { absolute: string } -- cookie?: { name?: string; sameSite?: "Lax"|"Strict"|"None"; secure?: boolean; domain?: string; path?: string; signingSecret: string; maxAgeSec?: number } -- protected?: string[] patterns + +```ts +issuer: { env: string; fallback?: string }; // required +clientId: { env: string; fallback?: string }; // required +scopes?: string; // default: "openid email profile" +routes?: { + login?: string; + callback?: string; + logout?: string; + logoutCallback?: string; +}; +redirectUri?: { mode: "infer-from-request" } | { absolute: string }; +cookie: { + name?: string; + sameSite?: "Lax" | "Strict" | "None"; + secure?: boolean; + domain?: string; + path?: string; + signingSecret: { env: string }; + maxAgeSec?: number; +}; +protected?: string[]; // path patterns +``` ## Types: Astro.locals Enable type augmentation by referencing the package export: -- Add to your tsconfig.json: { "compilerOptions": { "types": ["@resuely/astro-oidc-rp/astro-locals"] } } +Add to your `tsconfig.json`: -Then `locals.user` is typed as `{ sub: string; email?: string } | null | undefined`. +```json +{ + "compilerOptions": { + "types": ["@resuely/astro-oidc-rp/astro-locals"] + } +} +``` + +Then `Astro.locals.user` is typed as: + +```ts +{ sub: string; email?: string } | null | undefined +``` ## Security notes - Always provide a strong `cookie.signingSecret`. -- In production, cookies are `Secure` by default. +- Cookies are `Secure` by default; for local HTTP development you may need `cookie.secure: false`. - The init cookie used during login is short-lived (5 minutes) and set `HttpOnly` + `SameSite=Lax`. ## Build & Publish -- Build: npm run build -- Publish to npm: npm publish --access public +Build: + +```sh +npm run build +``` + +Publish: + +```sh +npm publish --access public +``` ## License MIT diff --git a/src/index.ts b/src/index.ts index e33df36..908de03 100644 --- a/src/index.ts +++ b/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)), + }); }, }, }; diff --git a/src/routes/logout.ts b/src/routes/logout.ts index 02c2f05..f3667d1 100644 --- a/src/routes/logout.ts +++ b/src/routes/logout.ts @@ -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 { + const out: Record = {}; + 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, 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 }); } diff --git a/src/runtime.ts b/src/runtime.ts index f3e880c..611a1f3 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -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;