oficina / home Public

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

Plain git — no account needed to clone.

Download

Download this file

1package home
2
3import (
4 "net/http"
5 "strings"
6 "testing"
7)
8
9// The control, and the proof the stub actually drives the Keymail
10// upgrade: an unenrolled person completes the round trip and gets a
11// session whose method says keymail.
12func TestKeymailSignInMintsASession(t *testing.T) {
13 app := newTestApp(t)
14 const addr = "person@keymail.invalid"
15 stub := newKeymailStub(t, app, addr)
16
17 pending, state := beginKeymail(t, app, addr)
18 w := callback(t, app, "code-1", state, pending)
19
20 if sessionCookie(w) == nil {
21 t.Fatalf("the keymail round trip minted no session (status %d, location %q)",
22 w.Code, w.Header().Get("Location"))
23 }
24
25 var subject, method string
26 if err := app.db.Writer().
27 QueryRow("SELECT subject, method FROM sessions").Scan(&subject, &method); err != nil {
28 t.Fatal(err)
29 }
30 if !ValidPersonRef(subject) {
31 t.Errorf("session subject is %q, not a person ref", subject)
32 }
33 if method != "keymail" {
34 t.Errorf("session method is %q, want keymail", method)
35 }
36 _ = stub
37}
38
39// FINDING 1, and the reason this file exists. The Gate must cover the
40// keymail door as well as the magic-link one.
41//
42// The previous guard was a text grep for "a.sessions.SignIn(" in the
43// package. A reviewer removed the Gate from this path by writing the
44// mint with a local variable instead, and the entire suite stayed green
45// — so the second factor could be taken off the keymail door by a
46// one-line spelling change. This asserts the behaviour instead.
47func TestKeymailSignInIsGatedForAnEnrolledPerson(t *testing.T) {
48 app := newTestApp(t)
49 const addr = "person@keymail.invalid"
50 newKeymailStub(t, app, addr)
51
52 // Enrol first, through the magic-link path.
53 pending, state := beginKeymail(t, app, addr)
54 first := callback(t, app, "code-1", state, pending)
55 cookie := sessionCookie(first)
56 if cookie == nil {
57 t.Fatalf("setup: no session from the first keymail sign-in (status %d)", first.Code)
58 }
59 enrol(t, app, cookie)
60
61 // Now sign in again through keymail alone.
62 pending2, state2 := beginKeymail(t, app, addr)
63 w := callback(t, app, "code-2", state2, pending2)
64
65 if got := w.Header().Get("Location"); got != confirmPath {
66 t.Errorf("the keymail callback went to %q, want %q — the Gate did not take over "+
67 "on this path, so one factor opened an enrolled account", got, confirmPath)
68 }
69 if c := sessionCookie(w); c != nil {
70 t.Fatal("the keymail callback minted a full session for an enrolled person")
71 }
72}
73
74// FINDING 5, mutant O. "It is cleared on read, so a blob is good for
75// exactly one callback" was asserted in a comment and by nothing else.
76func TestPendingCookieIsClearedOnRead(t *testing.T) {
77 app := newTestApp(t)
78 const addr = "person@keymail.invalid"
79 newKeymailStub(t, app, addr)
80
81 pending, state := beginKeymail(t, app, addr)
82 w := callback(t, app, "code-1", state, pending)
83
84 var cleared bool
85 for _, c := range (&http.Response{Header: w.Header()}).Cookies() {
86 if c.Name == pendingCookie && c.MaxAge < 0 {
87 cleared = true
88 }
89 }
90 if !cleared {
91 t.Error("the callback did not clear the pending cookie, so this browser will re-send it")
92 }
93
94 // Replaying the whole request — same blob, same code — gets nothing.
95 // Note what does the work: NOT the cookie clearing, which only
96 // touches one browser's jar, but the federation server redeeming the
97 // authorization code once. The first version of this test asserted
98 // the clearing was what made the round trip single-use, and it
99 // failed, because it is not.
100 again := callback(t, app, "code-1", state, pending)
101 if sessionCookie(again) != nil {
102 t.Error("replaying the round trip minted a second session")
103 }
104 var n int
105 if err := app.db.Writer().QueryRow("SELECT count(*) FROM sessions").Scan(&n); err != nil {
106 t.Fatal(err)
107 }
108 if n != 1 {
109 t.Errorf("%d sessions after a replayed round trip, want 1", n)
110 }
111}
112
113// FINDING 5, mutant P. Every attribute on the pending cookie is
114// load-bearing — they are what the "the callback may mint on a GET"
115// argument rests on — and none of them was asserted, because no test
116// ever took the keymail branch.
117func TestPendingCookieAttributes(t *testing.T) {
118 app := newTestApp(t)
119 const addr = "person@keymail.invalid"
120 newKeymailStub(t, app, addr)
121
122 pending, _ := beginKeymail(t, app, addr)
123
124 if !strings.HasPrefix(pending.Name, "__Host-") {
125 t.Errorf("pending cookie is %q; the __Host- prefix is what pins it to this exact "+
126 "origin with no Domain, so no sibling host can set or read it", pending.Name)
127 }
128 if pending.Domain != "" {
129 t.Errorf("pending cookie carries Domain=%q; it must be host-only", pending.Domain)
130 }
131 if pending.Path != "/" {
132 t.Errorf("pending cookie Path=%q; __Host- requires /", pending.Path)
133 }
134 if !pending.HttpOnly {
135 t.Error("pending cookie is not HttpOnly, so script can read the blob")
136 }
137 if !pending.Secure {
138 t.Error("pending cookie is not Secure, which __Host- also requires")
139 }
140 if pending.SameSite != http.SameSiteLaxMode {
141 t.Errorf("pending cookie SameSite=%v, want Lax: Strict would break the keymail "+
142 "return itself, which is a top-level navigation from another origin",
143 pending.SameSite)
144 }
145}
146
147// A callback carrying a valid blob but the wrong state must mint
148// nothing. State is the browser binding; without it an attacker could
149// fix their own completed round trip onto a victim's browser.
150func TestCallbackWithWrongStateMintsNothing(t *testing.T) {
151 app := newTestApp(t)
152 const addr = "person@keymail.invalid"
153 newKeymailStub(t, app, addr)
154
155 pending, _ := beginKeymail(t, app, addr)
156 w := callback(t, app, "code-1", "not-the-sealed-state", pending)
157
158 if sessionCookie(w) != nil {
159 t.Fatal("a mismatched state still minted a session")
160 }
161 var n int
162 if err := app.db.Writer().QueryRow("SELECT count(*) FROM sessions").Scan(&n); err != nil {
163 t.Fatal(err)
164 }
165 if n != 0 {
166 t.Fatalf("a mismatched state created %d sessions", n)
167 }
168}
169
170// The stub replaces app.flow.Keymail, so it cannot prove that newFlow
171// wires a bounded client. This does, directly: the classifier and the
172// exchange both reach hosts chosen by whoever typed the address, and an
173// unbounded client there is a way to hold a handler open on a host the
174// submitter picked.
175func TestFlowUsesBoundedHTTPClients(t *testing.T) {
176 app := newTestApp(t)
177 fresh := newFlow(app.secrets, app.links, app.origins.Credential)
178
179 if fresh.Classifier.HTTP == nil {
180 t.Error("the classifier has no HTTP client, so it uses an unbounded default")
181 } else if fresh.Classifier.HTTP.Timeout != classifierTimeout {
182 t.Errorf("classifier timeout is %v, want %v", fresh.Classifier.HTTP.Timeout, classifierTimeout)
183 }
184
185 km := fresh.Keymail("example.invalid")
186 if km.HTTP == nil {
187 t.Error("the keymail client is unbounded")
188 } else if km.HTTP.Timeout != classifierTimeout {
189 t.Errorf("keymail timeout is %v, want %v", km.HTTP.Timeout, classifierTimeout)
190 }
191 if km.RedirectPath != callbackPath {
192 t.Errorf("keymail RedirectPath is %q, want %q — signin's default is /auth/callback, "+
193 "which this app does not serve", km.RedirectPath, callbackPath)
194 }
195}
196