| 1 | package handoff |
| 2 | |
| 3 | import ( |
| 4 | "io" |
| 5 | "mime" |
| 6 | "net/http" |
| 7 | "net/url" |
| 8 | "strings" |
| 9 | "time" |
| 10 | |
| 11 | "amadan.net/rastrillo/rastrillo/assertion" |
| 12 | ) |
| 13 | |
| 14 | const ( |
| 15 | StartPath = "/auth/start" |
| 16 | CallbackPath = "/auth/assertion" |
| 17 | FinishPath = "/auth/finish" |
| 18 | pendingCookie = "__Host-oficina-assertion-pending" |
| 19 | inboxCookie = "__Host-oficina-assertion-inbox" |
| 20 | ) |
| 21 | |
| 22 | type ConsumerConfig struct { |
| 23 | Store *Store |
| 24 | ConsumerID string |
| 25 | // RequireFreshAuth requests and enforces actual authentication within five minutes. |
| 26 | RequireFreshAuth bool |
| 27 | // BindStart binds the public nonce to an app initiation after pending storage |
| 28 | // commits, before any cookie or redirect. It must not make network calls or |
| 29 | // create grants. Rejection cancels this new pending request. |
| 30 | BindStart func(*http.Request, string) error |
| 31 | // Resolve before store transactions, using the app's session middleware. |
| 32 | CurrentSubject func(*http.Request) string |
| 33 | // Complete runs only after replay consumption is committed. It creates the |
| 34 | // app's session, preserving AuthTime. An error cannot restore the assertion. |
| 35 | Complete func(http.ResponseWriter, *http.Request, assertion.Claims) error |
| 36 | Fail func(http.ResponseWriter, *http.Request, int) |
| 37 | Now func() time.Time |
| 38 | } |
| 39 | type Consumer struct{ cfg ConsumerConfig } |
| 40 | |
| 41 | func NewConsumer(cfg ConsumerConfig) (*Consumer, error) { |
| 42 | if cfg.Store == nil || cfg.Store.db == nil || cfg.Complete == nil || !consumerID(cfg.ConsumerID) { |
| 43 | return nil, ErrConfig |
| 44 | } |
| 45 | if cfg.Now == nil { |
| 46 | cfg.Now = time.Now |
| 47 | } |
| 48 | if cfg.CurrentSubject == nil { |
| 49 | cfg.CurrentSubject = func(*http.Request) string { return "" } |
| 50 | } |
| 51 | if cfg.Fail == nil { |
| 52 | cfg.Fail = func(w http.ResponseWriter, r *http.Request, status int) { |
| 53 | http.Error(w, "Unable to sign in. Start again.", status) |
| 54 | } |
| 55 | } |
| 56 | return &Consumer{cfg}, nil |
| 57 | } |
| 58 | func consumerID(s string) bool { |
| 59 | if len(s) == 0 || len(s) > 64 { |
| 60 | return false |
| 61 | } |
| 62 | for _, b := range []byte(s) { |
| 63 | if !(b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || b == '_' || b == '-') { |
| 64 | return false |
| 65 | } |
| 66 | } |
| 67 | return true |
| 68 | } |
| 69 | |
| 70 | func (c *Consumer) guard(w http.ResponseWriter, r *http.Request, method, path string) bool { |
| 71 | w.Header().Set("Cache-Control", "no-store") |
| 72 | if r.Host != strings.TrimPrefix(c.cfg.Store.audience, "https://") || r.URL.Path != path { |
| 73 | c.cfg.Fail(w, r, http.StatusNotFound) |
| 74 | return false |
| 75 | } |
| 76 | if r.Method != method { |
| 77 | w.Header().Set("Allow", method) |
| 78 | c.cfg.Fail(w, r, http.StatusMethodNotAllowed) |
| 79 | return false |
| 80 | } |
| 81 | return true |
| 82 | } |
| 83 | func cookie(r *http.Request, name string) (string, bool) { |
| 84 | value := "" |
| 85 | count := 0 |
| 86 | for _, c := range r.Cookies() { |
| 87 | if c.Name == name { |
| 88 | count++ |
| 89 | value = c.Value |
| 90 | } |
| 91 | } |
| 92 | return value, count == 1 && validHandle(value) |
| 93 | } |
| 94 | func setCookie(w http.ResponseWriter, name, value string, seconds int) { |
| 95 | http.SetCookie(w, &http.Cookie{Name: name, Value: value, Path: "/", MaxAge: seconds, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode}) |
| 96 | } |
| 97 | func (c *Consumer) fail(w http.ResponseWriter, r *http.Request, e error) { |
| 98 | status := http.StatusBadRequest |
| 99 | if e == ErrStorage || e == ErrClock || e == ErrFull { |
| 100 | status = http.StatusServiceUnavailable |
| 101 | } |
| 102 | c.cfg.Fail(w, r, status) |
| 103 | } |
| 104 | |
| 105 | // Start accepts a bounded local return_to query and writes only sealed intent. |
| 106 | func (c *Consumer) Start(w http.ResponseWriter, r *http.Request) { |
| 107 | if !c.guard(w, r, http.MethodGet, StartPath) { |
| 108 | return |
| 109 | } |
| 110 | q, e := url.ParseQuery(r.URL.RawQuery) |
| 111 | if e != nil || len(q) > 1 || len(q["return_to"]) > 1 { |
| 112 | c.fail(w, r, ErrRefused) |
| 113 | return |
| 114 | } |
| 115 | path := "/" |
| 116 | if len(q) > 0 { |
| 117 | values, ok := q["return_to"] |
| 118 | if !ok || len(values) != 1 { |
| 119 | c.fail(w, r, ErrRefused) |
| 120 | return |
| 121 | } |
| 122 | path = values[0] |
| 123 | } |
| 124 | matches := 0 |
| 125 | for _, item := range r.Cookies() { |
| 126 | if item.Name == pendingCookie { |
| 127 | matches++ |
| 128 | } |
| 129 | } |
| 130 | if matches > 1 { |
| 131 | c.fail(w, r, ErrRefused) |
| 132 | return |
| 133 | } |
| 134 | previous, ok := cookie(r, pendingCookie) |
| 135 | if !ok { |
| 136 | previous = "" |
| 137 | } |
| 138 | start := c.cfg.Store.Start |
| 139 | if c.cfg.RequireFreshAuth { |
| 140 | start = c.cfg.Store.StartFresh |
| 141 | } |
| 142 | secret, nonce, e := start(r.Context(), path, previous, c.cfg.Now()) |
| 143 | if e != nil { |
| 144 | c.fail(w, r, e) |
| 145 | return |
| 146 | } |
| 147 | if c.cfg.BindStart != nil { |
| 148 | if e = c.cfg.BindStart(r, nonce); e != nil { |
| 149 | failure := ErrRefused |
| 150 | if c.cfg.Store.cancel(r.Context(), secret) != nil { |
| 151 | failure = ErrStorage |
| 152 | } |
| 153 | c.fail(w, r, failure) |
| 154 | return |
| 155 | } |
| 156 | } |
| 157 | setCookie(w, pendingCookie, secret, int(pendingLifetime)) |
| 158 | query := url.Values{"consumer": {c.cfg.ConsumerID}, "nonce": {nonce}} |
| 159 | if c.cfg.RequireFreshAuth { |
| 160 | query.Set("max_age", "300") |
| 161 | } |
| 162 | target := c.cfg.Store.issuer + "/authorize?" + query.Encode() |
| 163 | http.Redirect(w, r, target, http.StatusSeeOther) |
| 164 | } |
| 165 | |
| 166 | // Callback is the one route mounted outside ordinary same-origin CSRF checks. |
| 167 | // It requires the exact issuer Origin even for a same-site request and needs no |
| 168 | // cookies. Keep every other app mutation inside the app's CSRF middleware. |
| 169 | func (c *Consumer) Callback(w http.ResponseWriter, r *http.Request) { |
| 170 | if !c.guard(w, r, http.MethodPost, CallbackPath) { |
| 171 | return |
| 172 | } |
| 173 | origins := r.Header.Values("Origin") |
| 174 | if len(origins) != 1 || origins[0] != c.cfg.Store.issuer || r.URL.RawQuery != "" { |
| 175 | c.fail(w, r, ErrRefused) |
| 176 | return |
| 177 | } |
| 178 | media, _, e := mime.ParseMediaType(r.Header.Get("Content-Type")) |
| 179 | if e != nil || media != "application/x-www-form-urlencoded" { |
| 180 | c.fail(w, r, ErrRefused) |
| 181 | return |
| 182 | } |
| 183 | // Form encoding expands the 8192-byte core wire bound; 12 KiB comfortably |
| 184 | // holds a base64url token while bounding input before parsing or crypto. |
| 185 | body, e := io.ReadAll(http.MaxBytesReader(w, r.Body, 12*1024)) |
| 186 | if e != nil { |
| 187 | c.fail(w, r, ErrRefused) |
| 188 | return |
| 189 | } |
| 190 | form, e := url.ParseQuery(string(body)) |
| 191 | if e != nil || len(form) != 1 || len(form["assertion"]) != 1 { |
| 192 | c.fail(w, r, ErrRefused) |
| 193 | return |
| 194 | } |
| 195 | handle, e := c.cfg.Store.Receive(r.Context(), form["assertion"][0], c.cfg.Now()) |
| 196 | if e != nil { |
| 197 | c.fail(w, r, e) |
| 198 | return |
| 199 | } |
| 200 | setCookie(w, inboxCookie, handle, int(inboxLifetime)) |
| 201 | http.Redirect(w, r, FinishPath, http.StatusSeeOther) |
| 202 | } |
| 203 | |
| 204 | // Finish receives the Lax cookies on the top-level GET after the callback's |
| 205 | // redirect. Only its browser-bound transaction can reach Complete. |
| 206 | func (c *Consumer) Finish(w http.ResponseWriter, r *http.Request) { |
| 207 | if !c.guard(w, r, http.MethodGet, FinishPath) { |
| 208 | return |
| 209 | } |
| 210 | if r.URL.RawQuery != "" { |
| 211 | c.fail(w, r, ErrRefused) |
| 212 | return |
| 213 | } |
| 214 | secret, ok := cookie(r, pendingCookie) |
| 215 | handle, have := cookie(r, inboxCookie) |
| 216 | if !ok || !have { |
| 217 | c.fail(w, r, ErrRefused) |
| 218 | return |
| 219 | } |
| 220 | setCookie(w, pendingCookie, "", -1) |
| 221 | setCookie(w, inboxCookie, "", -1) |
| 222 | existing := c.cfg.CurrentSubject(r) |
| 223 | claims, path, e := c.cfg.Store.Finish(r.Context(), secret, handle, existing, c.cfg.Now()) |
| 224 | if e != nil { |
| 225 | c.fail(w, r, e) |
| 226 | return |
| 227 | } |
| 228 | if e = c.cfg.Complete(w, r, claims); e != nil { |
| 229 | c.fail(w, r, ErrStorage) |
| 230 | return |
| 231 | } |
| 232 | http.Redirect(w, r, path, http.StatusSeeOther) |
| 233 | } |
| 234 | |