Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/backend/src/lib/projects.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions apps/backend/src/oauth/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -31,6 +32,7 @@ const _providers = {
linkedin: LinkedInProvider,
x: XProvider,
twitch: TwitchProvider,
netsuite: NetSuiteProvider,
} as const;

const mockProvider = MockProvider;
Expand Down Expand Up @@ -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,
});
}
}
Expand Down
128 changes: 128 additions & 0 deletions apps/backend/src/oauth/providers/netsuite.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof OAuthBaseProvider>
) {
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<OAuthUserInfo> {
// 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) {
Comment thread
sicarius97 marked this conversation as resolved.
// 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<boolean> {
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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ function toTitle(id: string) {
linkedin: "LinkedIn",
twitch: "Twitch",
x: "X",
netsuite: "NetSuite",
}[id];
}

Expand All @@ -61,6 +62,7 @@ export const providerFormSchema = yupObject({
}),
facebookConfigId: yupString().optional(),
microsoftTenantId: yupString().optional(),
netsuiteAccountId: yupString().optional(),
});

export type ProviderFormValues = yup.InferType<typeof providerFormSchema>
Expand All @@ -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) => {
Expand All @@ -86,6 +89,7 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: (
clientSecret: values.clientSecret || "",
facebookConfigId: values.facebookConfigId,
microsoftTenantId: values.microsoftTenantId,
netsuiteAccountId: values.netsuiteAccountId,
});
}
};
Expand Down Expand Up @@ -164,6 +168,15 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: (
placeholder="Tenant ID"
/>
)}

{props.id === 'netsuite' && (
<InputField
control={form.control}
name="netsuiteAccountId"
label="Account ID"
placeholder="Account ID"
/>
)}
</>
)}
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 { <some fields may have been hidden> },
}
`);
Expand Down
1 change: 1 addition & 0 deletions packages/stack-shared/src/config/schema-fuzzer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ const environmentSchemaFuzzerConfig = [{
clientSecret: ["some-client-secret"],
facebookConfigId: ["some-facebook-config-id"],
microsoftTenantId: ["some-microsoft-tenant-id"],
netsuiteAccountId: ["some-netsuite-account-id"],
}]]))] as const,
}],
}],
Expand Down
2 changes: 2 additions & 0 deletions packages/stack-shared/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({
clientSecret: schemaFields.oauthClientSecretSchema.optional(),
facebookConfigId: schemaFields.oauthFacebookConfigIdSchema.optional(),
microsoftTenantId: schemaFields.oauthMicrosoftTenantIdSchema.optional(),
netsuiteAccountId: schemaFields.oauthNetSuiteAccountIdSchema.optional(),
Comment thread
sicarius97 marked this conversation as resolved.
allowSignIn: yupBoolean().optional(),
allowConnectedAccounts: yupBoolean().optional(),
}),
Expand Down Expand Up @@ -507,6 +508,7 @@ const organizationConfigDefaults = {
clientSecret: undefined,
facebookConfigId: undefined,
microsoftTenantId: undefined,
netsuiteAccountId: undefined,
}),
},
},
Expand Down
1 change: 1 addition & 0 deletions packages/stack-shared/src/interface/crud/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const oauthProviderReadSchema = yupObject({
// extra params
facebook_config_id: schemaFields.oauthFacebookConfigIdSchema.optional(),
microsoft_tenant_id: schemaFields.oauthMicrosoftTenantIdSchema.optional(),
netsuite_account_id: schemaFields.oauthNetSuiteAccountIdSchema.optional(),
Comment thread
sicarius97 marked this conversation as resolved.
});

const oauthProviderWriteSchema = oauthProviderReadSchema.omit(['provider_config_id']);
Expand Down
1 change: 1 addition & 0 deletions packages/stack-shared/src/schema-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,7 @@ export const oauthClientIdSchema = yupString().meta({ openapiField: { descriptio
export const oauthClientSecretSchema = yupString().meta({ openapiField: { description: 'OAuth client secret. Needs to be specified when using type="standard"', exampleValue: 'google-oauth-client-secret' } });
export const oauthFacebookConfigIdSchema = yupString().meta({ openapiField: { description: 'The configuration id for Facebook business login (for things like ads and marketing). This is only required if you are using the standard OAuth with Facebook and you are using Facebook business login.' } });
export const oauthMicrosoftTenantIdSchema = yupString().meta({ openapiField: { description: 'The Microsoft tenant id for Microsoft directory. This is only required if you are using the standard OAuth with Microsoft and you have an Azure AD tenant.' } });
export const oauthNetSuiteAccountIdSchema = yupString().meta({ openapiField: { description: 'The NetSuite account ID. This is required when using the standard OAuth with NetSuite and should be your NetSuite account identifier.', exampleValue: 'TSTDRV123456' } });
export const oauthAccountMergeStrategySchema = yupString().oneOf(['link_method', 'raise_error', 'allow_duplicates']).meta({ openapiField: { description: 'Determines how to handle OAuth logins that match an existing user by email. `link_method` adds the OAuth method to the existing user. `raise_error` rejects the login with an error. `allow_duplicates` creates a new user.', exampleValue: 'link_method' } });
// Project email config
export const emailTypeSchema = yupString().oneOf(['shared', 'standard']).meta({ openapiField: { description: 'Email provider type, one of shared, standard. "shared" uses Stack shared email provider and it is only meant for development. "standard" uses your own email server and will have your email address as the sender.', exampleValue: 'standard' } });
Expand Down
2 changes: 1 addition & 1 deletion packages/stack-shared/src/utils/oauth.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export const standardProviders = ["google", "github", "microsoft", "spotify", "facebook", "discord", "gitlab", "bitbucket", "linkedin", "apple", "x", "twitch"] as const;
export const standardProviders = ["google", "github", "microsoft", "spotify", "facebook", "discord", "gitlab", "bitbucket", "linkedin", "apple", "x", "twitch", "netsuite"] as const;
// No more shared providers should be added except for special cases
export const sharedProviders = ["google", "github", "microsoft", "spotify"] as const;
export const allProviders = standardProviders;
Expand Down
Loading