diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index 170d902b94..090893ecba 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -183,6 +183,7 @@ export async function createOrUpdateProjectWithLegacyConfig( clientSecret: provider.client_secret, facebookConfigId: provider.facebook_config_id, microsoftTenantId: provider.microsoft_tenant_id, + netsuiteAccountId: provider.netsuite_account_id, allowSignIn: true, allowConnectedAccounts: true, } satisfies CompleteConfig['auth']['oauth']['providers'][string] diff --git a/apps/backend/src/oauth/index.tsx b/apps/backend/src/oauth/index.tsx index d7165a2c11..29449e6ea5 100644 --- a/apps/backend/src/oauth/index.tsx +++ b/apps/backend/src/oauth/index.tsx @@ -14,6 +14,7 @@ import { GoogleProvider } from "./providers/google"; import { LinkedInProvider } from "./providers/linkedin"; import { MicrosoftProvider } from "./providers/microsoft"; import { MockProvider } from "./providers/mock"; +import { NetSuiteProvider } from "./providers/netsuite"; import { SpotifyProvider } from "./providers/spotify"; import { TwitchProvider } from "./providers/twitch"; import { XProvider } from "./providers/x"; @@ -31,6 +32,7 @@ const _providers = { linkedin: LinkedInProvider, x: XProvider, twitch: TwitchProvider, + netsuite: NetSuiteProvider, } as const; const mockProvider = MockProvider; @@ -78,6 +80,7 @@ export async function getProvider(provider: Tenancy['config']['auth']['oauth'][' clientSecret: provider.clientSecret || throwErr("Client secret is required for standard providers"), facebookConfigId: provider.facebookConfigId, microsoftTenantId: provider.microsoftTenantId, + netsuiteAccountId: provider.netsuiteAccountId, }); } } diff --git a/apps/backend/src/oauth/providers/netsuite.tsx b/apps/backend/src/oauth/providers/netsuite.tsx new file mode 100644 index 0000000000..66016cb8e6 --- /dev/null +++ b/apps/backend/src/oauth/providers/netsuite.tsx @@ -0,0 +1,128 @@ +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { OAuthUserInfo, validateUserInfo } from "../utils"; +import { OAuthBaseProvider, TokenSet } from "./base"; + +export class NetSuiteProvider extends OAuthBaseProvider { + private accountId: string; + + private constructor( + accountId: string, + ...args: ConstructorParameters + ) { + super(...args); + this.accountId = accountId; + } + + static async create(options: { + clientId: string, + clientSecret: string, + netsuiteAccountId?: string, + }) { + const accountId = options.netsuiteAccountId || getEnvVariable("STACK_NETSUITE_ACCOUNT_ID", ""); + if (!accountId) { + throw new StackAssertionError("NetSuite Account ID is required. Set STACK_NETSUITE_ACCOUNT_ID environment variable or provide accountId in options."); + } + + return new NetSuiteProvider( + accountId, + ...await OAuthBaseProvider.createConstructorArgs({ + issuer: `https://system.netsuite.com`, + authorizationEndpoint: `https://${accountId}.app.netsuite.com/app/login/oauth2/authorize.nl`, + tokenEndpoint: `https://${accountId}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token`, + redirectUri: getEnvVariable("NEXT_PUBLIC_STACK_API_URL") + "/api/v1/auth/oauth/callback/netsuite", + baseScope: "rest_webservices", + tokenEndpointAuthMethod: "client_secret_basic", + // NetSuite access tokens typically expire in 1 hour + defaultAccessTokenExpiresInMillis: 1000 * 60 * 60, // 1 hour + ...options, + }) + ); + } + + async postProcessUserInfo(tokenSet: TokenSet): Promise { + // First, get the current user's employee record ID + const currentUserRes = await fetch(`https://${this.accountId}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/userinfo`, { + method: "GET", + headers: { + Authorization: `Bearer ${tokenSet.accessToken}`, + "Content-Type": "application/json", + "Accept": "application/json", + }, + }); + + if (!currentUserRes.ok) { + // If employee endpoint fails, try to get basic user info from a different approach + // NetSuite doesn't have a standard userinfo endpoint, so we'll use what we can get + throw new StackAssertionError(`Error fetching user info from NetSuite: Status code ${currentUserRes.status}`, { + currentUserRes, + hasAccessToken: !!tokenSet.accessToken, + hasRefreshToken: !!tokenSet.refreshToken, + accessTokenExpiredAt: tokenSet.accessTokenExpiredAt, + }); + } + + const userData = await currentUserRes.json(); + + // NetSuite employee records structure can vary, but typically include: + // - id: internal ID + // - entityId: employee ID + // - firstName, lastName: name components + // - email: email address + let accountId: string; + let displayName: string | null = null; + let email: string | null = null; + let emailVerified = false; + + if (userData.items && userData.items.length > 0) { + // If we get a list of employees, take the first one (current user) + const employee = userData.items[0]; + accountId = employee.id?.toString() || employee.entityId?.toString() || ""; + if (!accountId) { + throw new StackAssertionError("No valid ID found in NetSuite employee record", { employee }); + } + displayName = [employee.firstName, employee.lastName].filter(Boolean).join(" ") || employee.entityId; + email = employee.email; + emailVerified = !!employee.email; // Assume verified if present + } else if (userData.id) { + // If we get a single employee record + accountId = userData.id.toString(); + displayName = [userData.firstName, userData.lastName].filter(Boolean).join(" ") || userData.entityId; + email = userData.email; + emailVerified = !!userData.email; + } else { + throw new StackAssertionError("Unable to extract user information from NetSuite response", { + userData, + }); + } + + if (!accountId) { + throw new StackAssertionError("No account ID found in NetSuite user data", { + userData, + }); + } + + return validateUserInfo({ + accountId, + displayName, + email, + profileImageUrl: null, // NetSuite typically doesn't provide profile images via API + emailVerified, + }); + } + + async checkAccessTokenValidity(accessToken: string): Promise { + try { + const res = await fetch(`https://${this.accountId}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/userinfo`, { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + return res.ok; + } catch (error) { + return false; + } + } +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx index 600a28a41a..4ed98a01be 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx @@ -42,6 +42,7 @@ function toTitle(id: string) { linkedin: "LinkedIn", twitch: "Twitch", x: "X", + netsuite: "NetSuite", }[id]; } @@ -61,6 +62,7 @@ export const providerFormSchema = yupObject({ }), facebookConfigId: yupString().optional(), microsoftTenantId: yupString().optional(), + netsuiteAccountId: yupString().optional(), }); export type ProviderFormValues = yup.InferType @@ -73,6 +75,7 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( clientSecret: (props.provider as any)?.clientSecret ?? "", facebookConfigId: (props.provider as any)?.facebookConfigId ?? "", microsoftTenantId: (props.provider as any)?.microsoftTenantId ?? "", + netsuiteAccountId: (props.provider as any)?.netsuiteAccountId ?? "", }; const onSubmit = async (values: ProviderFormValues) => { @@ -86,6 +89,7 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( clientSecret: values.clientSecret || "", facebookConfigId: values.facebookConfigId, microsoftTenantId: values.microsoftTenantId, + netsuiteAccountId: values.netsuiteAccountId, }); } }; @@ -164,6 +168,15 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( placeholder="Tenant ID" /> )} + + {props.id === 'netsuite' && ( + + )} )} diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts index aaa7e6fe4f..9049620491 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts @@ -374,7 +374,7 @@ it("returns an error when the oauth config is misconfigured", async ({ expect }) expect(invalidTypeResponse).toMatchInlineSnapshot(` NiceResponse { "status": 400, - "body": "auth.oauth.providers.invalid.type must be one of the following values: google, github, microsoft, spotify, facebook, discord, gitlab, bitbucket, linkedin, apple, x, twitch", + "body": "auth.oauth.providers.invalid.type must be one of the following values: google, github, microsoft, spotify, facebook, discord, gitlab, bitbucket, linkedin, apple, x, twitch, netsuite", "headers": Headers {