Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
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
22 changes: 22 additions & 0 deletions .changeset/feat-parse-json-options-39.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"react-native-passkeys": minor
---

Add support for parsing JSON credential options on web platform

This change implements support for the browser's native `parseCreationOptionsFromJSON` and `parseRequestOptionsFromJSON` static methods introduced in WebAuthn Level 3, enabling seamless conversion between JSON representations and WebAuthn credential options.

Changes:
- Added comprehensive JSON conversion utilities matching Chromium's implementation (`src/utils/json.ts`)
- Refactored web module to use `PublicKeyCredential.parseCreationOptionsFromJSON` and `PublicKeyCredential.parseRequestOptionsFromJSON`
- Simplified extension handling by leveraging native JSON parsing
- Added utility to detect JSON format vs binary format for future compatibility
- Extracted extension warning logic to separate module for better code organization

Benefits:
- Cleaner, more maintainable code with ~100 fewer lines in the web module
- Better compliance with WebAuthn Level 3 specification
- Proper handling of base64url encoding/decoding for all credential fields
- Improved extension support (PRF, largeBlob, credProps)

Fixes #39
116 changes: 11 additions & 105 deletions src/ReactNativePasskeysModule.web.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,25 @@
import { NotSupportedError } from "./errors";
import { base64URLStringToBuffer, bufferToBase64URLString } from "./utils/base64";
import { bufferToBase64URLString } from "./utils/base64";

import type {
AuthenticationCredential,
AuthenticationExtensionsClientInputs,
AuthenticationExtensionsClientOutputs,
AuthenticationExtensionsClientOutputsJSON,
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationCredential,
CreationResponse,
} from "./ReactNativePasskeys.types";
import { normalizePRFInputs } from "./utils/prf";
import { authenticationExtensionsClientOutputsToJSON } from "./utils/json";
import { warnUserOfMissingWebauthnExtensions } from "./utils/warn-user-of-missing-webauthn-extensions";

export default {
get name(): string {
return "ReactNativePasskeys";
},

isAutoFillAvalilable(): Promise<boolean> {
return window.PublicKeyCredential.isConditionalMediationAvailable?.() ?? Promise.resolve(false);
return PublicKeyCredential.isConditionalMediationAvailable?.() ?? Promise.resolve(false);
},

isSupported() {
Expand All @@ -38,28 +37,13 @@ export default {

const credential = (await navigator.credentials.create({
signal,
publicKey: {
...request,
challenge: base64URLStringToBuffer(request.challenge),
user: { ...request.user, id: base64URLStringToBuffer(request.user.id) },
excludeCredentials: request.excludeCredentials?.map((credential) => ({
...credential,
id: base64URLStringToBuffer(credential.id),
// TODO: remove the override when typescript has updated webauthn types
transports: (credential.transports ?? undefined) as AuthenticatorTransport[] | undefined,
})),
extensions: {
...request.extensions,
prf: normalizePRFInputs(request),
},
},
publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(request),
})) as RegistrationCredential;

// TODO: remove the override when typescript has updated webauthn types
const extensions =
credential?.getClientExtensionResults() as AuthenticationExtensionsClientOutputs;
warnUserOfMissingWebauthnExtensions(request.extensions, extensions);
const { largeBlob, prf, credProps, ...clientExtensionResults } = extensions;

if (!credential) return null;

Expand All @@ -78,25 +62,7 @@ export default {
},
authenticatorAttachment: undefined,
type: "public-key",
clientExtensionResults: {
...clientExtensionResults,
...(largeBlob && {
largeBlob: {
...largeBlob,
blob: largeBlob?.blob ? bufferToBase64URLString(largeBlob.blob) : undefined,
},
}),
...(prf?.results && {
prf: {
enabled: prf.enabled,
results: {
first: bufferToBase64URLString(prf.results.first),
second: prf.results.second ? bufferToBase64URLString(prf.results.second) : undefined,
},
},
}),
...(credProps && { credProps }),
} satisfies AuthenticationExtensionsClientOutputsJSON,
clientExtensionResults: authenticationExtensionsClientOutputsToJSON(extensions),
};
},

Expand All @@ -112,43 +78,18 @@ export default {
const credential = (await navigator.credentials.get({
mediation,
signal,
publicKey: {
...request,
extensions: {
...request.extensions,
prf: normalizePRFInputs(request),
/**
* the navigator interface doesn't have a largeBlob property
* as it may not be supported by all browsers
*
* browsers that do not support the extension will just ignore the property so it's safe to include it
*
* @ts-expect-error:*/
largeBlob: request.extensions?.largeBlob?.write
? {
...request.extensions?.largeBlob,
write: base64URLStringToBuffer(request.extensions.largeBlob.write),
}
: request.extensions?.largeBlob,
},
challenge: base64URLStringToBuffer(request.challenge),
allowCredentials: request.allowCredentials?.map((credential) => ({
...credential,
id: base64URLStringToBuffer(credential.id),
// TODO: remove the override when typescript has updated webauthn types
transports: (credential.transports ?? undefined) as AuthenticatorTransport[] | undefined,
})),
},
publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(request),
})) as AuthenticationCredential;

// TODO: remove the override when typescript has updated webauthn types
const extensions =
credential?.getClientExtensionResults() as AuthenticationExtensionsClientOutputs;
warnUserOfMissingWebauthnExtensions(request.extensions, extensions);
const { largeBlob, prf, credProps, ...clientExtensionResults } = extensions;

if (!credential) return null;

if (credential.toJSON) return credential.toJSON() as AuthenticationResponseJSON;

return {
id: credential.id,
rawId: credential.id,
Expand All @@ -161,43 +102,8 @@ export default {
: undefined,
},
authenticatorAttachment: undefined,
clientExtensionResults: {
...clientExtensionResults,
...(largeBlob && {
largeBlob: {
...largeBlob,
blob: largeBlob?.blob ? bufferToBase64URLString(largeBlob.blob) : undefined,
},
}),
...(prf?.results && {
prf: {
results: {
first: bufferToBase64URLString(prf.results.first),
second: prf.results.second ? bufferToBase64URLString(prf.results.second) : undefined,
},
},
}),
...(credProps && { credProps }),
} satisfies AuthenticationExtensionsClientOutputsJSON,
clientExtensionResults: authenticationExtensionsClientOutputsToJSON(extensions),
type: "public-key",
};
},
};

/**
* warn the user about extensions that they tried to use that are not supported
*/
const warnUserOfMissingWebauthnExtensions = (
requestedExtensions: AuthenticationExtensionsClientInputs | undefined,
clientExtensionResults: AuthenticationExtensionsClientOutputs | undefined,
) => {
if (clientExtensionResults) {
for (const key in requestedExtensions) {
if (typeof clientExtensionResults[key] === "undefined") {
alert(
`Webauthn extension ${key} is undefined -- your browser probably doesn't know about it`,
);
}
}
}
};
};
34 changes: 34 additions & 0 deletions src/utils/is-json-format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
class ArrayBufferFoundError extends Error {
constructor() {
super("ArrayBuffer found");
this.name = "ArrayBufferFoundError";
}
}

/**
* Helper to detect if input is JSON format (base64url strings) vs binary format (ArrayBuffers).
*
* This determines whether we can use the static method `parseCreationOptionsFromJSON`
*
* Uses JSON.stringify with a custom replacer to efficiently detect nested ArrayBuffers or TypedArrays.
* Throws early when binary data is found to avoid traversing the entire object tree.
*
* @param input - The credential options object to check
* @returns true if the input is in JSON format (no ArrayBuffers), false if it contains binary data
*/
export function isJSONFormat(input: unknown): boolean {
if (typeof input !== 'object' || input === null) return true;

try {
JSON.stringify(input, (_key, value) => {
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value))
throw new ArrayBufferFoundError();
return value;
});
return true;
} catch (e) {
if (e instanceof ArrayBufferFoundError) return false;
// Re-throw any other error
throw e;
}
}
Loading