From f9212c3e963a91bd3ccc56fd65a364fc0a616230 Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Wed, 7 Aug 2024 10:25:52 -0700 Subject: [PATCH] Add autofill() method using promise-based API (#44) Fixes #41 in a non-breaking way, though it marks the previous method as deprecated and indicates it'll be removed in the future. Happily, even without removing the old method, this very slightly shrinks the bundle size since the change enabled further consolidating the auth logic. --- README.md | 35 ++++++++++++++++++++--------------- src/SDK.ts | 43 ++++++++++++++++++++++--------------------- 2 files changed, 42 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 0daf9df..a22a25f 100644 --- a/README.md +++ b/README.md @@ -117,36 +117,41 @@ To take advantage of this, you need two things: ``` -2) Run the `handleAutofill` API. This takes a callback which runs on successful authentication using the autofill API: +2) Run the `autofill` API. + This returns an `AuthResponse`, just like the modal `startAuth()` method. ```typescript -// Type import is optional, but recommended. -import { AuthResponse } from '@snapauth/sdk' -const onSignIn = (auth: AuthResponse) => { - if (auth.ok) { - // send `auth.data.token` to your backend, as above - } -} -snapAuth.handleAutofill(onSignIn) +const auth = await snapAuth.autofill() ``` -Unlike the direct startRegister and startAuth calls, handleAutofill CAN and SHOULD be called as early in the page lifecycle is possible (_not_ in response to a user gesture). +Unlike the direct startRegister and startAuth calls, autofill CAN and SHOULD be called as early in the page lifecycle is possible (_not_ in response to a user gesture). This helps ensure that autofill can occur when a user interacts with the form field. > [!TIP] -> Re-use the `handleAutofill` callback in the traditional flow to create a consistent experience: +> Use the same logic to validate the the response from both `autofill()` and `startAuth()`. +> +> Avoid giving the user visual feedback if autofill returns an error. ```typescript +import { AuthResponse } from '@snapauth/sdk' const validateAuth = async (auth: AuthResponse) => { if (auth.ok) { - await fetch(...) // send auth.data.token + await fetch(...) // send auth.data.token to your backend to sign in the user } } const onSignInSubmit = async (e) => { - // ... + // get `handle` (commonly username or email) from a form field or similar const auth = await snapAuth.startAuth({ handle }) - await validateAuth(auth) + if (auth.ok) { + await validateAuth(auth) + } else { + // Display a message to the user, send to a different flow, etc. + } +} + +const afAuth = await snapauth.autofill() +if (afAuth.ok) { + validateAuth(afAuth) } -sdk.handleAutofill(validateAuth) ``` ## Building the SDK diff --git a/src/SDK.ts b/src/SDK.ts index d34eb54..c56df9a 100644 --- a/src/SDK.ts +++ b/src/SDK.ts @@ -32,6 +32,7 @@ type WebAuthnError = | 'canceled_by_user' | 'invalid_domain' | 'browser_bug?' + | 'api_unsupported_in_browser' | 'unexpected' export type AuthResponse = Result<{ token: string }, WebAuthnError> @@ -103,12 +104,15 @@ class SDK { if (!this.isWebAuthnAvailable) { return { ok: false, error: 'webauthn_unavailable' } } - const res = await this.api('/assertion/options', { user }) as Result - if (!res.ok) { - return res + return await this.doAuth(user) + } + + async autofill(): Promise { + // TODO: warn if no is found? + if (!(await this.isConditionalGetAvailable())) { + return { ok: false, error: 'api_unsupported_in_browser' } } - const options = parseRequestOptions(res.data) - return await this.doAuth(options, user) + return await this.doAuth(undefined) } async startRegister(user: UserRegistrationInfo): Promise { @@ -150,29 +154,26 @@ class SDK { } } + /** + * @deprecated use `await autofill()` instead, and ignore non-successful + * responses. This method will be removed prior to 1.0. + */ async handleAutofill(callback: (arg0: AuthResponse) => void) { - if (!(await this.isConditionalGetAvailable())) { - return false + // TODO: await autofill(), callback(res) if ok + const result = await this.autofill() + if (result.ok) { + callback(result) } - // TODO: warn if no is found? + } - // Autofill API is available. Make the calls and set it up. - const res = await this.api('/assertion/options', {}) as Result + private async doAuth(user: UserIdOrHandle|undefined): Promise { + // Get the remotely-built WebAuthn options + const res = await this.api('/assertion/options', { user }) as Result if (!res.ok) { - // This results in a silent failure. Intetional but subject to change. - return + return res } const options = parseRequestOptions(res.data) - const response = await this.doAuth(options, undefined) - if (response.ok) { - callback(response) - } else { - // User aborted conditional mediation (UI doesn't even exist in all - // browsers). Do not run the callback. - } - } - private async doAuth(options: CredentialRequestOptions, user: UserIdOrHandle|undefined): Promise { const signal = this.cancelExistingRequests() try { options.signal = signal