feat(): enable custom domain usage (#9911)

# Content
- Introduce the `workspaceUrls` property. It contains two
sub-properties: `customUrl, subdomainUrl`. These endpoints are used to
access the workspace. Even if the `workspaceUrls` is invalid for
multiple reasons, the `subdomainUrl` remains valid.
- Introduce `ResolveField` workspaceEndpoints to avoid unnecessary URL
computation on the frontend part.
- Add a `forceSubdomainUrl` to avoid custom URL using a query parameter
This commit is contained in:
Antoine Moreaux
2025-02-07 14:34:26 +01:00
committed by GitHub
parent 3cc66fe712
commit 68183b7c85
87 changed files with 645 additions and 373 deletions

View File

@ -221,7 +221,7 @@ export class AuthResolver {
await this.emailVerificationService.sendVerificationEmail(
user.id,
user.email,
workspace.subdomain,
workspace,
);
const loginToken = await this.loginTokenService.generateLoginToken(
@ -233,7 +233,7 @@ export class AuthResolver {
loginToken,
workspace: {
id: workspace.id,
subdomain: workspace.subdomain,
workspaceUrls: this.domainManagerService.getworkspaceUrls(workspace),
},
};
}

View File

@ -113,7 +113,7 @@ export class GoogleAPIsAuthController {
return res.redirect(
this.domainManagerService
.buildWorkspaceURL({
subdomain: workspace.subdomain,
workspace,
pathname: redirectLocation || '/settings/accounts',
})
.toString(),

View File

@ -19,7 +19,6 @@ import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'
import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
@Controller('auth/google')
@ -28,7 +27,6 @@ export class GoogleAuthController {
constructor(
private readonly loginTokenService: LoginTokenService,
private readonly authService: AuthService,
private readonly environmentService: EnvironmentService,
private readonly guardRedirectService: GuardRedirectService,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
@ -110,7 +108,7 @@ export class GoogleAuthController {
return res.redirect(
this.authService.computeRedirectURI({
loginToken: loginToken.token,
subdomain: workspace.subdomain,
workspace,
billingCheckoutSessionState,
}),
);
@ -118,9 +116,9 @@ export class GoogleAuthController {
return res.redirect(
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions(
err,
currentWorkspace ?? {
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
},
this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
currentWorkspace,
),
),
);
}

View File

@ -120,7 +120,7 @@ export class MicrosoftAPIsAuthController {
return res.redirect(
this.domainManagerService
.buildWorkspaceURL({
subdomain: workspace.subdomain,
workspace,
pathname: redirectLocation || '/settings/accounts',
})
.toString(),

View File

@ -18,7 +18,6 @@ import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'
import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
@Controller('auth/microsoft')
@ -28,7 +27,6 @@ export class MicrosoftAuthController {
private readonly loginTokenService: LoginTokenService,
private readonly authService: AuthService,
private readonly guardRedirectService: GuardRedirectService,
private readonly environmentService: EnvironmentService,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
) {}
@ -111,8 +109,7 @@ export class MicrosoftAuthController {
return res.redirect(
this.authService.computeRedirectURI({
loginToken: loginToken.token,
subdomain: workspace.subdomain,
workspace,
billingCheckoutSessionState,
}),
);
@ -120,9 +117,9 @@ export class MicrosoftAuthController {
return res.redirect(
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions(
err,
currentWorkspace ?? {
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
},
this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
currentWorkspace,
),
),
);
}

View File

@ -32,7 +32,6 @@ import {
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { AuthOAuthExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-oauth-exception.filter';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { SAMLRequest } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy';
import { OIDCRequest } from 'src/engine/core-modules/auth/strategies/oidc.auth.strategy';
@ -45,7 +44,6 @@ export class SSOAuthController {
private readonly loginTokenService: LoginTokenService,
private readonly authService: AuthService,
private readonly guardRedirectService: GuardRedirectService,
private readonly environmentService: EnvironmentService,
private readonly sSOService: SSOService,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
@ -152,16 +150,16 @@ export class SSOAuthController {
return res.redirect(
this.authService.computeRedirectURI({
loginToken: loginToken.token,
subdomain: currentWorkspace.subdomain,
workspace: currentWorkspace,
}),
);
} catch (err) {
return res.redirect(
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions(
err,
workspaceIdentityProvider?.workspace ?? {
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
},
this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
workspaceIdentityProvider?.workspace,
),
),
);
}

View File

@ -7,6 +7,7 @@ import {
IdentityProviderType,
SSOIdentityProviderStatus,
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { workspaceUrls } from 'src/engine/core-modules/workspace/dtos/workspace-endpoints.dto';
@ObjectType()
class SSOConnection {
@ -34,8 +35,8 @@ export class AvailableWorkspaceOutput {
@Field(() => String, { nullable: true })
displayName?: string;
@Field(() => String)
subdomain: string;
@Field(() => workspaceUrls)
workspaceUrls: workspaceUrls;
@Field(() => String, { nullable: true })
hostname?: string;

View File

@ -1,6 +1,6 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { WorkspaceSubdomainAndId } from 'src/engine/core-modules/workspace/dtos/workspace-subdomain-id.dto';
import { workspaceUrlsAndId } from 'src/engine/core-modules/workspace/dtos/workspace-subdomain-id.dto';
import { AuthToken } from './token.entity';
@ -9,6 +9,6 @@ export class SignUpOutput {
@Field(() => AuthToken)
loginToken: AuthToken;
@Field(() => WorkspaceSubdomainAndId)
workspace: WorkspaceSubdomainAndId;
@Field(() => workspaceUrlsAndId)
workspace: workspaceUrlsAndId;
}

View File

@ -27,9 +27,11 @@ export class EnterpriseFeaturesEnabledGuard implements CanActivate {
return true;
} catch (err) {
this.guardRedirectService.dispatchErrorFromGuard(context, err, {
subdomain: this.guardRedirectService.getSubdomainFromContext(context),
});
this.guardRedirectService.dispatchErrorFromGuard(
context,
err,
this.guardRedirectService.getSubdomainAndHostnameFromContext(context),
);
return false;
}

View File

@ -50,9 +50,11 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
return (await super.canActivate(context)) as boolean;
} catch (err) {
this.guardRedirectService.dispatchErrorFromGuard(context, err, {
subdomain: this.guardRedirectService.getSubdomainFromContext(context),
});
this.guardRedirectService.dispatchErrorFromGuard(
context,
err,
this.guardRedirectService.getSubdomainAndHostnameFromContext(context),
);
return false;
}

View File

@ -71,9 +71,9 @@ export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') {
this.guardRedirectService.dispatchErrorFromGuard(
context,
err,
workspace ?? {
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
},
this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
workspace,
),
);
return false;

View File

@ -11,13 +11,11 @@ import {
} from 'src/engine/core-modules/auth/auth.exception';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@Injectable()
export class GoogleOauthGuard extends AuthGuard('google') {
constructor(
private readonly guardRedirectService: GuardRedirectService,
private readonly environmentService: EnvironmentService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
) {
@ -53,9 +51,9 @@ export class GoogleOauthGuard extends AuthGuard('google') {
this.guardRedirectService.dispatchErrorFromGuard(
context,
err,
workspace ?? {
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
},
this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
workspace,
),
);
return false;

View File

@ -28,9 +28,11 @@ export class GoogleProviderEnabledGuard implements CanActivate {
return true;
} catch (err) {
this.guardRedirectService.dispatchErrorFromGuard(context, err, {
subdomain: this.guardRedirectService.getSubdomainFromContext(context),
});
this.guardRedirectService.dispatchErrorFromGuard(
context,
err,
this.guardRedirectService.getSubdomainAndHostnameFromContext(context),
);
return false;
}

View File

@ -57,9 +57,7 @@ export class MicrosoftAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
AuthExceptionCode.INSUFFICIENT_SCOPES,
)
: error,
{
subdomain: this.guardRedirectService.getSubdomainFromContext(context),
},
this.guardRedirectService.getSubdomainAndHostnameFromContext(context),
);
return false;

View File

@ -72,9 +72,9 @@ export class MicrosoftAPIsOauthRequestCodeGuard extends AuthGuard(
this.guardRedirectService.dispatchErrorFromGuard(
context,
err,
workspace ?? {
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
},
this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
workspace,
),
);
return false;

View File

@ -5,14 +5,12 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
export class MicrosoftOAuthGuard extends AuthGuard('microsoft') {
constructor(
private readonly guardRedirectService: GuardRedirectService,
private readonly environmentService: EnvironmentService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
) {
@ -41,9 +39,9 @@ export class MicrosoftOAuthGuard extends AuthGuard('microsoft') {
this.guardRedirectService.dispatchErrorFromGuard(
context,
err,
workspace ?? {
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
},
this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
workspace,
),
);
return false;

View File

@ -28,9 +28,11 @@ export class MicrosoftProviderEnabledGuard implements CanActivate {
return true;
} catch (err) {
this.guardRedirectService.dispatchErrorFromGuard(context, err, {
subdomain: this.guardRedirectService.getSubdomainFromContext(context),
});
this.guardRedirectService.dispatchErrorFromGuard(
context,
err,
this.guardRedirectService.getSubdomainAndHostnameFromContext(context),
);
return false;
}

View File

@ -12,7 +12,6 @@ import {
import { OIDCAuthStrategy } from 'src/engine/core-modules/auth/strategies/oidc.auth.strategy';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type';
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
@ -21,12 +20,13 @@ export class OIDCAuthGuard extends AuthGuard('openidconnect') {
constructor(
private readonly sSOService: SSOService,
private readonly guardRedirectService: GuardRedirectService,
private readonly environmentService: EnvironmentService,
) {
super();
}
private getIdentityProviderId(request: any): string {
private getStateByRequest(request: any): {
identityProviderId: string;
} {
if (request.params.identityProviderId) {
return request.params.identityProviderId;
}
@ -39,24 +39,27 @@ export class OIDCAuthGuard extends AuthGuard('openidconnect') {
) {
const state = JSON.parse(request.query.state);
return state.identityProviderId;
return {
identityProviderId: state.identityProviderId,
};
}
throw new Error('Invalid OIDC identity provider params');
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const request = context.switchToHttp().getRequest<Request>();
let identityProvider:
| (SSOConfiguration & WorkspaceSSOIdentityProvider)
| null = null;
try {
const identityProviderId = this.getIdentityProviderId(request);
const state = this.getStateByRequest(request);
identityProvider =
await this.sSOService.findSSOIdentityProviderById(identityProviderId);
identityProvider = await this.sSOService.findSSOIdentityProviderById(
state.identityProviderId,
);
if (!identityProvider) {
throw new AuthException(
@ -77,9 +80,9 @@ export class OIDCAuthGuard extends AuthGuard('openidconnect') {
this.guardRedirectService.dispatchErrorFromGuard(
context,
err,
identityProvider?.workspace ?? {
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
},
this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
identityProvider?.workspace,
),
);
return false;

View File

@ -3,6 +3,8 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Request } from 'express';
import {
AuthException,
AuthExceptionCode,
@ -10,22 +12,34 @@ import {
import { SamlAuthStrategy } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type';
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
@Injectable()
export class SAMLAuthGuard extends AuthGuard('saml') {
constructor(
private readonly sSOService: SSOService,
private readonly guardRedirectService: GuardRedirectService,
private readonly environmentService: EnvironmentService,
private readonly exceptionHandlerService: ExceptionHandlerService,
) {
super();
}
private getRelayStateByRequest(request: Request) {
try {
const relayStateRaw = request.body.RelayState || request.query.RelayState;
if (relayStateRaw) {
return JSON.parse(relayStateRaw);
}
} catch (error) {
this.exceptionHandlerService.captureExceptions(error);
}
}
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
const request = context.switchToHttp().getRequest<Request>();
let identityProvider:
| (SSOConfiguration & WorkspaceSSOIdentityProvider)
@ -49,9 +63,9 @@ export class SAMLAuthGuard extends AuthGuard('saml') {
this.guardRedirectService.dispatchErrorFromGuard(
context,
err,
identityProvider?.workspace ?? {
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
},
this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
identityProvider?.workspace,
),
);
return false;

View File

@ -455,15 +455,15 @@ export class AuthService {
computeRedirectURI({
loginToken,
subdomain,
workspace,
billingCheckoutSessionState,
}: {
loginToken: string;
subdomain: string;
workspace: Pick<Workspace, 'subdomain' | 'hostname'>;
billingCheckoutSessionState?: string;
}) {
const url = this.domainManagerService.buildWorkspaceURL({
subdomain,
workspace,
pathname: '/verify',
searchParams: {
loginToken,

View File

@ -50,7 +50,6 @@ export class OIDCAuthStrategy extends PassportStrategy(
...options,
state: JSON.stringify({
identityProviderId: req.params.identityProviderId,
...(req.query.forceSubdomainUrl ? { forceSubdomainUrl: true } : {}),
...(req.query.workspaceInviteHash
? { workspaceInviteHash: req.query.workspaceInviteHash }
: {}),