Skip to content

Commit

Permalink
Support defered token retrieval (#3317)
Browse files Browse the repository at this point in the history
  • Loading branch information
jdalrymple authored Jun 26, 2023
1 parent 71db47b commit f56dfad
Show file tree
Hide file tree
Showing 15 changed files with 160 additions and 42 deletions.
2 changes: 1 addition & 1 deletion packages/core/test/unit/resources/ApplicationAppearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe('ApplicationAppearance.edit', () => {
it('should request PUT /application/appearence without arguments', async () => {
await service.edit();

expect(RequestHelper.put()).toHaveBeenCalledWith(service, 'application/appearence', undefined);
expect(RequestHelper.put()).toHaveBeenCalledWith(service, 'application/appearence', {});
});

it('should request PUT /application/appearence with a logo property', async () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/core/test/unit/resources/General.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ describe('Instantiating services', () => {
expect(service.constructor.name).toBe(k);
expect(service.url).toBeDefined();
expect(service.rejectUnauthorized).toBeTruthy();
expect(service.headers).toMatchObject({ 'private-token': 'abcdefg' });
expect(service.authHeaders).toMatchObject({
'private-token': expect.any(Function),
});
expect(service.queryTimeout).toBe(300000);
});
});
Expand Down
1 change: 0 additions & 1 deletion packages/core/test/unit/resources/GroupHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ describe('Instantiating GroupHooks service', () => {
expect(service).toBeInstanceOf(GroupHooks);
expect(service.url).toBeDefined();
expect(service.rejectUnauthorized).toBeTruthy();
expect(service.headers).toMatchObject({ 'private-token': 'abcdefg' });
});

it('should call /groups prefix', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/test/unit/resources/Issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ describe('Issues.show', () => {
it('should request GET /projects/:id/issues/:id', async () => {
await service.show(1, { projectId: 2 });

expect(RequestHelper.get()).toHaveBeenCalledWith(service, 'projects/1/issues/2', undefined);
expect(RequestHelper.get()).toHaveBeenCalledWith(service, 'projects/2/issues/1', {});
});
});

Expand Down
2 changes: 1 addition & 1 deletion packages/core/test/unit/resources/MergeRequestApprovals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe('MergeRequestApprovals.editConfiguration', () => {
it('should request POST /projects/:id/approvals without options', async () => {
await service.editConfiguration(3);

expect(RequestHelper.post()).toHaveBeenCalledWith(service, 'projects/3/approvals', {});
expect(RequestHelper.post()).toHaveBeenCalledWith(service, 'projects/3/approvals', undefined);
});

it('should request POST /projects/:id/approvals', async () => {
Expand Down
5 changes: 4 additions & 1 deletion packages/core/test/unit/resources/ProjectWikis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ describe('ProjectWikis.create', () => {
it('should request POST /projects/:id/wikis', async () => {
await service.create(1, 'content', 'title');

expect(RequestHelper.post()).toHaveBeenCalledWith(service, '1/wikis', undefined);
expect(RequestHelper.post()).toHaveBeenCalledWith(service, '1/wikis', {
content: 'content',
title: 'title',
});
});
});

Expand Down
2 changes: 1 addition & 1 deletion packages/core/test/unit/resources/Runners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ describe('Runners.remove', () => {
it('should request DEL /runners/:id', async () => {
await service.remove({ runnerId: 2 });

expect(RequestHelper.del()).toHaveBeenCalledWith(service, 'runners/2', undefined);
expect(RequestHelper.del()).toHaveBeenCalledWith(service, 'runners/2', {});
});

it('should request DEL /runners with token', async () => {
Expand Down
1 change: 0 additions & 1 deletion packages/core/test/unit/templates/ResourceDiscussions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ describe('Instantiating ResourceDiscussions service', () => {
expect(service).toBeInstanceOf(ResourceDiscussions);
expect(service.url).toBeDefined();
expect(service.rejectUnauthorized).toBeTruthy();
expect(service.headers).toMatchObject({ 'private-token': 'abcdefg' });
});
});

Expand Down
28 changes: 22 additions & 6 deletions packages/requester-utils/src/BaseResource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,29 @@ export interface RootResourceOptions<C> {
profileMode?: 'execution' | 'memory';
}

export type GitlabToken = string | (() => Promise<string>);

export interface BaseRequestOptionsWithOAuthToken<C> extends RootResourceOptions<C> {
oauthToken: string;
oauthToken: GitlabToken;
}

export interface BaseRequestOptionsWithAccessToken<C> extends RootResourceOptions<C> {
token: string;
token: GitlabToken;
}

export interface BaseRequestOptionsWithJobToken<C> extends RootResourceOptions<C> {
jobToken: string;
jobToken: GitlabToken;
}

export type BaseResourceOptions<C> =
| BaseRequestOptionsWithOAuthToken<C>
| BaseRequestOptionsWithAccessToken<C>
| BaseRequestOptionsWithJobToken<C>;

function getDynamicToken(tokenArgument: (() => Promise<string>) | string): Promise<string> {
return tokenArgument instanceof Function ? tokenArgument() : Promise.resolve(tokenArgument);
}

export class BaseResource<C extends boolean = false> {
public readonly url: string;

Expand All @@ -39,6 +45,8 @@ export class BaseResource<C extends boolean = false> {

public readonly headers: { [header: string]: string };

public readonly authHeaders: { [authHeader: string]: () => Promise<string> };

public readonly camelize: C | undefined;

public readonly rejectUnauthorized: boolean;
Expand All @@ -59,14 +67,22 @@ export class BaseResource<C extends boolean = false> {

this.url = [host, 'api', 'v4', prefixUrl].join('/');
this.headers = {};
this.authHeaders = {};
this.rejectUnauthorized = rejectUnauthorized;
this.camelize = camelize;
this.queryTimeout = queryTimeout;

// Handle auth tokens
if ('oauthToken' in tokens) this.headers.authorization = `Bearer ${tokens.oauthToken}`;
else if ('jobToken' in tokens) this.headers['job-token'] = tokens.jobToken;
else if ('token' in tokens) this.headers['private-token'] = tokens.token;
if ('oauthToken' in tokens)
this.authHeaders.authorization = async () => {
const token = await getDynamicToken(tokens.oauthToken);

return `Bearer ${token}`;
};
else if ('jobToken' in tokens)
this.authHeaders['job-token'] = async () => getDynamicToken(tokens.jobToken);
else if ('token' in tokens)
this.authHeaders['private-token'] = async () => getDynamicToken(tokens.token);
else {
throw new ReferenceError('A token, oauthToken or jobToken must be passed');
}
Expand Down
12 changes: 9 additions & 3 deletions packages/requester-utils/src/RequesterUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface Constructable<T = any> {

export type DefaultResourceOptions = {
headers: { [header: string]: string };
authHeaders: { [authHeader: string]: () => Promise<string> };
url: string;
rejectUnauthorized: boolean;
};
Expand Down Expand Up @@ -89,7 +90,7 @@ function isFormData(object: FormData | Record<string, unknown>) {
return typeof object === 'object' && object.constructor.name === 'FormData';
}

export function defaultOptionsHandler(
export async function defaultOptionsHandler(
resourceOptions: DefaultResourceOptions,
{
body,
Expand All @@ -100,7 +101,7 @@ export function defaultOptionsHandler(
method = 'get',
}: DefaultRequestOptions = {},
): Promise<RequestOptions> {
const { headers: preconfiguredHeaders, url } = resourceOptions;
const { headers: preconfiguredHeaders, authHeaders, url } = resourceOptions;
const headers = { ...preconfiguredHeaders };
const defaultOptions: RequestOptions = {
headers,
Expand All @@ -118,10 +119,15 @@ export function defaultOptionsHandler(
defaultOptions.body = body as FormData;
} else {
defaultOptions.body = JSON.stringify(decamelizeKeys(body));
headers['content-type'] = 'application/json';
defaultOptions.headers['content-type'] = 'application/json';
}
}

// Append dynamic auth header
const [authHeaderKey, authHeaderFn] = Object.entries(authHeaders)[0];

defaultOptions.headers[authHeaderKey] = await authHeaderFn();

// Format query parameters
const q = formatQuery(searchParams);

Expand Down
73 changes: 53 additions & 20 deletions packages/requester-utils/test/unit/BaseResource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,6 @@ describe('Creation of BaseResource instance', () => {
expect(service.url).toBe('https://gitlab.com/api/v4/');
});

it('should use the Oauth Token when a given both a Private Token and a Oauth Token', () => {
const service = new BaseResource({
requesterFn: jest.fn(),
token: 'test',
oauthToken: '1234',
});

expect(service.headers['private-token']).toBeUndefined();
expect(service.headers.authorization).toBe('Bearer 1234');
});

it('should append api and version number to host when using a custom host url', () => {
const service = new BaseResource({
requesterFn: jest.fn(),
Expand All @@ -35,34 +24,78 @@ describe('Creation of BaseResource instance', () => {
expect(service.camelize).toBe(true);
});

it('should add Oauth token to authorization header as a bearer token', () => {
it('should accept a string oauthToken', async () => {
const service = new BaseResource({
requesterFn: jest.fn(),
host: 'https://testing.com',
oauthToken: '1234',
});

expect(service.headers.authorization).toBe('Bearer 1234');
expect(service.authHeaders.authorization).toBeFunction();

await expect(service.authHeaders.authorization()).resolves.toBe('Bearer 1234');
});

it('should add Private token to private-token header', () => {
it('should accept a function oauthToken that returns a promise<string>', async () => {
const service = new BaseResource({
requesterFn: jest.fn(),
oauthToken: () => Promise.resolve('1234'),
});

expect(service.authHeaders.authorization).toBeFunction();

await expect(service.authHeaders.authorization()).resolves.toBe('Bearer 1234');
});

it('should use the Oauth Token when a given both a Private Token and a Oauth Token', async () => {
const service = new BaseResource({
requesterFn: jest.fn(),
token: 'test',
oauthToken: () => Promise.resolve('1234'),
});

expect(Object.keys(service.authHeaders).length).toBe(1);
expect(service.authHeaders.authorization).toBeFunction();
await expect(service.authHeaders.authorization()).resolves.toBe('Bearer 1234');
});

it('should accept a string token (private-token)', async () => {
const service = new BaseResource({
requesterFn: jest.fn(),
host: 'https://testing.com',
token: '1234',
});

expect(service.headers['private-token']).toBe('1234');
expect(service.authHeaders['private-token']).toBeFunction();
await expect(service.authHeaders['private-token']()).resolves.toBe('1234');
});

it('should add Job token to job-token header', () => {
it('should accept a function token (private-token) that returns a promise<string>', async () => {
const service = new BaseResource({
requesterFn: jest.fn(),
token: () => Promise.resolve('1234'),
});

expect(service.authHeaders['private-token']).toBeFunction();
await expect(service.authHeaders['private-token']()).resolves.toBe('1234');
});

it('should accept a string jobToken (job-token)', async () => {
const service = new BaseResource({
requesterFn: jest.fn(),
host: 'https://testing.com',
jobToken: '1234',
});

expect(service.headers['job-token']).toBe('1234');
expect(service.authHeaders['job-token']).toBeFunction();
await expect(service.authHeaders['job-token']()).resolves.toBe('1234');
});

it('should accept a function jobToken (job-token) that returns a promise<string>', async () => {
const service = new BaseResource({
requesterFn: jest.fn(),
jobToken: () => Promise.resolve('1234'),
});

expect(service.authHeaders['job-token']).toBeFunction();
await expect(service.authHeaders['job-token']()).resolves.toBe('1234');
});

it('should set the X-Profile-Token header if the profileToken option is given', () => {
Expand Down
29 changes: 25 additions & 4 deletions packages/requester-utils/test/unit/RequesterUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ const methods = ['get', 'put', 'patch', 'delete', 'post'];
describe('defaultOptionsHandler', () => {
const serviceOptions = {
headers: { test: '5' },
authHeaders: {
token: () => Promise.resolve('1234'),
},
url: 'testurl',
rejectUnauthorized: false,
};
Expand Down Expand Up @@ -43,18 +46,18 @@ describe('defaultOptionsHandler', () => {
});

it('should not assign the sudo property if omitted', async () => {
const { headers } = (await defaultOptionsHandler(serviceOptions, {
const { headers } = await defaultOptionsHandler(serviceOptions, {
sudo: undefined,
method: 'get',
})) as { headers: Record<string, string> };
});

expect(headers.sudo).toBeUndefined();
});

it('should assign the sudo property if passed', async () => {
const { headers } = (await defaultOptionsHandler(serviceOptions, {
const { headers } = await defaultOptionsHandler(serviceOptions, {
sudo: 'testsudo',
})) as { headers: Record<string, string> };
});

expect(headers.sudo).toBe('testsudo');
});
Expand Down Expand Up @@ -88,13 +91,25 @@ describe('defaultOptionsHandler', () => {

expect(searchParams).toBe('this_search_term=5');
});

it('should append dynamic authentication token headers', async () => {
const { headers } = await defaultOptionsHandler(serviceOptions, {
sudo: undefined,
method: 'get',
});

expect(headers.token).toBe('1234');
});
});

describe('createInstance', () => {
const requestHandler = jest.fn();
const optionsHandler = jest.fn(() => Promise.resolve({} as RequestOptions));
const serviceOptions = {
headers: { test: '5' },
authHeaders: {
token: () => Promise.resolve('1234'),
},
url: 'testurl',
rejectUnauthorized: false,
};
Expand Down Expand Up @@ -130,11 +145,17 @@ describe('createInstance', () => {
it('should respect the closure variables', async () => {
const serviceOptions1 = {
headers: { test: '5' },
authHeaders: {
token: () => Promise.resolve('1234'),
},
url: 'testurl',
rejectUnauthorized: false,
};
const serviceOptions2 = {
headers: { test: '5' },
authHeaders: {
token: () => Promise.resolve('1234'),
},
url: 'testurl2',
rejectUnauthorized: true,
};
Expand Down
Loading

0 comments on commit f56dfad

Please sign in to comment.