oficina / slides Public

Sign in with Keymail in Slides, without an allowlist oracle

Slides forced every sign-in to an emailed link (force=1 on the form and
again in gateBegin), so a Keymail address never reached Keymail. The
reason was an oracle: a permitted Keymail address would have redirected
out while a non-permitted one got ?sent=1.

Now the ceremony depends on the address's domain alone. Every address
reaches rauth's Begin; a Keymail address, permitted or not, gets the
same continuation page to Keymail (connect.OAuthContinuation, in both
standalone and Home-local mode, so Safari never applies form-action to
a redirect out of the form POST). Any other address gets ?sent=1, and
admissionMailer drops the mail unless it is permitted, as Docs does.
Admission stays rauth's Authorize, after verification; its bare-text
403 is turned into the door with ?err=refused (doorOnRefusal) until
rastrillo has a hook for it. ?force=1&err=keymail still carries force
back, so a failed Keymail exchange falls back to an emailed link.

Tests stub classification and Keymail in-process (keymailtest: an
NXDOMAIN resolver and a DefaultTransport fixture, the hooks Docs'
auth_keymail_test uses), and every POST /signin in both test packages
now stays inside the process.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev a61f0d274d2131dfbb06259d5d90a48c3e787459 parent 7288515
11 files changed, +770 −126
  • AGENTS.md +12 −6
  • internal/slides/app.go +6 −6
  • internal/slides/auth.go +197 −88
  • internal/slides/config.go +11 −10
  • internal/slides/keymail_home_test.go +109 −0
  • internal/slides/keymailtest/keymailtest.go +190 −0
  • internal/slides/locales/en.toml +6 −1
  • internal/slides/render.go +3 −0
  • internal/slides/templates/signin.html +11 −8
  • internal/slidestest/keymail_signin_test.go +218 −0
  • internal/slidestest/signin_test.go +7 −7

CI passed — run details

CI log
=== gate ===
=== rust: fmt
=== rust: clippy
   Compiling proc-macro2 v1.0.107
   Compiling unicode-ident v1.0.24
   Compiling quote v1.0.47
   Compiling serde_core v1.0.229
   Compiling version_check v0.9.5
   Compiling zerocopy v0.8.56
    Checking cfg-if v1.0.4
    Checking once_cell v1.21.4
   Compiling serde v1.0.229
   Compiling libc v0.2.189
    Checking smallvec v1.15.2
   Compiling rustversion v1.0.23
    Checking parking v2.2.1
   Compiling parking_lot_core v0.9.12
   Compiling autocfg v1.5.1
   Compiling zmij v1.0.23
   Compiling crossbeam-utils v0.8.22
    Checking pin-project-lite v0.2.17
   Compiling thiserror v2.0.20
    Checking event-listener v5.4.2
   Compiling libm v0.2.16
   Compiling serde_json v1.0.151
   Compiling ahash v0.8.12
    Checking scopeguard v1.2.0
   Compiling paste v1.0.15
    Checking itoa v1.0.18
    Checking memchr v2.8.3
    Checking lock_api v0.4.14
    Checking foldhash v0.2.0
   Compiling num-traits v0.2.19
    Checking event-listener-strategy v0.5.4
    Checking smallstr v0.3.1
    Checking fastrand v2.5.0 (/home/paulca/.local/state/amadan/runner/repos/oficina__slides/wt/fix__keymail-signin/core/vendor/fastrand-2.5.0)
    Checking async-lock v3.4.2
    Checking indexmap-nostd v0.4.0
    Checking downcast-rs v1.2.1
    Checking equivalent v1.0.2
    Checking semver v1.0.28
    Checking bitflags v2.13.1
    Checking arrayvec v0.7.8
    Checking wasmparser-nostd v0.100.2
    Checking multi-stash v0.2.0
    Checking spin v0.9.9
    Checking arc-swap v1.9.2
   Compiling syn v3.0.4
   Compiling syn v2.0.119
    Checking wasmi_core v0.32.3
    Checking hashbrown v0.17.1
   Compiling num-derive v0.4.2
    Checking indexmap v2.14.1
   Compiling serde_derive v1.0.229
   Compiling thiserror-impl v2.0.20
   Compiling async-trait v0.1.92
    Checking hashbrown v0.14.5
    Checking dashmap v6.2.1
    Checking string-interner v0.17.0
    Checking yrs v0.23.5
    Checking wasmparser v0.258.0
    Checking wasmi_collections v0.32.3
    Checking wasmi v0.32.3
    Checking slides-core v0.1.0 (/home/paulca/.local/state/amadan/runner/repos/oficina__slides/wt/fix__keymail-signin/core)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 8.29s
=== wasm: the artifact the host embeds
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.06s
   Compiling proc-macro2 v1.0.107
   Compiling unicode-ident v1.0.24
   Compiling quote v1.0.47
   Compiling cfg-if v1.0.4
   Compiling serde_core v1.0.229
   Compiling crossbeam-utils v0.8.22
   Compiling getrandom v0.4.3
   Compiling zmij v1.0.23
   Compiling rustversion v1.0.23
   Compiling pin-project-lite v0.2.17
   Compiling smallvec v1.15.2
   Compiling parking_lot_core v0.9.12
   Compiling thiserror v2.0.20
   Compiling serde v1.0.229
   Compiling scopeguard v1.2.0
   Compiling serde_json v1.0.151
   Compiling memchr v2.8.3
   Compiling itoa v1.0.18
   Compiling event-listener v5.4.2
   Compiling lock_api v0.4.14
   Compiling hashbrown v0.14.5
   Compiling once_cell v1.21.4
   Compiling smallstr v0.3.1
   Compiling event-listener-strategy v0.5.4
   Compiling fastrand v2.5.0 (/home/paulca/.local/state/amadan/runner/repos/oficina__slides/wt/fix__keymail-signin/core/vendor/fastrand-2.5.0)
   Compiling async-lock v3.4.2
   Compiling arc-swap v1.9.2
   Compiling dashmap v6.2.1
   Compiling syn v3.0.4
   Compiling thiserror-impl v2.0.20
   Compiling serde_derive v1.0.229
   Compiling async-trait v0.1.92
   Compiling yrs v0.23.5
   Compiling slides-core v0.1.0 (/home/paulca/.local/state/amadan/runner/repos/oficina__slides/wt/fix__keymail-signin/core)
    Finished `release` profile [optimized] target(s) in 12.52s
core/target/wasm32-unknown-unknown/release/slides_core.wasm (1228229 bytes)
=== the embedded wasm must match a fresh build
=== rust: test
   Compiling cfg-if v1.0.4
   Compiling once_cell v1.21.4
   Compiling smallvec v1.15.2
   Compiling parking v2.2.1
   Compiling pin-project-lite v0.2.17
   Compiling serde_core v1.0.229
   Compiling zerocopy v0.8.56
   Compiling libc v0.2.189
   Compiling scopeguard v1.2.0
   Compiling crossbeam-utils v0.8.22
   Compiling zmij v1.0.23
   Compiling foldhash v0.2.0
   Compiling itoa v1.0.18
   Compiling memchr v2.8.3
   Compiling arc-swap v1.9.2
   Compiling thiserror v2.0.20
   Compiling libm v0.2.16
   Compiling event-listener v5.4.2
   Compiling lock_api v0.4.14
   Compiling num-traits v0.2.19
   Compiling indexmap-nostd v0.4.0
   Compiling downcast-rs v1.2.1
   Compiling equivalent v1.0.2
   Compiling fastrand v2.5.0 (/home/paulca/.local/state/amadan/runner/repos/oficina__slides/wt/fix__keymail-signin/core/vendor/fastrand-2.5.0)
   Compiling spin v0.9.9
   Compiling semver v1.0.28
   Compiling event-listener-strategy v0.5.4
   Compiling smallstr v0.3.1
   Compiling bitflags v2.13.1
   Compiling async-lock v3.4.2
   Compiling arrayvec v0.7.8
   Compiling multi-stash v0.2.0
   Compiling wasmparser-nostd v0.100.2
   Compiling parking_lot_core v0.9.12
   Compiling wasmi_core v0.32.3
   Compiling serde v1.0.229
   Compiling hashbrown v0.17.1
   Compiling serde_json v1.0.151
   Compiling indexmap v2.14.1
   Compiling wasmparser v0.258.0
   Compiling ahash v0.8.12
   Compiling hashbrown v0.14.5
   Compiling string-interner v0.17.0
   Compiling dashmap v6.2.1
   Compiling wasmi_collections v0.32.3
   Compiling yrs v0.23.5
   Compiling wasmi v0.32.3
   Compiling slides-core v0.1.0 (/home/paulca/.local/state/amadan/runner/repos/oficina__slides/wt/fix__keymail-signin/core)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 11.45s
     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::body_region_rect_narrows_for_an_aside ... ok
test layout::tests::default_region_by_kind ... ok
test layout::tests::aside_region_rect ... ok
test layout::tests::heading_region_rect ... ok
test layout::tests::media_region_rect ... ok
test check::tests::what_the_ops_write_passes ... ok
test layout::tests::to_canvas_narrows_body_when_an_aside_is_present ... ok
test markdown::tests::marks_nest_and_links_carry_marks ... ok
test check::tests::bare_text_at_fragment_level_is_reported ... ok
test markdown::tests::empty_input_is_no_paragraphs_and_lines_keep_blanks ... 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 check::tests::y_prosemirror_shapes_pass ... ok
test markdown::tests::inline_marks ... ok
test check::tests::a_non_paragraph_child_is_reported ... ok
test layout::tests::to_flow_classifies_by_position_and_kind ... ok
test layout::tests::to_flow_is_a_no_op_when_already_in_order ... 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_empty_map_reads_as_a_set_mark ... ok
test schema::tests::an_href_map_without_a_string_url_reads_as_no_link ... ok
test schema::tests::href_is_written_as_a_one_key_map_and_reads_back ... ok
test schema::tests::href_written_as_a_plain_string_still_reads_back ... ok
test view::tests::malformed_box_is_null_and_phone_keeps_only_typed_fields ... ok
test view::tests::malformed_fonts_are_null ... ok
test view::tests::assets_are_typed_or_skipped ... ok
test check::tests::an_element_inside_a_paragraph_is_reported ... ok
test layout::tests::to_flow_keeps_a_concurrent_text_edit ... ok
test view::tests::malformed_background_is_the_default ... 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 ops_are_atomic ... ok
test add_slide_then_view_lists_it ... 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 update_roundtrip ... ok
test move_block_keeps_a_concurrent_text_edit ... ok
test columns_children_have_one_parent_and_no_cycles ... 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)
   Compiling proc-macro2 v1.0.107
   Compiling quote v1.0.47
   Compiling unicode-ident v1.0.24
   Compiling autocfg v1.5.1
   Compiling libm v0.2.16
    Checking simd-adler32 v0.3.10
   Compiling serde_core v1.0.229
    Checking arrayvec v0.7.8
    Checking log v0.4.34
    Checking bitflags v2.13.1
   Compiling crc32fast v1.5.1
    Checking adler2 v2.0.1
    Checking smallvec v1.16.0
    Checking cfg-if v1.0.4
   Compiling version_check v0.9.5
   Compiling libc v0.2.189
    Checking foldhash v0.2.0
    Checking equivalent v1.0.2
    Checking memchr v2.8.3
    Checking arrayref v0.3.9
    Checking unicode-properties v0.1.4
    Checking miniz_oxide v0.9.1
    Checking fdeflate v0.3.7
    Checking miniz_oxide v0.8.9
    Checking unicode-ccc v0.4.0
    Checking unicode-bidi-mirroring v0.4.0
    Checking polycool v0.4.0
    Checking unicode-script v0.5.8
    Checking siphasher v1.0.3
    Checking roxmltree v0.20.0
    Checking tinyvec_macros v0.1.1
   Compiling slotmap v1.1.1
    Checking tinyvec v1.13.2
    Checking kurbo v0.13.1
    Checking byteorder-lite v0.1.0
   Compiling num-traits v0.2.19
    Checking base64 v0.22.1
    Checking color_quant v1.1.0
    Checking weezl v0.1.12
    Checking quick-error v2.0.1
    Checking zune-core v0.5.3
    Checking imagesize v0.14.0
    Checking flate2 v1.1.10
    Checking roxmltree v0.21.1
    Checking fontconfig-parser v0.5.8
    Checking image-webp v0.2.4
    Checking simplecss v0.2.2
    Checking data-url v0.3.2
    Checking gif v0.14.2
    Checking zune-jpeg v0.5.15
    Checking unicode-bidi v0.3.18
    Checking itoa v1.0.18
    Checking rustc-hash v2.1.3
    Checking unicode-vo v0.1.0
    Checking png v0.18.1
    Checking xmlwriter v0.1.0
    Checking stable_deref_trait v1.2.1
    Checking pico-args v0.5.0
    Checking ryu v1.0.23
    Checking bumpalo v3.20.3
   Compiling serde v1.0.229
   Compiling syn v3.0.5
   Compiling syn v2.0.119
    Checking xmp-writer v0.3.3
    Checking semver v1.0.28
    Checking pdf-writer v0.15.0
    Checking svgtypes v0.16.1
    Checking core_maths v0.1.1
    Checking ttf-parser v0.25.1
    Checking memmap2 v0.9.11
    Checking float-cmp v0.9.0
    Checking strict-num v0.1.1
    Checking hashbrown v0.17.1
    Checking indexmap v2.14.2
   Compiling bytemuck_derive v1.12.0
    Checking fontdb v0.23.0
   Compiling synstructure v0.13.2
    Checking wasmparser v0.258.0
   Compiling zerofrom-derive v0.1.7
   Compiling yoke-derive v0.8.2
    Checking bytemuck v1.25.2
    Checking font-types v0.11.3
    Checking tiny-skia-path v0.12.0
    Checking rustybuzz v0.20.1
    Checking rgb v0.8.53
    Checking zerofrom v0.1.8
    Checking yoke v0.8.3
    Checking read-fonts v0.39.2
    Checking tiny-skia v0.12.0
    Checking usvg v0.47.0
    Checking resvg v0.47.0
    Checking skrifa v0.42.1
    Checking write-fonts v0.48.1
    Checking subsetter v0.2.6
    Checking krilla v0.8.2
    Checking krilla-svg v0.8.1
    Checking slides-pdf v0.1.0 (/home/paulca/.local/state/amadan/runner/repos/oficina__slides/wt/fix__keymail-signin/core/pdf)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 10.55s
=== wasm: the pdf artifact the host embeds
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.03s
   Compiling proc-macro2 v1.0.107
   Compiling unicode-ident v1.0.24
   Compiling quote v1.0.47
   Compiling autocfg v1.5.1
   Compiling libm v0.2.16
   Compiling arrayvec v0.7.8
   Compiling log v0.4.34
   Compiling simd-adler32 v0.3.10
   Compiling adler2 v2.0.1
   Compiling crc32fast v1.5.1
   Compiling smallvec v1.16.0
   Compiling bitflags v2.13.1
   Compiling cfg-if v1.0.4
   Compiling version_check v0.9.5
   Compiling arrayref v0.3.9
   Compiling memchr v2.8.3
   Compiling unicode-properties v0.1.4
   Compiling fdeflate v0.3.7
   Compiling siphasher v1.0.3
   Compiling miniz_oxide v0.9.1
   Compiling polycool v0.4.0
   Compiling miniz_oxide v0.8.9
   Compiling unicode-ccc v0.4.0
   Compiling tinyvec_macros v0.1.1
   Compiling unicode-script v0.5.8
   Compiling hashbrown v0.17.1
   Compiling unicode-bidi-mirroring v0.4.0
   Compiling equivalent v1.0.2
   Compiling tinyvec v1.13.2
   Compiling zune-core v0.5.3
   Compiling base64 v0.22.1
   Compiling kurbo v0.13.1
   Compiling num-traits v0.2.19
   Compiling slotmap v1.1.1
   Compiling byteorder-lite v0.1.0
   Compiling flate2 v1.1.10
   Compiling weezl v0.1.12
   Compiling quick-error v2.0.1
   Compiling color_quant v1.1.0
   Compiling imagesize v0.14.0
   Compiling memmap2 v0.9.11
   Compiling image-webp v0.2.4
   Compiling png v0.18.1
   Compiling indexmap v2.14.2
   Compiling zune-jpeg v0.5.15
   Compiling gif v0.14.2
   Compiling syn v3.0.5
   Compiling syn v2.0.119
   Compiling roxmltree v0.21.1
   Compiling simplecss v0.2.2
   Compiling float-cmp v0.9.0
   Compiling core_maths v0.1.1
   Compiling svgtypes v0.16.1
   Compiling strict-num v0.1.1
   Compiling ttf-parser v0.25.1
   Compiling unicode-vo v0.1.0
   Compiling unicode-bidi v0.3.18
   Compiling xmlwriter v0.1.0
   Compiling pico-args v0.5.0
   Compiling ryu v1.0.23
   Compiling rustc-hash v2.1.3
   Compiling itoa v1.0.18
   Compiling data-url v0.3.2
   Compiling stable_deref_trait v1.2.1
   Compiling pdf-writer v0.15.0
   Compiling xmp-writer v0.3.3
   Compiling bumpalo v3.20.3
   Compiling bytemuck_derive v1.12.0
   Compiling synstructure v0.13.2
   Compiling zerofrom-derive v0.1.7
   Compiling yoke-derive v0.8.2
   Compiling fontdb v0.23.0
   Compiling bytemuck v1.25.2
   Compiling zerofrom v0.1.8
   Compiling yoke v0.8.3
   Compiling font-types v0.11.3
   Compiling tiny-skia-path v0.12.0
   Compiling rustybuzz v0.20.1
   Compiling rgb v0.8.53
   Compiling read-fonts v0.39.2
   Compiling tiny-skia v0.12.0
   Compiling usvg v0.47.0
   Compiling resvg v0.47.0
   Compiling skrifa v0.42.1
   Compiling write-fonts v0.48.1
   Compiling subsetter v0.2.6
   Compiling krilla v0.8.2
   Compiling krilla-svg v0.8.1
   Compiling slides-pdf v0.1.0 (/home/paulca/.local/state/amadan/runner/repos/oficina__slides/wt/fix__keymail-signin/core/pdf)
    Finished `release` profile [optimized] target(s) in 20.60s
core/pdf/target/wasm32-unknown-unknown/release/slides_pdf.wasm (5301002 bytes)
=== the embedded pdf wasm must match a fresh build
=== rust: test (pdf)
   Compiling bytemuck v1.25.2
   Compiling simd-adler32 v0.3.10
   Compiling arrayvec v0.7.8
   Compiling log v0.4.34
   Compiling adler2 v2.0.1
   Compiling smallvec v1.16.0
   Compiling bitflags v2.13.1
   Compiling cfg-if v1.0.4
   Compiling foldhash v0.2.0
   Compiling serde_core v1.0.229
   Compiling libm v0.2.16
   Compiling num-traits v0.2.19
   Compiling equivalent v1.0.2
   Compiling arrayref v0.3.9
   Compiling memchr v2.8.3
   Compiling libc v0.2.189
   Compiling crc32fast v1.5.1
   Compiling siphasher v1.0.3
   Compiling roxmltree v0.20.0
   Compiling tinyvec_macros v0.1.1
   Compiling polycool v0.4.0
   Compiling miniz_oxide v0.9.1
   Compiling miniz_oxide v0.8.9
   Compiling fdeflate v0.3.7
   Compiling unicode-properties v0.1.4
   Compiling unicode-ccc v0.4.0
   Compiling unicode-bidi-mirroring v0.4.0
   Compiling unicode-script v0.5.8
   Compiling tinyvec v1.13.2
   Compiling slotmap v1.1.1
   Compiling kurbo v0.13.1
   Compiling font-types v0.11.3
   Compiling color_quant v1.1.0
   Compiling zune-core v0.5.3
   Compiling flate2 v1.1.10
   Compiling fontconfig-parser v0.5.8
   Compiling read-fonts v0.39.2
   Compiling quick-error v2.0.1
   Compiling byteorder-lite v0.1.0
   Compiling base64 v0.22.1
   Compiling weezl v0.1.12
   Compiling imagesize v0.14.0
   Compiling float-cmp v0.9.0
   Compiling png v0.18.1
   Compiling image-webp v0.2.4
   Compiling zune-jpeg v0.5.15
   Compiling gif v0.14.2
   Compiling zerofrom v0.1.8
   Compiling strict-num v0.1.1
   Compiling roxmltree v0.21.1
   Compiling simplecss v0.2.2
   Compiling unicode-bidi v0.3.18
   Compiling tiny-skia-path v0.12.0
   Compiling ryu v1.0.23
   Compiling memmap2 v0.9.11
   Compiling core_maths v0.1.1
   Compiling ttf-parser v0.25.1
   Compiling svgtypes v0.16.1
   Compiling xmlwriter v0.1.0
   Compiling stable_deref_trait v1.2.1
   Compiling pico-args v0.5.0
   Compiling unicode-vo v0.1.0
   Compiling itoa v1.0.18
   Compiling rustc-hash v2.1.3
   Compiling data-url v0.3.2
   Compiling yoke v0.8.3
   Compiling pdf-writer v0.15.0
   Compiling rgb v0.8.53
   Compiling xmp-writer v0.3.3
   Compiling bumpalo v3.20.3
   Compiling hashbrown v0.17.1
   Compiling tiny-skia v0.12.0
   Compiling serde v1.0.229
   Compiling semver v1.0.28
   Compiling indexmap v2.14.2
   Compiling rustybuzz v0.20.1
   Compiling fontdb v0.23.0
   Compiling wasmparser v0.258.0
   Compiling usvg v0.47.0
   Compiling write-fonts v0.48.1
   Compiling skrifa v0.42.1
   Compiling resvg v0.47.0
   Compiling subsetter v0.2.6
   Compiling krilla v0.8.2
   Compiling krilla-svg v0.8.1
   Compiling slides-pdf v0.1.0 (/home/paulca/.local/state/amadan/runner/repos/oficina__slides/wt/fix__keymail-signin/core/pdf)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 17.93s
     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 876ms

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 (12.513425ms)
✔ a record missing its numbers still makes an op the core accepts (1.032212ms)
✔ unusedAssets is what no slide refers to, and remove_asset drops one (3.141978ms)
✔ the Unused images list reconciles by id and names each Remove button (1.225992ms)
✔ an upload refusal is said in words the person can act on (0.221772ms)
✔ clampBox keeps the box inside the frame and no smaller than the minimum (1.653829ms)
✔ fixBox writes four decimals (0.301982ms)
✔ guides are the thirds plus every other block edge (0.294698ms)
✔ snapMove pulls the nearest edge onto a guide within one per cent, and leaves a far one (0.56907ms)
✔ snapResize moves only the edges the handle drags (0.267839ms)
✔ moveBox and resizeBox by handle (0.318974ms)
✔ nudge: arrows move by half a per cent, Shift by two, Alt resizes (0.264322ms)
✔ rectBox reads a block rectangle against its frame (0.321228ms)
✔ typing into a block is a text change on its slide only (11.247279ms)
✔ a block's props changing is structure on its slide (2.117311ms)
✔ adding, moving or removing a block is structure on its slide (5.569949ms)
✔ the notes are text on their slide (1.508107ms)
✔ a slide added, removed or moved is an order change (2.355876ms)
✔ one transaction touching several things reports each once (1.263733ms)
✔ what the ops write passes, and so does an empty document (11.062825ms)
✔ the shapes y-prosemirror and older documents write pass (2.691039ms)
✔ an unknown formatting attribute is reported with the core's message (2.405217ms)
✔ an element inside a paragraph, a non-paragraph child and bare text are reported (3.541954ms)
✔ the unknownmark poison shape is what the check refuses (15.139844ms)
✔ y-prosemirror 1.3.7 deletes the words under an unknown mark; sanitising first keeps them (24.267828ms)
✔ the vocabulary is the five marks and nothing else (0.223726ms)
✔ a reload reuses the id this tab stored for the deck (1.222385ms)
✔ two tabs, and two decks in one tab, get different ids (1.554053ms)
✔ a stored value that is not an id, or storage that throws, gives a fresh id (0.420312ms)
✔ a duplicated tab gives up the copied id; the original keeps it (7.823573ms)
✔ a reload finds no rival and keeps its id (3.183988ms)
✔ presence switches id: beats carry the new one and the old one is somebody else (1.977812ms)
✔ an empty body is no frames (1.529858ms)
✔ frames come back in order with their exact bytes (0.379827ms)
✔ a length prefix is big-endian (0.180135ms)
✔ a truncated frame throws rather than returning a short update (1.767361ms)
✔ deck controls are set_meta (31.813492ms)
✔ slide controls are set_slide, and background keeps its colour (7.535297ms)
✔ move up and down are move_slide with the right after (4.096597ms)
✔ block props: value kinds, region, a shape label (2.759126ms)
✔ box fields are set_block on the desktop and set_phone in the phone view (6.625013ms)
✔ move block and the layer buttons (2.983072ms)
✔ add block: region after the selection on flow, a default box on canvas, a heading level that fits (13.55252ms)
✔ a block is named by its type and its first words (1.473193ms)
✔ blockChange: a control element to its op (2.374209ms)
✔ the Blocks list reconciles by id and keeps its buttons (1.233446ms)
✔ open resolves to the button that closed it and the trimmed field (1.892804ms)
✔ a second open while the dialog is up joins the first (0.260675ms)
✔ a missing dialog mounts as nothing (0.157704ms)
✔ a tab advances its own cursor only when its POST is the very next row (6.519276ms)
✔ the cursor never moves backwards when a response is overtaken by this tab's own write (0.626967ms)
✔ a later response still carries the cursor forward (0.30623ms)
✔ a burst of notifications collapses into one fetch at a time (0.246909ms)
✔ an event naming a seq the tab already has fetches nothing (0.200333ms)
✔ a stopped subscriber drops a response that arrives late (0.287936ms)
✔ stop closes the stream, and start opens exactly one (0.224057ms)
✔ a hidden tab closes its stream on a shortened deadline, and activity reopens it (1.013156ms)
✔ switching away and back inside the grace never drops the stream, so nothing has to be caught up (0.27906ms)
✔ a visible tab that is only being READ closes when idle and comes back on any activity (0.348078ms)
✔ activity while streaming defers the idle close rather than reopening anything (0.198991ms)
✔ a stream failure is never reported as a save failure, and heals on activity (0.247501ms)
✔ an empty response advances the cursor without applying anything (0.377503ms)
✔ a failed fetch leaves the cursor alone so the next notification retries it (1.694335ms)
✔ a decode failure retries once through the raw log, then gives up (0.270153ms)
✔ updatesURL spells the query string exactly, in particular raw as the literal raw=1 (0.186106ms)
✔ empty_doc_views_as_defaults (5.258909ms)
✔ set_meta (3.898056ms)
✔ slides: add, move, remove, set_slide, set_notes (7.549203ms)
✔ duplicate_slide (5.2105ms)
✔ blocks: add, move, remove, set_block (4.259809ms)
✔ set_phone (1.481207ms)
✔ table cells follow rows and cols (2.07331ms)
✔ canvas blocks: box defaults from the region, z from the index (1.764114ms)
✔ assets: register and remove (1.435633ms)
✔ markdown: every mark, code blocks are plain lines (6.343478ms)
✔ batch_is_atomic (2.272271ms)
✔ a batch applies in one transaction with the given origin (2.803218ms)
✔ columns_children_validated (2.754427ms)
✔ layout_switch_round_trip (3.133835ms)
✔ view_matches_snapshot_fixtures (20.828464ms)
✔ columns children have one parent and no cycles (codex 7) (1.525439ms)
✔ canvas-to-flow keeps a concurrent text edit (codex 4) (6.209319ms)
✔ move_block keeps a concurrent text edit, and later inserts land where after says (codex 4) (5.15673ms)
✔ move_slide keeps a concurrent text edit, and later inserts land where after says (codex 4 at slide level) (4.510306ms)
✔ format: {name} and {n} interpolate; an unknown key is left as text (1.06882ms)
✔ a beat on connect, then one every twenty seconds (2.129134ms)
✔ a move posts once, 200ms after the last of a burst, and re-arms the beat (9.883838ms)
✔ a hidden tab stops beating; coming back posts at once (0.358958ms)
✔ applySet leaves this tab out and answers what changed (0.562908ms)
✔ a set with rubbish in it is read for what it does carry (0.264452ms)
✔ initials, and a client id per tab (7.108193ms)
✔ a resync that fails is retried with backoff until it succeeds, without ever running twice at once (1.630485ms)
✔ the browser's online event retries immediately, not just on the next backoff tick (0.269501ms)
✔ reportDivergence before any failure runs the first attempt without a caller ever calling online (1.007155ms)
✔ a permanent 4xx drops its batch instead of retrying it, and does not poison the next edit (82.220731ms)
✔ a network failure or a 5xx is retried, not dropped (60.076526ms)
✔ a 500 is retried the same way a network failure is, never dropped (60.871908ms)
✔ the core-written structure reads as ProseMirror marks (6.887913ms)
✔ a y-prosemirror round trip preserves bold and the link (3.513421ms)
✔ y-prosemirror writes href as the one-key map and marks as {} (0.868427ms)
✔ a plain-string href, written before the map shape, still reads (0.743594ms)
✔ textFragment writes the same shape as writeText (0.626285ms)
✔ safeHref is the server allowlist, and a disallowed href never draws an anchor (0.457782ms)
✔ parseHash splits the slide and the block, and survives a stray % (1.647777ms)
✔ destroy takes the listeners off, and a second mount leaves one of each (0.739586ms)
✔ formatHash is the inverse (1.04724ms)
✔ textBlocks finds the text-bearing blocks and skips the rest (2.132199ms)
✔ mountTarget is the element whose children are the paragraphs (0.273349ms)
✔ paragraphTag keeps the renderer's own markup per type (0.173773ms)
✔ blockFragment finds a block's text by slide and block id (8.363048ms)
✔ notesFragment finds the slide's notes (1.500944ms)
✔ blockIDs and slideIDs are the orders on the stage and the strip (1.484003ms)
✔ cellFragments are a table's cells, row-major (1.852699ms)
✔ a load that fails leaves the stage on the slide it was showing and says so (1.667113ms)
✔ slideAfter is the slide to show once one is gone (0.266777ms)
✔ the queue words map to savestatus states, failed staying failed (5.283216ms)
✔ a dropped batch outranks the queue, for the rest of the session (0.286934ms)
✔ the editor status: the save line and the load error are two lines (0.309086ms)
✔ mountSaveStatus loads estilo's module and mounts it on the line (1.000793ms)
✔ a save-status module that will not load says so and refuses to mount (0.485594ms)
✔ dropTarget: before puts it ahead of the item, after behind it, null means first (1.556027ms)
✔ a drop that leaves the order as it is is no move (0.234857ms)
✔ dropOp is the move_slide (0.793717ms)
✔ labelFor is the slide's first heading, else the numbered fallback (6.454525ms)
✔ flush re-points every dirty thumbnail and relabels it (2.463946ms)
✔ destroy takes the item-menu listener off; a second mount leaves one (0.430561ms)
✔ plan says what to add, drop and where each item goes (1.100929ms)
✔ sync moves only the items out of place and keeps focus (1.961331ms)
✔ desktop view: box and size are the block; hidden and order are always the phone map (1.320117ms)
✔ phone view: everything goes to set_phone, carrying the rest of the map (0.766187ms)
✔ the ops it makes are ones the document takes (9.843634ms)
✔ phoneMap reads only the four keys, and only well-typed (0.209179ms)
✔ the stored view is read defensively (0.270794ms)
✔ linkAt reads the link under the cursor and nothing elsewhere (3.370104ms)
✔ setLink marks the selection (2.660392ms)
✔ setLink on a collapsed cursor inside a link replaces that link (1.037462ms)
✔ setLink with nothing selected and no link does nothing (0.330946ms)
✔ removeLink clears the link under the cursor (0.664277ms)
✔ the keymap binds every documented shortcut (0.280001ms)
✔ Mod-b toggles bold through the keymap (0.862626ms)
✔ setLink refuses an address outside the allowlist and writes nothing (0.742472ms)
✔ the state machine runs choose → sign → put → register → op (3.150915ms)
✔ a file the deck will not take never reaches the network (0.680807ms)
✔ alt text is required before the bytes move, unless the image is decorative (0.366903ms)
✔ a step that fails stops the ones after it and reports its own code (0.598805ms)
✔ a failure that is not ours still becomes an UploadError (0.335194ms)
✔ a second pick while one is in flight is refused, not raced (0.427916ms)
✔ an unreadable size is zero rather than a failure (0.268821ms)
✔ a per-call applyOp wins, so an upload lands on the block it was chosen for (0.264222ms)
✔ checkFile takes exactly the four types (0.433617ms)
✔ sha256Hex is the hex digest of the bytes (13.411797ms)
✔ refusal reads this app's JSON error shape, and survives one that is not (0.361392ms)
✔ malformed fonts are null (4.406974ms)
✔ a malformed background is the theme default (2.571688ms)
✔ a malformed box is null (1.440993ms)
✔ the phone layer keeps only its typed fields, order as a whole number (1.436393ms)
✔ assets are typed or skipped (1.34845ms)
✔ every poison update is deterministic bytes that apply to any deck (12.934599ms)
ℹ tests 158
ℹ suites 0
ℹ pass 158
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 367.47483
=== 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	12.711s
ok  	amadan.net/oficina/slides/internal/slides/core	2.861s
ok  	amadan.net/oficina/slides/internal/slides/deck	0.006s
ok  	amadan.net/oficina/slides/internal/slides/fonts	0.017s
?   	amadan.net/oficina/slides/internal/slides/icons	[no test files]
?   	amadan.net/oficina/slides/internal/slides/keymailtest	[no test files]
ok  	amadan.net/oficina/slides/internal/slides/layout	0.021s
ok  	amadan.net/oficina/slides/internal/slides/live	0.004s
ok  	amadan.net/oficina/slides/internal/slides/pdfcore	4.181s
ok  	amadan.net/oficina/slides/internal/slides/present	0.009s
ok  	amadan.net/oficina/slides/internal/slides/render	0.015s
ok  	amadan.net/oficina/slides/internal/slides/svg	0.016s
ok  	amadan.net/oficina/slides/internal/slidestest	40.562s
?   	amadan.net/oficina/slides/scripts/writeestilo	[no test files]
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/AGENTS.md b/AGENTS.md
index 24aa65b..05b7831 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -44,12 +44,18 @@ Slices 1, 2 and 3. A deck is edited in the browser at `/d/{sqid}/edit`,
driven through the API (`docs/api.md`), or arrives as the sample deck
seeded on first sign-in — and comes back out as a PDF.
-- **Sign-in** is a magic link (`internal/slides/auth.go`), gated by
- `SLIDES_ALLOWED_ADDRESSES` in front of `POST /signin` as well as in
- rastrillo/auth's `Authorize`. The gate in front matters more, because
- that is the one that stops the mail; a non-permitted address gets
- byte-for-byte the same `303 ?sent=1` a permitted one gets, or the
- endpoint becomes a membership oracle. The list is also **rechecked on
+- **Sign-in** (`internal/slides/auth.go`) is Keymail for an address
+ whose domain runs Keymail and an emailed magic link for every other
+ address — decided by the domain alone, never by
+ `SLIDES_ALLOWED_ADDRESSES`, or the endpoint becomes a membership
+ oracle. A Keymail address, permitted or not, gets the continuation
+ page to Keymail (never a 303 out of the form POST: Safari applies
+ `form-action` to it); any other gets `303 ?sent=1`, and only a
+ permitted one is mailed (`admissionMailer`). Admission is
+ rastrillo/auth's `Authorize`, after verification, and its refusal is
+ the door with `?err=refused` (`doorOnRefusal`), not rastrillo's bare
+ text. `?force=1&err=keymail` (a failed Keymail exchange) makes the
+ form ask for a link instead. The list is also **rechecked on
every request** that resolves a principal, so taking an address off it
signs that person out of every device and refuses their agents' tokens
rather than leaving them everything they already held (spec §6.1,
diff --git a/internal/slides/app.go b/internal/slides/app.go
index 2af5061..c820921 100644
--- a/internal/slides/app.go
+++ b/internal/slides/app.go
@@ -79,9 +79,9 @@ func New(d *db.DB, cfg Config) (*http.ServeMux, *app, error) {
// get in.
if a.homeRoutes == nil {
r.Get(signinPath, a.signin)
- r.Post(signinPath, gateBegin(authPlugin.Begin, a.allowed, a.logger))
- r.Get("/auth/callback", authPlugin.Callback)
- r.Get("/auth/verify", authPlugin.Verify)
+ r.Post(signinPath, a.newBegin(authPlugin.Begin))
+ r.Get("/auth/callback", doorOnRefusal(authPlugin.Callback, signinPath))
+ r.Get("/auth/verify", doorOnRefusal(authPlugin.Verify, signinPath))
r.Get(landedPath, a.landed)
r.Post("/signout", signedOutAfter(authPlugin.Signout))
r.Get("/signed-out", a.signedOut)
@@ -89,9 +89,9 @@ func New(d *db.DB, cfg Config) (*http.ServeMux, *app, error) {
h := a.homeRoutes
r.Get(signinPath, a.homeSignin)
r.Get("/signin/local", a.signin)
- r.Post(signinPath, h.LocalGuard(gateBegin(authPlugin.Begin, a.allowed, a.logger)))
- r.Get("/auth/callback", h.LocalGuard(authPlugin.Callback))
- r.Get("/auth/verify", h.LocalGuard(authPlugin.Verify))
+ r.Post(signinPath, h.LocalGuard(a.newBegin(authPlugin.Begin)))
+ r.Get("/auth/callback", h.LocalGuard(doorOnRefusal(authPlugin.Callback, "/signin/local")))
+ r.Get("/auth/verify", h.LocalGuard(doorOnRefusal(authPlugin.Verify, "/signin/local")))
r.Post("/signout", h.Signout)
r.Get("/signed-out", h.SignedOut)
r.Get(connect.LocalCompletePath, h.LocalComplete)
diff --git a/internal/slides/auth.go b/internal/slides/auth.go
index 884fea1..d8abe2e 100644
--- a/internal/slides/auth.go
+++ b/internal/slides/auth.go
@@ -1,14 +1,24 @@
-// auth.go is sign-in: rastrillo/auth's magic-link plugin, the
-// allowlist that gates it at both ends, and the mapping from a verified
+// auth.go is sign-in: rastrillo/auth's plugin, the allowlist that gates
+// who is mailed and who is admitted, and the mapping from a verified
// address to this app's own users row.
//
-// Per spec §6.1 a single instance signs people in itself — an emailed,
-// single-use link, no password, no credential enrolled. Deliberately
-// magic link only, never a passkey second factor: a passkey's relying
-// party id derives from this instance's own host, so a person on three
-// team instances would enrol three separate credentials under one app.
-// A magic link enrols nothing, so it creates no relying party and no
-// sprawl. That is a design ruling, not an omission to improve on later.
+// Per spec §6.1 a single instance signs people in itself, with no
+// password and no credential enrolled here. How an address proves
+// itself depends on its domain alone, through rastrillo/auth's
+// classification: an address whose domain runs Keymail (keymail.dev
+// itself, or a domain delegating through a `_keymail` TXT record)
+// signs in at Keymail, where its passkey lives; every other address is
+// emailed a single-use link. Paul ruled on 2026-10-06 that Slides uses
+// Keymail. Never a passkey enrolled at this instance: its relying party
+// id would derive from this host, so a person on three team instances
+// would hold three credentials for one app. Keymail keeps the
+// credential at Keymail's own origin, and a magic link enrols nothing.
+//
+// Admission is a separate question from the ceremony, asked at two
+// places that cannot be observed from the form: the mailer
+// (admissionMailer — a non-permitted address is never mailed) and
+// rauth's Authorize, after Keymail or the link has verified the
+// address. Neither changes what POST /signin answers (gateBegin).
//
// This file is a port of oficina/docs' internal/docs/auth.go, adapted
// to rastrillo v0.26.0 and to Config.AllowedAddresses being a slice
@@ -138,89 +148,173 @@ func resolveAllowlist(cfg Config) (allowlist, error) {
return allowlist{}, fmt.Errorf("slides: Config.AllowedAddresses is empty and Env is %q, not %q — refusing to start with no allowlist: an instance admitting nobody is indistinguishable from one working correctly, since every POST /signin answers the same 303 either way; set $SLIDES_ALLOWED_ADDRESSES", cfg.Env, EnvDevelopment)
}
-// gateBegin wraps rauth.Auth.Begin so a non-permitted address never
-// reaches it at all. Begin is what sends the mail;
-// rauth.Config.Authorize runs only afterwards, inside rauth's own
-// admit(), at the callback — by which point a link has already been
-// mailed and clicked. Gating only Authorize would leave this instance's
-// sending identity an open relay for any address a stranger types into
-// the form.
-//
-// THE GUARANTEE: every answer this endpoint gives is byte-identical —
-// `303 Location: /signin?sent=1`, no body — whatever address was
-// submitted and whatever happened next. Any observable difference at
-// all would turn this into a membership oracle, letting anyone learn
-// which addresses this instance admits by watching how its answers
-// differ.
+// gateBegin is POST /signin: rauth.Auth.Begin for EVERY address,
+// permitted or not, so what the form answers depends on the address's
+// domain and never on the allowlist. That is the whole guarantee, and
+// any difference at all would turn this endpoint into a membership
+// oracle, letting anyone learn who this instance admits by watching how
+// its answers differ.
//
-// Making the denied path match the happy path is only half of that, and
-// the half that is easy to get wrong is the other one: the PERMITTED
-// path has outcomes the denied path can never reach. `signin.Flow.Begin`
-// enforces a per-address budget (5 in a rolling 15 minutes) and a
-// per-IP one (20), and over either budget rauth redirects to
-// `?err=rate` instead — so six POSTs of one candidate address would
-// separate "on the list" (`?err=rate` on the sixth) from "not on it"
-// (`?sent=1` forever), and the whole guarantee with it. `?err=address`
-// and `?err=1` are the same shape of leak: only a permitted address can
-// reach either.
+// Every address therefore takes the same road through Begin: the same
+// per-IP and per-address budgets, the same classification, the same
+// ceremony.
//
-// So the permitted branch runs under sameAnswer, which rewrites every
-// one of those outcomes back to `?sent=1` on the way out. What that
-// costs is inline feedback: a person over the budget, or one whose
-// address rauth could not parse, is told a link is on its way when it
-// is not. On a closed allowlist that was already true — such an address
-// is almost always denied and short-circuited above — so the loss is
-// confined to development's admit-everyone list, where the log carries
-// the real outcome instead. That is the trade, made deliberately: a
-// recoverable "wait five minutes and try again" that nobody sees, in
-// exchange for an allowlist nobody can enumerate.
+// - A Keymail address — permitted or not — goes to Keymail. Begin
+// answers with a 303 to Keymail's authorize URL, which newBegin's
+// continuation turns into a page (Safari applies form-action to the
+// whole redirect chain of a form POST, and ours is 'self'). Whether
+// the address may sign in here is asked only after Keymail has
+// verified it, by rauth's Authorize, and a refusal is the sign-in
+// door saying so (doorOnRefusal) — told only to somebody who has
+// just proved they hold the address.
+// - Any other address is answered `303 /signin?sent=1`, and a link is
+// mailed only if it is permitted: admissionMailer drops the rest.
+// So this instance's sending identity is no open relay for whatever
+// a stranger types, which is why a gate exists at all
+// (rauth.Config.Authorize alone runs only after a link was mailed
+// and clicked).
//
-// The denied branch deliberately spends no budget of its own. That is
-// not an oversight — a budget consumed only by denied addresses would
-// itself be observable, since a permitted address's own budget would
-// then behave differently depending on what else had been tried.
-// Rate limiting the front door as a whole belongs at the edge, not
-// here, and the per-IP budget inside Begin already bounds what a
-// permitted address can spend.
+// force (any non-empty value) is passed through as the form sent it.
+// Only the recovery form carries it — the page rastrillo lands a failed
+// Keymail exchange on, `?force=1&err=keymail` — and it selects an
+// emailed link over Keymail, so a person whose Keymail failed is never
+// stuck. A stranger posting force for any address gets exactly the
+// plain-email answer above.
//
-// force=1 is set unconditionally on the permitted branch, not left to
-// what the caller's form said. It selects rauth's plain-magic-link path
-// over keymail classification, which this app never wants (see this
-// file's package comment). Without it a permitted keymail-hosted
-// address would redirect out to keymail while a non-permitted one got
-// ?sent=1 — the same oracle again. It also saves the classification
-// network call outright.
+// What sameAnswer still has to hide is the one outcome only a
+// permitted address can reach: `?err=1`, a send that failed, since a
+// non-permitted address never gets as far as a real send. `?err=rate`
+// and `?err=address` no longer depend on admission — both kinds of
+// address spend the same budgets and parse the same way — but
+// sameAnswer still answers them as sent, as it always has. Giving those
+// two back their own sentences is a separate, safe change, not made
+// here.
+func gateBegin(next http.HandlerFunc, logger *slog.Logger) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ next(&sameAnswer{ResponseWriter: w, logger: logger}, r)
+ }
+}
+
+// admissionMailer is the mail half of the allowlist: Begin runs for
+// every address (gateBegin), and this is where a non-permitted one is
+// quietly not mailed. rauth has already stored an expiring link token
+// by the time it calls Send; one that is never delivered is never
+// redeemed, and Authorize would refuse it if it were.
+type admissionMailer struct {
+ next mail.Sender
+ list allowlist
+}
+
+func (m admissionMailer) Send(ctx context.Context, to, subject, body string) error {
+ if !m.list.allows(to) {
+ return nil
+ }
+ return m.next.Send(ctx, to, subject, body)
+}
+
+// newBegin is the POST /signin handler both modes mount: gateBegin
+// under connect.OAuthContinuation, which ends the POST with a page
+// (renderKeymailContinuation) when Begin's answer is Keymail's
+// authorize URL, and passes every other answer through untouched.
+func (a *app) newBegin(begin http.HandlerFunc) http.HandlerFunc {
+ return connect.OAuthContinuation(a.cfg.Origin, gateBegin(begin, a.logger), a.renderKeymailContinuation)
+}
+
+// renderKeymailContinuation is the page between the form and Keymail:
+// the door's own card, saying where the person is going, with a meta
+// refresh that goes there by itself and a link that does when the
+// refresh is blocked. A navigation from a page, unlike a redirect from
+// a form POST, is outside form-action.
+func (a *app) renderKeymailContinuation(w http.ResponseWriter, r *http.Request, destination string) {
+ p := connect.OAuthPage(destination)
+ p.Title = rastrillo.T(r, "slides.signin.keymail.title")
+ p.ActionLabel = rastrillo.T(r, "slides.signin.keymail.action")
+ notice := chrome.SignInNotice{
+ Icon: brand.Path("slides"), App: "Slides", Sky: sky.For(r.Host),
+ Heading: p.Title, Body: rastrillo.T(r, "slides.signin.keymail.body"),
+ Link: &chrome.Link{Href: p.URL, Label: p.ActionLabel},
+ }
+ render(w, "signin", signinView{
+ shell: a.publicShell(r, frame{parts: []string{p.Title}, signinPage: true}),
+ Sent: &notice,
+ Continue: &p,
+ })
+}
+
+// rauthRefusal is the bare-text body rastrillo/auth's admit() writes,
+// with a 403, when a verified address is not admitted (Authorize said
+// no). rastrillo at the version pinned here has no hook to render
+// anything else; doorOnRefusal recognises this exact answer instead.
+// The Keymail refusal test pins it, so a rastrillo bump that changes
+// the sentence fails loudly rather than letting the bare page back.
+const rauthRefusal = "This address is verified but not admitted here."
+
+// doorOnRefusal wraps rauth's Callback and Verify, the two handlers
+// that end in admit(), and turns its bare-text refusal into the sign-in
+// door: a 303 to the local form with ?err=refused, which says this
+// address cannot sign in here and offers the form for a different one.
+// Every other answer, cookies included, passes through untouched.
//
-// r.ParseForm is documented idempotent, so reading the address here and
-// again inside Begin costs nothing and cannot disagree with itself. A
-// malformed body reads back as address == "", which is never on a
-// non-empty allowlist — the same answer Begin would eventually reach,
-// just reached before any mail could go out.
-func gateBegin(next http.HandlerFunc, list allowlist, logger *slog.Logger) http.HandlerFunc {
+// It is told only to somebody who has just verified the address, so it
+// is no oracle. Remove it when rastrillo grows a refusal hook (a bump
+// past this pin; see the fix/keymail-signin discussion).
+func doorOnRefusal(next http.HandlerFunc, form string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- _ = r.ParseForm()
- if !list.allows(r.FormValue("address")) {
- http.Redirect(w, r, sentLocation, http.StatusSeeOther)
+ rec := &bufferedResponse{header: make(http.Header)}
+ next(rec, r)
+ if rec.status == http.StatusForbidden && strings.TrimSpace(rec.body.String()) == rauthRefusal {
+ for _, c := range rec.header.Values("Set-Cookie") {
+ w.Header().Add("Set-Cookie", c)
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ http.Redirect(w, r, form+"?err=refused", http.StatusSeeOther)
return
}
- if r.Form == nil {
- r.Form = url.Values{}
+ for name, values := range rec.header {
+ for _, v := range values {
+ w.Header().Add(name, v)
+ }
+ }
+ if rec.status == 0 {
+ rec.status = http.StatusOK
}
- r.Form.Set("force", "1")
- next(&sameAnswer{ResponseWriter: w, logger: logger}, r)
+ w.WriteHeader(rec.status)
+ _, _ = w.Write(rec.body.Bytes())
}
}
-// sentLocation is the one answer POST /signin ever gives — see
-// gateBegin's guarantee. Spelled once so the two paths that write it
-// cannot drift apart by a character.
+// bufferedResponse holds a whole answer so doorOnRefusal can decide
+// what to send after seeing it. Callback and Verify answer with a
+// redirect or a line of text, never anything large.
+type bufferedResponse struct {
+ header http.Header
+ status int
+ body bytes.Buffer
+}
+
+func (b *bufferedResponse) Header() http.Header { return b.header }
+func (b *bufferedResponse) WriteHeader(status int) {
+ if b.status == 0 {
+ b.status = status
+ }
+}
+func (b *bufferedResponse) Write(p []byte) (int, error) {
+ if b.status == 0 {
+ b.status = http.StatusOK
+ }
+ return b.body.Write(p)
+}
+
+// sentLocation is the answer POST /signin gives every address that is
+// not going to Keymail — see gateBegin. Spelled once so the paths that
+// write it cannot drift apart by a character.
const sentLocation = signinPath + "?sent=1"
// sameAnswer is the ResponseWriter gateBegin hands to Begin: it
// rewrites any `?err=…` outcome back to sentLocation before the status
// line goes out, so a permitted address cannot be told apart from a
-// denied one by its failure mode. See gateBegin's own doc comment for
-// what that buys and what it costs.
+// denied one by a failed send. See gateBegin's own doc comment for
+// which outcomes that still matters for.
//
// It hooks WriteHeader rather than the Location header itself because
// that is the last moment a header is still settable: http.Redirect
@@ -251,12 +345,12 @@ func (s *sameAnswer) WriteHeader(status int) {
// both the emailed link's base and the cookie decision, InstanceKey and
// Mailer already resolved and fail-closed above.
//
-// Authorize is list.allows as well as gateBegin's gate — belt and
-// braces, defending two different things: gateBegin stops the mail,
-// Authorize stops a session being minted from a link that was somehow
-// issued for a non-permitted address (mailed before the allowlist
-// changed, say). SecondFactor stays nil, per this file's package
-// comment.
+// Authorize is list.allows as well as admissionMailer — two checks
+// defending two different things: admissionMailer stops the mail,
+// Authorize stops a session being minted for a non-permitted address,
+// whether Keymail verified it or a link was somehow issued for it
+// (mailed before the allowlist changed, say). SecondFactor stays nil,
+// per this file's package comment.
//
// cfg must be the resolved Config (config.go's resolve), not a
// caller's raw one: Origin, InstanceKey and Mailer are all required
@@ -277,7 +371,7 @@ func newAuthPlugin(cfg Config, writer *sql.DB, list allowlist) (*rauth.Auth, err
DB: writer,
Origin: cfg.Origin,
InstanceKey: cfg.InstanceKey,
- Mailer: cfg.Mailer,
+ Mailer: admissionMailer{next: cfg.Mailer, list: list},
Subject: chrome.EmailSubject("Slides"),
Authorize: list.allows,
SigninPath: signinPath,
@@ -546,15 +640,20 @@ type signinView struct {
shell
SignIn chrome.SignIn
Sent *chrome.SignInNotice
+ // Continue is set on the page between the form and Keymail
+ // (renderKeymailContinuation): its meta refresh goes on to
+ // Keymail, and Sent holds the door's card saying so.
+ Continue *connect.OAuthData
}
// signin serves GET /signin: the form, and the landing spot for every
// outcome rauth redirects back to (?sent=1, ?err=rate|address|expired|
-// keymail|removed|1). rate, address, expired and removed each get their
-// own sentence; keymail and a bare failure share the default, because
-// every outcome on this page means the same operational thing — sign-in
-// did not complete, ask for a new link — and rauth's Verify deliberately
-// refuses to be more specific than that.
+// keymail|removed|refused|1, ?force=1). rate, address, expired, removed
+// and refused each get their own sentence; keymail and a bare failure
+// share the default, because both mean the same operational thing —
+// sign-in did not complete, ask for a new link — and rauth's Verify
+// deliberately refuses to be more specific than that. ?force=1 (with
+// err=keymail) makes the form ask for an emailed link.
//
// Already signed in, it skips the form (spec §9.2): 303 to return_to or
// the deck list. The admission recheck runs FIRST (currentUser), so an
@@ -621,9 +720,14 @@ func (a *app) signin(w http.ResponseWriter, r *http.Request) {
App: "Slides",
Sky: sky.For(r.Host),
Action: signinPath,
- Hidden: []chrome.Hidden{{Name: "force", Value: "1"}},
OpenSource: &chrome.Link{Href: "/help/open-source", Label: rastrillo.T(r, "slides.credits.title")},
}
+ if q.Get("force") != "" {
+ // A failed Keymail exchange lands here as ?force=1&err=keymail.
+ // Posting force back asks for an emailed link instead, so the
+ // next attempt does not return to the Keymail that just failed.
+ form.Hidden = []chrome.Hidden{{Name: "force", Value: "1"}}
+ }
if hasReturn {
form.Return = ret
}
@@ -637,6 +741,11 @@ func (a *app) signin(w http.ResponseWriter, r *http.Request) {
form.AddressError = rastrillo.T(r, "slides.signin.error.address")
case "expired":
form.Error = rastrillo.T(r, "slides.signin.error.expired")
+ case "refused":
+ // Keymail or a link verified the address and the allowlist does
+ // not hold it (doorOnRefusal). The form below is the way on: a
+ // different address.
+ form.Error = rastrillo.T(r, "slides.signin.error.refused")
case "removed":
// Not "sign-in did not complete": this address had access and
// no longer does (offboard). Asking for another link will not
diff --git a/internal/slides/config.go b/internal/slides/config.go
index 85c0af4..3374f11 100644
--- a/internal/slides/config.go
+++ b/internal/slides/config.go
@@ -91,21 +91,22 @@ type Config struct {
// normalizeAddress).
//
// It is wired to two places on purpose: rauth.Config.Authorize
- // gates admission, and gateBegin gates POST /signin ahead of
- // rauth's own Begin. The second is the one that matters —
- // Authorize fires only inside rauth's admit(), after the link has
- // already been mailed, so Authorize alone would leave this
- // instance's sending identity an open relay for any address a
- // stranger types into the form.
+ // gates admission, after Keymail or a link has verified the
+ // address, and admissionMailer (auth.go) drops the mail for an
+ // address not on it. The second is what keeps this instance's
+ // sending identity from being an open relay for any address a
+ // stranger types into the form, since Authorize fires only after
+ // a link has been mailed and clicked. Neither changes what POST
+ // /signin answers (gateBegin).
//
// Empty is allowed only in development, where it means "admit
// everyone" so the dev script and the suite need no setup.
// Anywhere else it is a startup refusal rather than "admit
// nobody": an instance admitting nobody boots looking perfectly
- // healthy — /signin renders, POST /signin answers its ordinary 303
- // for every address, because that identical answer is the whole
- // point of gateBegin — and the only symptom is that no link ever
- // arrives.
+ // healthy — /signin renders, POST /signin answers as it would for
+ // anyone, because an answer that does not depend on the list is
+ // the whole point of gateBegin — and the only symptom is that no
+ // link ever arrives.
AllowedAddresses []string
// Blobs is where uploaded bytes live — the object store, never the
diff --git a/internal/slides/keymail_home_test.go b/internal/slides/keymail_home_test.go
new file mode 100644
index 0000000..66841cf
--- /dev/null
+++ b/internal/slides/keymail_home_test.go
@@ -0,0 +1,109 @@
+package slides
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "html"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "regexp"
+ "strings"
+ "testing"
+
+ "amadan.net/oficina/home/connect"
+ "amadan.net/oficina/slides/internal/slides/keymailtest"
+ assertioncrypto "amadan.net/rastrillo/rastrillo/crypto"
+)
+
+// TestMain keeps Keymail classification inside the process for this
+// package's tests: every DNS query is answered NXDOMAIN and only the
+// Keymail fixtures a test registers answer HTTPS (keymailtest).
+func TestMain(m *testing.M) {
+ restore, err := keymailtest.Install()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "keymailtest.Install:", err)
+ os.Exit(1)
+ }
+ code := m.Run()
+ restore()
+ os.Exit(code)
+}
+
+// TestHomeLocalKeymailIsAContinuationAndRefusesAtTheDoor is the
+// Home-mode half of the Keymail sign-in: with Home configured, the
+// local form (/signin/local, posting to /signin under LocalGuard) sends
+// a permitted and a non-permitted Keymail address to the same
+// continuation page — never a 303 out of the form POST, whose
+// form-action is 'self' plus the Home issuer — and a non-permitted one
+// that Keymail verified is refused on the local door, not in bare text.
+func TestHomeLocalKeymailIsAContinuationAndRefusesAtTheDoor(t *testing.T) {
+ const host = "keymail.home.test"
+ member, stranger := "owner@"+host, "stranger@"+host
+ km := keymailtest.Serve(t, host, member, stranger)
+
+ key, err := assertioncrypto.Generate()
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, _ := json.Marshal(map[string]string{"test": base64.StdEncoding.EncodeToString(key.SignPub())})
+ mail := &homeTestMail{}
+ mux, a, err := New(testDB(t), Config{Mailer: mail, Env: EnvDevelopment, Origin: homeTestOrigin, Logger: quietLogger(),
+ AllowedAddresses: []string{member},
+ Home: &connect.Credentials{Issuer: "https://home.example.com", ConsumerID: "slides-test",
+ KeysBase64: base64.StdEncoding.EncodeToString(raw), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{8}, 32))}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { a.Close() })
+
+ do := func(method, path string, form url.Values, cookies []*http.Cookie) *httptest.ResponseRecorder {
+ r := httptest.NewRequest(method, homeTestOrigin+path, strings.NewReader(form.Encode()))
+ if method == http.MethodPost {
+ r.Header.Set("Origin", homeTestOrigin)
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ }
+ for _, c := range cookies {
+ r.AddCookie(c)
+ }
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, r)
+ return w
+ }
+ href := regexp.MustCompile(`href="(https://` + regexp.QuoteMeta(host) + `/oauth/authorize\?[^"]+)"`)
+ begin := func(address string) (*httptest.ResponseRecorder, *url.URL) {
+ rec := do(http.MethodPost, "/signin", url.Values{"address": {address}}, nil)
+ if rec.Code != http.StatusOK || rec.Header().Get("Location") != "" {
+ t.Fatalf("POST /signin %q in Home mode: %d %q, want 200 and the continuation page", address, rec.Code, rec.Header().Get("Location"))
+ }
+ m := href.FindStringSubmatch(rec.Body.String())
+ if m == nil || !strings.Contains(rec.Body.String(), `http-equiv="refresh"`) {
+ t.Fatalf("POST /signin %q in Home mode: no Keymail continuation:\n%s", address, rec.Body.String())
+ }
+ u, err := url.Parse(html.UnescapeString(m[1]))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return rec, u
+ }
+
+ _, _ = begin(member)
+ rec, u := begin(stranger)
+ if mail.body != "" {
+ t.Fatal("a Keymail address was mailed a link")
+ }
+
+ cb := do(http.MethodGet, "/auth/callback?"+url.Values{"state": {u.Query().Get("state")}, "code": {km.Approve(stranger)}}.Encode(), nil, rec.Result().Cookies())
+ if cb.Code != http.StatusSeeOther || cb.Header().Get("Location") != "/signin/local?err=refused" {
+ t.Fatalf("Home-mode callback for a non-permitted address: %d %q, want 303 /signin/local?err=refused\n%s", cb.Code, cb.Header().Get("Location"), cb.Body.String())
+ }
+ // This mux has no locale middleware, so the catalog key itself is
+ // what proves the refusal sentence was chosen.
+ door := do(http.MethodGet, "/signin/local?err=refused", nil, nil)
+ if body := door.Body.String(); door.Code != http.StatusOK || !strings.Contains(body, "slides.signin.error.refused") || !strings.Contains(body, `name="address"`) {
+ t.Fatalf("refusal door: %d\n%s", door.Code, body)
+ }
+}
diff --git a/internal/slides/keymailtest/keymailtest.go b/internal/slides/keymailtest/keymailtest.go
new file mode 100644
index 0000000..92ad056
--- /dev/null
+++ b/internal/slides/keymailtest/keymailtest.go
@@ -0,0 +1,190 @@
+// Package keymailtest keeps sign-in's Keymail classification inside the
+// test process. It is imported by tests only.
+//
+// rastrillo/auth (at the version this app pins) builds its classifier
+// with no seams: TXT lookups go through net.DefaultResolver and the
+// well-known probe, the federation lookup and the token exchange go
+// through http.DefaultTransport. So those two globals are the hooks,
+// exactly as oficina/docs' auth_keymail_test.go uses them. Install
+// replaces both for the life of the test binary:
+//
+// - every DNS query is answered NXDOMAIN by a resolver on loopback,
+// so no DNS packet leaves the process, and a domain with no
+// `_keymail` record is probed directly — keymail.dev's own shape;
+// - an https request to a host registered with Serve is answered
+// in-process by that fixture; anything else falls through to the
+// real transport, where the resolver above makes a name
+// unreachable (IP literals, which httptest servers use, still work).
+//
+// Every address at a domain nobody registered therefore classifies as
+// "not Keymail", which is what every pre-existing sign-in test assumes.
+package keymailtest
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "time"
+)
+
+var (
+ mu sync.Mutex
+ servers = map[string]*Server{}
+)
+
+// Install swaps net.DefaultResolver and http.DefaultTransport for the
+// stubs described in the package comment and returns a function that
+// puts them back. Call it once, from TestMain.
+func Install() (restore func(), err error) {
+ dns, err := net.ListenPacket("udp", "127.0.0.1:0")
+ if err != nil {
+ return nil, err
+ }
+ go answerNXDOMAIN(dns)
+ oldResolver, oldTransport := net.DefaultResolver, http.DefaultTransport
+ net.DefaultResolver = &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ return (&net.Dialer{}).DialContext(ctx, "udp", dns.LocalAddr().String())
+ }}
+ http.DefaultTransport = transport{next: oldTransport}
+ return func() {
+ net.DefaultResolver, http.DefaultTransport = oldResolver, oldTransport
+ _ = dns.Close()
+ }, nil
+}
+
+// answerNXDOMAIN echoes each query back as a "no such name" answer.
+func answerNXDOMAIN(conn net.PacketConn) {
+ b := make([]byte, 4096)
+ for {
+ n, addr, err := conn.ReadFrom(b)
+ if err != nil {
+ return
+ }
+ if n < 12 {
+ continue
+ }
+ reply := append([]byte(nil), b[:n]...)
+ reply[2], reply[3] = 0x81, 0x83 // response, recursion, NXDOMAIN
+ for i := 6; i < 12; i++ {
+ reply[i] = 0 // no answer, authority or additional records
+ }
+ _, _ = conn.WriteTo(reply, addr)
+ }
+}
+
+type transport struct{ next http.RoundTripper }
+
+func (t transport) RoundTrip(r *http.Request) (*http.Response, error) {
+ mu.Lock()
+ s := servers[r.URL.Host]
+ mu.Unlock()
+ if s == nil || r.URL.Scheme != "https" {
+ return t.next.RoundTrip(r)
+ }
+ rec := httptest.NewRecorder()
+ s.ServeHTTP(rec, r)
+ res := rec.Result()
+ res.Request = r
+ return res, nil
+}
+
+// Server is one Keymail deployment at one host: the well-known
+// document, the federation lookup for the accounts it holds, and the
+// token exchange for the codes Approve hands out.
+type Server struct {
+ host string
+
+ mu sync.Mutex
+ accounts map[string]bool
+ codes map[string]string
+ lookups int
+ tokens int
+}
+
+// Serve registers a Keymail deployment at host (which is also the
+// address domain it answers for, since nothing publishes a `_keymail`
+// record) holding the given accounts. It is unregistered when the test
+// ends.
+func Serve(t interface{ Cleanup(func()) }, host string, accounts ...string) *Server {
+ s := &Server{host: host, accounts: map[string]bool{}, codes: map[string]string{}}
+ for _, a := range accounts {
+ s.accounts[strings.ToLower(a)] = true
+ }
+ mu.Lock()
+ servers[host] = s
+ mu.Unlock()
+ t.Cleanup(func() {
+ mu.Lock()
+ delete(servers, host)
+ mu.Unlock()
+ })
+ return s
+}
+
+// Approve is Keymail's consent screen saying yes for address: it
+// returns the code the browser would carry back to /auth/callback.
+func (s *Server) Approve(address string) string {
+ var b [12]byte
+ _, _ = rand.Read(b[:])
+ code := hex.EncodeToString(b[:])
+ s.mu.Lock()
+ s.codes[code] = address
+ s.mu.Unlock()
+ return code
+}
+
+// Lookups is how many federation lookups reached this deployment.
+func (s *Server) Lookups() int {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.lookups
+}
+
+// Tokens is how many token exchanges reached this deployment.
+func (s *Server) Tokens() int {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.tokens
+}
+
+func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/.well-known/keymail":
+ _ = json.NewEncoder(w).Encode(map[string]any{"version": "1", "reauth": true})
+ case "/api/federation/lookup":
+ s.mu.Lock()
+ s.lookups++
+ ok := s.accounts[strings.ToLower(r.URL.Query().Get("addr"))]
+ s.mu.Unlock()
+ if !ok {
+ w.WriteHeader(http.StatusNotFound)
+ }
+ _ = json.NewEncoder(w).Encode(map[string]bool{"ok": ok})
+ case "/api/oauth/token":
+ var in map[string]string
+ _ = json.NewDecoder(r.Body).Decode(&in)
+ s.mu.Lock()
+ s.tokens++
+ address, ok := s.codes[in["code"]]
+ delete(s.codes, in["code"])
+ s.mu.Unlock()
+ if r.Method != http.MethodPost || !ok {
+ w.WriteHeader(http.StatusBadRequest)
+ _ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid_grant"})
+ return
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "access_token": "synthetic", "token_type": "bearer",
+ "address": address, "auth_time": time.Now().Unix(),
+ })
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ }
+}
diff --git a/internal/slides/locales/en.toml b/internal/slides/locales/en.toml
index 64292ef..1afb195 100644
--- a/internal/slides/locales/en.toml
+++ b/internal/slides/locales/en.toml
@@ -10,7 +10,8 @@
# slides.signin.* — GET /signin (auth.go's signin handler): the errors
# estilo's sign-in door shows, on the way back from rastrillo/auth, for
# whichever outcome it was redirected here with
-# (?err=rate|address|expired|keymail|removed|1). The door's own words
+# (?err=rate|address|expired|keymail|removed|refused|1), and the card
+# between the form and Keymail (slides.signin.keymail.*). The door's own words
# (form, check-your-email, signed out) are estilo's chrome.English, not
# keys here, so no app can drift from the others (shared chrome spec §9.2).
#
@@ -24,6 +25,10 @@ slides.signin.error.address = "That doesn’t look like an email address."
slides.signin.error.expired = "That link has expired or was already used. Enter your email to get a new one."
slides.signin.error.unknown = "We couldn’t sign you in. Enter your email to get a new link."
slides.signin.error.removed = "Your access has been removed, so you’ve been signed out. Ask whoever runs this team if that’s wrong."
+slides.signin.error.refused = "That address can’t sign in to Slides. Use a different address, or ask whoever runs this team to add it."
+slides.signin.keymail.title = "Continue signing in"
+slides.signin.keymail.body = "Taking you to Keymail to finish signing in."
+slides.signin.keymail.action = "Continue to Keymail"
# slides.chrome.* — estilo's shared bar and account menu (chrome.go).
slides.chrome.skip = "Skip to content"
diff --git a/internal/slides/render.go b/internal/slides/render.go
index d20fb12..58a9acc 100644
--- a/internal/slides/render.go
+++ b/internal/slides/render.go
@@ -146,6 +146,9 @@ func init() {
switch name {
case "credits":
t = template.Must(t.ParseFS(credits.Templates(), "templates/*.html"))
+ case "signin":
+ // The Keymail continuation's meta refresh (home-oauth-head).
+ t = template.Must(t.ParseFS(connect.Templates(), "templates/oauth.html"))
case "home_link":
t = template.Must(t.ParseFS(connect.Templates(), "templates/home_link.html"))
}
diff --git a/internal/slides/templates/signin.html b/internal/slides/templates/signin.html
index 0fb9491..c5f5f0e 100644
--- a/internal/slides/templates/signin.html
+++ b/internal/slides/templates/signin.html
@@ -1,15 +1,18 @@
{{/* signin — GET /signin (auth.go's signin handler): estilo's sign-in
form, or once a link is on its way, the check-your-email state that
- replaces it. rastrillo/auth's own handlers never render a page
- (magic-links.md: "the sign-in page stays yours"): Begin, Callback and
- Verify report every outcome by redirecting back here with a query the
- handler reads.
+ replaces it, or the card between the form and Keymail. rastrillo/auth's
+ own handlers never render a page (magic-links.md: "the sign-in page
+ stays yours"): Begin, Callback and Verify report every outcome by
+ redirecting back here with a query the handler reads.
- The form POSTs straight to rastrillo/auth's own Begin behind
- gateBegin (app.go). The hidden force=1 says "plain magic link": this
- app never offers a keymail or passkey ceremony, and gateBegin sets it
- again server-side, so the guarantee does not depend on this field.
+ The form POSTs to rastrillo/auth's own Begin through gateBegin
+ (app.go, auth.go). An address whose domain runs Keymail is answered
+ with this page again in its Keymail state (.Continue): the door's
+ card, and a meta refresh that goes on to Keymail — a navigation from a
+ page, which form-action does not govern. The form carries force=1 only
+ on ?force=1 (a failed Keymail exchange), to ask for an emailed link.
The bar shows the brand only: nobody is signed in here to have an
account, and everything the brand could link to is behind the
session gate. Data: signinView. */}}
+{{define "page-head"}}{{with .Continue}}{{template "home-oauth-head" .}}{{end}}{{end}}
{{define "content"}}{{with .Sent}}{{template "estilo-signin-sent" .}}{{else}}{{template "estilo-signin" .SignIn}}{{end}}{{end}}
diff --git a/internal/slidestest/keymail_signin_test.go b/internal/slidestest/keymail_signin_test.go
new file mode 100644
index 0000000..33fdc58
--- /dev/null
+++ b/internal/slidestest/keymail_signin_test.go
@@ -0,0 +1,218 @@
+package slidestest
+
+import (
+ "fmt"
+ "html"
+ "net/http"
+ "net/url"
+ "os"
+ "regexp"
+ "strings"
+ "testing"
+
+ "amadan.net/oficina/slides/internal/slides/keymailtest"
+)
+
+// TestMain keeps every sign-in in this package inside the process: no
+// DNS query or HTTPS probe made by Keymail classification leaves it
+// (keymailtest's package comment says how). An address at a domain no
+// test registered classifies as plain email.
+func TestMain(m *testing.M) {
+ restore, err := keymailtest.Install()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "keymailtest.Install:", err)
+ os.Exit(1)
+ }
+ code := m.Run()
+ restore()
+ os.Exit(code)
+}
+
+// The Keymail deployment these tests sign in through: keymail.dev's
+// shape, a domain that is its own Keymail (no `_keymail` record).
+const (
+ keymailHost = "keymail.slides.test"
+ keymailMember = "paul@" + keymailHost
+ keymailStranger = "stranger@" + keymailHost
+)
+
+// newKeymailApp is newApp with keymailMember on the allowlist beside
+// allowedAddress, and a Keymail deployment holding both keymail
+// addresses — one permitted here, one not.
+func newKeymailApp(t *testing.T) (*testApp, *keymailtest.Server) {
+ t.Helper()
+ km := keymailtest.Serve(t, keymailHost, keymailMember, keymailStranger)
+ cfg, mailer := newConfig(t)
+ cfg.AllowedAddresses = append(cfg.AllowedAddresses, keymailMember)
+ return newAppWith(t, cfg, mailer), km
+}
+
+var authorizeHref = regexp.MustCompile(`href="(https://[^"]+/oauth/authorize\?[^"]+)"`)
+
+// continuation asserts rec is the page between the form and Keymail —
+// a 200 document, never a redirect — and returns the authorize URL it
+// carries.
+func continuation(t *testing.T, label string, rec interface {
+ Result() *http.Response
+}, body string) *url.URL {
+ t.Helper()
+ res := rec.Result()
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("%s: status %d, want 200 — the hop to Keymail must be a page, not a redirect from the form POST (Safari applies form-action to it)", label, res.StatusCode)
+ }
+ if loc := res.Header.Get("Location"); loc != "" {
+ t.Fatalf("%s: Location %q, want none", label, loc)
+ }
+ m := authorizeHref.FindStringSubmatch(body)
+ if m == nil {
+ t.Fatalf("%s: no Keymail authorize link on the page:\n%s", label, body)
+ }
+ u, err := url.Parse(html.UnescapeString(m[1]))
+ if err != nil {
+ t.Fatalf("%s: parse authorize link: %v", label, err)
+ }
+ if u.Host != keymailHost {
+ t.Errorf("%s: authorize link goes to %q, want %q", label, u.Host, keymailHost)
+ }
+ if !strings.Contains(body, `http-equiv="refresh"`) {
+ t.Errorf("%s: the continuation does not navigate on by itself (no meta refresh)", label)
+ }
+ return u
+}
+
+// TestKeymailAddressGoesToKeymailWhetherPermittedOrNot is the oracle
+// test. Whether an address goes to Keymail depends on its domain alone:
+// a permitted and a non-permitted Keymail address get the same first
+// answer, a continuation page to Keymail, so the answer says nothing
+// about the allowlist. Neither is mailed anything.
+func TestKeymailAddressGoesToKeymailWhetherPermittedOrNot(t *testing.T) {
+ app, km := newKeymailApp(t)
+
+ member := post(t, app, "/signin", url.Values{"address": {keymailMember}})
+ stranger := post(t, app, "/signin", url.Values{"address": {keymailStranger}})
+
+ mu := continuation(t, "permitted Keymail address", member, member.Body.String())
+ su := continuation(t, "non-permitted Keymail address", stranger, stranger.Body.String())
+
+ // The same page but for the per-attempt state and PKCE challenge.
+ mask := func(body string, u *url.URL) string {
+ return strings.ReplaceAll(body, html.EscapeString(u.String()), "AUTHORIZE")
+ }
+ if a, b := mask(member.Body.String(), mu), mask(stranger.Body.String(), su); a != b {
+ t.Errorf("the two Keymail answers differ beyond the authorize URL:\npermitted:\n%s\nnot permitted:\n%s", a, b)
+ }
+ cookieNames := func(res *http.Response) string {
+ var n []string
+ for _, c := range res.Cookies() {
+ n = append(n, c.Name)
+ }
+ return strings.Join(n, ",")
+ }
+ if a, b := cookieNames(member.Result()), cookieNames(stranger.Result()); a != b {
+ t.Errorf("cookies differ: permitted %q, not permitted %q", a, b)
+ }
+ if n := len(app.mailer.messages()); n != 0 {
+ t.Errorf("mailer captured %d messages, want 0: a Keymail address is never mailed", n)
+ }
+ if n := km.Lookups(); n != 2 {
+ t.Errorf("Keymail saw %d federation lookups, want 2 (both addresses classified)", n)
+ }
+}
+
+// TestNonPermittedPlainAddressIsAnsweredSentAndNotMailed: an address at
+// a domain with no Keymail still gets the ordinary ?sent=1, permitted
+// or not, and only the permitted one is mailed.
+func TestNonPermittedPlainAddressIsAnsweredSentAndNotMailed(t *testing.T) {
+ app, _ := newKeymailApp(t)
+ for _, address := range []string{deniedAddress, allowedAddress} {
+ rec := post(t, app, "/signin", url.Values{"address": {address}})
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" {
+ t.Fatalf("POST /signin %q: %d %q, want 303 /signin?sent=1", address, rec.Code, rec.Header().Get("Location"))
+ }
+ }
+ msgs := app.mailer.messages()
+ if len(msgs) != 1 || msgs[0].To != allowedAddress {
+ t.Fatalf("mail sent = %+v, want exactly one, to %q", msgs, allowedAddress)
+ }
+}
+
+// TestKeymailFailureFallsBackToAnEmailLink: rastrillo answers a failed
+// Keymail exchange with ?force=1&err=keymail. That page must carry
+// force back so the next POST mails a link instead of returning to the
+// Keymail that just failed — Paul is never stuck. The ordinary form
+// carries no force at all.
+func TestKeymailFailureFallsBackToAnEmailLink(t *testing.T) {
+ app, km := newKeymailApp(t)
+ forced := `name="force" value="1"`
+
+ if body := get(t, app, "/signin").Body.String(); strings.Contains(body, forced) {
+ t.Fatalf("the ordinary sign-in form forces an email link; Keymail addresses would never reach Keymail")
+ }
+ page := get(t, app, "/signin?force=1&err=keymail")
+ if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), forced) {
+ t.Fatalf("GET /signin?force=1&err=keymail: %d, carries force=%v", page.Code, strings.Contains(page.Body.String(), forced))
+ }
+
+ rec := post(t, app, "/signin", url.Values{"address": {keymailMember}, "force": {"1"}})
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" {
+ t.Fatalf("forced POST: %d %q, want 303 /signin?sent=1", rec.Code, rec.Header().Get("Location"))
+ }
+ msgs := app.mailer.messages()
+ if len(msgs) != 1 || msgs[0].To != keymailMember || magicLinkPattern.FindString(msgs[0].Body) == "" {
+ t.Fatalf("mail sent = %+v, want one magic link to %q", msgs, keymailMember)
+ }
+ if n := km.Lookups(); n != 0 {
+ t.Errorf("a forced sign-in classified the address (%d lookups)", n)
+ }
+}
+
+// keymailRoundTrip runs POST /signin → continuation → Keymail approves
+// → GET /auth/callback for address and returns the callback's answer
+// and the cookies collected along the way.
+func keymailRoundTrip(t *testing.T, app *testApp, km *keymailtest.Server, address string) (*http.Response, []*http.Cookie) {
+ t.Helper()
+ begin := post(t, app, "/signin", url.Values{"address": {address}})
+ u := continuation(t, address, begin, begin.Body.String())
+ cookies := begin.Result().Cookies()
+ cb := get(t, app, "/auth/callback?"+url.Values{"state": {u.Query().Get("state")}, "code": {km.Approve(address)}}.Encode(), cookies...)
+ return cb.Result(), append(cookies, cb.Result().Cookies()...)
+}
+
+// TestKeymailMemberIsSignedIn: the whole Keymail round trip for a
+// permitted address ends signed in.
+func TestKeymailMemberIsSignedIn(t *testing.T) {
+ app, km := newKeymailApp(t)
+ res, cookies := keymailRoundTrip(t, app, km, keymailMember)
+ if res.StatusCode != http.StatusSeeOther {
+ t.Fatalf("callback: %d, want 303 into the app", res.StatusCode)
+ }
+ if rec := get(t, app, "/", cookies...); rec.Code != http.StatusOK {
+ t.Fatalf("GET / after Keymail sign-in: %d, want 200", rec.Code)
+ }
+}
+
+// TestKeymailRefusalIsTheDoor: a non-permitted address that Keymail
+// has verified is refused AFTER verification, and the refusal is the
+// sign-in door with an error and a form for a different address — not
+// rastrillo's bare-text 403. No session is minted.
+func TestKeymailRefusalIsTheDoor(t *testing.T) {
+ app, km := newKeymailApp(t)
+ res, cookies := keymailRoundTrip(t, app, km, keymailStranger)
+ if res.StatusCode != http.StatusSeeOther {
+ t.Fatalf("callback for a non-permitted address: %d, want 303 to the door (bare text is the bug)", res.StatusCode)
+ }
+ loc := res.Header.Get("Location")
+ if loc != "/signin?err=refused" {
+ t.Fatalf("callback for a non-permitted address: Location %q, want /signin?err=refused", loc)
+ }
+ door := get(t, app, loc, cookies...)
+ body := door.Body.String()
+ for _, want := range []string{"estilo-door", `name="address"`, "can’t sign in to Slides"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("refusal page lacks %q:\n%s", want, body)
+ }
+ }
+ if rec := get(t, app, "/", cookies...); rec.Code == http.StatusOK {
+ t.Fatal("a refused Keymail sign-in left a working session")
+ }
+}
diff --git a/internal/slidestest/signin_test.go b/internal/slidestest/signin_test.go
index 5ec4677..02c5011 100644
--- a/internal/slidestest/signin_test.go
+++ b/internal/slidestest/signin_test.go
@@ -91,13 +91,13 @@ func TestSigninMailsAllowedAddressOnly(t *testing.T) {
// the no-oracle guarantee, and the case the first version of gateBegin
// got wrong.
//
-// A denied address is short-circuited before rastrillo/auth's Begin, so
-// it can answer ?sent=1 forever. A permitted address reaches Begin,
-// which enforces a per-address budget of 5 in a rolling window and
-// redirects to ?err=rate once it is spent. So without the fix, six
-// POSTs of one candidate address separate "on the list" from "not on
-// it" — the whole allowlist, enumerable by anyone, one address at a
-// time.
+// That version short-circuited a denied address before rastrillo/auth's
+// Begin, so it answered ?sent=1 forever, while a permitted address
+// reached Begin's per-address budget of 5 and got ?err=rate: six POSTs
+// of one candidate address separated "on the list" from "not on it".
+// Every address reaches Begin now (gateBegin), so the budgets are the
+// same for both; sameAnswer still answers ?err=rate as sent, and this
+// pins that.
//
// Seven POSTs, every one byte-identical to what a denied address gets.
// The mailer stops at 5, which is the other half of the proof: the