diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4c65dec8d..423f95171 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -80,7 +80,10 @@ jobs: - name: Build run: | - yarn workspaces run build + yarn workspace edgedb build + yarn workspace @edgedb/auth-core build + yarn workspace @edgedb/auth-nextjs build + yarn workspace @edgedb/generate build # - name: Compile for Deno # run: | diff --git a/packages/auth-core/genConsts.js b/packages/auth-core/genConsts.js new file mode 100644 index 000000000..e25bc7508 --- /dev/null +++ b/packages/auth-core/genConsts.js @@ -0,0 +1,47 @@ +const fs = require("node:fs/promises"); +const { createClient } = require("edgedb"); + +const client = createClient("_localdev"); + +(async function () { + const providerNames = await client.queryRequiredSingle(` + with + oauth_providers := ( + select schema::ObjectType + filter .bases.name = 'ext::auth::OAuthProviderConfig' + ), + emailpassword_provider := ( + select schema::ObjectType + filter .name = 'ext::auth::EmailPasswordProviderConfig' + ) + select { + oauth := ( + select oauth_providers.properties filter .name = 'name' + ).default, + emailpassword := assert_single(( + select emailpassword_provider.properties filter .name = 'name' + ).default) + }`); + + await fs.writeFile( + "./src/consts.ts", + `// AUTOGENERATED - Run \`yarn gen-consts\` to re-generate. + +export const builtinOAuthProviderNames = [ +${providerNames.oauth + .sort() + .map((provider) => ` ${provider.replace(/^'|'$/g, '"')},`) + .join("\n")} +] as const; +export type BuiltinOAuthProviderNames = + (typeof builtinOAuthProviderNames)[number]; + +export const emailPasswordProviderName = ${providerNames.emailpassword.replace( + /^'|'$/g, + '"' + )}; +` + ); + + console.log('Generated into "src/consts.ts"'); +})(); diff --git a/packages/auth-core/jest.config.js b/packages/auth-core/jest.config.js new file mode 100644 index 000000000..c0515229e --- /dev/null +++ b/packages/auth-core/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + testPathIgnorePatterns: ["./dist"], + globalSetup: "./test/globalSetup.ts", + globalTeardown: "./test/globalTeardown.ts", +}; diff --git a/packages/auth-core/package.json b/packages/auth-core/package.json new file mode 100644 index 000000000..8743a91c1 --- /dev/null +++ b/packages/auth-core/package.json @@ -0,0 +1,37 @@ +{ + "name": "@edgedb/auth-core", + "description": "Core helper library for the EdgeDB Auth extension", + "version": "0.1.0-alpha.1", + "author": "EdgeDB ", + "repository": { + "type": "git", + "url": "https://github.com/edgedb/edgedb-js.git", + "directory": "packages/auth-core" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "license": "Apache-2.0", + "sideEffects": false, + "files": [ + "/dist" + ], + "scripts": { + "test": "jest --detectOpenHandles", + "build": "tsc --project tsconfig.json", + "gen-consts": "node genConsts.js" + }, + "devDependencies": { + "@types/node": "^20.8.4", + "edgedb": "^1.3.6", + "typescript": "5.0.4", + "@types/jest": "^29.5.2", + "jest": "29.5.0", + "ts-jest": "29.1.0" + }, + "peerDependencies": { + "edgedb": "^1.3.6" + }, + "dependencies": { + "jwt-decode": "^3.1.2" + } +} diff --git a/packages/auth-core/readme.md b/packages/auth-core/readme.md new file mode 100644 index 000000000..33bf96348 --- /dev/null +++ b/packages/auth-core/readme.md @@ -0,0 +1,10 @@ +# @edgedb/auth-core: Core helper library for the EdgeDB Auth extension + +> Warning: This library is still in an alpha state, and so, bugs are likely and the api's should be considered unstable and may change in future releases. + +This library contains some low-level utilities to help working with the EdgeDB auth extension; namely resolving the api endpoint urls from an edgedb `Client` object, providing wrappers around those api's to help handle the various auth flows (including handling PKCE), and adding typesafety. + +It is recommended to instead use the separate helper libraries created to make integration with popular frameworks as easy as possible. Currently supported frameworks: + +- Next.js (@edgedb/auth-nextjs) +- _...more coming soon_ diff --git a/packages/auth-core/src/consts.ts b/packages/auth-core/src/consts.ts new file mode 100644 index 000000000..2a847e58c --- /dev/null +++ b/packages/auth-core/src/consts.ts @@ -0,0 +1,12 @@ +// AUTOGENERATED - Run `yarn gen-consts` to re-generate. + +export const builtinOAuthProviderNames = [ + "builtin::oauth_apple", + "builtin::oauth_azure", + "builtin::oauth_github", + "builtin::oauth_google", +] as const; +export type BuiltinOAuthProviderNames = + (typeof builtinOAuthProviderNames)[number]; + +export const emailPasswordProviderName = "builtin::local_emailpassword"; diff --git a/packages/auth-core/src/core.ts b/packages/auth-core/src/core.ts new file mode 100644 index 000000000..259ad7e10 --- /dev/null +++ b/packages/auth-core/src/core.ts @@ -0,0 +1,277 @@ +import jwtDecode from "jwt-decode"; +import * as edgedb from "edgedb"; +import { ResolvedConnectConfig } from "edgedb/dist/conUtils"; + +import * as pkce from "./pkce"; +import { BuiltinOAuthProviderNames, emailPasswordProviderName } from "./consts"; + +export interface TokenData { + auth_token: string; + identity_id: string | null; + provider_token: string | null; + provider_refresh_token: string | null; +} + +export class Auth { + /** @internal */ + public readonly baseUrl: string; + + protected constructor( + public readonly client: edgedb.Client, + baseUrl: string + ) { + this.baseUrl = baseUrl; + } + + static async create(client: edgedb.Client) { + const connectConfig: ResolvedConnectConfig = ( + await (client as any).pool._getNormalizedConnectConfig() + ).connectionParams; + + const [host, port] = connectConfig.address; + const baseUrl = `${ + connectConfig.tlsSecurity === "insecure" ? "http" : "https" + }://${host}:${port}/db/${connectConfig.database}/ext/auth`; + + return new this(client, baseUrl); + } + + /** @internal */ + public async _get(path: string): Promise { + const res = await fetch(new URL(path, this.baseUrl), { + method: "get", + }); + if (!res.ok) { + throw new Error(await res.text()); + } + if (res.headers.get("content-type")?.startsWith("application/json")) { + return res.json(); + } + return null as any; + } + + /** @internal */ + public async _post( + path: string, + body?: any + ): Promise { + const res = await fetch(new URL(path, this.baseUrl), { + method: "post", + ...(body != null + ? { + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + } + : undefined), + }); + if (!res.ok) { + throw new Error(await res.text()); + } + if (res.headers.get("content-type")?.startsWith("application/json")) { + return res.json(); + } + return null as any; + } + + createPKCESession() { + return new AuthPCKESession(this); + } + + getToken(code: string, verifier: string): Promise { + return this._get( + `token?${new URLSearchParams({ + code, + verifier, + }).toString()}` + ); + } + + async signinWithEmailPassword(email: string, password: string) { + const { challenge, verifier } = pkce.createVerifierChallengePair(); + const { code } = await this._post<{ code: string }>("authenticate", { + provider: emailPasswordProviderName, + challenge, + email, + password, + }); + return this.getToken(code, verifier); + } + + async signupWithEmailPassword( + email: string, + password: string, + verifyUrl: string + ): Promise< + | { status: "complete"; tokenData: TokenData } + | { status: "verificationRequired"; verifier: string } + > { + const { challenge, verifier } = pkce.createVerifierChallengePair(); + const result = await this._post< + { code: string } | { verification_email_sent_at: string } + >("register", { + provider: emailPasswordProviderName, + challenge, + email, + password, + verify_url: verifyUrl, + }); + if ("code" in result) { + return { + status: "complete", + tokenData: await this.getToken(result.code, verifier), + }; + } else { + return { status: "verificationRequired", verifier }; + } + } + + async verifyEmailPasswordSignup(verificationToken: string, verifier: string) { + const { code } = await this._post<{ code: string }>("verify", { + provider: emailPasswordProviderName, + verification_token: verificationToken, + }); + return this.getToken(code, verifier); + } + + async resendVerificationEmail(verificationToken: string) { + await this._post("resend-verification-email", { + provider: emailPasswordProviderName, + verification_token: verificationToken, + }); + } + + async sendPasswordResetEmail(email: string, resetUrl: string) { + return this._post<{ email_sent: string }>("send-reset-email", { + provider: emailPasswordProviderName, + email, + reset_url: resetUrl, + }); + } + + static checkPasswordResetTokenValid(resetToken: string) { + try { + const payload = jwtDecode(resetToken); + if ( + typeof payload !== "object" || + payload == null || + !("exp" in payload) || + typeof payload.exp !== "number" + ) { + return false; + } + return payload.exp < Date.now(); + } catch { + return false; + } + } + + async resetPasswordWithResetToken(resetToken: string, password: string) { + return this._post("reset-password", { + provider: emailPasswordProviderName, + reset_token: resetToken, + password, + }); + } + + async getProvidersInfo() { + // TODO: cache this data when we have a way to invalidate on config update + try { + return await this.client.queryRequiredSingle<{ + oauth: { name: string; display_name: string }[]; + emailPassword: boolean; + }>(` + with + module ext::auth, + providers := (select cfg::Config.extensions[is AuthConfig].providers) + select { + oauth := providers[is OAuthProviderConfig] { + name, + display_name + }, + emailPassword := exists providers[is EmailPasswordProviderConfig] + }`); + } catch (err) { + if (err instanceof edgedb.InvalidReferenceError) { + throw new Error("auth extension is not enabled"); + } + throw err; + } + } +} + +export class AuthPCKESession { + public readonly challenge: string; + public readonly verifier: string; + + constructor(private auth: Auth) { + const { challenge, verifier } = pkce.createVerifierChallengePair(); + this.challenge = challenge; + this.verifier = verifier; + } + + getOAuthUrl( + providerName: BuiltinOAuthProviderNames, + redirectTo: string, + redirectToOnSignup?: string + ) { + const url = new URL("authorize", this.auth.baseUrl); + + url.searchParams.set("provider", providerName); + url.searchParams.set("challenge", this.challenge); + url.searchParams.set("redirect_to", redirectTo); + + if (redirectToOnSignup) { + url.searchParams.set("redirect_to_on_signup", redirectToOnSignup); + } + + return url.toString(); + } + + // getEmailPasswordSigninFormActionUrl( + // redirectTo: string, + // redirectToOnFailure?: string + // ) { + // const params = new URLSearchParams({ + // provider_name: emailPasswordProviderName, + // challenge: this.challenge, + // redirect_to: redirectTo, + // }); + + // if (redirectToOnFailure) { + // params.append("redirect_on_failure", redirectToOnFailure); + // } + + // return `${this.auth.baseUrl}/authenticate?${params.toString()}`; + // } + + // getEmailPasswordSignupFormActionUrl( + // redirectTo: string, + // redirectToOnFailure?: string + // ) { + // const params = new URLSearchParams({ + // provider_name: emailPasswordProviderName, + // challenge: this.challenge, + // redirect_to: redirectTo, + // }); + + // if (redirectToOnFailure) { + // params.append("redirect_on_failure", redirectToOnFailure); + // } + + // return `${this.auth.baseUrl}/register?${params.toString()}`; + // } + + getHostedUISigninUrl() { + const url = new URL("ui/signin", this.auth.baseUrl); + url.searchParams.set("challenge", this.challenge); + + return url.toString(); + } + + getHostedUISignupUrl() { + const url = new URL("ui/signup", this.auth.baseUrl); + url.searchParams.set("challenge", this.challenge); + + return url.toString(); + } +} diff --git a/packages/auth-core/src/index.ts b/packages/auth-core/src/index.ts new file mode 100644 index 000000000..0e6d6c87a --- /dev/null +++ b/packages/auth-core/src/index.ts @@ -0,0 +1,3 @@ +export * from "./core"; +export * from "./pkce"; +export * from "./consts"; diff --git a/packages/auth-core/src/pkce.ts b/packages/auth-core/src/pkce.ts new file mode 100644 index 000000000..31e0bb61c --- /dev/null +++ b/packages/auth-core/src/pkce.ts @@ -0,0 +1,14 @@ +import crypto from "node:crypto"; + +export function createVerifierChallengePair(): { + verifier: string; + challenge: string; +} { + const verifier = crypto.randomBytes(32).toString("base64url"); + const challenge = crypto + .createHash("sha256") + .update(verifier) + .digest("base64url"); + + return { verifier, challenge }; +} diff --git a/packages/auth-core/test/core.test.ts b/packages/auth-core/test/core.test.ts new file mode 100644 index 000000000..c998ba2b1 --- /dev/null +++ b/packages/auth-core/test/core.test.ts @@ -0,0 +1,72 @@ +import crypto from "node:crypto"; +import { getClient } from "./testbase"; + +import { Auth } from "../src/core"; + +const SIGNING_KEY = crypto.randomBytes(32).toString("base64"); + +beforeAll(async () => { + const client = getClient(); + + try { + await client.execute(` + create extension pgcrypto; + create extension auth; + + configure current database set + ext::auth::AuthConfig::auth_signing_key := '${SIGNING_KEY}'; + + configure current database set + ext::auth::AuthConfig::token_time_to_live := '24 hours'; + + configure current database set + ext::auth::SMTPConfig::sender := 'noreply@example.edgedb.com'; + + configure current database + insert ext::auth::EmailPasswordProviderConfig {}; + `); + + // wait for config to be applied + await new Promise((resolve) => setTimeout(resolve, 1000)); + } finally { + client.close(); + } +}, 20_000); + +test("test password signup/signin flow", async () => { + const client = getClient({ tlsSecurity: "insecure" }); + try { + const auth = await Auth.create(client); + + const signupToken = await auth.signupWithEmailPassword( + "test@example.edgedb.com", + "supersecretpassword" + ); + + expect(typeof signupToken.auth_token).toBe("string"); + expect(typeof signupToken.identity_id).toBe("string"); + expect(signupToken.provider_refresh_token).toBeNull(); + expect(signupToken.provider_token).toBeNull(); + + await expect( + auth.signinWithEmailPassword("test@example.edgedb.com", "wrongpassword") + ).rejects.toThrow(); + + const signinToken = await auth.signinWithEmailPassword( + "test@example.edgedb.com", + "supersecretpassword" + ); + + const identity = (await client.withGlobals({ + "ext::auth::client_token": signinToken.auth_token, + }).querySingle(` + select assert_single(global ext::auth::ClientTokenIdentity { + * + }) + `)) as any; + + expect(identity.id).toBe(signinToken.identity_id); + } finally { + await client.close(); + } +}); diff --git a/packages/auth-core/test/globalSetup.ts b/packages/auth-core/test/globalSetup.ts new file mode 100644 index 000000000..79198b746 --- /dev/null +++ b/packages/auth-core/test/globalSetup.ts @@ -0,0 +1,39 @@ +import * as process from "process"; +import { + connectToServer, + generateStatusFileName, + getServerCommand, + getWSLPath, + startServer, +} from "../../driver/test/testUtil"; + +export default async () => { + // tslint:disable-next-line + console.log("\nStarting EdgeDB test cluster..."); + + const statusFile = generateStatusFileName("node"); + console.log("Node status file:", statusFile); + + const { args, availableFeatures } = getServerCommand( + getWSLPath(statusFile), + false + ); + console.log(`Starting server...`); + const { proc, config } = await startServer(args, statusFile); + + // @ts-ignore + global.edgedbProc = proc; + + process.env._JEST_EDGEDB_CONNECT_CONFIG = JSON.stringify(config); + process.env._JEST_EDGEDB_AVAILABLE_FEATURES = + JSON.stringify(availableFeatures); + + const { client, version } = await connectToServer(config); + + // @ts-ignore + global.edgedbConn = client; + process.env._JEST_EDGEDB_VERSION = JSON.stringify(version); + + // tslint:disable-next-line + console.log(`EdgeDB test cluster is up [port: ${config.port}]...`); +}; diff --git a/packages/auth-core/test/globalTeardown.ts b/packages/auth-core/test/globalTeardown.ts new file mode 100644 index 000000000..ef11aad67 --- /dev/null +++ b/packages/auth-core/test/globalTeardown.ts @@ -0,0 +1,14 @@ +import { shutdown } from "../../driver/test/testUtil"; + +export default async () => { + // tslint:disable-next-line + console.log("Shutting down EdgeDB test cluster..."); + + try { + // @ts-ignore + await shutdown(global.edgedbProc, global.edgedbConn); + } finally { + // tslint:disable-next-line + console.log("EdgeDB test cluster is down..."); + } +}; diff --git a/packages/auth-core/test/testbase.ts b/packages/auth-core/test/testbase.ts new file mode 100644 index 000000000..3d6344206 --- /dev/null +++ b/packages/auth-core/test/testbase.ts @@ -0,0 +1,6 @@ +import { Client, ConnectOptions } from "edgedb"; +import * as testbase from "../../driver/test/testbase"; + +export const getClient = testbase.getClient as unknown as ( + opts?: ConnectOptions +) => Client; diff --git a/packages/auth-core/tsconfig.json b/packages/auth-core/tsconfig.json new file mode 100644 index 000000000..f42b194ef --- /dev/null +++ b/packages/auth-core/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "declarationDir": "./dist", + "outDir": "./dist", + "lib": [ + "ESNext" + ], + "target": "ESNext", + "module": "CommonJS", + "moduleResolution": "node", + "declaration": true, + "strict": true, + "downlevelIteration": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "isolatedModules": true + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/packages/auth-nextjs/package.json b/packages/auth-nextjs/package.json new file mode 100644 index 000000000..4302d8472 --- /dev/null +++ b/packages/auth-nextjs/package.json @@ -0,0 +1,34 @@ +{ + "name": "@edgedb/auth-nextjs", + "description": "Helper library to integrate the EdgeDB Auth extension with Next.js", + "version": "0.1.0-alpha.1", + "author": "EdgeDB ", + "repository": { + "type": "git", + "url": "https://github.com/edgedb/edgedb-js.git", + "directory": "packages/auth-nextjs" + }, + "license": "Apache-2.0", + "sideEffects": false, + "files": [ + "/dist" + ], + "exports": { + "./*": "./dist/*/index.js" + }, + "scripts": { + "build": "tsc --project tsconfig.json" + }, + "devDependencies": { + "@types/node": "^20.8.4", + "edgedb": "^1.3.6", + "typescript": "5.0.4", + "next": "13.5.6" + }, + "peerDependencies": { + "edgedb": "^1.3.6" + }, + "dependencies": { + "@edgedb/auth-core": "0.1.0-alpha.1" + } +} diff --git a/packages/auth-nextjs/readme.md b/packages/auth-nextjs/readme.md new file mode 100644 index 000000000..ddd04d6d4 --- /dev/null +++ b/packages/auth-nextjs/readme.md @@ -0,0 +1,121 @@ +# @edgedb/auth-nextjs: Helper library to integrate the EdgeDB Auth extension with Next.js + +> Warning: This library is still in an alpha state, and so, bugs are likely and the api's should be considered unstable and may change in future releases. + +> Note: Currently only the Next.js 'App Router' is supported. + +### Setup + +**Prerequisites**: Before adding EdgeDB auth to your Next.js app, you will first need to enable the `auth` extension in your EdgeDB schema, and have configured the extension with some providers. Refer to the auth extension docs for details on how to do this. + +1. Initialize the auth helper by passing an EdgeDB `Client` object to `createAuth()`, along with some configuration options. This will return a `NextAppAuth` object which you can use across your app. Similarly to the `Client` it's recommended to export this auth object from some root configuration file in your app. + + ```ts + // edgedb.ts + + import { createClient } from "edgedb"; + import createAuth from "@edgedb/auth-nextjs/app"; + + export const client = createClient({ + // Note: when developing locally you will need to set tls security to insecure, because the development server uses self-signed certificates which will cause api calls with the fetch api to fail. + tlsSecurity: "insecure", + }); + + export const auth = createAuth(client, { + baseUrl: "http://localhost:3000", + }); + ``` + + The available auth config options are as follows: + + - `baseUrl: string`, _required_, The url of your application; needed for various auth flows (eg. OAuth) to redirect back to. + - `authRoutesPath?: string`, The path to the auth route handlers, defaults to `'auth'`, see below for more details. + - `authCookieName?: string`, The name of the cookie where the auth token will be stored, defaults to `'edgedb-session'`. + - `pkceVerifierCookieName?: string`: The name of the cookie where the verifier for the PKCE flow will be stored, defaults to `'edgedb-pkce-verifier'` + - `passwordResetUrl?: string`: The url of the the password reset page; needed if you want to enable password reset emails in your app. + +2. Setup the auth route handlers, with `auth.createAuthRouteHandlers()`. Callback functions can be provided to handle various auth events, where you can define what to do in the case of successful signin's or errors. You only need to configure callback functions for the types of auth you wish to use in your app. + + ```ts + // app/auth/[...auth]/route.ts + + import { redirect } from "next/navigation"; + import { auth } from "@/edgedb"; + + const { GET, POST } = auth.createAuthRouteHandlers({ + onOAuthCallback({ error, tokenData, isSignUp }) { + redirect("/"); + }, + onSignout() { + redirect("/"); + }, + }); + + export { GET, POST }; + ``` + + The currently available auth handlers are: + + - `onOAuthCallback` + - `onEmailPasswordSignIn` + - `onEmailPasswordSignUp` + - `onEmailPasswordReset` + - `onEmailVerify` + - `onBuiltinUICallback` + - `onSignout` + + By default the handlers expect to exist under the `/auth` path in your app, however if you want to place them elsewhere, you will also need to configure the `authRoutesPath` option of `createAuth` to match. + +3. Now we just need to setup the UI to allow your users to sign in/up, etc. The easiest way to get started is to use the EdgeDB Auth's builtin UI. Or alternatively you can implement your own custom UI. + + **Builtin UI** + + To use the builtin auth UI, first you will need to enable the UI in the auth ext configuration (see the auth ext docs for details). For the `redirect_to` and `redirect_to_on_signup` configuration options, set them to `{your_app_url}/auth/builtin/callback` and `{your_app_url}/auth/builtin/callback?isSignUp=true` respectively. (Note: if you have setup the auth route handlers under a custom path, replace 'auth' in the above url with that path). + + Then you just need to configure the `onBuiltinUICallback` handler to define what to do once the builtin ui redirects back to your app, and place a link to the builtin UI url returned by `auth.auth.getBuiltinUIUrl()` where you want to in app. + + **Custom UI** + + To help with implementing your own custom auth UI, the `Auth` object has a number of methods you can use: + + - `getOAuthUrl(providerName: string)`: This method takes the name of an OAuth provider (make sure you configure that ones you need in the auth ext config first) and returns a link that will initiate the OAuth sign in flow for that provider. You will also need to configure the `onOAuthCallback` auth route handler. + - `createServerActions()`: Returns a number of server actions that you can use in your UI: + - `emailPasswordSignIn` + - `emailPasswordSignUp` + - `emailPasswordSendPasswordResetEmail` + - `emailPasswordResetPassword` + - `emailPasswordResendVerificationEmail` + - `signout` + - `isPasswordResetTokenValid(resetToken: string)`: Checks if a password reset token is still valid. + +### Usage + +Now you have auth all configured and user's can signin/signup/etc. you can use the `auth.getSession()` method in your app pages to retrieve an `AuthSession` object. This session object allows you to check if the user is currently logged in with the `isLoggedIn` method, and also provides a `Client` object automatically configured with the `ext::auth::client_token` global, so you can run queries using the `ext::auth::ClientTokenIdentity` of the currently signed in user. + +```ts +import { auth } from "@/edgedb"; + +export default async function Home() { + const session = await auth.getSession(); + + const loggedIn = await session.isLoggedIn(); + + return ( +
+

Home

+ + {loggedIn ? ( + <> +
You are logged in
+ {await session.client.queryJSON(`...`)} + + ) : ( + <> +
You are not logged in
+ Sign in with Built-in UI + + )} +
+ ); +} +``` diff --git a/packages/auth-nextjs/src/app/index.ts b/packages/auth-nextjs/src/app/index.ts new file mode 100644 index 000000000..90e125a7f --- /dev/null +++ b/packages/auth-nextjs/src/app/index.ts @@ -0,0 +1,551 @@ +import { Client } from "edgedb"; +import { Auth, builtinOAuthProviderNames, TokenData } from "@edgedb/auth-core"; +import { NextAuth, NextAuthOptions, NextAuthSession } from "../shared"; + +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { NextRequest } from "next/server"; + +export { type NextAuthOptions, NextAuthSession }; + +type ParamsOrError = + | ({ error: null } & Result) + | ({ error: Error } & { [Key in keyof Result]?: undefined }); + +export interface CreateAuthRouteHandlers { + onOAuthCallback( + params: ParamsOrError<{ tokenData: TokenData; isSignUp: boolean }> + ): void; + onEmailPasswordSignIn(params: ParamsOrError<{ tokenData: TokenData }>): void; + onEmailPasswordSignUp( + params: ParamsOrError<{ tokenData: TokenData | null }> + ): void; + onEmailPasswordReset(params: ParamsOrError<{ tokenData: TokenData }>): void; + onEmailVerify(params: ParamsOrError<{ tokenData: TokenData }>): void; + onBuiltinUICallback( + params: ParamsOrError<{ tokenData: TokenData | null; isSignUp: boolean }> + ): void; + onSignout(): void; +} + +export class NextAppAuth extends NextAuth { + private readonly core: Promise; + + constructor(client: Client, options: NextAuthOptions) { + super(client, options); + this.core = Auth.create(client); + } + + getSession() { + return new NextAuthSession( + this.client, + cookies().get(this.options.authCookieName)?.value.split(";")[0] + ); + } + + createAuthRouteHandlers({ + onOAuthCallback, + onEmailPasswordSignIn, + onEmailPasswordSignUp, + onEmailPasswordReset, + onEmailVerify, + onBuiltinUICallback, + onSignout, + }: Partial) { + return { + GET: async ( + req: NextRequest, + { params }: { params: { auth: string[] } } + ) => { + switch (params.auth.join("/")) { + case "oauth": { + if (!onOAuthCallback) { + throw new Error( + `'onOAuthCallback' auth route handler not configured` + ); + } + const provider = req.nextUrl.searchParams.get( + "provider_name" + ) as any; + if (!provider || !builtinOAuthProviderNames.includes(provider)) { + throw new Error(`invalid provider_name: ${provider}`); + } + const redirectUrl = `${this._authRoute}/oauth/callback`; + const pkceSession = (await this.core).createPKCESession(); + cookies().set({ + name: this.options.pkceVerifierCookieName, + value: pkceSession.verifier, + httpOnly: true, + }); + return redirect( + pkceSession.getOAuthUrl( + provider, + redirectUrl, + `${redirectUrl}?isSignUp=true` + ) + ); + } + case "oauth/callback": { + if (!onOAuthCallback) { + throw new Error( + `'onOAuthCallback' auth route handler not configured` + ); + } + const error = req.nextUrl.searchParams.get("error"); + if (error) { + const desc = req.nextUrl.searchParams.get("error_description"); + return onOAuthCallback({ + error: new Error(error + (desc ? `: ${desc}` : "")), + }); + } + const code = req.nextUrl.searchParams.get("code"); + const isSignUp = + req.nextUrl.searchParams.get("isSignUp") === "true"; + const verifier = req.cookies.get( + this.options.pkceVerifierCookieName + )?.value; + if (!code) { + return onOAuthCallback({ + error: new Error("no pkce code in response"), + }); + } + if (!verifier) { + return onOAuthCallback({ + error: new Error("no pkce verifier cookie found"), + }); + } + let tokenData: TokenData; + try { + tokenData = await (await this.core).getToken(code, verifier); + } catch (err) { + return onOAuthCallback({ + error: err instanceof Error ? err : new Error(String(err)), + }); + } + cookies().set({ + name: this.options.authCookieName, + value: tokenData.auth_token, + httpOnly: true, + sameSite: "lax", + }); + cookies().delete(this.options.pkceVerifierCookieName); + + return onOAuthCallback({ error: null, tokenData, isSignUp }); + } + case "emailpassword/verify": { + if (!onEmailVerify) { + throw new Error( + `'onEmailVerify' auth route handler not configured` + ); + } + const verificationToken = + req.nextUrl.searchParams.get("verification_token"); + const verifier = req.cookies.get( + this.options.pkceVerifierCookieName + )?.value; + if (!verificationToken) { + return onEmailVerify({ + error: new Error("no verification_token in response"), + }); + } + if (!verifier) { + return onEmailVerify({ + error: new Error("no pkce verifier cookie found"), + }); + } + let tokenData: TokenData; + try { + tokenData = await ( + await this.core + ).verifyEmailPasswordSignup(verificationToken, verifier); + } catch (err) { + return onEmailVerify({ + error: err instanceof Error ? err : new Error(String(err)), + }); + } + cookies().set({ + name: this.options.authCookieName, + value: tokenData.auth_token, + httpOnly: true, + sameSite: "strict", + }); + cookies().delete(this.options.pkceVerifierCookieName); + + return onEmailVerify({ error: null, tokenData }); + } + case "builtin/callback": { + if (!onBuiltinUICallback) { + throw new Error( + `'onBuiltinUICallback' auth route handler not configured` + ); + } + const error = req.nextUrl.searchParams.get("error"); + if (error) { + const desc = req.nextUrl.searchParams.get("error_description"); + return onBuiltinUICallback({ + error: new Error(error + (desc ? `: ${desc}` : "")), + }); + } + const code = req.nextUrl.searchParams.get("code"); + const verificationEmailSentAt = req.nextUrl.searchParams.get( + "verification_email_sent_at" + ); + + if (!code) { + if (verificationEmailSentAt) { + return onBuiltinUICallback({ + error: null, + tokenData: null, + isSignUp: true, + }); + } + return onBuiltinUICallback({ + error: new Error("no pkce code in response"), + }); + } + const verifier = req.cookies.get( + this.options.pkceVerifierCookieName + )?.value; + if (!verifier) { + return onBuiltinUICallback({ + error: new Error("no pkce verifier cookie found"), + }); + } + const isSignUp = + req.nextUrl.searchParams.get("isSignUp") === "true"; + let tokenData: TokenData; + try { + tokenData = await (await this.core).getToken(code, verifier); + } catch (err) { + return onBuiltinUICallback({ + error: err instanceof Error ? err : new Error(String(err)), + }); + } + cookies().set({ + name: this.options.authCookieName, + value: tokenData.auth_token, + httpOnly: true, + sameSite: "lax", + }); + cookies().delete(this.options.pkceVerifierCookieName); + + return onBuiltinUICallback({ error: null, tokenData, isSignUp }); + } + case "builtin/signin": + case "builtin/signup": { + const pkceSession = (await this.core).createPKCESession(); + cookies().set({ + name: this.options.pkceVerifierCookieName, + value: pkceSession.verifier, + httpOnly: true, + }); + return redirect( + params.auth[params.auth.length - 1] === "signup" + ? pkceSession.getHostedUISignupUrl() + : pkceSession.getHostedUISigninUrl() + ); + } + case "signout": { + if (!onSignout) { + throw new Error(`'onSignout' auth route handler not configured`); + } + cookies().delete(this.options.authCookieName); + return onSignout(); + } + default: + return new Response("Unknown auth route", { + status: 404, + }); + } + }, + POST: async ( + req: NextRequest, + { params }: { params: { auth: string[] } } + ) => { + switch (params.auth.join("/")) { + case "emailpassword/signin": { + if (!onEmailPasswordSignIn) { + throw new Error( + `'onEmailPasswordSignIn' auth route handler not configured` + ); + } + let tokenData: TokenData; + try { + const [email, password] = _extractParams( + await _getReqBody(req), + ["email", "password"], + "email or password missing from request body" + ); + tokenData = await ( + await this.core + ).signinWithEmailPassword(email, password); + } catch (err) { + return onEmailPasswordSignIn({ + error: err instanceof Error ? err : new Error(String(err)), + }); + } + cookies().set({ + name: this.options.authCookieName, + value: tokenData.auth_token, + httpOnly: true, + sameSite: "strict", + }); + return onEmailPasswordSignIn({ error: null, tokenData }); + } + case "emailpassword/signup": { + if (!onEmailPasswordSignUp) { + throw new Error( + `'onEmailPasswordSignUp' auth route handler not configured` + ); + } + let result: Awaited< + ReturnType["signupWithEmailPassword"]> + >; + try { + const [email, password] = _extractParams( + await _getReqBody(req), + ["email", "password"], + "email or password missing from request body" + ); + result = await ( + await this.core + ).signupWithEmailPassword( + email, + password, + `${this._authRoute}/emailpassword/verify` + ); + } catch (err) { + return onEmailPasswordSignUp({ + error: err instanceof Error ? err : new Error(String(err)), + }); + } + if (result.status === "complete") { + cookies().set({ + name: this.options.authCookieName, + value: result.tokenData.auth_token, + httpOnly: true, + sameSite: "strict", + }); + return onEmailPasswordSignUp({ + error: null, + tokenData: result.tokenData, + }); + } else { + cookies().set({ + name: this.options.pkceVerifierCookieName, + value: result.verifier, + httpOnly: true, + sameSite: "strict", + }); + return onEmailPasswordSignUp({ error: null, tokenData: null }); + } + } + case "emailpassword/send-reset-email": { + if (!this.options.passwordResetUrl) { + throw new Error(`'passwordResetUrl' option not configured`); + } + const [email] = _extractParams( + await _getReqBody(req), + ["email"], + "email missing from request body" + ); + (await this.core).sendPasswordResetEmail( + email, + this.options.passwordResetUrl + ); + } + case "emailpassword/reset-password": { + if (!onEmailPasswordReset) { + throw new Error( + `'onEmailPasswordReset' auth route handler not configured` + ); + } + let tokenData: TokenData; + try { + const [resetToken, password] = _extractParams( + await _getReqBody(req), + ["reset_token", "password"], + "reset_token or password missing from request body" + ); + + tokenData = await ( + await this.core + ).resetPasswordWithResetToken(resetToken, password); + } catch (err) { + return onEmailPasswordReset({ + error: err instanceof Error ? err : new Error(String(err)), + }); + } + cookies().set({ + name: this.options.authCookieName, + value: tokenData.auth_token, + httpOnly: true, + sameSite: "strict", + }); + return onEmailPasswordReset({ error: null, tokenData }); + } + case "emailpassword/resend-verification-email": { + const [verificationToken] = _extractParams( + await _getReqBody(req), + ["verification_token"], + "verification_token missing from request body" + ); + (await this.core).resendVerificationEmail(verificationToken); + } + default: + return new Response("Unknown auth route", { + status: 404, + }); + } + }, + }; + } + + createServerActions() { + return { + signout: async () => { + cookies().delete(this.options.authCookieName); + }, + emailPasswordSignIn: async ( + data: FormData | { email: string; password: string } + ) => { + const [email, password] = _extractParams( + data, + ["email", "password"], + "email or password missing" + ); + const tokenData = await ( + await this.core + ).signinWithEmailPassword(email, password); + cookies().set({ + name: this.options.authCookieName, + value: tokenData.auth_token, + httpOnly: true, + sameSite: "strict", + }); + return tokenData; + }, + emailPasswordSignUp: async ( + data: FormData | { email: string; password: string } + ) => { + const [email, password] = _extractParams( + data, + ["email", "password"], + "email or password missing" + ); + const result = await ( + await this.core + ).signupWithEmailPassword( + email, + password, + `${this._authRoute}/emailpassword/verify` + ); + if (result.status === "complete") { + cookies().set({ + name: this.options.authCookieName, + value: result.tokenData.auth_token, + httpOnly: true, + sameSite: "strict", + }); + return result.tokenData; + } else { + cookies().set({ + name: this.options.pkceVerifierCookieName, + value: result.verifier, + httpOnly: true, + sameSite: "strict", + }); + return null; + } + }, + emailPasswordSendPasswordResetEmail: async ( + data: FormData | { email: string; resetUrl: string } + ) => { + if (!this.options.passwordResetUrl) { + throw new Error(`'passwordResetUrl' option not configured`); + } + const [email] = _extractParams(data, ["email"], "email missing"); + await ( + await this.core + ).sendPasswordResetEmail( + email, + `${this.options.baseUrl}/${this.options.passwordResetUrl}` + ); + }, + emailPasswordResetPassword: async ( + data: FormData | { resetToken: string; password: string } + ) => { + const [resetToken, password] = _extractParams( + data, + ["reset_token", "password"], + "reset_token or password missing" + ); + const tokenData = await ( + await this.core + ).resetPasswordWithResetToken(resetToken, password); + cookies().set({ + name: this.options.authCookieName, + value: tokenData.auth_token, + httpOnly: true, + sameSite: "strict", + }); + return tokenData; + }, + emailPasswordResendVerificationEmail: async ( + data: FormData | { verification_token: string } + ) => { + const [verificationToken] = _extractParams( + data, + ["verification_token"], + "verification_token missing" + ); + await (await this.core).resendVerificationEmail(verificationToken); + }, + }; + } +} + +export default function createNextAppAuth( + client: Client, + options: NextAuthOptions +) { + return new NextAppAuth(client, options); +} + +function _getReqBody(req: NextRequest) { + return req.headers.get("Content-Type") === "application/json" + ? req.json() + : req.formData(); +} + +function _extractParams( + data: FormData | any, + paramNames: string[], + errMessage: string +) { + const params: string[] = []; + if (data instanceof FormData) { + for (const paramName of paramNames) { + const param = data.get(paramName)?.toString(); + if (!param) { + throw new Error(errMessage); + } + params.push(param); + } + } else { + if (typeof data !== "object") { + throw new Error("expected json object"); + } + for (const paramName of paramNames) { + const param = data[paramName]; + if (!param) { + throw new Error(errMessage); + } + if (typeof param !== "string") { + throw new Error(`expected '${paramName}' to be a string`); + } + params.push(param); + } + } + return params; +} diff --git a/packages/auth-nextjs/src/pages/index.ts b/packages/auth-nextjs/src/pages/index.ts new file mode 100644 index 000000000..6828d0be6 --- /dev/null +++ b/packages/auth-nextjs/src/pages/index.ts @@ -0,0 +1,68 @@ +import { Client } from "edgedb"; +import { NextAuth, NextAuthOptions, NextAuthSession } from "../shared"; + +export { type NextAuthOptions, NextAuthSession }; + +export class NextPagesAuth extends NextAuth { + getSession(req: { + cookies: Partial<{ + [key: string]: string; + }>; + }) { + return new NextAuthSession( + this.client, + req.cookies[this.options.authCookieName]?.split(";")[0] + ); + } +} + +export default function createNextPagesAuth( + client: Client, + options: NextAuthOptions +) { + return new NextPagesAuth(client, options); +} + +async function apiRequest(url: string, data: any) { + const res = await fetch(url, { + method: "post", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }); + if (res.ok) { + return; + } + throw new Error(`${res.statusText}: ${await res.text()}`); +} + +export function getClientActions(options: NextAuthOptions) { + const authRoute = `${options.baseUrl}/${options.authRoutesPath}`; + + return { + // signout: async () => { + // }, + emailPasswordSignIn: async (data: { email: string; password: string }) => { + await apiRequest(`${authRoute}/emailpassword/signin`, data); + }, + emailPasswordSignUp: async (data: { email: string; password: string }) => { + await apiRequest(`${authRoute}/emailpassword/signup`, data); + }, + emailPasswordSendPasswordResetEmail: async (data: { email: string }) => { + await apiRequest(`${authRoute}/emailpassword/send-reset-email`, data); + }, + emailPasswordResetPassword: async (data: { + resetToken: string; + password: string; + }) => { + await apiRequest(`${authRoute}/emailpassword/reset-password`, data); + }, + emailPasswordResendVerificationEmail: async (data: { + verification_token: string; + }) => { + await apiRequest( + `${authRoute}/emailpassword/resend-verification-email`, + data + ); + }, + }; +} diff --git a/packages/auth-nextjs/src/shared.ts b/packages/auth-nextjs/src/shared.ts new file mode 100644 index 000000000..ef7351b86 --- /dev/null +++ b/packages/auth-nextjs/src/shared.ts @@ -0,0 +1,72 @@ +import { Client } from "edgedb"; +import { Auth, BuiltinOAuthProviderNames } from "@edgedb/auth-core"; + +export interface NextAuthOptions { + baseUrl: string; + authRoutesPath?: string; + authCookieName?: string; + pkceVerifierCookieName?: string; + passwordResetUrl?: string; +} + +type OptionalOptions = "passwordResetUrl"; + +export abstract class NextAuth { + /** @internal */ + readonly options: Required> & + Pick; + + /** @internal */ + constructor(protected readonly client: Client, options: NextAuthOptions) { + this.options = { + baseUrl: options.baseUrl.replace(/\/$/, ""), + authRoutesPath: options.authRoutesPath?.replace(/^\/|\/$/g, "") ?? "auth", + authCookieName: options.authCookieName ?? "edgedb-session", + pkceVerifierCookieName: + options.pkceVerifierCookieName ?? "edgedb-pkce-verifier", + }; + } + + protected get _authRoute() { + return `${this.options.baseUrl}/${this.options.authRoutesPath}`; + } + + isPasswordResetTokenValid(resetToken: string) { + return Auth.checkPasswordResetTokenValid(resetToken); + } + + getOAuthUrl(providerName: BuiltinOAuthProviderNames) { + return `${this._authRoute}/oauth?${new URLSearchParams({ + provider_name: providerName, + }).toString()}`; + } + + getBuiltinUIUrl() { + return `${this._authRoute}/builtin/signin`; + } + getBuiltinUISignUpUrl() { + return `${this._authRoute}/builtin/signup`; + } + + getSignoutUrl() { + return `${this._authRoute}/signout`; + } +} + +export class NextAuthSession { + public readonly client: Client; + + /** @internal */ + constructor(client: Client, private readonly authToken: string | undefined) { + this.client = this.authToken + ? client.withGlobals({ "ext::auth::client_token": this.authToken }) + : client; + } + + async isLoggedIn() { + if (!this.authToken) return false; + return (await this.client.querySingle( + `select exists global ext::auth::ClientTokenIdentity` + )) as boolean; + } +} diff --git a/packages/auth-nextjs/tsconfig.json b/packages/auth-nextjs/tsconfig.json new file mode 100644 index 000000000..f42b194ef --- /dev/null +++ b/packages/auth-nextjs/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "declarationDir": "./dist", + "outDir": "./dist", + "lib": [ + "ESNext" + ], + "target": "ESNext", + "module": "CommonJS", + "moduleResolution": "node", + "declaration": true, + "strict": true, + "downlevelIteration": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "isolatedModules": true + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/packages/driver/test/testUtil.ts b/packages/driver/test/testUtil.ts index d9bcefac9..1854780fb 100644 --- a/packages/driver/test/testUtil.ts +++ b/packages/driver/test/testUtil.ts @@ -81,7 +81,8 @@ export const generateStatusFileName = (tag: string): string => { }; export const getServerCommand = ( - statusFile: string + statusFile: string, + strictSecurity = true ): { args: string[]; availableFeatures: string[] } => { const availableFeatures: string[] = []; let srvcmd = `edgedb-server`; @@ -126,7 +127,7 @@ export const getServerCommand = ( "--testmode", "--port=auto", "--emit-server-status=" + statusFile, - "--security=strict", + `--security=${strictSecurity ? "strict" : "insecure_dev_mode"}`, "--bootstrap-command=ALTER ROLE edgedb { SET password := 'edgedbtest' }", ]; diff --git a/yarn.lock b/yarn.lock index 992b1cede..a8c996bda 100644 --- a/yarn.lock +++ b/yarn.lock @@ -847,6 +847,56 @@ jsbi "^4.1.0" tslib "^2.3.1" +"@next/env@13.5.6": + version "13.5.6" + resolved "https://registry.yarnpkg.com/@next/env/-/env-13.5.6.tgz#c1148e2e1aa166614f05161ee8f77ded467062bc" + integrity sha512-Yac/bV5sBGkkEXmAX5FWPS9Mmo2rthrOPRQQNfycJPkjUAUclomCPH7QFVCDQ4Mp2k2K1SSM6m0zrxYrOwtFQw== + +"@next/swc-darwin-arm64@13.5.6": + version "13.5.6" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.5.6.tgz#b15d139d8971360fca29be3bdd703c108c9a45fb" + integrity sha512-5nvXMzKtZfvcu4BhtV0KH1oGv4XEW+B+jOfmBdpFI3C7FrB/MfujRpWYSBBO64+qbW8pkZiSyQv9eiwnn5VIQA== + +"@next/swc-darwin-x64@13.5.6": + version "13.5.6" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-13.5.6.tgz#9c72ee31cc356cb65ce6860b658d807ff39f1578" + integrity sha512-6cgBfxg98oOCSr4BckWjLLgiVwlL3vlLj8hXg2b+nDgm4bC/qVXXLfpLB9FHdoDu4057hzywbxKvmYGmi7yUzA== + +"@next/swc-linux-arm64-gnu@13.5.6": + version "13.5.6" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.5.6.tgz#59f5f66155e85380ffa26ee3d95b687a770cfeab" + integrity sha512-txagBbj1e1w47YQjcKgSU4rRVQ7uF29YpnlHV5xuVUsgCUf2FmyfJ3CPjZUvpIeXCJAoMCFAoGnbtX86BK7+sg== + +"@next/swc-linux-arm64-musl@13.5.6": + version "13.5.6" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.5.6.tgz#f012518228017052736a87d69bae73e587c76ce2" + integrity sha512-cGd+H8amifT86ZldVJtAKDxUqeFyLWW+v2NlBULnLAdWsiuuN8TuhVBt8ZNpCqcAuoruoSWynvMWixTFcroq+Q== + +"@next/swc-linux-x64-gnu@13.5.6": + version "13.5.6" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.5.6.tgz#339b867a7e9e7ee727a700b496b269033d820df4" + integrity sha512-Mc2b4xiIWKXIhBy2NBTwOxGD3nHLmq4keFk+d4/WL5fMsB8XdJRdtUlL87SqVCTSaf1BRuQQf1HvXZcy+rq3Nw== + +"@next/swc-linux-x64-musl@13.5.6": + version "13.5.6" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.5.6.tgz#ae0ae84d058df758675830bcf70ca1846f1028f2" + integrity sha512-CFHvP9Qz98NruJiUnCe61O6GveKKHpJLloXbDSWRhqhkJdZD2zU5hG+gtVJR//tyW897izuHpM6Gtf6+sNgJPQ== + +"@next/swc-win32-arm64-msvc@13.5.6": + version "13.5.6" + resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.5.6.tgz#a5cc0c16920485a929a17495064671374fdbc661" + integrity sha512-aFv1ejfkbS7PUa1qVPwzDHjQWQtknzAZWGTKYIAaS4NMtBlk3VyA6AYn593pqNanlicewqyl2jUhQAaFV/qXsg== + +"@next/swc-win32-ia32-msvc@13.5.6": + version "13.5.6" + resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.5.6.tgz#6a2409b84a2cbf34bf92fe714896455efb4191e4" + integrity sha512-XqqpHgEIlBHvzwG8sp/JXMFkLAfGLqkbVsyN+/Ih1mR8INb6YCc2x/Mbwi6hsAgUnqQztz8cvEbHJUbSl7RHDg== + +"@next/swc-win32-x64-msvc@13.5.6": + version "13.5.6" + resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.5.6.tgz#4a3e2a206251abc729339ba85f60bc0433c2865d" + integrity sha512-Cqfe1YmOS7k+5mGu92nl5ULkzpKuxJrP3+4AEuPmrpFZ3BHxTY3TnHmU1On3bFmFFs6FbTcdF58CCUProGpIGQ== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -887,6 +937,13 @@ dependencies: "@sinonjs/commons" "^2.0.0" +"@swc/helpers@0.5.2": + version "0.5.2" + resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.2.tgz#85ea0c76450b61ad7d10a37050289eded783c27d" + integrity sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw== + dependencies: + tslib "^2.4.0" + "@tootallnate/once@2": version "2.0.0" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" @@ -983,6 +1040,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-20.3.2.tgz#fa6a90f2600e052a03c18b8cb3fd83dd4e599898" integrity sha512-vOBLVQeCQfIcF/2Y7eKFTqrMnizK5lRNQ7ykML/5RuwVXVWxYkgwS7xbt4B6fKCUPgbSL5FSsjHQpaGQP/dQmw== +"@types/node@^20.8.4": + version "20.8.7" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.8.7.tgz#ad23827850843de973096edfc5abc9e922492a25" + integrity sha512-21TKHHh3eUHIi2MloeptJWALuCu5H7HQTdTrWIFReA8ad+aggoX+lRes3ex7/FtpC+sVUpFMQ+QTfYr74mruiQ== + dependencies: + undici-types "~5.25.1" + "@types/prettier@^2.1.5": version "2.7.2" resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.7.2.tgz#6c2324641cc4ba050a8c710b2b251b377581fbf0" @@ -1319,6 +1383,13 @@ builtin-modules@^1.1.1: resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f" integrity sha512-wxXCdllwGhI2kCC0MnvTGYTMvnVZTvqgypkiTI8Pa5tcz2i6VqsqwYGgqwXji+4RgCzms6EajE4IxiUH6HH8nQ== +busboy@1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" + integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== + dependencies: + streamsearch "^1.1.0" + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -1334,6 +1405,11 @@ camelcase@^6.2.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== +caniuse-lite@^1.0.30001406: + version "1.0.30001554" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001554.tgz#ba80d88dff9acbc0cd4b7535fc30e0191c5e2e2a" + integrity sha512-A2E3U//MBwbJVzebddm1YfNp7Nud5Ip+IPn4BozBmn4KqVX7AvluoIDFWjsv5OkGnKUXQVmMSoMKLa3ScCblcQ== + caniuse-lite@^1.0.30001449: version "1.0.30001481" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001481.tgz#f58a717afe92f9e69d0e35ff64df596bfad93912" @@ -1371,6 +1447,11 @@ cjs-module-lexer@^1.0.0: resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz#9f84ba3244a512f3a54e5277e8eef4c489864e40" integrity sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA== +client-only@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1" + integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== + cliui@^8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" @@ -1992,6 +2073,11 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" +glob-to-regexp@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" + integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== + glob@^7.1.1, glob@^7.1.3, glob@^7.1.4: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" @@ -2039,7 +2125,7 @@ globby@^13.2.0: merge2 "^1.4.1" slash "^4.0.0" -graceful-fs@^4.2.9: +graceful-fs@^4.1.2, graceful-fs@^4.2.9: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== @@ -2719,6 +2805,11 @@ json5@^2.2.2, json5@^2.2.3: resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== +jwt-decode@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59" + integrity sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A== + kleur@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" @@ -2866,6 +2957,11 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +nanoid@^3.3.6: + version "3.3.6" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" + integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA== + natural-compare-lite@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz#17b09581988979fddafe0201e931ba933c96cbb4" @@ -2876,6 +2972,29 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +next@13.5.6: + version "13.5.6" + resolved "https://registry.yarnpkg.com/next/-/next-13.5.6.tgz#e964b5853272236c37ce0dd2c68302973cf010b1" + integrity sha512-Y2wTcTbO4WwEsVb4A8VSnOsG1I9ok+h74q0ZdxkwM3EODqrs4pasq7O0iUxbcS9VtWMicG7f3+HAj0r1+NtKSw== + dependencies: + "@next/env" "13.5.6" + "@swc/helpers" "0.5.2" + busboy "1.6.0" + caniuse-lite "^1.0.30001406" + postcss "8.4.31" + styled-jsx "5.1.1" + watchpack "2.4.0" + optionalDependencies: + "@next/swc-darwin-arm64" "13.5.6" + "@next/swc-darwin-x64" "13.5.6" + "@next/swc-linux-arm64-gnu" "13.5.6" + "@next/swc-linux-arm64-musl" "13.5.6" + "@next/swc-linux-x64-gnu" "13.5.6" + "@next/swc-linux-x64-musl" "13.5.6" + "@next/swc-win32-arm64-msvc" "13.5.6" + "@next/swc-win32-ia32-msvc" "13.5.6" + "@next/swc-win32-x64-msvc" "13.5.6" + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -3045,6 +3164,15 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" +postcss@8.4.31: + version "8.4.31" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" + integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== + dependencies: + nanoid "^3.3.6" + picocolors "^1.0.0" + source-map-js "^1.0.2" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -3228,6 +3356,11 @@ slash@^4.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7" integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew== +source-map-js@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" + integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== + source-map-support@0.5.13: version "0.5.13" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.13.tgz#31b24a9c2e73c2de85066c0feb7d44767ed52932" @@ -3261,6 +3394,11 @@ stack-utils@^2.0.3: dependencies: escape-string-regexp "^2.0.0" +streamsearch@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" + integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== + string-length@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" @@ -3300,6 +3438,13 @@ strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== +styled-jsx@5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.1.1.tgz#839a1c3aaacc4e735fed0781b8619ea5d0009d1f" + integrity sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw== + dependencies: + client-only "0.0.1" + superjson@^1.12.4: version "1.12.4" resolved "https://registry.yarnpkg.com/superjson/-/superjson-1.12.4.tgz#cfea35b0d1eb0f12d8b185f1d871272555f5a61f" @@ -3410,6 +3555,11 @@ tslib@^2.3.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.0.tgz#b295854684dbda164e181d259a22cd779dcd7bc3" integrity sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA== +tslib@^2.4.0: + version "2.6.2" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" + integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== + tslint-config-prettier@^1.18.0: version "1.18.0" resolved "https://registry.yarnpkg.com/tslint-config-prettier/-/tslint-config-prettier-1.18.0.tgz#75f140bde947d35d8f0d238e0ebf809d64592c37" @@ -3502,6 +3652,11 @@ typescript@5.0.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.4.tgz#b217fd20119bd61a94d4011274e0ab369058da3b" integrity sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw== +undici-types@~5.25.1: + version "5.25.3" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.25.3.tgz#e044115914c85f0bcbb229f346ab739f064998c3" + integrity sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA== + universalify@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" @@ -3553,6 +3708,14 @@ walker@^1.0.8: dependencies: makeerror "1.0.12" +watchpack@2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d" + integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg== + dependencies: + glob-to-regexp "^0.4.1" + graceful-fs "^4.1.2" + webidl-conversions@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a"