oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1package handoff
2
3import (
4 "io"
5 "mime"
6 "net/http"
7 "net/url"
8 "strings"
9 "time"
10
11 "amadan.net/rastrillo/rastrillo/assertion"
12)
13
14const (
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
22type 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}
39type Consumer struct{ cfg ConsumerConfig }
40
41func 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}
58func 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
70func (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}
83func 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}
94func 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}
97func (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.
106func (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.
169func (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.
206func (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