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.Enabled() || !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.Enabled() }
52
53// OIDCDiscovery returns the OpenID Provider configuration.
54func (c *CI) OIDCDiscovery() ([]byte, error) {
55 return c.cfg.OIDC.Discovery()
56}
57
58// OIDCKeys returns the JWKS.
59func (c *CI) OIDCKeys() ([]byte, error) {
60 return c.cfg.OIDC.JWKS()
61}
62
63// IDToken signs an ID token for the job whose request token is secret. The
64// token must exist (it is deleted when the job ends), not have expired, and
65// its job must be assigned or running, still allowed to have ID tokens.
66// audience "" is the default, "<public URL>/<owner>" as GitHub's
67// "https://github.com/<owner>".
68func (c *CI) IDToken(ctx context.Context, secret, audience string) (string, error) {
69 o := c.cfg.OIDC
70 if !o.Enabled() {
71 return "", domain.ErrNotFound
72 }
73 if len(audience) > domain.MaxOIDCAudienceLen {
74 return "", &domain.InvalidFieldsError{Fields: []domain.FieldViolation{{Field: "audience", Reason: fmt.Sprintf("must be at most %d bytes", domain.MaxOIDCAudienceLen)}}}
75 }
76 if !domain.IsOIDCRequestToken(secret) {
77 return "", domain.ErrUnauthenticated
78 }
79 t, err := o.cfg.Tokens.FindRepoTokenByHash(ctx, domain.HashTokenSecret(secret))
80 switch {
81 case errors.Is(err, domain.ErrNotFound):
82 return "", domain.ErrUnauthenticated
83 case err != nil:
84 return "", fmt.Errorf("find the id token request token: %w", err)
85 }
86 now := c.now()
87 if !t.OIDC || t.Expired(now) {
88 return "", domain.ErrUnauthenticated
89 }
90 rs, err := c.loadRun(ctx, t.RunID, t.RunAttempt)
91 switch {
92 case errors.Is(err, domain.ErrNotFound):
93 return "", domain.ErrUnauthenticated
94 case err != nil:
95 return "", err
96 }
97 j := rs.find(t.JobID)
98 if j == nil || !j.Active() || !domain.IDTokenAllowed(j.Permissions, rs.run.Trusted) {
99 return "", domain.ErrUnauthenticated
100 }
101 if audience == "" {
102 audience = c.cfg.PublicURL + "/" + c.cfg.Owner
103 }
104 claims, err := idTokenClaims(j.ContextsJSON, j.Environment, idTokenMeta{
105 issuer: o.Issuer(), audience: audience, jti: newJTI(), now: now,
106 })
107 if err != nil {
108 return "", err
109 }
110 return o.sign(claims)
111}
112
113// idTokenMeta are the claims of a token that do not come from the job.
114type idTokenMeta struct {
115 issuer, audience, jti string
116 now time.Time
117}
118
119// idTokenClaims builds the claims of a job's ID token from the contexts the
120// job was given (Job.contexts): the github context for the run, the job
121// context for the workflow that defines the job (the called one for a job of a
122// reusable workflow: job_workflow_ref), as GitHub's tokens. The order is the one
123// of GitHub's example token.
124func idTokenClaims(contextsJSON, environment string, t idTokenMeta) (ordered, error) {
125 var ctxs struct {
126 GitHub map[string]any `json:"github"`
127 Job map[string]any `json:"job"`
128 }
129 dec := json.NewDecoder(bytes.NewReader([]byte(contextsJSON)))
130 dec.UseNumber()
131 if err := dec.Decode(&ctxs); err != nil {
132 return nil, fmt.Errorf("the contexts of the job are not valid: %w", err)
133 }
134 gh := func(k string) string { return claimString(ctxs.GitHub[k]) }
135 job := func(k string) string { return claimString(ctxs.Job[k]) }
136 claims := ordered{
137 {"jti", t.jti},
138 {"sub", domain.OIDCSubject(gh("repository"), gh("event_name"), environment, gh("ref"))},
139 }
140 if environment != "" {
141 claims = append(claims, pair{"environment", environment})
142 }
143 iat := t.now.Unix()
144 claims = append(claims, ordered{
145 {"aud", t.audience},
146 {"ref", gh("ref")}, {"sha", gh("sha")},
147 {"repository", gh("repository")}, {"repository_owner", gh("repository_owner")},
148 {"repository_owner_id", gh("repository_owner_id")},
149 {"run_id", gh("run_id")}, {"run_number", gh("run_number")}, {"run_attempt", gh("run_attempt")},
150 {"repository_visibility", gh("repository_visibility")}, {"repository_id", gh("repository_id")},
151 {"actor_id", gh("actor_id")}, {"actor", gh("actor")}, {"workflow", gh("workflow")},
152 {"head_ref", gh("head_ref")}, {"base_ref", gh("base_ref")}, {"event_name", gh("event_name")},
153 {"ref_protected", gh("ref_protected")}, {"ref_type", gh("ref_type")},
154 {"workflow_ref", gh("workflow_ref")}, {"workflow_sha", gh("workflow_sha")},
155 {"job_workflow_ref", job("workflow_ref")}, {"job_workflow_sha", job("workflow_sha")},
156 {"check_run_id", job("check_run_id")},
157 {"runner_environment", domain.OIDCRunnerEnvironment},
158 {"iss", t.issuer},
159 {"nbf", iat - int64(domain.OIDCNotBeforeSkew/time.Second)},
160 {"exp", iat + int64(domain.OIDCTokenLifetime/time.Second)},
161 {"iat", iat},
162 }...)
163 return claims, nil
164}
165
166// claimString is a context value as GitHub puts it in a claim: a string, numbers
167// and booleans written out, nothing for null.
168func claimString(v any) string {
169 switch v := v.(type) {
170 case string:
171 return v
172 case json.Number:
173 return v.String()
174 case bool:
175 return strconv.FormatBool(v)
176 }
177 return ""
178}
179
180// newJTI is a random UUID (version 4), the shape of GitHub's jti.
181func newJTI() string {
182 var b [16]byte
183 _, _ = rand.Read(b[:])
184 b[6] = b[6]&0x0f | 0x40
185 b[8] = b[8]&0x3f | 0x80
186 return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
187}