Skip to content

Commit

Permalink
feat(api): jwt verification (#339)
Browse files Browse the repository at this point in the history
  • Loading branch information
timonmasberg authored Feb 13, 2024
1 parent 8935894 commit 3afd227
Show file tree
Hide file tree
Showing 17 changed files with 1,091 additions and 504 deletions.
4 changes: 4 additions & 0 deletions apps/api/.env.template
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
15 changes: 7 additions & 8 deletions apps/api/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,7 @@ import { MongooseModule } from '@nestjs/mongoose';
import * as path from 'path';

import { AuthModule } from '@kordis/api/auth';
import {
DevObservabilityModule,
SentryObservabilityModule,
} from '@kordis/api/observability';
import { ObservabilityModule } from '@kordis/api/observability';
import { OrganizationModule } from '@kordis/api/organization';
import { SharedKernel, errorFormatterFactory } from '@kordis/api/shared';

Expand All @@ -21,13 +18,15 @@ import { GraphqlSubscriptionsController } from './controllers/graphql-subscripti
import { HealthCheckController } from './controllers/health-check.controller';
import environment from './environment';

const isNextOrProdEnv = ['next', 'prod'].includes(
process.env.ENVIRONMENT_NAME ?? '',
);

const FEATURE_MODULES = [OrganizationModule];
const UTILITY_MODULES = [
SharedKernel,
AuthModule,
...(process.env.NODE_ENV === 'production' && !process.env.GITHUB_ACTIONS
? [SentryObservabilityModule]
: [DevObservabilityModule]),
AuthModule.forRoot(isNextOrProdEnv ? 'aadb2c' : 'dev'),
ObservabilityModule.forRoot(isNextOrProdEnv ? 'sentry' : 'dev'),
];

@Module({
Expand Down
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();
});
});
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 libs/api/auth/src/lib/auth-strategies/verify-auth-user.strategy.ts
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>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,14 @@ import { Request } from 'express';

import { AuthUser } from '@kordis/shared/auth';

export abstract class AuthUserExtractorStrategy {
abstract getUserFromRequest(req: Request): AuthUser | null;
}
import { VerifyAuthUserStrategy } from './verify-auth-user.strategy';

export class ExtractUserFromMsPrincipleHeader extends AuthUserExtractorStrategy {
getUserFromRequest(req: Request): AuthUser | null {
export class VerifyDevBearerStrategy extends VerifyAuthUserStrategy {
verifyUserFromRequest(req: Request): Promise<AuthUser | null> {
const headerValue = req.headers['authorization'];

if (!headerValue) {
return null;
return Promise.resolve(null);
}
const payloadBuffer = Buffer.from(headerValue.split('.')[1], 'base64');
const decodedToken = JSON.parse(payloadBuffer.toString()) as {
Expand All @@ -23,12 +21,12 @@ export class ExtractUserFromMsPrincipleHeader extends AuthUserExtractorStrategy
organization: string;
};

return {
id: decodedToken['oid'] || decodedToken['sub'],
return Promise.resolve({
id: decodedToken['oid'] ?? decodedToken['sub'],
email: decodedToken['emails'][0],
firstName: decodedToken['given_name'],
lastName: decodedToken['family_name'],
organization: decodedToken['organization'],
};
});
}
}
Loading

0 comments on commit 3afd227

Please sign in to comment.