1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6 "bytes"
7 "context"
8 "crypto/rand"
9 "encoding/json"
10 "errors"
11 "fmt"
12 "strconv"
13 "time"
14
15 "bnema.dev/ermite/internal/domain"
16 "bnema.dev/ermite/internal/ports"
17)
18
19// The ID tokens of CI jobs (CI-4, D5). A job that may have them (a trusted run
20// and id-token: write, domain.IDTokenAllowed) gets with its assignment the
21// token endpoint and a request token of its own: a job repository token
22// (CIRepoToken.OIDC) that lives and dies with the job as its github.token does,
23// and that nothing but the token endpoint accepts. The runner gives both to
24// the steps as ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN,
25// as GitHub's runner does; never ACTIONS_RUNTIME_TOKEN.
26
27var _ ports.OIDCService = (*CI)(nil)
28
29// issueIDTokenRequest adds the ID token request token of j to the next flush
30// and returns its secret, or "" when the job may not request ID tokens or the
31// server issues none. j must be assigned (its AssignedAt is set).
32func (rs *runState) issueIDTokenRequest(j *domain.CIJob) string {
33 c := rs.c
34 if c.cfg.OIDC == nil || !domain.IDTokenAllowed(j.Permissions, rs.run.Trusted) {
35 return ""
36 }
37 now := c.now()
38 at := *j
39 if at.AssignedAt.IsZero() {
40 at.AssignedAt = now
41 }
42 secret := newToken(domain.OIDCRequestTokenPrefix)
43 t := c.repoTokenExpiry(at)
44 t.ID, t.Hash, t.Repo, t.OIDC = newRepoTokenID(), domain.HashTokenSecret(secret), rs.run.Repo, true
45 t.RunID, t.RunAttempt, t.JobID, t.CreatedAt = j.RunID, j.RunAttempt, j.ID, now
46 rs.tokens = append(rs.tokens, t)
47 return secret
48}
49
50// OIDCEnabled reports whether the server issues ID tokens.
51func (c *CI) OIDCEnabled() bool { return c.cfg.OIDC != nil }
52
53// OIDCDiscovery returns the OpenID Provider configuration.
54func (c *CI) OIDCDiscovery() ([]byte, error) {
55 if c.cfg.OIDC == nil {
56 return nil, domain.ErrNotFound
57 }
58 return c.cfg.OIDC.Discovery()
59}
60
61// OIDCKeys returns the JWKS.
62func (c *CI) OIDCKeys() ([]byte, error) {
63 if c.cfg.OIDC == nil {
64 return nil, domain.ErrNotFound
65 }
66 return c.cfg.OIDC.JWKS()
67}
68
69// IDToken signs an ID token for the job whose request token is secret. The
70// token must exist (it is deleted when the job ends), not have expired, and
71// its job must be assigned or running, still allowed to have ID tokens.
72// audience "" is the default, "<public URL>/<owner>" as GitHub's
73// "https://github.com/<owner>".
74func (c *CI) IDToken(ctx context.Context, secret, audience string) (string, error) {
75 o := c.cfg.OIDC
76 if o == nil {
77 return "", domain.ErrNotFound
78 }
79 if len(audience) > domain.MaxOIDCAudienceLen {
80 return "", &domain.InvalidFieldsError{Fields: []domain.FieldViolation{{Field: "audience", Reason: fmt.Sprintf("must be at most %d bytes", domain.MaxOIDCAudienceLen)}}}
81 }
82 if !domain.IsOIDCRequestToken(secret) {
83 return "", domain.ErrUnauthenticated
84 }
85 t, err := o.cfg.Tokens.FindRepoTokenByHash(ctx, domain.HashTokenSecret(secret))
86 switch {
87 case errors.Is(err, domain.ErrNotFound):
88 return "", domain.ErrUnauthenticated
89 case err != nil:
90 return "", fmt.Errorf("find the id token request token: %w", err)
91 }
92 now := c.now()
93 if !t.OIDC || t.Expired(now) {
94 return "", domain.ErrUnauthenticated
95 }
96 rs, err := c.loadRun(ctx, t.RunID, t.RunAttempt)
97 switch {
98 case errors.Is(err, domain.ErrNotFound):
99 return "", domain.ErrUnauthenticated
100 case err != nil:
101 return "", err
102 }
103 j := rs.find(t.JobID)
104 if j == nil || !j.Active() || !domain.IDTokenAllowed(j.Permissions, rs.run.Trusted) {
105 return "", domain.ErrUnauthenticated
106 }
107 if audience == "" {
108 audience = c.cfg.PublicURL + "/" + c.cfg.Owner
109 }
110 claims, err := idTokenClaims(j.ContextsJSON, j.Environment, idTokenMeta{
111 issuer: o.Issuer(), audience: audience, jti: newJTI(), now: now,
112 })
113 if err != nil {
114 return "", err
115 }
116 return o.sign(claims)
117}
118
119// idTokenMeta are the claims of a token that do not come from the job.
120type idTokenMeta struct {
121 issuer, audience, jti string
122 now time.Time
123}
124
125// idTokenClaims builds the claims of a job's ID token from the contexts the
126// job was given (Job.contexts): the github context for the run, the job
127// context for the workflow that defines the job (the called one for a job of a
128// reusable workflow: job_workflow_ref), as GitHub's tokens. The order is the one
129// of GitHub's example token.
130func idTokenClaims(contextsJSON, environment string, t idTokenMeta) (ordered, error) {
131 var ctxs struct {
132 GitHub map[string]any `json:"github"`
133 Job map[string]any `json:"job"`
134 }
135 dec := json.NewDecoder(bytes.NewReader([]byte(contextsJSON)))
136 dec.UseNumber()
137 if err := dec.Decode(&ctxs); err != nil {
138 return nil, fmt.Errorf("the contexts of the job are not valid: %w", err)
139 }
140 gh := func(k string) string { return claimString(ctxs.GitHub[k]) }
141 job := func(k string) string { return claimString(ctxs.Job[k]) }
142 claims := ordered{
143 {"jti", t.jti},
144 {"sub", domain.OIDCSubject(gh("repository"), gh("event_name"), environment, gh("ref"))},
145 }
146 if environment != "" {
147 claims = append(claims, pair{"environment", environment})
148 }
149 iat := t.now.Unix()
150 claims = append(claims, ordered{
151 {"aud", t.audience},
152 {"ref", gh("ref")}, {"sha", gh("sha")},
153 {"repository", gh("repository")}, {"repository_owner", gh("repository_owner")},
154 {"repository_owner_id", gh("repository_owner_id")},
155 {"run_id", gh("run_id")}, {"run_number", gh("run_number")}, {"run_attempt", gh("run_attempt")},
156 {"repository_visibility", gh("repository_visibility")}, {"repository_id", gh("repository_id")},
157 {"actor_id", gh("actor_id")}, {"actor", gh("actor")}, {"workflow", gh("workflow")},
158 {"head_ref", gh("head_ref")}, {"base_ref", gh("base_ref")}, {"event_name", gh("event_name")},
159 {"ref_protected", gh("ref_protected")}, {"ref_type", gh("ref_type")},
160 {"workflow_ref", gh("workflow_ref")}, {"workflow_sha", gh("workflow_sha")},
161 {"job_workflow_ref", job("workflow_ref")}, {"job_workflow_sha", job("workflow_sha")},
162 {"check_run_id", job("check_run_id")},
163 {"runner_environment", domain.OIDCRunnerEnvironment},
164 {"iss", t.issuer},
165 {"nbf", iat - int64(domain.OIDCNotBeforeSkew/time.Second)},
166 {"exp", iat + int64(domain.OIDCTokenLifetime/time.Second)},
167 {"iat", iat},
168 }...)
169 return claims, nil
170}
171
172// claimString is a context value as GitHub puts it in a claim: a string, numbers
173// and booleans written out, nothing for null.
174func claimString(v any) string {
175 switch v := v.(type) {
176 case string:
177 return v
178 case json.Number:
179 return v.String()
180 case bool:
181 return strconv.FormatBool(v)
182 }
183 return ""
184}
185
186// newJTI is a random UUID (version 4), the shape of GitHub's jti.
187func newJTI() string {
188 var b [16]byte
189 _, _ = rand.Read(b[:])
190 b[6] = b[6]&0x0f | 0x40
191 b[8] = b[8]&0x3f | 0x80
192 return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
193}