New contact channels (#287)

* removed contact channels from otp

* fixed types

* fixed bugs

* fixed bug

* fixed bugs

* updated user contact channel

* updated tests

* updated tests

* added unique key to otp and password auth

* removed contact channel from user object
This commit is contained in:
Zai Shi 2024-10-01 06:22:12 +02:00 committed by GitHub
parent d0b3d6e620
commit 28c3f57f31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 216 additions and 548 deletions

View File

@ -0,0 +1,64 @@
/*
Warnings:
- You are about to drop the column `contactChannelId` on the `OtpAuthMethod` table. All the data in the column will be lost.
- You are about to drop the column `identifier` on the `PasswordAuthMethod` table. All the data in the column will be lost.
- A unique constraint covering the columns `[projectId,type,value,usedForAuth]` on the table `ContactChannel` will be added. If there are existing duplicate values, this will fail.
- You are about to drop the column `identifierType` on the `PasswordAuthMethod` table. All the data in the column will be lost.
- You are about to drop the column `identifierType` on the `PasswordAuthMethodConfig` table. All the data in the column will be lost.
*/
-- DropForeignKey
ALTER TABLE "OtpAuthMethod" DROP CONSTRAINT "OtpAuthMethod_projectId_projectUserId_contactChannelId_fkey";
-- DropIndex
DROP INDEX "OtpAuthMethod_projectId_contactChannelId_key";
-- DropIndex
DROP INDEX "PasswordAuthMethod_projectId_identifierType_identifier_key";
-- AlterTable
ALTER TABLE "ContactChannel" ADD COLUMN "usedForAuth" "BooleanTrue";
-- Set the usedForAuth value to "TRUE" if the contact channel is used in `OtpAuthMethod` or the value is the same as the `PasswordAuthMethod` of the same user
UPDATE "ContactChannel" cc
SET "usedForAuth" = 'TRUE'
WHERE EXISTS (
SELECT 1
FROM "OtpAuthMethod" oam
WHERE oam."projectId" = cc."projectId"
AND oam."projectUserId" = cc."projectUserId"
)
OR EXISTS (
SELECT 1
FROM "PasswordAuthMethod" pam
WHERE pam."projectId" = cc."projectId"
AND pam."projectUserId" = cc."projectUserId"
AND pam."identifier" = cc."value"
);
-- AlterTable
ALTER TABLE "OtpAuthMethod" DROP COLUMN "contactChannelId";
-- AlterTable
ALTER TABLE "PasswordAuthMethod" DROP COLUMN "identifier";
-- CreateIndex
CREATE UNIQUE INDEX "ContactChannel_projectId_type_value_usedForAuth_key" ON "ContactChannel"("projectId", "type", "value", "usedForAuth");
-- AlterTable
ALTER TABLE "PasswordAuthMethod" DROP COLUMN "identifierType";
-- AlterTable
ALTER TABLE "PasswordAuthMethodConfig" DROP COLUMN "identifierType";
-- DropEnum
DROP TYPE "PasswordAuthMethodIdentifierType";
-- CreateIndex
CREATE UNIQUE INDEX "OtpAuthMethod_projectId_projectUserId_key" ON "OtpAuthMethod"("projectId", "projectUserId");
-- CreateIndex
CREATE UNIQUE INDEX "PasswordAuthMethod_projectId_projectUserId_key" ON "PasswordAuthMethod"("projectId", "projectUserId");

View File

@ -290,20 +290,22 @@ model ContactChannel {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
type ContactChannelType
isPrimary BooleanTrue?
isVerified Boolean
value String
type ContactChannelType
isPrimary BooleanTrue?
usedForAuth BooleanTrue?
isVerified Boolean
value String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectUser ProjectUser @relation(fields: [projectId, projectUserId], references: [projectId, projectUserId], onDelete: Cascade)
otpAuthMethod OtpAuthMethod[]
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectUser ProjectUser @relation(fields: [projectId, projectUserId], references: [projectId, projectUserId], onDelete: Cascade)
@@id([projectId, projectUserId, id])
// each user has at most one primary contact channel of each type
@@unique([projectId, projectUserId, type, isPrimary])
// value must be unique per user per type
@@unique([projectId, projectUserId, type, value])
// only one contact channel per project with the same value and type can be used for auth
@@unique([projectId, type, value, usedForAuth])
}
model ConnectedAccountConfig {
@ -379,11 +381,6 @@ model OtpAuthMethodConfig {
@@id([projectConfigId, authMethodConfigId])
}
enum PasswordAuthMethodIdentifierType {
EMAIL
// USERNAME
}
model PasswordAuthMethodConfig {
projectConfigId String @db.Uuid
authMethodConfigId String @db.Uuid
@ -393,8 +390,6 @@ model PasswordAuthMethodConfig {
authMethodConfig AuthMethodConfig @relation(fields: [projectConfigId, authMethodConfigId], references: [projectConfigId, id], onDelete: Cascade)
identifierType PasswordAuthMethodIdentifierType
@@id([projectConfigId, authMethodConfigId])
}
@ -498,21 +493,19 @@ model AuthMethod {
}
model OtpAuthMethod {
projectId String
authMethodId String @db.Uuid
contactChannelId String @db.Uuid
projectUserId String @db.Uuid
projectId String
authMethodId String @db.Uuid
projectUserId String @db.Uuid
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contactChannel ContactChannel @relation(fields: [projectId, projectUserId, contactChannelId], references: [projectId, projectUserId, id], onDelete: Cascade)
authMethod AuthMethod @relation(fields: [projectId, authMethodId], references: [projectId, id], onDelete: Cascade)
projectUser ProjectUser @relation(fields: [projectId, projectUserId], references: [projectId, projectUserId], onDelete: Cascade)
authMethod AuthMethod @relation(fields: [projectId, authMethodId], references: [projectId, id], onDelete: Cascade)
projectUser ProjectUser @relation(fields: [projectId, projectUserId], references: [projectId, projectUserId], onDelete: Cascade)
@@id([projectId, authMethodId])
// each contact channel can only be used once per project as an otp method
@@unique([projectId, contactChannelId])
// a user can only have one OTP auth method
@@unique([projectId, projectUserId])
}
model PasswordAuthMethod {
@ -523,17 +516,14 @@ model PasswordAuthMethod {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
identifierType PasswordAuthMethodIdentifierType
// The identifier is the email or username, depending on the type.
identifier String
passwordHash String
passwordHash String
authMethod AuthMethod @relation(fields: [projectId, authMethodId], references: [projectId, id], onDelete: Cascade)
projectUser ProjectUser @relation(fields: [projectId, projectUserId], references: [projectId, projectUserId], onDelete: Cascade)
@@id([projectId, authMethodId])
// each identifier of each type can only occur once per project
@@unique([projectId, identifierType, identifier])
// a user can only have one password auth method
@@unique([projectId, projectUserId])
}
// This connects to projectUserOauthAccount, which might be shared between auth method and connected account.

View File

@ -81,9 +81,7 @@ async function seed() {
},
{
passwordConfig: {
create: {
identifierType: 'EMAIL',
}
create: {}
}
},
...(['github', 'spotify', 'google', 'microsoft'] as const).map((id) => ({

View File

@ -39,33 +39,38 @@ export const POST = createSmartRouteHandler({
throw new StatusError(StatusError.Forbidden, "Magic link is not enabled for this project");
}
const authMethods = await prismaClient.otpAuthMethod.findMany({
const contactChannel = await prismaClient.contactChannel.findUnique({
where: {
projectId: project.id,
contactChannel: {
projectId_type_value_usedForAuth: {
projectId: project.id,
type: "EMAIL",
value: email,
},
usedForAuth: "TRUE",
}
},
include: {
projectUser: true,
contactChannel: true,
projectUser: {
include: {
authMethods: {
include: {
otpAuthMethod: true,
}
}
}
}
}
});
if (authMethods.length > 1) {
throw new StackAssertionError("Tried to send OTP sign in code but found multiple auth methods? The uniqueness on the DB schema should prevent this");
}
const authMethod = authMethods.length === 1 ? authMethods[0] : null;
const otpAuthMethod = contactChannel?.projectUser.authMethods.find((m) => m.otpAuthMethod)?.otpAuthMethod;
const isNewUser = !authMethod;
const isNewUser = !otpAuthMethod;
if (isNewUser && !project.config.sign_up_enabled) {
throw new KnownErrors.SignUpNotEnabled();
}
let user;
if (!authMethod) {
if (!otpAuthMethod) {
// TODO this should be in the same transaction as the read above
user = await usersCrudHandlers.adminCreate({
project,
@ -79,7 +84,7 @@ export const POST = createSmartRouteHandler({
} else {
user = await usersCrudHandlers.adminRead({
project,
user_id: authMethod.projectUser.projectUserId,
user_id: contactChannel.projectUser.projectUserId,
});
}

View File

@ -55,33 +55,39 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({
};
},
async handler(project, { email }, data) {
const authMethods = await prismaClient.otpAuthMethod.findMany({
const contactChannel = await prismaClient.contactChannel.findUnique({
where: {
projectId: project.id,
contactChannel: {
projectId_type_value_usedForAuth: {
projectId: project.id,
type: "EMAIL",
value: email,
},
usedForAuth: "TRUE",
}
},
include: {
projectUser: true,
projectUser: {
include: {
authMethods: {
include: {
otpAuthMethod: true,
}
}
}
}
}
});
if (authMethods.length === 0) {
const otpAuthMethod = contactChannel?.projectUser.authMethods.find((m) => m.otpAuthMethod)?.otpAuthMethod;
if (!contactChannel || !otpAuthMethod) {
throw new StackAssertionError("Tried to use OTP sign in but auth method was not found?");
}
if (authMethods.length > 1) {
throw new StackAssertionError("Tried to use OTP sign in but found multiple auth methods? The uniqueness on the DB schema should prevent this");
}
const authMethod = authMethods[0];
if (authMethod.projectUser.requiresTotpMfa) {
if (contactChannel.projectUser.requiresTotpMfa) {
throw await createMfaRequiredError({
project,
isNewUser: data.is_new_user,
userId: authMethod.projectUserId,
userId: contactChannel.projectUser.projectUserId,
});
}
@ -89,7 +95,7 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({
where: {
projectId_projectUserId_type_value: {
projectId: project.id,
projectUserId: authMethod.projectUserId,
projectUserId: contactChannel.projectUser.projectUserId,
type: "EMAIL",
value: email,
}
@ -101,7 +107,7 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({
const { refreshToken, accessToken } = await createAuthTokens({
projectId: project.id,
projectUserId: authMethod.projectUserId,
projectUserId: contactChannel.projectUser.projectUserId,
});
return {

View File

@ -37,39 +37,50 @@ export const POST = createSmartRouteHandler({
throw new KnownErrors.PasswordAuthenticationNotEnabled();
}
const authMethod = await prismaClient.passwordAuthMethod.findUnique({
const contactChannel = await prismaClient.contactChannel.findUnique({
where: {
projectId_identifierType_identifier: {
projectId_type_value_usedForAuth: {
projectId: project.id,
identifierType: "EMAIL",
identifier: email,
type: "EMAIL",
value: email,
usedForAuth: "TRUE",
}
},
include: {
projectUser: true,
projectUser: {
include: {
authMethods: {
include: {
passwordAuthMethod: true,
}
}
}
}
}
});
const passwordAuthMethod = contactChannel?.projectUser.authMethods.find((m) => m.passwordAuthMethod)?.passwordAuthMethod;
// we compare the password even if the authMethod doesn't exist to prevent timing attacks
if (!await comparePassword(password, authMethod?.passwordHash || "")) {
if (!await comparePassword(password, passwordAuthMethod?.passwordHash || "")) {
throw new KnownErrors.EmailPasswordMismatch();
}
if (!authMethod) {
if (!contactChannel || !passwordAuthMethod) {
throw new StackAssertionError("This should never happen (the comparePassword call should've already caused this to fail)");
}
if (authMethod.projectUser.requiresTotpMfa) {
if (contactChannel.projectUser.requiresTotpMfa) {
throw await createMfaRequiredError({
project,
isNewUser: false,
userId: authMethod.projectUser.projectUserId,
userId: contactChannel.projectUser.projectUserId,
});
}
const { refreshToken, accessToken } = await createAuthTokens({
projectId: project.id,
projectUserId: authMethod.projectUser.projectUserId,
projectUserId: contactChannel.projectUser.projectUserId,
});
return {
@ -78,7 +89,7 @@ export const POST = createSmartRouteHandler({
body: {
access_token: accessToken,
refresh_token: refreshToken,
user_id: authMethod.projectUser.projectUserId,
user_id: contactChannel.projectUser.projectUserId,
}
};
},

View File

@ -141,9 +141,7 @@ export const internalProjectsCrudHandlers = createLazyProxy(() => createCrudHand
...(data.config?.credential_enabled ?? true) ? [{
enabled: true,
passwordConfig: {
create: {
identifierType: 'EMAIL',
}
create: {}
},
}] : [],
]

View File

@ -339,7 +339,6 @@ export const projectsCrudHandlers = createLazyProxy(() => createCrudHandlers(pro
const passwordAuth = await tx.passwordAuthMethodConfig.findFirst({
where: {
projectConfigId: oldProject.config.id,
identifierType: "EMAIL",
},
});
if (data.config?.credential_enabled !== undefined) {
@ -349,9 +348,7 @@ export const projectsCrudHandlers = createLazyProxy(() => createCrudHandlers(pro
projectConfigId: oldProject.config.id,
enabled: data.config.credential_enabled,
passwordConfig: {
create: {
identifierType: "EMAIL",
},
create: {},
},
},
});

View File

@ -22,37 +22,14 @@ export const userFullInclude = {
providerConfig: true,
},
},
contactChannels: true,
authMethods: {
include: {
passwordAuthMethod: true,
oauthAuthMethod: {
include: {
oauthProviderConfig: {
include: {
proxiedOAuthConfig: true,
standardOAuthConfig: true,
},
}
}
},
otpAuthMethod: {
include: {
contactChannel: true,
}
}
}
},
connectedAccounts: {
include: {
oauthProviderConfig: {
include: {
proxiedOAuthConfig: true,
standardOAuthConfig: true,
},
}
otpAuthMethod: true,
oauthAuthMethod: true,
}
},
contactChannels: true,
teamMembers: {
include: {
team: true,
@ -70,8 +47,12 @@ export const contactChannelToCrud = (channel: Prisma.ContactChannelGetPayload<{}
}
return {
id: channel.id,
type: 'email',
email: channel.value,
value: channel.value,
is_primary: !!channel.isPrimary,
is_verified: channel.isVerified,
used_for_auth: !!channel.usedForAuth,
};
};
@ -105,49 +86,6 @@ export const userPrismaToCrud = (
throw new StackAssertionError("User cannot have more than one selected team; this should never happen");
}
const authMethods: UsersCrud["Admin"]["Read"]["auth_methods"] = prisma.authMethods
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.map((m) => {
if ([m.passwordAuthMethod, m.otpAuthMethod, m.oauthAuthMethod].filter(Boolean).length > 1) {
throw new StackAssertionError(`AuthMethod ${m.id} violates the union constraint`, m);
}
if (m.passwordAuthMethod) {
return {
type: 'password',
identifier: m.passwordAuthMethod.identifier,
};
} else if (m.otpAuthMethod) {
return {
type: 'otp',
contact_channel: {
type: 'email',
email: m.otpAuthMethod.contactChannel.value,
},
};
} else if (m.oauthAuthMethod) {
return {
type: 'oauth',
provider: {
...oauthProviderConfigToCrud(m.oauthAuthMethod.oauthProviderConfig),
provider_user_id: m.oauthAuthMethod.providerAccountId,
},
};
} else {
throw new StackAssertionError("AuthMethod has no auth methods", m);
}
});
const connectedAccounts: UsersCrud["Admin"]["Read"]["connected_accounts"] = prisma.connectedAccounts.map((a) => {
return {
type: 'oauth',
provider: {
...oauthProviderConfigToCrud(a.oauthProviderConfig),
provider_user_id: a.providerAccountId,
},
};
});
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const primaryEmailContactChannel = prisma.contactChannels.find((c) => c.type === 'EMAIL' && c.isPrimary);
const passwordAuth = prisma.authMethods.find((m) => m.passwordAuthMethod);
@ -171,8 +109,6 @@ export const userPrismaToCrud = (
account_id: a.providerAccountId,
email: a.email,
})),
auth_methods: authMethods,
connected_accounts: connectedAccounts,
selected_team_id: selectedTeamMembers[0]?.teamId ?? null,
selected_team: selectedTeamMembers[0] ? teamPrismaToCrud(selectedTeamMembers[0]?.team) : null,
last_active_at_millis: lastActiveAtMillis,
@ -201,30 +137,18 @@ async function checkAuthData(
}
if (data.primaryEmailAuthEnabled) {
if (!data.oldPrimaryEmail || data.oldPrimaryEmail !== data.primaryEmail) {
const otpAuth = await tx.otpAuthMethod.findFirst({
const otpAuth = await tx.contactChannel.findFirst({
where: {
projectId: data.projectId,
contactChannel: {
type: 'EMAIL',
value: data.primaryEmail || throwErr("primary_email_auth_enabled is true but primary_email is not set"),
},
type: 'EMAIL',
value: data.primaryEmail || throwErr("primary_email_auth_enabled is true but primary_email is not set"),
usedForAuth: BooleanTrue.TRUE,
}
});
if (otpAuth) {
throw new KnownErrors.UserEmailAlreadyExists();
}
const passwordAuth = await tx.passwordAuthMethod.findFirst({
where: {
projectId: data.projectId,
identifier: data.primaryEmail || throwErr("primary_email_auth_enabled is true but primary_email is not set"),
}
});
if (passwordAuth) {
throw new KnownErrors.UserEmailAlreadyExists();
}
}
}
}
@ -460,7 +384,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
}
if (data.primary_email) {
const contactChannel = await tx.contactChannel.create({
await tx.contactChannel.create({
data: {
projectUserId: newUser.projectUserId,
projectId: auth.project.id,
@ -468,6 +392,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
value: data.primary_email || throwErr("primary_email_auth_enabled is true but primary_email is not set"),
isVerified: data.primary_email_verified ?? false,
isPrimary: "TRUE",
usedForAuth: data.primary_email_auth_enabled ? BooleanTrue.TRUE : null,
}
});
@ -484,7 +409,6 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
otpAuthMethod: {
create: {
projectUserId: newUser.projectUserId,
contactChannelId: contactChannel.id,
}
}
}
@ -507,9 +431,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
authMethodConfigId: passwordConfig.authMethodConfigId,
passwordAuthMethod: {
create: {
identifier: data.primary_email || throwErr("password is set but primary_email is not"),
passwordHash: await hashPassword(data.password),
identifierType: 'EMAIL',
projectUserId: newUser.projectUserId,
}
}
@ -612,8 +534,8 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const primaryEmailContactChannel = oldUser.contactChannels.find((c) => c.type === 'EMAIL' && c.isPrimary);
const otpAuth = oldUser.authMethods.find((m) => m.otpAuthMethod && m.otpAuthMethod.contactChannel.id === primaryEmailContactChannel?.id)?.otpAuthMethod;
const passwordAuth = oldUser.authMethods.find((m) => m.passwordAuthMethod && m.passwordAuthMethod.identifier === primaryEmailContactChannel?.value)?.passwordAuthMethod;
const otpAuth = oldUser.authMethods.find((m) => m.otpAuthMethod)?.otpAuthMethod;
const passwordAuth = oldUser.authMethods.find((m) => m.passwordAuthMethod)?.passwordAuthMethod;
await checkAuthData(tx, {
projectId: auth.project.id,
@ -627,10 +549,8 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
// if there is a new primary email
// - create a new primary email contact channel if it doesn't exist
// - update the primary email contact channel if it exists
// - update the password auth method if it exists
// if the primary email is null
// - delete the primary email contact channel if it exists (note that this will also delete the related auth methods)
// - delete the password auth method if it exists
if (data.primary_email !== undefined) {
if (data.primary_email === null) {
await tx.contactChannel.delete({
@ -643,17 +563,6 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
},
},
});
if (passwordAuth) {
await tx.authMethod.delete({
where: {
projectId_id: {
projectId: auth.project.id,
id: passwordAuth.authMethodId,
},
},
});
}
} else {
await tx.contactChannel.upsert({
where: {
@ -674,22 +583,9 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
},
update: {
value: data.primary_email,
usedForAuth: data.primary_email_auth_enabled ? BooleanTrue.TRUE : null,
}
});
if (passwordAuth) {
await tx.passwordAuthMethod.update({
where: {
projectId_authMethodId: {
projectId: auth.project.id,
authMethodId: passwordAuth.authMethodId,
},
},
data: {
identifier: data.primary_email,
}
});
}
}
}
@ -743,7 +639,6 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
otpAuthMethod: {
create: {
projectUserId: params.user_id,
contactChannelId: primaryEmailChannel.id,
}
}
}
@ -821,9 +716,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
authMethodConfigId: passwordConfig.authMethodConfigId,
passwordAuthMethod: {
create: {
identifier: primaryEmailChannel.value,
passwordHash: await hashPassword(data.password),
identifierType: 'EMAIL',
projectUserId: params.user_id,
}
}

View File

@ -34,20 +34,9 @@ describe("with grant_type === 'authorization_code'", async () => {
NiceResponse {
"status": 200,
"body": {
"auth_methods": [
{
"provider": {
"id": "spotify",
"provider_user_id": "<stripped UUID>@stack-generated.example.com",
"type": "spotify",
},
"type": "oauth",
},
],
"auth_with_email": false,
"client_metadata": null,
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": null,
"has_password": false,
"id": "<stripped UUID>",

View File

@ -18,22 +18,30 @@ it("should allow signing in to existing accounts", async ({ expect }) => {
}
`);
const response = await niceBackendFetch("/api/v1/users/me", { accessType: "client" });
expect(response.body.auth_methods).toMatchInlineSnapshot(`
[
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"auth_with_email": true,
"client_metadata": null,
"client_read_only_metadata": null,
"display_name": null,
"has_password": true,
"id": "<stripped UUID>",
"oauth_providers": [],
"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'>,
},
{
"identifier": "<stripped UUID>@stack-generated.example.com",
"type": "password",
},
]
"headers": Headers { <some fields may have been hidden> },
}
`);
});
// TODO: check auth methods
it("should not allow signing in with an e-mail that never signed up", async ({ expect }) => {
const response = await niceBackendFetch("/api/v1/auth/password/sign-in", {

View File

@ -27,20 +27,27 @@ it("should sign up new users", async ({ expect }) => {
]
`);
const response = await niceBackendFetch("/api/v1/users/me", { accessType: "client" });
expect(response.body.auth_methods).toMatchInlineSnapshot(`
[
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"auth_with_email": true,
"client_metadata": null,
"client_read_only_metadata": null,
"display_name": null,
"has_password": true,
"id": "<stripped UUID>",
"oauth_providers": [],
"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'>,
},
{
"identifier": "<stripped UUID>@stack-generated.example.com",
"type": "password",
},
]
"headers": Headers { <some fields may have been hidden> },
}
`);
});

View File

@ -68,19 +68,9 @@ it("creates a team and manage users on the server", async ({ expect }) => {
"is_paginated": false,
"items": [
{
"auth_methods": [
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
},
],
"auth_with_email": true,
"client_metadata": null,
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": null,
"has_password": false,
"id": "<stripped UUID>",
@ -96,19 +86,9 @@ it("creates a team and manage users on the server", async ({ expect }) => {
"signed_up_at_millis": <stripped field 'signed_up_at_millis'>,
},
{
"auth_methods": [
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
},
],
"auth_with_email": true,
"client_metadata": null,
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": null,
"has_password": false,
"id": "<stripped UUID>",
@ -154,19 +134,9 @@ it("creates a team and manage users on the server", async ({ expect }) => {
"is_paginated": false,
"items": [
{
"auth_methods": [
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
},
],
"auth_with_email": true,
"client_metadata": null,
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": null,
"has_password": false,
"id": "<stripped UUID>",

View File

@ -77,19 +77,9 @@ describe("with client access", () => {
NiceResponse {
"status": 200,
"body": {
"auth_methods": [
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
},
],
"auth_with_email": true,
"client_metadata": null,
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": null,
"has_password": false,
"id": "<stripped UUID>",
@ -117,19 +107,9 @@ describe("with client access", () => {
NiceResponse {
"status": 200,
"body": {
"auth_methods": [
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
},
],
"auth_with_email": true,
"client_metadata": null,
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": null,
"has_password": false,
"id": "<stripped UUID>",
@ -202,19 +182,9 @@ describe("with client access", () => {
NiceResponse {
"status": 200,
"body": {
"auth_methods": [
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
},
],
"auth_with_email": true,
"client_metadata": null,
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": "John Doe",
"has_password": false,
"id": "<stripped UUID>",
@ -241,19 +211,9 @@ describe("with client access", () => {
NiceResponse {
"status": 200,
"body": {
"auth_methods": [
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
},
],
"auth_with_email": true,
"client_metadata": { "key": "value" },
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": "John Doe",
"has_password": false,
"id": "<stripped UUID>",
@ -390,19 +350,9 @@ describe("with client access", () => {
NiceResponse {
"status": 200,
"body": {
"auth_methods": [
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
},
],
"auth_with_email": true,
"client_metadata": null,
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": "John Doe",
"has_password": false,
"id": "<stripped UUID>",
@ -429,19 +379,9 @@ describe("with client access", () => {
NiceResponse {
"status": 200,
"body": {
"auth_methods": [
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
},
],
"auth_with_email": true,
"client_metadata": null,
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": null,
"has_password": false,
"id": "<stripped UUID>",
@ -566,19 +506,9 @@ describe("with client access", () => {
NiceResponse {
"status": 200,
"body": {
"auth_methods": [
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
},
],
"auth_with_email": true,
"client_metadata": { "key": "value" },
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": null,
"has_password": false,
"id": "<stripped UUID>",
@ -682,19 +612,9 @@ describe("with server access", () => {
NiceResponse {
"status": 200,
"body": {
"auth_methods": [
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
},
],
"auth_with_email": true,
"client_metadata": null,
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": null,
"has_password": false,
"id": "<stripped UUID>",
@ -727,19 +647,9 @@ describe("with server access", () => {
NiceResponse {
"status": 200,
"body": {
"auth_methods": [
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
},
],
"auth_with_email": true,
"client_metadata": null,
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": "John Doe",
"has_password": false,
"id": "<stripped UUID>",
@ -788,19 +698,9 @@ describe("with server access", () => {
"is_paginated": false,
"items": [
{
"auth_methods": [
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
},
],
"auth_with_email": true,
"client_metadata": null,
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": null,
"has_password": false,
"id": "<stripped UUID>",
@ -838,19 +738,9 @@ describe("with server access", () => {
NiceResponse {
"status": 200,
"body": {
"auth_methods": [
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
},
],
"auth_with_email": true,
"client_metadata": null,
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": null,
"has_password": false,
"id": "<stripped UUID>",
@ -881,11 +771,9 @@ describe("with server access", () => {
NiceResponse {
"status": 201,
"body": {
"auth_methods": [],
"auth_with_email": false,
"client_metadata": null,
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": null,
"has_password": false,
"id": "<stripped UUID>",
@ -920,19 +808,9 @@ describe("with server access", () => {
NiceResponse {
"status": 201,
"body": {
"auth_methods": [
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
},
],
"auth_with_email": true,
"client_metadata": null,
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": "John Dough",
"has_password": false,
"id": "<stripped UUID>",
@ -967,23 +845,9 @@ describe("with server access", () => {
NiceResponse {
"status": 201,
"body": {
"auth_methods": [
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
},
{
"identifier": "<stripped UUID>@stack-generated.example.com",
"type": "password",
},
],
"auth_with_email": true,
"client_metadata": null,
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": null,
"has_password": true,
"id": "<stripped UUID>",
@ -1063,19 +927,9 @@ describe("with server access", () => {
NiceResponse {
"status": 201,
"body": {
"auth_methods": [
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
},
],
"auth_with_email": true,
"client_metadata": null,
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": null,
"has_password": false,
"id": "<stripped UUID>",
@ -1128,11 +982,9 @@ describe("with server access", () => {
NiceResponse {
"status": 201,
"body": {
"auth_methods": [],
"auth_with_email": false,
"client_metadata": null,
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": null,
"has_password": false,
"id": "<stripped UUID>",
@ -1164,23 +1016,9 @@ describe("with server access", () => {
NiceResponse {
"status": 201,
"body": {
"auth_methods": [
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
},
{
"identifier": "<stripped UUID>@stack-generated.example.com",
"type": "password",
},
],
"auth_with_email": true,
"client_metadata": null,
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": null,
"has_password": true,
"id": "<stripped UUID>",
@ -1227,23 +1065,9 @@ describe("with server access", () => {
NiceResponse {
"status": 201,
"body": {
"auth_methods": [
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
},
{
"identifier": "<stripped UUID>@stack-generated.example.com",
"type": "password",
},
],
"auth_with_email": true,
"client_metadata": null,
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": null,
"has_password": true,
"id": "<stripped UUID>",
@ -1272,11 +1096,9 @@ describe("with server access", () => {
NiceResponse {
"status": 201,
"body": {
"auth_methods": [],
"auth_with_email": false,
"client_metadata": null,
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": null,
"has_password": false,
"id": "<stripped UUID>",
@ -1329,19 +1151,9 @@ describe("with server access", () => {
NiceResponse {
"status": 200,
"body": {
"auth_methods": [
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
},
],
"auth_with_email": true,
"client_metadata": null,
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": "John Doe",
"has_password": false,
"id": "<stripped UUID>",
@ -1366,19 +1178,9 @@ describe("with server access", () => {
NiceResponse {
"status": 200,
"body": {
"auth_methods": [
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
},
],
"auth_with_email": true,
"client_metadata": null,
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": "John Doe",
"has_password": false,
"id": "<stripped UUID>",
@ -1412,19 +1214,9 @@ describe("with server access", () => {
NiceResponse {
"status": 200,
"body": {
"auth_methods": [
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
},
],
"auth_with_email": true,
"client_metadata": null,
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": "John Doe",
"has_password": false,
"id": "<stripped UUID>",
@ -1458,23 +1250,9 @@ describe("with server access", () => {
NiceResponse {
"status": 200,
"body": {
"auth_methods": [
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
},
{
"identifier": "<stripped UUID>@stack-generated.example.com",
"type": "password",
},
],
"auth_with_email": true,
"client_metadata": null,
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": null,
"has_password": true,
"id": "<stripped UUID>",
@ -1557,19 +1335,9 @@ describe("with server access", () => {
NiceResponse {
"status": 200,
"body": {
"auth_methods": [
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
},
],
"auth_with_email": true,
"client_metadata": { "key": "client value" },
"client_read_only_metadata": { "key": "client read only value" },
"connected_accounts": [],
"display_name": null,
"has_password": false,
"id": "<stripped UUID>",
@ -1614,19 +1382,9 @@ describe("with server access", () => {
NiceResponse {
"status": 200,
"body": {
"auth_methods": [
{
"contact_channel": {
"email": "new-primary-email@example.com",
"type": "email",
},
"type": "otp",
},
],
"auth_with_email": true,
"client_metadata": null,
"client_read_only_metadata": null,
"connected_accounts": [],
"display_name": null,
"has_password": false,
"id": "<stripped UUID>",

View File

@ -26,8 +26,6 @@ const clientReadSchema = usersCrudServerReadSchema.pick([
"auth_with_email",
"oauth_providers",
"selected_team_id",
"auth_methods",
"connected_accounts",
"requires_totp_mfa",
]).concat(yupObject({
selected_team: teamsCrudClientReadSchema.nullable().defined(),

View File

@ -27,6 +27,17 @@ export const usersCrudServerReadSchema = fieldSchema.yupObject({
profile_image_url: fieldSchema.profileImageUrlSchema.nullable().defined(),
signed_up_at_millis: fieldSchema.signedUpAtMillisSchema.required(),
has_password: fieldSchema.yupBoolean().required().meta({ openapiField: { description: 'Whether the user has a password associated with their account', exampleValue: true } }),
client_metadata: fieldSchema.userClientMetadataSchema,
client_read_only_metadata: fieldSchema.userClientReadOnlyMetadataSchema,
server_metadata: fieldSchema.userServerMetadataSchema,
last_active_at_millis: fieldSchema.userLastActiveAtMillisSchema.required(),
oauth_providers: fieldSchema.yupArray(fieldSchema.yupObject({
id: fieldSchema.yupString().required(),
account_id: fieldSchema.yupString().required(),
email: fieldSchema.yupString().nullable(),
}).required()).required().meta({ openapiField: { hidden: true, description: 'A list of OAuth providers connected to this account', exampleValue: [{ id: 'google', account_id: '12345', email: 'john.doe@gmail.com' }] } }),
/**
* @deprecated
*/
@ -35,41 +46,6 @@ export const usersCrudServerReadSchema = fieldSchema.yupObject({
* @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
*/
oauth_providers: fieldSchema.yupArray(fieldSchema.yupObject({
id: fieldSchema.yupString().required(),
account_id: fieldSchema.yupString().required(),
email: fieldSchema.yupString().nullable(),
}).required()).required().meta({ openapiField: { hidden: true, description: 'A list of OAuth providers connected to this account', exampleValue: [{ id: 'google', account_id: '12345', email: 'john.doe@gmail.com' }] } }),
auth_methods: fieldSchema.yupArray(fieldSchema.yupUnion(
fieldSchema.yupObject({
type: fieldSchema.yupString().oneOf(['password']).required(),
identifier: fieldSchema.yupString().required(),
}).required(),
fieldSchema.yupObject({
type: fieldSchema.yupString().oneOf(['otp']).required(),
contact_channel: fieldSchema.yupObject({
type: fieldSchema.yupString().oneOf(['email']).required(),
email: fieldSchema.yupString().required(),
}).required(),
}).required(),
fieldSchema.yupObject({
type: fieldSchema.yupString().oneOf(['oauth']).required(),
provider: fieldSchema.userOAuthProviderSchema.required(),
}).required(),
)).required().meta({ openapiField: { hidden: true, description: 'A list of authentication methods available for this user to sign in with', exampleValue: [ { "contact_channel": { "email": "john.doe@gmail.com", "type": "email", }, "type": "otp", } ] } }),
connected_accounts: fieldSchema.yupArray(fieldSchema.yupUnion(
fieldSchema.yupObject({
type: fieldSchema.yupString().oneOf(['oauth']).required(),
provider: fieldSchema.userOAuthProviderSchema.required(),
}).required(),
)).required().meta({ openapiField: { hidden: true, description: 'A list of connected accounts to this user', exampleValue: [ { "provider": { "provider_user_id": "12345", "type": "google", }, "type": "oauth", } ] } }),
client_metadata: fieldSchema.userClientMetadataSchema,
client_read_only_metadata: fieldSchema.userClientReadOnlyMetadataSchema,
server_metadata: fieldSchema.userServerMetadataSchema,
last_active_at_millis: fieldSchema.userLastActiveAtMillisSchema.required(),
}).required();
export const usersCrudServerCreateSchema = usersCrudServerUpdateSchema.omit(['selected_team_id']).concat(fieldSchema.yupObject({