diff options
-rw-r--r-- | README.md | 52 | ||||
-rw-r--r-- | app.json | 3 | ||||
-rw-r--r-- | app/(tabs)/_layout.tsx | 81 | ||||
-rw-r--r-- | app/(tabs)/login.tsx | 16 | ||||
-rw-r--r-- | bun.lock | 8 | ||||
-rw-r--r-- | components/auth/Auth.tsx | 164 | ||||
-rw-r--r-- | lib/passkey.ts | 71 | ||||
-rw-r--r-- | lib/types.ts | 2 | ||||
-rw-r--r-- | package.json | 2 |
9 files changed, 157 insertions, 242 deletions
@@ -1,50 +1,4 @@ -# Welcome to your Expo app 👋 +# Moses Client -This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app). - -## Get started - -1. Install dependencies - - ```bash - npm install - ``` - -2. Start the app - - ```bash - npx expo start - ``` - -In the output, you'll find options to open the app in a - -- [development build](https://docs.expo.dev/develop/development-builds/introduction/) -- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/) -- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/) -- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo - -You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction). - -## Get a fresh project - -When you're ready, run: - -```bash -npm run reset-project -``` - -This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing. - -## Learn more - -To learn more about developing your project with Expo, look at the following resources: - -- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides). -- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web. - -## Join the community - -Join our community of developers creating universal apps. - -- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute. -- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions. +React Server Components on Expo: +https://docs.expo.dev/guides/server-components/ @@ -37,7 +37,8 @@ ], "experiments": { "typedRoutes": true, - "reactServerFunctions": true + "reactServerFunctions": true, + "reactServerComponentRoutes": true } } } diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index cfbc1e2..a102c45 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,45 +1,54 @@ -import { Tabs } from 'expo-router'; -import React from 'react'; -import { Platform } from 'react-native'; +import { Tabs } from "expo-router"; +import { Toaster } from "react-hot-toast"; +import React from "react"; +import { Platform } from "react-native"; -import { HapticTab } from '@/components/HapticTab'; -import { IconSymbol } from '@/components/ui/IconSymbol'; -import TabBarBackground from '@/components/ui/TabBarBackground'; -import { Colors } from '@/constants/Colors'; -import { useColorScheme } from '@/hooks/useColorScheme'; +import { HapticTab } from "@/components/HapticTab"; +import { IconSymbol } from "@/components/ui/IconSymbol"; +import TabBarBackground from "@/components/ui/TabBarBackground"; +import { Colors } from "@/constants/Colors"; +import { useColorScheme } from "@/hooks/useColorScheme"; export default function TabLayout() { const colorScheme = useColorScheme(); return ( - <Tabs - screenOptions={{ - tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint, - headerShown: false, - tabBarButton: HapticTab, - tabBarBackground: TabBarBackground, - tabBarStyle: Platform.select({ - ios: { - // Use a transparent background on iOS to show the blur effect - position: 'absolute', - }, - default: {}, - }), - }}> - <Tabs.Screen - name="index" - options={{ - title: 'Home', - tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />, + <> + <Tabs + screenOptions={{ + tabBarActiveTintColor: Colors[colorScheme ?? "light"].tint, + headerShown: false, + tabBarButton: HapticTab, + tabBarBackground: TabBarBackground, + tabBarStyle: Platform.select({ + ios: { + // Use a transparent background on iOS to show the blur effect + position: "absolute", + }, + default: {}, + }), }} - /> - <Tabs.Screen - name="explore" - options={{ - title: 'Explore', - tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />, - }} - /> - </Tabs> + > + <Tabs.Screen + name="index" + options={{ + title: "Home", + tabBarIcon: ({ color }) => ( + <IconSymbol size={28} name="house.fill" color={color} /> + ), + }} + /> + <Tabs.Screen + name="explore" + options={{ + title: "Explore", + tabBarIcon: ({ color }) => ( + <IconSymbol size={28} name="paperplane.fill" color={color} /> + ), + }} + /> + </Tabs> + <Toaster position="top-center" /> + </> ); } diff --git a/app/(tabs)/login.tsx b/app/(tabs)/login.tsx index d09e613..c5f826d 100644 --- a/app/(tabs)/login.tsx +++ b/app/(tabs)/login.tsx @@ -19,7 +19,7 @@ import { Passkee } from "@/components/auth/Auth"; // import { createPasskey, isPasskeySupported } from "../lib/passkey"; // import { navigationRef } from "../lib/navigationRef"; -const PasskeySetupScreen = () => { +const PasskeySetupScreen = async () => { const [isLoading, setIsLoading] = useState(false); // const isDarkMode = useSettingsStore((s) => s.isDarkMode); const isDarkMode = false; @@ -31,20 +31,6 @@ const PasskeySetupScreen = () => { ? require("../../assets/urbit-logo-dark.png") : require("../../assets/urbit-logo-light.png"); - const handleCreatePasskey = async () => { - console.log("creaing psskey"); - setIsLoading(true); - - try { - // const res = await startRegistration({}) - } catch (error) { - console.error("Passkey creation error:", error); - Alert.alert("Error", "An error occurred while creating the passkey."); - } finally { - setIsLoading(false); - } - }; - const handleSkip = () => { // setHasSeenPasskeyPrompt(true); // navigationRef.current?.navigate(ROUTES.LOGIN as never); @@ -17,6 +17,7 @@ "expo-image": "~2.3.2", "expo-linking": "~7.1.7", "expo-router": "~5.1.3", + "expo-secure-store": "^14.2.3", "expo-splash-screen": "~0.30.10", "expo-status-bar": "~2.2.3", "expo-symbols": "~0.4.5", @@ -24,6 +25,7 @@ "expo-web-browser": "~14.2.0", "react": "19.0.0", "react-dom": "19.0.0", + "react-hot-toast": "^2.5.2", "react-native": "0.79.5", "react-native-gesture-handler": "~2.24.0", "react-native-reanimated": "~3.17.4", @@ -881,6 +883,8 @@ "expo-router": ["expo-router@5.1.3", "", { "dependencies": { "@expo/metro-runtime": "5.0.4", "@expo/server": "^0.6.3", "@radix-ui/react-slot": "1.2.0", "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/native": "^7.1.6", "@react-navigation/native-stack": "^7.3.10", "client-only": "^0.0.1", "invariant": "^2.2.4", "react-fast-compare": "^3.2.2", "react-native-is-edge-to-edge": "^1.1.6", "schema-utils": "^4.0.1", "semver": "~7.6.3", "server-only": "^0.0.1", "shallowequal": "^1.1.0" }, "peerDependencies": { "@react-navigation/drawer": "^7.3.9", "expo": "*", "expo-constants": "*", "expo-linking": "*", "react-native-reanimated": "*", "react-native-safe-area-context": "*", "react-native-screens": "*" }, "optionalPeers": ["@react-navigation/drawer", "react-native-reanimated"] }, "sha512-zoAU0clwEj569PpGOzc06wCcxOskHLEyonJhLNPsweJgu+vE010d6XW+yr5ODR6F3ViFJpfcjbe7u3SaTjl24Q=="], + "expo-secure-store": ["expo-secure-store@14.2.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-hYBbaAD70asKTFd/eZBKVu+9RTo9OSTMMLqXtzDF8ndUGjpc6tmRCoZtrMHlUo7qLtwL5jm+vpYVBWI8hxh/1Q=="], + "expo-splash-screen": ["expo-splash-screen@0.30.10", "", { "dependencies": { "@expo/prebuild-config": "^9.0.10" }, "peerDependencies": { "expo": "*" } }, "sha512-Tt9va/sLENQDQYeOQ6cdLdGvTZ644KR3YG9aRlnpcs2/beYjOX1LHT510EGzVN9ljUTg+1ebEo5GGt2arYtPjw=="], "expo-status-bar": ["expo-status-bar@2.2.3", "", { "dependencies": { "react-native-edge-to-edge": "1.6.0", "react-native-is-edge-to-edge": "^1.1.6" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-+c8R3AESBoduunxTJ8353SqKAKpxL6DvcD8VKBuh81zzJyUUbfB4CVjr1GufSJEKsMzNPXZU+HJwXx7Xh7lx8Q=="], @@ -975,6 +979,8 @@ "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + "goober": ["goober@2.1.16", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -1407,6 +1413,8 @@ "react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="], + "react-hot-toast": ["react-hot-toast@2.5.2", "", { "dependencies": { "csstype": "^3.1.3", "goober": "^2.1.16" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-Tun3BbCxzmXXM7C+NI4qiv6lT0uwGh4oAfeJyNOjYUejTsm35mK9iCaYLGv8cBz9L5YxZLx/2ii7zsIwPtPUdw=="], + "react-is": ["react-is@19.1.0", "", {}, "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg=="], "react-native": ["react-native@0.79.5", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.79.5", "@react-native/codegen": "0.79.5", "@react-native/community-cli-plugin": "0.79.5", "@react-native/gradle-plugin": "0.79.5", "@react-native/js-polyfills": "0.79.5", "@react-native/normalize-colors": "0.79.5", "@react-native/virtualized-lists": "0.79.5", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.25.1", "base64-js": "^1.5.1", "chalk": "^4.0.0", "commander": "^12.0.0", "event-target-shim": "^5.0.1", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.82.0", "metro-source-map": "^0.82.0", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.1", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.25.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.0.0", "react": "^19.0.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-jVihwsE4mWEHZ9HkO1J2eUZSwHyDByZOqthwnGrVZCh6kTQBCm4v8dicsyDa6p0fpWNE5KicTcpX/XXl0ASJFg=="], diff --git a/components/auth/Auth.tsx b/components/auth/Auth.tsx index 40512ca..2123774 100644 --- a/components/auth/Auth.tsx +++ b/components/auth/Auth.tsx @@ -1,149 +1,41 @@ "use client"; +import { authenticateWithPasskey, createPasskey } from "@/lib/passkey"; import PrimaryButton from "../PrimaryButton"; import { SymbolView, SymbolViewProps, SymbolWeight } from "expo-symbols"; import { useState } from "react"; +import toast from "react-hot-toast"; import { Platform, StyleProp, ViewStyle } from "react-native"; -const PASSKEY_CREDENTIAL_ID_KEY = "urbit_wallet_passkey_id"; -const RELYING_PARTY_ID = "wallet.urbit.org"; // Change this to your domain -const RELYING_PARTY_NAME = "Urbit Wallet"; - export function Passkee() { const [isLoading, setIsLoading] = useState(false); - async function handleCreatePasskey() { - const pkok = - await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); - console.log({ pkok }); - - // if (Platform.OS !== "web") { - // return false; - // } - console.log("creating passkey"); - - try { - // Generate a random user ID - const userId = new Uint8Array(16); - crypto.getRandomValues(userId); - - // Generate a random challenge - const challenge = new Uint8Array(32); - crypto.getRandomValues(challenge); - - const createCredentialOptions: CredentialCreationOptions = { - publicKey: { - // REQUIRED: Random challenge to prevent replay attacks - // Must be at least 16 bytes, we use 32 for extra security - challenge, - - // REQUIRED: Relying Party (your website/app) - rp: { - // REQUIRED: Human-readable name shown in UI prompts - name: RELYING_PARTY_NAME, - - // OPTIONAL: Domain that owns this credential - // If omitted, defaults to current domain - // Must match or be a registrable suffix of the current domain - id: - window.location.hostname === "localhost" - ? "localhost" - : RELYING_PARTY_ID, - }, - - // REQUIRED: User information - user: { - // REQUIRED: Unique user ID (must not contain PII) - // Used by authenticator to distinguish between accounts - id: userId, - - // REQUIRED: Unique username/identifier for this RP - // Shown in some UI contexts, should be recognizable to user - name: "urbit-user", - - // REQUIRED: Human-readable name for display - // Shown in account selectors and prompts - displayName: "Urbit Wallet User", - }, - - // REQUIRED: List of acceptable public key algorithms - // Order matters - authenticator will use first supported algorithm - pubKeyCredParams: [ - { alg: -7, type: "public-key" }, // ES256 (ECDSA with SHA-256) - most common - { alg: -257, type: "public-key" }, // RS256 (RSASSA-PKCS1-v1_5) - fallback - ], - - // OPTIONAL: Criteria for authenticator selection - authenticatorSelection: { - // OPTIONAL: "platform" (built-in like Touch ID) or "cross-platform" (USB key) - // Omitting allows both types - // authenticatorAttachment: "platform", - - // OPTIONAL: User verification requirement - // "required" - must verify user (biometric/PIN) - // "preferred" - verify if possible, continue if not (default) - // "discouraged" - don't verify unless required by RP - userVerification: "preferred", - - // OPTIONAL: Whether to create a discoverable credential (passkey) - // "required" - must create discoverable credential - // "preferred" - create if possible (default) - // "discouraged" - don't create discoverable credential - residentKey: "preferred", - - // DEPRECATED: Use residentKey instead - // Kept for backwards compatibility with older authenticators - requireResidentKey: false, - }, - - // OPTIONAL: Time limit in milliseconds (default varies by browser) - // 60 seconds is reasonable for user interaction - timeout: 60000, - - // OPTIONAL: Attestation preference (proof of authenticator legitimacy) - // "none" - no attestation needed (default, best for privacy) - // "indirect" - RP prefers attestation but allows anonymization - // "direct" - RP needs attestation from authenticator - // "enterprise" - RP needs attestation and device info (requires permission) - attestation: "none", - - // OPTIONAL: List of credentials to exclude (prevent duplicates) - // excludeCredentials: [ - // { id: existingCredentialId, type: "public-key" } - // ], - - // OPTIONAL: Extensions for additional features - // extensions: { - // credProps: true, // Request additional credential properties - // hmacCreateSecret: true, // For symmetric key operations - // }, - }, - }; - - const credential = (await navigator.credentials.create( - createCredentialOptions, - )) as PublicKeyCredential; - console.log({ credential }); - - if (credential) { - // Store the credential ID for later use - // await SecureStore.setItemAsync(PASSKEY_CREDENTIAL_ID_KEY, credential.id); - - console.log("Passkey created successfully", credential.id); - return true; - } - - return false; - } catch (error) { - console.error("Error creating passkey:", error); - return false; - } + async function handleCreate() { + // + const ok = await createPasskey(); + if (!ok) toast.error("Error generating passkey"); + else toast.success("Passkey generated!"); + } + async function handleCheck() { + // + const ok = await authenticateWithPasskey(); + if (!ok) toast.error("Can't login"); + else toast.success("Passkey auth successful"); } + return ( - <PrimaryButton - label="Create Passkey" - onPress={handleCreatePasskey} - isLoading={isLoading} - style={{ marginBottom: 16 }} - /> + <> + <PrimaryButton + label="Create Passkey" + onPress={handleCreate} + isLoading={isLoading} + style={{ marginBottom: 16 }} + /> + <PrimaryButton + label="Check Passkey" + onPress={handleCheck} + isLoading={isLoading} + style={{ marginBottom: 16 }} + /> + </> ); } diff --git a/lib/passkey.ts b/lib/passkey.ts index 573c62b..81a21a5 100644 --- a/lib/passkey.ts +++ b/lib/passkey.ts @@ -1,8 +1,14 @@ +"use client"; + +import { Platform } from "react-native"; +import * as SecureStore from "expo-secure-store"; +import type { AsyncRes } from "./types"; + const PASSKEY_CREDENTIAL_ID_KEY = "urbit_wallet_passkey_id"; const RELYING_PARTY_ID = "wallet.urbit.org"; // Change this to your domain const RELYING_PARTY_NAME = "Urbit Wallet"; -async function createPasskey() { +export async function createPasskey(): AsyncRes<PublicKeyCredential> { const pkok = await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); console.log({ pkok }); @@ -117,15 +123,70 @@ async function createPasskey() { if (credential) { // Store the credential ID for later use - // await SecureStore.setItemAsync(PASSKEY_CREDENTIAL_ID_KEY, credential.id); + await SecureStore.setItemAsync(PASSKEY_CREDENTIAL_ID_KEY, credential.id); console.log("Passkey created successfully", credential.id); - return true; + return { ok: credential }; } - return false; + return { error: "Failed to create passkey" }; } catch (error) { console.error("Error creating passkey:", error); - return false; + return { error: `${error}` }; + } +} + +export async function authenticateWithPasskey(): AsyncRes<PublicKeyCredential> { + // if (Platform.OS !== "web") { + // return false; + // } + + try { + const credentialId = await SecureStore.getItemAsync( + PASSKEY_CREDENTIAL_ID_KEY, + ); + + if (!credentialId) { + console.log("No passkey found"); + return { error: "No passkey found" }; + } + + // Generate a random challenge + const challenge = new Uint8Array(32); + crypto.getRandomValues(challenge); + + const getCredentialOptions: CredentialRequestOptions = { + publicKey: { + challenge, + rpId: + window.location.hostname === "localhost" + ? "localhost" + : RELYING_PARTY_ID, + allowCredentials: [ + { + id: Uint8Array.from(atob(credentialId), (c) => c.charCodeAt(0)), + type: "public-key", + transports: ["internal", "hybrid"] as AuthenticatorTransport[], + }, + ], + userVerification: "preferred", + timeout: 60000, + }, + }; + + const credential = (await navigator.credentials.get( + getCredentialOptions, + )) as PublicKeyCredential; + + if (credential) { + // In a production app, you would send the assertion to your server + // to verify it. For this demo, we're just checking if we got a credential. + console.log("Passkey authentication successful"); + return { ok: credential }; + } + + return { error: "failed to generate creds" }; + } catch (error) { + return { error: `${error}` }; } } diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 0000000..a22e8be --- /dev/null +++ b/lib/types.ts @@ -0,0 +1,2 @@ +export type Result<T> = { ok: T } | { error: string }; +export type AsyncRes<T> = Promise<Result<T>>; diff --git a/package.json b/package.json index 5fed9f5..a2c0110 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "expo-image": "~2.3.2", "expo-linking": "~7.1.7", "expo-router": "~5.1.3", + "expo-secure-store": "^14.2.3", "expo-splash-screen": "~0.30.10", "expo-status-bar": "~2.2.3", "expo-symbols": "~0.4.5", @@ -31,6 +32,7 @@ "expo-web-browser": "~14.2.0", "react": "19.0.0", "react-dom": "19.0.0", + "react-hot-toast": "^2.5.2", "react-native": "0.79.5", "react-native-gesture-handler": "~2.24.0", "react-native-reanimated": "~3.17.4", |