-
Notifications
You must be signed in to change notification settings - Fork 69
feat: implement SSO support for Google Workspace (fixes #413) #416
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import { | ||
| IOrganizationParametersService, | ||
| ORGANIZATION_PARAMETERS_SERVICE_TOKEN, | ||
| } from '@/core/domain/organizationParameters/contracts/organizationParameters.service.contract'; | ||
| import { SSOConfig } from '@/core/domain/organizationParameters/types/sso-config.type'; | ||
| import { PinoLoggerService } from '@/core/infrastructure/adapters/services/logger/pino.service'; | ||
| import { OrganizationParametersKey } from '@/shared/domain/enums/organization-parameters-key.enum'; | ||
| import { IUseCase } from '@/shared/domain/interfaces/use-case.interface'; | ||
| import { Inject, Injectable } from '@nestjs/common'; | ||
|
|
||
| @Injectable() | ||
| export class CheckSSOUseCase implements IUseCase { | ||
| constructor( | ||
| @Inject(ORGANIZATION_PARAMETERS_SERVICE_TOKEN) | ||
| private readonly organizationParametersService: IOrganizationParametersService, | ||
| private readonly logger: PinoLoggerService, | ||
| ) { } | ||
|
|
||
| public async execute(email: string): Promise<{ | ||
| ssoEnabled: boolean; | ||
| redirectUrl?: string; | ||
| organizationId?: string; | ||
| }> { | ||
| try { | ||
| if (!email) { | ||
| return { ssoEnabled: false }; | ||
| } | ||
|
|
||
| const domain = email.split('@')[1]; | ||
| if (!domain) { | ||
| return { ssoEnabled: false }; | ||
| } | ||
|
|
||
| const ssoConfigs = | ||
| await this.organizationParametersService.findByKeyAndValue({ | ||
| configKey: OrganizationParametersKey.SSO_CONFIG, | ||
| configValue: { enabled: true }, | ||
| fuzzy: true, | ||
| }); | ||
|
|
||
| if (!ssoConfigs || ssoConfigs.length === 0) { | ||
| return { ssoEnabled: false }; | ||
| } | ||
|
|
||
| const lowercaseDomain = domain.toLowerCase(); | ||
| const matchingConfig = ssoConfigs.find((param) => { | ||
| const config = param.configValue as SSOConfig; | ||
| return config?.domains?.some( | ||
| (d) => d.toLowerCase() === lowercaseDomain, | ||
| ); | ||
| }); | ||
|
|
||
| if (!matchingConfig) { | ||
| return { ssoEnabled: false }; | ||
| } | ||
|
|
||
| const config = matchingConfig.configValue as SSOConfig; | ||
| const redirectUri = config.redirectUris[0]; // Assuming first one for now | ||
|
|
||
| // Construct Google OIDC URL | ||
| const scope = encodeURIComponent('openid email profile'); | ||
| const responseType = 'code'; | ||
| const clientId = encodeURIComponent(config.clientId); | ||
| const redirect = encodeURIComponent(redirectUri); | ||
| const state = encodeURIComponent( | ||
| JSON.stringify({ | ||
| organizationId: matchingConfig.organization.uuid, | ||
| provider: 'google', | ||
| }), | ||
| ); | ||
|
|
||
| const googleAuthUrl = `${config.issuer}/o/oauth2/v2/auth?client_id=${clientId}&redirect_uri=${redirect}&response_type=${responseType}&scope=${scope}&state=${state}&access_type=offline&prompt=consent`; | ||
|
|
||
| return { | ||
| ssoEnabled: true, | ||
| redirectUrl: googleAuthUrl, | ||
| organizationId: matchingConfig.organization.uuid, | ||
| }; | ||
| } catch (error) { | ||
| this.logger.error({ | ||
| message: 'Error checking SSO status', | ||
| error, | ||
| context: CheckSSOUseCase.name, | ||
| metadata: { email }, | ||
| }); | ||
| return { ssoEnabled: false }; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,188 @@ | ||
| import { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. // Rename file to: ssoLoginUseCase.ts
import {The file name 'sso-login.use-case.ts' uses kebab-case, which violates the rule requiring camelCase for new files. Please rename the file to 'ssoLoginUseCase.ts'. Kody Rule violation: Seguir padrão de nomenclatura de arquivos Talk to Kody by mentioning @kody Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction. |
||
| AUTH_SERVICE_TOKEN, | ||
| IAuthService, | ||
| } from '@/core/domain/auth/contracts/auth.service.contracts'; | ||
| import { | ||
| IOrganizationService, | ||
| ORGANIZATION_SERVICE_TOKEN, | ||
| } from '@/core/domain/organization/contracts/organization.service.contract'; | ||
| import { | ||
| ITeamService, | ||
| TEAM_SERVICE_TOKEN, | ||
| } from '@/core/domain/team/contracts/team.service.contract'; | ||
| import { | ||
| ITeamMemberService, | ||
| TEAM_MEMBERS_SERVICE_TOKEN, | ||
| } from '@/core/domain/teamMembers/contracts/teamMembers.service.contracts'; | ||
| import { TeamMemberRole } from '@/core/domain/teamMembers/enums/teamMemberRole.enum'; | ||
| import { | ||
| IUsersService, | ||
| USER_SERVICE_TOKEN, | ||
| } from '@/core/domain/user/contracts/user.service.contract'; | ||
| import { IUser } from '@/core/domain/user/interfaces/user.interface'; | ||
| import { PinoLoggerService } from '@/core/infrastructure/adapters/services/logger/pino.service'; | ||
| import { AuthProvider } from '@/shared/domain/enums/auth-provider.enum'; | ||
| import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; | ||
| import axios from 'axios'; | ||
| import { randomBytes } from 'node:crypto'; | ||
| import { GetOrganizationsByDomainUseCase } from '@/core/application/use-cases/organization/get-organizations-domain.use-case'; | ||
| import { SignUpUseCase } from './signup.use-case'; | ||
| import { SSOConfig } from '@/core/domain/organizationParameters/types/ssoConfig.type'; | ||
| import { | ||
| IOrganizationParametersService, | ||
| ORGANIZATION_PARAMETERS_SERVICE_TOKEN, | ||
| } from '@/core/domain/organizationParameters/contracts/organizationParameters.service.contract'; | ||
| import { OrganizationParametersKey } from '@/shared/domain/enums/organization-parameters-key.enum'; | ||
|
|
||
| @Injectable() | ||
| export class SSOLoginUseCase { | ||
| constructor( | ||
| @Inject(AUTH_SERVICE_TOKEN) | ||
| private readonly authService: IAuthService, | ||
| @Inject(USER_SERVICE_TOKEN) | ||
| private readonly usersService: IUsersService, | ||
| @Inject(ORGANIZATION_SERVICE_TOKEN) | ||
| private readonly organizationService: IOrganizationService, | ||
| @Inject(TEAM_MEMBERS_SERVICE_TOKEN) | ||
| private readonly teamMembersService: ITeamMemberService, | ||
| @Inject(TEAM_SERVICE_TOKEN) | ||
| private readonly teamService: ITeamService, | ||
| @Inject(ORGANIZATION_PARAMETERS_SERVICE_TOKEN) | ||
| private readonly organizationParametersService: IOrganizationParametersService, | ||
| private readonly signUpUseCase: SignUpUseCase, | ||
| private readonly getOrganizationsByDomainUseCase: GetOrganizationsByDomainUseCase, | ||
| private readonly logger: PinoLoggerService, | ||
| ) { } | ||
|
|
||
| public async execute( | ||
| code: string, | ||
| state: string, | ||
| ): Promise<{ accessToken: string; refreshToken: string }> { | ||
| let organizationId: string | undefined; | ||
| try { | ||
| const stateJson = JSON.parse(decodeURIComponent(state)); | ||
| organizationId = stateJson.organizationId; | ||
|
|
||
| if (!organizationId) { | ||
| throw new UnauthorizedException('Invalid state: missing organizationId'); | ||
| } | ||
|
|
||
| // 1. Get SSO Config for the organization | ||
| const orgParams = await this.organizationParametersService.findByKeyAndValue({ | ||
| configKey: OrganizationParametersKey.SSO_CONFIG, | ||
| configValue: { enabled: true }, | ||
| fuzzy: true, | ||
| }); | ||
|
|
||
| const orgConfig = orgParams.find( | ||
| (p) => p.organization.uuid === organizationId, | ||
| ); | ||
|
|
||
| if (!orgConfig) { | ||
| throw new UnauthorizedException('SSO not enabled for this organization'); | ||
| } | ||
|
|
||
| const ssoConfig = orgConfig.configValue as SSOConfig; | ||
|
|
||
| // 2. Exchange code for tokens | ||
| const tokenResponse = await axios.post( | ||
| 'https://oauth2.googleapis.com/token', | ||
| { | ||
| code, | ||
| client_id: ssoConfig.clientId, | ||
| client_secret: ssoConfig.clientSecret, | ||
| redirect_uri: ssoConfig.redirectUris[0], | ||
| grant_type: 'authorization_code', | ||
| }, | ||
| ); | ||
|
|
||
| const { id_token, refresh_token } = tokenResponse.data; | ||
|
|
||
| // 3. Verify ID Token and get email (Simplified: just decode for now, in prod verify signature) | ||
| // Using google's tokeninfo endpoint for verification is safer | ||
| const tokenInfo = await axios.get( | ||
| `https://oauth2.googleapis.com/tokeninfo?id_token=${id_token}`, | ||
| ); | ||
|
|
||
| const email = tokenInfo.data.email; | ||
| const name = tokenInfo.data.name || email.split('@')[0]; | ||
|
|
||
| if (!email) { | ||
| throw new UnauthorizedException('Could not retrieve email from ID token'); | ||
| } | ||
|
|
||
| // 4. Find or Create User | ||
| let user = await this.authService.validateUser({ email }); | ||
|
|
||
| if (!user || !user.uuid) { | ||
|
Check warning on line 117 in src/core/application/use-cases/auth/ssoLoginUseCase.ts
|
||
| // Create user and add to the SSO organization | ||
| user = await this.signUpUseCase.execute({ | ||
| email, | ||
| name, | ||
| password: randomBytes(32).toString('base64').slice(0, 32), | ||
| organizationId, | ||
| }); | ||
| } else { | ||
| // Ensure user is member of the SSO organization | ||
| await this.ensureOrganizationMembership(user as IUser, organizationId); | ||
| } | ||
|
|
||
| // 5. Auto Join Logic (for OTHER organizations) | ||
| const domain = email.split('@')[1]; | ||
| const autoJoinOrgs = await this.getOrganizationsByDomainUseCase.execute(domain); | ||
|
|
||
| for (const org of autoJoinOrgs) { | ||
| if (org.uuid !== organizationId) { | ||
| await this.ensureOrganizationMembership(user as IUser, org.uuid); | ||
| } | ||
| } | ||
|
|
||
| // 6. Login | ||
| const tokens = await this.authService.login( | ||
| user as IUser, | ||
| AuthProvider.GOOGLE, | ||
| { refreshToken: refresh_token }, | ||
| ); | ||
|
|
||
| return tokens; | ||
| } catch (error) { | ||
| this.logger.error({ | ||
| message: 'SSO Login failed', | ||
| error, | ||
| context: SSOLoginUseCase.name, | ||
| metadata: { | ||
| organizationId, | ||
| }, | ||
| }); | ||
| throw new UnauthorizedException('SSO Login failed'); | ||
| } | ||
| } | ||
|
|
||
| private async ensureOrganizationMembership(user: IUser, organizationId: string) { | ||
| const member = await this.teamMembersService.findOne({ | ||
| user: { uuid: user.uuid }, | ||
| organization: { uuid: organizationId }, | ||
| }); | ||
|
|
||
| if (!member) { | ||
| const organization = await this.organizationService.findOne({ | ||
| uuid: organizationId, | ||
| }); | ||
|
|
||
| const team = await this.teamService.findOne({ | ||
| organization: { uuid: organizationId }, | ||
| }); | ||
|
|
||
| if (organization && team) { | ||
| await this.teamMembersService.create({ | ||
| user, | ||
| name: user.email, | ||
| organization, | ||
| team, | ||
| teamRole: TeamMemberRole.MEMBER, | ||
| status: true, | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| export interface SSOConfig { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. export interface SSOConfig {The file name Kody Rule violation: Seguir padrão de nomenclatura de arquivos Talk to Kody by mentioning @kody Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction. |
||
| enabled: boolean; | ||
| issuer: string; | ||
| clientId: string; | ||
| clientSecret: string; | ||
| redirectUris: string[]; | ||
| domains: string[]; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The file
check-sso.use-case.tsis named using kebab-case, which violates the rule requiring camelCase for new files. Please rename the file to follow the camelCase convention (e.g.,checkSsoUseCase.ts).Kody Rule violation: Seguir padrão de nomenclatura de arquivos
Talk to Kody by mentioning @kody
Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.