main 80d5067

ci_oidc.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 44

↑↓ moveenter openesc close