mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
TOTP 2FA endpoints
This commit is contained in:
parent
50fd3dfc2c
commit
1b550e7e48
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -33,6 +33,7 @@
|
||||
"reqs",
|
||||
"stackframe",
|
||||
"Svix",
|
||||
"totp",
|
||||
"typecheck",
|
||||
"typehack",
|
||||
"Uncapitalize",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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';
|
||||
@ -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
|
||||
|
||||
3
apps/backend/src/app/api/v1/auth/mfa/sign-in/route.tsx
Normal file
3
apps/backend/src/app/api/v1/auth/mfa/sign-in/route.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
import { mfaVerificationCodeHandler } from "./verification-code-handler";
|
||||
|
||||
export const POST = mfaVerificationCodeHandler.postHandler;
|
||||
@ -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);
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
140
apps/e2e/tests/backend/endpoints/api/v1/auth/mfa/sign-in.test.ts
Normal file
140
apps/e2e/tests/backend/endpoints/api/v1/auth/mfa/sign-in.test.ts
Normal 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>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
@ -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>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
@ -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'>,
|
||||
|
||||
@ -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");
|
||||
|
||||
|
||||
@ -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>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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" },
|
||||
|
||||
@ -39,6 +39,7 @@ const stripFields = [
|
||||
"publishable_client_key",
|
||||
"secret_server_key",
|
||||
"super_secret_admin_key",
|
||||
"attempt_code",
|
||||
] as const;
|
||||
|
||||
const keyedCookieNamePrefixes = [
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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']);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
366
pnpm-lock.yaml
366
pnpm-lock.yaml
@ -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: {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user