Skip to content

Commit

Permalink
Merge pull request #172 from mdrxtech/refrest-token
Browse files Browse the repository at this point in the history
implement grant_type client_credentials and refresh_token to tokens e…
  • Loading branch information
whitfield-mj authored Jun 9, 2023
2 parents 243e672 + 4c5339b commit 1319554
Show file tree
Hide file tree
Showing 8 changed files with 151 additions and 32 deletions.
13 changes: 7 additions & 6 deletions src/server/api/api.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { ThrottlerModule } from '@nestjs/throttler';
import { FULL_TOPIC_PATH } from '../../../test/mocks/topics';
import * as R from 'ramda';
import { OrpSearchMapper } from '../search/utils/orp-search-mapper';
import ApiTokenRequestDto from './entities/api-token-request.dto';

describe('ApiController', () => {
let controller: ApiController;
Expand Down Expand Up @@ -179,12 +180,13 @@ describe('ApiController', () => {
});

describe('login', () => {
it('should return response from apiAuthService', async () => {
it('should return response from apiAuthService for grant_type client_credentials', async () => {
const getTokensMock = jest
.spyOn(apiAuthService, 'authenticateApiClient')
.mockResolvedValue(mockTokens);

const creds = {
grant_type: 'client_credentials' as ApiTokenRequestDto['grant_type'],
client_id: 'clid',
client_secret: 'cls',
};
Expand All @@ -196,16 +198,15 @@ describe('ApiController', () => {
});
expect(result).toEqual(mockTokens);
});
});

describe('refreshToken', () => {
it('should return response from apiAuthService', async () => {
it('should return response from apiAuthService for grant_type refresh_token', async () => {
const refreshTokensMock = jest
.spyOn(apiAuthService, 'refreshApiUser')
.mockResolvedValue(mockTokens);

const result = await controller.refreshToken({
token: mockTokens.RefreshToken,
const result = await controller.login({
grant_type: 'refresh_token' as ApiTokenRequestDto['grant_type'],
refresh_token: mockTokens.RefreshToken,
});

expect(refreshTokensMock).toBeCalledWith(mockTokens.RefreshToken);
Expand Down
26 changes: 11 additions & 15 deletions src/server/api/api.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ import JwtAuthenticationGuard from '../auth/jwt.guard';
import JwtRegulatorGuard from '../auth/jwt-regulator.guard';
import { ApiAuthService } from '../auth/api-auth.service';
import ApiTokenRequestDto from './entities/api-token-request.dto';
import ApiRefreshTokenRequestDto from './entities/api-refresh-token-request.dto';
import toSearchRequest from './utils/to-search-request';
import toApiSearchResult, {
FilteredOrpSearchItemForApi,
Expand All @@ -50,7 +49,6 @@ import {
ApiSearchResponseDto,
} from './entities/api-search-response.dto';
import ApiTokensDto from './entities/api-tokens-dto';
import ApiRefreshTokenDto from './entities/api-refresh-token.dto';
import { LinkedDocumentsRequestDto } from './entities/linked-documents-request.dto';
import { User } from '../user/user.decorator';
import { ApiUser as UserType } from '../auth/entities/user';
Expand All @@ -61,8 +59,8 @@ import FileNotEmptyValidator from '../validators/file-not-empty.validator';
import { LinkedDocumentsResponseDto } from '../search/entities/linked-documents-response.dto';
import { SnakeCaseInterceptor } from './utils/snake-case.interceptor';
import { ApiLinkedDocumentsResponseDto } from './entities/api-linked-documents-response.dto';
import { CognitoRefreshResponse } from '../auth/entities/cognito-refresh-response.dto';
import { CognitoAuthResponse } from '../auth/entities/cognito-auth-response';
import { CognitoRefreshResponse } from '../auth/entities/cognito-refresh-response.dto';

@UseGuards(ThrottlerGuard)
@UsePipes(new ValidationPipe())
Expand Down Expand Up @@ -180,24 +178,22 @@ export class ApiController {
@ApiOkResponse({ type: ApiTokensDto })
@UseInterceptors(SnakeCaseInterceptor)
async login(
@Body() { client_id, client_secret }: ApiTokenRequestDto,
): Promise<CognitoAuthResponse['AuthenticationResult']> {
@Body()
{ grant_type, refresh_token, client_id, client_secret }: ApiTokenRequestDto,
): Promise<
| CognitoAuthResponse['AuthenticationResult']
| CognitoRefreshResponse['AuthenticationResult']
> {
if (grant_type === 'refresh_token') {
return this.apiAuthService.refreshApiUser(refresh_token);
}

return this.apiAuthService.authenticateApiClient({
clientId: client_id,
clientSecret: client_secret,
});
}

@Post('refresh-tokens')
@ApiTags('auth')
@ApiOkResponse({ type: ApiRefreshTokenDto })
@UseInterceptors(SnakeCaseInterceptor)
async refreshToken(
@Body() apiRefreshTokenRequestDto: ApiRefreshTokenRequestDto,
): Promise<CognitoRefreshResponse['AuthenticationResult']> {
return this.apiAuthService.refreshApiUser(apiRefreshTokenRequestDto.token);
}

@Post('linked-documents')
@UseGuards(JwtAuthenticationGuard)
@ApiBearerAuth()
Expand Down
7 changes: 0 additions & 7 deletions src/server/api/entities/api-refresh-token-request.dto.ts

This file was deleted.

17 changes: 14 additions & 3 deletions src/server/api/entities/api-token-request.dto.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { IsIn, IsNotEmpty, IsString, ValidateIf } from 'class-validator';

export default class ApiTokenRequestDto {
@IsNotEmpty()
@IsIn(['client_credentials', 'refresh_token'])
grant_type: 'client_credentials' | 'refresh_token';

@ValidateIf((o) => o.grant_type === 'refresh_token')
@IsString()
@IsNotEmpty()
refresh_token?: string;

@ValidateIf((o) => o.grant_type === 'client_credentials')
@IsString()
@IsNotEmpty()
client_secret: string;
client_secret?: string;

@ValidateIf((o) => o.grant_type === 'client_credentials')
@IsString()
@IsNotEmpty()
client_id: string;
client_id?: string;
}
8 changes: 7 additions & 1 deletion src/server/auth/api-auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AwsConfig } from '../config/application-config';
import {
Expand Down Expand Up @@ -95,6 +95,9 @@ export class ApiAuthService {
);
return result.AuthenticationResult;
} catch (err) {
if (err.name === 'NotAuthorizedException') {
throw new UnauthorizedException(err.message);
}
throw err;
}
}
Expand Down Expand Up @@ -184,6 +187,9 @@ export class ApiAuthService {
);
return result.AuthenticationResult;
} catch (err) {
if (err.name === 'NotAuthorizedException') {
throw new UnauthorizedException(err.message);
}
throw err;
}
}
Expand Down
84 changes: 84 additions & 0 deletions test/api/token-api.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import {
CORRECT_API_CLIENT,
CORRECT_API_SECRET,
E2eFixture,
} from '../e2e.fixture';
import { mockTokens, mockTokensResponse } from '../mocks/tokens.mock';

describe('api/tokens (GET)', () => {
const fixture = new E2eFixture();

beforeAll(async () => {
await fixture.init();
});

describe('token', () => {
it('returns bad request if client_credentials not supplied', async () => {
return fixture
.request()
.post('/api/tokens')
.send({ client_id: 'a', client_secret: 'b' })
.expect(400);
});

it('returns bad request if client_credentials no secret', async () => {
return fixture
.request()
.post('/api/tokens')
.send({
grant_type: 'client_credentials',
client_id: 'a',
client_secret: '',
})
.expect(400);
});

it('returns bad request if client_credentials no id', async () => {
return fixture
.request()
.post('/api/tokens')
.send({
grant_type: 'client_credentials',
client_id: '',
client_secret: 'b',
})
.expect(400);
});

it('logs in with client credentials if grant type selected', async () => {
return fixture
.request()
.post('/api/tokens')
.send({
grant_type: 'client_credentials',
client_id: CORRECT_API_CLIENT,
client_secret: CORRECT_API_SECRET,
})
.expect(201)
.expect(mockTokensResponse);
});

it('logs in with refresh token if grant type selected', async () => {
return fixture
.request()
.post('/api/tokens')
.send({
grant_type: 'refresh_token',
refresh_token: mockTokens.RefreshToken,
})
.expect(201)
.expect(mockTokensResponse);
});

it('returns bad request if refresh_token no token', async () => {
return fixture
.request()
.post('/api/tokens')
.send({
grant_type: 'refresh_token',
refresh_token: '',
})
.expect(400);
});
});
});
20 changes: 20 additions & 0 deletions test/e2e.fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,31 @@ import { NextFunction, Request } from 'express';
import { ApiUser } from '../src/server/auth/entities/user';
import * as session from 'express-session';
import { magicLinkInitiationResponse } from './mocks/magicLink.mock';
import { mockTokens } from './mocks/tokens.mock';

export const CORRECT_CODE = '123456';
export const CORRECT_API_CLIENT = 'a';
export const CORRECT_API_SECRET = 'b';
export const mockCognito = {
send: jest.fn().mockImplementation((command) => {
if (command.authRequest) {
// is api client creds login
if (
command.AuthFlow === 'ADMIN_USER_PASSWORD_AUTH' &&
command.AuthParameters.USERNAME === CORRECT_API_CLIENT &&
command.AuthParameters.PASSWORD === CORRECT_API_SECRET
) {
return { AuthenticationResult: mockTokens };
}

// is api refresh token login
if (
command.AuthFlow === 'REFRESH_TOKEN_AUTH' &&
command.AuthParameters.REFRESH_TOKEN === mockTokens.RefreshToken
) {
return { AuthenticationResult: mockTokens };
}

return magicLinkInitiationResponse;
}

Expand Down
8 changes: 8 additions & 0 deletions test/mocks/tokens.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,11 @@ export const mockTokens: CognitoAuthResponse['AuthenticationResult'] = {
RefreshToken: 'refresh_token',
TokenType: 'Bearer',
};

export const mockTokensResponse = {
access_token: 'access_token',
expires_in: 3600,
id_token: 'id_token',
refresh_token: 'refresh_token',
token_type: 'Bearer',
};

0 comments on commit 1319554

Please sign in to comment.