From 2677fa45dbd0a046515f32b786304be27c86391b Mon Sep 17 00:00:00 2001 From: Scott Trinh Date: Thu, 21 Mar 2024 13:45:57 -0400 Subject: [PATCH 1/2] Add Magic Link implementation to SvelteKit --- packages/auth-sveltekit/src/client.ts | 10 +- packages/auth-sveltekit/src/server.ts | 156 +++++++++++++++++++++----- 2 files changed, 135 insertions(+), 31 deletions(-) diff --git a/packages/auth-sveltekit/src/client.ts b/packages/auth-sveltekit/src/client.ts index ab89e4d6a..d5c6eb5dd 100644 --- a/packages/auth-sveltekit/src/client.ts +++ b/packages/auth-sveltekit/src/client.ts @@ -6,9 +6,10 @@ export interface AuthOptions { authCookieName?: string; pkceVerifierCookieName?: string; passwordResetPath?: string; + magicLinkFailurePath?: string; } -type OptionalOptions = "passwordResetPath"; +type OptionalOptions = "passwordResetPath" | "magicLinkFailurePath"; export type AuthConfig = Required> & Pick & { authRoute: string }; @@ -19,12 +20,11 @@ export function getConfig(options: AuthOptions) { options.authRoutesPath?.replace(/^\/|\/$/g, "") ?? "auth"; return { + authCookieName: "edgedb-session", + pkceVerifierCookieName: "edgedb-pkce-verifier", + ...options, baseUrl, authRoutesPath, - authCookieName: options.authCookieName ?? "edgedb-session", - pkceVerifierCookieName: - options.pkceVerifierCookieName ?? "edgedb-pkce-verifier", - passwordResetPath: options.passwordResetPath, authRoute: `${baseUrl}/${authRoutesPath}`, }; } diff --git a/packages/auth-sveltekit/src/server.ts b/packages/auth-sveltekit/src/server.ts index 4877a16a1..cf8ea7288 100644 --- a/packages/auth-sveltekit/src/server.ts +++ b/packages/auth-sveltekit/src/server.ts @@ -47,6 +47,9 @@ export interface AuthRouteHandlers { isSignUp: boolean; }> ) => Promise; + onMagicLinkCallback?: ( + params: ParamsOrError<{ tokenData: TokenData; isSignUp: boolean }> + ) => Promise; onBuiltinUICallback?: ( params: ParamsOrError< ( @@ -153,21 +156,12 @@ export class ServerRequestAuth extends ClientAuth { `${this.config.authRoute}/emailpassword/verify` ); - this.cookies.set(this.config.pkceVerifierCookieName, result.verifier, { - httpOnly: true, - sameSite: "strict", - path: "/", - }); + this.setVerifierCookie(result.verifier); if (result.status === "complete") { const tokenData = result.tokenData; - this.cookies.set(this.config.authCookieName, tokenData.auth_token, { - httpOnly: true, - sameSite: "strict", - path: "/", - }); - + this.setAuthTokenCookie(tokenData.auth_token); return { tokenData }; } @@ -199,11 +193,7 @@ export class ServerRequestAuth extends ClientAuth { await this.core ).signinWithEmailPassword(email, password); - this.cookies.set(this.config.authCookieName, tokenData.auth_token, { - httpOnly: true, - sameSite: "strict", - path: "/", - }); + this.setAuthTokenCookie(tokenData.auth_token); return { tokenData }; } @@ -224,11 +214,7 @@ export class ServerRequestAuth extends ClientAuth { new URL(this.config.passwordResetPath, this.config.baseUrl).toString() ); - this.cookies.set(this.config.pkceVerifierCookieName, verifier, { - httpOnly: true, - sameSite: "strict", - path: "/", - }); + this.setVerifierCookie(verifier); } async emailPasswordResetPassword( @@ -250,20 +236,82 @@ export class ServerRequestAuth extends ClientAuth { await this.core ).resetPasswordWithResetToken(resetToken, verifier, password); - this.cookies.set(this.config.authCookieName, tokenData.auth_token, { + this.setAuthTokenCookie(tokenData.auth_token); + this.deleteVerifierCookie(); + return { tokenData }; + } + + async magicLinkSignUp(data: { email: string } | FormData): Promise { + if (!this.config.magicLinkFailurePath) { + throw new ConfigurationError( + `'magicLinkFailurePath' option not configured` + ); + } + const [email] = extractParams(data, ["email"], "email missing"); + + const callbackUrl = new URL("magiclink/callback", this.config.authRoute); + callbackUrl.searchParams.set("isSignUp", "true"); + const errorUrl = new URL( + this.config.magicLinkFailurePath, + this.config.baseUrl + ); + const { verifier } = await ( + await this.core + ).signupWithMagicLink(email, callbackUrl.href, errorUrl.href); + + this.setVerifierCookie(verifier); + } + + async magicLinkSend(data: { email: string } | FormData): Promise { + if (!this.config.magicLinkFailurePath) { + throw new ConfigurationError( + `'magicLinkFailurePath' option not configured` + ); + } + const [email] = extractParams(data, ["email"], "email missing"); + + const callbackUrl = new URL("magiclink/callback", this.config.authRoute); + const errorUrl = new URL( + this.config.magicLinkFailurePath, + this.config.baseUrl + ); + const { verifier } = await ( + await this.core + ).signinWithMagicLink(email, callbackUrl.href, errorUrl.href); + + this.setVerifierCookie(verifier); + } + + async signout(): Promise { + this.deleteAuthTokenCookie(); + } + + private setVerifierCookie(verifier: string) { + this.cookies.set(this.config.pkceVerifierCookieName, verifier, { httpOnly: true, - sameSite: "lax", + sameSite: "strict", path: "/", }); + } + private deleteVerifierCookie() { this.cookies.delete(this.config.pkceVerifierCookieName, { path: "/", }); - return { tokenData }; } - async signout(): Promise { - this.cookies.delete(this.config.authCookieName, { path: "/" }); + private setAuthTokenCookie(authToken: string) { + this.cookies.set(this.config.authCookieName, authToken, { + httpOnly: true, + sameSite: "strict", + path: "/", + }); + } + + private deleteAuthTokenCookie() { + this.cookies.delete(this.config.authCookieName, { + path: "/", + }); } } @@ -328,6 +376,7 @@ async function handleAuthRoutes( onBuiltinUICallback, onEmailVerify, onSignout, + onMagicLinkCallback, }: AuthRouteHandlers, { url, cookies }: RequestEvent, core: Promise, @@ -423,6 +472,61 @@ async function handleAuthRoutes( }); } + 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 onMagicLinkCallback({ + error: new EdgeDBAuthError(error + (desc ? `: ${desc}` : "")), + }); + } + + const code = searchParams.get("code"); + if (!code) { + return onMagicLinkCallback({ + error: new PKCEError("no pkce code in response"), + }); + } + const isSignUp = searchParams.get("isSignUp") === "true"; + const verifier = cookies.get(config.pkceVerifierCookieName); + + if (!verifier) { + return onMagicLinkCallback({ + error: new PKCEError("no pkce verifier cookie found"), + }); + } + let tokenData: TokenData; + try { + tokenData = await (await core).getToken(code, verifier); + } catch (err) { + return onMagicLinkCallback({ + error: err instanceof Error ? err : new Error(String(err)), + }); + } + cookies.set(config.authCookieName, tokenData.auth_token, { + httpOnly: true, + sameSite: "strict", + path: "/", + }); + + cookies.set(config.pkceVerifierCookieName, "", { + maxAge: 0, + path: "/", + }); + + return onMagicLinkCallback({ + error: null, + tokenData, + isSignUp, + }); + } + case "builtin/callback": { if (!onBuiltinUICallback) { throw new ConfigurationError( From c64c7e7738f41992f8b4b28bbed820ba3cf0b4ef Mon Sep 17 00:00:00 2001 From: Scott Trinh Date: Thu, 21 Mar 2024 14:06:16 -0400 Subject: [PATCH 2/2] Add WebAuthn implementation to SvelteKit --- packages/auth-sveltekit/src/client.ts | 9 +++ packages/auth-sveltekit/src/server.ts | 101 ++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/packages/auth-sveltekit/src/client.ts b/packages/auth-sveltekit/src/client.ts index d5c6eb5dd..2ffca9329 100644 --- a/packages/auth-sveltekit/src/client.ts +++ b/packages/auth-sveltekit/src/client.ts @@ -1,4 +1,5 @@ import type { BuiltinOAuthProviderNames } from "@edgedb/auth-core"; +import { WebAuthnClient } from "@edgedb/auth-core/webauthn"; export interface AuthOptions { baseUrl: string; @@ -35,10 +36,18 @@ export default function createClientAuth(options: AuthOptions) { export class ClientAuth { protected readonly config: AuthConfig; + readonly webAuthnClient: WebAuthnClient; /** @internal */ constructor(options: AuthOptions) { this.config = getConfig(options); + this.webAuthnClient = new WebAuthnClient({ + signupOptionsUrl: `${this.config.authRoute}/webauthn/signup/options`, + signupUrl: `${this.config.authRoute}/webauthn/signup`, + signinOptionsUrl: `${this.config.authRoute}/webauthn/signin/options`, + signinUrl: `${this.config.authRoute}/webauthn/signin`, + verifyUrl: `${this.config.authRoute}/webauthn/verify`, + }); } getOAuthUrl(providerName: BuiltinOAuthProviderNames) { diff --git a/packages/auth-sveltekit/src/server.ts b/packages/auth-sveltekit/src/server.ts index cf8ea7288..b37a1d3a1 100644 --- a/packages/auth-sveltekit/src/server.ts +++ b/packages/auth-sveltekit/src/server.ts @@ -17,6 +17,8 @@ import { InvalidDataError, OAuthProviderFailureError, EdgeDBAuthError, + type RegistrationResponseJSON, + type AuthenticationResponseJSON, } from "@edgedb/auth-core"; import { ClientAuth, @@ -282,6 +284,45 @@ export class ServerRequestAuth extends ClientAuth { this.setVerifierCookie(verifier); } + async webAuthnSignUp(data: { + email: string; + credentials: RegistrationResponseJSON; + verify_url: string; + user_handle: string; + }): Promise<{ tokenData: TokenData | null }> { + const { + email, + credentials, + verify_url: verifyUrl, + user_handle: userHandle, + } = data; + + const result = await ( + await this.core + ).signupWithWebAuthn(email, credentials, verifyUrl, userHandle); + + this.setVerifierCookie(result.verifier); + if (result.status === "complete") { + this.setAuthTokenCookie(result.tokenData.auth_token); + return { tokenData: result.tokenData }; + } + + return { tokenData: null }; + } + + async webAuthnSignIn(data: { + email: string; + assertion: AuthenticationResponseJSON; + }): Promise<{ tokenData: TokenData | null }> { + const { email, assertion } = data; + const tokenData = await ( + await this.core + ).signinWithWebAuthn(email, assertion); + + this.setAuthTokenCookie(tokenData.auth_token); + return { tokenData }; + } + async signout(): Promise { this.deleteAuthTokenCookie(); } @@ -653,6 +694,66 @@ async function handleAuthRoutes( }); } + case "webauthn/signup/options": { + const email = searchParams.get("email"); + if (!email) { + throw new InvalidDataError("email missing"); + } + return redirect(302, (await core).getWebAuthnSignupOptionsUrl(email)); + } + + case "webauthn/signin/options": { + const email = searchParams.get("email"); + if (!email) { + throw new InvalidDataError("email missing"); + } + return redirect(302, (await core).getWebAuthnSigninOptionsUrl(email)); + } + + case "webauthn/verify": { + if (!onEmailVerify) { + throw new ConfigurationError( + `'onEmailVerify' auth route handler not configured` + ); + } + + const verificationToken = searchParams.get("verification_token"); + if (!verificationToken) { + return onEmailVerify({ + error: new InvalidDataError("verification_token missing"), + }); + } + const verifier = cookies.get(config.pkceVerifierCookieName); + if (!verifier) { + return onEmailVerify({ + error: new PKCEError("no pkce verifier cookie found"), + verificationToken, + }); + } + let tokenData: TokenData; + try { + tokenData = await ( + await core + ).verifyWebAuthnSignup(verificationToken, verifier); + } catch (err) { + return onEmailVerify({ + error: err instanceof Error ? err : new Error(String(err)), + verificationToken, + }); + } + + cookies.set(config.authCookieName, tokenData.auth_token, { + httpOnly: true, + sameSite: "strict", + path: "/", + }); + + return onEmailVerify({ + error: null, + tokenData, + }); + } + case "signout": { if (!onSignout) { throw new ConfigurationError(