From 49efc50f42c0aebdef617e8647af0cb1e7b7779c Mon Sep 17 00:00:00 2001 From: Johannes Date: Sun, 7 Apr 2024 18:31:39 +0100 Subject: [PATCH 1/2] Add access check to middleware --- .dev.vars | 1 + package.json | 5 +- pnpm-lock.yaml | 12 ++++ src/access.ts | 135 ++++++++++++++++++++++++++++++++++++++++++++ src/hooks.server.ts | 21 ++++++- 5 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 .dev.vars create mode 100644 src/access.ts diff --git a/.dev.vars b/.dev.vars new file mode 100644 index 0000000..9410ca0 --- /dev/null +++ b/.dev.vars @@ -0,0 +1 @@ +IS_LOCAL_MODE=1 diff --git a/package.json b/package.json index bb6457b..9d68767 100644 --- a/package.json +++ b/package.json @@ -79,5 +79,8 @@ "bugs": { "url": "https://github.com/JacobLinCool/d1-manager/issues" }, - "packageManager": "pnpm@8.14.3" + "packageManager": "pnpm@8.14.3", + "dependencies": { + "@cloudflare/pages-plugin-cloudflare-access": "^1.0.4" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be58da6..53da6e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +dependencies: + "@cloudflare/pages-plugin-cloudflare-access": + specifier: ^1.0.4 + version: 1.0.4 + devDependencies: "@ai-d/aid": specifier: ^0.1.5 @@ -502,6 +507,13 @@ packages: mime: 3.0.0 dev: true + /@cloudflare/pages-plugin-cloudflare-access@1.0.4: + resolution: + { + integrity: sha512-9R80Y4a+TSneX0v8zkwAc6scpYTMxxfyWI9BB3HJLkTtEviASTjZnV4tPjMk90bOtL9yoHT+Xpo90pSaplugUg==, + } + dev: false + /@cloudflare/workerd-darwin-64@1.20231218.0: resolution: { diff --git a/src/access.ts b/src/access.ts new file mode 100644 index 0000000..0eac630 --- /dev/null +++ b/src/access.ts @@ -0,0 +1,135 @@ +// Copied from https://github.com/cloudflare/pages-plugins/blob/434fad8db20e483cc532d9c678d46a73a4ae7115/packages/cloudflare-access/functions/_middleware.ts + +import type { PluginArgs } from "@cloudflare/pages-plugin-cloudflare-access"; +import { generateLoginURL, getIdentity } from "@cloudflare/pages-plugin-cloudflare-access/api"; + +type CloudflareAccessPagesPluginFunction< + Env = unknown, + Params extends string = any, + Data extends Record = Record, +> = PagesPluginFunction; + +const extractJWTFromRequest = (request: Request) => request.headers.get("Cf-Access-Jwt-Assertion"); + +// Adapted slightly from https://github.com/cloudflare/workers-access-external-auth-example +const base64URLDecode = (s: string) => { + s = s.replace(/-/g, "+").replace(/_/g, "/").replace(/\s/g, ""); + return new Uint8Array(Array.from(atob(s)).map((c: string) => c.charCodeAt(0))); +}; + +const asciiToUint8Array = (s: string) => { + const chars = []; + for (let i = 0; i < s.length; ++i) { + chars.push(s.charCodeAt(i)); + } + return new Uint8Array(chars); +}; + +const generateValidator = + ({ domain, aud }: { domain: string; aud: string }) => + async ( + request: Request, + ): Promise<{ + jwt: string; + payload: object; + }> => { + const jwt = extractJWTFromRequest(request); + + const parts = jwt.split("."); + if (parts.length !== 3) { + throw new Error("JWT does not have three parts."); + } + const [header, payload, signature] = parts; + + const textDecoder = new TextDecoder("utf-8"); + const { kid, alg } = JSON.parse(textDecoder.decode(base64URLDecode(header))); + if (alg !== "RS256") { + throw new Error("Unknown JWT type or algorithm."); + } + + const certsURL = new URL("/cdn-cgi/access/certs", domain); + const certsResponse = await fetch(certsURL.toString()); + const { keys } = (await certsResponse.json()) as { + keys: ({ + kid: string; + } & JsonWebKey)[]; + public_cert: { kid: string; cert: string }; + public_certs: { kid: string; cert: string }[]; + }; + if (!keys) { + throw new Error("Could not fetch signing keys."); + } + const jwk = keys.find((key) => key.kid === kid); + if (!jwk) { + throw new Error("Could not find matching signing key."); + } + if (jwk.kty !== "RSA" || jwk.alg !== "RS256") { + throw new Error("Unknown key type of algorithm."); + } + + const key = await crypto.subtle.importKey( + "jwk", + jwk, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["verify"], + ); + + const unroundedSecondsSinceEpoch = Date.now() / 1000; + + const payloadObj = JSON.parse(textDecoder.decode(base64URLDecode(payload))); + + if (payloadObj.iss && payloadObj.iss !== certsURL.origin) { + throw new Error("JWT issuer is incorrect."); + } + if (payloadObj.aud && !payloadObj.aud.includes(aud)) { + throw new Error("JWT audience is incorrect."); + } + if (payloadObj.exp && Math.floor(unroundedSecondsSinceEpoch) >= payloadObj.exp) { + throw new Error("JWT has expired."); + } + if (payloadObj.nbf && Math.ceil(unroundedSecondsSinceEpoch) < payloadObj.nbf) { + throw new Error("JWT is not yet valid."); + } + + const verified = await crypto.subtle.verify( + "RSASSA-PKCS1-v1_5", + key, + base64URLDecode(signature), + asciiToUint8Array(`${header}.${payload}`), + ); + if (!verified) { + throw new Error("Could not verify JWT."); + } + + return { jwt, payload: payloadObj }; + }; + +export const onRequest: CloudflareAccessPagesPluginFunction = async ({ + request, + pluginArgs: { domain, aud }, + data, + next, +}) => { + try { + const validator = generateValidator({ domain, aud }); + + const { jwt, payload } = await validator(request); + + data.cloudflareAccess = { + JWT: { + payload, + getIdentity: () => getIdentity({ jwt, domain }), + }, + }; + + return next(); + } catch {} + + return new Response(null, { + status: 302, + headers: { + Location: generateLoginURL({ redirectURL: request.url, domain, aud }), + }, + }); +}; diff --git a/src/hooks.server.ts b/src/hooks.server.ts index a5ef7a2..9fa1fa9 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,9 +1,11 @@ +import { env } from "$env/dynamic/private"; import { extend } from "$lib/log"; import { DBMS } from "$lib/server/db/dbms"; import type { Handle, HandleServerError } from "@sveltejs/kit"; import { locale, waitLocale } from "svelte-i18n"; +import { onRequest } from "./access"; -export const handle: Handle = async ({ event, resolve }) => { +const handler: Handle = async ({ event, resolve }) => { const lang = event.request.headers.get("accept-language")?.split(",")[0] || "en"; locale.set(lang); await waitLocale(lang); @@ -14,6 +16,23 @@ export const handle: Handle = async ({ event, resolve }) => { return result; }; +export const handle: Handle = async ({ event, resolve }) => { + console.log(event.request.url); + // check request is authenticated + if (env.IS_LOCAL_MODE === "1") { + return await handler({ event, resolve }); + } else { + return await onRequest({ + request: event.request, + pluginArgs: { domain: env.ACCESS_DOMAIN, aud: env.ACCESS_AUD }, + data: {}, + next: async () => { + return await handler({ event, resolve }); + }, + }); + } +}; + const elog = extend("server-error"); elog.enabled = true; From 46757fce354462050f9e45ae61fa88d0524ace37 Mon Sep 17 00:00:00 2001 From: Johannes Date: Sun, 7 Apr 2024 21:28:41 +0100 Subject: [PATCH 2/2] Check for cookie as well as header --- package.json | 4 +++- pnpm-lock.yaml | 7 ++++++- src/access.ts | 8 +++++--- src/app.d.ts | 2 ++ src/hooks.server.ts | 8 +++++--- 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 9d68767..53d1166 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@sveltejs/kit": "^2.4.3", "@sveltejs/vite-plugin-svelte": "^3.0.1", "@tailwindcss/typography": "^0.5.10", + "@types/cookie": "^0.6.0", "@types/debug": "^4.1.12", "@types/sql.js": "^1.4.9", "@typescript-eslint/eslint-plugin": "^6.19.1", @@ -81,6 +82,7 @@ }, "packageManager": "pnpm@8.14.3", "dependencies": { - "@cloudflare/pages-plugin-cloudflare-access": "^1.0.4" + "@cloudflare/pages-plugin-cloudflare-access": "^1.0.4", + "cookie": "^0.6.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53da6e0..3834626 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ dependencies: "@cloudflare/pages-plugin-cloudflare-access": specifier: ^1.0.4 version: 1.0.4 + cookie: + specifier: ^0.6.0 + version: 0.6.0 devDependencies: "@ai-d/aid": @@ -43,6 +46,9 @@ devDependencies: "@tailwindcss/typography": specifier: ^0.5.10 version: 0.5.10(tailwindcss@3.4.1) + "@types/cookie": + specifier: ^0.6.0 + version: 0.6.0 "@types/debug": specifier: ^4.1.12 version: 4.1.12 @@ -3004,7 +3010,6 @@ packages: integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==, } engines: { node: ">= 0.6" } - dev: true /cross-spawn@5.1.0: resolution: diff --git a/src/access.ts b/src/access.ts index 0eac630..02b3cfa 100644 --- a/src/access.ts +++ b/src/access.ts @@ -1,7 +1,7 @@ // Copied from https://github.com/cloudflare/pages-plugins/blob/434fad8db20e483cc532d9c678d46a73a4ae7115/packages/cloudflare-access/functions/_middleware.ts - import type { PluginArgs } from "@cloudflare/pages-plugin-cloudflare-access"; import { generateLoginURL, getIdentity } from "@cloudflare/pages-plugin-cloudflare-access/api"; +import { parse as parseCookies } from "cookie"; type CloudflareAccessPagesPluginFunction< Env = unknown, @@ -9,7 +9,10 @@ type CloudflareAccessPagesPluginFunction< Data extends Record = Record, > = PagesPluginFunction; -const extractJWTFromRequest = (request: Request) => request.headers.get("Cf-Access-Jwt-Assertion"); +const extractJWTFromRequest = (request: Request) => + request.headers.get("Cf-Access-Jwt-Assertion") || + // I had to add this as some requests didn't have the header, just the cookie.. + parseCookies(request.headers.get("Cookie") || "")["CF_Authorization"]; // Adapted slightly from https://github.com/cloudflare/workers-access-external-auth-example const base64URLDecode = (s: string) => { @@ -34,7 +37,6 @@ const generateValidator = payload: object; }> => { const jwt = extractJWTFromRequest(request); - const parts = jwt.split("."); if (parts.length !== 3) { throw new Error("JWT does not have three parts."); diff --git a/src/app.d.ts b/src/app.d.ts index 016abb0..ba9ac93 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -12,6 +12,8 @@ declare global { SHOW_INTERNAL_TABLES?: string; OPENAI_API_KEY?: string; AI?: unknown; + ACCESS_DOMAIN?: string; + ACCESS_AUD?: string; } & Record; } } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 9fa1fa9..2556e55 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,4 +1,3 @@ -import { env } from "$env/dynamic/private"; import { extend } from "$lib/log"; import { DBMS } from "$lib/server/db/dbms"; import type { Handle, HandleServerError } from "@sveltejs/kit"; @@ -19,12 +18,15 @@ const handler: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => { console.log(event.request.url); // check request is authenticated - if (env.IS_LOCAL_MODE === "1") { + if (event.platform?.env.IS_LOCAL_MODE === "1") { return await handler({ event, resolve }); } else { return await onRequest({ request: event.request, - pluginArgs: { domain: env.ACCESS_DOMAIN, aud: env.ACCESS_AUD }, + pluginArgs: { + domain: event.platform?.env.ACCESS_DOMAIN, + aud: event.platform?.env.ACCESS_AUD, + }, data: {}, next: async () => { return await handler({ event, resolve });