From 7ebc805ffa6dbcc87910206fa4ac7200a944b2d7 Mon Sep 17 00:00:00 2001 From: Roberto Vidal Date: Mon, 29 Jul 2024 19:31:45 +0100 Subject: [PATCH] feat: oauth-based login (#7) --- README.md | 6 + package.json | 3 +- packages/bolt/README.md | 7 +- .../bolt/app/components/header/Header.tsx | 6 +- packages/bolt/app/lib/.server/login.ts | 32 ++- packages/bolt/app/lib/.server/sessions.ts | 165 +++++++++++--- packages/bolt/app/lib/auth.ts | 4 + packages/bolt/app/lib/constants.ts | 2 + packages/bolt/app/lib/fetch.ts | 14 ++ packages/bolt/app/lib/webcontainer/index.ts | 6 +- packages/bolt/app/routes/api.chat.ts | 7 +- packages/bolt/app/routes/api.enhancer.ts | 7 +- packages/bolt/app/routes/login.tsx | 210 +++++++++++++----- packages/bolt/app/routes/logout.tsx | 10 + packages/bolt/app/utils/logger.ts | 2 +- packages/bolt/package.json | 3 + pnpm-lock.yaml | 141 +++++++++++- 17 files changed, 523 insertions(+), 102 deletions(-) create mode 100644 packages/bolt/app/lib/auth.ts create mode 100644 packages/bolt/app/lib/constants.ts create mode 100644 packages/bolt/app/lib/fetch.ts create mode 100644 packages/bolt/app/routes/logout.tsx diff --git a/README.md b/README.md index cea0485..d93d679 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,12 @@ cd bolt pnpm i ``` +3. Optionally, init git hooks: + +```bash +pnpmx husky +``` + ### Development To start developing the Bolt UI: diff --git a/package.json b/package.json index 9c8520a..ae3f474 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,7 @@ "playground:dev": "pnpm run --filter=playground dev", "lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .", "test": "pnpm run -r test", - "typecheck": "pnpm run -r typecheck", - "prepare": "husky" + "typecheck": "pnpm run -r typecheck" }, "commitlint": { "extends": [ diff --git a/packages/bolt/README.md b/packages/bolt/README.md index 4b0d965..3f30735 100644 --- a/packages/bolt/README.md +++ b/packages/bolt/README.md @@ -36,12 +36,11 @@ Optionally, you an set the debug level: VITE_LOG_LEVEL=debug ``` -If you want to test the login locally you need to add the following variables: - +If you want to run authentication against a local StackBlitz instance, add: ``` -SESSION_SECRET=XXX -LOGIN_PASSWORD=XXX +VITE_CLIENT_ORIGIN=https://local.stackblitz.com:3000 ``` +` **Important**: Never commit your `.env.local` file to version control. It's already included in .gitignore. diff --git a/packages/bolt/app/components/header/Header.tsx b/packages/bolt/app/components/header/Header.tsx index f51d861..5fd661c 100644 --- a/packages/bolt/app/components/header/Header.tsx +++ b/packages/bolt/app/components/header/Header.tsx @@ -1,5 +1,6 @@ import { ClientOnly } from 'remix-utils/client-only'; import { OpenStackBlitz } from './OpenStackBlitz.client'; +import { IconButton } from '~/components/ui/IconButton'; export function Header() { return ( @@ -7,8 +8,11 @@ export function Header() {
Bolt
-
+
{() => } + + +
); diff --git a/packages/bolt/app/lib/.server/login.ts b/packages/bolt/app/lib/.server/login.ts index 5a501b3..2ce10fd 100644 --- a/packages/bolt/app/lib/.server/login.ts +++ b/packages/bolt/app/lib/.server/login.ts @@ -8,12 +8,34 @@ export function verifyPassword(password: string, cloudflareEnv: Env) { return password === loginPassword; } -export async function handleAuthRequest({ request, context }: LoaderFunctionArgs, body: object = {}) { - const authenticated = await isAuthenticated(request, context.cloudflare.env); +type RequestArgs = Pick; - if (import.meta.env.DEV || authenticated) { - return json(body); +export async function handleAuthRequest(args: T, body: object = {}) { + const { request, context } = args; + const { authenticated, response } = await isAuthenticated(request, context.cloudflare.env); + + if (authenticated) { + return json(body, response); } - return redirect('/login'); + return redirect('/login', response); +} + +export async function handleWithAuth(args: T, handler: (args: T) => Promise) { + const { request, context } = args; + const { authenticated, response } = await isAuthenticated(request, context.cloudflare.env); + + if (authenticated) { + const handlerResponse = await handler(args); + + if (response) { + for (const [key, value] of Object.entries(response.headers)) { + handlerResponse.headers.append(key, value); + } + } + + return handlerResponse; + } + + return json({}, { status: 401 }); } diff --git a/packages/bolt/app/lib/.server/sessions.ts b/packages/bolt/app/lib/.server/sessions.ts index 92f875d..5501bc5 100644 --- a/packages/bolt/app/lib/.server/sessions.ts +++ b/packages/bolt/app/lib/.server/sessions.ts @@ -1,31 +1,89 @@ import { createCookieSessionStorage, redirect } from '@remix-run/cloudflare'; -import { env } from 'node:process'; +import { request as doRequest } from '~/lib/fetch'; +import { CLIENT_ID, CLIENT_ORIGIN } from '~/lib/constants'; +import { logger } from '~/utils/logger'; +import { decode } from 'jsonwebtoken'; -const USER_SESSION_KEY = 'userId'; +const DEV_SESSION_SECRET = import.meta.env.DEV ? 'LZQMrERo3Ewn/AbpSYJ9aw==' : undefined; -function createSessionStorage(cloudflareEnv: Env) { - return createCookieSessionStorage({ +interface SessionData { + refresh: string; + expiresAt: number; +} + +export async function isAuthenticated(request: Request, env: Env) { + const { session, sessionStorage } = await getSession(request, env); + const token = session.get('refresh'); + + const header = async (cookie: Promise) => ({ headers: { 'Set-Cookie': await cookie } }); + const destroy = () => header(sessionStorage.destroySession(session)); + + if (token == null) { + return { authenticated: false as const, response: await destroy() }; + } + + const expiresAt = session.get('expiresAt') ?? 0; + + if (Date.now() < expiresAt) { + return { authenticated: true as const }; + } + + let data: Awaited> | null = null; + + try { + data = await refreshToken(token); + } catch { + // ignore + } + + if (data != null) { + const expiresAt = cookieExpiration(data.expires_in, data.created_at); + session.set('expiresAt', expiresAt); + + return { authenticated: true as const, response: await header(sessionStorage.commitSession(session)) }; + } else { + return { authenticated: false as const, response: await destroy() }; + } +} + +export async function createUserSession( + request: Request, + env: Env, + tokens: { refresh: string; expires_in: number; created_at: number }, +): Promise { + const { session, sessionStorage } = await getSession(request, env); + + const expiresAt = cookieExpiration(tokens.expires_in, tokens.created_at); + + session.set('refresh', tokens.refresh); + session.set('expiresAt', expiresAt); + + return { + headers: { + 'Set-Cookie': await sessionStorage.commitSession(session, { + maxAge: 3600 * 24 * 30, // 1 month + }), + }, + }; +} + +function getSessionStorage(cloudflareEnv: Env) { + return createCookieSessionStorage({ cookie: { name: '__session', httpOnly: true, path: '/', - sameSite: 'lax', - secrets: [env.SESSION_SECRET || cloudflareEnv.SESSION_SECRET], - secure: false, + secrets: [DEV_SESSION_SECRET || cloudflareEnv.SESSION_SECRET], + secure: import.meta.env.PROD, }, }); } -export async function getSession(request: Request, env: Env) { - const sessionStorage = createSessionStorage(env); - const cookie = request.headers.get('Cookie'); - - return { session: await sessionStorage.getSession(cookie), sessionStorage }; -} - export async function logout(request: Request, env: Env) { const { session, sessionStorage } = await getSession(request, env); + revokeToken(session.get('refresh')); + return redirect('/login', { headers: { 'Set-Cookie': await sessionStorage.destroySession(session), @@ -33,23 +91,76 @@ export async function logout(request: Request, env: Env) { }); } -export async function isAuthenticated(request: Request, env: Env) { - const { session } = await getSession(request, env); - const userId = session.get(USER_SESSION_KEY); +export function validateAccessToken(access: string) { + const jwtPayload = decode(access); - return !!userId; + const boltEnabled = typeof jwtPayload === 'object' && jwtPayload != null && jwtPayload.bolt === true; + + return boltEnabled; } -export async function createUserSession(request: Request, env: Env): Promise { - const { session, sessionStorage } = await getSession(request, env); +async function getSession(request: Request, env: Env) { + const sessionStorage = getSessionStorage(env); + const cookie = request.headers.get('Cookie'); - session.set(USER_SESSION_KEY, 'anonymous_user'); + return { session: await sessionStorage.getSession(cookie), sessionStorage }; +} - return { - headers: { - 'Set-Cookie': await sessionStorage.commitSession(session, { - maxAge: 60 * 60 * 24 * 7, // 7 days, +async function refreshToken(refresh: string): Promise<{ expires_in: number; created_at: number }> { + const response = await doRequest(`${CLIENT_ORIGIN}/oauth/token`, { + method: 'POST', + body: urlParams({ grant_type: 'refresh_token', client_id: CLIENT_ID, refresh_token: refresh }), + }); + + const body = await response.json(); + + if (!response.ok) { + throw new Error(`Unable to refresh token\n${JSON.stringify(body)}`); + } + + const { access_token: access } = body; + + if (!validateAccessToken(access)) { + throw new Error('User is no longer authorized for Bolt'); + } + + return body; +} + +function cookieExpiration(expireIn: number, createdAt: number) { + return (expireIn + createdAt - 10 * 60) * 1000; +} + +async function revokeToken(refresh?: string) { + if (refresh == null) { + return; + } + + try { + const response = await doRequest(`${CLIENT_ORIGIN}/oauth/revoke`, { + method: 'POST', + body: urlParams({ + token: refresh, + token_type_hint: 'refresh_token', + client_id: CLIENT_ID, }), - }, - }; + }); + + if (!response.ok) { + throw new Error(`Unable to revoke token: ${response.status}`); + } + } catch (error) { + logger.debug(error); + return; + } +} + +function urlParams(data: Record) { + const encoded = new URLSearchParams(); + + for (const [key, value] of Object.entries(data)) { + encoded.append(key, value); + } + + return encoded; } diff --git a/packages/bolt/app/lib/auth.ts b/packages/bolt/app/lib/auth.ts new file mode 100644 index 0000000..6d43611 --- /dev/null +++ b/packages/bolt/app/lib/auth.ts @@ -0,0 +1,4 @@ +export function forgetAuth() { + // FIXME: use dedicated method + localStorage.removeItem('__wc_api_tokens__'); +} diff --git a/packages/bolt/app/lib/constants.ts b/packages/bolt/app/lib/constants.ts new file mode 100644 index 0000000..80120af --- /dev/null +++ b/packages/bolt/app/lib/constants.ts @@ -0,0 +1,2 @@ +export const CLIENT_ID = 'bolt'; +export const CLIENT_ORIGIN = import.meta.env.VITE_CLIENT_ORIGIN ?? 'https://stackblitz.com'; diff --git a/packages/bolt/app/lib/fetch.ts b/packages/bolt/app/lib/fetch.ts new file mode 100644 index 0000000..4c5f53d --- /dev/null +++ b/packages/bolt/app/lib/fetch.ts @@ -0,0 +1,14 @@ +type CommonRequest = Omit & { body?: URLSearchParams }; + +export async function request(url: string, init?: CommonRequest) { + if (import.meta.env.DEV) { + const nodeFetch = await import('node-fetch'); + const https = await import('node:https'); + + const agent = url.startsWith('https') ? new https.Agent({ rejectUnauthorized: false }) : undefined; + + return nodeFetch.default(url, { ...init, agent }); + } + + return fetch(url, init); +} diff --git a/packages/bolt/app/lib/webcontainer/index.ts b/packages/bolt/app/lib/webcontainer/index.ts index 92f1685..1830061 100644 --- a/packages/bolt/app/lib/webcontainer/index.ts +++ b/packages/bolt/app/lib/webcontainer/index.ts @@ -1,5 +1,6 @@ import { WebContainer } from '@webcontainer/api'; import { WORK_DIR_NAME } from '~/utils/constants'; +import { forgetAuth } from '~/lib/auth'; interface WebContainerContext { loaded: boolean; @@ -21,7 +22,10 @@ if (!import.meta.env.SSR) { webcontainer = import.meta.hot?.data.webcontainer ?? Promise.resolve() - .then(() => WebContainer.boot({ workdirName: WORK_DIR_NAME })) + .then(() => { + forgetAuth(); + return WebContainer.boot({ workdirName: WORK_DIR_NAME }); + }) .then((webcontainer) => { webcontainerContext.loaded = true; return webcontainer; diff --git a/packages/bolt/app/routes/api.chat.ts b/packages/bolt/app/routes/api.chat.ts index a123f5a..30436f3 100644 --- a/packages/bolt/app/routes/api.chat.ts +++ b/packages/bolt/app/routes/api.chat.ts @@ -4,8 +4,13 @@ import { MAX_RESPONSE_SEGMENTS, MAX_TOKENS } from '~/lib/.server/llm/constants'; import { CONTINUE_PROMPT } from '~/lib/.server/llm/prompts'; import { streamText, type Messages, type StreamingOptions } from '~/lib/.server/llm/stream-text'; import SwitchableStream from '~/lib/.server/llm/switchable-stream'; +import { handleWithAuth } from '~/lib/.server/login'; -export async function action({ context, request }: ActionFunctionArgs) { +export async function action(args: ActionFunctionArgs) { + return handleWithAuth(args, chatAction); +} + +async function chatAction({ context, request }: ActionFunctionArgs) { const { messages } = await request.json<{ messages: Messages }>(); const stream = new SwitchableStream(); diff --git a/packages/bolt/app/routes/api.enhancer.ts b/packages/bolt/app/routes/api.enhancer.ts index 2da992e..4bc1201 100644 --- a/packages/bolt/app/routes/api.enhancer.ts +++ b/packages/bolt/app/routes/api.enhancer.ts @@ -1,12 +1,17 @@ import { type ActionFunctionArgs } from '@remix-run/cloudflare'; import { StreamingTextResponse, parseStreamPart } from 'ai'; import { streamText } from '~/lib/.server/llm/stream-text'; +import { handleWithAuth } from '~/lib/.server/login'; import { stripIndents } from '~/utils/stripIndent'; const encoder = new TextEncoder(); const decoder = new TextDecoder(); -export async function action({ context, request }: ActionFunctionArgs) { +export async function action(args: ActionFunctionArgs) { + return handleWithAuth(args, enhancerAction); +} + +async function enhancerAction({ context, request }: ActionFunctionArgs) { const { message } = await request.json<{ message: string }>(); try { diff --git a/packages/bolt/app/routes/login.tsx b/packages/bolt/app/routes/login.tsx index 561b75f..421f9e4 100644 --- a/packages/bolt/app/routes/login.tsx +++ b/packages/bolt/app/routes/login.tsx @@ -3,49 +3,96 @@ import { redirect, type ActionFunctionArgs, type LoaderFunctionArgs, - type TypedResponse, + redirectDocument, } from '@remix-run/cloudflare'; -import { Form, useActionData } from '@remix-run/react'; -import { verifyPassword } from '~/lib/.server/login'; -import { createUserSession, isAuthenticated } from '~/lib/.server/sessions'; - -interface Errors { - password?: string; -} +import { useFetcher, useLoaderData } from '@remix-run/react'; +import { auth, type AuthAPI } from '@webcontainer/api'; +import { useEffect, useState } from 'react'; +import { createUserSession, isAuthenticated, validateAccessToken } from '~/lib/.server/sessions'; +import { request as doRequest } from '~/lib/fetch'; +import { CLIENT_ID, CLIENT_ORIGIN } from '~/lib/constants'; +import { logger } from '~/utils/logger'; export async function loader({ request, context }: LoaderFunctionArgs) { - const authenticated = await isAuthenticated(request, context.cloudflare.env); + const { authenticated, response } = await isAuthenticated(request, context.cloudflare.env); if (authenticated) { - return redirect('/'); + return redirect('/', response); } - return json({}); + const url = new URL(request.url); + + return json( + { + redirected: url.searchParams.has('code') || url.searchParams.has('error'), + }, + response, + ); } -export async function action({ request, context }: ActionFunctionArgs): Promise> { +export async function action({ request, context }: ActionFunctionArgs) { const formData = await request.formData(); - const password = String(formData.get('password')); - const errors: Errors = {}; + const payload = { + access: String(formData.get('access')), + refresh: String(formData.get('refresh')), + }; - if (!password) { - errors.password = 'Please provide a password'; + let response: Awaited> | undefined; + + try { + response = await doRequest(`${CLIENT_ORIGIN}/oauth/token/info`, { + headers: { authorization: `Bearer ${payload.access}` }, + }); + + if (!response.ok) { + throw await response.json(); + } + } catch (error) { + logger.warn('Authentication failure'); + logger.warn(error); + + return json({ error: 'invalid-token' as const }, { status: 401 }); } - if (!verifyPassword(password, context.cloudflare.env)) { - errors.password = 'Invalid password'; + const boltEnabled = validateAccessToken(payload.access); + + if (!boltEnabled) { + return json({ error: 'bolt-access' as const }, { status: 401 }); } - if (Object.keys(errors).length > 0) { - return json({ errors }); - } + const tokenInfo: { expires_in: number; created_at: number } = await response.json(); - return redirect('/', await createUserSession(request, context.cloudflare.env)); + const init = await createUserSession(request, context.cloudflare.env, { ...payload, ...tokenInfo }); + + return redirectDocument('/', init); } +type LoginState = + | { + kind: 'error'; + error: string; + description: string; + } + | { kind: 'pending' }; + +const ERRORS = { + 'bolt-access': 'You do not have access to Bolt.', + 'invalid-token': 'Authentication failed.', +}; + export default function Login() { - const actionData = useActionData(); + const { redirected } = useLoaderData(); + + useEffect(() => { + if (!import.meta.hot?.data.wcAuth) { + auth.init({ clientId: CLIENT_ID, scope: 'public', editorOrigin: CLIENT_ORIGIN }); + } + + if (import.meta.hot) { + import.meta.hot.data.wcAuth = true; + } + }, []); return (
@@ -53,38 +100,93 @@ export default function Login() {

Login

-
-
- - - {actionData?.errors?.password ? ( - -
- {actionData?.errors.password} -
- ) : null} -
-
- -
-
+ + {redirected ? 'Processing auth...' : }
); } + +function LoginForm() { + const [login, setLogin] = useState(null); + + const fetcher = useFetcher(); + + useEffect(() => { + auth.logout({ ignoreRevokeError: true }); + }, []); + + useEffect(() => { + if (fetcher.data?.error) { + auth.logout({ ignoreRevokeError: true }); + + setLogin({ + kind: 'error' as const, + ...{ error: fetcher.data.error, description: ERRORS[fetcher.data.error] }, + }); + } + }, [fetcher.data]); + + async function attemptLogin() { + startAuthFlow(); + + function startAuthFlow() { + auth.startAuthFlow({ popup: true }); + + Promise.race([authEvent(auth, 'auth-failed'), auth.loggedIn()]).then((error) => { + if (error) { + setLogin({ kind: 'error', ...error }); + } else { + onTokens(); + } + }); + } + + function onTokens() { + const tokens = auth.tokens()!; + + fetcher.submit(tokens, { + method: 'POST', + }); + + setLogin({ kind: 'pending' }); + } + } + + return ( + <> + + + {login?.kind === 'error' && ( +
+

+ {login.error} +

+

{login.description}

+
+ )} + + ); +} + +interface AuthError { + error: string; + description: string; +} + +function authEvent(auth: AuthAPI, event: 'logged-out'): Promise; +function authEvent(auth: AuthAPI, event: 'auth-failed'): Promise; +function authEvent(auth: AuthAPI, event: 'logged-out' | 'auth-failed') { + return new Promise((resolve) => { + const unsubscribe = auth.on(event as any, (arg: any) => { + unsubscribe(); + resolve(arg); + }); + }); +} diff --git a/packages/bolt/app/routes/logout.tsx b/packages/bolt/app/routes/logout.tsx new file mode 100644 index 0000000..b1de638 --- /dev/null +++ b/packages/bolt/app/routes/logout.tsx @@ -0,0 +1,10 @@ +import type { LoaderFunctionArgs } from '@remix-run/cloudflare'; +import { logout } from '~/lib/.server/sessions'; + +export async function loader({ request, context }: LoaderFunctionArgs) { + return logout(request, context.cloudflare.env); +} + +export default function Logout() { + return ''; +} diff --git a/packages/bolt/app/utils/logger.ts b/packages/bolt/app/utils/logger.ts index 615c73a..88bb62a 100644 --- a/packages/bolt/app/utils/logger.ts +++ b/packages/bolt/app/utils/logger.ts @@ -11,7 +11,7 @@ interface Logger { setLevel: (level: DebugLevel) => void; } -let currentLevel: DebugLevel = import.meta.env.VITE_LOG_LEVEL ?? 'warn'; +let currentLevel: DebugLevel = import.meta.env.VITE_LOG_LEVEL ?? import.meta.env.DEV ? 'debug' : 'info'; export const logger: Logger = { trace: (...messages: any[]) => log('trace', undefined, messages), diff --git a/packages/bolt/package.json b/packages/bolt/package.json index 7a48c52..8d8f5f7 100644 --- a/packages/bolt/package.json +++ b/packages/bolt/package.json @@ -48,6 +48,7 @@ "framer-motion": "^11.2.12", "isbot": "^4.1.0", "istextorbinary": "^9.5.0", + "jsonwebtoken": "^9.0.2", "nanostores": "^0.10.3", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -64,9 +65,11 @@ "@cloudflare/workers-types": "^4.20240620.0", "@remix-run/dev": "^2.10.0", "@types/diff": "^5.2.1", + "@types/jsonwebtoken": "^9.0.6", "@types/react": "^18.2.20", "@types/react-dom": "^18.2.7", "fast-glob": "^3.3.2", + "node-fetch": "^3.3.2", "typescript": "^5.5.2", "unified": "^11.0.5", "unocss": "^0.61.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc2e950..a4d2ba3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,6 +131,9 @@ importers: istextorbinary: specifier: ^9.5.0 version: 9.5.0 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 nanostores: specifier: ^0.10.3 version: 0.10.3 @@ -174,6 +177,9 @@ importers: '@types/diff': specifier: ^5.2.1 version: 5.2.1 + '@types/jsonwebtoken': + specifier: ^9.0.6 + version: 9.0.6 '@types/react': specifier: ^18.2.20 version: 18.3.3 @@ -183,6 +189,9 @@ importers: fast-glob: specifier: ^3.3.2 version: 3.3.2 + node-fetch: + specifier: ^3.3.2 + version: 3.3.2 typescript: specifier: ^5.5.2 version: 5.5.2 @@ -1484,6 +1493,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@9.0.6': + resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==} + '@types/mdast@3.0.15': resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==} @@ -1949,6 +1961,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -2234,6 +2249,10 @@ packages: resolution: {integrity: sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==} engines: {node: '>= 6'} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + date-fns@3.6.0: resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} @@ -2353,6 +2372,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + editions@6.21.0: resolution: {integrity: sha512-ofkXJtn7z0urokN62DI3SBo/5xAtF0rR7tn+S/bSYV79Ka8pTajIIl+fFQ1q88DQEImymmo97M4azY3WX/nUdg==} engines: {node: '>=4'} @@ -2615,6 +2637,10 @@ packages: fault@2.0.1: resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -2653,6 +2679,10 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -3144,6 +3174,16 @@ packages: resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} engines: {'0': node >= 0.2.0} + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + + jwa@1.4.1: + resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3190,9 +3230,24 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.kebabcase@4.1.1: resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} @@ -3202,6 +3257,9 @@ packages: lodash.mergewith@4.6.2: resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.snakecase@4.1.1: resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} @@ -3687,9 +3745,17 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + node-fetch-native@1.6.4: resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==} + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-forge@1.3.1: resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} engines: {node: '>= 6.13.0'} @@ -4679,8 +4745,8 @@ packages: resolution: {integrity: sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==} engines: {node: '>=14.0'} - undici@6.19.2: - resolution: {integrity: sha512-JfjKqIauur3Q6biAtHJ564e3bWa8VvT+7cSiOJHFbX4Erv6CLGDpg8z+Fmg/1OI/47RA+GI2QZaF48SSaLvyBA==} + undici@6.19.4: + resolution: {integrity: sha512-i3uaEUwNdkRq2qtTRRJb13moW5HWqviu7Vl7oYRYz++uPtGHJj+x7TGjcEuwS5Mt2P4nA0U9dhIX3DdB6JGY0g==} engines: {node: '>=18.17'} unenv-nightly@1.10.0-1717606461.a117952: @@ -6189,7 +6255,7 @@ snapshots: cookie-signature: 1.2.1 source-map-support: 0.5.21 stream-slice: 0.1.2 - undici: 6.19.2 + undici: 6.19.4 optionalDependencies: typescript: 5.5.2 @@ -6201,7 +6267,7 @@ snapshots: cookie-signature: 1.2.1 source-map-support: 0.5.21 stream-slice: 0.1.2 - undici: 6.19.2 + undici: 6.19.4 optionalDependencies: typescript: 5.5.2 optional: true @@ -6416,6 +6482,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.6': + dependencies: + '@types/node': 20.14.9 + '@types/mdast@3.0.15': dependencies: '@types/unist': 2.0.10 @@ -7091,6 +7161,8 @@ snapshots: node-releases: 2.0.14 update-browserslist-db: 1.0.16(browserslist@4.23.1) + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer-xor@1.0.3: {} @@ -7396,6 +7468,8 @@ snapshots: data-uri-to-buffer@3.0.1: {} + data-uri-to-buffer@4.0.1: {} + date-fns@3.6.0: {} debug@2.6.9: @@ -7490,6 +7564,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + editions@6.21.0: dependencies: version-range: 4.14.0 @@ -7876,6 +7954,11 @@ snapshots: dependencies: format: 0.2.2 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -7925,6 +8008,10 @@ snapshots: format@0.2.2: {} + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + forwarded@0.2.0: {} framer-motion@11.2.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -8406,6 +8493,30 @@ snapshots: jsonparse@1.3.1: {} + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.6.2 + + jwa@1.4.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -8444,14 +8555,26 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + lodash.isplainobject@4.0.6: {} + lodash.isstring@4.0.1: {} + lodash.kebabcase@4.1.1: {} lodash.merge@4.6.2: {} lodash.mergewith@4.6.2: {} + lodash.once@4.1.1: {} + lodash.snakecase@4.1.1: {} lodash.startcase@4.4.0: {} @@ -9317,8 +9440,16 @@ snapshots: negotiator@0.6.3: {} + node-domexception@1.0.0: {} + node-fetch-native@1.6.4: {} + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + node-forge@1.3.1: {} node-releases@2.0.14: {} @@ -10393,7 +10524,7 @@ snapshots: dependencies: '@fastify/busboy': 2.1.1 - undici@6.19.2: {} + undici@6.19.4: {} unenv-nightly@1.10.0-1717606461.a117952: dependencies: