Skip to content

Commit dd24b14

Browse files
authored
fix: ensuring org user has its own setup configured (#482)
* fix: ensuring org user has its own setup configured * fix: removing unused auth access token from env * chore: promoting single env var usage for ci tokens * chore: token provision now accepts either orgId or previousToken * chore: removing back auth/user-setup feature-flags
1 parent b24910d commit dd24b14

18 files changed

Lines changed: 65 additions & 305 deletions

.envrc.example

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,8 @@ export OAUTH_CLIENT_ID='';
1616
# export IAM_HOST='https://apps.herodevs.io/api/iam';
1717
# export IAM_PATH='/graphql';
1818

19-
# Auth toggles (optional; both default false)
20-
# export ENABLE_AUTH='true';
21-
# export ENABLE_USER_SETUP='true';
22-
23-
# CI token (for headless flows)
24-
# HD_ORG_ID: required when using HD_AUTH_TOKEN; also stored when provisioning
25-
# HD_AUTH_TOKEN: refresh token from provision; exchanged for access token
26-
# HD_ACCESS_TOKEN: direct access token (skips exchange)
27-
# export HD_ORG_ID='1234';
28-
# export HD_AUTH_TOKEN='<long-lived-refresh-token>';
29-
# export HD_ACCESS_TOKEN='<access-token>';
19+
# CI credential (for headless flows):
20+
# export HD_CI_CREDENTIAL='<long-lived-refresh-token>';
3021

3122
# Performance (optional)
3223
# export CONCURRENT_PAGE_REQUESTS='3';

README.md

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -357,32 +357,31 @@ hd auth login
357357
hd auth provision-ci-token
358358
```
359359

360-
Copy the token output, add as CI secrets: `HD_AUTH_TOKEN` and `HD_ORG_ID` (orgId is obtained from user setup and stored at provision time when using locally).
360+
Copy the token output, add as CI secret: `HD_CI_CREDENTIAL`
361361

362-
**CI pipeline (headless):** Run `hd scan eol` directly with `HD_AUTH_TOKEN` and `HD_ORG_ID` set. The CLI exchanges the token for an access token automatically:
362+
**CI pipeline (headless):** Run `hd scan eol` directly with `HD_CI_CREDENTIAL` set. The CLI exchanges the token for an access token automatically:
363363

364364
```bash
365-
export HD_ORG_ID=<id> HD_AUTH_TOKEN="<token>"
365+
export HD_CI_CREDENTIAL="<token>"
366366
hd scan eol --dir .
367367
```
368368

369369
| Secret / Env Var | Purpose |
370370
|------------------|---------|
371-
| `HD_AUTH_TOKEN` | Long-lived refresh token from provision |
372-
| `HD_ORG_ID` | Organization ID (required when using HD_AUTH_TOKEN; also stored at provision time when using local file) |
371+
| `HD_CI_CREDENTIAL` | Refresh token from provision; exchanged for access token |
373372

374373
#### Local testing
375374

376375
Reproduce the CI flow locally:
377376

378377
```bash
379-
export HD_ORG_ID=1234 HD_AUTH_TOKEN="eyJ..."
378+
export HD_CI_CREDENTIAL="<token-from-provision>"
380379
hd scan eol --dir /path/to/project
381380
```
382381

383382
#### GitHub Actions (authenticated scan)
384383

385-
Add secrets `HD_AUTH_TOKEN` and `HD_ORG_ID` in your repository or organization, then:
384+
Add secret `HD_CI_CREDENTIAL` in your repository or organization, then:
386385

387386
```yaml
388387
- uses: actions/checkout@v5
@@ -391,21 +390,19 @@ Add secrets `HD_AUTH_TOKEN` and `HD_ORG_ID` in your repository or organization,
391390
node-version: '24'
392391
- name: Run EOL Scan
393392
env:
394-
HD_ORG_ID: ${{ secrets.HD_ORG_ID }}
395-
HD_AUTH_TOKEN: ${{ secrets.HD_AUTH_TOKEN }}
393+
HD_CI_CREDENTIAL: ${{ secrets.HD_CI_CREDENTIAL }}
396394
run: npx @herodevs/cli@beta scan eol -s
397395
```
398396
399397
#### GitLab CI (authenticated scan)
400398
401-
Add CI/CD variables `HD_AUTH_TOKEN` and `HD_ORG_ID` (masked) in your project:
399+
Add CI/CD variable `HD_CI_CREDENTIAL` (masked) in your project:
402400

403401
```yaml
404402
eol-scan:
405403
image: node:24
406404
variables:
407-
HD_ORG_ID: $HD_ORG_ID
408-
HD_AUTH_TOKEN: $HD_AUTH_TOKEN
405+
HD_CI_CREDENTIAL: $HD_CI_CREDENTIAL
409406
script:
410407
- npx @herodevs/cli@beta scan eol -s
411408
artifacts:

src/api/apollo.client.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client/core';
2-
import { config } from '../config/constants.ts';
32
import { requireAccessTokenForScan } from '../service/auth.svc.ts';
43

54
export type TokenProvider = (forceRefresh?: boolean) => Promise<string>;
@@ -28,17 +27,14 @@ const createAuthorizedFetch =
2827
async (input, init) => {
2928
const headers = new Headers(init?.headers);
3029

31-
if (config.enableAuth) {
32-
const token = await tokenProvider();
33-
if (token) {
34-
headers.set('Authorization', `Bearer ${token}`);
35-
}
30+
const token = await tokenProvider();
31+
if (token) {
32+
headers.set('Authorization', `Bearer ${token}`);
3633
}
3734

3835
const response = await fetch(input, { ...init, headers });
3936

4037
if (
41-
config.enableAuth &&
4238
response.status === 401 &&
4339
!isTokenEndpoint(input) &&
4440
(init?.method === 'GET' || init?.method === undefined || init?.method === 'POST')

src/api/ci-token.client.ts

Lines changed: 16 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { GraphQLFormattedError } from 'graphql';
22
import { config } from '../config/constants.ts';
33
import { requireAccessToken } from '../service/auth.svc.ts';
4-
import { isAccessTokenExpired } from '../service/auth-token.svc.ts';
54
import { debugLogger } from '../service/log.svc.ts';
65
import { createApollo } from './apollo.client.ts';
76
import { ApiError, type ApiErrorCode, isApiErrorCode } from './errors.ts';
@@ -12,26 +11,16 @@ const graphqlUrl = `${config.iamHost}${config.iamPath}`;
1211

1312
const noAuthTokenProvider = async (): Promise<string> => '';
1413

15-
function createOptionalTokenProvider(token?: string) {
16-
return async (): Promise<string> => {
17-
if (token && !isAccessTokenExpired(token)) {
18-
return token;
19-
}
20-
return '';
21-
};
22-
}
23-
24-
export interface IamAccessOrgTokensInput {
25-
orgId: number | null;
26-
previousToken: string | null;
27-
}
14+
export type IamAccessOrgTokensInput =
15+
| { orgId: number; previousToken?: never }
16+
| { orgId?: never; previousToken: string };
2817

2918
export interface ProvisionCITokenResponse {
3019
refresh_token: string;
3120
}
3221

3322
export interface ProvisionCITokenOptions {
34-
orgId?: number | null;
23+
orgId?: number;
3524
previousToken?: string | null;
3625
}
3726

@@ -130,23 +119,25 @@ async function callGetOrgAccessTokensInternal(
130119

131120
export interface ExchangeCITokenOptions {
132121
refreshToken: string;
133-
orgId: number;
134-
optionalAccessToken?: string;
135122
}
136123

137124
export async function exchangeCITokenForAccess(
138125
options: ExchangeCITokenOptions,
139126
): Promise<{ accessToken: string; refreshToken: string }> {
140-
const { refreshToken, orgId, optionalAccessToken } = options;
141-
const tokenProvider = createOptionalTokenProvider(optionalAccessToken);
142-
return callGetOrgAccessTokensInternal({ orgId, previousToken: refreshToken }, tokenProvider);
127+
const { refreshToken } = options;
128+
return callGetOrgAccessTokensInternal({ previousToken: refreshToken }, noAuthTokenProvider);
143129
}
144130

145131
export async function provisionCIToken(options: ProvisionCITokenOptions = {}): Promise<ProvisionCITokenResponse> {
146-
const { orgId = null, previousToken = null } = options;
147-
const result = await getOrgAccessTokens({
148-
orgId,
149-
previousToken,
150-
});
132+
const { orgId, previousToken } = options;
133+
let input: IamAccessOrgTokensInput;
134+
if (previousToken != null && previousToken !== '') {
135+
input = { previousToken };
136+
} else if (orgId != null) {
137+
input = { orgId };
138+
} else {
139+
throw new Error('Either orgId or previousToken is required to provision a CI token');
140+
}
141+
const result = await getOrgAccessTokens(input);
151142
return { refresh_token: result.refreshToken };
152143
}

src/api/user-setup.client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ function extractErrorCode(errors: ReadonlyArray<GraphQLFormattedError>): ApiErro
3838

3939
function getTokenProvider(preferOAuth?: boolean) {
4040
if (preferOAuth) return requireAccessToken;
41-
if (getCIToken() || config.accessTokenFromEnv) return requireAccessTokenForScan;
41+
if (getCIToken()) return requireAccessTokenForScan;
4242
return requireAccessToken;
4343
}
4444

src/commands/auth/login.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { createInterface } from 'node:readline';
44
import { URL } from 'node:url';
55
import { Command } from '@oclif/core';
66
import { ensureUserSetup } from '../../api/user-setup.client.ts';
7-
import { config } from '../../config/constants.ts';
87
import { persistTokenResponse } from '../../service/auth.svc.ts';
98
import { getClientId, getRealmUrl } from '../../service/auth-config.svc.ts';
109
import { debugLogger, getErrorMessage } from '../../service/log.svc.ts';
@@ -49,12 +48,10 @@ export default class AuthLogin extends Command {
4948
return;
5049
}
5150

52-
if (config.enableUserSetup) {
53-
try {
54-
await ensureUserSetup({ preferOAuth: true });
55-
} catch (error) {
56-
this.error(`User setup failed. ${getErrorMessage(error)}`);
57-
}
51+
try {
52+
await ensureUserSetup({ preferOAuth: true });
53+
} catch (error) {
54+
this.error(`User setup failed. ${getErrorMessage(error)}`);
5855
}
5956

6057
this.log('\nLogin completed successfully.');

src/commands/auth/provision-ci-token.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Command } from '@oclif/core';
22
import { provisionCIToken } from '../../api/ci-token.client.ts';
33
import { ensureUserSetup } from '../../api/user-setup.client.ts';
44
import { requireAccessToken } from '../../service/auth.svc.ts';
5-
import { saveCIOrgId, saveCIToken } from '../../service/ci-token.svc.ts';
5+
import { saveCIToken } from '../../service/ci-token.svc.ts';
66
import { getErrorMessage } from '../../service/log.svc.ts';
77

88
export default class AuthProvisionCiToken extends Command {
@@ -19,7 +19,7 @@ export default class AuthProvisionCiToken extends Command {
1919

2020
let orgId: number;
2121
try {
22-
orgId = await ensureUserSetup({ preferOAuth: true });
22+
orgId = await ensureUserSetup();
2323
} catch (error) {
2424
this.error(`User setup failed. ${getErrorMessage(error)}`);
2525
}
@@ -28,12 +28,10 @@ export default class AuthProvisionCiToken extends Command {
2828
const result = await provisionCIToken({ orgId });
2929
const refreshToken = result.refresh_token;
3030
saveCIToken(refreshToken);
31-
saveCIOrgId(orgId);
3231
this.log('CI token provisioned and saved locally.');
3332
this.log('');
34-
this.log('For CI/CD, set these environment variables:');
35-
this.log(` HD_ORG_ID=${orgId}`);
36-
this.log(` HD_AUTH_TOKEN=${refreshToken}`);
33+
this.log('For CI/CD, set this environment variable:');
34+
this.log(` HD_CI_CREDENTIAL=${refreshToken}`);
3735
} catch (error) {
3836
this.error(`CI token provisioning failed. ${getErrorMessage(error)}`);
3937
}

src/config/constants.ts

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@ export const GIT_OUTPUT_FORMAT = `"${['%h', '%an', '%ad'].join('|')}"`;
1212
export const DEFAULT_DATE_FORMAT = 'yyyy-MM-dd';
1313
export const DEFAULT_DATE_COMMIT_FORMAT = 'MM/dd/yyyy, h:mm:ss a';
1414
export const DEFAULT_DATE_COMMIT_MONTH_FORMAT = 'MMMM yyyy';
15-
export const ENABLE_AUTH = false;
16-
export const ENABLE_USER_SETUP = false;
1715

1816
// Trackers - Constants
1917
export const DEFAULT_TRACKER_RUN_DATA_FILE = 'data.json';
@@ -31,15 +29,6 @@ if (parsedPageSize > 0) {
3129
pageSize = parsedPageSize;
3230
}
3331

34-
const enableAuthEnv = process.env.ENABLE_AUTH;
35-
const enableAuth = enableAuthEnv === 'true' ? true : enableAuthEnv === 'false' ? false : ENABLE_AUTH;
36-
const enableUserSetupEnv = process.env.ENABLE_USER_SETUP;
37-
const enableUserSetup =
38-
enableUserSetupEnv === 'true' ? true : enableUserSetupEnv === 'false' ? false : ENABLE_USER_SETUP;
39-
const orgIdEnv = process.env.HD_ORG_ID?.trim();
40-
const orgIdParsed = orgIdEnv ? Number.parseInt(orgIdEnv, 10) : NaN;
41-
const orgIdFromEnv = Number.isInteger(orgIdParsed) && orgIdParsed >= 1 ? orgIdParsed : undefined;
42-
4332
export const config = {
4433
eolReportUrl: process.env.EOL_REPORT_URL || EOL_REPORT_URL,
4534
graphqlHost: process.env.GRAPHQL_HOST || GRAPHQL_HOST,
@@ -49,11 +38,7 @@ export const config = {
4938
analyticsUrl: process.env.ANALYTICS_URL || ANALYTICS_URL,
5039
concurrentPageRequests,
5140
pageSize,
52-
enableAuth,
53-
enableUserSetup,
54-
ciTokenFromEnv: process.env.HD_AUTH_TOKEN?.trim() || undefined,
55-
orgIdFromEnv,
56-
accessTokenFromEnv: process.env.HD_ACCESS_TOKEN?.trim() || undefined,
41+
ciTokenFromEnv: process.env.HD_CI_CREDENTIAL?.trim() || undefined,
5742
};
5843

5944
export const filenamePrefix = 'herodevs';

src/service/auth.svc.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { config } from '../config/constants.ts';
21
import type { TokenResponse } from '../types/auth.ts';
32
import { refreshTokens } from './auth-refresh.svc.ts';
43
import { clearStoredTokens, getStoredTokens, isAccessTokenExpired, saveTokens } from './auth-token.svc.ts';
@@ -61,7 +60,7 @@ export async function logoutLocally() {
6160
}
6261

6362
export async function requireAccessTokenForScan(): Promise<string> {
64-
if (getCIToken() || config.accessTokenFromEnv) {
63+
if (getCIToken()) {
6564
return requireCIAccessToken();
6665
}
6766

src/service/ci-auth.svc.ts

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
import { exchangeCITokenForAccess } from '../api/ci-token.client.ts';
22
import { config } from '../config/constants.ts';
3-
import { isAccessTokenExpired } from './auth-token.svc.ts';
4-
import { getCIOrgId, getCIToken, saveCIToken } from './ci-token.svc.ts';
3+
import { getCIToken, saveCIToken } from './ci-token.svc.ts';
54
import { debugLogger } from './log.svc.ts';
65

76
export type CITokenErrorCode = 'CI_TOKEN_INVALID' | 'CI_TOKEN_REFRESH_FAILED' | 'CI_ORG_ID_REQUIRED';
87

98
const CITOKEN_ERROR_MESSAGE =
109
"CI token is invalid or expired. To provision a new CI token, run 'hd auth provision-ci-token' (after logging in with 'hd auth login').";
1110

12-
const CI_ORG_ID_ERROR_MESSAGE =
13-
'Organization ID is required for CI token. When using HD_AUTH_TOKEN, set HD_ORG_ID to your organization ID (e.g. HD_ORG_ID=123). When using a locally stored CI token, re-provision with: hd auth provision-ci-token';
14-
1511
export class CITokenError extends Error {
1612
readonly code: CITokenErrorCode;
1713

@@ -23,25 +19,14 @@ export class CITokenError extends Error {
2319
}
2420

2521
export async function requireCIAccessToken(): Promise<string> {
26-
if (config.accessTokenFromEnv && !isAccessTokenExpired(config.accessTokenFromEnv)) {
27-
return config.accessTokenFromEnv;
28-
}
29-
3022
const ciToken = getCIToken();
3123
if (!ciToken) {
3224
throw new CITokenError(CITOKEN_ERROR_MESSAGE, 'CI_TOKEN_INVALID');
3325
}
3426

35-
const orgId = config.ciTokenFromEnv !== undefined ? config.orgIdFromEnv : getCIOrgId();
36-
if (orgId === undefined) {
37-
throw new CITokenError(CI_ORG_ID_ERROR_MESSAGE, 'CI_ORG_ID_REQUIRED');
38-
}
39-
4027
try {
4128
const result = await exchangeCITokenForAccess({
4229
refreshToken: ciToken,
43-
orgId,
44-
optionalAccessToken: config.accessTokenFromEnv,
4530
});
4631
if (result.refreshToken && config.ciTokenFromEnv === undefined) {
4732
saveCIToken(result.refreshToken);

0 commit comments

Comments
 (0)