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