v0.27.0 0039612

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	assert.Nil(t, o)
127	keys, err := f.store.ListOIDCKeys(context.Background())
128	require.NoError(t, err)
129	assert.Empty(t, keys, "no key is generated")
130}
131
132// The key is generated once, stored sealed (never as a PEM or DER in the
133// clear) and bound to its path, and survives a restart.
134func TestOIDCKeyIsSealedAndKept(t *testing.T) {
135	t.Parallel()
136	ctx := context.Background()
137	f := newOIDCFixture(t)
138	o := f.open(t)
139	assert.Equal(t, "https://forge.test/-/oidc", o.Issuer())
140	assert.Equal(t, "https://forge.test/-/oidc/token?api-version=2.0", o.TokenURL())
141	jwks, err := o.JWKS()
142	require.NoError(t, err)
143	kids := jwksKids(t, jwks)
144	require.Len(t, kids, 1)
145	assert.Equal(t, usecase.Thumbprint(&testRSAKey(t, 0).PublicKey), kids[0])
146
147	raw, ok := f.store.RawOIDCKey(ctx, kids[0])
148	require.True(t, ok)
149	assert.NotContains(t, string(raw), "PRIVATE KEY", "not a PEM")
150	priv := testRSAKey(t, 0)
151	assert.False(t, bytes.Contains(raw, priv.D.Bytes()), "the private exponent is not in the clear")
152	assert.False(t, bytes.Contains(raw, priv.Primes[0].Bytes()), "nor a prime")
153
154	// It survives a restart: no second key.
155	o2 := f.open(t)
156	jwks2, err := o2.JWKS()
157	require.NoError(t, err)
158	assert.Equal(t, kids, jwksKids(t, jwks2))
159
160	// A sealed key moved to another id does not open: the server refuses to
161	// start rather than serve a key it cannot check.
162	stored, err := f.store.ListOIDCKeys(ctx)
163	require.NoError(t, err)
164	require.Len(t, stored, 1)
165	moved := stored[0]
166	moved.ID = "another"
167	require.NoError(t, f.store.SaveOIDCKeys(ctx, []domain.OIDCKey{moved}, []string{stored[0].ID}))
168	_, 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})
169	require.Error(t, err)
170}
171
172// Rotation: the new key signs at once, the old one stays published 24 hours
173// and is forgotten afterwards. Only the owner rotates.
174func TestOIDCRotation(t *testing.T) {
175	t.Parallel()
176	ctx := context.Background()
177	f := newOIDCFixture(t)
178	o := f.open(t)
179	first := usecase.Thumbprint(&testRSAKey(t, 0).PublicKey)
180	second := usecase.Thumbprint(&testRSAKey(t, 1).PublicKey)
181
182	_, err := o.RotateKey(ctx, domain.Anonymous())
183	require.ErrorIs(t, err, domain.ErrUnauthenticated)
184	limited := domain.TokenPrincipal(domain.Token{ID: "t", Scopes: domain.AllScopes(), Repos: []domain.RepoName{name(t, "proj")}})
185	_, err = o.RotateKey(ctx, limited)
186	require.ErrorIs(t, err, domain.ErrForbidden)
187
188	r, err := o.RotateKey(ctx, domain.Root())
189	require.NoError(t, err)
190	assert.Equal(t, second, r.Kid)
191	assert.Equal(t, first, r.RetiredKid)
192	assert.Equal(t, now.Add(24*time.Hour), r.RetiredUntil)
193	jwks, err := o.JWKS()
194	require.NoError(t, err)
195	assert.ElementsMatch(t, []string{first, second}, jwksKids(t, jwks))
196
197	// A token signed now names the new key.
198	token := signWith(t, o)
199	assert.Equal(t, second, jwtHeader(t, token)["kid"])
200
201	// A restart within the 24 hours still publishes both, the new one signs.
202	f.clock.t = now.Add(23 * time.Hour)
203	o = f.open(t)
204	jwks, err = o.JWKS()
205	require.NoError(t, err)
206	assert.ElementsMatch(t, []string{first, second}, jwksKids(t, jwks))
207
208	// After 24 hours the old one is no longer published, and a restart
209	// deletes it.
210	f.clock.t = now.Add(24 * time.Hour)
211	jwks, err = o.JWKS()
212	require.NoError(t, err)
213	assert.Equal(t, []string{second}, jwksKids(t, jwks))
214	f.open(t)
215	stored, err := f.store.ListOIDCKeys(ctx)
216	require.NoError(t, err)
217	require.Len(t, stored, 1)
218	assert.Equal(t, second, stored[0].ID)
219}
220
221// signWith signs a token through the exported path of the tests.
222func signWith(t *testing.T, o *usecase.OIDC) string {
223	t.Helper()
224	tok, err := o.SignForTest(map[string]any{"sub": "x"})
225	require.NoError(t, err)
226	verifyJWT(t, tok, o)
227	return tok
228}
229
230func jwtHeader(t *testing.T, token string) map[string]any {
231	t.Helper()
232	h, _, _ := strings.Cut(token, ".")
233	raw, err := base64.RawURLEncoding.DecodeString(h)
234	require.NoError(t, err)
235	var out map[string]any
236	require.NoError(t, json.Unmarshal(raw, &out))
237	return out
238}
239
240// verifyJWT checks the RS256 signature of token against the JWKS of o, with
241// the standard library only, and returns its claims.
242func verifyJWT(t *testing.T, token string, o *usecase.OIDC) map[string]any {
243	t.Helper()
244	jwks, err := o.JWKS()
245	require.NoError(t, err)
246	var set struct {
247		Keys []struct{ Kid, N, E string } `json:"keys"`
248	}
249	require.NoError(t, json.Unmarshal(jwks, &set))
250	parts := strings.Split(token, ".")
251	require.Len(t, parts, 3)
252	h := jwtHeader(t, token)
253	assert.Equal(t, "RS256", h["alg"])
254	var pub *rsa.PublicKey
255	for _, k := range set.Keys {
256		if k.Kid == h["kid"] {
257			pub = jwkPublic(t, k.N, k.E)
258		}
259	}
260	require.NotNil(t, pub, "the kid is in the JWKS")
261	sig, err := base64.RawURLEncoding.DecodeString(parts[2])
262	require.NoError(t, err)
263	sum := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
264	require.NoError(t, rsa.VerifyPKCS1v15(pub, crypto.SHA256, sum[:], sig))
265	raw, err := base64.RawURLEncoding.DecodeString(parts[1])
266	require.NoError(t, err)
267	var claims map[string]any
268	require.NoError(t, json.Unmarshal(raw, &claims))
269	return claims
270}
271
272// The contexts of a job (Job.contexts), as the planner computes them.
273func jobContexts(event, ref, headRef, baseRef, jobWorkflowRef string) string {
274	b, _ := json.Marshal(map[string]any{
275		"github": map[string]any{
276			"ref": ref, "sha": "abc123", "repository": "owner/proj", "repository_owner": "owner",
277			"repository_owner_id": "1048577", "run_id": "42", "run_number": "7", "run_attempt": "2",
278			"repository_visibility": "private", "repository_id": "1048999", "actor_id": "1048577", "actor": "owner",
279			"workflow": "CI", "head_ref": headRef, "base_ref": baseRef, "event_name": event, "ref_protected": false,
280			"ref_type": "branch", "workflow_ref": "owner/proj/.github/workflows/ci.yml@" + ref, "workflow_sha": "abc123",
281		},
282		"job": map[string]any{
283			"check_run_id": 9007199254740991, "workflow_ref": jobWorkflowRef, "workflow_sha": "def456",
284		},
285	})
286	return string(b)
287}
288
289// The claims follow GitHub's token: sub per event and environment, the job's
290// workflow in job_workflow_ref, numbers as strings, the times around iat.
291func TestIDTokenClaimsPerEvent(t *testing.T) {
292	t.Parallel()
293	own := "owner/proj/.github/workflows/ci.yml@refs/heads/main"
294	called := "owner/tools/.github/workflows/deploy.yml@refs/tags/v1"
295	tests := []struct {
296		name, event, ref, head, base, env, jobWF string
297		sub                                      string
298	}{
299		{"push", "push", "refs/heads/main", "", "", "", own, "repo:owner/proj:ref:refs/heads/main"},
300		{"tag", "push", "refs/tags/v1.0.0", "", "", "", own, "repo:owner/proj:ref:refs/tags/v1.0.0"},
301		{"dispatch", "workflow_dispatch", "refs/heads/main", "", "", "", own, "repo:owner/proj:ref:refs/heads/main"},
302		{"schedule", "schedule", "refs/heads/main", "", "", "", own, "repo:owner/proj:ref:refs/heads/main"},
303		{"pull_request", "pull_request", "refs/pull/3/merge", "feat", "main", "", own, "repo:owner/proj:pull_request"},
304		{"pull_request_target", "pull_request_target", "refs/heads/main", "feat", "main", "", own, "repo:owner/proj:pull_request"},
305		{"environment wins", "pull_request", "refs/pull/3/merge", "feat", "main", "prod", own, "repo:owner/proj:environment:prod"},
306		{"push to an environment", "push", "refs/heads/main", "", "", "staging", own, "repo:owner/proj:environment:staging"},
307		{"reusable workflow", "push", "refs/heads/main", "", "", "", called, "repo:owner/proj:ref:refs/heads/main"},
308	}
309	for _, tt := range tests {
310		t.Run(tt.name, func(t *testing.T) {
311			t.Parallel()
312			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)
313			require.NoError(t, err)
314			assert.Equal(t, tt.sub, c["sub"])
315			assert.Equal(t, "jti", keys[0])
316			assert.Equal(t, "https://forge.test/-/oidc", c["iss"])
317			assert.Equal(t, "https://forge.test/owner", c["aud"])
318			assert.Equal(t, tt.ref, c["ref"])
319			assert.Equal(t, tt.event, c["event_name"])
320			assert.Equal(t, tt.head, c["head_ref"])
321			assert.Equal(t, tt.base, c["base_ref"])
322			assert.Equal(t, "2", c["run_attempt"])
323			assert.Equal(t, "42", c["run_id"])
324			assert.Equal(t, "false", c["ref_protected"])
325			assert.Equal(t, "owner/proj/.github/workflows/ci.yml@"+tt.ref, c["workflow_ref"], "the caller's workflow")
326			assert.Equal(t, tt.jobWF, c["job_workflow_ref"], "the workflow that defines the job")
327			assert.Equal(t, "def456", c["job_workflow_sha"])
328			assert.Equal(t, "9007199254740991", c["check_run_id"], "exact, as a string")
329			assert.Equal(t, "self-hosted", c["runner_environment"])
330			assert.Equal(t, now.Unix(), c["iat"])
331			assert.Equal(t, now.Unix()-600, c["nbf"])
332			assert.Equal(t, now.Unix()+300, c["exp"])
333			if tt.env != "" {
334				assert.Equal(t, tt.env, c["environment"])
335			} else {
336				assert.NotContains(t, c, "environment", "GitHub leaves it out without an environment")
337			}
338		})
339	}
340}

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