Skip to content

Heartbeat-ping the PAR while the OTP form is open (extends 5-min window) #140

Closed

Description

@aspiers

Background

@atproto/oauth-provider hardcodes PAR_EXPIRES_IN = 5 minutes and AUTHORIZATION_INACTIVITY_TIMEOUT = 5 minutes (sliding window, reset on each requestManager.get()). Neither constant is exposed as a configuration option, so we can't simply bump the PAR lifetime to a more user-friendly window like 10 min.

A real user reported (issue #132, fixed by PR #128) that they took longer than 5 minutes between requesting an OTP and submitting it (slow inbox + multiple Resend cycles), causing the PAR to expire and stranding them on a raw-JSON error page. PR #128 fixed the failure UX — they now see a graceful page or get redirected back to the client with a spec-compliant error — but it didn't extend the actual time window. A user who waits >5 min still has to start sign-in over.

Proposal

Add a heartbeat ping from the OTP form page while it's open. Each ping calls auth-service's existing POST /_internal/ping-par-request (or equivalent), which in turn calls pds-core's requestManager.get(). That get() resets the inactivity timer to +5 min, exactly the sliding-window mechanism upstream is designed for. Effective result: as long as the user has the OTP page open in a focused tab, their PAR stays alive indefinitely.

This is preferable to other approaches considered:

  • Wrapping the RequestStore to lie about expiry (security smell — deviates from spec, may surprise stricter clients).
  • PRing upstream to expose parExpiresIn (correct long-term answer, but slow; would warrant its own follow-up).
  • Just accepting 5 min (what PR fix: graceful OAuth error when PAR has expired (no more raw JSON leak) #128 does today; the heartbeat is an add-on improvement).

Implementation sketch

  1. Add a small client-side heartbeat in the auth-service login page's OTP step:
    • Fire on initial render; repeat every ~3 min; cancel on submit / page hide.
    • Backoff if the response is non-200 (don't hammer the AS).
  2. Auth-service exposes a public-but-flow-scoped endpoint, e.g. POST /auth/keepalive, that:
    • Reads the epds_auth_flow cookie to find the request_uri.
    • Calls pingParRequest() (existing helper).
    • Returns 200 / 404 / 410 to signal "alive" / "expired".
  3. On 410 (or 404), the OTP form surfaces "your sign-in expired, please start again" inline, before the user wastes time entering an OTP that won't survive the bridge.

Failure modes worth thinking through

  • JavaScript disabled / offline: the heartbeat doesn't run. User falls back to the 5-min window — same as today. The PR-hypercerts-org/ePDS#128 graceful failure path catches them.
  • Tab unfocused for hours: heartbeat keeps firing in the background tab. PAR stays alive. Not a security issue (the PAR can only complete a flow that's already in motion for a verified email); arguable resource concern (an idle tab keeps a row warm in pds-core's authorization_request). Mitigate: cap heartbeat duration to ~30 min, or pause when document.visibilityState === 'hidden'.
  • Multiple browser tabs on the same flow: cookie-shared, both tabs ping. Same row, same effect. Harmless.
  • CSRF: the keepalive endpoint should not require CSRF (it's a same-origin XHR with credentials), but if added to a CSRF-protected route the form needs the token threaded through.

Acceptance criteria

  • Heartbeat in auth-service's OTP form page; cadence ~3 min, cancels on submit or page hide.
  • New /auth/keepalive endpoint that pings the cookie-bound request_uri.
  • When the keepalive returns 404/410, the OTP form transitions to the "session expired" UI before the user submits.
  • e2e: a scenario that simulates an idle tab for >5 min (test hook to fast-forward + heartbeat fires) and asserts sign-in completes.
  • Document the new endpoint and the security model in the auth-service README / design docs.

Related

Activity

  1. aspiers commented on May 28, 2026

    @aspiers
    ContributorAuthor

    PR #154 (merged 2026-05-05) implements this. The heartbeat half of that PR is exactly this issue's proposal; #154 just didn't reference #140, so it never auto-linked.

    Mapping against the acceptance criteria:

    • Heartbeat in the OTP form, ~3 min cadence, cancels on submit / page-hide — inlined setInterval in the login + recovery OTP forms (commit b1fc940).
    • New endpoint that pings the cookie-bound request_uri — shipped as GET /auth/ping (reads epds_auth_flow, proxies the existing pingParRequest() into pds-core's existing /_internal/ping-request). Two intentional deviations from the sketch here: it's /auth/ping rather than /auth/keepalive, and it reuses the existing internal ping endpoint instead of adding a new one (zero new pds-core white-boxing surface).
    • 404/410 → "session expired" before the user wastes an OTP — heartbeat distinguishes par_expired (terminal, only on 404) from transient (keep polling), so a single dropped packet no longer kills keepalive (9fb0e69).
    • e2e idle-tab proof — @par-heartbeat scenario plus a ?no_heartbeat=1 test toggle (gated on EPDS_TEST_HOOKS=1).
    • Document the endpoint + security model — docs/design/par-expiry-and-clean-exits.md.

    Note #154 was broader than this issue: it also shipped the clean-exit layer (dead-PAR users get redirected back to the OAuth client with a spec-compliant error), which is the sibling concern this issue explicitly scoped out. That part addresses #150/#151, not this.

    Closing as implemented by #154.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions