From 625afa4b8bb0bcf5e6649196d35fc4a61249e10f Mon Sep 17 00:00:00 2001 From: Kristaps Fabians Geikins Date: Thu, 19 Sep 2024 12:06:47 +0300 Subject: [PATCH] chore(server): auth IoC 16 - azureAdStrategyBuilderFactory (#3038) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(server): auth IoC 3 - getAllAppsCreatedByUserFactory * minor fix * chore(server): auth IoC 4 - getAllAppsAuthorizedByUserFactory * chore(server): auth IoC 5 - createAppFactory * chore(server): auth IoC 6 - updateAppFactory * chore(server): auth IoC 7 - deleteAppFactory * chore(server): auth IoC 8 - revokeExistingAppCredentialsForUserFactory * chore(server): auth IoC 9 - revokeRefreshTokenFactory * chore(server): auth IoC 10 - createAuthorizationCodeFactory * chore(server): auth IoC 11 - createAppTokenFromAccessCodeFactory * chore(server): auth IoC 12 - refreshAppTokenFactory * chore(server): auth IoC 13 - index repo * chore(server): auth IoC 14 - localStrategyBuilderFactory * chore(server): auth IoC 15 - oidcStrategyBuilderFactory * chore(server): auth IoC 16 - azureAdStrategyBuilderFactory --------- Co-authored-by: Gergő Jedlicska --- packages/server/modules/auth/strategies.ts | 13 +- .../server/modules/auth/strategies/azureAd.ts | 334 +++++++++--------- 2 files changed, 178 insertions(+), 169 deletions(-) diff --git a/packages/server/modules/auth/strategies.ts b/packages/server/modules/auth/strategies.ts index da564b3e9..4112793fc 100644 --- a/packages/server/modules/auth/strategies.ts +++ b/packages/server/modules/auth/strategies.ts @@ -203,8 +203,17 @@ const setupStrategies = async (app: Express) => { } if (process.env.STRATEGY_AZURE_AD === 'true') { - const azureAdStrategyBuilder = (await import('@/modules/auth/strategies/azureAd')) - .default + const azureAdStrategyBuilderFactory = ( + await import('@/modules/auth/strategies/azureAd') + ).default + const azureAdStrategyBuilder = azureAdStrategyBuilderFactory({ + getServerInfo, + getUserByEmail, + findOrCreateUser, + validateServerInvite, + finalizeInvitedServerRegistration, + resolveAuthRedirectPath + }) const azureAdStrategy = await azureAdStrategyBuilder( app, sessionMiddleware, diff --git a/packages/server/modules/auth/strategies/azureAd.ts b/packages/server/modules/auth/strategies/azureAd.ts index 7a5c9f396..72862d1fd 100644 --- a/packages/server/modules/auth/strategies/azureAd.ts +++ b/packages/server/modules/auth/strategies/azureAd.ts @@ -3,22 +3,13 @@ import passport from 'passport' import { OIDCStrategy, IProfile, VerifyCallback } from 'passport-azure-ad' import { findOrCreateUser, getUserByEmail } from '@/modules/core/services/users' import { getServerInfo } from '@/modules/core/services/generic' -import { - validateServerInviteFactory, - finalizeInvitedServerRegistrationFactory, - resolveAuthRedirectPathFactory -} from '@/modules/serverinvites/services/processing' + import { passportAuthenticate } from '@/modules/auth/services/passportService' import { UserInputError, UnverifiedEmailSSOLoginError } from '@/modules/core/errors/userinput' -import db from '@/db/knex' -import { - deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory, - findServerInviteFactory -} from '@/modules/serverinvites/repositories/serverInvites' + import { ServerInviteResourceType } from '@/modules/serverinvites/domain/constants' import { getResourceTypeRole } from '@/modules/serverinvites/helpers/core' import { AuthStrategyBuilder } from '@/modules/auth/helpers/types' @@ -32,181 +23,190 @@ import { import type { Request } from 'express' import { ensureError, Optional } from '@speckle/shared' import { ServerInviteRecord } from '@/modules/serverinvites/domain/types' +import { + FinalizeInvitedServerRegistration, + ResolveAuthRedirectPath, + ValidateServerInvite +} from '@/modules/serverinvites/services/operations' -const azureAdStrategyBuilder: AuthStrategyBuilder = async ( - app, - sessionMiddleware, - moveAuthParamsToSessionMiddleware, - finalizeAuthMiddleware -) => { - const strategy = new OIDCStrategy( - { - identityMetadata: getAzureAdIdentityMetadata(), - clientID: getAzureAdClientId(), - responseType: 'code id_token', - responseMode: 'form_post', - issuer: getAzureAdIssuer(), - redirectUrl: new URL('/auth/azure/callback', getServerOrigin()).toString(), - allowHttpForRedirectUrl: true, - clientSecret: getAzureAdClientSecret(), - scope: ['profile', 'email', 'openid'], - loggingLevel: process.env.NODE_ENV === 'development' ? 'info' : 'error', - passReqToCallback: true - }, - // Dunno why TS isn't picking up on the types automatically - async ( - _req: Request, - _iss: string, - _sub: string, - profile: IProfile, - _accessToken: string, - _refreshToken: string, - done: VerifyCallback - ) => { - done(null, profile) - } - ) - - passport.use(strategy) - - // 1. Auth init - app.get( - '/auth/azure', +const azureAdStrategyBuilderFactory = + (deps: { + getServerInfo: typeof getServerInfo + getUserByEmail: typeof getUserByEmail + findOrCreateUser: typeof findOrCreateUser + validateServerInvite: ValidateServerInvite + finalizeInvitedServerRegistration: FinalizeInvitedServerRegistration + resolveAuthRedirectPath: ResolveAuthRedirectPath + }): AuthStrategyBuilder => + async ( + app, sessionMiddleware, moveAuthParamsToSessionMiddleware, - passportAuthenticate('azuread-openidconnect') - ) + finalizeAuthMiddleware + ) => { + const strategy = new OIDCStrategy( + { + identityMetadata: getAzureAdIdentityMetadata(), + clientID: getAzureAdClientId(), + responseType: 'code id_token', + responseMode: 'form_post', + issuer: getAzureAdIssuer(), + redirectUrl: new URL('/auth/azure/callback', getServerOrigin()).toString(), + allowHttpForRedirectUrl: true, + clientSecret: getAzureAdClientSecret(), + scope: ['profile', 'email', 'openid'], + loggingLevel: process.env.NODE_ENV === 'development' ? 'info' : 'error', + passReqToCallback: true + }, + // Dunno why TS isn't picking up on the types automatically + async ( + _req: Request, + _iss: string, + _sub: string, + profile: IProfile, + _accessToken: string, + _refreshToken: string, + done: VerifyCallback + ) => { + done(null, profile) + } + ) - // 2. Auth finish callback - app.post( - '/auth/azure/callback', - sessionMiddleware, - passportAuthenticate('azuread-openidconnect'), - async (req, _res, next) => { - const serverInfo = await getServerInfo() - let logger = req.log.child({ - authStrategy: 'entraId', - serverVersion: serverInfo.version - }) + passport.use(strategy) - try { - // This is the only strategy that does its own type for req.user - easier to force type cast for now - // than to refactor everything - const profile = req.user as Optional - if (!profile) { - throw new Error('No profile provided by Entra ID') - } + // 1. Auth init + app.get( + '/auth/azure', + sessionMiddleware, + moveAuthParamsToSessionMiddleware, + passportAuthenticate('azuread-openidconnect') + ) - logger = logger.child({ profileId: profile.oid }) + // 2. Auth finish callback + app.post( + '/auth/azure/callback', + sessionMiddleware, + passportAuthenticate('azuread-openidconnect'), + async (req, _res, next) => { + const serverInfo = await deps.getServerInfo() + let logger = req.log.child({ + authStrategy: 'entraId', + serverVersion: serverInfo.version + }) - const user = { - email: profile._json.email, - name: profile._json.name || profile.displayName - } + try { + // This is the only strategy that does its own type for req.user - easier to force type cast for now + // than to refactor everything + const profile = req.user as Optional + if (!profile) { + throw new Error('No profile provided by Entra ID') + } - const existingUser = await getUserByEmail({ email: user.email }) + logger = logger.child({ profileId: profile.oid }) - if (existingUser && !existingUser.verified) { - throw new UnverifiedEmailSSOLoginError(undefined, { - info: { - email: user.email + const user = { + email: profile._json.email, + name: profile._json.name || profile.displayName + } + + const existingUser = await deps.getUserByEmail({ email: user.email }) + + if (existingUser && !existingUser.verified) { + throw new UnverifiedEmailSSOLoginError(undefined, { + info: { + email: user.email + } + }) + } + + // if there is an existing user, go ahead and log them in (regardless of + // whether the server is invite only or not). + if (existingUser) { + const myUser = await deps.findOrCreateUser({ + user + }) + // ID is used later for verifying access token + req.user = { + ...profile, + id: myUser.id, + email: myUser.email + } + return next() + } + + // if the server is invite only and we have no invite id, throw. + if (serverInfo.inviteOnly && !req.session.token) { + throw new UserInputError( + 'This server is invite only. Please authenticate yourself through a valid invite link.' + ) + } + + // 2. if you have an invite it must be valid, both for invite only and public servers + let invite: Optional = undefined + if (req.session.token) { + invite = await deps.validateServerInvite(user.email, req.session.token) + } + + // create the user + const myUser = await deps.findOrCreateUser({ + user: { + ...user, + role: invite + ? getResourceTypeRole(invite.resource, ServerInviteResourceType) + : undefined, + verified: !!invite } }) - } - // if there is an existing user, go ahead and log them in (regardless of - // whether the server is invite only or not). - if (existingUser) { - const myUser = await findOrCreateUser({ - user - }) // ID is used later for verifying access token req.user = { ...profile, id: myUser.id, - email: myUser.email + email: myUser.email, + isNewUser: myUser.isNewUser, + isInvite: !!invite + } + + req.log = req.log.child({ userId: myUser.id }) + + // use the invite + await deps.finalizeInvitedServerRegistration(user.email, myUser.id) + + // Resolve redirect path + req.authRedirectPath = deps.resolveAuthRedirectPath(invite) + + // return to the auth flow + return next() + } catch (err) { + const e = ensureError( + err, + 'Unexpected issue occured while authenticating with Entra ID' + ) + + switch (e.constructor) { + case UserInputError: + logger.info( + { e }, + 'User input error during Entra ID authentication callback.' + ) + break + default: + logger.error(e, 'Error during Entra ID authentication callback.') } return next() } + }, + finalizeAuthMiddleware + ) - // if the server is invite only and we have no invite id, throw. - if (serverInfo.inviteOnly && !req.session.token) { - throw new UserInputError( - 'This server is invite only. Please authenticate yourself through a valid invite link.' - ) - } - - // 2. if you have an invite it must be valid, both for invite only and public servers - let invite: Optional = undefined - if (req.session.token) { - invite = await validateServerInviteFactory({ - findServerInvite: findServerInviteFactory({ db }) - })(user.email, req.session.token) - } - - // create the user - const myUser = await findOrCreateUser({ - user: { - ...user, - role: invite - ? getResourceTypeRole(invite.resource, ServerInviteResourceType) - : undefined, - verified: !!invite - } - }) - - // ID is used later for verifying access token - req.user = { - ...profile, - id: myUser.id, - email: myUser.email, - isNewUser: myUser.isNewUser, - isInvite: !!invite - } - - req.log = req.log.child({ userId: myUser.id }) - - // use the invite - await finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - })(user.email, myUser.id) - - // Resolve redirect path - req.authRedirectPath = resolveAuthRedirectPathFactory()(invite) - - // return to the auth flow - return next() - } catch (err) { - const e = ensureError( - err, - 'Unexpected issue occured while authenticating with Entra ID' - ) - - switch (e.constructor) { - case UserInputError: - logger.info( - { e }, - 'User input error during Entra ID authentication callback.' - ) - break - default: - logger.error(e, 'Error during Entra ID authentication callback.') - } - return next() - } - }, - finalizeAuthMiddleware - ) - - return { - id: 'azuread', - name: process.env.AZURE_AD_ORG_NAME || 'Microsoft', - icon: 'mdi-microsoft', - color: 'blue darken-3', - url: '/auth/azure', - callbackUrl: new URL('/auth/azure/callback', getServerOrigin()).toString() + return { + id: 'azuread', + name: process.env.AZURE_AD_ORG_NAME || 'Microsoft', + icon: 'mdi-microsoft', + color: 'blue darken-3', + url: '/auth/azure', + callbackUrl: new URL('/auth/azure/callback', getServerOrigin()).toString() + } } -} -export = azureAdStrategyBuilder +export = azureAdStrategyBuilderFactory