From 49b79bd16da3cae7f1df01a24bcd21a951442efa Mon Sep 17 00:00:00 2001 From: Benjamin Okkema Date: Thu, 5 Sep 2024 20:29:06 -0500 Subject: [PATCH] feat: add api and middleware --- package-lock.json | 157 ++++++++++++++++++++++++++++++++-------- package.json | 8 +- src/auth/jwt.test.ts | 4 +- src/auth/jwt.ts | 13 ++++ src/core/api.ts | 40 ++++++++++ src/core/index.ts | 1 + src/crypto/rsa.ts | 1 + src/middleware/auth.ts | 52 +++++++++++++ src/middleware/error.ts | 11 +++ src/middleware/index.ts | 2 + 10 files changed, 256 insertions(+), 33 deletions(-) create mode 100644 src/core/api.ts create mode 100644 src/middleware/auth.ts create mode 100644 src/middleware/error.ts create mode 100644 src/middleware/index.ts diff --git a/package-lock.json b/package-lock.json index 0ff5b93..5686a88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { "name": "@okkema/worker", - "version": "2.0.1", + "version": "2.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@okkema/worker", - "version": "2.0.1", + "version": "2.1.0", "hasInstallScript": true, "license": "MIT", "dependencies": { "@cloudflare/workers-types": "^4.20240222.0", + "chanfana": "^2.0.4", + "hono": "^4.5.11", "npm-run-all": "^4.1.5", "rfc4648": "^1.5.2", "typescript": "^5.1.6" @@ -56,6 +58,17 @@ "node": ">=6.0.0" } }, + "node_modules/@asteasolutions/zod-to-openapi": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-7.1.1.tgz", + "integrity": "sha512-lF0d1gAc0lYLO9/BAGivwTwE2Sh9h6CHuDcbk5KnGBfIuAsAkDC+Fdat4dkQY3CS/zUWKHRmFEma0B7X132Ymw==", + "dependencies": { + "openapi3-ts": "^4.1.2" + }, + "peerDependencies": { + "zod": "^3.20.2" + } + }, "node_modules/@babel/code-frame": { "version": "7.23.5", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", @@ -3330,8 +3343,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/array-buffer-byte-length": { "version": "1.0.0", @@ -3634,12 +3646,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -3776,6 +3788,17 @@ "node": ">=4" } }, + "node_modules/chanfana": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/chanfana/-/chanfana-2.0.4.tgz", + "integrity": "sha512-iy2rcZaMl6eTMPlLdOtqB+pbUmkNeV4LTGnen47+xXYSmeTJmxI5bMgzsPPu6n//lbghaDpyBnZKV+Tqg8A37g==", + "dependencies": { + "@asteasolutions/zod-to-openapi": "^7.1.1", + "js-yaml": "^4.1.0", + "openapi3-ts": "^4.3.2", + "zod": "^3.23.8" + } + }, "node_modules/char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -4727,9 +4750,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -5070,6 +5093,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hono": { + "version": "4.5.11", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.5.11.tgz", + "integrity": "sha512-62FcjLPtjAFwISVBUshryl+vbHOjg8rE4uIK/dxyR8GpLztunZpwFmfEvmJCUI7xoGh/Sr3CGCDPCmYxVw7wUQ==", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/hosted-git-info": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", @@ -7132,7 +7163,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -7414,12 +7444,12 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -7681,6 +7711,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi3-ts": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.4.0.tgz", + "integrity": "sha512-9asTNB9IkKEzWMcHmVZE7Ts3kC9G7AFHfs8i7caD8HbI76gEjdkId4z/AkP83xdZsH7PLAnnbl47qZkXuxpArw==", + "dependencies": { + "yaml": "^2.5.0" + } + }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -9174,6 +9212,17 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true }, + "node_modules/yaml": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", + "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -9212,6 +9261,14 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } }, "dependencies": { @@ -9231,6 +9288,14 @@ "@jridgewell/trace-mapping": "^0.3.9" } }, + "@asteasolutions/zod-to-openapi": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-7.1.1.tgz", + "integrity": "sha512-lF0d1gAc0lYLO9/BAGivwTwE2Sh9h6CHuDcbk5KnGBfIuAsAkDC+Fdat4dkQY3CS/zUWKHRmFEma0B7X132Ymw==", + "requires": { + "openapi3-ts": "^4.1.2" + } + }, "@babel/code-frame": { "version": "7.23.5", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", @@ -11585,8 +11650,7 @@ "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "array-buffer-byte-length": { "version": "1.0.0", @@ -11813,12 +11877,12 @@ } }, "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "requires": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" } }, "browser-process-hrtime": { @@ -11900,6 +11964,17 @@ "supports-color": "^5.3.0" } }, + "chanfana": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/chanfana/-/chanfana-2.0.4.tgz", + "integrity": "sha512-iy2rcZaMl6eTMPlLdOtqB+pbUmkNeV4LTGnen47+xXYSmeTJmxI5bMgzsPPu6n//lbghaDpyBnZKV+Tqg8A37g==", + "requires": { + "@asteasolutions/zod-to-openapi": "^7.1.1", + "js-yaml": "^4.1.0", + "openapi3-ts": "^4.3.2", + "zod": "^3.23.8" + } + }, "char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -12570,9 +12645,9 @@ } }, "fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "requires": { "to-regex-range": "^5.0.1" @@ -12804,6 +12879,11 @@ "has-symbols": "^1.0.2" } }, + "hono": { + "version": "4.5.11", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.5.11.tgz", + "integrity": "sha512-62FcjLPtjAFwISVBUshryl+vbHOjg8rE4uIK/dxyR8GpLztunZpwFmfEvmJCUI7xoGh/Sr3CGCDPCmYxVw7wUQ==" + }, "hosted-git-info": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", @@ -14300,7 +14380,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "requires": { "argparse": "^2.0.1" } @@ -14526,12 +14605,12 @@ "dev": true }, "micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "requires": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" } }, @@ -14729,6 +14808,14 @@ "is-wsl": "^2.2.0" } }, + "openapi3-ts": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.4.0.tgz", + "integrity": "sha512-9asTNB9IkKEzWMcHmVZE7Ts3kC9G7AFHfs8i7caD8HbI76gEjdkId4z/AkP83xdZsH7PLAnnbl47qZkXuxpArw==", + "requires": { + "yaml": "^2.5.0" + } + }, "optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -15797,6 +15884,11 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true }, + "yaml": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", + "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==" + }, "yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -15823,6 +15915,11 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true + }, + "zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==" } } } diff --git a/package.json b/package.json index a8b8d0e..2091ace 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@okkema/worker", - "version": "2.0.1", + "version": "2.1.0", "description": "Cloudflare Workers Toolkit", "files": [ "dist" @@ -15,6 +15,9 @@ "crypto": [ "./dist/crypto/index.d.ts" ], + "middleware": [ + "./dist/middleware/index.d.ts" + ], "utils": [ "./dist/utils/index.d.ts" ] @@ -24,6 +27,7 @@ ".": "./dist/core/index.js", "./auth": "./dist/auth/index.js", "./crypto": "./dist/crypto/index.js", + "./middleware": "./dist/middleware/index.js", "./utils": "./dist/utils/index.js" }, "scripts": { @@ -76,6 +80,8 @@ }, "dependencies": { "@cloudflare/workers-types": "^4.20240222.0", + "chanfana": "^2.0.4", + "hono": "^4.5.11", "npm-run-all": "^4.1.5", "rfc4648": "^1.5.2", "typescript": "^5.1.6" diff --git a/src/auth/jwt.test.ts b/src/auth/jwt.test.ts index a1128f5..9f9e757 100644 --- a/src/auth/jwt.test.ts +++ b/src/auth/jwt.test.ts @@ -17,12 +17,12 @@ const createJWT = () => ({ typ: "JWT", }, payload: { - aud: ["audience"], + aud: ["https://audience/"], exp: Date.now() / 1000 + 1000, iss: "issuer", sub: "subject", }, - signature: "", + signature: new Uint8Array(), }, raw: { header: "", diff --git a/src/auth/jwt.ts b/src/auth/jwt.ts index ea2fad7..8f953de 100644 --- a/src/auth/jwt.ts +++ b/src/auth/jwt.ts @@ -39,6 +39,7 @@ async function validateSignature({ throw new Problem({ title: "JWT Signature Validation Error", detail: `No matching JWK found: ${header.kid}`, + status: 401, }) try { await RSA.verify(key.key, signature, `${header}.${payload}`) @@ -46,6 +47,7 @@ async function validateSignature({ throw new Problem({ title: "JWT Signature Validation Error", detail: "Invalid JWT signature", + status: 401, }) } } @@ -56,6 +58,7 @@ function validateExpiration(exp: number): void { throw new Problem({ title: "JWT Expiration Validation Error", detail: "Missing JWT expiration", + status: 401, }) expiration.setUTCSeconds(exp) const now = new Date(Date.now()) @@ -63,6 +66,7 @@ function validateExpiration(exp: number): void { throw new Problem({ title: "JWT Expiration Validation Error", detail: "JWT is expired", + status: 401, }) } @@ -75,17 +79,21 @@ function validatePayload( audience: string, issuer: string, ) { + if (!audience.startsWith("https://")) audience = `https://${audience}` + if (!audience.endsWith("/")) audience = `${audience}/` if (Array.isArray(aud)) { if (!aud.includes(audience)) throw new Problem({ title: "JWT Payload Validation Error", detail: `Invalid JWT audience: ${aud.join(",")}`, + status: 401, }) } else { if (aud !== audience) { throw new Problem({ title: "JWT Payload Validation Error", detail: `Invalid JWT audience: ${aud}`, + status: 401, }) } } @@ -93,6 +101,7 @@ function validatePayload( throw new Problem({ title: "JWT Payload Validation Error", detail: `Invalid JWT issuer: ${iss}`, + status: 401, }) validateExpiration(exp) } @@ -107,16 +116,19 @@ function validateHeader(jwt: DecodedJsonWebToken) { throw new Problem({ title: "JWT Header Validation Error", detail: `Invalid JWT type: ${typ}`, + status: 401, }) if (alg !== "RS256") throw new Problem({ title: "JWT Header Validation Error", detail: `Invalid JWT algorithm: ${alg}`, + status: 401, }) if (!kid) throw new Problem({ title: "JWT Header Validation Error", detail: "Missing JWT key id", + status: 401, }) } @@ -145,6 +157,7 @@ export const JWT = { throw new Problem({ title: "JWT Decode Error", detail: `Unable to decode JWT: ${error}`, + status: 401, }) } }, diff --git a/src/core/api.ts b/src/core/api.ts new file mode 100644 index 0000000..c57aa72 --- /dev/null +++ b/src/core/api.ts @@ -0,0 +1,40 @@ +import { fromHono, type RouterOptions } from "chanfana" +import { Hono } from "hono" +import { + authenticate, + error, + type AuthBindings, + type AuthVariables, +} from "../middleware" + +type APIInit = { + tokenUrl: string + options?: RouterOptions + scopes?: Record +} + +type APIBindings = AuthBindings + +type APIVariables = AuthVariables + +export function API< + Bindings extends APIBindings, + Variables extends APIVariables, +>({ tokenUrl, options, scopes }: APIInit | undefined) { + const base = new Hono<{ Bindings: Bindings; Variables: Variables }>() + const app = fromHono(base, options) + + app.registry.registerComponent("securitySchemes", "Oauth2", { + type: "oauth2", + flows: { + clientCredentials: { + tokenUrl, + scopes, + }, + }, + }) + + app.use("*", authenticate) + app.onError(error) + return app as Hono<{ Bindings: Bindings; Variables: Variables }> +} diff --git a/src/core/index.ts b/src/core/index.ts index 77d4f92..65656b6 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,3 +1,4 @@ export * from "./worker" export * from "./problem" export * from "./types" +export * from "./api" diff --git a/src/crypto/rsa.ts b/src/crypto/rsa.ts index df5e1a8..ab2d6f1 100644 --- a/src/crypto/rsa.ts +++ b/src/crypto/rsa.ts @@ -1,5 +1,6 @@ import { base64 } from "rfc4648" import { Problem } from "../core" +import type { JsonWebKey } from "@cloudflare/workers-types" export const RSA = { ALGORITHM: "RSASSA-PKCS1-v1_5", diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..3069f24 --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,52 @@ +import type { Context, Next } from "hono" +import { type JsonWebToken, JWT } from "../auth" +import { Problem } from "../core" +/** + * Auth Middleware Bindings + */ +export type AuthBindings = { + OAUTH_AUDIENCE: string + OAUTH_TENANT: string +} +/** + * Auth Middleware Variables + */ +export type AuthVariables = { + jwt: JsonWebToken +} + +export async function authenticate( + c: Context<{ Bindings: AuthBindings; Variables: AuthVariables }>, + next: Next, +) { + const token = JWT.get(c.req.raw) + const decoded = JWT.decode(token) + await JWT.validate(decoded, c.env.OAUTH_AUDIENCE, c.env.OAUTH_TENANT) + c.set("jwt", decoded.decoded) + await next() +} + +export function authorize(...scopes: string[]) { + return async function ( + c: Context<{ Bindings: AuthBindings; Variables: AuthVariables }>, + next: Next, + ) { + const raw = c.get("jwt").payload.scope + if (!raw) + throw new Problem({ + detail: "JWT scope is empty", + title: "Scope Middleware Error", + status: 403, + }) + const parts = raw.split(" ") + for (const scope of scopes) { + if (!parts.includes(scope)) + throw new Problem({ + detail: `JWT is missing required scope: ${scope}`, + title: "Scope Middleware Error", + status: 403, + }) + } + await next() + } +} diff --git a/src/middleware/error.ts b/src/middleware/error.ts new file mode 100644 index 0000000..170cbe0 --- /dev/null +++ b/src/middleware/error.ts @@ -0,0 +1,11 @@ +import { Context } from "hono" +import { Problem } from "../core" + +export async function error(error: Error, c: Context) { + console.log(error) + if (!c.env.DEBUG) return c.text("Internal Server Error", { status: 500 }) + if (error instanceof Problem) return error.response + else + return new Problem({ title: "Unknown Error", detail: error.message }) + .response +} diff --git a/src/middleware/index.ts b/src/middleware/index.ts new file mode 100644 index 0000000..72d38d5 --- /dev/null +++ b/src/middleware/index.ts @@ -0,0 +1,2 @@ +export * from "./auth" +export * from "./error"