From 03c3eac16cb181300b806da8b769fde0ac88adcb Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Fri, 6 Sep 2024 11:27:21 -0700 Subject: [PATCH] Prepare support for automatic passkey upgrades (#36) This adds a new API, `upgradeToPasskey()`, which is intended to be invoked for automatic passkey upgrades after a user signs in with a non-WebAuthn method. It's used similarly to the `handleAutofill()`, where it's called optimistically and your callback will be invoked (or not) if the right conditions are met. As of today, the only supporting browser is the beta versions of Safari, which should be publicly released in fall-ish with iOS18 and macOS Sequoia. However, the SnapAuth API is universally available. Fixes #39 --- README.md | 27 ++++++++++++ src/SDK.ts | 111 ++++++++++++++++++++++++++++++++--------------- src/fromJSON.ts | 19 ++------ types/index.d.ts | 4 ++ 4 files changed, 110 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index be055de..ae98434 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,33 @@ You may also set `displayName`, though browsers typically (counter-intuitively) > Once browser APIs exist to modify it, we will add support to the SDK. > See [#40](https://github.com/snapauthapp/sdk-typescript/issues/40) for details. +#### Automatic Passkey Upgrades + +Some browsers support automatic passkey upgrades (and others will be adding support soon). +These allow adding passkeys to existing accounts without having to send the user through a separate UI flow. +If the browser supports it and the credential manager deems it appropriate, it will automatically create a passkey for the user. +See [the WWDC24 session video](https://developer.apple.com/videos/play/wwdc2024/10125/?time=38) for more information (automatic passkey upgrades are not Apple-specific). + +To do this with SnapAuth, it's very similar to registration process above. +Simply swap `startRegister` to `upgradeToPasskey`, and _avoid_ showing feedback to users on failures. +This should be called just _after_ the user signs in with a non-WebAuthn credential, such as a password or OTP code. + +```typescript +// Name should, again, be a "handle" that the user uses to sign in (username, +// email, etc) +const registration = await snapAuth.upgradeToPasskey({ name }) +if (registration.ok) { + const token = registration.data.token + // Send token to your backend to use the /credential/create API +} else { + // You may want to log this error or add metrics, but should NOT display + // anything to the user in this flow. +} +``` + +SnapAuth will automatically handle browser support detection, and return an `api_unsupported_in_browser` for browsers that do not support automatic upgrades. +You can call our API in any browser! + ### Authenticating diff --git a/src/SDK.ts b/src/SDK.ts index c56df9a..bb3226f 100644 --- a/src/SDK.ts +++ b/src/SDK.ts @@ -65,6 +65,10 @@ class SDK { return !!window.PublicKeyCredential } + /** + * Browser support utilities + */ + async isConditionalCreateAvailable(): Promise { if (!window.PublicKeyCredential) { return false @@ -100,6 +104,17 @@ class SDK { return false } + /** + * Core async APIs + */ + + async startRegister(user: UserRegistrationInfo): Promise { + if (!this.isWebAuthnAvailable) { + return { ok: false, error: 'webauthn_unavailable' } + } + return await this.doRegister(user, false) + } + async startAuth(user: UserAuthenticationInfo): Promise { if (!this.isWebAuthnAvailable) { return { ok: false, error: 'webauthn_unavailable' } @@ -107,6 +122,10 @@ class SDK { return await this.doAuth(user) } + /** + * Conditional mediation (background) APIs + */ + async autofill(): Promise { // TODO: warn if no is found? if (!(await this.isConditionalGetAvailable())) { @@ -115,43 +134,12 @@ class SDK { return await this.doAuth(undefined) } - async startRegister(user: UserRegistrationInfo): Promise { - if (!this.isWebAuthnAvailable) { - return { ok: false, error: 'webauthn_unavailable' } + async upgradeToPasskey(user: UserRegistrationInfo): Promise { + if (!(await this.isConditionalCreateAvailable())) { + return { ok: false, error: 'api_unsupported_in_browser' } } - // If you do this inside the try/catch it seems to fail. Some sort of race - // condition w/ the other request being canceled AFAICT. Doesn't make total - // sense to me and may be a browser specific issue. - const signal = this.cancelExistingRequests() - try { - // If user info provided, send only the id or handle. Do NOT send name or - // displayName. - let remoteUserData: UserIdOrHandle | undefined - if (user.id || user.handle) { - remoteUserData = { - id: user.id, - // @ts-ignore figure this type hack out later - handle: user.handle, - } - } - const res = await this.api('/attestation/options', { user: remoteUserData }) as Result - if (!res.ok) { - return res - } - const options = parseCreateOptions(user, res.data) - options.signal = signal - - const credential = await navigator.credentials.create(options) - this.mustBePublicKeyCredential(credential) - const json = registrationResponseToJSON(credential) - - // @ts-ignore - const response = await this.api('/attestation/process', { credential: json, user }) as RegisterResponse - return response - } catch (error) { - return error instanceof Error ? this.convertCredentialsError(error) : this.genericError(error) - } + return await this.doRegister(user, true) } /** @@ -166,6 +154,40 @@ class SDK { } } + /** + * Internal utilities + */ + + private async doRegister(user: UserRegistrationInfo, upgrade: boolean): Promise { + const remoteUserData = this.filterRegistrationData(user) + const res = await this.api('/attestation/options', { + user: remoteUserData, + upgrade, + }) as Result + if (!res.ok) { + return res + } + + const options = parseCreateOptions(user, res.data) + + // If you do this inside the try/catch it seems to fail. Some sort of race + // condition w/ the other request being canceled AFAICT. Doesn't make total + // sense to me and may be a browser specific issue. + const signal = this.cancelExistingRequests() + try { + options.signal = signal + const credential = await navigator.credentials.create(options) + this.mustBePublicKeyCredential(credential) + const json = registrationResponseToJSON(credential) + return await this.api('/attestation/process', { + credential: json as unknown as JsonEncodable, + user: remoteUserData, + }) as RegisterResponse + } catch (error) { + return error instanceof Error ? this.convertCredentialsError(error) : this.genericError(error) + } + } + private async doAuth(user: UserIdOrHandle|undefined): Promise { // Get the remotely-built WebAuthn options const res = await this.api('/assertion/options', { user }) as Result @@ -190,6 +212,9 @@ class SDK { } } + /** + * API wrapper. Catches and foramts network errors + */ private async api(path: string, body: JsonEncodable): Promise> { const headers = new Headers({ Accept: 'application/json', @@ -281,6 +306,22 @@ class SDK { return ac.signal } + /** + * Privacy enhancement: removes data from network request not needed by + * backend to complete registration + */ + private filterRegistrationData(user: UserRegistrationInfo): UserIdOrHandle|undefined { + // If user info provided, send only the id or handle. Do NOT send name or + // displayName. + if (user.id || user.handle) { + return { + id: user.id, + // @ts-ignore figure this type hack out later + handle: user.handle, + } + } + } + } const formatError = (error: WebAuthnError, obj: Error): Result => ({ diff --git a/src/fromJSON.ts b/src/fromJSON.ts index 0017aae..a07fa86 100644 --- a/src/fromJSON.ts +++ b/src/fromJSON.ts @@ -13,14 +13,12 @@ export const parseRequestOptions = (json: CredentialRequestOptionsJSON): Credent allowCredentials: json.publicKey.allowCredentials?.map(parseDescriptor), challenge: toAB(json.publicKey.challenge), } - let pk = json.publicKey - // add abort signal? return getOptions } export const parseCreateOptions = (user: UserRegistrationInfo, json: CredentialCreationOptionsJSON): CredentialCreationOptions => { - // Locally merge in user.name and displayName - they are never sent out and - // not part of the server response. + // Locally merge in user.name and displayName - they are never sent out (see + // filterRegistrationData) and thus are not part of the server response. json.publicKey.user = { ...json.publicKey.user, name: user.name, @@ -28,6 +26,7 @@ export const parseCreateOptions = (user: UserRegistrationInfo, json: CredentialC } let createOptions: CredentialCreationOptions = {} + createOptions.mediation = json.mediation // TODO: restore parseCreationOptionsFromJSON (see #16+#17) createOptions.publicKey = { @@ -40,7 +39,6 @@ export const parseCreateOptions = (user: UserRegistrationInfo, json: CredentialC } } - // TODO: abortSignal? return createOptions } @@ -48,14 +46,3 @@ const parseDescriptor = (json: PublicKeyCredentialDescriptorJSON): PublicKeyCred ...json, id: toAB(json.id), }) - -/** - * Add WebAuthn Level 3 type info that's missing from TS - */ -interface PublicKeyCredentialStaticMethods { - parseCreationOptionsFromJSON?: (data: PublicKeyCredentialCreationOptionsJSON) => PublicKeyCredentialCreationOptions - parseRequestOptionsFromJSON?: (data: PublicKeyCredentialRequestOptionsJSON) => PublicKeyCredentialRequestOptions - // copying these from the original version :shrug: - // isConditionalMediationAvailable?: () => Promise -} -declare var PublicKeyCredential: PublicKeyCredentialStaticMethods diff --git a/types/index.d.ts b/types/index.d.ts index c2bcb50..6abbe9b 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -122,6 +122,10 @@ declare global { } + interface CredentialCreationOptions { + // Only in draft spec + mediation?: CredentialMediationRequirement + } }