feat(sso): fix saml + allow to use public invite with sso + fix invite page with multiple sso provider (#9963)

- Fix SAML issue
- Fix the wrong state on the Invite page when multiple SSO provider
exists
- Allow to signup with SSO and public invite link
- For OIDC, use the property upn to guess email for Microsoft and enable
oidc with a specific context in azure
- Improve error in OIDC flow when email not found
This commit is contained in:
Antoine Moreaux
2025-02-03 18:48:25 +01:00
committed by GitHub
parent 253a3eb83f
commit 47487f5d1c
14 changed files with 122 additions and 92 deletions

View File

@ -36,6 +36,8 @@ import { EnvironmentService } from 'src/engine/core-modules/environment/environm
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';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
@Controller('auth')
export class SSOAuthController {
@ -107,9 +109,10 @@ export class SSOAuthController {
private async authCallback(req: OIDCRequest | SAMLRequest, res: Response) {
const workspaceIdentityProvider =
await this.findWorkspaceIdentityProviderByIdentityProviderId(
req.user.identityProviderId,
);
await this.workspaceSSOIdentityProviderRepository.findOne({
where: { id: req.user.identityProviderId },
relations: ['workspace'],
});
try {
if (!workspaceIdentityProvider) {
@ -126,15 +129,30 @@ export class SSOAuthController {
);
}
const { loginToken, identityProvider } = await this.generateLoginToken(
const currentWorkspace = await this.authService.findWorkspaceForSignInUp({
workspaceId: workspaceIdentityProvider.workspaceId,
workspaceInviteHash: req.user.workspaceInviteHash,
email: req.user.email,
authProvider: 'sso',
});
workspaceValidator.assertIsDefinedOrThrow(
currentWorkspace,
new AuthException(
'Workspace not found',
AuthExceptionCode.OAUTH_ACCESS_DENIED,
),
);
const { loginToken } = await this.generateLoginToken(
req.user,
workspaceIdentityProvider,
currentWorkspace,
);
return res.redirect(
this.authService.computeRedirectURI({
loginToken: loginToken.token,
subdomain: identityProvider.workspace.subdomain,
subdomain: currentWorkspace.subdomain,
}),
);
} catch (err) {
@ -149,33 +167,16 @@ export class SSOAuthController {
}
}
private async findWorkspaceIdentityProviderByIdentityProviderId(
identityProviderId: string,
) {
return await this.workspaceSSOIdentityProviderRepository.findOne({
where: { id: identityProviderId },
relations: ['workspace'],
});
}
private async generateLoginToken(
payload: { email: string },
identityProvider: WorkspaceSSOIdentityProvider,
payload: { email: string; workspaceInviteHash?: string },
currentWorkspace: Workspace,
) {
if (!identityProvider) {
throw new AuthException(
'Identity provider not found',
AuthExceptionCode.INVALID_DATA,
);
}
const invitation =
payload.email && identityProvider.workspace
? await this.authService.findInvitationForSignInUp({
currentWorkspace: identityProvider.workspace,
email: payload.email,
})
: undefined;
const invitation = payload.email
? await this.authService.findInvitationForSignInUp({
currentWorkspace,
email: payload.email,
})
: undefined;
const existingUser = await this.userRepository.findOne({
where: {
@ -191,12 +192,13 @@ export class SSOAuthController {
await this.authService.checkAccessForSignIn({
userData,
invitation,
workspace: identityProvider.workspace,
workspaceInviteHash: payload.workspaceInviteHash,
workspace: currentWorkspace,
});
const { workspace, user } = await this.authService.signInUp({
userData,
workspace: identityProvider.workspace,
workspace: currentWorkspace,
invitation,
authParams: {
provider: 'sso',
@ -204,7 +206,7 @@ export class SSOAuthController {
});
return {
identityProvider,
workspace,
loginToken: await this.loginTokenService.generateLoginToken(
user.email,
workspace.id,

View File

@ -3,7 +3,6 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { isEmail } from 'class-validator';
import { Request } from 'express';
import { Strategy, StrategyOptions, TokenSet } from 'openid-client';
@ -21,6 +20,7 @@ export type OIDCRequest = Omit<
email: string;
firstName?: string | null;
lastName?: string | null;
workspaceInviteHash?: string;
};
};
@ -50,12 +50,17 @@ 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 }
: {}),
}),
});
}
private extractState(req: Request): {
identityProviderId: string;
workspaceInviteHash?: string;
} {
try {
const state = JSON.parse(
@ -70,6 +75,7 @@ export class OIDCAuthStrategy extends PassportStrategy(
return {
identityProviderId: state.identityProviderId,
workspaceInviteHash: state.workspaceInviteHash,
};
} catch (err) {
throw new AuthException('Invalid state', AuthExceptionCode.INVALID_INPUT);
@ -86,12 +92,20 @@ export class OIDCAuthStrategy extends PassportStrategy(
const userinfo = await this.client.userinfo(tokenset);
if (!userinfo.email || !isEmail(userinfo.email)) {
return done(new Error('Invalid email'));
const email = userinfo.email ?? userinfo.upn;
if (!email || typeof email !== 'string') {
return done(
new AuthException(
'Email not found in identity provider payload',
AuthExceptionCode.INVALID_DATA,
),
);
}
done(null, {
email: userinfo.email,
email,
workspaceInviteHash: state.workspaceInviteHash,
identityProviderId: state.identityProviderId,
...(userinfo.given_name ? { firstName: userinfo.given_name } : {}),
...(userinfo.family_name ? { lastName: userinfo.family_name } : {}),

View File

@ -26,6 +26,7 @@ export type SAMLRequest = Omit<
> & {
user: {
identityProviderId: string;
workspaceInviteHash?: string;
email: string;
};
};
@ -78,6 +79,9 @@ export class SamlAuthStrategy extends PassportStrategy(
additionalParams: {
RelayState: JSON.stringify({
identityProviderId: req.params.identityProviderId,
...(req.query.workspaceInviteHash
? { workspaceInviteHash: req.query.workspaceInviteHash }
: {}),
}),
},
});
@ -85,6 +89,7 @@ export class SamlAuthStrategy extends PassportStrategy(
private extractState(req: Request): {
identityProviderId: string;
workspaceInviteHash?: string;
} {
try {
if ('RelayState' in req.body && typeof req.body.RelayState === 'string') {
@ -92,6 +97,7 @@ export class SamlAuthStrategy extends PassportStrategy(
return {
identityProviderId: RelayState.identityProviderId,
workspaceInviteHash: RelayState.workspaceInviteHash,
};
}
@ -114,11 +120,7 @@ export class SamlAuthStrategy extends PassportStrategy(
}
const state = this.extractState(request);
const result: Pick<SAMLRequest, 'user'> = {
user: { ...state, email },
};
done(null, result);
done(null, { ...state, email });
} catch (err) {
done(err);
}