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 "bytes"
5 "context"
6 "crypto/rand"
7 "crypto/sha256"
8 "encoding/hex"
9 "html"
10 "log/slog"
11 "net/http"
12 "net/http/httptest"
13 "net/url"
14 "os"
15 "path/filepath"
16 "regexp"
17 "strings"
18 "testing"
19 "time"
20
21 "amadan.net/oficina/home/handoff"
22 "amadan.net/oficina/home/internal/home/seal"
23 "amadan.net/oficina/home/internal/home/sealtest"
24 "amadan.net/rastrillo/rastrillo/assertion"
25 assertioncrypto "amadan.net/rastrillo/rastrillo/crypto"
26 "amadan.net/rastrillo/rastrillo/db"
27 "amadan.net/rastrillo/rastrillo/migrate"
28)
29
30type exchangePair struct {
31 credential *testApp
32 directory *App
33 directoryPath string
34 logs *bytes.Buffer
35}
36
37func newExchangePair(t *testing.T) exchangePair {
38 t.Helper()
39 cred := newTestApp(t)
40 key, err := assertioncrypto.Generate()
41 if err != nil {
42 t.Fatal(err)
43 }
44 sum := sha256.Sum256(key.SignPub())
45 kid := hex.EncodeToString(sum[:])
46 cred.runtime = &Runtime{role: "credential", origins: cred.origins, secrets: cred.secrets, signer: key, keyID: kid, keys: map[string][]byte{kid: key.SignPub()}, consumers: map[string]string{"directory": directoryOrigin, "team_docs": "https://blue.docs.oficina.works"}}
47 path := filepath.Join(t.TempDir(), "directory.db")
48 d, err := db.Open(path, quietLogger())
49 if err != nil {
50 t.Fatal(err)
51 }
52 t.Cleanup(func() { d.Close() })
53 if _, err = migrate.Apply(context.Background(), d, (Runtime{role: "directory"}).Schema()); err != nil {
54 t.Fatal(err)
55 }
56 dek := make([]byte, 32)
57 rand.Read(dek)
58 sealer, err := seal.New(dek)
59 if err != nil {
60 t.Fatal(err)
61 }
62 logs := new(bytes.Buffer)
63 logger := slog.New(slog.NewTextHandler(logs, nil))
64 cfg := Runtime{role: "directory", origins: cred.origins, secrets: Secrets{Sealer: sealer}, keys: cred.runtime.keys}
65 directory, err := NewRuntime(cfg, d, logger)
66 if err != nil {
67 t.Fatal(err)
68 }
69 cred.logger = logger
70 return exchangePair{cred, directory, path, logs}
71}
72func exchangeRequest(a *App, method, target, origin string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder {
73 body := ""
74 if form != nil {
75 body = form.Encode()
76 }
77 r := httptest.NewRequest(method, target, strings.NewReader(body))
78 if form != nil {
79 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
80 }
81 if origin != "" {
82 r.Header.Set("Origin", origin)
83 }
84 for _, cookie := range cookies {
85 if cookie != nil {
86 r.AddCookie(cookie)
87 }
88 }
89 rec := httptest.NewRecorder()
90 a.Router().ServeHTTP(rec, r)
91 return rec
92}
93func namedCookie(t *testing.T, r *httptest.ResponseRecorder, name string) *http.Cookie {
94 t.Helper()
95 for _, cookie := range r.Result().Cookies() {
96 if cookie.Name == name && cookie.Value != "" {
97 return cookie
98 }
99 }
100 t.Fatal("missing cookie", name)
101 return nil
102}
103func (p exchangePair) start(t *testing.T) (*http.Cookie, *http.Cookie) {
104 t.Helper()
105 start := exchangeRequest(p.directory, "GET", directoryOrigin+handoff.StartPath+"?return_to=%2F%3Fteam%3Dblue", "", nil)
106 if start.Code != 303 {
107 t.Fatal("start", start.Code)
108 }
109 pending := namedCookie(t, start, "__Host-oficina-assertion-pending")
110 authorize := exchangeRequest(p.credential.App, "GET", start.Header().Get("Location"), "", nil)
111 if authorize.Code != 303 || authorize.Header().Get("Location") != "/authorize/resume" {
112 t.Fatal("authorize", authorize.Code)
113 }
114 return pending, namedCookie(t, authorize, assertionIntentCookie)
115}
116func (p exchangePair) mint(t *testing.T, intent, session *http.Cookie) (string, *httptest.ResponseRecorder) {
117 t.Helper()
118 rec := exchangeRequest(p.credential.App, "GET", Origin+"/authorize/resume", "", nil, intent, session)
119 if rec.Code != 200 {
120 t.Fatal("mint", rec.Code)
121 }
122 m := regexp.MustCompile(`name="assertion" value="([^"]+)"`).FindStringSubmatch(rec.Body.String())
123 if len(m) != 2 {
124 t.Fatal("no assertion form")
125 }
126 token := html.UnescapeString(m[1])
127 if !strings.Contains(rec.Body.String(), `action="`+directoryOrigin+handoff.CallbackPath+`"`) || rec.Header().Get("Referrer-Policy") != "strict-origin-when-cross-origin" {
128 t.Fatal("handoff transport changed")
129 }
130 return token, rec
131}
132func (p exchangePair) finish(t *testing.T, token string, pending *http.Cookie) *http.Cookie {
133 t.Helper()
134 callback := exchangeRequest(p.directory, "POST", directoryOrigin+handoff.CallbackPath, Origin, url.Values{"assertion": {token}})
135 if callback.Code != 303 || sessionCookie(callback) != nil {
136 t.Fatal("callback minted session or refused", callback.Code)
137 }
138 inbox := namedCookie(t, callback, "__Host-oficina-assertion-inbox")
139 finish := exchangeRequest(p.directory, "GET", directoryOrigin+handoff.FinishPath, "", nil, pending, inbox)
140 if finish.Code != 303 || finish.Header().Get("Location") != "/?team=blue" || sessionCookie(finish) == nil {
141 t.Fatal("finish", finish.Code)
142 }
143 return sessionCookie(finish)
144}
145
146func TestHomeDirectoryExchangeAndPrivacy(t *testing.T) {
147 p := newExchangePair(t)
148 credentialSession := signInFully(t, p.credential, sealtest.CanaryAddress)
149 old := time.Now().Add(-time.Hour).UTC().Truncate(time.Second)
150 if _, err := p.credential.db.Writer().Exec(`UPDATE sessions SET auth_time=?`, old.Format(time.RFC3339)); err != nil {
151 t.Fatal(err)
152 }
153 pending, intent := p.start(t)
154 token, _ := p.mint(t, intent, credentialSession)
155 directorySession := p.finish(t, token, pending)
156 page := exchangeRequest(p.directory, "GET", directoryOrigin+"/", "", nil, directorySession)
157 if page.Code != 200 || !strings.Contains(page.Body.String(), "<details estilo-account") {
158 t.Fatal("directory session missing")
159 }
160 var subject, authTime string
161 if err := p.directory.db.Writer().QueryRow(`SELECT subject,auth_time FROM sessions`).Scan(&subject, &authTime); err != nil {
162 t.Fatal(err)
163 }
164 if subject == "" || strings.Contains(subject, "@") || authTime != old.Format(time.RFC3339) {
165 t.Fatal("identity or freshness changed")
166 }
167 // A cookie minted on the credential origin must not carry a directory
168 // session. "/" no longer redirects an unauthenticated caller — it
169 // renders the front page — so the check is that the credential cookie
170 // gets that anonymous page and none of the signed-in directory, which
171 // asserts the same separation against the response body instead of
172 // against a redirect that is no longer how the route reports it.
173 if rec := exchangeRequest(p.directory, "GET", directoryOrigin+"/", "", nil, credentialSession); rec.Code != 200 ||
174 strings.Contains(rec.Body.String(), "You are signed in.") || strings.Contains(rec.Body.String(), "Choose a team") {
175 t.Fatal("credential cookie opened directory", rec.Code)
176 }
177 if rec := exchangeRequest(p.credential.App, "GET", Origin+"/authorize/resume", "", nil, intent, directorySession); rec.Code != 303 {
178 t.Fatal("directory cookie opened credentials")
179 }
180 for _, path := range []string{p.credential.dbPath, p.directoryPath} {
181 sealtest.AssertNoPlaintext(t, path)
182 for _, suffix := range []string{"", "-wal"} {
183 b, err := os.ReadFile(path + suffix)
184 if err != nil {
185 t.Fatal(err)
186 }
187 for _, canary := range []string{token, "/?team=blue", pending.Value, intent.Value} {
188 if bytes.Contains(b, []byte(canary)) {
189 t.Fatal("exchange plaintext at rest")
190 }
191 }
192 }
193 }
194 if strings.Contains(p.logs.String(), token) || strings.Contains(p.logs.String(), sealtest.CanaryAddress) {
195 t.Fatal("exchange leaked in logs")
196 }
197}
198func TestHomeAssertionWaitsForCompleteSignIn(t *testing.T) {
199 p := newExchangePair(t)
200 pending, intent := p.start(t)
201 resume := exchangeRequest(p.credential.App, "GET", Origin+"/authorize/resume", "", nil, intent)
202 if resume.Code != 303 || !strings.Contains(resume.Header().Get("Location"), "return_to=") {
203 t.Fatal("missing sign-in continuation")
204 }
205 signin := exchangeRequest(p.credential.App, "GET", Origin+resume.Header().Get("Location"), "", nil, intent)
206 navigation := namedCookie(t, signin, returnCookie)
207 if navigation.Value != "/authorize/resume" {
208 t.Fatal("intent not remembered")
209 }
210 signIn(t, p.credential, sealtest.CanaryAddress)
211 redeemed := exchangeRequest(p.credential.App, "POST", Origin+linkPath, Origin, url.Values{"token": {capturedLink(t, p.credential)}}, navigation, intent)
212 if redeemed.Code != 303 || redeemed.Header().Get("Location") != "/authorize/resume" {
213 t.Fatal("sign-in lost continuation")
214 }
215 token, _ := p.mint(t, intent, sessionCookie(redeemed))
216 p.finish(t, token, pending)
217}
218func TestHomeAssertionWaitsForRecoverySecondFactor(t *testing.T) {
219 p := newExchangePair(t)
220 oldSession := signInFully(t, p.credential, sealtest.CanaryAddress)
221 enrol(t, p.credential, oldSession)
222 codes, err := p.credential.passkeys.RegenerateRecoveryCodes(subjectOf(t, p.credential, oldSession))
223 if err != nil {
224 t.Fatal(err)
225 }
226 pending, intent := p.start(t)
227 signin := exchangeRequest(p.credential.App, "GET", Origin+"/signin?return_to=/authorize/resume", "", nil, intent)
228 navigation := namedCookie(t, signin, returnCookie)
229 signIn(t, p.credential, sealtest.CanaryAddress)
230 gated := exchangeRequest(p.credential.App, "POST", Origin+linkPath, Origin, url.Values{"token": {capturedLink(t, p.credential)}}, navigation, intent)
231 if sessionCookie(gated) != nil || gated.Header().Get("Location") != confirmPath {
232 t.Fatal("first factor bypassed gate")
233 }
234 before := exchangeRequest(p.credential.App, "GET", Origin+"/authorize/resume", "", nil, append(gated.Result().Cookies(), intent)...)
235 if before.Code != 303 {
236 t.Fatal("first factor minted assertion")
237 }
238 recovery := exchangeRequest(p.credential.App, "POST", Origin+"/passkey/signin/recovery", Origin, url.Values{"code": {codes[0]}}, gated.Result().Cookies()...)
239 if sessionCookie(recovery) == nil || recovery.Header().Get("Location") != "/authorize/resume" {
240 t.Fatal("recovery lost continuation", recovery.Code)
241 }
242 token, _ := p.mint(t, intent, sessionCookie(recovery))
243 p.finish(t, token, pending)
244}
245func TestHomeRoleAndMutationIsolation(t *testing.T) {
246 p := newExchangePair(t)
247 for _, route := range []string{"/signin", "/authorize", "/authorize/resume", "/passkey/enrol", "/static/assertion.js"} {
248 rec := exchangeRequest(p.directory, "GET", Origin+route, "", nil)
249 if rec.Code != 404 {
250 t.Fatal("directory served credential host", route, rec.Code)
251 }
252 }
253 for _, route := range []string{"/", "/signin", "/authorize", "/auth/start", "/static/assertion.js"} {
254 rec := exchangeRequest(p.credential.App, "GET", directoryOrigin+route, "", nil)
255 if rec.Code != 404 {
256 t.Fatal("credential served directory host", route, rec.Code)
257 }
258 }
259 rec := exchangeRequest(p.directory, "POST", directoryOrigin+"/signout", Origin, url.Values{})
260 if rec.Code != 403 {
261 t.Fatal("ordinary directory mutation lost CSRF")
262 }
263 nonce, _ := assertion.RandomID()
264 for _, query := range []string{"consumer=evil&nonce=" + nonce, "consumer=directory&nonce=x", "consumer=directory&nonce=" + nonce + "&nonce=" + nonce, "consumer=directory&nonce=" + nonce + "&return_to=https://evil.invalid"} {
265 rec := exchangeRequest(p.credential.App, "GET", Origin+"/authorize?"+query, "", nil)
266 if rec.Code != 400 || len(rec.Result().Cookies()) != 0 {
267 t.Fatal("invalid authorize retained intent")
268 }
269 }
270}
271func TestHomePolicyMatchesRegisteredConsumers(t *testing.T) {
272 p := newExchangePair(t)
273 baseline := (&App{origins: p.credential.origins}).SecurityPolicy()
274 want := strings.Replace(baseline, "form-action 'self' "+directoryOrigin, "form-action 'self' https://blue.docs.oficina.works "+directoryOrigin, 1)
275 if p.credential.SecurityPolicy() != want {
276 t.Fatal("CSP differs outside exact manifest")
277 }
278}
279
280func TestDirectoryRoleUpgradePreservesExistingDatabase(t *testing.T) {
281 old := newTestApp(t)
282 ref, err := old.findOrMintPerson(context.Background(), sealtest.CanaryAddress)
283 if err != nil {
284 t.Fatal(err)
285 }
286 if _, err = migrate.Apply(context.Background(), old.db, directorySchema); err != nil {
287 t.Fatal(err)
288 }
289 var got string
290 if err = old.db.Writer().QueryRow(`SELECT person_ref FROM people`).Scan(&got); err != nil || got != ref {
291 t.Fatal("role migration changed existing data")
292 }
293 if _, err = migrate.Apply(context.Background(), old.db, BootSchema); err != nil {
294 t.Fatal("additive role schema prevents legacy rollback", err)
295 }
296 sealtest.AssertNoPlaintext(t, old.dbPath)
297}
298