Skip to content

feat(accounts): add OTP (passwordless) login support via xauthn#12681

Merged
mekarpeles merged 11 commits into
masterfrom
12664/otp-passwordless-login
Jun 4, 2026
Merged

feat(accounts): add OTP (passwordless) login support via xauthn#12681
mekarpeles merged 11 commits into
masterfrom
12664/otp-passwordless-login

Conversation

@mekarpeles

Copy link
Copy Markdown
Member

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/elements and the xauthn issue_otp / redeem_otp API.

Changes

Backend (openlibrary/accounts/model.py)

  • InternetArchiveAccount.issue_otp(email, service='ol') — calls xauthn issue_otp, forwards X-Originating-IP for rate limiting
  • InternetArchiveAccount.redeem_otp(email, password) — calls xauthn redeem_otp
  • xauth() gains an optional headers= kwarg

Backend (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 existing audit_accounts() to set all OL + IA session cookies

Note: existing /account/otp/issue and /account/otp/redeem (OL-internal TimedOneTimePassword) are unaffected.

Frontend (openlibrary/components/lit/OpenLibraryOTP.js)

  • New <ol-otp-login> Lit component: trigger button → modal dialog → two-step flow (email input → <ia-otp-form> code entry)
  • Handles loading / error / success states, resend, overlay click-to-close
  • ia-otp-form from @internetarchive/elements (npm ^0.2.5) — pure UI, no hardcoded endpoints

Template (openlibrary/templates/login.html)

  • Adds <ol-otp-login> with OR divider below the existing password form

Dev testing

  • Fake xauth extended with issue_otp (always succeeds) and redeem_otp (accepts OTP 123456)
  • New /internal/fake/s3auth endpoint + ia_s3_auth_url in conf/openlibrary.yml — enables full session-cookie flow locally without hitting archive.org
  • To test: issue to any email, then redeem with OTP 123456

Dependency

PR #11052 should land first (xauthn activate scaffolding). The issue_otp/redeem_otp xauthn ops used here are independent, but audit_accounts() IA↔OL account-linking improvements from #11052 are desirable before this ships.

Test plan

  • Login page renders with "Sign in with a one-time code" button
  • Button opens modal; email step submits to /account/login/otp/issue
  • OTP step renders ia-otp-form; correct code → {"success":true} + session cookies set
  • Wrong code → error state shown in ia-otp-form
  • "Email me another code" resend works (rate-limited by xauthn in prod)
  • Overlay click and × button close the modal
  • Password-based login unaffected
  • make test passes

Copilot AI review requested due to automatic review settings May 8, 2026 19:58
@mekarpeles mekarpeles added Type: Feature Request Issue describes a feature or enhancement we'd like to implement. [managed] Priority: 1 Do this week, receiving emails, time sensitive, . [managed] Lead: @jimchamp Issues overseen by Jim (Front-end Lead, BookNotes) [managed] Theme: Onboarding Issues relating to improving patrons discovery and usage of the website Needs: Staff / Internal Reviewed a PR but don't have merge powers? Use this. labels May 8, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 extends xauth() to accept request headers for forwarding X-Originating-IP.
  • Web endpoints: introduces /account/login/otp/issue and /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/elements dependency for ia-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.

Comment thread openlibrary/components/lit/OpenLibraryOTP.js Outdated
Comment thread openlibrary/components/lit/OpenLibraryOTP.js
Comment thread openlibrary/plugins/upstream/account.py Outdated
mekarpeles added a commit that referenced this pull request May 8, 2026
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.
Comment thread openlibrary/accounts/model.py Outdated
mekarpeles added a commit that referenced this pull request May 10, 2026
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.
@mekarpeles mekarpeles force-pushed the 12664/otp-passwordless-login branch from 3adef25 to 97f9189 Compare May 10, 2026 17:06
mekarpeles added a commit that referenced this pull request May 12, 2026
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.
@mekarpeles mekarpeles force-pushed the 12664/otp-passwordless-login branch from 5385ea7 to e0491fb Compare May 12, 2026 15:41

@jimchamp jimchamp left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed this -- my notes mainly focus on look and feel changes.

When my suggested changes are applied, the new button will look like this:

image

Comment thread package.json Outdated
Comment thread openlibrary/components/lit/OpenLibraryOTP.js Outdated
Comment thread openlibrary/components/lit/OpenLibraryOTP.js Outdated
Comment thread openlibrary/components/lit/OpenLibraryOTP.js Outdated
mekarpeles added a commit that referenced this pull request May 20, 2026
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.
@mekarpeles mekarpeles force-pushed the 12664/otp-passwordless-login branch from 3584794 to 627f56a Compare May 20, 2026 23:07
@mekarpeles

Copy link
Copy Markdown
Member Author

This is coming along. A few things I've noticed.

  1. In the flow, if one clicks the OTP login and enters an email, they are emailed a code... But clicking back on Open Library to enter the code dismisses the modal and when you click OTP again, it asks for email (instead of code). The failure case seems similar on archive.org, cc: @rebecca-shoptaw
  2. When entering email twice, the previous OTP code still validates -- maybe that's one for @jimbonator and @bfalling

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).

@mekarpeles

Copy link
Copy Markdown
Member Author

We should also move OTP login below google sign-in (this all only applies to login page for now).

mekarpeles added a commit that referenced this pull request May 24, 2026
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.
@mekarpeles mekarpeles force-pushed the 12664/otp-passwordless-login branch from 055d7a8 to cfd0769 Compare May 24, 2026 22:33
@mekarpeles

Copy link
Copy Markdown
Member Author

Addressed all open review threads in cfd0769:

jimchamp's suggestions (all applied):

  • @internetarchive/elements moved to devDependencies + npm i lockfile updated
  • Button: var(--primary-blue, hsl(202, 96%, 37%)) background, width: 100%, margin-top: 10px
  • Hover state: hsl(202, 96%, 17%) to match existing Log In button

Mek's comments:

  • otp instead of password in redeem_otp() param + fake xauth check
  • Modal no longer dismisses on overlay click while user is in the code-entry step (they may be switching back from their email client)
  • OTP section moved above password form — order is now: Google → OTP → password

Also squashed the stray pre-commit.ci bot commit into its parent.

@mekarpeles

Copy link
Copy Markdown
Member Author

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.

@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown

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.
For guidance on contributing, please see our Contributing guide.

@github-actions github-actions Bot added Stale Needs: Response Issues which require feedback from lead labels Jun 3, 2026
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
@mekarpeles mekarpeles force-pushed the 12664/otp-passwordless-login branch from cfd0769 to 1b14322 Compare June 3, 2026 17:11
@github-actions github-actions Bot removed the Needs: Submitter Input Waiting on input from the creator of the issue/pr [managed] label Jun 3, 2026
@mekarpeles

mekarpeles commented Jun 3, 2026

Copy link
Copy Markdown
Member Author

Latest screenshots:

image

The "click out" problem is resolved, thank you @rebecca-shoptaw!

It seems like suddenly the passcode is no longer working as expected

{error: ""password" not found in entity"}
Screenshot 2026-06-03 at 11 25 39 AM

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"
Screenshot 2026-06-03 at 11 27 55 AM

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.
@mekarpeles

Copy link
Copy Markdown
Member Author

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

@jimchamp

jimchamp commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Setting the z-index for #test-body-mobile to var(--z-index-level-3) fixes the z-index issues that we're seeing (but may cause others elsewhere). I'll make that update, then re-deploy to testing.

Edit: This change did cause other problems (details).

Comment thread static/css/layout/index.css Outdated
border-radius: 5px;
border: 1px solid var(--dark-beige);
z-index: var(--z-index-level-1);
z-index: var(--z-index-level-3);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
z-index: var(--z-index-level-3);
z-index: var(--z-index-level-1);

This should be reverted. It fixes the issue with the OTP modal, but causes issues elsewhere:

image

About to test another approach (which won't involve fussing with z-index).

@jimchamp jimchamp force-pushed the 12664/otp-passwordless-login branch from 186910c to f19165a Compare June 4, 2026 17:47
@jimchamp jimchamp force-pushed the 12664/otp-passwordless-login branch from f0a3e61 to 05027c5 Compare June 4, 2026 18:11
@jimchamp

jimchamp commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

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 document.body at render time (this is sometimes referred to as a "portal pattern"). With no ancestor stacking contexts to contend with, header elements no longer bleed through the overlay.

With this approach, event listeners must be wired up after the component is mounted to the light DOM.

@jimchamp

jimchamp commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

@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.

@mekarpeles mekarpeles merged commit ae053f1 into master Jun 4, 2026
8 of 9 checks passed
@mekarpeles mekarpeles deleted the 12664/otp-passwordless-login branch June 4, 2026 23:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Lead: @jimchamp Issues overseen by Jim (Front-end Lead, BookNotes) [managed] Needs: Response Issues which require feedback from lead Needs: Staff / Internal Reviewed a PR but don't have merge powers? Use this. Priority: 1 Do this week, receiving emails, time sensitive, . [managed] Stale Theme: Onboarding Issues relating to improving patrons discovery and usage of the website Type: Feature Request Issue describes a feature or enhancement we'd like to implement. [managed]

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support OTP (passwordless) login on Open Library login page

3 participants