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
2 changes: 1 addition & 1 deletion oclif.manifest.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "5.2.0",
"version": "5.2.1",
"commands": {
"authCommand": {
"id": "authCommand",
Expand Down
18 changes: 13 additions & 5 deletions src/auth/ApiAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { AuthConfig, storeAccessToken } from './config'
import { reportValidationErrors } from '../utils/reportValidationErrors'
import { AUTH_URL } from '../api/common'
import { TokenCache } from './TokenCache'
import { getTokenExpiry, shouldRefreshToken } from './utils'
import { getOrgIdFromToken, getTokenExpiry, shouldRefreshToken } from './utils'
import { CLI_CLIENT_ID } from './SSOAuth'

type SupportedFlags = {
Expand All @@ -24,7 +24,7 @@ export class ApiAuth {
this.tokenCache = new TokenCache(cacheDir)
}

public async getToken(flags: SupportedFlags): Promise<string> {
public async getToken(flags: SupportedFlags, orgId?: string): Promise<string> {
const clientId = flags['client-id']
|| process.env.DEVCYCLE_CLIENT_ID
|| process.env.DVC_CLIENT_ID
Expand All @@ -37,7 +37,7 @@ export class ApiAuth {
}

if (this.authPath && fs.existsSync(this.authPath)) {
return this.getTokenFromAuthFile()
return this.getTokenFromAuthFile(orgId)
}

return ''
Expand All @@ -59,11 +59,19 @@ export class ApiAuth {
return config
}

private async getTokenFromAuthFile(): Promise<string> {
private async getTokenFromAuthFile(orgId?: string): Promise<string> {
const config = this.loadAuthFile()

if (config.sso) {
const { accessToken, refreshToken } = config.sso
let { accessToken, refreshToken } = config.sso

if (orgId) {
if (config.sso.orgs && config.sso.orgs[orgId]) {
({ accessToken, refreshToken } = config.sso.orgs[orgId])
} else if (orgId !== getOrgIdFromToken(accessToken)) {
return ''
}
}

if (accessToken && refreshToken && shouldRefreshToken(accessToken)) {
return this.refreshClientToken(refreshToken)
Expand Down
23 changes: 18 additions & 5 deletions src/auth/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import fs from 'fs'
import path from 'path'
import jsYaml from 'js-yaml'
import { getOrgIdFromToken } from './utils'

export class ClientCredentialsAuthConfig {
@IsString()
Expand All @@ -27,6 +28,9 @@ export class SSOAuthConfig {
@IsString()
@IsOptional()
personalAccessToken?: string

@IsOptional()
orgs?: Record<string, Pick<SSOAuthConfig, 'accessToken' | 'refreshToken'>>
}

export class AuthConfig {
Expand All @@ -41,16 +45,25 @@ export class AuthConfig {
sso?: SSOAuthConfig
}

export function storeAccessToken(tokens: Partial<SSOAuthConfig>, authPath: string): void {
export function storeAccessToken(tokens: SSOAuthConfig, authPath: string): void {
const configDir = path.dirname(authPath)
let config: AuthConfig
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true })
config = new AuthConfig()
} else {
config = jsYaml.load(fs.readFileSync(authPath, 'utf8')) as AuthConfig
}
const config = new AuthConfig()
config.sso = config.sso || new SSOAuthConfig()
if (tokens.accessToken) config.sso.accessToken = tokens.accessToken
if (tokens.refreshToken) config.sso.refreshToken = tokens.refreshToken
if (tokens.personalAccessToken) config.sso.personalAccessToken = tokens.personalAccessToken
config.sso.orgs = config.sso.orgs || {}

const { accessToken, refreshToken, personalAccessToken } = tokens
if (accessToken) config.sso.accessToken = accessToken
if (refreshToken) config.sso.refreshToken = refreshToken
if (personalAccessToken) config.sso.personalAccessToken = personalAccessToken

const orgId = getOrgIdFromToken(accessToken)
if (orgId) config.sso.orgs[orgId] = { accessToken, refreshToken }

fs.writeFileSync(authPath, jsYaml.dump(config))
}
29 changes: 29 additions & 0 deletions src/auth/utils/getOrgIdFromToken.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as assert from 'assert'
import { getOrgIdFromToken } from './getOrgIdFromToken'

describe('getOrgIdFromToken', () => {
it('parses "org_id" when it exists', () => {
// eslint-disable-next-line max-len
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJvcmdfaWQiOiJvcmdBQkMifQ.FDLOcz96JfcVLJZZdfJ9OtW5EzLmVVHS7u4usjJYJ6g'

const orgId = getOrgIdFromToken(token)

assert.equal(orgId, 'orgABC')
})

it('parses "https://devcycle.com/org_id" when it exists', () => {
// eslint-disable-next-line max-len
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJodHRwczovL2RldmN5Y2xlLmNvbS9vcmdfaWQiOiJvcmcxMjMifQ.bV7-ZTb8HqYhjWLSz9yQyycmzBkZrm9Nexw03zvI0DE'

const orgId = getOrgIdFromToken(token)

assert.equal(orgId, 'org123')
})

it('returns undefined when org id does not exist', () => {
const token = 'not a jwt token'
const orgId = getOrgIdFromToken(token)

assert.equal(orgId, undefined)
})
})
6 changes: 6 additions & 0 deletions src/auth/utils/getOrgIdFromToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { getTokenPayload } from './getTokenPayload'

export const getOrgIdFromToken = (token: string): string | undefined => {
const payload = getTokenPayload(token)
return payload?.org_id || payload?.['https://devcycle.com/org_id']
}
2 changes: 2 additions & 0 deletions src/auth/utils/getTokenPayload.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
type TokenPayload = {
exp: number
'https://devcycle.com/org_id'?: string // client credential tokens
org_id?: string // user sso tokens
}

export const getTokenPayload = (token: string): TokenPayload | undefined => {
Expand Down
1 change: 1 addition & 0 deletions src/auth/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './getTokenExpiry'
export * from './getTokenPayload'
export * from './shouldRefreshToken'
export * from './getOrgIdFromToken'
1 change: 0 additions & 1 deletion src/commands/alias/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ const VARIABLE_DESCRIPTION = 'The DevCycle variable key'

export default class AddAlias extends Base {
static hidden = false
runsInRepo = true
static description = 'Add a variable alias to the repo configuration'
static flags = {
...Base.flags,
Expand Down
8 changes: 4 additions & 4 deletions src/commands/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export default abstract class Base extends Command {
authToken = ''
personalAccessToken = ''
projectKey = ''
orgId = ''
authPath = path.join(this.config.configDir, 'auth.yml')
configPath = path.join(this.config.configDir, 'user.yml')
repoConfigPath = '.devcycle/config.yml'
Expand All @@ -86,8 +87,6 @@ export default abstract class Base extends Command {
authSuggested = false
// Override to true in commands that must be authorized by a user in order to function
userAuthRequired = false
// Override to true in commands that expect to run in the repo
runsInRepo = false

userConfig: UserConfigFromFile | null
repoConfig: RepoConfigFromFile | null
Expand All @@ -103,7 +102,7 @@ export default abstract class Base extends Command {
private async authorizeApi(): Promise<void> {
const { flags } = await this.parse(this.constructor as typeof Base)
const auth = new ApiAuth(this.authPath, this.config.cacheDir)
this.authToken = await auth.getToken(flags)
this.authToken = await auth.getToken(flags, this.orgId)
this.personalAccessToken = auth.getPersonalToken()

if (!this.personalAccessToken && this.userAuthRequired) {
Expand Down Expand Up @@ -231,9 +230,10 @@ export default abstract class Base extends Command {
this.projectKey = flags['project']
|| process.env.DEVCYCLE_PROJECT_KEY
|| process.env.DVC_PROJECT_KEY
|| this.runsInRepo && this.repoConfig?.project
|| this.repoConfig?.project
|| this.userConfig?.project
|| ''
this.orgId = flags['org'] || this.repoConfig?.org?.id || this.userConfig?.org?.id
await this.authorizeApi()
setDVCReferrer(this.id, this.config.version, this.caller)
}
Expand Down
1 change: 0 additions & 1 deletion src/commands/cleanup/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import {

export default class Cleanup extends Base {
static hidden = false
runsInRepo = true

static description = 'Replace a DevCycle variable with a static value in the current version of your code. ' +
'Currently only JavaScript is supported.'
Expand Down
1 change: 0 additions & 1 deletion src/commands/diff/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ type MatchesByTypeEnriched = {
export default class Diff extends Base {
static hidden = false
authSuggested = true
runsInRepo = true

static description = 'Print a diff of DevCycle variable usage between two versions of your code.'
static examples = [
Expand Down
1 change: 0 additions & 1 deletion src/commands/usages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import { Variable } from '../../api/schemas'

export default class Usages extends Base {
static hidden = false
runsInRepo = true

static description = 'Print all DevCycle variable usages in the current version of your code.'
static examples = [
Expand Down