detached fda3c2b

ci_oidc_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"bytes"
7	"crypto"
8	"crypto/rand"
9	"crypto/rsa"
10	"crypto/sha256"
11	"encoding/base64"
12	"encoding/json"
13	"math/big"
14	"net/http"
15	"net/url"
16	"strings"
17	"sync"
18	"testing"
19	"time"
20
21	"connectrpc.com/connect"
22	"github.com/stretchr/testify/assert"
23	"github.com/stretchr/testify/require"
24
25	pb "bnema.dev/ermite-api/gen/ermite"
26	"bnema.dev/ermite/internal/domain"
27	"bnema.dev/ermite/internal/usecase"
28)
29
30// CI-4 D5 / AC5: the OIDC ID tokens of CI jobs.
31
32// oidcKeys are the RSA keys the test servers sign with, generated once for the
33// package: a 3072-bit key takes a while, more so under the race detector.
34var oidcKeys struct {
35	sync.Mutex
36	keys []*rsa.PrivateKey
37}
38
39// oidcTestKeys hands out the prepared keys in order, one per call, to one
40// server.
41func oidcTestKeys(t *testing.T) func() (*rsa.PrivateKey, error) {
42	n := 0
43	return func() (*rsa.PrivateKey, error) {
44		oidcKeys.Lock()
45		defer oidcKeys.Unlock()
46		for len(oidcKeys.keys) <= n {
47			k, err := rsa.GenerateKey(rand.Reader, usecase.OIDCKeyBits)
48			if err != nil {
49				return nil, err
50			}
51			oidcKeys.keys = append(oidcKeys.keys, k)
52		}
53		n++
54		return oidcKeys.keys[n-1], nil
55	}
56}
57
58const oidcWorkflow = `on: push
59jobs:
60  oidc:
61    runs-on: ubuntu-latest
62    permissions:
63      id-token: write
64      contents: read
65    steps:
66      - run: echo oidc
67  deploy:
68    runs-on: ubuntu-latest
69    environment: prod
70    permissions:
71      id-token: write
72    steps:
73      - run: echo deploy
74  plain:
75    runs-on: ubuntu-latest
76    steps:
77      - run: echo plain
78`
79
80// idToken asks the token endpoint for an ID token with the bearer, as
81// @actions/core does with ACTIONS_ID_TOKEN_REQUEST_URL (rebased on the test
82// server) and "&audience=".
83func (c *ciHarness) idToken(j *pb.Job, bearer, audience string) (int, string) {
84	c.t.Helper()
85	u, err := url.Parse(j.GetIdTokenUrl())
86	require.NoError(c.t, err)
87	path := u.RequestURI()
88	if audience != "" {
89		path += "&audience=" + url.QueryEscape(audience)
90	}
91	status, body, hdr := c.get(path, "Bearer "+bearer)
92	if status != http.StatusOK {
93		return status, body
94	}
95	assert.Equal(c.t, "no-store", hdr.Get("Cache-Control"))
96	var res struct {
97		Value string `json:"value"`
98	}
99	require.NoError(c.t, json.Unmarshal([]byte(body), &res))
100	require.NotEmpty(c.t, res.Value)
101	return status, res.Value
102}
103
104// verifyIDToken checks the RS256 signature of token against the JWKS the
105// server publishes, with the standard library only, and returns its header
106// and claims.
107func (c *ciHarness) verifyIDToken(token string) (header, claims map[string]any) {
108	c.t.Helper()
109	status, body, hdr := c.get("/-/oidc/.well-known/jwks", "")
110	require.Equal(c.t, http.StatusOK, status, body)
111	assert.Contains(c.t, hdr.Get("Cache-Control"), "public")
112	var set struct {
113		Keys []struct{ Kty, Kid, N, E string } `json:"keys"`
114	}
115	require.NoError(c.t, json.Unmarshal([]byte(body), &set))
116	parts := strings.Split(token, ".")
117	require.Len(c.t, parts, 3)
118	decode := func(s string, v any) {
119		raw, err := base64.RawURLEncoding.DecodeString(s)
120		require.NoError(c.t, err)
121		require.NoError(c.t, json.Unmarshal(raw, v))
122	}
123	decode(parts[0], &header)
124	decode(parts[1], &claims)
125	require.Equal(c.t, "RS256", header["alg"])
126	var pub *rsa.PublicKey
127	for _, k := range set.Keys {
128		if k.Kid != header["kid"] {
129			continue
130		}
131		n, err := base64.RawURLEncoding.DecodeString(k.N)
132		require.NoError(c.t, err)
133		e, err := base64.RawURLEncoding.DecodeString(k.E)
134		require.NoError(c.t, err)
135		pub = &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(new(big.Int).SetBytes(e).Int64())}
136	}
137	require.NotNil(c.t, pub, "the kid of the token is in the JWKS")
138	sig, err := base64.RawURLEncoding.DecodeString(parts[2])
139	require.NoError(c.t, err)
140	sum := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
141	require.NoError(c.t, rsa.VerifyPKCS1v15(pub, crypto.SHA256, sum[:], sig), "the signature verifies against the JWKS")
142	return header, claims
143}
144
145func TestOIDCIDTokens(t *testing.T) {
146	c := newSecretsHarness(t, secretsKeyA)
147	runner := c.registerRunner("alpha", "ubuntu-latest")
148	sha := c.push("main", map[string]string{".github/workflows/o.yml": oidcWorkflow}, "")
149	jobs := runner.jobsByKey(3)
150	job, deploy, plain := jobs["oidc"], jobs["deploy"], jobs["plain"]
151
152	// Only the jobs with id-token: write get the endpoint and a request token,
153	// and the token is nowhere in what the runner stores.
154	assert.Equal(t, "http://example.test/-/oidc/token?api-version=2.0", job.GetIdTokenUrl())
155	assert.True(t, domain.IsOIDCRequestToken(job.GetIdTokenRequestToken()))
156	assert.NotEqual(t, job.GetIdTokenRequestToken(), deploy.GetIdTokenRequestToken())
157	assert.NotContains(t, job.GetContexts(), job.GetIdTokenRequestToken())
158	assert.Empty(t, plain.GetIdTokenUrl(), "no id-token: write")
159	assert.Empty(t, plain.GetIdTokenRequestToken())
160	ver, err := c.root.server.GetVersion(c.ctx, connect.NewRequest(&pb.GetVersionRequest{}))
161	require.NoError(t, err)
162	assert.Contains(t, ver.Msg.GetFeatures(), "ci.oidc")
163
164	// Discovery, anonymous and cacheable.
165	status, body, hdr := c.get("/-/oidc/.well-known/openid-configuration", "")
166	require.Equal(t, http.StatusOK, status, body)
167	assert.Contains(t, hdr.Get("Cache-Control"), "max-age")
168	var disc map[string]any
169	require.NoError(t, json.Unmarshal([]byte(body), &disc))
170	assert.Equal(t, "http://example.test/-/oidc", disc["issuer"])
171	assert.Equal(t, "http://example.test/-/oidc/.well-known/jwks", disc["jwks_uri"])
172	assert.Equal(t, []any{"RS256"}, disc["id_token_signing_alg_values_supported"])
173
174	// The default audience, and the claims of a push.
175	before := time.Now().Unix()
176	status, tok := c.idToken(job, job.GetIdTokenRequestToken(), "")
177	require.Equal(t, http.StatusOK, status, tok)
178	header, claims := c.verifyIDToken(tok)
179	assert.Equal(t, "JWT", header["typ"])
180	assert.Equal(t, "http://example.test/-/oidc", claims["iss"])
181	assert.Equal(t, "http://example.test/owner", claims["aud"])
182	assert.Equal(t, "repo:owner/proj:ref:refs/heads/main", claims["sub"])
183	assert.Equal(t, "refs/heads/main", claims["ref"])
184	assert.Equal(t, "branch", claims["ref_type"])
185	assert.Equal(t, sha, claims["sha"])
186	assert.Equal(t, "owner/proj", claims["repository"])
187	assert.Equal(t, "owner", claims["repository_owner"])
188	assert.Equal(t, "private", claims["repository_visibility"])
189	assert.Equal(t, "push", claims["event_name"])
190	assert.Equal(t, "1", claims["run_attempt"])
191	assert.Equal(t, "self-hosted", claims["runner_environment"])
192	assert.Equal(t, "owner/proj/.github/workflows/o.yml@refs/heads/main", claims["job_workflow_ref"])
193	assert.Equal(t, sha, claims["job_workflow_sha"])
194	assert.NotEmpty(t, claims["jti"])
195	assert.NotContains(t, claims, "environment")
196	iat := int64(claims["iat"].(float64))
197	assert.GreaterOrEqual(t, iat, before)
198	assert.Equal(t, iat-600, int64(claims["nbf"].(float64)))
199	assert.Equal(t, iat+300, int64(claims["exp"].(float64)))
200
201	// A requested audience; every token has its own jti.
202	status, tok2 := c.idToken(job, job.GetIdTokenRequestToken(), "sts.amazonaws.com")
203	require.Equal(t, http.StatusOK, status, tok2)
204	_, claims2 := c.verifyIDToken(tok2)
205	assert.Equal(t, "sts.amazonaws.com", claims2["aud"])
206	assert.NotEqual(t, claims["jti"], claims2["jti"])
207	status, _ = c.idToken(job, job.GetIdTokenRequestToken(), strings.Repeat("a", domain.MaxOIDCAudienceLen+1))
208	assert.Equal(t, http.StatusBadRequest, status)
209
210	// The environment of a job is its subject.
211	status, tok3 := c.idToken(deploy, deploy.GetIdTokenRequestToken(), "")
212	require.Equal(t, http.StatusOK, status, tok3)
213	_, claims3 := c.verifyIDToken(tok3)
214	assert.Equal(t, "repo:owner/proj:environment:prod", claims3["sub"])
215	assert.Equal(t, "prod", claims3["environment"])
216
217	// No other secret gets a token: the job's repository token, its job token,
218	// a token of the right shape that does not exist, nothing.
219	for name, bearer := range map[string]string{
220		"github token": job.GetGithubToken(), "job token": job.GetJobToken(), "admin token": adminToken,
221		"unknown": domain.OIDCRequestTokenPrefix + strings.Repeat("A", domain.CITokenRandomLen),
222	} {
223		status, _ := c.idToken(job, bearer, "")
224		assert.Equal(t, http.StatusUnauthorized, status, name)
225	}
226	status, _, _ = c.get("/-/oidc/token?api-version=2.0", "")
227	assert.Equal(t, http.StatusUnauthorized, status, "no credentials")
228
229	// The request token authenticates nothing else: not the API, not Git.
230	status, _, _ = c.get("/-/github/api/repos/owner/proj", "Bearer "+job.GetIdTokenRequestToken())
231	assert.Equal(t, http.StatusUnauthorized, status)
232	out, err := c.clone(job.GetIdTokenRequestToken(), c.url("/owner/proj"))
233	require.Error(t, err, out)
234
235	// The key is stored sealed: the raw value is not a private key.
236	kid, _ := header["kid"].(string)
237	raw, ok := c.forge.store.RawOIDCKey(c.ctx, kid)
238	require.True(t, ok)
239	assert.NotContains(t, string(raw), "PRIVATE KEY", "not a PEM")
240	oidcKeys.Lock()
241	for _, k := range oidcKeys.keys {
242		assert.False(t, bytes.Contains(raw, k.D.Bytes()), "no private exponent in the clear")
243	}
244	oidcKeys.Unlock()
245
246	// Once the job ended, its request token is refused, while another job's
247	// still works.
248	runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
249	status, _ = c.idToken(job, job.GetIdTokenRequestToken(), "")
250	assert.Equal(t, http.StatusUnauthorized, status, "the job ended")
251	status, _ = c.idToken(deploy, deploy.GetIdTokenRequestToken(), "")
252	assert.Equal(t, http.StatusOK, status)
253}
254
255// A run that is not trusted (pushed by a token limited to branches) gets no ID
256// tokens, whatever its permissions say.
257func TestOIDCUntrustedRunGetsNoToken(t *testing.T) {
258	c := newSecretsHarness(t, secretsKeyA)
259	runner := c.registerRunner("alpha", "ubuntu-latest")
260	c.push("main", map[string]string{"README.md": "hi"}, "")
261	secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), []string{"agent/"}, "proj")
262	c.pushAs(secret, "agent/x", map[string]string{".github/workflows/o.yml": oidcWorkflow})
263	for key, j := range runner.jobsByKey(3) {
264		assert.False(t, j.GetTrusted(), key)
265		assert.Empty(t, j.GetIdTokenUrl(), key)
266		assert.Empty(t, j.GetIdTokenRequestToken(), key)
267	}
268}
269
270// Without ERMITE_SECRETS_KEY there is no OIDC: no variables, no provider, no
271// rotation.
272func TestOIDCWithoutSecretsKey(t *testing.T) {
273	c := newSecretsHarness(t, "")
274	runner := c.registerRunner("alpha", "ubuntu-latest")
275	c.push("main", map[string]string{".github/workflows/o.yml": oidcWorkflow}, "")
276	for key, j := range runner.jobsByKey(3) {
277		assert.Empty(t, j.GetIdTokenUrl(), key)
278		assert.Empty(t, j.GetIdTokenRequestToken(), key)
279	}
280	for _, path := range []string{"/-/oidc/.well-known/openid-configuration", "/-/oidc/.well-known/jwks", "/-/oidc/token?api-version=2.0"} {
281		status, _, _ := c.get(path, "")
282		assert.Equal(t, http.StatusNotFound, status, path)
283	}
284	_, err := c.root.server.RotateOIDCKey(c.ctx, connect.NewRequest(&pb.RotateOIDCKeyRequest{}))
285	assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err))
286	ver, err := c.root.server.GetVersion(c.ctx, connect.NewRequest(&pb.GetVersionRequest{}))
287	require.NoError(t, err)
288	assert.NotContains(t, ver.Msg.GetFeatures(), "ci.oidc")
289}
290
291// `ermite oidc rotate-key`: owner only; the new key signs at once and the old
292// one still verifies the tokens it signed.
293func TestOIDCRotateKey(t *testing.T) {
294	c := newSecretsHarness(t, secretsKeyA)
295	runner := c.registerRunner("alpha", "ubuntu-latest")
296	c.push("main", map[string]string{".github/workflows/o.yml": oidcWorkflow}, "")
297	job := runner.jobsByKey(3)["oidc"]
298	status, before := c.idToken(job, job.GetIdTokenRequestToken(), "")
299	require.Equal(t, http.StatusOK, status)
300	oldHeader, _ := c.verifyIDToken(before)
301
302	limited := c.api(c.token(scopes(domain.ScopeRepoRead, domain.ScopeCIWrite), "proj"))
303	_, err := limited.server.RotateOIDCKey(c.ctx, connect.NewRequest(&pb.RotateOIDCKeyRequest{}))
304	assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err))
305
306	res := runCLI(t, map[string]string{"ERMITE_URL": c.srv.URL, "ERMITE_TOKEN": adminToken}, "", "oidc", "rotate-key", "--json").ok(t)
307	var rot struct {
308		Issuer       string  `json:"issuer"`
309		Kid          string  `json:"kid"`
310		RetiredKid   *string `json:"retired_kid"`
311		RetiredUntil *string `json:"retired_until"`
312	}
313	require.NoError(t, json.Unmarshal([]byte(res.stdout), &rot), res.stdout)
314	assert.Equal(t, "http://example.test/-/oidc", rot.Issuer)
315	require.NotNil(t, rot.RetiredKid)
316	assert.Equal(t, oldHeader["kid"], *rot.RetiredKid)
317	assert.NotEqual(t, *rot.RetiredKid, rot.Kid)
318	require.NotNil(t, rot.RetiredUntil)
319
320	c.verifyIDToken(before) // the old key is still published
321	status, after := c.idToken(job, job.GetIdTokenRequestToken(), "")
322	require.Equal(t, http.StatusOK, status)
323	newHeader, _ := c.verifyIDToken(after)
324	assert.Equal(t, rot.Kid, newHeader["kid"])
325}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close