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}