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}