Skip to content

Commit

Permalink
Merge pull request #1693 from actions/bdehamer/oidc-provenance
Browse files Browse the repository at this point in the history
(@actions/attest) build provenance statement from OIDC claims
  • Loading branch information
bdehamer authored Mar 28, 2024
2 parents ef77c9d + 4ce4c76 commit 59e9d28
Show file tree
Hide file tree
Showing 13 changed files with 1,018 additions and 199 deletions.
6 changes: 3 additions & 3 deletions package-lock.json

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

4 changes: 4 additions & 0 deletions packages/attest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ export type AttestProvenanceOptions = {
sigstore?: 'public-good' | 'github'
// Whether to skip writing the attestation to the GH attestations API.
skipWrite?: boolean
// Issuer URL responsible for minting the OIDC token from which the
// provenance data is read. Defaults to
// 'https://token.actions.githubusercontent.com".
issuer?: string
}
```
Expand Down
4 changes: 4 additions & 0 deletions packages/attest/RELEASES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# @actions/attest Releases

### 1.1.0

- Updates the `attestProvenance` function to retrieve a token from the GitHub OIDC provider and use the token claims to populate the provenance statement.

### 1.0.0

- Initial release
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`buildIntotoStatement returns a provenance hydrated from env vars 1`] = `
exports[`buildIntotoStatement returns an intoto statement 1`] = `
{
"_type": "https://in-toto.io/Statement/v1",
"predicate": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`buildSLSAProvenancePredicate returns a provenance hydrated from env vars 1`] = `
exports[`provenance functions buildSLSAProvenancePredicate returns a provenance hydrated from an OIDC token 1`] = `
{
"params": {
"buildDefinition": {
Expand Down
2 changes: 1 addition & 1 deletion packages/attest/__tests__/intoto.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ describe('buildIntotoStatement', () => {
}
}

it('returns a provenance hydrated from env vars', () => {
it('returns an intoto statement', () => {
const statement = buildIntotoStatement(subject, predicate)
expect(statement).toMatchSnapshot()
})
Expand Down
147 changes: 147 additions & 0 deletions packages/attest/__tests__/oidc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import * as jose from 'jose'
import nock from 'nock'
import {getIDTokenClaims} from '../src/oidc'

describe('getIDTokenClaims', () => {
const originalEnv = process.env
const issuer = 'https://example.com'
const audience = 'nobody'
const requestToken = 'token'
const openidConfigPath = '/.well-known/openid-configuration'
const jwksPath = '/.well-known/jwks.json'
const tokenPath = '/token'
const openIDConfig = {jwks_uri: `${issuer}${jwksPath}`}

/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
let key: any

beforeEach(async () => {
process.env = {
...originalEnv,
ACTIONS_ID_TOKEN_REQUEST_URL: `${issuer}${tokenPath}?`,
ACTIONS_ID_TOKEN_REQUEST_TOKEN: requestToken
}

// Generate JWT signing key
key = await jose.generateKeyPair('PS256')

// Create JWK and JWKS
const jwk = await jose.exportJWK(key.publicKey)
const jwks = {keys: [jwk]}

nock(issuer).get(openidConfigPath).reply(200, openIDConfig)
nock(issuer).get(jwksPath).reply(200, jwks)
})

afterEach(() => {
process.env = originalEnv
})

describe('when ID token is valid', () => {
const claims = {
iss: issuer,
aud: audience,
ref: 'ref',
sha: 'sha',
repository: 'repo',
event_name: 'push',
workflow_ref: 'main',
repository_id: '1',
repository_owner_id: '1',
runner_environment: 'github-hosted',
run_id: '1',
run_attempt: '1'
}

beforeEach(async () => {
const jwt = await new jose.SignJWT(claims)
.setProtectedHeader({alg: 'PS256'})
.sign(key.privateKey)

nock(issuer).get(tokenPath).query({audience}).reply(200, {value: jwt})
})

it('returns the ID token claims', async () => {
const result = await getIDTokenClaims(issuer)
expect(result).toEqual(claims)
})
})

describe('when ID token is missing required claims', () => {
const claims = {
iss: issuer,
aud: audience
}

beforeEach(async () => {
const jwt = await new jose.SignJWT(claims)
.setProtectedHeader({alg: 'PS256'})
.sign(key.privateKey)

nock(issuer).get(tokenPath).query({audience}).reply(200, {value: jwt})
})

it('throws an error', async () => {
await expect(getIDTokenClaims(issuer)).rejects.toThrow(/missing claims/i)
})
})

describe('when ID has the wrong issuer', () => {
const claims = {foo: 'bar', iss: 'foo', aud: 'nobody'}

beforeEach(async () => {
const jwt = await new jose.SignJWT(claims)
.setProtectedHeader({alg: 'PS256'})
.sign(key.privateKey)

nock(issuer).get(tokenPath).query({audience}).reply(200, {value: jwt})
})

it('throws an error', async () => {
await expect(getIDTokenClaims(issuer)).rejects.toThrow(/issuer invalid/)
})
})

describe('when ID has the wrong audience', () => {
const claims = {foo: 'bar', iss: issuer, aud: 'bar'}

beforeEach(async () => {
const jwt = await new jose.SignJWT(claims)
.setProtectedHeader({alg: 'PS256'})
.sign(key.privateKey)

nock(issuer).get(tokenPath).query({audience}).reply(200, {value: jwt})
})

it('throw an error', async () => {
await expect(getIDTokenClaims(issuer)).rejects.toThrow(/audience invalid/)
})
})

describe('when openid config cannot be retrieved', () => {
const claims = {foo: 'bar', iss: issuer, aud: 'nobody'}

beforeEach(async () => {
const jwt = await new jose.SignJWT(claims)
.setProtectedHeader({alg: 'PS256'})
.sign(key.privateKey)

nock(issuer).get(tokenPath).query({audience}).reply(200, {value: jwt})

// Disable the openid config endpoint
nock.removeInterceptor({
proto: 'https',
hostname: 'example.com',
port: '443',
method: 'GET',
path: openidConfigPath
})
})

it('throws an error', async () => {
await expect(getIDTokenClaims(issuer)).rejects.toThrow(
/failed to get id/i
)
})
})
})
Loading

0 comments on commit 59e9d28

Please sign in to comment.