oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3import (
4 "bytes"
5 "context"
6 "net/http"
7 "net/http/httptest"
8 "net/url"
9 "strings"
10 "testing"
11 "time"
12
13 "amadan.net/oficina/home/handoff"
14 "amadan.net/rastrillo/rastrillo/assertion"
15 rauth "amadan.net/rastrillo/rastrillo/auth"
16 assertioncrypto "amadan.net/rastrillo/rastrillo/crypto"
17 "amadan.net/rastrillo/rastrillo/csrf"
18 "amadan.net/rastrillo/rastrillo/migrate"
19 "amadan.net/rastrillo/rastrillo/sessions"
20 "github.com/carlosframework/docs/internal/docs/linkstore"
21)
22
23const bridgeOrigin = "https://blue.docs.oficina.works"
24const bridgeIssuer = "https://id.oficina.works"
25
26type bridgeFixture struct {
27 b *homeBridge
28 app *App
29 key *assertioncrypto.Keypair
30}
31
32func newBridgeFixture(t *testing.T) *bridgeFixture {
33 t.Helper()
34 app, err := NewForTestWith(t, Config{Origin: bridgeOrigin, SampleDocuments: "false"})
35 if err != nil {
36 t.Fatal(err)
37 }
38 schema := migrate.Merge(linkstore.Schema, migrate.MustFromFS(handoff.Migrations, "oficina_handoff"))
39 if _, err := migrate.Apply(context.Background(), app.db, schema); err != nil {
40 t.Fatal(err)
41 }
42 key, err := assertioncrypto.Generate()
43 if err != nil {
44 t.Fatal(err)
45 }
46 settings := &homeSettings{issuer: bridgeIssuer, consumerID: "blue-docs", keys: map[string][]byte{"key": key.SignPub()}, dek: bytes.Repeat([]byte{41}, 32)}
47 auth, err := rauth.New(rauth.Config{DB: app.db.Writer(), Origin: bridgeOrigin, InstanceKey: devInstanceKey, Logger: app.logger, ProofOff: true})
48 if err != nil {
49 t.Fatal(err)
50 }
51 b, err := newHomeBridge(settings, bridgeOrigin, app.db.Writer(), auth, app.handlers.render, app.store)
52 if err != nil {
53 t.Fatal(err)
54 }
55 return &bridgeFixture{b: b, app: app, key: key}
56}
57func bridgeRequest(method, path string, cookies []*http.Cookie) *http.Request {
58 r := httptest.NewRequest(method, bridgeOrigin+path, nil)
59 for _, cookie := range cookies {
60 r.AddCookie(cookie)
61 }
62 return r
63}
64func bridgeCookie(t *testing.T, w *httptest.ResponseRecorder, name string) *http.Cookie {
65 t.Helper()
66 for _, cookie := range w.Result().Cookies() {
67 if cookie.Name == name && cookie.MaxAge >= 0 {
68 if !cookie.Secure || !cookie.HttpOnly || cookie.Domain != "" || cookie.Path != "/" || cookie.SameSite != http.SameSiteLaxMode {
69 t.Fatal("unsafe cookie attributes")
70 }
71 return cookie
72 }
73 }
74 t.Fatalf("missing cookie %s; status=%d", name, w.Code)
75 return nil
76}
77func bridgeNoSession(t *testing.T, b *homeBridge, w *httptest.ResponseRecorder) {
78 t.Helper()
79 for _, c := range w.Result().Cookies() {
80 if c.Name == b.sessionCookie {
81 t.Fatal("unexpected app session mutation")
82 }
83 }
84}
85func (f *bridgeFixture) local(t *testing.T, address, method string, authTime time.Time) *http.Cookie {
86 t.Helper()
87 if _, err := f.app.store.EnsurePerson(context.Background(), address); err != nil {
88 t.Fatal(err)
89 }
90 token, err := f.b.sessions.Mint(sessions.Session{Subject: address, Method: method, AuthTime: authTime})
91 if err != nil {
92 t.Fatal(err)
93 }
94 return &http.Cookie{Name: f.b.sessionCookie, Value: token}
95}
96func (f *bridgeFixture) start(t *testing.T, cookies []*http.Cookie, path string) (*http.Cookie, string) {
97 t.Helper()
98 w := httptest.NewRecorder()
99 f.b.Start(w, bridgeRequest(http.MethodGet, "/auth/start?return_to="+url.QueryEscape(path), cookies))
100 if w.Code != 303 {
101 t.Fatalf("start=%d body=%s", w.Code, w.Body.String())
102 }
103 target, err := url.Parse(w.Header().Get("Location"))
104 if err != nil {
105 t.Fatal(err)
106 }
107 return bridgeCookie(t, w, "__Host-oficina-assertion-pending"), target.Query().Get("nonce")
108}
109func (f *bridgeFixture) callback(t *testing.T, subject, nonce string, authTime time.Time) *http.Cookie {
110 t.Helper()
111 now := time.Now().Unix()
112 id, err := assertion.RandomID()
113 if err != nil {
114 t.Fatal(err)
115 }
116 token, err := assertion.Sign(f.key, assertion.Claims{Version: 1, Issuer: bridgeIssuer, Audience: bridgeOrigin, Subject: subject, KeyID: "key", ID: id, Nonce: nonce, IssuedAt: now, ExpiresAt: now + 90, AuthTime: authTime.Unix()})
117 if err != nil {
118 t.Fatal(err)
119 }
120 form := url.Values{"assertion": {token}}
121 r := httptest.NewRequest(http.MethodPost, bridgeOrigin+"/auth/assertion", strings.NewReader(form.Encode()))
122 r.Header.Set("Origin", bridgeIssuer)
123 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
124 w := httptest.NewRecorder()
125 f.b.Callback(w, r)
126 if w.Code != 303 {
127 t.Fatalf("callback=%d", w.Code)
128 }
129 bridgeNoSession(t, f.b, w)
130 return bridgeCookie(t, w, "__Host-oficina-assertion-inbox")
131}
132func (f *bridgeFixture) linkProof(t *testing.T, local *http.Cookie, subject string) (*http.Cookie, []*http.Cookie) {
133 t.Helper()
134 w := httptest.NewRecorder()
135 f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{local}))
136 if w.Code != 303 {
137 t.Fatalf("link start=%d", w.Code)
138 }
139 init := bridgeCookie(t, w, homeInitCookie)
140 cookies := []*http.Cookie{local, init}
141 start := httptest.NewRecorder()
142 f.b.Start(start, bridgeRequest(http.MethodGet, w.Header().Get("Location"), cookies))
143 if start.Code != 303 {
144 t.Fatalf("fresh start=%d", start.Code)
145 }
146 target, _ := url.Parse(start.Header().Get("Location"))
147 if target.Query().Get("max_age") != "300" {
148 t.Fatal("link exchange did not request fresh authentication")
149 }
150 pending := bridgeCookie(t, start, "__Host-oficina-assertion-pending")
151 inbox := f.callback(t, subject, target.Query().Get("nonce"), time.Now().Add(-30*time.Second))
152 finish := httptest.NewRecorder()
153 f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", append(cookies, pending, inbox)))
154 if finish.Code != 303 || finish.Header().Get("Location") != "/account/home/confirm" {
155 t.Fatalf("link finish=%d location=%q", finish.Code, finish.Header().Get("Location"))
156 }
157 bridgeNoSession(t, f.b, finish)
158 return bridgeCookie(t, finish, homeProofCookie), []*http.Cookie{local}
159}
160func (f *bridgeFixture) link(t *testing.T, local *http.Cookie, subject string) {
161 t.Helper()
162 proof, cookies := f.linkProof(t, local, subject)
163 cookies = append(cookies, proof)
164 page := httptest.NewRecorder()
165 f.b.ConfirmPage(page, bridgeRequest(http.MethodGet, "/account/home/confirm", cookies))
166 if page.Code != 200 {
167 t.Fatalf("confirm page=%d", page.Code)
168 }
169 w := httptest.NewRecorder()
170 f.b.ConfirmPost(w, bridgeRequest(http.MethodPost, "/account/home/confirm", cookies))
171 if w.Code != 303 || w.Header().Get("Location") != "/" {
172 t.Fatalf("confirm=%d", w.Code)
173 }
174 bridgeNoSession(t, f.b, w)
175}
176
177func TestHomeBridgeLinkThenOrdinarySignInPreservesIdentity(t *testing.T) {
178 f := newBridgeFixture(t)
179 local := f.local(t, "one@example.com", "magiclink", time.Time{})
180 person, err := f.app.store.EnsurePerson(context.Background(), "one@example.com")
181 if err != nil {
182 t.Fatal(err)
183 }
184 doc, err := f.app.store.Create(context.Background(), person, "Existing document")
185 if err != nil {
186 t.Fatal(err)
187 }
188 f.link(t, local, "opaque-subject-one")
189 if _, ok := f.b.sessions.From(bridgeRequest(http.MethodGet, "/", []*http.Cookie{local})); !ok {
190 t.Fatal("linking replaced local session")
191 }
192 pending, nonce := f.start(t, nil, "/d/"+doc.Sqid)
193 original := time.Now().Add(-time.Hour).Truncate(time.Second)
194 inbox := f.callback(t, "opaque-subject-one", nonce, original)
195 finish := httptest.NewRecorder()
196 f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{pending, inbox}))
197 if finish.Code != 303 || finish.Header().Get("Location") != "/d/"+doc.Sqid {
198 t.Fatalf("ordinary finish=%d %q", finish.Code, finish.Header().Get("Location"))
199 }
200 cookie := bridgeCookie(t, finish, f.b.sessionCookie)
201 r := bridgeRequest(http.MethodGet, "/", []*http.Cookie{cookie})
202 session, ok := f.b.sessions.From(r)
203 if !ok || session.Method != homeAssertionMethod || session.Subject != "opaque-subject-one" || !session.AuthTime.Equal(original) {
204 t.Fatalf("wrong Home session: %+v", session)
205 }
206 identity, err := f.b.resolver.resolve(sessions.WithSession(r, session))
207 if err != nil || identity.Actor.PersonID != person || (identity.Actor.VerifiedAddress == nil || *identity.Actor.VerifiedAddress != "one@example.com") {
208 t.Fatalf("identity=%+v %v", identity, err)
209 }
210 if _, err := f.app.store.Get(context.Background(), person, doc.Sqid); err != nil {
211 t.Fatal("existing ownership changed", err)
212 }
213 var count int
214 if err := f.app.db.Writer().QueryRow("SELECT COUNT(*) FROM people").Scan(&count); err != nil || count != 1 {
215 t.Fatal("SSO created an extra person", count, err)
216 }
217 replay := httptest.NewRecorder()
218 f.b.Finish(replay, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{pending, inbox}))
219 if replay.Code == 303 {
220 t.Fatal("assertion replay minted session")
221 }
222 bridgeNoSession(t, f.b, replay)
223}
224
225func TestHomeBridgeUnknownAndDifferentCurrentAccountNeverMint(t *testing.T) {
226 f := newBridgeFixture(t)
227 local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute))
228 other := f.local(t, "two@example.com", "magiclink", time.Now().Add(-time.Minute))
229 f.link(t, local, "linked-subject")
230 for _, tc := range []struct {
231 name, subject string
232 cookies []*http.Cookie
233 }{{"unknown", "unknown-private-subject", nil}, {"different-local", "linked-subject", []*http.Cookie{other}}} {
234 t.Run(tc.name, func(t *testing.T) {
235 pending, n := f.start(t, tc.cookies, "/")
236 inbox := f.callback(t, tc.subject, n, time.Now().Add(-time.Minute))
237 w := httptest.NewRecorder()
238 f.b.Finish(w, bridgeRequest(http.MethodGet, "/auth/finish", append(tc.cookies, pending, inbox)))
239 if w.Code != 403 {
240 t.Fatalf("rejected exchange=%d", w.Code)
241 }
242 bridgeNoSession(t, f.b, w)
243 if tc.name == "unknown" && !strings.Contains(w.Body.String(), "/signin/local") {
244 t.Fatal("unknown identity lacks local continuation")
245 }
246 if strings.Contains(w.Body.String(), tc.subject) {
247 t.Fatal("opaque identity leaked into response")
248 }
249 retry := httptest.NewRecorder()
250 f.b.Finish(retry, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{pending, inbox}))
251 if retry.Code == 303 {
252 t.Fatal("rejected completion remained reusable")
253 }
254 })
255 }
256}
257
258func TestHomeBridgeChangedLocalSessionAndProofReplayRefused(t *testing.T) {
259 f := newBridgeFixture(t)
260 authTime := time.Now().Add(-time.Minute)
261 original := f.local(t, "one@example.com", "magiclink", authTime)
262 proof, cookies := f.linkProof(t, original, "opaque")
263 rotated := f.local(t, "one@example.com", "magiclink", authTime)
264 w := httptest.NewRecorder()
265 f.b.ConfirmPost(w, bridgeRequest(http.MethodPost, "/account/home/confirm", []*http.Cookie{rotated, proof}))
266 if w.Code != 403 {
267 t.Fatal("different session linked proof", w.Code)
268 }
269 retry := httptest.NewRecorder()
270 f.b.ConfirmPost(retry, bridgeRequest(http.MethodPost, "/account/home/confirm", append(cookies, proof)))
271 if retry.Code != 403 {
272 t.Fatal("rejected proof reusable", retry.Code)
273 }
274}
275
276func TestHomeBridgeStrictCookiesAndStaleLocalRefusal(t *testing.T) {
277 f := newBridgeFixture(t)
278 local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute))
279 for _, cookies := range [][]*http.Cookie{{{Name: homeInitCookie, Value: "bad"}}, {{Name: homeInitCookie, Value: strings.Repeat("A", 43)}, {Name: homeInitCookie, Value: strings.Repeat("A", 43)}}, {local, local}, {{Name: f.b.sessionCookie, Value: strings.Repeat("A", 43)}}} {
280 w := httptest.NewRecorder()
281 f.b.Start(w, bridgeRequest(http.MethodGet, "/auth/start", cookies))
282 if w.Code == 303 {
283 t.Fatal("malformed/duplicate/unresolved cookie downgraded into ordinary sign-in")
284 }
285 }
286 for _, method := range []string{"magiclink", "keymail", homeAssertionMethod} {
287 cookie := f.local(t, "one@example.com", method, time.Now().Add(-6*time.Minute))
288 w := httptest.NewRecorder()
289 f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{cookie}))
290 if w.Code != 403 {
291 t.Fatal("stale or Home method began linking", method, w.Code)
292 }
293 }
294 pending, n := f.start(t, nil, "/")
295 inbox := f.callback(t, "unknown", n, time.Now().Add(-time.Minute))
296 for _, cookies := range [][]*http.Cookie{{pending, inbox, local, local}, {pending, inbox, {Name: f.b.sessionCookie, Value: strings.Repeat("A", 43)}}} {
297 w := httptest.NewRecorder()
298 f.b.Finish(w, bridgeRequest(http.MethodGet, "/auth/finish", cookies))
299 if w.Code != 403 {
300 t.Fatal("unresolved/ambiguous current account accepted", w.Code)
301 }
302 bridgeNoSession(t, f.b, w)
303 }
304}
305
306func TestHomeBridgeCallbackHasNoSessionDependencyAndExactOrigin(t *testing.T) {
307 f := newBridgeFixture(t)
308 _, n := f.start(t, nil, "/")
309 id, _ := assertion.RandomID()
310 now := time.Now().Unix()
311 token, err := assertion.Sign(f.key, assertion.Claims{Version: 1, Issuer: bridgeIssuer, Audience: bridgeOrigin, Subject: "opaque", KeyID: "key", ID: id, Nonce: n, IssuedAt: now, ExpiresAt: now + 90, AuthTime: now})
312 if err != nil {
313 t.Fatal(err)
314 }
315 for _, origin := range []string{"", "https://evil.example", bridgeIssuer} {
316 r := httptest.NewRequest(http.MethodPost, bridgeOrigin+"/auth/assertion", strings.NewReader(url.Values{"assertion": {token}}.Encode()))
317 r.Header.Set("Origin", origin)
318 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
319 // Even malformed duplicate app cookies cannot affect the issuer callback.
320 r.AddCookie(&http.Cookie{Name: f.b.sessionCookie, Value: "bad"})
321 r.AddCookie(&http.Cookie{Name: f.b.sessionCookie, Value: "bad"})
322 w := httptest.NewRecorder()
323 f.b.Callback(w, r)
324 bridgeNoSession(t, f.b, w)
325 if origin == bridgeIssuer && w.Code != 303 {
326 t.Fatal("callback resolved local session", w.Code)
327 }
328 if origin != bridgeIssuer && w.Code == 303 {
329 t.Fatal("callback accepted different origin")
330 }
331 }
332}
333
334func TestHomeBridgeLinkMutationsRemainBehindCSRF(t *testing.T) {
335 f := newBridgeFixture(t)
336 local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute))
337 protected := csrf.Protect(bridgeOrigin)(http.HandlerFunc(f.b.LinkStart))
338 r := bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{local})
339 r.Header.Set("Origin", "https://evil.example")
340 r.Header.Set("Sec-Fetch-Site", "cross-site")
341 w := httptest.NewRecorder()
342 protected.ServeHTTP(w, r)
343 if w.Code != 403 {
344 t.Fatal("cross-site link initiation accepted", w.Code)
345 }
346 var count int
347 if err := f.app.db.Writer().QueryRow("SELECT COUNT(*) FROM docs_link_flows").Scan(&count); err != nil || count != 0 {
348 t.Fatal("cross-site initiation retained state", count, err)
349 }
350}
351
352func TestHomeBridgeOrdinaryProofCannotBecomeLinkProof(t *testing.T) {
353 f := newBridgeFixture(t)
354 local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute))
355 w := httptest.NewRecorder()
356 f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{local}))
357 init := bridgeCookie(t, w, homeInitCookie)
358 // Bind an explicit link to its own generated nonce.
359 f.start(t, []*http.Cookie{local, init}, "/account/home/confirm")
360 // A separate ordinary sign-in produces a valid assertion with another nonce.
361 pending, n := f.start(t, []*http.Cookie{local}, "/")
362 inbox := f.callback(t, "opaque", n, time.Now().Add(-time.Minute))
363 finish := httptest.NewRecorder()
364 f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{local, init, pending, inbox}))
365 if finish.Code != 403 {
366 t.Fatal("ordinary exchange reused for linking", finish.Code)
367 }
368 bridgeNoSession(t, f.b, finish)
369 for _, cookie := range finish.Result().Cookies() {
370 if cookie.Name == homeProofCookie && cookie.MaxAge > 0 {
371 t.Fatal("wrong nonce created proof")
372 }
373 }
374 var rows int
375 if err := f.app.db.Writer().QueryRow("SELECT COUNT(*) FROM docs_link_flows").Scan(&rows); err != nil || rows != 0 {
376 t.Fatal("failed link initiation not burned", rows, err)
377 }
378}
379
380func TestHomeBridgeStaleFreshExchangeCannotMintOrCreateProof(t *testing.T) {
381 f := newBridgeFixture(t)
382 local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute))
383 w := httptest.NewRecorder()
384 f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{local}))
385 init := bridgeCookie(t, w, homeInitCookie)
386 pending, n := f.start(t, []*http.Cookie{local, init}, "/account/home/confirm")
387 inbox := f.callback(t, "opaque", n, time.Now().Add(-6*time.Minute))
388 for i := 0; i < 2; i++ {
389 finish := httptest.NewRecorder()
390 f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{local, init, pending, inbox}))
391 if finish.Code == 303 {
392 t.Fatal("stale Home proof accepted or revived")
393 }
394 bridgeNoSession(t, f.b, finish)
395 }
396 var rows int
397 if err := f.app.db.Writer().QueryRow("SELECT COUNT(*) FROM docs_link_flows WHERE stage = 'pending'").Scan(&rows); err != nil || rows != 0 {
398 t.Fatal("stale Home gesture created a pending link", rows, err)
399 }
400}
401
402func TestHomeBridgeCurrentHomeSubjectIsCheckedByConsumer(t *testing.T) {
403 f := newBridgeFixture(t)
404 local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute))
405 f.link(t, local, "subject-one")
406 other := f.local(t, "two@example.com", "magiclink", time.Now().Add(-time.Minute))
407 f.link(t, other, "subject-two")
408 homeToken, err := f.b.sessions.Mint(sessions.Session{Subject: "subject-one", Method: homeAssertionMethod, AuthTime: time.Now().Add(-time.Minute)})
409 if err != nil {
410 t.Fatal(err)
411 }
412 current := &http.Cookie{Name: f.b.sessionCookie, Value: homeToken}
413 pending, n := f.start(t, []*http.Cookie{current}, "/")
414 inbox := f.callback(t, "subject-two", n, time.Now().Add(-time.Minute))
415 w := httptest.NewRecorder()
416 f.b.Finish(w, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{current, pending, inbox}))
417 if w.Code == 303 {
418 t.Fatal("signed-in Home identity silently replaced")
419 }
420 bridgeNoSession(t, f.b, w)
421}
422
423func TestHomeBridgeReturnPathCallbackRemainsSafe(t *testing.T) {
424 for _, tc := range []struct{ input, want string }{{"/d/existing-document?view=reading", "/d/existing-document?view=reading"}, {"https://evil.example/private", "/"}, {"//evil.example", "/"}, {"/auth/finish", "/"}, {"/%2fhost", "/"}, {"", "/"}} {
425 t.Run(tc.input, func(t *testing.T) {
426 f := newBridgeFixture(t)
427 f.b.returnPath = func(*http.Request) string { return tc.input }
428 local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute))
429 proof, cookies := f.linkProof(t, local, "opaque")
430 w := httptest.NewRecorder()
431 f.b.ConfirmPost(w, bridgeRequest(http.MethodPost, "/account/home/confirm", append(cookies, proof)))
432 if w.Code != 303 || w.Header().Get("Location") != tc.want {
433 t.Fatalf("return location=%q status=%d", w.Header().Get("Location"), w.Code)
434 }
435 })
436 }
437}
438