-
Notifications
You must be signed in to change notification settings - Fork 11
feat: OIDC trusted publishing — deploy from CI with no stored credential #2173
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
37 commits
Select commit
Hold shift + click to select a range
c8c1164
feat(security): OIDC identity token verification for trusted publishing
dawsontoth 97e0902
feat(security): hdb_oidc_trust table and trust policy operations
dawsontoth 63e026c
feat(security): exchange_oidc_token — mint a token from a CI identity
dawsontoth 1f27a6c
feat(cli): exchange a CI identity for a Harper token automatically
dawsontoth 08c7ceb
refactor(security): cut comments the code can carry itself
dawsontoth 568cfbf
fix(security): keep identity and refresh tokens out of the operations…
dawsontoth b0bfa96
refactor(security): make the OIDC core issuer-agnostic, GitHub a profile
dawsontoth 7ddbf0f
test: remove the JWT keys the exchange suite writes, and share the he…
dawsontoth 2e2dd0c
feat(security): narrow a minted token to a subset of its user's opera…
dawsontoth ff04730
fix(security): carry an empty operation scope instead of dropping it
dawsontoth 4cf3881
fix(security): apply the token operation scope to the SQL path too
dawsontoth b66736f
fix(security): carry the token operation scope across credential minting
dawsontoth 1e84eaf
fix(security): deny a scoped token from minting a login token
dawsontoth 9daa298
refactor(security): extract the token-scope carry-forward into one he…
dawsontoth 86799c2
fix(security): gate the token scope on the API operation, not the han…
dawsontoth c8d429b
fix(security): gate the token scope on the job op for non-SQL export …
dawsontoth a0d4e62
fix(cli): drop an unused path import left by the rebase conflict reso…
dawsontoth d8740b7
fix(security): harden token scoping per deep-review (4 findings)
dawsontoth 41042ec
fix(security): close credential-minting, scope, and lifetime gaps fro…
dawsontoth 2afa5a6
fix(security): key replay on the signed input, and fix issuer/audienc…
dawsontoth 562fa75
fix(security): bootstrap the replay table and carry the job's real op…
dawsontoth 0286fb7
fix(security): reject a non-boolean `enabled`, and pin exact claim ma…
dawsontoth db6a5a9
fix(security): act on the SQL permission denial processAST was discar…
dawsontoth 6487950
refactor: split the processAST guard fix out to its own PR (#2202)
dawsontoth 602cdc6
fix(security): never read the SQL scope's operation from the request …
dawsontoth 42315f8
fix(upgrade): patch is_hash_attribute on the replay table; pin the en…
dawsontoth ce1f999
docs(upgrade): record the TTL-on-first-use limitation for the replay …
dawsontoth 14bcc6f
test: assert the export-job scope gate enforces, not just computes
dawsontoth ced27a5
test: make the #2202 ordering constraint fail loudly instead of livin…
dawsontoth 10256f4
test: decouple the #2202 tripwire from the status literal it watches
dawsontoth 9c322ed
fix(security): refuse malformed stored policies; let exchange_oidc_to…
dawsontoth 2ebb8e0
fix(security): back off after a failed JWKS refresh; stop overclaimin…
dawsontoth d8d826c
fix(security): apply the JWKS backoff to an issuer with no cached keys
dawsontoth be34107
fix(security): tell the truth in list_oidc_trust; pin the guards the …
dawsontoth 9436f50
fix(security): report a deleted or deactivated policy user in list_oi…
dawsontoth d5a1821
test: pin the invalid_reason precedence a row with both problems reli…
dawsontoth 5da87ee
test: pin the stale-key grace ceiling with an injected clock
dawsontoth File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| /** | ||
| * Client half of OIDC trusted publishing (#2171): ask the runtime for a workload identity token | ||
| * addressed to this instance, trade it for a short-lived operation token via `exchange_oidc_token`. | ||
| * | ||
| * Structured as a provider list because the runtimes differ only in how the token is obtained. | ||
| * GitHub Actions is entry one; a Kubernetes entry is `available()` testing for a projected | ||
| * service-account token path and `requestToken()` reading that file. A runtime none of them | ||
| * recognizes falls through to the CLI's other credential sources. | ||
| */ | ||
|
|
||
| import { httpRequest } from '../utility/common_utils.ts'; | ||
|
|
||
| interface WorkloadIdentityProvider { | ||
| name: string; | ||
| /** True when this process can obtain a token from this runtime. */ | ||
| available(): boolean; | ||
| /** | ||
| * Obtains an identity token bound to `audience`. Binding it is what makes the token unusable | ||
| * anywhere else — see SHARED_DEFAULT_AUDIENCE in security/authn/oidc/providers/githubActions.ts | ||
| * for what an unbound one costs. | ||
| */ | ||
| requestToken(audience: string): Promise<string>; | ||
| } | ||
|
|
||
| /** GitHub sets both of these on a job that declares `permissions: id-token: write`. */ | ||
| const GITHUB_TOKEN_REQUEST_URL = 'ACTIONS_ID_TOKEN_REQUEST_URL'; | ||
| const GITHUB_TOKEN_REQUEST_TOKEN = 'ACTIONS_ID_TOKEN_REQUEST_TOKEN'; | ||
|
|
||
| const IDENTITY_REQUEST_TIMEOUT_MS = 10_000; | ||
|
|
||
| const githubActions: WorkloadIdentityProvider = { | ||
| name: 'GitHub Actions', | ||
|
|
||
| /** | ||
| * Both variables are required: GitHub sets them together, so their absence means the workflow did | ||
| * not grant `id-token: write` — a configuration answer, not a failure to report here. | ||
| */ | ||
| available(): boolean { | ||
| return Boolean(process.env[GITHUB_TOKEN_REQUEST_URL] && process.env[GITHUB_TOKEN_REQUEST_TOKEN]); | ||
| }, | ||
|
|
||
| async requestToken(audience: string): Promise<string> { | ||
| const requestUrl = new URL(process.env[GITHUB_TOKEN_REQUEST_URL] as string); | ||
| requestUrl.searchParams.set('audience', audience); | ||
|
|
||
| const response = await fetch(requestUrl, { | ||
| headers: { | ||
| authorization: `Bearer ${process.env[GITHUB_TOKEN_REQUEST_TOKEN]}`, | ||
| accept: 'application/json', | ||
| }, | ||
| signal: AbortSignal.timeout(IDENTITY_REQUEST_TIMEOUT_MS), | ||
| }); | ||
| if (!response.ok) throw new Error(`GitHub returned ${response.status} for an identity token`); | ||
|
|
||
| const body: any = await response.json(); | ||
| if (typeof body?.value !== 'string' || body.value === '') { | ||
| throw new Error('GitHub returned no identity token value'); | ||
| } | ||
| return body.value; | ||
| }, | ||
| }; | ||
|
|
||
| const PROVIDERS: WorkloadIdentityProvider[] = [githubActions]; | ||
|
|
||
| function activeProvider(): WorkloadIdentityProvider | undefined { | ||
| return PROVIDERS.find((provider) => provider.available()); | ||
| } | ||
|
|
||
| /** True when this runtime can prove its own identity to the cluster. */ | ||
| export function workloadIdentityAvailable(): boolean { | ||
| return activeProvider() !== undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Trades a workload identity token for a Harper operation token, or undefined when this runtime has | ||
| * no identity to offer. Failures are reported and swallowed: this is the last credential source | ||
| * before the request goes out unauthenticated, and the resulting 401 says nothing useful, so the | ||
| * reason is worth printing even though it is not by itself fatal. | ||
| */ | ||
| export async function exchangeWorkloadIdentityForToken(options: any, audience: string): Promise<string | undefined> { | ||
| const provider = activeProvider(); | ||
| if (!provider) return undefined; | ||
|
|
||
| console.error(`Requesting a ${provider.name} identity token for ${audience}...`); | ||
| let identityToken: string; | ||
| try { | ||
| identityToken = await provider.requestToken(audience); | ||
| } catch (error) { | ||
| console.error(`Could not obtain a ${provider.name} identity token: ${(error as Error).message}`); | ||
| return undefined; | ||
| } | ||
|
|
||
| try { | ||
| const response = await httpRequest(options, { operation: 'exchange_oidc_token', token: identityToken }); | ||
|
dawsontoth marked this conversation as resolved.
|
||
| if (response.statusCode === 200) { | ||
| const data = JSON.parse(response.body); | ||
| if (data.operation_token) { | ||
| console.error(`Authenticated as '${data.username}' via OIDC trust policy '${data.policy}'.`); | ||
| return data.operation_token; | ||
| } | ||
| console.error('The OIDC exchange returned no operation token.'); | ||
| return undefined; | ||
| } | ||
| if (response.statusCode === 401) { | ||
| // The server deliberately does not say which check failed, so point at the two things the | ||
| // operator can actually inspect rather than inventing a cause. | ||
| console.error( | ||
| 'Harper rejected the identity token. Check that a trust policy matches this workload ' + | ||
| '(list_oidc_trust) and that its audience is this instance; the server log records the reason.' | ||
| ); | ||
| return undefined; | ||
| } | ||
| console.error(`OIDC exchange failed: ${response.statusCode}`); | ||
| return undefined; | ||
| } catch (error) { | ||
| console.error(`Error exchanging the identity token: ${(error as Error).message}`); | ||
| return undefined; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.