first commit
This commit is contained in:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
.dist
|
||||
/.turbo
|
||||
.vscode
|
||||
.DS_Store
|
||||
coverage
|
||||
npm-debug.log*
|
||||
yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
dist
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Resuely
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
57
README.md
Normal file
57
README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# @resuely/astro-oidc-rp
|
||||
|
||||
Astro integration that injects OIDC login/callback/logout routes, a middleware that sets `Astro.locals.user`, and type augmentation.
|
||||
|
||||
## Install
|
||||
|
||||
npm install @resuely/astro-oidc-rp
|
||||
|
||||
## Usage (astro.config.mjs)
|
||||
|
||||
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! },
|
||||
protected: ["/app/*", "/me"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
- Injected routes:
|
||||
- Login: /login
|
||||
- Callback: /oidc/callback
|
||||
- Logout: /logout
|
||||
|
||||
## 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
|
||||
|
||||
## Types: Astro.locals
|
||||
Enable type augmentation by referencing the package export:
|
||||
|
||||
- Add to your tsconfig.json: { "compilerOptions": { "types": ["@resuely/astro-oidc-rp/astro-locals"] } }
|
||||
|
||||
Then `locals.user` is typed as `{ sub: string; email?: string } | null | undefined`.
|
||||
|
||||
## Security notes
|
||||
- Always provide a strong `cookie.signingSecret`.
|
||||
- In production, cookies are `Secure` by default.
|
||||
- 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
|
||||
|
||||
## License
|
||||
MIT
|
||||
6299
package-lock.json
generated
Normal file
6299
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
63
package.json
Normal file
63
package.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "@resuely/astro-oidc-rp",
|
||||
"version": "0.1.0",
|
||||
"description": "Astro integration providing OIDC relying-party routes, middleware, and types.",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"jose": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"astro": ">=4.0.0 <6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.9",
|
||||
"astro": "^5.16.11",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"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",
|
||||
"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",
|
||||
"postbuild": "mkdir -p dist/types && cp -R types/*.d.ts dist/types/ || true",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"./integration": {
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./middleware": {
|
||||
"import": "./dist/middleware.js"
|
||||
},
|
||||
"./routes/login.ts": {
|
||||
"import": "./dist/routes/login.js"
|
||||
},
|
||||
"./routes/callback.ts": {
|
||||
"import": "./dist/routes/callback.js"
|
||||
},
|
||||
"./routes/logout.ts": {
|
||||
"import": "./dist/routes/logout.js"
|
||||
},
|
||||
"./astro-locals": {
|
||||
"types": "./dist/types/astro.locals.d.ts"
|
||||
}
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": ""
|
||||
}
|
||||
}
|
||||
112
src/index.ts
Normal file
112
src/index.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { AstroIntegration } from "astro";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export type OidcIntegrationOptions = {
|
||||
issuer: string;
|
||||
clientId: string;
|
||||
scopes?: string; // default "openid email profile"
|
||||
routes?: { login?: string; callback?: string; logout?: string }; // defaults: /login, /oidc/callback, /logout
|
||||
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 to guard, e.g., ["/app/*", "/me"]
|
||||
};
|
||||
|
||||
export type OidcResolvedOptions = Required<
|
||||
Pick<OidcIntegrationOptions, "issuer" | "clientId">
|
||||
> & {
|
||||
scopes: string;
|
||||
routes: { login: string; callback: string; logout: string };
|
||||
redirectUri: { mode: "infer-from-request" } | { absolute: string };
|
||||
cookie: Required<
|
||||
Pick<
|
||||
NonNullable<OidcIntegrationOptions["cookie"]>,
|
||||
"name" | "sameSite" | "secure" | "path" | "signingSecret"
|
||||
>
|
||||
> & {
|
||||
domain?: string;
|
||||
maxAgeSec?: number;
|
||||
};
|
||||
protected: string[];
|
||||
};
|
||||
|
||||
function resolveOptions(user: OidcIntegrationOptions): OidcResolvedOptions {
|
||||
const routes = {
|
||||
login: user.routes?.login ?? "/login",
|
||||
callback: user.routes?.callback ?? "/oidc/callback",
|
||||
logout: user.routes?.logout ?? "/logout",
|
||||
};
|
||||
const cookie = {
|
||||
name: user.cookie?.name ?? "oidc_session",
|
||||
sameSite: user.cookie?.sameSite ?? "Lax",
|
||||
secure: user.cookie?.secure ?? true,
|
||||
path: user.cookie?.path ?? "/",
|
||||
signingSecret: user.cookie?.signingSecret ?? "",
|
||||
domain: user.cookie?.domain,
|
||||
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 {
|
||||
issuer: user.issuer,
|
||||
clientId: user.clientId,
|
||||
scopes: user.scopes ?? "openid email profile",
|
||||
routes,
|
||||
redirectUri: user.redirectUri ?? { mode: "infer-from-request" },
|
||||
cookie,
|
||||
protected: user.protected ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export default function resuelyOidc(
|
||||
options: OidcIntegrationOptions,
|
||||
): AstroIntegration {
|
||||
const resolved = resolveOptions(options);
|
||||
return {
|
||||
name: "@resuely/astro-oidc-rp",
|
||||
hooks: {
|
||||
"astro:config:setup": ({ injectRoute, addMiddleware, updateConfig }) => {
|
||||
// Provide runtime options to middleware/routes via Vite define replacement
|
||||
updateConfig({
|
||||
vite: {
|
||||
define: {
|
||||
__RESUELY_OIDC_OPTIONS: JSON.stringify(resolved),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Add middleware (run early to populate locals and guard protected patterns)
|
||||
addMiddleware({
|
||||
entrypoint: fileURLToPath(new URL("./middleware.js", import.meta.url)),
|
||||
order: "pre",
|
||||
});
|
||||
|
||||
// Inject routes
|
||||
injectRoute({
|
||||
pattern: resolved.routes.login,
|
||||
entrypoint: fileURLToPath(new URL("./routes/login.js", import.meta.url)),
|
||||
});
|
||||
injectRoute({
|
||||
pattern: resolved.routes.callback,
|
||||
entrypoint: fileURLToPath(new URL("./routes/callback.js", import.meta.url)),
|
||||
});
|
||||
injectRoute({
|
||||
pattern: resolved.routes.logout,
|
||||
entrypoint: fileURLToPath(new URL("./routes/logout.js", import.meta.url)),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
38
src/lib/cookies.ts
Normal file
38
src/lib/cookies.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export type CookieOptions = {
|
||||
httpOnly?: boolean;
|
||||
secure?: boolean;
|
||||
sameSite?: "Lax" | "Strict" | "None";
|
||||
domain?: string;
|
||||
path?: string;
|
||||
maxAge?: number; // seconds
|
||||
expires?: Date;
|
||||
};
|
||||
|
||||
function toUTC(date: Date): string {
|
||||
return date.toUTCString();
|
||||
}
|
||||
|
||||
export function serializeCookie(
|
||||
name: string,
|
||||
value: string,
|
||||
opts: CookieOptions = {},
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(`${encodeURIComponent(name)}=${encodeURIComponent(value)}`);
|
||||
if (opts.maxAge != null) parts.push(`Max-Age=${Math.floor(opts.maxAge)}`);
|
||||
if (opts.expires) parts.push(`Expires=${toUTC(opts.expires)}`);
|
||||
if (opts.domain) parts.push(`Domain=${opts.domain}`);
|
||||
if (opts.path) parts.push(`Path=${opts.path}`);
|
||||
if (opts.sameSite) parts.push(`SameSite=${opts.sameSite}`);
|
||||
if (opts.secure) parts.push(`Secure`);
|
||||
if (opts.httpOnly ?? true) parts.push(`HttpOnly`);
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
export function clearCookie(name: string, opts: CookieOptions = {}): string {
|
||||
return serializeCookie(name, "", {
|
||||
...opts,
|
||||
maxAge: 0,
|
||||
expires: new Date(0),
|
||||
});
|
||||
}
|
||||
46
src/lib/pkce.ts
Normal file
46
src/lib/pkce.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
const te = new TextEncoder();
|
||||
const cryptoRef: any = (globalThis as any).crypto ?? webcrypto;
|
||||
|
||||
export function base64url(data: ArrayBuffer | Uint8Array | string): string {
|
||||
let bytes: Uint8Array;
|
||||
if (typeof data === "string") bytes = te.encode(data);
|
||||
else bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
||||
let str = Buffer.from(bytes).toString("base64");
|
||||
return str.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
||||
}
|
||||
|
||||
export async function sha256(input: string | Uint8Array): Promise<ArrayBuffer> {
|
||||
const bytes = typeof input === "string" ? te.encode(input) : input;
|
||||
return await cryptoRef.subtle.digest("SHA-256", bytes as unknown as BufferSource);
|
||||
}
|
||||
|
||||
function randomBytes(len: number): Uint8Array {
|
||||
const arr = new Uint8Array(len);
|
||||
cryptoRef.getRandomValues(arr);
|
||||
return arr;
|
||||
}
|
||||
|
||||
function randomUrlSafe(size = 32): string {
|
||||
// 32 bytes -> 43 chars base64url
|
||||
return base64url(randomBytes(size));
|
||||
}
|
||||
|
||||
export function generateCodeVerifier(): string {
|
||||
// RFC 7636: between 43 and 128 characters. Use 64 for good entropy.
|
||||
return randomUrlSafe(48); // ~64 chars
|
||||
}
|
||||
|
||||
export async function codeChallengeS256(verifier: string): Promise<string> {
|
||||
const hash = await sha256(verifier);
|
||||
return base64url(hash);
|
||||
}
|
||||
|
||||
export function generateState(): string {
|
||||
return randomUrlSafe(32);
|
||||
}
|
||||
|
||||
export function generateNonce(): string {
|
||||
return randomUrlSafe(32);
|
||||
}
|
||||
68
src/lib/sign.ts
Normal file
68
src/lib/sign.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
const te = new TextEncoder();
|
||||
|
||||
function b64uToBytes(input: string): Uint8Array {
|
||||
// pad and replace
|
||||
input = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const pad = input.length % 4;
|
||||
if (pad) input += "=".repeat(4 - pad);
|
||||
return new Uint8Array(Buffer.from(input, "base64"));
|
||||
}
|
||||
|
||||
function bytesToB64u(bytes: Uint8Array | ArrayBuffer): string {
|
||||
const buf = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||
return Buffer.from(buf)
|
||||
.toString("base64")
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
}
|
||||
|
||||
async function importKey(secret: string): Promise<CryptoKey> {
|
||||
return await crypto.subtle.importKey(
|
||||
"raw",
|
||||
te.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign", "verify"],
|
||||
);
|
||||
}
|
||||
|
||||
export async function signPayload(
|
||||
payload: unknown,
|
||||
secret: string,
|
||||
): Promise<string> {
|
||||
const payloadJson =
|
||||
typeof payload === "string" ? payload : JSON.stringify(payload);
|
||||
const payloadB64u = bytesToB64u(te.encode(payloadJson));
|
||||
const key = await importKey(secret);
|
||||
const sig = await crypto.subtle.sign("HMAC", key, te.encode(payloadB64u));
|
||||
const sigB64u = bytesToB64u(new Uint8Array(sig));
|
||||
return `${payloadB64u}.${sigB64u}`;
|
||||
}
|
||||
|
||||
export async function verifyAndDecode<T = any>(
|
||||
token: string,
|
||||
secret: string,
|
||||
): Promise<{ valid: boolean; payload?: T }> {
|
||||
const [payloadB64u, sigB64u] = token.split(".");
|
||||
if (!payloadB64u || !sigB64u) return { valid: false };
|
||||
const key = await importKey(secret);
|
||||
const expected = await crypto.subtle.sign(
|
||||
"HMAC",
|
||||
key,
|
||||
te.encode(payloadB64u),
|
||||
);
|
||||
const given = b64uToBytes(sigB64u);
|
||||
const expectedBytes = new Uint8Array(expected);
|
||||
if (given.length !== expectedBytes.length) return { valid: false };
|
||||
// Constant-time compare
|
||||
let diff = 0;
|
||||
for (let i = 0; i < given.length; i++) diff |= given[i] ^ expectedBytes[i];
|
||||
if (diff !== 0) return { valid: false };
|
||||
try {
|
||||
const json = Buffer.from(b64uToBytes(payloadB64u)).toString("utf8");
|
||||
return { valid: true, payload: JSON.parse(json) as T };
|
||||
} catch {
|
||||
return { valid: false };
|
||||
}
|
||||
}
|
||||
57
src/middleware.ts
Normal file
57
src/middleware.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { MiddlewareHandler } from "astro";
|
||||
import { options } from "./runtime.js";
|
||||
import { verifyAndDecode } from "./lib/sign.js";
|
||||
|
||||
function parseCookies(cookieHeader: string | null): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!cookieHeader) return out;
|
||||
const parts = cookieHeader.split(/;\s*/);
|
||||
for (const part of parts) {
|
||||
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 escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function globToRegExp(pattern: string): RegExp {
|
||||
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));
|
||||
}
|
||||
|
||||
export const onRequest: MiddlewareHandler = async (context, next) => {
|
||||
const { request, locals, url } = context;
|
||||
const cookieName = options.cookie.name;
|
||||
const cookies = parseCookies(request.headers.get("cookie"));
|
||||
const token = cookies[cookieName];
|
||||
|
||||
locals.user = null;
|
||||
if (token) {
|
||||
const res = await verifyAndDecode<{ sub: string; email?: string }>(
|
||||
token,
|
||||
options.cookie.signingSecret,
|
||||
);
|
||||
if (res.valid && res.payload && typeof res.payload.sub === "string") {
|
||||
locals.user = { sub: res.payload.sub, email: res.payload.email };
|
||||
}
|
||||
}
|
||||
|
||||
if (isProtected(url.pathname, options.protected) && !locals.user) {
|
||||
const returnTo = url.pathname + (url.search || "");
|
||||
const loginUrl = new URL(options.routes.login, url);
|
||||
loginUrl.searchParams.set("return_to", returnTo);
|
||||
return Response.redirect(loginUrl, 302);
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
106
src/routes/callback.ts
Normal file
106
src/routes/callback.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { APIContext } from "astro";
|
||||
import { options } from "../runtime.js";
|
||||
import { clearCookie, serializeCookie } from "../lib/cookies.js";
|
||||
import { signPayload } from "../lib/sign.js";
|
||||
import { createRemoteJWKSet, jwtVerify } from "jose";
|
||||
|
||||
async function discover(issuer: string) {
|
||||
const res = await fetch(
|
||||
new URL("/.well-known/openid-configuration", issuer).toString(),
|
||||
);
|
||||
if (!res.ok) throw new Error(`OIDC discovery failed: ${res.status}`);
|
||||
return (await res.json()) as {
|
||||
token_endpoint: string;
|
||||
jwks_uri: string;
|
||||
};
|
||||
}
|
||||
|
||||
function inferRedirectUri(reqUrl: URL): string {
|
||||
if ("absolute" in options.redirectUri) return options.redirectUri.absolute;
|
||||
const u = new URL(options.routes.callback, reqUrl);
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
export async function GET(ctx: APIContext) {
|
||||
const { url, request } = ctx;
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
|
||||
if (!code || !state)
|
||||
return new Response("Missing code/state", { status: 400 });
|
||||
|
||||
const initCookieName = `${options.cookie.name}_init`;
|
||||
const cookieHeader = request.headers.get("cookie") || "";
|
||||
const init = (() => {
|
||||
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)
|
||||
return new Response("Invalid state", { status: 400 });
|
||||
|
||||
const redirectUri = inferRedirectUri(url);
|
||||
const disco = await discover(options.issuer);
|
||||
|
||||
const form = new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: options.clientId,
|
||||
code_verifier: init.verifier,
|
||||
});
|
||||
|
||||
const tokenRes = await fetch(disco.token_endpoint, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: form.toString(),
|
||||
});
|
||||
if (!tokenRes.ok)
|
||||
return new Response("Token exchange failed", { status: 400 });
|
||||
const token = await tokenRes.json();
|
||||
const idToken = token.id_token as string | undefined;
|
||||
if (!idToken) return new Response("Missing id_token", { status: 400 });
|
||||
|
||||
const jwks = createRemoteJWKSet(new URL(disco.jwks_uri));
|
||||
const { payload } = await jwtVerify(idToken, jwks, {
|
||||
issuer: options.issuer,
|
||||
audience: options.clientId,
|
||||
});
|
||||
|
||||
const session = {
|
||||
sub: String(payload.sub),
|
||||
email: typeof payload.email === "string" ? payload.email : undefined,
|
||||
};
|
||||
const signed = await signPayload(session, options.cookie.signingSecret);
|
||||
|
||||
const setSession = serializeCookie(options.cookie.name, signed, {
|
||||
path: options.cookie.path,
|
||||
domain: options.cookie.domain,
|
||||
sameSite: options.cookie.sameSite,
|
||||
secure: options.cookie.secure,
|
||||
httpOnly: true,
|
||||
maxAge: options.cookie.maxAgeSec,
|
||||
});
|
||||
const clearInit = clearCookie(initCookieName, {
|
||||
path: options.cookie.path,
|
||||
domain: options.cookie.domain,
|
||||
});
|
||||
|
||||
const returnTo = typeof init.return_to === "string" ? init.return_to : "/";
|
||||
const location = new URL(returnTo, url).toString();
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set("Location", location);
|
||||
headers.append("Set-Cookie", setSession);
|
||||
headers.append("Set-Cookie", clearInit);
|
||||
|
||||
return new Response(null, {
|
||||
status: 303,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
71
src/routes/login.ts
Normal file
71
src/routes/login.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { APIContext } from "astro";
|
||||
import { options } from "../runtime.js";
|
||||
import {
|
||||
generateCodeVerifier,
|
||||
codeChallengeS256,
|
||||
generateState,
|
||||
generateNonce,
|
||||
} from "../lib/pkce.js";
|
||||
import { serializeCookie } from "../lib/cookies.js";
|
||||
|
||||
async function discover(issuer: string) {
|
||||
const res = await fetch(
|
||||
new URL("/.well-known/openid-configuration", issuer).toString(),
|
||||
);
|
||||
if (!res.ok) throw new Error(`OIDC discovery failed: ${res.status}`);
|
||||
return (await res.json()) as {
|
||||
authorization_endpoint: string;
|
||||
};
|
||||
}
|
||||
|
||||
function inferRedirectUri(reqUrl: URL): string {
|
||||
if ("absolute" in options.redirectUri) return options.redirectUri.absolute;
|
||||
const u = new URL(options.routes.callback, reqUrl);
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
export async function GET(ctx: APIContext) {
|
||||
const { url } = ctx;
|
||||
const verifier = generateCodeVerifier();
|
||||
const challenge = await codeChallengeS256(verifier);
|
||||
const state = generateState();
|
||||
const nonce = generateNonce();
|
||||
|
||||
const returnTo = url.searchParams.get("return_to") || undefined;
|
||||
|
||||
const initPayload = JSON.stringify({
|
||||
state,
|
||||
nonce,
|
||||
verifier,
|
||||
return_to: returnTo,
|
||||
});
|
||||
const initCookieName = `${options.cookie.name}_init`;
|
||||
const cookie = serializeCookie(initCookieName, initPayload, {
|
||||
path: options.cookie.path,
|
||||
domain: options.cookie.domain,
|
||||
sameSite: "Lax",
|
||||
secure: options.cookie.secure,
|
||||
httpOnly: true,
|
||||
maxAge: 5 * 60, // 5 minutes
|
||||
});
|
||||
|
||||
const disco = await discover(options.issuer);
|
||||
const redirectUri = inferRedirectUri(url);
|
||||
const authorize = new URL(disco.authorization_endpoint);
|
||||
authorize.searchParams.set("client_id", options.clientId);
|
||||
authorize.searchParams.set("redirect_uri", redirectUri);
|
||||
authorize.searchParams.set("response_type", "code");
|
||||
authorize.searchParams.set("scope", options.scopes);
|
||||
authorize.searchParams.set("code_challenge", challenge);
|
||||
authorize.searchParams.set("code_challenge_method", "S256");
|
||||
authorize.searchParams.set("state", state);
|
||||
authorize.searchParams.set("nonce", nonce);
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: authorize.toString(),
|
||||
"Set-Cookie": cookie,
|
||||
},
|
||||
});
|
||||
}
|
||||
45
src/routes/logout.ts
Normal file
45
src/routes/logout.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { APIContext } from "astro";
|
||||
import { options } from "../runtime.js";
|
||||
import { clearCookie } from "../lib/cookies.js";
|
||||
|
||||
async function discover(issuer: string) {
|
||||
const res = await fetch(
|
||||
new URL("/.well-known/openid-configuration", issuer).toString(),
|
||||
);
|
||||
if (!res.ok) throw new Error(`OIDC discovery failed: ${res.status}`);
|
||||
return (await res.json()) as {
|
||||
end_session_endpoint?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function inferPostLogoutRedirectUri(reqUrl: URL): string {
|
||||
if ("absolute" in options.redirectUri) return options.redirectUri.absolute;
|
||||
// Default to site root
|
||||
const u = new URL("/", reqUrl);
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
export async function GET(ctx: APIContext) {
|
||||
const { url } = ctx;
|
||||
const disco = await discover(options.issuer);
|
||||
const endSession =
|
||||
disco.end_session_endpoint || new URL("/logout", options.issuer).toString();
|
||||
|
||||
const postLogout = inferPostLogoutRedirectUri(url);
|
||||
const redirect = new URL(endSession);
|
||||
redirect.searchParams.set("client_id", options.clientId);
|
||||
redirect.searchParams.set("post_logout_redirect_uri", postLogout);
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
11
src/runtime.ts
Normal file
11
src/runtime.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { OidcResolvedOptions } from "./index.js";
|
||||
|
||||
// This constant is provided via Vite define by the integration during astro:config:setup
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
declare const __RESUELY_OIDC_OPTIONS: OidcResolvedOptions;
|
||||
|
||||
export const options: OidcResolvedOptions = (
|
||||
typeof __RESUELY_OIDC_OPTIONS !== "undefined"
|
||||
? __RESUELY_OIDC_OPTIONS
|
||||
: (undefined as any)
|
||||
) as OidcResolvedOptions;
|
||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src", "types"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
8
types/astro.locals.d.ts
vendored
Normal file
8
types/astro.locals.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
user?: { sub: string; email?: string } | null;
|
||||
}
|
||||
}
|
||||
}
|
||||
export {};
|
||||
Reference in New Issue
Block a user