Skip to content

feat: v0.20 adapter: recovery sessions, account notices, placeholder secrets and console headers - #13

Merged
Bccorb merged 5 commits into
mainfrom
feat/v020-adapter
Oct 25, 2026
Merged

Bccorb merged 5 commits into
mainfrom
feat/v020-adapter

Conversation

@Bccorb

@Bccorb Bccorb commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

The v0.20 work for the Rust adapter: the console proxy headers, placeholder secrets, recovery sessions and account recovery notices. One commit per item, plus a follow-up that cancels a recovery request when no notice was delivered.

#11: console proxy headers

  • console_router now also forwards the API's X-Frame-Options, Content-Security-Policy and X-Content-Type-Options, so the console served on the app origin cannot be framed. Other upstream headers are still dropped.

Placeholder secrets (fells-code/seamless-auth-server#231)

  • build() refuses dev-only-cookie-signing-key-replace-me, GRAB_FROM_SEAMLESS_AUTH_PORTAL_DEV and anything containing replace-me (case-insensitive) for the cookie secret or the service secret, with the new BuildError::PlaceholderCookieSecret / PlaceholderServiceSecret.
  • They are still allowed with insecure_cookies(true), the adapter's local development mode, and a warning is logged.

Recovery sessions (fells-code/seamless-auth-server#232)

  • User gains recovery, read from the access token's recovery: true claim (cookie and bearer).
  • require_auth() answers a recovery session with 403 {"error":"enrollment_required"}. require_auth().allow_recovery(true) lets one through. authenticate() returns the user with recovery set and leaves the check to the caller (documented).
  • In cookie transport, after a recovery session gets a successful POST /webauthn/register/finish or POST /totp/enroll/verify, the adapter refreshes in the same response, so the access cookie drops the claim. It goes through the shared refresher. If a silent refresh already rotated the cookie earlier in the request, it spends the token that refresh just issued, and the response carries one access and one refresh cookie. A failed refresh leaves the enrollment answer as it was. Bearer clients keep their own tokens and are documented to call POST /auth/refresh after enrolling.

#12: account recovery delivery (fells-code/seamless-auth-api#500, @seamless-auth/types 0.31.0)

  • Delivery gains purpose, notice, subject, text and cancel_url, plus is_account_recovery(). Debug redacts text and cancel_url, since both carry the cancel credential.
  • New Builder::deliver_account_notices(bool), default false. The deliver callback receives every kind, so the adapter cannot tell for itself whether an app delivers notices. With the option on, routes the manifest marks notices: true (today POST /recovery/verify) send external delivery with the delivery service token plus x-seamless-auth-delivery-kinds: account_notice_email,account_notice_sms. Every item in deliveries goes to deliver, and deliveries is stripped from the answer in both transports. Every notice is tried, and one delivered is enough to answer normally. If none was delivered, the API has already opened the request, so the adapter cancels it (POST /recovery/cancel with { "cancelToken" } taken from the fragment of a notice's cancelUrl, with the proxy service token and no credential) and answers 503 recovery_notice_failed (fix(recovery): do not open an account recovery request unless the owner was notified seamless-auth-api#497). The cancel link and token are never logged.
  • With the option off, the notices route is not sent in external mode, so the API sends the notices itself. The API counts a returned notice as the owner being told, so the header is only sent when the app says it delivers both kinds.
  • A delivery of a kind this version does not know is logged (kind only) and skipped. It never reaches the callback, and it does not fail parsing or the request.
  • ManifestRoute gains notices (serde default; the manifest has no deny_unknown_fields, and a test covers unknown fields). The bundled manifest.json is synced from the API with scripts/sync-manifest.sh, which adds the /recovery/*, /recovery-codes*, /recovery/requests* and /system-config/policy* routes.
  • Tests cover these passing through unchanged: 503 recovery_notice_failed on /recovery/verify, 403 step_up_required bodies on admin writes, and every new access, pre-auth and public recovery and policy route. /recovery-codes/redeem and /recovery/complete issue session cookies.

Checks

$ cargo fmt --check
(clean)
$ cargo clippy --all-targets -- -D warnings
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.69s
$ cargo clippy --all-targets --no-default-features -- -D warnings
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.46s
$ cargo clippy --all-targets --no-default-features --features native-tls -- -D warnings
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.69s
$ cargo test --locked
test result: ok. 80 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
test result: ok. 1 passed; 0 failed; 0 ignored (doctests)
$ cargo +1.88 test
test result: ok. 80 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
test result: ok. 1 passed; 0 failed; 0 ignored (doctests)

I did not run the conformance suite locally. CI runs it. The conformance secrets are not placeholders, so the new startup check does not affect them.

Closes #11
Closes #12

…console proxy

The console proxy forwarded only Content-Type, Cache-Control, ETag and
Last-Modified, so the API's X-Frame-Options, Content-Security-Policy and
X-Content-Type-Options were dropped and the console served on the app origin
could be framed. Forward those three headers too.

Closes #11
The starters' example cookie secret and the portal's service token
placeholder are long enough to pass the length check, so a deployment that
never replaced them booted. build() now refuses
dev-only-cookie-signing-key-replace-me, GRAB_FROM_SEAMLESS_AUTH_PORTAL_DEV
and anything containing replace-me for either secret, unless
insecure_cookies is on for local development.

Refs fells-code/seamless-auth-server#231
… enrollment

An access token from a recovery code or self-service recovery carries
recovery: true. require_auth now answers it with 403 enrollment_required
unless the layer is built with allow_recovery(true), and User gains a
recovery field so authenticate() callers can tell.

After a recovery session finishes passkey registration or TOTP enrollment in
cookie transport, the adapter refreshes the session in the same response so
the access cookie drops the claim. A failed refresh leaves the enrollment
answer as it was.

Refs fells-code/seamless-auth-server#232
…overy routes

Delivery carries the account notice kinds (account_notice_email and
account_notice_sms, with subject, text, notice and cancel_url) and the
purpose an OTP code may carry. Debug output redacts text and cancel_url,
which hold the cancel credential.

With deliver_account_notices(true), routes the manifest marks notices
(POST /recovery/verify) ask for external delivery with
x-seamless-auth-delivery-kinds, hand every item in deliveries to the
deliver callback and strip deliveries from the answer. Without it the auth
API sends the notices itself, since it counts a returned notice as the owner
being told. A delivery of a kind this version does not know is logged and
skipped instead of reaching the callback.

The bundled manifest is synced from the auth API, adding the recovery,
recovery code and staged policy routes, and ManifestRoute gains notices.

Closes #12
Once the auth API returns deliveries it has opened the recovery request and
counted the owner as notified, so answering 502 left the request running
while the owner heard nothing. Every notice is still tried; one delivered is
enough to answer normally. If none was delivered, the adapter cancels the
request through POST /recovery/cancel with the token from a notice's cancel
link, and answers 503 recovery_notice_failed. The link and token are never
logged.

Refs fells-code/seamless-auth-api#497
@Bccorb
Bccorb merged commit 7ace399 into main Oct 25, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant