Skip to content

Commit

Permalink
Add autofill() method using promise-based API (#44)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Firehed authored Aug 7, 2024
1 parent b503cd5 commit f9212c3
Show file tree
Hide file tree
Showing 2 changed files with 42 additions and 36 deletions.
35 changes: 20 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,36 +117,41 @@ To take advantage of this, you need two things:
<input type="text" autocomplete="username webauthn" placeholder="Username" />
```

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
Expand Down
43 changes: 22 additions & 21 deletions src/SDK.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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<CredentialRequestOptionsJSON, WebAuthnError>
if (!res.ok) {
return res
return await this.doAuth(user)
}

async autofill(): Promise<AuthResponse> {
// TODO: warn if no <input autocomplete="webauthn"> 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<RegisterResponse> {
Expand Down Expand Up @@ -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 <input autocomplete="webauthn"> is found?
}

// Autofill API is available. Make the calls and set it up.
const res = await this.api('/assertion/options', {}) as Result<CredentialRequestOptionsJSON, WebAuthnError>
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>
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<AuthResponse> {
const signal = this.cancelExistingRequests()
try {
options.signal = signal
Expand Down

0 comments on commit f9212c3

Please sign in to comment.