-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
8935894
commit 3afd227
Showing
17 changed files
with
1,091 additions
and
504 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,7 @@ | ||
MONGODB_URI=$MONGODB_URI | ||
ENVIRONMENT_NAME=$ENVIRONMENT_NAME | ||
SENTRY_KEY=$SENTRY_KEY# Prod | ||
AADB2C_TENANT_NAME=$AADB2C_TENANT_NAME# Prod | ||
AADB2C_SIGN_IN_POLICY=$AADB2C_SIGN_IN_POLICY# Prod | ||
AADB2C_CLIENT_ID=$AADB2C_CLIENT_ID# Prod | ||
AADB2C_ISSUER=$AADB2C_ISSUER# Prod |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
136 changes: 136 additions & 0 deletions
136
libs/api/auth/src/lib/auth-strategies/verify-aadb2c-jwt.strategy.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,136 @@ | ||
import { createMock } from '@golevelup/ts-jest'; | ||
import { ConfigService } from '@nestjs/config'; | ||
import { Test, TestingModule } from '@nestjs/testing'; | ||
import jwt from 'jsonwebtoken'; | ||
|
||
import { KordisRequest } from '@kordis/api/shared'; | ||
|
||
import { VerifyAADB2CJWTStrategy } from './verify-aadb2c-jwt.strategy'; | ||
|
||
jest.mock('jwks-rsa', () => () => { | ||
return { | ||
getKeys: jest.fn(), | ||
getSigningKey: jest.fn().mockResolvedValue({ | ||
kid: 'kid', | ||
alg: 'alg', | ||
getPublicKey: jest.fn().mockReturnValue('publicKey'), | ||
rsaPublicKey: 'publicKey', | ||
}), | ||
getSigningKeys: jest.fn(), | ||
}; | ||
}); | ||
|
||
describe('VerifyAADB2CJWTStrategy', () => { | ||
let verifyAADB2CJWTStrategy: VerifyAADB2CJWTStrategy; | ||
|
||
beforeEach(async () => { | ||
jest.clearAllMocks(); | ||
|
||
const module: TestingModule = await Test.createTestingModule({ | ||
providers: [ | ||
{ | ||
provide: ConfigService, | ||
useValue: createMock<ConfigService>({ | ||
getOrThrow: jest | ||
.fn() | ||
.mockReturnValue('tenant') | ||
.mockReturnValue('policy') | ||
.mockReturnValue('clientId') | ||
.mockReturnValue('issuer'), | ||
}), | ||
}, | ||
VerifyAADB2CJWTStrategy, | ||
], | ||
}).compile(); | ||
|
||
verifyAADB2CJWTStrategy = module.get<VerifyAADB2CJWTStrategy>( | ||
VerifyAADB2CJWTStrategy, | ||
); | ||
}); | ||
|
||
it('should verify and return user on valid JWT', async () => { | ||
const req = createMock<Omit<KordisRequest, 'user'>>({ | ||
headers: { | ||
authorization: 'Bearer 123', | ||
}, | ||
}); | ||
|
||
jest.spyOn(jwt, 'decode').mockImplementationOnce( | ||
() => | ||
({ | ||
header: { kid: 'mockKid' }, | ||
payload: { | ||
sub: 'id', | ||
given_name: 'foo', | ||
family_name: 'bar', | ||
emails: ['[email protected]'], | ||
organization: 'testorg', | ||
}, | ||
}) as any, | ||
); | ||
|
||
const verifySpy = jest.spyOn(jwt, 'verify').mockReturnValueOnce(undefined); | ||
await expect( | ||
verifyAADB2CJWTStrategy.verifyUserFromRequest(req), | ||
).resolves.toEqual({ | ||
id: 'id', | ||
email: '[email protected]', | ||
firstName: 'foo', | ||
lastName: 'bar', | ||
organization: 'testorg', | ||
}); | ||
|
||
expect(verifySpy).toHaveBeenCalledWith( | ||
'123', | ||
'publicKey', | ||
expect.anything(), | ||
); | ||
}); | ||
|
||
it('should return null on empty authorization header', async () => { | ||
const req = createMock<Omit<KordisRequest, 'user'>>({ | ||
headers: {}, | ||
}); | ||
|
||
await expect( | ||
verifyAADB2CJWTStrategy.verifyUserFromRequest(req), | ||
).resolves.toBeNull(); | ||
}); | ||
|
||
it('should return null on invalid JWT', async () => { | ||
const req = createMock<Omit<KordisRequest, 'user'>>({ | ||
headers: { | ||
authorization: 'Bearer 123', | ||
}, | ||
}); | ||
|
||
jest.spyOn(jwt, 'decode').mockImplementationOnce( | ||
() => | ||
({ | ||
header: { kid: 'mockKid' }, | ||
}) as any, | ||
); | ||
|
||
jest.spyOn(jwt, 'verify').mockImplementationOnce(() => { | ||
throw new Error('Invalid JWT'); | ||
}); | ||
|
||
await expect( | ||
verifyAADB2CJWTStrategy.verifyUserFromRequest(req), | ||
).resolves.toBeNull(); | ||
}); | ||
|
||
it('should return null on jwt decode fail', async () => { | ||
const req = createMock<Omit<KordisRequest, 'user'>>({ | ||
headers: { | ||
authorization: 'Bearer 123', | ||
}, | ||
}); | ||
|
||
jest.spyOn(jwt, 'decode').mockImplementationOnce(() => null); | ||
|
||
await expect( | ||
verifyAADB2CJWTStrategy.verifyUserFromRequest(req), | ||
).resolves.toBeNull(); | ||
}); | ||
}); |
91 changes: 91 additions & 0 deletions
91
libs/api/auth/src/lib/auth-strategies/verify-aadb2c-jwt.strategy.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
import { Injectable, Logger } from '@nestjs/common'; | ||
import { ConfigService } from '@nestjs/config'; | ||
import { Request } from 'express'; | ||
import * as jwt from 'jsonwebtoken'; | ||
import jwksClient from 'jwks-rsa'; | ||
|
||
import { KordisLogger } from '@kordis/api/observability'; | ||
import { AuthUser } from '@kordis/shared/auth'; | ||
|
||
import { VerifyAuthUserStrategy } from './verify-auth-user.strategy'; | ||
|
||
declare module 'jsonwebtoken' { | ||
export interface JwtPayload { | ||
sub?: string; | ||
oid: string; | ||
emails: string[]; | ||
given_name: string; | ||
family_name: string; | ||
organization: string; | ||
} | ||
} | ||
|
||
@Injectable() | ||
export class VerifyAADB2CJWTStrategy extends VerifyAuthUserStrategy { | ||
private readonly client: jwksClient.JwksClient; | ||
private readonly verifyOptions: jwt.VerifyOptions; | ||
private readonly logger: KordisLogger = new Logger( | ||
VerifyAADB2CJWTStrategy.name, | ||
); | ||
|
||
constructor(config: ConfigService) { | ||
super(); | ||
|
||
const tenant = config.getOrThrow<string>('AADB2C_TENANT_NAME'); | ||
const signInPolicy = config.getOrThrow<string>('AADB2C_SIGN_IN_POLICY'); | ||
const clientId = config.getOrThrow<string>('AADB2C_CLIENT_ID'); | ||
const issuer = config.getOrThrow<string>('AADB2C_ISSUER'); | ||
|
||
this.verifyOptions = { | ||
algorithms: ['RS256'], | ||
audience: clientId, | ||
issuer, | ||
}; | ||
this.client = jwksClient({ | ||
jwksUri: `https://${tenant}.b2clogin.com/${tenant}.onmicrosoft.com/${signInPolicy}/discovery/v2.0/keys`, | ||
}); | ||
} | ||
|
||
async verifyUserFromRequest(req: Request): Promise<AuthUser | null> { | ||
const authHeaderValue = req.headers['authorization']; | ||
|
||
if (!authHeaderValue) { | ||
return null; | ||
} | ||
|
||
let decodedToken: jwt.Jwt; | ||
try { | ||
const bearerToken = authHeaderValue.split(' ')[1]; | ||
const possibleDecodedToken = jwt.decode(bearerToken, { | ||
complete: true, | ||
}); | ||
if (!possibleDecodedToken) { | ||
return null; | ||
} | ||
decodedToken = possibleDecodedToken; | ||
|
||
const key = await this.client.getSigningKey(decodedToken.header.kid); | ||
const publicKey = key.getPublicKey(); | ||
|
||
jwt.verify(bearerToken, publicKey, this.verifyOptions); | ||
} catch (error) { | ||
this.logger.warn('Failed to decode or verify bearer token', { | ||
error, | ||
}); | ||
return null; | ||
} | ||
|
||
const { payload } = decodedToken; | ||
if (typeof payload === 'string') { | ||
return null; | ||
} | ||
|
||
return { | ||
id: payload['sub'] ?? payload['oid'], | ||
email: payload['emails'][0], | ||
firstName: payload['given_name'], | ||
lastName: payload['family_name'], | ||
organization: payload['organization'], | ||
}; | ||
} | ||
} |
10 changes: 10 additions & 0 deletions
10
libs/api/auth/src/lib/auth-strategies/verify-auth-user.strategy.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
import { Request } from 'express'; | ||
|
||
import { AuthUser } from '@kordis/shared/auth'; | ||
|
||
export abstract class VerifyAuthUserStrategy { | ||
/* | ||
* Returns the AuthUser if the user is authenticated, otherwise null. | ||
*/ | ||
abstract verifyUserFromRequest(req: Request): Promise<AuthUser | null>; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,36 +2,33 @@ import { createMock } from '@golevelup/ts-jest'; | |
|
||
import { KordisRequest } from '@kordis/api/shared'; | ||
|
||
import { | ||
AuthUserExtractorStrategy, | ||
ExtractUserFromMsPrincipleHeader, | ||
} from './auth-user-extractor.strategy'; | ||
import { VerifyAuthUserStrategy } from './verify-auth-user.strategy'; | ||
import { VerifyDevBearerStrategy } from './verify-dev-bearer.strategy'; | ||
|
||
describe('ExtractUserFromMsPrincipleHeader', () => { | ||
let extractStrat: AuthUserExtractorStrategy; | ||
describe('VerifyDevBearerStrategy', () => { | ||
let extractStrat: VerifyAuthUserStrategy; | ||
|
||
beforeEach(() => { | ||
extractStrat = new ExtractUserFromMsPrincipleHeader(); | ||
extractStrat = new VerifyDevBearerStrategy(); | ||
}); | ||
|
||
it('should return null if the authorization header is not present', () => { | ||
it('should return null if the authorization header is not present', async () => { | ||
const req = createMock<Omit<KordisRequest, 'user'>>({ | ||
headers: {}, | ||
}); | ||
const result = extractStrat.getUserFromRequest(req); | ||
|
||
expect(result).toBeNull(); | ||
await expect(extractStrat.verifyUserFromRequest(req)).resolves.toBeNull(); | ||
}); | ||
|
||
it('should extract user correctly from signed access token', () => { | ||
it('should extract user correctly from signed access token', async () => { | ||
const headerValue = | ||
'Bearer eyJhbGciOiJIUzI1NiJ9.eyJvaWQiOiJjMGNjNDQwNC03OTA3LTQ0ODAtODZkMy1iYTRiZmM1MTNjNmQiLCJzdWIiOiJjMGNjNDQwNC03OTA3LTQ0ODAtODZkMy1iYTRiZmM1MTNjNmQiLCJnaXZlbl9uYW1lIjoiVGVzdCIsImZhbWlseV9uYW1lIjoiVXNlciIsImVtYWlscyI6WyJ0ZXN0QHRpbW9ubWFzYmVyZy5jb20iXX0.9FXjgT037QkeE0KptQo3MzMriuXGzqCNfBDVEkWbJaA'; | ||
|
||
const req = createMock<Omit<KordisRequest, 'user'>>({ | ||
headers: { authorization: headerValue }, | ||
}); | ||
|
||
expect(extractStrat.getUserFromRequest(req)).toEqual({ | ||
await expect(extractStrat.verifyUserFromRequest(req)).resolves.toEqual({ | ||
id: 'c0cc4404-7907-4480-86d3-ba4bfc513c6d', | ||
email: '[email protected]', | ||
firstName: 'Test', | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.