package handoff import ( "io" "mime" "net/http" "net/url" "strings" "time" "amadan.net/rastrillo/rastrillo/assertion" ) const ( StartPath = "/auth/start" CallbackPath = "/auth/assertion" FinishPath = "/auth/finish" pendingCookie = "__Host-oficina-assertion-pending" inboxCookie = "__Host-oficina-assertion-inbox" ) type ConsumerConfig struct { Store *Store ConsumerID string // RequireFreshAuth requests and enforces actual authentication within five minutes. RequireFreshAuth bool // BindStart binds the public nonce to an app initiation after pending storage // commits, before any cookie or redirect. It must not make network calls or // create grants. Rejection cancels this new pending request. BindStart func(*http.Request, string) error // Resolve before store transactions, using the app's session middleware. CurrentSubject func(*http.Request) string // Complete runs only after replay consumption is committed. It creates the // app's session, preserving AuthTime. An error cannot restore the assertion. Complete func(http.ResponseWriter, *http.Request, assertion.Claims) error Fail func(http.ResponseWriter, *http.Request, int) Now func() time.Time } type Consumer struct{ cfg ConsumerConfig } func NewConsumer(cfg ConsumerConfig) (*Consumer, error) { if cfg.Store == nil || cfg.Store.db == nil || cfg.Complete == nil || !consumerID(cfg.ConsumerID) { return nil, ErrConfig } if cfg.Now == nil { cfg.Now = time.Now } if cfg.CurrentSubject == nil { cfg.CurrentSubject = func(*http.Request) string { return "" } } if cfg.Fail == nil { cfg.Fail = func(w http.ResponseWriter, r *http.Request, status int) { http.Error(w, "Unable to sign in. Start again.", status) } } return &Consumer{cfg}, nil } func consumerID(s string) bool { if len(s) == 0 || len(s) > 64 { return false } for _, b := range []byte(s) { if !(b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || b == '_' || b == '-') { return false } } return true } func (c *Consumer) guard(w http.ResponseWriter, r *http.Request, method, path string) bool { w.Header().Set("Cache-Control", "no-store") if r.Host != strings.TrimPrefix(c.cfg.Store.audience, "https://") || r.URL.Path != path { c.cfg.Fail(w, r, http.StatusNotFound) return false } if r.Method != method { w.Header().Set("Allow", method) c.cfg.Fail(w, r, http.StatusMethodNotAllowed) return false } return true } func cookie(r *http.Request, name string) (string, bool) { value := "" count := 0 for _, c := range r.Cookies() { if c.Name == name { count++ value = c.Value } } return value, count == 1 && validHandle(value) } func setCookie(w http.ResponseWriter, name, value string, seconds int) { http.SetCookie(w, &http.Cookie{Name: name, Value: value, Path: "/", MaxAge: seconds, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode}) } func (c *Consumer) fail(w http.ResponseWriter, r *http.Request, e error) { status := http.StatusBadRequest if e == ErrStorage || e == ErrClock || e == ErrFull { status = http.StatusServiceUnavailable } c.cfg.Fail(w, r, status) } // Start accepts a bounded local return_to query and writes only sealed intent. func (c *Consumer) Start(w http.ResponseWriter, r *http.Request) { if !c.guard(w, r, http.MethodGet, StartPath) { return } q, e := url.ParseQuery(r.URL.RawQuery) if e != nil || len(q) > 1 || len(q["return_to"]) > 1 { c.fail(w, r, ErrRefused) return } path := "/" if len(q) > 0 { values, ok := q["return_to"] if !ok || len(values) != 1 { c.fail(w, r, ErrRefused) return } path = values[0] } matches := 0 for _, item := range r.Cookies() { if item.Name == pendingCookie { matches++ } } if matches > 1 { c.fail(w, r, ErrRefused) return } previous, ok := cookie(r, pendingCookie) if !ok { previous = "" } start := c.cfg.Store.Start if c.cfg.RequireFreshAuth { start = c.cfg.Store.StartFresh } secret, nonce, e := start(r.Context(), path, previous, c.cfg.Now()) if e != nil { c.fail(w, r, e) return } if c.cfg.BindStart != nil { if e = c.cfg.BindStart(r, nonce); e != nil { failure := ErrRefused if c.cfg.Store.cancel(r.Context(), secret) != nil { failure = ErrStorage } c.fail(w, r, failure) return } } setCookie(w, pendingCookie, secret, int(pendingLifetime)) query := url.Values{"consumer": {c.cfg.ConsumerID}, "nonce": {nonce}} if c.cfg.RequireFreshAuth { query.Set("max_age", "300") } target := c.cfg.Store.issuer + "/authorize?" + query.Encode() http.Redirect(w, r, target, http.StatusSeeOther) } // Callback is the one route mounted outside ordinary same-origin CSRF checks. // It requires the exact issuer Origin even for a same-site request and needs no // cookies. Keep every other app mutation inside the app's CSRF middleware. func (c *Consumer) Callback(w http.ResponseWriter, r *http.Request) { if !c.guard(w, r, http.MethodPost, CallbackPath) { return } origins := r.Header.Values("Origin") if len(origins) != 1 || origins[0] != c.cfg.Store.issuer || r.URL.RawQuery != "" { c.fail(w, r, ErrRefused) return } media, _, e := mime.ParseMediaType(r.Header.Get("Content-Type")) if e != nil || media != "application/x-www-form-urlencoded" { c.fail(w, r, ErrRefused) return } // Form encoding expands the 8192-byte core wire bound; 12 KiB comfortably // holds a base64url token while bounding input before parsing or crypto. body, e := io.ReadAll(http.MaxBytesReader(w, r.Body, 12*1024)) if e != nil { c.fail(w, r, ErrRefused) return } form, e := url.ParseQuery(string(body)) if e != nil || len(form) != 1 || len(form["assertion"]) != 1 { c.fail(w, r, ErrRefused) return } handle, e := c.cfg.Store.Receive(r.Context(), form["assertion"][0], c.cfg.Now()) if e != nil { c.fail(w, r, e) return } setCookie(w, inboxCookie, handle, int(inboxLifetime)) http.Redirect(w, r, FinishPath, http.StatusSeeOther) } // Finish receives the Lax cookies on the top-level GET after the callback's // redirect. Only its browser-bound transaction can reach Complete. func (c *Consumer) Finish(w http.ResponseWriter, r *http.Request) { if !c.guard(w, r, http.MethodGet, FinishPath) { return } if r.URL.RawQuery != "" { c.fail(w, r, ErrRefused) return } secret, ok := cookie(r, pendingCookie) handle, have := cookie(r, inboxCookie) if !ok || !have { c.fail(w, r, ErrRefused) return } setCookie(w, pendingCookie, "", -1) setCookie(w, inboxCookie, "", -1) existing := c.cfg.CurrentSubject(r) claims, path, e := c.cfg.Store.Finish(r.Context(), secret, handle, existing, c.cfg.Now()) if e != nil { c.fail(w, r, e) return } if e = c.cfg.Complete(w, r, claims); e != nil { c.fail(w, r, ErrStorage) return } http.Redirect(w, r, path, http.StatusSeeOther) }