Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.production
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
NODE_ENV=production
NX_API_URL=https://connect-api.redi-school.org/api
NEST_API_URL=https://connect-nestjs-api.redi-school.org/api
NX_S3_UPLOAD_SIGN_URL=https://connect-api.redi-school.org/s3/sign

NX_SENTRY_TRACES_SAMPLE_RATE=1.0
Expand Down
10 changes: 8 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
}
]
},
"editor.codeActionsOnSave": { "source.organizeImports": true },
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
},
"explorer.fileNesting.patterns": {
"*.ts": "${capture}.js, ${capture}.typegen.ts, ${capture}.graphql, ${capture}.generated.ts",
"*.js": "${capture}.js.map, ${capture}.min.js, ${capture}.d.ts",
Expand All @@ -23,5 +25,9 @@
"tsconfig.json": "tsconfig.*.json",
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml",
"*.graphql": "${capture}.generated.ts"
}
},
"cSpell.words": [
"entra",
"msal"
]
}
2 changes: 2 additions & 0 deletions apps/nestjs-api/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'
import { CacheModule, Module } from '@nestjs/common'
import { EventEmitterModule } from '@nestjs/event-emitter'
import { GraphQLModule } from '@nestjs/graphql'
import { EntraIdModule } from '../auth-entra-id/entra-id.module'
import { AuthModule } from '../auth/auth.module'
import { ConMenteeFavoritedMentorsModule } from '../con-mentee-favorited-mentors/con-mentee-favorited-mentors.module'
import { ConMentoringSessionsModule } from '../con-mentoring-sessions/con-mentoring-sessions.module'
Expand Down Expand Up @@ -41,6 +42,7 @@ import { AppService } from './app.service'
SfApiModule,
SalesforceRecordEventsListenerModule,
AuthModule,
EntraIdModule,
ConProfilesModule,
ConMentoringSessionsModule,
ConMentorshipMatchesModule,
Expand Down
39 changes: 39 additions & 0 deletions apps/nestjs-api/src/auth-entra-id/entra-id-config.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import msal, { LogLevel } from '@azure/msal-node'
import { Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { NEST_API_URL } from '@talent-connect/shared-config'
import { EntraIdLoginOptions } from './entra-id-login-options.interface'

@Injectable()
export class EntraIdConfigProvider {
readonly options: EntraIdLoginOptions
readonly msalConfig: msal.Configuration

constructor(configService: ConfigService) {
this.options = {
scopes: [],
redirectUri: `${NEST_API_URL}/api/auth/entra-redirect`, // to backend
successRedirect: configService.get<string>('NX_FRONTEND_URI') + '/front/login/entra-login', // to frontend
cloudInstance: configService.get<string>('NX_ENTRA_ID_CLOUD_INSTANCE'),
}
this.msalConfig = {
auth: {
clientId: configService.get<string>('NX_ENTRA_ID_CLIENT_ID'), // 'Application (client) ID' of app registration in Azure portal - this value is a GUID
authority: configService.get<string>('NX_ENTRA_ID_CLOUD_INSTANCE') + '/consumers', // Full directory URL, in the form of https://login.microsoftonline.com/<tenant>
clientSecret: configService.get<string>('NX_ENTRA_ID_CLIENT_SECRET'), // Client secret generated from the app registration in Azure portal
},
system: {
loggerOptions: {
loggerCallback(logLevel, message, containsPii) {
if (!containsPii) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Such a cool trick. I wonder if AWS has a similar feature.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I was really happy to see this too. 😊

if (logLevel === LogLevel.Error) console.error(message)
else console.log(message)
}
},
piiLoggingEnabled: false,
logLevel: LogLevel.Verbose,
},
},
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface EntraIdLoginOptions {
scopes: string[]
redirectUri: string
successRedirect: string
cloudInstance: string
}
85 changes: 85 additions & 0 deletions apps/nestjs-api/src/auth-entra-id/entra-id-login.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { AuthorizationUrlRequest, ResponseMode } from '@azure/msal-node'
import { Injectable, NestMiddleware } from '@nestjs/common'
import { NextFunction, Request, Response } from 'express'
import { EntraIdConfigProvider } from './entra-id-config.provider'
import { EntraIdService } from './entra-id.service'
import { VerificationData } from './verification-data.interface'

@Injectable()
export class EntraIdLoginMiddleware implements NestMiddleware {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work here!

I've never used NestJS middleware. Is there a reason we're using it instead of a simple controller?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seemed like the easiest way to protect this route and make it reusable for other routes.

private authorizationUrlRequestParams: AuthorizationUrlRequest

constructor(
private readonly idService: EntraIdService,
private readonly configProvider: EntraIdConfigProvider
) {
this.authorizationUrlRequestParams = this.prepareAuthCodeRequestParams()
}

use(_: Request, res: Response, next: NextFunction) {
this.redirectToAuthCodeUrl(res, next)
return null
}

private async redirectToAuthCodeUrl(res: Response, next: NextFunction) {
const { verifier, challenge } = await this.idService.generatePkceCodes()

this.storeVerificationCookie(verifier, res)

const clientApplication = await this.idService.getClientApplication()
clientApplication
.getAuthCodeUrl(this.prepareAuthCodeUrlRequest(challenge))
.then((url) => res.redirect(url))
.catch((err) => {
console.error(err)
next(err)
})
}

private storeVerificationCookie(verifier: string, res: Response) {
const verificationData = {
...this.authorizationUrlRequestParams,
code: '',
codeVerifier: verifier,
} as VerificationData

res.cookie(
this.idService.verifierCookieName,
this.idService.encodeObject(verificationData),
{ maxAge: 2 * 60 * 60, httpOnly: true }
)
}

private prepareAuthCodeUrlRequest(
challenge: string
): AuthorizationUrlRequest {
return {
...this.authorizationUrlRequestParams,
responseMode: ResponseMode.FORM_POST, // recommended for confidential clients
codeChallenge: challenge,
codeChallengeMethod: 'S256',
}
}

private prepareAuthCodeRequestParams(): AuthorizationUrlRequest {
/**
* MSAL Node library allows you to pass your custom state as state parameter in the Request object.
* The state parameter can also be used to encode information of the app's state before redirect.
* You can pass the user's state in the app, such as the page or view they were on, as input to this parameter.
*/
const state: string = this.idService.encodeObject({
successRedirect: this.configProvider.options.successRedirect || '/',
})

return {
state: state,
/**
* In future we could use this to set more specific auth scopes for different user types.
* By default, MSAL Node will add OIDC scopes to the auth code url request. For more information, visit:
* https://docs.microsoft.com/azure/active-directory/develop/v2-permissions-and-consent#openid-connect-scopes
*/
scopes: this.configProvider.options.scopes || [],
redirectUri: this.configProvider.options.redirectUri,
}
}
}
20 changes: 20 additions & 0 deletions apps/nestjs-api/src/auth-entra-id/entra-id.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Controller, Get, Next, Post, Req, Res } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import { EntraIdService } from './entra-id.service';

@Controller('auth')
export class EntraIdController {
constructor(private readonly entraIdService: EntraIdService) {}

// empty route to trigger the entra-id auth middleware
@Get('entra-id')
entraId() {
return ''
}

@Post('entra-redirect')
redirectPost(@Req() req: Request, @Res() res: Response, @Next() next: NextFunction) {
this.entraIdService.handleAuthRedirect(req, res, next)
return ''
}
}
20 changes: 20 additions & 0 deletions apps/nestjs-api/src/auth-entra-id/entra-id.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { HttpModule } from '@nestjs/axios'
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { EntraIdConfigProvider } from './entra-id-config.provider'
import { EntraIdLoginMiddleware } from './entra-id-login.middleware'
import { EntraIdController } from './entra-id.controller'
import { EntraIdService } from './entra-id.service'

@Module({
imports: [ConfigModule, HttpModule],
controllers: [EntraIdController],
providers: [EntraIdConfigProvider, EntraIdService, EntraIdLoginMiddleware],
})
export class EntraIdModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(EntraIdLoginMiddleware)
.forRoutes('auth/entra-id');
}
}
147 changes: 147 additions & 0 deletions apps/nestjs-api/src/auth-entra-id/entra-id.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import {
AuthenticationResult,
AuthorizationCodeRequest,
ClientApplication,
ConfidentialClientApplication,
Configuration,
CryptoProvider,
} from '@azure/msal-node'
import { HttpService } from '@nestjs/axios'
import { Injectable } from '@nestjs/common'
import { AxiosError, AxiosRequestConfig } from 'axios'
import { NextFunction, Request, Response } from 'express'
import { firstValueFrom } from 'rxjs'
import { catchError } from 'rxjs/operators'
import { EntraIdConfigProvider } from './entra-id-config.provider'
import { VerificationData } from './verification-data.interface'

@Injectable()
export class EntraIdService {
readonly verifierCookieName = 'entra_id_verifier'
private readonly cryptoProvider = new CryptoProvider()

constructor(
private readonly configProvider: EntraIdConfigProvider,
private readonly httpService: HttpService
) {}

async getClientApplication(): Promise<ClientApplication> {
const config = await this.prepareConfig()

return new ConfidentialClientApplication(config)
}

async handleAuthRedirect(req: Request, res: Response, next: NextFunction) {
const body = req.body
if (!body.state || !body.code) {
console.error('malformed request body from entra id')
throw 'could not log in'
}

if (!(this.verifierCookieName in req.cookies)) {
throw 'missing verification data'
}

try {
const tokenResponse = await this.verifyToken(req, res)
const decryptedState = this.decodeObject(body.state)

/**
* TODO - do legacy salesforce validation and add these values to token
* for now, we'll just return to the success redirect page
*/
res.redirect(this.configProvider.options.successRedirect)
} catch (error) {
next(error)
}
}

encodeObject(o: object): string {
return this.cryptoProvider.base64Encode(JSON.stringify(o))
}

decodeObject(s: string): object {
return JSON.parse(this.cryptoProvider.base64Decode(s))
}

async generatePkceCodes() {
return await this.cryptoProvider.generatePkceCodes()
}

private async verifyToken(req: Request, res: Response): Promise<AuthenticationResult> {
const verificationData = this.decodeObject(
req.cookies[this.verifierCookieName]
) as VerificationData

res.clearCookie(this.verifierCookieName)

const authCodeRequest: AuthorizationCodeRequest = {
scopes: verificationData.scopes,
redirectUri: verificationData.redirectUri,
state: verificationData.state,
codeVerifier: verificationData.codeVerifier,
code: req.body.code,
}

const clientApplication = await this.getClientApplication()

// throws error if verification is false
return clientApplication.acquireTokenByCode(authCodeRequest, req.body)
}

private async prepareConfig(): Promise<Configuration> {
const config = this.configProvider.msalConfig

/**
* If the current msal configuration does not have cloudDiscoveryMetadata or authorityMetadata, we will
* make a request to the relevant endpoints to retrieve the metadata. This allows MSAL to avoid making
* metadata discovery calls, thereby improving performance of token acquisition process. For more, see:
* https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-node/docs/performance.md
*/
if (!config.auth.cloudDiscoveryMetadata || !config.auth.authorityMetadata) {
const [cloudDiscoveryMetadata, authorityMetadata] = await Promise.all([
this.getCloudDiscoveryMetadata(),
this.getAuthorityMetadata(),
])

config.auth.cloudDiscoveryMetadata = JSON.stringify(
cloudDiscoveryMetadata
)
config.auth.authorityMetadata = JSON.stringify(authorityMetadata)
}

return config
}

// Retrieves cloud discovery metadata from the /discovery/instance endpoint
private async getCloudDiscoveryMetadata() {
const endpoint = `${this.configProvider.options.cloudInstance}/common/discovery/instance`

return this.queryEndpoint(endpoint, {
params: {
'api-version': '1.1',
authorization_endpoint: `${this.configProvider.msalConfig.auth.authority}/oauth2/v2.0/authorize`,
},
})
}

// Retrieves oidc metadata from the openid endpoint
private async getAuthorityMetadata() {
const endpoint = `${this.configProvider.msalConfig.auth.authority}/v2.0/.well-known/openid-configuration`

return this.queryEndpoint(endpoint)
}

private async queryEndpoint(endpoint: string, params?: AxiosRequestConfig) {
const { data } = await firstValueFrom(
this.httpService.get(endpoint, params).pipe(
catchError((error: AxiosError) => {
console.error(error.response.data)
throw 'Could not setup login service'
})
)
)

return data
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface VerificationData {
state: string
scopes: string[]
redirectUri: string
code: string
codeVerifier: string
}
4 changes: 4 additions & 0 deletions apps/nestjs-api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ import { Logger } from '@nestjs/common'
import { NestFactory } from '@nestjs/core'
import { json, urlencoded } from 'express'
import { AppModule } from './app/app.module'
import cookieParser from 'cookie-parser'

async function bootstrap() {
const app = await NestFactory.create(AppModule)
if (process.env.NODE_ENV !== 'production') app.enableCors()

const globalPrefix = 'api'
app.setGlobalPrefix(globalPrefix)
app.use(json({ limit: '1mb' }))
app.use(urlencoded({ extended: true, limit: '1mb' }))
app.use(cookieParser())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

// TODO! Re-enable this? If we can set to a higher debug log level
// app.useLogger(SentryService.SentryServiceInstance())
const port = process.env.PORT || 3333
Expand Down
Loading