Skip to content
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

feat: add aurora entra id login #839

Open
wants to merge 17 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 12 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
38 changes: 38 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,38 @@
import msal, { LogLevel } from '@azure/msal-node'
import { Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/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: configService.get<string>('NX_NESTJS_API_URI') + '/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');
}
}
149 changes: 149 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,149 @@
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 Promise((resolve) => {
ericbolikowski marked this conversation as resolved.
Show resolved Hide resolved
return resolve(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
}
18 changes: 18 additions & 0 deletions apps/nestjs-api/src/entra-id/entra-id.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { EntraIdService } from './entra-id.service';

describe('EntraIdService', () => {
let service: EntraIdService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [EntraIdService],
}).compile();

service = module.get<EntraIdService>(EntraIdService);
});

it('should be defined', () => {
expect(service).toBeDefined();
});
});
Loading
Loading