feat(accounts): add OTP (passwordless) login support via xauthn#12681
Conversation
There was a problem hiding this comment.
Pull request overview
Adds passwordless OTP login support to Open Library by integrating the xauthn issue_otp/redeem_otp flow into the existing login experience, including local-dev fakes to test the full IA↔OL session-cookie path.
Changes:
- Backend: adds
InternetArchiveAccount.issue_otp()/redeem_otp()and extendsxauth()to accept request headers for forwardingX-Originating-IP. - Web endpoints: introduces
/account/login/otp/issueand/account/login/otp/redeem, plus a local-dev fake S3 auth endpoint to complete the audit/cookie flow. - Frontend: adds a new
<ol-otp-login>Lit component and renders it on the login page; adds@internetarchive/elementsdependency foria-otp-form.
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| package.json | Adds @internetarchive/elements runtime dependency for the OTP UI component. |
| package-lock.json | Locks @internetarchive/elements (and transitive deps) for reproducible installs/builds. |
| openlibrary/templates/login.html | Adds an “OR” divider and renders <ol-otp-login> under the password form. |
| openlibrary/plugins/upstream/account.py | Adds OTP issue/redeem endpoints and local-dev fake S3 auth endpoint to support the full session flow. |
| openlibrary/components/lit/OpenLibraryOTP.js | Implements the OTP modal UX and wires it to the new backend endpoints. |
| openlibrary/components/lit/index.js | Exports/registers the new Lit component so it’s included in the Lit bundle. |
| openlibrary/accounts/model.py | Adds xauthn OTP helpers and optional header forwarding for xauth calls. |
| conf/openlibrary.yml | Adds ia_s3_auth_url pointing at the new local fake endpoint for dev/testing. |
Three issues from PR #12681 Copilot review: 1. Focus management (a11y): add focus trap with sentinel divs, Esc-to-close keydown handler, focus-on-open via updated() lifecycle, and restore previous focus on close. role/aria-modal moved to .dialog, not the backdrop. 2. Redirect sanitization: server-side only. account_login_otp_redeem now accepts a `redirect` param, applies the same blacklist as the password login handler (/account/login, /account/create → fallback to /account/books), and returns the sanitized URL in the JSON response. Client navigates to data.redirect, never raw this.redirect. 3. Missing S3 key guard: explicit check that both access and secret are present after redeem_otp before calling audit_accounts; returns {"error": "otp_redeem_incomplete"} if either is absent.
Three issues from PR #12681 Copilot review: 1. Focus management (a11y): add focus trap with sentinel divs, Esc-to-close keydown handler, focus-on-open via updated() lifecycle, and restore previous focus on close. role/aria-modal moved to .dialog, not the backdrop. 2. Redirect sanitization: server-side only. account_login_otp_redeem now accepts a `redirect` param, applies the same blacklist as the password login handler (/account/login, /account/create → fallback to /account/books), and returns the sanitized URL in the JSON response. Client navigates to data.redirect, never raw this.redirect. 3. Missing S3 key guard: explicit check that both access and secret are present after redeem_otp before calling audit_accounts; returns {"error": "otp_redeem_incomplete"} if either is absent.
3adef25 to
97f9189
Compare
Three issues from PR #12681 Copilot review: 1. Focus management (a11y): add focus trap with sentinel divs, Esc-to-close keydown handler, focus-on-open via updated() lifecycle, and restore previous focus on close. role/aria-modal moved to .dialog, not the backdrop. 2. Redirect sanitization: server-side only. account_login_otp_redeem now accepts a `redirect` param, applies the same blacklist as the password login handler (/account/login, /account/create → fallback to /account/books), and returns the sanitized URL in the JSON response. Client navigates to data.redirect, never raw this.redirect. 3. Missing S3 key guard: explicit check that both access and secret are present after redeem_otp before calling audit_accounts; returns {"error": "otp_redeem_incomplete"} if either is absent.
5385ea7 to
e0491fb
Compare
Three issues from PR #12681 Copilot review: 1. Focus management (a11y): add focus trap with sentinel divs, Esc-to-close keydown handler, focus-on-open via updated() lifecycle, and restore previous focus on close. role/aria-modal moved to .dialog, not the backdrop. 2. Redirect sanitization: server-side only. account_login_otp_redeem now accepts a `redirect` param, applies the same blacklist as the password login handler (/account/login, /account/create → fallback to /account/books), and returns the sanitized URL in the JSON response. Client navigates to data.redirect, never raw this.redirect. 3. Missing S3 key guard: explicit check that both access and secret are present after redeem_otp before calling audit_accounts; returns {"error": "otp_redeem_incomplete"} if either is absent.
3584794 to
627f56a
Compare
|
This is coming along. A few things I've noticed.
We might want the passcode screen to be stateful (and have a back arrow if you entered the wrong email) and not dismiss so easily if one clicks out. One by definition is going to be switching between email client and focusing service (which often requires a click). |
|
We should also move OTP login below google sign-in (this all only applies to login page for now). |
Three issues from PR #12681 Copilot review: 1. Focus management (a11y): add focus trap with sentinel divs, Esc-to-close keydown handler, focus-on-open via updated() lifecycle, and restore previous focus on close. role/aria-modal moved to .dialog, not the backdrop. 2. Redirect sanitization: server-side only. account_login_otp_redeem now accepts a `redirect` param, applies the same blacklist as the password login handler (/account/login, /account/create → fallback to /account/books), and returns the sanitized URL in the JSON response. Client navigates to data.redirect, never raw this.redirect. 3. Missing S3 key guard: explicit check that both access and secret are present after redeem_otp before calling audit_accounts; returns {"error": "otp_redeem_incomplete"} if either is absent.
055d7a8 to
cfd0769
Compare
|
Addressed all open review threads in cfd0769: jimchamp's suggestions (all applied):
Mek's comments:
Also squashed the stray pre-commit.ci bot commit into its parent. |
|
One, we should try to extend the OTP lit element to prevent easy accidental click-out when entering the OTP (the problem is that the patron likely opens a new window or tab to get the OTP from their email and if they click anywhere but the modal when returning back to Open Library, the whole modals dismisses and then the patron has to re-request code or enter email again). We should also have this flow preserve the email if they entered one (across requests) and let them go back if the email is wrong. |
|
Hello! This pull request has been marked as stale because it has been waiting for submitter input for 7 days. If you're still working on this, please add a comment to keep it open. Otherwise, a maintainer will have to determine how to proceed including reassigning or opening the issue for others to work on after communicating with you. |
Implements issue #12664. Users who registered via archive.org's passwordless flow can now log into Open Library without resetting their password. Backend: - InternetArchiveAccount.issue_otp() / redeem_otp() — thin xauthn wrappers forwarding X-Originating-IP for rate limiting - xauth() gains an optional `headers` kwarg - POST /account/login/otp/issue — calls xauthn issue_otp, handles both initial send and resend (same endpoint; xauthn self-rate-limits) - POST /account/login/otp/redeem — calls xauthn redeem_otp, feeds returned S3 keys through existing audit_accounts() to set all OL + IA cookies Frontend: - OpenLibraryOTP.js — Lit component wrapping ia-otp-form from @internetarchive/elements. Two-step modal: email → code entry. Handles loading/error/success states, resend, overlay click-to-close. - login.html — adds <ol-otp-login> trigger below the password form Closes #12664
- fake xauth: add issue_otp (always succeeds) and redeem_otp
(accepts OTP "123456") stubs; parse JSON body correctly for ops
that check request fields
- fake s3auth endpoint at /internal/fake/s3auth: accepts dummy
keys "foo:foo" from the fake xauth to allow audit_accounts() to
complete the full session-cookie flow in local dev
- conf/openlibrary.yml: add ia_s3_auth_url pointing to fake endpoint
- login.html: fix web.py template expression syntax for redirect attr
To test OTP login locally:
1. issue: POST /account/login/otp/issue email=<any> → {"success":true}
2. redeem: POST /account/login/otp/redeem email=<any>&otp=123456
→ {"success":true} + sets session + pd cookies
Three issues from PR #12681 Copilot review: 1. Focus management (a11y): add focus trap with sentinel divs, Esc-to-close keydown handler, focus-on-open via updated() lifecycle, and restore previous focus on close. role/aria-modal moved to .dialog, not the backdrop. 2. Redirect sanitization: server-side only. account_login_otp_redeem now accepts a `redirect` param, applies the same blacklist as the password login handler (/account/login, /account/create → fallback to /account/books), and returns the sanitized URL in the JSON response. Client navigates to data.redirect, never raw this.redirect. 3. Missing S3 key guard: explicit check that both access and secret are present after redeem_otp before calling audit_accounts; returns {"error": "otp_redeem_incomplete"} if either is absent.
The prior check only blacklisted specific path substrings, allowing https://evil.com through. Now we also require the redirect to start with "/" and not "//" (protocol-relative URL), matching the same-origin guard already used in the verify_human endpoint. [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci
- Move @internetarchive/elements to devDependencies (matches OL convention; all other JS deps are devDependencies) - Button: use OL primary-blue CSS var, full width, margin-top spacing, and correct hover state to match existing Log In button - Keep modal open on overlay click when user is in code-entry step — avoids losing flow when switching back from email client - Rename redeem_otp() parameter password→otp for clarity; update xauth() kwarg and fake xauth endpoint check to match - Move OTP login section above password form (right after Google sign-in) per Mek's request
cfd0769 to
1b14322
Compare
for more information, see https://pre-commit.ci
|
Latest screenshots:
The "click out" problem is resolved, thank you @rebecca-shoptaw! It seems like suddenly the passcode is no longer working as expected
Also, after requesting email twice, I no longer receive any email (and no error message) Switching emails says: "Unable to send code. Please try again" |
Replaces inline cookie-setting in account_login.login() and the OTP redeem handler with a single module-level _set_login_cookies(audit, ol_account, remember) that owns all session cookies (login token, pd, sfw, yrg_banner_pref). Also fixes the CI ruff F821 error caused by a call to _set_account_cookies which was removed when #11052 landed.
xauthn's redeem_otp op expects the OTP code under the 'password' key, matching how authenticate sends credentials. Also fixes the fake xauth stub to match so local dev testing stays consistent.
|
Ready for review, tested and working on testing. Please squash on merge One UI note: that there are some z-index issues (some fields don't go below the blurred dimmed background |
|
Setting the Edit: This change did cause other problems (details). |
| border-radius: 5px; | ||
| border: 1px solid var(--dark-beige); | ||
| z-index: var(--z-index-level-1); | ||
| z-index: var(--z-index-level-3); |
186910c to
f19165a
Compare
f0a3e61 to
05027c5
Compare
for more information, see https://pre-commit.ci
|
The z-index issues were caused by the OTP component existing within an entirely different stacking context then the header elements, rendering any z-index change to the component ineffective. These issues have been circumvented by this commit, which attaches the overlay directly to With this approach, event listeners must be wired up after the component is mounted to the light DOM. |
|
@mek, all of your changes look good to me. Can you give my latest commit a quick review when you have a chance? If it looks good to you, this PR should be alright to merge. |





Summary
Closes #12664
Users who register on archive.org via the new passwordless (OTP) flow receive a randomly-generated password and are currently unable to log into Open Library. This PR adds OTP login support to OL's login page using
@internetarchive/elementsand the xauthnissue_otp/redeem_otpAPI.Changes
Backend (
openlibrary/accounts/model.py)InternetArchiveAccount.issue_otp(email, service='ol')— calls xauthnissue_otp, forwardsX-Originating-IPfor rate limitingInternetArchiveAccount.redeem_otp(email, password)— calls xauthnredeem_otpxauth()gains an optionalheaders=kwargBackend (
openlibrary/plugins/upstream/account.py)POST /account/login/otp/issue— issues OTP via xauthn; same endpoint handles resend (xauthn self-rate-limits at HTTP 429)POST /account/login/otp/redeem— redeems OTP; on success feeds returned S3 keys into existingaudit_accounts()to set all OL + IA session cookiesNote: existing
/account/otp/issueand/account/otp/redeem(OL-internalTimedOneTimePassword) are unaffected.Frontend (
openlibrary/components/lit/OpenLibraryOTP.js)<ol-otp-login>Lit component: trigger button → modal dialog → two-step flow (email input →<ia-otp-form>code entry)ia-otp-formfrom@internetarchive/elements(npm^0.2.5) — pure UI, no hardcoded endpointsTemplate (
openlibrary/templates/login.html)<ol-otp-login>with OR divider below the existing password formDev testing
issue_otp(always succeeds) andredeem_otp(accepts OTP123456)/internal/fake/s3authendpoint +ia_s3_auth_urlinconf/openlibrary.yml— enables full session-cookie flow locally without hitting archive.org123456Dependency
PR #11052 should land first (xauthn
activatescaffolding). Theissue_otp/redeem_otpxauthn ops used here are independent, butaudit_accounts()IA↔OL account-linking improvements from #11052 are desirable before this ships.Test plan
/account/login/otp/issueia-otp-form; correct code →{"success":true}+ session cookies setia-otp-formmake testpasses