Fix OAuth provider disablement

This commit is contained in:
Konstantin Wohlwend 2026-02-24 12:43:40 -08:00
parent 078073b843
commit 936e298032
2 changed files with 170 additions and 18 deletions

View File

@ -6,6 +6,7 @@ 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 { allProviders, ProviderType } from "@stackframe/stack-shared/dist/utils/oauth";
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
// Helper function to check if a provider type is already used for signing in
@ -107,24 +108,33 @@ async function ensureProviderExists(tenancy: Tenancy, userId: string, providerId
return provider;
}
function getProviderConfig(tenancy: Tenancy, providerConfigId: string) {
function findProviderConfig(tenancy: Tenancy, providerConfigId: string) {
const config = tenancy.config;
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;
return { id: providerId, ...provider };
}
}
return null;
}
if (!providerConfig) {
function getProviderConfig(tenancy: Tenancy, providerConfigId: string) {
const config = findProviderConfig(tenancy, providerConfigId);
if (!config) {
throw new StatusError(StatusError.NotFound, `OAuth provider ${providerConfigId} not found or not configured`);
}
return config;
}
return providerConfig;
function resolveProviderType(tenancy: Tenancy, configOAuthProviderId: string): ProviderType | null {
const config = findProviderConfig(tenancy, configOAuthProviderId);
if (config?.type != null) {
return config.type;
}
if ((allProviders as readonly string[]).includes(configOAuthProviderId)) {
return configOAuthProviderId as ProviderType;
}
return null;
}
@ -148,14 +158,17 @@ export const oauthProviderCrudHandlers = createLazyProxy(() => createCrudHandler
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);
const providerType = resolveProviderType(auth.tenancy, oauthAccount.configOAuthProviderId);
if (providerType == null) {
throw new StatusError(StatusError.NotFound, `OAuth provider ${oauthAccount.configOAuthProviderId} not found or not configured`);
}
return {
user_id: params.user_id,
id: oauthAccount.id,
email: oauthAccount.email || undefined,
provider_config_id: oauthAccount.configOAuthProviderId,
type: providerConfig.type as any, // Type assertion to match schema
type: providerType,
allow_sign_in: oauthAccount.allowSignIn,
allow_connected_accounts: oauthAccount.allowConnectedAccounts,
account_id: oauthAccount.providerAccountId,
@ -187,19 +200,22 @@ export const oauthProviderCrudHandlers = createLazyProxy(() => createCrudHandler
return {
items: oauthAccounts
.map((oauthAccount) => {
const providerConfig = getProviderConfig(auth.tenancy, oauthAccount.configOAuthProviderId);
.flatMap((oauthAccount) => {
const providerType = resolveProviderType(auth.tenancy, oauthAccount.configOAuthProviderId);
if (providerType == null) {
return [];
}
return {
return [{
user_id: oauthAccount.projectUserId || throwErr("OAuth account has no project user ID"),
id: oauthAccount.id,
email: oauthAccount.email || undefined,
provider_config_id: oauthAccount.configOAuthProviderId,
type: providerConfig.type as any, // Type assertion to match schema
type: providerType,
allow_sign_in: oauthAccount.allowSignIn,
allow_connected_accounts: oauthAccount.allowConnectedAccounts,
account_id: oauthAccount.providerAccountId,
};
}];
}),
is_paginated: false,
};
@ -299,14 +315,15 @@ export const oauthProviderCrudHandlers = createLazyProxy(() => createCrudHandler
},
});
const providerConfig = getProviderConfig(auth.tenancy, existingOAuthAccount.configOAuthProviderId);
const providerType = resolveProviderType(auth.tenancy, existingOAuthAccount.configOAuthProviderId)
?? throwErr(new StatusError(StatusError.NotFound, `OAuth provider ${existingOAuthAccount.configOAuthProviderId} not found or not configured`));
return {
user_id: params.user_id,
id: params.provider_id,
email: data.email ?? existingOAuthAccount.email ?? undefined,
provider_config_id: existingOAuthAccount.configOAuthProviderId,
type: providerConfig.type as any,
type: providerType,
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,

View File

@ -1027,3 +1027,138 @@ it("should not allow get, update, delete oauth providers with wrong user id and
}
`);
});
it("should still list and read OAuth providers after the provider is disabled on the project", async ({ expect }) => {
const { adminAccessToken, createProjectResponse } = await createAndSwitchToOAuthEnabledProject();
await Auth.fastSignUp();
const providerConfig = createProjectResponse.body.config.oauth_providers.find((p: any) => p.provider_config_id === "spotify");
expect(providerConfig).toBeDefined();
const createResponse = await niceBackendFetch("/api/v1/oauth-providers", {
method: "POST",
accessType: "server",
body: {
user_id: "me",
provider_config_id: providerConfig.id,
account_id: "test_spotify_user_123",
email: "test@example.com",
allow_sign_in: true,
allow_connected_accounts: true,
},
});
expect(createResponse.status).toBe(201);
const providerId = createResponse.body.id;
// Disable the OAuth provider on the project
await Project.updateCurrent(adminAccessToken, {
config: {
oauth_providers: [],
},
});
// List should still work and include the provider
const listResponse = await niceBackendFetch("/api/v1/oauth-providers?user_id=me", {
method: "GET",
accessType: "server",
});
expect(listResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"is_paginated": false,
"items": [
{
"account_id": "test_spotify_user_123",
"allow_connected_accounts": true,
"allow_sign_in": true,
"email": "test@example.com",
"id": "<stripped UUID>",
"provider_config_id": "spotify",
"type": "spotify",
"user_id": "<stripped UUID>",
},
],
},
"headers": Headers { <some fields may have been hidden> },
}
`);
// Read should still work
const readResponse = await niceBackendFetch(`/api/v1/oauth-providers/me/${providerId}`, {
method: "GET",
accessType: "server",
});
expect(readResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"account_id": "test_spotify_user_123",
"allow_connected_accounts": true,
"allow_sign_in": true,
"email": "test@example.com",
"id": "<stripped UUID>",
"provider_config_id": "spotify",
"type": "spotify",
"user_id": "<stripped UUID>",
},
"headers": Headers { <some fields may have been hidden> },
}
`);
// Delete should still work
const deleteResponse = await niceBackendFetch(`/api/v1/oauth-providers/me/${providerId}`, {
method: "DELETE",
accessType: "server",
});
expect(deleteResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "success": true },
"headers": Headers { <some fields may have been hidden> },
}
`);
// After delete, list should return empty
const listAfterDelete = await niceBackendFetch("/api/v1/oauth-providers?user_id=me", {
method: "GET",
accessType: "server",
});
expect(listAfterDelete.body.items).toHaveLength(0);
});
it("should still return the user when their OAuth provider is disabled on the project", async ({ expect }) => {
const { adminAccessToken, createProjectResponse } = await createAndSwitchToOAuthEnabledProject();
const { userId } = await Auth.fastSignUp();
const providerConfig = createProjectResponse.body.config.oauth_providers.find((p: any) => p.provider_config_id === "spotify");
expect(providerConfig).toBeDefined();
await niceBackendFetch("/api/v1/oauth-providers", {
method: "POST",
accessType: "server",
body: {
user_id: "me",
provider_config_id: providerConfig.id,
account_id: "test_spotify_user_123",
email: "test@example.com",
allow_sign_in: true,
allow_connected_accounts: true,
},
});
// Disable the OAuth provider on the project
await Project.updateCurrent(adminAccessToken, {
config: {
oauth_providers: [],
},
});
// User endpoint should still work
const userResponse = await niceBackendFetch(`/api/v1/users/${userId}`, {
method: "GET",
accessType: "server",
});
expect(userResponse.status).toBe(200);
expect(userResponse.body.id).toBe(userId);
});