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

> [!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>
for bca9bcf12b. 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>
for aaa55f7d5c. 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:
Zai Shi 2025-07-19 02:50:05 +02:00 committed by GitHub
parent 40582b6cf2
commit 018be1fdff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1889 additions and 73 deletions

View File

@ -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;

View File

@ -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[]

View File

@ -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,

View File

@ -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,
}
});

View File

@ -0,0 +1,5 @@
import { oauthProviderCrudHandlers } from "../../crud";
export const GET = oauthProviderCrudHandlers.readHandler;
export const PATCH = oauthProviderCrudHandlers.updateHandler;
export const DELETE = oauthProviderCrudHandlers.deleteHandler;

View 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,
};
},
}));

View File

@ -0,0 +1,4 @@
import { oauthProviderCrudHandlers } from "./crud";
export const GET = oauthProviderCrudHandlers.listHandler;
export const POST = oauthProviderCrudHandlers.createHandler;

View File

@ -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,
}
});
}

View File

@ -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,

View File

@ -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",
},
],

View File

@ -113,6 +113,7 @@ it("lists oauth providers", async ({ expect }) => {
"oauth_providers": [
{
"id": "google",
"provider_config_id": "google",
"type": "shared",
},
],

View File

@ -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",
},
],

View File

@ -182,6 +182,7 @@ it("creates a new project with different configurations", async ({ expect }) =>
"oauth_providers": [
{
"id": "google",
"provider_config_id": "google",
"type": "shared",
},
],

File diff suppressed because it is too large Load Diff

View File

@ -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",
},
],

View File

@ -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();
}
}

View 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>;

View File

@ -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(),

View File

@ -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();
}
}

View File

@ -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,

View File

@ -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;