From 846080e74b209a8af35fb80f2d0120dce7cf91d8 Mon Sep 17 00:00:00 2001 From: Peter Anyaogu Date: Mon, 30 Sep 2024 22:58:34 +0100 Subject: [PATCH] add web credential, and expo web compat --- .../cosmr1/Cosmr1CredentialHandlerModule.kt | 14 +- packages/cred-native/package.json | 3 +- .../src/Cosmr1CredentialHandlerModule.ts | 95 ++++++++- .../src/Cosmr1CredentialHandlerModule.web.ts | 13 +- packages/cred-native/src/index.ts | 29 +-- packages/cred-web/index.ts | 193 +++++++++++++++++- packages/cred-web/package.json | 3 +- packages/shared-types/index.ts | 148 ++++++++++---- 8 files changed, 406 insertions(+), 92 deletions(-) diff --git a/packages/cred-native/android/src/main/java/expo/modules/cosmr1/Cosmr1CredentialHandlerModule.kt b/packages/cred-native/android/src/main/java/expo/modules/cosmr1/Cosmr1CredentialHandlerModule.kt index a21a83d..46ed06d 100644 --- a/packages/cred-native/android/src/main/java/expo/modules/cosmr1/Cosmr1CredentialHandlerModule.kt +++ b/packages/cred-native/android/src/main/java/expo/modules/cosmr1/Cosmr1CredentialHandlerModule.kt @@ -41,13 +41,13 @@ class Cosmr1CredentialHandlerModule : Module() { } Constants( - "timeout" to Constants.TIMEOUT, - "attestation" to Constants.ATTESTATION, - "authenticatorAttachment" to Constants.AUTHENTICATOR_ATTACHMENT, - "requireResidentKey" to Constants.REQUIRE_RESIDENT_KEY, - "residentKey" to Constants.RESIDENT_KEY, - "userVerification" to Constants.USER_VERIFICATION, - "pubKeyCredParam" to Constants.PUB_KEY_CRED_PARAM + "TIMEOUT" to Constants.TIMEOUT, + "ATTESTATION" to Constants.ATTESTATION, + "AUTHENTICATOR_ATTACHMENT" to Constants.AUTHENTICATOR_ATTACHMENT, + "REQUIRE_RESIDENT_KEY" to Constants.REQUIRE_RESIDENT_KEY, + "RESIDENT_KEY" to Constants.RESIDENT_KEY, + "USER_VERIFICATION" to Constants.USER_VERIFICATION, + "PUB_KEY_CRED_PARAM" to Constants.PUB_KEY_CRED_PARAM ) Events( diff --git a/packages/cred-native/package.json b/packages/cred-native/package.json index 83e57c3..35bd971 100644 --- a/packages/cred-native/package.json +++ b/packages/cred-native/package.json @@ -27,7 +27,8 @@ "license": "MIT", "homepage": "https://github.com/vaariance/cosm-r1#readme", "dependencies": { - "shared-types": "*" + "@vaariance/shared-types": "*", + "@vaariance/cred-web": "*" }, "devDependencies": { "@types/react": "^18.0.25", diff --git a/packages/cred-native/src/Cosmr1CredentialHandlerModule.ts b/packages/cred-native/src/Cosmr1CredentialHandlerModule.ts index 7d4431b..12bafed 100644 --- a/packages/cred-native/src/Cosmr1CredentialHandlerModule.ts +++ b/packages/cred-native/src/Cosmr1CredentialHandlerModule.ts @@ -1,5 +1,96 @@ -import { requireNativeModule } from 'expo-modules-core'; +import { + type EventTypeMap, + type PublicKeyCredential, + type AuthenticatorAttestationResponse, + type AuthenticatorAssertionResponse, + type AttestationOptions, + type AssertionOptions, +} from "@vaariance/shared-types"; +import { + requireNativeModule, + NativeModulesProxy, + EventEmitter, +} from "expo-modules-core"; // It loads the native module object from the JSI or falls back to // the bridge module (from NativeModulesProxy) if the remote debugger is on. -export default requireNativeModule('Cosmr1CredentialHandler'); +const nativeModule = requireNativeModule("Cosmr1CredentialHandler"); + +const emitter = new EventEmitter( + nativeModule ?? NativeModulesProxy.Cosmr1CredentialHandler, +); + +const moduleObjects = { + defaultConfiguraton: { + TIMEOUT: nativeModule.TIMEOUT, + ATTESTATION: nativeModule.ATTESTATION as AttestationConveyancePreference, + AUTHENTICATOR_ATTACHMENT: + nativeModule.AUTHENTICATOR_ATTACHMENT as AuthenticatorAttachment, + REQUIRE_RESIDENT_KEY: nativeModule.REQUIRE_RESIDENT_KEY as boolean, + RESIDENT_KEY: nativeModule.RESIDENT_KEY as ResidentKeyRequirement, + USER_VERIFICATION: + nativeModule.USER_VERIFICATION as UserVerificationRequirement, + PUB_KEY_CRED_PARAM: + nativeModule.PUB_KEY_CRED_PARAM as PublicKeyCredentialParameters, + }, + + events: { + onRegistrationStarted: ( + callback: (event: EventTypeMap["onRegistrationStarted"]) => void, + ) => emitter.addListener("onRegistrationStarted", callback), + onRegistrationFailed: ( + callback: (event: EventTypeMap["onRegistrationFailed"]) => void, + ) => emitter.addListener("onRegistrationFailed", callback), + onRegistrationComplete: ( + callback: (event: EventTypeMap["onRegistrationComplete"]) => void, + ) => emitter.addListener("onRegistrationComplete", callback), + onAuthenticationStarted: ( + callback: (event: EventTypeMap["onAuthenticationStarted"]) => void, + ) => emitter.addListener("onAuthenticationStarted", callback), + onAuthenticationFailed: ( + callback: (event: EventTypeMap["onAuthenticationFailed"]) => void, + ) => emitter.addListener("onAuthenticationFailed", callback), + onAuthenticationSuccess: ( + callback: (event: EventTypeMap["onAuthenticationSuccess"]) => void, + ) => emitter.addListener("onAuthenticationSuccess", callback), + }, +}; + +const mainFunctions = { + async register( + args: AttestationOptions, + ): Promise + > | null> { + const credential = await nativeModule.register( + args.preferImmediatelyAvailableCred ?? false, + args.challenge, + args.rp, + args.user, + args.timeout, + args.attestation, + args.excludeCredentials, + args.authenticatorSelection, + ); + return JSON.parse(credential); + }, + + async authenticate( + args: AssertionOptions, + ): Promise + > | null> { + const credential = await nativeModule.authenticate( + args.challenge, + args.timeout, + args.rpId, + args.userVerification, + args.allowCredentials, + ); + return JSON.parse(credential); + }, +}; + +export default Object.assign(moduleObjects, mainFunctions); diff --git a/packages/cred-native/src/Cosmr1CredentialHandlerModule.web.ts b/packages/cred-native/src/Cosmr1CredentialHandlerModule.web.ts index 360a27f..ffdb73a 100644 --- a/packages/cred-native/src/Cosmr1CredentialHandlerModule.web.ts +++ b/packages/cred-native/src/Cosmr1CredentialHandlerModule.web.ts @@ -1,13 +1,8 @@ +import Cosmr1CredentialHandlerModule from "@vaariance/cred-web"; import { EventEmitter } from "expo-modules-core"; const emitter = new EventEmitter({} as any); -export default { - PI: Math.PI, - async setValueAsync(value: string): Promise { - emitter.emit("onChange", { value }); - }, - hello() { - return "Hello world! 👋"; - }, -}; +const webModule = Cosmr1CredentialHandlerModule(emitter); + +export default webModule; diff --git a/packages/cred-native/src/index.ts b/packages/cred-native/src/index.ts index af710c7..90fc7b9 100644 --- a/packages/cred-native/src/index.ts +++ b/packages/cred-native/src/index.ts @@ -1,32 +1,5 @@ -import { - NativeModulesProxy, - EventEmitter, - Subscription, -} from "expo-modules-core"; - // Import the native module. On web, it will be resolved to Cosmr1CredentialHandler.web.ts // and on native platforms to Cosmr1CredentialHandler.ts -//import { ChangeEventPayload } from "shared-types"; - import Cosmr1CredentialHandlerModule from "./Cosmr1CredentialHandlerModule"; -// Get the native constant value. -export const PI = Cosmr1CredentialHandlerModule.PI; - -export function hello(): string { - return Cosmr1CredentialHandlerModule.hello(); -} - -export async function setValueAsync(value: string) { - return await Cosmr1CredentialHandlerModule.setValueAsync(value); -} - -const emitter = new EventEmitter( - Cosmr1CredentialHandlerModule ?? NativeModulesProxy.Cosmr1CredentialHandler, -); - -// export function addChangeListener( -// listener: (event: ChangeEventPayload) => void, -// ): Subscription { -// return emitter.addListener("onChange", listener); -// } +export default Cosmr1CredentialHandlerModule; diff --git a/packages/cred-web/index.ts b/packages/cred-web/index.ts index f67b2c6..22e9cf5 100644 --- a/packages/cred-web/index.ts +++ b/packages/cred-web/index.ts @@ -1 +1,192 @@ -console.log("Hello via Bun!"); \ No newline at end of file +import { + type AssertionOptions, + type AttestationOptions, + type CreateCredentialOptions, + type GetCredentialOptions, + type PublicKeyCredential, + type AuthenticatorAttestationResponse, + type AuthenticatorAssertionResponse, + type Cosmr1CredentialHandlerModuleEvents, + type Subscription, + type EventEmitter, + type EventTypeMap, + Constants, +} from "@vaariance/shared-types"; + +const EventEmitter = (): EventEmitter => { + const listeners = < + { + [K in keyof EventTypeMap]: Set<(event: EventTypeMap[K]) => void>; + } + >{}; + + return { + addListener( + eventName: T, + listener: (event: EventTypeMap[T]) => void, + ): Subscription { + if (!listeners[eventName]) { + listeners[eventName] = new Set(); + } + listeners[eventName].add(listener); + + return { + remove: () => { + listeners[eventName].delete(listener); + }, + }; + }, + + removeAllListeners(eventName: Cosmr1CredentialHandlerModuleEvents): void { + delete listeners[eventName]; + }, + + removeSubscription(subscription: Subscription): void { + subscription.remove(); + }, + + emit( + eventName: T, + params: EventTypeMap[T], + ): void { + const callbacks = listeners[eventName]; + if (callbacks) { + callbacks.forEach((callback) => callback(params)); + } + }, + }; +}; + +const Cosmr1CredentialHandlerModule = ( + eventEmitter: EventEmitter = EventEmitter(), +) => { + const moduleObject = { + // Constants + defaultConfiguraton: { ...Constants }, + + // Events + events: { + onRegistrationStarted: ( + callback: (event: EventTypeMap["onRegistrationStarted"]) => void, + ) => eventEmitter.addListener("onRegistrationStarted", callback), + onRegistrationFailed: ( + callback: (event: EventTypeMap["onRegistrationFailed"]) => void, + ) => eventEmitter.addListener("onRegistrationFailed", callback), + onRegistrationComplete: ( + callback: (event: EventTypeMap["onRegistrationComplete"]) => void, + ) => eventEmitter.addListener("onRegistrationComplete", callback), + onAuthenticationStarted: ( + callback: (event: EventTypeMap["onAuthenticationStarted"]) => void, + ) => eventEmitter.addListener("onAuthenticationStarted", callback), + onAuthenticationFailed: ( + callback: (event: EventTypeMap["onAuthenticationFailed"]) => void, + ) => eventEmitter.addListener("onAuthenticationFailed", callback), + onAuthenticationSuccess: ( + callback: (event: EventTypeMap["onAuthenticationSuccess"]) => void, + ) => eventEmitter.addListener("onAuthenticationSuccess", callback), + }, + }; + + // Helper functions + const helpers = { + parseAssertionOptions: ( + args: AssertionOptions, + ): GetCredentialOptions => { + return { + ...args, + allowCredentials: args.allowCredentials?.items ?? [], + timeout: args.timeout ?? Constants.TIMEOUT, + userVerification: args.userVerification ?? Constants.USER_VERIFICATION, + }; + }, + + parseAttestationOptions: ( + args: AttestationOptions, + ): CreateCredentialOptions => { + return { + ...args, + pubKeyCredParams: [Constants.PUB_KEY_CRED_PARAM], + timeout: args.timeout ?? Constants.TIMEOUT, + attestation: args.attestation ?? Constants.ATTESTATION, + excludeCredentials: args.excludeCredentials?.items ?? [], + authenticatorSelection: args.authenticatorSelection ?? { + authenticatorAttachment: Constants.AUTHENTICATOR_ATTACHMENT, + requireResidentKey: Constants.REQUIRE_RESIDENT_KEY, + residentKey: Constants.RESIDENT_KEY, + userVerification: Constants.USER_VERIFICATION, + }, + }; + }, + _emitEvent: eventEmitter.emit.bind(eventEmitter), + }; + + const mainFunctions = { + async register( + this: typeof helpers, + args: AttestationOptions, + ) { + if (!navigator.credentials) { + throw new Error( + "Web Authentication API is not supported in this environment.", + ); + } + try { + const createOptions = this.parseAttestationOptions(args); + this._emitEvent("onRegistrationStarted", createOptions); + const credential = await internalFunctions.register(createOptions); + this._emitEvent("onRegistrationComplete", credential); + return credential; + } catch (error: unknown) { + this._emitEvent("onRegistrationFailed", error); + throw error; + } + }, + + async authenticate( + this: typeof helpers, + args: AssertionOptions, + ) { + if (!navigator.credentials) { + throw new Error( + "Web Authentication API is not supported in this environment.", + ); + } + try { + const getOptions = this.parseAssertionOptions(args); + this._emitEvent("onAuthenticationStarted", getOptions); + const credential = await internalFunctions.authenticate(getOptions); + this._emitEvent("onAuthenticationSuccess", credential); + return credential; + } catch (error: unknown) { + this._emitEvent("onAuthenticationFailed", error); + throw error; + } + }, + }; + + const internalFunctions = { + register: async ( + request: CreateCredentialOptions, + ): Promise + > | null> => + navigator.credentials.create({ + publicKey: request, + }), + + authenticate: async ( + request: GetCredentialOptions, + ): Promise + > | null> => + navigator.credentials.get({ + publicKey: request, + }), + }; + + return Object.assign(moduleObject, mainFunctions); +}; + +export default Cosmr1CredentialHandlerModule; diff --git a/packages/cred-web/package.json b/packages/cred-web/package.json index d1ee397..97869f7 100644 --- a/packages/cred-web/package.json +++ b/packages/cred-web/package.json @@ -4,7 +4,8 @@ "type": "module", "description": "Browser credential handler for passkey signatures in cosm-r1 lib", "devDependencies": { - "@types/bun": "latest" + "@types/bun": "latest", + "@vaariance/shared-types": "*" }, "peerDependencies": { "typescript": "^5.0.0" diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts index aab4051..b4e5f43 100644 --- a/packages/shared-types/index.ts +++ b/packages/shared-types/index.ts @@ -48,105 +48,167 @@ export const Ok = (value: T): Result => ({ map: (f: (v: T) => U): Result => Ok(f(value)), }); +export type Subscription = { + remove: () => void; +}; +export type EventEmitter = { + addListener: ( + eventName: T, + listener: (event: EventTypeMap[T]) => void, + ) => Subscription; + removeAllListeners: (eventName: Cosmr1CredentialHandlerModuleEvents) => void; + removeSubscription: (subscription: Subscription) => void; + emit: ( + eventName: T, + params: EventTypeMap[T], + ) => void; +}; + +export type Cosmr1CredentialHandlerModuleEvents = + | "onRegistrationStarted" + | "onRegistrationFailed" + | "onRegistrationComplete" + | "onAuthenticationStarted" + | "onAuthenticationFailed" + | "onAuthenticationSuccess"; + +export type EventTypeMap = { + onRegistrationStarted: CreateCredentialOptions; + onRegistrationComplete: PublicKeyCredential< + BufferSource, + AuthenticatorAttestationResponse + > | null; + onRegistrationFailed: unknown; + onAuthenticationStarted: GetCredentialOptions; + onAuthenticationSuccess: PublicKeyCredential< + BufferSource, + AuthenticatorAssertionResponse + > | null; + onAuthenticationFailed: unknown; +}; + export type RelyingParty = { name: string; id: string; }; export type UserEntity = { - id: string; + id: BufferSource; name: string; displayName: string; }; export type AuthenticatorSelection = { - authenticatorAttachment: string; + authenticatorAttachment: AuthenticatorAttachment; requireResidentKey: boolean; - residentKey: string; - userVerification: string; -}; - -export type PublicKeyCredentialDescriptor = { - id: string; - type: string; - transports?: string[]; + residentKey: ResidentKeyRequirement; + userVerification: UserVerificationRequirement; }; export type ExclusiveCredentials = { items: PublicKeyCredentialDescriptor[]; }; -export type PublicKeyCred = { - type: string; - alg: number; +type ExclusiveCredentialsB64 = { + items: Pick & + { id: string }[]; }; export interface CreateCredentialOptions { rp: RelyingParty; user: UserEntity; - challenge: string; - pubKeyCredParams: PublicKeyCred[]; + challenge: BufferSource; + pubKeyCredParams: PublicKeyCredentialParameters[]; timeout: number; authenticatorSelection: AuthenticatorSelection; - attestation: string; + attestation: AttestationConveyancePreference; excludeCredentials?: PublicKeyCredentialDescriptor[]; } export interface GetCredentialOptions { - challenge: string; + challenge: BufferSource; allowCredentials: PublicKeyCredentialDescriptor[]; timeout: number; - userVerification: string; - rpId: string; + userVerification?: UserVerificationRequirement; + rpId?: string; } export const Constants = { TIMEOUT: 60000, - ATTESTATION: "direct", - AUTHENTICATOR_ATTACHMENT: "platform", + ATTESTATION: "direct" as AttestationConveyancePreference, + AUTHENTICATOR_ATTACHMENT: "platform" as AuthenticatorAttachment, REQUIRE_RESIDENT_KEY: true, - RESIDENT_KEY: "required", - USER_VERIFICATION: "required", + RESIDENT_KEY: "required" as ResidentKeyRequirement, + USER_VERIFICATION: "required" as UserVerificationRequirement, + PUB_KEY_CRED_PARAM: { + type: "public-key", + alg: -7, + } as PublicKeyCredentialParameters, }; -export type AttestationOptions = { - preferImmediatelyAvailableCred: boolean; - challenge: string; +export type AttestationOptions = + G extends BufferSource ? AttestationOptionsBinary : AttestationOptionsB64; + +export type AttestationOptionsBinary = { + preferImmediatelyAvailableCred?: boolean; + challenge: BufferSource; rp: RelyingParty; user: UserEntity; - timeout?: number; - attestation?: string; + timeout: number | null; + attestation: AttestationConveyancePreference | null; excludeCredentials?: ExclusiveCredentials; authenticatorSelection?: AuthenticatorSelection; }; -export type AssertionOptions = { +export type AttestationOptionsB64 = Omit< + AttestationOptionsBinary, + "challenge" | "user" | "excludeCredentials" +> & { challenge: string; - allowCredentials: PublicKeyCredentialDescriptor[]; - timeout?: number; - userVerification?: string; + user: Pick & { id: string }; + excludeCredentials?: ExclusiveCredentialsB64; +}; + +export type AssertionOptions = H extends BufferSource + ? AssertionOptionsBinary + : AssertionOptionsB64; + +export type AssertionOptionsBinary = { + challenge: BufferSource; + allowCredentials?: ExclusiveCredentials; + timeout: number | null; + userVerification?: UserVerificationRequirement; rpId?: string; }; +export type AssertionOptionsB64 = Omit< + AssertionOptionsBinary, + "challenge" | "allowCredentials" +> & { + challenge: string; + allowCredentials?: ExclusiveCredentialsB64; +}; + export type PublicKeyCredential< - T = AuthenticatorAttestationResponse | AuthenticatorAssertionResponse, + S = BufferSource | string, + T = AuthenticatorAttestationResponse | AuthenticatorAssertionResponse, > = { id: string; - rawId: ArrayBuffer; + rawId?: S; type: string; - response: T; + response?: T; }; -export type AuthenticatorAttestationResponse = { - attestationObject: ArrayBuffer; - clientDataJSON: ArrayBuffer; +export type AuthenticatorAttestationResponse = { + attestationObject: T; + clientDataJSON: T; }; -export type AuthenticatorAssertionResponse = { - authenticatorData: ArrayBuffer; - signature: ArrayBuffer; - clientDataJSON: ArrayBuffer; - userHandle: ArrayBuffer; +export type AuthenticatorAssertionResponse = { + authenticatorData: U; + signature: U; + clientDataJSON: U; + userHandle: U; }; export type ClientDataObject = {