Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add autofill() method using promise-based API #44

Merged
merged 5 commits into from
Aug 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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