1package oidcrp
2
3import (
4 "bytes"
5 "cmp"
6 "context"
7 "crypto/hkdf"
8 "crypto/rand"
9 "crypto/sha256"
10 "crypto/subtle"
11 "encoding/base64"
12 "encoding/json"
13 "errors"
14 "fmt"
15 "net/http"
16 "net/url"
17 "slices"
18 "strings"
19 "time"
20
21 "github.com/coreos/go-oidc/v3/oidc"
22 "github.com/dexidp/dex/server/tokens"
23 "golang.org/x/oauth2"
24
25 "tug/internal/platform/config"
26 "tug/internal/platform/spent"
27 transporthttp "tug/internal/transport/http"
28)
29
30const (
31 TxnCookie = "__Host-authsvc_txn"
32 SessionCookie = "__Host-authsvc_sess"
33 SecretLength = 32
34 MaxTxnTTL = 10 * time.Minute
35 maxTxnLimit = 16
36 MaxReturnBytes = 2048
37)
38
39var Scopes = []string{oidc.ScopeOpenID, "profile", "groups", tokens.ScopeFederatedID}
40
41var (
42 ErrUnknownHost = errors.New("oidcrp: the host has no client")
43 ErrReturn = errors.New("oidcrp: the return path is not an absolute path")
44 ErrNoTxn = errors.New("oidcrp: no transaction cookie")
45 ErrTxnMalformed = errors.New("oidcrp: the transaction does not decode")
46 ErrTxnExpired = errors.New("oidcrp: the transaction expired")
47 ErrTxnHost = errors.New("oidcrp: the transaction belongs to another host")
48 ErrState = errors.New("oidcrp: state does not match the transaction")
49 ErrTxnSpent = errors.New("oidcrp: the transaction was already spent")
50 ErrAuthorization = errors.New("oidcrp: the provider returned an error")
51 ErrExchange = errors.New("oidcrp: the code exchange failed")
52 ErrIDToken = errors.New("oidcrp: the ID token did not verify")
53 ErrNonce = errors.New("oidcrp: nonce does not match the transaction")
54 ErrSubject = errors.New("oidcrp: the subject does not match the federated user id")
55 ErrUser = errors.New("oidcrp: the user is missing or disabled")
56 ErrNoSession = errors.New("oidcrp: no session")
57 ErrSessions = errors.New("oidcrp: the session store failed")
58)
59
60type Session struct {
61 UserID string
62 Expires time.Time
63}
64
65type Sessions interface {
66 Current(ctx context.Context, secret []byte, host string) (Session, error)
67 OpenAt(ctx context.Context, userID, host, clientID string, epoch int64, browser string) ([]byte, Session, error)
68 Revoke(ctx context.Context, secret []byte, host string) error
69 Authenticated(ctx context.Context, secret []byte, host, clientID string) error
70}
71
72type Client struct {
73 ID string
74 Secret config.Secret
75 Origins []string
76 CallbackPath string
77}
78
79type Config struct {
80 Issuer string
81 IDP http.Handler
82 Hosts []string
83 MasterKey config.Secret
84 Clients []Client
85 ConnectorID string
86 Sessions Sessions
87 TxnTTL time.Duration
88 TxnLimit int
89 Now func() time.Time
90 Refuse func(w http.ResponseWriter, r *http.Request, status int, err error)
91 Rotate func(w http.ResponseWriter)
92 CodeEpoch func(ctx context.Context, code string) (string, int64, string, error)
93}
94
95type txn struct {
96 State string `json:"s"`
97 Nonce string `json:"n"`
98 Verifier string `json:"v"`
99 Host string `json:"h"`
100 Return string `json:"r"`
101 Expiry int64 `json:"x"`
102}
103
104type target struct {
105 client Client
106 origin string
107}
108
109type RP struct {
110 cfg Config
111 now func() time.Time
112 ttl time.Duration
113 client *http.Client
114 endpoint oauth2.Endpoint
115 byHost map[string]target
116 verifiers map[string]*oidc.IDTokenVerifier
117 txn *Sealer
118 sess *Sealer
119 login *Sealer
120 proofKey []byte
121 spent *spent.Set
122}
123
124func New(ctx context.Context, cfg Config) (*RP, error) {
125 iss, err := url.Parse(cfg.Issuer)
126 if err != nil || iss.Host == "" || (iss.Scheme != "https" && iss.Scheme != "http") {
127 return nil, fmt.Errorf("oidcrp: issuer %q is not scheme://host", cfg.Issuer)
128 }
129 if cfg.IDP == nil || cfg.Sessions == nil || cfg.Refuse == nil || cfg.Rotate == nil || cfg.ConnectorID == "" {
130 return nil, errors.New("oidcrp: the provider handler, the sessions, the refusal page, the CSRF rotation and the connector id are required")
131 }
132 if cfg.CodeEpoch == nil {
133 return nil, errors.New("oidcrp: the code epoch reader is required")
134 }
135 ttl := cfg.TxnTTL
136 if ttl == 0 {
137 ttl = MaxTxnTTL
138 }
139 if ttl < 0 || ttl > MaxTxnTTL {
140 return nil, errors.New("oidcrp: the transaction lifetime is at most ten minutes")
141 }
142 if cfg.TxnLimit < 0 || cfg.TxnLimit > maxTxnLimit {
143 return nil, fmt.Errorf("oidcrp: the transaction limit is between 0 and %d", maxTxnLimit)
144 }
145 now := cfg.Now
146 if now == nil {
147 now = time.Now
148 }
149 key := cfg.MasterKey.Bytes()
150 txnSealer, err := NewSealer(key, TxnInfo)
151 if err != nil {
152 return nil, err
153 }
154 sessSealer, err := NewSealer(key, SessionInfo)
155 if err != nil {
156 return nil, err
157 }
158 loginSealer, err := NewSealer(key, LoginInfo)
159 if err != nil {
160 return nil, err
161 }
162 proofKey, err := hkdf.Key(sha256.New, key, nil, LoginProofInfo, LoginProofLength)
163 if err != nil {
164 return nil, err
165 }
166 byHost := map[string]target{}
167 for _, c := range cfg.Clients {
168 if c.ID == "" || len(c.Secret.Bytes()) == 0 || !strings.HasPrefix(c.CallbackPath, "/") || len(c.Origins) == 0 {
169 return nil, fmt.Errorf("oidcrp: client %q needs a secret, a callback path and an origin", c.ID)
170 }
171 for _, raw := range c.Origins {
172 host, err := transporthttp.ParseOrigin(raw, iss.Scheme, cfg.Hosts...)
173 if err != nil {
174 return nil, fmt.Errorf("oidcrp: client %q: %w", c.ID, err)
175 }
176 if _, dup := byHost[host]; dup {
177 return nil, fmt.Errorf("oidcrp: host %q has two origins: %w", host, transporthttp.ErrOrigin)
178 }
179 byHost[host] = target{client: c, origin: raw}
180 }
181 }
182 client := &http.Client{
183 Transport: InProcess(iss.Host, cfg.IDP),
184 Timeout: 15 * time.Second,
185 CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
186 }
187 provider, err := oidc.NewProvider(oidc.ClientContext(context.WithoutCancel(ctx), client), cfg.Issuer)
188 if err != nil {
189 return nil, fmt.Errorf("oidcrp: discovery: %w", err)
190 }
191 endpoint := provider.Endpoint()
192 endpoint.AuthStyle = oauth2.AuthStyleInHeader
193 rp := &RP{
194 cfg: cfg, now: now, ttl: ttl, client: client, endpoint: endpoint,
195 byHost: byHost, verifiers: map[string]*oidc.IDTokenVerifier{},
196 txn: txnSealer, sess: sessSealer, login: loginSealer, proofKey: proofKey, spent: spent.New(),
197 }
198 for _, c := range cfg.Clients {
199 rp.verifiers[c.ID] = provider.Verifier(&oidc.Config{ClientID: c.ID, Now: now})
200 }
201 return rp, nil
202}
203
204func (rp *RP) oauth(tg target) *oauth2.Config {
205 return &oauth2.Config{
206 ClientID: tg.client.ID, ClientSecret: string(tg.client.Secret.Bytes()), Endpoint: rp.endpoint,
207 RedirectURL: tg.origin + tg.client.CallbackPath, Scopes: Scopes,
208 }
209}
210
211func ValidReturn(p string) error {
212 if !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") || strings.HasPrefix(p, "/\\") {
213 return ErrReturn
214 }
215 u, err := url.Parse(p)
216 if err != nil || u.Scheme != "" || u.Host != "" || u.User != nil || u.Opaque != "" {
217 return ErrReturn
218 }
219 if b, err := json.Marshal(p); err != nil || len(b)-2 > MaxReturnBytes {
220 return ErrReturn
221 }
222 return nil
223}
224
225func ReturnOrRoot(p string) string {
226 if ValidReturn(p) != nil {
227 return "/"
228 }
229 return p
230}
231
232func random() string {
233 b := make([]byte, SecretLength)
234 rand.Read(b)
235 return base64.RawURLEncoding.EncodeToString(b)
236}
237
238func cookie(name, value string, maxAge int) *http.Cookie {
239 return &http.Cookie{Name: name, Value: value, Path: "/", MaxAge: maxAge, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode}
240}
241
242func (rp *RP) Begin(w http.ResponseWriter, r *http.Request, returnPath string) {
243 w.Header().Set("Cache-Control", "no-store")
244 host := transporthttp.CanonicalHost(r.Host)
245 tg, ok := rp.byHost[host]
246 if !ok {
247 rp.cfg.Refuse(w, r, http.StatusNotFound, ErrUnknownHost)
248 return
249 }
250 if err := ValidReturn(returnPath); err != nil {
251 rp.cfg.Refuse(w, r, http.StatusBadRequest, err)
252 return
253 }
254 t := txn{
255 State: random(), Nonce: random(), Verifier: oauth2.GenerateVerifier(), Host: host,
256 Return: returnPath, Expiry: rp.now().Add(rp.ttl).Unix(),
257 }
258 b, err := json.Marshal(t)
259 if err != nil {
260 rp.cfg.Refuse(w, r, http.StatusInternalServerError, err)
261 return
262 }
263 name := rp.txnName(t.State)
264 if rp.cfg.TxnLimit > 0 {
265 rp.evictTxns(w, r, host)
266 }
267 http.SetCookie(w, cookie(name, rp.txn.Seal(name, b), int(rp.ttl/time.Second)))
268 http.Redirect(w, r, rp.oauth(tg).AuthCodeURL(t.State, oauth2.S256ChallengeOption(t.Verifier), oidc.Nonce(t.Nonce)), http.StatusFound)
269}
270
271func txnCookieName(state string) string {
272 return cookieName(TxnCookie, state)
273}
274
275func cookieName(prefix, state string) string {
276 sum := sha256.Sum256([]byte(state))
277 return prefix + "_" + base64.RawURLEncoding.EncodeToString(sum[:])[:11]
278}
279
280func (rp *RP) txnName(state string) string {
281 if rp.cfg.TxnLimit == 0 {
282 return TxnCookie
283 }
284 return txnCookieName(state)
285}
286
287func (rp *RP) evictTxns(w http.ResponseWriter, r *http.Request, host string) {
288 evictCookies(w, r, TxnCookie+"_", rp.cfg.TxnLimit, rp.now(), func(c *http.Cookie) (time.Time, bool) {
289 t, err := rp.decodeTxn(c.Name, c.Value)
290 return time.Unix(t.Expiry, 0), err == nil && t.Host == host
291 })
292}
293
294func evictCookies(w http.ResponseWriter, r *http.Request, prefix string, limit int, now time.Time, decode func(*http.Cookie) (time.Time, bool)) {
295 if limit <= 0 {
296 return
297 }
298 live := liveCookies(w, r, prefix, now, decode)
299 for len(live) >= limit {
300 http.SetCookie(w, cookie(live[0].name, "", -1))
301 live = live[1:]
302 }
303}
304
305type heldCookie struct {
306 name string
307 expiry int64
308}
309
310func liveCookies(w http.ResponseWriter, r *http.Request, prefix string, now time.Time, decode func(*http.Cookie) (time.Time, bool)) []heldCookie {
311 var live []heldCookie
312 for _, ck := range r.Cookies() {
313 if !strings.HasPrefix(ck.Name, prefix) {
314 continue
315 }
316 expiry, ok := decode(ck)
317 if !ok || !now.Before(expiry) {
318 http.SetCookie(w, cookie(ck.Name, "", -1))
319 continue
320 }
321 live = append(live, heldCookie{ck.Name, expiry.UnixMicro()})
322 }
323 slices.SortStableFunc(live, func(a, b heldCookie) int { return cmp.Compare(a.expiry, b.expiry) })
324 return live
325}
326
327func (rp *RP) openTxn(r *http.Request, name string) (txn, error) {
328 ck, err := r.Cookie(name)
329 if err != nil {
330 return txn{}, ErrNoTxn
331 }
332 return rp.decodeTxn(name, ck.Value)
333}
334
335func (rp *RP) decodeTxn(name, value string) (txn, error) {
336 raw, err := rp.txn.Open(name, value)
337 if err != nil {
338 return txn{}, err
339 }
340 dec := json.NewDecoder(bytes.NewReader(raw))
341 dec.DisallowUnknownFields()
342 var t txn
343 if err := dec.Decode(&t); err != nil || dec.More() || t.State == "" || t.Nonce == "" || t.Verifier == "" || ValidReturn(t.Return) != nil {
344 return txn{}, ErrTxnMalformed
345 }
346 return t, nil
347}
348
349func CheckSubject(sub, userID, connectorID string) error {
350 if userID == "" {
351 return ErrSubject
352 }
353 want, err := tokens.GenSubject(userID, connectorID)
354 if err != nil {
355 return fmt.Errorf("%w: %w", ErrSubject, err)
356 }
357 if subtle.ConstantTimeCompare([]byte(sub), []byte(want)) != 1 {
358 return ErrSubject
359 }
360 return nil
361}
362
363type federated struct {
364 Claims struct {
365 UserID string `json:"user_id"`
366 } `json:"federated_claims"`
367}
368
369func (rp *RP) Callback(w http.ResponseWriter, r *http.Request) {
370 w.Header().Set("Cache-Control", "no-store")
371 host := transporthttp.CanonicalHost(r.Host)
372 tg, ok := rp.byHost[host]
373 if !ok {
374 rp.cfg.Refuse(w, r, http.StatusNotFound, ErrUnknownHost)
375 return
376 }
377 q, err := transporthttp.Query(r)
378 if err != nil {
379 rp.cfg.Refuse(w, r, http.StatusBadRequest, err)
380 return
381 }
382 state := q.Get("state")
383 name := rp.txnName(state)
384 t, err := rp.openTxn(r, name)
385 if err != nil {
386 rp.cfg.Refuse(w, r, http.StatusBadRequest, err)
387 return
388 }
389 expiry := time.Unix(t.Expiry, 0)
390 if !rp.now().Before(expiry) {
391 http.SetCookie(w, cookie(name, "", -1))
392 rp.cfg.Refuse(w, r, http.StatusBadRequest, ErrTxnExpired)
393 return
394 }
395 if t.Host != host {
396 rp.cfg.Refuse(w, r, http.StatusBadRequest, ErrTxnHost)
397 return
398 }
399 if subtle.ConstantTimeCompare([]byte(state), []byte(t.State)) != 1 {
400 rp.cfg.Refuse(w, r, http.StatusBadRequest, ErrState)
401 return
402 }
403 http.SetCookie(w, cookie(name, "", -1))
404 if !rp.spent.Spend(t.State, expiry, rp.now()) {
405 rp.cfg.Refuse(w, r, http.StatusBadRequest, ErrTxnSpent)
406 return
407 }
408 if q.Get("error") != "" || q.Get("code") == "" {
409 rp.cfg.Refuse(w, r, http.StatusBadRequest, ErrAuthorization)
410 return
411 }
412 codeUser, epoch, browser, err := rp.cfg.CodeEpoch(r.Context(), q.Get("code"))
413 if err != nil {
414 rp.cfg.Refuse(w, r, http.StatusBadGateway, fmt.Errorf("%w: %w", ErrExchange, err))
415 return
416 }
417 ctx := context.WithValue(r.Context(), oauth2.HTTPClient, rp.client)
418 tok, err := rp.oauth(tg).Exchange(ctx, q.Get("code"), oauth2.VerifierOption(t.Verifier))
419 if err != nil {
420 rp.cfg.Refuse(w, r, http.StatusBadGateway, fmt.Errorf("%w: %w", ErrExchange, err))
421 return
422 }
423 rawID, _ := tok.Extra("id_token").(string)
424 if rawID == "" {
425 rp.cfg.Refuse(w, r, http.StatusBadGateway, ErrExchange)
426 return
427 }
428 idt, err := rp.verifiers[tg.client.ID].Verify(oidc.ClientContext(r.Context(), rp.client), rawID)
429 if err != nil {
430 rp.cfg.Refuse(w, r, http.StatusUnauthorized, fmt.Errorf("%w: %w", ErrIDToken, err))
431 return
432 }
433 if subtle.ConstantTimeCompare([]byte(idt.Nonce), []byte(t.Nonce)) != 1 {
434 rp.cfg.Refuse(w, r, http.StatusUnauthorized, ErrNonce)
435 return
436 }
437 var fc federated
438 if err := idt.Claims(&fc); err != nil {
439 rp.cfg.Refuse(w, r, http.StatusUnauthorized, ErrSubject)
440 return
441 }
442 if err := CheckSubject(idt.Subject, fc.Claims.UserID, rp.cfg.ConnectorID); err != nil {
443 rp.cfg.Refuse(w, r, http.StatusUnauthorized, err)
444 return
445 }
446 if codeUser != fc.Claims.UserID {
447 rp.cfg.Refuse(w, r, http.StatusUnauthorized, fmt.Errorf("%w: the code was issued to another user", ErrSubject))
448 return
449 }
450 secret, opened, err := rp.establish(r, host, tg.client.ID, fc.Claims.UserID, epoch, browser)
451 switch {
452 case errors.Is(err, ErrUser):
453 rp.cfg.Refuse(w, r, http.StatusForbidden, err)
454 return
455 case err != nil:
456 rp.cfg.Refuse(w, r, http.StatusInternalServerError, err)
457 return
458 }
459 if opened == nil {
460 if err := rp.cfg.Sessions.Authenticated(r.Context(), secret, host, tg.client.ID); err != nil {
461 rp.cfg.Refuse(w, r, http.StatusInternalServerError, fmt.Errorf("%w: %w", ErrSessions, err))
462 return
463 }
464 } else {
465 rp.SetSession(w, secret, *opened)
466 rp.cfg.Rotate(w)
467 }
468 http.Redirect(w, r, tg.origin+t.Return, http.StatusSeeOther)
469}
470
471func (rp *RP) establish(r *http.Request, host, clientID, userID string, epoch int64, browser string) ([]byte, *Session, error) {
472 ctx := r.Context()
473 if secret, ok := rp.SessionSecret(r); ok {
474 s, err := rp.cfg.Sessions.Current(ctx, secret, host)
475 switch {
476 case err == nil && s.UserID == userID:
477 return secret, nil, nil
478 case err == nil:
479 if err := rp.cfg.Sessions.Revoke(ctx, secret, host); err != nil {
480 return nil, nil, fmt.Errorf("%w: %w", ErrSessions, err)
481 }
482 case !errors.Is(err, ErrNoSession):
483 return nil, nil, fmt.Errorf("%w: %w", ErrSessions, err)
484 }
485 }
486 secret, s, err := rp.cfg.Sessions.OpenAt(ctx, userID, host, clientID, epoch, browser)
487 if err != nil {
488 return nil, nil, fmt.Errorf("%w: %w", ErrSessions, err)
489 }
490 return secret, &s, nil
491}
492
493func (rp *RP) SessionSecret(r *http.Request) ([]byte, bool) {
494 ck, err := r.Cookie(SessionCookie)
495 if err != nil {
496 return nil, false
497 }
498 secret, err := rp.sess.Open(SessionCookie, ck.Value)
499 if err != nil || len(secret) != SecretLength {
500 return nil, false
501 }
502 return secret, true
503}
504
505func (rp *RP) SetSession(w http.ResponseWriter, secret []byte, s Session) {
506 maxAge := int(s.Expires.Sub(rp.now()) / time.Second)
507 if maxAge < 1 {
508 maxAge = 1
509 }
510 http.SetCookie(w, cookie(SessionCookie, rp.sess.Seal(SessionCookie, secret), maxAge))
511}
512
513func (rp *RP) ClearSession(w http.ResponseWriter) {
514 http.SetCookie(w, cookie(SessionCookie, "", -1))
515}