Skip to content

Add WebAuthn/Passkey support to Login and LoginPassword flows#960

Merged
garronej merged 8 commits intokeycloakify:mainfrom
discostur:feature/passkey-integration-login-password
Nov 25, 2025
Merged

Add WebAuthn/Passkey support to Login and LoginPassword flows#960
garronej merged 8 commits intokeycloakify:mainfrom
discostur:feature/passkey-integration-login-password

Conversation

@discostur
Copy link
Copy Markdown
Contributor

@discostur discostur commented Nov 19, 2025

Add Passkey Integration to Login and Login-Password Forms

Completes passkey integration for Keycloakify, addressing issues #918 and #69.

What's Changed:

  • Added WebAuthn support to login.ftl and login-password.ftl forms
  • Extended KcContext types with WebAuthn fields for Login and LoginPassword
  • Created Login.useScript.tsx and LoginPassword.useScript.tsx for authentication handlers
  • Updated Login.tsx, LoginPassword.tsx, KcContext.ts, and kcContextMocks.ts

Implementation:

  • Conditional passkey button (shown when enableWebAuthnConditionalUI enabled)
  • Hidden forms for WebAuthn credential submission
  • Consistent with existing LoginUsername implementation

Regards,
Kilian

Summary by CodeRabbit

  • New Features

    • Added WebAuthn (passkey) support across login flows: conditional passkey UI behind a feature flag, localized error text, visible "Authenticate with passkey" button, hidden fields to submit passkey responses, and selection of registered authenticators when available.
  • Tests

    • Expanded login mocks to include WebAuthn-related fields for more complete test coverage.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Nov 19, 2025

Walkthrough

Adds WebAuthn (passkey) support to login flows: expands KcContext types and mocks with WebAuthn fields, conditionally renders WebAuthn UI in Login and LoginPassword pages, and adds two hooks (Login.useScript and LoginPassword.useScript) that inject a module script to perform client-side WebAuthn authentication.

Changes

Cohort / File(s) Summary
KcContext types & mocks
src/login/KcContext/KcContext.ts, src/login/KcContext/kcContextMocks.ts
Extend KcContext.Login, KcContext.LoginUsername, and KcContext.LoginPassword with WebAuthn properties (e.g. enableWebAuthnConditionalUI, authenticators, challenge, userVerification, rpId, createTimeout, isUserIdentified, shouldDisplayAuthenticators) and update mocks to include those fields.
Login page + hook
src/login/pages/Login.tsx, src/login/pages/Login.useScript.tsx
Login.tsx reads WebAuthn fields, conditionally renders hidden WebAuthn inputs, optional authenticator-list form, and a visible authenticate button; imports and calls useScript. New Login.useScript.tsx hook inserts a module script that imports authenticateByWebAuthn and binds a click handler using kcContext and i18n.
LoginPassword page + hook
src/login/pages/LoginPassword.tsx, src/login/pages/LoginPassword.useScript.tsx
LoginPassword.tsx mirrors Login: conditionally renders WebAuthn hidden inputs/authenticator list and authenticate button, and calls useScript. New LoginPassword.useScript.tsx hook injects a module script scoped to the password step and binds the authenticate click handler with kcContext/i18n values.
LoginUsername minor tweak
src/login/pages/LoginUsername.tsx
Use authenticators.authenticators.length to check availability; add readOnly to generated hidden inputs for authenticator credential IDs; minor whitespace adjustments.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Page as Login / LoginPassword
    participant Hook as useScript
    participant DOM
    participant Script as Injected Module
    participant Auth as authenticateByWebAuthn

    User->>Page: load (enableWebAuthnConditionalUI = true)
    Page->>Hook: call useScript(webAuthnButtonId, kcContext, i18n)
    Hook->>Hook: wait for translations & validate kcContext
    Hook->>DOM: wait for button element to mount
    Hook->>DOM: insert module script (imports authenticateByWebAuthn)
    User->>DOM: click authenticate button
    DOM->>Script: click handler executes
    Script->>Auth: call authenticateByWebAuthn({challenge,userVerification,rpId,createTimeout,isUserIdentified,errmsg})
    Auth-->>Script: result (success/error)
    Script->>DOM: populate hidden inputs and submit form
    DOM-->>User: proceed with login flow
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

  • Pay attention to:
    • src/login/KcContext/KcContext.ts — union/literal typing changes across variants.
    • src/login/pages/*useScript.tsx — timing of script injection, DOM wait logic, and payload construction.
    • src/login/pages/Login*.tsx — hidden input names, authenticator-list rendering, and form integration.
    • src/login/KcContext/kcContextMocks.ts — mock shape and test coverage for new fields.

Possibly related PRs

Poem

🐇 I found a tiny secret key,
I tucked it in a hidden tree.
A script, a click, a challenge hum,
Inputs wake and forms become,
Passkeys hop — the login's free.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: adding WebAuthn/Passkey support to Login and LoginPassword flows, which is the primary objective of this PR.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8189cbb and 4939dc8.

📒 Files selected for processing (3)
  • src/login/pages/Login.tsx (4 hunks)
  • src/login/pages/LoginPassword.tsx (4 hunks)
  • src/login/pages/LoginUsername.tsx (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/login/pages/Login.tsx
🧰 Additional context used
🧬 Code graph analysis (1)
src/login/pages/LoginPassword.tsx (1)
src/login/pages/LoginPassword.useScript.tsx (1)
  • useScript (26-71)
🔇 Additional comments (5)
src/login/pages/LoginUsername.tsx (2)

167-175: LGTM! Authenticators array check corrected.

The condition now correctly evaluates authenticators.authenticators.length !== 0 instead of checking object keys. This ensures the form only renders when the authenticators array actually contains items, fixing the logic issue where Object.keys(authenticators) would return a non-empty array even when no authenticators exist.


171-171: LGTM! ReadOnly attribute added to credential input.

The readOnly attribute is appropriate for this hidden input containing authenticator credential IDs, preventing accidental client-side modification. This aligns with the secure handling pattern applied consistently across the WebAuthn implementation.

src/login/pages/LoginPassword.tsx (3)

1-4: LGTM! Helpful JSDoc added.

The comment clearly documents the page purpose and notes the conditional WebAuthn functionality, improving code maintainability.


14-14: LGTM! WebAuthn script integration correctly wired.

The useScript hook is properly imported and invoked with the required parameters (webAuthnButtonId, kcContext, i18n). The webAuthnButtonId constant follows the naming convention established for newly introduced pages, maintaining consistency with the Login page while preserving backward compatibility with the existing LoginUsername implementation.

Also applies to: 30-36


114-143: LGTM! WebAuthn UI implementation follows established pattern.

The WebAuthn UI block correctly mirrors the structure established in LoginUsername.tsx:

  • Hidden webauth form contains all required WebAuthn response fields
  • Conditional authn_select form renders only when authenticators exist (line 125 correctly checks authenticators.authenticators.length)
  • readOnly attribute on credential inputs (line 129) prevents client-side tampering
  • Authenticate button uses proper i18n key and is wired to the useScript hook via webAuthnButtonId

The implementation is secure, consistent, and aligns with the reviewer's expectations to match the reference implementation.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (4)
src/login/pages/Login.useScript.tsx (1)

58-75: Align guard comment with actual condition (optional)

The comment says “Only insert script if enableWebAuthnConditionalUI is true”, but the guard actually checks for presence of WebAuthn challenge data (challenge, userVerification, rpId, createTimeout). To avoid confusion, you can update the comment to reflect what’s really gating:

-        // Only insert script if enableWebAuthnConditionalUI is true
+        // Only insert script once WebAuthn challenge data is present

This keeps behavior as‑is while making the intent clearer (same applies to the LoginPassword hook).

src/login/pages/LoginPassword.tsx (1)

10-33: WebAuthn UI integration is consistent; tiny optional tweak on authenticator check

The useScript wiring and conditional WebAuthn block mirror the Login/WebauthnAuthenticate patterns and look correct: hidden webauth + authn_select forms and a dedicated button with the expected authButtonId.

Minor nit: the authn_select guard

authenticators !== undefined && Object.keys(authenticators).length !== 0

is effectively equivalent to just checking authenticators, since the object will always have at least the authenticators key. If you actually want to gate on having at least one credential, consider:

authenticators?.authenticators.length

Not required for correctness, but a bit clearer.

Also applies to: 110-139

src/login/pages/Login.tsx (1)

10-33: Login WebAuthn wiring matches other flows; consider small cleanup on authenticator guard

The useScript integration and conditional WebAuthn section align with the LoginPassword/WebauthnAuthenticate implementations and should work as expected with the new KC context fields.

As in LoginPassword.tsx, the condition

authenticators !== undefined && Object.keys(authenticators).length !== 0

doesn’t really filter out the “no authenticators” case—Object.keys will still be ["authenticators"] even when the inner array is empty. If you want to require at least one credential, you can simplify/clarify to:

authenticators?.authenticators.length

Also, the WebAuthn JSX block is now duplicated between Login and LoginPassword; extracting a tiny shared fragment (e.g. WebauthnConditionalBlock) would reduce duplication, but that’s optional.

Also applies to: 204-233

src/login/KcContext/KcContext.ts (1)

175-218: KcContext WebAuthn extensions are consistent; optional type tightening

The added WebAuthn fields on Login, LoginUsername, LoginPassword, and LoginPasskeysConditionalAuthenticate line up with how the new hooks and mocks consume them, and keep existing pages backward‑compatible via optional properties where needed.

If you want stricter typing, you could consider reusing the same userVerification union (UserVerificationRequirement | "not specified") used on WebauthnAuthenticate/LoginUsername for Login, LoginPassword, and LoginPasskeysConditionalAuthenticate instead of plain string, but that’s purely a type‑safety nicety.

Also applies to: 329-359, 361-385, 611-635

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3ba0551 and 617d32d.

📒 Files selected for processing (6)
  • src/login/KcContext/KcContext.ts (2 hunks)
  • src/login/KcContext/kcContextMocks.ts (2 hunks)
  • src/login/pages/Login.tsx (3 hunks)
  • src/login/pages/Login.useScript.tsx (1 hunks)
  • src/login/pages/LoginPassword.tsx (3 hunks)
  • src/login/pages/LoginPassword.useScript.tsx (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (5)
src/login/pages/Login.useScript.tsx (6)
src/login/KcContext/KcContext.ts (2)
  • KcContext (28-65)
  • Login (175-218)
src/login/pages/Login.tsx (1)
  • Login (12-236)
src/login/pages/LoginPassword.useScript.tsx (1)
  • useScript (26-76)
src/tools/useInsertScriptTags.ts (1)
  • useInsertScriptTags (32-120)
src/tools/waitForElementMountedOnDom.ts (1)
  • waitForElementMountedOnDom (1-30)
src/login/pages/LoginUsername.useScript.tsx (1)
  • useScript (26-71)
src/login/pages/LoginPassword.tsx (1)
src/login/pages/LoginPassword.useScript.tsx (1)
  • useScript (26-76)
src/login/pages/LoginPassword.useScript.tsx (5)
src/login/KcContext/KcContext.ts (2)
  • KcContext (28-65)
  • LoginPassword (361-386)
src/login/pages/LoginPassword.tsx (1)
  • LoginPassword (12-142)
src/login/pages/Login.useScript.tsx (1)
  • useScript (26-76)
src/tools/useInsertScriptTags.ts (1)
  • useInsertScriptTags (32-120)
src/tools/waitForElementMountedOnDom.ts (1)
  • waitForElementMountedOnDom (1-30)
src/login/pages/Login.tsx (1)
src/login/pages/Login.useScript.tsx (1)
  • useScript (26-76)
src/login/KcContext/KcContext.ts (1)
src/login/pages/WebauthnAuthenticate.tsx (1)
  • WebauthnAuthenticate (9-138)
🔇 Additional comments (3)
src/login/pages/Login.useScript.tsx (1)

7-56: WebAuthn script wiring and KcContext typing look consistent

The KcContextLike/I18nLike shapes, compile‑time assert checks, and injected module script closely mirror the existing LoginUsername.useScript pattern while safely coercing isUserIdentified and createTimeout into JS literals. The dynamic errmsg via JSON.stringify is also a good choice to avoid quoting issues.

src/login/KcContext/kcContextMocks.ts (1)

201-231: Mocks correctly extended for WebAuthn flows

The added WebAuthn fields on Login, LoginUsername, and LoginPassword mocks match the new KcContext typings and give sane defaults (enableWebAuthnConditionalUI: false, empty challenge, etc.), so the new hooks can short‑circuit cleanly in dev/Storybook while still exercising the shapes.

Also applies to: 339-362, 363-381

src/login/pages/LoginPassword.useScript.tsx (1)

7-75: Hook implementation is consistent with Login.useScript and types

KcContextLike/I18nLike and the injected module script mirror Login.useScript while being correctly tied to KcContext.LoginPassword through the assert calls. The effect guards on translations + WebAuthn data before inserting the script, which should avoid running the flow when KC hasn’t provided the necessary fields.

@discostur
Copy link
Copy Markdown
Contributor Author

@coderabbitai generate docstrings

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Nov 19, 2025

✅ Actions performed

Initiated docstring generation; will generate only if new commits exist.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Nov 19, 2025

Note

Docstrings generation - SUCCESS
Generated docstrings for this pull request at #961

@discostur discostur changed the title Add missing passkey stuff Add WebAuthn/Passkey support to Login and LoginPassword flows Nov 19, 2025
coderabbitai Bot added a commit that referenced this pull request Nov 19, 2025
Docstrings generation was requested by @discostur.

* #960 (comment)

The following files were modified:

* `src/login/pages/Login.tsx`
* `src/login/pages/Login.useScript.tsx`
* `src/login/pages/LoginPassword.tsx`
* `src/login/pages/LoginPassword.useScript.tsx`
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (2)
src/login/pages/LoginPassword.useScript.tsx (1)

63-81: Add missing dependencies to useEffect array.

The effect uses authButtonId (line 75) and insertScriptTags (line 78) but doesn't list them in the dependency array (line 80). While authButtonId is a constant and insertScriptTags should be stable from useCallback, React expects all dependencies to be declared.

-    }, [isFetchingTranslations, challenge, userVerification, rpId, createTimeout]);
+    }, [isFetchingTranslations, challenge, userVerification, rpId, createTimeout, authButtonId, insertScriptTags]);

This satisfies the exhaustive-deps rule and makes dependencies explicit.

src/login/pages/Login.useScript.tsx (1)

64-82: Add missing dependencies to useEffect array.

Same issue as in LoginPassword.useScript.tsx: the effect uses authButtonId (line 76) and insertScriptTags (line 79) but they're not in the dependency array (line 81).

-    }, [isFetchingTranslations, challenge, userVerification, rpId, createTimeout]);
+    }, [isFetchingTranslations, challenge, userVerification, rpId, createTimeout, authButtonId, insertScriptTags]);
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 617d32d and e22d876.

📒 Files selected for processing (5)
  • src/login/KcContext/KcContext.ts (2 hunks)
  • src/login/pages/Login.tsx (4 hunks)
  • src/login/pages/Login.useScript.tsx (1 hunks)
  • src/login/pages/LoginPassword.tsx (4 hunks)
  • src/login/pages/LoginPassword.useScript.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/login/pages/LoginPassword.tsx
🧰 Additional context used
🧬 Code graph analysis (4)
src/login/pages/Login.tsx (1)
src/login/pages/Login.useScript.tsx (1)
  • useScript (32-82)
src/login/pages/Login.useScript.tsx (6)
src/login/KcContext/KcContext.ts (2)
  • KcContext (28-65)
  • Login (175-219)
src/login/pages/Login.tsx (1)
  • Login (16-240)
src/login/pages/LoginPassword.useScript.tsx (1)
  • useScript (31-81)
src/tools/useInsertScriptTags.ts (1)
  • useInsertScriptTags (32-120)
src/tools/waitForElementMountedOnDom.ts (1)
  • waitForElementMountedOnDom (1-30)
src/login/pages/LoginUsername.useScript.tsx (1)
  • useScript (26-71)
src/login/pages/LoginPassword.useScript.tsx (4)
src/login/KcContext/KcContext.ts (2)
  • KcContext (28-65)
  • LoginPassword (362-388)
src/login/pages/LoginPassword.tsx (1)
  • LoginPassword (16-146)
src/tools/useInsertScriptTags.ts (1)
  • useInsertScriptTags (32-120)
src/tools/waitForElementMountedOnDom.ts (1)
  • waitForElementMountedOnDom (1-30)
src/login/KcContext/KcContext.ts (1)
src/login/pages/WebauthnAuthenticate.tsx (1)
  • WebauthnAuthenticate (9-138)
🔇 Additional comments (6)
src/login/pages/Login.tsx (4)

14-14: LGTM: useScript hook imported.

The import path is correct and aligns with the new hook introduced in this PR.


24-25: LGTM: WebAuthn context fields destructured.

The WebAuthn fields are correctly extracted from kcContext, matching the type extensions in KcContext.ts.


31-37: Consider conditionally invoking useScript hook.

The useScript hook is invoked unconditionally even when WebAuthn is disabled. While the hook internally guards script insertion, you could optimize by conditionally calling it:

  const authButtonId = "authenticateWebAuthnButton";

- useScript({
-     authButtonId,
-     kcContext,
-     i18n
- });
+ if (enableWebAuthnConditionalUI) {
+     useScript({
+         authButtonId,
+         kcContext,
+         i18n
+     });
+ }

However, this would violate the Rules of Hooks. A better approach is to keep the hook unconditional (as it is now) since the internal guards prevent unnecessary work when WebAuthn is disabled.


208-237: LGTM: WebAuthn UI properly integrated.

The conditional WebAuthn block is well-structured:

  • Properly gated by enableWebAuthnConditionalUI
  • Hidden forms correctly configured for credential submission
  • Authenticator list rendered conditionally
  • Button wired with the expected ID
src/login/KcContext/KcContext.ts (2)

377-387: LGTM: WebAuthn fields added to LoginPassword type.

The fields mirror those added to the Login type, maintaining consistency between the two password-based flows.


208-218: Verify WebAuthn field optionality and type alignment reflects actual Keycloak behavior.

The inconsistencies flagged in the review are confirmed:

  • LoginUsername requires isUserIdentified, challenge, userVerification, rpId, createTimeout as non-optional fields with type UserVerificationRequirement | "not specified"
  • Login and LoginPassword make all WebAuthn fields optional and use type WebauthnAuthenticate["userVerification"] instead
  • Login.isUserIdentified also includes boolean in its union, while LoginUsername.isUserIdentified restricts to "true" | "false"

Verify whether these differences are intentional to match Keycloak's actual API contract for each login flow, or whether alignment is needed.

Comment thread src/login/pages/Login.useScript.tsx
Comment thread src/login/pages/Login.useScript.tsx Outdated
Comment on lines +1 to +81
/**
* Hook to wire WebAuthn passkey authentication on the password step (login-password.ftl).
* Mirrors Login.useScript but scoped to the second step of the flow.
*/
import { useEffect } from "react";
import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
import { assert } from "keycloakify/tools/assert";
import { KcContext } from "keycloakify/login/KcContext/KcContext";
import { waitForElementMountedOnDom } from "keycloakify/tools/waitForElementMountedOnDom";

type KcContextLike = {
url: {
resourcesPath: string;
};
isUserIdentified?: "true" | "false" | boolean;
challenge?: string;
userVerification?: string;
rpId?: string;
createTimeout?: number | string;
};

assert<keyof KcContextLike extends keyof KcContext.LoginPassword ? true : false>();
assert<KcContext.LoginPassword extends KcContextLike ? true : false>();

type I18nLike = {
msgStr: (key: "webauthn-unsupported-browser-text") => string;
isFetchingTranslations: boolean;
};

/** Injects the WebAuthn authentication script and binds click listener to the passkey button. */
export function useScript(params: { authButtonId: string; kcContext: KcContextLike; i18n: I18nLike }) {
const { authButtonId, kcContext, i18n } = params;

const { url, isUserIdentified, challenge, userVerification, rpId, createTimeout } = kcContext;

const { msgStr, isFetchingTranslations } = i18n;

const { insertScriptTags } = useInsertScriptTags({
componentOrHookName: "LoginPassword",
scriptTags: [
{
type: "module",
textContent: () => `

import { authenticateByWebAuthn } from "${url.resourcesPath}/js/webauthnAuthenticate.js";
const authButton = document.getElementById('${authButtonId}');
authButton.addEventListener("click", function() {
const input = {
isUserIdentified : ${isUserIdentified ?? false},
challenge : '${challenge ?? ""}',
userVerification : '${userVerification ?? ""}',
rpId : '${rpId ?? ""}',
createTimeout : ${createTimeout ?? 0},
errmsg : ${JSON.stringify(msgStr("webauthn-unsupported-browser-text"))}
};
authenticateByWebAuthn(input);
});
`
}
]
});

useEffect(() => {
if (isFetchingTranslations) {
return;
}

// Only insert script if enableWebAuthnConditionalUI is true
if (!challenge || !userVerification || !rpId || createTimeout === undefined) {
return;
}

(async () => {
await waitForElementMountedOnDom({
elementId: authButtonId
});

insertScriptTags();
})();
}, [isFetchingTranslations, challenge, userVerification, rpId, createTimeout]);
}
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Nov 19, 2025

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Extract shared logic to eliminate duplication.

This file is nearly identical to src/login/pages/Login.useScript.tsx (only difference: componentOrHookName). Consider extracting the shared logic:

// src/login/pages/shared/useWebAuthnScript.tsx
function useWebAuthnScript(params: { 
    authButtonId: string; 
    kcContext: KcContextLike; 
    i18n: I18nLike;
    componentOrHookName: string;
}) {
    // ... shared implementation
}

// Then in Login.useScript.tsx:
export function useScript(params: { authButtonId: string; kcContext: KcContextLike; i18n: I18nLike }) {
    return useWebAuthnScript({ ...params, componentOrHookName: "Login" });
}

// And in LoginPassword.useScript.tsx:
export function useScript(params: { authButtonId: string; kcContext: KcContextLike; i18n: I18nLike }) {
    return useWebAuthnScript({ ...params, componentOrHookName: "LoginPassword" });
}

This eliminates ~70 lines of duplication and makes future maintenance easier.

🤖 Prompt for AI Agents
src/login/pages/LoginPassword.useScript.tsx lines 1-81, the hook duplicates
Login.useScript.tsx; extract the shared logic into a new module (e.g.,
src/login/pages/shared/useWebAuthnScript.tsx) that accepts { authButtonId,
kcContext, i18n, componentOrHookName } and contains the existing implementation
(including types KcContextLike and I18nLike, useInsertScriptTags usage,
waitForElementMountedOnDom logic and effect dependencies); then replace this
file’s useScript with a thin wrapper that calls the shared hook with
componentOrHookName: "LoginPassword" and update Login.useScript.tsx to call the
same shared hook with componentOrHookName: "Login"; adjust imports accordingly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

not sure about that ... will wait for feedback from maintainer

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.

@discostur That sounds reasonable! Waiting for maintainer feedback on architectural decisions like this is a good practice. The current implementation works correctly, so there's no urgency to refactor.

Feel free to ping me if you or the maintainer would like to proceed with the refactoring later, or if you need any assistance with it. Happy to help when you're ready! 🐰

Comment thread src/login/pages/LoginPassword.useScript.tsx
@discostur
Copy link
Copy Markdown
Contributor Author

@garronej could you please have a look - thanks

@garronej
Copy link
Copy Markdown
Collaborator

Hey @discostur,

Thank you very much for taking this on, and sorry for the late review.

I skimmed through the changes, and I’m a bit concerned about how the implementation diverges from the reference version in Keycloakify:
f039e59

From my understanding, this part should behave exactly the same.

I’m guessing some of the structure may come from @coderabbitai suggestions. While those can be helpful for catching certain classes of bugs, they often add defensive checks that don’t match the actual asserted state of the system, and that can lead to misleading logic paths. In patterns like these, it’s usually better to mirror Keycloak’s behavior precisely.

For example, in the snippet below:

useEffect(() => {
    if (isFetchingTranslations) {
        return;
    }

    // Only insert script if enableWebAuthnConditionalUI is true
    if (!challenge || !userVerification || !rpId || createTimeout === undefined) {
        return;
    }

    (async () => {
        await waitForElementMountedOnDom({
            elementId: authButtonId
        });

        insertScriptTags();
    })();
}, [isFetchingTranslations, challenge, userVerification, rpId, createTimeout]);

A few points:

  • isFetchingTranslations, challenge, userVerification, rpId, and createTimeout all come from kcContext.
    These are immutable values for the whole lifespan of this screen, so putting them in the dependency array is misleading.

  • The check

    if (!challenge || !userVerification || !rpId || createTimeout === undefined)

    is redundant.
    The button we’re waiting for only exists when WebAuthn is enabled.
    If any of those values were unexpectedly missing, we want to surface the error rather than silently skipping execution. Silent failure would make the logic harder to debug later.

Because of this, the safest and clearest approach is simply to mirror the logic from login-username.ftl exactly. That version encodes the correct assumptions about when this should run and keeps the behavior aligned with Keycloak.

Could you refactor the code to match that version as closely as possible?

Thanks again for the contribution, it’s genuinely appreciated!

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Nov 21, 2025

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Fetch the reference commit diff to understand the correct implementation pattern
git fetch origin f039e5905d706eab05577055656de1b6e6bd88da 2>/dev/null || true
git show f039e5905d706eab05577055656de1b6e6bd88da --stat

Length of output: 506


🏁 Script executed:

#!/bin/bash
# Get the actual changes from the reference commit for login-username related files
git show f039e5905d706eab05577055656de1b6e6bd88da -- src/login/pages/LoginUsername.tsx src/login/pages/LoginUsername.useScript.tsx 2>/dev/null | head -n 300

Length of output: 6158


🏁 Script executed:

#!/bin/bash
# Look at the current Login.useScript.tsx implementation
cat src/login/pages/Login.useScript.tsx

Length of output: 3259


🏁 Script executed:

#!/bin/bash
# Look at the current LoginPassword.useScript.tsx implementation
cat src/login/pages/LoginPassword.useScript.tsx

Length of output: 3170


🏁 Script executed:

#!/bin/bash
# Also check LoginUsername.useScript.tsx to see the reference pattern in the current codebase
cat src/login/pages/LoginUsername.useScript.tsx

Length of output: 2552


@garronej, thank you for the detailed feedback! You're absolutely right about the divergence from the reference implementation. I can see the issue clearly now when comparing with LoginUsername.useScript.tsx.

The current Login.useScript.tsx and LoginPassword.useScript.tsx implementations have several problems:

  1. Optional properties - Making challenge, userVerification, etc. optional when they should be required
  2. Defensive checks - The if (!challenge || !userVerification...) silently returns, hiding errors instead of surfacing them
  3. Bloated dependency array - Including immutable kcContext values that never change during the screen's lifespan
  4. Inconsistent script generation - Using JSON.stringify with fallbacks instead of direct value insertion

Here's how both files should be refactored to match the LoginUsername.useScript.tsx pattern:

src/login/pages/Login.useScript.tsx:

import { useEffect } from "react";
import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
import { assert } from "keycloakify/tools/assert";
import { KcContext } from "keycloakify/login/KcContext/KcContext";
import { waitForElementMountedOnDom } from "keycloakify/tools/waitForElementMountedOnDom";

type KcContextLike = {
    url: {
        resourcesPath: string;
    };
    isUserIdentified: "true" | "false" | boolean;
    challenge: string;
    userVerification: string | KcContext.WebauthnAuthenticate["userVerification"];
    rpId: string;
    createTimeout: number | string;
};

assert<keyof KcContextLike extends keyof KcContext.Login ? true : false>();
assert<KcContext.Login extends KcContextLike ? true : false>();

type I18nLike = {
    msgStr: (key: "webauthn-unsupported-browser-text") => string;
    isFetchingTranslations: boolean;
};

export function useScript(params: { authButtonId: string; kcContext: KcContextLike; i18n: I18nLike }) {
    const { authButtonId, kcContext, i18n } = params;

    const { url, isUserIdentified, challenge, userVerification, rpId, createTimeout } = kcContext;

    const { msgStr, isFetchingTranslations } = i18n;

    const { insertScriptTags } = useInsertScriptTags({
        componentOrHookName: "Login",
        scriptTags: [
            {
                type: "module",
                textContent: () => `

                    import { authenticateByWebAuthn } from "${url.resourcesPath}/js/webauthnAuthenticate.js";
                    const authButton = document.getElementById('${authButtonId}');
                    authButton.addEventListener("click", function() {
                        const input = {
                            isUserIdentified : ${isUserIdentified},
                            challenge : '${challenge}',
                            userVerification : '${userVerification}',
                            rpId : '${rpId}',
                            createTimeout : ${createTimeout},
                            errmsg : ${JSON.stringify(msgStr("webauthn-unsupported-browser-text"))}
                        };
                        authenticateByWebAuthn(input);
                    });
                `
            }
        ]
    });

    useEffect(() => {
        if (isFetchingTranslations) {
            return;
        }

        (async () => {
            await waitForElementMountedOnDom({
                elementId: authButtonId
            });

            insertScriptTags();
        })();
    }, [isFetchingTranslations]);
}

src/login/pages/LoginPassword.useScript.tsx:

import { useEffect } from "react";
import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
import { assert } from "keycloakify/tools/assert";
import { KcContext } from "keycloakify/login/KcContext/KcContext";
import { waitForElementMountedOnDom } from "keycloakify/tools/waitForElementMountedOnDom";

type KcContextLike = {
    url: {
        resourcesPath: string;
    };
    isUserIdentified: "true" | "false" | boolean;
    challenge: string;
    userVerification: string | KcContext.WebauthnAuthenticate["userVerification"];
    rpId: string;
    createTimeout: number | string;
};

assert<keyof KcContextLike extends keyof KcContext.LoginPassword ? true : false>();
assert<KcContext.LoginPassword extends KcContextLike ? true : false>();

type I18nLike = {
    msgStr: (key: "webauthn-unsupported-browser-text") => string;
    isFetchingTranslations: boolean;
};

export function useScript(params: { authButtonId: string; kcContext: KcContextLike; i18n: I18nLike }) {
    const { authButtonId, kcContext, i18n } = params;

    const { url, isUserIdentified, challenge, userVerification, rpId, createTimeout } = kcContext;

    const { msgStr, isFetchingTranslations } = i18n;

    const { insertScriptTags } = useInsertScriptTags({
        componentOrHookName: "LoginPassword",
        scriptTags: [
            {
                type: "module",
                textContent: () => `

                    import { authenticateByWebAuthn } from "${url.resourcesPath}/js/webauthnAuthenticate.js";
                    const authButton = document.getElementById('${authButtonId}');
                    authButton.addEventListener("click", function() {
                        const input = {
                            isUserIdentified : ${isUserIdentified},
                            challenge : '${challenge}',
                            userVerification : '${userVerification}',
                            rpId : '${rpId}',
                            createTimeout : ${createTimeout},
                            errmsg : ${JSON.stringify(msgStr("webauthn-unsupported-browser-text"))}
                        };
                        authenticateByWebAuthn(input);
                    });
                `
            }
        ]
    });

    useEffect(() => {
        if (isFetchingTranslations) {
            return;
        }

        (async () => {
            await waitForElementMountedOnDom({
                elementId: authButtonId
            });

            insertScriptTags();
        })();
    }, [isFetchingTranslations]);
}

The key changes:

  • Removed optional ? markers from WebAuthn properties
  • Removed defensive checks - let errors surface if values are missing
  • Simplified dependency array to only [isFetchingTranslations]
  • Changed script generation to use direct value insertion (no JSON.stringify for most fields, matching Keycloak's behavior)
  • Removed docstrings to match the reference style

This now mirrors the LoginUsername.useScript.tsx implementation exactly, encoding the same assumptions about when WebAuthn should run.

@discostur
Copy link
Copy Markdown
Contributor Author

@garronej thanks for your feedback. i just provided the changes based on your feedback:

Key Changes

  1. Removed Optional Properties

    • WebAuthn properties (challenge, userVerification, rpId, createTimeout, isUserIdentified) are now required in type definitions
    • Updated KcContext.Login and KcContext.LoginPassword types in KcContext.ts
  2. Removed Defensive Checks

    • Eliminated conditional logic that checked for presence of WebAuthn parameters
    • Removed early returns that masked configuration errors
    • Let type system enforce correct usage instead of runtime guards
  3. Simplified Dependency Arrays

    • Changed from [isFetchingTranslations, challenge, userVerification, rpId, createTimeout, authButtonId, insertScriptTags]
    • To only [isFetchingTranslations] (kcContext values are immutable)
  4. Direct Value Interpolation

    • Removed unnecessary JSON.stringify() wrapping for direct values
    • Only kept JSON.stringify() for errmsg translation string
    • Matches pattern: isUserIdentified : ${isUserIdentified} instead of isUserIdentified : ${JSON.stringify(isUserIdentified ?? false)}

@garronej
Copy link
Copy Markdown
Collaborator

Thank you very much!

If I may ask a last little thing, I made an error. authButtonId should have been called webAuthnButtonId.
I know this is nitpicking and it's my bad but since we try to avoid breaking would you name it correctly on the pages you introduced the feature?

And if I might ask, would you apply those changes here as well? https://github.com/keycloakify/login-ui

Thank you in advance, I'm a bit under and you're saving me a lot of time with this!

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
src/login/pages/LoginPassword.tsx (1)

114-143: Consider extracting shared WebAuthn UI block.

The WebAuthn UI block (lines 114–143) is nearly identical to the one in Login.tsx (lines 208–237). Extracting this into a shared component would eliminate ~30 lines of duplication and ensure consistency.

Example structure:

// src/login/components/WebAuthnAuthenticator.tsx
export function WebAuthnAuthenticator(props: {
    webAuthnButtonId: string;
    url: { loginAction: string };
    authenticators?: { authenticators: Array<...> };
    kcClsx: KcClsx;
    msgStr: (key: string) => string;
}) {
    const { webAuthnButtonId, url, authenticators, kcClsx, msgStr } = props;
    return (
        <>
            <form id="webauth" action={url.loginAction} method="post">
                {/* hidden inputs */}
            </form>
            {/* authenticators form */}
            <input id={webAuthnButtonId} ... />
        </>
    );
}

Then use <WebAuthnAuthenticator ... /> in both Login.tsx and LoginPassword.tsx.

src/login/pages/Login.useScript.tsx (1)

1-71: Consider extracting shared useScript logic.

Login.useScript.tsx and LoginPassword.useScript.tsx are nearly identical (~70 lines of duplication). The only difference is the componentOrHookName parameter passed to useInsertScriptTags. Consider extracting the shared logic into a common hook:

// src/login/pages/shared/useWebAuthnScript.tsx
export function useWebAuthnScript(params: { 
    webAuthnButtonId: string; 
    kcContext: KcContextLike; 
    i18n: I18nLike;
    componentOrHookName: string;
}) {
    // ... current implementation
}

// Login.useScript.tsx
export function useScript(params: { webAuthnButtonId: string; kcContext: KcContextLike; i18n: I18nLike }) {
    return useWebAuthnScript({ ...params, componentOrHookName: "Login" });
}

// LoginPassword.useScript.tsx
export function useScript(params: { webAuthnButtonId: string; kcContext: KcContextLike; i18n: I18nLike }) {
    return useWebAuthnScript({ ...params, componentOrHookName: "LoginPassword" });
}

Note: This was previously discussed; awaiting maintainer decision.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4febe61 and 8189cbb.

📒 Files selected for processing (5)
  • src/login/KcContext/KcContext.ts (2 hunks)
  • src/login/pages/Login.tsx (4 hunks)
  • src/login/pages/Login.useScript.tsx (1 hunks)
  • src/login/pages/LoginPassword.tsx (4 hunks)
  • src/login/pages/LoginPassword.useScript.tsx (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (5)
src/login/pages/Login.useScript.tsx (5)
src/login/KcContext/KcContext.ts (3)
  • KcContext (28-65)
  • WebauthnAuthenticate (388-408)
  • Login (175-218)
src/login/pages/Login.tsx (1)
  • Login (16-240)
src/login/pages/LoginPassword.useScript.tsx (1)
  • useScript (26-71)
src/tools/useInsertScriptTags.ts (1)
  • useInsertScriptTags (32-120)
src/tools/waitForElementMountedOnDom.ts (1)
  • waitForElementMountedOnDom (1-30)
src/login/pages/Login.tsx (1)
src/login/pages/Login.useScript.tsx (1)
  • useScript (26-71)
src/login/pages/LoginPassword.useScript.tsx (4)
src/login/KcContext/KcContext.ts (3)
  • KcContext (28-65)
  • WebauthnAuthenticate (388-408)
  • LoginPassword (361-386)
src/login/pages/Login.useScript.tsx (1)
  • useScript (26-71)
src/tools/useInsertScriptTags.ts (1)
  • useInsertScriptTags (32-120)
src/tools/waitForElementMountedOnDom.ts (1)
  • waitForElementMountedOnDom (1-30)
src/login/pages/LoginPassword.tsx (1)
src/login/pages/LoginPassword.useScript.tsx (1)
  • useScript (26-71)
src/login/KcContext/KcContext.ts (1)
src/login/pages/WebauthnAuthenticate.tsx (1)
  • WebauthnAuthenticate (9-138)
🔇 Additional comments (3)
src/login/pages/Login.useScript.tsx (1)

1-71: LGTM! Implementation aligns with maintainer requirements.

The hook correctly:

  • Defines constrained types (KcContextLike, I18nLike) with compile-time assertions ensuring compatibility with KcContext.Login
  • Uses direct value interpolation in the script template as requested by the maintainer to match the reference implementation
  • Defers script insertion until translations are ready and the button is mounted
  • Keeps the effect dependency array minimal (isFetchingTranslations only), treating kcContext fields as immutable per maintainer feedback
src/login/pages/LoginPassword.useScript.tsx (1)

1-71: LGTM! Correctly mirrors Login.useScript pattern.

This hook properly adapts the WebAuthn script injection for the LoginPassword flow:

  • Type assertions validate compatibility with KcContext.LoginPassword instead of Login
  • Uses "LoginPassword" as the componentOrHookName to ensure proper script tracking
  • Follows the same implementation pattern as Login.useScript.tsx per maintainer requirements
src/login/KcContext/KcContext.ts (1)

208-217: LGTM! WebAuthn type additions are well-structured.

The WebAuthn field additions to Login (lines 208–217) and LoginPassword (lines 376–385) correctly:

  • Mark core WebAuthn parameters (challenge, userVerification, rpId, createTimeout, isUserIdentified) as required, reflecting that they're immutable and always present per maintainer guidance
  • Keep UI control flags (enableWebAuthnConditionalUI, authenticators, shouldDisplayAuthenticators) optional to support conditional rendering
  • Reuse the WebauthnAuthenticate["userVerification"] type for consistency across contexts
  • Align with the existing WebauthnAuthenticate type structure

Also applies to: 376-385

Comment thread src/login/pages/Login.tsx Outdated
Comment on lines +219 to +227
{authenticators !== undefined && Object.keys(authenticators).length !== 0 && (
<>
<form id="authn_select" className={kcClsx("kcFormClass")}>
{authenticators.authenticators.map((authenticator, i) => (
<input key={i} type="hidden" name="authn_use_chk" readOnly value={authenticator.credentialId} />
))}
</form>
</>
)}
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.

⚠️ Potential issue | 🔴 Critical

Correct the authenticators array check.

Same issue as in LoginPassword.tsx: the condition Object.keys(authenticators).length !== 0 does not correctly validate that the authenticators.authenticators array is non-empty. This will incorrectly render the form even when the array is empty.

Apply this diff:

-                    {authenticators !== undefined && Object.keys(authenticators).length !== 0 && (
+                    {authenticators !== undefined && authenticators.authenticators.length !== 0 && (
                        <>
                            <form id="authn_select" className={kcClsx("kcFormClass")}>
                                {authenticators.authenticators.map((authenticator, i) => (
                                    <input key={i} type="hidden" name="authn_use_chk" readOnly value={authenticator.credentialId} />
                                ))}
                            </form>
                        </>
                    )}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{authenticators !== undefined && Object.keys(authenticators).length !== 0 && (
<>
<form id="authn_select" className={kcClsx("kcFormClass")}>
{authenticators.authenticators.map((authenticator, i) => (
<input key={i} type="hidden" name="authn_use_chk" readOnly value={authenticator.credentialId} />
))}
</form>
</>
)}
{authenticators !== undefined && authenticators.authenticators.length !== 0 && (
<>
<form id="authn_select" className={kcClsx("kcFormClass")}>
{authenticators.authenticators.map((authenticator, i) => (
<input key={i} type="hidden" name="authn_use_chk" readOnly value={authenticator.credentialId} />
))}
</form>
</>
)}
🤖 Prompt for AI Agents
In src/login/pages/Login.tsx around lines 219 to 227, the current conditional
uses Object.keys(authenticators).length !== 0 which does not verify that the
authenticators.authenticators array is present or non-empty; update the
condition to specifically check that authenticators.authenticators is an array
with length > 0 (e.g., Array.isArray(authenticators.authenticators) &&
authenticators.authenticators.length > 0 or
authenticators?.authenticators?.length > 0) so the form only renders when the
authenticators array actually contains items.

Comment thread src/login/pages/LoginPassword.tsx Outdated
@discostur
Copy link
Copy Markdown
Contributor Author

@garronej done ;)

@garronej
Copy link
Copy Markdown
Collaborator

Thank you @discostur,

Beleve me when I say that I appreciate that very much.

But my god, again, this is not you, it's me but coderabbit last suggestion is fully valid. It's write about this it's authenticators.authenticators.length !== 0 !!!

Would you fix that (including in my implementation) after that I swear I'll merge and add you to the contributor list.

Sorry again it's no fault of your own, my mistrakes just surface through your PR...

@discostur
Copy link
Copy Markdown
Contributor Author

@garronej of course - changes are implemented ;)

@garronej
Copy link
Copy Markdown
Collaborator

Thank you @discostur!

@allcontributors add @discostur for code

@garronej garronej merged commit e22b8b3 into keycloakify:main Nov 25, 2025
1 check passed
@allcontributors
Copy link
Copy Markdown
Contributor

@garronej

I've put up a pull request to add @discostur! 🎉

garronej added a commit that referenced this pull request Nov 25, 2025
garronej added a commit to keycloakify/login-ui that referenced this pull request Nov 25, 2025
@discostur discostur deleted the feature/passkey-integration-login-password branch November 26, 2025 13:57
@coderabbitai coderabbitai Bot mentioned this pull request Feb 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants