| 1 | package home |
| 2 | |
| 3 | import ( |
| 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 | |
| 18 | func (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 | } |
| 30 | func 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 | } |
| 36 | func 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 | } |
| 73 | func 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 | } |
| 85 | func 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 | } |
| 95 | func 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 | } |
| 105 | func 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 | |
| 139 | func 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 | |