From 018be1fdff58e996bec9b02eed069ace0d74e788 Mon Sep 17 00:00:00 2001 From: Zai Shi Date: Sat, 19 Jul 2025 02:50:05 +0200 Subject: [PATCH] OAuth provider crud (#759) > [!IMPORTANT] > Add CRUD operations for OAuth providers, update schemas and error handling, and include tests for new functionality. > > - **Behavior**: > - Adds CRUD operations for OAuth providers in `client-interface.ts` and `server-interface.ts`. > - Introduces `oauthProviderCrud` in `oauth-providers.ts` for managing OAuth provider data. > - Updates `schema-fields.ts` to include new schemas for OAuth provider attributes. > - Adds error handling for OAuth provider operations in `known-errors.tsx`. > - **Schema**: > - Defines `oauthProviderCrudClientUpdateSchema`, `oauthProviderCrudServerUpdateSchema`, and `oauthProviderCrudServerCreateSchema` in `oauth-providers.ts`. > - Updates `projects.ts` to include `oauthProviderReadSchema` and `oauthProviderWriteSchema`. > - **Tests**: > - Adds tests for OAuth provider CRUD operations in `oauth-providers.test.ts`. > - **Misc**: > - Renames `oauth.ts` to `connected-accounts.ts` in `crud` directory. > - Updates `projects.test.ts` to include `provider_config_id` in OAuth provider configurations. > > This description was created by [Ellipsis](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral) for bca9bcf12b6f34a2f951a4f5846eac14f3ed5ac2. You can [customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this summary. It will automatically update as commits are pushed. ---- > [!IMPORTANT] > Add CRUD operations for OAuth providers, update schemas, handle errors, and include tests. > > - **Behavior**: > - Adds CRUD operations for OAuth providers in `client-interface.ts` and `server-interface.ts`. > - Introduces `oauthProviderCrud` in `oauth-providers.ts` for managing OAuth provider data. > - Updates `schema-fields.ts` to include new schemas for OAuth provider attributes. > - Adds error handling for OAuth provider operations in `known-errors.tsx`. > - **Schema**: > - Defines `oauthProviderCrudClientUpdateSchema`, `oauthProviderCrudServerUpdateSchema`, and `oauthProviderCrudServerCreateSchema` in `oauth-providers.ts`. > - Updates `projects.ts` to include `oauthProviderReadSchema` and `oauthProviderWriteSchema`. > - **Tests**: > - Adds tests for OAuth provider CRUD operations in `oauth-providers.test.ts`. > - **Misc**: > - Renames `oauth.ts` to `connected-accounts.ts` in `crud` directory. > - Updates `projects.test.ts` to include `provider_config_id` in OAuth provider configurations. > > This description was created by [Ellipsis](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral) for aaa55f7d5c445f60f3961e8bec398c0b4b8a4404. You can [customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this summary. It will automatically update as commits are pushed. --------- Co-authored-by: Konsti Wohlwend --- .../20250711232750_oauth_method/migration.sql | 81 ++ apps/backend/prisma/schema.prisma | 51 +- .../oauth/callback/[provider_id]/route.tsx | 56 +- .../[provider_id]/access-token/crud.tsx | 18 +- .../[user_id]/[provider_id]/route.tsx | 5 + .../app/api/latest/oauth-providers/crud.tsx | 407 +++++++ .../app/api/latest/oauth-providers/route.tsx | 4 + .../backend/src/app/api/latest/users/crud.tsx | 13 +- apps/backend/src/lib/config.tsx | 1 + .../custom/projects/provision.test.ts | 2 + .../integrations/neon/oauth-providers.test.ts | 1 + .../neon/projects/provision.test.ts | 2 + .../api/v1/internal/projects.test.ts | 1 + .../endpoints/api/v1/oauth-providers.test.ts | 1018 +++++++++++++++++ .../backend/endpoints/api/v1/projects.test.ts | 8 + .../src/interface/client-interface.ts | 75 +- .../crud/{oauth.ts => connected-accounts.ts} | 0 .../src/interface/crud/oauth-providers.ts | 84 ++ .../src/interface/crud/projects.ts | 9 +- .../src/interface/server-interface.ts | 107 +- packages/stack-shared/src/known-errors.tsx | 11 + packages/stack-shared/src/schema-fields.ts | 8 + 22 files changed, 1889 insertions(+), 73 deletions(-) create mode 100644 apps/backend/prisma/migrations/20250711232750_oauth_method/migration.sql create mode 100644 apps/backend/src/app/api/latest/oauth-providers/[user_id]/[provider_id]/route.tsx create mode 100644 apps/backend/src/app/api/latest/oauth-providers/crud.tsx create mode 100644 apps/backend/src/app/api/latest/oauth-providers/route.tsx create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/oauth-providers.test.ts rename packages/stack-shared/src/interface/crud/{oauth.ts => connected-accounts.ts} (100%) create mode 100644 packages/stack-shared/src/interface/crud/oauth-providers.ts diff --git a/apps/backend/prisma/migrations/20250711232750_oauth_method/migration.sql b/apps/backend/prisma/migrations/20250711232750_oauth_method/migration.sql new file mode 100644 index 000000000..5543fb8cd --- /dev/null +++ b/apps/backend/prisma/migrations/20250711232750_oauth_method/migration.sql @@ -0,0 +1,81 @@ +/* + Warnings: + + - A unique constraint covering the columns `[tenancyId,projectUserId,configOAuthProviderId]` on the table `OAuthAuthMethod` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "OAuthAuthMethod_tenancyId_projectUserId_configOAuthProvider_key" ON "OAuthAuthMethod"("tenancyId", "projectUserId", "configOAuthProviderId"); + +-- DropForeignKey +ALTER TABLE "ConnectedAccount" DROP CONSTRAINT "ConnectedAccount_tenancyId_configOAuthProviderId_providerA_fkey"; + +-- DropForeignKey +ALTER TABLE "ConnectedAccount" DROP CONSTRAINT "ConnectedAccount_tenancyId_projectUserId_fkey"; + +-- DropForeignKey +ALTER TABLE "OAuthAccessToken" DROP CONSTRAINT "OAuthAccessToken_tenancyId_configOAuthProviderId_providerA_fkey"; + +-- DropForeignKey +ALTER TABLE "OAuthAuthMethod" DROP CONSTRAINT "OAuthAuthMethod_tenancyId_configOAuthProviderId_providerAc_fkey"; + +-- DropForeignKey +ALTER TABLE "OAuthToken" DROP CONSTRAINT "OAuthToken_tenancyId_configOAuthProviderId_providerAccount_fkey"; + +-- AlterTable +ALTER TABLE "ProjectUserOAuthAccount" DROP CONSTRAINT "ProjectUserOAuthAccount_pkey", +ADD COLUMN "allowConnectedAccounts" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "allowSignIn" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "id" UUID NOT NULL, +ADD CONSTRAINT "ProjectUserOAuthAccount_pkey" PRIMARY KEY ("tenancyId", "id"); + + +-- AlterTable +ALTER TABLE "OAuthAccessToken" ADD COLUMN "oauthAccountId" UUID; + + +-- Update OAuthAccessToken.oauthAccountId with the corresponding ProjectUserOAuthAccount.id +UPDATE "OAuthAccessToken" +SET "oauthAccountId" = "ProjectUserOAuthAccount"."id" +FROM "ProjectUserOAuthAccount" +WHERE "OAuthAccessToken"."tenancyId" = "ProjectUserOAuthAccount"."tenancyId" + AND "OAuthAccessToken"."configOAuthProviderId" = "ProjectUserOAuthAccount"."configOAuthProviderId" + AND "OAuthAccessToken"."providerAccountId" = "ProjectUserOAuthAccount"."providerAccountId"; + +-- AlterTable +ALTER TABLE "OAuthAccessToken" DROP COLUMN "configOAuthProviderId", DROP COLUMN "providerAccountId"; +ALTER TABLE "OAuthAccessToken" ALTER COLUMN "oauthAccountId" SET NOT NULL; + +-- AlterTable +ALTER TABLE "OAuthToken" ADD COLUMN "oauthAccountId" UUID; + +-- Update OAuthToken.oauthAccountId with the corresponding ProjectUserOAuthAccount.id +UPDATE "OAuthToken" +SET "oauthAccountId" = "ProjectUserOAuthAccount"."id" +FROM "ProjectUserOAuthAccount" +WHERE "OAuthToken"."tenancyId" = "ProjectUserOAuthAccount"."tenancyId" + AND "OAuthToken"."configOAuthProviderId" = "ProjectUserOAuthAccount"."configOAuthProviderId" + AND "OAuthToken"."providerAccountId" = "ProjectUserOAuthAccount"."providerAccountId"; + +ALTER TABLE "OAuthToken" DROP COLUMN "configOAuthProviderId", DROP COLUMN "providerAccountId"; +ALTER TABLE "OAuthToken" ALTER COLUMN "oauthAccountId" SET NOT NULL; + +-- DropTable +DROP TABLE "ConnectedAccount"; + +-- CreateIndex +CREATE UNIQUE INDEX "OAuthAuthMethod_tenancyId_configOAuthProviderId_projectUser_key" ON "OAuthAuthMethod"("tenancyId", "configOAuthProviderId", "projectUserId", "providerAccountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectUserOAuthAccount_tenancyId_configOAuthProviderId_pro_key" ON "ProjectUserOAuthAccount"("tenancyId", "configOAuthProviderId", "projectUserId", "providerAccountId"); + +-- AddForeignKey +ALTER TABLE "OAuthAuthMethod" ADD CONSTRAINT "OAuthAuthMethod_tenancyId_configOAuthProviderId_projectUse_fkey" FOREIGN KEY ("tenancyId", "configOAuthProviderId", "projectUserId", "providerAccountId") REFERENCES "ProjectUserOAuthAccount"("tenancyId", "configOAuthProviderId", "projectUserId", "providerAccountId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthToken" ADD CONSTRAINT "OAuthToken_tenancyId_oauthAccountId_fkey" FOREIGN KEY ("tenancyId", "oauthAccountId") REFERENCES "ProjectUserOAuthAccount"("tenancyId", "id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthAccessToken" ADD CONSTRAINT "OAuthAccessToken_tenancyId_oauthAccountId_fkey" FOREIGN KEY ("tenancyId", "oauthAccountId") REFERENCES "ProjectUserOAuthAccount"("tenancyId", "id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "ProjectUserOAuthAccount" ALTER COLUMN "projectUserId" DROP NOT NULL; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index c6ab7f67d..60a1851b5 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -170,7 +170,6 @@ model ProjectUser { teamMembers TeamMember[] contactChannels ContactChannel[] authMethods AuthMethod[] - connectedAccounts ConnectedAccount[] // some backlinks for the unique constraints on some auth methods passwordAuthMethod PasswordAuthMethod[] @@ -196,8 +195,9 @@ model ProjectUser { // This should be renamed to "OAuthAccount" as it is not always bound to a user // When ever a user goes through the OAuth flow and gets an account ID from the OAuth provider, we store that here. model ProjectUserOAuthAccount { - tenancyId String @db.Uuid - projectUserId String @db.Uuid + id String @default(uuid()) @db.Uuid + tenancyId String @db.Uuid + projectUserId String? @db.Uuid configOAuthProviderId String providerAccountId String @@ -213,11 +213,13 @@ model ProjectUserOAuthAccount { oauthTokens OAuthToken[] oauthAccessToken OAuthAccessToken[] - // At lease one of the authMethod or connectedAccount should be set. - connectedAccount ConnectedAccount? - oauthAuthMethod OAuthAuthMethod? + // if allowSignIn is true, oauthAuthMethod must be set + oauthAuthMethod OAuthAuthMethod? + allowConnectedAccounts Boolean @default(true) + allowSignIn Boolean @default(true) - @@id([tenancyId, configOAuthProviderId, providerAccountId]) + @@id([tenancyId, id]) + @@unique([tenancyId, configOAuthProviderId, projectUserId, providerAccountId]) @@index([tenancyId, projectUserId]) } @@ -251,23 +253,6 @@ model ContactChannel { @@unique([tenancyId, type, value, usedForAuth]) } -model ConnectedAccount { - tenancyId String @db.Uuid - id String @default(uuid()) @db.Uuid - projectUserId String @db.Uuid - configOAuthProviderId String - providerAccountId String - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - oauthAccount ProjectUserOAuthAccount @relation(fields: [tenancyId, configOAuthProviderId, providerAccountId], references: [tenancyId, configOAuthProviderId, providerAccountId]) - projectUser ProjectUser @relation(fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade) - - @@id([tenancyId, id]) - @@unique([tenancyId, configOAuthProviderId, providerAccountId]) -} - model AuthMethod { tenancyId String @db.Uuid id String @default(uuid()) @db.Uuid @@ -357,11 +342,13 @@ model OAuthAuthMethod { updatedAt DateTime @updatedAt authMethod AuthMethod @relation(fields: [tenancyId, authMethodId], references: [tenancyId, id], onDelete: Cascade) - oauthAccount ProjectUserOAuthAccount @relation(fields: [tenancyId, configOAuthProviderId, providerAccountId], references: [tenancyId, configOAuthProviderId, providerAccountId]) + oauthAccount ProjectUserOAuthAccount @relation(fields: [tenancyId, configOAuthProviderId, projectUserId, providerAccountId], references: [tenancyId, configOAuthProviderId, projectUserId, providerAccountId]) projectUser ProjectUser @relation(fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade) @@id([tenancyId, authMethodId]) @@unique([tenancyId, configOAuthProviderId, providerAccountId]) + @@unique([tenancyId, projectUserId, configOAuthProviderId]) + @@unique([tenancyId, configOAuthProviderId, projectUserId, providerAccountId]) } enum StandardOAuthProviderType { @@ -381,14 +368,13 @@ enum StandardOAuthProviderType { model OAuthToken { id String @id @default(uuid()) @db.Uuid - tenancyId String @db.Uuid - configOAuthProviderId String - providerAccountId String + tenancyId String @db.Uuid + oauthAccountId String @db.Uuid createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - projectUserOAuthAccount ProjectUserOAuthAccount @relation(fields: [tenancyId, configOAuthProviderId, providerAccountId], references: [tenancyId, configOAuthProviderId, providerAccountId], onDelete: Cascade) + projectUserOAuthAccount ProjectUserOAuthAccount @relation(fields: [tenancyId, oauthAccountId], references: [tenancyId, id], onDelete: Cascade) refreshToken String scopes String[] @@ -398,14 +384,13 @@ model OAuthToken { model OAuthAccessToken { id String @id @default(uuid()) @db.Uuid - tenancyId String @db.Uuid - configOAuthProviderId String - providerAccountId String + tenancyId String @db.Uuid + oauthAccountId String @db.Uuid createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - projectUserOAuthAccount ProjectUserOAuthAccount @relation(fields: [tenancyId, configOAuthProviderId, providerAccountId], references: [tenancyId, configOAuthProviderId, providerAccountId], onDelete: Cascade) + projectUserOAuthAccount ProjectUserOAuthAccount @relation(fields: [tenancyId, oauthAccountId], references: [tenancyId, id], onDelete: Cascade) accessToken String scopes String[] diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx index 4e023af58..fe655cf5e 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx @@ -187,15 +187,14 @@ const handler = createSmartRouteHandler({ } }); - const storeTokens = async () => { + const storeTokens = async (oauthAccountId: string) => { if (tokenSet.refreshToken) { await prisma.oAuthToken.create({ data: { tenancyId: outerInfo.tenancyId, - configOAuthProviderId: provider.id, refreshToken: tokenSet.refreshToken, - providerAccountId: userInfo.accountId, scopes: extractScopes(providerObj.scope + " " + providerScope), + oauthAccountId, } }); } @@ -203,11 +202,10 @@ const handler = createSmartRouteHandler({ await prisma.oAuthAccessToken.create({ data: { tenancyId: outerInfo.tenancyId, - configOAuthProviderId: provider.id, accessToken: tokenSet.accessToken, - providerAccountId: userInfo.accountId, scopes: extractScopes(providerObj.scope + " " + providerScope), expiresAt: tokenSet.accessTokenExpiredAt, + oauthAccountId, } }); }; @@ -220,16 +218,21 @@ const handler = createSmartRouteHandler({ { authenticateHandler: { handle: async () => { - const oldAccount = await prisma.projectUserOAuthAccount.findUnique({ + const oldAccounts = await prisma.projectUserOAuthAccount.findMany({ where: { - tenancyId_configOAuthProviderId_providerAccountId: { - tenancyId: outerInfo.tenancyId, - configOAuthProviderId: provider.id, - providerAccountId: userInfo.accountId, - }, + tenancyId: outerInfo.tenancyId, + configOAuthProviderId: provider.id, + providerAccountId: userInfo.accountId, + allowSignIn: true, }, }); + if (oldAccounts.length > 1) { + throw new StackAssertionError("Multiple accounts found for the same provider and account ID"); + } + + const oldAccount = oldAccounts[0] as (typeof oldAccounts)[number] | undefined; + // ========================== link account with user ========================== if (type === "link") { if (!projectUserId) { @@ -241,19 +244,20 @@ const handler = createSmartRouteHandler({ if (oldAccount.projectUserId !== projectUserId) { throw new KnownErrors.OAuthConnectionAlreadyConnectedToAnotherUser(); } - await storeTokens(); + await storeTokens(oldAccount.id); } else { // ========================== connect account with user ========================== - await createProjectUserOAuthAccount(prisma, { + const newOAuthAccount = await createProjectUserOAuthAccount(prisma, { tenancyId: outerInfo.tenancyId, providerId: provider.id, providerAccountId: userInfo.accountId, email: userInfo.email, projectUserId, }); + + await storeTokens(newOAuthAccount.id); } - await storeTokens(); return { id: projectUserId, newUser: false, @@ -264,7 +268,7 @@ const handler = createSmartRouteHandler({ // ========================== sign in user ========================== if (oldAccount) { - await storeTokens(); + await storeTokens(oldAccount.id); return { id: oldAccount.projectUserId, @@ -311,7 +315,7 @@ const handler = createSmartRouteHandler({ const existingUser = oldContactChannel.projectUser; // First create the OAuth account - await createProjectUserOAuthAccount(prisma, { + const newOAuthAccount = await createProjectUserOAuthAccount(prisma, { tenancyId: outerInfo.tenancyId, providerId: provider.id, providerAccountId: userInfo.accountId, @@ -333,7 +337,7 @@ const handler = createSmartRouteHandler({ } }); - await storeTokens(); + await storeTokens(newOAuthAccount.id); return { id: existingUser.projectUserId, newUser: false, @@ -367,7 +371,23 @@ const handler = createSmartRouteHandler({ }, }); - await storeTokens(); + const oauthAccount = await prisma.projectUserOAuthAccount.findUnique({ + where: { + tenancyId_configOAuthProviderId_projectUserId_providerAccountId: { + tenancyId: outerInfo.tenancyId, + configOAuthProviderId: provider.id, + providerAccountId: userInfo.accountId, + projectUserId: newAccount.id, + }, + }, + }); + + if (!oauthAccount) { + throw new StackAssertionError("OAuth account not found"); + } + + await storeTokens(oauthAccount.id); + return { id: newAccount.id, newUser: true, diff --git a/apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/access-token/crud.tsx b/apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/access-token/crud.tsx index cbe61bf39..e40a1bb9a 100644 --- a/apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/access-token/crud.tsx +++ b/apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/access-token/crud.tsx @@ -4,7 +4,7 @@ import { TokenSet } from "@/oauth/providers/base"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { KnownErrors } from "@stackframe/stack-shared"; -import { connectedAccountAccessTokenCrud } from "@stackframe/stack-shared/dist/interface/crud/oauth"; +import { connectedAccountAccessTokenCrud } from "@stackframe/stack-shared/dist/interface/crud/connected-accounts"; import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { StackAssertionError, StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; @@ -43,9 +43,9 @@ export const connectedAccountAccessTokenCrudHandlers = createLazyProxy(() => cre const accessTokens = await prisma.oAuthAccessToken.findMany({ where: { tenancyId: auth.tenancy.id, - configOAuthProviderId: params.provider_id, projectUserOAuthAccount: { projectUserId: params.user_id, + configOAuthProviderId: params.provider_id, }, expiresAt: { // is at least 5 minutes in the future @@ -53,6 +53,9 @@ export const connectedAccountAccessTokenCrudHandlers = createLazyProxy(() => cre }, isValid: true, }, + include: { + projectUserOAuthAccount: true, + }, }); const filteredTokens = accessTokens.filter((t) => { return extractScopes(data.scope || "").every((scope) => t.scopes.includes(scope)); @@ -79,12 +82,15 @@ export const connectedAccountAccessTokenCrudHandlers = createLazyProxy(() => cre const refreshTokens = await prisma.oAuthToken.findMany({ where: { tenancyId: auth.tenancy.id, - configOAuthProviderId: params.provider_id, projectUserOAuthAccount: { projectUserId: params.user_id, + configOAuthProviderId: params.provider_id, }, isValid: true, }, + include: { + projectUserOAuthAccount: true, + }, }); const filteredRefreshTokens = refreshTokens.filter((t) => { @@ -125,9 +131,8 @@ export const connectedAccountAccessTokenCrudHandlers = createLazyProxy(() => cre await prisma.oAuthAccessToken.create({ data: { tenancyId: auth.tenancy.id, - configOAuthProviderId: params.provider_id, accessToken: tokenSet.accessToken, - providerAccountId: token.providerAccountId, + oauthAccountId: token.projectUserOAuthAccount.id, scopes: token.scopes, expiresAt: tokenSet.accessTokenExpiredAt } @@ -143,9 +148,8 @@ export const connectedAccountAccessTokenCrudHandlers = createLazyProxy(() => cre await prisma.oAuthToken.create({ data: { tenancyId: auth.tenancy.id, - configOAuthProviderId: params.provider_id, refreshToken: tokenSet.refreshToken, - providerAccountId: token.providerAccountId, + oauthAccountId: token.projectUserOAuthAccount.id, scopes: token.scopes, } }); diff --git a/apps/backend/src/app/api/latest/oauth-providers/[user_id]/[provider_id]/route.tsx b/apps/backend/src/app/api/latest/oauth-providers/[user_id]/[provider_id]/route.tsx new file mode 100644 index 000000000..a8f286a32 --- /dev/null +++ b/apps/backend/src/app/api/latest/oauth-providers/[user_id]/[provider_id]/route.tsx @@ -0,0 +1,5 @@ +import { oauthProviderCrudHandlers } from "../../crud"; + +export const GET = oauthProviderCrudHandlers.readHandler; +export const PATCH = oauthProviderCrudHandlers.updateHandler; +export const DELETE = oauthProviderCrudHandlers.deleteHandler; diff --git a/apps/backend/src/app/api/latest/oauth-providers/crud.tsx b/apps/backend/src/app/api/latest/oauth-providers/crud.tsx new file mode 100644 index 000000000..d84c4387e --- /dev/null +++ b/apps/backend/src/app/api/latest/oauth-providers/crud.tsx @@ -0,0 +1,407 @@ +import { ensureUserExists } from "@/lib/request-checks"; +import { Tenancy } from "@/lib/tenancies"; +import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client"; +import { createCrudHandlers } from "@/route-handlers/crud-handler"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { oauthProviderCrud } from "@stackframe/stack-shared/dist/interface/crud/oauth-providers"; +import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; + +// Helper function to check if a provider type is already used for signing in +async function checkInputValidity(options: { + tenancy: Tenancy, +} & ({ + type: 'update', + providerId: string, + accountId?: string, + userId: string, + allowSignIn?: boolean, + allowConnectedAccounts?: boolean, +} | { + type: 'create', + providerConfigId: string, + accountId: string, + userId: string, + allowSignIn: boolean, + allowConnectedAccounts: boolean, +})): Promise { + const prismaClient = getPrismaClientForTenancy(options.tenancy); + + let providerConfigId: string; + if (options.type === 'update') { + const existingProvider = await prismaClient.projectUserOAuthAccount.findUnique({ + where: { + tenancyId_id: { + tenancyId: options.tenancy.id, + id: options.providerId, + }, + }, + }); + if (!existingProvider) { + throw new StatusError(StatusError.NotFound, `OAuth provider ${options.providerId} not found`); + } + providerConfigId = existingProvider.configOAuthProviderId; + } else { + providerConfigId = options.providerConfigId; + } + + const providersWithTheSameAccountIdAndAllowSignIn = (await prismaClient.projectUserOAuthAccount.findMany({ + where: { + tenancyId: options.tenancy.id, + providerAccountId: options.accountId, + allowSignIn: true, + }, + })).filter(p => p.id !== (options.type === 'update' ? options.providerId : undefined)); + + const providersWithTheSameTypeAndSameUserAndAllowSignIn = (await prismaClient.projectUserOAuthAccount.findMany({ + where: { + tenancyId: options.tenancy.id, + configOAuthProviderId: providerConfigId, + projectUserId: options.userId, + allowSignIn: true, + }, + })).filter(p => p.id !== (options.type === 'update' ? options.providerId : undefined)); + + const providersWithTheSameTypeAndUserAndAccountId = options.accountId ? (await prismaClient.projectUserOAuthAccount.findMany({ + where: { + tenancyId: options.tenancy.id, + configOAuthProviderId: providerConfigId, + projectUserId: options.userId, + providerAccountId: options.accountId, + }, + })).filter(p => p.id !== (options.type === 'update' ? options.providerId : undefined)) : []; + + if (options.allowSignIn && providersWithTheSameTypeAndSameUserAndAllowSignIn.length > 0) { + throw new StatusError(StatusError.BadRequest, `The same provider type with sign-in enabled already exists for this user.`); + } + + if (providersWithTheSameTypeAndUserAndAccountId.length > 0) { + throw new StatusError(StatusError.BadRequest, `The same provider type with the same account ID already exists for this user.`); + } + + if (options.allowSignIn && providersWithTheSameAccountIdAndAllowSignIn.length > 0) { + throw new KnownErrors.OAuthProviderAccountIdAlreadyUsedForSignIn(); + } +} + +async function ensureProviderExists(tenancy: Tenancy, userId: string, providerId: string) { + const prismaClient = getPrismaClientForTenancy(tenancy); + const provider = await prismaClient.projectUserOAuthAccount.findUnique({ + where: { + tenancyId_id: { + tenancyId: tenancy.id, + id: providerId, + }, + projectUserId: userId, + }, + include: { + oauthAuthMethod: true, + }, + }); + + if (!provider) { + throw new StatusError(StatusError.NotFound, `OAuth provider ${providerId} for user ${userId} not found`); + } + + return provider; +} + +function getProviderConfig(tenancy: Tenancy, providerConfigId: string) { + const config = tenancy.completeConfig; + let providerConfig: (typeof config.auth.oauth.providers)[number] & { id: string } | undefined; + for (const [providerId, provider] of Object.entries(config.auth.oauth.providers)) { + if (providerId === providerConfigId) { + providerConfig = { + id: providerId, + ...provider, + }; + break; + } + } + + if (!providerConfig) { + throw new StatusError(StatusError.NotFound, `OAuth provider ${providerConfigId} not found or not configured`); + } + + return providerConfig; +} + + +export const oauthProviderCrudHandlers = createLazyProxy(() => createCrudHandlers(oauthProviderCrud, { + paramsSchema: yupObject({ + provider_id: yupString().uuid().defined(), + user_id: userIdOrMeSchema.defined(), + }), + querySchema: yupObject({ + user_id: userIdOrMeSchema.optional().meta({ openapiField: { onlyShowInOperations: ['List'] } }), + }), + async onRead({ auth, params }) { + if (auth.type === 'client') { + const currentUserId = auth.user?.id || throwErr(new KnownErrors.CannotGetOwnUserWithoutUser()); + if (currentUserId !== params.user_id) { + throw new StatusError(StatusError.Forbidden, 'Client can only read OAuth providers for their own user.'); + } + } + + const prismaClient = getPrismaClientForTenancy(auth.tenancy); + await ensureUserExists(prismaClient, { tenancyId: auth.tenancy.id, userId: params.user_id }); + const oauthAccount = await ensureProviderExists(auth.tenancy, params.user_id, params.provider_id); + + const providerConfig = getProviderConfig(auth.tenancy, oauthAccount.configOAuthProviderId); + + return { + user_id: params.user_id, + id: oauthAccount.id, + email: oauthAccount.email || undefined, + type: providerConfig.type as any, // Type assertion to match schema + allow_sign_in: oauthAccount.allowSignIn, + allow_connected_accounts: oauthAccount.allowConnectedAccounts, + account_id: oauthAccount.providerAccountId, + }; + }, + async onList({ auth, query }) { + if (auth.type === 'client') { + const currentUserId = auth.user?.id || throwErr(new KnownErrors.CannotGetOwnUserWithoutUser()); + if (currentUserId !== query.user_id) { + throw new StatusError(StatusError.Forbidden, 'Client can only list OAuth providers for their own user.'); + } + } + + const prismaClient = getPrismaClientForTenancy(auth.tenancy); + + if (query.user_id) { + await ensureUserExists(prismaClient, { tenancyId: auth.tenancy.id, userId: query.user_id }); + } + + const oauthAccounts = await prismaClient.projectUserOAuthAccount.findMany({ + where: { + tenancyId: auth.tenancy.id, + projectUserId: query.user_id, + }, + orderBy: { + createdAt: 'asc', + }, + }); + + return { + items: oauthAccounts + .map((oauthAccount) => { + const providerConfig = getProviderConfig(auth.tenancy, oauthAccount.configOAuthProviderId); + + return { + user_id: oauthAccount.projectUserId || throwErr("OAuth account has no project user ID"), + id: oauthAccount.id, + email: oauthAccount.email || undefined, + type: providerConfig.type as any, // Type assertion to match schema + allow_sign_in: oauthAccount.allowSignIn, + allow_connected_accounts: oauthAccount.allowConnectedAccounts, + account_id: oauthAccount.providerAccountId, + }; + }), + is_paginated: false, + }; + }, + async onUpdate({ auth, data, params }) { + if (auth.type === 'client') { + const currentUserId = auth.user?.id || throwErr(new KnownErrors.CannotGetOwnUserWithoutUser()); + if (currentUserId !== params.user_id) { + throw new StatusError(StatusError.Forbidden, 'Client can only update OAuth providers for their own user.'); + } + } + + const prismaClient = getPrismaClientForTenancy(auth.tenancy); + await ensureUserExists(prismaClient, { tenancyId: auth.tenancy.id, userId: params.user_id }); + const existingOAuthAccount = await ensureProviderExists(auth.tenancy, params.user_id, params.provider_id); + + await checkInputValidity({ + tenancy: auth.tenancy, + type: 'update', + providerId: params.provider_id, + accountId: data.account_id, + userId: params.user_id, + allowSignIn: data.allow_sign_in, + allowConnectedAccounts: data.allow_connected_accounts, + }); + + const result = await retryTransaction(prismaClient, async (tx) => { + // Handle allow_sign_in changes + if (data.allow_sign_in !== undefined) { + await tx.projectUserOAuthAccount.update({ + where: { + tenancyId_id: { + tenancyId: auth.tenancy.id, + id: params.provider_id, + }, + }, + data: { + allowSignIn: data.allow_sign_in, + }, + }); + + if (data.allow_sign_in) { + if (!existingOAuthAccount.oauthAuthMethod) { + await tx.authMethod.create({ + data: { + tenancyId: auth.tenancy.id, + projectUserId: params.user_id, + oauthAuthMethod: { + create: { + configOAuthProviderId: existingOAuthAccount.configOAuthProviderId, + projectUserId: params.user_id, + providerAccountId: existingOAuthAccount.providerAccountId, + }, + }, + }, + }); + } + } else { + if (existingOAuthAccount.oauthAuthMethod) { + await tx.authMethod.delete({ + where: { + tenancyId_id: { + tenancyId: auth.tenancy.id, + id: existingOAuthAccount.oauthAuthMethod.authMethodId, + }, + }, + }); + } + } + } + + // Handle allow_connected_accounts changes + if (data.allow_connected_accounts !== undefined) { + await tx.projectUserOAuthAccount.update({ + where: { + tenancyId_id: { + tenancyId: auth.tenancy.id, + id: params.provider_id, + }, + }, + data: { + allowConnectedAccounts: data.allow_connected_accounts, + }, + }); + } + + await tx.projectUserOAuthAccount.update({ + where: { + tenancyId_id: { + tenancyId: auth.tenancy.id, + id: params.provider_id, + }, + }, + data: { + email: data.email, + providerAccountId: data.account_id, + }, + }); + + const providerConfig = getProviderConfig(auth.tenancy, existingOAuthAccount.configOAuthProviderId); + + return { + user_id: params.user_id, + id: params.provider_id, + email: data.email ?? existingOAuthAccount.email ?? undefined, + type: providerConfig.type as any, + allow_sign_in: data.allow_sign_in ?? existingOAuthAccount.allowSignIn, + allow_connected_accounts: data.allow_connected_accounts ?? existingOAuthAccount.allowConnectedAccounts, + account_id: data.account_id ?? existingOAuthAccount.providerAccountId, + }; + }); + + return result; + }, + async onDelete({ auth, params }) { + if (auth.type === 'client') { + const currentUserId = auth.user?.id || throwErr(new KnownErrors.CannotGetOwnUserWithoutUser()); + if (currentUserId !== params.user_id) { + throw new StatusError(StatusError.Forbidden, 'Client can only delete OAuth providers for their own user.'); + } + } + + const prismaClient = getPrismaClientForTenancy(auth.tenancy); + await ensureUserExists(prismaClient, { tenancyId: auth.tenancy.id, userId: params.user_id }); + const existingOAuthAccount = await ensureProviderExists(auth.tenancy, params.user_id, params.provider_id); + + await retryTransaction(prismaClient, async (tx) => { + if (existingOAuthAccount.oauthAuthMethod) { + await tx.authMethod.delete({ + where: { + tenancyId_id: { + tenancyId: auth.tenancy.id, + id: existingOAuthAccount.oauthAuthMethod.authMethodId, + }, + }, + }); + } + + await tx.projectUserOAuthAccount.delete({ + where: { + tenancyId_id: { + tenancyId: auth.tenancy.id, + id: params.provider_id, + }, + }, + }); + }); + }, + async onCreate({ auth, data }) { + const prismaClient = getPrismaClientForTenancy(auth.tenancy); + const providerConfig = getProviderConfig(auth.tenancy, data.provider_config_id); + + await ensureUserExists(prismaClient, { tenancyId: auth.tenancy.id, userId: data.user_id }); + + await checkInputValidity({ + tenancy: auth.tenancy, + type: 'create', + providerConfigId: data.provider_config_id, + accountId: data.account_id, + userId: data.user_id, + allowSignIn: data.allow_sign_in, + allowConnectedAccounts: data.allow_connected_accounts, + }); + + const created = await retryTransaction(prismaClient, async (tx) => { + const created = await tx.projectUserOAuthAccount.create({ + data: { + tenancyId: auth.tenancy.id, + projectUserId: data.user_id, + configOAuthProviderId: data.provider_config_id, + providerAccountId: data.account_id, + email: data.email, + allowSignIn: data.allow_sign_in, + allowConnectedAccounts: data.allow_connected_accounts, + }, + }); + + if (data.allow_sign_in) { + await tx.authMethod.create({ + data: { + tenancyId: auth.tenancy.id, + projectUserId: data.user_id, + oauthAuthMethod: { + create: { + configOAuthProviderId: data.provider_config_id, + projectUserId: data.user_id, + providerAccountId: data.account_id, + }, + }, + }, + }); + } + + return created; + }); + + return { + user_id: data.user_id, + email: data.email, + id: created.id, + type: providerConfig.type as any, + allow_sign_in: data.allow_sign_in, + allow_connected_accounts: data.allow_connected_accounts, + account_id: data.account_id, + }; + }, +})); diff --git a/apps/backend/src/app/api/latest/oauth-providers/route.tsx b/apps/backend/src/app/api/latest/oauth-providers/route.tsx new file mode 100644 index 000000000..7be57ca99 --- /dev/null +++ b/apps/backend/src/app/api/latest/oauth-providers/route.tsx @@ -0,0 +1,4 @@ +import { oauthProviderCrudHandlers } from "./crud"; + +export const GET = oauthProviderCrudHandlers.listHandler; +export const POST = oauthProviderCrudHandlers.createHandler; diff --git a/apps/backend/src/app/api/latest/users/crud.tsx b/apps/backend/src/app/api/latest/users/crud.tsx index d2e5a73de..eaf2bebd9 100644 --- a/apps/backend/src/app/api/latest/users/crud.tsx +++ b/apps/backend/src/app/api/latest/users/crud.tsx @@ -18,7 +18,7 @@ import { validateBase64Image } from "@stackframe/stack-shared/dist/utils/base64" import { decodeBase64 } from "@stackframe/stack-shared/dist/utils/bytes"; import { StackAssertionError, StatusError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { hashPassword, isPasswordHashValid } from "@stackframe/stack-shared/dist/utils/hashes"; -import { get, has } from "@stackframe/stack-shared/dist/utils/objects"; +import { has } from "@stackframe/stack-shared/dist/utils/objects"; import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids"; import { teamPrismaToCrud, teamsCrudHandlers } from "../teams/crud"; @@ -498,7 +498,6 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC throw new StatusError(StatusError.BadRequest, `OAuth provider ${provider.id} not found`); } - const oauthProvider = get(config.auth.oauth.providers, provider.id); const authMethod = await tx.authMethod.create({ data: { @@ -514,19 +513,13 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC configOAuthProviderId: provider.id, providerAccountId: provider.account_id, email: provider.email, - ...oauthProvider.allowConnectedAccounts ? { - connectedAccount: { - create: { - projectUserId: newUser.projectUserId, - } - } - } : {}, oauthAuthMethod: { create: { - projectUserId: newUser.projectUserId, authMethodId: authMethod.id, } }, + allowConnectedAccounts: true, + allowSignIn: true, } }); } diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index 6449e3444..f68b99c64 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -377,6 +377,7 @@ export const renderedOrganizationConfigToProjectCrud = (renderedConfig: Organiza return undefined; } return filterUndefined({ + provider_config_id: oauthProviderId, id: oauthProvider.type, type: oauthProvider.isShared ? 'shared' : 'standard', client_id: oauthProvider.clientId, diff --git a/apps/e2e/tests/backend/endpoints/api/v1/integrations/custom/projects/provision.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/integrations/custom/projects/provision.test.ts index 4b5708faa..d3c797d41 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/integrations/custom/projects/provision.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/integrations/custom/projects/provision.test.ts @@ -62,10 +62,12 @@ it("should be able to provision a new project if client details are correct", as "oauth_providers": [ { "id": "github", + "provider_config_id": "github", "type": "shared", }, { "id": "google", + "provider_config_id": "google", "type": "shared", }, ], diff --git a/apps/e2e/tests/backend/endpoints/api/v1/integrations/neon/oauth-providers.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/integrations/neon/oauth-providers.test.ts index bdf000c51..cbd93debc 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/integrations/neon/oauth-providers.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/integrations/neon/oauth-providers.test.ts @@ -113,6 +113,7 @@ it("lists oauth providers", async ({ expect }) => { "oauth_providers": [ { "id": "google", + "provider_config_id": "google", "type": "shared", }, ], diff --git a/apps/e2e/tests/backend/endpoints/api/v1/integrations/neon/projects/provision.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/integrations/neon/projects/provision.test.ts index f237e70c0..4b89d2b45 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/integrations/neon/projects/provision.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/integrations/neon/projects/provision.test.ts @@ -62,10 +62,12 @@ it("should be able to provision a new project if neon client details are correct "oauth_providers": [ { "id": "github", + "provider_config_id": "github", "type": "shared", }, { "id": "google", + "provider_config_id": "google", "type": "shared", }, ], diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/projects.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/projects.test.ts index d25dd5523..2c6148065 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/projects.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/projects.test.ts @@ -182,6 +182,7 @@ it("creates a new project with different configurations", async ({ expect }) => "oauth_providers": [ { "id": "google", + "provider_config_id": "google", "type": "shared", }, ], diff --git a/apps/e2e/tests/backend/endpoints/api/v1/oauth-providers.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/oauth-providers.test.ts new file mode 100644 index 000000000..1c3f2e319 --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/oauth-providers.test.ts @@ -0,0 +1,1018 @@ +import { it } from "../../../../helpers"; +import { Auth, Project, backendContext, createMailbox, niceBackendFetch } from "../../../backend-helpers"; + +async function createAndSwitchToOAuthEnabledProject() { + return await Project.createAndSwitch({ + config: { + magic_link_enabled: true, + oauth_providers: [ + { + id: "spotify", + type: "standard", + client_id: "test_client_id", + client_secret: "test_client_secret", + } + ] + } + }); +} + +it("should create an OAuth provider connection", async ({ expect }: { expect: any }) => { + const { createProjectResponse } = await createAndSwitchToOAuthEnabledProject(); + await Auth.Otp.signIn(); + + const providerConfig = createProjectResponse.body.config.oauth_providers.find((p: any) => p.provider_config_id === "spotify"); + expect(providerConfig).toBeDefined(); + + const createResponse = await niceBackendFetch("/api/v1/oauth-providers", { + method: "POST", + accessType: "server", + body: { + user_id: "me", + provider_config_id: providerConfig.id, + account_id: "test_spotify_user_123", + email: "test@example.com", + allow_sign_in: true, + allow_connected_accounts: true, + }, + }); + + expect(createResponse).toMatchInlineSnapshot(` + NiceResponse { + "status": 201, + "body": { + "account_id": "test_spotify_user_123", + "allow_connected_accounts": true, + "allow_sign_in": true, + "email": "test@example.com", + "id": "", + "type": "spotify", + "user_id": "", + }, + "headers": Headers {