Skip to content

Commit

Permalink
raw response option (#71)
Browse files Browse the repository at this point in the history
## Description
Sometimes we want the full response and not just the token.
Particularly when we want to see when the token will expire.
  • Loading branch information
gagoar authored Dec 14, 2020
1 parent 90555f3 commit 956b602
Show file tree
Hide file tree
Showing 4 changed files with 115 additions and 24 deletions.
48 changes: 42 additions & 6 deletions __tests__/getToken.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,26 @@ describe('getToken', () => {

const getAccessTokensURL = (installationID: number) => `/app/installations/${installationID}/access_tokens`;
const installationID = 1234;
// The response object has to be in snake_case because is how the API returns fields, octokit will camelCase every field
const response = {
token: `secret-installation-token-${installationID}`,
expires_at: '1970-01-01T01:00:00.000Z',
type: 'token',
token_type: 'installation',
token: 'secret-installation-token-1234',
installation_id: 12400204,
permissions: {
actions: 'write',
administration: 'admin',
checks: 'write',
contents: 'write',
issues: 'write',
metadata: 'read',
pull_requests: 'write',
statuses: 'admin',
},
created_at: new Date('2020-12-14').toDateString(),
expires_at: new Date('2020-12-15').toDateString(),
repository_selection: 'all',
};

it('It tries to get the token, but it gets a malformed response from Github', async () => {
const { token, ...responseWithoutToken } = response;
const github = nock(GITHUB_URL).post(getAccessTokensURL(installationID)).reply(201, responseWithoutToken);
Expand All @@ -58,11 +69,11 @@ describe('getToken', () => {
expect(github.isDone()).toBe(true);
});
it('Retrieves the token', async () => {
const github = nock(GITHUB_URL).post(getAccessTokensURL(installationID)).reply(201, response);
const github = nock(GITHUB_URL).post(getAccessTokensURL(123456)).reply(201, response);

const { token } = await getToken({
appId: APP_ID,
installationId: installationID,
installationId: 123456,
privateKey: PRIVATE_KEY,
});

Expand Down Expand Up @@ -108,6 +119,31 @@ describe('getToken', () => {
expect(github.isDone()).toBe(true);
});

it('invokes getToken via command providing a private.key location(with raw Response)', async () => {
const github = nock(GITHUB_URL).post(getAccessTokensURL(installationID)).reply(201, response);

const mockExit = mockProcessExit();
await getTokenCommand({
appId: APP_ID,
installationId: installationID,
privateKeyLocation: KEY_LOCATION,
rawResponse: true,
});

expect(mockExit).not.toHaveBeenCalled();
expect(stopAndPersist).toHaveBeenCalledTimes(1);
expect(stopAndPersist.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
"symbol": "💫",
"text": "The token is: secret-installation-token-1234 and expires Tue Dec 15 2020",
},
],
]
`);
expect(github.isDone()).toBe(true);
});
it('invokes getToken via command providing a private.key location', async () => {
const github = nock(GITHUB_URL).post(getAccessTokensURL(installationID)).reply(201, response);

Expand All @@ -125,7 +161,7 @@ describe('getToken', () => {
Array [
Object {
"symbol": "💫",
"text": "The token is: secret-installation-token-1234",
"text": "The token is: secret-installation-token-1234 and expires Tue Dec 15 2020",
},
],
]
Expand Down
1 change: 1 addition & 0 deletions src/bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ program
'--privateKeyLocation [path/to/the/private.key]',
'path to the key location, for more information https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/#generating-a-private-key '
)
.option('--rawResponse', 'It will return the full response in stringified json format (not just the token)')

.action(getTokenCommand);

Expand Down
57 changes: 43 additions & 14 deletions src/commands/getToken.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import ora from 'ora';
import { Octokit } from '@octokit/rest';
import { request as Request } from '@octokit/request';
import { RequestRequestOptions } from '@octokit/types';
import { createAppAuth } from '@octokit/auth-app';
import NodeRSA from 'node-rsa';

import { SUCCESS_SYMBOL } from '../utils/constants';
import { logger } from '../utils/debug';
import { isAppsCreateInstallationAccessTokenResponseData } from '../utils/guards';
import {
AppsCreateInstallationAccessTokenResponse,
isAppsCreateInstallationAccessTokenResponse,
} from '../utils/guards';
import { readContent } from '../utils/readFile';
import { Command } from 'commander';

const debug = logger('generate');
// just left the async signature to make it easier in the future
Expand All @@ -17,13 +20,32 @@ interface GetTokenInput {
installationId: number;
privateKey: string;
}
type RequestOptions = RequestRequestOptions & Required<{ rawResponse: true }>;
type PlainRequest = RequestRequestOptions & { rawResponse?: false };

export const getToken = async (
export async function getToken(
{ appId, installationId, privateKey }: GetTokenInput,
request: RequestRequestOptions = Request
): Promise<{ token: string }> => {
requestOptions: RequestOptions
): Promise<AppsCreateInstallationAccessTokenResponse>;
export async function getToken(
{ appId, installationId, privateKey }: GetTokenInput,
requestOptions?: PlainRequest
): Promise<Pick<AppsCreateInstallationAccessTokenResponse, 'token'>>;
export async function getToken(
{ appId, installationId, privateKey }: GetTokenInput,
requestOptions?: PlainRequest | RequestOptions
): Promise<Pick<AppsCreateInstallationAccessTokenResponse, 'token'> | AppsCreateInstallationAccessTokenResponse> {
const key = new NodeRSA(privateKey);

let request: Request;

if (requestOptions?.rawResponse) {
const { rawResponse: _rawResponse, ...rest } = requestOptions;
request = rest as Request;
} else {
request = requestOptions as Request;
}

const octokit = new Octokit({
authStrategy: createAppAuth,
auth: {
Expand All @@ -39,20 +61,24 @@ export const getToken = async (
installationId,
});

if (!isAppsCreateInstallationAccessTokenResponseData(response)) {
if (!isAppsCreateInstallationAccessTokenResponse(response)) {
debug(`response is missing the token, we got ${response}`);
throw new Error('Something went wrong on the token retrieval, enable debug to inspect further');
}
const { token } = response;

return { token };
};
return requestOptions?.rawResponse ? response : { token: response.token };
}

type Input = Omit<GetTokenInput, 'privateKey'> & { privateKeyLocation?: string; privateKey?: string };
type Input = Omit<Command & GetTokenInput, 'privateKey'> & {
privateKeyLocation?: string;
privateKey?: string;
rawResponse?: boolean;
};

const isValidInput = (input: Input): boolean => {
return !!(input.privateKey || input.privateKeyLocation);
};

export const command = async (input: Input): Promise<void> => {
debug('input:', input);

Expand All @@ -61,7 +87,7 @@ export const command = async (input: Input): Promise<void> => {
try {
let privateKey: string;

const { privateKeyLocation, installationId, appId } = input;
const { privateKeyLocation, installationId, appId, rawResponse } = input;

if (!isValidInput(input)) {
loader.fail('Input is not valid, either privateKey or privateKeyLocation should be provided');
Expand All @@ -74,11 +100,14 @@ export const command = async (input: Input): Promise<void> => {
privateKey = input.privateKey as string;
}

const { token } = await getToken({ privateKey, installationId, appId });
const response = await getToken({ privateKey, installationId, appId }, { rawResponse: true });

loader.stopAndPersist({ text: `The token is: ${token}`, symbol: SUCCESS_SYMBOL });
loader.stopAndPersist({
text: `The token is: ${response.token} and expires ${response.expiresAt}`,
symbol: SUCCESS_SYMBOL,
});

console.log(token);
console.log(rawResponse ? response : response.token);
} catch (e) {
loader.fail(`We encountered an error: ${e}`);
process.exit(1);
Expand Down
33 changes: 29 additions & 4 deletions src/utils/guards.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,36 @@
import { AppsCreateInstallationAccessTokenResponseData } from '@octokit/types';

/**
* Default permission level members have for organization repositories:
* \* `read` - can pull, but not push to or administer this repository.
* \* `write` - can pull and push, but not administer this repository.
* \* `admin` - can pull, push, and administer this repository.
* \* `none` - no permissions granted by default.
*/
type DefaultRepositoryPermission = 'read' | 'write' | 'admin' | 'none';
export interface AppsCreateInstallationAccessTokenResponse {
type: string;
tokenType: string;
token: string;
installationId: number;
permissions: {
actions: DefaultRepositoryPermission;
administration: DefaultRepositoryPermission;
checks: DefaultRepositoryPermission;
contents: DefaultRepositoryPermission;
issues: DefaultRepositoryPermission;
metadata: DefaultRepositoryPermission;
pull_requests: DefaultRepositoryPermission;
statuses: DefaultRepositoryPermission;
};
createdAt: string;
expiresAt: string;
repositorySelection: 'all' | 'selected';
}
export const isObject = (value: unknown): value is Record<string, unknown> => {
return !!value && typeof value === 'object';
};
export const isString = (value: unknown): value is string => typeof value === 'string';
export const isAppsCreateInstallationAccessTokenResponseData = (
export const isAppsCreateInstallationAccessTokenResponse = (
response: unknown
): response is AppsCreateInstallationAccessTokenResponseData => {
): response is AppsCreateInstallationAccessTokenResponse => {
return isObject(response) && typeof response?.token === 'string';
};

0 comments on commit 956b602

Please sign in to comment.