diff --git a/lib/build/recipe/webauthn/core-mock.d.ts b/lib/build/core-mock.d.ts similarity index 66% rename from lib/build/recipe/webauthn/core-mock.d.ts rename to lib/build/core-mock.d.ts index 0bb5666c4..8471fde1a 100644 --- a/lib/build/recipe/webauthn/core-mock.d.ts +++ b/lib/build/core-mock.d.ts @@ -1,3 +1,3 @@ // @ts-nocheck -import { Querier } from "../../querier"; +import { Querier } from "./querier"; export declare const getMockQuerier: (recipeId: string) => Querier; diff --git a/lib/build/core-mock.js b/lib/build/core-mock.js new file mode 100644 index 000000000..9a511d842 --- /dev/null +++ b/lib/build/core-mock.js @@ -0,0 +1,217 @@ +"use strict"; +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getMockQuerier = void 0; +const querier_1 = require("./querier"); +const server_1 = require("@simplewebauthn/server"); +const crypto_1 = __importDefault(require("crypto")); +const db = { + generatedOptions: {}, + credentials: {}, + users: {}, +}; +const writeDb = (table, key, value) => { + db[table][key] = value; +}; +const readDb = (table, key) => { + return db[table][key]; +}; +// const readDbBy = (table: keyof typeof db, func: (value: any) => boolean) => { +// return Object.values(db[table]).find(func); +// }; +const getMockQuerier = (recipeId) => { + const querier = querier_1.Querier.getNewInstanceOrThrowError(recipeId); + const sendPostRequest = async (path, body, _userContext) => { + var _a, _b; + if (path.getAsStringDangerous().includes("/recipe/webauthn/options/register")) { + const registrationOptions = await server_1.generateRegistrationOptions({ + rpID: body.relyingPartyId, + rpName: body.relyingPartyName, + userName: body.email, + timeout: body.timeout, + attestationType: body.attestation || "none", + authenticatorSelection: { + userVerification: body.userVerification || "preferred", + requireResidentKey: body.requireResidentKey || false, + residentKey: body.residentKey || "required", + }, + supportedAlgorithmIDs: body.supportedAlgorithmIDs || [-8, -7, -257], + userDisplayName: body.displayName || body.email, + }); + const id = crypto_1.default.randomUUID(); + const now = new Date(); + writeDb( + "generatedOptions", + id, + Object.assign(Object.assign({}, registrationOptions), { + id, + origin: body.origin, + tenantId: body.tenantId, + email: body.email, + rpId: registrationOptions.rp.id, + createdAt: now.getTime(), + expiresAt: now.getTime() + body.timeout * 1000, + }) + ); + // @ts-ignore + return Object.assign({ status: "OK", webauthnGeneratedOptionsId: id }, registrationOptions); + } else if (path.getAsStringDangerous().includes("/recipe/webauthn/options/signin")) { + const signInOptions = await server_1.generateAuthenticationOptions({ + rpID: body.relyingPartyId, + timeout: body.timeout, + userVerification: body.userVerification || "preferred", + }); + const id = crypto_1.default.randomUUID(); + const now = new Date(); + writeDb( + "generatedOptions", + id, + Object.assign(Object.assign({}, signInOptions), { + id, + origin: body.origin, + tenantId: body.tenantId, + email: body.email, + createdAt: now.getTime(), + expiresAt: now.getTime() + body.timeout * 1000, + }) + ); + // @ts-ignore + return Object.assign({ status: "OK", webauthnGeneratedOptionsId: id }, signInOptions); + } else if (path.getAsStringDangerous().includes("/recipe/webauthn/signup")) { + const options = readDb("generatedOptions", body.webauthnGeneratedOptionsId); + if (!options) { + // @ts-ignore + return { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" }; + } + const registrationVerification = await server_1.verifyRegistrationResponse({ + expectedChallenge: options.challenge, + expectedOrigin: options.origin, + expectedRPID: options.rpId, + response: body.credential, + }); + if (!registrationVerification.verified) { + // @ts-ignore + return { status: "INVALID_CREDENTIALS_ERROR" }; + } + const credentialId = body.credential.id; + if (!credentialId) { + // @ts-ignore + return { status: "INVALID_CREDENTIALS_ERROR" }; + } + const recipeUserId = crypto_1.default.randomUUID(); + const now = new Date(); + writeDb("credentials", credentialId, { + id: credentialId, + userId: recipeUserId, + counter: 0, + publicKey: + (_a = registrationVerification.registrationInfo) === null || _a === void 0 + ? void 0 + : _a.credential.publicKey.toString(), + rpId: options.rpId, + transports: + (_b = registrationVerification.registrationInfo) === null || _b === void 0 + ? void 0 + : _b.credential.transports, + createdAt: now.toISOString(), + }); + const user = { + id: recipeUserId, + timeJoined: now.getTime(), + isPrimaryUser: true, + tenantIds: [body.tenantId], + emails: [options.email], + phoneNumbers: [], + thirdParty: [], + webauthn: { + credentialIds: [credentialId], + }, + loginMethods: [ + { + recipeId: "webauthn", + recipeUserId, + tenantIds: [body.tenantId], + verified: true, + timeJoined: now.getTime(), + webauthn: { + credentialIds: [credentialId], + }, + email: options.email, + }, + ], + }; + writeDb("users", recipeUserId, user); + const response = { + status: "OK", + user: user, + recipeUserId, + }; + // @ts-ignore + return response; + } else if (path.getAsStringDangerous().includes("/recipe/webauthn/signin")) { + const options = readDb("generatedOptions", body.webauthnGeneratedOptionsId); + if (!options) { + // @ts-ignore + return { status: "INVALID_CREDENTIALS_ERROR" }; + } + const credentialId = body.credential.id; + const credential = readDb("credentials", credentialId); + if (!credential) { + // @ts-ignore + return { status: "INVALID_CREDENTIALS_ERROR" }; + } + const authenticationVerification = await server_1.verifyAuthenticationResponse({ + expectedChallenge: options.challenge, + expectedOrigin: options.origin, + expectedRPID: options.rpId, + response: body.credential, + credential: { + publicKey: new Uint8Array(credential.publicKey.split(",").map((byte) => parseInt(byte))), + transports: credential.transports, + counter: credential.counter, + id: credential.id, + }, + }); + if (!authenticationVerification.verified) { + // @ts-ignore + return { status: "INVALID_CREDENTIALS_ERROR" }; + } + const user = readDb("users", credential.userId); + if (!user) { + // @ts-ignore + return { status: "INVALID_CREDENTIALS_ERROR" }; + } + // @ts-ignore + return { + status: "OK", + user, + recipeUserId: user.id, + }; + } + throw new Error(`Unmocked endpoint: ${path}`); + }; + const sendGetRequest = async (path, _body, _userContext) => { + if (path.getAsStringDangerous().includes("/recipe/webauthn/options")) { + const webauthnGeneratedOptionsId = path.getAsStringDangerous().split("/").pop(); + if (!webauthnGeneratedOptionsId) { + // @ts-ignore + return { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" }; + } + const options = readDb("generatedOptions", webauthnGeneratedOptionsId); + if (!options) { + // @ts-ignore + return { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" }; + } + return Object.assign({ status: "OK" }, options); + } + throw new Error(`Unmocked endpoint: ${path}`); + }; + querier.sendPostRequest = sendPostRequest; + querier.sendGetRequest = sendGetRequest; + return querier; +}; +exports.getMockQuerier = getMockQuerier; diff --git a/lib/build/recipe/webauthn/api/implementation.js b/lib/build/recipe/webauthn/api/implementation.js index ed63c0ed8..412737cf8 100644 --- a/lib/build/recipe/webauthn/api/implementation.js +++ b/lib/build/recipe/webauthn/api/implementation.js @@ -354,7 +354,7 @@ function getAPIImplementation() { session, shouldTryLinkingWithSessionUser, }); - if (preAuthChecks.status === "SIGN_UP_NOT_ALLOWED") { + if (preAuthChecks.status === "SIGN_IN_NOT_ALLOWED") { throw new Error("This should never happen: pre-auth checks should not fail for sign in"); } if (preAuthChecks.status !== "OK") { diff --git a/lib/build/recipe/webauthn/api/signInOptions.js b/lib/build/recipe/webauthn/api/signInOptions.js index 25034546a..49f3445a5 100644 --- a/lib/build/recipe/webauthn/api/signInOptions.js +++ b/lib/build/recipe/webauthn/api/signInOptions.js @@ -13,13 +13,29 @@ * License for the specific language governing permissions and limitations * under the License. */ +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; Object.defineProperty(exports, "__esModule", { value: true }); const utils_1 = require("../../../utils"); +const error_1 = __importDefault(require("../error")); async function signInOptions(apiImplementation, tenantId, options, userContext) { + var _a; if (apiImplementation.signInOptionsPOST === undefined) { return false; } + const requestBody = await options.req.getJSONBody(); + let email = (_a = requestBody.email) === null || _a === void 0 ? void 0 : _a.trim(); + if (email === undefined || typeof email !== "string") { + throw new error_1.default({ + type: error_1.default.BAD_INPUT_ERROR, + message: "Please provide the email", + }); + } let result = await apiImplementation.signInOptionsPOST({ + email, tenantId, options, userContext, diff --git a/lib/build/recipe/webauthn/core-mock.js b/lib/build/recipe/webauthn/core-mock.js deleted file mode 100644 index c0ac30bd0..000000000 --- a/lib/build/recipe/webauthn/core-mock.js +++ /dev/null @@ -1,107 +0,0 @@ -"use strict"; -var __importDefault = - (this && this.__importDefault) || - function (mod) { - return mod && mod.__esModule ? mod : { default: mod }; - }; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.getMockQuerier = void 0; -const querier_1 = require("../../querier"); -const server_1 = require("@simplewebauthn/server"); -const crypto_1 = __importDefault(require("crypto")); -const db = { - generatedOptions: {}, -}; -const writeDb = (table, key, value) => { - db[table][key] = value; -}; -// const readDb = (table: keyof typeof db, key: string) => { -// return db[table][key]; -// }; -const getMockQuerier = (recipeId) => { - const querier = querier_1.Querier.getNewInstanceOrThrowError(recipeId); - const sendPostRequest = async (path, body, _userContext) => { - if (path.getAsStringDangerous().includes("/recipe/webauthn/options/register")) { - const registrationOptions = await server_1.generateRegistrationOptions({ - rpID: body.relyingPartyId, - rpName: body.relyingPartyName, - userName: body.email, - timeout: body.timeout, - attestationType: body.attestation || "none", - authenticatorSelection: { - userVerification: body.userVerification || "preferred", - requireResidentKey: body.requireResidentKey || false, - residentKey: body.residentKey || "required", - }, - supportedAlgorithmIDs: body.supportedAlgorithmIDs || [-8, -7, -257], - userDisplayName: body.displayName || body.email, - }); - const id = crypto_1.default.randomUUID(); - writeDb( - "generatedOptions", - id, - Object.assign(Object.assign({}, registrationOptions), { - id, - origin: body.origin, - tenantId: body.tenantId, - }) - ); - // @ts-ignore - return Object.assign({ status: "OK", webauthnGeneratedOptionsId: id }, registrationOptions); - } else if (path.getAsStringDangerous().includes("/recipe/webauthn/options/signin")) { - const signInOptions = await server_1.generateAuthenticationOptions({ - rpID: body.relyingPartyId, - timeout: body.timeout, - userVerification: body.userVerification || "preferred", - }); - const id = crypto_1.default.randomUUID(); - writeDb( - "generatedOptions", - id, - Object.assign(Object.assign({}, signInOptions), { id, origin: body.origin, tenantId: body.tenantId }) - ); - // @ts-ignore - return Object.assign({ status: "OK", webauthnGeneratedOptionsId: id }, signInOptions); - // } else if (path.getAsStringDangerous().includes("/recipe/webauthn/user/recover/token")) { - // // @ts-ignore - // return { - // status: "OK", - // token: "dummy-recover-token", - // }; - // } else if (path.getAsStringDangerous().includes("/recipe/webauthn/user/recover/token/consume")) { - // // @ts-ignore - // return { - // status: "OK", - // userId: "dummy-user-id", - // email: "user@example.com", - // }; - // } - } else if (path.getAsStringDangerous().includes("/recipe/webauthn/signup")) { - // @ts-ignore - return { - status: "OK", - user: { - id: "dummy-user-id", - email: "user@example.com", - timeJoined: Date.now(), - }, - recipeUserId: "dummy-recipe-user-id", - }; - } else if (path.getAsStringDangerous().includes("/recipe/webauthn/signin")) { - // @ts-ignore - return { - status: "OK", - user: { - id: "dummy-user-id", - email: "user@example.com", - timeJoined: Date.now(), - }, - recipeUserId: "dummy-recipe-user-id", - }; - } - throw new Error(`Unmocked endpoint: ${path}`); - }; - querier.sendPostRequest = sendPostRequest; - return querier; -}; -exports.getMockQuerier = getMockQuerier; diff --git a/lib/build/recipe/webauthn/index.d.ts b/lib/build/recipe/webauthn/index.d.ts index 10ece7871..154a2c439 100644 --- a/lib/build/recipe/webauthn/index.d.ts +++ b/lib/build/recipe/webauthn/index.d.ts @@ -107,7 +107,7 @@ export default class Wrapper { userContext, ...rest }: { - email?: string; + email: string; timeout?: number; userVerification?: UserVerification; tenantId?: string; @@ -134,6 +134,28 @@ export default class Wrapper { status: "INVALID_GENERATED_OPTIONS_ERROR"; } >; + static getGeneratedOptions({ + webauthnGeneratedOptionsId, + tenantId, + userContext, + }: { + webauthnGeneratedOptionsId: string; + tenantId?: string; + userContext?: Record; + }): Promise< + | { + status: "OK"; + id: string; + relyingPartyId: string; + origin: string; + email: string; + timeout: string; + challenge: string; + } + | { + status: "GENERATED_OPTIONS_NOT_FOUND_ERROR"; + } + >; static signUp({ tenantId, webauthnGeneratedOptionsId, @@ -386,3 +408,4 @@ export type { RecipeInterface, APIOptions, APIInterface }; export declare let createRecoverAccountLink: typeof Wrapper.createRecoverAccountLink; export declare let sendRecoverAccountEmail: typeof Wrapper.sendRecoverAccountEmail; export declare let sendEmail: typeof Wrapper.sendEmail; +export declare let getGeneratedOptions: typeof Wrapper.getGeneratedOptions; diff --git a/lib/build/recipe/webauthn/index.js b/lib/build/recipe/webauthn/index.js index 35d01c9e8..cd54ae5f2 100644 --- a/lib/build/recipe/webauthn/index.js +++ b/lib/build/recipe/webauthn/index.js @@ -30,7 +30,7 @@ var __importDefault = return mod && mod.__esModule ? mod : { default: mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.sendEmail = exports.sendRecoverAccountEmail = exports.createRecoverAccountLink = exports.registerCredential = exports.consumeRecoverAccountToken = exports.recoverAccount = exports.generateRecoverAccountToken = exports.verifyCredentials = exports.signIn = exports.signInOptions = exports.registerOptions = exports.Error = exports.init = void 0; +exports.getGeneratedOptions = exports.sendEmail = exports.sendRecoverAccountEmail = exports.createRecoverAccountLink = exports.registerCredential = exports.consumeRecoverAccountToken = exports.recoverAccount = exports.generateRecoverAccountToken = exports.verifyCredentials = exports.signIn = exports.signInOptions = exports.registerOptions = exports.Error = exports.init = void 0; const recipe_1 = __importDefault(require("./recipe")); const error_1 = __importDefault(require("./error")); const recipeUserId_1 = __importDefault(require("../../recipeUserId")); @@ -174,6 +174,13 @@ class Wrapper { userContext: utils_2.getUserContext(userContext), }); } + static getGeneratedOptions({ webauthnGeneratedOptionsId, tenantId = constants_1.DEFAULT_TENANT_ID, userContext }) { + return recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.getGeneratedOptions({ + webauthnGeneratedOptionsId, + tenantId, + userContext: utils_2.getUserContext(userContext), + }); + } static signUp({ tenantId = constants_1.DEFAULT_TENANT_ID, webauthnGeneratedOptionsId, @@ -356,3 +363,4 @@ exports.registerCredential = Wrapper.registerCredential; exports.createRecoverAccountLink = Wrapper.createRecoverAccountLink; exports.sendRecoverAccountEmail = Wrapper.sendRecoverAccountEmail; exports.sendEmail = Wrapper.sendEmail; +exports.getGeneratedOptions = Wrapper.getGeneratedOptions; diff --git a/lib/build/recipe/webauthn/recipe.js b/lib/build/recipe/webauthn/recipe.js index 6d76efb0d..1f6b7689d 100644 --- a/lib/build/recipe/webauthn/recipe.js +++ b/lib/build/recipe/webauthn/recipe.js @@ -41,7 +41,7 @@ const recipe_1 = __importDefault(require("../multifactorauth/recipe")); const recipe_2 = __importDefault(require("../multitenancy/recipe")); const utils_3 = require("../thirdparty/utils"); const multifactorauth_1 = require("../multifactorauth"); -const core_mock_1 = require("./core-mock"); +const core_mock_1 = require("../../core-mock"); class Recipe extends recipeModule_1.default { constructor(recipeId, appInfo, isInServerlessEnv, config, ingredients) { super(recipeId, appInfo); diff --git a/lib/build/recipe/webauthn/recipeImplementation.js b/lib/build/recipe/webauthn/recipeImplementation.js index dc345bee9..089433503 100644 --- a/lib/build/recipe/webauthn/recipeImplementation.js +++ b/lib/build/recipe/webauthn/recipeImplementation.js @@ -146,7 +146,7 @@ function getRecipeInterface(querier, getWebauthnConfig) { userContext ); }, - signInOptions: async function ({ relyingPartyId, origin, timeout, tenantId, userContext }) { + signInOptions: async function ({ relyingPartyId, origin, timeout, tenantId, userContext, email }) { // the input user ID can be a recipe or a primary user ID. return await querier.sendPostRequest( new normalisedURLPath_1.default( @@ -155,6 +155,7 @@ function getRecipeInterface(querier, getWebauthnConfig) { }/recipe/webauthn/options/signin` ), { + email, relyingPartyId, origin, timeout, diff --git a/lib/build/recipe/webauthn/types.d.ts b/lib/build/recipe/webauthn/types.d.ts index d58b6c2cb..86bde2d7a 100644 --- a/lib/build/recipe/webauthn/types.d.ts +++ b/lib/build/recipe/webauthn/types.d.ts @@ -142,7 +142,7 @@ export declare type RecipeInterface = { } >; signInOptions(input: { - email?: string; + email: string; relyingPartyId: string; origin: string; userVerification: UserVerification | undefined; @@ -541,7 +541,7 @@ export declare type APIInterface = { signInOptionsPOST: | undefined | ((input: { - email?: string; + email: string; tenantId: string; options: APIOptions; userContext: UserContext; diff --git a/lib/ts/core-mock.ts b/lib/ts/core-mock.ts new file mode 100644 index 000000000..20fca1520 --- /dev/null +++ b/lib/ts/core-mock.ts @@ -0,0 +1,248 @@ +import NormalisedURLPath from "./normalisedURLPath"; +import { Querier } from "./querier"; +import { UserContext } from "./types"; +import { + generateAuthenticationOptions, + generateRegistrationOptions, + verifyAuthenticationResponse, + verifyRegistrationResponse, +} from "@simplewebauthn/server"; +import crypto from "crypto"; + +const db = { + generatedOptions: {} as Record, + credentials: {} as Record, + users: {} as Record, +}; +const writeDb = (table: keyof typeof db, key: string, value: any) => { + db[table][key] = value; +}; +const readDb = (table: keyof typeof db, key: string) => { + return db[table][key]; +}; +// const readDbBy = (table: keyof typeof db, func: (value: any) => boolean) => { +// return Object.values(db[table]).find(func); +// }; + +export const getMockQuerier = (recipeId: string) => { + const querier = Querier.getNewInstanceOrThrowError(recipeId); + + const sendPostRequest = async ( + path: NormalisedURLPath, + body: any, + _userContext: UserContext + ): Promise => { + if (path.getAsStringDangerous().includes("/recipe/webauthn/options/register")) { + const registrationOptions = await generateRegistrationOptions({ + rpID: body.relyingPartyId, + rpName: body.relyingPartyName, + userName: body.email, + timeout: body.timeout, + attestationType: body.attestation || "none", + authenticatorSelection: { + userVerification: body.userVerification || "preferred", + requireResidentKey: body.requireResidentKey || false, + residentKey: body.residentKey || "required", + }, + supportedAlgorithmIDs: body.supportedAlgorithmIDs || [-8, -7, -257], + userDisplayName: body.displayName || body.email, + }); + + const id = crypto.randomUUID(); + const now = new Date(); + + writeDb("generatedOptions", id, { + ...registrationOptions, + id, + origin: body.origin, + tenantId: body.tenantId, + email: body.email, + rpId: registrationOptions.rp.id, + createdAt: now.getTime(), + expiresAt: now.getTime() + body.timeout * 1000, + }); + + // @ts-ignore + return { + status: "OK", + webauthnGeneratedOptionsId: id, + ...registrationOptions, + }; + } else if (path.getAsStringDangerous().includes("/recipe/webauthn/options/signin")) { + const signInOptions = await generateAuthenticationOptions({ + rpID: body.relyingPartyId, + timeout: body.timeout, + userVerification: body.userVerification || "preferred", + }); + + const id = crypto.randomUUID(); + const now = new Date(); + + writeDb("generatedOptions", id, { + ...signInOptions, + id, + origin: body.origin, + tenantId: body.tenantId, + email: body.email, + createdAt: now.getTime(), + expiresAt: now.getTime() + body.timeout * 1000, + }); + + // @ts-ignore + return { + status: "OK", + webauthnGeneratedOptionsId: id, + ...signInOptions, + }; + } else if (path.getAsStringDangerous().includes("/recipe/webauthn/signup")) { + const options = readDb("generatedOptions", body.webauthnGeneratedOptionsId); + if (!options) { + // @ts-ignore + return { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" }; + } + + const registrationVerification = await verifyRegistrationResponse({ + expectedChallenge: options.challenge, + expectedOrigin: options.origin, + expectedRPID: options.rpId, + response: body.credential, + }); + + if (!registrationVerification.verified) { + // @ts-ignore + return { status: "INVALID_CREDENTIALS_ERROR" }; + } + + const credentialId = body.credential.id; + if (!credentialId) { + // @ts-ignore + return { status: "INVALID_CREDENTIALS_ERROR" }; + } + + const recipeUserId = crypto.randomUUID(); + const now = new Date(); + + writeDb("credentials", credentialId, { + id: credentialId, + userId: recipeUserId, + counter: 0, + publicKey: registrationVerification.registrationInfo?.credential.publicKey.toString(), + rpId: options.rpId, + transports: registrationVerification.registrationInfo?.credential.transports, + createdAt: now.toISOString(), + }); + + const user = { + id: recipeUserId, + timeJoined: now.getTime(), + isPrimaryUser: true, + tenantIds: [body.tenantId], + emails: [options.email], + phoneNumbers: [], + thirdParty: [], + webauthn: { + credentialIds: [credentialId], + }, + loginMethods: [ + { + recipeId: "webauthn", + recipeUserId, + tenantIds: [body.tenantId], + verified: true, + timeJoined: now.getTime(), + webauthn: { + credentialIds: [credentialId], + }, + email: options.email, + }, + ], + }; + writeDb("users", recipeUserId, user); + + const response = { + status: "OK", + user: user, + recipeUserId, + }; + + // @ts-ignore + return response; + } else if (path.getAsStringDangerous().includes("/recipe/webauthn/signin")) { + const options = readDb("generatedOptions", body.webauthnGeneratedOptionsId); + if (!options) { + // @ts-ignore + return { status: "INVALID_CREDENTIALS_ERROR" }; + } + + const credentialId = body.credential.id; + const credential = readDb("credentials", credentialId); + if (!credential) { + // @ts-ignore + return { status: "INVALID_CREDENTIALS_ERROR" }; + } + + const authenticationVerification = await verifyAuthenticationResponse({ + expectedChallenge: options.challenge, + expectedOrigin: options.origin, + expectedRPID: options.rpId, + response: body.credential, + credential: { + publicKey: new Uint8Array(credential.publicKey.split(",").map((byte: string) => parseInt(byte))), + transports: credential.transports, + counter: credential.counter, + id: credential.id, + }, + }); + + if (!authenticationVerification.verified) { + // @ts-ignore + return { status: "INVALID_CREDENTIALS_ERROR" }; + } + + const user = readDb("users", credential.userId); + + if (!user) { + // @ts-ignore + return { status: "INVALID_CREDENTIALS_ERROR" }; + } + + // @ts-ignore + return { + status: "OK", + user, + recipeUserId: user.id, + }; + } + + throw new Error(`Unmocked endpoint: ${path}`); + }; + + const sendGetRequest = async ( + path: NormalisedURLPath, + _body: any, + _userContext: UserContext + ): Promise => { + if (path.getAsStringDangerous().includes("/recipe/webauthn/options")) { + const webauthnGeneratedOptionsId = path.getAsStringDangerous().split("/").pop(); + if (!webauthnGeneratedOptionsId) { + // @ts-ignore + return { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" }; + } + + const options = readDb("generatedOptions", webauthnGeneratedOptionsId); + if (!options) { + // @ts-ignore + return { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" }; + } + + return { status: "OK", ...options }; + } + + throw new Error(`Unmocked endpoint: ${path}`); + }; + + querier.sendPostRequest = sendPostRequest; + querier.sendGetRequest = sendGetRequest; + + return querier; +}; diff --git a/lib/ts/recipe/webauthn/api/implementation.ts b/lib/ts/recipe/webauthn/api/implementation.ts index 2a00b910c..4056cda5c 100644 --- a/lib/ts/recipe/webauthn/api/implementation.ts +++ b/lib/ts/recipe/webauthn/api/implementation.ts @@ -131,7 +131,7 @@ export default function getAPIImplementation(): APIInterface { options, userContext, }: { - email?: string; + email: string; tenantId: string; options: APIOptions; userContext: UserContext; @@ -470,7 +470,7 @@ export default function getAPIImplementation(): APIInterface { session, shouldTryLinkingWithSessionUser, }); - if (preAuthChecks.status === "SIGN_UP_NOT_ALLOWED") { + if (preAuthChecks.status === "SIGN_IN_NOT_ALLOWED") { throw new Error("This should never happen: pre-auth checks should not fail for sign in"); } if (preAuthChecks.status !== "OK") { diff --git a/lib/ts/recipe/webauthn/api/signInOptions.ts b/lib/ts/recipe/webauthn/api/signInOptions.ts index f81fde810..4e0802657 100644 --- a/lib/ts/recipe/webauthn/api/signInOptions.ts +++ b/lib/ts/recipe/webauthn/api/signInOptions.ts @@ -16,6 +16,7 @@ import { send200Response } from "../../../utils"; import { APIInterface, APIOptions } from ".."; import { UserContext } from "../../../types"; +import STError from "../error"; export default async function signInOptions( apiImplementation: APIInterface, @@ -26,8 +27,19 @@ export default async function signInOptions( if (apiImplementation.signInOptionsPOST === undefined) { return false; } + const requestBody = await options.req.getJSONBody(); + + let email = requestBody.email?.trim(); + + if (email === undefined || typeof email !== "string") { + throw new STError({ + type: STError.BAD_INPUT_ERROR, + message: "Please provide the email", + }); + } let result = await apiImplementation.signInOptionsPOST({ + email, tenantId, options, userContext, diff --git a/lib/ts/recipe/webauthn/core-mock.ts b/lib/ts/recipe/webauthn/core-mock.ts deleted file mode 100644 index ecf81c0d8..000000000 --- a/lib/ts/recipe/webauthn/core-mock.ts +++ /dev/null @@ -1,112 +0,0 @@ -import NormalisedURLPath from "../../normalisedURLPath"; -import { Querier } from "../../querier"; -import { UserContext } from "../../types"; -import { generateAuthenticationOptions, generateRegistrationOptions } from "@simplewebauthn/server"; -import crypto from "crypto"; - -const db = { - generatedOptions: {} as Record, -}; -const writeDb = (table: keyof typeof db, key: string, value: any) => { - db[table][key] = value; -}; -// const readDb = (table: keyof typeof db, key: string) => { -// return db[table][key]; -// }; - -export const getMockQuerier = (recipeId: string) => { - const querier = Querier.getNewInstanceOrThrowError(recipeId); - - const sendPostRequest = async ( - path: NormalisedURLPath, - body: any, - _userContext: UserContext - ): Promise => { - if (path.getAsStringDangerous().includes("/recipe/webauthn/options/register")) { - const registrationOptions = await generateRegistrationOptions({ - rpID: body.relyingPartyId, - rpName: body.relyingPartyName, - userName: body.email, - timeout: body.timeout, - attestationType: body.attestation || "none", - authenticatorSelection: { - userVerification: body.userVerification || "preferred", - requireResidentKey: body.requireResidentKey || false, - residentKey: body.residentKey || "required", - }, - supportedAlgorithmIDs: body.supportedAlgorithmIDs || [-8, -7, -257], - userDisplayName: body.displayName || body.email, - }); - const id = crypto.randomUUID(); - writeDb("generatedOptions", id, { - ...registrationOptions, - id, - origin: body.origin, - tenantId: body.tenantId, - }); - // @ts-ignore - return { - status: "OK", - webauthnGeneratedOptionsId: id, - ...registrationOptions, - }; - } else if (path.getAsStringDangerous().includes("/recipe/webauthn/options/signin")) { - const signInOptions = await generateAuthenticationOptions({ - rpID: body.relyingPartyId, - timeout: body.timeout, - userVerification: body.userVerification || "preferred", - }); - const id = crypto.randomUUID(); - writeDb("generatedOptions", id, { ...signInOptions, id, origin: body.origin, tenantId: body.tenantId }); - - // @ts-ignore - return { - status: "OK", - webauthnGeneratedOptionsId: id, - ...signInOptions, - }; - // } else if (path.getAsStringDangerous().includes("/recipe/webauthn/user/recover/token")) { - // // @ts-ignore - // return { - // status: "OK", - // token: "dummy-recover-token", - // }; - // } else if (path.getAsStringDangerous().includes("/recipe/webauthn/user/recover/token/consume")) { - // // @ts-ignore - // return { - // status: "OK", - // userId: "dummy-user-id", - // email: "user@example.com", - // }; - // } - } else if (path.getAsStringDangerous().includes("/recipe/webauthn/signup")) { - // @ts-ignore - return { - status: "OK", - user: { - id: "dummy-user-id", - email: "user@example.com", - timeJoined: Date.now(), - }, - recipeUserId: "dummy-recipe-user-id", - }; - } else if (path.getAsStringDangerous().includes("/recipe/webauthn/signin")) { - // @ts-ignore - return { - status: "OK", - user: { - id: "dummy-user-id", - email: "user@example.com", - timeJoined: Date.now(), - }, - recipeUserId: "dummy-recipe-user-id", - }; - } - - throw new Error(`Unmocked endpoint: ${path}`); - }; - - querier.sendPostRequest = sendPostRequest; - - return querier; -}; diff --git a/lib/ts/recipe/webauthn/index.ts b/lib/ts/recipe/webauthn/index.ts index c2b10dbbe..e5b9c6903 100644 --- a/lib/ts/recipe/webauthn/index.ts +++ b/lib/ts/recipe/webauthn/index.ts @@ -187,7 +187,7 @@ export default class Wrapper { userContext, ...rest }: { - email?: string; + email: string; timeout?: number; userVerification?: UserVerification; tenantId?: string; @@ -245,6 +245,22 @@ export default class Wrapper { }); } + static getGeneratedOptions({ + webauthnGeneratedOptionsId, + tenantId = DEFAULT_TENANT_ID, + userContext, + }: { + webauthnGeneratedOptionsId: string; + tenantId?: string; + userContext?: Record; + }) { + return Recipe.getInstanceOrThrowError().recipeInterfaceImpl.getGeneratedOptions({ + webauthnGeneratedOptionsId, + tenantId, + userContext: getUserContext(userContext), + }); + } + static signUp({ tenantId = DEFAULT_TENANT_ID, webauthnGeneratedOptionsId, @@ -583,3 +599,5 @@ export let createRecoverAccountLink = Wrapper.createRecoverAccountLink; export let sendRecoverAccountEmail = Wrapper.sendRecoverAccountEmail; export let sendEmail = Wrapper.sendEmail; + +export let getGeneratedOptions = Wrapper.getGeneratedOptions; diff --git a/lib/ts/recipe/webauthn/recipe.ts b/lib/ts/recipe/webauthn/recipe.ts index 8620c3fde..533dbc293 100644 --- a/lib/ts/recipe/webauthn/recipe.ts +++ b/lib/ts/recipe/webauthn/recipe.ts @@ -48,7 +48,7 @@ import MultitenancyRecipe from "../multitenancy/recipe"; import { User } from "../../user"; import { isFakeEmail } from "../thirdparty/utils"; import { FactorIds } from "../multifactorauth"; -import { getMockQuerier } from "./core-mock"; +import { getMockQuerier } from "../../core-mock"; export default class Recipe extends RecipeModule { private static instance: Recipe | undefined = undefined; diff --git a/lib/ts/recipe/webauthn/recipeImplementation.ts b/lib/ts/recipe/webauthn/recipeImplementation.ts index db6e693f6..9c3361c2f 100644 --- a/lib/ts/recipe/webauthn/recipeImplementation.ts +++ b/lib/ts/recipe/webauthn/recipeImplementation.ts @@ -94,13 +94,14 @@ export default function getRecipeInterface( ); }, - signInOptions: async function ({ relyingPartyId, origin, timeout, tenantId, userContext }) { + signInOptions: async function ({ relyingPartyId, origin, timeout, tenantId, userContext, email }) { // the input user ID can be a recipe or a primary user ID. return await querier.sendPostRequest( new NormalisedURLPath( `/${tenantId === undefined ? DEFAULT_TENANT_ID : tenantId}/recipe/webauthn/options/signin` ), { + email, relyingPartyId, origin, timeout, diff --git a/lib/ts/recipe/webauthn/types.ts b/lib/ts/recipe/webauthn/types.ts index 2f694f1b1..b1a6b4540 100644 --- a/lib/ts/recipe/webauthn/types.ts +++ b/lib/ts/recipe/webauthn/types.ts @@ -217,7 +217,7 @@ export type RecipeInterface = { >; signInOptions(input: { - email?: string; + email: string; relyingPartyId: string; origin: string; userVerification: UserVerification | undefined; // see register options @@ -615,7 +615,7 @@ export type APIInterface = { signInOptionsPOST: | undefined | ((input: { - email?: string; + email: string; tenantId: string; options: APIOptions; userContext: UserContext; diff --git a/package-lock.json b/package-lock.json index 5e765d6c9..35d08da5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2172,9 +2172,10 @@ } }, "node_modules/axios": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", - "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.8.tgz", + "integrity": "sha512-Uu0wb7KNqK2t5K+YQyVCLM76prD5sRFjKHbJYCP1J7JFGEQ6nN7HWn9+04LAeiJ3ji54lgS/gZCH1oxyrf1SPw==", + "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -3788,6 +3789,21 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", diff --git a/test/webauthn/apis.test.js b/test/webauthn/apis.test.js index 85d26917b..c7eba3f13 100644 --- a/test/webauthn/apis.test.js +++ b/test/webauthn/apis.test.js @@ -12,27 +12,23 @@ * License for the specific language governing permissions and limitations * under the License. */ -const { - printPath, - setupST, - startST, - startSTWithMultitenancy, - killAllST, - cleanST, - setKeyValueInConfig, - stopST, -} = require("../utils"); +const { printPath, setupST, startST, killAllST, cleanST, stopST } = require("../utils"); +let assert = require("assert"); + +const request = require("supertest"); +const express = require("express"); + let STExpress = require("../../"); let Session = require("../../recipe/session"); let WebAuthn = require("../../recipe/webauthn"); -let assert = require("assert"); let { ProcessState } = require("../../lib/build/processState"); let SuperTokens = require("../../lib/build/supertokens").default; -const request = require("supertest"); -const express = require("express"); let { middleware, errorHandler } = require("../../framework/express"); let { isCDIVersionCompatible } = require("../utils"); -const { default: RecipeUserId } = require("../../lib/build/recipeUserId"); +const { readFile } = require("fs/promises"); +const nock = require("nock"); + +require("./wasm_exec"); describe(`apisFunctions: ${printPath("[test/webauthn/apis.test.js]")}`, function () { beforeEach(async function () { @@ -46,104 +42,737 @@ describe(`apisFunctions: ${printPath("[test/webauthn/apis.test.js]")}`, function await cleanST(); }); - it("test registerOptionsAPI with default values", async function () { - const connectionURI = await startST(); - - STExpress.init({ - supertokens: { - connectionURI, - }, - appInfo: { - apiDomain: "api.supertokens.io", - appName: "SuperTokensplm", - websiteDomain: "supertokens.io", - }, - recipeList: [WebAuthn.init()], + describe("[registerOptions]", function () { + it("test registerOptions with default values", async function () { + const connectionURI = await startST(); + + STExpress.init({ + supertokens: { + connectionURI, + }, + appInfo: { + apiDomain: "api.supertokens.io", + appName: "SuperTokensplm", + websiteDomain: "supertokens.io", + }, + recipeList: [WebAuthn.init()], + }); + + // run test if current CDI version >= 2.11 + // todo update this to crrect version + if (!(await isCDIVersionCompatible("2.11"))) return; + + const app = express(); + app.use(middleware()); + app.use(errorHandler()); + + // passing valid field + let registerOptionsResponse = await new Promise((resolve, reject) => + request(app) + .post("/auth/webauthn/options/register") + .send({ + email: "test@example.com", + }) + .expect(200) + .end((err, res) => { + if (err) { + console.log(err); + reject(err); + } else { + resolve(JSON.parse(res.text)); + } + }) + ); + + assert(registerOptionsResponse.status === "OK"); + + assert(typeof registerOptionsResponse.challenge === "string"); + assert(registerOptionsResponse.attestation === "none"); + assert(registerOptionsResponse.rp.id === "supertokens.io"); + assert(registerOptionsResponse.rp.name === "SuperTokensplm"); + assert(registerOptionsResponse.user.name === "test@example.com"); + assert(registerOptionsResponse.user.displayName === "test@example.com"); + assert(Number.isInteger(registerOptionsResponse.timeout)); + assert(registerOptionsResponse.authenticatorSelection.userVerification === "preferred"); + assert(registerOptionsResponse.authenticatorSelection.requireResidentKey === true); + assert(registerOptionsResponse.authenticatorSelection.residentKey === "required"); + + const generatedOptions = await SuperTokens.getInstanceOrThrowError().recipeModules[0].recipeInterfaceImpl.getGeneratedOptions( + { + webauthnGeneratedOptionsId: registerOptionsResponse.webauthnGeneratedOptionsId, + } + ); + + assert(generatedOptions.origin === "https://supertokens.io"); + }); + + it("test registerOptions with custom values", async function () { + const connectionURI = await startST(); + + STExpress.init({ + supertokens: { + connectionURI, + }, + appInfo: { + apiDomain: "api.supertokens.io", + appName: "SuperTokensplm", + websiteDomain: "supertokens.io", + }, + recipeList: [ + WebAuthn.init({ + getOrigin: () => { + return "testOrigin.com"; + }, + getRelyingPartyId: () => { + return "testId.com"; + }, + getRelyingPartyName: () => { + return "testName"; + }, + validateEmailAddress: (email) => { + return email === "test@example.com" ? undefined : "Invalid email"; + }, + }), + ], + }); + + // run test if current CDI version >= 2.11 + // todo update this to crrect version + if (!(await isCDIVersionCompatible("2.11"))) return; + + const app = express(); + app.use(middleware()); + app.use(errorHandler()); + + // passing valid field + let registerOptionsResponse = await new Promise((resolve, reject) => + request(app) + .post("/auth/webauthn/options/register") + .send({ + email: "test@example.com", + }) + .expect(200) + .end((err, res) => { + if (err) { + console.log(err); + reject(err); + } else { + resolve(JSON.parse(res.text)); + } + }) + ); + + assert(registerOptionsResponse.status === "OK"); + + assert(typeof registerOptionsResponse.challenge === "string"); + assert(registerOptionsResponse.attestation === "none"); + assert(registerOptionsResponse.rp.id === "testId.com"); + assert(registerOptionsResponse.rp.name === "testName"); + assert(registerOptionsResponse.user.name === "test@example.com"); + assert(registerOptionsResponse.user.displayName === "test@example.com"); + assert(Number.isInteger(registerOptionsResponse.timeout)); + assert(registerOptionsResponse.authenticatorSelection.userVerification === "preferred"); + assert(registerOptionsResponse.authenticatorSelection.requireResidentKey === true); + assert(registerOptionsResponse.authenticatorSelection.residentKey === "required"); + + const generatedOptions = await SuperTokens.getInstanceOrThrowError().recipeModules[0].recipeInterfaceImpl.getGeneratedOptions( + { + webauthnGeneratedOptionsId: registerOptionsResponse.webauthnGeneratedOptionsId, + } + ); + assert(generatedOptions.origin === "testOrigin.com"); }); + }); + + describe("[signInOptions]", function () { + it("test signInOptions with default values", async function () { + const connectionURI = await startST(); + + STExpress.init({ + supertokens: { + connectionURI, + }, + appInfo: { + apiDomain: "api.supertokens.io", + appName: "SuperTokensplm", + websiteDomain: "supertokens.io", + }, + recipeList: [WebAuthn.init()], + }); + + // run test if current CDI version >= 2.11 + // todo update this to crrect version + if (!(await isCDIVersionCompatible("2.11"))) return; + + const app = express(); + app.use(middleware()); + app.use(errorHandler()); + + // passing valid field + let signInOptionsResponse = await new Promise((resolve, reject) => + request(app) + .post("/auth/webauthn/options/signin") + .send({ email: "test@example.com" }) + .expect(200) + .end((err, res) => { + if (err) { + console.log(err); + reject(err); + } else { + resolve(JSON.parse(res.text)); + } + }) + ); + + assert(signInOptionsResponse.status === "OK"); + + assert(typeof signInOptionsResponse.challenge === "string"); + assert(Number.isInteger(signInOptionsResponse.timeout)); + assert(signInOptionsResponse.userVerification === "preferred"); + + const generatedOptions = await SuperTokens.getInstanceOrThrowError().recipeModules[0].recipeInterfaceImpl.getGeneratedOptions( + { + webauthnGeneratedOptionsId: signInOptionsResponse.webauthnGeneratedOptionsId, + } + ); + + assert(generatedOptions.rpId === "supertokens.io"); + assert(generatedOptions.origin === "https://supertokens.io"); + }); + + it("test signInOptions with custom values", async function () { + const connectionURI = await startST(); + + STExpress.init({ + supertokens: { + connectionURI, + }, + appInfo: { + apiDomain: "api.supertokens.io", + appName: "SuperTokensplm", + websiteDomain: "supertokens.io", + }, + recipeList: [ + WebAuthn.init({ + getOrigin: () => { + return "testOrigin.com"; + }, + getRelyingPartyId: () => { + return "testId.com"; + }, + getRelyingPartyName: () => { + return "testName"; + }, + }), + ], + }); + + // run test if current CDI version >= 2.11 + // todo update this to crrect version + if (!(await isCDIVersionCompatible("2.11"))) return; + + const app = express(); + app.use(middleware()); + app.use(errorHandler()); + + // passing valid field + let signInOptionsResponse = await new Promise((resolve, reject) => + request(app) + .post("/auth/webauthn/options/signin") + .send({ email: "test@example.com" }) + .expect(200) + .end((err, res) => { + if (err) { + console.log(err); + reject(err); + } else { + resolve(JSON.parse(res.text)); + } + }) + ); + + assert(signInOptionsResponse.status === "OK"); + + assert(typeof signInOptionsResponse.challenge === "string"); + assert(Number.isInteger(signInOptionsResponse.timeout)); + assert(signInOptionsResponse.userVerification === "preferred"); + + const generatedOptions = await SuperTokens.getInstanceOrThrowError().recipeModules[0].recipeInterfaceImpl.getGeneratedOptions( + { + webauthnGeneratedOptionsId: signInOptionsResponse.webauthnGeneratedOptionsId, + } + ); + + assert(generatedOptions.rpId === "testId.com"); + assert(generatedOptions.origin === "testOrigin.com"); + }); + }); + + describe("[signUp]", function () { + it("test signUp with no account linking", async function () { + const connectionURI = await startST(); + + const origin = "https://supertokens.io"; + const rpId = "supertokens.io"; + const rpName = "SuperTokensplm"; + + STExpress.init({ + supertokens: { + connectionURI, + }, + appInfo: { + apiDomain: "api.supertokens.io", + appName: "SuperTokensplm", + websiteDomain: "supertokens.io", + }, + recipeList: [ + Session.init(), + WebAuthn.init({ + getOrigin: async () => { + return origin; + }, + getRelyingPartyId: async () => { + return rpId; + }, + getRelyingPartyName: async () => { + return rpName; + }, + }), + ], + }); - // run test if current CDI version >= 2.11 - if (!(await isCDIVersionCompatible("2.11"))) return; + // run test if current CDI version >= 2.11 + // todo update this to crrect version + if (!(await isCDIVersionCompatible("2.11"))) return; - const app = express(); - app.use(middleware()); - app.use(errorHandler()); + const app = express(); + app.use(middleware()); + app.use(errorHandler()); - // passing valid field - let validCreateCodeResponse = await new Promise((resolve) => - request(app) - .post("/auth/webauthn/options/register") - .send({ - email: "test@example.com", + const email = `${Math.random().toString().slice(2)}@supertokens.com`; + let registerOptionsResponse = await new Promise((resolve, reject) => + request(app) + .post("/auth/webauthn/options/register") + .send({ + email, + }) + .expect(200) + .end((err, res) => { + if (err) { + console.log(err); + reject(err); + } else { + resolve(JSON.parse(res.text)); + } + }) + ); + assert(registerOptionsResponse.status === "OK"); + + const { createCredential } = await getWebauthnLib(); + const credential = createCredential(registerOptionsResponse, { + rpId, + rpName, + origin, + userNotPresent: false, + userNotVerified: false, + }); + + let signUpResponse = await new Promise((resolve, reject) => + request(app) + .post("/auth/webauthn/signup") + .send({ + credential, + webauthnGeneratedOptionsId: registerOptionsResponse.webauthnGeneratedOptionsId, + shouldTryLinkingWithSessionUser: false, + }) + .expect(200) + .end((err, res) => { + if (err) { + console.log(err); + reject(err); + } else { + resolve(JSON.parse(res.text)); + } + }) + ); + + assert(signUpResponse.status === "OK"); + + assert(signUpResponse?.user?.id !== undefined); + assert(signUpResponse?.user?.emails?.length === 1); + assert(signUpResponse?.user?.emails?.[0] === email); + assert(signUpResponse?.user?.webauthn?.credentialIds?.length === 1); + assert(signUpResponse?.user?.webauthn?.credentialIds?.[0] === credential.id); + }); + }); + + describe("[signIn]", function () { + it("test signIn with no account linking", async function () { + const connectionURI = await startST(); + + const origin = "https://supertokens.io"; + const rpId = "supertokens.io"; + const rpName = "SuperTokensplm"; + + STExpress.init({ + supertokens: { + connectionURI, + }, + appInfo: { + apiDomain: "api.supertokens.io", + appName: "SuperTokensplm", + websiteDomain: "supertokens.io", + }, + recipeList: [ + Session.init(), + WebAuthn.init({ + getOrigin: async () => { + return origin; + }, + getRelyingPartyId: async () => { + return rpId; + }, + getRelyingPartyName: async () => { + return rpName; + }, + }), + ], + }); + + // run test if current CDI version >= 2.11 + // todo update this to crrect version + if (!(await isCDIVersionCompatible("2.11"))) return; + + const app = express(); + app.use(middleware()); + app.use(errorHandler()); + + const email = `${Math.random().toString().slice(2)}@supertokens.com`; + let registerOptionsResponse = await new Promise((resolve, reject) => + request(app) + .post("/auth/webauthn/options/register") + .send({ + email, + }) + .expect(200) + .end((err, res) => { + if (err) { + console.log(err); + reject(err); + } else { + resolve(JSON.parse(res.text)); + } + }) + ); + assert(registerOptionsResponse.status === "OK"); + + let signInOptionsResponse = await new Promise((resolve, reject) => + request(app) + .post("/auth/webauthn/options/signin") + .send({ email }) + .expect(200) + .end((err, res) => { + if (err) { + console.log(err); + reject(err); + } else { + resolve(JSON.parse(res.text)); + } + }) + ); + assert(signInOptionsResponse.status === "OK"); + + const { createAndAssertCredential } = await getWebauthnLib(); + const credential = createAndAssertCredential(registerOptionsResponse, signInOptionsResponse, { + rpId, + rpName, + origin, + userNotPresent: false, + userNotVerified: false, + }); + + let signUpResponse = await new Promise((resolve, reject) => + request(app) + .post("/auth/webauthn/signup") + .send({ + credential: credential.attestation, + webauthnGeneratedOptionsId: registerOptionsResponse.webauthnGeneratedOptionsId, + shouldTryLinkingWithSessionUser: false, + }) + .expect(200) + .end((err, res) => { + if (err) { + console.log(err); + reject(err); + } else { + resolve(JSON.parse(res.text)); + } + }) + ); + + assert(signUpResponse.status === "OK"); + + // todo remove this when the core is implemented + // mock the core to return the user + nock("http://localhost:8080/", { allowUnmocked: true }) + .get("/public/users/by-accountinfo") + .query({ email, doUnionOfAccountInfo: true }) + .reply(200, (uri, body) => { + return { status: "OK", users: [signUpResponse.user] }; }) - .expect(200) - .end((err, res) => { - if (err) { - console.log(err); - resolve(undefined); - } else { - resolve(JSON.parse(res.text)); - } + .get("/user/id") + .query({ userId: signUpResponse.user.id }) + .reply(200, (uri, body) => { + return { status: "OK", user: signUpResponse.user }; + }); + + let signInResponse = await new Promise((resolve, reject) => + request(app) + .post("/auth/webauthn/signin") + .send({ + credential: credential.assertion, + webauthnGeneratedOptionsId: signInOptionsResponse.webauthnGeneratedOptionsId, + shouldTryLinkingWithSessionUser: false, + }) + .expect(200) + .end((err, res) => { + if (err) { + console.log(err); + reject(err); + } else { + resolve(JSON.parse(res.text)); + } + }) + ); + + assert(signInResponse.status === "OK"); + + assert(signInResponse?.user?.id !== undefined); + assert(signInResponse?.user?.emails?.length === 1); + assert(signInResponse?.user?.emails?.[0] === email); + assert(signInResponse?.user?.webauthn?.credentialIds?.length === 1); + assert(signInResponse?.user?.webauthn?.credentialIds?.[0] === credential.attestation.id); + }); + + it("test signIn fail with wrong credential", async function () { + const connectionURI = await startST(); + + const origin = "https://supertokens.io"; + const rpId = "supertokens.io"; + const rpName = "SuperTokensplm"; + + STExpress.init({ + supertokens: { + connectionURI, + }, + appInfo: { + apiDomain: "api.supertokens.io", + appName: "SuperTokensplm", + websiteDomain: "supertokens.io", + }, + recipeList: [ + Session.init(), + WebAuthn.init({ + getOrigin: async () => { + return origin; + }, + getRelyingPartyId: async () => { + return rpId; + }, + getRelyingPartyName: async () => { + return rpName; + }, + }), + ], + }); + + // run test if current CDI version >= 2.11 + // todo update this to crrect version + if (!(await isCDIVersionCompatible("2.11"))) return; + + const app = express(); + app.use(middleware()); + app.use(errorHandler()); + + const email = `${Math.random().toString().slice(2)}@supertokens.com`; + let registerOptionsResponse = await new Promise((resolve, reject) => + request(app) + .post("/auth/webauthn/options/register") + .send({ + email, + }) + .expect(200) + .end((err, res) => { + if (err) { + console.log(err); + reject(err); + } else { + resolve(JSON.parse(res.text)); + } + }) + ); + assert(registerOptionsResponse.status === "OK"); + + let signInOptionsResponse = await new Promise((resolve, reject) => + request(app) + .post("/auth/webauthn/options/signin") + .send({ email: email + "wrong" }) + .expect(200) + .end((err, res) => { + if (err) { + console.log(err); + reject(err); + } else { + resolve(JSON.parse(res.text)); + } + }) + ); + assert(signInOptionsResponse.status === "OK"); + + const { createAndAssertCredential } = await getWebauthnLib(); + const credential = createAndAssertCredential(registerOptionsResponse, signInOptionsResponse, { + rpId, + rpName, + origin, + userNotPresent: false, + userNotVerified: false, + }); + + let signUpResponse = await new Promise((resolve, reject) => + request(app) + .post("/auth/webauthn/signup") + .send({ + credential: credential.attestation, + webauthnGeneratedOptionsId: registerOptionsResponse.webauthnGeneratedOptionsId, + shouldTryLinkingWithSessionUser: false, + }) + .expect(200) + .end((err, res) => { + if (err) { + console.log(err); + reject(err); + } else { + resolve(JSON.parse(res.text)); + } + }) + ); + + assert(signUpResponse.status === "OK"); + + // todo remove this when the core is implemented + // mock the core to return the user + nock("http://localhost:8080/", { allowUnmocked: true }) + .get("/public/users/by-accountinfo") + .query({ email, doUnionOfAccountInfo: true }) + .reply(200, (uri, body) => { + return { status: "OK", users: [signUpResponse.user] }; }) - ); - console.log(validCreateCodeResponse); - - assert(validCreateCodeResponse.status === "OK"); - - assert(typeof validCreateCodeResponse.challenge === "string"); - assert(validCreateCodeResponse.attestation === "none"); - assert(validCreateCodeResponse.rp.id === "supertokens.io"); - assert(validCreateCodeResponse.rp.name === "SuperTokensplm"); - assert(validCreateCodeResponse.user.name === "test@example.com"); - assert(validCreateCodeResponse.user.displayName === "test@example.com"); - assert(Number.isInteger(validCreateCodeResponse.timeout)); - assert(validCreateCodeResponse.authenticatorSelection.userVerification === "preferred"); - assert(validCreateCodeResponse.authenticatorSelection.requireResidentKey === true); - assert(validCreateCodeResponse.authenticatorSelection.residentKey === "required"); + .get("/user/id") + .query({ userId: signUpResponse.user.id }) + .reply(200, (uri, body) => { + return { status: "OK", user: signUpResponse.user }; + }); + + let signInResponse = await new Promise((resolve, reject) => + request(app) + .post("/auth/webauthn/signin") + .send({ + credential: credential.assertion, + webauthnGeneratedOptionsId: signInOptionsResponse.webauthnGeneratedOptionsId, + shouldTryLinkingWithSessionUser: false, + }) + .expect(200) + .end((err, res) => { + if (err) { + console.log(err); + reject(err); + } else { + resolve(JSON.parse(res.text)); + } + }) + ); + + assert(signInResponse.status === "INVALID_CREDENTIALS_ERROR"); + }); }); }); -function checkConsumeResponse(validUserInputCodeResponse, { email, phoneNumber, isNew, isPrimary }) { - assert.strictEqual(validUserInputCodeResponse.status, "OK"); - assert.strictEqual(validUserInputCodeResponse.createdNewRecipeUser, isNew); - - assert.strictEqual(typeof validUserInputCodeResponse.user.id, "string"); - assert.strictEqual(typeof validUserInputCodeResponse.user.timeJoined, "number"); - assert.strictEqual(validUserInputCodeResponse.user.isPrimaryUser, isPrimary); - - assert(validUserInputCodeResponse.user.emails instanceof Array); - if (email !== undefined) { - assert.strictEqual(validUserInputCodeResponse.user.emails.length, 1); - assert.strictEqual(validUserInputCodeResponse.user.emails[0], email); - } else { - assert.strictEqual(validUserInputCodeResponse.user.emails.length, 0); - } - - assert(validUserInputCodeResponse.user.phoneNumbers instanceof Array); - if (phoneNumber !== undefined) { - assert.strictEqual(validUserInputCodeResponse.user.phoneNumbers.length, 1); - assert.strictEqual(validUserInputCodeResponse.user.phoneNumbers[0], phoneNumber); - } else { - assert.strictEqual(validUserInputCodeResponse.user.phoneNumbers.length, 0); - } - - assert.strictEqual(validUserInputCodeResponse.user.thirdParty.length, 0); - - assert.strictEqual(validUserInputCodeResponse.user.loginMethods.length, 1); - const loginMethod = { - recipeId: "passwordless", - recipeUserId: validUserInputCodeResponse.user.id, - timeJoined: validUserInputCodeResponse.user.timeJoined, - verified: true, - tenantIds: ["public"], +const getWebauthnLib = async () => { + const wasmBuffer = await readFile(__dirname + "/webauthn.wasm"); + + // Set up the WebAssembly module instance + const go = new Go(); + const { instance } = await WebAssembly.instantiate(wasmBuffer, go.importObject); + go.run(instance); + + // Export extractURL from the global object + const createCredential = ( + registerOptions, + { userNotPresent = true, userNotVerified = true, rpId, rpName, origin } + ) => { + const registerOptionsString = JSON.stringify(registerOptions); + const result = global.createCredential( + registerOptionsString, + rpId, + rpName, + origin, + userNotPresent, + userNotVerified + ); + + if (!result) { + throw new Error("Failed to create credential"); + } + + try { + const credential = JSON.parse(result); + return credential; + } catch (e) { + throw new Error("Failed to parse credential"); + } + }; + + const createAndAssertCredential = ( + registerOptions, + signInOptions, + { userNotPresent = false, userNotVerified = false, rpId, rpName, origin } + ) => { + const registerOptionsString = JSON.stringify(registerOptions); + const signInOptionsString = JSON.stringify(signInOptions); + + const result = global.createAndAssertCredential( + registerOptionsString, + signInOptionsString, + rpId, + rpName, + origin, + userNotPresent, + userNotVerified + ); + + if (!result) { + throw new Error("Failed to create/assert credential"); + } + + try { + const parsedResult = JSON.parse(result); + return { attestation: parsedResult.attestation, assertion: parsedResult.assertion }; + } catch (e) { + throw new Error("Failed to parse result"); + } }; - if (email) { - loginMethod.email = email; - } - if (phoneNumber) { - loginMethod.phoneNumber = phoneNumber; - } - assert.deepStrictEqual(validUserInputCodeResponse.user.loginMethods, [loginMethod]); - - assert.strictEqual(Object.keys(validUserInputCodeResponse.user).length, 8); - assert.strictEqual(Object.keys(validUserInputCodeResponse).length, 3); -} + + return { createCredential, createAndAssertCredential }; +}; + +const log = ({ ...args }) => { + Object.keys(args).forEach((key) => { + console.log(); + console.log("------------------------------------------------"); + console.log(`${key}`); + console.log("------------------------------------------------"); + console.log(JSON.stringify(args[key], null, 2)); + console.log("================================================"); + console.log(); + }); +}; diff --git a/test/webauthn/wasm_exec.js b/test/webauthn/wasm_exec.js new file mode 100644 index 000000000..5a1d281ec --- /dev/null +++ b/test/webauthn/wasm_exec.js @@ -0,0 +1,639 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +"use strict"; + +(() => { + const enosys = () => { + const err = new Error("not implemented"); + err.code = "ENOSYS"; + return err; + }; + + if (!globalThis.fs) { + let outputBuf = ""; + globalThis.fs = { + constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused + writeSync(fd, buf) { + outputBuf += decoder.decode(buf); + const nl = outputBuf.lastIndexOf("\n"); + if (nl != -1) { + console.log(outputBuf.substring(0, nl)); + outputBuf = outputBuf.substring(nl + 1); + } + return buf.length; + }, + write(fd, buf, offset, length, position, callback) { + if (offset !== 0 || length !== buf.length || position !== null) { + callback(enosys()); + return; + } + const n = this.writeSync(fd, buf); + callback(null, n); + }, + chmod(path, mode, callback) { + callback(enosys()); + }, + chown(path, uid, gid, callback) { + callback(enosys()); + }, + close(fd, callback) { + callback(enosys()); + }, + fchmod(fd, mode, callback) { + callback(enosys()); + }, + fchown(fd, uid, gid, callback) { + callback(enosys()); + }, + fstat(fd, callback) { + callback(enosys()); + }, + fsync(fd, callback) { + callback(null); + }, + ftruncate(fd, length, callback) { + callback(enosys()); + }, + lchown(path, uid, gid, callback) { + callback(enosys()); + }, + link(path, link, callback) { + callback(enosys()); + }, + lstat(path, callback) { + callback(enosys()); + }, + mkdir(path, perm, callback) { + callback(enosys()); + }, + open(path, flags, mode, callback) { + callback(enosys()); + }, + read(fd, buffer, offset, length, position, callback) { + callback(enosys()); + }, + readdir(path, callback) { + callback(enosys()); + }, + readlink(path, callback) { + callback(enosys()); + }, + rename(from, to, callback) { + callback(enosys()); + }, + rmdir(path, callback) { + callback(enosys()); + }, + stat(path, callback) { + callback(enosys()); + }, + symlink(path, link, callback) { + callback(enosys()); + }, + truncate(path, length, callback) { + callback(enosys()); + }, + unlink(path, callback) { + callback(enosys()); + }, + utimes(path, atime, mtime, callback) { + callback(enosys()); + }, + }; + } + + if (!globalThis.process) { + globalThis.process = { + getuid() { + return -1; + }, + getgid() { + return -1; + }, + geteuid() { + return -1; + }, + getegid() { + return -1; + }, + getgroups() { + throw enosys(); + }, + pid: -1, + ppid: -1, + umask() { + throw enosys(); + }, + cwd() { + throw enosys(); + }, + chdir() { + throw enosys(); + }, + }; + } + + if (!globalThis.path) { + globalThis.path = { + resolve(...pathSegments) { + return pathSegments.join("/"); + }, + }; + } + + if (!globalThis.crypto) { + throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)"); + } + + if (!globalThis.performance) { + throw new Error("globalThis.performance is not available, polyfill required (performance.now only)"); + } + + if (!globalThis.TextEncoder) { + throw new Error("globalThis.TextEncoder is not available, polyfill required"); + } + + if (!globalThis.TextDecoder) { + throw new Error("globalThis.TextDecoder is not available, polyfill required"); + } + + const encoder = new TextEncoder("utf-8"); + const decoder = new TextDecoder("utf-8"); + + globalThis.Go = class { + constructor() { + this.argv = ["js"]; + this.env = {}; + this.exit = (code) => { + if (code !== 0) { + console.warn("exit code:", code); + } + }; + this._exitPromise = new Promise((resolve) => { + this._resolveExitPromise = resolve; + }); + this._pendingEvent = null; + this._scheduledTimeouts = new Map(); + this._nextCallbackTimeoutID = 1; + + const setInt64 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true); + }; + + const setInt32 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + }; + + const getInt64 = (addr) => { + const low = this.mem.getUint32(addr + 0, true); + const high = this.mem.getInt32(addr + 4, true); + return low + high * 4294967296; + }; + + const loadValue = (addr) => { + const f = this.mem.getFloat64(addr, true); + if (f === 0) { + return undefined; + } + if (!isNaN(f)) { + return f; + } + + const id = this.mem.getUint32(addr, true); + return this._values[id]; + }; + + const storeValue = (addr, v) => { + const nanHead = 0x7ff80000; + + if (typeof v === "number" && v !== 0) { + if (isNaN(v)) { + this.mem.setUint32(addr + 4, nanHead, true); + this.mem.setUint32(addr, 0, true); + return; + } + this.mem.setFloat64(addr, v, true); + return; + } + + if (v === undefined) { + this.mem.setFloat64(addr, 0, true); + return; + } + + let id = this._ids.get(v); + if (id === undefined) { + id = this._idPool.pop(); + if (id === undefined) { + id = this._values.length; + } + this._values[id] = v; + this._goRefCounts[id] = 0; + this._ids.set(v, id); + } + this._goRefCounts[id]++; + let typeFlag = 0; + switch (typeof v) { + case "object": + if (v !== null) { + typeFlag = 1; + } + break; + case "string": + typeFlag = 2; + break; + case "symbol": + typeFlag = 3; + break; + case "function": + typeFlag = 4; + break; + } + this.mem.setUint32(addr + 4, nanHead | typeFlag, true); + this.mem.setUint32(addr, id, true); + }; + + const loadSlice = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + return new Uint8Array(this._inst.exports.mem.buffer, array, len); + }; + + const loadSliceOfValues = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + const a = new Array(len); + for (let i = 0; i < len; i++) { + a[i] = loadValue(array + i * 8); + } + return a; + }; + + const loadString = (addr) => { + const saddr = getInt64(addr + 0); + const len = getInt64(addr + 8); + return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len)); + }; + + const testCallExport = (a, b) => { + this._inst.exports.testExport0(); + return this._inst.exports.testExport(a, b); + }; + + const timeOrigin = Date.now() - performance.now(); + this.importObject = { + _gotest: { + add: (a, b) => a + b, + callExport: testCallExport, + }, + gojs: { + // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) + // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported + // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). + // This changes the SP, thus we have to update the SP used by the imported function. + + // func wasmExit(code int32) + "runtime.wasmExit": (sp) => { + sp >>>= 0; + const code = this.mem.getInt32(sp + 8, true); + this.exited = true; + delete this._inst; + delete this._values; + delete this._goRefCounts; + delete this._ids; + delete this._idPool; + this.exit(code); + }, + + // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) + "runtime.wasmWrite": (sp) => { + sp >>>= 0; + const fd = getInt64(sp + 8); + const p = getInt64(sp + 16); + const n = this.mem.getInt32(sp + 24, true); + fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); + }, + + // func resetMemoryDataView() + "runtime.resetMemoryDataView": (sp) => { + sp >>>= 0; + this.mem = new DataView(this._inst.exports.mem.buffer); + }, + + // func nanotime1() int64 + "runtime.nanotime1": (sp) => { + sp >>>= 0; + setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); + }, + + // func walltime() (sec int64, nsec int32) + "runtime.walltime": (sp) => { + sp >>>= 0; + const msec = new Date().getTime(); + setInt64(sp + 8, msec / 1000); + this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true); + }, + + // func scheduleTimeoutEvent(delay int64) int32 + "runtime.scheduleTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this._nextCallbackTimeoutID; + this._nextCallbackTimeoutID++; + this._scheduledTimeouts.set( + id, + setTimeout(() => { + this._resume(); + while (this._scheduledTimeouts.has(id)) { + // for some reason Go failed to register the timeout event, log and try again + // (temporary workaround for https://github.com/golang/go/issues/28975) + console.warn("scheduleTimeoutEvent: missed timeout event"); + this._resume(); + } + }, getInt64(sp + 8)) + ); + this.mem.setInt32(sp + 16, id, true); + }, + + // func clearTimeoutEvent(id int32) + "runtime.clearTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this.mem.getInt32(sp + 8, true); + clearTimeout(this._scheduledTimeouts.get(id)); + this._scheduledTimeouts.delete(id); + }, + + // func getRandomData(r []byte) + "runtime.getRandomData": (sp) => { + sp >>>= 0; + crypto.getRandomValues(loadSlice(sp + 8)); + }, + + // func finalizeRef(v ref) + "syscall/js.finalizeRef": (sp) => { + sp >>>= 0; + const id = this.mem.getUint32(sp + 8, true); + this._goRefCounts[id]--; + if (this._goRefCounts[id] === 0) { + const v = this._values[id]; + this._values[id] = null; + this._ids.delete(v); + this._idPool.push(id); + } + }, + + // func stringVal(value string) ref + "syscall/js.stringVal": (sp) => { + sp >>>= 0; + storeValue(sp + 24, loadString(sp + 8)); + }, + + // func valueGet(v ref, p string) ref + "syscall/js.valueGet": (sp) => { + sp >>>= 0; + const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 32, result); + }, + + // func valueSet(v ref, p string, x ref) + "syscall/js.valueSet": (sp) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); + }, + + // func valueDelete(v ref, p string) + "syscall/js.valueDelete": (sp) => { + sp >>>= 0; + Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16)); + }, + + // func valueIndex(v ref, i int) ref + "syscall/js.valueIndex": (sp) => { + sp >>>= 0; + storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); + }, + + // valueSetIndex(v ref, i int, x ref) + "syscall/js.valueSetIndex": (sp) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24)); + }, + + // func valueCall(v ref, m string, args []ref) (ref, bool) + "syscall/js.valueCall": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const m = Reflect.get(v, loadString(sp + 16)); + const args = loadSliceOfValues(sp + 32); + const result = Reflect.apply(m, v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, result); + this.mem.setUint8(sp + 64, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, err); + this.mem.setUint8(sp + 64, 0); + } + }, + + // func valueInvoke(v ref, args []ref) (ref, bool) + "syscall/js.valueInvoke": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.apply(v, undefined, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueNew(v ref, args []ref) (ref, bool) + "syscall/js.valueNew": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.construct(v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueLength(v ref) int + "syscall/js.valueLength": (sp) => { + sp >>>= 0; + setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); + }, + + // valuePrepareString(v ref) (ref, int) + "syscall/js.valuePrepareString": (sp) => { + sp >>>= 0; + const str = encoder.encode(String(loadValue(sp + 8))); + storeValue(sp + 16, str); + setInt64(sp + 24, str.length); + }, + + // valueLoadString(v ref, b []byte) + "syscall/js.valueLoadString": (sp) => { + sp >>>= 0; + const str = loadValue(sp + 8); + loadSlice(sp + 16).set(str); + }, + + // func valueInstanceOf(v ref, t ref) bool + "syscall/js.valueInstanceOf": (sp) => { + sp >>>= 0; + this.mem.setUint8(sp + 24, loadValue(sp + 8) instanceof loadValue(sp + 16) ? 1 : 0); + }, + + // func copyBytesToGo(dst []byte, src ref) (int, bool) + "syscall/js.copyBytesToGo": (sp) => { + sp >>>= 0; + const dst = loadSlice(sp + 8); + const src = loadValue(sp + 32); + if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + // func copyBytesToJS(dst ref, src []byte) (int, bool) + "syscall/js.copyBytesToJS": (sp) => { + sp >>>= 0; + const dst = loadValue(sp + 8); + const src = loadSlice(sp + 16); + if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + debug: (value) => { + console.log(value); + }, + }, + }; + } + + async run(instance) { + if (!(instance instanceof WebAssembly.Instance)) { + throw new Error("Go.run: WebAssembly.Instance expected"); + } + this._inst = instance; + this.mem = new DataView(this._inst.exports.mem.buffer); + this._values = [ + // JS values that Go currently has references to, indexed by reference id + NaN, + 0, + null, + true, + false, + globalThis, + this, + ]; + this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id + this._ids = new Map([ + // mapping from JS values to reference ids + [0, 1], + [null, 2], + [true, 3], + [false, 4], + [globalThis, 5], + [this, 6], + ]); + this._idPool = []; // unused ids that have been garbage collected + this.exited = false; // whether the Go program has exited + + // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. + let offset = 4096; + + const strPtr = (str) => { + const ptr = offset; + const bytes = encoder.encode(str + "\0"); + new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes); + offset += bytes.length; + if (offset % 8 !== 0) { + offset += 8 - (offset % 8); + } + return ptr; + }; + + const argc = this.argv.length; + + const argvPtrs = []; + this.argv.forEach((arg) => { + argvPtrs.push(strPtr(arg)); + }); + argvPtrs.push(0); + + const keys = Object.keys(this.env).sort(); + keys.forEach((key) => { + argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); + }); + argvPtrs.push(0); + + const argv = offset; + argvPtrs.forEach((ptr) => { + this.mem.setUint32(offset, ptr, true); + this.mem.setUint32(offset + 4, 0, true); + offset += 8; + }); + + // The linker guarantees global data starts from at least wasmMinDataAddr. + // Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr. + const wasmMinDataAddr = 4096 + 8192; + if (offset >= wasmMinDataAddr) { + throw new Error("total length of command line and environment variables exceeds limit"); + } + + this._inst.exports.run(argc, argv); + if (this.exited) { + this._resolveExitPromise(); + } + await this._exitPromise; + } + + _resume() { + if (this.exited) { + throw new Error("Go program has already exited"); + } + this._inst.exports.resume(); + if (this.exited) { + this._resolveExitPromise(); + } + } + + _makeFuncWrapper(id) { + const go = this; + return function () { + const event = { id: id, this: this, args: arguments }; + go._pendingEvent = event; + go._resume(); + return event.result; + }; + } + }; +})(); diff --git a/test/webauthn/webauthn.wasm b/test/webauthn/webauthn.wasm new file mode 100755 index 000000000..c1e114cde Binary files /dev/null and b/test/webauthn/webauthn.wasm differ