Keep local reauthentication continuation separate from link proof
3 files changed,
+513
−0
implementation/local-link-continuation.md+37 −0internal/docs/home_local_navigation.go+155 −0internal/docs/home_local_navigation_test.go+321 −0
CI passed — run details
diff --git a/implementation/local-link-continuation.md b/implementation/local-link-continuation.md| new file mode 100644 |
| index 0000000..5e7fdd4 |
| --- /dev/null |
| +++ b/implementation/local-link-continuation.md |
| @@ -0,0 +1,37 @@ |
| +# Local reauthentication navigation before Home linking |
| + |
| +Status: design amendment approved by claude-fable-5 at dccdb746303b87f64f4156053943441ad2ca60af, 2026-09-08. Review used pinned source excerpts inline; it did not execute tests. Supersedes only the local-signin continuation paragraph of the approved Docs account-link design2e69825. The navigation helper is implemented on feat/local-link-navigation; application routing/configuration and whole-branch code review remain integration work. |
| + |
| +## Why the earlier continuation cannot work |
| + |
| +The approved design says that local signin returns to a confirmation continuation only while a valid pending link exists. But linkstore.Start correctly requires an already fresh local proof. A stale user opening GET /account/home therefore has no valid link initiation to resume. Moreover, successful local reauthentication rotates the actual session token, so a previous initiation bound to the old token must not survive it. |
| + |
| +The pinned Rastrillo v0.23.0 auth.Verify and auth.Callback call admit. admit mints a session with sessions.SignIn and redirects to the fixed Config.SignedInPath, default /. Neither auth handler reads return_to. sessions.SafeReturn exists in the sessions package, reads r.FormValue("return_to"), and is not called by auth. It neither persists navigation through email nor supplies a dynamic post-verification destination. Do not depend on it for this flow or intercept auth response bodies to infer verification success. |
| + |
| +## Navigation-only continuation |
| + |
| +For suite-configured Docs only, set the local auth plugin's SignedInPath to the fixed /auth/local/complete route. Standalone Docs keeps its existing /. Ordinary suite-local signins without a continuation reach that fixed bridge and immediately return to /. |
| + |
| +When a stale local session reaches GET /account/home, invalidate any previous link initiation/proof and issue a navigation-only marker before directing the person to the existing local signin page. The existing gateBegin already forces the actual magic-link method server-side and retains its normal allowlist and CSRF checks. Showing this GET must not send mail, initiate a link, bind a nonce, or create a proof. |
| + |
| +The marker is an AEAD-sealed host-only Secure/HttpOnly/SameSite=Lax cookie using the reviewed shared sealer, a dedicated purpose and the exact configured Docs origin. It contains only version1, the fixed action link-start, issued-at and expiry. It contains no person ID, address, subject, local session token/hash, Home claim, link proof, assertion, or arbitrary return path. Its TTL is at most ten minutes. Decode only one cookie with a hard encoded-size bound (1024 bytes), canonical strict base64url, strict closed JSON fields, no trailing value, exact supported version/action, and issued/expiry bounds: issued>0, issued<=now<expiry, expiry-issued<=600 seconds. The cookie max-age does not replace sealed expiry validation. Missing, duplicate, tampered, malformed, unsupported or expired markers clear the marker and fall back to /. |
| + |
| +/auth/local/complete is a GET navigation bridge. It clears the marker on every request. With a valid marker it resolves the actual newly presented session and checks freshLocalAuthentication against the current clock. Only a fresh local magiclink/keymail session can reach the fixed /account/home destination; missing, duplicate, refused, unsupported, stale or future session proof falls back to /. The bridge itself creates no session, calls no Home exchange and writes no identity link. No request parameter selects its destination. Replaying a copied marker can at most navigate to the same initiation page while the current local session is fresh; there is no replay-backed single-use or identity-proof claim for this cookie. |
| + |
| +The /account/home page identifies the currently signed-in local account. An explicit fresh same-origin POST is still required to create a new linkstore initiation, bound to that actual current session and person, followed by the new shared Home nonce exchange and separate confirmation POST. A user who reauthenticated as another local account merely reaches that account's page; the old account's proof cannot transfer. A stale or changed initiation/proof is invalidated and never resumes after rotation. The original resource-return-path guarantee belongs to a newly initiated valid link's sealed state; this navigation marker stores no resource path and never resurrects a stale flow's path or proof. |
| + |
| +## Session source constraints |
| + |
| +Rastrillo auth.SessionFrom(r) directly resolves the request cookie and returns signin.Identity, copying session Subject into Address even for Method=home-assertion. It is not a local-address resolver. sessions.From returns sessions.Session; auth.RequireSession also stashes this under sessions.Current. Use the typed Docs identity adapter for ownership and grants, and the method-aware local predicate for link proof. |
| + |
| +sessions.SignIn(w,r,sess) revokes the presented old cookie's row, creates a fresh row, and sets a new cookie. It preserves supplied AuthTime, but its database create uses time.Now for created_at; supplied Session.At is ignored. The next bridge request sees the new cookie. Do not resolve the original verification request and mistake its old cookie for the newly minted session. A Home session's new At never becomes local proof because home-assertion is refused by the predicate. |
| + |
| +Rastrillo v0.23.0 sessions.resolve and SignIn use r.Cookie, accepting the first matching cookie. Root integration must reject duplicate session cookies before sensitive resolution or mutation (including local completion and Home session replacement), and count the exact configured cookie name. Missing cookies remain normal for ordinary local signin, but cannot be used as a current local link proof. Keymail Callback similarly takes the first pending cookie; if kept reachable, its wrapper rejects duplicate pending cookies before completion. These are guards around existing APIs, not new identity mechanisms. |
| + |
| +## Acceptance |
| + |
| +Actual local email verification with a valid marker returns through the fixed bridge to the initiation page; no Home or linkstore operation occurs until the explicit new POST. Ordinary local signin still lands /. Failed verification never reaches the bridge. Marker missing/duplicate/tamper/expiry/unknown fields, hostile query destinations and wrong origin purpose all fall back to /. Browser tests include JS off. A changed local account displays its own account before a new initiation. Stale prior proofs cannot be confirmed after session rotation; the original session token is revoked. A freshly minted Home session cannot use At to pass local freshness. A meaningful mutation must fail if the bridge skips the method/freshness guard or if a stale initiation resumes. Whole integration code review, actual CI and canary acceptance remain required. |
| + |
| +Review implementation notes (non-blocking, to honor): explicitly guard duplicate session cookies before GET /auth/verify, GET /auth/callback, GET /auth/local/complete, the initiation/confirmation POSTs and Home exchange/session replacement. Check signin still renders to an already-signed-in user (the current h.signin renders directly without checking a session); preserve this in suite integration and JS-off browser acceptance. Set Cache-Control: no-store on the bridge and every marker-setting response. A cross-site top-level GET to /account/home may invalidate an existing flow; this deliberately fails closed by destroying a flow, never granting or creating one. Review result: /tmp/oficina-docs-local-continuation-review/review.ndjson. |
| + |
| +Helper implementation: home_local_navigation.go supplies newLocalLinkNavigation(sealer, sessions, origin), Begin and Complete. Begin clears the agreed __Host-docs-home-init and __Host-docs-home-proof browser pointers; old database rows remain bounded by their existing expiry and current-session/freshness binding. Complete only redirects and never calls linkstore. Tests include actual captured local email verification with session rotation, changed local account, ordinary signin without a marker, refused duplicate markers/sessions, strict encoding and expiry, wrong host/origin purpose, and local method/time boundaries. The test mail sender discards delivery after the app captures its synthetic message in memory. |
diff --git a/internal/docs/home_local_navigation.go b/internal/docs/home_local_navigation.go| new file mode 100644 |
| index 0000000..584d441 |
| --- /dev/null |
| +++ b/internal/docs/home_local_navigation.go |
| @@ -0,0 +1,155 @@ |
| +package docs |
| + |
| +import ( |
| + "bytes" |
| + "encoding/base64" |
| + "encoding/json" |
| + "math" |
| + "net/http" |
| + "net/url" |
| + "time" |
| + |
| + "amadan.net/oficina/home/handoff" |
| + "github.com/carlosframework/rastrillo" |
| + "github.com/carlosframework/rastrillo/sessions" |
| +) |
| + |
| +const localLinkNavigationCookie = "__Host-oficina-docs-local-link-navigation" |
| +const localLinkNavigationLifetime = int64(600) |
| +const localLinkNavigationLimit = 1024 |
| +const localLinkCompletePath = "/auth/local/complete" |
| + |
| +type localLinkMarker struct { |
| + Version int `json:"v"` |
| + Action string `json:"action"` |
| + Issued int64 `json:"issued"` |
| + Expires int64 `json:"expires"` |
| +} |
| + |
| +type localLinkNavigation struct { |
| + sealer handoff.Sealer |
| + sess *sessions.Sessions |
| + origin, host string |
| + now func() time.Time |
| +} |
| + |
| +// newLocalLinkNavigation takes the already validated suite origin and the same |
| +// session store used by local auth. The marker conveys navigation only. |
| +func newLocalLinkNavigation(sealer handoff.Sealer, sess *sessions.Sessions, origin string) *localLinkNavigation { |
| + u, err := url.Parse(origin) |
| + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || sealer == nil || sess == nil { |
| + return nil |
| + } |
| + return &localLinkNavigation{sealer: sealer, sess: sess, origin: origin, host: u.Host, now: time.Now} |
| +} |
| + |
| +func localNavigationCookie(w http.ResponseWriter, name, value string, age int) { |
| + http.SetCookie(w, &http.Cookie{Name: name, Value: value, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: age}) |
| +} |
| + |
| +func (n *localLinkNavigation) purpose() string { return "docs.local-link-navigation.v1:" + n.origin } |
| + |
| +func (n *localLinkNavigation) guard(w http.ResponseWriter, r *http.Request, path string) bool { |
| + w.Header().Set("Cache-Control", "no-store") |
| + if r.Method != http.MethodGet { |
| + w.Header().Set("Allow", http.MethodGet) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusMethodNotAllowed) |
| + return false |
| + } |
| + if r.Host != n.host || r.URL.Path != path || r.URL.RawQuery != "" { |
| + http.Redirect(w, r, "/", http.StatusSeeOther) |
| + return false |
| + } |
| + return true |
| +} |
| + |
| +// Begin abandons browser pointers to an old link. Rotation and freshness also |
| +// invalidate its binding; clearing cookies does not consume database rows. |
| +// A cross-site top-level GET may abandon a flow, but cannot create a link. |
| +func (n *localLinkNavigation) Begin(w http.ResponseWriter, r *http.Request) { |
| + localNavigationCookie(w, localLinkNavigationCookie, "", -1) |
| + if !n.guard(w, r, "/account/home") { |
| + return |
| + } |
| + localNavigationCookie(w, "__Host-docs-home-init", "", -1) |
| + localNavigationCookie(w, "__Host-docs-home-proof", "", -1) |
| + now := n.now().Unix() |
| + if now <= 0 || now > math.MaxInt64-localLinkNavigationLifetime { |
| + http.Redirect(w, r, "/", http.StatusSeeOther) |
| + return |
| + } |
| + payload, _ := json.Marshal(localLinkMarker{Version: 1, Action: "link-start", Issued: now, Expires: now + localLinkNavigationLifetime}) |
| + sealed, err := n.sealer.Seal(n.purpose(), payload) |
| + if err != nil { |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusServiceUnavailable) |
| + return |
| + } |
| + encoded := base64.RawURLEncoding.EncodeToString(sealed) |
| + if len(encoded) > localLinkNavigationLimit { |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusServiceUnavailable) |
| + return |
| + } |
| + localNavigationCookie(w, localLinkNavigationCookie, encoded, int(localLinkNavigationLifetime)) |
| + http.Redirect(w, r, "/signin/local", http.StatusSeeOther) |
| +} |
| + |
| +func (n *localLinkNavigation) marker(r *http.Request, now int64) bool { |
| + value := "" |
| + count := 0 |
| + for _, c := range r.Cookies() { |
| + if c.Name == localLinkNavigationCookie { |
| + count++ |
| + value = c.Value |
| + } |
| + } |
| + if count != 1 || value == "" || len(value) > localLinkNavigationLimit { |
| + return false |
| + } |
| + sealed, err := base64.RawURLEncoding.Strict().DecodeString(value) |
| + if err != nil || base64.RawURLEncoding.EncodeToString(sealed) != value { |
| + return false |
| + } |
| + payload, err := n.sealer.Open(n.purpose(), sealed) |
| + if err != nil || len(payload) > localLinkNavigationLimit { |
| + return false |
| + } |
| + var marker localLinkMarker |
| + if json.Unmarshal(payload, &marker) != nil { |
| + return false |
| + } |
| + // Only this helper writes the payload. Exact re-encoding excludes duplicate |
| + // and unknown fields, alternate numbers, and any trailing JSON value. |
| + canonical, _ := json.Marshal(marker) |
| + if !bytes.Equal(payload, canonical) { |
| + return false |
| + } |
| + return marker.Version == 1 && marker.Action == "link-start" && marker.Issued > 0 && marker.Issued <= now && now < marker.Expires && marker.Expires > marker.Issued && marker.Expires-marker.Issued <= localLinkNavigationLifetime |
| +} |
| + |
| +// Complete reads the newly presented session after local verification. It |
| +// cannot initiate or confirm linking, and never treats a Home mint as local |
| +// proof. A copied marker can at most repeat navigation to the same page. |
| +func (n *localLinkNavigation) Complete(w http.ResponseWriter, r *http.Request) { |
| + localNavigationCookie(w, localLinkNavigationCookie, "", -1) |
| + if !n.guard(w, r, localLinkCompletePath) { |
| + return |
| + } |
| + now := n.now() |
| + target := "/" |
| + if n.marker(r, now.Unix()) { |
| + count := 0 |
| + for _, c := range r.Cookies() { |
| + if c.Name == n.sess.CookieName() { |
| + count++ |
| + } |
| + } |
| + if count == 1 { |
| + if session, ok := n.sess.From(r); ok { |
| + if _, fresh := freshLocalAuthentication(session, now); fresh { |
| + target = "/account/home" |
| + } |
| + } |
| + } |
| + } |
| + http.Redirect(w, r, target, http.StatusSeeOther) |
| +} |
diff --git a/internal/docs/home_local_navigation_test.go b/internal/docs/home_local_navigation_test.go| new file mode 100644 |
| index 0000000..5b2c3e1 |
| --- /dev/null |
| +++ b/internal/docs/home_local_navigation_test.go |
| @@ -0,0 +1,321 @@ |
| +package docs |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/base64" |
| + "encoding/json" |
| + "errors" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/home/handoff/seal" |
| + rauth "github.com/carlosframework/rastrillo/auth" |
| + "github.com/carlosframework/rastrillo/sessions" |
| +) |
| + |
| +const navigationOrigin = "https://docs.example.com" |
| + |
| +type navigationMail struct{} |
| + |
| +func (navigationMail) Send(context.Context, string, string, string) error { return nil } |
| + |
| +func navigationFixture(t *testing.T) (*App, *localLinkNavigation) { |
| + t.Helper() |
| + app, err := NewForTestWith(t, Config{Origin: navigationOrigin, SampleDocuments: "0", Mailer: navigationMail{}}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sess, err := sessions.New(sessions.Config{DB: app.db.Writer(), Origin: navigationOrigin}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sealer, err := seal.New(bytes.Repeat([]byte{19}, 32), "oficina/docs") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + n := newLocalLinkNavigation(sealer, sess, navigationOrigin) |
| + if n == nil { |
| + t.Fatal("navigation config refused") |
| + } |
| + return app, n |
| +} |
| +func navigationRequest(path string) *http.Request { |
| + return httptest.NewRequest("GET", navigationOrigin+path, nil) |
| +} |
| +func navigationMarker(t *testing.T, n *localLinkNavigation) *http.Cookie { |
| + t.Helper() |
| + rec := httptest.NewRecorder() |
| + n.Begin(rec, navigationRequest("/account/home")) |
| + if rec.Code != 303 || rec.Header().Get("Location") != "/signin/local" { |
| + t.Fatal("local navigation did not begin") |
| + } |
| + var marker *http.Cookie |
| + for _, c := range rec.Result().Cookies() { |
| + if c.Name == localLinkNavigationCookie && c.MaxAge > 0 { |
| + marker = c |
| + } |
| + } |
| + if marker == nil { |
| + t.Fatal("navigation marker missing") |
| + } |
| + return marker |
| +} |
| +func navigationSession(t *testing.T, n *localLinkNavigation, s sessions.Session) *http.Cookie { |
| + t.Helper() |
| + rec := httptest.NewRecorder() |
| + if err := n.sess.SignIn(rec, navigationRequest("/"), s); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, c := range rec.Result().Cookies() { |
| + if c.Name == n.sess.CookieName() { |
| + return c |
| + } |
| + } |
| + t.Fatal("session cookie missing") |
| + return nil |
| +} |
| +func assertNavigation(t *testing.T, n *localLinkNavigation, r *http.Request, want string) { |
| + t.Helper() |
| + rec := httptest.NewRecorder() |
| + n.Complete(rec, r) |
| + if rec.Code != 303 || rec.Header().Get("Location") != want || rec.Header().Get("Cache-Control") != "no-store" { |
| + t.Fatalf("navigation status=%d target=%q wanted%q", rec.Code, rec.Header().Get("Location"), want) |
| + } |
| + cleared := false |
| + for _, c := range rec.Result().Cookies() { |
| + if c.Name == localLinkNavigationCookie && c.MaxAge < 0 { |
| + cleared = true |
| + } |
| + } |
| + if !cleared { |
| + t.Fatal("marker not cleared") |
| + } |
| +} |
| + |
| +func TestLocalNavigationBeginIsBoundedFixedAndClearsOldPointers(t *testing.T) { |
| + _, n := navigationFixture(t) |
| + now := time.Now().Truncate(time.Second) |
| + n.now = func() time.Time { return now } |
| + rec := httptest.NewRecorder() |
| + n.Begin(rec, navigationRequest("/account/home")) |
| + cleared := map[string]bool{} |
| + for _, c := range rec.Result().Cookies() { |
| + if !c.Secure || !c.HttpOnly || c.Domain != "" || c.Path != "/" || c.SameSite != http.SameSiteLaxMode { |
| + t.Fatal("cookie policy weakened") |
| + } |
| + if c.MaxAge < 0 { |
| + cleared[c.Name] = true |
| + } |
| + } |
| + if !cleared["__Host-docs-home-init"] || !cleared["__Host-docs-home-proof"] { |
| + t.Fatal("old link browser pointers retained") |
| + } |
| + marker := navigationMarker(t, n) |
| + if marker.MaxAge != 600 || len(marker.Value) > 1024 { |
| + t.Fatal("unbounded navigation") |
| + } |
| + encrypted, err := base64.RawURLEncoding.DecodeString(marker.Value) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + plain, err := n.sealer.Open(n.purpose(), encrypted) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want, _ := json.Marshal(localLinkMarker{1, "link-start", now.Unix(), now.Unix() + 600}) |
| + if !bytes.Equal(plain, want) { |
| + t.Fatal("navigation carries something beyond fixed action/time") |
| + } |
| + for _, path := range []string{"/account/home?return_to=%2Fsecret", "/account/home?next=https://evil.example", "/other"} { |
| + rec = httptest.NewRecorder() |
| + n.Begin(rec, navigationRequest(path)) |
| + if rec.Code != 303 || rec.Header().Get("Location") != "/" { |
| + t.Fatal("caller selected navigation") |
| + } |
| + for _, c := range rec.Result().Cookies() { |
| + if c.MaxAge > 0 { |
| + t.Fatal("bad path set marker") |
| + } |
| + } |
| + } |
| +} |
| + |
| +func TestLocalNavigationUsesActualFreshSessionMethod(t *testing.T) { |
| + app, n := navigationFixture(t) |
| + now := time.Now().Truncate(time.Second) |
| + n.now = func() time.Time { return now } |
| + for _, tc := range []struct { |
| + name, method string |
| + auth, created time.Time |
| + want string |
| + }{ |
| + {"magic zero", "magiclink", time.Time{}, now, "/account/home"}, |
| + {"magic old stored At", "magiclink", time.Time{}, now.Add(-301 * time.Second), "/"}, |
| + {"magic future stored At", "magiclink", time.Time{}, now.Add(time.Second), "/"}, |
| + {"magic actual boundary", "magiclink", now.Add(-300 * time.Second), now, "/account/home"}, |
| + {"magic old original", "magiclink", now.Add(-301 * time.Second), now, "/"}, |
| + {"magic future original", "magiclink", now.Add(time.Second), now, "/"}, |
| + {"Home remint", "home-assertion", time.Time{}, now, "/"}, |
| + {"Home fresh claim", "home-assertion", now, now, "/"}, |
| + {"keymail zero", "keymail", time.Time{}, now, "/"}, |
| + {"keymail fresh", "keymail", now.Add(-time.Minute), now, "/account/home"}, |
| + {"keymail stale", "keymail", now.Add(-301 * time.Second), now, "/"}, |
| + {"unknown", "password", now, now, "/"}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + cookie := navigationSession(t, n, sessions.Session{Subject: "local@example.com", Method: tc.method, AuthTime: tc.auth}) |
| + if _, err := app.db.Writer().Exec(`UPDATE sessions SET created_at=? WHERE token_hash=?`, tc.created.UTC().Format(time.RFC3339), sessions.HashToken(cookie.Value)); err != nil { |
| + t.Fatal(err) |
| + } |
| + r := navigationRequest(localLinkCompletePath) |
| + r.AddCookie(navigationMarker(t, n)) |
| + r.AddCookie(cookie) |
| + assertNavigation(t, n, r, tc.want) |
| + }) |
| + } |
| +} |
| + |
| +func TestLocalNavigationRealEmailRotationAndChangedAccount(t *testing.T) { |
| + app, n := navigationFixture(t) |
| + // This plugin differs only in its configured successful local landing. |
| + plugin, err := rauth.New(rauth.Config{DB: app.db.Writer(), Origin: navigationOrigin, InstanceKey: devInstanceKey, Mailer: app.mailRecorder, SignedInPath: localLinkCompletePath}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + first := app.TestRequest(t, "first@example.com", "GET", "/", nil) |
| + old, err := first.Cookie(n.sess.CookieName()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + marker := navigationMarker(t, n) |
| + // The newly verified account may differ: only the displayed initiation page |
| + // is reached. No old account proof or local person is carried by the marker. |
| + link, err := app.TestSignIn(t, "second@example.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + verify := httptest.NewRequest("GET", link, nil) |
| + verify.AddCookie(old) |
| + rec := httptest.NewRecorder() |
| + plugin.Verify(rec, verify) |
| + if rec.Code != 303 || rec.Header().Get("Location") != localLinkCompletePath { |
| + t.Fatal("actual plugin did not use fixed landing") |
| + } |
| + if _, ok := n.sess.From(first); ok { |
| + t.Fatal("old actual session survived rotation") |
| + } |
| + complete := navigationRequest(localLinkCompletePath) |
| + complete.AddCookie(marker) |
| + for _, c := range rec.Result().Cookies() { |
| + complete.AddCookie(c) |
| + } |
| + actual, ok := n.sess.From(complete) |
| + if !ok || actual.Subject != "second@example.com" || actual.Method != "magiclink" || !actual.AuthTime.IsZero() { |
| + t.Fatal("real email verification identity wrong") |
| + } |
| + assertNavigation(t, n, complete, "/account/home") |
| + // Ordinary local verification has no continuation marker and still lands /. |
| + ordinary := navigationRequest(localLinkCompletePath) |
| + for _, c := range rec.Result().Cookies() { |
| + ordinary.AddCookie(c) |
| + } |
| + assertNavigation(t, n, ordinary, "/") |
| + failed := httptest.NewRecorder() |
| + plugin.Verify(failed, verify) |
| + if failed.Header().Get("Location") == localLinkCompletePath { |
| + t.Fatal("failed replayed verification reached bridge") |
| + } |
| +} |
| + |
| +func TestLocalNavigationRejectsAmbiguousCookies(t *testing.T) { |
| + _, n := navigationFixture(t) |
| + marker := navigationMarker(t, n) |
| + cookie := navigationSession(t, n, sessions.Session{Subject: "local@example.com", Method: "magiclink"}) |
| + for _, cookies := range [][]*http.Cookie{nil, {marker}, {cookie}, {marker, marker, cookie}, {marker, cookie, cookie}, {marker, {Name: n.sess.CookieName(), Value: "unknown"}}, {marker, cookie, {Name: n.sess.CookieName(), Value: "unknown"}}} { |
| + r := navigationRequest(localLinkCompletePath) |
| + for _, c := range cookies { |
| + r.AddCookie(c) |
| + } |
| + assertNavigation(t, n, r, "/") |
| + } |
| +} |
| + |
| +func TestLocalNavigationRejectsInvalidMarkersAndOtherHosts(t *testing.T) { |
| + _, n := navigationFixture(t) |
| + now := time.Now().Truncate(time.Second) |
| + n.now = func() time.Time { return now } |
| + cookie := navigationSession(t, n, sessions.Session{Subject: "local@example.com", Method: "magiclink"}) |
| + valid := navigationMarker(t, n).Value |
| + plain := func(m localLinkMarker) string { b, _ := json.Marshal(m); return string(b) } |
| + base := localLinkMarker{1, "link-start", now.Unix(), now.Unix() + 600} |
| + badJSON := []string{ |
| + "{}", `{"v":1,"v":1,"action":"link-start","issued":1,"expires":2}`, |
| + plain(base) + " {}", strings.TrimSuffix(plain(base), "}") + `,"unknown":true}`, |
| + } |
| + for _, m := range []localLinkMarker{{2, "link-start", now.Unix(), now.Unix() + 600}, {1, "other", now.Unix(), now.Unix() + 600}, {1, "link-start", 0, now.Unix() + 600}, {1, "link-start", now.Unix() + 1, now.Unix() + 600}, {1, "link-start", now.Unix() - 600, now.Unix()}, {1, "link-start", now.Unix(), now.Unix() + 601}} { |
| + badJSON = append(badJSON, plain(m)) |
| + } |
| + badValues := []string{"", strings.Repeat("a", 1025), valid + "=", "%" + valid, valid[:len(valid)-2]} |
| + for _, p := range badJSON { |
| + sealed, err := n.sealer.Seal(n.purpose(), []byte(p)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + badValues = append(badValues, base64.RawURLEncoding.EncodeToString(sealed)) |
| + } |
| + otherPurpose, err := n.sealer.Seal("docs.local-link-navigation.v1:https://other.example", []byte(plain(base))) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + badValues = append(badValues, base64.RawURLEncoding.EncodeToString(otherPurpose)) |
| + for _, value := range badValues { |
| + r := navigationRequest(localLinkCompletePath) |
| + r.AddCookie(cookie) |
| + r.AddCookie(&http.Cookie{Name: localLinkNavigationCookie, Value: value}) |
| + assertNavigation(t, n, r, "/") |
| + } |
| + for _, change := range []func(*http.Request){func(r *http.Request) { r.Host = "other.example" }, func(r *http.Request) { r.URL.RawQuery = "return_to=https://evil.example" }, func(r *http.Request) { r.URL.Path = "/wrong" }} { |
| + r := navigationRequest(localLinkCompletePath) |
| + r.AddCookie(cookie) |
| + r.AddCookie(&http.Cookie{Name: localLinkNavigationCookie, Value: valid}) |
| + change(r) |
| + assertNavigation(t, n, r, "/") |
| + } |
| + // A valid marker expires independently of the browser cookie's MaxAge. |
| + n.now = func() time.Time { return now.Add(600 * time.Second) } |
| + r := navigationRequest(localLinkCompletePath) |
| + r.AddCookie(cookie) |
| + r.AddCookie(&http.Cookie{Name: localLinkNavigationCookie, Value: valid}) |
| + assertNavigation(t, n, r, "/") |
| +} |
| + |
| +type navigationBrokenSealer struct{} |
| + |
| +func (navigationBrokenSealer) Seal(string, []byte) ([]byte, error) { |
| + return nil, errors.New("private failure") |
| +} |
| +func (navigationBrokenSealer) Open(string, []byte) ([]byte, error) { |
| + return nil, errors.New("private failure") |
| +} |
| +func TestLocalNavigationStorageFailureAndMethod(t *testing.T) { |
| + _, n := navigationFixture(t) |
| + n.sealer = navigationBrokenSealer{} |
| + rec := httptest.NewRecorder() |
| + n.Begin(rec, navigationRequest("/account/home")) |
| + if rec.Code != 503 || strings.Contains(rec.Body.String(), "private") || rec.Header().Get("Location") != "" { |
| + t.Fatal("storage failure published/leaked navigation") |
| + } |
| + for _, fn := range []http.HandlerFunc{n.Begin, n.Complete} { |
| + rec = httptest.NewRecorder() |
| + r := navigationRequest(localLinkCompletePath) |
| + r.Method = "POST" |
| + fn(rec, r) |
| + if rec.Code != 405 || rec.Header().Get("Allow") != "GET" || rec.Header().Get("Cache-Control") != "no-store" { |
| + t.Fatal("wrong method accepted") |
| + } |
| + } |
| +} |