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 "amadan.net/oficina/home/handoff"
5 "amadan.net/oficina/home/internal/home/sealtest"
6 "amadan.net/rastrillo/rastrillo/assertion"
7 "context"
8 "encoding/base64"
9 "encoding/json"
10 "github.com/keymaildev/signin"
11 "net/http"
12 "net/url"
13 "strings"
14 "testing"
15 "time"
16)
17
18func (p exchangePair) startFresh(t *testing.T, session *http.Cookie) (*http.Cookie, *http.Cookie) {
19 t.Helper()
20 start := exchangeRequest(p.directory, "GET", directoryOrigin+handoff.StartPath+"?return_to=%2F%3Fteam%3Dblue", "", nil)
21 if start.Code != 303 {
22 t.Fatal("start", start.Code)
23 }
24 authorize := exchangeRequest(p.credential.App, "GET", start.Header().Get("Location")+"&max_age=300", "", nil, session)
25 if authorize.Code != 303 {
26 t.Fatal("authorize", authorize.Code)
27 }
28 return namedCookie(t, start, "__Host-oficina-assertion-pending"), namedCookie(t, authorize, assertionIntentCookie)
29}
30func backdateCredential(t *testing.T, p exchangePair, at time.Time) {
31 t.Helper()
32 if _, err := p.credential.db.Writer().Exec(`UPDATE sessions SET auth_time=?`, at.UTC().Format(time.RFC3339)); err != nil {
33 t.Fatal(err)
34 }
35}
36func TestFreshAssertionReauthenticatesStaleSession(t *testing.T) {
37 p := newExchangePair(t)
38 oldSession := signInFully(t, p.credential, sealtest.CanaryAddress)
39 backdateCredential(t, p, time.Now().Add(-time.Hour))
40 pending, intent := p.startFresh(t, oldSession)
41 resume := exchangeRequest(p.credential.App, "GET", Origin+"/authorize/resume", "", nil, intent, oldSession)
42 if resume.Code != 303 || resume.Header().Get("Location") != "/signin?return_to=%2Fauthorize%2Fresume" || strings.Contains(resume.Body.String(), `name="assertion"`) {
43 t.Fatal("stale session did not require real reauthentication", resume.Code)
44 }
45 form := exchangeRequest(p.credential.App, "GET", Origin+resume.Header().Get("Location"), "", nil, intent, oldSession)
46 navigation := namedCookie(t, form, returnCookie)
47 p.credential.flow.Classifier = &signin.Classifier{HTTP: &http.Client{Timeout: classifierTimeout}}
48 newKeymailStub(t, p.credential, sealtest.CanaryAddress)
49 if !p.credential.flow.Classifier.Classify(context.Background(), sealtest.CanaryAddress).Keymail {
50 t.Fatal("fixture does not advertise Keymail")
51 }
52 begin := exchangeRequest(p.credential.App, "POST", Origin+"/signin", Origin, url.Values{"address": {sealtest.CanaryAddress}}, intent, oldSession, navigation)
53 if begin.Code != 303 || begin.Header().Get("Location") != "/signin/sent" {
54 t.Fatal("fresh flow reused upstream sign-in instead of emailing", begin.Code)
55 }
56 verified := exchangeRequest(p.credential.App, "POST", Origin+linkPath, Origin, url.Values{"token": {capturedLink(t, p.credential)}}, intent, oldSession, navigation)
57 if verified.Code != 303 || verified.Header().Get("Location") != "/authorize/resume" {
58 t.Fatal("lost reauthentication continuation", verified.Code)
59 }
60 token, _ := p.mint(t, intent, sessionCookie(verified))
61 verifier, err := assertion.NewVerifier(Origin, directoryOrigin, p.credential.runtime.keys)
62 if err != nil {
63 t.Fatal(err)
64 }
65 claims, err := verifier.Verify(token, time.Now())
66 if err != nil || time.Now().Unix()-claims.AuthTime > 10 {
67 t.Fatal("assertion did not carry fresh verified time", err)
68 }
69 p.finish(t, token, pending)
70 sealtest.AssertNoPlaintext(t, p.credential.dbPath)
71 sealtest.AssertNoPlaintext(t, p.directoryPath)
72}
73func TestFreshAssertionUsesFreshSessionAndBindsOriginalPerson(t *testing.T) {
74 p := newExchangePair(t)
75 first := signInFully(t, p.credential, sealtest.CanaryAddress)
76 _, intent := p.startFresh(t, first)
77 p.mint(t, intent, first)
78 _, intent = p.startFresh(t, first)
79 second := signInFully(t, p.credential, "different@example.invalid")
80 r := exchangeRequest(p.credential.App, "GET", Origin+"/authorize/resume", "", nil, intent, second)
81 if r.Code != 400 {
82 t.Fatal("different person satisfied original identity step-up", r.Code)
83 }
84}
85func TestFreshAssertionRefusesFutureAuthentication(t *testing.T) {
86 p := newExchangePair(t)
87 session := signInFully(t, p.credential, sealtest.CanaryAddress)
88 backdateCredential(t, p, time.Now().Add(time.Hour))
89 _, intent := p.startFresh(t, session)
90 r := exchangeRequest(p.credential.App, "GET", Origin+"/authorize/resume", "", nil, intent, session)
91 if r.Code != 303 || !strings.HasPrefix(r.Header().Get("Location"), "/signin?") {
92 t.Fatal("future authentication was credited", r.Code)
93 }
94}
95func TestFreshAssertionStrictAge(t *testing.T) {
96 p := newExchangePair(t)
97 nonce, _ := assertion.RandomID()
98 for _, suffix := range []string{"&max_age=0", "&max_age=301", "&max_age=0300", "&max_age=300&max_age=300", "&max_age=300&unexpected=1", "&max_age="} {
99 r := exchangeRequest(p.credential.App, "GET", Origin+"/authorize?consumer=directory&nonce="+nonce+suffix, "", nil)
100 if r.Code != 400 || len(r.Result().Cookies()) != 0 {
101 t.Fatal("invalid age accepted", suffix, r.Code)
102 }
103 }
104}
105func TestOrdinaryIntentKeepsKeymailAndInvalidIntentCannotRequestFreshness(t *testing.T) {
106 for _, mode := range []string{"ordinary", "tampered", "duplicate", "expired"} {
107 t.Run(mode, func(t *testing.T) {
108 p := newExchangePair(t)
109 newKeymailStub(t, p.credential, sealtest.CanaryAddress)
110 _, intent := p.start(t)
111 if mode != "ordinary" {
112 _, intent = p.startFresh(t, nil)
113 }
114 cookies := []*http.Cookie{intent}
115 switch mode {
116 case "tampered":
117 intent.Value = "tampered"
118 case "duplicate":
119 cookies = append(cookies, intent)
120 case "expired":
121 nonce, _ := assertion.RandomID()
122 raw, _ := json.Marshal(assertionIntent{Consumer: "directory", Nonce: nonce, Expires: time.Now().Unix() - 1, MaxAge: 300})
123 sealed, err := p.credential.secrets.Sealer.Seal("assertion.intent", raw)
124 if err != nil {
125 t.Fatal(err)
126 }
127 intent.Value = base64.RawURLEncoding.EncodeToString(sealed)
128 }
129 r := exchangeRequest(p.credential.App, "POST", Origin+"/signin", Origin, url.Values{"address": {sealtest.CanaryAddress}}, cookies...)
130 _, links := continuationTargets(t, r.Body.String())
131 if len(links) != 1 || !strings.Contains(links[0], "/oauth/authorize?") {
132 t.Fatal("ordinary or invalid intent lost Keymail continuation", r.Code)
133 }
134 assertContinuationPage(t, r, links[0])
135 })
136 }
137}
138
139func TestFreshAssertionAcceptsCompletedKeymailRecovery(t *testing.T) {
140 p := newExchangePair(t)
141 const address = "person@keymail.invalid"
142 newKeymailStub(t, p.credential, address)
143 initialPending, initialState := beginKeymail(t, p.credential, address)
144 session := sessionCookie(callback(t, p.credential, "code-1", initialState, initialPending))
145 if session == nil {
146 t.Fatal("initial Keymail sign-in failed")
147 }
148 enrol(t, p.credential, session)
149 codes, err := p.credential.passkeys.RegenerateRecoveryCodes(subjectOf(t, p.credential, session))
150 if err != nil {
151 t.Fatal(err)
152 }
153 kp, state := beginKeymail(t, p.credential, address)
154 gated := callback(t, p.credential, "code-2", state, kp)
155 if sessionCookie(gated) != nil || gated.Header().Get("Location") != confirmPath {
156 t.Fatal("Keymail skipped second factor")
157 }
158 _, intent := p.startFresh(t, nil)
159 before := exchangeRequest(p.credential.App, "GET", Origin+"/authorize/resume", "", nil, append(gated.Result().Cookies(), intent)...)
160 if before.Code != 303 {
161 t.Fatal("half-session satisfied freshness")
162 }
163 recovery := exchangeRequest(p.credential.App, "POST", Origin+"/passkey/signin/recovery", Origin, url.Values{"code": {codes[0]}}, gated.Result().Cookies()...)
164 if sessionCookie(recovery) == nil {
165 t.Fatal("recovery did not complete", recovery.Code)
166 }
167 p.mint(t, intent, sessionCookie(recovery))
168}
169