Skip to content

feat: adds long lived refresh token feature for ci#475

Open
v3nant wants to merge 2 commits intomainfrom
feat/long-token-provision
Open

feat: adds long lived refresh token feature for ci#475
v3nant wants to merge 2 commits intomainfrom
feat/long-token-provision

Conversation

@v3nant
Copy link
Contributor

@v3nant v3nant commented Feb 12, 2026

CI Login Token Exchange: Long-Lived Refresh Tokens for Headless Auth

Summary

Adds support for long-lived organization-scoped refresh tokens for headless CI/CD pipelines. Users can provision a token once (interactive) and use it in CI without running hd auth login in the pipeline.

Design

Flow

sequenceDiagram
    participant User
    participant Login
    participant Provision
    participant CILogin
    participant Scan

    User->>Login: hd auth login
    Login->>Login: OAuth PKCE, persist tokens (keyring)

    User->>Provision: hd auth-ci provision --org-id N
    Provision->>IAM: getOrgAccessTokens(orgId, null) [Bearer from login]
    IAM-->>Provision: refreshToken (long-lived)
    Provision->>Provision: save to ~/.hdcli/ci-token (encrypted)

    Note over User: CI pipeline runs (headless)
    User->>CILogin: eval $(hd auth-ci login)
    CILogin->>IAM: getOrgAccessTokens(orgId, refreshToken)
    IAM-->>CILogin: accessToken, refreshToken (maybe rotated)
    CILogin->>User: export HD_ACCESS_TOKEN=...

    User->>Scan: hd scan eol
    Scan->>Scan: requireAccessTokenForScan uses HD_ACCESS_TOKEN
Loading

Auth Resolution Order

requireAccessTokenForScan (used by scan, Apollo default, completeUserSetup) now:

  1. CI first: If getCIToken() or config.accessTokenFromEnv → use CI path (requireCIAccessToken)
  2. Keyring second: Else use stored OAuth tokens (keyring) with refresh via Keycloak

CI tokens come from:

  • Env (CI): HD_AUTH_TOKEN, HD_ORG_ID, HD_ACCESS_TOKEN
  • File (local): ~/.hdcli/ci-token (encrypted, machine-bound)

Changes

New Commands

Command Purpose
hd auth-ci provision --org-id <id> Provision long-lived CI token (requires hd auth login first). Saves to ~/.hdcli/ci-token and outputs token for CI secrets.
hd auth-ci login Exchange CI refresh token for access token. Outputs export HD_ACCESS_TOKEN=... for eval.

New / Updated Files

  • src/commands/auth-ci/login.ts – CI login command
  • src/commands/auth-ci/provision.ts – CI provision command
  • src/service/ci-auth.svc.ts – CI auth path (exchange, no keyring)
  • src/service/ci-token.svc.ts – CI token storage (env + encrypted file via conf)
  • src/api/ci-token.client.ts – IAM getOrgAccessTokens client (provisionCIToken, exchangeCITokenForAccess)
  • src/api/apollo.client.ts – Extracted from nes.client.ts; shared Apollo factory with token provider
  • src/config/constants.ts – Added ENABLE_AUTH, ENABLE_USER_SETUP, ciTokenFromEnv, orgIdFromEnv, accessTokenFromEnv, IAM config
  • src/service/auth.svc.tsrequireAccessTokenForScan delegates to CI path first, re-exports CITokenError

README

  • CI/CD authentication section updated with new flow
  • One-time setup: hd auth loginhd auth-ci provision --org-id <id>
  • CI pipeline: eval $(hd auth-ci login) before scan
  • GitHub Actions and GitLab CI examples with HD_ORG_ID and HD_AUTH_TOKEN
  • Local testing example

Other

  • nes.client.ts: Uses shared createApollo from apollo.client.ts
  • user-setup.client.ts: completeUserSetup uses requireAccessTokenForScan; error propagation improved
  • scan/eol.ts: Auth gated by config.enableAuth; error message handling improved

Usage

One-time setup (interactive):

hd auth login
hd auth-ci provision --org-id <your-org-id>

Copy token and org ID into CI secrets: HD_AUTH_TOKEN, HD_ORG_ID.

CI pipeline:

export HD_ORG_ID=<id> HD_AUTH_TOKEN="<token>"
eval $(hd auth-ci login) && hd scan eol --dir .

Gaps & Follow-ups

Gap Notes
Docker image auth README Docker examples do not show CI auth; document if ghcr.io/herodevs/eol-scan supports HD_AUTH_TOKEN / HD_ORG_ID.
E2E with real auth E2E no longer mocks auth; verify E2E still passes when ENABLE_AUTH is false or with CI token injection.

@v3nant v3nant force-pushed the feat/long-token-provision branch 3 times, most recently from 79c0592 to 248965d Compare February 13, 2026 14:21
@v3nant v3nant force-pushed the feat/long-token-provision branch 4 times, most recently from 8c7c9ef to 6b414ed Compare February 13, 2026 15:49
@v3nant v3nant force-pushed the feat/long-token-provision branch from 6b414ed to da30ac7 Compare February 13, 2026 15:56
@v3nant v3nant marked this pull request as ready for review February 13, 2026 17:07
@v3nant v3nant requested a review from a team as a code owner February 13, 2026 17:07
export type TokenProvider = (forceRefresh?: boolean) => Promise<string>;

function isTokenEndpoint(input: string | URL | Request): boolean {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : input.toString();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nested ternaries are not great for readbility


function isTokenEndpoint(input: string | URL | Request): boolean {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : input.toString();
return url.endsWith('/token');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this is the case but what if url has request params? We might need better url parsing

const refreshed = await tokenProvider(true);
const retryHeaders = new Headers(init?.headers);
retryHeaders.set('Authorization', `Bearer ${refreshed}`);
response = await fetch(input, { ...init, headers: retryHeaders });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need to re assign you can just return

}
}

let response = await fetch(input, { ...init, headers });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you dont need the body consider performing a HEAD request. This improves the memory footprint
https://undici.nodejs.org/#/?id=garbage-collection

};
};

function getGraphqlUrl(): string {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally believe it's better to pass the config in input otherwise since its a constant you can just pre-evaluate the string into a separate variable


function extractErrorCode(errors: ReadonlyArray<GraphQLFormattedError>): ApiErrorCode | undefined {
const code = (errors[0]?.extensions as { code?: string })?.code;
if (!code || !isApiErrorCode(code)) return undefined;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!code || !isApiErrorCode(code)) return undefined;
if (!code || !isApiErrorCode(code)) return;

return callGetOrgAccessTokensInternal({ orgId, previousToken: refreshToken }, tokenProvider);
}

export async function getAccessTokenFromCIRefresh(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this looks like a passthrough function not adding much

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants