diff --git a/.changeset/feat-parse-json-options-39.md b/.changeset/feat-parse-json-options-39.md new file mode 100644 index 0000000..142f587 --- /dev/null +++ b/.changeset/feat-parse-json-options-39.md @@ -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 diff --git a/src/ReactNativePasskeysModule.web.ts b/src/ReactNativePasskeysModule.web.ts index 5bee97c..954b659 100644 --- a/src/ReactNativePasskeysModule.web.ts +++ b/src/ReactNativePasskeysModule.web.ts @@ -1,18 +1,17 @@ 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 { @@ -20,7 +19,7 @@ export default { }, isAutoFillAvalilable(): Promise { - return window.PublicKeyCredential.isConditionalMediationAvailable?.() ?? Promise.resolve(false); + return PublicKeyCredential.isConditionalMediationAvailable?.() ?? Promise.resolve(false); }, isSupported() { @@ -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; @@ -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), }; }, @@ -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, @@ -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`, - ); - } - } - } -}; +}; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 933dc78..974cd86 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,3 +50,9 @@ export async function get( ): Promise { return await ReactNativePasskeysModule.get(request); } + +// Export JSON conversion utilities for cross-platform use +export { + publicKeyCredentialCreationOptionsFromJSON as parseCreationOptionsFromJSON, + publicKeyCredentialRequestOptionsFromJSON as parseRequestOptionsFromJSON, +} from './utils/json'; diff --git a/src/utils/is-json-format.ts b/src/utils/is-json-format.ts new file mode 100644 index 0000000..eb6e663 --- /dev/null +++ b/src/utils/is-json-format.ts @@ -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; + } +} diff --git a/src/utils/json.ts b/src/utils/json.ts new file mode 100644 index 0000000..f3305aa --- /dev/null +++ b/src/utils/json.ts @@ -0,0 +1,354 @@ +// This file replicates Chromium's C++ implementation of the WebAuthn API for JSON conversion. +// https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/modules/credentialmanagement/json.cc;l=320;drc=a24ab0d52080d0e89c4ef595c1187f64cff72684;bpv=0;bpt=1 + +import type { + Base64URLString, + PublicKeyCredentialUserEntity, + PublicKeyCredentialUserEntityJSON, + PublicKeyCredentialDescriptor, + PublicKeyCredentialDescriptorJSON, + PublicKeyCredentialCreationOptions, + PublicKeyCredentialRequestOptions, +} from '../ReactNativePasskeys.types'; +import type { + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, + AuthenticationExtensionsClientInputs, + AuthenticationExtensionsClientOutputs, + AuthenticationExtensionsClientOutputsJSON, + AuthenticationExtensionsPRFValuesJSON, + AuthenticationExtensionsLargeBlobInputs, + AuthenticationExtensionsPRFInputs, +} from '../ReactNativePasskeys.types'; +import { base64URLStringToBuffer, bufferToBase64URLString } from './base64'; + +/** + * Error class for JSON conversion errors + */ +export class WebAuthnJSONError extends Error { + constructor(message: string) { + super(message); + this.name = 'WebAuthnJSONError'; + } +} + +/** + * Encodes an ArrayBuffer to base64url string (without padding). + */ +export function webAuthnBase64UrlEncode(buffer: ArrayBuffer): Base64URLString { + return bufferToBase64URLString(buffer); +} + +/** + * Decodes a base64url string to ArrayBuffer. + * @returns ArrayBuffer or null if decoding fails + */ +export function webAuthnBase64UrlDecode(input: Base64URLString): ArrayBuffer | null { + try { + return base64URLStringToBuffer(input); + } catch { + return null; + } +} + +/** + * Converts PublicKeyCredentialUserEntityJSON to PublicKeyCredentialUserEntity. + */ +export function publicKeyCredentialUserEntityFromJSON( + json: PublicKeyCredentialUserEntityJSON +): PublicKeyCredentialUserEntity { + const id = webAuthnBase64UrlDecode(json.id); + if (!id) { + throw new WebAuthnJSONError("'user.id' contains invalid base64url data"); + } + + return { + id, + name: json.name, + displayName: json.displayName, + }; +} + +/** + * Converts PublicKeyCredentialDescriptorJSON to PublicKeyCredentialDescriptor. + */ +export function publicKeyCredentialDescriptorFromJSON( + fieldName: string, + json: PublicKeyCredentialDescriptorJSON +): PublicKeyCredentialDescriptor { + const id = webAuthnBase64UrlDecode(json.id); + if (!id) { + throw new WebAuthnJSONError( + `'${fieldName}' contains PublicKeyCredentialDescriptorJSON with invalid base64url data in 'id'` + ); + } + + const descriptor: PublicKeyCredentialDescriptor = { + id, + type: json.type, + }; + + if (json.transports) { + // @ts-expect-error - known mismatch between AuthenticatorTransportFuture & AuthenticatorTransport types + descriptor.transports = [...json.transports]; + } + + return descriptor; +} + +/** + * Converts array of PublicKeyCredentialDescriptorJSON to array of PublicKeyCredentialDescriptor. + */ +export function publicKeyCredentialDescriptorVectorFromJSON( + fieldName: string, + jsonArray: PublicKeyCredentialDescriptorJSON[] +): PublicKeyCredentialDescriptor[] { + return jsonArray.map((jsonDescriptor) => + publicKeyCredentialDescriptorFromJSON(fieldName, jsonDescriptor) + ); +} + +/** + * Converts AuthenticationExtensionsPRFValuesJSON to internal PRF values with ArrayBuffers. + */ +export function authenticationExtensionsPRFValuesFromJSON( + json: AuthenticationExtensionsPRFValuesJSON +): { first: ArrayBuffer; second?: ArrayBuffer } | null { + const first = webAuthnBase64UrlDecode(json.first); + if (!first) + return null; + + const values: { first: ArrayBuffer; second?: ArrayBuffer } = { first }; + + if (json.second) { + const second = webAuthnBase64UrlDecode(json.second); + if (!second) return null; + + values.second = second; + } + + return values; +} + +/** + * Converts AuthenticationExtensionsClientInputs from JSON format (with base64url strings) + * to internal format (with ArrayBuffers where appropriate). + */ +export function authenticationExtensionsClientInputsFromJSON( + json: AuthenticationExtensionsClientInputs +): AuthenticationExtensionsClientInputs { + const result: AuthenticationExtensionsClientInputs = {}; + + if (json.appid !== undefined) { + result.appid = json.appid; + } + // TODO: add support for appidExclude when supported on native platforms + // if (json.appidExclude !== undefined) { + // result.appidExclude = json.appidExclude; + // } + if (json.hmacCreateSecret !== undefined) { + result.hmacCreateSecret = json.hmacCreateSecret; + } + // TODO: add support for credentialProtectionPolicy and enforceCredentialProtectionPolicy when supported on native platforms + // if (json.credentialProtectionPolicy !== undefined) { + // result.credentialProtectionPolicy = json.credentialProtectionPolicy; + // } + // if (json.enforceCredentialProtectionPolicy !== undefined) { + // result.enforceCredentialProtectionPolicy = json.enforceCredentialProtectionPolicy; + // } + // TODO: add support for minPinLength when supported on native platforms + // if (json.minPinLength !== undefined) { + // result.minPinLength = json.minPinLength; + // } + if (json.credProps !== undefined) { + result.credProps = json.credProps; + } + + // Handle largeBlob extension + if (json.largeBlob) { + const largeBlob: AuthenticationExtensionsLargeBlobInputs = {}; + if (json.largeBlob.support !== undefined) { + largeBlob.support = json.largeBlob.support; + } + if (json.largeBlob.read !== undefined) { + largeBlob.read = json.largeBlob.read; + } + if (json.largeBlob.write !== undefined) { + largeBlob.write = json.largeBlob.write; + } + result.largeBlob = largeBlob; + } + + // Handle PRF extension + if (json.prf) { + const prf: AuthenticationExtensionsPRFInputs = {}; + + if (json.prf.eval) { + const evalValues = authenticationExtensionsPRFValuesFromJSON(json.prf.eval); + if (!evalValues) { + throw new WebAuthnJSONError("'extensions.prf.eval' contains invalid base64url data"); + } + prf.eval = json.prf.eval; // Keep as JSON format for inputs + } + + if (json.prf.evalByCredential) { + const evalByCredential: Record = {}; + for (const [key, jsonValues] of Object.entries(json.prf.evalByCredential)) { + // Validate the values can be decoded + const values = authenticationExtensionsPRFValuesFromJSON(jsonValues); + if (!values) { + throw new WebAuthnJSONError( + "'extensions.prf.evalByCredential' contains invalid base64url data" + ); + } + evalByCredential[key] = jsonValues; // Keep as JSON format for inputs + } + prf.evalByCredential = evalByCredential; + } + + result.prf = prf; + } + + return result; +} + +/** + * Converts AuthenticationExtensionsClientOutputs to JSON format. + */ +export function authenticationExtensionsClientOutputsToJSON( + outputs: AuthenticationExtensionsClientOutputs +): AuthenticationExtensionsClientOutputsJSON { + const json: AuthenticationExtensionsClientOutputsJSON = {}; + + // TODO: add support for appid and hmacCreateSecret when supported on native platforms + // if (outputs.appid !== undefined) { + // json.appid = outputs.appid; + // } + // if (outputs.hmacCreateSecret !== undefined) { + // json.hmacCreateSecret = outputs.hmacCreateSecret; + // } + if (outputs.credProps !== undefined) { + json.credProps = outputs.credProps; + } + + // Handle largeBlob extension + if (outputs.largeBlob) { + const largeBlob: AuthenticationExtensionsClientOutputsJSON['largeBlob'] = {}; + if (outputs.largeBlob.supported !== undefined) { + largeBlob.supported = outputs.largeBlob.supported; + } + if (outputs.largeBlob.blob !== undefined) { + largeBlob.blob = webAuthnBase64UrlEncode(outputs.largeBlob.blob); + } + if (outputs.largeBlob.written !== undefined) { + largeBlob.written = outputs.largeBlob.written; + } + json.largeBlob = largeBlob; + } + + // Handle PRF extension + if (outputs.prf) { + const prf: AuthenticationExtensionsClientOutputsJSON['prf'] = {}; + if (outputs.prf.enabled !== undefined) { + prf.enabled = outputs.prf.enabled; + } + if (outputs.prf.results) { + prf.results = { + first: webAuthnBase64UrlEncode(outputs.prf.results.first), + }; + if (outputs.prf.results.second !== undefined) { + prf.results.second = webAuthnBase64UrlEncode(outputs.prf.results.second); + } + } + json.prf = prf; + } + + return json; +} + +/** + * Converts PublicKeyCredentialCreationOptionsJSON to PublicKeyCredentialCreationOptions. + */ +export function publicKeyCredentialCreationOptionsFromJSON( + json: PublicKeyCredentialCreationOptionsJSON +): PublicKeyCredentialCreationOptions { + const challenge = webAuthnBase64UrlDecode(json.challenge); + if (!challenge) { + throw new WebAuthnJSONError("'challenge' contains invalid base64url data"); + } + + const user = publicKeyCredentialUserEntityFromJSON(json.user); + + const result: PublicKeyCredentialCreationOptions = { + rp: json.rp, + user, + challenge, + pubKeyCredParams: json.pubKeyCredParams, + }; + + if (json.timeout !== undefined) { + result.timeout = json.timeout; + } + + if (json.excludeCredentials) { + result.excludeCredentials = publicKeyCredentialDescriptorVectorFromJSON( + 'excludeCredentials', + json.excludeCredentials + ); + } + + if (json.authenticatorSelection) { + result.authenticatorSelection = json.authenticatorSelection; + } + + if (json.attestation !== undefined) { + result.attestation = json.attestation; + } + + if (json.extensions) { + result.extensions = authenticationExtensionsClientInputsFromJSON(json.extensions); + } + + return result; +} + +/** + * Converts PublicKeyCredentialRequestOptionsJSON to PublicKeyCredentialRequestOptions. + */ +export function publicKeyCredentialRequestOptionsFromJSON( + json: PublicKeyCredentialRequestOptionsJSON +): PublicKeyCredentialRequestOptions { + const challenge = webAuthnBase64UrlDecode(json.challenge); + if (!challenge) { + throw new WebAuthnJSONError("'challenge' contains invalid base64url data"); + } + + const result: PublicKeyCredentialRequestOptions = { + challenge, + }; + + if (json.timeout !== undefined) { + result.timeout = json.timeout; + } + + if (json.rpId !== undefined) { + result.rpId = json.rpId; + } + + if (json.allowCredentials) { + result.allowCredentials = publicKeyCredentialDescriptorVectorFromJSON( + 'allowCredentials', + json.allowCredentials + ); + } + + if (json.userVerification !== undefined) { + result.userVerification = json.userVerification; + } + + if (json.extensions) { + result.extensions = authenticationExtensionsClientInputsFromJSON(json.extensions); + } + + return result; +}