feat: OIDC name fields added (#4872)

Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
Piyush Gupta
2025-03-09 14:12:00 +05:30
committed by GitHub
parent ddc767e53e
commit 48a92f3e55
5 changed files with 492 additions and 26 deletions

View File

@@ -0,0 +1,5 @@
export type TOidcNameFields = {
given_name?: string;
family_name?: string;
preferred_username?: string;
};

View File

@@ -1,6 +1,7 @@
import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
import { getUserByEmail, updateUser } from "@/modules/auth/lib/user";
import { createUser } from "@/modules/auth/lib/user";
import { TOidcNameFields } from "@/modules/auth/types/auth";
import { getIsSamlSsoEnabled, getisSsoEnabled } from "@/modules/ee/license-check/lib/utils";
import type { IdentityProvider } from "@prisma/client";
import type { Account } from "next-auth";
@@ -79,9 +80,22 @@ export const handleSSOCallback = async ({ user, account }: { user: TUser; accoun
return true;
}
let userName = user.name;
if (provider === "openid") {
const oidcUser = user as TUser & TOidcNameFields;
if (oidcUser.name) {
userName = oidcUser.name;
} else if (oidcUser.given_name || oidcUser.family_name) {
userName = `${oidcUser.given_name} ${oidcUser.family_name}`;
} else if (oidcUser.preferred_username) {
userName = oidcUser.preferred_username;
}
}
const userProfile = await createUser({
name:
user.name ||
userName ||
user.email
.split("@")[0]
.replace(/[^'\p{L}\p{M}\s\d-]+/gu, " ")

View File

@@ -0,0 +1,89 @@
import { Organization } from "@prisma/client";
import type { Account } from "next-auth";
import type { TUser } from "@formbricks/types/user";
// Mock user data
export const mockUser: TUser = {
id: "user-123",
email: "test@example.com",
name: "Test User",
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
emailVerified: new Date(),
imageUrl: "https://example.com/image.png",
twoFactorEnabled: false,
identityProvider: "google",
locale: "en-US",
role: "other",
createdAt: new Date(),
updatedAt: new Date(),
objective: "improve_user_retention",
};
// Mock account data
export const mockAccount: Account = {
provider: "google",
type: "oauth",
providerAccountId: "provider-123",
};
// Mock OpenID account
export const mockOpenIdAccount: Account = {
...mockAccount,
provider: "openid",
};
// Mock SAML account
export const mockSamlAccount: Account = {
...mockAccount,
provider: "saml",
};
// Mock organization data
export const mockOrganization: Organization = {
id: "org-123",
name: "Test Organization",
isAIEnabled: false,
whitelabel: {
enabled: false,
},
billing: {
stripeCustomerId: null,
plan: "free",
period: "monthly",
limits: { monthly: { responses: null, miu: null }, projects: null },
periodStart: new Date(),
},
createdAt: new Date(),
updatedAt: new Date(),
};
// Mock user with OpenID fields
export const mockOpenIdUser = (options?: {
name?: string;
given_name?: string;
family_name?: string;
preferred_username?: string;
email?: string;
}): TUser & {
given_name?: string;
family_name?: string;
preferred_username?: string;
} => ({
...mockUser,
name: options?.name || "",
given_name: options?.given_name,
family_name: options?.family_name,
preferred_username: options?.preferred_username,
email: options?.email || mockUser.email,
});
// Mock created user response
export const mockCreatedUser = (name: string = mockUser.name): TUser => ({
...mockUser,
name,
emailVerified: new Date(),
});

View File

@@ -0,0 +1,357 @@
import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
import { createUser, getUserByEmail, updateUser } from "@/modules/auth/lib/user";
import { getIsSamlSsoEnabled, getisSsoEnabled } from "@/modules/ee/license-check/lib/utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { createAccount } from "@formbricks/lib/account/service";
import { createMembership } from "@formbricks/lib/membership/service";
import { createOrganization, getOrganization } from "@formbricks/lib/organization/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { handleSSOCallback } from "../sso-handlers";
import {
mockAccount,
mockCreatedUser,
mockOpenIdAccount,
mockOpenIdUser,
mockOrganization,
mockSamlAccount,
mockUser,
} from "./__mock__/sso-handlers.mock";
// Mock all dependencies
vi.mock("@/modules/auth/lib/brevo", () => ({
createBrevoCustomer: vi.fn(),
}));
vi.mock("@/modules/auth/lib/user", () => ({
getUserByEmail: vi.fn(),
updateUser: vi.fn(),
createUser: vi.fn(),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsSamlSsoEnabled: vi.fn(),
getisSsoEnabled: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@formbricks/lib/account/service", () => ({
createAccount: vi.fn(),
}));
vi.mock("@formbricks/lib/membership/service", () => ({
createMembership: vi.fn(),
}));
vi.mock("@formbricks/lib/organization/service", () => ({
createOrganization: vi.fn(),
getOrganization: vi.fn(),
}));
vi.mock("@formbricks/lib/utils/locale", () => ({
findMatchingLocale: vi.fn(),
}));
// Mock environment variables
vi.mock("@formbricks/lib/constants", () => ({
DEFAULT_ORGANIZATION_ID: "org-123",
DEFAULT_ORGANIZATION_ROLE: "member",
}));
describe("handleSSOCallback", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock implementations
vi.mocked(getisSsoEnabled).mockResolvedValue(true);
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true);
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
// Mock organization-related functions
vi.mocked(getOrganization).mockResolvedValue(mockOrganization);
vi.mocked(createOrganization).mockResolvedValue(mockOrganization);
vi.mocked(createMembership).mockResolvedValue({
role: "member",
accepted: true,
userId: mockUser.id,
organizationId: mockOrganization.id,
});
vi.mocked(updateUser).mockResolvedValue({ ...mockUser, id: "user-123" });
});
describe("Early return conditions", () => {
it("should return false if SSO is not enabled", async () => {
vi.mocked(getisSsoEnabled).mockResolvedValue(false);
const result = await handleSSOCallback({ user: mockUser, account: mockAccount });
expect(result).toBe(false);
expect(getisSsoEnabled).toHaveBeenCalled();
});
it("should return false if user email is missing", async () => {
const userWithoutEmail = { ...mockUser, email: "" };
const result = await handleSSOCallback({ user: userWithoutEmail, account: mockAccount });
expect(result).toBe(false);
});
it("should return false if account type is not oauth", async () => {
const nonOauthAccount = { ...mockAccount, type: "credentials" as const };
const result = await handleSSOCallback({ user: mockUser, account: nonOauthAccount });
expect(result).toBe(false);
});
it("should return false if provider is SAML and SAML SSO is not enabled", async () => {
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(false);
const result = await handleSSOCallback({ user: mockUser, account: mockSamlAccount });
expect(result).toBe(false);
expect(getIsSamlSsoEnabled).toHaveBeenCalled();
});
});
describe("Existing user handling", () => {
it("should return true if user with account already exists and email is the same", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
...mockUser,
email: mockUser.email,
accounts: [{ provider: mockAccount.provider }],
});
const result = await handleSSOCallback({ user: mockUser, account: mockAccount });
expect(result).toBe(true);
expect(prisma.user.findFirst).toHaveBeenCalledWith({
include: {
accounts: {
where: {
provider: mockAccount.provider,
},
},
},
where: {
identityProvider: mockAccount.provider.toLowerCase().replace("-", ""),
identityProviderAccountId: mockAccount.providerAccountId,
},
});
});
it("should update user email if user with account exists but email changed", async () => {
const existingUser = {
...mockUser,
id: "existing-user-id",
email: "old-email@example.com",
accounts: [{ provider: mockAccount.provider }],
};
vi.mocked(prisma.user.findFirst).mockResolvedValue(existingUser);
vi.mocked(getUserByEmail).mockResolvedValue(null);
vi.mocked(updateUser).mockResolvedValue({ ...existingUser, email: mockUser.email });
const result = await handleSSOCallback({ user: mockUser, account: mockAccount });
expect(result).toBe(true);
expect(updateUser).toHaveBeenCalledWith(existingUser.id, { email: mockUser.email });
});
it("should throw error if user with account exists, email changed, and another user has the new email", async () => {
const existingUser = {
...mockUser,
id: "existing-user-id",
email: "old-email@example.com",
accounts: [{ provider: mockAccount.provider }],
};
vi.mocked(prisma.user.findFirst).mockResolvedValue(existingUser);
vi.mocked(getUserByEmail).mockResolvedValue({
id: "another-user-id",
email: mockUser.email,
emailVerified: mockUser.emailVerified,
locale: mockUser.locale,
});
await expect(handleSSOCallback({ user: mockUser, account: mockAccount })).rejects.toThrow(
"Looks like you updated your email somewhere else. A user with this new email exists already."
);
});
it("should return true if user with email already exists", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue({
id: "existing-user-id",
email: mockUser.email,
emailVerified: mockUser.emailVerified,
locale: mockUser.locale,
});
const result = await handleSSOCallback({ user: mockUser, account: mockAccount });
expect(result).toBe(true);
});
});
describe("New user creation", () => {
it("should create a new user if no existing user found", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue(null);
vi.mocked(createUser).mockResolvedValue(mockCreatedUser());
const result = await handleSSOCallback({ user: mockUser, account: mockAccount });
expect(result).toBe(true);
expect(createUser).toHaveBeenCalledWith({
name: mockUser.name,
email: mockUser.email,
emailVerified: expect.any(Date),
identityProvider: mockAccount.provider.toLowerCase().replace("-", ""),
identityProviderAccountId: mockAccount.providerAccountId,
locale: "en-US",
});
expect(createBrevoCustomer).toHaveBeenCalledWith({ id: mockUser.id, email: mockUser.email });
});
it("should create organization and membership for new user when DEFAULT_ORGANIZATION_ID is set", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue(null);
vi.mocked(createUser).mockResolvedValue(mockCreatedUser());
vi.mocked(getOrganization).mockResolvedValue(null);
const result = await handleSSOCallback({ user: mockUser, account: mockAccount });
expect(result).toBe(true);
expect(createOrganization).toHaveBeenCalledWith({
id: "org-123",
name: expect.stringContaining("Organization"),
});
expect(createMembership).toHaveBeenCalledWith("org-123", mockCreatedUser().id, {
role: "owner",
accepted: true,
});
expect(createAccount).toHaveBeenCalledWith({
...mockAccount,
userId: mockCreatedUser().id,
});
expect(updateUser).toHaveBeenCalledWith(mockCreatedUser().id, {
notificationSettings: expect.objectContaining({
unsubscribedOrganizationIds: ["org-123"],
}),
});
});
it("should use existing organization if it exists", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue(null);
vi.mocked(createUser).mockResolvedValue(mockCreatedUser());
const result = await handleSSOCallback({ user: mockUser, account: mockAccount });
expect(result).toBe(true);
expect(createOrganization).not.toHaveBeenCalled();
expect(createMembership).toHaveBeenCalledWith(mockOrganization.id, mockCreatedUser().id, {
role: "member",
accepted: true,
});
});
});
describe("OpenID Connect name handling", () => {
it("should use oidcUser.name when available", async () => {
const openIdUser = mockOpenIdUser({
name: "Direct Name",
given_name: "John",
family_name: "Doe",
});
vi.mocked(createUser).mockResolvedValue(mockCreatedUser("Direct Name"));
const result = await handleSSOCallback({ user: openIdUser, account: mockOpenIdAccount });
expect(result).toBe(true);
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({
name: "Direct Name",
email: openIdUser.email,
identityProvider: "openid",
})
);
});
it("should use given_name + family_name when name is not available", async () => {
const openIdUser = mockOpenIdUser({
name: undefined,
given_name: "John",
family_name: "Doe",
});
vi.mocked(createUser).mockResolvedValue(mockCreatedUser("John Doe"));
const result = await handleSSOCallback({ user: openIdUser, account: mockOpenIdAccount });
expect(result).toBe(true);
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({
name: "John Doe",
email: openIdUser.email,
identityProvider: "openid",
})
);
});
it("should use preferred_username when name and given_name/family_name are not available", async () => {
const openIdUser = mockOpenIdUser({
name: undefined,
given_name: undefined,
family_name: undefined,
preferred_username: "preferred.user",
});
vi.mocked(createUser).mockResolvedValue(mockCreatedUser("preferred.user"));
const result = await handleSSOCallback({ user: openIdUser, account: mockOpenIdAccount });
expect(result).toBe(true);
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({
name: "preferred.user",
email: openIdUser.email,
identityProvider: "openid",
})
);
});
it("should fallback to email username when no OIDC name fields are available", async () => {
const openIdUser = mockOpenIdUser({
name: undefined,
given_name: undefined,
family_name: undefined,
preferred_username: undefined,
email: "test.user@example.com",
});
vi.mocked(createUser).mockResolvedValue(mockCreatedUser("test.user"));
const result = await handleSSOCallback({ user: openIdUser, account: mockOpenIdAccount });
expect(result).toBe(true);
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({
email: openIdUser.email,
identityProvider: "openid",
})
);
});
});
});

View File

@@ -1,7 +1,7 @@
// vitest.config.ts
import tsconfigPaths from 'vite-tsconfig-paths';
import { loadEnv } from 'vite'
import { defineConfig } from 'vitest/config';
import { loadEnv } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
@@ -10,33 +10,34 @@ export default defineConfig({
["**/page.test.tsx", "node"], // page files use node environment because it uses server-side rendering
["**/*.test.tsx", "jsdom"],
],
exclude: ['playwright/**', 'node_modules/**'],
setupFiles: ['../../packages/lib/vitestSetup.ts'],
env: loadEnv('', process.cwd(), ''),
exclude: ["playwright/**", "node_modules/**"],
setupFiles: ["../../packages/lib/vitestSetup.ts"],
env: loadEnv("", process.cwd(), ""),
coverage: {
provider: 'v8', // Use V8 as the coverage provider
reporter: ['text', 'html', 'lcov'], // Generate text summary and HTML reports
reportsDirectory: './coverage', // Output coverage reports to the coverage/ directory
provider: "v8", // Use V8 as the coverage provider
reporter: ["text", "html", "lcov"], // Generate text summary and HTML reports
reportsDirectory: "./coverage", // Output coverage reports to the coverage/ directory
include: [
'modules/api/v2/**/*.ts',
'modules/auth/lib/**/*.ts',
'modules/signup/lib/**/*.ts',
'modules/ee/whitelabel/email-customization/components/*.tsx',
'modules/email/components/email-template.tsx',
'modules/email/emails/survey/follow-up.tsx',
'app/(app)/environments/**/settings/(organization)/general/page.tsx',
"modules/api/v2/**/*.ts",
"modules/auth/lib/**/*.ts",
"modules/signup/lib/**/*.ts",
"modules/ee/whitelabel/email-customization/components/*.tsx",
"modules/email/components/email-template.tsx",
"modules/email/emails/survey/follow-up.tsx",
"app/(app)/environments/**/settings/(organization)/general/page.tsx",
"modules/ee/sso/lib/**/*.ts",
],
exclude: [
'**/.next/**',
'**/*.test.*',
'**/*.spec.*',
'**/constants.ts', // Exclude constants files
'**/route.ts', // Exclude route files
'**/openapi.ts', // Exclude openapi configuration files
'**/openapi-document.ts', // Exclude openapi document files
'modules/**/types/**', // Exclude types
"**/.next/**",
"**/*.test.*",
"**/*.spec.*",
"**/constants.ts", // Exclude constants files
"**/route.ts", // Exclude route files
"**/openapi.ts", // Exclude openapi configuration files
"**/openapi-document.ts", // Exclude openapi document files
"modules/**/types/**", // Exclude types
],
},
},
plugins: [tsconfigPaths()],
});
});