Skip to content

Commit c44865c

Browse files
authored
Guard against concurrent passkey autofill requests (#85)
* guard against concurrent passkey autofill requests * bump version * Add console log for rp error * export webauthnerror
1 parent 1144588 commit c44865c

File tree

5 files changed

+165
-102
lines changed

5 files changed

+165
-102
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@authsignal/browser",
3-
"version": "1.3.0",
3+
"version": "1.4.0",
44
"type": "module",
55
"main": "dist/index.js",
66
"module": "dist/index.js",

src/helpers.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {WebAuthnError} from "@simplewebauthn/browser";
12
import {ErrorResponse} from "./api/types/shared";
23

34
type CookieOptions = {
@@ -79,3 +80,13 @@ export function handleApiResponse<T>(response: ErrorResponse | T) {
7980
};
8081
}
8182
}
83+
84+
export function handleWebAuthnError(error: unknown) {
85+
if (error instanceof WebAuthnError && error.code === "ERROR_INVALID_RP_ID") {
86+
const rpId = error.message?.match(/"([^"]*)"/)?.[1] || "";
87+
88+
console.error(
89+
`[Authsignal] The Relying Party ID "${rpId}" is invalid for this domain.\n To learn more, visit https://docs.authsignal.com/scenarios/passkeys-prebuilt-ui#defining-the-relying-party`
90+
);
91+
}
92+
}

src/passkey.ts

Lines changed: 94 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {startAuthentication, startRegistration} from "@simplewebauthn/browser";
33
import {PasskeyApiClient} from "./api";
44
import {AuthenticationResponseJSON, RegistrationResponseJSON, AuthenticatorAttachment} from "@simplewebauthn/types";
55
import {TokenCache} from "./token-cache";
6-
import {handleErrorResponse} from "./helpers";
6+
import {handleErrorResponse, handleWebAuthnError} from "./helpers";
77
import {AuthsignalResponse} from "./types";
88

99
type PasskeyOptions = {
@@ -43,6 +43,8 @@ type SignInResponse = {
4343
authenticationResponse?: AuthenticationResponseJSON;
4444
};
4545

46+
let autofillRequestPending = false;
47+
4648
export class Passkey {
4749
public api: PasskeyApiClient;
4850
private passkeyLocalStorageKey = "as_user_passkey_map";
@@ -80,35 +82,43 @@ export class Passkey {
8082
return handleErrorResponse(optionsResponse);
8183
}
8284

83-
const registrationResponse = await startRegistration({optionsJSON: optionsResponse.options, useAutoRegister});
85+
try {
86+
const registrationResponse = await startRegistration({optionsJSON: optionsResponse.options, useAutoRegister});
8487

85-
const addAuthenticatorResponse = await this.api.addAuthenticator({
86-
challengeId: optionsResponse.challengeId,
87-
registrationCredential: registrationResponse,
88-
token: userToken,
89-
});
88+
const addAuthenticatorResponse = await this.api.addAuthenticator({
89+
challengeId: optionsResponse.challengeId,
90+
registrationCredential: registrationResponse,
91+
token: userToken,
92+
});
9093

91-
if ("error" in addAuthenticatorResponse) {
92-
return handleErrorResponse(addAuthenticatorResponse);
93-
}
94+
if ("error" in addAuthenticatorResponse) {
95+
return handleErrorResponse(addAuthenticatorResponse);
96+
}
9497

95-
if (addAuthenticatorResponse.isVerified) {
96-
this.storeCredentialAgainstDevice({
97-
...registrationResponse,
98-
userId: addAuthenticatorResponse.userId,
99-
});
100-
}
98+
if (addAuthenticatorResponse.isVerified) {
99+
this.storeCredentialAgainstDevice({
100+
...registrationResponse,
101+
userId: addAuthenticatorResponse.userId,
102+
});
103+
}
101104

102-
if (addAuthenticatorResponse.accessToken) {
103-
this.cache.token = addAuthenticatorResponse.accessToken;
104-
}
105+
if (addAuthenticatorResponse.accessToken) {
106+
this.cache.token = addAuthenticatorResponse.accessToken;
107+
}
105108

106-
return {
107-
data: {
108-
token: addAuthenticatorResponse.accessToken,
109-
registrationResponse,
110-
},
111-
};
109+
return {
110+
data: {
111+
token: addAuthenticatorResponse.accessToken,
112+
registrationResponse,
113+
},
114+
};
115+
} catch (e) {
116+
autofillRequestPending = false;
117+
118+
handleWebAuthnError(e);
119+
120+
throw e;
121+
}
112122
}
113123

114124
async signIn(params?: SignInParams): Promise<AuthsignalResponse<SignInResponse>> {
@@ -120,9 +130,19 @@ export class Passkey {
120130
throw new Error("action is not supported when providing a token");
121131
}
122132

133+
if (params?.autofill) {
134+
if (autofillRequestPending) {
135+
return {};
136+
} else {
137+
autofillRequestPending = true;
138+
}
139+
}
140+
123141
const challengeResponse = params?.action ? await this.api.challenge(params.action) : null;
124142

125143
if (challengeResponse && "error" in challengeResponse) {
144+
autofillRequestPending = false;
145+
126146
return handleErrorResponse(challengeResponse);
127147
}
128148

@@ -132,50 +152,64 @@ export class Passkey {
132152
});
133153

134154
if ("error" in optionsResponse) {
155+
autofillRequestPending = false;
156+
135157
return handleErrorResponse(optionsResponse);
136158
}
137159

138-
const authenticationResponse = await startAuthentication({
139-
optionsJSON: optionsResponse.options,
140-
useBrowserAutofill: params?.autofill,
141-
});
160+
try {
161+
const authenticationResponse = await startAuthentication({
162+
optionsJSON: optionsResponse.options,
163+
useBrowserAutofill: params?.autofill,
164+
});
142165

143-
if (params?.onVerificationStarted) {
144-
params.onVerificationStarted();
145-
}
166+
if (params?.onVerificationStarted) {
167+
params.onVerificationStarted();
168+
}
146169

147-
const verifyResponse = await this.api.verify({
148-
challengeId: optionsResponse.challengeId,
149-
authenticationCredential: authenticationResponse,
150-
token: params?.token,
151-
deviceId: this.anonymousId,
152-
});
170+
const verifyResponse = await this.api.verify({
171+
challengeId: optionsResponse.challengeId,
172+
authenticationCredential: authenticationResponse,
173+
token: params?.token,
174+
deviceId: this.anonymousId,
175+
});
153176

154-
if ("error" in verifyResponse) {
155-
return handleErrorResponse(verifyResponse);
156-
}
177+
if ("error" in verifyResponse) {
178+
autofillRequestPending = false;
157179

158-
if (verifyResponse.isVerified) {
159-
this.storeCredentialAgainstDevice({...authenticationResponse, userId: verifyResponse.userId});
160-
}
180+
return handleErrorResponse(verifyResponse);
181+
}
161182

162-
if (verifyResponse.accessToken) {
163-
this.cache.token = verifyResponse.accessToken;
164-
}
183+
if (verifyResponse.isVerified) {
184+
this.storeCredentialAgainstDevice({...authenticationResponse, userId: verifyResponse.userId});
185+
}
165186

166-
const {accessToken: token, userId, userAuthenticatorId, username, userDisplayName, isVerified} = verifyResponse;
167-
168-
return {
169-
data: {
170-
isVerified,
171-
token,
172-
userId,
173-
userAuthenticatorId,
174-
username,
175-
displayName: userDisplayName,
176-
authenticationResponse,
177-
},
178-
};
187+
if (verifyResponse.accessToken) {
188+
this.cache.token = verifyResponse.accessToken;
189+
}
190+
191+
const {accessToken: token, userId, userAuthenticatorId, username, userDisplayName, isVerified} = verifyResponse;
192+
193+
autofillRequestPending = false;
194+
195+
return {
196+
data: {
197+
isVerified,
198+
token,
199+
userId,
200+
userAuthenticatorId,
201+
username,
202+
displayName: userDisplayName,
203+
authenticationResponse,
204+
},
205+
};
206+
} catch (e) {
207+
autofillRequestPending = false;
208+
209+
handleWebAuthnError(e);
210+
211+
throw e;
212+
}
179213
}
180214

181215
async isAvailableOnDevice({userId}: {userId: string}) {

src/security-key.ts

Lines changed: 55 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {startAuthentication, startRegistration} from "@simplewebauthn/browser";
22

33
import {AuthenticationResponseJSON, RegistrationResponseJSON} from "@simplewebauthn/types";
44
import {TokenCache} from "./token-cache";
5-
import {handleErrorResponse} from "./helpers";
5+
import {handleErrorResponse, handleWebAuthnError} from "./helpers";
66
import {AuthsignalResponse} from "./types";
77
import {SecurityKeyApiClient} from "./api/security-key-api-client";
88

@@ -46,27 +46,33 @@ export class SecurityKey {
4646
return handleErrorResponse(optionsResponse);
4747
}
4848

49-
const registrationResponse = await startRegistration({optionsJSON: optionsResponse});
49+
try {
50+
const registrationResponse = await startRegistration({optionsJSON: optionsResponse});
5051

51-
const addAuthenticatorResponse = await this.api.addAuthenticator({
52-
registrationCredential: registrationResponse,
53-
token: this.cache.token,
54-
});
52+
const addAuthenticatorResponse = await this.api.addAuthenticator({
53+
registrationCredential: registrationResponse,
54+
token: this.cache.token,
55+
});
5556

56-
if ("error" in addAuthenticatorResponse) {
57-
return handleErrorResponse(addAuthenticatorResponse);
58-
}
57+
if ("error" in addAuthenticatorResponse) {
58+
return handleErrorResponse(addAuthenticatorResponse);
59+
}
5960

60-
if (addAuthenticatorResponse.accessToken) {
61-
this.cache.token = addAuthenticatorResponse.accessToken;
62-
}
61+
if (addAuthenticatorResponse.accessToken) {
62+
this.cache.token = addAuthenticatorResponse.accessToken;
63+
}
6364

64-
return {
65-
data: {
66-
token: addAuthenticatorResponse.accessToken,
67-
registrationResponse,
68-
},
69-
};
65+
return {
66+
data: {
67+
token: addAuthenticatorResponse.accessToken,
68+
registrationResponse,
69+
},
70+
};
71+
} catch (e) {
72+
handleWebAuthnError(e);
73+
74+
throw e;
75+
}
7076
}
7177

7278
async verify(): Promise<AuthsignalResponse<VerifyResponse>> {
@@ -82,29 +88,37 @@ export class SecurityKey {
8288
return handleErrorResponse(optionsResponse);
8389
}
8490

85-
const authenticationResponse = await startAuthentication({optionsJSON: optionsResponse});
86-
87-
const verifyResponse = await this.api.verify({
88-
authenticationCredential: authenticationResponse,
89-
token: this.cache.token,
90-
});
91-
92-
if ("error" in verifyResponse) {
93-
return handleErrorResponse(verifyResponse);
94-
}
95-
96-
if (verifyResponse.accessToken) {
97-
this.cache.token = verifyResponse.accessToken;
91+
try {
92+
const authenticationResponse = await startAuthentication({
93+
optionsJSON: optionsResponse,
94+
});
95+
96+
const verifyResponse = await this.api.verify({
97+
authenticationCredential: authenticationResponse,
98+
token: this.cache.token,
99+
});
100+
101+
if ("error" in verifyResponse) {
102+
return handleErrorResponse(verifyResponse);
103+
}
104+
105+
if (verifyResponse.accessToken) {
106+
this.cache.token = verifyResponse.accessToken;
107+
}
108+
109+
const {accessToken: token, isVerified} = verifyResponse;
110+
111+
return {
112+
data: {
113+
isVerified,
114+
token,
115+
authenticationResponse,
116+
},
117+
};
118+
} catch (e) {
119+
handleWebAuthnError(e);
120+
121+
throw e;
98122
}
99-
100-
const {accessToken: token, isVerified} = verifyResponse;
101-
102-
return {
103-
data: {
104-
isVerified,
105-
token,
106-
authenticationResponse,
107-
},
108-
};
109123
}
110124
}

src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import {WebAuthnError} from "@simplewebauthn/browser";
2+
13
type BaseLaunchOptions = {
24
/**
35
* How the Authsignal Prebuilt MFA page should launch.
@@ -103,3 +105,5 @@ export type CheckVerificationStatusResponse = {
103105
isVerified: boolean;
104106
token?: string;
105107
};
108+
109+
export {WebAuthnError};

0 commit comments

Comments
 (0)