feat(sso): allow to use OIDC and SAML (#7246)

## What it does
### Backend
- [x] Add a mutation to create OIDC and SAML configuration
- [x] Add a mutation to delete an SSO config
- [x] Add a feature flag to toggle SSO
- [x] Add a mutation to activate/deactivate an SSO config
- [x] Add a mutation to delete an SSO config
- [x] Add strategy to use OIDC or SAML
- [ ] Improve error management

### Frontend
- [x] Add section "security" in settings
- [x] Add page to list SSO configurations
- [x] Add page and forms to create OIDC or SAML configuration
- [x] Add field to "connect with SSO" in the signin/signup process
- [x] Trigger auth when a user switch to a workspace with SSO enable
- [x] Add an option on the security page to activate/deactivate the
global invitation link
- [ ] Add new Icons for SSO Identity Providers (okta, Auth0, Azure,
Microsoft)

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Antoine Moreaux
2024-10-21 20:07:08 +02:00
committed by GitHub
parent 11c3f1c399
commit 0f0a7966b1
132 changed files with 5245 additions and 306 deletions

View File

@ -22,6 +22,7 @@ export enum AppTokenType {
AuthorizationCode = 'AUTHORIZATION_CODE',
PasswordResetToken = 'PASSWORD_RESET_TOKEN',
InvitationToken = 'INVITATION_TOKEN',
OIDCCodeVerifier = 'OIDC_CODE_VERIFIER',
}
@Entity({ name: 'appToken', schema: 'core' })

View File

@ -17,4 +17,6 @@ export enum AuthExceptionCode {
INVALID_DATA = 'INVALID_DATA',
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
OAUTH_ACCESS_DENIED = 'OAUTH_ACCESS_DENIED',
SSO_AUTH_FAILED = 'SSO_AUTH_FAILED',
USE_SSO_AUTH = 'USE_SSO_AUTH',
}

View File

@ -27,7 +27,13 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module';
import { SSOAuthController } from 'src/engine/core-modules/auth/controllers/sso-auth.controller';
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { SamlAuthStrategy } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
import { AuthResolver } from './auth.resolver';
@ -43,7 +49,14 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
WorkspaceManagerModule,
TypeORMModule,
TypeOrmModule.forFeature(
[Workspace, User, AppToken, FeatureFlagEntity],
[
Workspace,
User,
AppToken,
FeatureFlagEntity,
WorkspaceSSOIdentityProvider,
KeyValuePair,
],
'core',
),
HttpModule,
@ -52,7 +65,9 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
WorkspaceModule,
OnboardingModule,
WorkspaceDataSourceModule,
WorkspaceInvitationModule,
ConnectedAccountModule,
WorkspaceSSOModule,
FeatureFlagModule,
],
controllers: [
@ -60,11 +75,13 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
MicrosoftAuthController,
GoogleAPIsAuthController,
VerifyAuthController,
SSOAuthController,
],
providers: [
SignInUpService,
AuthService,
JwtAuthStrategy,
SamlAuthStrategy,
AuthResolver,
TokenService,
GoogleAPIsService,

View File

@ -24,6 +24,11 @@ import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import {
GenerateJWTOutput,
GenerateJWTOutputWithAuthTokens,
GenerateJWTOutputWithSSOAUTH,
} from 'src/engine/core-modules/auth/dto/generateJWT.output';
import { ChallengeInput } from './dto/challenge.input';
import { ImpersonateInput } from './dto/impersonate.input';
@ -159,18 +164,41 @@ export class AuthResolver {
return authorizedApp;
}
@Mutation(() => AuthTokens)
@Mutation(() => GenerateJWTOutput)
@UseGuards(WorkspaceAuthGuard, UserAuthGuard)
async generateJWT(
@AuthUser() user: User,
@Args() args: GenerateJwtInput,
): Promise<AuthTokens> {
const token = await this.tokenService.generateSwitchWorkspaceToken(
): Promise<GenerateJWTOutputWithAuthTokens | GenerateJWTOutputWithSSOAUTH> {
const result = await this.tokenService.switchWorkspace(
user,
args.workspaceId,
);
return token;
if (result.useSSOAuth) {
return {
success: true,
reason: 'WORKSPACE_USE_SSO_AUTH',
availableSSOIDPs: result.availableSSOIdentityProviders.map(
(identityProvider) => ({
...identityProvider,
workspace: {
id: result.workspace.id,
displayName: result.workspace.displayName,
},
}),
),
};
}
return {
success: true,
reason: 'WORKSPACE_AVAILABLE_FOR_SWITCH',
authTokens: await this.tokenService.generateSwitchWorkspaceToken(
user,
result.workspace,
),
};
}
@Mutation(() => AuthTokens)

View File

@ -0,0 +1,161 @@
/* @license Enterprise */
import {
Controller,
Get,
Post,
Req,
Res,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { generateServiceProviderMetadata } from '@node-saml/node-saml';
import { Response } from 'express';
import { Repository } from 'typeorm';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { OIDCAuthGuard } from 'src/engine/core-modules/auth/guards/oidc-auth.guard';
import { SAMLAuthGuard } from 'src/engine/core-modules/auth/guards/saml-auth.guard';
import { SSOProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/sso-provider-enabled.guard';
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import {
IdentityProviderType,
WorkspaceSSOIdentityProvider,
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
@Controller('auth')
@UseFilters(AuthRestApiExceptionFilter)
export class SSOAuthController {
constructor(
private readonly tokenService: TokenService,
private readonly authService: AuthService,
private readonly workspaceInvitationService: WorkspaceInvitationService,
private readonly environmentService: EnvironmentService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly ssoService: SSOService,
@InjectRepository(WorkspaceSSOIdentityProvider, 'core')
private readonly workspaceSSOIdentityProviderRepository: Repository<WorkspaceSSOIdentityProvider>,
) {}
@Get('saml/metadata/:identityProviderId')
@UseGuards(SSOProviderEnabledGuard)
async generateMetadata(@Req() req: any): Promise<string> {
return generateServiceProviderMetadata({
wantAssertionsSigned: false,
issuer: this.ssoService.buildIssuerURL({
id: req.params.identityProviderId,
type: IdentityProviderType.SAML,
}),
callbackUrl: this.ssoService.buildCallbackUrl({
type: IdentityProviderType.SAML,
}),
});
}
@Get('oidc/login/:identityProviderId')
@UseGuards(SSOProviderEnabledGuard, OIDCAuthGuard)
async oidcAuth() {
// As this method is protected by OIDC Auth guard, it will trigger OIDC SSO flow
return;
}
@Get('saml/login/:identityProviderId')
@UseGuards(SSOProviderEnabledGuard, SAMLAuthGuard)
async samlAuth() {
// As this method is protected by SAML Auth guard, it will trigger SAML SSO flow
return;
}
@Get('oidc/callback')
@UseGuards(SSOProviderEnabledGuard, OIDCAuthGuard)
async oidcAuthCallback(@Req() req: any, @Res() res: Response) {
try {
const loginToken = await this.generateLoginToken(req.user);
return res.redirect(
this.tokenService.computeRedirectURI(loginToken.token),
);
} catch (err) {
// TODO: improve error management
res.status(403).send(err.message);
}
}
@Post('saml/callback')
@UseGuards(SSOProviderEnabledGuard, SAMLAuthGuard)
async samlAuthCallback(@Req() req: any, @Res() res: Response) {
try {
const loginToken = await this.generateLoginToken(req.user);
return res.redirect(
this.tokenService.computeRedirectURI(loginToken.token),
);
} catch (err) {
// TODO: improve error management
res.status(403).send(err.message);
res.redirect(`${this.environmentService.get('FRONT_BASE_URL')}/verify`);
}
}
private async generateLoginToken({
user,
identityProviderId,
}: {
identityProviderId?: string;
user: { email: string } & Record<string, string>;
}) {
const identityProvider =
await this.workspaceSSOIdentityProviderRepository.findOne({
where: { id: identityProviderId },
relations: ['workspace'],
});
if (!identityProvider) {
throw new AuthException(
'Identity provider not found',
AuthExceptionCode.INVALID_DATA,
);
}
const invitation =
await this.workspaceInvitationService.getOneWorkspaceInvitation(
identityProvider.workspaceId,
user.email,
);
if (invitation) {
await this.authService.signInUp({
...user,
workspacePersonalInviteToken: invitation.value,
workspaceInviteHash: identityProvider.workspace.inviteHash,
fromSSO: true,
});
}
const isUserExistInWorkspace =
await this.userWorkspaceService.checkUserWorkspaceExistsByEmail(
user.email,
identityProvider.workspaceId,
);
if (!isUserExistInWorkspace) {
throw new AuthException(
'User not found in workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
return this.tokenService.generateLoginToken(user.email);
}
}

View File

@ -0,0 +1,43 @@
import { Field, ObjectType, createUnionType } from '@nestjs/graphql';
import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity';
import { FindAvailableSSOIDPOutput } from 'src/engine/core-modules/sso/dtos/find-available-SSO-IDP.output';
@ObjectType()
export class GenerateJWTOutputWithAuthTokens {
@Field(() => Boolean)
success: boolean;
@Field(() => String)
reason: 'WORKSPACE_AVAILABLE_FOR_SWITCH';
@Field(() => AuthTokens)
authTokens: AuthTokens;
}
@ObjectType()
export class GenerateJWTOutputWithSSOAUTH {
@Field(() => Boolean)
success: boolean;
@Field(() => String)
reason: 'WORKSPACE_USE_SSO_AUTH';
@Field(() => [FindAvailableSSOIDPOutput])
availableSSOIDPs: Array<FindAvailableSSOIDPOutput>;
}
export const GenerateJWTOutput = createUnionType({
name: 'GenerateJWT',
types: () => [GenerateJWTOutputWithAuthTokens, GenerateJWTOutputWithSSOAUTH],
resolveType(value) {
if (value.reason === 'WORKSPACE_AVAILABLE_FOR_SWITCH') {
return GenerateJWTOutputWithAuthTokens;
}
if (value.reason === 'WORKSPACE_USE_SSO_AUTH') {
return GenerateJWTOutputWithSSOAUTH;
}
return null;
},
});

View File

@ -0,0 +1,73 @@
/* @license Enterprise */
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Issuer } from 'openid-client';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { OIDCAuthStrategy } from 'src/engine/core-modules/auth/strategies/oidc.auth.strategy';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
@Injectable()
export class OIDCAuthGuard extends AuthGuard('openidconnect') {
constructor(private readonly ssoService: SSOService) {
super();
}
private getIdentityProviderId(request: any): string {
if (request.params.identityProviderId) {
return request.params.identityProviderId;
}
if (
request.query.state &&
typeof request.query.state === 'string' &&
request.query.state.startsWith('{') &&
request.query.state.endsWith('}')
) {
const state = JSON.parse(request.query.state);
return state.identityProviderId;
}
throw new Error('Invalid OIDC identity provider params');
}
async canActivate(context: ExecutionContext): Promise<boolean> {
try {
const request = context.switchToHttp().getRequest();
const identityProviderId = this.getIdentityProviderId(request);
const identityProvider =
await this.ssoService.findSSOIdentityProviderById(identityProviderId);
if (!identityProvider) {
throw new AuthException(
'Identity provider not found',
AuthExceptionCode.INVALID_DATA,
);
}
const issuer = await Issuer.discover(identityProvider.issuer);
new OIDCAuthStrategy(
this.ssoService.getOIDCClient(identityProvider, issuer),
identityProvider.id,
);
return (await super.canActivate(context)) as boolean;
} catch (err) {
if (err instanceof AuthException) {
return false;
}
// TODO AMOREAUX: trigger sentry error
return false;
}
}
}

View File

@ -0,0 +1,48 @@
/* @license Enterprise */
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { SamlAuthStrategy } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
@Injectable()
export class SAMLAuthGuard extends AuthGuard('saml') {
constructor(private readonly sSOService: SSOService) {
super();
}
async canActivate(context: ExecutionContext) {
try {
const request = context.switchToHttp().getRequest();
const RelayState =
'RelayState' in request.body ? JSON.parse(request.body.RelayState) : {};
request.params.identityProviderId =
request.params.identityProviderId ?? RelayState.identityProviderId;
if (!request.params.identityProviderId) {
throw new AuthException(
'Invalid SAML identity provider',
AuthExceptionCode.INVALID_DATA,
);
}
new SamlAuthStrategy(this.sSOService);
return (await super.canActivate(context)) as boolean;
} catch (err) {
if (err instanceof AuthException) {
return false;
}
// TODO AMOREAUX: trigger sentry error
return false;
}
}
}

View File

@ -0,0 +1,27 @@
/* @license Enterprise */
import { CanActivate, Injectable } from '@nestjs/common';
import { Observable } from 'rxjs';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@Injectable()
export class SSOProviderEnabledGuard implements CanActivate {
constructor(private readonly environmentService: EnvironmentService) {}
canActivate(): boolean | Promise<boolean> | Observable<boolean> {
if (!this.environmentService.get('ENTERPRISE_KEY')) {
throw new AuthException(
'Enterprise key must be defined to use SSO',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
return true;
}
}

View File

@ -35,7 +35,6 @@ import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-u
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@ -43,7 +42,6 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
export class AuthService {
constructor(
private readonly tokenService: TokenService,
private readonly userService: UserService,
private readonly signInUpService: SignInUpService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,

View File

@ -225,23 +225,45 @@ export class SignInUpService {
email,
}) {
if (!workspacePersonalInviteToken && !workspaceInviteHash) {
throw new Error('No invite token or hash provided');
}
if (!workspacePersonalInviteToken && workspaceInviteHash) {
return (
(await this.workspaceRepository.findOneBy({
inviteHash: workspaceInviteHash,
})) ?? undefined
throw new AuthException(
'No invite token or hash provided',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const appToken = await this.userWorkspaceService.validateInvitation(
workspacePersonalInviteToken,
email,
);
const workspace = await this.workspaceRepository.findOneBy({
inviteHash: workspaceInviteHash,
});
return appToken?.workspace;
if (!workspace) {
throw new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
if (!workspacePersonalInviteToken && !workspace.isPublicInviteLinkEnabled) {
throw new AuthException(
'Workspace does not allow public invites',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
if (workspacePersonalInviteToken && workspace.isPublicInviteLinkEnabled) {
try {
await this.userWorkspaceService.validateInvitation(
workspacePersonalInviteToken,
email,
);
} catch (err) {
throw new AuthException(
err.message,
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
}
return workspace;
}
private async activateOnboardingForNewUser(

View File

@ -0,0 +1,86 @@
/* @license Enterprise */
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import {
Strategy,
StrategyOptions,
StrategyVerifyCallbackReq,
} from 'openid-client';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
@Injectable()
export class OIDCAuthStrategy extends PassportStrategy(
Strategy,
'openidconnect',
) {
constructor(
private client: StrategyOptions['client'],
sessionKey: string,
) {
super({
params: {
scope: 'openid email profile',
code_challenge_method: 'S256',
},
client,
usePKCE: true,
passReqToCallback: true,
sessionKey,
});
}
async authenticate(req: any, options: any) {
return super.authenticate(req, {
...options,
state: JSON.stringify({
identityProviderId: req.params.identityProviderId,
}),
});
}
validate: StrategyVerifyCallbackReq<{
identityProviderId: string;
user: {
email: string;
firstName?: string | null;
lastName?: string | null;
};
}> = async (req, tokenset, done) => {
try {
const state = JSON.parse(
'query' in req &&
req.query &&
typeof req.query === 'object' &&
'state' in req.query &&
req.query.state &&
typeof req.query.state === 'string'
? req.query.state
: '{}',
);
const userinfo = await this.client.userinfo(tokenset);
if (!userinfo || !userinfo.email) {
return done(
new AuthException('Email not found', AuthExceptionCode.INVALID_DATA),
);
}
const user = {
email: userinfo.email,
...(userinfo.given_name ? { firstName: userinfo.given_name } : {}),
...(userinfo.family_name ? { lastName: userinfo.family_name } : {}),
};
done(null, { user, identityProviderId: state.identityProviderId });
} catch (err) {
done(err);
}
};
}

View File

@ -0,0 +1,98 @@
/* @license Enterprise */
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import {
MultiSamlStrategy,
MultiStrategyConfig,
PassportSamlConfig,
SamlConfig,
VerifyWithRequest,
} from '@node-saml/passport-saml';
import { AuthenticateOptions } from '@node-saml/passport-saml/lib/types';
import { isEmail } from 'class-validator';
import { Request } from 'express';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
@Injectable()
export class SamlAuthStrategy extends PassportStrategy(
MultiSamlStrategy,
'saml',
) {
constructor(private readonly sSOService: SSOService) {
super({
getSamlOptions: (req, callback) => {
this.sSOService
.findSSOIdentityProviderById(req.params.identityProviderId)
.then((identityProvider) => {
if (
identityProvider &&
this.sSOService.isSAMLIdentityProvider(identityProvider)
) {
const config: SamlConfig = {
entryPoint: identityProvider.ssoURL,
issuer: this.sSOService.buildIssuerURL(identityProvider),
callbackUrl: this.sSOService.buildCallbackUrl(identityProvider),
idpCert: identityProvider.certificate,
wantAssertionsSigned: false,
// TODO: Improve the feature by sign the response
wantAuthnResponseSigned: false,
signatureAlgorithm: 'sha256',
};
return callback(null, config);
}
// TODO: improve error management
return callback(new Error('Invalid SAML identity provider'));
})
.catch((err) => {
// TODO: improve error management
return callback(err);
});
},
passReqToCallback: true,
} as PassportSamlConfig & MultiStrategyConfig);
}
authenticate(req: Request, options: AuthenticateOptions) {
super.authenticate(req, {
...options,
additionalParams: {
RelayState: JSON.stringify({
identityProviderId: req.params.identityProviderId,
}),
},
});
}
validate: VerifyWithRequest = async (request, profile, done) => {
if (!profile) {
return done(new Error('Profile is must be provided'));
}
const email = profile.email ?? profile.mail ?? profile.nameID;
if (!isEmail(email)) {
return done(new Error('Invalid email'));
}
const result: {
user: Record<string, string>;
identityProviderId?: string;
} = { user: { email } };
if (
'RelayState' in request.body &&
typeof request.body.RelayState === 'string'
) {
const RelayState = JSON.parse(request.body.RelayState);
result.identityProviderId = RelayState.identityProviderId;
}
done(null, result);
};
}

View File

@ -17,6 +17,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { TokenService } from './token.service';
@ -50,6 +51,12 @@ describe('TokenService', () => {
send: jest.fn(),
},
},
{
provide: SSOService,
useValue: {
send: jest.fn(),
},
},
{
provide: getRepositoryToken(User, 'core'),
useValue: {

View File

@ -46,6 +46,7 @@ import {
} from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
@Injectable()
export class TokenService {
@ -60,6 +61,7 @@ export class TokenService {
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
private readonly emailService: EmailService,
private readonly sSSOService: SSOService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
@ -341,10 +343,7 @@ export class TokenService {
};
}
async generateSwitchWorkspaceToken(
user: User,
workspaceId: string,
): Promise<AuthTokens> {
async switchWorkspace(user: User, workspaceId: string) {
const userExists = await this.userRepository.findBy({ id: user.id });
if (!userExists) {
@ -356,7 +355,7 @@ export class TokenService {
const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId },
relations: ['workspaceUsers'],
relations: ['workspaceUsers', 'workspaceSSOIdentityProviders'],
});
if (!workspace) {
@ -377,12 +376,44 @@ export class TokenService {
);
}
if (workspace.workspaceSSOIdentityProviders.length > 0) {
return {
useSSOAuth: true,
workspace,
availableSSOIdentityProviders:
await this.sSSOService.listSSOIdentityProvidersByWorkspaceId(
workspaceId,
),
} as {
useSSOAuth: true;
workspace: Workspace;
availableSSOIdentityProviders: Awaited<
ReturnType<
typeof this.sSSOService.listSSOIdentityProvidersByWorkspaceId
>
>;
};
}
return {
useSSOAuth: false,
workspace,
} as {
useSSOAuth: false;
workspace: Workspace;
};
}
async generateSwitchWorkspaceToken(
user: User,
workspace: Workspace,
): Promise<AuthTokens> {
await this.userRepository.save({
id: user.id,
defaultWorkspace: workspace,
});
const token = await this.generateAccessToken(user.id, workspaceId);
const token = await this.generateAccessToken(user.id, workspace.id);
const refreshToken = await this.generateRefreshToken(user.id);
return {

View File

@ -11,6 +11,7 @@ import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module';
@Module({
imports: [
@ -19,6 +20,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
TypeORMModule,
DataSourceModule,
EmailModule,
WorkspaceSSOModule,
],
providers: [TokenService, JwtAuthStrategy],
exports: [TokenService],

View File

@ -15,6 +15,9 @@ class AuthProviders {
@Field(() => Boolean)
microsoft: boolean;
@Field(() => Boolean)
sso: boolean;
}
@ObjectType()

View File

@ -16,6 +16,7 @@ export class ClientConfigResolver {
magicLink: false,
password: this.environmentService.get('AUTH_PASSWORD_ENABLED'),
microsoft: this.environmentService.get('AUTH_MICROSOFT_ENABLED'),
sso: this.environmentService.get('AUTH_SSO_ENABLED'),
},
billing: {
isBillingEnabled: this.environmentService.get('IS_BILLING_ENABLED'),

View File

@ -40,6 +40,7 @@ import { WorkflowTriggerApiModule } from 'src/engine/core-modules/workflow/workf
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module';
import { AnalyticsModule } from './analytics/analytics.module';
import { ClientConfigModule } from './client-config/client-config.module';
@ -61,6 +62,7 @@ import { FileModule } from './file/file.module';
UserModule,
WorkspaceModule,
WorkspaceInvitationModule,
WorkspaceSSOModule,
PostgresCredentialsModule,
WorkflowTriggerApiModule,
WorkspaceEventEmitterModule,
@ -117,6 +119,7 @@ import { FileModule } from './file/file.module';
UserModule,
WorkspaceModule,
WorkspaceInvitationModule,
WorkspaceSSOModule,
],
})
export class CoreEngineModule {}

View File

@ -225,6 +225,15 @@ export class EnvironmentVariables {
@ValidateIf((env) => env.AUTH_GOOGLE_ENABLED)
AUTH_GOOGLE_CALLBACK_URL: string;
@CastToBoolean()
@IsOptional()
@IsBoolean()
AUTH_SSO_ENABLED = false;
@IsString()
@IsOptional()
ENTERPRISE_KEY: string;
// Custom Code Engine
@IsEnum(ServerlessDriverType)
@IsOptional()
@ -443,6 +452,9 @@ export class EnvironmentVariables {
@CastToPositiveNumber()
CACHE_STORAGE_TTL: number = 3600 * 24 * 7;
@ValidateIf((env) => env.ENTERPRISE_KEY)
SESSION_STORE_SECRET: string;
@CastToBoolean()
CALENDAR_PROVIDER_GOOGLE_ENABLED = false;

View File

@ -10,6 +10,7 @@ export enum FeatureFlagKey {
IsMessageThreadSubscriberEnabled = 'IS_MESSAGE_THREAD_SUBSCRIBER_ENABLED',
IsQueryRunnerTwentyORMEnabled = 'IS_QUERY_RUNNER_TWENTY_ORM_ENABLED',
IsWorkspaceFavoriteEnabled = 'IS_WORKSPACE_FAVORITE_ENABLED',
IsSSOEnabled = 'IS_SSO_ENABLED',
IsGmailSendEmailScopeEnabled = 'IS_GMAIL_SEND_EMAIL_SCOPE_ENABLED',
IsAnalyticsV2Enabled = 'IS_ANALYTICS_V2_ENABLED',
IsUniqueIndexesEnabled = 'IS_UNIQUE_INDEXES_ENABLED',

View File

@ -0,0 +1,66 @@
import { Logger } from '@nestjs/common';
import { createClient } from 'redis';
import RedisStore from 'connect-redis';
import session from 'express-session';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { CacheStorageType } from 'src/engine/core-modules/cache-storage/types/cache-storage-type.enum';
import { MessageQueueDriverType } from 'src/engine/core-modules/message-queue/interfaces';
export const getSessionStorageOptions = (
environmentService: EnvironmentService,
): session.SessionOptions => {
const cacheStorageType = environmentService.get('CACHE_STORAGE_TYPE');
const SERVER_URL = environmentService.get('SERVER_URL');
const sessionStorage = {
secret: environmentService.get('SESSION_STORE_SECRET'),
resave: false,
saveUninitialized: false,
cookie: {
secure: !!(SERVER_URL && SERVER_URL.startsWith('https')),
maxAge: 1000 * 60 * 30, // 30 minutes
},
};
switch (cacheStorageType) {
case CacheStorageType.Memory: {
Logger.warn(
'Memory session storage is not recommended for production. Prefer Redis.',
);
return sessionStorage;
}
case CacheStorageType.Redis: {
const connectionString = environmentService.get('REDIS_URL');
if (!connectionString) {
throw new Error(
`${CacheStorageType.Redis} session storage requires REDIS_URL to be defined, check your .env file`,
);
}
const redisClient = createClient({
url: connectionString,
});
redisClient.connect().catch((err) => {
throw new Error(`Redis connection failed: ${err}`);
});
return {
...sessionStorage,
store: new RedisStore({
client: redisClient,
prefix: 'engine:session:',
}),
};
}
default:
throw new Error(
`Invalid session-storage (${cacheStorageType}), check your .env file`,
);
}
};

View File

@ -0,0 +1,12 @@
/* @license Enterprise */
import { Field, InputType } from '@nestjs/graphql';
import { IsUUID } from 'class-validator';
@InputType()
export class DeleteSsoInput {
@Field(() => String)
@IsUUID()
identityProviderId: string;
}

View File

@ -0,0 +1,9 @@
/* @license Enterprise */
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class DeleteSsoOutput {
@Field(() => String)
identityProviderId: string;
}

View File

@ -0,0 +1,19 @@
/* @license Enterprise */
import { Field, InputType } from '@nestjs/graphql';
import { IsString, IsUUID } from 'class-validator';
import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type';
import { SSOIdentityProviderStatus } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
@InputType()
export class EditSsoInput {
@Field(() => String)
@IsUUID()
id: string;
@Field(() => SSOIdentityProviderStatus)
@IsString()
status: SSOConfiguration['status'];
}

View File

@ -0,0 +1,27 @@
/* @license Enterprise */
import { Field, ObjectType } from '@nestjs/graphql';
import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type';
import {
IdentityProviderType,
SSOIdentityProviderStatus,
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
@ObjectType()
export class EditSsoOutput {
@Field(() => String)
id: string;
@Field(() => IdentityProviderType)
type: string;
@Field(() => String)
issuer: string;
@Field(() => String)
name: string;
@Field(() => SSOIdentityProviderStatus)
status: SSOConfiguration['status'];
}

View File

@ -0,0 +1,13 @@
/* @license Enterprise */
import { Field, InputType } from '@nestjs/graphql';
import { IsEmail, IsNotEmpty } from 'class-validator';
@InputType()
export class FindAvailableSSOIDPInput {
@Field(() => String)
@IsNotEmpty()
@IsEmail()
email: string;
}

View File

@ -0,0 +1,39 @@
/* @license Enterprise */
import { Field, ObjectType } from '@nestjs/graphql';
import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type';
import {
IdentityProviderType,
SSOIdentityProviderStatus,
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
@ObjectType()
class WorkspaceNameAndId {
@Field(() => String, { nullable: true })
displayName?: string | null;
@Field(() => String)
id: string;
}
@ObjectType()
export class FindAvailableSSOIDPOutput {
@Field(() => IdentityProviderType)
type: SSOConfiguration['type'];
@Field(() => String)
id: string;
@Field(() => String)
issuer: string;
@Field(() => String)
name: string;
@Field(() => SSOIdentityProviderStatus)
status: SSOConfiguration['status'];
@Field(() => WorkspaceNameAndId)
workspace: WorkspaceNameAndId;
}

View File

@ -0,0 +1,12 @@
/* @license Enterprise */
import { Field, InputType } from '@nestjs/graphql';
import { IsString } from 'class-validator';
@InputType()
export class GetAuthorizationUrlInput {
@Field(() => String)
@IsString()
identityProviderId: string;
}

View File

@ -0,0 +1,17 @@
/* @license Enterprise */
import { Field, ObjectType } from '@nestjs/graphql';
import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type';
@ObjectType()
export class GetAuthorizationUrlOutput {
@Field(() => String)
authorizationURL: string;
@Field(() => String)
type: SSOConfiguration['type'];
@Field(() => String)
id: string;
}

View File

@ -0,0 +1,50 @@
/* @license Enterprise */
import { Field, InputType } from '@nestjs/graphql';
import { IsOptional, IsString, IsUrl, IsUUID } from 'class-validator';
import { IsX509Certificate } from 'src/engine/core-modules/sso/dtos/validators/x509.validator';
@InputType()
class SetupSsoInputCommon {
@Field(() => String)
@IsString()
name: string;
@Field(() => String)
@IsString()
@IsUrl({ protocols: ['http', 'https'] })
issuer: string;
}
@InputType()
export class SetupOIDCSsoInput extends SetupSsoInputCommon {
@Field(() => String)
@IsString()
clientID: string;
@Field(() => String)
@IsString()
clientSecret: string;
}
@InputType()
export class SetupSAMLSsoInput extends SetupSsoInputCommon {
@Field(() => String)
@IsUUID()
id: string;
@Field(() => String)
@IsUrl({ protocols: ['http', 'https'] })
ssoURL: string;
@Field(() => String)
@IsX509Certificate()
certificate: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
fingerprint?: string;
}

View File

@ -0,0 +1,27 @@
/* @license Enterprise */
import { Field, ObjectType } from '@nestjs/graphql';
import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type';
import {
IdentityProviderType,
SSOIdentityProviderStatus,
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
@ObjectType()
export class SetupSsoOutput {
@Field(() => String)
id: string;
@Field(() => IdentityProviderType)
type: string;
@Field(() => String)
issuer: string;
@Field(() => String)
name: string;
@Field(() => SSOIdentityProviderStatus)
status: SSOConfiguration['status'];
}

View File

@ -0,0 +1,52 @@
/* @license Enterprise */
import * as crypto from 'crypto';
import {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
@ValidatorConstraint({ async: false })
export class IsX509CertificateConstraint
implements ValidatorConstraintInterface
{
validate(value: any) {
if (typeof value !== 'string') {
return false;
}
try {
const cleanCert = value.replace(
/-----BEGIN CERTIFICATE-----|-----END CERTIFICATE-----|\n|\r/g,
'',
);
const der = Buffer.from(cleanCert, 'base64');
const cert = new crypto.X509Certificate(der);
return cert instanceof crypto.X509Certificate;
} catch (error) {
return false;
}
}
defaultMessage() {
return 'The string is not a valid X509 certificate';
}
}
export function IsX509Certificate(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: IsX509CertificateConstraint,
});
};
}

View File

@ -0,0 +1,327 @@
/* @license Enterprise */
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Issuer } from 'openid-client';
import { Repository } from 'typeorm';
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FindAvailableSSOIDPOutput } from 'src/engine/core-modules/sso/dtos/find-available-SSO-IDP.output';
import {
SSOException,
SSOExceptionCode,
} from 'src/engine/core-modules/sso/sso.exception';
import {
OIDCConfiguration,
SAMLConfiguration,
SSOConfiguration,
} from 'src/engine/core-modules/sso/types/SSOConfigurations.type';
import {
IdentityProviderType,
OIDCResponseType,
WorkspaceSSOIdentityProvider,
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
export class SSOService {
constructor(
@InjectRepository(FeatureFlagEntity, 'core')
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
@InjectRepository(WorkspaceSSOIdentityProvider, 'core')
private readonly workspaceSSOIdentityProviderRepository: Repository<WorkspaceSSOIdentityProvider>,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
private readonly environmentService: EnvironmentService,
@InjectCacheStorage(CacheStorageNamespace.EngineWorkspace)
private readonly cacheStorageService: CacheStorageService,
) {}
private async isSSOEnabled(workspaceId: string) {
const isSSOEnabledFeatureFlag = await this.featureFlagRepository.findOneBy({
workspaceId,
key: FeatureFlagKey.IsSSOEnabled,
value: true,
});
if (!isSSOEnabledFeatureFlag?.value) {
throw new SSOException(
`${FeatureFlagKey.IsSSOEnabled} feature flag is disabled`,
SSOExceptionCode.SSO_DISABLE,
);
}
}
async createOIDCIdentityProvider(
data: Pick<
WorkspaceSSOIdentityProvider,
'issuer' | 'clientID' | 'clientSecret' | 'name'
>,
workspaceId: string,
) {
try {
await this.isSSOEnabled(workspaceId);
if (!data.issuer) {
throw new SSOException(
'Invalid issuer URL',
SSOExceptionCode.INVALID_ISSUER_URL,
);
}
const issuer = await Issuer.discover(data.issuer);
if (!issuer.metadata.issuer) {
throw new SSOException(
'Invalid issuer URL from discovery',
SSOExceptionCode.INVALID_ISSUER_URL,
);
}
const identityProvider =
await this.workspaceSSOIdentityProviderRepository.save({
type: IdentityProviderType.OIDC,
clientID: data.clientID,
clientSecret: data.clientSecret,
issuer: issuer.metadata.issuer,
name: data.name,
workspaceId,
});
return {
id: identityProvider.id,
type: identityProvider.type,
name: identityProvider.name,
status: identityProvider.status,
issuer: identityProvider.issuer,
};
} catch (err) {
if (err instanceof SSOException) {
return err;
}
return new SSOException(
'Unknown SSO configuration error',
SSOExceptionCode.UNKNOWN_SSO_CONFIGURATION_ERROR,
);
}
}
async createSAMLIdentityProvider(
data: Pick<
WorkspaceSSOIdentityProvider,
'ssoURL' | 'certificate' | 'fingerprint' | 'id'
>,
workspaceId: string,
) {
await this.isSSOEnabled(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,
};
}
async findAvailableSSOIdentityProviders(email: string) {
const user = await this.userRepository.findOne({
where: { email },
relations: [
'workspaces',
'workspaces.workspace',
'workspaces.workspace.workspaceSSOIdentityProviders',
],
});
if (!user) {
throw new SSOException('User not found', SSOExceptionCode.USER_NOT_FOUND);
}
return user.workspaces.flatMap((userWorkspace) =>
(
userWorkspace.workspace
.workspaceSSOIdentityProviders as Array<SSOConfiguration>
).reduce((acc, identityProvider) => {
if (identityProvider.status === 'Inactive') return acc;
acc.push({
id: identityProvider.id,
name: identityProvider.name ?? 'Unknown',
issuer: identityProvider.issuer,
type: identityProvider.type,
status: identityProvider.status,
workspace: {
id: userWorkspace.workspaceId,
displayName: userWorkspace.workspace.displayName,
},
});
return acc;
}, [] as Array<FindAvailableSSOIDPOutput>),
);
}
async findSSOIdentityProviderById(identityProviderId?: string) {
// if identityProviderId is not provide, typeorm return a random idp instead of undefined
if (!identityProviderId) return undefined;
return (await this.workspaceSSOIdentityProviderRepository.findOne({
where: { id: identityProviderId },
})) as (SSOConfiguration & WorkspaceSSOIdentityProvider) | undefined;
}
buildCallbackUrl(
identityProvider: Pick<WorkspaceSSOIdentityProvider, 'type'>,
) {
const callbackURL = new URL(this.environmentService.get('SERVER_URL'));
callbackURL.pathname = `/auth/${identityProvider.type.toLowerCase()}/callback`;
return callbackURL.toString();
}
buildIssuerURL(
identityProvider: Pick<WorkspaceSSOIdentityProvider, 'id' | 'type'>,
) {
return `${this.environmentService.get('SERVER_URL')}/auth/${identityProvider.type.toLowerCase()}/login/${identityProvider.id}`;
}
private isOIDCIdentityProvider(
identityProvider: WorkspaceSSOIdentityProvider,
): identityProvider is OIDCConfiguration & WorkspaceSSOIdentityProvider {
return identityProvider.type === IdentityProviderType.OIDC;
}
isSAMLIdentityProvider(
identityProvider: WorkspaceSSOIdentityProvider,
): identityProvider is SAMLConfiguration & WorkspaceSSOIdentityProvider {
return identityProvider.type === IdentityProviderType.SAML;
}
getOIDCClient(
identityProvider: WorkspaceSSOIdentityProvider,
issuer: Issuer,
) {
if (!this.isOIDCIdentityProvider(identityProvider)) {
throw new SSOException(
'Invalid Identity Provider type',
SSOExceptionCode.INVALID_IDP_TYPE,
);
}
return new issuer.Client({
client_id: identityProvider.clientID,
client_secret: identityProvider.clientSecret,
redirect_uris: [this.buildCallbackUrl(identityProvider)],
response_types: [OIDCResponseType.CODE],
});
}
async getAuthorizationUrl(identityProviderId: string) {
const identityProvider =
(await this.workspaceSSOIdentityProviderRepository.findOne({
where: {
id: identityProviderId,
},
})) as WorkspaceSSOIdentityProvider & SSOConfiguration;
if (!identityProvider) {
throw new SSOException(
'Identity Provider not found',
SSOExceptionCode.USER_NOT_FOUND,
);
}
return {
id: identityProvider.id,
authorizationURL: this.buildIssuerURL(identityProvider),
type: identityProvider.type,
};
}
async listSSOIdentityProvidersByWorkspaceId(workspaceId: string) {
return (await this.workspaceSSOIdentityProviderRepository.find({
where: { workspaceId },
select: ['id', 'name', 'type', 'issuer', 'status'],
})) as Array<
Pick<
WorkspaceSSOIdentityProvider,
'id' | 'name' | 'type' | 'issuer' | 'status'
>
>;
}
async deleteSSOIdentityProvider(
identityProviderId: string,
workspaceId: string,
) {
const identityProvider =
await this.workspaceSSOIdentityProviderRepository.findOne({
where: {
id: identityProviderId,
workspaceId,
},
});
if (!identityProvider) {
throw new SSOException(
'Identity Provider not found',
SSOExceptionCode.IDENTITY_PROVIDER_NOT_FOUND,
);
}
await this.workspaceSSOIdentityProviderRepository.delete({
id: identityProvider.id,
});
return { identityProviderId: identityProvider.id };
}
async editSSOIdentityProvider(
payload: Partial<WorkspaceSSOIdentityProvider>,
workspaceId: string,
) {
const ssoIdp = await this.workspaceSSOIdentityProviderRepository.findOne({
where: {
id: payload.id,
workspaceId,
},
});
if (!ssoIdp) {
throw new SSOException(
'Identity Provider not found',
SSOExceptionCode.IDENTITY_PROVIDER_NOT_FOUND,
);
}
const result = await this.workspaceSSOIdentityProviderRepository.save({
...ssoIdp,
...payload,
});
return {
id: result.id,
type: result.type,
issuer: result.issuer,
name: result.name,
status: result.status,
};
}
}

View File

@ -0,0 +1,20 @@
/* @license Enterprise */
import { CustomException } from 'src/utils/custom-exception';
export class SSOException extends CustomException {
code: SSOExceptionCode;
constructor(message: string, code: SSOExceptionCode) {
super(message, code);
}
}
export enum SSOExceptionCode {
USER_NOT_FOUND = 'USER_NOT_FOUND',
INVALID_SSO_CONFIGURATION = 'INVALID_SSO_CONFIGURATION',
IDENTITY_PROVIDER_NOT_FOUND = 'IDENTITY_PROVIDER_NOT_FOUND',
INVALID_ISSUER_URL = 'INVALID_ISSUER_URL',
INVALID_IDP_TYPE = 'INVALID_IDP_TYPE',
UNKNOWN_SSO_CONFIGURATION_ERROR = 'UNKNOWN_SSO_CONFIGURATION_ERROR',
SSO_DISABLE = 'SSO_DISABLE',
}

View File

@ -0,0 +1,24 @@
/* @license Enterprise */
import { Module } from '@nestjs/common';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { SSOResolver } from 'src/engine/core-modules/sso/sso.resolver';
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
@Module({
imports: [
NestjsQueryTypeOrmModule.forFeature(
[WorkspaceSSOIdentityProvider, User, AppToken, FeatureFlagEntity],
'core',
),
],
exports: [SSOService],
providers: [SSOService, SSOResolver],
})
export class WorkspaceSSOModule {}

View File

@ -0,0 +1,97 @@
/* @license Enterprise */
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { SSOProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/sso-provider-enabled.guard';
import { DeleteSsoInput } from 'src/engine/core-modules/sso/dtos/delete-sso.input';
import { DeleteSsoOutput } from 'src/engine/core-modules/sso/dtos/delete-sso.output';
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 { FindAvailableSSOIDPInput } from 'src/engine/core-modules/sso/dtos/find-available-SSO-IDP.input';
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 {
SetupOIDCSsoInput,
SetupSAMLSsoInput,
} from 'src/engine/core-modules/sso/dtos/setup-sso.input';
import { SetupSsoOutput } from 'src/engine/core-modules/sso/dtos/setup-sso.output';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { SSOException } from 'src/engine/core-modules/sso/sso.exception';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@Resolver()
export class SSOResolver {
constructor(private readonly sSOService: SSOService) {}
@UseGuards(WorkspaceAuthGuard, SSOProviderEnabledGuard)
@Mutation(() => SetupSsoOutput)
async createOIDCIdentityProvider(
@Args('input') setupSsoInput: SetupOIDCSsoInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
): Promise<SetupSsoOutput | SSOException> {
return this.sSOService.createOIDCIdentityProvider(
setupSsoInput,
workspaceId,
);
}
@UseGuards(SSOProviderEnabledGuard)
@Mutation(() => [FindAvailableSSOIDPOutput])
async findAvailableSSOIdentityProviders(
@Args('input') input: FindAvailableSSOIDPInput,
): Promise<Array<FindAvailableSSOIDPOutput>> {
return this.sSOService.findAvailableSSOIdentityProviders(input.email);
}
@UseGuards(SSOProviderEnabledGuard)
@Query(() => [FindAvailableSSOIDPOutput])
async listSSOIdentityProvidersByWorkspaceId(
@AuthWorkspace() { id: workspaceId }: Workspace,
) {
return this.sSOService.listSSOIdentityProvidersByWorkspaceId(workspaceId);
}
@Mutation(() => GetAuthorizationUrlOutput)
async getAuthorizationUrl(
@Args('input') { identityProviderId }: GetAuthorizationUrlInput,
) {
return this.sSOService.getAuthorizationUrl(identityProviderId);
}
@UseGuards(WorkspaceAuthGuard, SSOProviderEnabledGuard)
@Mutation(() => SetupSsoOutput)
async createSAMLIdentityProvider(
@Args('input') setupSsoInput: SetupSAMLSsoInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
): Promise<SetupSsoOutput | SSOException> {
return this.sSOService.createSAMLIdentityProvider(
setupSsoInput,
workspaceId,
);
}
@UseGuards(WorkspaceAuthGuard, SSOProviderEnabledGuard)
@Mutation(() => DeleteSsoOutput)
async deleteSSOIdentityProvider(
@Args('input') { identityProviderId }: DeleteSsoInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
) {
return this.sSOService.deleteSSOIdentityProvider(
identityProviderId,
workspaceId,
);
}
@UseGuards(WorkspaceAuthGuard, SSOProviderEnabledGuard)
@Mutation(() => EditSsoOutput)
async editSSOIdentityProvider(
@Args('input') input: EditSsoInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
) {
return this.sSOService.editSSOIdentityProvider(input, workspaceId);
}
}

View File

@ -0,0 +1,28 @@
/* @license Enterprise */
import {
IdentityProviderType,
SSOIdentityProviderStatus,
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
type CommonSSOConfiguration = {
id: string;
issuer: string;
name?: string;
status: SSOIdentityProviderStatus;
};
export type OIDCConfiguration = {
type: IdentityProviderType.OIDC;
clientID: string;
clientSecret: string;
} & CommonSSOConfiguration;
export type SAMLConfiguration = {
type: IdentityProviderType.SAML;
ssoURL: string;
certificate: string;
fingerprint?: string;
} & CommonSSOConfiguration;
export type SSOConfiguration = OIDCConfiguration | SAMLConfiguration;

View File

@ -0,0 +1,110 @@
/* @license Enterprise */
import { ObjectType, registerEnumType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Relation,
UpdateDateColumn,
} from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
export enum IdentityProviderType {
OIDC = 'OIDC',
SAML = 'SAML',
}
export enum OIDCResponseType {
// Only Authorization Code is used for now
CODE = 'code',
ID_TOKEN = 'id_token',
TOKEN = 'token',
NONE = 'none',
}
registerEnumType(IdentityProviderType, {
name: 'IdpType',
});
export enum SSOIdentityProviderStatus {
Active = 'Active',
Inactive = 'Inactive',
Error = 'Error',
}
registerEnumType(SSOIdentityProviderStatus, {
name: 'SSOIdentityProviderStatus',
});
@Entity({ name: 'workspaceSSOIdentityProvider', schema: 'core' })
@ObjectType('WorkspaceSSOIdentityProvider')
export class WorkspaceSSOIdentityProvider {
// COMMON
@IDField(() => UUIDScalarType)
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column({
type: 'enum',
enum: SSOIdentityProviderStatus,
default: SSOIdentityProviderStatus.Active,
})
status: SSOIdentityProviderStatus;
@ManyToOne(
() => Workspace,
(workspace) => workspace.workspaceSSOIdentityProviders,
{
onDelete: 'CASCADE',
},
)
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<Workspace>;
@Column()
workspaceId: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@Column({
type: 'enum',
enum: IdentityProviderType,
default: IdentityProviderType.OIDC,
})
type: IdentityProviderType;
@Column()
issuer: string;
// OIDC
@Column({ nullable: true })
clientID?: string;
@Column({ nullable: true })
clientSecret?: string;
// SAML
@Column({ nullable: true })
ssoURL?: string;
@Column({ nullable: true })
certificate?: string;
@Column({ nullable: true })
fingerprint?: string;
}

View File

@ -126,7 +126,7 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
throw new Error('Invalid invitation token');
}
if (appToken.context?.email !== email) {
if (!appToken.context?.email && appToken.context?.email !== email) {
throw new Error('Email does not match the invitation');
}

View File

@ -36,7 +36,7 @@ export class WorkspaceInvitationService {
private readonly onboardingService: OnboardingService,
) {}
private async getOneWorkspaceInvitation(workspaceId: string, email: string) {
async getOneWorkspaceInvitation(workspaceId: string, email: string) {
return await this.appTokenRepository
.createQueryBuilder('appToken')
.where('"appToken"."workspaceId" = :workspaceId', {
@ -160,7 +160,7 @@ export class WorkspaceInvitationService {
},
});
if (!appToken || !appToken.context || !('email' in appToken.context)) {
if (!appToken || !appToken.context?.email) {
throw new WorkspaceInvitationException(
'Invalid appToken',
WorkspaceInvitationExceptionCode.INVALID_INVITATION,

View File

@ -24,6 +24,11 @@ export class UpdateWorkspaceInput {
@IsOptional()
inviteHash?: string;
@Field({ nullable: true })
@IsBoolean()
@IsOptional()
isPublicInviteLinkEnabled?: boolean;
@Field({ nullable: true })
@IsBoolean()
@IsOptional()

View File

@ -19,6 +19,7 @@ import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-p
import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
export enum WorkspaceActivationStatus {
ONGOING_CREATION = 'ONGOING_CREATION',
@ -92,6 +93,10 @@ export class Workspace {
@Column({ default: true })
allowImpersonation: boolean;
@Field()
@Column({ default: true })
isPublicInviteLinkEnabled: boolean;
@OneToMany(() => FeatureFlagEntity, (featureFlag) => featureFlag.workspace)
featureFlags: Relation<FeatureFlagEntity[]>;
@ -118,6 +123,12 @@ export class Workspace {
)
allPostgresCredentials: Relation<PostgresCredentials[]>;
@OneToMany(
() => WorkspaceSSOIdentityProvider,
(workspaceSSOIdentityProviders) => workspaceSSOIdentityProviders.workspace,
)
workspaceSSOIdentityProviders: Relation<WorkspaceSSOIdentityProvider[]>;
@Field()
@Column({ default: 1 })
metadataVersion: number;