feat(inapp): add passkey authentication for native clients (#4022)
## Problem solved
Short description of the bug fixed or feature added
<!-- start pr-codex -->
---
## PR-Codex overview
This PR introduces support for `passkeyDomain` in in-app wallets, updates error handling in signup screens, and improves storage handling.
### Detailed summary
- Added `passkeyDomain` support in in-app wallets
- Updated error handling in signup screens
- Improved storage handling for in-app wallets
> The following files were skipped due to too many changes: `packages/thirdweb/src/wallets/in-app/web/utils/Storage/LocalStorage.ts`, `packages/thirdweb/src/utils/bytecode/cbor-decode.ts`, `packages/thirdweb/package.json`, `packages/thirdweb/src/wallets/wallet-connect/receiver/session-store.test.ts`, `pnpm-lock.yaml`, `packages/thirdweb/src/wallets/in-app/native/native-connector.ts`, `packages/thirdweb/src/wallets/in-app/web/lib/web-connector.ts`, `packages/thirdweb/src/wallets/in-app/core/authentication/passkeys.ts`, `packages/thirdweb/src/wallets/in-app/native/auth/passkeys.ts`, `packages/thirdweb/src/wallets/in-app/web/lib/auth/passkeys.ts`
> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`
<!-- end pr-codex -->
This commit is contained in:
@@ -18,6 +18,7 @@ export const WALLETS = [
|
||||
"facebook",
|
||||
],
|
||||
mode: "redirect",
|
||||
passkeyDomain: process.env.VERCEL_ENV ? "thirdweb.com" : undefined,
|
||||
},
|
||||
}),
|
||||
createWallet("io.metamask"),
|
||||
|
||||
@@ -117,54 +117,22 @@
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"adapters/*": [
|
||||
"./dist/types/exports/adapters/*.d.ts"
|
||||
],
|
||||
"auth": [
|
||||
"./dist/types/exports/auth.d.ts"
|
||||
],
|
||||
"chains": [
|
||||
"./dist/types/exports/chains.d.ts"
|
||||
],
|
||||
"contract": [
|
||||
"./dist/types/exports/contract.d.ts"
|
||||
],
|
||||
"deploys": [
|
||||
"./dist/types/exports/deploys.d.ts"
|
||||
],
|
||||
"event": [
|
||||
"./dist/types/exports/event.d.ts"
|
||||
],
|
||||
"extensions/*": [
|
||||
"./dist/types/exports/extensions/*.d.ts"
|
||||
],
|
||||
"pay": [
|
||||
"./dist/types/exports/pay.d.ts"
|
||||
],
|
||||
"react": [
|
||||
"./dist/types/exports/react.d.ts"
|
||||
],
|
||||
"react-native": [
|
||||
"./dist/types/exports/react-native.d.ts"
|
||||
],
|
||||
"rpc": [
|
||||
"./dist/types/exports/rpc.d.ts"
|
||||
],
|
||||
"storage": [
|
||||
"./dist/types/exports/storage.d.ts"
|
||||
],
|
||||
"transaction": [
|
||||
"./dist/types/exports/transaction.d.ts"
|
||||
],
|
||||
"utils": [
|
||||
"./dist/types/exports/utils.d.ts"
|
||||
],
|
||||
"wallets": [
|
||||
"./dist/types/exports/wallets.d.ts"
|
||||
],
|
||||
"wallets/*": [
|
||||
"./dist/types/exports/wallets/*.d.ts"
|
||||
]
|
||||
"adapters/*": ["./dist/types/exports/adapters/*.d.ts"],
|
||||
"auth": ["./dist/types/exports/auth.d.ts"],
|
||||
"chains": ["./dist/types/exports/chains.d.ts"],
|
||||
"contract": ["./dist/types/exports/contract.d.ts"],
|
||||
"deploys": ["./dist/types/exports/deploys.d.ts"],
|
||||
"event": ["./dist/types/exports/event.d.ts"],
|
||||
"extensions/*": ["./dist/types/exports/extensions/*.d.ts"],
|
||||
"pay": ["./dist/types/exports/pay.d.ts"],
|
||||
"react": ["./dist/types/exports/react.d.ts"],
|
||||
"react-native": ["./dist/types/exports/react-native.d.ts"],
|
||||
"rpc": ["./dist/types/exports/rpc.d.ts"],
|
||||
"storage": ["./dist/types/exports/storage.d.ts"],
|
||||
"transaction": ["./dist/types/exports/transaction.d.ts"],
|
||||
"utils": ["./dist/types/exports/utils.d.ts"],
|
||||
"wallets": ["./dist/types/exports/wallets.d.ts"],
|
||||
"wallets/*": ["./dist/types/exports/wallets/*.d.ts"]
|
||||
}
|
||||
},
|
||||
"browser": {
|
||||
@@ -249,6 +217,9 @@
|
||||
"react-native-quick-crypto": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native-passkey": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native-svg": {
|
||||
"optional": true
|
||||
},
|
||||
@@ -326,6 +297,7 @@
|
||||
"msw": "^2.3.4",
|
||||
"react-native": "0.74.5",
|
||||
"react-native-aes-gcm-crypto": "0.2.2",
|
||||
"react-native-passkey": "3.0.0-beta2",
|
||||
"react-native-quick-crypto": "0.7.0-rc.6",
|
||||
"react-native-svg": "15.3.0",
|
||||
"typescript": "5.5.4",
|
||||
|
||||
@@ -10,9 +10,11 @@ export {
|
||||
} from "../../wallets/in-app/native/auth/index.js";
|
||||
|
||||
export { type GetAuthenticatedUserParams } from "../../wallets/in-app/core/authentication/types.js";
|
||||
export type {
|
||||
InAppWalletCreationOptions,
|
||||
InAppWalletAuth,
|
||||
InAppWalletSocialAuth,
|
||||
InAppWalletConnectionOptions,
|
||||
} from "../../wallets/in-app/core/wallet/types.js";
|
||||
|
||||
// NOT SUPPORTED (yet)
|
||||
|
||||
export const hasStoredPasskey = () => {
|
||||
throw new Error("Not supported in native");
|
||||
};
|
||||
export { hasStoredPasskey } from "../../wallets/in-app/native/auth/passkeys.js";
|
||||
|
||||
@@ -96,6 +96,9 @@ export function useAutoConnectCore(
|
||||
setConnectionStatus("disconnected");
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
console.warn("Error auto connecting wallet:", e.message);
|
||||
}
|
||||
setConnectionStatus("disconnected");
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useState } from "react";
|
||||
import type { Wallet } from "../../../../wallets/interfaces/wallet.js";
|
||||
import type { ConnectManagerOptions } from "../../../../wallets/manager/index.js";
|
||||
import { useConnectionManagerCtx } from "../../providers/connection-manager.js";
|
||||
import { useSetActiveWalletConnectionStatus } from "./useSetActiveWalletConnectionStatus.js";
|
||||
|
||||
/**
|
||||
* A hook to set a wallet as active wallet
|
||||
@@ -36,6 +37,7 @@ import { useConnectionManagerCtx } from "../../providers/connection-manager.js";
|
||||
export function useConnect(options?: ConnectManagerOptions) {
|
||||
const manager = useConnectionManagerCtx("useConnect");
|
||||
const { connect } = manager;
|
||||
const setConnectionStatus = useSetActiveWalletConnectionStatus();
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
@@ -43,23 +45,29 @@ export function useConnect(options?: ConnectManagerOptions) {
|
||||
async (walletOrFn: Wallet | (() => Promise<Wallet>)) => {
|
||||
// reset error state
|
||||
setError(null);
|
||||
setConnectionStatus("connecting");
|
||||
if (typeof walletOrFn !== "function") {
|
||||
return await connect(walletOrFn, options);
|
||||
const account = await connect(walletOrFn, options);
|
||||
setConnectionStatus("connected");
|
||||
return account;
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
const w = await walletOrFn();
|
||||
return await connect(w, options);
|
||||
const account = await connect(w, options);
|
||||
setConnectionStatus("connected");
|
||||
return account;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError(e as Error);
|
||||
setConnectionStatus("disconnected");
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[connect, options],
|
||||
[connect, options, setConnectionStatus],
|
||||
);
|
||||
|
||||
return { connect: handleConnection, isConnecting, error } as const;
|
||||
|
||||
@@ -217,9 +217,6 @@ function SignupScreen(props: {
|
||||
await linkProfile(wallet as Wallet<"inApp">, {
|
||||
strategy: "passkey",
|
||||
type: "sign-up",
|
||||
}).catch((e) => {
|
||||
setError(e.message);
|
||||
throw e;
|
||||
});
|
||||
} else {
|
||||
await wallet.connect({
|
||||
@@ -231,7 +228,11 @@ function SignupScreen(props: {
|
||||
await setLastAuthProvider("passkey", webLocalStorage);
|
||||
}
|
||||
done();
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (e instanceof Error) {
|
||||
setError(`Error creating passkey: ${e.message}`);
|
||||
}
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
// TODO: re-enable typescript and properly type this
|
||||
|
||||
// @ts-nocheck
|
||||
// @ts-nocheck - TODO: re-enable typescript and properly type this
|
||||
|
||||
let src;
|
||||
let srcEnd;
|
||||
@@ -32,6 +32,60 @@ const defaultOptions = {
|
||||
mapsAsObjects: true,
|
||||
};
|
||||
|
||||
function readFixedString(length) {
|
||||
let result
|
||||
if (length < 16) {
|
||||
if (result = shortStringInJS(length))
|
||||
return result
|
||||
}
|
||||
if (length > 64 && decoder)
|
||||
return decoder.decode(src.subarray(position, position += length))
|
||||
const end = position + length
|
||||
const units = []
|
||||
result = ''
|
||||
while (position < end) {
|
||||
const byte1 = src[position++]
|
||||
if ((byte1 & 0x80) === 0) {
|
||||
// 1 byte
|
||||
units.push(byte1)
|
||||
} else if ((byte1 & 0xe0) === 0xc0) {
|
||||
// 2 bytes
|
||||
const byte2 = src[position++] & 0x3f
|
||||
units.push(((byte1 & 0x1f) << 6) | byte2)
|
||||
} else if ((byte1 & 0xf0) === 0xe0) {
|
||||
// 3 bytes
|
||||
const byte2 = src[position++] & 0x3f
|
||||
const byte3 = src[position++] & 0x3f
|
||||
units.push(((byte1 & 0x1f) << 12) | (byte2 << 6) | byte3)
|
||||
} else if ((byte1 & 0xf8) === 0xf0) {
|
||||
// 4 bytes
|
||||
const byte2 = src[position++] & 0x3f
|
||||
const byte3 = src[position++] & 0x3f
|
||||
const byte4 = src[position++] & 0x3f
|
||||
let unit = ((byte1 & 0x07) << 0x12) | (byte2 << 0x0c) | (byte3 << 0x06) | byte4
|
||||
if (unit > 0xffff) {
|
||||
unit -= 0x10000
|
||||
units.push(((unit >>> 10) & 0x3ff) | 0xd800)
|
||||
unit = 0xdc00 | (unit & 0x3ff)
|
||||
}
|
||||
units.push(unit)
|
||||
} else {
|
||||
units.push(byte1)
|
||||
}
|
||||
|
||||
if (units.length >= 0x1000) {
|
||||
result += fromCharCode.apply(String, units)
|
||||
units.length = 0
|
||||
}
|
||||
}
|
||||
|
||||
if (units.length > 0) {
|
||||
result += fromCharCode.apply(String, units)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
class Decoder {
|
||||
constructor() {
|
||||
Object.assign(this, defaultOptions);
|
||||
@@ -451,4 +505,4 @@ for (let i = 0; i < 256; i++) {
|
||||
Number(`1e${Math.floor(45.15 - i * 0.30103)}`))();
|
||||
}
|
||||
const defaultDecoder = new Decoder();
|
||||
export const decode = defaultDecoder.decode;
|
||||
export const decode = defaultDecoder.decode;
|
||||
@@ -116,8 +116,17 @@ function assertString(value: unknown): asserts value is string {
|
||||
}
|
||||
}
|
||||
|
||||
function base64UrlToBase64(base64url: string) {
|
||||
return base64url.replaceAll("-", "+").replaceAll("_", "/");
|
||||
export function base64UrlToBase64(base64url: string): string {
|
||||
// Replace Base64URL characters with Base64 characters
|
||||
let base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
|
||||
|
||||
// Add padding if necessary
|
||||
const padding = base64.length % 4;
|
||||
if (padding !== 0) {
|
||||
base64 += "=".repeat(4 - padding);
|
||||
}
|
||||
|
||||
return base64;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+14
-11
@@ -1,52 +1,55 @@
|
||||
import type { EcosystemWalletId } from "../../../../wallet-types.js";
|
||||
import type { AsyncStorage } from "../../../../utils/storage/AsyncStorage.js";
|
||||
import type { EcosystemWalletId } from "../../../wallet-types.js";
|
||||
import {
|
||||
AUTH_TOKEN_LOCAL_STORAGE_NAME,
|
||||
DEVICE_SHARE_LOCAL_STORAGE_NAME,
|
||||
PASSKEY_CREDENTIAL_ID_LOCAL_STORAGE_NAME,
|
||||
WALLET_CONNECT_SESSIONS_LOCAL_STORAGE_NAME,
|
||||
WALLET_USER_ID_LOCAL_STORAGE_NAME,
|
||||
} from "../../../core/constants/settings.js";
|
||||
} from "../constants/settings.js";
|
||||
|
||||
const data = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export class LocalStorage {
|
||||
protected isSupported: boolean;
|
||||
export class ClientScopedStorage {
|
||||
protected key: string;
|
||||
protected storage: AsyncStorage | null;
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
constructor({
|
||||
storage,
|
||||
clientId,
|
||||
ecosystemId,
|
||||
}: {
|
||||
storage: AsyncStorage | null;
|
||||
clientId: string;
|
||||
ecosystemId?: EcosystemWalletId;
|
||||
}) {
|
||||
this.isSupported = typeof window !== "undefined" && !!window.localStorage;
|
||||
this.storage = storage;
|
||||
this.key = getLocalStorageKey(clientId, ecosystemId);
|
||||
}
|
||||
|
||||
protected async getItem(key: string): Promise<string | null> {
|
||||
if (this.isSupported) {
|
||||
return window.localStorage.getItem(key);
|
||||
if (this.storage) {
|
||||
return this.storage.getItem(key);
|
||||
}
|
||||
return data.get(key) ?? null;
|
||||
}
|
||||
|
||||
protected async setItem(key: string, value: string): Promise<void> {
|
||||
if (this.isSupported) {
|
||||
return window.localStorage.setItem(key, value);
|
||||
if (this.storage) {
|
||||
return this.storage.setItem(key, value);
|
||||
}
|
||||
data.set(key, value);
|
||||
}
|
||||
|
||||
protected async removeItem(key: string): Promise<boolean> {
|
||||
const item = await this.getItem(key);
|
||||
if (this.isSupported && item) {
|
||||
window.localStorage.removeItem(key);
|
||||
if (this.storage && item) {
|
||||
this.storage.removeItem(key);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { ThirdwebClient } from "../../../../client/client.js";
|
||||
import { getThirdwebBaseUrl } from "../../../../utils/domains.js";
|
||||
import { getClientFetch } from "../../../../utils/fetch.js";
|
||||
import type { AsyncStorage } from "../../../../utils/storage/AsyncStorage.js";
|
||||
import type { Ecosystem } from "../../web/types.js";
|
||||
import { ClientScopedStorage } from "./client-scoped-storage.js";
|
||||
import type { AuthStoredTokenWithCookieReturnType } from "./types.js";
|
||||
|
||||
function getVerificationPath() {
|
||||
return `${getThirdwebBaseUrl(
|
||||
"inAppWallet",
|
||||
)}/api/2024-05-05/login/passkey/callback`;
|
||||
}
|
||||
function getChallengePath(type: "sign-in" | "sign-up", username?: string) {
|
||||
return `${getThirdwebBaseUrl(
|
||||
"inAppWallet",
|
||||
)}/api/2024-05-05/login/passkey?type=${type}${
|
||||
username ? `&username=${username}` : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
export type RegisterResult = {
|
||||
authenticatorData: string;
|
||||
credentialId: string;
|
||||
clientData: string;
|
||||
credential: {
|
||||
publicKey: string;
|
||||
algorithm: string;
|
||||
};
|
||||
origin: string;
|
||||
};
|
||||
|
||||
export type AuthenticateResult = {
|
||||
credentialId: string;
|
||||
authenticatorData: string;
|
||||
clientData: string;
|
||||
signature: string;
|
||||
origin: string;
|
||||
};
|
||||
|
||||
export type RpInfo = { name: string; id: string };
|
||||
|
||||
export interface PasskeyClient {
|
||||
register: (args: {
|
||||
name: string;
|
||||
challenge: string;
|
||||
rp: RpInfo;
|
||||
}) => Promise<RegisterResult>;
|
||||
authenticate: (args: {
|
||||
credentialId: string | undefined;
|
||||
challenge: string;
|
||||
rp: RpInfo;
|
||||
}) => Promise<AuthenticateResult>;
|
||||
isAvailable: () => boolean;
|
||||
}
|
||||
|
||||
export async function registerPasskey(options: {
|
||||
client: ThirdwebClient;
|
||||
storage: AsyncStorage;
|
||||
passkeyClient: PasskeyClient;
|
||||
ecosystem?: Ecosystem;
|
||||
username?: string;
|
||||
rp: RpInfo;
|
||||
}): Promise<AuthStoredTokenWithCookieReturnType> {
|
||||
if (!options.passkeyClient.isAvailable()) {
|
||||
throw new Error("Passkeys are not available on this device");
|
||||
}
|
||||
const storage = new ClientScopedStorage({
|
||||
storage: options.storage,
|
||||
clientId: options.client.clientId,
|
||||
ecosystemId: options.ecosystem?.id,
|
||||
});
|
||||
const fetchWithId = getClientFetch(options.client, options.ecosystem);
|
||||
const generatedName = options.username ?? generateUsername(options.ecosystem);
|
||||
// 1. request challenge from server
|
||||
const res = await fetchWithId(getChallengePath("sign-up", generatedName));
|
||||
const challengeData = await res.json();
|
||||
if (!challengeData.challenge) {
|
||||
throw new Error("No challenge received");
|
||||
}
|
||||
const challenge = challengeData.challenge;
|
||||
|
||||
// 2. initiate registration
|
||||
const registration = await options.passkeyClient.register({
|
||||
name: generatedName,
|
||||
challenge,
|
||||
rp: options.rp,
|
||||
});
|
||||
|
||||
const customHeaders: Record<string, string> = {};
|
||||
if (options.ecosystem?.partnerId) {
|
||||
customHeaders["x-ecosystem-partner-id"] = options.ecosystem.partnerId;
|
||||
}
|
||||
if (options.ecosystem?.id) {
|
||||
customHeaders["x-ecosystem-id"] = options.ecosystem.id;
|
||||
}
|
||||
|
||||
// 3. send the registration object to the server
|
||||
const verifRes = await fetchWithId(getVerificationPath(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...customHeaders,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: "sign-up",
|
||||
authenticatorData: registration.authenticatorData,
|
||||
credentialId: registration.credentialId,
|
||||
serverVerificationId: challengeData.serverVerificationId,
|
||||
clientData: registration.clientData,
|
||||
username: generatedName,
|
||||
credential: {
|
||||
publicKey: registration.credential.publicKey,
|
||||
algorithm: registration.credential.algorithm,
|
||||
},
|
||||
origin: registration.origin,
|
||||
rpId: options.rp.id,
|
||||
}),
|
||||
});
|
||||
const verifData = await verifRes.json();
|
||||
|
||||
if (!verifData || !verifData.storedToken) {
|
||||
throw new Error(
|
||||
`Error verifying passkey: ${verifData.message ?? "unknown error"}`,
|
||||
);
|
||||
}
|
||||
// 4. store the credentialId in local storage
|
||||
await storage.savePasskeyCredentialId(registration.credentialId);
|
||||
|
||||
// 5. returns back the IAW authentication token
|
||||
return verifData;
|
||||
}
|
||||
|
||||
export async function loginWithPasskey(options: {
|
||||
client: ThirdwebClient;
|
||||
storage: AsyncStorage;
|
||||
passkeyClient: PasskeyClient;
|
||||
rp: RpInfo;
|
||||
ecosystem?: Ecosystem;
|
||||
}): Promise<AuthStoredTokenWithCookieReturnType> {
|
||||
if (!options.passkeyClient.isAvailable()) {
|
||||
throw new Error("Passkeys are not available on this device");
|
||||
}
|
||||
const storage = new ClientScopedStorage({
|
||||
storage: options.storage,
|
||||
clientId: options.client.clientId,
|
||||
ecosystemId: options.ecosystem?.id,
|
||||
});
|
||||
const fetchWithId = getClientFetch(options.client, options.ecosystem);
|
||||
// 1. request challenge from server/iframe
|
||||
const res = await fetchWithId(getChallengePath("sign-in"));
|
||||
const challengeData = await res.json();
|
||||
if (!challengeData.challenge) {
|
||||
throw new Error("No challenge received");
|
||||
}
|
||||
const challenge = challengeData.challenge;
|
||||
// 1.2. find the user's credentialId in local storage
|
||||
const credentialId = (await storage.getPasskeyCredentialId()) ?? undefined;
|
||||
// 2. initiate login
|
||||
const authentication = await options.passkeyClient.authenticate({
|
||||
credentialId,
|
||||
challenge,
|
||||
rp: options.rp,
|
||||
});
|
||||
|
||||
const customHeaders: Record<string, string> = {};
|
||||
if (options.ecosystem?.partnerId) {
|
||||
customHeaders["x-ecosystem-partner-id"] = options.ecosystem.partnerId;
|
||||
}
|
||||
if (options.ecosystem?.id) {
|
||||
customHeaders["x-ecosystem-id"] = options.ecosystem.id;
|
||||
}
|
||||
|
||||
const verifRes = await fetchWithId(getVerificationPath(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...customHeaders,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: "sign-in",
|
||||
authenticatorData: authentication.authenticatorData,
|
||||
credentialId: authentication.credentialId,
|
||||
serverVerificationId: challengeData.serverVerificationId,
|
||||
clientData: authentication.clientData,
|
||||
signature: authentication.signature,
|
||||
origin: authentication.origin,
|
||||
rpId: options.rp.id,
|
||||
}),
|
||||
});
|
||||
|
||||
const verifData = await verifRes.json();
|
||||
|
||||
if (!verifData || !verifData.storedToken) {
|
||||
throw new Error(
|
||||
`Error verifying passkey: ${verifData.message ?? "unknown error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. store the credentialId in local storage
|
||||
await storage.savePasskeyCredentialId(authentication.credentialId);
|
||||
|
||||
// 6. return the auth'd user type
|
||||
return verifData;
|
||||
}
|
||||
|
||||
function generateUsername(ecosystem?: Ecosystem) {
|
||||
return `${ecosystem?.id ?? "wallet"}-${new Date().toISOString()}`;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { AuthType } from "@passwordless-id/webauthn/dist/esm/types.js";
|
||||
import type { Chain } from "../../../../chains/types.js";
|
||||
import type { ThirdwebClient } from "../../../../client/client.js";
|
||||
import type { Address } from "../../../../utils/address.js";
|
||||
@@ -38,9 +37,23 @@ export type SingleStepAuthArgsType =
|
||||
| { strategy: "iframe" }
|
||||
| {
|
||||
strategy: "passkey";
|
||||
/**
|
||||
* Whether to create a new passkey (sign up) or login to an existing one (sign in).
|
||||
* You can use `hasStoredPasskey()` to check if a user has previously logged in with a passkey on this device.
|
||||
*/
|
||||
type: "sign-up" | "sign-in";
|
||||
/**
|
||||
* Optional name of the passkey to create, defaults to a generated name
|
||||
*/
|
||||
passkeyName?: string;
|
||||
authenticatorType?: AuthType;
|
||||
/**
|
||||
* Optional domain, defaults to current window location.
|
||||
* NOTE: this is required on native platforms.
|
||||
*/
|
||||
domain?: {
|
||||
displayName: string;
|
||||
hostname: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
strategy: "wallet";
|
||||
|
||||
@@ -29,9 +29,22 @@ export type InAppWalletAuth = AuthOption;
|
||||
export type InAppWalletCreationOptions =
|
||||
| {
|
||||
auth?: {
|
||||
/**
|
||||
* List of authentication options to display in the Connect Modal
|
||||
*/
|
||||
options: InAppWalletAuth[];
|
||||
/**
|
||||
* Whether to display the social auth prompt in a popup or redirect
|
||||
*/
|
||||
mode?: "popup" | "redirect";
|
||||
/**
|
||||
* The domain of the passkey to use for authentication
|
||||
*/
|
||||
passkeyDomain?: string;
|
||||
};
|
||||
/**
|
||||
* Metadata to display in the Connect Modal
|
||||
*/
|
||||
metadata?: {
|
||||
image?: {
|
||||
src: string;
|
||||
@@ -40,8 +53,17 @@ export type InAppWalletCreationOptions =
|
||||
alt?: string;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* The partnerId of the ecosystem wallet to connect to
|
||||
*/
|
||||
partnerId?: string;
|
||||
/**
|
||||
* The smart account options to convert the wallet to a smart account
|
||||
**/
|
||||
smartAccount?: SmartWalletOptions;
|
||||
/**
|
||||
* Whether to hide the private key export button in the Connect Modal
|
||||
*/
|
||||
hidePrivateKeyExport?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { Passkey } from "react-native-passkey";
|
||||
import { concat } from "viem";
|
||||
import type { ThirdwebClient } from "../../../../client/client.js";
|
||||
import { toBytes } from "../../../../utils/encoding/to-bytes.js";
|
||||
import { keccak256 } from "../../../../utils/hashing/keccak256.js";
|
||||
import { nativeLocalStorage } from "../../../../utils/storage/nativeStorage.js";
|
||||
import {
|
||||
base64ToString,
|
||||
base64ToUint8Array,
|
||||
base64UrlToBase64,
|
||||
uint8ArrayToBase64,
|
||||
} from "../../../../utils/uint8-array.js";
|
||||
import type { EcosystemWalletId } from "../../../wallet-types.js";
|
||||
import { ClientScopedStorage } from "../../core/authentication/client-scoped-storage.js";
|
||||
import type {
|
||||
AuthenticateResult,
|
||||
PasskeyClient,
|
||||
RegisterResult,
|
||||
RpInfo,
|
||||
} from "../../core/authentication/passkeys.js";
|
||||
|
||||
export class PasskeyNativeClient implements PasskeyClient {
|
||||
isAvailable(): boolean {
|
||||
return Passkey.isSupported();
|
||||
}
|
||||
|
||||
async register(args: {
|
||||
name: string;
|
||||
challenge: string;
|
||||
rp: RpInfo;
|
||||
}): Promise<RegisterResult> {
|
||||
const { name, challenge, rp } = args;
|
||||
const result = await Passkey.create({
|
||||
challenge,
|
||||
user: {
|
||||
displayName: name,
|
||||
name: name,
|
||||
id: keccak256(toBytes(name)),
|
||||
},
|
||||
authenticatorSelection: {
|
||||
authenticatorAttachment: "platform",
|
||||
residentKey: "required",
|
||||
userVerification: "required",
|
||||
requireResidentKey: true,
|
||||
},
|
||||
rp,
|
||||
pubKeyCredParams: [
|
||||
{
|
||||
alg: -7,
|
||||
type: "public-key",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const parsedResult =
|
||||
typeof result === "string" ? JSON.parse(result) : result;
|
||||
const { publicKey, authData } = await extractPublicKeyAndAuthData(
|
||||
parsedResult.response,
|
||||
);
|
||||
const clientDataB64 = base64UrlToBase64(
|
||||
parsedResult.response.clientDataJSON,
|
||||
);
|
||||
const clientDataParsed = JSON.parse(base64ToString(clientDataB64));
|
||||
|
||||
return {
|
||||
authenticatorData: authData,
|
||||
credentialId: parsedResult.id,
|
||||
clientData: clientDataB64,
|
||||
credential: {
|
||||
publicKey,
|
||||
algorithm: "ES256",
|
||||
},
|
||||
origin: clientDataParsed.origin,
|
||||
};
|
||||
}
|
||||
|
||||
async authenticate(args: {
|
||||
credentialId: string | undefined;
|
||||
challenge: string;
|
||||
rp: RpInfo;
|
||||
}): Promise<AuthenticateResult> {
|
||||
const { credentialId, challenge, rp } = args;
|
||||
const result = await Passkey.get({
|
||||
challenge,
|
||||
rpId: rp.id,
|
||||
allowCredentials: credentialId
|
||||
? [
|
||||
{
|
||||
id: credentialId,
|
||||
type: "public-key",
|
||||
// biome-ignore lint/suspicious/noExplicitAny: enum not exported
|
||||
transports: ["hybrid" as any],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
|
||||
const parsedResult =
|
||||
typeof result === "string" ? JSON.parse(result) : result;
|
||||
const clientDataB64 = base64UrlToBase64(
|
||||
parsedResult.response.clientDataJSON,
|
||||
);
|
||||
const clientDataParsed = JSON.parse(base64ToString(clientDataB64));
|
||||
|
||||
return {
|
||||
authenticatorData: parsedResult.response.authenticatorData,
|
||||
credentialId: parsedResult.id,
|
||||
clientData: clientDataB64,
|
||||
signature: parsedResult.response.signature,
|
||||
origin: clientDataParsed.origin,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether this device has a stored passkey ready to be used for sign-in
|
||||
* @param client - the thirdweb client
|
||||
* @returns whether the device has a stored passkey
|
||||
* @walletUtils
|
||||
*/
|
||||
export async function hasStoredPasskey(
|
||||
client: ThirdwebClient,
|
||||
ecosystemId?: EcosystemWalletId,
|
||||
) {
|
||||
const storage = new ClientScopedStorage({
|
||||
storage: nativeLocalStorage,
|
||||
clientId: client.clientId,
|
||||
ecosystemId: ecosystemId,
|
||||
});
|
||||
const credId = await storage.getPasskeyCredentialId();
|
||||
return !!credId;
|
||||
}
|
||||
|
||||
type ExtractedData = {
|
||||
publicKey: string;
|
||||
authData: string;
|
||||
};
|
||||
|
||||
async function extractPublicKeyAndAuthData(response: {
|
||||
attestationObject: string;
|
||||
}): Promise<ExtractedData> {
|
||||
const { decode } = await import("../../../../utils/bytecode/cbor-decode.js");
|
||||
const { attestationObject } = response;
|
||||
const attestationBase64 = base64UrlToBase64(attestationObject);
|
||||
|
||||
// Decode the attestationObject from base64url
|
||||
const attestationBytes = base64ToUint8Array(attestationBase64);
|
||||
const decodedAttestationObject = decode(attestationBytes);
|
||||
|
||||
// Extract the authenticator data (authData) from the attestation object
|
||||
const authData = Uint8Array.from(decodedAttestationObject.authData);
|
||||
const decoded = decodeAuthData(authData);
|
||||
|
||||
if (!decoded.publicKey) {
|
||||
throw new Error("No public key found");
|
||||
}
|
||||
const coseKey = decode(decoded.publicKey);
|
||||
// convert to PEM format
|
||||
const x = Uint8Array.from(coseKey[-2]); // x coordinate
|
||||
const y = Uint8Array.from(coseKey[-3]); // y coordinate
|
||||
// Concatenate x and y coordinates with the uncompressed point indicator (0x04)
|
||||
const pubKey = concat([
|
||||
Uint8Array.from([
|
||||
// ASN.1 DER encoding, aka X.690 ... https://en.wikipedia.org/wiki/X.690
|
||||
|
||||
0x30, // DER Sequence
|
||||
0x59, // Length (2+19 + 2+66) = 89
|
||||
|
||||
0x30, // DER Sequence
|
||||
0x13, // Length (2+7 + 2+8) = 19
|
||||
|
||||
0x06, // DER OBJECT IDENTIFIER
|
||||
0x07, // Length
|
||||
0x2a,
|
||||
0x86,
|
||||
0x48,
|
||||
0xce,
|
||||
0x3d,
|
||||
0x02,
|
||||
0x01, // OID 1.2.840.10045.2.1 ecPublicKey
|
||||
|
||||
0x06, // DER OBJECT IDENTIFIER
|
||||
0x08, // Length
|
||||
0x2a,
|
||||
0x86,
|
||||
0x48,
|
||||
0xce,
|
||||
0x3d,
|
||||
0x03,
|
||||
0x01,
|
||||
0x07, // OID 1.2.840.10045.3.1.7 prime256v1
|
||||
|
||||
0x03, // DER BIT STRING
|
||||
0x42, // Length (32 + 32 + 1(null) + 1) = 66
|
||||
0x00,
|
||||
|
||||
0x04, // ECC uncompressed... beginning of X9.62 Key
|
||||
]), // Uncompressed point indicator
|
||||
x,
|
||||
y,
|
||||
]);
|
||||
|
||||
return {
|
||||
publicKey: uint8ArrayToBase64(pubKey),
|
||||
authData: uint8ArrayToBase64(authData),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeAuthData(authData: Uint8Array) {
|
||||
let offset = 0;
|
||||
|
||||
// Step 1: Read the RP ID hash (32 bytes)
|
||||
const rpIdHash = authData.slice(offset, offset + 32);
|
||||
offset += 32;
|
||||
|
||||
// Step 2: Read the flags (1 byte)
|
||||
const flags = authData[offset];
|
||||
offset += 1;
|
||||
|
||||
if (!flags) {
|
||||
throw new Error("No flags found");
|
||||
}
|
||||
|
||||
// Step 3: Read the sign count (4 bytes)
|
||||
const signCount = authData.slice(offset, offset + 4);
|
||||
offset += 4;
|
||||
|
||||
// Step 4: Check if attested credential data is present
|
||||
const attestedCredentialDataPresent = (flags & 0x40) !== 0;
|
||||
let aaguid: Uint8Array | undefined;
|
||||
let credentialIdLength: number | undefined;
|
||||
let credentialId: Uint8Array | undefined;
|
||||
let publicKey: Uint8Array | undefined;
|
||||
|
||||
if (attestedCredentialDataPresent) {
|
||||
// Step 5: Read AAGUID (16 bytes)
|
||||
aaguid = authData.slice(offset, offset + 16);
|
||||
offset += 16;
|
||||
|
||||
const start = authData[offset];
|
||||
const end = authData[offset + 1];
|
||||
|
||||
if (start === undefined || end === undefined) {
|
||||
throw new Error("No credential ID found");
|
||||
}
|
||||
|
||||
// Step 6: Read Credential ID Length (2 bytes)
|
||||
credentialIdLength = (start << 8) + end;
|
||||
offset += 2;
|
||||
|
||||
// Step 7: Read Credential ID (variable length)
|
||||
credentialId = authData.slice(offset, offset + credentialIdLength);
|
||||
offset += credentialIdLength;
|
||||
|
||||
// Step 8: Read Public Key (variable length, COSE-encoded)
|
||||
publicKey = authData.slice(offset); // The rest is the public key
|
||||
offset += publicKey.length;
|
||||
}
|
||||
|
||||
return {
|
||||
rpIdHash,
|
||||
flags,
|
||||
signCount,
|
||||
aaguid,
|
||||
credentialIdLength,
|
||||
credentialId,
|
||||
publicKey,
|
||||
};
|
||||
}
|
||||
@@ -87,9 +87,8 @@ export async function fetchUserDetails(args: {
|
||||
}): Promise<UserDetailsApiType> {
|
||||
const url = new URL(ROUTE_EMBEDDED_WALLET_DETAILS);
|
||||
if (args) {
|
||||
if (args.email) {
|
||||
url.searchParams.append("email", args.email);
|
||||
}
|
||||
// TODO (inapp) remove this, unused in the backend but still required
|
||||
url.searchParams.append("email", args.email ?? "none");
|
||||
url.searchParams.append("clientId", args.client.clientId);
|
||||
}
|
||||
const resp = await authFetchEmbeddedWalletUser(args.client, url.href, {
|
||||
|
||||
@@ -64,6 +64,7 @@ export function inAppWallet(
|
||||
const { InAppNativeConnector } = await import("./native-connector.js");
|
||||
return new InAppNativeConnector({
|
||||
client,
|
||||
passkeyDomain: createOptions?.auth?.passkeyDomain,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { Chain } from "../../../chains/types.js";
|
||||
import type { ThirdwebClient } from "../../../client/client.js";
|
||||
import { stringify } from "../../../utils/json.js";
|
||||
import { nativeLocalStorage } from "../../../utils/storage/nativeStorage.js";
|
||||
import type { Account, Wallet } from "../../interfaces/wallet.js";
|
||||
import {
|
||||
loginWithPasskey,
|
||||
registerPasskey,
|
||||
} from "../core/authentication/passkeys.js";
|
||||
import { siweAuthenticate } from "../core/authentication/siwe.js";
|
||||
import {
|
||||
type AuthArgsType,
|
||||
@@ -27,12 +33,14 @@ import {
|
||||
} from "./auth/native-auth.js";
|
||||
import { fetchUserDetails } from "./helpers/api/fetchers.js";
|
||||
import { logoutUser } from "./helpers/auth/logout.js";
|
||||
import { postAuth } from "./helpers/auth/middleware.js";
|
||||
import { getWalletUserDetails } from "./helpers/storage/local.js";
|
||||
import { getExistingUserAccount } from "./helpers/wallet/retrieval.js";
|
||||
|
||||
export type NativeConnectorOptions = {
|
||||
client: ThirdwebClient;
|
||||
partnerId?: string | undefined;
|
||||
passkeyDomain?: string;
|
||||
};
|
||||
|
||||
export class InAppNativeConnector implements InAppConnector {
|
||||
@@ -107,6 +115,8 @@ export class InAppNativeConnector implements InAppConnector {
|
||||
params.redirectUrl || (ExpoLinking.createURL("") as string);
|
||||
return authenticate({ strategy, redirectUrl }, this.options.client);
|
||||
}
|
||||
case "passkey":
|
||||
return this.passkeyAuth(params);
|
||||
default:
|
||||
throw new Error(`Unsupported authentication type: ${strategy}`);
|
||||
}
|
||||
@@ -164,7 +174,16 @@ export class InAppNativeConnector implements InAppConnector {
|
||||
});
|
||||
}
|
||||
case "passkey": {
|
||||
throw new Error("Passkey authentication is not implemented yet");
|
||||
const authToken = await this.passkeyAuth(params);
|
||||
const account = await this.getAccount();
|
||||
return {
|
||||
user: {
|
||||
status: UserWalletStatus.LOGGED_IN_WALLET_INITIALIZED,
|
||||
account,
|
||||
authDetails: authToken.storedToken.authDetails,
|
||||
walletAddress: account.address,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "iframe": {
|
||||
throw new Error("iframe_email_verification is not supported in native");
|
||||
@@ -177,6 +196,75 @@ export class InAppNativeConnector implements InAppConnector {
|
||||
}
|
||||
}
|
||||
|
||||
private async passkeyAuth(args: {
|
||||
type: "sign-up" | "sign-in";
|
||||
passkeyName?: string;
|
||||
client: ThirdwebClient;
|
||||
ecosystem?: Ecosystem;
|
||||
}): Promise<AuthStoredTokenWithCookieReturnType> {
|
||||
const { type, passkeyName, client, ecosystem } = args;
|
||||
const domain = this.options.passkeyDomain;
|
||||
if (!domain) {
|
||||
throw new Error(
|
||||
"Passkey domain is required for native platforms. Please pass it in the 'auth' options when creating the inAppWallet().",
|
||||
);
|
||||
}
|
||||
try {
|
||||
const { PasskeyNativeClient } = await import("./auth/passkeys.js");
|
||||
const passkeyClient = new PasskeyNativeClient();
|
||||
const storage = nativeLocalStorage;
|
||||
let authToken: AuthStoredTokenWithCookieReturnType;
|
||||
|
||||
if (type === "sign-up") {
|
||||
authToken = await registerPasskey({
|
||||
client,
|
||||
ecosystem,
|
||||
username: passkeyName,
|
||||
passkeyClient,
|
||||
storage,
|
||||
rp: {
|
||||
id: domain,
|
||||
name: domain,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
authToken = await loginWithPasskey({
|
||||
client,
|
||||
ecosystem,
|
||||
passkeyClient,
|
||||
storage,
|
||||
rp: {
|
||||
id: domain,
|
||||
name: domain,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const toStoreToken: AuthStoredTokenWithCookieReturnType["storedToken"] = {
|
||||
jwtToken: authToken.storedToken.jwtToken,
|
||||
authDetails: authToken.storedToken.authDetails,
|
||||
authProvider: authToken.storedToken.authProvider,
|
||||
developerClientId: authToken.storedToken.developerClientId,
|
||||
cookieString: authToken.storedToken.cookieString,
|
||||
// we should always store the jwt cookie since there's no concept of cookie in react native
|
||||
shouldStoreCookieString: true,
|
||||
isNewUser: authToken.storedToken.isNewUser,
|
||||
};
|
||||
|
||||
await postAuth({ storedToken: toStoreToken, client });
|
||||
|
||||
return authToken;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error while signing in with passkey. ${(error as Error)?.message || typeof error === "object" ? stringify(error) : error}`,
|
||||
);
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`Error signing in with passkey: ${error.message}`);
|
||||
}
|
||||
throw new Error("An unknown error occurred signing in with passkey");
|
||||
}
|
||||
}
|
||||
|
||||
private async validateOtp(
|
||||
options: MultiStepAuthArgsType & {
|
||||
client: ThirdwebClient;
|
||||
|
||||
@@ -187,6 +187,7 @@ export function inAppWallet(
|
||||
const { InAppWebConnector } = await import("./lib/web-connector.js");
|
||||
return new InAppWebConnector({
|
||||
client,
|
||||
passkeyDomain: createOptions?.auth?.passkeyDomain,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { ThirdwebClient } from "../../../../../client/client.js";
|
||||
import { webLocalStorage } from "../../../../../utils/storage/webStorage.js";
|
||||
import { ClientScopedStorage } from "../../../core/authentication/client-scoped-storage.js";
|
||||
import type {
|
||||
AuthAndWalletRpcReturnType,
|
||||
AuthLoginReturnType,
|
||||
@@ -7,7 +9,6 @@ import type {
|
||||
SendEmailOtpReturnType,
|
||||
} from "../../../core/authentication/types.js";
|
||||
import type { ClientIdWithQuerierType, Ecosystem } from "../../types.js";
|
||||
import { LocalStorage } from "../../utils/Storage/LocalStorage.js";
|
||||
import type { InAppWalletIframeCommunicator } from "../../utils/iFrameCommunication/InAppWalletIframeCommunicator.js";
|
||||
import { BaseLogin } from "./base-login.js";
|
||||
|
||||
@@ -33,7 +34,7 @@ export type AuthQuerierTypes = {
|
||||
export class Auth {
|
||||
protected client: ThirdwebClient;
|
||||
protected AuthQuerier: InAppWalletIframeCommunicator<AuthQuerierTypes>;
|
||||
protected localStorage: LocalStorage;
|
||||
protected localStorage: ClientScopedStorage;
|
||||
protected onAuthSuccess: (
|
||||
authResults: AuthAndWalletRpcReturnType,
|
||||
) => Promise<AuthLoginReturnType>;
|
||||
@@ -59,7 +60,8 @@ export class Auth {
|
||||
this.client = client;
|
||||
|
||||
this.AuthQuerier = querier;
|
||||
this.localStorage = new LocalStorage({
|
||||
this.localStorage = new ClientScopedStorage({
|
||||
storage: webLocalStorage,
|
||||
clientId: client.clientId,
|
||||
ecosystemId: ecosystem?.id,
|
||||
});
|
||||
|
||||
@@ -1,160 +1,76 @@
|
||||
import { client } from "@passwordless-id/webauthn";
|
||||
import type { AuthType } from "@passwordless-id/webauthn/dist/esm/types.js";
|
||||
import type { ThirdwebClient } from "../../../../../client/client.js";
|
||||
import { getThirdwebBaseUrl } from "../../../../../utils/domains.js";
|
||||
import { getClientFetch } from "../../../../../utils/fetch.js";
|
||||
import { webLocalStorage } from "../../../../../utils/storage/webStorage.js";
|
||||
import {
|
||||
base64ToString,
|
||||
base64UrlToBase64,
|
||||
} from "../../../../../utils/uint8-array.js";
|
||||
import type { EcosystemWalletId } from "../../../../wallet-types.js";
|
||||
import type { AuthStoredTokenWithCookieReturnType } from "../../../core/authentication/types.js";
|
||||
import type { Ecosystem } from "../../types.js";
|
||||
import { LocalStorage } from "../../utils/Storage/LocalStorage.js";
|
||||
import { ClientScopedStorage } from "../../../core/authentication/client-scoped-storage.js";
|
||||
import type {
|
||||
AuthenticateResult,
|
||||
PasskeyClient,
|
||||
RegisterResult,
|
||||
RpInfo,
|
||||
} from "../../../core/authentication/passkeys.js";
|
||||
|
||||
function getVerificationPath() {
|
||||
return `${getThirdwebBaseUrl(
|
||||
"inAppWallet",
|
||||
)}/api/2024-05-05/login/passkey/callback`;
|
||||
}
|
||||
function getChallengePath(type: "sign-in" | "sign-up", username?: string) {
|
||||
return `${getThirdwebBaseUrl(
|
||||
"inAppWallet",
|
||||
)}/api/2024-05-05/login/passkey?type=${type}${
|
||||
username ? `&username=${username}` : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
export async function registerPasskey(options: {
|
||||
client: ThirdwebClient;
|
||||
ecosystem?: Ecosystem;
|
||||
authenticatorType?: AuthType;
|
||||
username?: string;
|
||||
}): Promise<AuthStoredTokenWithCookieReturnType> {
|
||||
if (!client.isAvailable()) {
|
||||
throw new Error("Passkeys are not available on this device");
|
||||
}
|
||||
// TODO inject this
|
||||
const storage = new LocalStorage({
|
||||
clientId: options.client.clientId,
|
||||
ecosystemId: options.ecosystem?.id,
|
||||
});
|
||||
const fetchWithId = getClientFetch(options.client, options.ecosystem);
|
||||
const generatedName = options.username ?? generateUsername(options.ecosystem);
|
||||
// 1. request challenge from server
|
||||
const res = await fetchWithId(getChallengePath("sign-up", generatedName));
|
||||
const challengeData = await res.json();
|
||||
if (!challengeData.challenge) {
|
||||
throw new Error("No challenge received");
|
||||
}
|
||||
const challenge = challengeData.challenge;
|
||||
// 2. initiate registration
|
||||
const registration = await client.register(generatedName, challenge, {
|
||||
authenticatorType: options.authenticatorType ?? "auto",
|
||||
userVerification: "required",
|
||||
attestation: true,
|
||||
debug: false,
|
||||
});
|
||||
// 3. store the credentialId in local storage
|
||||
await storage.savePasskeyCredentialId(registration.credential.id);
|
||||
|
||||
const customHeaders: Record<string, string> = {};
|
||||
if (options.ecosystem?.partnerId) {
|
||||
customHeaders["x-ecosystem-partner-id"] = options.ecosystem.partnerId;
|
||||
}
|
||||
if (options.ecosystem?.id) {
|
||||
customHeaders["x-ecosystem-id"] = options.ecosystem.id;
|
||||
export class PasskeyWebClient implements PasskeyClient {
|
||||
isAvailable(): boolean {
|
||||
return client.isAvailable();
|
||||
}
|
||||
|
||||
// 4. send the registration object to the server
|
||||
const verifRes = await fetchWithId(getVerificationPath(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...customHeaders,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: "sign-up",
|
||||
async register(args: {
|
||||
name: string;
|
||||
challenge: string;
|
||||
rp: RpInfo;
|
||||
}): Promise<RegisterResult> {
|
||||
const { name, challenge, rp } = args;
|
||||
const registration = await client.register(name, challenge, {
|
||||
authenticatorType: "auto",
|
||||
userVerification: "required",
|
||||
domain: rp.id,
|
||||
attestation: true,
|
||||
debug: false,
|
||||
});
|
||||
const clientDataB64 = base64UrlToBase64(registration.clientData);
|
||||
const clientDataParsed = JSON.parse(base64ToString(clientDataB64));
|
||||
return {
|
||||
authenticatorData: registration.authenticatorData,
|
||||
credentialId: registration.credential.id,
|
||||
serverVerificationId: challengeData.serverVerificationId,
|
||||
clientData: registration.clientData,
|
||||
username: generatedName,
|
||||
credential: {
|
||||
publicKey: registration.credential.publicKey,
|
||||
algorithm: registration.credential.algorithm,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const verifData = await verifRes.json();
|
||||
|
||||
if (!verifData) {
|
||||
throw new Error("No token received");
|
||||
}
|
||||
// 5. returns back the IAW authentication token
|
||||
return verifData;
|
||||
}
|
||||
|
||||
export async function loginWithPasskey(options: {
|
||||
client: ThirdwebClient;
|
||||
ecosystem?: Ecosystem;
|
||||
authenticatorType?: AuthType;
|
||||
}): Promise<AuthStoredTokenWithCookieReturnType> {
|
||||
if (!client.isAvailable()) {
|
||||
throw new Error("Passkeys are not available on this device");
|
||||
}
|
||||
// TODO inject this
|
||||
const storage = new LocalStorage({
|
||||
clientId: options.client.clientId,
|
||||
ecosystemId: options.ecosystem?.id,
|
||||
});
|
||||
const fetchWithId = getClientFetch(options.client, options.ecosystem);
|
||||
// 1. request challenge from server/iframe
|
||||
const res = await fetchWithId(getChallengePath("sign-in"));
|
||||
const challengeData = await res.json();
|
||||
if (!challengeData.challenge) {
|
||||
throw new Error("No challenge received");
|
||||
}
|
||||
const challenge = challengeData.challenge;
|
||||
// 1.2. find the user's credentialId in local storage
|
||||
const credentialId = await storage.getPasskeyCredentialId();
|
||||
const credentials = credentialId ? [credentialId] : [];
|
||||
// 2. initiate login
|
||||
const authentication = await client.authenticate(credentials, challenge, {
|
||||
authenticatorType: options.authenticatorType ?? "auto",
|
||||
userVerification: "required",
|
||||
});
|
||||
|
||||
const customHeaders: Record<string, string> = {};
|
||||
if (options.ecosystem?.partnerId) {
|
||||
customHeaders["x-ecosystem-partner-id"] = options.ecosystem.partnerId;
|
||||
}
|
||||
if (options.ecosystem?.id) {
|
||||
customHeaders["x-ecosystem-id"] = options.ecosystem.id;
|
||||
origin: clientDataParsed.origin,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. send the authentication object to the server/iframe
|
||||
const verifRes = await fetchWithId(getVerificationPath(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...customHeaders,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: "sign-in",
|
||||
authenticatorData: authentication.authenticatorData,
|
||||
credentialId: authentication.credentialId,
|
||||
serverVerificationId: challengeData.serverVerificationId,
|
||||
clientData: authentication.clientData,
|
||||
signature: authentication.signature,
|
||||
}),
|
||||
});
|
||||
// 5. store the credentialId in local storage
|
||||
await storage.savePasskeyCredentialId(authentication.credentialId);
|
||||
|
||||
const verifData = await verifRes.json();
|
||||
|
||||
if (!verifData) {
|
||||
throw new Error("No token received");
|
||||
async authenticate(args: {
|
||||
credentialId: string | undefined;
|
||||
challenge: string;
|
||||
rp: RpInfo;
|
||||
}): Promise<AuthenticateResult> {
|
||||
const { credentialId, challenge, rp } = args;
|
||||
const result = await client.authenticate(
|
||||
credentialId ? [credentialId] : [],
|
||||
challenge,
|
||||
{
|
||||
authenticatorType: "auto",
|
||||
userVerification: "required",
|
||||
domain: rp.id,
|
||||
},
|
||||
);
|
||||
const clientDataB64 = base64UrlToBase64(result.clientData);
|
||||
const clientDataParsed = JSON.parse(base64ToString(clientDataB64));
|
||||
return {
|
||||
authenticatorData: result.authenticatorData,
|
||||
credentialId: result.credentialId,
|
||||
clientData: result.clientData,
|
||||
signature: result.signature,
|
||||
origin: clientDataParsed.origin,
|
||||
};
|
||||
}
|
||||
// 6. return the auth'd user type
|
||||
return verifData;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,14 +83,11 @@ export async function hasStoredPasskey(
|
||||
client: ThirdwebClient,
|
||||
ecosystemId?: EcosystemWalletId,
|
||||
) {
|
||||
const storage = new LocalStorage({
|
||||
const storage = new ClientScopedStorage({
|
||||
storage: webLocalStorage, // TODO (passkey) react native variant of this fn
|
||||
clientId: client.clientId,
|
||||
ecosystemId: ecosystemId,
|
||||
});
|
||||
const credId = await storage.getPasskeyCredentialId();
|
||||
return !!credId;
|
||||
}
|
||||
|
||||
function generateUsername(ecosystem?: Ecosystem) {
|
||||
return `${ecosystem?.id ?? "wallet"}-${new Date().toISOString()}`;
|
||||
}
|
||||
|
||||
@@ -8,12 +8,14 @@ import { getAddress } from "../../../../utils/address.js";
|
||||
import { getThirdwebDomains } from "../../../../utils/domains.js";
|
||||
import { type Hex, hexToString } from "../../../../utils/encoding/hex.js";
|
||||
import { parseTypedData } from "../../../../utils/signatures/helpers/parseTypedData.js";
|
||||
import { webLocalStorage } from "../../../../utils/storage/webStorage.js";
|
||||
import type { Prettify } from "../../../../utils/type-utils.js";
|
||||
import { getEcosystemPartnerPermissions } from "../../../ecosystem/get-ecosystem-partner-permissions.js";
|
||||
import type {
|
||||
Account,
|
||||
SendTransactionOption,
|
||||
} from "../../../interfaces/wallet.js";
|
||||
import { ClientScopedStorage } from "../../core/authentication/client-scoped-storage.js";
|
||||
import {
|
||||
type GetUser,
|
||||
type GetUserWalletStatusRpcReturnType,
|
||||
@@ -29,7 +31,6 @@ import type {
|
||||
SignTransactionReturnType,
|
||||
SignedTypedDataReturnType,
|
||||
} from "../types.js";
|
||||
import { LocalStorage } from "../utils/Storage/LocalStorage.js";
|
||||
import type { InAppWalletIframeCommunicator } from "../utils/iFrameCommunication/InAppWalletIframeCommunicator.js";
|
||||
|
||||
export type WalletManagementTypes = {
|
||||
@@ -82,7 +83,7 @@ export class IFrameWallet {
|
||||
protected walletManagerQuerier: InAppWalletIframeCommunicator<
|
||||
WalletManagementTypes & WalletManagementUiTypes
|
||||
>;
|
||||
protected localStorage: LocalStorage;
|
||||
protected localStorage: ClientScopedStorage;
|
||||
|
||||
/**
|
||||
* Not meant to be initialized directly. Call {@link initializeUser} to get an instance
|
||||
@@ -101,7 +102,8 @@ export class IFrameWallet {
|
||||
this.ecosystem = ecosystem;
|
||||
this.walletManagerQuerier = querier;
|
||||
|
||||
this.localStorage = new LocalStorage({
|
||||
this.localStorage = new ClientScopedStorage({
|
||||
storage: webLocalStorage,
|
||||
clientId: client.clientId,
|
||||
ecosystemId: ecosystem?.id,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { ThirdwebClient } from "../../../../client/client.js";
|
||||
import { getThirdwebBaseUrl } from "../../../../utils/domains.js";
|
||||
import { webLocalStorage } from "../../../../utils/storage/webStorage.js";
|
||||
import type { SocialAuthOption } from "../../../../wallets/types.js";
|
||||
import type { Account } from "../../../interfaces/wallet.js";
|
||||
import {
|
||||
loginWithPasskey,
|
||||
registerPasskey,
|
||||
} from "../../core/authentication/passkeys.js";
|
||||
import { siweAuthenticate } from "../../core/authentication/siwe.js";
|
||||
import {
|
||||
type AuthLoginReturnType,
|
||||
@@ -19,7 +24,6 @@ import { InAppWalletIframeCommunicator } from "../utils/iFrameCommunication/InAp
|
||||
import { Auth, type AuthQuerierTypes } from "./auth/iframe-auth.js";
|
||||
import { loginWithOauth, loginWithOauthRedirect } from "./auth/oauth.js";
|
||||
import { sendOtp, verifyOtp } from "./auth/otp.js";
|
||||
import { loginWithPasskey, registerPasskey } from "./auth/passkeys.js";
|
||||
import { IFrameWallet } from "./in-app-account.js";
|
||||
|
||||
/**
|
||||
@@ -34,6 +38,7 @@ export class InAppWebConnector implements InAppConnector {
|
||||
* Used to manage the Auth state of the user.
|
||||
*/
|
||||
auth: Auth;
|
||||
private passkeyDomain?: string;
|
||||
|
||||
private isClientIdLegacyPaper(clientId: string): boolean {
|
||||
if (clientId.indexOf("-") > 0 && clientId.length === 36) {
|
||||
@@ -51,6 +56,7 @@ export class InAppWebConnector implements InAppConnector {
|
||||
client,
|
||||
onAuthSuccess,
|
||||
ecosystem,
|
||||
passkeyDomain,
|
||||
}: InAppWalletConstructorType) {
|
||||
if (this.isClientIdLegacyPaper(client.clientId)) {
|
||||
throw new Error(
|
||||
@@ -59,6 +65,7 @@ export class InAppWebConnector implements InAppConnector {
|
||||
}
|
||||
const baseUrl = getThirdwebBaseUrl("inAppWallet");
|
||||
this.client = client;
|
||||
this.passkeyDomain = passkeyDomain;
|
||||
this.querier = new InAppWalletIframeCommunicator({
|
||||
clientId: client.clientId,
|
||||
ecosystem,
|
||||
@@ -180,20 +187,9 @@ export class InAppWebConnector implements InAppConnector {
|
||||
jwt: args.jwt,
|
||||
encryptionKey: args.encryptionKey,
|
||||
});
|
||||
case "passkey":
|
||||
if (args.type === "sign-up") {
|
||||
return registerPasskey({
|
||||
client: this.wallet.client,
|
||||
ecosystem: this.wallet.ecosystem,
|
||||
authenticatorType: args.authenticatorType,
|
||||
username: args.passkeyName,
|
||||
});
|
||||
}
|
||||
return loginWithPasskey({
|
||||
client: this.wallet.client,
|
||||
ecosystem: this.wallet.ecosystem,
|
||||
authenticatorType: args.authenticatorType,
|
||||
});
|
||||
case "passkey": {
|
||||
return this.passkeyAuth(args);
|
||||
}
|
||||
case "auth_endpoint": {
|
||||
return this.auth.authenticateWithCustomAuthEndpoint({
|
||||
payload: args.payload,
|
||||
@@ -262,20 +258,7 @@ export class InAppWebConnector implements InAppConnector {
|
||||
return this.auth.loginWithModal();
|
||||
}
|
||||
case "passkey": {
|
||||
if (args.type === "sign-up") {
|
||||
const authToken = await registerPasskey({
|
||||
client: this.wallet.client,
|
||||
ecosystem: this.wallet.ecosystem,
|
||||
authenticatorType: args.authenticatorType,
|
||||
username: args.passkeyName,
|
||||
});
|
||||
return this.loginWithAuthToken(authToken);
|
||||
}
|
||||
const authToken = await loginWithPasskey({
|
||||
client: this.wallet.client,
|
||||
ecosystem: this.wallet.ecosystem,
|
||||
authenticatorType: args.authenticatorType,
|
||||
});
|
||||
const authToken = await this.passkeyAuth(args);
|
||||
return this.loginWithAuthToken(authToken);
|
||||
}
|
||||
case "phone":
|
||||
@@ -299,6 +282,37 @@ export class InAppWebConnector implements InAppConnector {
|
||||
async logout(): Promise<LogoutReturnType> {
|
||||
return await this.auth.logout();
|
||||
}
|
||||
|
||||
private async passkeyAuth(
|
||||
args: Extract<SingleStepAuthArgsType, { strategy: "passkey" }>,
|
||||
) {
|
||||
const { PasskeyWebClient } = await import("./auth/passkeys.js");
|
||||
const passkeyClient = new PasskeyWebClient();
|
||||
const storage = webLocalStorage;
|
||||
if (args.type === "sign-up") {
|
||||
return registerPasskey({
|
||||
client: this.wallet.client,
|
||||
ecosystem: this.wallet.ecosystem,
|
||||
username: args.passkeyName,
|
||||
passkeyClient,
|
||||
storage,
|
||||
rp: {
|
||||
id: this.passkeyDomain ?? window.location.hostname,
|
||||
name: this.passkeyDomain ?? window.document.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
return loginWithPasskey({
|
||||
client: this.wallet.client,
|
||||
ecosystem: this.wallet.ecosystem,
|
||||
passkeyClient,
|
||||
storage,
|
||||
rp: {
|
||||
id: args.domain?.hostname ?? window.location.hostname,
|
||||
name: args.domain?.displayName ?? window.document.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function assertUnreachable(x: never, message?: string): never {
|
||||
|
||||
@@ -39,6 +39,11 @@ export type InAppWalletConstructorType = ClientIdConstructorType & {
|
||||
* @param ecosystem - An optional set of options to connect to an ecosystem wallet.
|
||||
*/
|
||||
ecosystem?: Ecosystem;
|
||||
|
||||
/**
|
||||
* The domain of the passkey to use for authentication
|
||||
*/
|
||||
passkeyDomain?: string;
|
||||
};
|
||||
|
||||
export type ClientIdWithQuerierType = ClientIdConstructorType & {
|
||||
|
||||
+4
-2
@@ -1,6 +1,7 @@
|
||||
import { webLocalStorage } from "../../../../../utils/storage/webStorage.js";
|
||||
import { ClientScopedStorage } from "../../../core/authentication/client-scoped-storage.js";
|
||||
import { IN_APP_WALLET_PATH } from "../../../core/constants/settings.js";
|
||||
import type { Ecosystem } from "../../types.js";
|
||||
import { LocalStorage } from "../Storage/LocalStorage.js";
|
||||
import { IframeCommunicator } from "./IframeCommunicator.js";
|
||||
|
||||
/**
|
||||
@@ -43,7 +44,8 @@ export class InAppWalletIframeCommunicator<
|
||||
* @internal
|
||||
*/
|
||||
override async onIframeLoadedInitVariables() {
|
||||
const localStorage = new LocalStorage({
|
||||
const localStorage = new ClientScopedStorage({
|
||||
storage: webLocalStorage,
|
||||
clientId: this.clientId,
|
||||
ecosystemId: this.ecosystem?.id,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { LocalStorage } from "../../in-app/web/utils/Storage/LocalStorage.js";
|
||||
import { ClientScopedStorage } from "../../in-app/core/authentication/client-scoped-storage.js";
|
||||
import * as SessionStore from "./session-store.js";
|
||||
|
||||
vi.mock("../../in-app/web/utils/Storage/LocalStorage.js");
|
||||
@@ -19,12 +19,14 @@ describe("SessionStore", () => {
|
||||
it("initializes the session store with a new LocalStorage instance if not already initialized", () => {
|
||||
expect(SessionStore.walletConnectSessions).toBeUndefined();
|
||||
SessionStore.initializeSessionStore({ clientId: "test-client-id" });
|
||||
expect(SessionStore.walletConnectSessions).toBeInstanceOf(LocalStorage);
|
||||
expect(SessionStore.walletConnectSessions).toBeInstanceOf(
|
||||
ClientScopedStorage,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not reinitialize the session store if it is already initialized", () => {
|
||||
SessionStore.setWalletConnectSessions(
|
||||
mockLocalStorage as unknown as LocalStorage,
|
||||
mockLocalStorage as unknown as ClientScopedStorage,
|
||||
);
|
||||
SessionStore.initializeSessionStore({ clientId: "test-client-id" });
|
||||
expect(SessionStore.walletConnectSessions).toBe(mockLocalStorage);
|
||||
@@ -39,7 +41,7 @@ describe("SessionStore", () => {
|
||||
|
||||
it("returns parsed sessions from storage", async () => {
|
||||
SessionStore.setWalletConnectSessions(
|
||||
mockLocalStorage as unknown as LocalStorage,
|
||||
mockLocalStorage as unknown as ClientScopedStorage,
|
||||
);
|
||||
mockLocalStorage.getWalletConnectSessions.mockResolvedValue(
|
||||
JSON.stringify([{ topic: "123" }]),
|
||||
@@ -57,7 +59,7 @@ describe("SessionStore", () => {
|
||||
|
||||
it("saves a new session to the existing sessions", async () => {
|
||||
SessionStore.setWalletConnectSessions(
|
||||
mockLocalStorage as unknown as LocalStorage,
|
||||
mockLocalStorage as unknown as ClientScopedStorage,
|
||||
);
|
||||
mockLocalStorage.getWalletConnectSessions.mockResolvedValue(
|
||||
JSON.stringify([{ topic: "123" }]),
|
||||
@@ -77,7 +79,7 @@ describe("SessionStore", () => {
|
||||
|
||||
it("removes a session from the existing sessions", async () => {
|
||||
SessionStore.setWalletConnectSessions(
|
||||
mockLocalStorage as unknown as LocalStorage,
|
||||
mockLocalStorage as unknown as ClientScopedStorage,
|
||||
);
|
||||
mockLocalStorage.getWalletConnectSessions.mockResolvedValue(
|
||||
JSON.stringify([{ topic: "123" }, { topic: "456" }]),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { LocalStorage } from "../../in-app/web/utils/Storage/LocalStorage.js";
|
||||
import { ClientScopedStorage } from "../../in-app/core/authentication/client-scoped-storage.js";
|
||||
import type { WalletConnectSession } from "./types.js";
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export let walletConnectSessions: LocalStorage | undefined;
|
||||
export let walletConnectSessions: ClientScopedStorage | undefined;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@@ -25,7 +25,8 @@ export function initializeSessionStore(options: {
|
||||
clientId: string;
|
||||
}) {
|
||||
if (!walletConnectSessions) {
|
||||
walletConnectSessions = new LocalStorage({
|
||||
walletConnectSessions = new ClientScopedStorage({
|
||||
storage: null, // TODO: inject storage
|
||||
clientId: options.clientId,
|
||||
});
|
||||
}
|
||||
@@ -73,7 +74,7 @@ export async function removeSession(
|
||||
* @internal FOR TESTING ONLY
|
||||
*/
|
||||
export function setWalletConnectSessions(
|
||||
storage: LocalStorage | undefined,
|
||||
storage: ClientScopedStorage | undefined,
|
||||
): void {
|
||||
walletConnectSessions = storage;
|
||||
}
|
||||
|
||||
Generated
+15
-1
@@ -2351,6 +2351,9 @@ importers:
|
||||
react-native-aes-gcm-crypto:
|
||||
specifier: 0.2.2
|
||||
version: 0.2.2([email protected](@babel/[email protected])(@babel/[email protected](@babel/[email protected]))(@types/[email protected])([email protected])([email protected])([email protected]))([email protected])
|
||||
react-native-passkey:
|
||||
specifier: 3.0.0-beta2
|
||||
version: 3.0.0-beta2([email protected](@babel/[email protected])(@babel/[email protected](@babel/[email protected]))(@types/[email protected])([email protected])([email protected])([email protected]))([email protected])
|
||||
react-native-quick-crypto:
|
||||
specifier: 0.7.0-rc.6
|
||||
version: 0.7.0-rc.6([email protected](@babel/[email protected])(@babel/[email protected](@babel/[email protected]))(@types/[email protected])([email protected])([email protected])([email protected]))([email protected])
|
||||
@@ -16131,6 +16134,12 @@ packages:
|
||||
react: '*'
|
||||
react-native: '>=0.71.0'
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-6i6Jy/ZqSZHvxPDrLVzPsfDVFloREZWnZFdWYygh7x6BPLKLfaryrxQAQ+0eRY4EOFwGdgxIAZcy+zv0k0UXiw==}
|
||||
peerDependencies:
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xghaXpWdB0ji8OwYyo0fWezRroNxiNFCNFpGUIyE7+qc4gA/IGWnysIG5L0MbdoORv8FkTKUvfd6yCUN5R2VFA==}
|
||||
peerDependencies:
|
||||
@@ -30229,7 +30238,7 @@ snapshots:
|
||||
pathe: 1.1.2
|
||||
picocolors: 1.0.1
|
||||
sirv: 2.0.4
|
||||
vitest: 1.6.0(@types/node@22.0.2)(@vitest/[email protected])([email protected])([email protected])
|
||||
vitest: 1.6.0(@types/node@20.14.9)(@vitest/[email protected])([email protected])([email protected])
|
||||
|
||||
'@vitest/[email protected]':
|
||||
dependencies:
|
||||
@@ -39827,6 +39836,11 @@ snapshots:
|
||||
react: 18.3.1
|
||||
react-native: 0.74.5(@babel/[email protected])(@babel/[email protected](@babel/[email protected]))(@types/[email protected])([email protected])([email protected])([email protected])
|
||||
|
||||
[email protected]([email protected](@babel/[email protected])(@babel/[email protected](@babel/[email protected]))(@types/[email protected])([email protected])([email protected])([email protected]))([email protected]):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
react-native: 0.74.5(@babel/[email protected])(@babel/[email protected](@babel/[email protected]))(@types/[email protected])([email protected])([email protected])([email protected])
|
||||
|
||||
[email protected]([email protected](@babel/[email protected])(@babel/[email protected](@babel/[email protected]))(@types/[email protected])([email protected])([email protected])([email protected]))([email protected]):
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
|
||||
Reference in New Issue
Block a user