Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add WebAuthn and Magic Links in auth-nextjs #897

Merged
merged 8 commits into from Mar 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
17 changes: 10 additions & 7 deletions packages/auth-core/src/core.ts
Expand Up @@ -176,31 +176,34 @@ export class Auth {
async signupWithMagicLink(
email: string,
callbackUrl: string,
redirectOnFailure: string,
challenge: string
): Promise<void> {
redirectOnFailure: string
): Promise<{ verifier: string }> {
const { challenge, verifier } = await pkce.createVerifierChallengePair();
await this._post("magic-link/register", {
provider: magicLinkProviderName,
challenge,
email,
challenge,
callback_url: callbackUrl,
redirect_on_failure: redirectOnFailure,
});
return { verifier };
}

async signinWithMagicLink(
email: string,
callbackUrl: string,
redirectOnFailure: string,
challenge: string
): Promise<void> {
redirectOnFailure: string
): Promise<{ verifier: string }> {
const { challenge, verifier } = await pkce.createVerifierChallengePair();
await this._post("magic-link/email", {
provider: magicLinkProviderName,
challenge,
email,
callback_url: callbackUrl,
redirect_on_failure: redirectOnFailure,
});

return { verifier };
}

async resendVerificationEmail(verificationToken: string) {
Expand Down
4 changes: 2 additions & 2 deletions packages/auth-core/src/webauthn.ts
Expand Up @@ -94,7 +94,7 @@ export class WebAuthnClient {
);
}

async signIn(email: string): Promise<TokenData> {
async signIn(email: string): Promise<void> {
const options = await requestGET<PublicKeyCredentialRequestOptionsJSON>(
this.signinOptionsUrl,
{ email },
Expand Down Expand Up @@ -143,7 +143,7 @@ export class WebAuthnClient {
},
};

return await requestPOST<TokenData>(
await requestPOST(
this.signinUrl,
{
email,
Expand Down
1 change: 1 addition & 0 deletions packages/auth-nextjs/package.json
Expand Up @@ -15,6 +15,7 @@
],
"exports": {
"./app": "./dist/app/index.js",
"./app/client": "./dist/app/client.js",
"./pages/*": "./dist/pages/*.js"
},
"scripts": {
Expand Down
23 changes: 12 additions & 11 deletions packages/auth-nextjs/readme.md
Expand Up @@ -2,8 +2,6 @@

> 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.
Expand All @@ -16,10 +14,7 @@
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 client = createClient();

export const auth = createAuth(client, {
baseUrl: "http://localhost:3000",
Expand All @@ -32,7 +27,8 @@
- `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.
- `passwordResetPath?: string`: The path relative to the `baseUrl` of the the password reset page; needed if you want to enable password reset emails in your app.
- `magicLinkFailurePath?: string`: The path relative to the `baseUrl` of the page we should redirect users to if there is an error when trying to sign in with a magic link. The page will get an `error` search parameter attached with an error message. This property is required if you use the Magic Link authentication feature.

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.

Expand Down Expand Up @@ -62,6 +58,9 @@
- `onEmailPasswordReset`
- `onEmailVerify`
- `onBuiltinUICallback`
- `onWebAuthnSignIn`
- `onWebAuthnSignUp`
- `onMagicLinkCallback`
- `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.
Expand All @@ -85,6 +84,8 @@
- `emailPasswordSendPasswordResetEmail`
- `emailPasswordResetPassword`
- `emailPasswordResendVerificationEmail`
- `magicLinkSignUp`
- `magicLinkSignIn`
- `signout`
- `isPasswordResetTokenValid(resetToken: string)`: Checks if a password reset token is still valid.

Expand All @@ -98,20 +99,20 @@ import { auth } from "@/edgedb";
export default async function Home() {
const session = await auth.getSession();

const loggedIn = await session.isSignedIn();
const isSignedIn = await session.isSignedIn();

return (
<main>
<h1>Home</h1>

{loggedIn ? (
{isSignedIn ? (
<>
<div>You are logged in</div>
<div>You are signed in</div>
{await session.client.queryJSON(`...`)}
</>
) : (
<>
<div>You are not logged in</div>
<div>You are not signed in</div>
<a href={auth.getBuiltinUIUrl()}>Sign in with Built-in UI</a>
</>
)}
Expand Down
18 changes: 18 additions & 0 deletions packages/auth-nextjs/src/app/client.ts
@@ -0,0 +1,18 @@
import {
type BuiltinProviderNames,
NextAuthHelpers,
type NextAuthOptions,
} from "../shared.client";

export * from "@edgedb/auth-core/errors";
export { type NextAuthOptions, type BuiltinProviderNames };

export default function createNextAppClientAuth(options: NextAuthOptions) {
return new NextAppClientAuth(options);
}

export class NextAppClientAuth extends NextAuthHelpers {
constructor(options: NextAuthOptions) {
super(options);
}
}
50 changes: 49 additions & 1 deletion packages/auth-nextjs/src/app/index.ts
Expand Up @@ -12,7 +12,7 @@ import {
NextAuth,
NextAuthSession,
type NextAuthOptions,
BuiltinProviderNames,
type BuiltinProviderNames,
type CreateAuthRouteHandlers,
_extractParams,
} from "../shared";
Expand Down Expand Up @@ -178,6 +178,54 @@ export class NextAppAuth extends NextAuth {
);
}
},
magicLinkSignUp: async (data: FormData | { email: string }) => {
if (!this.options.magicLinkFailurePath) {
throw new ConfigurationError(
`'magicLinkFailurePath' option not configured`
);
}
const [email] = _extractParams(data, ["email"], "email missing");
const { verifier } = await (
await this.core
).signupWithMagicLink(
email,
`${this._authRoute}/magiclink/callback?isSignUp=true`,
new URL(
this.options.magicLinkFailurePath,
this.options.baseUrl
).toString()
);
cookies().set({
name: this.options.pkceVerifierCookieName,
value: verifier,
httpOnly: true,
sameSite: "strict",
});
},
magicLinkSignIn: async (data: FormData | { email: string }) => {
if (!this.options.magicLinkFailurePath) {
throw new ConfigurationError(
`'magicLinkFailurePath' option not configured`
);
}
const [email] = _extractParams(data, ["email"], "email missing");
const { verifier } = await (
await this.core
).signinWithMagicLink(
email,
`${this._authRoute}/magiclink/callback`,
new URL(
this.options.magicLinkFailurePath,
this.options.baseUrl
).toString()
);
cookies().set({
name: this.options.pkceVerifierCookieName,
value: verifier,
httpOnly: true,
sameSite: "strict",
});
},
};
}
}
Expand Down
8 changes: 8 additions & 0 deletions packages/auth-nextjs/src/pages/client.ts
Expand Up @@ -61,6 +61,14 @@ export class NextPagesClientAuth extends NextAuthHelpers {
data
);
}

async magicLinkSignUp(data: { email: string } | FormData) {
return await apiRequest(`${this._authRoute}/magiclink/signup`, data);
}

async magicLinkSend(data: { email: string } | FormData) {
return await apiRequest(`${this._authRoute}/magiclink/send`, data);
}
}

async function apiRequest(url: string, _data: any) {
Expand Down
2 changes: 1 addition & 1 deletion packages/auth-nextjs/src/pages/server.ts
@@ -1,4 +1,4 @@
import { Client } from "edgedb";
import { type Client } from "edgedb";
import { type TokenData } from "@edgedb/auth-core";
import {
type BuiltinProviderNames,
Expand Down
15 changes: 14 additions & 1 deletion packages/auth-nextjs/src/shared.client.ts
Expand Up @@ -2,6 +2,9 @@ import {
type BuiltinOAuthProviderNames,
type emailPasswordProviderName,
} from "@edgedb/auth-core";
import { WebAuthnClient } from "@edgedb/auth-core/webauthn";

export * as webauthn from "@edgedb/auth-core/webauthn";

export type BuiltinProviderNames =
| BuiltinOAuthProviderNames
Expand All @@ -13,14 +16,16 @@ export interface NextAuthOptions {
authCookieName?: string;
pkceVerifierCookieName?: string;
passwordResetPath?: string;
magicLinkFailurePath?: string;
}

type OptionalOptions = "passwordResetPath";
type OptionalOptions = "passwordResetPath" | "magicLinkFailurePath";

export abstract class NextAuthHelpers {
/** @internal */
readonly options: Required<Omit<NextAuthOptions, OptionalOptions>> &
Pick<NextAuthOptions, OptionalOptions>;
readonly webAuthnClient: WebAuthnClient;

/** @internal */
constructor(options: NextAuthOptions) {
Expand All @@ -31,7 +36,15 @@ export abstract class NextAuthHelpers {
pkceVerifierCookieName:
options.pkceVerifierCookieName ?? "edgedb-pkce-verifier",
passwordResetPath: options.passwordResetPath,
magicLinkFailurePath: options.magicLinkFailurePath,
};
this.webAuthnClient = new WebAuthnClient({
signupOptionsUrl: `${this._authRoute}/webauthn/signup/options`,
signupUrl: `${this._authRoute}/webauthn/signup`,
signinOptionsUrl: `${this._authRoute}/webauthn/signin/options`,
signinUrl: `${this._authRoute}/webauthn/signin`,
verifyUrl: `${this._authRoute}/webauthn/verify`,
});
}

protected get _authRoute() {
Expand Down