From 8ea7932fd975cd8a105a5ec52705fc317ab1dcd4 Mon Sep 17 00:00:00 2001 From: Scott Trinh Date: Thu, 21 Mar 2024 11:16:11 -0400 Subject: [PATCH] Add WebAuthn and Magic Links in auth-remix (#899) --- packages/auth-core/src/errors.ts | 7 + packages/auth-core/src/webauthn.ts | 6 +- packages/auth-remix/readme.md | 10 +- packages/auth-remix/src/client.ts | 19 +- packages/auth-remix/src/server.ts | 485 ++++++++++++++++++++++++++++- 5 files changed, 496 insertions(+), 31 deletions(-) diff --git a/packages/auth-core/src/errors.ts b/packages/auth-core/src/errors.ts index 675f28e39..b1379beac 100644 --- a/packages/auth-core/src/errors.ts +++ b/packages/auth-core/src/errors.ts @@ -88,6 +88,13 @@ export class OAuthProviderFailureError extends UserError { } } +/** Magic link flow failed. */ +export class MagicLinkFailureError extends UserError { + get type() { + return "MagicLinkFailure"; + } +} + /** Error with email verification. */ export class VerificationError extends UserError { get type() { diff --git a/packages/auth-core/src/webauthn.ts b/packages/auth-core/src/webauthn.ts index 86b3253cf..5be7c5e3d 100644 --- a/packages/auth-core/src/webauthn.ts +++ b/packages/auth-core/src/webauthn.ts @@ -4,8 +4,6 @@ import { requestGET, requestPOST } from "./utils"; import type { PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialRequestOptionsJSON, - SignupResponse, - TokenData, } from "./types"; interface WebAuthnClientOptions { @@ -31,7 +29,7 @@ export class WebAuthnClient { this.verifyUrl = options.verifyUrl; } - public async signUp(email: string): Promise { + public async signUp(email: string): Promise { const options = await requestGET( this.signupOptionsUrl, { email }, @@ -79,7 +77,7 @@ export class WebAuthnClient { type: credentials.type, }; - return await requestPOST( + return await requestPOST( this.signupUrl, { email, diff --git a/packages/auth-remix/readme.md b/packages/auth-remix/readme.md index e42b2b958..a90f9e384 100644 --- a/packages/auth-remix/readme.md +++ b/packages/auth-remix/readme.md @@ -41,7 +41,7 @@ export default auth; import createServerAuth from "@edgedb/auth-remix/server"; import { createClient } from "edgedb"; - import { options } from "./auth.client"; + import { options } from "./auth"; export const client = createClient({ //Note: when developing locally you will need to set tls security to insecure, because the dev server uses self-signed certificates which will cause api calls with the fetch api to fail. @@ -65,13 +65,13 @@ export default auth; // app/routes/auth.$.ts import { redirect } from "@remix-run/node"; - import { auth } from "~/services/auth.server"; + import auth from "~/services/auth.server"; export const { loader } = auth.createAuthRouteHandlers({ - onOAuthCallback({ error, tokenData, provider, isSignUp }) { + async onOAuthCallback({ error, tokenData, provider, isSignUp }) { return redirect("/"); }, - onSignout() { + async onSignout() { return redirect("/"); }, }); @@ -116,9 +116,9 @@ Now you have auth all configured and user's can signin/signup/etc. you can use t import type { LoaderFunctionArgs } from "@remix-run/node"; import { json } from "@remix-run/node"; import { useLoaderData, Link } from "@remix-run/react"; + import auth, { client } from "~/services/auth.server"; import clientAuth from "~/services/auth.client"; -import { transformSearchParams } from "~/utils"; export const loader = async ({ request }: LoaderFunctionArgs) => { const session = auth.getSession(request); diff --git a/packages/auth-remix/src/client.ts b/packages/auth-remix/src/client.ts index 00c72a7b4..0a8bb8830 100644 --- a/packages/auth-remix/src/client.ts +++ b/packages/auth-remix/src/client.ts @@ -1,4 +1,5 @@ import type { BuiltinOAuthProviderNames } from "@edgedb/auth-core"; +import { WebAuthnClient } from "@edgedb/auth-core/webauthn"; export interface RemixAuthOptions { baseUrl: string; @@ -6,9 +7,10 @@ export interface RemixAuthOptions { authCookieName?: string; pkceVerifierCookieName?: string; passwordResetPath?: string; + magicLinkFailurePath?: string; } -type OptionalOptions = "passwordResetPath"; +type OptionalOptions = "passwordResetPath" | "magicLinkFailurePath"; export default function createClientAuth(options: RemixAuthOptions) { return new RemixClientAuth(options); @@ -19,17 +21,24 @@ export class RemixClientAuth { Omit > & Pick; + readonly webAuthnClient: WebAuthnClient; /** @internal */ constructor(options: RemixAuthOptions) { this.options = { + authCookieName: "edgedb-session", + pkceVerifierCookieName: "edgedb-pkce-verifier", + ...options, baseUrl: options.baseUrl.replace(/\/$/, ""), authRoutesPath: options.authRoutesPath?.replace(/^\/|\/$/g, "") ?? "auth", - authCookieName: options.authCookieName ?? "edgedb-session", - pkceVerifierCookieName: - options.pkceVerifierCookieName ?? "edgedb-pkce-verifier", - passwordResetPath: options.passwordResetPath, }; + 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() { diff --git a/packages/auth-remix/src/server.ts b/packages/auth-remix/src/server.ts index 6b9a592b6..20210de2e 100644 --- a/packages/auth-remix/src/server.ts +++ b/packages/auth-remix/src/server.ts @@ -13,6 +13,9 @@ import { BackendError, OAuthProviderFailureError, EdgeDBAuthError, + MagicLinkFailureError, + type AuthenticationResponseJSON, + type RegistrationResponseJSON, } from "@edgedb/auth-core"; import { type RemixAuthOptions, RemixClientAuth } from "./client.js"; @@ -84,6 +87,12 @@ export interface CreateAuthRouteHandlers { { verificationToken?: string } > ): Promise; + onMagicLinkCallback( + params: ParamsOrError<{ + tokenData: TokenData; + isSignUp: boolean; + }> + ): Promise; onSignout(): Promise; } @@ -118,6 +127,7 @@ export class RemixServerAuth extends RemixClientAuth { onOAuthCallback, onBuiltinUICallback, onEmailVerify, + onMagicLinkCallback, onSignout, }: Partial) { return { @@ -237,6 +247,70 @@ export class RemixServerAuth extends RemixClientAuth { ); } + case "magiclink/callback": { + if (!onMagicLinkCallback) { + throw new ConfigurationError( + `'onMagicLinkCallback' auth route handler not configured` + ); + } + const error = searchParams.get("error"); + if (error) { + const desc = searchParams.get("error_description"); + return cbCall(onMagicLinkCallback, { + error: new MagicLinkFailureError( + error + (desc ? `: ${desc}` : "") + ), + }); + } + const code = searchParams.get("code"); + const isSignUp = searchParams.get("isSignUp") === "true"; + const verifier = + parseCookies(req)[this.options.pkceVerifierCookieName]; + if (!code) { + return cbCall(onMagicLinkCallback, { + error: new PKCEError("no pkce code in response"), + }); + } + if (!verifier) { + return cbCall(onMagicLinkCallback, { + error: new PKCEError("no pkce verifier cookie found"), + }); + } + let tokenData: TokenData; + try { + tokenData = await (await this.core).getToken(code, verifier); + } catch (err) { + return cbCall(onMagicLinkCallback, { + error: err instanceof Error ? err : new Error(String(err)), + }); + } + const headers = new Headers(); + headers.append( + "Set-Cookie", + cookie.serialize( + this.options.authCookieName, + tokenData.auth_token, + { httpOnly: true, sameSite: "lax", path: "/" } + ) + ); + headers.append( + "Set-Cookie", + cookie.serialize(this.options.pkceVerifierCookieName, "", { + maxAge: 0, + path: "/", + }) + ); + return cbCall( + onMagicLinkCallback, + { + error: null, + tokenData, + isSignUp, + }, + headers + ); + } + case "builtin/callback": { if (!onBuiltinUICallback) { throw new ConfigurationError( @@ -389,6 +463,78 @@ export class RemixServerAuth extends RemixClientAuth { ); } + case "webauthn/signup/options": { + const email = searchParams.get("email"); + if (!email) { + throw new InvalidDataError("email missing"); + } + return Response.redirect( + (await this.core).getWebAuthnSignupOptionsUrl(email) + ); + } + + case "webauthn/signin/options": { + const email = searchParams.get("email"); + if (!email) { + throw new InvalidDataError("email missing"); + } + return Response.redirect( + (await this.core).getWebAuthnSigninOptionsUrl(email) + ); + } + + case "webauthn/verify": { + if (!onEmailVerify) { + throw new ConfigurationError( + `'onEmailVerify' auth route handler not configured` + ); + } + const verificationToken = searchParams.get("verification_token"); + const verifier = + parseCookies(req)[this.options.pkceVerifierCookieName]; + if (!verificationToken) { + return cbCall(onEmailVerify, { + error: new PKCEError("no verification_token in response"), + }); + } + if (!verifier) { + return cbCall(onEmailVerify, { + error: new PKCEError("no pkce verifier cookie found"), + verificationToken, + }); + } + let tokenData: TokenData; + try { + tokenData = await ( + await this.core + ).verifyWebAuthnSignup(verificationToken, verifier); + } catch (err) { + return cbCall(onEmailVerify, { + error: err instanceof Error ? err : new Error(String(err)), + verificationToken, + }); + } + const headers = new Headers({ + "Set-Cookie": cookie.serialize( + this.options.authCookieName, + tokenData.auth_token, + { + httpOnly: true, + sameSite: "strict", + path: "/", + } + ), + }); + return cbCall( + onEmailVerify, + { + error: null, + tokenData, + }, + headers + ); + } + case "signout": { if (!onSignout) { throw new ConfigurationError( @@ -403,7 +549,7 @@ export class RemixServerAuth extends RemixClientAuth { maxAge: 0, }), }); - return cbCall(onSignout, {}, headers); + return cbCall(onSignout, undefined, headers); } default: @@ -416,24 +562,30 @@ export class RemixServerAuth extends RemixClientAuth { async emailPasswordSignUp( req: Request, data?: { email: string; password: string } - ): Promise<{ tokenData: TokenData; headers: Headers }>; + ): Promise<{ tokenData: TokenData | null; headers: Headers }>; async emailPasswordSignUp( req: Request, - cb: (params: ParamsOrError<{ tokenData: TokenData }>) => Res | Promise + cb: ( + params: ParamsOrError<{ tokenData: TokenData | null }> + ) => Res | Promise ): Promise>; async emailPasswordSignUp( req: Request, data: { email: string; password: string }, - cb: (params: ParamsOrError<{ tokenData: TokenData }>) => Res | Promise + cb: ( + params: ParamsOrError<{ tokenData: TokenData | null }> + ) => Res | Promise ): Promise>; async emailPasswordSignUp( req: Request, dataOrCb?: | { email: string; password: string } | (( - params: ParamsOrError<{ tokenData: TokenData }> + params: ParamsOrError<{ tokenData: TokenData | null }> ) => Res | Promise), - cb?: (params: ParamsOrError<{ tokenData: TokenData }>) => Res | Promise + cb?: ( + params: ParamsOrError<{ tokenData: TokenData | null }> + ) => Res | Promise ): Promise< | { tokenData: TokenData; @@ -594,6 +746,166 @@ export class RemixServerAuth extends RemixClientAuth { ); } + async webAuthnSignIn( + req: Request, + data?: { email: string; assertion: AuthenticationResponseJSON } + ): Promise<{ tokenData: TokenData; headers: Headers }>; + async webAuthnSignIn( + req: Request, + cb: (params: ParamsOrError<{ tokenData: TokenData }>) => Res | Promise + ): Promise>; + async webAuthnSignIn( + req: Request, + data: { email: string; assertion: AuthenticationResponseJSON }, + cb: (params: ParamsOrError<{ tokenData: TokenData }>) => Res | Promise + ): Promise>; + async webAuthnSignIn( + req: Request, + dataOrCb?: + | { + email: string; + assertion: AuthenticationResponseJSON; + } + | (( + params: ParamsOrError<{ tokenData: TokenData }> + ) => Res | Promise), + cb?: (params: ParamsOrError<{ tokenData: TokenData }>) => Res | Promise + ): Promise< + | { + tokenData: TokenData; + headers: Headers; + } + | (Res extends Response ? Res : TypedResponse) + > { + return handleAction<{ + email: string; + assertion: AuthenticationResponseJSON; + }>( + async (data, headers) => { + const { email, assertion } = data; + + const tokenData = await ( + await this.core + ).signinWithWebAuthn(email, assertion); + + headers.append( + "Set-Cookie", + cookie.serialize(this.options.authCookieName, tokenData.auth_token, { + httpOnly: true, + sameSite: "strict", + path: "/", + }) + ); + + return { tokenData }; + }, + req, + dataOrCb, + cb + ); + } + + async webAuthnSignUp( + req: Request, + data?: { + email: string; + credentials: RegistrationResponseJSON; + verify_url: string; + user_handle: string; + } + ): Promise<{ tokenData: TokenData | null; headers: Headers }>; + async webAuthnSignUp( + req: Request, + cb: ( + params: ParamsOrError<{ tokenData: TokenData | null }> + ) => Res | Promise + ): Promise>; + async webAuthnSignUp( + req: Request, + data: { + email: string; + credentials: RegistrationResponseJSON; + verify_url: string; + user_handle: string; + }, + cb: ( + params: ParamsOrError<{ tokenData: TokenData | null }> + ) => Res | Promise + ): Promise>; + async webAuthnSignUp( + req: Request, + dataOrCb?: + | { + email: string; + credentials: RegistrationResponseJSON; + verify_url: string; + user_handle: string; + } + | (( + params: ParamsOrError<{ tokenData: TokenData | null }> + ) => Res | Promise), + cb?: ( + params: ParamsOrError<{ tokenData: TokenData | null }> + ) => Res | Promise + ): Promise< + | { + tokenData: TokenData | null; + headers: Headers; + } + | (Res extends Response ? Res : TypedResponse) + > { + return handleAction<{ + email: string; + credentials: RegistrationResponseJSON; + verify_url: string; + user_handle: string; + }>( + async (data, headers) => { + const { email, credentials, verify_url, user_handle } = data; + + const result = await ( + await this.core + ).signupWithWebAuthn(email, credentials, verify_url, user_handle); + + headers.append( + "Set-Cookie", + cookie.serialize( + this.options.pkceVerifierCookieName, + result.verifier, + { + httpOnly: true, + sameSite: "strict", + path: "/", + } + ) + ); + + if (result.status === "complete") { + const tokenData = result.tokenData; + + headers.append( + "Set-Cookie", + cookie.serialize( + this.options.authCookieName, + tokenData.auth_token, + { + httpOnly: true, + sameSite: "strict", + path: "/", + } + ) + ); + return { tokenData, headers }; + } + + return { tokenData: null, headers }; + }, + req, + dataOrCb, + cb + ); + } + async emailPasswordSendPasswordResetEmail( req: Request, data?: { email: string } @@ -727,6 +1039,132 @@ export class RemixServerAuth extends RemixClientAuth { ); } + async magicLinkSignUp( + req: Request, + data?: { + email: string; + } + ): Promise<{ headers: Headers }>; + async magicLinkSignUp( + req: Request, + cb: (params: ParamsOrError>) => Res | Promise + ): Promise>; + async magicLinkSignUp( + req: Request, + data: { + email: string; + }, + cb: (params: ParamsOrError>) => Res | Promise + ): Promise>; + async magicLinkSignUp( + req: Request, + dataOrCb?: + | { + email: string; + } + | ((params: ParamsOrError>) => Res | Promise), + cb?: (params: ParamsOrError>) => Res | Promise + ): Promise< + { headers: Headers } | (Res extends Response ? Res : TypedResponse) + > { + return handleAction( + async (data, headers) => { + 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() + ); + + headers.append( + "Set-Cookie", + cookie.serialize(this.options.pkceVerifierCookieName, verifier, { + httpOnly: true, + sameSite: "strict", + path: "/", + }) + ); + }, + req, + dataOrCb, + cb + ); + } + + async magicLinkSend( + req: Request, + data?: { + email: string; + } + ): Promise<{ headers: Headers }>; + async magicLinkSend( + req: Request, + cb: (params: ParamsOrError>) => Res | Promise + ): Promise>; + async magicLinkSend( + req: Request, + data: { + email: string; + }, + cb: (params: ParamsOrError>) => Res | Promise + ): Promise>; + async magicLinkSend( + req: Request, + dataOrCb?: + | { + email: string; + } + | ((params: ParamsOrError>) => Res | Promise), + cb?: (params: ParamsOrError>) => Res | Promise + ): Promise< + { headers: Headers } | (Res extends Response ? Res : TypedResponse) + > { + return handleAction( + async (data, headers) => { + 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?isSignUp=true`, + new URL( + this.options.magicLinkFailurePath, + this.options.baseUrl + ).toString() + ); + + headers.append( + "Set-Cookie", + cookie.serialize(this.options.pkceVerifierCookieName, verifier, { + httpOnly: true, + sameSite: "strict", + path: "/", + }) + ); + }, + req, + dataOrCb, + cb + ); + } + async signout(): Promise<{ headers: Headers }>; async signout( cb: () => Res | Promise @@ -790,18 +1228,23 @@ function _extractParams( return params; } -async function handleAction( - action: ( - data: Record | FormData, - headers: Headers, - req: Request - ) => Promise, +async function handleAction | FormData>( + action: (data: DataT, headers: Headers, req: Request) => Promise, req: Request, - dataOrCb: Record | ((data: any) => any) | undefined, + dataOrCb: Record | ((data: any) => any) | undefined, cb: ((data: any) => any) | undefined ) { - const data = typeof dataOrCb === "object" ? dataOrCb : await req.formData(); - const callback = typeof dataOrCb === "function" ? dataOrCb : cb; + const contentType = req.headers.get("content-type") ?? "application/json"; + const data = ( + typeof dataOrCb === "object" + ? dataOrCb + : contentType.startsWith("application/json") + ? await req.json() + : await req.formData() + ) as DataT; + const callback = (typeof dataOrCb === "function" ? dataOrCb : cb) as ( + data: any + ) => any; const headers: Headers = new Headers(); let params: any; @@ -857,11 +1300,19 @@ async function actionCbCall( } } -async function cbCall(cb: (data?: any) => any, params: any, headers?: Headers) { +async function cbCall( + cb: undefined extends Params ? () => any : (data: Params) => any, + params: Params, + headers?: Headers +) { let res: any; try { - res = params ? await cb(params) : await cb(); + if (params === undefined) { + res = await (cb as () => any)(); + } else { + res = await cb(params); + } } catch (err) { if (err instanceof Response) { res = err;