-
Notifications
You must be signed in to change notification settings - Fork 8
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
base: master
Are you sure you want to change the base?
Changes from 12 commits
b7b999e
d6efe52
52d076c
1fb6b56
b0561b5
53c938a
bf49abd
7946050
95cc31b
88b2e84
fac1f34
5d70aa8
ae2a5bc
d71a5d4
3e25cc6
eae75ee
c485208
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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) { | ||
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 | ||
} |
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 { | ||
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. Nice work here! I've never used NestJS middleware. Is there a reason we're using it instead of a simple controller? 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. 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, | ||
} | ||
} | ||
} |
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 '' | ||
} | ||
} |
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'); | ||
} | ||
} |
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 | ||
} |
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(); | ||
}); | ||
}); |
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.
Such a cool trick. I wonder if AWS has a similar feature.
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.
Yeah, I was really happy to see this too. 😊