diff --git a/oclif.manifest.json b/oclif.manifest.json index c8a02b6c4..57f5fe794 100644 --- a/oclif.manifest.json +++ b/oclif.manifest.json @@ -1,5 +1,5 @@ { - "version": "5.2.0", + "version": "5.2.1", "commands": { "authCommand": { "id": "authCommand", diff --git a/src/auth/ApiAuth.ts b/src/auth/ApiAuth.ts index c9e19ba0b..b468b9726 100644 --- a/src/auth/ApiAuth.ts +++ b/src/auth/ApiAuth.ts @@ -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 = { @@ -24,7 +24,7 @@ export class ApiAuth { this.tokenCache = new TokenCache(cacheDir) } - public async getToken(flags: SupportedFlags): Promise { + public async getToken(flags: SupportedFlags, orgId?: string): Promise { const clientId = flags['client-id'] || process.env.DEVCYCLE_CLIENT_ID || process.env.DVC_CLIENT_ID @@ -37,7 +37,7 @@ export class ApiAuth { } if (this.authPath && fs.existsSync(this.authPath)) { - return this.getTokenFromAuthFile() + return this.getTokenFromAuthFile(orgId) } return '' @@ -59,11 +59,19 @@ export class ApiAuth { return config } - private async getTokenFromAuthFile(): Promise { + private async getTokenFromAuthFile(orgId?: string): Promise { 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) diff --git a/src/auth/config.ts b/src/auth/config.ts index f1e614c45..f00512a2b 100644 --- a/src/auth/config.ts +++ b/src/auth/config.ts @@ -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() @@ -27,6 +28,9 @@ export class SSOAuthConfig { @IsString() @IsOptional() personalAccessToken?: string + + @IsOptional() + orgs?: Record> } export class AuthConfig { @@ -41,16 +45,25 @@ export class AuthConfig { sso?: SSOAuthConfig } -export function storeAccessToken(tokens: Partial, 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)) } diff --git a/src/auth/utils/getOrgIdFromToken.test.ts b/src/auth/utils/getOrgIdFromToken.test.ts new file mode 100644 index 000000000..f4d089af0 --- /dev/null +++ b/src/auth/utils/getOrgIdFromToken.test.ts @@ -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) + }) +}) diff --git a/src/auth/utils/getOrgIdFromToken.ts b/src/auth/utils/getOrgIdFromToken.ts new file mode 100644 index 000000000..854647e6a --- /dev/null +++ b/src/auth/utils/getOrgIdFromToken.ts @@ -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'] +} diff --git a/src/auth/utils/getTokenPayload.ts b/src/auth/utils/getTokenPayload.ts index 43bd51984..cecfbf2c2 100644 --- a/src/auth/utils/getTokenPayload.ts +++ b/src/auth/utils/getTokenPayload.ts @@ -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 => { diff --git a/src/auth/utils/index.ts b/src/auth/utils/index.ts index 97b8c279a..5b2149b5d 100644 --- a/src/auth/utils/index.ts +++ b/src/auth/utils/index.ts @@ -1,3 +1,4 @@ export * from './getTokenExpiry' export * from './getTokenPayload' export * from './shouldRefreshToken' +export * from './getOrgIdFromToken' diff --git a/src/commands/alias/add.ts b/src/commands/alias/add.ts index 9dce91e8b..e80943f9a 100644 --- a/src/commands/alias/add.ts +++ b/src/commands/alias/add.ts @@ -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, diff --git a/src/commands/base.ts b/src/commands/base.ts index deba1962f..ab9e0255a 100644 --- a/src/commands/base.ts +++ b/src/commands/base.ts @@ -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' @@ -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 @@ -103,7 +102,7 @@ export default abstract class Base extends Command { private async authorizeApi(): Promise { 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) { @@ -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) } diff --git a/src/commands/cleanup/index.ts b/src/commands/cleanup/index.ts index 424ea78f6..ddae21f90 100644 --- a/src/commands/cleanup/index.ts +++ b/src/commands/cleanup/index.ts @@ -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.' diff --git a/src/commands/diff/index.ts b/src/commands/diff/index.ts index 410ad858e..333362d77 100644 --- a/src/commands/diff/index.ts +++ b/src/commands/diff/index.ts @@ -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 = [ diff --git a/src/commands/usages/index.ts b/src/commands/usages/index.ts index 7bd9cf329..a76b1e0f8 100644 --- a/src/commands/usages/index.ts +++ b/src/commands/usages/index.ts @@ -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 = [