diff --git a/.env.example b/.env.example index dc9fb26..b0f08b2 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ DEBUG=surface:* SELF_RPC_HOST=http://localhost:4000 -JWT_SECRET=sup4h.secr1t.jwt.🔑 + +WORKOS_API_KEY=your-work-os-api-key +WORKOS_CLIENT_ID=your-workos-client-id +WORKOS_REDIRECT_URI=http://localhost:4000/auth/callback diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 30a5c64..9b1fbb9 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -9,7 +9,10 @@ jobs: name: "Test" runs-on: ubuntu-latest env: - JWT_SECRET: "test.fake.secret" + WORKOS_API_KEY: "test.workos.api.key" + WORKOS_CLIENT_ID: "test.client.id" + WORKOS_REDIRECT_URI: "http://localhost:3000/auth/callback" + NODE_ENV: "test" steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v1 @@ -36,7 +39,7 @@ jobs: - name: "Push to AR" run: | docker build . \ - -f Dockerfile.node \ + -f Dockerfile.bun \ -t ${{ secrets.GCP_AR_PATH }}/app:${GITHUB_SHA::6} \ -t ${{ secrets.GCP_AR_PATH }}/app:latest docker push ${{ secrets.GCP_AR_PATH }}/app:${GITHUB_SHA::6} @@ -55,14 +58,14 @@ jobs: - name: "Deploy" run: | - echo "***DO NOT USE*** THESE VALUES IN PRODUCTION!" - echo "USE SECRETS MANAGER FOR JWT SIGNING KEY, OR A THIRD PARTY PROVIDER LIKE HASHICORP." echo "DEBUG: surface:*" >> env.yaml - echo "SELF_RPC_HOST: https://surface-demo-app-5v6fvk5ela-uw.a.run.app/" >> env.yaml - echo "JWT_SECRET: sup4h.secr1t.jwt.🔑" >> env.yaml + echo "SELF_RPC_HOST: https://surface.makeitstable.com/" >> env.yaml + echo "NODE_ENV: production" >> env.yaml + echo "WORKOS_REDIRECT_URI: https://surface.makeitstable.com/auth/callback" >> env.yaml gcloud run deploy surface-demo-app \ --image "${{ secrets.GCP_AR_PATH }}/app:${GITHUB_SHA::6}" \ --env-vars-file env.yaml \ + --set-secrets="WORKOS_API_KEY=WORKOS_API_KEY:latest,WORKOS_CLIENT_ID=WORKOS_CLIENT_ID:latest" \ --service-account ${{ secrets.GCP_APP_SERVICE_ACCOUNT }} \ --region us-west1 \ --allow-unauthenticated diff --git a/Dockerfile.bun b/Dockerfile.bun index cb7a56c..7eaaf6a 100644 --- a/Dockerfile.bun +++ b/Dockerfile.bun @@ -13,5 +13,5 @@ FROM base as dev CMD ["bun", "run", "dev:docker"] FROM base as prod -RUN bunx tsr generate -CMD ["bun", "surface.server.bun.ts"] \ No newline at end of file +RUN bun run build +CMD ["bun", "surface.server.bun.ts"] diff --git a/bun.lockb b/bun.lockb index a1acdf4..27e495a 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docker-compose.yaml b/docker-compose.yaml index 89d358b..50fe160 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -33,7 +33,8 @@ services: - DEBUG=surface:* - JWT_SECRET=foo.bar.baz - SELF_RPC_HOST=http://dev:4000 - + env_file: + - .env ports: - "4000:4000" diff --git a/handlers/error.handler.ts b/handlers/error.handler.ts index d436510..96e4d32 100644 --- a/handlers/error.handler.ts +++ b/handlers/error.handler.ts @@ -2,6 +2,15 @@ import { SurfaceContext } from "../surface.app.ctx"; export class PingError extends Error {} +export class AuthError extends Error { + constructor( + message: string, + public readonly code: string = "AUTH_FAILED", + ) { + super(message); + } +} + export const errorHandler = (err: unknown, c: SurfaceContext): Response => { const { error } = c.var.logger; const { text, json } = c; @@ -10,7 +19,20 @@ export const errorHandler = (err: unknown, c: SurfaceContext): Response => { error(`some kind of error happened: ${err}`); return text("oh noes", 418); } + + if (err instanceof AuthError) { + error(`Authentication error: ${err.message}`, err); + return json( + { + error: "Authentication failed", + code: err.code, + message: err.message, + }, + 401, + ); + } + // unknown error error(`unhandled service error type: ${err}`, err); - return json({}, 500); + return json({ error: "Internal server error" }, 500); }; diff --git a/package-lock.json b/package-lock.json index f87ca42..db72fae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@tanstack/react-router": "^1.115.0", "@tanstack/react-start": "^1.115.1", "@tanstack/router-plugin": "^1.115.0", + "@workos-inc/node": "^7.69.1", "bunyan": "^1.8.15", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -31,6 +32,7 @@ "hono": "^4.8.2", "hono-openapi": "^0.4.6", "install": "^0.13.0", + "jose": "^6.1.0", "lucide-react": "^0.509.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -58,13 +60,13 @@ "@typescript-eslint/parser": "^7.2.0", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.19", - "eslint": "^9.29.0", + "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.6", "glob": "^10.3.12", "rollup": "^4.44.0", "rollup-plugin-ignore-import": "^1.3.2", - "tw-animate-css": "^1.2.9", + "tw-animate-css": "^1.4.0", "typescript": "^5.2.2", "vite": "6.3.5" } @@ -892,79 +894,16 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@eslint/config-array": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.1.tgz", - "integrity": "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.6", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.3.tgz", - "integrity": "sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", - "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, - "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", + "espree": "^9.6.0", + "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", @@ -972,7 +911,7 @@ "strip-json-comments": "^3.1.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -983,7 +922,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -994,7 +932,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -1003,53 +940,12 @@ } }, "node_modules/@eslint/js": { - "version": "9.29.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz", - "integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, - "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.2.tgz", - "integrity": "sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.15.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.0.tgz", - "integrity": "sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, "node_modules/@fastify/busboy": { @@ -1138,42 +1034,41 @@ "zod": "^3.19.1" } }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", "dev": true, - "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, "engines": { - "node": ">=18.18.0" + "node": ">=10.10.0" } }, - "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, - "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" - }, - "engines": { - "node": ">=18.18.0" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" + "dependencies": { + "brace-expansion": "^1.1.7" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "engines": { + "node": "*" } }, "node_modules/@humanwhocodes/module-importer": { @@ -1188,19 +1083,12 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true }, "node_modules/@ioredis/commands": { "version": "1.2.0", @@ -1691,6 +1579,42 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.4.0.tgz", + "integrity": "sha512-umbembjIWOrPSOzEGG5vxFLkeM8kzIhLkgigtsOrfLKnuzxWxejAcUX+q/SoZCdemlODOcr5WiYa7+dIEzBXZQ==", + "dependencies": { + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.5.0.tgz", + "integrity": "sha512-BRs5XUAwiyCDQMsVA9IDvDa7UBR9gAvPHgugOeGng3YN6vJ9JYonyDc0lNczErgtCWtucjR5N7VtaonboD/ezg==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/json-schema": "^1.1.12", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2", + "webcrypto-core": "^1.8.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -4054,6 +3978,14 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@types/accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "license": "MIT", @@ -4088,6 +4020,15 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, "node_modules/@types/braces": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/braces/-/braces-3.0.4.tgz", @@ -4118,6 +4059,35 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/content-disposition": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.9.tgz", + "integrity": "sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==" + }, + "node_modules/@types/cookie": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.5.4.tgz", + "integrity": "sha512-7z/eR6O859gyWIAjuvBWFzNURmf2oPBmJlfVWkwehU5nzIyjwBsTh7WMmEEV4JFnHuQ3ex4oyTvfKzcyJVDBNA==" + }, + "node_modules/@types/cookies": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-E/DPgzifH4sM1UMadJMWd6mO2jOd4g1Ejwzx8/uRCDpJis1IrlyQEcGAYEomtAqRYmD5ORbNXMeI9U0RiVGZbg==", + "dependencies": { + "@types/connect": "*", + "@types/express": "*", + "@types/keygrip": "*", + "@types/node": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -4133,10 +4103,70 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/express": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-assert": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.6.tgz", + "integrity": "sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==" + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "license": "MIT" }, + "node_modules/@types/keygrip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", + "integrity": "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==" + }, + "node_modules/@types/koa": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@types/koa/-/koa-2.15.0.tgz", + "integrity": "sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g==", + "dependencies": { + "@types/accepts": "*", + "@types/content-disposition": "*", + "@types/cookies": "*", + "@types/http-assert": "*", + "@types/http-errors": "*", + "@types/keygrip": "*", + "@types/koa-compose": "*", + "@types/node": "*" + } + }, + "node_modules/@types/koa-compose": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@types/koa-compose/-/koa-compose-3.2.8.tgz", + "integrity": "sha512-4Olc63RY+MKvxMwVknCUDhRQX1pFQoBZ/lXcRLP69PQkEpze/0cr8LNqJQe5NFb/b19DWi2a5bTi2VAlQzhJuA==", + "dependencies": { + "@types/koa": "*" + } + }, "node_modules/@types/micromatch": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.7.tgz", @@ -4145,6 +4175,11 @@ "@types/braces": "*" } }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" + }, "node_modules/@types/ms": { "version": "0.7.34", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", @@ -4153,7 +4188,6 @@ }, "node_modules/@types/node": { "version": "20.12.5", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~5.26.4" @@ -4164,6 +4198,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" + }, "node_modules/@types/react": { "version": "18.2.74", "devOptional": true, @@ -4192,6 +4236,25 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/send": { + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", + "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", + "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, "node_modules/@types/ws": { "version": "8.5.10", "dev": true, @@ -4386,6 +4449,12 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true + }, "node_modules/@vercel/nft": { "version": "0.29.2", "resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-0.29.2.tgz", @@ -4465,6 +4534,29 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, + "node_modules/@workos-inc/node": { + "version": "7.69.1", + "resolved": "https://registry.npmjs.org/@workos-inc/node/-/node-7.69.1.tgz", + "integrity": "sha512-ml2TqUHjUVkubq4EnIIM1O1g+eR0ctKnpdHUJntG/1PuVt64CfntJrAUi/5ePgR4d12EeXunHyjOTK75k+f9Ww==", + "dependencies": { + "iron-session": "~6.3.1", + "jose": "~5.6.3", + "leb": "^1.0.0", + "pluralize": "8.0.0", + "qs": "6.14.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@workos-inc/node/node_modules/jose": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.6.3.tgz", + "integrity": "sha512-1Jh//hEEwMhNYPDDLwXHa2ePWgWiFNNUadVmguAAw2IJ6sj9mNxV5tGXJNqlMkJAybF6Lgw1mISDxTePP/187g==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/abbrev": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.0.tgz", @@ -4509,7 +4601,6 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, - "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -4535,7 +4626,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4700,6 +4790,19 @@ "printable-characters": "^1.0.42" } }, + "node_modules/asn1js": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.6.tgz", + "integrity": "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -5053,12 +5156,38 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } @@ -5604,6 +5733,18 @@ "node": ">=8" } }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/dot-prop": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", @@ -5653,6 +5794,19 @@ "node": ">=0.10" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", @@ -5696,11 +5850,38 @@ "node": ">=10.13.0" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.0.tgz", "integrity": "sha512-pqrTKmwEIgafsYZAGw9kszYzmagcE/n4dbgwGWLEXg7J4QFJVQRBld8j3Q3GNez79jzxZshq0bcT962QHOghjw==" }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.24.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", @@ -6095,64 +6276,59 @@ } }, "node_modules/eslint": { - "version": "9.29.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.29.0.tgz", - "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.1", - "@eslint/config-helpers": "^0.2.1", - "@eslint/core": "^0.14.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.29.0", - "@eslint/plugin-kit": "^0.3.1", - "@humanfs/node": "^0.16.6", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", "ajv": "^6.12.4", "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", + "cross-spawn": "^7.0.2", "debug": "^4.3.2", + "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", + "file-entry-cache": "^6.0.1", "find-up": "^5.0.0", "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.3" + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" }, "bin": { "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-plugin-react-hooks": { @@ -6175,17 +6351,16 @@ } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -6202,19 +6377,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/eslint/node_modules/minimatch": { "version": "3.1.2", "dev": true, @@ -6231,36 +6393,22 @@ "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, - "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -6282,7 +6430,6 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -6383,8 +6530,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/fast-fifo": { "version": "1.3.2", @@ -6420,8 +6566,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/fast-levenshtein": { "version": "2.0.6", @@ -6450,16 +6595,15 @@ } }, "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, - "license": "MIT", "dependencies": { - "flat-cache": "^4.0.0" + "flat-cache": "^3.0.4" }, "engines": { - "node": ">=16.0.0" + "node": "^10.12.0 || >=12.0.0" } }, "node_modules/file-uri-to-path": { @@ -6494,25 +6638,24 @@ } }, "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, - "license": "MIT", "dependencies": { "flatted": "^3.2.9", - "keyv": "^4.5.4" + "keyv": "^4.5.3", + "rimraf": "^3.0.2" }, "engines": { - "node": ">=16" + "node": "^10.12.0 || >=12.0.0" } }, "node_modules/flatted": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" + "dev": true }, "node_modules/follow-redirects": { "version": "1.15.6", @@ -6606,6 +6749,29 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -6619,6 +6785,18 @@ "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.1.2.tgz", "integrity": "sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==" }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-source": { "version": "2.0.12", "dev": true, @@ -6721,13 +6899,15 @@ } }, "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, - "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, "engines": { - "node": ">=18" + "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -6761,6 +6941,17 @@ "csstype": "^3.0.10" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -6810,6 +7001,17 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "license": "MIT", @@ -6996,7 +7198,6 @@ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, - "license": "MIT", "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -7069,6 +7270,63 @@ "url": "https://opencollective.com/ioredis" } }, + "node_modules/iron-session": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/iron-session/-/iron-session-6.3.1.tgz", + "integrity": "sha512-3UJ7y2vk/WomAtEySmPgM6qtYF1cZ3tXuWX5GsVX4PJXAcs5y/sV9HuSfpjKS6HkTL/OhZcTDWJNLZ7w+Erx3A==", + "dependencies": { + "@peculiar/webcrypto": "^1.4.0", + "@types/cookie": "^0.5.1", + "@types/express": "^4.17.13", + "@types/koa": "^2.13.5", + "@types/node": "^17.0.41", + "cookie": "^0.5.0", + "iron-webcrypto": "^0.2.5" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "express": ">=4", + "koa": ">=2", + "next": ">=10" + }, + "peerDependenciesMeta": { + "express": { + "optional": true + }, + "koa": { + "optional": true + }, + "next": { + "optional": true + } + } + }, + "node_modules/iron-session/node_modules/@types/node": { + "version": "17.0.45", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz", + "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==" + }, + "node_modules/iron-session/node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/iron-session/node_modules/iron-webcrypto": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-0.2.8.tgz", + "integrity": "sha512-YPdCvjFMOBjXaYuDj5tiHst5CEk6Xw84Jo8Y2+jzhMceclAnb3+vNPP/CTtb5fO2ZEuXEaO4N+w62Vfko757KA==", + "dependencies": { + "buffer": "^6" + }, + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, "node_modules/iron-webcrypto": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", @@ -7179,6 +7437,15 @@ "node": ">=0.12.0" } }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/is-reference": { "version": "1.2.1", "license": "MIT", @@ -7264,6 +7531,14 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz", + "integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "license": "MIT" @@ -7293,15 +7568,13 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/json-schema-walker": { "version": "2.0.0", @@ -7340,7 +7613,6 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, - "license": "MIT", "dependencies": { "json-buffer": "3.0.1" } @@ -7404,6 +7676,11 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/leb": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/leb/-/leb-1.0.0.tgz", + "integrity": "sha512-Y3c3QZfvKWHX60BVOQPhLCvVGmDYWyJEiINE3drOog6KCyN2AOwvuQQzlS3uJg1J85kzpILXIUwRXULWavir+w==" + }, "node_modules/levn": { "version": "0.4.1", "dev": true, @@ -7830,6 +8107,14 @@ "source-map-js": "^1.2.0" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -8692,6 +8977,17 @@ "pathe": "^2.0.3" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ofetch": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.4.1.tgz", @@ -8835,7 +9131,6 @@ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, - "license": "MIT", "dependencies": { "callsites": "^3.0.0" }, @@ -8861,8 +9156,8 @@ }, "node_modules/path-is-absolute": { "version": "1.0.1", + "devOptional": true, "license": "MIT", - "optional": true, "engines": { "node": ">=0.10.0" } @@ -8942,6 +9237,14 @@ "pathe": "^1.1.0" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "engines": { + "node": ">=4" + } + }, "node_modules/postcss": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", @@ -9031,11 +9334,40 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", + "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quansync": { "version": "0.2.10", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.10.tgz", @@ -9302,7 +9634,6 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, - "license": "MIT", "engines": { "node": ">=4" } @@ -9323,6 +9654,65 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/rollup": { "version": "4.44.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.0.tgz", @@ -9628,6 +10018,74 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "license": "ISC", @@ -9837,7 +10295,6 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" }, @@ -9971,6 +10428,12 @@ "b4a": "^1.6.4" } }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -10038,8 +10501,9 @@ } }, "node_modules/tslib": { - "version": "2.6.2", - "license": "0BSD" + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/tslog": { "version": "4.9.3", @@ -10155,10 +10619,11 @@ } }, "node_modules/tw-animate-css": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.2.9.tgz", - "integrity": "sha512-9O4k1at9pMQff9EAcCEuy1UNO43JmaPQvq+0lwza9Y0BQ6LB38NiMj+qHqjoQf40355MX+gs6wtlR6H9WsSXFg==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/Wombosvideo" } @@ -10174,6 +10639,18 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.4.4", "dev": true, @@ -10599,7 +11076,6 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } @@ -11598,6 +12074,18 @@ "@esbuild/win32-x64": "0.25.5" } }, + "node_modules/webcrypto-core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.8.1.tgz", + "integrity": "sha512-P+x1MvlNCXlKbLSOY4cYrdreqPG5hbzkmawbcXLKN/mf6DZW0SdNNkZ+sjwsqVkI4A4Ko2sPZmkZtCKY58w83A==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/json-schema": "^1.1.12", + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.5", + "tslib": "^2.7.0" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index 5ced963..cf357b0 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@tanstack/react-router": "^1.115.0", "@tanstack/react-start": "^1.115.1", "@tanstack/router-plugin": "^1.115.0", + "@workos-inc/node": "^7.69.1", "bunyan": "^1.8.15", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -37,6 +38,7 @@ "hono": "^4.8.2", "hono-openapi": "^0.4.6", "install": "^0.13.0", + "jose": "^6.1.0", "lucide-react": "^0.509.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -64,13 +66,13 @@ "@typescript-eslint/parser": "^7.2.0", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.19", - "eslint": "^9.29.0", + "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.6", "glob": "^10.3.12", "rollup": "^4.44.0", "rollup-plugin-ignore-import": "^1.3.2", - "tw-animate-css": "^1.2.9", + "tw-animate-css": "^1.4.0", "typescript": "^5.2.2", "vite": "6.3.5" } diff --git a/service/auth/README.md b/service/auth/README.md new file mode 100644 index 0000000..36f0e77 --- /dev/null +++ b/service/auth/README.md @@ -0,0 +1,277 @@ +# Authentication Service - WorkOS Sessions + +This authentication service uses WorkOS sessions for secure, managed authentication instead of self-signed JWT tokens. This provides enterprise-grade session management, automatic token refresh, and comprehensive session controls. + +## Overview + +The authentication flow leverages WorkOS's session management system: + +1. **OAuth Flow**: Users authenticate via WorkOS OAuth providers +2. **Session Creation**: WorkOS creates and manages the user session +3. **Token Management**: WorkOS provides access tokens (JWTs) and refresh tokens +4. **Automatic Refresh**: Tokens are automatically refreshed as needed +5. **Session Controls**: Configure session behavior via WorkOS Dashboard + +## Files + +- `auth.endpoints.ts` - Authentication endpoints using WorkOS sessions +- `auth.helpers.ts` - Helper functions for session validation and user management +- `auth.endpoint.test.ts` - Comprehensive tests for auth endpoints +- `auth.helpers.test.ts` - Tests for auth helper functions + +## Environment Variables + +Required environment variables for WorkOS integration: + +```bash +WORKOS_API_KEY=sk_test_... +WORKOS_CLIENT_ID=client_... +WORKOS_REDIRECT_URI=http://localhost:4000/api/auth/callback +JWT_SECRET=your_jwt_secret_here # Used for any additional signing if needed +``` + +## Authentication Flow + +### 1. Login Process + +``` +User → /api/auth/login + → WorkOS Authorization URL + → User authenticates + → WorkOS callback → /api/auth/callback + → Store WorkOS tokens → Redirect to app +``` + +#### `GET /api/auth/login` +Initiates WorkOS OAuth flow. + +**Query Parameters:** +- `return` (optional): URL to redirect to after successful authentication +- `test` (optional): When set to "true", returns JSON with authorization URL instead of redirecting + +**Response:** +- `302` - Redirects to WorkOS authorization URL +- `200` - Returns JSON with authorization URL (when `test=true`) + +### 2. Callback Handling + +#### `GET /api/auth/callback` +Handles OAuth callback from WorkOS and establishes session. + +**Query Parameters:** +- `code` (required): Authorization code from WorkOS +- `state` (optional): Original redirect destination + +**Process:** +1. Exchange authorization code for WorkOS tokens +2. Store access token and refresh token as secure HTTP-only cookies +3. Redirect to original destination or home page + +**Cookies Set:** +- `wos_access_token`: Short-lived WorkOS JWT (configurable duration) +- `wos_refresh_token`: Long-lived refresh token +- `user_id`: User ID for quick reference + +### 3. Session Validation + +The system validates sessions using WorkOS-signed JWTs: + +```typescript +// Validate WorkOS JWT using their JWKS endpoint +const JWKS = jose.createRemoteJWKSet( + new URL(`https://api.workos.com/sso/jwks/${env("WORKOS_CLIENT_ID")}`) +); + +const { payload } = await jose.jwtVerify(accessToken, JWKS, { + issuer: "https://api.workos.com", +}); +``` + +### 4. Automatic Token Refresh + +When an access token expires, the system automatically attempts to refresh it using the refresh token: + +```typescript +const { user, accessToken, refreshToken: newRefreshToken } = + await workos.userManagement.authenticateWithRefreshToken({ + refreshToken, + clientId: env("WORKOS_CLIENT_ID"), + }); +``` + +## API Endpoints + +### Authentication Endpoints + +#### `GET /api/auth/login` +Initiate WorkOS OAuth flow. + +#### `GET /api/auth/callback` +Handle OAuth callback and create session. + +#### `GET /api/auth/logout` +Clear session and logout from WorkOS. + +**Process:** +1. Extract session ID from access token +2. Clear all auth cookies +3. Redirect to WorkOS logout URL to invalidate session +4. User redirected back to configured logout URL + +#### `POST /api/auth/switch-organization` +Switch to a different organization context within the same session. + +**Request Body:** +```json +{ + "organizationId": "org_123" +} +``` + +**Response:** +```json +{ + "success": true, + "organizationId": "org_123" +} +``` + +#### `GET /api/auth/me` +Get current user information from WorkOS session. + +**Response:** +```json +{ + "id": "user_123", + "name": "John Doe", + "email": "john@example.com", + "roles": [], + "profilePicture": "https://...", + "organizationId": "org_123", + "role": "admin", + "permissions": ["read", "write"] +} +``` + +## Helper Functions + +### `getUser(c: SurfaceContext): Promise` +Extracts user information from the current WorkOS session. Automatically handles token refresh if needed. + +### `hasSession(c: SurfaceContext): Promise` +Checks if the current request has a valid WorkOS session. Returns JWT payload if valid. + +### `requireAuth(c: SurfaceContext, next: Function)` +Middleware to require authentication for protected routes. Returns 401 if no valid session exists. + +### Organization & Permission Helpers + +- `getCurrentOrganizationId(c)`: Get current organization ID from session +- `getUserPermissions(c)`: Get user permissions array +- `hasPermission(c, permission)`: Check if user has specific permission +- `getUserRole(c)`: Get user's role in current organization + +## Usage Examples + +### Protecting a Route +```typescript +export const protectedRoute = new Hono() + .use(requireAuth) + .get("/protected", async (c) => { + const user = await getUser(c); + return c.json({ message: `Hello ${user?.name}` }); + }); +``` + +### Manual Session Check +```typescript +export const optionalAuth = new Hono() + .get("/optional", async (c) => { + const user = await getUser(c); + if (user) { + return c.json({ message: `Welcome back ${user.name}` }); + } else { + return c.json({ message: "Hello anonymous user" }); + } + }); +``` + +### Permission-Based Access +```typescript +export const adminRoute = new Hono() + .use(requireAuth) + .get("/admin", async (c) => { + if (await hasPermission(c, "admin:write")) { + return c.json({ message: "Admin access granted" }); + } + return c.json({ error: "Insufficient permissions" }, 403); + }); +``` + +## WorkOS Dashboard Configuration + +Configure session behavior in the WorkOS Dashboard under Authentication settings: + +- **Maximum session length**: How long before user must re-authenticate +- **Access token duration**: How often tokens are refreshed (recommended: 15-60 minutes) +- **Inactivity timeout**: Session ends if no activity for this period +- **Logout redirect**: Where users go after logout + +## JWT Token Claims + +WorkOS access tokens contain the following claims: + +- `sub`: WorkOS user ID +- `sid`: Session ID (used for logout) +- `iss`: `https://api.workos.com` (or custom domain) +- `org_id`: Selected organization ID (if applicable) +- `role`: User's role in the organization +- `permissions`: Array of permissions assigned to the role +- `exp`: Token expiration timestamp +- `iat`: Token issued timestamp +- `email`: User's email address +- `name`: User's display name + +## Testing + +The authentication system includes comprehensive tests covering: +- All authentication endpoints (login, callback, logout, organization switching) +- Token validation and refresh logic +- Error handling for various failure scenarios +- Helper functions for session management +- Mock WorkOS integration + +Run tests with: +```bash +bun test ./service/auth/ +``` + +## Security Features + +- **HTTP-Only Cookies**: Tokens stored as secure, HTTP-only cookies +- **Automatic Refresh**: Seamless token refresh without user interaction +- **Session Invalidation**: Proper logout that clears WorkOS session +- **JWKS Validation**: Tokens validated using WorkOS's public keys +- **Token Rotation**: Refresh tokens may be rotated for security +- **Organization Context**: Built-in multi-organization support +- **Permission Management**: Fine-grained permission checking + +## Migration from Self-Signed JWT + +Key differences from the previous self-signed JWT approach: + +1. **Token Source**: WorkOS manages token creation and signing +2. **Validation**: Uses WorkOS JWKS instead of local JWT secret +3. **Refresh Logic**: Built-in refresh token rotation +4. **Session Management**: WorkOS tracks and manages session state +5. **Dashboard Controls**: Configure session behavior via WorkOS UI +6. **Organization Support**: Native multi-organization context switching + +## Benefits + +- **Enterprise Security**: Leverages WorkOS's security infrastructure +- **Reduced Complexity**: No need to implement session management +- **Automatic Refresh**: Built-in token refresh and rotation +- **Session Monitoring**: View and manage sessions from WorkOS dashboard +- **Compliance**: Built-in compliance and security features +- **Scalability**: Handles enterprise-scale session management \ No newline at end of file diff --git a/service/auth/auth.endpoint.test.ts b/service/auth/auth.endpoint.test.ts new file mode 100644 index 0000000..9abbc1d --- /dev/null +++ b/service/auth/auth.endpoint.test.ts @@ -0,0 +1,408 @@ +import { expect, it, mock, describe, beforeEach } from "bun:test"; +import { Hono } from "hono"; +import { sessions } from "./auth.endpoints"; +import { applyContext, SurfaceEnv } from "../../surface.app.ctx"; +import { logger } from "../../logger/logger"; +import { WorkOS } from "@workos-inc/node"; +import { errorHandler } from "../../handlers/error.handler"; + +describe("auth endpoints", () => { + // Set up test environment variables + process.env.WORKOS_CLIENT_ID = "client_test_123"; + process.env.WORKOS_REDIRECT_URI = "http://localhost:4000/auth/callback"; + // Mock logger + const mockLogger = { + ...logger, + info: mock(() => null), + error: mock(() => null), + } as unknown as typeof logger; + + // Mock functions for WorkOS + const mockGetAuthorizationUrl = mock(); + const mockAuthenticateWithCode = mock(); + const mockAuthenticateWithRefreshToken = mock(); + const mockGetLogoutUrl = mock(); + + // Mock WorkOS + const mockWorkOS = { + userManagement: { + getAuthorizationUrl: mockGetAuthorizationUrl, + authenticateWithCode: mockAuthenticateWithCode, + authenticateWithRefreshToken: mockAuthenticateWithRefreshToken, + getLogoutUrl: mockGetLogoutUrl, + }, + } as unknown as WorkOS; + + // Mock cookies + const mockCookies = { + get: mock(), + set: mock(), + }; + + // Mock JWT + const mockJwt = { + sign: mock(), + verify: mock(), + }; + + let testApp: Hono; + + beforeEach(() => { + // Reset all mocks + (mockLogger.info as any).mockClear?.(); + (mockLogger.error as any).mockClear?.(); + (mockCookies.get as any).mockClear?.(); + (mockCookies.set as any).mockClear?.(); + mockGetAuthorizationUrl.mockClear?.(); + mockAuthenticateWithCode.mockClear?.(); + mockAuthenticateWithRefreshToken.mockClear?.(); + mockGetLogoutUrl.mockClear?.(); + (mockJwt.sign as any).mockClear?.(); + (mockJwt.verify as any).mockClear?.(); + + testApp = new Hono() + .use( + applyContext({ + logger: mockLogger, + cookies: mockCookies, + workos: mockWorkOS, + jwt: mockJwt as any, + }), + ) + .route("/", sessions) + .onError(errorHandler); + }); + + describe("GET /login", () => { + it("should redirect to WorkOS authorization URL", async () => { + const mockAuthUrl = + "https://api.workos.com/sso/authorize?response_type=code&client_id=test&redirect_uri=http://localhost:4000/api/auth/callback"; + + mockGetAuthorizationUrl.mockReturnValue(mockAuthUrl); + + const response = await testApp.request("/login"); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe(mockAuthUrl); + expect(mockGetAuthorizationUrl).toHaveBeenCalledWith({ + provider: "authkit", + redirectUri: "http://localhost:4000/auth/callback", + clientId: "client_test_123", + state: "/", + }); + expect(mockLogger.info).toHaveBeenCalledWith( + "Redirecting to WorkOS authorization URL", + ); + }); + + it("should use return query parameter as state", async () => { + const mockAuthUrl = "https://api.workos.com/sso/authorize"; + mockGetAuthorizationUrl.mockReturnValue(mockAuthUrl); + + await testApp.request("/login?return=/dashboard"); + + expect(mockGetAuthorizationUrl).toHaveBeenCalledWith({ + provider: "authkit", + redirectUri: "http://localhost:4000/auth/callback", + clientId: "client_test_123", + state: "/dashboard", + }); + }); + + it("should return JSON for test requests", async () => { + const mockAuthUrl = "https://api.workos.com/sso/authorize"; + mockGetAuthorizationUrl.mockReturnValue(mockAuthUrl); + + const response = await testApp.request("/login?test=true"); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ redirectUrl: mockAuthUrl }); + }); + + it("should handle WorkOS errors", async () => { + mockGetAuthorizationUrl.mockImplementation(() => { + throw new Error("WorkOS error"); + }); + + const response = await testApp.request("/login"); + + expect(response.status).toBe(401); + expect(mockLogger.error).toHaveBeenCalledWith( + "Failed to create WorkOS authorization URL", + expect.any(Error), + ); + expect(mockLogger.error).toHaveBeenCalledWith( + "Authentication error: Authentication initialization failed", + expect.any(Error), + ); + }); + }); + + describe("GET /callback", () => { + const mockUser = { + id: "user_123", + firstName: "John", + lastName: "Doe", + email: "john.doe@example.com", + profilePictureUrl: "https://example.com/avatar.jpg", + }; + + beforeEach(() => { + mockAuthenticateWithCode.mockResolvedValue({ + user: mockUser, + accessToken: "mock.workos.access.token", + refreshToken: "mock.workos.refresh.token", + }); + }); + + it("should authenticate user and create session", async () => { + const response = await testApp.request( + "/callback?code=auth_code_123&state=/dashboard", + ); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("/dashboard"); + + expect(mockAuthenticateWithCode).toHaveBeenCalledWith({ + code: "auth_code_123", + clientId: "client_test_123", + }); + + expect(mockCookies.set).toHaveBeenCalledWith( + "wos_access_token", + "mock.workos.access.token", + { + path: "/", + httpOnly: true, + secure: false, // development mode + sameSite: "lax", + }, + ); + + expect(mockCookies.set).toHaveBeenCalledWith( + "wos_refresh_token", + "mock.workos.refresh.token", + { + path: "/", + httpOnly: true, + secure: false, + sameSite: "lax", + }, + ); + + expect(mockCookies.set).toHaveBeenCalledWith("user_id", "user_123", { + path: "/", + httpOnly: true, + secure: false, + sameSite: "lax", + }); + + expect(mockLogger.info).toHaveBeenCalledWith( + "User john.doe@example.com authenticated successfully with WorkOS session", + ); + }); + + it("should redirect to home page when no state provided", async () => { + const response = await testApp.request("/callback?code=auth_code_123"); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("/"); + }); + + it("should handle missing authorization code", async () => { + const response = await testApp.request("/callback"); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.error).toBe("Authentication failed"); + expect(body.code).toBe("MISSING_CODE"); + expect(mockLogger.error).toHaveBeenCalledWith( + "No authorization code provided in callback", + ); + }); + + it("should handle WorkOS authentication failure", async () => { + mockAuthenticateWithCode.mockRejectedValue(new Error("Invalid code")); + + const response = await testApp.request("/callback?code=invalid_code"); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.error).toBe("Authentication failed"); + expect(body.code).toBe("AUTH_FAILED"); + expect(mockLogger.error).toHaveBeenCalledWith( + "WorkOS authentication failed", + expect.any(Error), + ); + }); + + it("should handle user with only email (no first/last name)", async () => { + const userWithoutName = { + ...mockUser, + firstName: null, + lastName: null, + }; + + mockAuthenticateWithCode.mockResolvedValue({ + user: userWithoutName, + accessToken: "mock.workos.access.token", + refreshToken: "mock.workos.refresh.token", + }); + + const response = await testApp.request("/callback?code=auth_code_123"); + + expect(response.status).toBe(302); + expect(mockCookies.set).toHaveBeenCalledWith( + "wos_access_token", + "mock.workos.access.token", + { + path: "/", + httpOnly: true, + secure: false, + sameSite: "lax", + }, + ); + }); + }); + + describe("GET /logout", () => { + it("should clear cookies and redirect", async () => { + const response = await testApp.request("/logout?return=/login"); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("/login"); + + expect(mockCookies.set).toHaveBeenCalledWith("wos_access_token", "", { + path: "/", + httpOnly: true, + expires: new Date(0), + }); + + expect(mockCookies.set).toHaveBeenCalledWith("wos_refresh_token", "", { + path: "/", + httpOnly: true, + expires: new Date(0), + }); + + expect(mockCookies.set).toHaveBeenCalledWith("user_id", "", { + path: "/", + httpOnly: true, + expires: new Date(0), + }); + + expect(mockLogger.info).toHaveBeenCalledWith("User logging out"); + }); + + it("should redirect to home page by default", async () => { + const response = await testApp.request("/logout"); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("/"); + }); + + it("should return JSON for test requests", async () => { + const response = await testApp.request("/logout?test=true"); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ message: "Logged out successfully" }); + }); + + it("should redirect to WorkOS logout URL when access token is present", async () => { + // Mock getting an access token + (mockCookies.get as any).mockImplementation((key: string) => { + if (key === "wos_access_token") return "mock.workos.access.token"; + return undefined; + }); + + // Mock WorkOS getLogoutUrl + mockGetLogoutUrl.mockReturnValue( + "https://api.workos.com/sso/logout?session_id=test_session", + ); + + const response = await testApp.request("/logout"); + + expect(response.status).toBe(302); + }); + }); + + describe("POST /switch-organization", () => { + beforeEach(() => { + const updatedMockUser = { + id: "user_123", + email: "test@example.com", + firstName: "Test", + lastName: "User", + profilePictureURL: "https://example.com/avatar.jpg", + createdAt: "2023-01-01T00:00:00Z", + updatedAt: "2023-01-01T00:00:00Z", + emailVerified: true, + }; + + mockAuthenticateWithRefreshToken.mockResolvedValue({ + user: updatedMockUser, + organizationId: "org_123", + accessToken: "new_access_token_123", + refreshToken: "new_refresh_token_123", + }); + }); + + it("should switch organization successfully", async () => { + (mockCookies.get as any).mockImplementation((key: string) => { + if (key === "wos_refresh_token") return "valid.refresh.token"; + return undefined; + }); + + const response = await testApp.request("/switch-organization", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ organizationId: "org_123" }), + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toEqual({ success: true, organizationId: "org_123" }); + + expect(mockAuthenticateWithRefreshToken).toHaveBeenCalledWith({ + refreshToken: "valid.refresh.token", + clientId: "client_test_123", + organizationId: "org_123", + }); + }); + + it("should return 401 when no refresh token", async () => { + (mockCookies.get as any).mockImplementation(() => undefined); + + const response = await testApp.request("/switch-organization", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ organizationId: "org_123" }), + }); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.error).toBe("No active session"); + }); + + it("should handle WorkOS errors", async () => { + (mockCookies.get as any).mockImplementation((key: string) => { + if (key === "wos_refresh_token") return "valid.refresh.token"; + return undefined; + }); + mockAuthenticateWithRefreshToken.mockRejectedValue( + new Error("Organization not accessible"), + ); + + const response = await testApp.request("/switch-organization", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ organizationId: "invalid_org" }), + }); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("Failed to switch organization"); + }); + }); +}); diff --git a/service/auth/auth.endpoints.ts b/service/auth/auth.endpoints.ts index a709b7b..bf1a1f1 100644 --- a/service/auth/auth.endpoints.ts +++ b/service/auth/auth.endpoints.ts @@ -1,38 +1,394 @@ import { Hono } from "hono"; import { SurfaceEnv } from "../../surface.app.ctx"; -import { fakeUser } from "./auth.helpers"; +import { env } from "../../env"; +import { describeRoute } from "hono-openapi"; +import { resolver } from "hono-openapi/zod"; +import { z } from "zod"; +import { AuthError } from "../../handlers/error.handler"; +import * as jose from "jose"; export type User = { id: string; - name: string; - email: string; - roles: string[]; - profilePicture: string; + email?: string; + given_name?: string; + family_name?: string; + name?: string; + picture?: string; + org_id?: string; + role?: string; + permissions?: string[]; + // Legacy fields for backward compatibility + profilePicture?: string; + organizationId?: string; + roles?: string[]; }; +// Zod schemas for OpenAPI documentation +const loginRedirectResponse = z.object({ + redirectUrl: z.string().url(), +}); + +const logoutResponse = z.object({ + message: z.string(), +}); + +const switchOrganizationRequest = z.object({ + organizationId: z.string(), +}); + +const switchOrganizationResponse = z.object({ + success: z.boolean(), + organizationId: z.string(), +}); + +const userResponse = z.object({ + id: z.string(), + email: z.string().optional(), + given_name: z.string().optional(), + family_name: z.string().optional(), + name: z.string().optional(), + picture: z.string().optional(), + org_id: z.string().optional(), + role: z.string().optional(), + permissions: z.array(z.string()).optional(), + // Legacy fields for backward compatibility + profilePicture: z.string().optional(), + organizationId: z.string().optional(), + roles: z.array(z.string()).optional(), +}); + export const sessions = new Hono() - .get("/login", async (c) => { - const { cookies, jwt } = c.var; - cookies.set("user_id", fakeUser.id, { path: "/", httpOnly: true }); - - // generate a jwt for session calls - const now = Math.floor(Date.now() / 1000); - const payload = { - sub: fakeUser.id, - iat: now, - exp: now + 60 * 60 * 24, - }; - - // this will throw if JWT_SECRET is empty - const token = await jwt.sign(payload, process.env.JWT_SECRET ?? ""); - cookies.set("session", token, { path: "/", httpOnly: true }); - - // redirect to location (if passed) - const redirectTo = c.req.query("return") ?? "/"; - return c.redirect(redirectTo); - }) - .get("/logout", async (c) => { - c.var.cookies.set("user_id", "", { path: "/", httpOnly: true }); - const redirectTo = c.req.query("return") ?? "/"; - return c.redirect(redirectTo); - }); + .get( + "/login", + describeRoute({ + description: "Initiate WorkOS OAuth login flow", + responses: { + 302: { + description: "Redirect to WorkOS authorization URL", + }, + 200: { + description: "Authorization URL (for testing)", + content: { + "application/json": { schema: resolver(loginRedirectResponse) }, + }, + }, + }, + }), + async (c) => { + const { workos, logger } = c.var; + + try { + const redirectTo = c.req.query("return") ?? "/"; + + // Create authorization URL with WorkOS + const authorizationUrl = workos.userManagement.getAuthorizationUrl({ + provider: "authkit", + redirectUri: env("WORKOS_REDIRECT_URI"), + clientId: env("WORKOS_CLIENT_ID"), + state: redirectTo, // Pass the return URL in state + }); + + logger.info("Redirecting to WorkOS authorization URL"); + + // For testing purposes, if test=true query param is present, return JSON instead of redirecting + if (c.req.query("test") === "true") { + return c.json({ redirectUrl: authorizationUrl }); + } + + return c.redirect(authorizationUrl); + } catch (error) { + logger.error("Failed to create WorkOS authorization URL", error); + throw new AuthError( + "Authentication initialization failed", + "INIT_FAILED", + ); + } + }, + ) + .get( + "/callback", + describeRoute({ + description: "Handle WorkOS OAuth callback and create user session", + responses: { + 302: { + description: "Redirect to original destination or home page", + }, + 400: { + description: "Missing or invalid authorization code", + }, + }, + }), + async (c) => { + const { workos, cookies, logger } = c.var; + + try { + const code = c.req.query("code"); + const state = c.req.query("state"); + + if (!code) { + logger.error("No authorization code provided in callback"); + throw new AuthError("Missing authorization code", "MISSING_CODE"); + } + + logger.info("Processing WorkOS callback with authorization code"); + + // Exchange code for WorkOS tokens and user information + const { user, accessToken, refreshToken } = + await workos.userManagement.authenticateWithCode({ + code, + clientId: env("WORKOS_CLIENT_ID"), + }); + + // Store WorkOS access token (short-lived JWT) + cookies.set("wos_access_token", accessToken, { + path: "/", + httpOnly: true, + secure: env("NODE_ENV") === "production", + sameSite: "lax", + }); + + // Store WorkOS refresh token (longer-lived) + cookies.set("wos_refresh_token", refreshToken, { + path: "/", + httpOnly: true, + secure: env("NODE_ENV") === "production", + sameSite: "lax", + }); + + // Also store user ID for quick reference (optional) + cookies.set("user_id", user.id, { + path: "/", + httpOnly: true, + secure: env("NODE_ENV") === "production", + sameSite: "lax", + }); + + logger.info( + `User ${user.email} authenticated successfully with WorkOS session`, + ); + + // Redirect to original destination or home page + const redirectTo = state || "/"; + return c.redirect(redirectTo); + } catch (error) { + logger.error("WorkOS authentication failed", error); + + if (error instanceof AuthError) { + throw error; // Re-throw AuthError to be handled by error handler + } + + // Wrap other errors as AuthError + throw new AuthError("Authentication failed", "AUTH_FAILED"); + } + }, + ) + .get( + "/logout", + describeRoute({ + description: "Clear user session and logout from WorkOS", + responses: { + 302: { + description: "Redirect to WorkOS logout URL to terminate session", + }, + 200: { + description: "Logout confirmation (for testing)", + content: { + "application/json": { schema: resolver(logoutResponse) }, + }, + }, + }, + }), + async (c) => { + const { cookies, logger, workos } = c.var; + + logger.info("User logging out"); + + try { + // Get the access token to extract session ID + const accessToken = cookies.get("wos_access_token"); + let sessionId: string | undefined; + + if (accessToken) { + try { + // Extract session ID from the access token + const payload = jose.decodeJwt(accessToken); + sessionId = payload.sid as string; + logger.info(`Extracted session ID for logout: ${sessionId}`); + } catch (jwtError) { + logger.warn( + "Failed to extract session ID from access token", + jwtError, + ); + } + } + + // Clear all auth cookies first + cookies.set("wos_access_token", "", { + path: "/", + httpOnly: true, + expires: new Date(0), + }); + + cookies.set("wos_refresh_token", "", { + path: "/", + httpOnly: true, + expires: new Date(0), + }); + + cookies.set("user_id", "", { + path: "/", + httpOnly: true, + expires: new Date(0), + }); + + // For testing purposes, if test=true query param is present, return JSON + if (c.req.query("test") === "true") { + return c.json({ message: "Logged out successfully" }); + } + + // Get the return URL for after logout + const redirectTo = c.req.query("return") ?? "/"; + + // Revoke the session directly via WorkOS API to properly terminate the session + if (sessionId) { + try { + await workos.userManagement.revokeSession({ + sessionId: sessionId, + }); + logger.info(`Successfully revoked WorkOS session: ${sessionId}`); + } catch (revokeError) { + logger.error("Failed to revoke WorkOS session", revokeError); + // Continue with logout even if revoke fails + } + } else { + logger.warn("No session ID found, performing local logout only"); + } + + // Redirect after session is revoked + logger.info("Session revoked, redirecting user"); + return c.redirect(redirectTo); + } catch (error) { + logger.error("Error during logout", error); + + // Fallback: just redirect locally if WorkOS logout fails + const redirectTo = c.req.query("return") ?? "/"; + return c.redirect(redirectTo); + } + }, + ) + .post( + "/switch-organization", + describeRoute({ + description: + "Switch to a different organization context within the same session", + requestBody: { + content: { + "application/json": { schema: resolver(switchOrganizationRequest) }, + }, + }, + responses: { + 200: { + description: "Successfully switched organization", + content: { + "application/json": { + schema: resolver(switchOrganizationResponse), + }, + }, + }, + 401: { + description: "No active session", + }, + 400: { + description: "Failed to switch organization", + }, + }, + }), + async (c) => { + const { workos, cookies, logger } = c.var; + + try { + const { organizationId } = await c.req.json(); + const refreshToken = cookies.get("wos_refresh_token"); + + if (!refreshToken) { + return c.json({ error: "No active session" }, 401); + } + + logger.info(`Switching to organization: ${organizationId}`); + + // Get new access token for the specific organization + const { accessToken, refreshToken: newRefreshToken } = + await workos.userManagement.authenticateWithRefreshToken({ + refreshToken, + clientId: env("WORKOS_CLIENT_ID"), + organizationId, // This will set the org context in the new token + }); + + // Update stored access token + cookies.set("wos_access_token", accessToken, { + path: "/", + httpOnly: true, + secure: env("NODE_ENV") === "production", + sameSite: "lax", + }); + + // Update refresh token if it was rotated + if (newRefreshToken) { + cookies.set("wos_refresh_token", newRefreshToken, { + path: "/", + httpOnly: true, + secure: env("NODE_ENV") === "production", + sameSite: "lax", + }); + } + + logger.info(`Successfully switched to organization: ${organizationId}`); + return c.json({ success: true, organizationId }); + } catch (error) { + logger.error("Failed to switch organization", error); + return c.json( + { + error: "Failed to switch organization", + message: error instanceof Error ? error.message : "Unknown error", + }, + 400, + ); + } + }, + ) + .get( + "/me", + describeRoute({ + description: "Get current user information from WorkOS session", + responses: { + 200: { + description: "Current user information", + content: { + "application/json": { schema: resolver(userResponse) }, + }, + }, + 401: { + description: "Not authenticated", + }, + }, + }), + async (c) => { + const { logger } = c.var; + + try { + // Use the getUser helper to get current user from WorkOS session + const { getUser } = await import("./auth.helpers"); + const user = await getUser(c); + + if (!user) { + return c.json({ error: "Not authenticated" }, 401); + } + + logger.info(`Retrieved user info for: ${user.email}`); + return c.json(user); + } catch (error) { + logger.error("Failed to get current user", error); + return c.json({ error: "Failed to get user information" }, 500); + } + }, + ); diff --git a/service/auth/auth.helpers.test.ts b/service/auth/auth.helpers.test.ts new file mode 100644 index 0000000..d6e2bd5 --- /dev/null +++ b/service/auth/auth.helpers.test.ts @@ -0,0 +1,298 @@ +import { expect, it, mock, describe, beforeEach, spyOn } from "bun:test"; +import { getUser, hasSession, requireAuth } from "./auth.helpers"; +import { SurfaceContext } from "../../surface.app.ctx"; +import { WorkOS } from "@workos-inc/node"; +import * as jose from "jose"; + +describe("auth helpers", () => { + // Set up test environment variables + process.env.WORKOS_CLIENT_ID = "client_test_123"; + + const mockLogger = { + error: mock(), + info: mock(), + debug: mock(), + }; + + const mockCookies = { + get: mock(), + set: mock(), + }; + + const mockJwt = { + verify: mock(), + sign: mock(), + }; + + const mockGetUser = mock(); + + const mockWorkOS = { + userManagement: { + authenticateWithRefreshToken: mock(), + getUser: mockGetUser, + }, + } as unknown as WorkOS; + + const createMockContext = (overrides = {}): SurfaceContext => { + return { + var: { + logger: mockLogger, + cookies: mockCookies, + jwt: mockJwt, + workos: mockWorkOS, + ...overrides, + }, + req: { + header: mock(), + }, + json: mock(), + set: mock(), + } as unknown as SurfaceContext; + }; + + let jwtVerifySpy: any; + + beforeEach(() => { + // Reset existing spy or create new one + if (jwtVerifySpy) { + jwtVerifySpy.mockClear(); + } else { + jwtVerifySpy = spyOn(jose, "jwtVerify"); + } + + // Reset WorkOS API mocks + mockGetUser.mockClear(); + }); + + describe("getUser", () => { + it("should return user from valid WorkOS access token", async () => { + const mockPayload = { + sub: "user_123", + email: "john@example.com", + given_name: "John", + family_name: "Doe", + name: "John Doe", + picture: undefined, + iat: 1234567890, + exp: 1234567990, + org_id: "org_123", + role: "admin", + permissions: ["read", "write"], + }; + + const context = createMockContext({ + cookies: { + get: mock().mockReturnValue("valid.workos.access.token"), + set: mock(), + }, + }); + + jwtVerifySpy.mockResolvedValue({ payload: mockPayload }); + + // Mock WorkOS User Management API response + mockGetUser.mockResolvedValue({ + id: "user_123", + email: "john@example.com", + firstName: "John", + lastName: "Doe", + profilePictureUrl: null, + }); + + const user = await getUser(context); + + expect(user).toEqual({ + id: "user_123", + email: "john@example.com", + given_name: "John", + family_name: "Doe", + name: "John Doe", + picture: "", + org_id: "org_123", + role: "admin", + permissions: ["read", "write"], + // Legacy compatibility + profilePicture: "", + organizationId: "org_123", + roles: ["read", "write"], + }); + + expect(context.var.cookies.get).toHaveBeenCalledWith("wos_access_token"); + expect(mockGetUser).toHaveBeenCalledWith("user_123"); + }); + + it("should return undefined when no access token and refresh fails", async () => { + const context = createMockContext({ + cookies: { + get: mock().mockReturnValue(undefined), + set: mock(), + }, + }); + const user = await getUser(context); + + expect(user).toBeUndefined(); + }); + + it("should attempt refresh when JWT verification fails", async () => { + const mockGet = mock() + .mockReturnValueOnce("invalid.access.token") + .mockReturnValueOnce("valid.refresh.token") + .mockReturnValueOnce(undefined); // No new access token after failed refresh + + const context = createMockContext({ + cookies: { + get: mockGet, + set: mock(), + }, + }); + + jwtVerifySpy.mockRejectedValue(new Error("Invalid token")); + + ( + mockWorkOS.userManagement.authenticateWithRefreshToken as any + ).mockRejectedValue(new Error("Refresh failed")); + const user = await getUser(context); + + expect(user).toBeUndefined(); + expect(mockLogger.error).toHaveBeenCalledWith( + "Failed to refresh WorkOS tokens", + expect.any(Error), + ); + }); + }); + + describe("hasSession", () => { + it("should return JWT payload for valid WorkOS access token", async () => { + const mockPayload = { + sub: "user_123", + email: "john@example.com", + name: "John Doe", + iat: 1234567890, + exp: 1234567990, + }; + + const context = createMockContext({ + cookies: { + get: mock().mockReturnValue("valid.workos.access.token"), + set: mock(), + }, + }); + + jwtVerifySpy.mockResolvedValue({ payload: mockPayload }); + + const session = await hasSession(context); + + expect(session).toEqual(mockPayload); + }); + + it("should return JWT payload for valid Bearer token", async () => { + const mockPayload = { + sub: "user_123", + email: "john@example.com", + name: "John Doe", + iat: 1234567890, + exp: 1234567990, + }; + + const context = createMockContext({ + cookies: { + get: mock().mockReturnValue(undefined), + set: mock(), + }, + }); + context.req.header = mock().mockReturnValue( + "Bearer valid.workos.access.token", + ); + + jwtVerifySpy.mockResolvedValue({ payload: mockPayload }); + + const session = await hasSession(context); + + expect(session).toEqual(mockPayload); + expect(context.req.header).toHaveBeenCalledWith("Authorization"); + }); + + it("should return false when no token available", async () => { + const context = createMockContext({ + cookies: { + get: mock().mockReturnValue(undefined), + set: mock(), + }, + }); + context.req.header = mock().mockReturnValue(undefined); + + const session = await hasSession(context); + + expect(session).toBe(false); + }); + + it("should return false when JWT verification fails and refresh fails", async () => { + const mockGet = mock() + .mockReturnValueOnce("invalid.token") + .mockReturnValueOnce("valid.refresh.token") + .mockReturnValueOnce(undefined); // No new access token + + const context = createMockContext({ + cookies: { + get: mockGet, + set: mock(), + }, + }); + + jwtVerifySpy.mockRejectedValue(new Error("Invalid token")); + + ( + mockWorkOS.userManagement.authenticateWithRefreshToken as any + ).mockRejectedValue(new Error("Refresh failed")); + const session = await hasSession(context); + + expect(session).toBe(false); + expect(mockLogger.error).toHaveBeenCalledWith( + "Failed to refresh WorkOS tokens", + expect.any(Error), + ); + }); + }); + + describe("requireAuth", () => { + it("should call next() for authenticated requests", async () => { + const mockPayload = { sub: "user_123" }; + + const context = createMockContext({ + cookies: { + get: mock().mockReturnValue("valid.workos.access.token"), + set: mock(), + }, + }); + + jwtVerifySpy.mockResolvedValue({ payload: mockPayload }); + + const next = mock(); + + await requireAuth(context, next); + + expect(next).toHaveBeenCalled(); + expect((context as any).session).toEqual(mockPayload); + }); + + it("should return 401 for unauthenticated requests", async () => { + const context = createMockContext({ + cookies: { + get: mock().mockReturnValue(undefined), + set: mock(), + }, + }); + context.req.header = mock().mockReturnValue(undefined); + context.json = mock().mockReturnValue(new Response()); + + const next = mock(); + + await requireAuth(context, next); + + expect(next).not.toHaveBeenCalled(); + expect(context.json).toHaveBeenCalledWith( + { error: "Authentication required" }, + 401, + ); + }); + }); +}); diff --git a/service/auth/auth.helpers.ts b/service/auth/auth.helpers.ts index cf2c9e5..e21a5ba 100644 --- a/service/auth/auth.helpers.ts +++ b/service/auth/auth.helpers.ts @@ -1,40 +1,316 @@ import { JWTPayload } from "hono/utils/jwt/types"; import { SurfaceContext } from "../../surface.app.ctx"; import { User } from "./auth.endpoints"; +import { env } from "../../env"; +import * as jose from "jose"; -export const fakeUser = { - id: "123", - name: "Alice", - email: "alice@domain.com", - roles: ["admin"], - profilePicture: - "https://plus.unsplash.com/premium_photo-1672201106204-58e9af7a2888?q=80&w=80", -}; - +/** + * Get the current user from WorkOS session tokens + * @param c Surface context + * @returns User object or undefined if not authenticated + */ export async function getUser(c: SurfaceContext): Promise { - const userId = c.var.cookies.get("user_id"); - if (!userId || userId === "") return undefined; - return fakeUser; + try { + const accessToken = c.var.cookies.get("wos_access_token"); + + if (!accessToken) { + // Try to refresh the token + return await refreshAndGetUser(c); + } + + // Validate the WorkOS JWT using their JWKS + const JWKS = jose.createRemoteJWKSet( + new URL(`https://api.workos.com/sso/jwks/${env("WORKOS_CLIENT_ID")}`), + ); + + try { + const { payload } = await jose.jwtVerify(accessToken, JWKS, { + issuer: "https://api.workos.com", + }); + + // Validate we got a payload + if (!payload || !payload.sub) { + c.var.logger.error("Invalid JWT payload - missing sub claim"); + throw new Error("Invalid JWT payload"); + } + + // WorkOS JWT only contains minimal claims (sub, org_id, role, permissions) + // We need to fetch full user details from the User Management API + const userId = payload.sub as string; + + try { + // Fetch user details from WorkOS User Management API + const workosUser = await c.var.workos.userManagement.getUser(userId); + + const user: User = { + id: workosUser.id, + email: workosUser.email, + given_name: workosUser.firstName ?? "", + family_name: workosUser.lastName ?? "", + name: + `${workosUser.firstName || ""} ${workosUser.lastName || ""}`.trim() || + workosUser.email, + picture: workosUser.profilePictureUrl ?? "", + org_id: payload.org_id as string, + role: payload.role as string, + permissions: payload.permissions as string[], + // Legacy compatibility mappings + profilePicture: workosUser.profilePictureUrl ?? "", + organizationId: payload.org_id as string, + roles: payload.permissions as string[], + }; + + c.var.logger.info(`User authenticated: ${user.email} (${user.name})`); + return user; + } catch (userFetchError) { + c.var.logger.error( + "Failed to fetch user details from WorkOS", + userFetchError, + ); + + // Fallback: create minimal user object from JWT claims only + const user: User = { + id: userId, + org_id: payload.org_id as string, + role: payload.role as string, + permissions: payload.permissions as string[], + // Legacy compatibility mappings + organizationId: payload.org_id as string, + roles: payload.permissions as string[], + }; + + c.var.logger.info(`User authenticated (minimal): ${user.id}`); + return user; + } + } catch (jwtError) { + c.var.logger.debug("Access token validation failed, attempting refresh"); + // Token might be expired, try to refresh + return await refreshAndGetUser(c); + } + } catch (error) { + c.var.logger.error("Error getting user from WorkOS session", error); + return undefined; + } +} + +/** + * Attempt to refresh tokens and get user + * @param c Surface context + * @returns User object or undefined if refresh fails + */ +async function refreshAndGetUser(c: SurfaceContext): Promise { + try { + const refreshToken = c.var.cookies.get("wos_refresh_token"); + + if (!refreshToken) { + c.var.logger.debug("No refresh token available"); + return undefined; + } + + c.var.logger.info("Attempting to refresh WorkOS tokens"); + + // Use WorkOS refresh token endpoint + const refreshResult = + await c.var.workos.userManagement.authenticateWithRefreshToken({ + refreshToken, + clientId: env("WORKOS_CLIENT_ID"), + }); + + const { user, accessToken, refreshToken: newRefreshToken } = refreshResult; + + // Store the new access token + c.var.cookies.set("wos_access_token", accessToken, { + path: "/", + httpOnly: true, + secure: env("NODE_ENV") === "production", + sameSite: "lax", + }); + + // Update refresh token if it was rotated + if (newRefreshToken) { + c.var.cookies.set("wos_refresh_token", newRefreshToken, { + path: "/", + httpOnly: true, + secure: env("NODE_ENV") === "production", + sameSite: "lax", + }); + } + + // Update user ID cookie + c.var.cookies.set("user_id", user.id, { + path: "/", + httpOnly: true, + secure: env("NODE_ENV") === "production", + sameSite: "lax", + }); + + c.var.logger.info("Successfully refreshed WorkOS tokens"); + + // Convert WorkOS user to our User type + const surfaceUser: User = { + id: user.id, + email: user.email, + given_name: user.firstName ?? undefined, + family_name: user.lastName ?? undefined, + name: + `${user.firstName || ""} ${user.lastName || ""}`.trim() || user.email, + picture: user.profilePictureUrl ?? undefined, + // Legacy compatibility + profilePicture: user.profilePictureUrl ?? undefined, + roles: [], + }; + + c.var.logger.info( + `Successfully refreshed tokens for user ${surfaceUser.email || surfaceUser.id}`, + ); + return surfaceUser; + } catch (error) { + c.var.logger.error("Failed to refresh WorkOS tokens", error); + return undefined; + } } +/** + * Check if the current request has a valid WorkOS session + * @param c Surface context + * @returns JWT payload if valid session exists, false otherwise + */ export const hasSession = async ( - c: SurfaceContext + c: SurfaceContext, ): Promise => { - // we can simply check for cookie presence client side - // but for service calls we need to check the jwt as Bearer token - // plenty more to do here obviously but a good starting point for "real" auth. - const authHeader = c.req.header("Authorization") ?? ""; - const userId = c.var.cookies.get("user_id") || authHeader > "Bearer "; - const hasUserId = userId !== undefined && userId > ""; - if (hasUserId) { - return true; - } - const token = authHeader.replace("Bearer ", ""); try { - const decoded = await c.var.jwt.verify(token, process.env.JWT_SECRET ?? ""); - return decoded; - } catch (e) { - c.var.logger.error(`No valid session was found.`); + // Check for WorkOS access token first + const accessToken = c.var.cookies.get("wos_access_token"); + if (accessToken) { + const JWKS = jose.createRemoteJWKSet( + new URL(`https://api.workos.com/sso/jwks/${env("WORKOS_CLIENT_ID")}`), + ); + + try { + const { payload } = await jose.jwtVerify(accessToken, JWKS, { + issuer: "https://api.workos.com", + }); + return payload; + } catch (jwtError) { + c.var.logger.info("Access token validation failed, attempting refresh"); + // Try to refresh the token + const user = await refreshAndGetUser(c); + if (user) { + // Get the new access token after refresh + const newAccessToken = c.var.cookies.get("wos_access_token"); + if (newAccessToken) { + const { payload } = await jose.jwtVerify(newAccessToken, JWKS, { + issuer: "https://api.workos.com", + }); + return payload; + } + } + return false; + } + } + + // Fallback to Authorization header for API calls + const authHeader = c.req.header("Authorization"); + if (authHeader?.startsWith("Bearer ")) { + const token = authHeader.replace("Bearer ", ""); + const JWKS = jose.createRemoteJWKSet( + new URL(`https://api.workos.com/sso/jwks/${env("WORKOS_CLIENT_ID")}`), + ); + + try { + const { payload } = await jose.jwtVerify(token, JWKS, { + issuer: "https://api.workos.com", + }); + return payload; + } catch (jwtError) { + c.var.logger.error( + "Authorization header token validation failed", + jwtError, + ); + return false; + } + } + + return false; + } catch (error) { + c.var.logger.error("No valid WorkOS session found", error); return false; } }; + +/** + * Middleware to require authentication for protected routes + * @param c Surface context + * @param next Next function + */ +export const requireAuth = async ( + c: SurfaceContext, + next: () => Promise, +) => { + const session = await hasSession(c); + + if (!session) { + return c.json({ error: "Authentication required" }, 401); + } + + // Store the session data in context for use in handlers + (c as any).session = session; + await next(); +}; + +/** + * Get the current organization ID from the session + * @param c Surface context + * @returns Organization ID or undefined + */ +export async function getCurrentOrganizationId( + c: SurfaceContext, +): Promise { + const session = await hasSession(c); + if (session && typeof session === "object") { + return session.org_id as string; + } + return undefined; +} + +/** + * Get user permissions from the current session + * @param c Surface context + * @returns Array of permissions or empty array + */ +export async function getUserPermissions(c: SurfaceContext): Promise { + const session = await hasSession(c); + if (session && typeof session === "object") { + return (session.permissions as string[]) || []; + } + return []; +} + +/** + * Check if user has a specific permission + * @param c Surface context + * @param permission Permission to check + * @returns True if user has the permission + */ +export async function hasPermission( + c: SurfaceContext, + permission: string, +): Promise { + const permissions = await getUserPermissions(c); + return permissions.includes(permission); +} + +/** + * Get user role in the current organization + * @param c Surface context + * @returns User role or undefined + */ +export async function getUserRole( + c: SurfaceContext, +): Promise { + const session = await hasSession(c); + if (session && typeof session === "object") { + return session.role as string; + } + return undefined; +} diff --git a/service/members/member.service.client.ts b/service/members/member.service.client.ts index 29a43e7..9b578a5 100644 --- a/service/members/member.service.client.ts +++ b/service/members/member.service.client.ts @@ -1,14 +1,29 @@ import { User } from "../auth/auth.endpoints"; -import { fakeUser } from "../auth/auth.helpers"; const fakeMembers: User[] = [ - fakeUser, { - name: "Bob", - email: "bob@member.net", + id: "123", + email: "alice@domain.com", + given_name: "Alice", + family_name: "Smith", + name: "Alice Smith", + picture: + "https://plus.unsplash.com/premium_photo-1672201106204-58e9af7a2888?q=80&w=80", + // Legacy compatibility + profilePicture: + "https://plus.unsplash.com/premium_photo-1672201106204-58e9af7a2888?q=80&w=80", + roles: ["admin"], + }, + { id: "2", - roles: [], + email: "bob@member.net", + given_name: "Bob", + family_name: "Johnson", + name: "Bob Johnson", + picture: "", + // Legacy compatibility profilePicture: "", + roles: [], }, ]; diff --git a/service/members/members.endpoints.test.ts b/service/members/members.endpoints.test.ts index 7109823..97d2cb9 100644 --- a/service/members/members.endpoints.test.ts +++ b/service/members/members.endpoints.test.ts @@ -1,4 +1,4 @@ -import { expect, describe, mock, it } from "bun:test"; +import { expect, describe, mock, it, beforeEach, spyOn } from "bun:test"; import { applyContext, cookies, @@ -9,7 +9,7 @@ import { logger } from "../../logger/logger"; import { members } from "./members.endpoints"; import { Hono } from "hono"; import { User } from "../auth/auth.endpoints"; -import { fakeUser } from "../auth/auth.helpers"; +import * as jose from "jose"; describe("members endpoint tests", () => { const mockLogger = { @@ -19,13 +19,41 @@ describe("members endpoint tests", () => { } as unknown as typeof logger; const mockCookies = { - get: mock(() => "fake-session"), + get: mock(() => "valid.workos.access.token"), + set: mock(), } as unknown as ReturnType; + const mockJwt = { + verify: mock(() => + Promise.resolve({ sub: "user_123", email: "test@example.com" }), + ), + sign: mock(() => Promise.resolve("token")), + }; + + const mockWorkOS = { + userManagement: { + authenticateWithRefreshToken: mock(), + }, + }; + // Create mocks with correct return types to avoid type errors const getMembersMock = mock<() => Promise>(); const getMemberMock = mock<() => Promise>(); + const fakeUser: User = { + id: "123", + email: "alice@domain.com", + given_name: "Alice", + family_name: "Smith", + name: "Alice Smith", + picture: + "https://plus.unsplash.com/premium_photo-1672201106204-58e9af7a2888?q=80&w=80", + // Legacy compatibility + profilePicture: + "https://plus.unsplash.com/premium_photo-1672201106204-58e9af7a2888?q=80&w=80", + roles: ["admin"], + }; + // Set default implementation getMembersMock.mockImplementation(() => Promise.resolve([fakeUser])); getMemberMock.mockImplementation(() => Promise.resolve(fakeUser)); @@ -41,10 +69,26 @@ describe("members endpoint tests", () => { logger: mockLogger, memberServiceClient: mockMemberServiceClient, cookies: mockCookies, - }) + jwt: mockJwt as any, + workos: mockWorkOS as any, + }), ) .route("/api/members", members); + beforeEach(() => { + // Mock jose.jwtVerify to simulate valid WorkOS token + const mockPayload = { + sub: "user_123", + email: "test@example.com", + name: "Test User", + iat: Date.now() / 1000, + exp: Date.now() / 1000 + 3600, + }; + + // Use spyOn to mock jose.jwtVerify properly + spyOn(jose, "jwtVerify").mockResolvedValue({ payload: mockPayload } as any); + }); + it("returns members from api", async () => { // the mock service method delays for a second for UI, skipping this. const response = await testApp.request("/api/members"); diff --git a/state/user.store.ts b/state/user.store.ts index e833f7b..bac7473 100644 --- a/state/user.store.ts +++ b/state/user.store.ts @@ -26,9 +26,19 @@ export const useUserStore: StoreCreator = (set) => ({ }); registerLoader(async (c: SurfaceContext) => { - const user = await getUser(c); - logger.debug("loading user server side:", user); - return { - user, - }; + try { + const user = await getUser(c); + if (user) { + logger.debug(`User ${user.email || user.id || "unknown"} loaded for SSR`); + } + + return { + user, + }; + } catch (error) { + logger.error("Error loading user data server side:", error); + return { + user: undefined, + }; + } }); diff --git a/surface.app.ctx.ts b/surface.app.ctx.ts index 6dd087c..fe322e5 100644 --- a/surface.app.ctx.ts +++ b/surface.app.ctx.ts @@ -5,7 +5,8 @@ import * as jwt from "hono/jwt"; import { getCookie, setCookie } from "hono/cookie"; import { CookieOptions } from "hono/utils/cookie"; import { logger } from "./logger/logger"; - +import { WorkOS } from "@workos-inc/node"; +import { env } from "./env"; import { memberServiceClient } from "./service/members/member.service.client"; /** @@ -26,12 +27,24 @@ export function cookies(c: SurfaceContext) { return { get, set }; } +// works as singleton +const workos: WorkOS | undefined = undefined; +export const getWorkOS = () => { + if (workos !== undefined) { + return workos; + } + return new WorkOS(env("WORKOS_API_KEY")); +}; + export type Dependencies = { // utils logger: typeof logger; cookies: ReturnType; jwt: typeof jwt; + // auth via workos + workos: WorkOS; + // specifically for testing, allows overwriting the rpc client rpcClientMock?: typeof hc; @@ -51,11 +64,16 @@ export const applyContext = (injections: Partial) => createMiddleware(async (c, next) => { c.set("logger", injections.logger ?? logger); c.set("cookies", injections.cookies ?? cookies(c)); - c.set("jwt", jwt); + c.set("jwt", injections.jwt ?? jwt); + c.set("workos", injections.workos ?? getWorkOS()); + + // For demo only, remove as this gets cooler. c.set( "memberServiceClient", - injections.memberServiceClient ?? memberServiceClient + injections.memberServiceClient ?? memberServiceClient, ); + + // for testing only c.set("rpcClientMock", injections.rpcClientMock); await next(); }); diff --git a/views/components/app-sidebar.tsx b/views/components/app-sidebar.tsx index c72b839..0fcd480 100644 --- a/views/components/app-sidebar.tsx +++ b/views/components/app-sidebar.tsx @@ -23,14 +23,10 @@ import { SidebarHeader, SidebarRail, } from "@/components/ui/sidebar"; +import { useAppState } from "@/hooks/use-app-state"; -// This is sample data. +// This is sample data for teams and navigation. const data = { - user: { - name: "shadcn", - email: "m@example.com", - avatar: "/avatars/shadcn.jpg", - }, teams: [ { name: "Acme Inc", @@ -155,6 +151,27 @@ const data = { }; export function AppSidebar({ ...props }: React.ComponentProps) { + const { user } = useAppState(); + + // Use real user data if available, otherwise fallback to default + const userData = user + ? { + name: + user.name || + (user.given_name && user.family_name + ? `${user.given_name} ${user.family_name}`.trim() + : user.given_name || user.family_name) || + user.email || + "Unknown User", + email: user.email || "unknown@example.com", + avatar: user.picture || user.profilePicture || "/avatars/default.jpg", + } + : { + name: "Guest", + email: "guest@example.com", + avatar: "/avatars/default.jpg", + }; + return ( @@ -165,7 +182,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - + diff --git a/views/components/nav-user.tsx b/views/components/nav-user.tsx index da3c46c..0869272 100644 --- a/views/components/nav-user.tsx +++ b/views/components/nav-user.tsx @@ -97,8 +97,10 @@ export function NavUser({ - - Log out + + + Log out + diff --git a/views/hooks/use-app-state.ts b/views/hooks/use-app-state.ts index b785b6b..0650af4 100644 --- a/views/hooks/use-app-state.ts +++ b/views/hooks/use-app-state.ts @@ -1,6 +1,7 @@ import { useAppStore } from "../../state/root.store"; import { RootContext } from "@/providers/root-context-provider"; import React from "react"; +import { User } from "../../service/auth/auth.endpoints"; export function useAppState() { const ssrContext = React.useContext(RootContext); @@ -10,3 +11,13 @@ export function useAppState() { ...ssrContext, }; } + +export function useUser(): User | undefined { + const { user } = useAppState(); + return user; +} + +export function useIsAuthenticated(): boolean { + const user = useUser(); + return !!user; +} diff --git a/views/ssr.ts b/views/ssr.ts index 4190d9c..0768577 100644 --- a/views/ssr.ts +++ b/views/ssr.ts @@ -45,10 +45,11 @@ export async function render(opts: { }); const ssrState = await loadServerRouterContext(opts.c); + const accessToken = opts.c.var.cookies.get("wos_access_token"); const rpcClient = hc(`${process.env.SELF_RPC_HOST}/`, { headers: { "Content-Type": "application/json", - Authorization: `Bearer ${opts.c.var.cookies.get("session")}`, + ...(accessToken && { Authorization: `Bearer ${accessToken}` }), }, });