mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-22 10:08:42 -06:00
feat: OIDC name fields added (#4872)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
5
apps/web/modules/auth/types/auth.ts
Normal file
5
apps/web/modules/auth/types/auth.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type TOidcNameFields = {
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
preferred_username?: string;
|
||||
};
|
||||
@@ -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, " ")
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
357
apps/web/modules/ee/sso/lib/tests/sso-handlers.test.ts
Normal file
357
apps/web/modules/ee/sso/lib/tests/sso-handlers.test.ts
Normal 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",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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()],
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user