Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
efca73b
Update node dependencies
Stormheg Nov 22, 2025
db3b8f0
Remove deprecated pip flag
Stormheg Nov 22, 2025
362c8f0
Add ability to signal unknown credentials to the browser
Stormheg Oct 18, 2025
0db000c
Show response data in case of assertion failure
Stormheg Oct 24, 2025
c40570f
Fix coverage run documentation
Stormheg Oct 24, 2025
5c57f1d
Add support for signaling current user details and credentials
Stormheg Oct 24, 2025
51e23d6
Add properties to base64 encode handle and credential id
Stormheg Oct 24, 2025
d0365af
Fix selector bug
Stormheg Oct 24, 2025
5ad9d80
Add `playwright_manipulate_session` fixture
Stormheg Oct 24, 2025
ac0a496
Reimplement `wait_for_console_message` fixture
Stormheg Oct 24, 2025
d408069
Add test for checking WebAuthn signal apis are called
Stormheg Oct 24, 2025
9238525
Add user-facing error message when security error occurs...
Stormheg Nov 22, 2025
7be1db9
Avoid final newlines in templatetag templates
Stormheg Nov 22, 2025
10881b3
fixup! Add support for signaling current user details and credentials
Stormheg Nov 22, 2025
36789b4
Request user details sync after Passkey login and registration
Stormheg Nov 22, 2025
da39aea
Add how-to documentation about using
Stormheg Nov 22, 2025
34feb49
Link to next steps at end of quickstart
Stormheg Nov 22, 2025
39f3153
Update changelog entry for #96
Stormheg Nov 22, 2025
3876fc1
Apply Damilola's suggestions
Stormheg Nov 25, 2025
dcc12cf
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 25, 2025
7174f1b
Update docs/how_to_guides/keeping_passkeys_in_sync.rst
activus-d Nov 25, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,9 @@ trim_trailing_whitespace = true
indent_style = space
indent_size = 4

[src/django_otp_webauthn/templates/*.html]
# Final newlines don't have to end up in the HTML output of end users
insert_final_newline = false

[*.md]
trim_trailing_whitespace = false
1 change: 0 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ env:
FORCE_COLOR: "1" # Make tools pretty.
TOX_TESTENV_PASSENV: FORCE_COLOR
PIP_DISABLE_PIP_VERSION_CHECK: "1"
PIP_NO_PYTHON_VERSION_WARNING: "1"

jobs:
qa_javascript:
Expand Down
6 changes: 6 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ repos:
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
exclude: |
(?x)^(
src/django_otp_webauthn/templates/django_otp_webauthn/auth_scripts.html|
src/django_otp_webauthn/templates/django_otp_webauthn/register_scripts.html|
src/django_otp_webauthn/templates/django_otp_webauthn/sync_signals_scripts.html
)$
- id: check-added-large-files
- id: check-case-conflict
- id: check-json
Expand Down
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Nothing yet
- **New feature (experimental):** the browser will now be signaled to remove an unknown Passkey after a failed authentication attempt.
- The purpose of this is to improve user experience by removing Passkeys that are no longer valid from the users' device, stopping the user from being prompted to use this Passkey in the future.
- This is controlled by the new `OTP_WEBAUTHN_SIGNAL_UNKNOWN_CREDENTIAL` setting, which defaults to `True`. If set to `False`, the browser will not be signaled.
- It works on recent versions of Chrome, Edge and Safari but not Firefox (as of October 2025).
- Read more about the browser API used: [`PublicKeyCredential.signalUnknownCredential` on MDN](https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential/signalUnknownCredential_static).
- This feature is experimental because not all browsers support it properly yet. The specification is also still in draft status and may change in the future.
- **New feature (experimental)**: the `render_otp_webauthn_sync_signals_scripts` template tag has been added to allow updating user details stored in the browser when they change on the server side.
- The purpose of this is to improve user experience by keeping the user details (like display name) in sync between server and client, so that the browser can show the correct information when prompting the user to select a Passkey.
- It works on recent versions of Chrome, Edge and Safari but not Firefox (as of October 2025).
- This feature is experimental because not all browsers support it properly yet. The specification is also still in draft status and may change in the future.
- Read more about the browser APIs used:
- [`PublicKeyCredential.signalCurrentUserDetails` on MDN](https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential/signalCurrentUserDetails_static)
- [`PublicKeyCredential.signalAllAcceptedCredentials` on MDN](https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential/signalAllAcceptedCredentials_static)

### Changed

Expand Down
66 changes: 64 additions & 2 deletions client/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,11 @@ import {
return;
}

const responseJson = await response.json();
let attResp;
try {
attResp = await startAuthentication({
optionsJSON: await response.json(),
optionsJSON: responseJson,
useBrowserAutofill: true,
});
} catch (error: unknown) {
Expand Down Expand Up @@ -149,6 +150,9 @@ import {

// Handle failed verification
if (!verificationResp.ok && "detail" in verificationJSON) {
if (verificationResp.status === 404) {
signalPasskeyMissing(attResp.rawId, responseJson.rpId);
}
loginField.dispatchEvent(
new CustomEvent(EVENT_VERIFICATION_FAILED, {
detail: {
Expand Down Expand Up @@ -232,6 +236,8 @@ import {
},
});

const responseJson = await response.json();

if (!response.ok) {
await setPasskeyVerifyState({
buttonDisabled: false,
Expand All @@ -258,7 +264,7 @@ import {

try {
attResp = await startAuthentication({
optionsJSON: await response.json(),
optionsJSON: responseJson,
useBrowserAutofill: false,
});
} catch (error: unknown) {
Expand All @@ -284,6 +290,17 @@ import {
status: gettext("Verification canceled or not allowed."),
});
break;
case "SecurityError":
await setPasskeyVerifyState({
buttonDisabled: false,
buttonLabel,
requestFocus: true,
statusEnum: StatusEnum.SECURITY_ERROR,
status: gettext(
"Passkey authentication failed. A technical problem prevents the verification process for beginning. Please try another method.",
),
});
break;
default:
await setPasskeyVerifyState({
buttonDisabled: false,
Expand Down Expand Up @@ -363,6 +380,9 @@ import {
const verificationJSON = await verificationResp.json();

if (!verificationResp.ok) {
if (verificationResp.status === 404 && attResp) {
signalPasskeyMissing(attResp.rawId, responseJson.rpId);
}
const msg =
verificationJSON.detail ||
gettext("Verification failed. An unknown error occurred.");
Expand Down Expand Up @@ -471,6 +491,48 @@ import {
}
}

/* Uses the `PublicKeyCredential.signalUnknownCredential` API to inform the browser
* that the Passkey that was used is not recognized by the server, prompting the browser to delete it from its stored credentials.
* This function is a no-op if the API is not supported by the browser.
*/
async function signalPasskeyMissing(
credentialId: string,
rpId: string,
): Promise<void> {
if (!config.removeUnknownCredential) {
console.trace(
"Not signaling unknown credential to the browser as per configuration.",
);
return;
}
if (!("signalUnknownCredential" in PublicKeyCredential)) {
console.trace(
"PublicKeyCredential.signalUnknownCredential is not supported by this browser. Won't signal.",
);
return;
}

try {
await (PublicKeyCredential as any).signalUnknownCredential({
rpId,
credentialId,
});
console.trace(
// Important: 'Credential not found' is used as a needle for automated tests that check that the signaling happened.
"Credential not found. Requested browser remove credential.",
{
rpId,
credentialId,
},
);
} catch (error) {
console.error(
"Error while signaling unknown credential to the browser",
error,
);
}
}

async function setPasskeyVerificationVisible(
visible: boolean,
): Promise<void> {
Expand Down
93 changes: 93 additions & 0 deletions client/src/sync_signals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { SyncSignalConfig } from "./types";

// Extend the PublicKeyCredentialConstructor interface to include the sync signal methods.
// These don't exist currently in TypeScript's lib.dom.d.ts, so we declare them here.
// At some point this can likely be removed.
interface PublicKeyCredentialConstructor {
signalCurrentUserDetails?(details: {
rpId?: string;
userId?: string;
name?: string;
displayName?: string;
}): Promise<void>;
signalAllAcceptedCredentials(options: {
rpId: string;
userId: string;
allAcceptedCredentialIds: string[];
}): Promise<void>;
}

// augment the global constructor type
declare var PublicKeyCredential: PublicKeyCredentialConstructor;

/**
* Client-side sync signals for WebAuthn credentials.
*
* Reads JSON configuration from a script[id="webauthn-sync-signals-config"] and
* calls the PublicKeyCredential.signalCurrentUserDetails and
* PublicKeyCredential.signalAllAcceptedCredentials browser APIs to update user details
* and to hide removed credentials, so they won't be shown in future authentication prompts.
*/
(() => async () => {
const configScript = document.getElementById(
"otp_webauthn_sync_signals_config",
);
if (!configScript) {
return;
}

const config = JSON.parse(
configScript.textContent || "{}",
) as SyncSignalConfig;
if (!config) {
return;
}
// Remove the config script tag from the DOM now that we've read it
// don't make it available to any other scripts for security/privacy reasons
configScript.remove();

// Signal current user details
if (
typeof PublicKeyCredential === "undefined" ||
typeof PublicKeyCredential.signalCurrentUserDetails !== "function"
) {
console.warn(
"PublicKeyCredential.signalCurrentUserDetails is not supported by this browser.",
);
return;
} else {
const payload = {
rpId: config.rpId,
userId: config.userId,
name: config.name,
displayName: config.displayName,
};
await PublicKeyCredential.signalCurrentUserDetails(payload);
console.log(
"[WebAuthn] Signaled current user details to the browser.",
payload,
);
}

// Signal all accepted credentials
if (
typeof PublicKeyCredential === "undefined" ||
typeof PublicKeyCredential.signalAllAcceptedCredentials !== "function"
) {
console.warn(
"PublicKeyCredential.signalAllAcceptedCredentials is not supported by this browser.",
);
return;
} else {
const payload = {
rpId: config.rpId,
userId: config.userId,
allAcceptedCredentialIds: config.credentialIds,
};
await PublicKeyCredential.signalAllAcceptedCredentials(payload);
console.log(
"[WebAuthn] Signaled accepted credentials to the browser.",
payload,
);
}
})()();
9 changes: 9 additions & 0 deletions client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,13 @@ export type Config = {
nextFieldSelector: string;

csrfToken: string;
removeUnknownCredential: boolean;
};

export type SyncSignalConfig = {
rpId: string;
userId: string;
name: string;
displayName: string;
credentialIds: string[];
};
1 change: 1 addition & 0 deletions client/webpack.base.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const config: Configuration = {
entry: {
auth: "./client/src/auth.ts",
register: "./client/src/register.ts",
sync_signals: "./client/src/sync_signals.ts",
},
module: {
rules: [
Expand Down
2 changes: 1 addition & 1 deletion docs/contributing/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ For test coverage, use these commands:

.. code-block:: console

coverage run manage.py test
coverage run -m pytest
coverage report

Generate a visual HTML report in the htmlcov directory with the following command:
Expand Down
8 changes: 8 additions & 0 deletions docs/getting_started/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -248,3 +248,11 @@ Once you’ve done this, you will see the following on your login page:
* a **Register Passkey** button on the login page

* a **Login using a Passkey** button on the login page

Next steps
----------

Now that you have Django OTP WebAuthn set up, you can explore additional features such as:

* :ref:`keeping_passkeys_in_sync`, to improve your users' experience.
* :ref:`configure_related_origins`, for when your application is active on multiple domains and you want to share Passkeys across them.
5 changes: 5 additions & 0 deletions docs/how_to_guides/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ Here are what you will find in this section:

Learn how to configure WebAuthn to work across multiple domains. For example, if your main application runs on ``https://example.com`` and you have a localized version on ``https://example.co.uk``.

.. grid-item-card:: :ref:`Keeping Passkeys up-to-date with changing user details <keeping_passkeys_in_sync>`

Learn how to keep Passkey user details saved in users' browsers up-to-date when details like email or username change.

.. toctree::
:maxdepth: 2
:hidden:
Expand All @@ -33,3 +37,4 @@ Here are what you will find in this section:
Customize helper class <customize_helper_class.rst>
Customize models <customize_models.rst>
Configure related origins <configure_related_origins.rst>
Keep passkeys up-to-date <keeping_passkeys_in_sync.rst>
Loading