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}