Compare commits

..

2 Commits

Author SHA1 Message Date
Raul Lugo
7aa0d63cc6 1.1.0 2026-01-28 11:56:25 +01:00
Raul Lugo
eceee79d0b feat: logout callback with 'take me to I was' functionality 2026-01-28 11:55:57 +01:00
6 changed files with 187 additions and 36 deletions

View File

@@ -4,54 +4,97 @@ Astro integration that injects OIDC login/callback/logout routes, a middleware t
## Install ## Install
```sh
npm install @resuely/astro-oidc-rp npm install @resuely/astro-oidc-rp
```
## Usage (astro.config.mjs) ## Usage (astro.config.mjs)
```js
import { defineConfig } from "astro/config"; import { defineConfig } from "astro/config";
import resuelyOidc from "@resuely/astro-oidc-rp"; import resuelyOidc from "@resuely/astro-oidc-rp";
export default defineConfig({ export default defineConfig({
integrations: [ integrations: [
resuelyOidc({ resuelyOidc({
issuer: "https://your-idp", issuer: { env: "OIDC_ISSUER", fallback: "https://your-idp" },
clientId: "YOUR_CLIENT_ID", clientId: { env: "OIDC_CLIENT_ID" },
cookie: { signingSecret: process.env.OIDC_SIGNING_SECRET! }, cookie: { signingSecret: { env: "OIDC_SIGNING_SECRET" } },
protected: ["/app/*", "/me"], protected: ["/app/*", "/me"],
}), }),
], ],
}); });
```
- Injected routes: Injected routes (defaults):
- Login: /login
- Callback: /oidc/callback - Login: `/login`
- Logout: /logout - Callback: `/oidc/callback`
- Logout: `/logout`
- Logout callback: `/logout/callback`
## Options ## Options
- issuer: string (required)
- clientId: string (required) ```ts
- scopes?: string (default: "openid email profile") issuer: { env: string; fallback?: string }; // required
- routes?: { login?: string; callback?: string; logout?: string } clientId: { env: string; fallback?: string }; // required
- redirectUri?: { mode: "infer-from-request" } | { absolute: string } scopes?: string; // default: "openid email profile"
- cookie?: { name?: string; sameSite?: "Lax"|"Strict"|"None"; secure?: boolean; domain?: string; path?: string; signingSecret: string; maxAgeSec?: number } routes?: {
- protected?: string[] patterns 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 ## Types: Astro.locals
Enable type augmentation by referencing the package export: 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 ## Security notes
- Always provide a strong `cookie.signingSecret`. - 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`. - The init cookie used during login is short-lived (5 minutes) and set `HttpOnly` + `SameSite=Lax`.
## Build & Publish ## Build & Publish
- Build: npm run build Build:
- Publish to npm: npm publish --access public
```sh
npm run build
```
Publish:
```sh
npm publish --access public
```
## License ## License
MIT MIT

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@resuely/astro-oidc-rp", "name": "@resuely/astro-oidc-rp",
"version": "1.0.1", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@resuely/astro-oidc-rp", "name": "@resuely/astro-oidc-rp",
"version": "1.0.1", "version": "1.1.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"jose": "^5.0.0" "jose": "^5.0.0"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@resuely/astro-oidc-rp", "name": "@resuely/astro-oidc-rp",
"version": "1.0.1", "version": "1.1.0",
"description": "Astro integration providing OIDC relying-party routes, middleware, and types.", "description": "Astro integration providing OIDC relying-party routes, middleware, and types.",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",

View File

@@ -14,7 +14,12 @@ export type OidcIntegrationOptions = {
issuer: EnvString; issuer: EnvString;
clientId: EnvString; clientId: EnvString;
scopes?: string; // default "openid email profile" 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 }; redirectUri?: { mode: "infer-from-request" } | { absolute: string };
cookie: { cookie: {
name?: string; name?: string;
@@ -34,7 +39,7 @@ export type OidcInjectedOptions = {
clientIdEnv: string; clientIdEnv: string;
clientIdFallback?: string; clientIdFallback?: string;
scopes: 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 }; redirectUri: { mode: "infer-from-request" } | { absolute: string };
cookie: { cookie: {
name: string; name: string;
@@ -59,6 +64,8 @@ function resolveOptions(user: OidcIntegrationOptions): OidcInjectedOptions {
login: user.routes?.login ?? "/login", login: user.routes?.login ?? "/login",
callback: user.routes?.callback ?? "/oidc/callback", callback: user.routes?.callback ?? "/oidc/callback",
logout: user.routes?.logout ?? "/logout", logout: user.routes?.logout ?? "/logout",
logoutCallback:
user.routes?.logoutCallback ?? `${user.routes?.logout ?? "/logout"}/callback`,
}; };
const cookie = { const cookie = {
name: user.cookie?.name ?? "oidc_session", name: user.cookie?.name ?? "oidc_session",
@@ -126,6 +133,10 @@ export default function resuelyOidc(
pattern: resolved.routes.logout, pattern: resolved.routes.logout,
entrypoint: fileURLToPath(new URL("./routes/logout.js", import.meta.url)), 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)),
});
}, },
}, },
}; };

View File

@@ -1,6 +1,7 @@
import type { APIContext } from "astro"; import type { APIContext } from "astro";
import { getOptions } from "../runtime.js"; 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) { async function discover(issuer: string) {
const res = await fetch( 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( function inferPostLogoutRedirectUri(
options: ReturnType<typeof getOptions>, options: ReturnType<typeof getOptions>,
reqUrl: URL, reqUrl: URL,
): string { ): string {
if ("absolute" in options.redirectUri) return options.redirectUri.absolute; const u = new URL(options.routes.logoutCallback, reqUrl);
// Default to site root
const u = new URL("/", reqUrl);
return u.toString(); 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) { export async function GET(ctx: APIContext) {
const options = getOptions(); const options = getOptions();
const { url } = ctx; 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 disco = await discover(options.issuer);
const endSession = const endSession =
disco.end_session_endpoint || new URL("/logout", options.issuer).toString(); 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 postLogout = inferPostLogoutRedirectUri(options, url);
const redirect = new URL(endSession); const redirect = new URL(endSession);
redirect.searchParams.set("client_id", options.clientId); redirect.searchParams.set("client_id", options.clientId);
redirect.searchParams.set("post_logout_redirect_uri", postLogout); redirect.searchParams.set("post_logout_redirect_uri", postLogout);
redirect.searchParams.set("state", state);
const clear = clearCookie(options.cookie.name, { const clear = clearCookie(options.cookie.name, {
path: options.cookie.path, path: options.cookie.path,
domain: options.cookie.domain, domain: options.cookie.domain,
}); });
return new Response(null, { const headers = new Headers();
status: 303, headers.set("Location", redirect.toString());
headers: { headers.append("Set-Cookie", clear);
Location: redirect.toString(), headers.append("Set-Cookie", setLogout);
"Set-Cookie": clear,
}, return new Response(null, { status: 303, headers });
});
} }

View File

@@ -9,7 +9,7 @@ export type OidcRuntimeOptions = {
issuer: string; issuer: string;
clientId: string; clientId: string;
scopes: 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 }; redirectUri: { mode: "infer-from-request" } | { absolute: string };
cookie: { cookie: {
name: string; name: string;