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:
@ -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' })
|
||||
|
||||
@ -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',
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
},
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -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);
|
||||
};
|
||||
}
|
||||
@ -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: {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -15,6 +15,9 @@ class AuthProviders {
|
||||
|
||||
@Field(() => Boolean)
|
||||
microsoft: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
sso: boolean;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class DeleteSsoOutput {
|
||||
@Field(() => String)
|
||||
identityProviderId: string;
|
||||
}
|
||||
@ -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'];
|
||||
}
|
||||
@ -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'];
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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'];
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -24,6 +24,11 @@ export class UpdateWorkspaceInput {
|
||||
@IsOptional()
|
||||
inviteHash?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isPublicInviteLinkEnabled?: boolean;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user