TOTP 2FA endpoints

This commit is contained in:
Konstantin Wohlwend 2024-08-09 22:31:39 -07:00
parent 50fd3dfc2c
commit 1b550e7e48
33 changed files with 1050 additions and 67 deletions

View File

@ -33,6 +33,7 @@
"reqs",
"stackframe",
"Svix",
"totp",
"typecheck",
"typehack",
"Uncapitalize",

View File

@ -37,6 +37,7 @@
"next": "^14.1",
"nodemailer": "^6.9.10",
"openid-client": "^5.6.4",
"oslo": "^1.2.1",
"pg": "^8.11.3",
"posthog-js": "^1.149.1",
"react": "^18.2",

View File

@ -0,0 +1,16 @@
/*
Warnings:
- You are about to drop the column `email` on the `VerificationCode` table. All the data in the column will be lost.
*/
-- AlterEnum
ALTER TYPE "VerificationCodeType" ADD VALUE 'MFA_ATTEMPT';
-- AlterTable
ALTER TABLE "ProjectUser" ADD COLUMN "requiresTotpMfa" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "totpSecret" BYTEA;
-- AlterTable
ALTER TABLE "VerificationCode" DROP COLUMN "email",
ADD COLUMN "method" JSONB NOT NULL DEFAULT 'null';

View File

@ -239,6 +239,9 @@ model ProjectUser {
passwordHash String?
authWithEmail Boolean
requiresTotpMfa Boolean @default(false)
totpSecret Bytes?
serverMetadata Json?
clientMetadata Json?
@ -359,7 +362,7 @@ model VerificationCode {
usedAt DateTime?
redirectUrl String?
email String
method Json @default("null")
data Json
@ -372,6 +375,7 @@ enum VerificationCodeType {
PASSWORD_RESET
CONTACT_CHANNEL_VERIFICATION
TEAM_INVITATION
MFA_ATTEMPT
}
// @deprecated

View File

@ -0,0 +1,3 @@
import { mfaVerificationCodeHandler } from "./verification-code-handler";
export const POST = mfaVerificationCodeHandler.postHandler;

View File

@ -0,0 +1,89 @@
import { yupObject, yupString, yupNumber, yupBoolean } from "@stackframe/stack-shared/dist/schema-fields";
import { prismaClient } from "@/prisma-client";
import { createAuthTokens } from "@/lib/tokens";
import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler";
import { signInResponseSchema } from "@stackframe/stack-shared/dist/schema-fields";
import { VerificationCodeType } from "@prisma/client";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { TOTPController } from "oslo/otp";
import { KnownErrors } from "@stackframe/stack-shared";
import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects";
export const mfaVerificationCodeHandler = createVerificationCodeHandler({
metadata: {
post: {
summary: "MFA sign in",
description: "Complete multi-factor authorization to sign in, with a TOTP and an MFA attempt code",
tags: ["OTP"],
},
check: {
summary: "Verify MFA",
description: "Check if the MFA attempt is valid without using it",
tags: ["OTP"],
}
},
type: VerificationCodeType.ONE_TIME_PASSWORD,
data: yupObject({
user_id: yupString().required(),
is_new_user: yupBoolean().required(),
}),
method: yupObject({}),
requestBody: yupObject({
type: yupString().oneOf(["totp"]).required(),
totp: yupString().required(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).required(),
bodyType: yupString().oneOf(["json"]).required(),
body: signInResponseSchema.required(),
}),
async validate(project, method, data, body) {
const user = await prismaClient.projectUser.findUniqueOrThrow({
where: {
projectId_projectUserId: {
projectId: project.id,
projectUserId: data.user_id,
},
},
});
const totpSecret = user.totpSecret;
if (!totpSecret) {
throw new StackAssertionError("User does not have a TOTP secret", { user });
}
const isTotpValid = await new TOTPController().verify(body.totp, totpSecret);
if (!isTotpValid) {
throw new KnownErrors.InvalidTotpCode();
}
},
async handler(project, {}, data, body) {
const { refreshToken, accessToken } = await createAuthTokens({
projectId: project.id,
projectUserId: data.user_id,
});
return {
statusCode: 200,
bodyType: "json",
body: {
refresh_token: refreshToken,
access_token: accessToken,
is_new_user: data.is_new_user,
user_id: data.user_id,
},
};
},
});
export async function createMfaRequiredError(options: { project: ProjectsCrud["Admin"]["Read"], isNewUser: boolean, userId: string }) {
const attemptCode = await mfaVerificationCodeHandler.createCode({
expiresInMs: 1000 * 60 * 5,
project: options.project,
data: {
user_id: options.userId,
is_new_user: options.isNewUser,
},
method: {},
callbackUrl: undefined,
});
return new KnownErrors.MultiFactorAuthenticationRequired(attemptCode.code);
}

View File

@ -14,6 +14,7 @@ import { extractScopes } from "@stackframe/stack-shared/dist/utils/strings";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { oauthResponseToSmartResponse } from "../../oauth-helpers";
import { createMfaRequiredError } from "../../../mfa/sign-in/verification-code-handler";
const redirectOrThrowError = (error: KnownError, project: ProjectsCrud["Admin"]["Read"], errorRedirectUrl?: string) => {
if (!errorRedirectUrl || !validateRedirectUrl(errorRedirectUrl, project.config.domains, project.config.allow_localhost)) {
@ -231,46 +232,73 @@ export const GET = createSmartRouteHandler({
newUser: false,
afterCallbackRedirectUrl,
};
}
} else {
// ========================== sign in user ==========================
// ========================== sign in user ==========================
if (oldAccount) {
await storeTokens();
const projectUser = await prismaClient.projectUser.findUniqueOrThrow({
where: {
projectId_projectUserId: {
projectId: outerInfo.projectId,
projectUserId: oldAccount.projectUserId,
},
},
});
if (projectUser.requiresTotpMfa) {
throw await createMfaRequiredError({
project,
userId: projectUser.projectUserId,
isNewUser: false,
});
}
return {
id: oldAccount.projectUserId,
newUser: false,
afterCallbackRedirectUrl,
};
}
// ========================== sign up user ==========================
if (!project.config.sign_up_enabled) {
throw new KnownErrors.SignUpNotEnabled();
}
const newAccount = await usersCrudHandlers.adminCreate({
project,
data: {
display_name: userInfo.displayName,
profile_image_url: userInfo.profileImageUrl || undefined,
primary_email: userInfo.email,
primary_email_verified: false, // TODO: check if email is verified with the provider
primary_email_auth_enabled: false,
oauth_providers: [{
id: provider.id,
account_id: userInfo.accountId,
email: userInfo.email,
}],
},
});
if (newAccount.requires_totp_mfa) {
throw await createMfaRequiredError({
project,
userId: newAccount.id,
isNewUser: true,
});
}
if (oldAccount) {
await storeTokens();
return {
id: oldAccount.projectUserId,
newUser: false,
id: newAccount.id,
newUser: true,
afterCallbackRedirectUrl,
};
}
// ========================== sign up user ==========================
if (!project.config.sign_up_enabled) {
throw new KnownErrors.SignUpNotEnabled();
}
const newAccount = await usersCrudHandlers.adminCreate({
project,
data: {
display_name: userInfo.displayName,
profile_image_url: userInfo.profileImageUrl || undefined,
primary_email: userInfo.email,
primary_email_verified: false, // TODO: check if email is verified with the provider
primary_email_auth_enabled: false,
oauth_providers: [{
id: provider.id,
account_id: userInfo.accountId,
email: userInfo.email,
}],
},
});
await storeTokens();
return {
id: newAccount.id,
newUser: true,
afterCallbackRedirectUrl,
};
}
}
}

View File

@ -7,6 +7,8 @@ import { signInResponseSchema } from "@stackframe/stack-shared/dist/schema-field
import { VerificationCodeType } from "@prisma/client";
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
import { sendEmailFromTemplate } from "@/lib/emails";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { createMfaRequiredError } from "../../mfa/sign-in/verification-code-handler";
export const signInVerificationCodeHandler = createVerificationCodeHandler({
metadata: {
@ -26,6 +28,9 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({
user_id: yupString().required(),
is_new_user: yupBoolean().required(),
}),
method: yupObject({
email: yupString().email().required(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).required(),
bodyType: yupString().oneOf(["json"]).required(),
@ -45,6 +50,22 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({
});
},
async handler(project, { email }, data) {
const projectUserBefore = await prismaClient.projectUser.findUniqueOrThrow({
where: {
projectId_projectUserId: {
projectId: project.id,
projectUserId: data.user_id,
},
},
});
if (projectUserBefore.requiresTotpMfa) {
throw await createMfaRequiredError({
project,
isNewUser: data.is_new_user,
userId: projectUserBefore.projectUserId,
});
}
const projectUser = await prismaClient.projectUser.update({
where: {
projectId_projectUserId: {

View File

@ -1,8 +1,6 @@
import { yupObject, yupString, yupNumber, yupBoolean } from "@stackframe/stack-shared/dist/schema-fields";
import { prismaClient } from "@/prisma-client";
import { createAuthTokens } from "@/lib/tokens";
import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler";
import { signInResponseSchema } from "@stackframe/stack-shared/dist/schema-fields";
import { VerificationCodeType } from "@prisma/client";
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
import { sendEmailFromTemplate } from "@/lib/emails";
@ -27,6 +25,9 @@ export const resetPasswordVerificationCodeHandler = createVerificationCodeHandle
data: yupObject({
user_id: yupString().required(),
}),
method: yupObject({
email: yupString().email().required(),
}),
requestBody: yupObject({
password: yupString().required(),
}).required(),

View File

@ -5,6 +5,7 @@ import { KnownErrors } from "@stackframe/stack-shared";
import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { comparePassword } from "@stackframe/stack-shared/dist/utils/password";
import { createMfaRequiredError } from "../../mfa/sign-in/verification-code-handler";
export const POST = createSmartRouteHandler({
metadata: {
@ -56,6 +57,14 @@ export const POST = createSmartRouteHandler({
throw new StackAssertionError("This should never happen (the comparePassword call should've already caused this to fail)");
}
if (user.requiresTotpMfa) {
throw await createMfaRequiredError({
project,
isNewUser: false,
userId: user.projectUserId,
});
}
const { refreshToken, accessToken } = await createAuthTokens({
projectId: project.id,
projectUserId: user.projectUserId,

View File

@ -8,6 +8,7 @@ import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { KnownErrors } from "@stackframe/stack-shared";
import { usersCrudHandlers } from "../../../users/crud";
import { contactChannelVerificationCodeHandler } from "../../../contact-channels/verify/verification-code-handler";
import { createMfaRequiredError } from "../../mfa/sign-in/verification-code-handler";
export const POST = createSmartRouteHandler({
metadata: {
@ -73,6 +74,14 @@ export const POST = createSmartRouteHandler({
user: createdUser,
});
if (createdUser.requires_totp_mfa) {
throw await createMfaRequiredError({
project,
isNewUser: true,
userId: createdUser.id,
});
}
const { refreshToken, accessToken } = await createAuthTokens({
projectId: project.id,
projectUserId: createdUser.id,

View File

@ -23,6 +23,9 @@ export const contactChannelVerificationCodeHandler = createVerificationCodeHandl
data: yupObject({
user_id: yupString().required(),
}).required(),
method: yupObject({
email: yupString().email().required(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).required(),
bodyType: yupString().oneOf(["success"]).required(),

View File

@ -26,6 +26,9 @@ export const teamInvitationCodeHandler = createVerificationCodeHandler({
data: yupObject({
team_id: yupString().required(),
}).required(),
method: yupObject({
email: yupString().email().required(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).required(),
bodyType: yupString().oneOf(["json"]).required(),

View File

@ -12,6 +12,7 @@ import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
import { teamPrismaToCrud } from "../teams/crud";
import { sendUserCreatedWebhook, sendUserDeletedWebhook, sendUserUpdatedWebhook } from "@/lib/webhooks";
import { getPasswordError } from "@stackframe/stack-shared/dist/helpers/password";
import { decodeBase64, encodeBase64 } from "@stackframe/stack-shared/dist/utils/bytes";
const fullInclude = {
projectUserOAuthAccounts: {
@ -83,6 +84,7 @@ const prismaToCrud = (prisma: Prisma.ProjectUserGetPayload<{ include: typeof ful
server_metadata: prisma.serverMetadata,
has_password: !!prisma.passwordHash,
auth_with_email: prisma.authWithEmail,
requires_totp_mfa: prisma.requiresTotpMfa,
oauth_providers: prisma.projectUserOAuthAccounts.map((a) => ({
id: a.oauthProviderConfigId,
account_id: a.providerAccountId,
@ -183,6 +185,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
}))
}
} : undefined,
totpSecret: data.totp_secret_base64 == null ? data.totp_secret_base64 : Buffer.from(decodeBase64(data.totp_secret_base64)),
},
include: fullInclude,
});
@ -251,6 +254,8 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
authWithEmail: data.primary_email_auth_enabled,
passwordHash: data.password == null ? data.password : await hashPassword(data.password),
profileImageUrl: data.profile_image_url,
requiresTotpMfa: data.totp_secret_base64 === undefined ? undefined : (data.totp_secret_base64 !== null),
totpSecret: data.totp_secret_base64 == null ? data.totp_secret_base64 : Buffer.from(decodeBase64(data.totp_secret_base64)),
},
include: fullInclude,
});

View File

@ -13,11 +13,7 @@ import { DeepPartial } from "@stackframe/stack-shared/dist/utils/objects";
import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects";
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
type Method = {
email: string,
};
type CreateCodeOptions<Data, CallbackUrl extends string | URL = string | URL> = {
type CreateCodeOptions<Data, Method extends {}, CallbackUrl extends string | URL | undefined> = {
project: ProjectsCrud["Admin"]["Read"],
method: Method,
expiresInMs?: number,
@ -25,15 +21,15 @@ type CreateCodeOptions<Data, CallbackUrl extends string | URL = string | URL> =
callbackUrl: CallbackUrl,
};
type CodeObject = {
type CodeObject<CallbackUrl extends string | URL | undefined> = {
code: string,
link: URL,
link: CallbackUrl extends string | URL ? URL : undefined,
expiresAt: Date,
};
type VerificationCodeHandler<Data, SendCodeExtraOptions extends {}, HasDetails extends boolean> = {
createCode<CallbackUrl extends string | URL>(options: CreateCodeOptions<Data, CallbackUrl>): Promise<CodeObject>,
sendCode(options: CreateCodeOptions<Data>, sendOptions: SendCodeExtraOptions): Promise<void>,
type VerificationCodeHandler<Data, SendCodeExtraOptions extends {}, HasDetails extends boolean, Method extends {}> = {
createCode<CallbackUrl extends string | URL | undefined>(options: CreateCodeOptions<Data, Method, CallbackUrl>): Promise<CodeObject<CallbackUrl>>,
sendCode(options: CreateCodeOptions<Data, Method, string | URL>, sendOptions: SendCodeExtraOptions): Promise<void>,
postHandler: SmartRouteHandler<any, any, any>,
checkHandler: SmartRouteHandler<any, any, any>,
detailsHandler: HasDetails extends true ? SmartRouteHandler<any, any, any> : undefined,
@ -49,6 +45,7 @@ export function createVerificationCodeHandler<
DetailsResponse extends SmartResponse | undefined,
UserRequired extends boolean,
SendCodeExtraOptions extends {},
Method extends {},
>(options: {
metadata?: {
post?: SmartRouteHandlerOverloadMetadata,
@ -57,15 +54,23 @@ export function createVerificationCodeHandler<
},
type: VerificationCodeType,
data: yup.Schema<Data>,
method: yup.Schema<Method>,
requestBody?: yup.ObjectSchema<RequestBody>,
userRequired?: UserRequired,
detailsResponse?: yup.Schema<DetailsResponse>,
response: yup.Schema<Response>,
send(
codeObject: CodeObject,
createOptions: CreateCodeOptions<Data>,
send?(
codeObject: CodeObject<string | URL>,
createOptions: CreateCodeOptions<Data, Method, string | URL>,
sendOptions: SendCodeExtraOptions,
): Promise<void>,
validate?(
project: ProjectsCrud["Admin"]["Read"],
method: Method,
data: Data,
body: RequestBody,
user: UserRequired extends true ? UsersCrud["Admin"]["Read"] : undefined
): Promise<void>,
handler(
project: ProjectsCrud["Admin"]["Read"],
method: Method,
@ -80,7 +85,7 @@ export function createVerificationCodeHandler<
body: RequestBody,
user: UserRequired extends true ? UsersCrud["Admin"]["Read"] : undefined
) => Promise<DetailsResponse>) : undefined,
}): VerificationCodeHandler<Data, SendCodeExtraOptions, DetailsResponse extends SmartResponse ? true : false> {
}): VerificationCodeHandler<Data, SendCodeExtraOptions, DetailsResponse extends SmartResponse ? true : false, Method> {
const createHandler = (type: 'post' | 'check' | 'details') => createSmartRouteHandler({
metadata: options.metadata?.[type],
request: yupObject({
@ -119,10 +124,17 @@ export function createVerificationCodeHandler<
if (verificationCode.expiresAt < new Date()) throw new KnownErrors.VerificationCodeExpired();
if (verificationCode.usedAt) throw new KnownErrors.VerificationCodeAlreadyUsed();
const validatedMethod = await options.method.validate(verificationCode.method, {
strict: true,
});
const validatedData = await options.data.validate(verificationCode.data, {
strict: true,
});
if (options.validate) {
await options.validate(auth.project, validatedMethod, validatedData, requestBody as any, auth.user as any);
}
switch (type) {
case 'post': {
await prismaClient.verificationCode.update({
@ -137,7 +149,7 @@ export function createVerificationCodeHandler<
},
});
return await options.handler(auth.project, { email: verificationCode.email }, validatedData as any, requestBody as any, auth.user as any);
return await options.handler(auth.project, validatedMethod, validatedData, requestBody as any, auth.user as any);
}
case 'check': {
return {
@ -149,7 +161,7 @@ export function createVerificationCodeHandler<
};
}
case 'details': {
return await options.details?.(auth.project, { email: verificationCode.email }, validatedData as any, requestBody as any, auth.user as any) as any;
return await options.details?.(auth.project, validatedMethod, validatedData, requestBody as any, auth.user as any) as any;
}
}
},
@ -157,15 +169,11 @@ export function createVerificationCodeHandler<
return {
async createCode({ project, method, data, callbackUrl, expiresInMs }) {
if (!method.email) {
throw new StackAssertionError("No method specified");
}
const validatedData = await options.data.validate(data, {
strict: true,
});
if (!validateRedirectUrl(
if (callbackUrl !== undefined && !validateRedirectUrl(
callbackUrl,
project.config.domains,
project.config.allow_localhost,
@ -178,15 +186,18 @@ export function createVerificationCodeHandler<
projectId: project.id,
type: options.type,
code: generateSecureRandomString(),
redirectUrl: callbackUrl.toString(),
redirectUrl: callbackUrl?.toString(),
expiresAt: new Date(Date.now() + (expiresInMs ?? 1000 * 60 * 60 * 24 * 7)), // default: expire after 7 days
data: validatedData as any,
email: method.email,
method: method,
}
});
const link = new URL(callbackUrl);
link.searchParams.set('code', verificationCodePrisma.code);
let link;
if (callbackUrl !== undefined) {
link = new URL(callbackUrl);
link.searchParams.set('code', verificationCodePrisma.code);
}
return {
code: verificationCodePrisma.code,
@ -195,10 +206,13 @@ export function createVerificationCodeHandler<
},
async sendCode(createOptions, sendOptions) {
const codeObj = await this.createCode(createOptions);
if (!options.send) {
throw new StackAssertionError("Cannot use sendCode on this verification code handler because it doesn't have a send function");
}
await options.send(codeObj, createOptions, sendOptions);
},
postHandler: createHandler('post'),
checkHandler: createHandler('check'),
detailsHandler: (options.detailsResponse ? createHandler('details') : undefined) as any,
};
}
}

View File

@ -239,6 +239,9 @@ model ProjectUser {
passwordHash String?
authWithEmail Boolean
requiresTotpMfa Boolean @default(false)
totpSecret Bytes?
serverMetadata Json?
clientMetadata Json?
@ -359,7 +362,9 @@ model VerificationCode {
usedAt DateTime?
redirectUrl String?
email String
// @deprecated in favor of method (TODO next-release; this is no longer used)
email String @default("")
method Json
data Json
@ -372,6 +377,7 @@ enum VerificationCodeType {
PASSWORD_RESET
CONTACT_CHANNEL_VERIFICATION
TEAM_INVITATION
MFA_ATTEMPT
}
// @deprecated

View File

@ -11,8 +11,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@stackframe/stack-shared": "workspace:*",
"dotenv": "^16.4.5",
"@stackframe/stack-shared": "workspace:*"
},
"devDependencies": {}
"oslo": "^1.2.1"
}
}

View File

@ -1,4 +1,5 @@
import { InternalProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects";
import { encodeBase64 } from "@stackframe/stack-shared/dist/utils/bytes";
import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { filterUndefined } from "@stackframe/stack-shared/dist/utils/objects";
@ -494,6 +495,28 @@ export namespace Auth {
};
}
}
export namespace Mfa {
export async function setupTotpMfa() {
const totpSecretBytes = crypto.getRandomValues(new Uint8Array(20));
const totpSecretBase64 = encodeBase64(totpSecretBytes);
const response = await niceBackendFetch("/api/v1/users/me", {
accessType: "client",
method: "PATCH",
body: {
totp_secret_base64: totpSecretBase64,
},
});
expect(response).toMatchObject({
status: 200,
});
return {
setupTotpMfaResponse: response,
totpSecret: totpSecretBytes,
};
}
}
}
export namespace ContactChannels {

View File

@ -0,0 +1,140 @@
import { TOTPController } from "oslo/otp";
import { it } from "../../../../../../helpers";
import { Auth, backendContext, niceBackendFetch } from "../../../../../backend-helpers";
it("should sign in users with MFA enabled", async ({ expect }) => {
const passwordRes = await Auth.Password.signUpWithEmail();
const { totpSecret } = await Auth.Mfa.setupTotpMfa();
await Auth.signOut();
const signInRes = await niceBackendFetch("/api/v1/auth/password/sign-in", {
method: "POST",
accessType: "client",
body: {
email: backendContext.value.mailbox.emailAddress,
password: passwordRes.password,
},
});
expect(signInRes).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "MULTI_FACTOR_AUTHENTICATION_REQUIRED",
"details": { "attempt_code": <stripped field 'attempt_code'> },
"error": "Multi-factor authentication is required for this user.",
},
"headers": Headers {
"x-stack-known-error": "MULTI_FACTOR_AUTHENTICATION_REQUIRED",
<some fields may have been hidden>,
},
}
`);
const totp = await new TOTPController().generate(totpSecret);
const response = await niceBackendFetch("/api/v1/auth/mfa/sign-in", {
accessType: "client",
method: "POST",
body: {
code: signInRes.body.details.attempt_code,
type: "totp",
totp,
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"access_token": <stripped field 'access_token'>,
"is_new_user": false,
"refresh_token": <stripped field 'refresh_token'>,
"user_id": "<stripped UUID>",
},
"headers": Headers { <some fields may have been hidden> },
}
`);
backendContext.set({
userAuth: {
accessToken: response.body.access_token,
refreshToken: response.body.refresh_token,
},
});
await Auth.expectToBeSignedIn();
});
it("should reject invalid attempt codes", async ({ expect }) => {
const passwordRes = await Auth.Password.signUpWithEmail();
const { totpSecret } = await Auth.Mfa.setupTotpMfa();
await Auth.signOut();
const totp = await new TOTPController().generate(totpSecret);
const response = await niceBackendFetch("/api/v1/auth/mfa/sign-in", {
accessType: "client",
method: "POST",
body: {
code: "invalid-attempt-code",
type: "totp",
totp,
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 404,
"body": {
"code": "VERIFICATION_CODE_NOT_FOUND",
"error": "The verification code does not exist for this project.",
},
"headers": Headers {
"x-stack-known-error": "VERIFICATION_CODE_NOT_FOUND",
<some fields may have been hidden>,
},
}
`);
});
it("should reject invalid totp codes", async ({ expect }) => {
const passwordRes = await Auth.Password.signUpWithEmail();
const { totpSecret } = await Auth.Mfa.setupTotpMfa();
await Auth.signOut();
const signInRes = await niceBackendFetch("/api/v1/auth/password/sign-in", {
method: "POST",
accessType: "client",
body: {
email: backendContext.value.mailbox.emailAddress,
password: passwordRes.password,
},
});
expect(signInRes).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "MULTI_FACTOR_AUTHENTICATION_REQUIRED",
"details": { "attempt_code": <stripped field 'attempt_code'> },
"error": "Multi-factor authentication is required for this user.",
},
"headers": Headers {
"x-stack-known-error": "MULTI_FACTOR_AUTHENTICATION_REQUIRED",
<some fields may have been hidden>,
},
}
`);
const response = await niceBackendFetch("/api/v1/auth/mfa/sign-in", {
accessType: "client",
method: "POST",
body: {
code: signInRes.body.details.attempt_code,
type: "totp",
totp: "never-valid-totp",
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "INVALID_TOTP_CODE",
"error": "The given TOTP code is invalid. Please try again.",
},
"headers": Headers {
"x-stack-known-error": "INVALID_TOTP_CODE",
<some fields may have been hidden>,
},
}
`);
});

View File

@ -149,3 +149,33 @@ it("should fail if an untrusted redirect URL is provided", async ({ expect }) =>
}
`);
});
it("should fail when MFA is required", async ({ expect }) => {
await Auth.OAuth.signIn();
await Auth.Mfa.setupTotpMfa();
await Auth.signOut();
const getInnerCallbackUrlResponse = await Auth.OAuth.getInnerCallbackUrl();
const cookie = updateCookiesFromResponse("", getInnerCallbackUrlResponse.authorizeResponse);
const response = await niceBackendFetch(getInnerCallbackUrlResponse.innerCallbackUrl, {
redirect: "manual",
headers: {
cookie,
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "MULTI_FACTOR_AUTHENTICATION_REQUIRED",
"details": { "attempt_code": <stripped field 'attempt_code'> },
"error": "Multi-factor authentication is required for this user.",
},
"headers": Headers {
"set-cookie": <deleting cookie 'stack-oauth-inner-<stripped cookie name key>' at path '/'>,
"x-stack-known-error": "MULTI_FACTOR_AUTHENTICATION_REQUIRED",
<some fields may have been hidden>,
},
}
`);
});

View File

@ -65,6 +65,7 @@ describe("with grant_type === 'authorization_code'", async () => {
"primary_email": "<stripped UUID>@stack-generated.example.com",
"primary_email_verified": false,
"profile_image_url": null,
"requires_totp_mfa": false,
"selected_team": null,
"selected_team_id": null,
"signed_up_at_millis": <stripped field 'signed_up_at_millis'>,

View File

@ -1,3 +1,4 @@
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { it } from "../../../../../../helpers";
import { Auth, Project, backendContext, niceBackendFetch } from "../../../../../backend-helpers";
@ -105,6 +106,39 @@ it("should sign up a new user even if one already exists with email auth disable
`);
});
it("should not allow signing in when MFA is required", async ({ expect }) => {
const res = await Auth.Otp.signIn();
await Auth.Mfa.setupTotpMfa();
await Auth.signOut();
const mailbox = backendContext.value.mailbox;
const sendSignInCodeRes = await Auth.Otp.sendSignInCode();
const messages = await mailbox.fetchMessages();
const message = messages.findLast((message) => message.subject.includes("Sign in to")) ?? throwErr("Sign-in code message not found");
const signInCode = message.body?.text.match(/http:\/\/localhost:12345\/some-callback-url\?code=([a-zA-Z0-9]+)/)?.[1] ?? throwErr("Sign-in URL not found");
const response = await niceBackendFetch("/api/v1/auth/otp/sign-in", {
method: "POST",
accessType: "client",
body: {
code: signInCode,
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "MULTI_FACTOR_AUTHENTICATION_REQUIRED",
"details": { "attempt_code": <stripped field 'attempt_code'> },
"error": "Multi-factor authentication is required for this user.",
},
"headers": Headers {
"x-stack-known-error": "MULTI_FACTOR_AUTHENTICATION_REQUIRED",
<some fields may have been hidden>,
},
}
`);
});
it.todo("should not sign in if primary e-mail changed since sign-in code was sent");

View File

@ -83,3 +83,32 @@ it("should not allow signing in with an incorrect password", async ({ expect })
}
`);
});
it("should not allow signing in when MFA is required", async ({ expect }) => {
const res = await Auth.Password.signUpWithEmail();
await Auth.Mfa.setupTotpMfa();
await Auth.signOut();
const response = await niceBackendFetch("/api/v1/auth/password/sign-in", {
method: "POST",
accessType: "client",
body: {
email: backendContext.value.mailbox.emailAddress,
password: res.password,
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "MULTI_FACTOR_AUTHENTICATION_REQUIRED",
"details": { "attempt_code": <stripped field 'attempt_code'> },
"error": "Multi-factor authentication is required for this user.",
},
"headers": Headers {
"x-stack-known-error": "MULTI_FACTOR_AUTHENTICATION_REQUIRED",
<some fields may have been hidden>,
},
}
`);
});

View File

@ -84,6 +84,7 @@ it("creates a team and manage users on the server", async ({ expect }) => {
"primary_email": "<stripped UUID>@stack-generated.example.com",
"primary_email_verified": true,
"profile_image_url": null,
"requires_totp_mfa": false,
"selected_team": null,
"selected_team_id": null,
"server_metadata": null,
@ -109,6 +110,7 @@ it("creates a team and manage users on the server", async ({ expect }) => {
"primary_email": "<stripped UUID>@stack-generated.example.com",
"primary_email_verified": true,
"profile_image_url": null,
"requires_totp_mfa": false,
"selected_team": null,
"selected_team_id": null,
"server_metadata": null,
@ -164,6 +166,7 @@ it("creates a team and manage users on the server", async ({ expect }) => {
"primary_email": "<stripped UUID>@stack-generated.example.com",
"primary_email_verified": true,
"profile_image_url": null,
"requires_totp_mfa": false,
"selected_team": null,
"selected_team_id": null,
"server_metadata": null,

View File

@ -96,6 +96,7 @@ describe("with client access", () => {
"primary_email": "<stripped UUID>@stack-generated.example.com",
"primary_email_verified": true,
"profile_image_url": null,
"requires_totp_mfa": false,
"selected_team": null,
"selected_team_id": null,
"signed_up_at_millis": <stripped field 'signed_up_at_millis'>,
@ -134,6 +135,7 @@ describe("with client access", () => {
"primary_email": "<stripped UUID>@stack-generated.example.com",
"primary_email_verified": true,
"profile_image_url": null,
"requires_totp_mfa": false,
"selected_team": null,
"selected_team_id": null,
"signed_up_at_millis": <stripped field 'signed_up_at_millis'>,
@ -217,6 +219,7 @@ describe("with client access", () => {
"primary_email": "<stripped UUID>@stack-generated.example.com",
"primary_email_verified": true,
"profile_image_url": null,
"requires_totp_mfa": false,
"selected_team": null,
"selected_team_id": null,
"signed_up_at_millis": <stripped field 'signed_up_at_millis'>,
@ -254,6 +257,7 @@ describe("with client access", () => {
"primary_email": "<stripped UUID>@stack-generated.example.com",
"primary_email_verified": true,
"profile_image_url": null,
"requires_totp_mfa": false,
"selected_team": null,
"selected_team_id": null,
"signed_up_at_millis": <stripped field 'signed_up_at_millis'>,
@ -353,7 +357,7 @@ describe("with client access", () => {
`);
});
it("updating own display name to the empty string should set it to null", async ({ expect }) => {
it("should set own display name to null when set to the empty string", async ({ expect }) => {
await Auth.Otp.signIn();
const response1 = await niceBackendFetch("/api/v1/users/me", {
accessType: "client",
@ -385,6 +389,7 @@ describe("with client access", () => {
"primary_email": "<stripped UUID>@stack-generated.example.com",
"primary_email_verified": true,
"profile_image_url": null,
"requires_totp_mfa": false,
"selected_team": null,
"selected_team_id": null,
"signed_up_at_millis": <stripped field 'signed_up_at_millis'>,
@ -422,6 +427,7 @@ describe("with client access", () => {
"primary_email": "<stripped UUID>@stack-generated.example.com",
"primary_email_verified": true,
"profile_image_url": null,
"requires_totp_mfa": false,
"selected_team": null,
"selected_team_id": null,
"signed_up_at_millis": <stripped field 'signed_up_at_millis'>,
@ -431,6 +437,45 @@ describe("with client access", () => {
`);
});
it("should be able to update totp_secret_base64 to valid base64", async ({ expect }) => {
await Auth.Otp.signIn();
const secret = generateSecureRandomString(32);
const response = await niceBackendFetch("/api/v1/users/me", {
accessType: "client",
method: "PATCH",
body: {
totp_secret_base64: "ZXhhbXBsZSB2YWx1ZQ==",
},
});
expect(response.status).toEqual(200);
});
it("should not be able to update totp_secret_base64 to invalid base64", async ({ expect }) => {
await Auth.Otp.signIn();
const response = await niceBackendFetch("/api/v1/users/me", {
accessType: "client",
method: "PATCH",
body: {
totp_secret_base64: "not-valid-base64",
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "SCHEMA_ERROR",
"details": { "message": "Request validation failed on PATCH /api/v1/users/me:\\n - Invalid base64 format" },
"error": "Request validation failed on PATCH /api/v1/users/me:\\n - Invalid base64 format",
},
"headers": Headers {
"x-stack-known-error": "SCHEMA_ERROR",
<some fields may have been hidden>,
},
}
`);
});
it("should not be able to list users", async ({ expect }) => {
const response = await niceBackendFetch("/api/v1/users", {
accessType: "client",
@ -519,6 +564,7 @@ describe("with server access", () => {
"primary_email": "<stripped UUID>@stack-generated.example.com",
"primary_email_verified": true,
"profile_image_url": null,
"requires_totp_mfa": false,
"selected_team": null,
"selected_team_id": null,
"server_metadata": null,
@ -561,6 +607,7 @@ describe("with server access", () => {
"primary_email": "<stripped UUID>@stack-generated.example.com",
"primary_email_verified": true,
"profile_image_url": null,
"requires_totp_mfa": false,
"selected_team": null,
"selected_team_id": null,
"server_metadata": null,
@ -623,6 +670,7 @@ describe("with server access", () => {
"primary_email": "<stripped UUID>@stack-generated.example.com",
"primary_email_verified": true,
"profile_image_url": null,
"requires_totp_mfa": false,
"selected_team": null,
"selected_team_id": null,
"server_metadata": null,
@ -655,6 +703,7 @@ describe("with server access", () => {
"primary_email": null,
"primary_email_verified": false,
"profile_image_url": null,
"requires_totp_mfa": false,
"selected_team": null,
"selected_team_id": null,
"server_metadata": null,
@ -699,6 +748,7 @@ describe("with server access", () => {
"primary_email": "<stripped UUID>@stack-generated.example.com",
"primary_email_verified": false,
"profile_image_url": null,
"requires_totp_mfa": false,
"selected_team": null,
"selected_team_id": null,
"server_metadata": "test",
@ -747,6 +797,7 @@ describe("with server access", () => {
"primary_email": "<stripped UUID>@stack-generated.example.com",
"primary_email_verified": false,
"profile_image_url": null,
"requires_totp_mfa": false,
"selected_team": null,
"selected_team_id": null,
"server_metadata": null,
@ -836,6 +887,7 @@ describe("with server access", () => {
"primary_email": "<stripped UUID>@stack-generated.example.com",
"primary_email_verified": false,
"profile_image_url": null,
"requires_totp_mfa": false,
"selected_team": null,
"selected_team_id": null,
"server_metadata": null,
@ -890,6 +942,7 @@ describe("with server access", () => {
"primary_email": "<stripped UUID>@stack-generated.example.com",
"primary_email_verified": false,
"profile_image_url": null,
"requires_totp_mfa": false,
"selected_team": null,
"selected_team_id": null,
"server_metadata": null,
@ -935,6 +988,7 @@ describe("with server access", () => {
"primary_email": "<stripped UUID>@stack-generated.example.com",
"primary_email_verified": false,
"profile_image_url": null,
"requires_totp_mfa": false,
"selected_team": null,
"selected_team_id": null,
"server_metadata": null,
@ -995,6 +1049,7 @@ describe("with server access", () => {
"primary_email": "<stripped UUID>@stack-generated.example.com",
"primary_email_verified": false,
"profile_image_url": null,
"requires_totp_mfa": false,
"selected_team": null,
"selected_team_id": null,
"server_metadata": null,
@ -1025,6 +1080,7 @@ describe("with server access", () => {
"primary_email": "<stripped UUID>@stack-generated.example.com",
"primary_email_verified": false,
"profile_image_url": null,
"requires_totp_mfa": false,
"selected_team": null,
"selected_team_id": null,
"server_metadata": null,
@ -1087,6 +1143,7 @@ describe("with server access", () => {
"primary_email": "<stripped UUID>@stack-generated.example.com",
"primary_email_verified": true,
"profile_image_url": null,
"requires_totp_mfa": false,
"selected_team": null,
"selected_team_id": null,
"server_metadata": { "key": "value" },
@ -1121,6 +1178,7 @@ describe("with server access", () => {
"primary_email": "<stripped UUID>@stack-generated.example.com",
"primary_email_verified": true,
"profile_image_url": null,
"requires_totp_mfa": false,
"selected_team": null,
"selected_team_id": null,
"server_metadata": { "key": "value" },

View File

@ -39,6 +39,7 @@ const stripFields = [
"publishable_client_key",
"secret_server_key",
"super_secret_admin_key",
"attempt_code",
] as const;
const keyedCookieNamePrefixes = [

View File

@ -7,6 +7,7 @@ const clientUpdateSchema = usersCrudServerUpdateSchema.pick([
"display_name",
"client_metadata",
"selected_team_id",
"totp_secret_base64",
]).required();
const serverUpdateSchema = usersCrudServerUpdateSchema;
@ -25,6 +26,7 @@ const clientReadSchema = usersCrudServerReadSchema.pick([
"selected_team_id",
"auth_methods",
"connected_accounts",
"requires_totp_mfa",
]).concat(yupObject({
selected_team: teamsCrudClientReadSchema.nullable().defined(),
})).nullable().defined(); // TODO: next-release: make required

View File

@ -12,6 +12,7 @@ export const usersCrudServerUpdateSchema = fieldSchema.yupObject({
primary_email_verified: fieldSchema.primaryEmailVerifiedSchema.optional(),
primary_email_auth_enabled: fieldSchema.yupBoolean().optional().meta({ openapiField: { description: "Whether the primary email can be used to sign into this user's account", exampleValue: true } }),
password: fieldSchema.yupString().nullable().meta({ openapiField: { description: 'A new password for the user, overwriting the old one (if it exists).', exampleValue: 'password' } }),
totp_secret_base64: fieldSchema.base64Schema.nullable().meta({ openapiField: { description: 'A TOTP secret for the user, overwriting the old one (if it exists). Set to null to disable 2FA.', exampleValue: 'dG90cC1zZWNyZXQ=' } }),
selected_team_id: fieldSchema.selectedTeamIdSchema.nullable().optional(),
}).required();
@ -29,6 +30,10 @@ export const usersCrudServerReadSchema = fieldSchema.yupObject({
* @deprecated
*/
auth_with_email: fieldSchema.yupBoolean().required().meta({ openapiField: { hidden: true, description: 'Whether the user can authenticate with their primary e-mail. If set to true, the user can log-in with credentials and/or magic link, if enabled in the project settings.', exampleValue: true } }),
/**
* @deprecated
*/
requires_totp_mfa: fieldSchema.yupBoolean().required().meta({ openapiField: { hidden: true, description: 'Whether the user is required to use TOTP MFA to sign in', exampleValue: false } }),
/**
* @deprecated
*/

View File

@ -1,6 +1,7 @@
import { StackAssertionError, StatusError, throwErr } from "./utils/errors";
import { identityArgs } from "./utils/functions";
import { Json } from "./utils/json";
import { filterUndefined } from "./utils/objects";
import { deindent } from "./utils/strings";
export type KnownErrorJson = {
@ -1009,6 +1010,29 @@ const OAuthProviderNotFoundOrNotEnabled = createKnownErrorConstructor(
() => [] as const,
);
const MultiFactorAuthenticationRequired = createKnownErrorConstructor(
KnownError,
"MULTI_FACTOR_AUTHENTICATION_REQUIRED",
(attemptCode: string) => [
400,
"Multi-factor authentication is required for this user.",
{
attempt_code: attemptCode,
},
] as const,
(json) => [json.attempt_code] as const,
);
const InvalidTotpCode = createKnownErrorConstructor(
KnownError,
"INVALID_TOTP_CODE",
() => [
400,
"The given TOTP code is invalid. Please try again.",
] as const,
() => [] as const,
);
const UserAuthenticationRequired = createKnownErrorConstructor(
KnownError,
"USER_AUTHENTICATION_REQUIRED",
@ -1176,6 +1200,8 @@ export const KnownErrors = {
UserAlreadyConnectedToAnotherOAuthConnection,
OuterOAuthTimeout,
OAuthProviderNotFoundOrNotEnabled,
MultiFactorAuthenticationRequired,
InvalidTotpCode,
UserAuthenticationRequired,
TeamMembershipAlreadyExists,
TeamPermissionRequired,

View File

@ -1,4 +1,5 @@
import * as yup from "yup";
import { isBase64 } from "./utils/bytes";
import { StackAssertionError } from "./utils/errors";
import { allProviders } from "./utils/oauth";
import { isUuid } from "./utils/uuids";
@ -130,6 +131,10 @@ export const jsonStringOrEmptySchema = yupString().test("json", "Invalid JSON fo
}
});
export const emailSchema = yupString().email();
export const base64Schema = yupString().test("is-base64", "Invalid base64 format", (value) => {
if (value == null) return true;
return isBase64(value);
});
// Request auth
export const clientOrHigherAuthTypeSchema = yupString().oneOf(['client', 'server', 'admin']);

View File

@ -1,3 +1,5 @@
import { StackAssertionError } from "./errors";
const crockfordAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const crockfordReplacements = new Map([
["o", "0"],
@ -20,10 +22,20 @@ export function encodeBase32(input: Uint8Array): string {
if (bits > 0) {
output += crockfordAlphabet[(value << (5 - bits)) & 31];
}
// sanity check
if (!isBase32(output)) {
throw new StackAssertionError("Invalid base32 output; this should never happen");
}
return output;
}
export function decodeBase32(input: string): Uint8Array {
if (!isBase32(input)) {
throw new StackAssertionError("Invalid base32 string");
}
const output = new Uint8Array((input.length * 5 / 8) | 0);
let bits = 0;
let value = 0;
@ -47,3 +59,37 @@ export function decodeBase32(input: string): Uint8Array {
}
return output;
}
export function encodeBase64(input: Uint8Array): string {
const res = btoa(String.fromCharCode(...input));
// sanity check
if (!isBase64(res)) {
throw new StackAssertionError("Invalid base64 output; this should never happen");
}
return res;
}
export function decodeBase64(input: string): Uint8Array {
if (!isBase64(input)) {
throw new StackAssertionError("Invalid base64 string");
}
return new Uint8Array(atob(input).split("").map((char) => char.charCodeAt(0)));
}
export function isBase32(input: string): boolean {
for (const char of input) {
if (char === " ") continue;
if (!crockfordAlphabet.includes(char)) {
return false;
}
}
return true;
}
export function isBase64(input: string): boolean {
const regex = /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/;
return regex.test(input);
}

View File

@ -47,6 +47,7 @@
"js-cookie": "^3.0.5",
"lucide-react": "^0.378.0",
"oauth4webapi": "^2.10.3",
"oslo": "^1.2.1",
"react-hook-form": "^7.51.4",
"rimraf": "^5.0.5",
"server-only": "^0.0.1",

View File

@ -119,6 +119,9 @@ importers:
openid-client:
specifier: ^5.6.4
version: 5.6.5
oslo:
specifier: ^1.2.1
version: 1.2.1
pg:
specifier: ^8.11.3
version: 8.12.0
@ -337,6 +340,9 @@ importers:
dotenv:
specifier: ^16.4.5
version: 16.4.5
oslo:
specifier: ^1.2.1
version: 1.2.1
apps/oauth-mock-server:
dependencies:
@ -660,6 +666,9 @@ importers:
oauth4webapi:
specifier: ^2.10.3
version: 2.10.4
oslo:
specifier: ^1.2.1
version: 1.2.1
react-hook-form:
specifier: ^7.51.4
version: 7.52.0(react@18.3.1)
@ -1213,6 +1222,12 @@ packages:
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
engines: {node: '>=10.0.0'}
'@emnapi/core@0.45.0':
resolution: {integrity: sha512-DPWjcUDQkCeEM4VnljEOEcXdAD7pp8zSZsgOujk/LGIwCXWbXJngin+MO4zbH429lzeC3WbYLGjE2MaUOwzpyw==}
'@emnapi/runtime@0.45.0':
resolution: {integrity: sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w==}
'@emnapi/runtime@1.2.0':
resolution: {integrity: sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==}
@ -2174,6 +2189,180 @@ packages:
resolution: {integrity: sha512-sYvqL1GeZLRSwgl++/oOzxJj/ZBe2yXnp6E5LGNQ5qjpn0+t/dwquXILUe3Sk2Y8/wU7XeRxToOtBVeSVkuJag==}
engines: {node: '>=16.0.0'}
'@node-rs/argon2-android-arm-eabi@1.7.0':
resolution: {integrity: sha512-udDqkr5P9E+wYX1SZwAVPdyfYvaF4ry9Tm+R9LkfSHbzWH0uhU6zjIwNRp7m+n4gx691rk+lqqDAIP8RLKwbhg==}
engines: {node: '>= 10'}
cpu: [arm]
os: [android]
'@node-rs/argon2-android-arm64@1.7.0':
resolution: {integrity: sha512-s9j/G30xKUx8WU50WIhF0fIl1EdhBGq0RQ06lEhZ0Gi0ap8lhqbE2Bn5h3/G2D1k0Dx+yjeVVNmt/xOQIRG38A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@node-rs/argon2-darwin-arm64@1.7.0':
resolution: {integrity: sha512-ZIz4L6HGOB9U1kW23g+m7anGNuTZ0RuTw0vNp3o+2DWpb8u8rODq6A8tH4JRL79S+Co/Nq608m9uackN2pe0Rw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@node-rs/argon2-darwin-x64@1.7.0':
resolution: {integrity: sha512-5oi/pxqVhODW/pj1+3zElMTn/YukQeywPHHYDbcAW3KsojFjKySfhcJMd1DjKTc+CHQI+4lOxZzSUzK7mI14Hw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@node-rs/argon2-freebsd-x64@1.7.0':
resolution: {integrity: sha512-Ify08683hA4QVXYoIm5SUWOY5DPIT/CMB0CQT+IdxQAg/F+qp342+lUkeAtD5bvStQuCx/dFO3bnnzoe2clMhA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
'@node-rs/argon2-linux-arm-gnueabihf@1.7.0':
resolution: {integrity: sha512-7DjDZ1h5AUHAtRNjD19RnQatbhL+uuxBASuuXIBu4/w6Dx8n7YPxwTP4MXfsvuRgKuMWiOb/Ub/HJ3kXVCXRkg==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@node-rs/argon2-linux-arm64-gnu@1.7.0':
resolution: {integrity: sha512-nJDoMP4Y3YcqGswE4DvP080w6O24RmnFEDnL0emdI8Nou17kNYBzP2546Nasx9GCyLzRcYQwZOUjrtUuQ+od2g==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@node-rs/argon2-linux-arm64-musl@1.7.0':
resolution: {integrity: sha512-BKWS8iVconhE3jrb9mj6t1J9vwUqQPpzCbUKxfTGJfc+kNL58F1SXHBoe2cDYGnHrFEHTY0YochzXoAfm4Dm/A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@node-rs/argon2-linux-x64-gnu@1.7.0':
resolution: {integrity: sha512-EmgqZOlf4Jurk/szW1iTsVISx25bKksVC5uttJDUloTgsAgIGReCpUUO1R24pBhu9ESJa47iv8NSf3yAfGv6jQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@node-rs/argon2-linux-x64-musl@1.7.0':
resolution: {integrity: sha512-/o1efYCYIxjfuoRYyBTi2Iy+1iFfhqHCvvVsnjNSgO1xWiWrX0Rrt/xXW5Zsl7vS2Y+yu8PL8KFWRzZhaVxfKA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@node-rs/argon2-wasm32-wasi@1.7.0':
resolution: {integrity: sha512-Evmk9VcxqnuwQftfAfYEr6YZYSPLzmKUsbFIMep5nTt9PT4XYRFAERj7wNYp+rOcBenF3X4xoB+LhwcOMTNE5w==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
'@node-rs/argon2-win32-arm64-msvc@1.7.0':
resolution: {integrity: sha512-qgsU7T004COWWpSA0tppDqDxbPLgg8FaU09krIJ7FBl71Sz8SFO40h7fDIjfbTT5w7u6mcaINMQ5bSHu75PCaA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@node-rs/argon2-win32-ia32-msvc@1.7.0':
resolution: {integrity: sha512-JGafwWYQ/HpZ3XSwP4adQ6W41pRvhcdXvpzIWtKvX+17+xEXAe2nmGWM6s27pVkg1iV2ZtoYLRDkOUoGqZkCcg==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@node-rs/argon2-win32-x64-msvc@1.7.0':
resolution: {integrity: sha512-9oq4ShyFakw8AG3mRls0AoCpxBFcimYx7+jvXeAf2OqKNO+mSA6eZ9z7KQeVCi0+SOEUYxMGf5UiGiDb9R6+9Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@node-rs/argon2@1.7.0':
resolution: {integrity: sha512-zfULc+/tmcWcxn+nHkbyY8vP3+MpEqKORbszt4UkpqZgBgDAAIYvuDN/zukfTgdmo6tmJKKVfzigZOPk4LlIog==}
engines: {node: '>= 10'}
'@node-rs/bcrypt-android-arm-eabi@1.9.0':
resolution: {integrity: sha512-nOCFISGtnodGHNiLrG0WYLWr81qQzZKYfmwHc7muUeq+KY0sQXyHOwZk9OuNQAWv/lnntmtbwkwT0QNEmOyLvA==}
engines: {node: '>= 10'}
cpu: [arm]
os: [android]
'@node-rs/bcrypt-android-arm64@1.9.0':
resolution: {integrity: sha512-+ZrIAtigVmjYkqZQTThHVlz0+TG6D+GDHWhVKvR2DifjtqJ0i+mb9gjo++hN+fWEQdWNGxKCiBBjwgT4EcXd6A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@node-rs/bcrypt-darwin-arm64@1.9.0':
resolution: {integrity: sha512-CQiS+F9Pa0XozvkXR1g7uXE9QvBOPOplDg0iCCPRYTN9PqA5qYxhwe48G3o+v2UeQceNRrbnEtWuANm7JRqIhw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@node-rs/bcrypt-darwin-x64@1.9.0':
resolution: {integrity: sha512-4pTKGawYd7sNEjdJ7R/R67uwQH1VvwPZ0SSUMmeNHbxD5QlwAPXdDH11q22uzVXsvNFZ6nGQBg8No5OUGpx6Ug==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@node-rs/bcrypt-freebsd-x64@1.9.0':
resolution: {integrity: sha512-UmWzySX4BJhT/B8xmTru6iFif3h0Rpx3TqxRLCcbgmH43r7k5/9QuhpiyzpvKGpKHJCFNm4F3rC2wghvw5FCIg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
'@node-rs/bcrypt-linux-arm-gnueabihf@1.9.0':
resolution: {integrity: sha512-8qoX4PgBND2cVwsbajoAWo3NwdfJPEXgpCsZQZURz42oMjbGyhhSYbovBCskGU3EBLoC8RA2B1jFWooeYVn5BA==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@node-rs/bcrypt-linux-arm64-gnu@1.9.0':
resolution: {integrity: sha512-TuAC6kx0SbcIA4mSEWPi+OCcDjTQUMl213v5gMNlttF+D4ieIZx6pPDGTaMO6M2PDHTeCG0CBzZl0Lu+9b0c7Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@node-rs/bcrypt-linux-arm64-musl@1.9.0':
resolution: {integrity: sha512-/sIvKDABOI8QOEnLD7hIj02BVaNOuCIWBKvxcJOt8+TuwJ6zmY1UI5kSv9d99WbiHjTp97wtAUbZQwauU4b9ew==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@node-rs/bcrypt-linux-x64-gnu@1.9.0':
resolution: {integrity: sha512-DyyhDHDsLBsCKz1tZ1hLvUZSc1DK0FU0v52jK6IBQxrj24WscSU9zZe7ie/V9kdmA4Ep57BfpWX8Dsa2JxGdgQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@node-rs/bcrypt-linux-x64-musl@1.9.0':
resolution: {integrity: sha512-duIiuqQ+Lew8ASSAYm6ZRqcmfBGWwsi81XLUwz86a2HR7Qv6V4yc3ZAUQovAikhjCsIqe8C11JlAZSK6+PlXYg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@node-rs/bcrypt-wasm32-wasi@1.9.0':
resolution: {integrity: sha512-ylaGmn9Wjwv/D5lxtawttx3H6Uu2WTTR7lWlRHGT6Ga/MB1Vj4OjSGUW8G8zIVnKuXpGbZ92pgHlt4HUpSLctw==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
'@node-rs/bcrypt-win32-arm64-msvc@1.9.0':
resolution: {integrity: sha512-2h86gF7QFyEzODuDFml/Dp1MSJoZjxJ4yyT2Erf4NkwsiA5MqowUhUsorRwZhX6+2CtlGa7orbwi13AKMsYndw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@node-rs/bcrypt-win32-ia32-msvc@1.9.0':
resolution: {integrity: sha512-kqxalCvhs4FkN0+gWWfa4Bdy2NQAkfiqq/CEf6mNXC13RSV673Ev9V8sRlQyNpCHCNkeXfOT9pgoBdJmMs9muA==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@node-rs/bcrypt-win32-x64-msvc@1.9.0':
resolution: {integrity: sha512-2y0Tuo6ZAT2Cz8V7DHulSlv1Bip3zbzeXyeur+uR25IRNYXKvI/P99Zl85Fbuu/zzYAZRLLlGTRe6/9IHofe/w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@node-rs/bcrypt@1.9.0':
resolution: {integrity: sha512-u2OlIxW264bFUfvbFqDz9HZKFjwe8FHFtn7T/U8mYjPZ7DWYpbUB+/dkW/QgYfMSfR0ejkyuWaBBe0coW7/7ig==}
engines: {node: '>= 10'}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@ -3354,6 +3543,9 @@ packages:
'@types/react':
optional: true
'@tybys/wasm-util@0.8.3':
resolution: {integrity: sha512-Z96T/L6dUFFxgFJ+pQtkPpne9q7i6kIPYCFnQBHSgSPV9idTsKfIhCss0h5iM9irweZCatkrdeP8yi5uM1eX6Q==}
'@types/accepts@1.3.7':
resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==}
@ -5035,6 +5227,9 @@ packages:
resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==}
engines: {node: '>= 8'}
fs-monkey@1.0.6:
resolution: {integrity: sha512-b1FMfwetIKymC0eioW7mTywihSQE4oLzQn1dB6rZB5fx/3NpNEdAWeCSMB+60/AeT0TCXsxzAlcYVEFCTAksWg==}
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
@ -5928,6 +6123,13 @@ packages:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
memfs-browser@3.5.10302:
resolution: {integrity: sha512-JJTc/nh3ig05O0gBBGZjTCPOyydaTxNF0uHYBrcc1gHNnO+KIHIvo0Y1FKCJsaei6FCl8C6xfQomXqu+cuzkIw==}
memfs@3.5.3:
resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==}
engines: {node: '>= 4.0.0'}
meow@6.1.1:
resolution: {integrity: sha512-3YffViIt2QWgTy6Pale5QpopX/IvU3LPL03jOTqp6pGj3VjesdO/U8CuHMKpnQr4shCNCM5fd5XFFvIIl6JBHg==}
engines: {node: '>=8'}
@ -6433,6 +6635,9 @@ packages:
resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==}
engines: {node: '>=0.10.0'}
oslo@1.2.1:
resolution: {integrity: sha512-HfIhB5ruTdQv0XX2XlncWQiJ5SIHZ7NHZhVyHth0CSZ/xzge00etRyYy/3wp/Dsu+PkxMC+6+B2lS/GcKoewkA==}
outdent@0.5.0:
resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==}
@ -8566,6 +8771,16 @@ snapshots:
'@discoveryjs/json-ext@0.5.7': {}
'@emnapi/core@0.45.0':
dependencies:
tslib: 2.6.3
optional: true
'@emnapi/runtime@0.45.0':
dependencies:
tslib: 2.6.3
optional: true
'@emnapi/runtime@1.2.0':
dependencies:
tslib: 2.6.3
@ -9272,6 +9487,134 @@ snapshots:
basic-auth: 2.0.1
type-is: 1.6.18
'@node-rs/argon2-android-arm-eabi@1.7.0':
optional: true
'@node-rs/argon2-android-arm64@1.7.0':
optional: true
'@node-rs/argon2-darwin-arm64@1.7.0':
optional: true
'@node-rs/argon2-darwin-x64@1.7.0':
optional: true
'@node-rs/argon2-freebsd-x64@1.7.0':
optional: true
'@node-rs/argon2-linux-arm-gnueabihf@1.7.0':
optional: true
'@node-rs/argon2-linux-arm64-gnu@1.7.0':
optional: true
'@node-rs/argon2-linux-arm64-musl@1.7.0':
optional: true
'@node-rs/argon2-linux-x64-gnu@1.7.0':
optional: true
'@node-rs/argon2-linux-x64-musl@1.7.0':
optional: true
'@node-rs/argon2-wasm32-wasi@1.7.0':
dependencies:
'@emnapi/core': 0.45.0
'@emnapi/runtime': 0.45.0
'@tybys/wasm-util': 0.8.3
memfs-browser: 3.5.10302
optional: true
'@node-rs/argon2-win32-arm64-msvc@1.7.0':
optional: true
'@node-rs/argon2-win32-ia32-msvc@1.7.0':
optional: true
'@node-rs/argon2-win32-x64-msvc@1.7.0':
optional: true
'@node-rs/argon2@1.7.0':
optionalDependencies:
'@node-rs/argon2-android-arm-eabi': 1.7.0
'@node-rs/argon2-android-arm64': 1.7.0
'@node-rs/argon2-darwin-arm64': 1.7.0
'@node-rs/argon2-darwin-x64': 1.7.0
'@node-rs/argon2-freebsd-x64': 1.7.0
'@node-rs/argon2-linux-arm-gnueabihf': 1.7.0
'@node-rs/argon2-linux-arm64-gnu': 1.7.0
'@node-rs/argon2-linux-arm64-musl': 1.7.0
'@node-rs/argon2-linux-x64-gnu': 1.7.0
'@node-rs/argon2-linux-x64-musl': 1.7.0
'@node-rs/argon2-wasm32-wasi': 1.7.0
'@node-rs/argon2-win32-arm64-msvc': 1.7.0
'@node-rs/argon2-win32-ia32-msvc': 1.7.0
'@node-rs/argon2-win32-x64-msvc': 1.7.0
'@node-rs/bcrypt-android-arm-eabi@1.9.0':
optional: true
'@node-rs/bcrypt-android-arm64@1.9.0':
optional: true
'@node-rs/bcrypt-darwin-arm64@1.9.0':
optional: true
'@node-rs/bcrypt-darwin-x64@1.9.0':
optional: true
'@node-rs/bcrypt-freebsd-x64@1.9.0':
optional: true
'@node-rs/bcrypt-linux-arm-gnueabihf@1.9.0':
optional: true
'@node-rs/bcrypt-linux-arm64-gnu@1.9.0':
optional: true
'@node-rs/bcrypt-linux-arm64-musl@1.9.0':
optional: true
'@node-rs/bcrypt-linux-x64-gnu@1.9.0':
optional: true
'@node-rs/bcrypt-linux-x64-musl@1.9.0':
optional: true
'@node-rs/bcrypt-wasm32-wasi@1.9.0':
dependencies:
'@emnapi/core': 0.45.0
'@emnapi/runtime': 0.45.0
'@tybys/wasm-util': 0.8.3
memfs-browser: 3.5.10302
optional: true
'@node-rs/bcrypt-win32-arm64-msvc@1.9.0':
optional: true
'@node-rs/bcrypt-win32-ia32-msvc@1.9.0':
optional: true
'@node-rs/bcrypt-win32-x64-msvc@1.9.0':
optional: true
'@node-rs/bcrypt@1.9.0':
optionalDependencies:
'@node-rs/bcrypt-android-arm-eabi': 1.9.0
'@node-rs/bcrypt-android-arm64': 1.9.0
'@node-rs/bcrypt-darwin-arm64': 1.9.0
'@node-rs/bcrypt-darwin-x64': 1.9.0
'@node-rs/bcrypt-freebsd-x64': 1.9.0
'@node-rs/bcrypt-linux-arm-gnueabihf': 1.9.0
'@node-rs/bcrypt-linux-arm64-gnu': 1.9.0
'@node-rs/bcrypt-linux-arm64-musl': 1.9.0
'@node-rs/bcrypt-linux-x64-gnu': 1.9.0
'@node-rs/bcrypt-linux-x64-musl': 1.9.0
'@node-rs/bcrypt-wasm32-wasi': 1.9.0
'@node-rs/bcrypt-win32-arm64-msvc': 1.9.0
'@node-rs/bcrypt-win32-ia32-msvc': 1.9.0
'@node-rs/bcrypt-win32-x64-msvc': 1.9.0
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@ -10589,6 +10932,11 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.3
'@tybys/wasm-util@0.8.3':
dependencies:
tslib: 2.6.3
optional: true
'@types/accepts@1.3.7':
dependencies:
'@types/node': 20.14.2
@ -12628,6 +12976,9 @@ snapshots:
dependencies:
minipass: 3.3.6
fs-monkey@1.0.6:
optional: true
fs.realpath@1.0.0: {}
fsevents@2.3.3:
@ -13734,6 +14085,16 @@ snapshots:
media-typer@0.3.0: {}
memfs-browser@3.5.10302:
dependencies:
memfs: 3.5.3
optional: true
memfs@3.5.3:
dependencies:
fs-monkey: 1.0.6
optional: true
meow@6.1.1:
dependencies:
'@types/minimist': 1.2.5
@ -14441,6 +14802,11 @@ snapshots:
os-tmpdir@1.0.2: {}
oslo@1.2.1:
dependencies:
'@node-rs/argon2': 1.7.0
'@node-rs/bcrypt': 1.9.0
outdent@0.5.0: {}
p-cancelable@3.0.0: {}