Compare commits
6 Commits
4abf69844a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04f00993db | ||
|
|
81239dc6ab | ||
|
|
7aa0d63cc6 | ||
|
|
eceee79d0b | ||
|
|
5e4cd49d0a | ||
|
|
4e33401387 |
81
README.md
81
README.md
@@ -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
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@resuely/astro-oidc-rp",
|
"name": "@resuely/astro-oidc-rp",
|
||||||
"version": "0.1.0",
|
"version": "1.1.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@resuely/astro-oidc-rp",
|
"name": "@resuely/astro-oidc-rp",
|
||||||
"version": "0.1.0",
|
"version": "1.1.1",
|
||||||
"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.0.0",
|
"version": "1.1.1",
|
||||||
"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",
|
||||||
@@ -26,8 +26,8 @@
|
|||||||
"typescript": "^5.0.0"
|
"typescript": "^5.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup src/index.ts src/runtime.ts src/middleware.ts src/routes/login.ts src/routes/callback.ts src/routes/logout.ts src/lib/*.ts --dts --format esm --clean --sourcemap --out-dir dist",
|
"build": "tsup",
|
||||||
"dev": "tsup --watch src/index.ts src/runtime.ts src/middleware.ts src/routes/login.ts src/routes/callback.ts src/routes/logout.ts src/lib/*.ts --dts --format esm --out-dir dist",
|
"dev": "tsup --watch",
|
||||||
"postbuild": "mkdir -p dist/types && cp -R types/*.d.ts dist/types/ || true",
|
"postbuild": "mkdir -p dist/types && cp -R types/*.d.ts dist/types/ || true",
|
||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
},
|
},
|
||||||
|
|||||||
83
src/index.ts
83
src/index.ts
@@ -1,66 +1,94 @@
|
|||||||
import type { AstroIntegration } from "astro";
|
import type { AstroIntegration } from "astro";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
export type EnvString = {
|
||||||
|
env: string;
|
||||||
|
fallback?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RequiredSecret = {
|
||||||
|
env: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type OidcIntegrationOptions = {
|
export type OidcIntegrationOptions = {
|
||||||
issuer: string;
|
issuer: EnvString;
|
||||||
clientId: string;
|
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;
|
||||||
sameSite?: "Lax" | "Strict" | "None";
|
sameSite?: "Lax" | "Strict" | "None";
|
||||||
secure?: boolean;
|
secure?: boolean;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
signingSecret: string;
|
signingSecret: RequiredSecret;
|
||||||
maxAgeSec?: number;
|
maxAgeSec?: number;
|
||||||
};
|
};
|
||||||
protected?: string[]; // patterns to guard, e.g., ["/app/*", "/me"]
|
protected?: string[]; // patterns to guard, e.g., ["/app/*", "/me"]
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OidcResolvedOptions = Required<
|
export type OidcInjectedOptions = {
|
||||||
Pick<OidcIntegrationOptions, "issuer" | "clientId">
|
issuerEnv: string;
|
||||||
> & {
|
issuerFallback?: string;
|
||||||
|
clientIdEnv: 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: Required<
|
cookie: {
|
||||||
Pick<
|
name: string;
|
||||||
NonNullable<OidcIntegrationOptions["cookie"]>,
|
sameSite: "Lax" | "Strict" | "None";
|
||||||
"name" | "sameSite" | "secure" | "path" | "signingSecret"
|
secure: boolean;
|
||||||
>
|
path: string;
|
||||||
> & {
|
signingSecretEnv: string;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
maxAgeSec?: number;
|
maxAgeSec?: number;
|
||||||
};
|
};
|
||||||
protected: string[];
|
protected: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveOptions(user: OidcIntegrationOptions): OidcResolvedOptions {
|
function requireEnvName(name: string, label: string): string {
|
||||||
|
if (typeof name !== "string" || name.trim().length === 0)
|
||||||
|
throw new Error(`@resuely/astro-oidc-rp: ${label} must be a non-empty string`);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOptions(user: OidcIntegrationOptions): OidcInjectedOptions {
|
||||||
const routes = {
|
const routes = {
|
||||||
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",
|
||||||
sameSite: user.cookie?.sameSite ?? "Lax",
|
sameSite: user.cookie?.sameSite ?? "Lax",
|
||||||
secure: user.cookie?.secure ?? true,
|
secure: user.cookie?.secure ?? true,
|
||||||
path: user.cookie?.path ?? "/",
|
path: user.cookie?.path ?? "/",
|
||||||
signingSecret: user.cookie?.signingSecret ?? "",
|
signingSecretEnv: requireEnvName(
|
||||||
|
user.cookie?.signingSecret?.env,
|
||||||
|
"cookie.signingSecret.env",
|
||||||
|
),
|
||||||
domain: user.cookie?.domain,
|
domain: user.cookie?.domain,
|
||||||
maxAgeSec: user.cookie?.maxAgeSec,
|
maxAgeSec: user.cookie?.maxAgeSec,
|
||||||
};
|
};
|
||||||
if (!user.issuer)
|
|
||||||
throw new Error("@resuely/astro-oidc-rp: 'issuer' is required");
|
|
||||||
if (!user.clientId)
|
|
||||||
throw new Error("@resuely/astro-oidc-rp: 'clientId' is required");
|
|
||||||
if (!cookie.signingSecret)
|
|
||||||
throw new Error("@resuely/astro-oidc-rp: cookie.signingSecret is required");
|
|
||||||
return {
|
return {
|
||||||
issuer: user.issuer,
|
issuerEnv: requireEnvName(user.issuer.env, "issuer.env"),
|
||||||
clientId: user.clientId,
|
issuerFallback:
|
||||||
|
typeof user.issuer.fallback === "string" ? user.issuer.fallback : undefined,
|
||||||
|
clientIdEnv: requireEnvName(user.clientId.env, "clientId.env"),
|
||||||
|
clientIdFallback:
|
||||||
|
typeof user.clientId.fallback === "string"
|
||||||
|
? user.clientId.fallback
|
||||||
|
: undefined,
|
||||||
scopes: user.scopes ?? "openid email profile",
|
scopes: user.scopes ?? "openid email profile",
|
||||||
routes,
|
routes,
|
||||||
redirectUri: user.redirectUri ?? { mode: "infer-from-request" },
|
redirectUri: user.redirectUri ?? { mode: "infer-from-request" },
|
||||||
@@ -105,8 +133,11 @@ 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)),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export async function signPayload(
|
|||||||
return `${payloadB64u}.${sigB64u}`;
|
return `${payloadB64u}.${sigB64u}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyAndDecode<T = any>(
|
export async function verifyAndDecode<T = unknown>(
|
||||||
token: string,
|
token: string,
|
||||||
secret: string,
|
secret: string,
|
||||||
): Promise<{ valid: boolean; payload?: T }> {
|
): Promise<{ valid: boolean; payload?: T }> {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { MiddlewareHandler } from "astro";
|
import type { MiddlewareHandler } from "astro";
|
||||||
import { options } from "./runtime.js";
|
import { getOptions } from "./runtime.js";
|
||||||
import { verifyAndDecode } from "./lib/sign.js";
|
import { verifyAndDecode } from "./lib/sign.js";
|
||||||
|
|
||||||
function parseCookies(cookieHeader: string | null): Record<string, string> {
|
function parseCookies(cookieHeader: string | null): Record<string, string> {
|
||||||
@@ -21,6 +21,11 @@ function escapeRegex(str: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function globToRegExp(pattern: string): RegExp {
|
function globToRegExp(pattern: string): RegExp {
|
||||||
|
if (pattern.endsWith("/*")) {
|
||||||
|
const base = pattern.slice(0, -2);
|
||||||
|
const baseEscaped = escapeRegex(base);
|
||||||
|
return new RegExp(`^${baseEscaped}(?:/.*)?$`);
|
||||||
|
}
|
||||||
const escaped = pattern.split("*").map(escapeRegex).join(".*");
|
const escaped = pattern.split("*").map(escapeRegex).join(".*");
|
||||||
return new RegExp(`^${escaped}$`);
|
return new RegExp(`^${escaped}$`);
|
||||||
}
|
}
|
||||||
@@ -31,18 +36,24 @@ function isProtected(pathname: string, patterns: string[]): boolean {
|
|||||||
|
|
||||||
export const onRequest: MiddlewareHandler = async (context, next) => {
|
export const onRequest: MiddlewareHandler = async (context, next) => {
|
||||||
const { request, locals, url } = context;
|
const { request, locals, url } = context;
|
||||||
|
const options = getOptions();
|
||||||
const cookieName = options.cookie.name;
|
const cookieName = options.cookie.name;
|
||||||
const cookies = parseCookies(request.headers.get("cookie"));
|
const cookies = parseCookies(request.headers.get("cookie"));
|
||||||
const token = cookies[cookieName];
|
const token = cookies[cookieName];
|
||||||
|
|
||||||
locals.user = null;
|
locals.user = null;
|
||||||
if (token) {
|
if (token) {
|
||||||
const res = await verifyAndDecode<{ sub: string; email?: string }>(
|
const res = await verifyAndDecode<{
|
||||||
token,
|
sub: string;
|
||||||
options.cookie.signingSecret,
|
email?: string;
|
||||||
);
|
firstName?: string;
|
||||||
|
}>(token, options.cookie.signingSecret);
|
||||||
if (res.valid && res.payload && typeof res.payload.sub === "string") {
|
if (res.valid && res.payload && typeof res.payload.sub === "string") {
|
||||||
locals.user = { sub: res.payload.sub, email: res.payload.email };
|
locals.user = {
|
||||||
|
sub: res.payload.sub,
|
||||||
|
email: res.payload.email,
|
||||||
|
firstName: res.payload.firstName,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import type { APIContext } from "astro";
|
import type { APIContext } from "astro";
|
||||||
import { options } from "../runtime.js";
|
import { getOptions } from "../runtime.js";
|
||||||
import { clearCookie, serializeCookie } from "../lib/cookies.js";
|
import { clearCookie, serializeCookie } from "../lib/cookies.js";
|
||||||
import { signPayload } from "../lib/sign.js";
|
import { signPayload } from "../lib/sign.js";
|
||||||
import { createRemoteJWKSet, jwtVerify } from "jose";
|
import { createRemoteJWKSet, jwtVerify } from "jose";
|
||||||
|
|
||||||
|
type InitCookie = {
|
||||||
|
state: string;
|
||||||
|
nonce: string;
|
||||||
|
verifier: string;
|
||||||
|
returnTo?: string;
|
||||||
|
};
|
||||||
|
|
||||||
async function discover(issuer: string) {
|
async function discover(issuer: string) {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
new URL("/.well-known/openid-configuration", issuer).toString(),
|
new URL("/.well-known/openid-configuration", issuer).toString(),
|
||||||
@@ -15,13 +22,66 @@ async function discover(issuer: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferRedirectUri(reqUrl: URL): string {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegex(str: string): string {
|
||||||
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInitCookie(
|
||||||
|
cookieHeader: string,
|
||||||
|
initCookieName: string,
|
||||||
|
): InitCookie | null {
|
||||||
|
const match = cookieHeader.match(
|
||||||
|
new RegExp(`${escapeRegex(initCookieName)}=([^;]+)`),
|
||||||
|
);
|
||||||
|
const raw = match?.[1];
|
||||||
|
if (!raw) return null;
|
||||||
|
let decoded: string;
|
||||||
|
try {
|
||||||
|
decoded = decodeURIComponent(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(decoded);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(parsed)) return null;
|
||||||
|
if (
|
||||||
|
typeof parsed.state !== "string" ||
|
||||||
|
typeof parsed.nonce !== "string" ||
|
||||||
|
typeof parsed.verifier !== "string"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const returnTo = typeof parsed.returnTo === "string" ? parsed.returnTo : undefined;
|
||||||
|
return {
|
||||||
|
state: parsed.state,
|
||||||
|
nonce: parsed.nonce,
|
||||||
|
verifier: parsed.verifier,
|
||||||
|
returnTo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferRedirectUri(
|
||||||
|
options: ReturnType<typeof getOptions>,
|
||||||
|
reqUrl: URL,
|
||||||
|
): string {
|
||||||
if ("absolute" in options.redirectUri) return options.redirectUri.absolute;
|
if ("absolute" in options.redirectUri) return options.redirectUri.absolute;
|
||||||
const u = new URL(options.routes.callback, reqUrl);
|
const u = new URL(options.routes.callback, reqUrl);
|
||||||
return u.toString();
|
return u.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(ctx: APIContext) {
|
export async function GET(ctx: APIContext) {
|
||||||
|
const options = getOptions();
|
||||||
const { url, request } = ctx;
|
const { url, request } = ctx;
|
||||||
const code = url.searchParams.get("code");
|
const code = url.searchParams.get("code");
|
||||||
const state = url.searchParams.get("state");
|
const state = url.searchParams.get("state");
|
||||||
@@ -31,20 +91,12 @@ export async function GET(ctx: APIContext) {
|
|||||||
|
|
||||||
const initCookieName = `${options.cookie.name}_init`;
|
const initCookieName = `${options.cookie.name}_init`;
|
||||||
const cookieHeader = request.headers.get("cookie") || "";
|
const cookieHeader = request.headers.get("cookie") || "";
|
||||||
const init = (() => {
|
const init = parseInitCookie(cookieHeader, initCookieName);
|
||||||
const match = cookieHeader.match(new RegExp(`${initCookieName}=([^;]+)`));
|
|
||||||
if (!match) return null as any;
|
|
||||||
try {
|
|
||||||
return JSON.parse(decodeURIComponent(match[1]));
|
|
||||||
} catch {
|
|
||||||
return null as any;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (!init || init.state !== state)
|
if (!init || init.state !== state)
|
||||||
return new Response("Invalid state", { status: 400 });
|
return new Response("Invalid state", { status: 400 });
|
||||||
|
|
||||||
const redirectUri = inferRedirectUri(url);
|
const redirectUri = inferRedirectUri(options, url);
|
||||||
const disco = await discover(options.issuer);
|
const disco = await discover(options.issuer);
|
||||||
|
|
||||||
const form = new URLSearchParams({
|
const form = new URLSearchParams({
|
||||||
@@ -62,8 +114,11 @@ export async function GET(ctx: APIContext) {
|
|||||||
});
|
});
|
||||||
if (!tokenRes.ok)
|
if (!tokenRes.ok)
|
||||||
return new Response("Token exchange failed", { status: 400 });
|
return new Response("Token exchange failed", { status: 400 });
|
||||||
const token = await tokenRes.json();
|
const tokenJson: unknown = await tokenRes.json();
|
||||||
const idToken = token.id_token as string | undefined;
|
const idToken =
|
||||||
|
isRecord(tokenJson) && typeof tokenJson.id_token === "string"
|
||||||
|
? tokenJson.id_token
|
||||||
|
: undefined;
|
||||||
if (!idToken) return new Response("Missing id_token", { status: 400 });
|
if (!idToken) return new Response("Missing id_token", { status: 400 });
|
||||||
|
|
||||||
const jwks = createRemoteJWKSet(new URL(disco.jwks_uri));
|
const jwks = createRemoteJWKSet(new URL(disco.jwks_uri));
|
||||||
@@ -72,9 +127,13 @@ export async function GET(ctx: APIContext) {
|
|||||||
audience: options.clientId,
|
audience: options.clientId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const givenName =
|
||||||
|
typeof payload["given_name"] === "string" ? payload["given_name"] : undefined;
|
||||||
|
|
||||||
const session = {
|
const session = {
|
||||||
sub: String(payload.sub),
|
sub: String(payload.sub),
|
||||||
email: typeof payload.email === "string" ? payload.email : undefined,
|
email: typeof payload.email === "string" ? payload.email : undefined,
|
||||||
|
firstName: givenName,
|
||||||
};
|
};
|
||||||
const signed = await signPayload(session, options.cookie.signingSecret);
|
const signed = await signPayload(session, options.cookie.signingSecret);
|
||||||
|
|
||||||
@@ -91,7 +150,7 @@ export async function GET(ctx: APIContext) {
|
|||||||
domain: options.cookie.domain,
|
domain: options.cookie.domain,
|
||||||
});
|
});
|
||||||
|
|
||||||
const returnTo = typeof init.return_to === "string" ? init.return_to : "/";
|
const returnTo = typeof init.returnTo === "string" ? init.returnTo : "/";
|
||||||
const location = new URL(returnTo, url).toString();
|
const location = new URL(returnTo, url).toString();
|
||||||
|
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { APIContext } from "astro";
|
import type { APIContext } from "astro";
|
||||||
import { options } from "../runtime.js";
|
import { getOptions } from "../runtime.js";
|
||||||
import {
|
import {
|
||||||
generateCodeVerifier,
|
generateCodeVerifier,
|
||||||
codeChallengeS256,
|
codeChallengeS256,
|
||||||
@@ -18,13 +18,17 @@ async function discover(issuer: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferRedirectUri(reqUrl: URL): string {
|
function inferRedirectUri(
|
||||||
|
options: ReturnType<typeof getOptions>,
|
||||||
|
reqUrl: URL,
|
||||||
|
): string {
|
||||||
if ("absolute" in options.redirectUri) return options.redirectUri.absolute;
|
if ("absolute" in options.redirectUri) return options.redirectUri.absolute;
|
||||||
const u = new URL(options.routes.callback, reqUrl);
|
const u = new URL(options.routes.callback, reqUrl);
|
||||||
return u.toString();
|
return u.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(ctx: APIContext) {
|
export async function GET(ctx: APIContext) {
|
||||||
|
const options = getOptions();
|
||||||
const { url } = ctx;
|
const { url } = ctx;
|
||||||
const verifier = generateCodeVerifier();
|
const verifier = generateCodeVerifier();
|
||||||
const challenge = await codeChallengeS256(verifier);
|
const challenge = await codeChallengeS256(verifier);
|
||||||
@@ -37,7 +41,7 @@ export async function GET(ctx: APIContext) {
|
|||||||
state,
|
state,
|
||||||
nonce,
|
nonce,
|
||||||
verifier,
|
verifier,
|
||||||
return_to: returnTo,
|
returnTo,
|
||||||
});
|
});
|
||||||
const initCookieName = `${options.cookie.name}_init`;
|
const initCookieName = `${options.cookie.name}_init`;
|
||||||
const cookie = serializeCookie(initCookieName, initPayload, {
|
const cookie = serializeCookie(initCookieName, initPayload, {
|
||||||
@@ -50,7 +54,7 @@ export async function GET(ctx: APIContext) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const disco = await discover(options.issuer);
|
const disco = await discover(options.issuer);
|
||||||
const redirectUri = inferRedirectUri(url);
|
const redirectUri = inferRedirectUri(options, url);
|
||||||
const authorize = new URL(disco.authorization_endpoint);
|
const authorize = new URL(disco.authorization_endpoint);
|
||||||
authorize.searchParams.set("client_id", options.clientId);
|
authorize.searchParams.set("client_id", options.clientId);
|
||||||
authorize.searchParams.set("redirect_uri", redirectUri);
|
authorize.searchParams.set("redirect_uri", redirectUri);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { APIContext } from "astro";
|
import type { APIContext } from "astro";
|
||||||
import { options } 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,34 +13,173 @@ async function discover(issuer: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferPostLogoutRedirectUri(reqUrl: URL): string {
|
type LogoutCookie = {
|
||||||
if ("absolute" in options.redirectUri) return options.redirectUri.absolute;
|
state: string;
|
||||||
// Default to site root
|
returnTo?: string;
|
||||||
const u = new URL("/", reqUrl);
|
};
|
||||||
|
|
||||||
|
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 escapeRegex(str: string): string {
|
||||||
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function globToRegExp(pattern: string): RegExp {
|
||||||
|
if (pattern.endsWith("/*")) {
|
||||||
|
const base = pattern.slice(0, -2);
|
||||||
|
const baseEscaped = escapeRegex(base);
|
||||||
|
return new RegExp(`^${baseEscaped}(?:/.*)?$`);
|
||||||
|
}
|
||||||
|
const escaped = pattern.split("*").map(escapeRegex).join(".*");
|
||||||
|
return new RegExp(`^${escaped}$`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProtected(pathname: string, patterns: string[]): boolean {
|
||||||
|
return patterns.some((p) => globToRegExp(p).test(pathname));
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizePostLogoutReturnTo(
|
||||||
|
options: ReturnType<typeof getOptions>,
|
||||||
|
reqUrl: URL,
|
||||||
|
returnTo: string | undefined,
|
||||||
|
): string {
|
||||||
|
const safe = safeReturnTo(reqUrl, returnTo);
|
||||||
|
try {
|
||||||
|
const u = new URL(safe, reqUrl);
|
||||||
|
if (isProtected(u.pathname, options.protected)) return new URL("/", reqUrl).toString();
|
||||||
|
return u.toString();
|
||||||
|
} catch {
|
||||||
|
return new URL("/", reqUrl).toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferPostLogoutRedirectUri(
|
||||||
|
options: ReturnType<typeof getOptions>,
|
||||||
|
reqUrl: URL,
|
||||||
|
): string {
|
||||||
|
const u = new URL(options.routes.logoutCallback, 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 { 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
|
||||||
|
? finalizePostLogoutReturnTo(options, 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 postLogout = inferPostLogoutRedirectUri(url);
|
const state = generateState();
|
||||||
|
const returnTo = finalizePostLogoutReturnTo(
|
||||||
|
options,
|
||||||
|
url,
|
||||||
|
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);
|
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 });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,86 @@
|
|||||||
import type { OidcResolvedOptions } from "./index.js";
|
import type { OidcInjectedOptions } from "./index.js";
|
||||||
|
import { getSecret } from "astro:env/server";
|
||||||
|
|
||||||
// This constant is provided via Vite define by the integration during astro:config:setup
|
// This constant is provided via Vite define by the integration during astro:config:setup
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
declare const __RESUELY_OIDC_OPTIONS: OidcResolvedOptions;
|
declare const __RESUELY_OIDC_OPTIONS: OidcInjectedOptions;
|
||||||
|
|
||||||
export const options: OidcResolvedOptions = (
|
export type OidcRuntimeOptions = {
|
||||||
typeof __RESUELY_OIDC_OPTIONS !== "undefined"
|
issuer: string;
|
||||||
? __RESUELY_OIDC_OPTIONS
|
clientId: string;
|
||||||
: (undefined as any)
|
scopes: string;
|
||||||
) as OidcResolvedOptions;
|
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: string;
|
||||||
|
maxAgeSec?: number;
|
||||||
|
};
|
||||||
|
protected: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function getInjected(): OidcInjectedOptions {
|
||||||
|
if (typeof __RESUELY_OIDC_OPTIONS === "undefined") {
|
||||||
|
throw new Error(
|
||||||
|
"@resuely/astro-oidc-rp: missing injected options; is the integration configured?",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return __RESUELY_OIDC_OPTIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEnv(name: string): string | undefined {
|
||||||
|
const v = getSecret(name);
|
||||||
|
if (typeof v !== "string") return undefined;
|
||||||
|
if (v.trim().length === 0) return undefined;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireEnv(name: string): string {
|
||||||
|
const v = readEnv(name);
|
||||||
|
if (typeof v === "string") return v;
|
||||||
|
throw new Error(`@resuely/astro-oidc-rp: missing env var ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached: OidcRuntimeOptions | null = null;
|
||||||
|
|
||||||
|
export function getOptions(): OidcRuntimeOptions {
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const injected = getInjected();
|
||||||
|
|
||||||
|
const issuer = readEnv(injected.issuerEnv) ?? injected.issuerFallback;
|
||||||
|
if (!issuer)
|
||||||
|
throw new Error(
|
||||||
|
`@resuely/astro-oidc-rp: missing env var ${injected.issuerEnv}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const clientId = readEnv(injected.clientIdEnv) ?? injected.clientIdFallback;
|
||||||
|
if (!clientId)
|
||||||
|
throw new Error(
|
||||||
|
`@resuely/astro-oidc-rp: missing env var ${injected.clientIdEnv}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
cached = {
|
||||||
|
issuer,
|
||||||
|
clientId,
|
||||||
|
scopes: injected.scopes,
|
||||||
|
routes: injected.routes,
|
||||||
|
redirectUri: injected.redirectUri,
|
||||||
|
cookie: {
|
||||||
|
name: injected.cookie.name,
|
||||||
|
sameSite: injected.cookie.sameSite,
|
||||||
|
secure: injected.cookie.secure,
|
||||||
|
domain: injected.cookie.domain,
|
||||||
|
path: injected.cookie.path,
|
||||||
|
signingSecret: requireEnv(injected.cookie.signingSecretEnv),
|
||||||
|
maxAgeSec: injected.cookie.maxAgeSec,
|
||||||
|
},
|
||||||
|
protected: injected.protected,
|
||||||
|
};
|
||||||
|
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"lib": ["ES2020", "DOM"],
|
"lib": ["ES2020", "DOM"],
|
||||||
"types": ["node"]
|
"types": ["node", "astro/client"]
|
||||||
},
|
},
|
||||||
"include": ["src", "types"],
|
"include": ["src", "types"],
|
||||||
"exclude": ["dist", "node_modules"]
|
"exclude": ["dist", "node_modules"]
|
||||||
|
|||||||
21
tsup.config.ts
Normal file
21
tsup.config.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from "tsup";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: [
|
||||||
|
"src/index.ts",
|
||||||
|
"src/runtime.ts",
|
||||||
|
"src/middleware.ts",
|
||||||
|
"src/routes/login.ts",
|
||||||
|
"src/routes/callback.ts",
|
||||||
|
"src/routes/logout.ts",
|
||||||
|
"src/lib/cookies.ts",
|
||||||
|
"src/lib/pkce.ts",
|
||||||
|
"src/lib/sign.ts",
|
||||||
|
],
|
||||||
|
format: ["esm"],
|
||||||
|
dts: true,
|
||||||
|
sourcemap: true,
|
||||||
|
clean: true,
|
||||||
|
outDir: "dist",
|
||||||
|
external: ["astro:env/server"],
|
||||||
|
});
|
||||||
2
types/astro.locals.d.ts
vendored
2
types/astro.locals.d.ts
vendored
@@ -1,7 +1,7 @@
|
|||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
interface Locals {
|
interface Locals {
|
||||||
user?: { sub: string; email?: string } | null;
|
user?: { sub: string; email?: string; firstName?: string } | null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user