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(api): jwt verification #339

Merged
merged 12 commits into from
Feb 13, 2024
Merged
Show file tree
Hide file tree
Changes from 10 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
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
10 changes: 5 additions & 5 deletions apps/api/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
"path": "./tsconfig.app.json",
},
{
"path": "./tsconfig.spec.json"
}
"path": "./tsconfig.spec.json",
},
],
"compilerOptions": {
"esModuleInterop": true,
Expand All @@ -18,6 +18,6 @@
"noPropertyAccessFromIndexSignature": false,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
"noFallthroughCasesInSwitch": true,
},
}
6 changes: 3 additions & 3 deletions apps/spa-e2e/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"include": [],
"references": [
{
"path": "./tsconfig.e2e.json"
}
]
"path": "./tsconfig.e2e.json",
},
],
}
14 changes: 7 additions & 7 deletions apps/spa/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,26 @@
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
"path": "./tsconfig.app.json",
},
{
"path": "./tsconfig.spec.json"
"path": "./tsconfig.spec.json",
},
{
"path": "./tsconfig.editor.json"
}
"path": "./tsconfig.editor.json",
},
],
"extends": "../../tsconfig.base.json",
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
"strictTemplates": true,
},
}
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,82 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import * as jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';

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;

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;
}
const bearerToken = authHeaderValue.split(' ')[1];
JSPRH marked this conversation as resolved.
Show resolved Hide resolved

JSPRH marked this conversation as resolved.
Show resolved Hide resolved
const decodedToken = jwt.decode(bearerToken, {
complete: true,
});
if (!decodedToken) {
return null;
}

const key = await this.client.getSigningKey(decodedToken.header.kid);
JSPRH marked this conversation as resolved.
Show resolved Hide resolved
const publicKey = key.getPublicKey();

try {
jwt.verify(bearerToken, publicKey, this.verifyOptions);
} catch {
return null;
}

timonmasberg marked this conversation as resolved.
Show resolved Hide resolved
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
Loading