Compare commits
2 Commits
v1.1.0
...
31b85ba8bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31b85ba8bb | ||
|
|
b5bc4a74bf |
81
README.md
81
README.md
@@ -4,97 +4,54 @@ 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: { env: "OIDC_ISSUER", fallback: "https://your-idp" },
|
issuer: "https://your-idp",
|
||||||
clientId: { env: "OIDC_CLIENT_ID" },
|
clientId: "YOUR_CLIENT_ID",
|
||||||
cookie: { signingSecret: { env: "OIDC_SIGNING_SECRET" } },
|
cookie: { signingSecret: process.env.OIDC_SIGNING_SECRET! },
|
||||||
protected: ["/app/*", "/me"],
|
protected: ["/app/*", "/me"],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
```
|
|
||||||
|
|
||||||
Injected routes (defaults):
|
- Injected routes:
|
||||||
|
- Login: /login
|
||||||
- Login: `/login`
|
- Callback: /oidc/callback
|
||||||
- Callback: `/oidc/callback`
|
- Logout: /logout
|
||||||
- Logout: `/logout`
|
|
||||||
- Logout callback: `/logout/callback`
|
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
- issuer: string (required)
|
||||||
```ts
|
- clientId: string (required)
|
||||||
issuer: { env: string; fallback?: string }; // required
|
- scopes?: string (default: "openid email profile")
|
||||||
clientId: { env: string; fallback?: string }; // required
|
- routes?: { login?: string; callback?: string; logout?: string }
|
||||||
scopes?: string; // default: "openid email profile"
|
- redirectUri?: { mode: "infer-from-request" } | { absolute: string }
|
||||||
routes?: {
|
- cookie?: { name?: string; sameSite?: "Lax"|"Strict"|"None"; secure?: boolean; domain?: string; path?: string; signingSecret: string; maxAgeSec?: number }
|
||||||
login?: string;
|
- protected?: string[] patterns
|
||||||
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`:
|
- Add to your tsconfig.json: { "compilerOptions": { "types": ["@resuely/astro-oidc-rp/astro-locals"] } }
|
||||||
|
|
||||||
```json
|
Then `locals.user` is typed as `{ sub: string; email?: string } | null | undefined`.
|
||||||
{
|
|
||||||
"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`.
|
||||||
- Cookies are `Secure` by default; for local HTTP development you may need `cookie.secure: false`.
|
- In production, cookies are `Secure` by default.
|
||||||
- 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:
|
- Build: npm run 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
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@resuely/astro-oidc-rp",
|
"name": "@resuely/astro-oidc-rp",
|
||||||
"version": "1.1.0",
|
"version": "1.0.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@resuely/astro-oidc-rp",
|
"name": "@resuely/astro-oidc-rp",
|
||||||
"version": "1.1.0",
|
"version": "1.0.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jose": "^5.0.0"
|
"jose": "^5.0.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@resuely/astro-oidc-rp",
|
"name": "@resuely/astro-oidc-rp",
|
||||||
"version": "1.1.0",
|
"version": "1.0.2",
|
||||||
"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",
|
||||||
|
|||||||
15
src/index.ts
15
src/index.ts
@@ -14,12 +14,7 @@ export type OidcIntegrationOptions = {
|
|||||||
issuer: EnvString;
|
issuer: EnvString;
|
||||||
clientId: EnvString;
|
clientId: EnvString;
|
||||||
scopes?: string; // default "openid email profile"
|
scopes?: string; // default "openid email profile"
|
||||||
routes?: {
|
routes?: { login?: string; callback?: string; logout?: string }; // defaults: /login, /oidc/callback, /logout
|
||||||
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;
|
||||||
@@ -39,7 +34,7 @@ export type OidcInjectedOptions = {
|
|||||||
clientIdEnv: string;
|
clientIdEnv: string;
|
||||||
clientIdFallback?: string;
|
clientIdFallback?: string;
|
||||||
scopes: string;
|
scopes: string;
|
||||||
routes: { login: string; callback: string; logout: string; logoutCallback: string };
|
routes: { login: string; callback: string; logout: string };
|
||||||
redirectUri: { mode: "infer-from-request" } | { absolute: string };
|
redirectUri: { mode: "infer-from-request" } | { absolute: string };
|
||||||
cookie: {
|
cookie: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -64,8 +59,6 @@ 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",
|
||||||
@@ -133,10 +126,6 @@ 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)),
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { APIContext } from "astro";
|
import type { APIContext } from "astro";
|
||||||
import { getOptions } from "../runtime.js";
|
import { getOptions } from "../runtime.js";
|
||||||
import { clearCookie, serializeCookie } from "../lib/cookies.js";
|
import { clearCookie } 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(
|
||||||
@@ -13,134 +12,37 @@ 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 {
|
||||||
const u = new URL(options.routes.logoutCallback, reqUrl);
|
if ("absolute" in options.redirectUri) return options.redirectUri.absolute;
|
||||||
|
const u = new URL(options.routes.callback, 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
const headers = new Headers();
|
return new Response(null, {
|
||||||
headers.set("Location", redirect.toString());
|
status: 303,
|
||||||
headers.append("Set-Cookie", clear);
|
headers: {
|
||||||
headers.append("Set-Cookie", setLogout);
|
Location: redirect.toString(),
|
||||||
|
"Set-Cookie": clear,
|
||||||
return new Response(null, { status: 303, headers });
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; logoutCallback: string };
|
routes: { login: string; callback: string; logout: string };
|
||||||
redirectUri: { mode: "infer-from-request" } | { absolute: string };
|
redirectUri: { mode: "infer-from-request" } | { absolute: string };
|
||||||
cookie: {
|
cookie: {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user