feat: adds long lived refresh token feature for ci#475
Conversation
79c0592 to
248965d
Compare
8c7c9ef to
6b414ed
Compare
6b414ed to
da30ac7
Compare
| 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(); |
There was a problem hiding this comment.
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'); |
There was a problem hiding this comment.
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 }); |
There was a problem hiding this comment.
no need to re assign you can just return
| } | ||
| } | ||
|
|
||
| let response = await fetch(input, { ...init, headers }); |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
| if (!code || !isApiErrorCode(code)) return undefined; | |
| if (!code || !isApiErrorCode(code)) return; |
| return callGetOrgAccessTokensInternal({ orgId, previousToken: refreshToken }, tokenProvider); | ||
| } | ||
|
|
||
| export async function getAccessTokenFromCIRefresh( |
There was a problem hiding this comment.
this looks like a passthrough function not adding much
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 loginin 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_TOKENAuth Resolution Order
requireAccessTokenForScan(used by scan, Apollo default,completeUserSetup) now:getCIToken()orconfig.accessTokenFromEnv→ use CI path (requireCIAccessToken)CI tokens come from:
HD_AUTH_TOKEN,HD_ORG_ID,HD_ACCESS_TOKEN~/.hdcli/ci-token(encrypted, machine-bound)Changes
New Commands
hd auth-ci provision --org-id <id>hd auth loginfirst). Saves to~/.hdcli/ci-tokenand outputs token for CI secrets.hd auth-ci loginexport HD_ACCESS_TOKEN=...foreval.New / Updated Files
src/commands/auth-ci/login.ts– CI login commandsrc/commands/auth-ci/provision.ts– CI provision commandsrc/service/ci-auth.svc.ts– CI auth path (exchange, no keyring)src/service/ci-token.svc.ts– CI token storage (env + encrypted file viaconf)src/api/ci-token.client.ts– IAMgetOrgAccessTokensclient (provisionCIToken,exchangeCITokenForAccess)src/api/apollo.client.ts– Extracted fromnes.client.ts; shared Apollo factory with token providersrc/config/constants.ts– AddedENABLE_AUTH,ENABLE_USER_SETUP,ciTokenFromEnv,orgIdFromEnv,accessTokenFromEnv, IAM configsrc/service/auth.svc.ts–requireAccessTokenForScandelegates to CI path first, re-exportsCITokenErrorREADME
hd auth login→hd auth-ci provision --org-id <id>eval $(hd auth-ci login)before scanHD_ORG_IDandHD_AUTH_TOKENOther
nes.client.ts: Uses sharedcreateApollofromapollo.client.tsuser-setup.client.ts:completeUserSetupusesrequireAccessTokenForScan; error propagation improvedscan/eol.ts: Auth gated byconfig.enableAuth; error message handling improvedUsage
One-time setup (interactive):
Copy token and org ID into CI secrets:
HD_AUTH_TOKEN,HD_ORG_ID.CI pipeline:
Gaps & Follow-ups
ghcr.io/herodevs/eol-scansupportsHD_AUTH_TOKEN/HD_ORG_ID.ENABLE_AUTHis false or with CI token injection.