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}