v0.19.0 55a688e

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 == 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}

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 2

Tags 45

↑↓ moveenter openesc close