Skip to content

Commit

Permalink
Prepare support for automatic passkey upgrades (#36)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
Firehed authored Sep 6, 2024
1 parent bf884f7 commit 03c3eac
Show file tree
Hide file tree
Showing 4 changed files with 110 additions and 51 deletions.
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
111 changes: 76 additions & 35 deletions src/SDK.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ class SDK {
return !!window.PublicKeyCredential
}

/**
* Browser support utilities
*/

async isConditionalCreateAvailable(): Promise<boolean> {
if (!window.PublicKeyCredential) {
return false
Expand Down Expand Up @@ -100,13 +104,28 @@ class SDK {
return false
}

/**
* Core async APIs
*/

async startRegister(user: UserRegistrationInfo): Promise<RegisterResponse> {
if (!this.isWebAuthnAvailable) {
return { ok: false, error: 'webauthn_unavailable' }
}
return await this.doRegister(user, false)
}

async startAuth(user: UserAuthenticationInfo): Promise<AuthResponse> {
if (!this.isWebAuthnAvailable) {
return { ok: false, error: 'webauthn_unavailable' }
}
return await this.doAuth(user)
}

/**
* Conditional mediation (background) APIs
*/

async autofill(): Promise<AuthResponse> {
// TODO: warn if no <input autocomplete="webauthn"> is found?
if (!(await this.isConditionalGetAvailable())) {
Expand All @@ -115,43 +134,12 @@ class SDK {
return await this.doAuth(undefined)
}

async startRegister(user: UserRegistrationInfo): Promise<RegisterResponse> {
if (!this.isWebAuthnAvailable) {
return { ok: false, error: 'webauthn_unavailable' }
async upgradeToPasskey(user: UserRegistrationInfo): Promise<RegisterResponse> {
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<CredentialCreationOptionsJSON, WebAuthnError>
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)
}

/**
Expand All @@ -166,6 +154,40 @@ class SDK {
}
}

/**
* Internal utilities
*/

private async doRegister(user: UserRegistrationInfo, upgrade: boolean): Promise<RegisterResponse> {
const remoteUserData = this.filterRegistrationData(user)
const res = await this.api('/attestation/options', {
user: remoteUserData,
upgrade,
}) as Result<CredentialCreationOptionsJSON, WebAuthnError>
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<AuthResponse> {
// Get the remotely-built WebAuthn options
const res = await this.api('/assertion/options', { user }) as Result<CredentialRequestOptionsJSON, WebAuthnError>
Expand All @@ -190,6 +212,9 @@ class SDK {
}
}

/**
* API wrapper. Catches and foramts network errors
*/
private async api(path: string, body: JsonEncodable): Promise<Result<any, WebAuthnError>> {
const headers = new Headers({
Accept: 'application/json',
Expand Down Expand Up @@ -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 = <T>(error: WebAuthnError, obj: Error): Result<T, WebAuthnError> => ({
Expand Down
19 changes: 3 additions & 16 deletions src/fromJSON.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,20 @@ 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,
displayName: user.displayName ?? user.name,
}

let createOptions: CredentialCreationOptions = {}
createOptions.mediation = json.mediation

// TODO: restore parseCreationOptionsFromJSON (see #16+#17)
createOptions.publicKey = {
Expand All @@ -40,22 +39,10 @@ export const parseCreateOptions = (user: UserRegistrationInfo, json: CredentialC
}
}

// TODO: abortSignal?
return createOptions
}

const parseDescriptor = (json: PublicKeyCredentialDescriptorJSON): PublicKeyCredentialDescriptor => ({
...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<boolean>
}
declare var PublicKeyCredential: PublicKeyCredentialStaticMethods
4 changes: 4 additions & 0 deletions types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ declare global {
}


interface CredentialCreationOptions {
// Only in draft spec
mediation?: CredentialMediationRequirement
}


}
Expand Down

0 comments on commit 03c3eac

Please sign in to comment.