Skip to content

Commit e474353

Browse files
committed
feat: add APIs for listing and removing WebAuthn credentials
1 parent 15a77a8 commit e474353

File tree

5 files changed

+214
-28
lines changed

5 files changed

+214
-28
lines changed

lib/ts/recipe/webauthn/api/implementation.ts

Lines changed: 52 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ export default function getAPIImplementation(): APIInterface {
174174
// here to be on the safe side.
175175
if (!email) {
176176
throw new Error(
177-
"Should never come here since we already check that the email value is a string in validateEmailAddress"
177+
"Should never come here since we already check that the email value is a string in validateEmailAddress",
178178
);
179179
}
180180

@@ -208,7 +208,7 @@ export default function getAPIImplementation(): APIInterface {
208208

209209
if (
210210
conflictingUsers.some((u) =>
211-
u.loginMethods.some((lm) => lm.recipeId === "webauthn" && lm.hasSameEmailAs(email))
211+
u.loginMethods.some((lm) => lm.recipeId === "webauthn" && lm.hasSameEmailAs(email)),
212212
)
213213
) {
214214
return {
@@ -355,7 +355,7 @@ export default function getAPIImplementation(): APIInterface {
355355

356356
// we find the email of the user that has the same credentialId as the one we are verifying
357357
const email = authenticatingUser.user.loginMethods.find(
358-
(lm) => lm.recipeId === "webauthn" && lm.webauthn?.credentialIds.includes(credential.id)
358+
(lm) => lm.recipeId === "webauthn" && lm.webauthn?.credentialIds.includes(credential.id),
359359
)?.email;
360360
if (email === undefined) {
361361
throw new Error("This should never happen: webauthn user has no email");
@@ -472,14 +472,14 @@ export default function getAPIImplementation(): APIInterface {
472472
// in validation but kept here to be safe.
473473
if (typeof email !== "string") {
474474
throw new Error(
475-
"Should never come here since we already check that the email value is a string in validateFormFieldsOrThrowError"
475+
"Should never come here since we already check that the email value is a string in validateFormFieldsOrThrowError",
476476
);
477477
}
478478

479479
// this function will be reused in different parts of the flow below..
480480
async function generateAndSendRecoverAccountToken(
481481
primaryUserId: string,
482-
recipeUserId: RecipeUserId | undefined
482+
recipeUserId: RecipeUserId | undefined,
483483
): Promise<{
484484
status: "OK";
485485
}> {
@@ -495,7 +495,7 @@ export default function getAPIImplementation(): APIInterface {
495495
logDebugMessage(
496496
`Recover account email not sent, unknown user id: ${
497497
recipeUserId === undefined ? primaryUserId : recipeUserId.getAsString()
498-
}`
498+
}`,
499499
);
500500
return {
501501
status: "OK",
@@ -543,7 +543,7 @@ export default function getAPIImplementation(): APIInterface {
543543
let webauthnAccount: RecipeLevelUser | undefined = undefined;
544544
for (let i = 0; i < users.length; i++) {
545545
const webauthnAccountTmp = users[i].loginMethods.find(
546-
(l) => l.recipeId === "webauthn" && l.hasSameEmailAs(email)
546+
(l) => l.recipeId === "webauthn" && l.hasSameEmailAs(email),
547547
);
548548
if (webauthnAccountTmp !== undefined) {
549549
webauthnAccount = webauthnAccountTmp;
@@ -565,7 +565,7 @@ export default function getAPIImplementation(): APIInterface {
565565
}
566566
return await generateAndSendRecoverAccountToken(
567567
webauthnAccount.recipeUserId.getAsString(),
568-
webauthnAccount.recipeUserId
568+
webauthnAccount.recipeUserId,
569569
);
570570
}
571571

@@ -607,7 +607,7 @@ export default function getAPIImplementation(): APIInterface {
607607
primaryUserAssociatedWithEmail,
608608
undefined,
609609
tenantId,
610-
userContext
610+
userContext,
611611
);
612612

613613
// Now we need to check that if there exists any webauthn user at all
@@ -625,7 +625,7 @@ export default function getAPIImplementation(): APIInterface {
625625
// not generate a recover account reset token
626626
if (!shouldDoAccountLinkingResponse.shouldAutomaticallyLink) {
627627
logDebugMessage(
628-
`Recover account email not sent, since webauthn user didn't exist, and account linking not enabled`
628+
`Recover account email not sent, since webauthn user didn't exist, and account linking not enabled`,
629629
);
630630
return {
631631
status: "OK",
@@ -649,7 +649,7 @@ export default function getAPIImplementation(): APIInterface {
649649
return await generateAndSendRecoverAccountToken(primaryUserAssociatedWithEmail.id, undefined);
650650
} else {
651651
logDebugMessage(
652-
`Recover account email not sent, isSignUpAllowed returned false for email: ${email}`
652+
`Recover account email not sent, isSignUpAllowed returned false for email: ${email}`,
653653
);
654654
return {
655655
status: "OK",
@@ -669,7 +669,7 @@ export default function getAPIImplementation(): APIInterface {
669669
if (areTheTwoAccountsLinked) {
670670
return await generateAndSendRecoverAccountToken(
671671
primaryUserAssociatedWithEmail.id,
672-
webauthnAccount.recipeUserId
672+
webauthnAccount.recipeUserId,
673673
);
674674
}
675675

@@ -699,7 +699,7 @@ export default function getAPIImplementation(): APIInterface {
699699
// so no need to check for anything
700700
return await generateAndSendRecoverAccountToken(
701701
webauthnAccount.recipeUserId.getAsString(),
702-
webauthnAccount.recipeUserId
702+
webauthnAccount.recipeUserId,
703703
);
704704
}
705705

@@ -708,13 +708,13 @@ export default function getAPIImplementation(): APIInterface {
708708
// does not care about that, then we should just continue with token generation
709709
return await generateAndSendRecoverAccountToken(
710710
primaryUserAssociatedWithEmail.id,
711-
webauthnAccount.recipeUserId
711+
webauthnAccount.recipeUserId,
712712
);
713713
}
714714

715715
return await generateAndSendRecoverAccountToken(
716716
primaryUserAssociatedWithEmail.id,
717-
webauthnAccount.recipeUserId
717+
webauthnAccount.recipeUserId,
718718
);
719719
},
720720

@@ -751,7 +751,7 @@ export default function getAPIImplementation(): APIInterface {
751751
}
752752

753753
async function doRegisterCredentialAndVerifyEmailAndTryLinkIfNotPrimary(
754-
recipeUserId: RecipeUserId
754+
recipeUserId: RecipeUserId,
755755
): Promise<
756756
| {
757757
status: "OK";
@@ -891,7 +891,7 @@ export default function getAPIImplementation(): APIInterface {
891891

892892
if (webauthnUserIsLinkedToExistingUser) {
893893
return doRegisterCredentialAndVerifyEmailAndTryLinkIfNotPrimary(
894-
new RecipeUserId(userIdForWhomTokenWasGenerated)
894+
new RecipeUserId(userIdForWhomTokenWasGenerated),
895895
);
896896
} else {
897897
// this means that the existingUser does not have an webauthn user associated
@@ -937,7 +937,7 @@ export default function getAPIImplementation(): APIInterface {
937937
// as verified.
938938
await markEmailAsVerified(
939939
createUserResponse.user.loginMethods[0].recipeUserId,
940-
tokenConsumptionResponse.email
940+
tokenConsumptionResponse.email,
941941
);
942942
const updatedUser = await getUser(createUserResponse.user.id, userContext);
943943
if (updatedUser === undefined) {
@@ -978,11 +978,31 @@ export default function getAPIImplementation(): APIInterface {
978978
// Linking to an existing account will be done after the user goes through the email
979979
// verification flow once they log in (if applicable).
980980
return doRegisterCredentialAndVerifyEmailAndTryLinkIfNotPrimary(
981-
new RecipeUserId(userIdForWhomTokenWasGenerated)
981+
new RecipeUserId(userIdForWhomTokenWasGenerated),
982982
);
983983
}
984984
},
985985

986+
listCredentialsGET: async function ({ options, userContext, session }) {
987+
const credentials = await options.recipeImplementation.listCredentials({
988+
recipeUserId: session.getRecipeUserId().getAsString(),
989+
userContext,
990+
});
991+
if (credentials.status !== "OK") {
992+
// TODO BETTER ERROR HANDLING
993+
return { status: "GENERAL_ERROR", message: "Internal server error" };
994+
}
995+
996+
return {
997+
status: "OK",
998+
credentials: credentials.credentials.map((credential) => ({
999+
webauthnCredentialId: credential.webauthnCredentialId,
1000+
relyingPartyId: credential.relyingPartyId,
1001+
createdAt: credential.createdAt,
1002+
})),
1003+
};
1004+
},
1005+
9861006
registerCredentialPOST: async function ({
9871007
webauthnGeneratedOptionsId,
9881008
credential,
@@ -1016,7 +1036,7 @@ export default function getAPIImplementation(): APIInterface {
10161036
// here to be on the safe side.
10171037
if (!email) {
10181038
throw new Error(
1019-
"Should never come here since we already check that the email value is a string in validateEmailAddress"
1039+
"Should never come here since we already check that the email value is a string in validateEmailAddress",
10201040
);
10211041
}
10221042

@@ -1032,13 +1052,24 @@ export default function getAPIImplementation(): APIInterface {
10321052
return AuthUtils.getErrorStatusResponseWithReason(
10331053
registerCredentialResponse,
10341054
errorCodeMap,
1035-
"REGISTER_CREDENTIAL_NOT_ALLOWED"
1055+
"REGISTER_CREDENTIAL_NOT_ALLOWED",
10361056
);
10371057
}
10381058

10391059
return {
10401060
status: "OK",
10411061
};
10421062
},
1063+
removeCredentialPOST: async function ({ webauthnCredentialId, options, userContext, session }) {
1064+
const removeCredentialResponse = await options.recipeImplementation.removeCredential({
1065+
webauthnCredentialId,
1066+
recipeUserId: session.getRecipeUserId().getAsString(),
1067+
userContext,
1068+
});
1069+
if (removeCredentialResponse.status !== "OK") {
1070+
return removeCredentialResponse;
1071+
}
1072+
return { status: "OK" };
1073+
},
10431074
};
10441075
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/* Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved.
2+
*
3+
* This software is licensed under the Apache License, Version 2.0 (the
4+
* "License") as published by the Apache Software Foundation.
5+
*
6+
* You may not use this file except in compliance with the License. You may
7+
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12+
* License for the specific language governing permissions and limitations
13+
* under the License.
14+
*/
15+
16+
import { send200Response } from "../../../utils";
17+
import { APIInterface, APIOptions } from "..";
18+
import STError from "../error";
19+
import { UserContext } from "../../../types";
20+
import { AuthUtils } from "../../../authUtils";
21+
22+
export default async function listCredentialsAPI(
23+
apiImplementation: APIInterface,
24+
tenantId: string,
25+
options: APIOptions,
26+
userContext: UserContext,
27+
): Promise<boolean> {
28+
if (apiImplementation.listCredentialsGET === undefined) {
29+
return false;
30+
}
31+
32+
const session = await AuthUtils.loadSessionInAuthAPIIfNeeded(options.req, options.res, undefined, userContext);
33+
if (session === undefined) {
34+
throw new STError({
35+
type: STError.BAD_INPUT_ERROR,
36+
message: "A valid session is required to register a credential",
37+
});
38+
}
39+
40+
const result = await apiImplementation.listCredentialsGET({
41+
options,
42+
userContext: userContext,
43+
session,
44+
});
45+
46+
if (result.status === "OK") {
47+
send200Response(options.res, {
48+
status: "OK",
49+
});
50+
} else {
51+
send200Response(options.res, result);
52+
}
53+
54+
return true;
55+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/* Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved.
2+
*
3+
* This software is licensed under the Apache License, Version 2.0 (the
4+
* "License") as published by the Apache Software Foundation.
5+
*
6+
* You may not use this file except in compliance with the License. You may
7+
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12+
* License for the specific language governing permissions and limitations
13+
* under the License.
14+
*/
15+
16+
import { send200Response } from "../../../utils";
17+
import { APIInterface, APIOptions } from "..";
18+
import STError from "../error";
19+
import { UserContext } from "../../../types";
20+
import { AuthUtils } from "../../../authUtils";
21+
22+
export default async function removeCredentialAPI(
23+
apiImplementation: APIInterface,
24+
tenantId: string,
25+
options: APIOptions,
26+
userContext: UserContext,
27+
): Promise<boolean> {
28+
if (apiImplementation.removeCredentialPOST === undefined) {
29+
return false;
30+
}
31+
32+
const requestBody = await options.req.getJSONBody();
33+
const webauthnCredentialId = requestBody.webauthnCredentialId;
34+
if (webauthnCredentialId === undefined) {
35+
throw new STError({
36+
type: STError.BAD_INPUT_ERROR,
37+
message: "A valid webauthnCredentialId is required",
38+
});
39+
}
40+
41+
const session = await AuthUtils.loadSessionInAuthAPIIfNeeded(options.req, options.res, undefined, userContext);
42+
if (session === undefined) {
43+
throw new STError({
44+
type: STError.BAD_INPUT_ERROR,
45+
message: "A valid session is required to register a credential",
46+
});
47+
}
48+
49+
const result = await apiImplementation.removeCredentialPOST({
50+
webauthnCredentialId,
51+
options,
52+
userContext: userContext,
53+
session,
54+
});
55+
56+
if (result.status === "OK") {
57+
send200Response(options.res, {
58+
status: "OK",
59+
});
60+
} else {
61+
send200Response(options.res, result);
62+
}
63+
64+
return true;
65+
}

lib/ts/recipe/webauthn/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ export const RECOVER_ACCOUNT_API = "/user/webauthn/reset";
2727

2828
export const SIGNUP_EMAIL_EXISTS_API = "/webauthn/email/exists";
2929

30+
export const LIST_CREDENTIALS_API = "/webauthn/credentials";
31+
32+
export const REMOVE_CREDENTIAL_API = "/webauthn/credentials/remove";
33+
3034
// 60 seconds (60 * 1000ms)
3135
export const DEFAULT_REGISTER_OPTIONS_TIMEOUT = 60000;
3236
export const DEFAULT_REGISTER_OPTIONS_ATTESTATION = "none";

0 commit comments

Comments
 (0)