From 21bf1c44c11ffa87e7936e534aa79ae2930a866b Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 21 May 2026 13:12:51 +0200 Subject: [PATCH 1/5] Add action to get short-lived access token using OIDC --- .github/actions/get-token/action.yml | 86 ++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 .github/actions/get-token/action.yml diff --git a/.github/actions/get-token/action.yml b/.github/actions/get-token/action.yml new file mode 100644 index 00000000..f8fcffbc --- /dev/null +++ b/.github/actions/get-token/action.yml @@ -0,0 +1,86 @@ +name: Get Token +description: 'Get a short-lived access token to interact with the GitHub API. Note: Using this action requires the "id-token: write" permission to be set for the GitHub Actions workflow job.' + +inputs: + token-exchange-url: + description: 'The URL to exchange the OIDC token for an access token.' + required: true + permissions: + description: 'The permissions to request for the access token, in the format of "scope: permission", separated by newlines.' + required: true + +outputs: + token: + value: '${{ steps.access-token.outputs.token }}' + description: 'The short-lived access token obtained from the token exchange service.' + +runs: + using: composite + steps: + - name: Get OIDC token + id: oidc-token + uses: actions/github-script@v9 + with: + script: | + const token = await core.getIDToken(); + core.setSecret(token); + core.setOutput('token', token); + + - name: Exchange OIDC token + id: access-token + uses: actions/github-script@v9 + env: + OIDC_TOKEN: ${{ steps.oidc-token.outputs.token }} + TOKEN_EXCHANGE_URL: ${{ inputs.token-exchange-url }} + REQUESTED_PERMISSIONS: ${{ inputs.permissions }} + with: + script: | + const { + GITHUB_REPOSITORY, + OIDC_TOKEN, + TOKEN_EXCHANGE_URL, + REQUESTED_PERMISSIONS, + } = process.env; + + // This assumes `REQUESTED_PERMISSIONS` is a newline-separated string + // of "scope: permission" pairs, e.g.: + // ``` + // contents: read + // issues: write + // ``` + const requestedPermissions = REQUESTED_PERMISSIONS + .split('\n') + .filter(line => line.trim() !== '') + .map(line => { + const [scope, permission] = line.split(':').map(part => part.trim()); + return { scope, permission }; + }); + + const response = await fetch(`${TOKEN_EXCHANGE_URL}/api/exchange/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + oidcToken: OIDC_TOKEN, + targetRepo: GITHUB_REPOSITORY, + requested_permissions: requestedPermissions, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + return core.setFailed(`Token exchange failed: ${response.status} ${response.statusText} - ${errorText}`); + } + + const { token, permissions, expires_at, } = await response.json(); + if (!token) { + return core.setFailed('Token exchange response did not contain an access token.'); + } + + core.setSecret(token); + core.setOutput('token', token); + + core.info(`Access token obtained successfully. Token expires at: "${expires_at}".`); + + From be7b59c11d096c7aa5f153be838ae062115be1ca Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 21 May 2026 13:13:22 +0200 Subject: [PATCH 2/5] Remove extra newline --- .github/actions/get-token/action.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/actions/get-token/action.yml b/.github/actions/get-token/action.yml index f8fcffbc..e306ae3c 100644 --- a/.github/actions/get-token/action.yml +++ b/.github/actions/get-token/action.yml @@ -82,5 +82,3 @@ runs: core.setOutput('token', token); core.info(`Access token obtained successfully. Token expires at: "${expires_at}".`); - - From 318a7b2517c01f0f7c01ce8f67aab0fc4594aa19 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 28 May 2026 18:11:54 +0200 Subject: [PATCH 3/5] Fix format of `requested_permissions` --- .github/actions/get-token/action.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/actions/get-token/action.yml b/.github/actions/get-token/action.yml index e306ae3c..8ff1c7a8 100644 --- a/.github/actions/get-token/action.yml +++ b/.github/actions/get-token/action.yml @@ -48,13 +48,12 @@ runs: // contents: read // issues: write // ``` - const requestedPermissions = REQUESTED_PERMISSIONS - .split('\n') - .filter(line => line.trim() !== '') - .map(line => { - const [scope, permission] = line.split(':').map(part => part.trim()); - return { scope, permission }; - }); + const requestedPermissions = Object.fromEntries( + REQUESTED_PERMISSIONS + .split('\n') + .filter(line => line.trim() !== '') + .map(line => line.split(':').map(part => part.trim())) + ); const response = await fetch(`${TOKEN_EXCHANGE_URL}/api/exchange/token`, { method: 'POST', From a79d2501886ddab8f822d2d1deffd2fec64ee748 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 28 May 2026 18:15:00 +0200 Subject: [PATCH 4/5] Add OIDC audience --- .github/actions/get-token/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/get-token/action.yml b/.github/actions/get-token/action.yml index 8ff1c7a8..894d750b 100644 --- a/.github/actions/get-token/action.yml +++ b/.github/actions/get-token/action.yml @@ -22,7 +22,7 @@ runs: uses: actions/github-script@v9 with: script: | - const token = await core.getIDToken(); + const token = await core.getIDToken('api://token-exchange-service'); core.setSecret(token); core.setOutput('token', token); From 8bfb594eb4d316d929ee191c77383b17c6237a0c Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 29 May 2026 00:26:27 +0200 Subject: [PATCH 5/5] Add option to set target repository --- .github/actions/get-token/action.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/actions/get-token/action.yml b/.github/actions/get-token/action.yml index 894d750b..51b0a3f0 100644 --- a/.github/actions/get-token/action.yml +++ b/.github/actions/get-token/action.yml @@ -8,6 +8,10 @@ inputs: permissions: description: 'The permissions to request for the access token, in the format of "scope: permission", separated by newlines.' required: true + target-repository: + description: 'The repository to target for the access token. Defaults to the current repository.' + required: false + default: '${{ github.repository }}' outputs: token: @@ -33,13 +37,14 @@ runs: OIDC_TOKEN: ${{ steps.oidc-token.outputs.token }} TOKEN_EXCHANGE_URL: ${{ inputs.token-exchange-url }} REQUESTED_PERMISSIONS: ${{ inputs.permissions }} + TARGET_REPOSITORY: ${{ inputs.target-repository }} with: script: | const { - GITHUB_REPOSITORY, OIDC_TOKEN, TOKEN_EXCHANGE_URL, REQUESTED_PERMISSIONS, + TARGET_REPOSITORY, } = process.env; // This assumes `REQUESTED_PERMISSIONS` is a newline-separated string @@ -62,7 +67,7 @@ runs: }, body: JSON.stringify({ oidcToken: OIDC_TOKEN, - targetRepo: GITHUB_REPOSITORY, + targetRepo: TARGET_REPOSITORY, requested_permissions: requestedPermissions, }), });