Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code 31a7474b7e fix: prevent SSO setup form from being accessible without SSO entitlement
https://sonarly.com/issue/9855?type=bug

A user on a hosted Twenty workspace (globalsign.twenty.com) without an SSO billing entitlement can navigate to the SSO configuration form but gets an error when submitting, because the backend rejects the request with "No entitlement found for this workspace".

Fix: Added try-catch error handling to `createSAMLIdentityProvider` to match the existing pattern in `createOIDCIdentityProvider`.

Previously, `createSAMLIdentityProvider` let exceptions (including expected `SSOException` for billing entitlement failures) propagate as thrown errors, which caused them to be captured by Sentry as unhandled errors. The sibling method `createOIDCIdentityProvider` already handled this correctly by catching `SSOException` and returning it as a value, which the GraphQL resolver (`SetupSsoDTO | SSOException` return type) handles gracefully.

The fix wraps the method body in try-catch that:
1. Returns `SSOException` instances as values (not thrown) — matching OIDC behavior
2. Captures unexpected errors via `exceptionHandlerService` and returns a generic `SSOException`

Also added tests for `createSAMLIdentityProvider` covering both success and SSO-disabled scenarios, matching the existing test patterns for the OIDC method.
2026-03-23 13:00:19 +00:00
2 changed files with 90 additions and 14 deletions
@@ -128,6 +128,69 @@ describe('SSOService', () => {
});
});
describe('createSAMLIdentityProvider', () => {
it('should create a SAML identity provider successfully', async () => {
const workspaceId = 'workspace-123';
const data = {
ssoURL: 'https://idp.example.com/sso',
certificate: 'test-certificate',
fingerprint: undefined as unknown as string,
id: 'provider-123',
};
const mockSavedProvider = {
id: 'provider-123',
type: 'SAML',
name: 'Test SAML Provider',
status: 'ACTIVE',
};
jest.spyOn(billingService, 'hasEntitlement').mockResolvedValue(true);
jest
.spyOn(repository, 'save')
.mockResolvedValue(mockSavedProvider as any);
jest
.spyOn(service as any, 'buildIssuerURL')
.mockReturnValue('https://example.com/auth/saml/login/provider-123');
const result = await service.createSAMLIdentityProvider(
data,
workspaceId,
);
expect(result).toEqual({
id: 'provider-123',
type: 'SAML',
name: 'Test SAML Provider',
issuer: 'https://example.com/auth/saml/login/provider-123',
status: 'ACTIVE',
});
expect(billingService.hasEntitlement).toHaveBeenCalledWith(
workspaceId,
'SSO',
);
});
it('should return an SSOException when SSO is disabled', async () => {
const workspaceId = 'workspace-123';
const data = {
ssoURL: 'https://idp.example.com/sso',
certificate: 'test-certificate',
fingerprint: undefined as unknown as string,
id: 'provider-123',
};
jest.spyOn(billingService, 'hasEntitlement').mockResolvedValue(false);
const result = await service.createSAMLIdentityProvider(
data,
workspaceId,
);
expect(result).toBeInstanceOf(SSOException);
});
});
describe('deleteSSOIdentityProvider', () => {
it('should delete the identity provider successfully', async () => {
const identityProviderId = 'provider-123';
@@ -111,22 +111,35 @@ export class SSOService {
>,
workspaceId: string,
) {
await this.isSSOEnabled(workspaceId);
try {
await this.isSSOEnabled(workspaceId);
const identityProvider =
await this.workspaceSSOIdentityProviderRepository.save({
...data,
type: IdentityProviderType.SAML,
workspaceId,
});
const identityProvider =
await this.workspaceSSOIdentityProviderRepository.save({
...data,
type: IdentityProviderType.SAML,
workspaceId,
});
return {
id: identityProvider.id,
type: identityProvider.type,
name: identityProvider.name,
issuer: this.buildIssuerURL(identityProvider),
status: identityProvider.status,
};
return {
id: identityProvider.id,
type: identityProvider.type,
name: identityProvider.name,
issuer: this.buildIssuerURL(identityProvider),
status: identityProvider.status,
};
} catch (err) {
if (err instanceof SSOException) {
return err;
}
this.exceptionHandlerService.captureExceptions([err]);
return new SSOException(
'Unknown SSO configuration error',
SSOExceptionCode.UNKNOWN_SSO_CONFIGURATION_ERROR,
);
}
}
async findSSOIdentityProviderById(identityProviderId: string) {