| 1 | package docs |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "amadan.net/rastrillo/rastrillo/sessions" |
| 12 | ) |
| 13 | |
| 14 | func identityRequest(method, subject, authMethod string) *http.Request { |
| 15 | return sessions.WithSession(httptest.NewRequest(method, "https://docs.example/", nil), sessions.Session{Subject: subject, Method: authMethod}) |
| 16 | } |
| 17 | |
| 18 | func TestHomeIdentityNeverCreatesPersonOrSamples(t *testing.T) { |
| 19 | app := newSampleDocumentsInternalTestApp(t, "1") |
| 20 | store := app.Store() |
| 21 | h := &handlers{store: store} |
| 22 | for _, subject := range []string{"opaque_subject", "looks-like-local@example.com"} { |
| 23 | r := identityRequest("GET", subject, homeAssertionMethod) |
| 24 | if _, err := h.owner(r); err != errIdentityRefused { |
| 25 | t.Fatal("unconfigured Home identity reached legacy owner", err) |
| 26 | } |
| 27 | if _, err := h.actor(r); err != errIdentityRefused { |
| 28 | t.Fatal("unconfigured Home identity reached legacy actor", err) |
| 29 | } |
| 30 | if h.member(r) { |
| 31 | t.Fatal("unconfigured Home identity gained membership") |
| 32 | } |
| 33 | for _, id := range []int64{0, 999999} { |
| 34 | v := identityResolver{store: store, issuer: "https://id.example", linkedPerson: func(ctx context.Context, issuer, got string) (int64, error) { |
| 35 | if issuer != "https://id.example" || got != subject { |
| 36 | t.Fatal("issuer/subject changed") |
| 37 | } |
| 38 | return id, nil |
| 39 | }} |
| 40 | reached := false |
| 41 | rec := httptest.NewRecorder() |
| 42 | v.middleware("/signin")(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true })).ServeHTTP(rec, r) |
| 43 | if reached || rec.Code != 303 { |
| 44 | t.Fatal("unknown Home identity reached handler") |
| 45 | } |
| 46 | } |
| 47 | } |
| 48 | var people, documents int64 |
| 49 | if err := store.db.Model(&Person{}).Count(&people).Error; err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | if err := store.db.Model(&Document{}).Count(&documents).Error; err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | if people != 0 || documents != 0 { |
| 56 | t.Fatalf("Home path created people=%d documents=%d", people, documents) |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | func TestLinkedHomeIdentityPreservesOwnersAndAddressGrants(t *testing.T) { |
| 61 | app := newSampleDocumentsInternalTestApp(t, "0") |
| 62 | store := app.Store() |
| 63 | ctx := t.Context() |
| 64 | alice, err := store.EnsurePerson(ctx, "alice@example.com") |
| 65 | if err != nil { |
| 66 | t.Fatal(err) |
| 67 | } |
| 68 | bob, err := store.EnsurePerson(ctx, "bob@example.com") |
| 69 | if err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | own, err := store.Create(ctx, alice, "Alice private") |
| 73 | if err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | shared, err := store.Create(ctx, bob, "Bob shared") |
| 77 | if err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | hidden, err := store.Create(ctx, bob, "Bob private") |
| 81 | if err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | if err = store.Grant(ctx, bob, shared.Sqid, "alice@example.com", RoleWriter); err != nil { |
| 85 | t.Fatal(err) |
| 86 | } |
| 87 | v := identityResolver{store: store, issuer: "https://id.example", linkedPerson: func(ctx context.Context, issuer, subject string) (int64, error) { |
| 88 | if issuer != "https://id.example" || subject != "opaque_alice" { |
| 89 | return 0, nil |
| 90 | } |
| 91 | return alice, nil |
| 92 | }} |
| 93 | memberAddresses := []string{} |
| 94 | h := &handlers{store: store, isMember: func(address string) bool { |
| 95 | memberAddresses = append(memberAddresses, address) |
| 96 | return address == "alice@example.com" |
| 97 | }} |
| 98 | reached := false |
| 99 | end := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 100 | reached = true |
| 101 | a, err := h.actor(r) |
| 102 | if err != nil || a.PersonID != alice || (a.VerifiedAddress == nil || *a.VerifiedAddress != "alice@example.com") { |
| 103 | t.Fatal("linked actor changed", a, err) |
| 104 | } |
| 105 | id, err := h.owner(r) |
| 106 | if err != nil || id != alice { |
| 107 | t.Fatal("linked owner changed", err) |
| 108 | } |
| 109 | if !h.member(r) { |
| 110 | t.Fatal("member did not use verified local address") |
| 111 | } |
| 112 | for _, d := range []*Document{own, shared} { |
| 113 | got, err := store.GetFor(ctx, a, d.Sqid) |
| 114 | if err != nil || got.ID != d.ID || !store.CanWrite(ctx, a, d.ID) { |
| 115 | t.Fatal("existing access lost", err) |
| 116 | } |
| 117 | } |
| 118 | if _, err := store.GetFor(ctx, a, hidden.Sqid); err == nil { |
| 119 | t.Fatal("link widened access") |
| 120 | } |
| 121 | if id, ok := currentIdentity(r); !ok || id.Session.Subject != "opaque_alice" { |
| 122 | t.Fatal("opaque identity not kept separate") |
| 123 | } |
| 124 | }) |
| 125 | rec := httptest.NewRecorder() |
| 126 | v.middleware("/signin")(end).ServeHTTP(rec, identityRequest("GET", "opaque_alice", homeAssertionMethod)) |
| 127 | if !reached || len(memberAddresses) != 1 { |
| 128 | t.Fatal("adapter not used") |
| 129 | } |
| 130 | var after Document |
| 131 | if err = store.db.First(&after, own.ID).Error; err != nil || after.OwnerID != alice { |
| 132 | t.Fatal("ownership rewritten") |
| 133 | } |
| 134 | grants, err := store.ListGrants(ctx, bob, shared.Sqid) |
| 135 | if err != nil || len(grants) != 1 || grants[0].Subject != "alice@example.com" { |
| 136 | t.Fatal("grant rewritten") |
| 137 | } |
| 138 | // Resolve the current local address on every request, not a saved copy. |
| 139 | if err = store.db.Model(&Person{}).Where("id=?", alice).Update("email", "renamed@example.com").Error; err != nil { |
| 140 | t.Fatal(err) |
| 141 | } |
| 142 | id, err := v.resolve(identityRequest("GET", "opaque_alice", homeAssertionMethod)) |
| 143 | if err != nil || (id.Actor.VerifiedAddress == nil || *id.Actor.VerifiedAddress != "renamed@example.com") { |
| 144 | t.Fatal("stale local address") |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | func TestHomeAdmissionRequiresAdapterAndCurrentAddress(t *testing.T) { |
| 149 | app := newSampleDocumentsInternalTestApp(t, "0") |
| 150 | store := app.Store() |
| 151 | person, err := store.EnsurePerson(t.Context(), "local@example.com") |
| 152 | if err != nil { |
| 153 | t.Fatal(err) |
| 154 | } |
| 155 | list, err := resolveAllowlist(Config{AllowedAddresses: "local@example.com"}) |
| 156 | if err != nil { |
| 157 | t.Fatal(err) |
| 158 | } |
| 159 | v := identityResolver{store: store, issuer: "https://id.example", linkedPerson: func(context.Context, string, string) (int64, error) { return person, nil }} |
| 160 | reached := false |
| 161 | next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }) |
| 162 | for _, method := range []string{"GET", "POST"} { |
| 163 | reached = false |
| 164 | rec := httptest.NewRecorder() |
| 165 | requireAdmission(list, "/signin")(next).ServeHTTP(rec, identityRequest(method, "local@example.com", homeAssertionMethod)) |
| 166 | if reached || (method == "GET" && rec.Code != 303) || (method == "POST" && rec.Code != 403) { |
| 167 | t.Fatal("opaque address bypassed adapter") |
| 168 | } |
| 169 | } |
| 170 | rec := httptest.NewRecorder() |
| 171 | v.middleware("/signin")(requireAdmission(list, "/signin")(next)).ServeHTTP(rec, identityRequest("GET", "opaque", homeAssertionMethod)) |
| 172 | if !reached { |
| 173 | t.Fatal("resolved local address not admitted") |
| 174 | } |
| 175 | reached = false |
| 176 | list, err = resolveAllowlist(Config{AllowedAddresses: "someone-else@example.com"}) |
| 177 | if err != nil { |
| 178 | t.Fatal(err) |
| 179 | } |
| 180 | rec = httptest.NewRecorder() |
| 181 | v.middleware("/signin")(requireAdmission(list, "/signin")(next)).ServeHTTP(rec, identityRequest("POST", "opaque", homeAssertionMethod)) |
| 182 | if reached || rec.Code != 403 { |
| 183 | t.Fatal("link bypassed changed admission") |
| 184 | } |
| 185 | v.linkedPerson = func(context.Context, string, string) (int64, error) { return 0, errors.New("fixture storage failure") } |
| 186 | rec = httptest.NewRecorder() |
| 187 | v.middleware("/signin")(next).ServeHTTP(rec, identityRequest("POST", "opaque", homeAssertionMethod)) |
| 188 | if reached || rec.Code != 503 { |
| 189 | t.Fatal("storage failure became admission or permanent write refusal") |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | func TestLocalFreshnessUsesActualMethodAndVerificationTime(t *testing.T) { |
| 194 | now := time.Unix(1700000000, 0) |
| 195 | for _, tc := range []struct { |
| 196 | name, method string |
| 197 | auth, created time.Time |
| 198 | want bool |
| 199 | }{ |
| 200 | {"magic original", "magiclink", now.Add(-time.Minute), now, true}, |
| 201 | {"magic persisted creation", "magiclink", time.Time{}, now.Add(-time.Minute), true}, |
| 202 | {"boundary", "magiclink", now.Add(-5 * time.Minute), now, true}, |
| 203 | {"old verification fresh remint", "magiclink", now.Add(-301 * time.Second), now, false}, |
| 204 | {"old persisted creation", "magiclink", time.Time{}, now.Add(-301 * time.Second), false}, |
| 205 | {"future verification", "magiclink", now.Add(time.Second), now, false}, |
| 206 | {"future creation", "magiclink", time.Time{}, now.Add(time.Second), false}, |
| 207 | {"missing times", "magiclink", time.Time{}, time.Time{}, false}, |
| 208 | {"keymail actual", "keymail", now.Add(-time.Minute), now, true}, |
| 209 | {"keymail no original", "keymail", time.Time{}, now, false}, |
| 210 | {"keymail stale", "keymail", now.Add(-301 * time.Second), now, false}, |
| 211 | {"keymail future", "keymail", now.Add(time.Second), now, false}, |
| 212 | {"home original", "home-assertion", now, now, false}, |
| 213 | {"home remint", "home-assertion", time.Time{}, now, false}, |
| 214 | {"other original", "password", now, now, false}, |
| 215 | {"unknown remint", "", time.Time{}, now, false}, |
| 216 | } { |
| 217 | t.Run(tc.name, func(t *testing.T) { |
| 218 | got, ok := freshLocalAuthentication(sessions.Session{Subject: "local@example.com", Method: tc.method, AuthTime: tc.auth, At: tc.created}, now) |
| 219 | if ok != tc.want { |
| 220 | t.Fatal("wrong local freshness decision") |
| 221 | } |
| 222 | expected := tc.auth |
| 223 | if expected.IsZero() { |
| 224 | expected = tc.created |
| 225 | } |
| 226 | if ok && !got.Equal(expected) { |
| 227 | t.Fatal("verification time rewritten") |
| 228 | } |
| 229 | if !ok && !got.IsZero() { |
| 230 | t.Fatal("refused proof returned freshness") |
| 231 | } |
| 232 | }) |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | func TestLocalMagicLinkUsesPersistedVerificationAndRotation(t *testing.T) { |
| 237 | app := newSampleDocumentsInternalTestApp(t, "0") |
| 238 | reader, err := sessions.New(sessions.Config{DB: app.db.Writer(), Origin: app.cfg.Origin}) |
| 239 | if err != nil { |
| 240 | t.Fatal(err) |
| 241 | } |
| 242 | first := app.TestRequest(t, "local@example.com", "GET", "/", nil) |
| 243 | session, ok := reader.From(first) |
| 244 | if !ok || session.Method != "magiclink" || !session.AuthTime.IsZero() || session.At.IsZero() { |
| 245 | t.Fatal("real local magic-link session did not match fallback contract") |
| 246 | } |
| 247 | verified, ok := freshLocalAuthentication(session, time.Now()) |
| 248 | if !ok || !verified.Equal(session.At) { |
| 249 | t.Fatal("actual completed magic link not fresh") |
| 250 | } |
| 251 | oldCookie, err := first.Cookie(reader.CookieName()) |
| 252 | if err != nil { |
| 253 | t.Fatal(err) |
| 254 | } |
| 255 | // Complete a second real local email token while presenting the first session. |
| 256 | link, err := app.TestSignIn(t, "local@example.com") |
| 257 | if err != nil { |
| 258 | t.Fatal(err) |
| 259 | } |
| 260 | verify := confirmRequest(link) |
| 261 | verify.AddCookie(oldCookie) |
| 262 | rec := httptest.NewRecorder() |
| 263 | app.Handler().ServeHTTP(rec, verify) |
| 264 | if rec.Code != 303 { |
| 265 | t.Fatal("local reverification failed", rec.Code) |
| 266 | } |
| 267 | second := httptest.NewRequest("GET", "/", nil) |
| 268 | for _, c := range rec.Result().Cookies() { |
| 269 | second.AddCookie(c) |
| 270 | } |
| 271 | if _, ok = reader.From(first); ok { |
| 272 | t.Fatal("reverification left old session usable") |
| 273 | } |
| 274 | next, ok := reader.From(second) |
| 275 | if !ok { |
| 276 | t.Fatal("rotated session missing") |
| 277 | } |
| 278 | if _, ok = freshLocalAuthentication(next, time.Now()); !ok { |
| 279 | t.Fatal("rotated real magic link not fresh") |
| 280 | } |
| 281 | newCookie, err := second.Cookie(reader.CookieName()) |
| 282 | if err != nil || newCookie.Value == oldCookie.Value { |
| 283 | t.Fatal("actual browser binding did not rotate") |
| 284 | } |
| 285 | } |
| 286 | |