Repository navigation
Heartbeat-ping the PAR while the OTP form is open (extends 5-min window) #140
Description
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
RequestStoreto 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
- 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).
- Auth-service exposes a public-but-flow-scoped endpoint, e.g.
POST /auth/keepalive, that:- Reads the
epds_auth_flowcookie to find therequest_uri. - Calls
pingParRequest()(existing helper). - Returns 200 / 404 / 410 to signal "alive" / "expired".
- Reads the
- 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 whendocument.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/keepaliveendpoint that pings the cookie-boundrequest_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
- OAuth callback leaks raw JSON when PAR has expired #132 — the original bug report.
- fix: graceful OAuth error when PAR has expired (no more raw JSON leak) #128 — the fix PR; introduces the graceful failure UX that this issue complements rather than replaces.
Activity
aspiers commentedon May 28, 2026
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:
setIntervalin the login + recovery OTP forms (commitb1fc940).request_uri— shipped asGET /auth/ping(readsepds_auth_flow, proxies the existingpingParRequest()into pds-core's existing/_internal/ping-request). Two intentional deviations from the sketch here: it's/auth/pingrather than/auth/keepalive, and it reuses the existing internal ping endpoint instead of adding a new one (zero new pds-core white-boxing surface).par_expired(terminal, only on 404) fromtransient(keep polling), so a single dropped packet no longer kills keepalive (9fb0e69).@par-heartbeatscenario plus a?no_heartbeat=1test toggle (gated onEPDS_TEST_HOOKS=1).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.