From 956b60248ef6174bbb5d66f18012504759757fa7 Mon Sep 17 00:00:00 2001 From: Gago Date: Sun, 13 Dec 2020 23:50:42 -0800 Subject: [PATCH] raw response option (#71) ## Description Sometimes we want the full response and not just the token. Particularly when we want to see when the token will expire. --- __tests__/getToken.spec.ts | 48 ++++++++++++++++++++++++++++---- src/bin/cli.ts | 1 + src/commands/getToken.ts | 57 ++++++++++++++++++++++++++++---------- src/utils/guards.ts | 33 +++++++++++++++++++--- 4 files changed, 115 insertions(+), 24 deletions(-) diff --git a/__tests__/getToken.spec.ts b/__tests__/getToken.spec.ts index 4ba46a4..dae65d2 100644 --- a/__tests__/getToken.spec.ts +++ b/__tests__/getToken.spec.ts @@ -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); @@ -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, }); @@ -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); @@ -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", }, ], ] diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 02a1590..8b512f9 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -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); diff --git a/src/commands/getToken.ts b/src/commands/getToken.ts index e73f582..04b223f 100644 --- a/src/commands/getToken.ts +++ b/src/commands/getToken.ts @@ -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 @@ -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; +export async function getToken( + { appId, installationId, privateKey }: GetTokenInput, + requestOptions?: PlainRequest +): Promise>; +export async function getToken( + { appId, installationId, privateKey }: GetTokenInput, + requestOptions?: PlainRequest | RequestOptions +): Promise | 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: { @@ -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 & { privateKeyLocation?: string; privateKey?: string }; +type Input = Omit & { + privateKeyLocation?: string; + privateKey?: string; + rawResponse?: boolean; +}; const isValidInput = (input: Input): boolean => { return !!(input.privateKey || input.privateKeyLocation); }; + export const command = async (input: Input): Promise => { debug('input:', input); @@ -61,7 +87,7 @@ export const command = async (input: Input): Promise => { 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'); @@ -74,11 +100,14 @@ export const command = async (input: Input): Promise => { 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); diff --git a/src/utils/guards.ts b/src/utils/guards.ts index 0037f94..7fb7803 100644 --- a/src/utils/guards.ts +++ b/src/utils/guards.ts @@ -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 => { 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'; };