Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
20 changes: 0 additions & 20 deletions src/api/clientCredentialsAuth.ts

This file was deleted.

83 changes: 83 additions & 0 deletions src/auth/ApiAuth.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string> {
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<string> {
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.')
}
}
}
47 changes: 47 additions & 0 deletions src/auth/TokenCache.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string | null> {
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
}
}
2 changes: 1 addition & 1 deletion src/auth/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
47 changes: 0 additions & 47 deletions src/auth/getToken.ts

This file was deleted.

6 changes: 3 additions & 3 deletions src/commands/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -96,7 +96,7 @@ export default abstract class Base extends Command {
}
private async authorizeApi(): Promise<void> {
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(
Expand Down Expand Up @@ -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<void> {
Expand Down
4 changes: 2 additions & 2 deletions src/commands/generate/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Expand All @@ -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()
Expand Down
7 changes: 7 additions & 0 deletions test/setup.ts
Original file line number Diff line number Diff line change
@@ -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()
})
Loading