/** * @vitest-environment node */ import { createEnvMock, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetSession, mockRegisterSSOProvider, mockHasSSOAccess, mockValidateUrlWithDNS, mockSecureFetchWithPinnedIP, dbState, memberTable, ssoProviderTable, } = vi.hoisted(() => ({ mockGetSession: vi.fn(), mockRegisterSSOProvider: vi.fn(), mockHasSSOAccess: vi.fn(), mockValidateUrlWithDNS: vi.fn(), mockSecureFetchWithPinnedIP: vi.fn(), dbState: { members: [] as any[], providers: [] as any[] }, memberTable: { userId: 'member.userId', organizationId: 'member.organizationId', role: 'member.role', }, ssoProviderTable: { id: 'sso.id', providerId: 'sso.providerId', domain: 'sso.domain', issuer: 'sso.issuer', userId: 'sso.userId', organizationId: 'sso.organizationId', oidcConfig: 'sso.oidcConfig', samlConfig: 'sso.samlConfig', }, })) function makeBuilder(rows: any[]): any { const thenable: any = Promise.resolve(rows) thenable.where = (condition: any) => { const values = condition?.values if (Array.isArray(values) && values.length > 0) { const target = String(values[values.length - 1]).toLowerCase() return makeBuilder(rows.filter((r) => String(r.domain ?? '').toLowerCase() === target)) } return makeBuilder(rows) } thenable.limit = () => Promise.resolve(rows) thenable.orderBy = () => Promise.resolve(rows) return thenable } vi.mock('@sim/db', () => ({ db: { select: () => ({ from: (table: unknown) => makeBuilder(table === memberTable ? dbState.members : dbState.providers), }), }, member: memberTable, ssoProvider: ssoProviderTable, })) vi.mock('@/lib/auth', () => ({ getSession: mockGetSession, auth: { api: { registerSSOProvider: mockRegisterSSOProvider } }, })) vi.mock('@/lib/billing', () => ({ hasSSOAccess: mockHasSSOAccess, })) vi.mock('@/lib/auth/sso/domain', () => ({ normalizeSSODomain: (input: unknown): string | null => { if (typeof input !== 'string') return null const value = input.trim().toLowerCase() return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(value) ? value : null }, })) vi.mock('@/lib/core/security/input-validation.server', () => ({ validateUrlWithDNS: mockValidateUrlWithDNS, secureFetchWithPinnedIP: mockSecureFetchWithPinnedIP, })) vi.mock('@/lib/core/config/env', () => createEnvMock({ SSO_ENABLED: 'true' })) import { POST } from '@/app/api/auth/sso/register/route' const OIDC_BODY = { providerType: 'oidc' as const, providerId: 'acme-oidc', issuer: 'https://idp.acme.com', domain: 'acme.com', clientId: 'client-id', clientSecret: 'client-secret', authorizationEndpoint: 'https://idp.acme.com/authorize', tokenEndpoint: 'https://idp.acme.com/token', userInfoEndpoint: 'https://idp.acme.com/userinfo', jwksEndpoint: 'https://idp.acme.com/jwks', } function request(body: Record) { return createMockRequest('POST', body) } describe('POST /api/auth/sso/register', () => { beforeEach(() => { vi.clearAllMocks() dbState.members = [] dbState.providers = [] mockGetSession.mockResolvedValue({ user: { id: 'u1' } }) mockHasSSOAccess.mockResolvedValue(true) mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '1.2.3.4' }) mockSecureFetchWithPinnedIP.mockRejectedValue(new Error('discovery not mocked for this test')) mockRegisterSSOProvider.mockResolvedValue({ providerId: 'acme-oidc' }) }) it('rejects callers without an Enterprise plan', async () => { mockHasSSOAccess.mockResolvedValue(false) const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(403) expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) it('rejects callers who are not an admin/owner of the target org', async () => { dbState.members = [{ organizationId: 'org1', role: 'member' }] const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(403) expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) it('rejects an invalid domain', async () => { dbState.members = [{ organizationId: 'org1', role: 'owner' }] const res = await POST(request({ ...OIDC_BODY, domain: 'not-a-domain', orgId: 'org1' })) expect(res.status).toBe(400) expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) it('rejects a domain already registered by another organization', async () => { dbState.members = [{ organizationId: 'org-attacker', role: 'owner' }] dbState.providers = [{ domain: 'acme.com', userId: 'u-victim', organizationId: 'org-victim' }] const res = await POST(request({ ...OIDC_BODY, orgId: 'org-attacker' })) const json = await res.json() expect(res.status).toBe(409) expect(json.code).toBe('SSO_DOMAIN_ALREADY_REGISTERED') expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) it('matches conflicts across casing variants', async () => { dbState.members = [{ organizationId: 'org-attacker', role: 'owner' }] dbState.providers = [{ domain: 'ACME.com', userId: 'u-victim', organizationId: 'org-victim' }] const res = await POST(request({ ...OIDC_BODY, orgId: 'org-attacker' })) expect(res.status).toBe(409) expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) it('registers when the domain is unclaimed', async () => { dbState.members = [{ organizationId: 'org1', role: 'owner' }] const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(200) expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) }) it('allows the owning tenant to update its own provider for the same domain', async () => { dbState.members = [{ organizationId: 'org1', role: 'owner' }] dbState.providers = [{ domain: 'acme.com', userId: 'u1', organizationId: 'org1' }] const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(200) expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) }) it('lets an org admin adopt their own user-scoped provider for the same domain', async () => { dbState.members = [{ organizationId: 'org1', role: 'owner' }] dbState.providers = [{ domain: 'acme.com', userId: 'u1', organizationId: null }] const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(200) expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) }) it("still blocks an org admin from claiming another user's user-scoped domain", async () => { dbState.members = [{ organizationId: 'org1', role: 'owner' }] dbState.providers = [{ domain: 'acme.com', userId: 'someone-else', organizationId: null }] const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(409) expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) it('normalizes the domain before persisting it', async () => { dbState.members = [{ organizationId: 'org1', role: 'owner' }] const res = await POST(request({ ...OIDC_BODY, domain: 'ACME.com', orgId: 'org1' })) expect(res.status).toBe(200) expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) const config = mockRegisterSSOProvider.mock.calls[0][0].body expect(config.domain).toBe('acme.com') }) it('passes skipDiscovery since Sim already resolved and validated the OIDC endpoints', async () => { dbState.members = [{ organizationId: 'org1', role: 'owner' }] const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(200) const config = mockRegisterSSOProvider.mock.calls[0][0].body expect(config.oidcConfig.skipDiscovery).toBe(true) }) it('omits userInfoEndpoint when skipUserInfoEndpoint is requested, forcing ID token claims', async () => { dbState.members = [{ organizationId: 'org1', role: 'owner' }] const res = await POST(request({ ...OIDC_BODY, skipUserInfoEndpoint: true, orgId: 'org1' })) expect(res.status).toBe(200) const config = mockRegisterSSOProvider.mock.calls[0][0].body expect(config.oidcConfig.userInfoEndpoint).toBeUndefined() }) it('does not SSRF-validate userInfoEndpoint when skipUserInfoEndpoint is requested', async () => { dbState.members = [{ organizationId: 'org1', role: 'owner' }] mockValidateUrlWithDNS.mockImplementation(async (url: string, label: string) => { if (label === 'OIDC userInfoEndpoint') { return { isValid: false, error: 'resolves to a private IP address' } } return { isValid: true, resolvedIP: '1.2.3.4' } }) const res = await POST(request({ ...OIDC_BODY, skipUserInfoEndpoint: true, orgId: 'org1' })) expect(res.status).toBe(200) const config = mockRegisterSSOProvider.mock.calls[0][0].body expect(config.oidcConfig.userInfoEndpoint).toBeUndefined() }) it('does not SSRF-validate a discovered userinfo_endpoint when skipUserInfoEndpoint is requested', async () => { dbState.members = [{ organizationId: 'org1', role: 'owner' }] mockValidateUrlWithDNS.mockImplementation(async (url: string, label: string) => { if (label === 'OIDC userinfo_endpoint') { return { isValid: false, error: 'resolves to a private IP address' } } return { isValid: true, resolvedIP: '1.2.3.4' } }) mockSecureFetchWithPinnedIP.mockResolvedValue({ ok: true, json: async () => ({ authorization_endpoint: 'https://idp.acme.com/authorize', token_endpoint: 'https://idp.acme.com/token', userinfo_endpoint: 'http://169.254.169.254/userinfo', jwks_uri: 'https://idp.acme.com/jwks', }), }) const discoveredBody = { ...OIDC_BODY, authorizationEndpoint: undefined, tokenEndpoint: undefined, jwksEndpoint: undefined, skipUserInfoEndpoint: true, } const res = await POST(request({ ...discoveredBody, orgId: 'org1' })) expect(res.status).toBe(200) const config = mockRegisterSSOProvider.mock.calls[0][0].body expect(config.oidcConfig.userInfoEndpoint).toBeUndefined() }) it('keeps userInfoEndpoint when skipUserInfoEndpoint is not requested', async () => { dbState.members = [{ organizationId: 'org1', role: 'owner' }] const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(200) const config = mockRegisterSSOProvider.mock.calls[0][0].body expect(config.oidcConfig.userInfoEndpoint).toBe('https://idp.acme.com/userinfo') }) it('selects tokenEndpointAuthentication from the discovery document when endpoints are auto-discovered', async () => { dbState.members = [{ organizationId: 'org1', role: 'owner' }] mockSecureFetchWithPinnedIP.mockResolvedValue({ ok: true, json: async () => ({ authorization_endpoint: 'https://idp.acme.com/authorize', token_endpoint: 'https://idp.acme.com/token', userinfo_endpoint: 'https://idp.acme.com/userinfo', jwks_uri: 'https://idp.acme.com/jwks', token_endpoint_auth_methods_supported: ['client_secret_post'], }), }) const discoveredBody = { ...OIDC_BODY, authorizationEndpoint: undefined, tokenEndpoint: undefined, jwksEndpoint: undefined, } const res = await POST(request({ ...discoveredBody, orgId: 'org1' })) expect(res.status).toBe(200) const config = mockRegisterSSOProvider.mock.calls[0][0].body expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post') }) it('still selects tokenEndpointAuthentication from discovery when all endpoints are explicit', async () => { dbState.members = [{ organizationId: 'org1', role: 'owner' }] mockSecureFetchWithPinnedIP.mockResolvedValue({ ok: true, json: async () => ({ token_endpoint_auth_methods_supported: ['client_secret_post'], }), }) const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(200) const config = mockRegisterSSOProvider.mock.calls[0][0].body expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post') expect(config.oidcConfig.authorizationEndpoint).toBe(OIDC_BODY.authorizationEndpoint) }) it('registers successfully when discovery is unreachable and all endpoints are explicit', async () => { dbState.members = [{ organizationId: 'org1', role: 'owner' }] mockSecureFetchWithPinnedIP.mockRejectedValue(new Error('ECONNREFUSED')) const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(200) const config = mockRegisterSSOProvider.mock.calls[0][0].body expect(config.oidcConfig.skipDiscovery).toBe(true) expect(config.oidcConfig.authorizationEndpoint).toBe(OIDC_BODY.authorizationEndpoint) expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post') }) it('prefers client_secret_post over client_secret_basic when an IdP supports both', async () => { dbState.members = [{ organizationId: 'org1', role: 'owner' }] mockSecureFetchWithPinnedIP.mockResolvedValue({ ok: true, json: async () => ({ token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'], }), }) const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(200) const config = mockRegisterSSOProvider.mock.calls[0][0].body expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post') }) it('defaults to client_secret_post when discovery advertises no auth methods', async () => { dbState.members = [{ organizationId: 'org1', role: 'owner' }] mockSecureFetchWithPinnedIP.mockResolvedValue({ ok: true, json: async () => ({}), }) const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(200) const config = mockRegisterSSOProvider.mock.calls[0][0].body expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post') }) it('surfaces the specific discovery failure reason when endpoints are missing', async () => { dbState.members = [{ organizationId: 'org1', role: 'owner' }] mockValidateUrlWithDNS.mockImplementation(async (url: string, label: string) => { if (label === 'OIDC discovery URL') { return { isValid: false, error: 'resolves to a private IP address' } } return { isValid: true, resolvedIP: '1.2.3.4' } }) const discoveredBody = { ...OIDC_BODY, authorizationEndpoint: undefined, tokenEndpoint: undefined, jwksEndpoint: undefined, } const res = await POST(request({ ...discoveredBody, orgId: 'org1' })) const json = await res.json() expect(res.status).toBe(400) expect(json.error).toContain('resolves to a private IP address') expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) })