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:
@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user