Skip to content

feat: v0.20 recovery sessions, account notice delivery and console headers - #14

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 Python adapter: the console proxy headers, placeholder secrets, recovery sessions, and account recovery notice delivery.

#12: console proxy headers

  • The console proxy now also forwards the API's X-Frame-Options, Content-Security-Policy and X-Content-Type-Options, on GET and HEAD, so the console served on the app origin cannot be framed.

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

  • Adapter refuses cookie_secret or service_secret set to dev-only-cookie-signing-key-replace-me, GRAB_FROM_SEAMLESS_AUTH_PORTAL_DEV, or anything containing replace-me (case-insensitive).
  • They are allowed only with insecure_cookies=True, the adapter's local development mode.

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

  • The guard refuses an access token carrying recovery: true (from the cookie or a bearer token) by raising EnrollmentRequired, a subclass of Unauthenticated. Only a literal true counts.
    • Django require_auth answers 403 {"error": "enrollment_required"}.
    • FastAPI RequireUser answers 403 {"detail": "enrollment_required"}, matching its existing 401 and 403 bodies.
  • Opt-in options: RequireUser(auth, allow_recovery=True), @require_auth(allow_recovery=True) and authenticate(..., allow_recovery=True). User.recovery reports the state.
  • In cookie transport, when a recovery session gets a 2xx from POST /webauthn/register/finish or POST /totp/enroll/verify, the adapter refreshes in the same response, so the access cookie drops the claim.
    • If the request already refreshed silently, it uses the new refresh token.
    • If that refresh fails, the enrollment response is returned unchanged.
  • In bearer transport the client holds its own refresh token and calls POST /auth/refresh itself. This is documented.

#13: account recovery delivery

  • New option deliver_account_notices (default False, needs deliver). With it set, a manifest route marked notices: true (today POST /recovery/verify) is called with external delivery, the delivery service token, and x-seamless-auth-delivery-kinds: account_notice_email,account_notice_sms.
  • The adapter delivers every item in deliveries and strips deliveries from the response in both transports.
    • If at least one notice is delivered, the client gets the API's body.
    • If none is delivered, the adapter cancels the request with POST /recovery/cancel {"cancelToken": <fragment of cancelUrl>} (proxy service token, no credential) and answers 503 {"error": "recovery_notice_failed"}.
  • With the option off, external mode and the kinds header are not sent on that route, so the API sends the notices itself.
  • Delivery gains notice, subject, text, cancel_url and purpose, plus is_account_recovery.
    • cancel_url and text (which contains the link) are hidden from repr.
    • Delivery failures are logged by exception type only, so neither the cancel URL nor its token is ever logged.
  • otp_email and otp_sms with purpose: "account_recovery" reach the callback with that purpose. The README asks apps to word them as a recovery code.
  • An unknown delivery kind is logged and skipped.
  • The manifest parser keeps the new optional notices field. The bundled manifest is synced from the API's committed copy (apiVersion 0.19.4), which adds the /recovery/*, /recovery-codes*, GET /recovery/requests, DELETE /recovery/requests/{id} and /system-config/policy* routes.
  • Tests cover these pass-throughs: 503 recovery_notice_failed, 403 step_up_required, every new access route, and the pre-auth recovery routes, including /recovery/complete issuing a recovery session.

Checks

$ uv run ruff check .
All checks passed!
$ uv run ruff format --check .
34 files already formatted
$ uv run mypy
Success: no issues found in 13 source files
$ uv run pytest
============================= 227 passed in 0.66s ==============================

Each commit was also checked on its own (ruff, mypy, pytest). The conformance suite was not run locally; it runs in CI.

Closes #12
Closes #13

…ole 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
application's origin could be framed. Forward those three as well.

Closes #12
A placeholder such as the starters' dev-only-cookie-signing-key-replace-me is
long enough to pass the length check, and it is public. Refuse it, the
GRAB_FROM_SEAMLESS_AUTH_PORTAL_DEV example, and anything containing
replace-me, for both cookie_secret and service_secret, unless
insecure_cookies is set for local development.

Refs fells-code/seamless-auth-server#231
An access token from a recovery code or self-service recovery carries
recovery: true, and the auth API refuses it on everything but enrolling a
new factor. The guard now refuses it too, raising EnrollmentRequired (a
subclass of Unauthenticated) that the bindings answer 403
enrollment_required, so an application does not treat a half-recovered
account as signed in. RequireUser(allow_recovery=True),
@require_auth(allow_recovery=True) and authenticate(allow_recovery=True)
admit it, and User.recovery reports it.

When a recovery session in cookie transport completes passkey registration
or TOTP enrollment, the adapter refreshes in the same response so the access
cookie drops the claim. A bearer client refreshes itself.

Refs fells-code/seamless-auth-server#232
…outes

The auth API tells an account's owner when recovery opens, and hands the
notices back to an adapter that owns delivery and names their kinds.

- With deliver and the new deliver_account_notices=True, a manifest route
  marked notices (today POST /recovery/verify) asks for external delivery
  with x-seamless-auth-delivery-kinds:
  account_notice_email,account_notice_sms, delivers every item in the
  response's deliveries, and strips them before answering. Without the
  option the header is not sent and the API sends the notices itself,
  because it counts a returned notice as the owner being told.
- Delivery gains notice, subject, text and cancel_url (hidden from repr,
  never logged), and purpose, which is account_recovery on a recovery code.
- An unknown delivery kind is logged and skipped. A failed notice no longer
  stops the others, and a delivery error is logged by type only.
- The manifest parser keeps the optional notices field, and the bundled
  manifest is synced from the API (0.19.4), which adds the recovery,
  recovery-code and system-config policy routes. 503 recovery_notice_failed
  and 403 step_up_required bodies pass through unchanged.

Closes #13
The auth API opens a recovery request once it hands the notices back, and
counts the owner as told. If the application delivers none of them, the
owner has not been told, so the adapter now cancels the request through
POST /recovery/cancel with the token from the notice's cancel link and
answers 503 recovery_notice_failed. One delivered notice is enough to
answer normally. The cancel link and its token are never logged.

Refs #13
@Bccorb
Bccorb merged commit 8247e20 into main Oct 25, 2026
6 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