detached 83e495f

handler.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package oidchttp serves the OIDC provider of CI jobs under /-/oidc, the
4// issuer (CI-4, D5):
5//
6//	GET /-/oidc/.well-known/openid-configuration   discovery, anonymous
7//	GET /-/oidc/.well-known/jwks                   public keys, anonymous
8//	GET /-/oidc/token?api-version=2.0[&audience=…] {"value":"<jwt>"}
9//
10// The token endpoint is the ACTIONS_ID_TOKEN_REQUEST_URL of a job, called by
11// @actions/core's getIDToken with "Authorization: Bearer <the job's request
12// token>". Everything is 404 when the server issues no ID tokens.
13package oidchttp
14
15import (
16	"encoding/json"
17	"errors"
18	"io"
19	"net/http"
20	"net/netip"
21	"strconv"
22	"strings"
23
24	"github.com/bnema/zerowrap"
25
26	"bnema.dev/ermite/internal/clientlimit"
27	"bnema.dev/ermite/internal/domain"
28	"bnema.dev/ermite/internal/ports"
29)
30
31// Prefix is where the provider is mounted: the path of the issuer.
32const Prefix = "/-/oidc"
33
34// Patterns are the ServeMux patterns the handler serves: its whole prefix.
35var Patterns = []string{Prefix + "/"}
36
37// How long clients may cache the public documents. The JWKS is short: after a
38// rotation the new key signs at once, and relying parties that do not refetch
39// on an unknown kid see it within this delay.
40const (
41	discoveryMaxAge = "public, max-age=3600"
42	jwksMaxAge      = "public, max-age=300"
43)
44
45// Config configures a Handler.
46type Config struct {
47	OIDC ports.OIDCService
48	// TrustedProxies are the networks allowed to set X-Forwarded-For.
49	TrustedProxies []netip.Prefix
50	// Limiter counts failed authentications; nil builds a default one.
51	Limiter ports.FailureLimiter
52}
53
54// Handler serves the provider.
55type Handler struct {
56	cfg     Config
57	limiter ports.FailureLimiter
58}
59
60// New builds a Handler.
61func New(cfg Config) *Handler {
62	h := &Handler{cfg: cfg, limiter: cfg.Limiter}
63	if h.limiter == nil {
64		h.limiter = clientlimit.NewLimiter(nil)
65	}
66	return h
67}
68
69// ServeHTTP implements http.Handler.
70func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
71	w.Header().Set("X-Content-Type-Options", "nosniff")
72	path := strings.TrimPrefix(r.URL.Path, Prefix)
73	if !h.cfg.OIDC.OIDCEnabled() {
74		w.Header().Set("Cache-Control", "no-store")
75		jsonError(w, http.StatusNotFound, "Not Found")
76		return
77	}
78	if r.Method != http.MethodGet && r.Method != http.MethodHead {
79		w.Header().Set("Allow", "GET, HEAD")
80		jsonError(w, http.StatusMethodNotAllowed, "Method Not Allowed")
81		return
82	}
83	switch path {
84	case "/.well-known/openid-configuration":
85		h.public(w, r, discoveryMaxAge, h.cfg.OIDC.OIDCDiscovery)
86	case "/.well-known/jwks":
87		h.public(w, r, jwksMaxAge, h.cfg.OIDC.OIDCKeys)
88	case "/token":
89		h.token(w, r)
90	default:
91		w.Header().Set("Cache-Control", "no-store")
92		jsonError(w, http.StatusNotFound, "Not Found")
93	}
94}
95
96// public answers a document anyone may read and cache.
97func (h *Handler) public(w http.ResponseWriter, r *http.Request, cache string, doc func() ([]byte, error)) {
98	body, err := doc()
99	if err != nil {
100		w.Header().Set("Cache-Control", "no-store")
101		h.fail(w, r, err)
102		return
103	}
104	w.Header().Set("Cache-Control", cache)
105	writeJSON(w, r, body)
106}
107
108// token answers an ID token for the job whose request token is the bearer.
109// api-version is accepted as GitHub's endpoint takes it, and not checked.
110func (h *Handler) token(w http.ResponseWriter, r *http.Request) {
111	w.Header().Set("Cache-Control", "no-store")
112	ctx := r.Context()
113	addr := clientlimit.ClientAddr(r, h.cfg.TrustedProxies)
114	if blocked, _ := h.limiter.Blocked(ctx, addr); blocked {
115		jsonError(w, http.StatusTooManyRequests, "Too many failed authentications")
116		return
117	}
118	secret, ok := bearer(r)
119	if !ok {
120		w.Header().Set("WWW-Authenticate", `Bearer realm="ermite oidc"`)
121		jsonError(w, http.StatusUnauthorized, "Bad credentials")
122		return
123	}
124	jwt, err := h.cfg.OIDC.IDToken(ctx, secret, r.URL.Query().Get("audience"))
125	if err != nil {
126		if errors.Is(err, domain.ErrUnauthenticated) {
127			h.limiter.Fail(ctx, addr)
128			w.Header().Set("WWW-Authenticate", `Bearer realm="ermite oidc"`)
129			jsonError(w, http.StatusUnauthorized, "Bad credentials")
130			return
131		}
132		h.fail(w, r, err)
133		return
134	}
135	body, err := json.Marshal(map[string]string{"value": jwt})
136	if err != nil {
137		h.fail(w, r, err)
138		return
139	}
140	writeJSON(w, r, body)
141}
142
143// bearer extracts the token of "Authorization: Bearer <token>".
144func bearer(r *http.Request) (string, bool) {
145	scheme, tok, ok := strings.Cut(r.Header.Get("Authorization"), " ")
146	if !ok || !strings.EqualFold(scheme, "Bearer") {
147		return "", false
148	}
149	tok = strings.TrimSpace(tok)
150	return tok, tok != ""
151}
152
153func (h *Handler) fail(w http.ResponseWriter, r *http.Request, err error) {
154	switch {
155	case errors.Is(err, domain.ErrNotFound):
156		jsonError(w, http.StatusNotFound, "Not Found")
157	case domain.IsInvalid(err):
158		jsonError(w, http.StatusBadRequest, err.Error())
159	default:
160		log := zerowrap.FromCtx(r.Context())
161		log.Error().Err(err).Msg("oidc request failed")
162		jsonError(w, http.StatusInternalServerError, "Server Error")
163	}
164}
165
166func jsonError(w http.ResponseWriter, status int, msg string) {
167	w.Header().Set("Content-Type", "application/json; charset=utf-8")
168	w.WriteHeader(status)
169	_, _ = io.WriteString(w, `{"message":`+strconv.Quote(msg)+`}`)
170}
171
172func writeJSON(w http.ResponseWriter, r *http.Request, body []byte) {
173	w.Header().Set("Content-Type", "application/json; charset=utf-8")
174	w.Header().Set("Content-Length", strconv.Itoa(len(body)))
175	w.WriteHeader(http.StatusOK)
176	if r.Method != http.MethodHead {
177		_, _ = w.Write(body)
178	}
179}

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 4

Tags 45

↑↓ moveenter openesc close