From 51e9c8d9dfce82f3483e228c03b84330174a4e5a Mon Sep 17 00:00:00 2001 From: Laura Warr Date: Wed, 19 Jul 2023 09:24:17 -0700 Subject: [PATCH] feat: cache token to reuse if client id and secret match --- package.json | 3 +- src/api/clientCredentialsAuth.ts | 20 ------- src/auth/ApiAuth.ts | 83 ++++++++++++++++++++++++++++ src/auth/TokenCache.ts | 47 ++++++++++++++++ src/auth/config.ts | 2 +- src/auth/getToken.ts | 47 ---------------- src/commands/base.ts | 6 +- src/commands/generate/types.test.ts | 4 +- test/setup.ts | 7 +++ yarn.lock | 86 ++++++++++++++++++++++++++++- 10 files changed, 228 insertions(+), 77 deletions(-) delete mode 100644 src/api/clientCredentialsAuth.ts create mode 100644 src/auth/ApiAuth.ts create mode 100644 src/auth/TokenCache.ts delete mode 100644 src/auth/getToken.ts create mode 100644 test/setup.ts diff --git a/package.json b/package.json index 889d2e97f..70d5a782e 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "nock": "^13.2.2", "oclif": "^3.9.0", "shx": "^0.3.3", + "sinon": "^15.2.0", "ts-node": "^10.2.1", "tslib": "^2.3.1", "typescript": "^4.4.3", @@ -120,7 +121,7 @@ "postpack": "shx rm -f oclif.manifest.json", "posttest": "yarn lint", "prepack": "yarn build && oclif manifest && oclif readme --multi", - "test": "mocha \"src/**/*.test.ts\"", + "test": "mocha test/*.ts src/**/*.test.ts", "test:ci": "yarn test --forbid-only", "version": "oclif readme --multi && git add README.md", "build:watch": "watch 'yarn build' src" diff --git a/src/api/clientCredentialsAuth.ts b/src/api/clientCredentialsAuth.ts deleted file mode 100644 index 91c325581..000000000 --- a/src/api/clientCredentialsAuth.ts +++ /dev/null @@ -1,20 +0,0 @@ -import axios from 'axios' -import { AUTH_URL } from './common' - -export const clientCredentialsAuth = async (client_id: string, client_secret: string): Promise => { - const url = new URL('/oauth/token', AUTH_URL) - - try { - const response = await axios.post(url.href, { - grant_type: 'client_credentials', - client_id, - client_secret, - audience: 'https://api.devcycle.com/', - }) - - return response.data.access_token - } catch (e) { - throw new Error('Failed to authenticate with the DevCycle API. Check your credentials.') - } - -} diff --git a/src/auth/ApiAuth.ts b/src/auth/ApiAuth.ts new file mode 100644 index 000000000..b84fb422f --- /dev/null +++ b/src/auth/ApiAuth.ts @@ -0,0 +1,83 @@ +import jsYaml from 'js-yaml' +import fs from 'fs' +import axios from 'axios' +import { plainToClass } from 'class-transformer' +import { validateSync } from 'class-validator' +import { AuthConfig } from './config' +import { reportValidationErrors } from '../utils/reportValidationErrors' +import { AUTH_URL } from '../api/common' +import { TokenCache } from './TokenCache' + +type SupportedFlags = { + 'client-id'?: string + 'client-secret'?: string +} + +export class ApiAuth { + private authPath: string + private tokenCache: TokenCache + + constructor(authPath: string, cacheDir: string) { + this.authPath = authPath + this.tokenCache = new TokenCache(cacheDir) + } + + public async getToken(flags: SupportedFlags): Promise { + const clientId = flags['client-id'] + || process.env.DEVCYCLE_CLIENT_ID + || process.env.DVC_CLIENT_ID + const clientSecret = flags['client-secret'] + || process.env.DEVCYCLE_CLIENT_SECRET + || process.env.DVC_CLIENT_SECRET + + if (clientId && clientSecret) { + return this.fetchClientToken(clientId, clientSecret) + } + + if (this.authPath && fs.existsSync(this.authPath)) { + return this.getTokenFromAuthFile() + } + + return '' + } + + private async getTokenFromAuthFile(): Promise { + const rawConfig = jsYaml.load(fs.readFileSync(this.authPath, 'utf8')) + const config = plainToClass(AuthConfig, rawConfig) + const errors = validateSync(config) + reportValidationErrors(errors) + + if (config.sso) { + return config.sso.accessToken || '' + } else if (config.clientCredentials) { + const { client_id, client_secret } = config.clientCredentials + return this.fetchClientToken(client_id, client_secret) + } + + return '' + } + + private async fetchClientToken(client_id: string, client_secret: string): Promise { + const cachedToken = await this.tokenCache.get(client_id, client_secret) + if (cachedToken) { + return cachedToken + } + + const url = new URL('/oauth/token', AUTH_URL) + + try { + const response = await axios.post(url.href, { + grant_type: 'client_credentials', + client_id, + client_secret, + audience: 'https://api.devcycle.com/', + }) + + const accessToken = response.data.access_token + await this.tokenCache.set(client_id, client_secret, accessToken) + return accessToken + } catch (e) { + throw new Error('Failed to authenticate with the DevCycle API. Check your credentials.') + } + } +} diff --git a/src/auth/TokenCache.ts b/src/auth/TokenCache.ts new file mode 100644 index 000000000..78f08483c --- /dev/null +++ b/src/auth/TokenCache.ts @@ -0,0 +1,47 @@ +import path from 'path' +import * as crypto from 'crypto' +import * as fs from 'fs/promises' + +export class TokenCache { + filePath: string + + constructor(cacheDir: string) { + this.filePath = path.join(cacheDir, 'token.json') + } + + private hashCredentials(clientId: string, clientSecret: string): string { + return crypto.createHash('md5').update(clientId + clientSecret).digest('hex') + } + + public async set(clientId: string, clientSecret: string, token: string): Promise { + try { + const identifier = this.hashCredentials(clientId, clientSecret) + const tokenPayload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()) + const expiry = tokenPayload.exp * 1000 + await fs.writeFile(this.filePath, JSON.stringify({ identifier, token, expiry })) + } catch (err) { + // don't throw error + } + } + + public async get(clientId: string, clientSecret: string): Promise { + try { + const identifier = this.hashCredentials(clientId, clientSecret) + const fileContent = await fs.readFile(this.filePath) + const cache = JSON.parse(fileContent.toString()) + + if ( + cache && + cache.token && + cache.identifier === identifier && + cache.expiry > Date.now() + ) { + return cache.token + } + } catch (err) { + // don't throw error + } + + return null + } +} \ No newline at end of file diff --git a/src/auth/config.ts b/src/auth/config.ts index ff6c6f15d..48e5e2689 100644 --- a/src/auth/config.ts +++ b/src/auth/config.ts @@ -33,7 +33,7 @@ export class AuthConfig { sso?: SSOAuthConfig } -export function storeAccessToken(accessToken:string, authPath:string): void { +export function storeAccessToken(accessToken: string, authPath: string): void { const configDir = path.dirname(authPath) if (!fs.existsSync(configDir)) { fs.mkdirSync(configDir, { recursive: true }) diff --git a/src/auth/getToken.ts b/src/auth/getToken.ts deleted file mode 100644 index adce3200c..000000000 --- a/src/auth/getToken.ts +++ /dev/null @@ -1,47 +0,0 @@ -import jsYaml from 'js-yaml' -import fs from 'fs' -import { plainToClass } from 'class-transformer' -import { validateSync } from 'class-validator' -import { AuthConfig } from './config' -import { clientCredentialsAuth } from '../api/clientCredentialsAuth' -import { reportValidationErrors } from '../utils/reportValidationErrors' - -type SupportedFlags = { - 'client-id'?: string - 'client-secret'?: string -} - -export async function getToken(authPath: string, flags: SupportedFlags): Promise { - const client_id = flags['client-id'] - || process.env.DEVCYCLE_CLIENT_ID - || process.env.DVC_CLIENT_ID - const client_secret = flags['client-secret'] - || process.env.DEVCYCLE_CLIENT_SECRET - || process.env.DVC_CLIENT_SECRET - - if (client_id && client_secret) { - return clientCredentialsAuth(client_id, client_secret) - } - - if (authPath && fs.existsSync(authPath)) { - return getTokenFromAuthFile(authPath) - } - - return '' -} - -async function getTokenFromAuthFile(authPath: string): Promise { - const rawConfig = jsYaml.load(fs.readFileSync(authPath, 'utf8')) - const config = plainToClass(AuthConfig, rawConfig) - const errors = validateSync(config) - reportValidationErrors(errors) - - if (config.sso) { - return config.sso.accessToken || '' - } else if (config.clientCredentials) { - const { client_id, client_secret } = config.clientCredentials - return clientCredentialsAuth(client_id, client_secret) - } - - return '' -} diff --git a/src/commands/base.ts b/src/commands/base.ts index ec26b35a5..3318ce76b 100644 --- a/src/commands/base.ts +++ b/src/commands/base.ts @@ -8,7 +8,7 @@ import { RepoConfigFromFile, UserConfigFromFile } from '../types' import { ClassConstructor, plainToClass } from 'class-transformer' import { validateSync } from 'class-validator' import { reportValidationErrors, reportZodValidationErrors, validateParams } from '../utils/reportValidationErrors' -import { getToken } from '../auth/getToken' +import { ApiAuth } from '../auth/ApiAuth' import { fetchProjects } from '../api/projects' import { promptForProject } from '../ui/promptForProject' import inquirer from 'inquirer' @@ -96,7 +96,7 @@ export default abstract class Base extends Command { } private async authorizeApi(): Promise { const { flags } = await this.parse(this.constructor as typeof Base) - this.authToken = await getToken(this.authPath, flags) + this.authToken = await new ApiAuth(this.authPath, this.config.cacheDir).getToken(flags) if (!this.hasToken()) { if (this.authRequired) { throw new Error( @@ -227,7 +227,7 @@ export default abstract class Base extends Command { const parsedToken = JSON.parse( Buffer.from(this.authToken.split('.')[1], 'base64').toString() ) - return parsedToken.exp < Math.floor(Date.now()/1000) + return parsedToken.exp < Math.floor(Date.now() / 1000) } async requireProject(projectFlag?: string, headless?: boolean): Promise { diff --git a/src/commands/generate/types.test.ts b/src/commands/generate/types.test.ts index 3ecc35b38..ef590fe18 100644 --- a/src/commands/generate/types.test.ts +++ b/src/commands/generate/types.test.ts @@ -106,7 +106,7 @@ describe('generate types', () => { }) dvcTest() - .nock(BASE_URL, (api) => + .nock(BASE_URL, (api) => api.get('/v1/projects/project/variables?perPage=1000&page=1&status=active') .reply(200, mockVariablesResponse) ) @@ -120,10 +120,10 @@ describe('generate types', () => { ]) .it('correctly generates JS SDK types', (ctx) => { const outputDir = jsOutputDir + '/dvcVariableTypes.ts' + expect(ctx.stdout).to.contain(`Generated new types to ${outputDir}`) expect(fs.existsSync(outputDir)).to.be.true const typesString = fs.readFileSync(outputDir, 'utf-8') expect(typesString).to.equal(expectedTypesString) - expect(ctx.stdout).to.contain(`Generated new types to ${outputDir}`) }) dvcTest() diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 000000000..75a59cde8 --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,7 @@ +import sinon from 'sinon' +import { TokenCache } from '../src/auth/TokenCache' + +before(() => { + sinon.stub(TokenCache.prototype, 'get').resolves('') + sinon.stub(TokenCache.prototype, 'set').resolves() +}) \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 9b2db0582..b21496776 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1032,6 +1032,41 @@ resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz" integrity sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw== +"@sinonjs/commons@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-2.0.0.tgz#fd4ca5b063554307e8327b4564bd56d3b73924a3" + integrity sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg== + dependencies: + type-detect "4.0.8" + +"@sinonjs/commons@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.0.tgz#beb434fe875d965265e04722ccfc21df7f755d72" + integrity sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@^10.0.2", "@sinonjs/fake-timers@^10.3.0": + version "10.3.0" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz#55fdff1ecab9f354019129daf4df0dd4d923ea66" + integrity sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA== + dependencies: + "@sinonjs/commons" "^3.0.0" + +"@sinonjs/samsam@^8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-8.0.0.tgz#0d488c91efb3fa1442e26abea81759dfc8b5ac60" + integrity sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew== + dependencies: + "@sinonjs/commons" "^2.0.0" + lodash.get "^4.4.2" + type-detect "^4.0.8" + +"@sinonjs/text-encoding@^0.7.1": + version "0.7.2" + resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz#5981a8db18b56ba38ef0efb7d995b12aa7b51918" + integrity sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ== + "@szmarczak/http-timer@^4.0.5": version "4.0.6" resolved "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz" @@ -2444,7 +2479,7 @@ diff@^4.0.1: resolved "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz" integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== -diff@^5.0.0: +diff@^5.0.0, diff@^5.1.0: version "5.1.0" resolved "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz" integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw== @@ -3733,6 +3768,11 @@ is-wsl@^2.2.0: dependencies: is-docker "^2.0.0" +isarray@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" + integrity sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ== + isarray@^1.0.0, isarray@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" @@ -4018,6 +4058,11 @@ just-diff@^5.0.1: resolved "https://registry.npmjs.org/just-diff/-/just-diff-5.2.0.tgz" integrity sha512-6ufhP9SHjb7jibNFrNxyFZ6od3g+An6Ai9mhGRvcYe8UJlH0prseN64M+6ZBBUoKYHZsitDP42gAJ8+eVWr3lw== +just-extend@^4.0.2: + version "4.2.1" + resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.2.1.tgz#ef5e589afb61e5d66b24eca749409a8939a8c744" + integrity sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg== + keyv@^4.0.0: version "4.5.2" resolved "https://registry.npmjs.org/keyv/-/keyv-4.5.2.tgz" @@ -4067,6 +4112,11 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash.get@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" + integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== + lodash.merge@^4.6.2: version "4.6.2" resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz" @@ -4536,6 +4586,17 @@ nice-try@^1.0.4: resolved "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +nise@^5.1.4: + version "5.1.4" + resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.4.tgz#491ce7e7307d4ec546f5a659b2efe94a18b4bbc0" + integrity sha512-8+Ib8rRJ4L0o3kfmyVCL7gzrohyDe0cMFTBa2d364yIrEGMEoetznKJx899YxjybU6bL9SQkYPSBBs1gyYs8Xg== + dependencies: + "@sinonjs/commons" "^2.0.0" + "@sinonjs/fake-timers" "^10.0.2" + "@sinonjs/text-encoding" "^0.7.1" + just-extend "^4.0.2" + path-to-regexp "^1.7.0" + nock@^13.2.2, nock@^13.3.1: version "13.3.1" resolved "https://registry.npmjs.org/nock/-/nock-13.3.1.tgz" @@ -5156,6 +5217,13 @@ path-scurry@^1.7.0: lru-cache "^9.1.1" minipass "^5.0.0 || ^6.0.2" +path-to-regexp@^1.7.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" + integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA== + dependencies: + isarray "0.0.1" + path-type@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz" @@ -5723,6 +5791,18 @@ sigstore@^1.3.0: make-fetch-happen "^11.0.1" tuf-js "^1.1.3" +sinon@^15.2.0: + version "15.2.0" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-15.2.0.tgz#5e44d4bc5a9b5d993871137fd3560bebfac27565" + integrity sha512-nPS85arNqwBXaIsFCkolHjGIkFo+Oxu9vbgmBJizLAhqe6P2o3Qmj3KCUoRkfhHtvgDhZdWD3risLHAUJ8npjw== + dependencies: + "@sinonjs/commons" "^3.0.0" + "@sinonjs/fake-timers" "^10.3.0" + "@sinonjs/samsam" "^8.0.0" + diff "^5.1.0" + nise "^5.1.4" + supports-color "^7.2.0" + slash@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz" @@ -5985,7 +6065,7 @@ supports-color@^5.3.0: dependencies: has-flag "^3.0.0" -supports-color@^7.0.0, supports-color@^7.1.0: +supports-color@^7.0.0, supports-color@^7.1.0, supports-color@^7.2.0: version "7.2.0" resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz" integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== @@ -6176,7 +6256,7 @@ type-check@^0.4.0, type-check@~0.4.0: dependencies: prelude-ls "^1.2.1" -type-detect@^4.0.0, type-detect@^4.0.5: +type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.5, type-detect@^4.0.8: version "4.0.8" resolved "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==