oficina / slides Public

Keep local sign-in retries on their return path

Paul Campbell pushed by paul@keymail.dev 5e0bb775f1678518d26362d4a2dc16b3cd389df2 parent 3300471
5 files changed, +215 −18
  • internal/slides/app.go +18 −11
  • internal/slides/auth.go +80 −7
  • internal/slides/auth_test.go +49 −0
  • internal/slides/handlers.go +7 −0
  • internal/slides/home_test.go +61 −0

CI passed — run details

CI log
=== gate ===
=== rust: fmt
=== rust: clippy
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
=== wasm: the artifact the host embeds
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.03s
    Finished `release` profile [optimized] target(s) in 0.08s
core/target/wasm32-unknown-unknown/release/slides_core.wasm (1228229 bytes)
=== the embedded wasm must match a fresh build
=== rust: test
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.03s
     Running unittests src/lib.rs (core/target/debug/deps/slides_core-929bfceb2edd4a94)

running 30 tests
test check::tests::the_empty_document_passes ... ok
test layout::tests::aside_region_rect ... ok
test layout::tests::body_region_rect_narrows_for_an_aside ... ok
test layout::tests::default_region_by_kind ... ok
test layout::tests::heading_region_rect ... ok
test layout::tests::media_region_rect ... ok
test layout::tests::to_canvas_narrows_body_when_an_aside_is_present ... ok
test markdown::tests::empty_input_is_no_paragraphs_and_lines_keep_blanks ... ok
test check::tests::bare_text_at_fragment_level_is_reported ... ok
test check::tests::a_non_paragraph_child_is_reported ... ok
test check::tests::what_the_ops_write_passes ... ok
test layout::tests::to_flow_is_a_no_op_when_already_in_order ... ok
test check::tests::y_prosemirror_shapes_pass ... ok
test markdown::tests::paragraphs_split_on_blank_lines_and_list_markers ... ok
test layout::tests::to_canvas_splits_a_shared_region_equally_and_sets_z_by_index ... ok
test layout::tests::to_flow_classifies_by_position_and_kind ... ok
test schema::tests::an_empty_map_reads_as_a_set_mark ... ok
test markdown::tests::marks_nest_and_links_carry_marks ... ok
test check::tests::an_element_inside_a_paragraph_is_reported ... ok
test schema::tests::href_is_written_as_a_one_key_map_and_reads_back ... ok
test markdown::tests::inline_marks ... ok
test schema::tests::href_written_as_a_plain_string_still_reads_back ... ok
test layout::tests::to_flow_orders_y_then_x ... ok
test check::tests::an_unknown_formatting_attribute_is_reported ... ok
test schema::tests::an_href_map_without_a_string_url_reads_as_no_link ... ok
test view::tests::malformed_box_is_null_and_phone_keeps_only_typed_fields ... ok
test view::tests::assets_are_typed_or_skipped ... ok
test view::tests::malformed_fonts_are_null ... ok
test view::tests::malformed_background_is_the_default ... ok
test layout::tests::to_flow_keeps_a_concurrent_text_edit ... ok

test result: ok. 30 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/no_host_imports.rs (core/target/debug/deps/no_host_imports-184c0d8bf00e14f9)

running 1 test
test no_host_imports ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/ops.rs (core/target/debug/deps/ops-256addce27f366d1)

running 12 tests
test add_slide_then_view_lists_it ... ok
test ops_are_atomic ... ok
test add_block_with_markdown_produces_runs ... ok
test layout_switch_assigns_boxes ... ok
test remove_block_drops_it_from_columns_children ... ok
test columns_children_must_name_blocks_on_the_slide ... ok
test duplicate_slide_remaps_columns_children ... ok
test columns_children_have_one_parent_and_no_cycles ... ok
test move_block_keeps_a_concurrent_text_edit ... ok
test update_roundtrip ... ok
test move_slide_keeps_a_concurrent_text_edit ... ok
test remaining_ops_roundtrip ... ok

test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

   Doc-tests slides_core

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

=== rust: fmt (pdf)
=== rust: clippy (pdf)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
=== wasm: the pdf artifact the host embeds
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.03s
    Finished `release` profile [optimized] target(s) in 0.07s
core/pdf/target/wasm32-unknown-unknown/release/slides_pdf.wasm (5301002 bytes)
=== the embedded pdf wasm must match a fresh build
=== rust: test (pdf)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.03s
     Running unittests src/lib.rs (core/pdf/target/debug/deps/slides_pdf-891fafaa44f9fd64)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/no_host_imports.rs (core/pdf/target/debug/deps/no_host_imports-17ccbb1d62120093)

running 1 test
test no_host_imports ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

     Running tests/render.rs (core/pdf/target/debug/deps/render-2e1d7e8b19d78595)

running 6 tests
test a_null_zero_length_pair_is_no_frames_not_a_dereference ... ok
test text_with_no_fonts_at_all_still_renders_the_shapes ... ok
test raster_image_is_embedded ... ok
test abi_frames_round_trip ... ok
test renders_text_as_embedded_glyphs ... ok
test two_svgs_make_two_pages ... ok

test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

   Doc-tests slides_pdf

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

=== fixtures: node dependencies
v24.19.0
11.17.0
=== fixtures: npm ci (needs the registry — see .amadan/README.md)

added 19 packages, and audited 20 packages in 519ms

5 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
npm warn allow-scripts 1 package has install scripts not yet covered by allowScripts:
npm warn allow-scripts   esbuild@0.28.2 (postinstall: node install.js)
npm warn allow-scripts
npm warn allow-scripts Run `npm approve-scripts --allow-scripts-pending` to review, or `npm approve-scripts <pkg>` to allow.
=== fixtures: the JS side's own tests
✔ one finished upload is register_asset plus the block that uses it, in one batch (10.70926ms)
✔ a record missing its numbers still makes an op the core accepts (1.402386ms)
✔ unusedAssets is what no slide refers to, and remove_asset drops one (2.924795ms)
✔ the Unused images list reconciles by id and names each Remove button (1.026818ms)
✔ an upload refusal is said in words the person can act on (0.202787ms)
✔ clampBox keeps the box inside the frame and no smaller than the minimum (1.403819ms)
✔ fixBox writes four decimals (0.238594ms)
✔ guides are the thirds plus every other block edge (0.26858ms)
✔ snapMove pulls the nearest edge onto a guide within one per cent, and leaves a far one (0.428036ms)
✔ snapResize moves only the edges the handle drags (0.256336ms)
✔ moveBox and resizeBox by handle (0.29091ms)
✔ nudge: arrows move by half a per cent, Shift by two, Alt resizes (0.266435ms)
✔ rectBox reads a block rectangle against its frame (0.205111ms)
✔ typing into a block is a text change on its slide only (11.232764ms)
✔ a block's props changing is structure on its slide (3.026093ms)
✔ adding, moving or removing a block is structure on its slide (3.273424ms)
✔ the notes are text on their slide (4.483001ms)
✔ a slide added, removed or moved is an order change (1.701562ms)
✔ one transaction touching several things reports each once (1.045313ms)
✔ what the ops write passes, and so does an empty document (9.025552ms)
✔ the shapes y-prosemirror and older documents write pass (2.129558ms)
✔ an unknown formatting attribute is reported with the core's message (2.314952ms)
✔ an element inside a paragraph, a non-paragraph child and bare text are reported (3.402302ms)
✔ the unknownmark poison shape is what the check refuses (1.619049ms)
✔ y-prosemirror 1.3.7 deletes the words under an unknown mark; sanitising first keeps them (5.622199ms)
✔ the vocabulary is the five marks and nothing else (0.229326ms)
✔ a reload reuses the id this tab stored for the deck (1.206181ms)
✔ two tabs, and two decks in one tab, get different ids (1.723433ms)
✔ a stored value that is not an id, or storage that throws, gives a fresh id (0.862553ms)
✔ a duplicated tab gives up the copied id; the original keeps it (7.830571ms)
✔ a reload finds no rival and keeps its id (2.979357ms)
✔ presence switches id: beats carry the new one and the old one is somebody else (2.150066ms)
✔ an empty body is no frames (1.604722ms)
✔ frames come back in order with their exact bytes (0.388131ms)
✔ a length prefix is big-endian (0.19944ms)
✔ a truncated frame throws rather than returning a short update (1.918425ms)
✔ deck controls are set_meta (13.35099ms)
✔ slide controls are set_slide, and background keeps its colour (3.87387ms)
✔ move up and down are move_slide with the right after (2.063705ms)
✔ block props: value kinds, region, a shape label (2.396253ms)
✔ box fields are set_block on the desktop and set_phone in the phone view (5.577436ms)
✔ move block and the layer buttons (3.644202ms)
✔ add block: region after the selection on flow, a default box on canvas, a heading level that fits (10.526271ms)
✔ a block is named by its type and its first words (1.305245ms)
✔ blockChange: a control element to its op (3.354293ms)
✔ the Blocks list reconciles by id and keeps its buttons (1.384343ms)
✔ open resolves to the button that closed it and the trimmed field (3.852469ms)
✔ a second open while the dialog is up joins the first (0.272988ms)
✔ a missing dialog mounts as nothing (0.157663ms)
✔ a tab advances its own cursor only when its POST is the very next row (2.192726ms)
✔ the cursor never moves backwards when a response is overtaken by this tab's own write (0.510288ms)
✔ a later response still carries the cursor forward (0.530887ms)
✔ a burst of notifications collapses into one fetch at a time (0.278518ms)
✔ an event naming a seq the tab already has fetches nothing (0.209208ms)
✔ a stopped subscriber drops a response that arrives late (0.281032ms)
✔ stop closes the stream, and start opens exactly one (0.234646ms)
✔ a hidden tab closes its stream on a shortened deadline, and activity reopens it (0.945016ms)
✔ switching away and back inside the grace never drops the stream, so nothing has to be caught up (0.244985ms)
✔ a visible tab that is only being READ closes when idle and comes back on any activity (0.307983ms)
✔ activity while streaming defers the idle close rather than reopening anything (0.195022ms)
✔ a stream failure is never reported as a save failure, and heals on activity (0.251146ms)
✔ an empty response advances the cursor without applying anything (0.217625ms)
✔ a failed fetch leaves the cursor alone so the next notification retries it (0.261857ms)
✔ a decode failure retries once through the raw log, then gives up (0.273248ms)
✔ updatesURL spells the query string exactly, in particular raw as the literal raw=1 (0.148566ms)
✔ empty_doc_views_as_defaults (4.60576ms)
✔ set_meta (3.699875ms)
✔ slides: add, move, remove, set_slide, set_notes (7.224225ms)
✔ duplicate_slide (8.056551ms)
✔ blocks: add, move, remove, set_block (2.696641ms)
✔ set_phone (1.030515ms)
✔ table cells follow rows and cols (1.169173ms)
✔ canvas blocks: box defaults from the region, z from the index (0.833018ms)
✔ assets: register and remove (0.795108ms)
✔ markdown: every mark, code blocks are plain lines (4.619174ms)
✔ batch_is_atomic (2.321564ms)
✔ a batch applies in one transaction with the given origin (0.572915ms)
✔ columns_children_validated (1.305106ms)
✔ layout_switch_round_trip (1.516127ms)
✔ view_matches_snapshot_fixtures (12.751817ms)
✔ columns children have one parent and no cycles (codex 7) (1.349107ms)
✔ canvas-to-flow keeps a concurrent text edit (codex 4) (3.715645ms)
✔ move_block keeps a concurrent text edit, and later inserts land where after says (codex 4) (3.006677ms)
✔ move_slide keeps a concurrent text edit, and later inserts land where after says (codex 4 at slide level) (2.460323ms)
✔ format: {name} and {n} interpolate; an unknown key is left as text (0.82838ms)
✔ a beat on connect, then one every twenty seconds (2.013272ms)
✔ a move posts once, 200ms after the last of a burst, and re-arms the beat (1.130221ms)
✔ a hidden tab stops beating; coming back posts at once (0.408128ms)
✔ applySet leaves this tab out and answers what changed (0.598152ms)
✔ a set with rubbish in it is read for what it does carry (0.221913ms)
✔ initials, and a client id per tab (3.004332ms)
✔ a resync that fails is retried with backoff until it succeeds, without ever running twice at once (2.216178ms)
✔ the browser's online event retries immediately, not just on the next backoff tick (0.370869ms)
✔ reportDivergence before any failure runs the first attempt without a caller ever calling online (1.52263ms)
✔ a permanent 4xx drops its batch instead of retrying it, and does not poison the next edit (83.159699ms)
✔ a network failure or a 5xx is retried, not dropped (60.934918ms)
✔ a 500 is retried the same way a network failure is, never dropped (60.368024ms)
✔ the core-written structure reads as ProseMirror marks (4.64976ms)
✔ a y-prosemirror round trip preserves bold and the link (2.476222ms)
✔ y-prosemirror writes href as the one-key map and marks as {} (0.804996ms)
✔ a plain-string href, written before the map shape, still reads (0.567164ms)
✔ textFragment writes the same shape as writeText (0.490161ms)
✔ safeHref is the server allowlist, and a disallowed href never draws an anchor (0.426222ms)
✔ parseHash splits the slide and the block, and survives a stray % (1.548288ms)
✔ destroy takes the listeners off, and a second mount leaves one of each (0.654396ms)
✔ formatHash is the inverse (1.324521ms)
✔ textBlocks finds the text-bearing blocks and skips the rest (1.319452ms)
✔ mountTarget is the element whose children are the paragraphs (0.180335ms)
✔ paragraphTag keeps the renderer's own markup per type (0.121866ms)
✔ blockFragment finds a block's text by slide and block id (5.335194ms)
✔ notesFragment finds the slide's notes (0.686075ms)
✔ blockIDs and slideIDs are the orders on the stage and the strip (0.678712ms)
✔ cellFragments are a table's cells, row-major (0.993496ms)
✔ a load that fails leaves the stage on the slide it was showing and says so (1.153815ms)
✔ slideAfter is the slide to show once one is gone (0.157452ms)
✔ the queue words map to savestatus states, failed staying failed (1.987142ms)
✔ a dropped batch outranks the queue, for the rest of the session (0.457711ms)
✔ the editor status: the save line and the load error are two lines (0.303554ms)
✔ mountSaveStatus loads estilo's module and mounts it on the line (1.157471ms)
✔ a save-status module that will not load says so and refuses to mount (0.508725ms)
✔ dropTarget: before puts it ahead of the item, after behind it, null means first (1.061603ms)
✔ a drop that leaves the order as it is is no move (0.197206ms)
✔ dropOp is the move_slide (0.756165ms)
✔ labelFor is the slide's first heading, else the numbered fallback (4.188695ms)
✔ flush re-points every dirty thumbnail and relabels it (1.421732ms)
✔ destroy takes the item-menu listener off; a second mount leaves one (0.235157ms)
✔ plan says what to add, drop and where each item goes (0.622967ms)
✔ sync moves only the items out of place and keeps focus (0.948492ms)
✔ desktop view: box and size are the block; hidden and order are always the phone map (1.449333ms)
✔ phone view: everything goes to set_phone, carrying the rest of the map (0.32318ms)
✔ the ops it makes are ones the document takes (7.601346ms)
✔ phoneMap reads only the four keys, and only well-typed (0.188671ms)
✔ the stored view is read defensively (0.29564ms)
✔ linkAt reads the link under the cursor and nothing elsewhere (2.003403ms)
✔ setLink marks the selection (1.758438ms)
✔ setLink on a collapsed cursor inside a link replaces that link (0.490171ms)
✔ setLink with nothing selected and no link does nothing (0.197858ms)
✔ removeLink clears the link under the cursor (0.334161ms)
✔ the keymap binds every documented shortcut (0.205041ms)
✔ Mod-b toggles bold through the keymap (0.530926ms)
✔ setLink refuses an address outside the allowlist and writes nothing (0.455015ms)
✔ the state machine runs choose → sign → put → register → op (4.539384ms)
✔ a file the deck will not take never reaches the network (0.951899ms)
✔ alt text is required before the bytes move, unless the image is decorative (0.434007ms)
✔ a step that fails stops the ones after it and reports its own code (0.898941ms)
✔ a failure that is not ours still becomes an UploadError (0.521199ms)
✔ a second pick while one is in flight is refused, not raced (0.468691ms)
✔ an unreadable size is zero rather than a failure (1.578904ms)
✔ a per-call applyOp wins, so an upload lands on the block it was chosen for (0.279429ms)
✔ checkFile takes exactly the four types (0.487276ms)
✔ sha256Hex is the hex digest of the bytes (8.720997ms)
✔ refusal reads this app's JSON error shape, and survives one that is not (0.286522ms)
✔ malformed fonts are null (2.766731ms)
✔ a malformed background is the theme default (1.279649ms)
✔ a malformed box is null (0.64058ms)
✔ the phone layer keeps only its typed fields, order as a whole number (1.833658ms)
✔ assets are typed or skipped (0.342306ms)
✔ every poison update is deterministic bytes that apply to any deck (6.276144ms)
ℹ tests 158
ℹ suites 0
ℹ pass 158
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 290.328016
=== the vectors must match a fresh generation
canvas: 22 ops, 3715 bytes
columns: 23 ops, 3134 bytes
every-block: 36 ops, 3180 bytes
table: 14 ops, 1509 bytes
title-only: 6 ops, 1136 bytes
=== the editor bundle must match a fresh build
wrote internal/slides/static/editor.0463180baf5aab9f.js (376416 bytes)
=== go: vet, gofmt, test, migration check
go vet ./...
go test ./...
?   	amadan.net/oficina/slides/cmd/slides	[no test files]
ok  	amadan.net/oficina/slides/internal/slides	11.658s
ok  	amadan.net/oficina/slides/internal/slides/core	2.612s
ok  	amadan.net/oficina/slides/internal/slides/deck	(cached)
ok  	amadan.net/oficina/slides/internal/slides/fonts	(cached)
?   	amadan.net/oficina/slides/internal/slides/icons	[no test files]
ok  	amadan.net/oficina/slides/internal/slides/layout	(cached)
ok  	amadan.net/oficina/slides/internal/slides/live	(cached)
ok  	amadan.net/oficina/slides/internal/slides/pdfcore	(cached)
ok  	amadan.net/oficina/slides/internal/slides/present	(cached)
ok  	amadan.net/oficina/slides/internal/slides/render	(cached)
ok  	amadan.net/oficina/slides/internal/slides/svg	(cached)
ok  	amadan.net/oficina/slides/internal/slidestest	39.827s
go run amadan.net/rastrillo/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
python3 scripts/credits.py --check
credits: checked
gate: ok
diff --git a/internal/slides/app.go b/internal/slides/app.go
index ae643e1..5fda220 100644
--- a/internal/slides/app.go
+++ b/internal/slides/app.go
@@ -3,6 +3,7 @@ package slides
import (
"context"
"net/http"
+ "time"
"amadan.net/oficina/estilo.git/brand"
"amadan.net/oficina/estilo.git/chrome"
@@ -299,6 +300,10 @@ func newApp(d *db.DB, cfg Config) (*app, *rauth.Auth, error) {
if err != nil {
return nil, nil, err
}
+ returnSeal, err := newReturnSealer(cfg.InstanceKey)
+ if err != nil {
+ return nil, nil, err
+ }
// One Runtime for the process: it owns the compiled wasm module,
// which is expensive to build and cheap to instantiate, and every
@@ -313,17 +318,19 @@ func newApp(d *db.DB, cfg Config) (*app, *rauth.Auth, error) {
}
a := &app{
- db: d,
- logger: logger,
- cfg: cfg,
- allowed: list,
- core: rt,
- auth: authPlugin,
- hub: present.New(),
- live: live.New(),
- jobs: jobs.New(logger),
- exports: newExportStore(),
- pdf: &pdfRuntime{},
+ db: d,
+ logger: logger,
+ cfg: cfg,
+ allowed: list,
+ returnSeal: returnSeal,
+ returnNow: time.Now,
+ core: rt,
+ auth: authPlugin,
+ hub: present.New(),
+ live: live.New(),
+ jobs: jobs.New(logger),
+ exports: newExportStore(),
+ pdf: &pdfRuntime{},
}
// The optional session read behind every page outside the signed-in
diff --git a/internal/slides/auth.go b/internal/slides/auth.go
index 158170a..46cada1 100644
--- a/internal/slides/auth.go
+++ b/internal/slides/auth.go
@@ -18,11 +18,16 @@
package slides
import (
+ "bytes"
"context"
+ "crypto/sha256"
"database/sql"
+ "encoding/base64"
+ "encoding/json"
"errors"
"fmt"
"log/slog"
+ "math"
"net/http"
"net/url"
"strings"
@@ -33,6 +38,7 @@ import (
"amadan.net/oficina/home/connect"
"amadan.net/oficina/home/connect/linkstore"
"amadan.net/oficina/home/handoff"
+ "amadan.net/oficina/home/handoff/seal"
"amadan.net/oficina/slides/internal/slides/present"
"amadan.net/rastrillo/rastrillo"
rauth "amadan.net/rastrillo/rastrillo/auth"
@@ -563,7 +569,7 @@ type signinView struct {
func (a *app) signin(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
ret, hasReturn := safeReturn(q)
- if a.home == nil {
+ if a.home == nil || r.URL.Path == "/signin/local" {
if s, ok := a.sessions.From(r); ok {
_, outcome := a.currentUser(w, sessions.WithSession(r, s))
switch outcome {
@@ -595,6 +601,9 @@ func (a *app) signin(w http.ResponseWriter, r *http.Request) {
// return (spec §9.2): the remembered page, not a bare /signin,
// which would forget it (Astra on Docs #65, finding 2).
again := signinPath
+ if a.home != nil {
+ again = "/signin/local"
+ }
if remembered, ok := a.rememberedReturn(r); ok {
again += "?return_to=" + url.QueryEscape(remembered)
}
@@ -712,12 +721,48 @@ func (a *app) secure() bool { return strings.HasPrefix(a.cfg.Origin, "https://")
// returnCookieAge is how long a remembered page lasts: a link opened
// after ten minutes, or in another browser, lands on the deck list.
-const returnCookieAge = 600
+const returnCookieAge int64 = 600
+const returnCookieLimit = 4096
+
+type returnIntent struct {
+ Path string `json:"path"`
+ Issued int64 `json:"issued"`
+ Expires int64 `json:"expires"`
+}
+
+// newReturnSealer keeps the cookie independent from any other encrypted
+// value. The raw instance key need not be exactly 32 bytes; its domain-bound
+// hash is. A changed key invalidates old return cookies, which is safe.
+func newReturnSealer(instanceKey string) (*seal.Sealer, error) {
+ key := sha256.Sum256([]byte("oficina/slides return v1\x00" + instanceKey))
+ return seal.New(key[:], "oficina/slides")
+}
+
+func (a *app) returnPurpose() string { return "slides.return.v1:" + a.cfg.Origin }
func (a *app) rememberReturn(w http.ResponseWriter, path string) {
+ if !handoff.SafePath(path) || a.returnSeal == nil || a.returnNow == nil {
+ return
+ }
+ now := a.returnNow().Unix()
+ if now <= 0 || now > math.MaxInt64-returnCookieAge {
+ return
+ }
+ raw, err := json.Marshal(returnIntent{Path: path, Issued: now, Expires: now + returnCookieAge})
+ if err != nil {
+ return
+ }
+ sealed, err := a.returnSeal.Seal(a.returnPurpose(), raw)
+ if err != nil {
+ return
+ }
+ value := base64.RawURLEncoding.EncodeToString(sealed)
+ if len(value) > returnCookieLimit {
+ return
+ }
http.SetCookie(w, &http.Cookie{
- Name: a.returnCookieName(), Value: url.QueryEscape(path), Path: "/",
- MaxAge: returnCookieAge, HttpOnly: true, Secure: a.secure(),
+ Name: a.returnCookieName(), Value: value, Path: "/",
+ MaxAge: int(returnCookieAge), HttpOnly: true, Secure: a.secure(),
SameSite: http.SameSiteLaxMode,
})
}
@@ -735,15 +780,43 @@ func (a *app) forgetReturn(w http.ResponseWriter) {
// a same-origin path (handoff.SafePath, which also refuses /signin and
// /auth/*, so this can never loop).
func (a *app) rememberedReturn(r *http.Request) (string, bool) {
+ if a.returnSeal == nil || a.returnNow == nil {
+ return "", false
+ }
+ count := 0
+ for _, c := range r.Cookies() {
+ if c.Name == a.returnCookieName() {
+ count++
+ }
+ }
+ if count != 1 {
+ return "", false
+ }
c, err := r.Cookie(a.returnCookieName())
if err != nil {
return "", false
}
- path, err := url.QueryUnescape(c.Value)
- if err != nil || !handoff.SafePath(path) {
+ if c.Value == "" || len(c.Value) > returnCookieLimit {
+ return "", false
+ }
+ sealed, err := base64.RawURLEncoding.Strict().DecodeString(c.Value)
+ if err != nil || base64.RawURLEncoding.EncodeToString(sealed) != c.Value {
+ return "", false
+ }
+ raw, err := a.returnSeal.Open(a.returnPurpose(), sealed)
+ if err != nil || len(raw) > returnCookieLimit {
+ return "", false
+ }
+ var intent returnIntent
+ if json.Unmarshal(raw, &intent) != nil {
+ return "", false
+ }
+ canonical, _ := json.Marshal(intent)
+ now := a.returnNow().Unix()
+ if !bytes.Equal(raw, canonical) || intent.Issued <= 0 || intent.Issued > now || intent.Expires <= now || intent.Expires <= intent.Issued || intent.Expires-intent.Issued > returnCookieAge || !handoff.SafePath(intent.Path) {
return "", false
}
- return path, true
+ return intent.Path, true
}
// safeReturn is the request's return_to, when there is exactly one and
diff --git a/internal/slides/auth_test.go b/internal/slides/auth_test.go
index 3a6a14d..3c6cb55 100644
--- a/internal/slides/auth_test.go
+++ b/internal/slides/auth_test.go
@@ -1,12 +1,61 @@
package slides
import (
+ "bytes"
"context"
+ "encoding/base64"
+ "net/http"
+ "net/http/httptest"
"testing"
+ "time"
"gorm.io/gorm"
)
+// TestRememberedReturnRejectsTamperAndExpiry is the boundary before a return
+// path can become a redirect. A request cookie is attacker input: neither a
+// replacement path nor an expired sealed intent is allowed to select where a
+// completed sign-in goes.
+func TestRememberedReturnRejectsTamperAndExpiry(t *testing.T) {
+ a, _, err := newApp(testDB(t), Config{Env: EnvDevelopment, Origin: "https://slides.example.test", Logger: quietLogger()})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { a.Close() })
+ now := time.Unix(1900000000, 0)
+ a.returnNow = func() time.Time { return now }
+ w := httptest.NewRecorder()
+ a.rememberReturn(w, "/d/private?view=present")
+ cookies := w.Result().Cookies()
+ if len(cookies) != 1 {
+ t.Fatalf("remembered %d cookies, want one", len(cookies))
+ }
+ cookie := cookies[0]
+ sealed, err := base64.RawURLEncoding.DecodeString(cookie.Value)
+ if err != nil || bytes.Contains(sealed, []byte("private")) {
+ t.Fatal("return path was not sealed")
+ }
+ valid := httptest.NewRequest(http.MethodGet, "https://slides.example.test/auth/landed", nil)
+ valid.AddCookie(cookie)
+ if got, ok := a.rememberedReturn(valid); !ok || got != "/d/private?view=present" {
+ t.Fatalf("sealed return = %q, %v", got, ok)
+ }
+
+ sealed[len(sealed)-1] ^= 1
+ tampered := *cookie
+ tampered.Value = base64.RawURLEncoding.EncodeToString(sealed)
+ r := httptest.NewRequest(http.MethodGet, "https://slides.example.test/auth/landed", nil)
+ r.AddCookie(&tampered)
+ if got, ok := a.rememberedReturn(r); ok || got != "" {
+ t.Fatalf("tampered return cookie was accepted as %q", got)
+ }
+
+ now = now.Add(time.Duration(returnCookieAge) * time.Second)
+ if got, ok := a.rememberedReturn(valid); ok || got != "" {
+ t.Fatalf("expired return cookie was accepted as %q", got)
+ }
+}
+
// countCreates registers a GORM callback that counts INSERT statements
// on the handle, returning a function that reads the count. It is the
// only honest way to answer "did that request write?": a row's id and
diff --git a/internal/slides/handlers.go b/internal/slides/handlers.go
index aeab57b..83fa42d 100644
--- a/internal/slides/handlers.go
+++ b/internal/slides/handlers.go
@@ -3,8 +3,10 @@ package slides
import (
"context"
"log/slog"
+ "time"
"amadan.net/oficina/home/connect"
+ "amadan.net/oficina/home/handoff/seal"
"amadan.net/oficina/slides/internal/slides/core"
"amadan.net/oficina/slides/internal/slides/live"
"amadan.net/oficina/slides/internal/slides/present"
@@ -40,6 +42,11 @@ type app struct {
logger *slog.Logger
cfg Config
allowed allowlist
+ // returnSeal authenticates the short-lived return_to cookie. It is
+ // derived from this instance's sign-in key, never from a browser
+ // value, and the clock is a seam for expiry tests.
+ returnSeal *seal.Sealer
+ returnNow func() time.Time
// core is the compiled deck core, shared by every request.
// Compiling the wasm module is expensive and instantiating it is
// not, so there is exactly one of these per process and a Handle
diff --git a/internal/slides/home_test.go b/internal/slides/home_test.go
index 7db78d4..79b323d 100644
--- a/internal/slides/home_test.go
+++ b/internal/slides/home_test.go
@@ -215,6 +215,67 @@ func TestHomeFrontDoorLocalRecoveryAndCallbackGuard(t *testing.T) {
t.Fatal("Home POST continuation omitted from CSP")
}
}
+
+// TestHomeDifferentAddressStaysInLocalRecovery keeps the recovery route
+// separate from Home's front door. The sent page is reached at /signin, but
+// its retry must return to /signin/local with the same safe destination so a
+// second submission still completes the local recovery.
+func TestHomeDifferentAddressStaysInLocalRecovery(t *testing.T) {
+ mux, _, u := homeFixture(t)
+ jar := map[string]*http.Cookie{}
+ do := func(method, path string, form url.Values) *httptest.ResponseRecorder {
+ r := httptest.NewRequest(method, homeTestOrigin+path, strings.NewReader(form.Encode()))
+ r.Header.Set("Accept", "text/html")
+ if method == http.MethodPost {
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ r.Header.Set("Origin", homeTestOrigin)
+ r.Header.Set("Sec-Fetch-Site", "same-origin")
+ }
+ for _, c := range jar {
+ r.AddCookie(c)
+ }
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, r)
+ for _, c := range w.Result().Cookies() {
+ if c.MaxAge < 0 {
+ delete(jar, c.Name)
+ } else {
+ jar[c.Name] = c
+ }
+ }
+ return w
+ }
+
+ const deep = "/d/keep-going?from=signin"
+ if first := do(http.MethodGet, "/signin/local?return_to="+url.QueryEscape(deep), nil); first.Code != http.StatusOK {
+ t.Fatalf("local form: %d", first.Code)
+ }
+ sent := do(http.MethodPost, signinPath, url.Values{"address": {u.Email}})
+ if sent.Code != http.StatusSeeOther || sent.Header().Get("Location") != "/signin?sent=1" {
+ t.Fatalf("first submission: %d %q", sent.Code, sent.Header().Get("Location"))
+ }
+ sentPage := do(http.MethodGet, sent.Header().Get("Location"), nil)
+ if sentPage.Code != http.StatusOK {
+ t.Fatalf("sent page: %d", sentPage.Code)
+ }
+ m := regexp.MustCompile(`href="([^"]+)">slides\.signin\.again`).FindStringSubmatch(sentPage.Body.String())
+ if m == nil {
+ t.Fatal("sent page has no Use a different address link")
+ }
+ again := strings.ReplaceAll(m[1], "&amp;", "&")
+ wantAgain := "/signin/local?return_to=" + url.QueryEscape(deep)
+ if again != wantAgain {
+ t.Fatalf("retry link = %q, want %q", again, wantAgain)
+ }
+ retry := do(http.MethodGet, again, nil)
+ if retry.Code != http.StatusOK || !strings.Contains(retry.Body.String(), `name="return_to" value="`+deep+`"`) {
+ t.Fatalf("retry form lost return_to: %d\n%s", retry.Code, retry.Body.String())
+ }
+ second := do(http.MethodPost, signinPath, url.Values{"address": {u.Email}, "return_to": {deep}})
+ if second.Code != http.StatusSeeOther || second.Header().Get("Location") != "/signin?sent=1" {
+ t.Fatalf("second submission did not retain the local flow: %d %q", second.Code, second.Header().Get("Location"))
+ }
+}
func TestHomeInvalidConfigurationDoesNotMigrate(t *testing.T) {
d := testDB(t)
_, _, err := New(d, Config{Env: EnvDevelopment, Origin: homeTestOrigin, Home: &connect.Credentials{Issuer: "https://home.example.com"}})