v0.31.0 d5e0505

oidc_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6	"bytes"
7	"context"
8	"crypto"
9	"crypto/rand"
10	"crypto/rsa"
11	"crypto/sha256"
12	"encoding/base64"
13	"encoding/json"
14	"math/big"
15	"strings"
16	"sync"
17	"testing"
18	"time"
19
20	"github.com/stretchr/testify/assert"
21	"github.com/stretchr/testify/require"
22
23	"bnema.dev/ermite/internal/adapters/kvstore"
24	"bnema.dev/ermite/internal/adapters/secretbox"
25	"bnema.dev/ermite/internal/domain"
26	"bnema.dev/ermite/internal/usecase"
27)
28
29// testRSAKeys are generated once for the package: a key of the real size takes
30// a while, more so under the race detector.
31var testRSAKeys struct {
32	sync.Mutex
33	keys []*rsa.PrivateKey
34}
35
36// testRSAKey returns the i-th test key.
37func testRSAKey(t *testing.T, i int) *rsa.PrivateKey {
38	t.Helper()
39	testRSAKeys.Lock()
40	defer testRSAKeys.Unlock()
41	for len(testRSAKeys.keys) <= i {
42		k, err := rsa.GenerateKey(rand.Reader, usecase.OIDCKeyBits)
43		require.NoError(t, err)
44		testRSAKeys.keys = append(testRSAKeys.keys, k)
45	}
46	return testRSAKeys.keys[i]
47}
48
49// keySeq hands out the test keys in order, one per call.
50func keySeq(t *testing.T) func() (*rsa.PrivateKey, error) {
51	n := 0
52	return func() (*rsa.PrivateKey, error) {
53		k := testRSAKey(t, n)
54		n++
55		return k, nil
56	}
57}
58
59type oidcFixture struct {
60	store  *kvstore.Store
61	box    *secretbox.Box
62	clock  *fakeClock
63	newKey func() (*rsa.PrivateKey, error)
64}
65
66func newOIDCFixture(t *testing.T) *oidcFixture {
67	t.Helper()
68	store, err := kvstore.Open(context.Background(), t.TempDir())
69	require.NoError(t, err)
70	t.Cleanup(func() { _ = store.Close(context.Background()) })
71	box, err := secretbox.New(bytes.Repeat([]byte{7}, secretbox.KeyLen))
72	require.NoError(t, err)
73	return &oidcFixture{store: store, box: box, clock: &fakeClock{t: now}, newKey: keySeq(t)}
74}
75
76func (f *oidcFixture) open(t *testing.T) *usecase.OIDC {
77	t.Helper()
78	o, err := usecase.NewOIDC(context.Background(), usecase.OIDCConfig{
79		Store: f.store, Sealer: f.box, Tokens: f.store.CI(), Clock: f.clock, PublicURL: "https://forge.test", NewKey: f.newKey,
80	})
81	require.NoError(t, err)
82	require.NotNil(t, o)
83	return o
84}
85
86// jwksKids lists the kids of a JWKS, and checks each is the RFC 7638
87// thumbprint of its key.
88func jwksKids(t *testing.T, jwks []byte) []string {
89	t.Helper()
90	var set struct {
91		Keys []struct{ Kty, Alg, Use, Kid, N, E string } `json:"keys"`
92	}
93	require.NoError(t, json.Unmarshal(jwks, &set))
94	var kids []string
95	for _, k := range set.Keys {
96		assert.Equal(t, "RSA", k.Kty)
97		assert.Equal(t, "RS256", k.Alg)
98		assert.Equal(t, "sig", k.Use)
99		assert.Equal(t, k.Kid, usecase.Thumbprint(jwkPublic(t, k.N, k.E)))
100		kids = append(kids, k.Kid)
101	}
102	return kids
103}
104
105func jwkPublic(t *testing.T, n, e string) *rsa.PublicKey {
106	t.Helper()
107	nb, err := base64.RawURLEncoding.DecodeString(n)
108	require.NoError(t, err)
109	eb, err := base64.RawURLEncoding.DecodeString(e)
110	require.NoError(t, err)
111	return &rsa.PublicKey{N: new(big.Int).SetBytes(nb), E: int(new(big.Int).SetBytes(eb).Int64())}
112}
113
114// The example of RFC 7638, section 3.1.
115func TestThumbprintRFC7638(t *testing.T) {
116	t.Parallel()
117	pub := jwkPublic(t, "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", "AQAB")
118	assert.Equal(t, "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs", usecase.Thumbprint(pub))
119}
120
121func TestNoSecretsKeyNoOIDC(t *testing.T) {
122	t.Parallel()
123	f := newOIDCFixture(t)
124	o, err := usecase.NewOIDC(context.Background(), usecase.OIDCConfig{Store: f.store, Tokens: f.store.CI(), Clock: f.clock, PublicURL: "https://forge.test"})
125	require.NoError(t, err)
126	require.NotNil(t, o)
127	assert.False(t, o.Enabled())
128	keys, err := f.store.ListOIDCKeys(context.Background())
129	require.NoError(t, err)
130	assert.Empty(t, keys, "no key is generated")
131}
132
133// The key is generated once, stored sealed (never as a PEM or DER in the
134// clear) and bound to its path, and survives a restart.
135func TestOIDCKeyIsSealedAndKept(t *testing.T) {
136	t.Parallel()
137	ctx := context.Background()
138	f := newOIDCFixture(t)
139	o := f.open(t)
140	assert.Equal(t, "https://forge.test/-/oidc", o.Issuer())
141	assert.Equal(t, "https://forge.test/-/oidc/token?api-version=2.0", o.TokenURL())
142	jwks, err := o.JWKS()
143	require.NoError(t, err)
144	kids := jwksKids(t, jwks)
145	require.Len(t, kids, 1)
146	assert.Equal(t, usecase.Thumbprint(&testRSAKey(t, 0).PublicKey), kids[0])
147
148	raw, ok := f.store.RawOIDCKey(ctx, kids[0])
149	require.True(t, ok)
150	assert.NotContains(t, string(raw), "PRIVATE KEY", "not a PEM")
151	priv := testRSAKey(t, 0)
152	assert.False(t, bytes.Contains(raw, priv.D.Bytes()), "the private exponent is not in the clear")
153	assert.False(t, bytes.Contains(raw, priv.Primes[0].Bytes()), "nor a prime")
154
155	// It survives a restart: no second key.
156	o2 := f.open(t)
157	jwks2, err := o2.JWKS()
158	require.NoError(t, err)
159	assert.Equal(t, kids, jwksKids(t, jwks2))
160
161	// A sealed key moved to another id does not open: the server refuses to
162	// start rather than serve a key it cannot check.
163	stored, err := f.store.ListOIDCKeys(ctx)
164	require.NoError(t, err)
165	require.Len(t, stored, 1)
166	moved := stored[0]
167	moved.ID = "another"
168	require.NoError(t, f.store.SaveOIDCKeys(ctx, []domain.OIDCKey{moved}, []string{stored[0].ID}))
169	_, err = usecase.NewOIDC(ctx, usecase.OIDCConfig{Store: f.store, Sealer: f.box, Tokens: f.store.CI(), Clock: f.clock, PublicURL: "https://forge.test", NewKey: f.newKey})
170	require.Error(t, err)
171}
172
173// Rotation: the new key signs at once, the old one stays published 24 hours
174// and is forgotten afterwards. Only the owner rotates.
175func TestOIDCRotation(t *testing.T) {
176	t.Parallel()
177	ctx := context.Background()
178	f := newOIDCFixture(t)
179	o := f.open(t)
180	first := usecase.Thumbprint(&testRSAKey(t, 0).PublicKey)
181	second := usecase.Thumbprint(&testRSAKey(t, 1).PublicKey)
182
183	_, err := o.RotateKey(ctx, domain.Anonymous())
184	require.ErrorIs(t, err, domain.ErrUnauthenticated)
185	limited := domain.TokenPrincipal(domain.Token{ID: "t", Scopes: domain.AllScopes(), Repos: []domain.RepoName{name(t, "proj")}})
186	_, err = o.RotateKey(ctx, limited)
187	require.ErrorIs(t, err, domain.ErrForbidden)
188
189	r, err := o.RotateKey(ctx, domain.Root())
190	require.NoError(t, err)
191	assert.Equal(t, second, r.Kid)
192	assert.Equal(t, first, r.RetiredKid)
193	assert.Equal(t, now.Add(24*time.Hour), r.RetiredUntil)
194	jwks, err := o.JWKS()
195	require.NoError(t, err)
196	assert.ElementsMatch(t, []string{first, second}, jwksKids(t, jwks))
197
198	// A token signed now names the new key.
199	token := signWith(t, o)
200	assert.Equal(t, second, jwtHeader(t, token)["kid"])
201
202	// A restart within the 24 hours still publishes both, the new one signs.
203	f.clock.t = now.Add(23 * time.Hour)
204	o = f.open(t)
205	jwks, err = o.JWKS()
206	require.NoError(t, err)
207	assert.ElementsMatch(t, []string{first, second}, jwksKids(t, jwks))
208
209	// After 24 hours the old one is no longer published, and a restart
210	// deletes it.
211	f.clock.t = now.Add(24 * time.Hour)
212	jwks, err = o.JWKS()
213	require.NoError(t, err)
214	assert.Equal(t, []string{second}, jwksKids(t, jwks))
215	f.open(t)
216	stored, err := f.store.ListOIDCKeys(ctx)
217	require.NoError(t, err)
218	require.Len(t, stored, 1)
219	assert.Equal(t, second, stored[0].ID)
220}
221
222// signWith signs a token through the exported path of the tests.
223func signWith(t *testing.T, o *usecase.OIDC) string {
224	t.Helper()
225	tok, err := o.SignForTest(map[string]any{"sub": "x"})
226	require.NoError(t, err)
227	verifyJWT(t, tok, o)
228	return tok
229}
230
231func jwtHeader(t *testing.T, token string) map[string]any {
232	t.Helper()
233	h, _, _ := strings.Cut(token, ".")
234	raw, err := base64.RawURLEncoding.DecodeString(h)
235	require.NoError(t, err)
236	var out map[string]any
237	require.NoError(t, json.Unmarshal(raw, &out))
238	return out
239}
240
241// verifyJWT checks the RS256 signature of token against the JWKS of o, with
242// the standard library only, and returns its claims.
243func verifyJWT(t *testing.T, token string, o *usecase.OIDC) map[string]any {
244	t.Helper()
245	jwks, err := o.JWKS()
246	require.NoError(t, err)
247	var set struct {
248		Keys []struct{ Kid, N, E string } `json:"keys"`
249	}
250	require.NoError(t, json.Unmarshal(jwks, &set))
251	parts := strings.Split(token, ".")
252	require.Len(t, parts, 3)
253	h := jwtHeader(t, token)
254	assert.Equal(t, "RS256", h["alg"])
255	var pub *rsa.PublicKey
256	for _, k := range set.Keys {
257		if k.Kid == h["kid"] {
258			pub = jwkPublic(t, k.N, k.E)
259		}
260	}
261	require.NotNil(t, pub, "the kid is in the JWKS")
262	sig, err := base64.RawURLEncoding.DecodeString(parts[2])
263	require.NoError(t, err)
264	sum := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
265	require.NoError(t, rsa.VerifyPKCS1v15(pub, crypto.SHA256, sum[:], sig))
266	raw, err := base64.RawURLEncoding.DecodeString(parts[1])
267	require.NoError(t, err)
268	var claims map[string]any
269	require.NoError(t, json.Unmarshal(raw, &claims))
270	return claims
271}
272
273// The contexts of a job (Job.contexts), as the planner computes them.
274func jobContexts(event, ref, headRef, baseRef, jobWorkflowRef string) string {
275	b, _ := json.Marshal(map[string]any{
276		"github": map[string]any{
277			"ref": ref, "sha": "abc123", "repository": "owner/proj", "repository_owner": "owner",
278			"repository_owner_id": "1048577", "run_id": "42", "run_number": "7", "run_attempt": "2",
279			"repository_visibility": "private", "repository_id": "1048999", "actor_id": "1048577", "actor": "owner",
280			"workflow": "CI", "head_ref": headRef, "base_ref": baseRef, "event_name": event, "ref_protected": false,
281			"ref_type": "branch", "workflow_ref": "owner/proj/.github/workflows/ci.yml@" + ref, "workflow_sha": "abc123",
282		},
283		"job": map[string]any{
284			"check_run_id": 9007199254740991, "workflow_ref": jobWorkflowRef, "workflow_sha": "def456",
285		},
286	})
287	return string(b)
288}
289
290// The claims follow GitHub's token: sub per event and environment, the job's
291// workflow in job_workflow_ref, numbers as strings, the times around iat.
292func TestIDTokenClaimsPerEvent(t *testing.T) {
293	t.Parallel()
294	own := "owner/proj/.github/workflows/ci.yml@refs/heads/main"
295	called := "owner/tools/.github/workflows/deploy.yml@refs/tags/v1"
296	tests := []struct {
297		name, event, ref, head, base, env, jobWF string
298		sub                                      string
299	}{
300		{"push", "push", "refs/heads/main", "", "", "", own, "repo:owner/proj:ref:refs/heads/main"},
301		{"tag", "push", "refs/tags/v1.0.0", "", "", "", own, "repo:owner/proj:ref:refs/tags/v1.0.0"},
302		{"dispatch", "workflow_dispatch", "refs/heads/main", "", "", "", own, "repo:owner/proj:ref:refs/heads/main"},
303		{"schedule", "schedule", "refs/heads/main", "", "", "", own, "repo:owner/proj:ref:refs/heads/main"},
304		{"pull_request", "pull_request", "refs/pull/3/merge", "feat", "main", "", own, "repo:owner/proj:pull_request"},
305		{"pull_request_target", "pull_request_target", "refs/heads/main", "feat", "main", "", own, "repo:owner/proj:pull_request"},
306		{"environment wins", "pull_request", "refs/pull/3/merge", "feat", "main", "prod", own, "repo:owner/proj:environment:prod"},
307		{"push to an environment", "push", "refs/heads/main", "", "", "staging", own, "repo:owner/proj:environment:staging"},
308		{"reusable workflow", "push", "refs/heads/main", "", "", "", called, "repo:owner/proj:ref:refs/heads/main"},
309	}
310	for _, tt := range tests {
311		t.Run(tt.name, func(t *testing.T) {
312			t.Parallel()
313			c, keys, err := usecase.IDTokenClaims(jobContexts(tt.event, tt.ref, tt.head, tt.base, tt.jobWF), tt.env, "https://forge.test/-/oidc", "https://forge.test/owner", now)
314			require.NoError(t, err)
315			assert.Equal(t, tt.sub, c["sub"])
316			assert.Equal(t, "jti", keys[0])
317			assert.Equal(t, "https://forge.test/-/oidc", c["iss"])
318			assert.Equal(t, "https://forge.test/owner", c["aud"])
319			assert.Equal(t, tt.ref, c["ref"])
320			assert.Equal(t, tt.event, c["event_name"])
321			assert.Equal(t, tt.head, c["head_ref"])
322			assert.Equal(t, tt.base, c["base_ref"])
323			assert.Equal(t, "2", c["run_attempt"])
324			assert.Equal(t, "42", c["run_id"])
325			assert.Equal(t, "false", c["ref_protected"])
326			assert.Equal(t, "owner/proj/.github/workflows/ci.yml@"+tt.ref, c["workflow_ref"], "the caller's workflow")
327			assert.Equal(t, tt.jobWF, c["job_workflow_ref"], "the workflow that defines the job")
328			assert.Equal(t, "def456", c["job_workflow_sha"])
329			assert.Equal(t, "9007199254740991", c["check_run_id"], "exact, as a string")
330			assert.Equal(t, "self-hosted", c["runner_environment"])
331			assert.Equal(t, now.Unix(), c["iat"])
332			assert.Equal(t, now.Unix()-600, c["nbf"])
333			assert.Equal(t, now.Unix()+300, c["exp"])
334			if tt.env != "" {
335				assert.Equal(t, tt.env, c["environment"])
336			} else {
337				assert.NotContains(t, c, "environment", "GitHub leaves it out without an environment")
338			}
339		})
340	}
341}
342
343func TestDisabledOIDCRotationAuthorization(t *testing.T) {
344	o, err := usecase.NewOIDC(context.Background(), usecase.OIDCConfig{})
345	require.NoError(t, err)
346	for _, tc := range []struct {
347		name string
348		p    domain.Principal
349		want error
350	}{
351		{"anonymous", domain.Anonymous(), domain.ErrForbidden},
352		{"limited", domain.TokenPrincipal(domain.Token{ID: "limited"}), domain.ErrForbidden},
353		{"root", domain.Root(), domain.ErrPreconditionFailed},
354		{"owner", domain.Owner(), domain.ErrPreconditionFailed},
355	} {
356		t.Run(tc.name, func(t *testing.T) {
357			_, err := o.RotateKey(context.Background(), tc.p)
358			require.ErrorIs(t, err, tc.want)
359			if tc.want == domain.ErrPreconditionFailed {
360				assert.EqualError(t, err, "the server issues no OIDC ID tokens: it has no ERMITE_SECRETS_KEY")
361			}
362		})
363	}
364	_, err = o.Discovery()
365	assert.ErrorIs(t, err, domain.ErrNotFound)
366	_, err = o.JWKS()
367	assert.ErrorIs(t, err, domain.ErrNotFound)
368}
369
370func TestDisabledOIDCPublicEndpoints(t *testing.T) {
371	for _, provider := range []*usecase.OIDC{nil, {}} {
372		_, err := provider.Discovery()
373		require.ErrorIs(t, err, domain.ErrNotFound)
374		_, err = provider.JWKS()
375		require.ErrorIs(t, err, domain.ErrNotFound)
376		ci := usecase.NewCI(context.Background(), usecase.CIConfig{Owner: "owner", Clock: fixedClock{}, OIDC: provider})
377		_, err = ci.OIDCDiscovery()
378		require.ErrorIs(t, err, domain.ErrNotFound)
379		_, err = ci.OIDCKeys()
380		require.ErrorIs(t, err, domain.ErrNotFound)
381		ci.Close(context.Background())
382	}
383}

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 2

Tags 44

↑↓ moveenter openesc close