Auth methods and connected accounts (#164)

This commit is contained in:
Konsti Wohlwend 2024-08-04 11:39:26 -07:00 committed by GitHub
parent 94a3edd77d
commit dfb51b8346
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 299 additions and 17 deletions

View File

@ -222,11 +222,15 @@ model ProjectUser {
projectUserRefreshTokens ProjectUserRefreshToken[]
projectUserAuthorizationCodes ProjectUserAuthorizationCode[]
projectUserOAuthAccounts ProjectUserOAuthAccount[]
projectUserEmailVerificationCode ProjectUserEmailVerificationCode[]
projectUserPasswordResetCode ProjectUserPasswordResetCode[]
projectUserMagicLinkCode ProjectUserMagicLinkCode[]
teamMembers TeamMember[]
// @deprecated
projectUserEmailVerificationCode ProjectUserEmailVerificationCode[]
// @deprecated
projectUserPasswordResetCode ProjectUserPasswordResetCode[]
// @deprecated
projectUserMagicLinkCode ProjectUserMagicLinkCode[]
primaryEmail String?
primaryEmailVerified Boolean
profileImageUrl String?

View File

@ -3,16 +3,20 @@ import { createCrudHandlers } from "@/route-handlers/crud-handler";
import { BooleanTrue, Prisma } from "@prisma/client";
import { KnownErrors } from "@stackframe/stack-shared";
import { currentUserCrud } from "@stackframe/stack-shared/dist/interface/crud/current-user";
import { usersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
import { UsersCrud, usersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { StackAssertionError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { hashPassword } from "@stackframe/stack-shared/dist/utils/password";
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
import { teamPrismaToCrud } from "../teams/crud";
import { sendUserCreatedWebhook, sendUserDeletedWebhook, sendUserUpdatedWebhook } from "@/lib/webhooks";
const fullInclude = {
projectUserOAuthAccounts: true,
projectUserOAuthAccounts: {
include: {
providerConfig: true,
},
},
teamMembers: {
include: {
team: true,
@ -21,13 +25,51 @@ const fullInclude = {
isSelected: BooleanTrue.TRUE,
},
},
};
} satisfies Prisma.ProjectUserInclude;
const prismaToCrud = (prisma: Prisma.ProjectUserGetPayload<{ include: typeof fullInclude}>) => {
const prismaToCrud = (prisma: Prisma.ProjectUserGetPayload<{ include: typeof fullInclude}>): UsersCrud["Admin"]["Read"] => {
const selectedTeamMembers = prisma.teamMembers;
if (selectedTeamMembers.length > 1) {
throw new StackAssertionError("User cannot have more than one selected team; this should never happen");
}
if (prisma.passwordHash && !prisma.authWithEmail) {
captureError("prismaToCrud", new StackAssertionError("User has password but authWithEmail is false; this is an assertion error that should never happen", { prisma }));
}
if (prisma.authWithEmail && !prisma.primaryEmail) {
captureError("prismaToCrud", new StackAssertionError("User has authWithEmail but no primary email; this is an assertion error that should never happen", { prisma }));
}
const authMethods: UsersCrud["Admin"]["Read"]["auth_methods"] = [
...prisma.passwordHash ? [{
type: 'password',
identifier: prisma.primaryEmail ?? "",
}] as const : [],
...prisma.authWithEmail ? [{
type: 'otp',
contact_channel: {
type: 'email',
email: prisma.primaryEmail ?? "",
},
}] as const : [],
...prisma.projectUserOAuthAccounts.map((a) => ({
type: 'oauth',
provider: {
type: a.oauthProviderConfigId,
provider_user_id: a.providerAccountId,
},
} as const)),
] as const;
const connectedAccounts: UsersCrud["Admin"]["Read"]["connected_accounts"] = [
...prisma.projectUserOAuthAccounts.map((a) => ({
type: 'oauth',
provider: {
type: a.oauthProviderConfigId,
provider_user_id: a.providerAccountId,
},
} as const)),
];
return {
id: prisma.projectUserId,
display_name: prisma.displayName || null,
@ -37,7 +79,6 @@ const prismaToCrud = (prisma: Prisma.ProjectUserGetPayload<{ include: typeof ful
signed_up_at_millis: prisma.createdAt.getTime(),
client_metadata: prisma.clientMetadata,
server_metadata: prisma.serverMetadata,
auth_method: prisma.passwordHash ? 'credential' as const : 'oauth' as const, // not used anymore, for backwards compatibility
has_password: !!prisma.passwordHash,
auth_with_email: prisma.authWithEmail,
oauth_providers: prisma.projectUserOAuthAccounts.map((a) => ({
@ -45,6 +86,8 @@ const prismaToCrud = (prisma: Prisma.ProjectUserGetPayload<{ include: typeof ful
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,
};

View File

@ -222,11 +222,15 @@ model ProjectUser {
projectUserRefreshTokens ProjectUserRefreshToken[]
projectUserAuthorizationCodes ProjectUserAuthorizationCode[]
projectUserOAuthAccounts ProjectUserOAuthAccount[]
projectUserEmailVerificationCode ProjectUserEmailVerificationCode[]
projectUserPasswordResetCode ProjectUserPasswordResetCode[]
projectUserMagicLinkCode ProjectUserMagicLinkCode[]
teamMembers TeamMember[]
// @deprecated
projectUserEmailVerificationCode ProjectUserEmailVerificationCode[]
// @deprecated
projectUserPasswordResetCode ProjectUserPasswordResetCode[]
// @deprecated
projectUserMagicLinkCode ProjectUserMagicLinkCode[]
primaryEmail String?
primaryEmailVerified Boolean
profileImageUrl String?

View File

@ -115,6 +115,7 @@ export namespace Auth {
headers: expect.anything(),
body: expect.anything(),
});
return response;
}
export async function expectToBeSignedOut() {

View File

@ -17,7 +17,22 @@ it("should allow signing in to existing accounts", async ({ expect }) => {
"headers": Headers { <some fields may have been hidden> },
}
`);
await Auth.expectToBeSignedIn();
const response = await Auth.expectToBeSignedIn();
expect(response.body.auth_methods).toMatchInlineSnapshot(`
[
{
"identifier": "<stripped UUID>@stack-generated.example.com",
"type": "password",
},
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
},
]
`);
});
it("should not allow signing in with an e-mail that never signed up", async ({ expect }) => {

View File

@ -26,7 +26,22 @@ it("should sign up new users", async ({ expect }) => {
},
]
`);
await Auth.expectToBeSignedIn();
const response = await Auth.expectToBeSignedIn();
expect(response.body.auth_methods).toMatchInlineSnapshot(`
[
{
"identifier": "<stripped UUID>@stack-generated.example.com",
"type": "password",
},
{
"contact_channel": {
"email": "<stripped UUID>@stack-generated.example.com",
"type": "email",
},
"type": "otp",
},
]
`);
});
it("should not allow signing up with an e-mail that already exists", async ({ expect }) => {

View File

@ -65,8 +65,18 @@ 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,
"connected_accounts": [],
"display_name": null,
"has_password": false,
"id": "<stripped UUID>",
@ -80,8 +90,18 @@ 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,
"connected_accounts": [],
"display_name": null,
"has_password": false,
"id": "<stripped UUID>",
@ -125,8 +145,18 @@ 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,
"connected_accounts": [],
"display_name": null,
"has_password": false,
"id": "<stripped UUID>",

View File

@ -76,8 +76,18 @@ 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,
"connected_accounts": [],
"display_name": null,
"has_password": false,
"id": "<stripped UUID>",
@ -104,8 +114,18 @@ 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,
"connected_accounts": [],
"display_name": null,
"has_password": false,
"id": "<stripped UUID>",
@ -177,8 +197,18 @@ 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,
"connected_accounts": [],
"display_name": "John Doe",
"has_password": false,
"id": "<stripped UUID>",
@ -204,8 +234,18 @@ 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" },
"connected_accounts": [],
"display_name": "John Doe",
"has_password": false,
"id": "<stripped UUID>",
@ -299,8 +339,18 @@ 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,
"connected_accounts": [],
"display_name": "John Doe",
"has_password": false,
"id": "<stripped UUID>",
@ -326,8 +376,18 @@ 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,
"connected_accounts": [],
"display_name": null,
"has_password": false,
"id": "<stripped UUID>",
@ -413,8 +473,18 @@ 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,
"connected_accounts": [],
"display_name": null,
"has_password": false,
"id": "<stripped UUID>",
@ -445,8 +515,18 @@ 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,
"connected_accounts": [],
"display_name": "John Doe",
"has_password": false,
"id": "<stripped UUID>",
@ -497,8 +577,18 @@ 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,
"connected_accounts": [],
"display_name": null,
"has_password": false,
"id": "<stripped UUID>",
@ -540,8 +630,18 @@ 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,
"connected_accounts": [],
"display_name": "John Doe",
"has_password": false,
"id": "<stripped UUID>",
@ -564,8 +664,18 @@ 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,
"connected_accounts": [],
"display_name": "John Doe",
"has_password": false,
"id": "<stripped UUID>",

View File

@ -23,6 +23,8 @@ const clientReadSchema = usersCrudServerReadSchema.pick([
"auth_with_email",
"oauth_providers",
"selected_team_id",
"auth_methods",
"connected_accounts",
]).concat(yupObject({
selected_team: teamsCrudClientReadSchema.nullable().defined(),
})).nullable().defined(); // TODO: next-release: make required

View File

@ -25,12 +25,41 @@ 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 } }),
auth_with_email: fieldSchema.yupBoolean().required().meta({ openapiField: { 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
*/
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
*/
oauth_providers: fieldSchema.yupArray(fieldSchema.yupObject({
id: fieldSchema.yupString().required(),
account_id: fieldSchema.yupString().required(),
email: fieldSchema.yupString().nullable(),
}).required()).required().meta({ openapiField: { description: 'A list of OAuth providers connected to this account', exampleValue: [{ id: 'google', account_id: '12345', email: 'john.doe@gmail.com' }] } }),
}).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: { 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: { description: 'A list of connected accounts to this user', exampleValue: [ { "provider": { "provider_user_id": "12345", "type": "google", }, "type": "oauth", } ] } }),
client_metadata: fieldSchema.userClientMetadataSchema,
server_metadata: fieldSchema.userServerMetadataSchema,
}).required();
@ -112,4 +141,4 @@ export const userDeletedWebhookEvent = {
description: "This event is triggered when a user is deleted.",
tags: ["Users"],
},
} satisfies WebhookEvent<typeof webhookUserDeletedSchema>;
} satisfies WebhookEvent<typeof webhookUserDeletedSchema>;

View File

@ -1,4 +1,5 @@
import * as yup from "yup";
import { StackAssertionError } from "./utils/errors";
import { allProviders } from "./utils/oauth";
import { isUuid } from "./utils/uuids";
@ -67,6 +68,30 @@ export function yupObject<A extends yup.Maybe<yup.AnyObject>, B extends yup.Obje
}
/* eslint-enable no-restricted-syntax */
export function yupUnion<T extends yup.ISchema<any>[]>(...args: T): yup.ISchema<yup.InferType<T[number]>> {
if (args.length === 0) throw new Error('yupUnion must have at least one schema');
const [first] = args;
const firstDesc = first.describe();
for (const schema of args) {
const desc = schema.describe();
if (desc.type !== firstDesc.type) throw new StackAssertionError(`yupUnion must have schemas of the same type (got: ${firstDesc.type} and ${desc.type})`, { first, schema, firstDesc, desc });
}
return yupMixed().required().test('is-one-of', 'Invalid value', async (value, context) => {
const errors = [];
for (const schema of args) {
try {
await schema.validate(value, context.options);
return true;
} catch (e) {
errors.push(e);
}
}
throw new AggregateError(errors, 'Invalid value; must be one of the provided schemas');
});
}
// Common
export const adaptSchema = yupMixed<StackAdaptSentinel>();
/**
@ -165,6 +190,10 @@ export const profileImageUrlSchema = yupString().meta({ openapiField: { descript
export const signedUpAtMillisSchema = yupNumber().meta({ openapiField: { description: _signedUpAtMillisDescription, exampleValue: 1630000000000 } });
export const userClientMetadataSchema = jsonSchema.meta({ openapiField: { description: _clientMetaDataDescription('user'), exampleValue: { key: 'value' } } });
export const userServerMetadataSchema = jsonSchema.meta({ openapiField: { description: _serverMetaDataDescription('user'), exampleValue: { key: 'value' } } });
export const userOAuthProviderSchema = yupObject({
type: yupString().required(),
provider_user_id: yupString().required(),
});
// Auth
export const signInEmailSchema = emailSchema.meta({ openapiField: { description: 'The email to sign in with.', exampleValue: 'johndoe@example.com' } });