GH#1357: feat(auth): add passwordless login with passkeys#1358
Conversation
Completion Summary
aidevops.sh v3.20.41 plugin for OpenCode v1.16.2 with gpt-5.5 spent 4h 16m and 1,842,331 tokens on this with the user in an interactive session. |
📝 WalkthroughWalkthroughAdds native passwordless authentication: WebAuthn passkeys with short-lived email OTP fallback across DB schemas, WebAuthn helpers/challenges, OTP/passkey services, a manager with AJAX endpoints, client-side runtime JS, login/checkout UI integration, tests, and docs updates. ChangesPasswordless authentication feature
Sequence DiagramsequenceDiagram
participant Browser
participant PasswordlessAuthJS as wuPasswordlessAuth
participant AuthManager as Passwordless_Auth_Manager
participant OTPService as Email_OTP_Service
participant PasskeyService as Passkey_Service
Browser->>PasswordlessAuthJS: submit identifier
PasswordlessAuthJS->>AuthManager: ajax_start(identifier)
AuthManager->>PasskeyService: get_authentication_options(user)
alt user has passkeys
AuthManager-->>PasswordlessAuthJS: mode=passkey + publicKey options
PasswordlessAuthJS->>Browser: navigator.credentials.get()
PasswordlessAuthJS->>AuthManager: ajax_verify_passkey(payload)
AuthManager->>PasskeyService: verify_authentication(payload)
AuthManager-->>PasswordlessAuthJS: success + redirect
else user has no passkeys
AuthManager->>OTPService: create_and_send(user, email)
AuthManager-->>PasswordlessAuthJS: mode=otp + token
PasswordlessAuthJS->>AuthManager: ajax_verify_otp(token, code)
AuthManager->>OTPService: verify(token, code)
AuthManager-->>PasswordlessAuthJS: success + redirect + enrollment options
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🔨 Build Complete - Ready for Testing!📦 Download Build Artifact (Recommended)Download the zip build, upload to WordPress and test:
🌐 Test in WordPress Playground (Very Experimental)Click the link below to instantly test this PR in your browser - no installation needed! Login credentials: |
🔨 Build Complete - Ready for Testing!📦 Download Build Artifact (Recommended)Download the zip build, upload to WordPress and test:
🌐 Test in WordPress Playground (Very Experimental)Click the link below to instantly test this PR in your browser - no installation needed! Login credentials: |
There was a problem hiding this comment.
Actionable comments posted: 9
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
views/checkout/partials/inline-login-prompt.php (1)
24-37:⚠️ Potential issue | 🟠 MajorHook
wu_inline_login_prompt_before_submitexecutes after all passwordless login UI is output, contradicting its name and intended use.The hook fires at line 36, after
get_inline_login_markup()has already output the complete wrapper containing email input, "Continue", "Use passkey", "Verify code", and "Create passkey" buttons. Callbacks (e.g., captcha fields) will render after all these controls, not before submit as the hook name implies.Either move the hook call before line 24, or rename/document it as
wu_inline_login_prompt_after_auth_controls.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@views/checkout/partials/inline-login-prompt.php` around lines 24 - 37, The hook wu_inline_login_prompt_before_submit is fired after \WP_Ultimo\Auth\Passwordless_Auth_Manager::get_instance()->get_inline_login_markup($field_type) outputs the full passwordless controls, so move the do_action('wu_inline_login_prompt_before_submit', $field_type) call to run before the get_inline_login_markup() echo (so callbacks render inside the form before the submit controls), and update the surrounding docblock/comment to reflect the hook now fires prior to the submit/auth controls; alternatively, if you prefer not to move it, rename the hook to wu_inline_login_prompt_after_auth_controls and update usages and docs to match.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@assets/js/passwordless-auth.js`:
- Around line 297-304: Change the redirect precedence in the finish flow so the
server-issued redirect (data.redirect_url) is used before any client-seeded
value (state.redirectUrl from container.dataset.redirect-to) to avoid open
redirects; in the block that currently calls
window.location.assign(state.redirectUrl || data.redirect_url ||
window.location.href), flip the order to
window.location.assign(data.redirect_url || state.redirectUrl ||
window.location.href) and keep the existing reload check
(container.dataset.success === 'reload') intact.
In `@inc/auth/class-email-otp-service.php`:
- Around line 164-196: The code currently validates OTPs then calls
$this->consume((int) $attempt->id) after returning the user, allowing races to
accept the same token concurrently; fix by enforcing atomic single-use in the
verification path: change the consume() implementation (and/or use a new
atomicConsume method) to perform an atomic UPDATE that sets consumed_at WHERE id
= ? AND consumed_at IS NULL and return whether rows_affected === 1, and in the
verify method (class Email_OTP_Service / current verify function) call that
atomic consume before returning the $user and only return success when
atomicConsume returns true; also ensure increment_attempts() uses an atomic
UPDATE on attempts to avoid lost updates.
- Around line 130-140: create_and_send() currently ignores the result of
send_email() and returns a success payload even if email delivery failed;
capture the boolean return from $this->send_email($user, $code) and if it
returns false, undo the created OTP (remove the row identified by $token) and
return a payload indicating sent => false (and avoid advertising a usable
token), e.g. call your existing delete/revoke method with $token (or add one if
missing) before returning so a failed SMTP send does not leave a live OTP
behind.
In `@inc/auth/class-passwordless-auth-manager.php`:
- Around line 411-437: The current branch in class-passwordless-auth-manager.php
leaks account existence by returning 'mode: passkey' when
passkeys->credentials()->user_has_credentials($user->ID) is true; instead, keep
the initial response generic and do not reveal passkey availability. Change the
logic around passkeys->get_authentication_options(...) and the
wp_send_json_success call so the endpoint always returns a non-enumerable
generic success payload/message (same shape for all identifiers) and only expose
passkey-specific options after the caller proves control (e.g., after OTP
verification or authenticated challenge), ensuring otp->create_and_send($user,
$email) and send_error(is_wp_error($otp)) behavior remains intact. Use the
unique symbols passkeys->credentials()->user_has_credentials,
passkeys->get_authentication_options, otp->create_and_send, send_error, and
wp_send_json_success to locate and update the code.
- Around line 406-408: The boolean request values are being cast incorrectly
with (bool) wu_request(...) causing string '0' to become true; update the
parsing of supports_webauthn and force_otp in the PasswordlessAuthManager code
to explicitly sanitize/convert the incoming values (e.g., use
rest_sanitize_boolean(wu_request('...')) or filter_var(wu_request('...'),
FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) and then coerce to bool) before
any branching that uses $supports_webauthn or $force_otp so passkey vs OTP logic
behaves correctly.
In `@inc/auth/class-webauthn-challenge-store.php`:
- Around line 94-139: The current flow (WebAuthn_Challenge_Store::get_valid() +
mark_used()) has a TOCTOU race allowing challenge replay; modify the logic to
atomically reserve a challenge before verification by performing a single
conditional update or transactional SELECT+UPDATE: change mark_used() (or add a
new reserve_challenge()) to run an UPDATE on wu_webauthn_challenges that sets
used_at = current_time(...) WHERE id = absint($id) AND used_at IS NULL and
return false if affected_rows === 0, then have
Passkey_Service::verify_authentication() and verify_registration() call this
reserve method before doing signature/origin/counter checks and abort if
reservation failed; alternatively implement SELECT ... FOR UPDATE inside a
transaction in get_valid()/mark_used() to lock the row until verification
completes. Ensure callers check the boolean result and stop verification when
reservation fails; keep sanitize_key($type) and current_time usage as before.
In `@inc/auth/class-webauthn-helper.php`:
- Around line 78-82: The RP ID extraction currently strips ports only when no
']' is present, causing bracketed IPv6 with ports (e.g. "[::1]:8080") to remain
malformed; update the logic around $host so the port is removed prior to
trimming brackets: ensure the preg_replace that strips ':\d+$' is applied for
bracketed addresses too (or use a regex that removes ']:\d+$' before calling
trim($host, '[]')), and keep use of strpos($host, ':')/strpos($host, ']') and
functions preg_replace and trim to locate and update the code path that returns
trim($host, '[]').
In `@inc/ui/class-login-form-element.php`:
- Around line 789-801: The passwordless login render call to
\WP_Ultimo\Auth\Passwordless_Auth_Manager::get_instance()->get_login_form_markup(...)
drops this element's existing settings (label_*, remember toggle, submit label,
fallback_url), removing the "Use password instead" escape hatch and custom
labels; fix by passing the element's current settings into get_login_form_markup
from this class when building the 'passwordless_login' field (i.e., enrich the
array currently containing 'context', 'redirect_to', and 'redirect_type' with
the element's label_* values, remember toggle flag, submit label, and
fallback_url derived from the element/atts/state so get_login_form_markup
receives those options and preserves prior behavior).
In `@tests/e2e/cypress/support/commands/login.js`:
- Around line 46-53: The assertion in the passwordless branch of the support
command using cy.get("`#user_pass`").should("not.be.visible") can hang if the
`#user_pass` element is not in the DOM; update the passwordless branch in
tests/e2e/cypress/support/commands/login.js (the block that checks
.wu-passwordless-auth and calls cy.loginByApi and cy.visit) to assert that
`#user_pass` does not exist instead of not visible (use the Cypress not.exist
assertion) so the test will pass when the password field is omitted from the
DOM.
---
Outside diff comments:
In `@views/checkout/partials/inline-login-prompt.php`:
- Around line 24-37: The hook wu_inline_login_prompt_before_submit is fired
after
\WP_Ultimo\Auth\Passwordless_Auth_Manager::get_instance()->get_inline_login_markup($field_type)
outputs the full passwordless controls, so move the
do_action('wu_inline_login_prompt_before_submit', $field_type) call to run
before the get_inline_login_markup() echo (so callbacks render inside the form
before the submit controls), and update the surrounding docblock/comment to
reflect the hook now fires prior to the submit/auth controls; alternatively, if
you prefer not to move it, rename the hook to
wu_inline_login_prompt_after_auth_controls and update usages and docs to match.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 1fc05124-237e-4c2d-978a-deb09ee4b471
📒 Files selected for processing (24)
README.mdassets/js/passwordless-auth.jsinc/auth/class-email-otp-service.phpinc/auth/class-passkey-credential-store.phpinc/auth/class-passkey-service.phpinc/auth/class-passwordless-auth-manager.phpinc/auth/class-webauthn-challenge-store.phpinc/auth/class-webauthn-helper.phpinc/checkout/class-checkout.phpinc/class-wp-ultimo.phpinc/database/email-otp-attempts/class-email-otp-attempts-schema.phpinc/database/email-otp-attempts/class-email-otp-attempts-table.phpinc/database/passkey-credentials/class-passkey-credentials-schema.phpinc/database/passkey-credentials/class-passkey-credentials-table.phpinc/database/webauthn-challenges/class-webauthn-challenges-schema.phpinc/database/webauthn-challenges/class-webauthn-challenges-table.phpinc/functions/helper.phpinc/loaders/class-table-loader.phpinc/ui/class-login-form-element.phpreadme.txttests/WP_Ultimo/Auth/Passwordless_Auth_Test.phptests/e2e/cypress/integration/login.spec.jstests/e2e/cypress/support/commands/login.jsviews/checkout/partials/inline-login-prompt.php
🔨 Build Complete - Ready for Testing!📦 Download Build Artifact (Recommended)Download the zip build, upload to WordPress and test:
🌐 Test in WordPress Playground (Very Experimental)Click the link below to instantly test this PR in your browser - no installation needed! Login credentials: |
|
CLAIM_RELEASED reason=worker_complete runner=superdav42 ts=2026-06-10T01:57:08Z aidevops_version=3.20.43 opencode_version=1.16.2 |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@inc/auth/class-passkey-service.php`:
- Around line 281-291: After consuming the challenge you must ensure persisting
the new sign counter succeeded; check the boolean return of
Passkey_Credential_Store::update_usage (called via
$this->credentials->update_usage with $credential->id and $new_count) and if it
returns false, fail closed by returning a WP_Error (e.g. 'storage_error')
instead of continuing; locate the block around $this->challenges->mark_used(...)
and the subsequent $this->credentials->update_usage(...) call and add the
conditional error return when update_usage fails so authentication does not
proceed if the write fails.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 739cfabc-9b5e-49d3-86c1-bd46d39be2f1
📒 Files selected for processing (9)
assets/js/passwordless-auth.jsinc/auth/class-email-otp-service.phpinc/auth/class-passkey-service.phpinc/auth/class-passwordless-auth-manager.phpinc/auth/class-webauthn-challenge-store.phpinc/auth/class-webauthn-helper.phpinc/ui/class-login-form-element.phptests/WP_Ultimo/Auth/Passwordless_Auth_Test.phptests/e2e/cypress/support/commands/login.js
🚧 Files skipped from review as they are similar to previous changes (6)
- tests/e2e/cypress/support/commands/login.js
- inc/auth/class-webauthn-challenge-store.php
- inc/ui/class-login-form-element.php
- inc/auth/class-email-otp-service.php
- inc/auth/class-passwordless-auth-manager.php
- inc/auth/class-webauthn-helper.php
🔨 Build Complete - Ready for Testing!📦 Download Build Artifact (Recommended)Download the zip build, upload to WordPress and test:
🌐 Test in WordPress Playground (Very Experimental)Click the link below to instantly test this PR in your browser - no installation needed! Login credentials: |
|
CLAIM_RELEASED reason=worker_complete runner=superdav42 ts=2026-06-10T03:41:46Z aidevops_version=3.20.46 opencode_version=1.16.2 |
🔨 Build Complete - Ready for Testing!📦 Download Build Artifact (Recommended)Download the zip build, upload to WordPress and test:
🌐 Test in WordPress Playground (Very Experimental)Click the link below to instantly test this PR in your browser - no installation needed! Login credentials: |
|
CLAIM_RELEASED reason=worker_complete runner=superdav42 ts=2026-06-10T04:08:25Z aidevops_version=3.20.46 opencode_version=1.16.2 |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
inc/auth/class-passwordless-auth-manager.php (1)
406-420:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftPasskey sign-in no longer has a way to start.
Line 418 now hard-codes the public start flow to OTP, but this class no longer exposes any unauthenticated endpoint that issues a WebAuthn authentication challenge/options for logged-out users.
ajax_verify_passkey()only verifies an assertion after the browser has already completednavigator.credentials.get(), so existing passkey users no longer have a viable path to perform a passkey login.This regresses the passkey-primary flow described for the feature; please restore a non-enumerable way to mint authentication options before keeping
ajax_start()OTP-only.Also applies to: 466-487
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@inc/auth/class-passwordless-auth-manager.php` around lines 406 - 420, The AJAX start flow was locked to OTP only, breaking passkey logins; restore a non-enumerable path that mints WebAuthn assertion/options for logged-out users by updating ajax_start() to detect a passkey start request (or client capability) and call the WebAuthn options factory (e.g. the class/method used elsewhere for assertions—refer to ajax_verify_passkey() and the WebAuthn helper on this class) to create assertion options and a token, then return mode='webauthn' with the token and the generated options (and keep the public message generic/non-enumerable). Apply the same change to the second start-like block referenced around lines 466-487 so both entry points can return either OTP or webauthn responses depending on client request/capability.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@inc/auth/class-passwordless-auth-manager.php`:
- Around line 406-420: The AJAX start flow was locked to OTP only, breaking
passkey logins; restore a non-enumerable path that mints WebAuthn
assertion/options for logged-out users by updating ajax_start() to detect a
passkey start request (or client capability) and call the WebAuthn options
factory (e.g. the class/method used elsewhere for assertions—refer to
ajax_verify_passkey() and the WebAuthn helper on this class) to create assertion
options and a token, then return mode='webauthn' with the token and the
generated options (and keep the public message generic/non-enumerable). Apply
the same change to the second start-like block referenced around lines 466-487
so both entry points can return either OTP or webauthn responses depending on
client request/capability.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 6b5d266b-dd65-4304-ba47-fdad546f9753
📒 Files selected for processing (1)
inc/auth/class-passwordless-auth-manager.php
Summary
Adds native email-first passwordless authentication with passkey/WebAuthn support, email OTP fallback, auth tables, login/checkout UI integration, docs, and focused unit/E2E coverage.
Files Changed
README.md,assets/js/passwordless-auth.js,inc/auth/class-email-otp-service.php,inc/auth/class-passkey-credential-store.php,inc/auth/class-passkey-service.php,inc/auth/class-passwordless-auth-manager.php,inc/auth/class-webauthn-challenge-store.php,inc/auth/class-webauthn-helper.php,inc/checkout/class-checkout.php,inc/class-wp-ultimo.php,inc/database/email-otp-attempts/class-email-otp-attempts-schema.php,inc/database/email-otp-attempts/class-email-otp-attempts-table.php,inc/database/passkey-credentials/class-passkey-credentials-schema.php,inc/database/passkey-credentials/class-passkey-credentials-table.php,inc/database/webauthn-challenges/class-webauthn-challenges-schema.php,inc/database/webauthn-challenges/class-webauthn-challenges-table.php,inc/functions/helper.php,inc/loaders/class-table-loader.php,inc/ui/class-login-form-element.php,readme.txt,tests/WP_Ultimo/Auth/Passwordless_Auth_Test.php,tests/e2e/cypress/integration/login.spec.js,tests/e2e/cypress/support/commands/login.js,views/checkout/partials/inline-login-prompt.php
Runtime Testing
Resolves #1357
aidevops.sh v3.20.41 plugin for OpenCode v1.16.2 with gpt-5.5 spent 4h 16m and 1,842,331 tokens on this with the user in an interactive session.
Summary by CodeRabbit