refactor(auth/sso): rename GetAuthorizationUrl for clarity (#10173)

- Rename `GetAuthorizationUrl` to `GetAuthorizationUrlForSSO`
- Move `GetAuthorizationUrlForSSO` from `sso.resolver.ts` to
`auth.resolver.ts` to avoid the permission guard and let users use an
SSO provider.
- Fix an issue in OIDC guard that breaks the connection if you have
multiple SSO providers + add tests for OIDC guard.
This commit is contained in:
Antoine Moreaux
2025-02-13 11:15:22 +01:00
committed by GitHub
parent d7b84de1b5
commit 77d72e9b1c
16 changed files with 214 additions and 66 deletions

View File

@ -648,13 +648,13 @@ export type FullName = {
lastName: Scalars['String']['output']; lastName: Scalars['String']['output'];
}; };
export type GetAuthorizationUrlInput = { export type GetAuthorizationUrlForSsoInput = {
identityProviderId: Scalars['String']['input']; identityProviderId: Scalars['String']['input'];
workspaceInviteHash?: InputMaybe<Scalars['String']['input']>; workspaceInviteHash?: InputMaybe<Scalars['String']['input']>;
}; };
export type GetAuthorizationUrlOutput = { export type GetAuthorizationUrlForSsoOutput = {
__typename?: 'GetAuthorizationUrlOutput'; __typename?: 'GetAuthorizationUrlForSSOOutput';
authorizationURL: Scalars['String']['output']; authorizationURL: Scalars['String']['output'];
id: Scalars['String']['output']; id: Scalars['String']['output'];
type: Scalars['String']['output']; type: Scalars['String']['output'];
@ -840,7 +840,7 @@ export type Mutation = {
generateApiKeyToken: ApiKeyToken; generateApiKeyToken: ApiKeyToken;
generateTransientToken: TransientToken; generateTransientToken: TransientToken;
getAuthTokensFromLoginToken: AuthTokens; getAuthTokensFromLoginToken: AuthTokens;
getAuthorizationUrl: GetAuthorizationUrlOutput; getAuthorizationUrlForSSO: GetAuthorizationUrlForSsoOutput;
getLoginTokenFromCredentials: LoginToken; getLoginTokenFromCredentials: LoginToken;
getLoginTokenFromEmailVerificationToken: LoginToken; getLoginTokenFromEmailVerificationToken: LoginToken;
impersonate: ImpersonateOutput; impersonate: ImpersonateOutput;
@ -1031,8 +1031,8 @@ export type MutationGetAuthTokensFromLoginTokenArgs = {
}; };
export type MutationGetAuthorizationUrlArgs = { export type MutationGetAuthorizationUrlForSsoArgs = {
input: GetAuthorizationUrlInput; input: GetAuthorizationUrlForSsoInput;
}; };

View File

@ -573,13 +573,13 @@ export type FullName = {
lastName: Scalars['String']; lastName: Scalars['String'];
}; };
export type GetAuthorizationUrlInput = { export type GetAuthorizationUrlForSsoInput = {
identityProviderId: Scalars['String']; identityProviderId: Scalars['String'];
workspaceInviteHash?: InputMaybe<Scalars['String']>; workspaceInviteHash?: InputMaybe<Scalars['String']>;
}; };
export type GetAuthorizationUrlOutput = { export type GetAuthorizationUrlForSsoOutput = {
__typename?: 'GetAuthorizationUrlOutput'; __typename?: 'GetAuthorizationUrlForSSOOutput';
authorizationURL: Scalars['String']; authorizationURL: Scalars['String'];
id: Scalars['String']; id: Scalars['String'];
type: Scalars['String']; type: Scalars['String'];
@ -761,7 +761,7 @@ export type Mutation = {
generateApiKeyToken: ApiKeyToken; generateApiKeyToken: ApiKeyToken;
generateTransientToken: TransientToken; generateTransientToken: TransientToken;
getAuthTokensFromLoginToken: AuthTokens; getAuthTokensFromLoginToken: AuthTokens;
getAuthorizationUrl: GetAuthorizationUrlOutput; getAuthorizationUrlForSSO: GetAuthorizationUrlForSsoOutput;
getLoginTokenFromCredentials: LoginToken; getLoginTokenFromCredentials: LoginToken;
getLoginTokenFromEmailVerificationToken: LoginToken; getLoginTokenFromEmailVerificationToken: LoginToken;
impersonate: ImpersonateOutput; impersonate: ImpersonateOutput;
@ -918,8 +918,8 @@ export type MutationGetAuthTokensFromLoginTokenArgs = {
}; };
export type MutationGetAuthorizationUrlArgs = { export type MutationGetAuthorizationUrlForSsoArgs = {
input: GetAuthorizationUrlInput; input: GetAuthorizationUrlForSsoInput;
}; };
@ -2090,12 +2090,12 @@ export type GetAuthTokensFromLoginTokenMutationVariables = Exact<{
export type GetAuthTokensFromLoginTokenMutation = { __typename?: 'Mutation', getAuthTokensFromLoginToken: { __typename?: 'AuthTokens', tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; export type GetAuthTokensFromLoginTokenMutation = { __typename?: 'Mutation', getAuthTokensFromLoginToken: { __typename?: 'AuthTokens', tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
export type GetAuthorizationUrlMutationVariables = Exact<{ export type GetAuthorizationUrlForSsoMutationVariables = Exact<{
input: GetAuthorizationUrlInput; input: GetAuthorizationUrlForSsoInput;
}>; }>;
export type GetAuthorizationUrlMutation = { __typename?: 'Mutation', getAuthorizationUrl: { __typename?: 'GetAuthorizationUrlOutput', id: string, type: string, authorizationURL: string } }; export type GetAuthorizationUrlForSsoMutation = { __typename?: 'Mutation', getAuthorizationUrlForSSO: { __typename?: 'GetAuthorizationUrlForSSOOutput', id: string, type: string, authorizationURL: string } };
export type GetLoginTokenFromCredentialsMutationVariables = Exact<{ export type GetLoginTokenFromCredentialsMutationVariables = Exact<{
email: Scalars['String']; email: Scalars['String'];
@ -3072,41 +3072,41 @@ export function useGetAuthTokensFromLoginTokenMutation(baseOptions?: Apollo.Muta
export type GetAuthTokensFromLoginTokenMutationHookResult = ReturnType<typeof useGetAuthTokensFromLoginTokenMutation>; export type GetAuthTokensFromLoginTokenMutationHookResult = ReturnType<typeof useGetAuthTokensFromLoginTokenMutation>;
export type GetAuthTokensFromLoginTokenMutationResult = Apollo.MutationResult<GetAuthTokensFromLoginTokenMutation>; export type GetAuthTokensFromLoginTokenMutationResult = Apollo.MutationResult<GetAuthTokensFromLoginTokenMutation>;
export type GetAuthTokensFromLoginTokenMutationOptions = Apollo.BaseMutationOptions<GetAuthTokensFromLoginTokenMutation, GetAuthTokensFromLoginTokenMutationVariables>; export type GetAuthTokensFromLoginTokenMutationOptions = Apollo.BaseMutationOptions<GetAuthTokensFromLoginTokenMutation, GetAuthTokensFromLoginTokenMutationVariables>;
export const GetAuthorizationUrlDocument = gql` export const GetAuthorizationUrlForSsoDocument = gql`
mutation GetAuthorizationUrl($input: GetAuthorizationUrlInput!) { mutation GetAuthorizationUrlForSSO($input: GetAuthorizationUrlForSSOInput!) {
getAuthorizationUrl(input: $input) { getAuthorizationUrlForSSO(input: $input) {
id id
type type
authorizationURL authorizationURL
} }
} }
`; `;
export type GetAuthorizationUrlMutationFn = Apollo.MutationFunction<GetAuthorizationUrlMutation, GetAuthorizationUrlMutationVariables>; export type GetAuthorizationUrlForSsoMutationFn = Apollo.MutationFunction<GetAuthorizationUrlForSsoMutation, GetAuthorizationUrlForSsoMutationVariables>;
/** /**
* __useGetAuthorizationUrlMutation__ * __useGetAuthorizationUrlForSsoMutation__
* *
* To run a mutation, you first call `useGetAuthorizationUrlMutation` within a React component and pass it any options that fit your needs. * To run a mutation, you first call `useGetAuthorizationUrlForSsoMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useGetAuthorizationUrlMutation` returns a tuple that includes: * When your component renders, `useGetAuthorizationUrlForSsoMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation * - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution * - An object with fields that represent the current status of the mutation's execution
* *
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
* *
* @example * @example
* const [getAuthorizationUrlMutation, { data, loading, error }] = useGetAuthorizationUrlMutation({ * const [getAuthorizationUrlForSsoMutation, { data, loading, error }] = useGetAuthorizationUrlForSsoMutation({
* variables: { * variables: {
* input: // value for 'input' * input: // value for 'input'
* }, * },
* }); * });
*/ */
export function useGetAuthorizationUrlMutation(baseOptions?: Apollo.MutationHookOptions<GetAuthorizationUrlMutation, GetAuthorizationUrlMutationVariables>) { export function useGetAuthorizationUrlForSsoMutation(baseOptions?: Apollo.MutationHookOptions<GetAuthorizationUrlForSsoMutation, GetAuthorizationUrlForSsoMutationVariables>) {
const options = {...defaultOptions, ...baseOptions} const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<GetAuthorizationUrlMutation, GetAuthorizationUrlMutationVariables>(GetAuthorizationUrlDocument, options); return Apollo.useMutation<GetAuthorizationUrlForSsoMutation, GetAuthorizationUrlForSsoMutationVariables>(GetAuthorizationUrlForSsoDocument, options);
} }
export type GetAuthorizationUrlMutationHookResult = ReturnType<typeof useGetAuthorizationUrlMutation>; export type GetAuthorizationUrlForSsoMutationHookResult = ReturnType<typeof useGetAuthorizationUrlForSsoMutation>;
export type GetAuthorizationUrlMutationResult = Apollo.MutationResult<GetAuthorizationUrlMutation>; export type GetAuthorizationUrlForSsoMutationResult = Apollo.MutationResult<GetAuthorizationUrlForSsoMutation>;
export type GetAuthorizationUrlMutationOptions = Apollo.BaseMutationOptions<GetAuthorizationUrlMutation, GetAuthorizationUrlMutationVariables>; export type GetAuthorizationUrlForSsoMutationOptions = Apollo.BaseMutationOptions<GetAuthorizationUrlForSsoMutation, GetAuthorizationUrlForSsoMutationVariables>;
export const GetLoginTokenFromCredentialsDocument = gql` export const GetLoginTokenFromCredentialsDocument = gql`
mutation GetLoginTokenFromCredentials($email: String!, $password: String!, $captchaToken: String) { mutation GetLoginTokenFromCredentials($email: String!, $password: String!, $captchaToken: String) {
getLoginTokenFromCredentials( getLoginTokenFromCredentials(

View File

@ -1,11 +0,0 @@
import { gql } from '@apollo/client';
export const GET_AUTHORIZATION_URL = gql`
mutation GetAuthorizationUrl($input: GetAuthorizationUrlInput!) {
getAuthorizationUrl(input: $input) {
id
type
authorizationURL
}
}
`;

View File

@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const GET_AUTHORIZATION_URL_FOR_SSO = gql`
mutation GetAuthorizationUrlForSSO($input: GetAuthorizationUrlForSSOInput!) {
getAuthorizationUrlForSSO(input: $input) {
id
type
authorizationURL
}
}
`;

View File

@ -8,6 +8,7 @@ import { HorizontalSeparator, MainButton } from 'twenty-ui';
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState'; import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { isDefined } from 'twenty-shared'; import { isDefined } from 'twenty-shared';
import React from 'react';
const StyledContentContainer = styled.div` const StyledContentContainer = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(8)}; margin-bottom: ${({ theme }) => theme.spacing(8)};
@ -24,16 +25,15 @@ export const SignInUpSSOIdentityProviderSelection = () => {
<StyledContentContainer> <StyledContentContainer>
{isDefined(workspaceAuthProviders?.sso) && {isDefined(workspaceAuthProviders?.sso) &&
workspaceAuthProviders?.sso.map((idp) => ( workspaceAuthProviders?.sso.map((idp) => (
<> <React.Fragment key={idp.id}>
<MainButton <MainButton
key={idp.id}
title={idp.name} title={idp.name}
onClick={() => redirectToSSOLoginPage(idp.id)} onClick={() => redirectToSSOLoginPage(idp.id)}
Icon={guessSSOIdentityProviderIconByUrl(idp.issuer)} Icon={guessSSOIdentityProviderIconByUrl(idp.issuer)}
fullWidth fullWidth
/> />
<HorizontalSeparator visible={false} /> <HorizontalSeparator visible={false} />
</> </React.Fragment>
))} ))}
</StyledContentContainer> </StyledContentContainer>
</> </>

View File

@ -1,4 +1,4 @@
import { GET_AUTHORIZATION_URL } from '@/auth/graphql/mutations/getAuthorizationUrl'; import { GET_AUTHORIZATION_URL_FOR_SSO } from '@/auth/graphql/mutations/getAuthorizationUrlForSSO';
import { useSSO } from '@/auth/sign-in-up/hooks/useSSO'; import { useSSO } from '@/auth/sign-in-up/hooks/useSSO';
import { useRedirect } from '@/domain-manager/hooks/useRedirect'; import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@ -23,7 +23,7 @@ const mockRedirect = jest.fn();
const apolloMocks = [ const apolloMocks = [
{ {
request: { request: {
query: GET_AUTHORIZATION_URL, query: GET_AUTHORIZATION_URL_FOR_SSO,
variables: { variables: {
input: { input: {
identityProviderId: 'success-id', identityProviderId: 'success-id',
@ -32,13 +32,13 @@ const apolloMocks = [
}, },
result: { result: {
data: { data: {
getAuthorizationUrl: { authorizationURL: 'http://example.com' }, getAuthorizationUrlForSSO: { authorizationURL: 'http://example.com' },
}, },
}, },
}, },
{ {
request: { request: {
query: GET_AUTHORIZATION_URL, query: GET_AUTHORIZATION_URL_FOR_SSO,
variables: { variables: {
input: { input: {
identityProviderId: 'error-id', identityProviderId: 'error-id',

View File

@ -1,6 +1,6 @@
/* @license Enterprise */ /* @license Enterprise */
import { GET_AUTHORIZATION_URL } from '@/auth/graphql/mutations/getAuthorizationUrl'; import { GET_AUTHORIZATION_URL_FOR_SSO } from '@/auth/graphql/mutations/getAuthorizationUrlForSSO';
import { useRedirect } from '@/domain-manager/hooks/useRedirect'; import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@ -17,7 +17,7 @@ export const useSSO = () => {
let authorizationUrlForSSOResult; let authorizationUrlForSSOResult;
try { try {
authorizationUrlForSSOResult = await apolloClient.mutate({ authorizationUrlForSSOResult = await apolloClient.mutate({
mutation: GET_AUTHORIZATION_URL, mutation: GET_AUTHORIZATION_URL_FOR_SSO,
variables: { variables: {
input: { input: {
identityProviderId, identityProviderId,
@ -32,7 +32,8 @@ export const useSSO = () => {
} }
redirect( redirect(
authorizationUrlForSSOResult.data?.getAuthorizationUrl.authorizationURL, authorizationUrlForSSOResult.data?.getAuthorizationUrlForSSO
.authorizationURL,
); );
}; };

View File

@ -11,6 +11,7 @@ import { UserService } from 'src/engine/core-modules/user/services/user.service'
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { AuthResolver } from './auth.resolver'; import { AuthResolver } from './auth.resolver';
@ -95,6 +96,10 @@ describe('AuthResolver', () => {
provide: FeatureFlagService, provide: FeatureFlagService,
useValue: {}, useValue: {},
}, },
{
provide: SSOService,
useValue: {},
},
// { // {
// provide: OAuthService, // provide: OAuthService,
// useValue: {}, // useValue: {},

View File

@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { SettingsFeatures, SOURCE_LOCALE } from 'twenty-shared'; import { SettingsFeatures, SOURCE_LOCALE } from 'twenty-shared';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import omit from 'lodash.omit';
import { ApiKeyTokenInput } from 'src/engine/core-modules/auth/dto/api-key-token.input'; import { ApiKeyTokenInput } from 'src/engine/core-modules/auth/dto/api-key-token.input';
import { AppTokenInput } from 'src/engine/core-modules/auth/dto/app-token.input'; import { AppTokenInput } from 'src/engine/core-modules/auth/dto/app-token.input';
@ -47,6 +48,9 @@ import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { GetAuthorizationUrlForSSOOutput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.output';
import { GetAuthorizationUrlForSSOInput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.input';
import { GetAuthTokensFromLoginTokenInput } from './dto/get-auth-tokens-from-login-token.input'; import { GetAuthTokensFromLoginTokenInput } from './dto/get-auth-tokens-from-login-token.input';
import { GetLoginTokenFromCredentialsInput } from './dto/get-login-token-from-credentials.input'; import { GetLoginTokenFromCredentialsInput } from './dto/get-login-token-from-credentials.input';
@ -77,6 +81,7 @@ export class AuthResolver {
private domainManagerService: DomainManagerService, private domainManagerService: DomainManagerService,
private userWorkspaceService: UserWorkspaceService, private userWorkspaceService: UserWorkspaceService,
private emailVerificationTokenService: EmailVerificationTokenService, private emailVerificationTokenService: EmailVerificationTokenService,
private sSOService: SSOService,
) {} ) {}
@UseGuards(CaptchaGuard) @UseGuards(CaptchaGuard)
@ -87,6 +92,16 @@ export class AuthResolver {
return await this.authService.checkUserExists(checkUserExistsInput.email); return await this.authService.checkUserExists(checkUserExistsInput.email);
} }
@Mutation(() => GetAuthorizationUrlForSSOOutput)
async getAuthorizationUrlForSSO(
@Args('input') params: GetAuthorizationUrlForSSOInput,
) {
return await this.sSOService.getAuthorizationUrlForSSO(
params.identityProviderId,
omit(params, ['identityProviderId']),
);
}
@Query(() => WorkspaceInviteHashValid) @Query(() => WorkspaceInviteHashValid)
async checkWorkspaceInviteHashIsValid( async checkWorkspaceInviteHashIsValid(
@Args() workspaceInviteHashValidInput: WorkspaceInviteHashValidInput, @Args() workspaceInviteHashValidInput: WorkspaceInviteHashValidInput,

View File

@ -5,7 +5,7 @@ import { Field, InputType } from '@nestjs/graphql';
import { IsOptional, IsString } from 'class-validator'; import { IsOptional, IsString } from 'class-validator';
@InputType() @InputType()
export class GetAuthorizationUrlInput { export class GetAuthorizationUrlForSSOInput {
@Field(() => String) @Field(() => String)
@IsString() @IsString()
identityProviderId: string; identityProviderId: string;

View File

@ -5,7 +5,7 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type'; import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type';
@ObjectType() @ObjectType()
export class GetAuthorizationUrlOutput { export class GetAuthorizationUrlForSSOOutput {
@Field(() => String) @Field(() => String)
authorizationURL: string; authorizationURL: string;

View File

@ -28,7 +28,9 @@ export class OIDCAuthGuard extends AuthGuard('openidconnect') {
identityProviderId: string; identityProviderId: string;
} { } {
if (request.params.identityProviderId) { if (request.params.identityProviderId) {
return request.params.identityProviderId; return {
identityProviderId: request.params.identityProviderId,
};
} }
if ( if (
@ -57,6 +59,13 @@ export class OIDCAuthGuard extends AuthGuard('openidconnect') {
try { try {
const state = this.getStateByRequest(request); const state = this.getStateByRequest(request);
if (!state.identityProviderId) {
throw new AuthException(
'identityProviderId missing',
AuthExceptionCode.INVALID_DATA,
);
}
identityProvider = await this.sSOService.findSSOIdentityProviderById( identityProvider = await this.sSOService.findSSOIdentityProviderById(
state.identityProviderId, state.identityProviderId,
); );
@ -67,7 +76,6 @@ export class OIDCAuthGuard extends AuthGuard('openidconnect') {
AuthExceptionCode.INVALID_DATA, AuthExceptionCode.INVALID_DATA,
); );
} }
const issuer = await Issuer.discover(identityProvider.issuer); const issuer = await Issuer.discover(identityProvider.issuer);
new OIDCAuthStrategy( new OIDCAuthStrategy(

View File

@ -0,0 +1,130 @@
import { ExecutionContext } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { AuthGuard } from '@nestjs/passport';
import { Issuer } from 'openid-client';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { OIDCAuthGuard } from 'src/engine/core-modules/auth/guards/oidc-auth.guard';
import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type';
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
const createMockExecutionContext = (mockedRequest: any): ExecutionContext => {
return {
switchToHttp: jest.fn().mockReturnValue({
getRequest: jest.fn().mockReturnValue(mockedRequest),
}),
} as unknown as ExecutionContext;
};
const createMockedRequest = (params = {}, query = {}) => ({
params,
query,
});
jest
.spyOn(AuthGuard('openidconnect').prototype, 'canActivate')
.mockResolvedValue(true);
jest.mock('openid-client', () => ({
Strategy: jest.fn(),
Issuer: {
discover: jest.fn().mockResolvedValue({} as Issuer),
},
}));
describe('OIDCAuthGuard', () => {
let guard: OIDCAuthGuard;
let sSOService: SSOService;
let guardRedirectService: GuardRedirectService;
let mockExecutionContext: ExecutionContext;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
OIDCAuthGuard,
{
provide: SSOService,
useValue: {
findSSOIdentityProviderById: jest.fn(),
getOIDCClient: jest.fn(),
},
},
{
provide: GuardRedirectService,
useValue: {
dispatchErrorFromGuard: jest.fn(),
getSubdomainAndCustomDomainFromWorkspace: jest.fn(),
},
},
],
}).compile();
guard = module.get<OIDCAuthGuard>(OIDCAuthGuard);
sSOService = module.get<SSOService>(SSOService);
guardRedirectService =
module.get<GuardRedirectService>(GuardRedirectService);
mockExecutionContext = {
switchToHttp: jest.fn().mockReturnValue({
getRequest: jest.fn(),
}),
} as unknown as ExecutionContext;
});
it('should activate when identity provider exists and is valid', async () => {
const mockedRequest = createMockedRequest({
identityProviderId: 'test-id',
});
mockExecutionContext = createMockExecutionContext(mockedRequest);
jest.spyOn(sSOService, 'findSSOIdentityProviderById').mockResolvedValue({
id: 'test-id',
issuer: 'https://issuer.example.com',
workspace: {},
} as SSOConfiguration & WorkspaceSSOIdentityProvider);
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(true);
expect(guardRedirectService.dispatchErrorFromGuard).not.toHaveBeenCalled();
expect(sSOService.findSSOIdentityProviderById).toHaveBeenCalledWith(
'test-id',
);
});
it('should throw error when identity provider is not found', async () => {
const mockedRequest = createMockedRequest({
identityProviderId: 'non-existent-id',
});
mockExecutionContext = createMockExecutionContext(mockedRequest);
jest
.spyOn(sSOService, 'findSSOIdentityProviderById')
.mockResolvedValue(null);
await expect(guard.canActivate(mockExecutionContext)).resolves.toBe(false);
expect(sSOService.findSSOIdentityProviderById).toHaveBeenCalledWith(
'non-existent-id',
);
expect(guardRedirectService.dispatchErrorFromGuard).toHaveBeenCalled();
});
it('should handle invalid OIDC identity provider params in request', async () => {
const mockedRequest = createMockedRequest({
identityProviderId: undefined,
});
mockExecutionContext = createMockExecutionContext(mockedRequest);
jest
.spyOn(sSOService, 'findSSOIdentityProviderById')
.mockResolvedValue(null);
await expect(guard.canActivate(mockExecutionContext)).resolves.toBe(false);
expect(guardRedirectService.dispatchErrorFromGuard).toHaveBeenCalled();
});
});

View File

@ -198,7 +198,7 @@ export class SSOService {
}); });
} }
async getAuthorizationUrl( async getAuthorizationUrlForSSO(
identityProviderId: string, identityProviderId: string,
searchParams: Record<string, string | boolean>, searchParams: Record<string, string | boolean>,
) { ) {

View File

@ -3,7 +3,6 @@
import { UseFilters, UseGuards } from '@nestjs/common'; import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import omit from 'lodash.omit';
import { SettingsFeatures } from 'twenty-shared'; import { SettingsFeatures } from 'twenty-shared';
import { EnterpriseFeaturesEnabledGuard } from 'src/engine/core-modules/auth/guards/enterprise-features-enabled.guard'; import { EnterpriseFeaturesEnabledGuard } from 'src/engine/core-modules/auth/guards/enterprise-features-enabled.guard';
@ -12,8 +11,6 @@ import { DeleteSsoOutput } from 'src/engine/core-modules/sso/dtos/delete-sso.out
import { EditSsoInput } from 'src/engine/core-modules/sso/dtos/edit-sso.input'; import { EditSsoInput } from 'src/engine/core-modules/sso/dtos/edit-sso.input';
import { EditSsoOutput } from 'src/engine/core-modules/sso/dtos/edit-sso.output'; import { EditSsoOutput } from 'src/engine/core-modules/sso/dtos/edit-sso.output';
import { FindAvailableSSOIDPOutput } from 'src/engine/core-modules/sso/dtos/find-available-SSO-IDP.output'; import { FindAvailableSSOIDPOutput } from 'src/engine/core-modules/sso/dtos/find-available-SSO-IDP.output';
import { GetAuthorizationUrlInput } from 'src/engine/core-modules/sso/dtos/get-authorization-url.input';
import { GetAuthorizationUrlOutput } from 'src/engine/core-modules/sso/dtos/get-authorization-url.output';
import { import {
SetupOIDCSsoInput, SetupOIDCSsoInput,
SetupSAMLSsoInput, SetupSAMLSsoInput,
@ -53,14 +50,6 @@ export class SSOResolver {
return this.sSOService.listSSOIdentityProvidersByWorkspaceId(workspaceId); return this.sSOService.listSSOIdentityProvidersByWorkspaceId(workspaceId);
} }
@Mutation(() => GetAuthorizationUrlOutput)
async getAuthorizationUrl(@Args('input') params: GetAuthorizationUrlInput) {
return await this.sSOService.getAuthorizationUrl(
params.identityProviderId,
omit(params, ['identityProviderId']),
);
}
@UseGuards(WorkspaceAuthGuard, EnterpriseFeaturesEnabledGuard) @UseGuards(WorkspaceAuthGuard, EnterpriseFeaturesEnabledGuard)
@Mutation(() => SetupSsoOutput) @Mutation(() => SetupSsoOutput)
async createSAMLIdentityProvider( async createSAMLIdentityProvider(

View File

@ -14,6 +14,6 @@ export const EXCLUDED_MIDDLEWARE_OPERATIONS = [
'UpdatePasswordViaResetToken', 'UpdatePasswordViaResetToken',
'IntrospectionQuery', 'IntrospectionQuery',
'ExchangeAuthorizationCode', 'ExchangeAuthorizationCode',
'GetAuthorizationUrl', 'GetAuthorizationUrlForSSO',
'GetPublicWorkspaceDataByDomain', 'GetPublicWorkspaceDataByDomain',
] as const; ] as const;