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

Add support for parseCreationOptionsFromJSON and parseRequestOptionsFromJSON across all platforms

This change exports `parseCreationOptionsFromJSON` and `parseRequestOptionsFromJSON` functions, enabling cross-platform JSON-to-WebAuthn conversion following the WebAuthn Level 3 specification.

**New Exports:**
- `parseCreationOptionsFromJSON(json)` - Converts PublicKeyCredentialCreationOptionsJSON to native format
- `parseRequestOptionsFromJSON(json)` - Converts PublicKeyCredentialRequestOptionsJSON to native format

**Implementation:**
- Added comprehensive JSON conversion utilities matching Chromium's implementation (`src/utils/json.ts`)
- Web platform uses browser's native `PublicKeyCredential.parseCreationOptionsFromJSON` and `parseRequestOptionsFromJSON`
- Native platforms (iOS/Android) already accept JSON format, these utilities provide validation and type safety
- Handles all credential fields including challenge, user, excludeCredentials, allowCredentials
- Supports all WebAuthn extensions (PRF, largeBlob, credProps)
- Proper base64url encoding/decoding for ArrayBuffer fields

**Additional utilities:**
- Added `isJSONFormat` utility to detect JSON vs binary format
- Extracted extension warning logic to separate module

**Benefits:**
- ✨ Cross-platform API consistency
- 📋 WebAuthn Level 3 spec compliance
- 🔧 Type-safe JSON conversion with helpful error messages
- 🎯 ~100 fewer lines in web module through code consolidation
- 🚀 Native browser methods on web for optimal performance

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`,
);
}
}
}
};
};
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,9 @@ export async function get(
): Promise<AuthenticationResponseJSON | null> {
return await ReactNativePasskeysModule.get(request);
}

// Export JSON conversion utilities for cross-platform use
export {
publicKeyCredentialCreationOptionsFromJSON as parseCreationOptionsFromJSON,
publicKeyCredentialRequestOptionsFromJSON as parseRequestOptionsFromJSON,
} from './utils/json';
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