Skip to content

Commit

Permalink
Support trusted publishers (#138)
Browse files Browse the repository at this point in the history
  • Loading branch information
segiddins authored Dec 11, 2023
1 parent 506bea6 commit 93fa771
Show file tree
Hide file tree
Showing 11 changed files with 265 additions and 38 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/check-dist.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Set Node.js 16.x
- name: Set up Node.js
uses: actions/[email protected]
with:
node-version: 16.x
node-version-file: .nvmrc

- name: Install dependencies
run: npm ci
Expand Down
28 changes: 26 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
npm install
- run: |
npm run all
test: # make sure the action works on a clean machine without building
test-oidc: # make sure the action works on a clean machine without building
runs-on: ubuntu-latest
permissions:
id-token: write
Expand All @@ -42,8 +42,32 @@ jobs:
- name: Test token
run: |
curl -v --fail '${{ matrix.gem-server }}/api/v1/oidc/api_key_roles/${{ matrix.roleToken }}' -H 'Authorization: ${{ env.RUBYGEMS_API_KEY }}'
test-trusted-publisher: # make sure the action works on a clean machine without building
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
strategy:
fail-fast: false
matrix:
gem-server:
- 'rubygems.org'
- 'staging.rubygems.org'
- 'oidc-api-token.rubygems.org'

steps:
- uses: actions/checkout@v4
- uses: ./
with:
gem-server: 'https://${{ matrix.gem-server }}'
audience: '${{ matrix.gem-server }}'
- name: Test token
run: |
output="$(curl -s -w "\n\n%{http_code}" -v -X POST 'https://${{ matrix.gem-server }}/api/v1/gems' -H 'Authorization: ${{ env.RUBYGEMS_API_KEY }}' -H 'Accept: application/json')"
expected="$(printf "RubyGems.org cannot process this gem.\nPlease try rebuilding it and installing it locally to make sure it's valid.\nError:\npackage metadata is missing\n\n\n422")"
test "$output" = "$expected" || (echo "$output" && exit 1)
test-all:
needs: test
needs: [test-oidc, test-trusted-publisher]
runs-on: ubuntu-latest
steps:
- run: |
Expand Down
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
20
46 changes: 46 additions & 0 deletions __tests__/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as core from '@actions/core'

import {configureApiToken} from '../src/configure-api-token'
import {assumeRole} from '../src/oidc/assumeRole'
import {exchangeToken} from '../src/oidc/trustedPublisher'

jest.mock('os', () => {
const originalModule = jest.requireActual('os') as any
Expand Down Expand Up @@ -125,6 +126,51 @@ describe('assumeRole', () => {
})
})

describe('exchangeToken', () => {
test('works', async () => {
jest.spyOn(core, 'getIDToken').mockReturnValue(Promise.resolve('ID_TOKEN'))

nock('https://rubygems.org')
.post('/api/v1/oidc/trusted_publisher/exchange_token', {
jwt: 'ID_TOKEN'
})
.reply(201, {
name: 'role name',
rubygems_api_key: 'API_KEY',
expires_at: '2021-01-01T00:00:00Z',
scopes: ['push_rubygem']
})

await expect(
exchangeToken('rubygems.org', 'https://rubygems.org')
).resolves.toEqual({
expiresAt: '2021-01-01T00:00:00Z',
gem: undefined,
name: 'role name',
rubygemsApiKey: 'API_KEY',
scopes: ['push_rubygem']
})
})

test('handles a 404', async () => {
jest.spyOn(core, 'getIDToken').mockReturnValue(Promise.resolve('ID_TOKEN'))

nock('https://rubygems.org')
.post('/api/v1/oidc/trusted_publisher/exchange_token', {
jwt: 'ID_TOKEN'
})
.reply(404, '')

await expect(
exchangeToken('rubygems.org', 'https://rubygems.org')
).rejects.toEqual(
new Error(
'No trusted publisher configured for this workflow found on https://rubygems.org for audience rubygems.org'
)
)
})
})

function mockHomedir(homedir: string) {
mockOf(os.homedir).mockReturnValue(homedir)
}
Expand Down
6 changes: 5 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ inputs:
api-token:
description: 'The rubygems api token to use for authentication.'
required: false
trusted-publisher:
description: >-
Whether to configure the credentials as a trusted publisher. Defaults to true if no other configuration is given.
required: false
runs:
using: 'node16'
using: 'node20'
main: 'dist/index.js'
117 changes: 108 additions & 9 deletions dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
import * as core from '@actions/core'
import {configureApiToken} from './configure-api-token'
import {assumeRole} from './oidc/assumeRole'
import {exchangeToken} from './oidc/trustedPublisher'

async function run(): Promise<void> {
try {
const gemServer = core.getInput('gem-server')
const audience = core.getInput('audience')
const roleToAssume = core.getInput('role-to-assume')
const apiToken = core.getInput('api-token')
const trustedPublisher: boolean = (() => {
const trustedPublisherConfigured = !!core.getInput('trusted-publisher')
if (!trustedPublisherConfigured && !apiToken && !roleToAssume) {
// default to trusted publishing if no api-token or role-to-assume is specified and trusted-publisher is not configured
return true
} else if (trustedPublisherConfigured) {
return core.getBooleanInput('trusted-publisher')
} else {
return false
}
})()

if (!gemServer) throw new Error('Missing gem-server input')

Expand All @@ -16,13 +28,24 @@ async function run(): Promise<void> {
throw new Error('Cannot specify audience when using api-token')
if (roleToAssume)
throw new Error('Cannot specify role-to-assume when using api-token')
if (trustedPublisher)
throw new Error('Cannot specify trusted-publisher when using api-token')

await configureApiToken(apiToken, gemServer)
} else if (roleToAssume) {
if (!audience) throw new Error('Missing audience input')
if (trustedPublisher)
throw new Error(
'Cannot specify trusted-publisher when using role-to-assume'
)

const oidcIdToken = await assumeRole(roleToAssume, audience, gemServer)
await configureApiToken(oidcIdToken.rubygemsApiKey, gemServer)
} else if (trustedPublisher) {
if (!audience) throw new Error('Missing audience input')

const oidcIdToken = await exchangeToken(audience, gemServer)
await configureApiToken(oidcIdToken.rubygemsApiKey, gemServer)
} else {
throw new Error('Missing api-token or role-to-assume input')
}
Expand Down
24 changes: 1 addition & 23 deletions src/oidc/assumeRole.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,6 @@
import * as core from '@actions/core'
import {HttpClient} from '@actions/http-client'
import {z} from 'zod'

const RubygemSchema = z.object({
name: z.string()
})

const IdTokenSchema = z
.object({
rubygems_api_key: z.string(),
name: z.string(),
scopes: z.array(z.string()),
gem: RubygemSchema.optional(),
expires_at: z.string().datetime({offset: true})
})
.transform(({rubygems_api_key, expires_at, ...rest}) => {
return {
rubygemsApiKey: rubygems_api_key,
expiresAt: expires_at,
...rest
}
})

type IdToken = z.infer<typeof IdTokenSchema>
import {IdToken, IdTokenSchema} from './responses'

export async function assumeRole(
roleToAssume: string,
Expand Down
Loading

0 comments on commit 93fa771

Please sign in to comment.