mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
OAuth provider crud (#759)
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
Docker Emulator Test / docker (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Test / docker (push) Has been cancelled
Runs E2E API Tests / build (22.x) (push) Has been cancelled
Runs E2E API Tests with external source of truth / build (22.x) (push) Has been cancelled
Lint & build / lint_and_build (latest) (push) Has been cancelled
Dev Environment Test / restart-dev-and-test (push) Has been cancelled
Run setup tests / setup-tests (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
Docker Emulator Test / docker (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Test / docker (push) Has been cancelled
Runs E2E API Tests / build (22.x) (push) Has been cancelled
Runs E2E API Tests with external source of truth / build (22.x) (push) Has been cancelled
Lint & build / lint_and_build (latest) (push) Has been cancelled
Dev Environment Test / restart-dev-and-test (push) Has been cancelled
Run setup tests / setup-tests (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
> [!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. > > <sup>This description was created by </sup>[<img alt="Ellipsis" src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral)<sup> forbca9bcf12b. You can [customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this summary. It will automatically update as commits are pushed.</sup> <!-- ELLIPSIS_HIDDEN --> ---- > [!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. > > <sup>This description was created by </sup>[<img alt="Ellipsis" src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral)<sup> foraaa55f7d5c. You can [customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this summary. It will automatically update as commits are pushed.</sup> <!-- ELLIPSIS_HIDDEN --> --------- Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
This commit is contained in:
parent
40582b6cf2
commit
018be1fdff
@ -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;
|
||||
@ -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[]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
}
|
||||
});
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
import { oauthProviderCrudHandlers } from "../../crud";
|
||||
|
||||
export const GET = oauthProviderCrudHandlers.readHandler;
|
||||
export const PATCH = oauthProviderCrudHandlers.updateHandler;
|
||||
export const DELETE = oauthProviderCrudHandlers.deleteHandler;
|
||||
407
apps/backend/src/app/api/latest/oauth-providers/crud.tsx
Normal file
407
apps/backend/src/app/api/latest/oauth-providers/crud.tsx
Normal file
@ -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<void> {
|
||||
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,
|
||||
};
|
||||
},
|
||||
}));
|
||||
@ -0,0 +1,4 @@
|
||||
import { oauthProviderCrudHandlers } from "./crud";
|
||||
|
||||
export const GET = oauthProviderCrudHandlers.listHandler;
|
||||
export const POST = oauthProviderCrudHandlers.createHandler;
|
||||
@ -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,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
},
|
||||
],
|
||||
|
||||
@ -113,6 +113,7 @@ it("lists oauth providers", async ({ expect }) => {
|
||||
"oauth_providers": [
|
||||
{
|
||||
"id": "google",
|
||||
"provider_config_id": "google",
|
||||
"type": "shared",
|
||||
},
|
||||
],
|
||||
|
||||
@ -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",
|
||||
},
|
||||
],
|
||||
|
||||
@ -182,6 +182,7 @@ it("creates a new project with different configurations", async ({ expect }) =>
|
||||
"oauth_providers": [
|
||||
{
|
||||
"id": "google",
|
||||
"provider_config_id": "google",
|
||||
"type": "shared",
|
||||
},
|
||||
],
|
||||
|
||||
1018
apps/e2e/tests/backend/endpoints/api/v1/oauth-providers.test.ts
Normal file
1018
apps/e2e/tests/backend/endpoints/api/v1/oauth-providers.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -758,6 +758,7 @@ it("updates the project oauth configuration", async ({ expect }) => {
|
||||
"oauth_providers": [
|
||||
{
|
||||
"id": "google",
|
||||
"provider_config_id": "google",
|
||||
"type": "shared",
|
||||
},
|
||||
],
|
||||
@ -807,6 +808,7 @@ it("updates the project oauth configuration", async ({ expect }) => {
|
||||
"oauth_providers": [
|
||||
{
|
||||
"id": "google",
|
||||
"provider_config_id": "google",
|
||||
"type": "shared",
|
||||
},
|
||||
],
|
||||
@ -860,6 +862,7 @@ it("updates the project oauth configuration", async ({ expect }) => {
|
||||
"client_id": "client_id",
|
||||
"client_secret": "client_secret",
|
||||
"id": "google",
|
||||
"provider_config_id": "google",
|
||||
"type": "standard",
|
||||
},
|
||||
],
|
||||
@ -908,6 +911,7 @@ it("updates the project oauth configuration", async ({ expect }) => {
|
||||
"oauth_providers": [
|
||||
{
|
||||
"id": "spotify",
|
||||
"provider_config_id": "spotify",
|
||||
"type": "shared",
|
||||
},
|
||||
],
|
||||
@ -966,10 +970,12 @@ it("updates the project oauth configuration", async ({ expect }) => {
|
||||
"oauth_providers": [
|
||||
{
|
||||
"id": "google",
|
||||
"provider_config_id": "google",
|
||||
"type": "shared",
|
||||
},
|
||||
{
|
||||
"id": "spotify",
|
||||
"provider_config_id": "spotify",
|
||||
"type": "shared",
|
||||
},
|
||||
],
|
||||
@ -1028,10 +1034,12 @@ it("updates the project oauth configuration", async ({ expect }) => {
|
||||
"oauth_providers": [
|
||||
{
|
||||
"id": "google",
|
||||
"provider_config_id": "google",
|
||||
"type": "shared",
|
||||
},
|
||||
{
|
||||
"id": "spotify",
|
||||
"provider_config_id": "spotify",
|
||||
"type": "shared",
|
||||
},
|
||||
],
|
||||
|
||||
@ -13,10 +13,10 @@ import { AuthenticationResponseJSON, PublicKeyCredentialCreationOptionsJSON, Pub
|
||||
import { wait } from '../utils/promises';
|
||||
import { Result } from "../utils/results";
|
||||
import { deindent } from '../utils/strings';
|
||||
import { ConnectedAccountAccessTokenCrud } from './crud/connected-accounts';
|
||||
import { ContactChannelsCrud } from './crud/contact-channels';
|
||||
import { CurrentUserCrud } from './crud/current-user';
|
||||
import { NotificationPreferenceCrud } from './crud/notification-preferences';
|
||||
import { ConnectedAccountAccessTokenCrud } from './crud/oauth';
|
||||
import { TeamApiKeysCrud, UserApiKeysCrud, teamApiKeysCreateInputSchema, teamApiKeysCreateOutputSchema, userApiKeysCreateInputSchema, userApiKeysCreateOutputSchema } from './crud/project-api-keys';
|
||||
import { ProjectPermissionsCrud } from './crud/project-permissions';
|
||||
import { AdminUserProjectsCrud, ClientProjectsCrud } from './crud/projects';
|
||||
@ -1669,5 +1669,78 @@ export class StackClientInterface {
|
||||
session,
|
||||
);
|
||||
}
|
||||
|
||||
async getOAuthProvider(
|
||||
userId: string,
|
||||
providerId: string,
|
||||
session: InternalSession | null,
|
||||
requestType: "client" | "server" | "admin" = "client",
|
||||
): Promise<{
|
||||
id: string,
|
||||
type: string,
|
||||
user_id: string,
|
||||
account_id?: string,
|
||||
email: string,
|
||||
allow_sign_in: boolean,
|
||||
allow_connected_accounts: boolean,
|
||||
}> {
|
||||
const sendRequest = requestType === "client" ? this.sendClientRequest : (this as any).sendServerRequest;
|
||||
const response = await sendRequest.call(this,
|
||||
`/oauth-providers/${userId}/${providerId}`,
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
session,
|
||||
requestType,
|
||||
);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async listOAuthProviders(
|
||||
options: {
|
||||
user_id?: string,
|
||||
} = {},
|
||||
session: InternalSession | null,
|
||||
requestType: "client" | "server" | "admin" = "client",
|
||||
): Promise<{
|
||||
id: string,
|
||||
type: string,
|
||||
user_id: string,
|
||||
account_id?: string,
|
||||
email: string,
|
||||
allow_sign_in: boolean,
|
||||
allow_connected_accounts: boolean,
|
||||
}[]> {
|
||||
const sendRequest = requestType === "client" ? this.sendClientRequest : (this as any).sendServerRequest;
|
||||
const queryParams = new URLSearchParams(filterUndefined(options));
|
||||
const response = await sendRequest.call(this,
|
||||
`/oauth-providers${queryParams.toString() ? `?${queryParams.toString()}` : ''}`,
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
session,
|
||||
requestType,
|
||||
);
|
||||
const result = await response.json();
|
||||
return result.items;
|
||||
}
|
||||
|
||||
async deleteOAuthProvider(
|
||||
userId: string,
|
||||
providerId: string,
|
||||
session: InternalSession | null,
|
||||
requestType: "client" | "server" | "admin" = "client",
|
||||
): Promise<{ success: boolean }> {
|
||||
const sendRequest = requestType === "client" ? this.sendClientRequest : (this as any).sendServerRequest;
|
||||
const response = await sendRequest.call(this,
|
||||
`/oauth-providers/${userId}/${providerId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
session,
|
||||
requestType,
|
||||
);
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
84
packages/stack-shared/src/interface/crud/oauth-providers.ts
Normal file
84
packages/stack-shared/src/interface/crud/oauth-providers.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { CrudTypeOf, createCrud } from "../../crud";
|
||||
import {
|
||||
oauthProviderAccountIdSchema,
|
||||
oauthProviderAllowConnectedAccountsSchema,
|
||||
oauthProviderAllowSignInSchema,
|
||||
oauthProviderEmailSchema,
|
||||
oauthProviderIdSchema,
|
||||
oauthProviderTypeSchema,
|
||||
userIdOrMeSchema,
|
||||
yupMixed,
|
||||
yupObject,
|
||||
yupString
|
||||
} from "../../schema-fields";
|
||||
|
||||
export const oauthProviderClientReadSchema = yupObject({
|
||||
user_id: userIdOrMeSchema.defined(),
|
||||
id: oauthProviderIdSchema.defined(),
|
||||
email: oauthProviderEmailSchema.optional(),
|
||||
type: oauthProviderTypeSchema.defined(),
|
||||
allow_sign_in: oauthProviderAllowSignInSchema.defined(),
|
||||
allow_connected_accounts: oauthProviderAllowConnectedAccountsSchema.defined(),
|
||||
}).defined();
|
||||
|
||||
export const oauthProviderServerReadSchema = oauthProviderClientReadSchema.concat(yupObject({
|
||||
account_id: oauthProviderAccountIdSchema.defined(),
|
||||
}));
|
||||
|
||||
export const oauthProviderCrudClientUpdateSchema = yupObject({
|
||||
allow_sign_in: oauthProviderAllowSignInSchema.optional(),
|
||||
allow_connected_accounts: oauthProviderAllowConnectedAccountsSchema.optional(),
|
||||
}).defined();
|
||||
|
||||
export const oauthProviderCrudServerUpdateSchema = oauthProviderCrudClientUpdateSchema.concat(yupObject({
|
||||
email: oauthProviderEmailSchema.optional(),
|
||||
account_id: oauthProviderAccountIdSchema.optional(),
|
||||
}));
|
||||
|
||||
export const oauthProviderCrudServerCreateSchema = yupObject({
|
||||
user_id: userIdOrMeSchema.defined(),
|
||||
provider_config_id: yupString().defined(),
|
||||
email: oauthProviderEmailSchema.optional(),
|
||||
allow_sign_in: oauthProviderAllowSignInSchema.defined(),
|
||||
allow_connected_accounts: oauthProviderAllowConnectedAccountsSchema.defined(),
|
||||
account_id: oauthProviderAccountIdSchema.defined(),
|
||||
}).defined();
|
||||
|
||||
export const oauthProviderCrudClientDeleteSchema = yupMixed();
|
||||
|
||||
export const oauthProviderCrud = createCrud({
|
||||
clientReadSchema: oauthProviderClientReadSchema,
|
||||
clientUpdateSchema: oauthProviderCrudClientUpdateSchema,
|
||||
clientDeleteSchema: oauthProviderCrudClientDeleteSchema,
|
||||
serverReadSchema: oauthProviderServerReadSchema,
|
||||
serverUpdateSchema: oauthProviderCrudServerUpdateSchema,
|
||||
serverCreateSchema: oauthProviderCrudServerCreateSchema,
|
||||
docs: {
|
||||
clientRead: {
|
||||
summary: "Get an OAuth provider",
|
||||
description: "Retrieves a specific OAuth provider by the user ID and the OAuth provider ID.",
|
||||
tags: ["OAuth Providers"],
|
||||
},
|
||||
serverCreate: {
|
||||
summary: "Create an OAuth provider",
|
||||
description: "Add a new OAuth provider for a user.",
|
||||
tags: ["OAuth Providers"],
|
||||
},
|
||||
serverUpdate: {
|
||||
summary: "Update an OAuth provider",
|
||||
description: "Updates an existing OAuth provider. Only the values provided will be updated.",
|
||||
tags: ["OAuth Providers"],
|
||||
},
|
||||
clientDelete: {
|
||||
summary: "Delete an OAuth provider",
|
||||
description: "Removes an OAuth provider for a given user.",
|
||||
tags: ["OAuth Providers"],
|
||||
},
|
||||
clientList: {
|
||||
summary: "List OAuth providers",
|
||||
description: "Retrieves a list of all OAuth providers for a user.",
|
||||
tags: ["OAuth Providers"],
|
||||
},
|
||||
}
|
||||
});
|
||||
export type OAuthProviderCrud = CrudTypeOf<typeof oauthProviderCrud>;
|
||||
@ -6,7 +6,8 @@ const teamPermissionSchema = yupObject({
|
||||
id: yupString().defined(),
|
||||
}).defined();
|
||||
|
||||
const oauthProviderSchema = yupObject({
|
||||
const oauthProviderReadSchema = yupObject({
|
||||
provider_config_id: schemaFields.yupString().defined(),
|
||||
id: schemaFields.oauthIdSchema.defined(),
|
||||
type: schemaFields.oauthTypeSchema.defined(),
|
||||
client_id: schemaFields.yupDefinedAndNonEmptyWhen(
|
||||
@ -23,6 +24,8 @@ const oauthProviderSchema = yupObject({
|
||||
microsoft_tenant_id: schemaFields.oauthMicrosoftTenantIdSchema.optional(),
|
||||
});
|
||||
|
||||
const oauthProviderWriteSchema = oauthProviderReadSchema.omit(['provider_config_id']);
|
||||
|
||||
const enabledOAuthProviderSchema = yupObject({
|
||||
id: schemaFields.oauthIdSchema.defined(),
|
||||
});
|
||||
@ -76,7 +79,7 @@ export const projectsCrudAdminReadSchema = yupObject({
|
||||
client_user_deletion_enabled: schemaFields.projectClientUserDeletionEnabledSchema.defined(),
|
||||
allow_user_api_keys: schemaFields.yupBoolean().defined(),
|
||||
allow_team_api_keys: schemaFields.yupBoolean().defined(),
|
||||
oauth_providers: yupArray(oauthProviderSchema.defined()).defined(),
|
||||
oauth_providers: yupArray(oauthProviderReadSchema.defined()).defined(),
|
||||
enabled_oauth_providers: yupArray(enabledOAuthProviderSchema.defined()).defined().meta({ openapiField: { hidden: true } }),
|
||||
domains: yupArray(domainSchema.defined()).defined(),
|
||||
email_config: emailConfigSchema.defined(),
|
||||
@ -123,7 +126,7 @@ export const projectsCrudAdminUpdateSchema = yupObject({
|
||||
email_config: emailConfigSchema.optional().default(undefined),
|
||||
email_theme: schemaFields.emailThemeSchema.optional(),
|
||||
domains: yupArray(domainSchema.defined()).optional().default(undefined),
|
||||
oauth_providers: yupArray(oauthProviderSchema.defined()).optional().default(undefined),
|
||||
oauth_providers: yupArray(oauthProviderWriteSchema.defined()).optional().default(undefined),
|
||||
create_team_on_sign_up: schemaFields.projectCreateTeamOnSignUpSchema.optional(),
|
||||
team_creator_default_permissions: yupArray(teamPermissionSchema.defined()).optional(),
|
||||
team_member_default_permissions: yupArray(teamPermissionSchema.defined()).optional(),
|
||||
|
||||
@ -8,10 +8,10 @@ import {
|
||||
ClientInterfaceOptions,
|
||||
StackClientInterface
|
||||
} from "./client-interface";
|
||||
import { ConnectedAccountAccessTokenCrud } from "./crud/connected-accounts";
|
||||
import { ContactChannelsCrud } from "./crud/contact-channels";
|
||||
import { CurrentUserCrud } from "./crud/current-user";
|
||||
import { NotificationPreferenceCrud } from "./crud/notification-preferences";
|
||||
import { ConnectedAccountAccessTokenCrud } from "./crud/oauth";
|
||||
import { ProjectPermissionsCrud } from "./crud/project-permissions";
|
||||
import { SessionsCrud } from "./crud/sessions";
|
||||
import { TeamInvitationCrud } from "./crud/team-invitation";
|
||||
@ -690,4 +690,109 @@ export class StackServerInterface extends StackClientInterface {
|
||||
return res.error;
|
||||
}
|
||||
}
|
||||
|
||||
// OAuth Providers CRUD operations
|
||||
async createServerOAuthProvider(
|
||||
data: {
|
||||
user_id: string,
|
||||
provider_config_id: string,
|
||||
account_id: string,
|
||||
email: string,
|
||||
allow_sign_in: boolean,
|
||||
allow_connected_accounts: boolean,
|
||||
},
|
||||
): Promise<{
|
||||
id: string,
|
||||
type: string,
|
||||
user_id: string,
|
||||
account_id: string,
|
||||
email: string,
|
||||
allow_sign_in: boolean,
|
||||
allow_connected_accounts: boolean,
|
||||
}> {
|
||||
const response = await this.sendServerRequest(
|
||||
"/oauth-providers",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
null,
|
||||
);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
|
||||
async listServerOAuthProviders(
|
||||
options: {
|
||||
user_id?: string,
|
||||
} = {},
|
||||
): Promise<{
|
||||
id: string,
|
||||
type: string,
|
||||
user_id: string,
|
||||
account_id: string,
|
||||
email: string,
|
||||
allow_sign_in: boolean,
|
||||
allow_connected_accounts: boolean,
|
||||
}[]> {
|
||||
const queryParams = new URLSearchParams(filterUndefined(options));
|
||||
const response = await this.sendServerRequest(
|
||||
`/oauth-providers${queryParams.toString() ? `?${queryParams.toString()}` : ''}`,
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
null,
|
||||
);
|
||||
const result = await response.json();
|
||||
return result.items;
|
||||
}
|
||||
|
||||
async updateServerOAuthProvider(
|
||||
userId: string,
|
||||
providerId: string,
|
||||
data: {
|
||||
account_id?: string,
|
||||
email?: string,
|
||||
allow_sign_in?: boolean,
|
||||
allow_connected_accounts?: boolean,
|
||||
},
|
||||
): Promise<{
|
||||
id: string,
|
||||
type: string,
|
||||
user_id: string,
|
||||
account_id: string,
|
||||
email: string,
|
||||
allow_sign_in: boolean,
|
||||
allow_connected_accounts: boolean,
|
||||
}> {
|
||||
const response = await this.sendServerRequest(
|
||||
urlString`/oauth-providers/${userId}/${providerId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
null,
|
||||
);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async deleteServerOAuthProvider(
|
||||
userId: string,
|
||||
providerId: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
const response = await this.sendServerRequest(
|
||||
urlString`/oauth-providers/${userId}/${providerId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
null,
|
||||
);
|
||||
return await response.json();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1109,6 +1109,16 @@ const OAuthProviderNotFoundOrNotEnabled = createKnownErrorConstructor(
|
||||
() => [] as const,
|
||||
);
|
||||
|
||||
const OAuthProviderAccountIdAlreadyUsedForSignIn = createKnownErrorConstructor(
|
||||
KnownError,
|
||||
"OAUTH_PROVIDER_ACCOUNT_ID_ALREADY_USED_FOR_SIGN_IN",
|
||||
() => [
|
||||
400,
|
||||
`A provider with the same account ID is already used for signing in.`,
|
||||
] as const,
|
||||
() => [] as const,
|
||||
);
|
||||
|
||||
const MultiFactorAuthenticationRequired = createKnownErrorConstructor(
|
||||
KnownError,
|
||||
"MULTI_FACTOR_AUTHENTICATION_REQUIRED",
|
||||
@ -1475,6 +1485,7 @@ export const KnownErrors = {
|
||||
UserAlreadyConnectedToAnotherOAuthConnection,
|
||||
OuterOAuthTimeout,
|
||||
OAuthProviderNotFoundOrNotEnabled,
|
||||
OAuthProviderAccountIdAlreadyUsedForSignIn,
|
||||
MultiFactorAuthenticationRequired,
|
||||
InvalidTotpCode,
|
||||
UserAuthenticationRequired,
|
||||
|
||||
@ -491,6 +491,14 @@ export const contactChannelUsedForAuthSchema = yupBoolean().meta({ openapiField:
|
||||
export const contactChannelIsVerifiedSchema = yupBoolean().meta({ openapiField: { description: 'Whether the contact channel has been verified. If this is set to `true`, the contact channel has been verified to belong to the user.', exampleValue: true } });
|
||||
export const contactChannelIsPrimarySchema = yupBoolean().meta({ openapiField: { description: 'Whether the contact channel is the primary contact channel. If this is set to `true`, it will be used for authentication and notifications by default.', exampleValue: true } });
|
||||
|
||||
// OAuth providers
|
||||
export const oauthProviderIdSchema = yupString().uuid().meta({ openapiField: { description: _idDescription('OAuth provider'), exampleValue: 'b3d396b8-c574-4c80-97b3-50031675ceb2' } });
|
||||
export const oauthProviderEmailSchema = emailSchema.meta({ openapiField: { description: 'Email of the OAuth provider. This is used to display and identify the OAuth provider in the UI.', exampleValue: 'test@gmail.com' } });
|
||||
export const oauthProviderTypeSchema = yupString().oneOf(allProviders).meta({ openapiField: { description: `OAuth provider type, one of ${allProviders.map(x => `\`${x}\``).join(', ')}`, exampleValue: 'google' } });
|
||||
export const oauthProviderAllowSignInSchema = yupBoolean().meta({ openapiField: { description: 'Whether the user can use this OAuth provider to sign in. Only one OAuth provider per type can have this set to `true`.', exampleValue: true } });
|
||||
export const oauthProviderAllowConnectedAccountsSchema = yupBoolean().meta({ openapiField: { description: 'Whether the user can use this OAuth provider as connected account. Multiple OAuth providers per type can have this set to `true`.', exampleValue: true } });
|
||||
export const oauthProviderAccountIdSchema = yupString().meta({ openapiField: { description: 'Account ID of the OAuth provider. This uniquely identifies the account on the provider side.', exampleValue: 'google-account-id-12345' } });
|
||||
|
||||
// Headers
|
||||
export const basicAuthorizationHeaderSchema = yupString().test('is-basic-authorization-header', 'Authorization header must be in the format "Basic <base64>"', (value) => {
|
||||
if (!value) return true;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user