From 6ebfca58a7ef617d0915661171038daa0b497068 Mon Sep 17 00:00:00 2001 From: Iyk Azorji Date: Wed, 18 Dec 2024 11:17:51 -0500 Subject: [PATCH] feat: add OTP authentication capabilities to RN Signer (#1231) * feat: add OTP authentication capabilities to RN Signer * fix: update redirect route path * fix: fix linting --- account-kit/rn-signer/example/package.json | 1 + .../example/redirect-server/index.ts | 4 +- account-kit/rn-signer/example/src/App.tsx | 28 +++- .../screens/{Home.tsx => magic-link-auth.tsx} | 20 ++- .../example/src/screens/otp-auth.tsx | 150 ++++++++++++++++++ account-kit/rn-signer/src/client.ts | 26 ++- account-kit/rn-signer/src/signer.ts | 23 ++- yarn.lock | 15 ++ 8 files changed, 243 insertions(+), 24 deletions(-) rename account-kit/rn-signer/example/src/screens/{Home.tsx => magic-link-auth.tsx} (93%) create mode 100644 account-kit/rn-signer/example/src/screens/otp-auth.tsx diff --git a/account-kit/rn-signer/example/package.json b/account-kit/rn-signer/example/package.json index e5afa19f37..8bb4829484 100644 --- a/account-kit/rn-signer/example/package.json +++ b/account-kit/rn-signer/example/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@account-kit/signer": "^4.3.0", + "@react-navigation/bottom-tabs": "^7.2.0", "@react-navigation/native": "7.0.3", "@react-navigation/native-stack": "7.1.0", "dotenv": "^16.4.5", diff --git a/account-kit/rn-signer/example/redirect-server/index.ts b/account-kit/rn-signer/example/redirect-server/index.ts index b8c6756c80..7c7f2021a9 100644 --- a/account-kit/rn-signer/example/redirect-server/index.ts +++ b/account-kit/rn-signer/example/redirect-server/index.ts @@ -11,7 +11,9 @@ app.get("/", (req, res) => { const bundle = req.query.bundle; const orgId = req.query.orgId; - res.redirect(`${appScheme}://home?bundle=${bundle}&orgId=${orgId}`); + res.redirect( + `${appScheme}://magic-link-auth?bundle=${bundle}&orgId=${orgId}` + ); }); app.listen(port, () => { diff --git a/account-kit/rn-signer/example/src/App.tsx b/account-kit/rn-signer/example/src/App.tsx index ae41b10abe..4316a9b890 100644 --- a/account-kit/rn-signer/example/src/App.tsx +++ b/account-kit/rn-signer/example/src/App.tsx @@ -1,21 +1,35 @@ /* eslint-disable import/extensions */ import { createStaticNavigation } from "@react-navigation/native"; -import { createNativeStackNavigator } from "@react-navigation/native-stack"; +import { Text } from "react-native"; +import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; import { SafeAreaProvider } from "react-native-safe-area-context"; -import HomeScreen from "./screens/Home"; +import OtpAuthScreen from "./screens/otp-auth"; +import MagicLinkAuthScreen from "./screens/magic-link-auth"; const linking = { enabled: "auto" as const /* Automatically generate paths for all screens */, prefixes: ["rn-signer-demo://"], }; -const RootStack = createNativeStackNavigator({ - initialRouteName: "Home", +const RootStack = createBottomTabNavigator({ + initialRouteName: "MagicLinkAuth", screens: { - Home: { - screen: HomeScreen, - linking: { path: "home" }, + MagicLinkAuth: { + screen: MagicLinkAuthScreen, + linking: { path: "magic-link-auth" }, + options: { + tabBarLabel: "Magic Link", + tabBarIcon: () => 🪄, + }, + }, + OtpAuth: { + screen: OtpAuthScreen, + linking: { path: "otp-auth" }, + options: { + tabBarLabel: "OTP", + tabBarIcon: () => 🔑, + }, }, }, }); diff --git a/account-kit/rn-signer/example/src/screens/Home.tsx b/account-kit/rn-signer/example/src/screens/magic-link-auth.tsx similarity index 93% rename from account-kit/rn-signer/example/src/screens/Home.tsx rename to account-kit/rn-signer/example/src/screens/magic-link-auth.tsx index ccf8ef0a88..0821472b1f 100644 --- a/account-kit/rn-signer/example/src/screens/Home.tsx +++ b/account-kit/rn-signer/example/src/screens/magic-link-auth.tsx @@ -1,5 +1,4 @@ /* eslint-disable import/extensions */ -import { RNAlchemySigner } from "@account-kit/react-native-signer"; import type { User } from "@account-kit/signer"; import { useEffect, useState } from "react"; import { @@ -10,15 +9,18 @@ import { Linking, TouchableOpacity, } from "react-native"; + import Config from "react-native-config"; +import { RNAlchemySigner } from "@account-kit/react-native-signer"; -export default function HomeScreen() { - const [email, setEmail] = useState(""); - const [user, setUser] = useState(null); - const signer = new RNAlchemySigner({ +export default function MagicLinkAuthScreen() { + const signer = RNAlchemySigner({ client: { connection: { apiKey: Config.API_KEY! } }, }); + const [email, setEmail] = useState(""); + const [user, setUser] = useState(null); + const handleUserAuth = ({ bundle }: { bundle: string }) => { signer .authenticate({ @@ -73,12 +75,16 @@ export default function HomeScreen() { onChangeText={setEmail} value={email} /> - {/* TODO: implement OTP */} + { signer - .authenticate({ email, type: "email", emailMode: "magicLink" }) + .authenticate({ + email, + type: "email", + emailMode: "magicLink", + }) .catch(console.error); }} > diff --git a/account-kit/rn-signer/example/src/screens/otp-auth.tsx b/account-kit/rn-signer/example/src/screens/otp-auth.tsx new file mode 100644 index 0000000000..426abc9571 --- /dev/null +++ b/account-kit/rn-signer/example/src/screens/otp-auth.tsx @@ -0,0 +1,150 @@ +/* eslint-disable import/extensions */ +import type { User } from "@account-kit/signer"; +import { useEffect, useState } from "react"; +import { + View, + Text, + TextInput, + StyleSheet, + TouchableOpacity, +} from "react-native"; + +import Config from "react-native-config"; +import { RNAlchemySigner } from "@account-kit/react-native-signer"; +export default function MagicLinkAuthScreen() { + const [email, setEmail] = useState(""); + const [user, setUser] = useState(null); + + const signer = RNAlchemySigner({ + client: { connection: { apiKey: Config.API_KEY! } }, + }); + + const [awaitingOtp, setAwaitingOtp] = useState(false); + + const [otpCode, setOtpCode] = useState(""); + + const handleUserAuth = ({ otpCode }: { otpCode: string }) => { + setAwaitingOtp(false); + signer + .authenticate({ + otpCode: otpCode, + type: "otp", + }) + .then((res) => { + console.log("res", res); + setUser(res); + }) + .catch(console.error); + }; + + useEffect(() => { + // get the user if already logged in + signer.getAuthDetails().then(setUser); + }, []); + + return ( + + {awaitingOtp ? ( + <> + + handleUserAuth({ otpCode })} + > + Sign in + + + ) : !user ? ( + <> + + { + signer + .authenticate({ + email, + type: "email", + emailMode: "otp", + }) + .catch(console.error); + setAwaitingOtp(true); + }} + > + Sign in + + + ) : ( + <> + + Currently logged in as: {user.email} + + OrgId: {user.orgId} + Address: {user.address} + + signer.disconnect().then(() => setUser(null))} + > + Sign out + + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: "center", + justifyContent: "center", + backgroundColor: "#FFFFF", + paddingHorizontal: 20, + }, + textInput: { + width: "100%", + height: 40, + borderColor: "gray", + borderWidth: 1, + paddingHorizontal: 10, + backgroundColor: "rgba(0,0,0,0.05)", + marginTop: 20, + marginBottom: 10, + }, + box: { + width: 60, + height: 60, + marginVertical: 20, + }, + button: { + width: 200, + padding: 10, + height: 50, + backgroundColor: "rgb(147, 197, 253)", + borderRadius: 5, + alignItems: "center", + justifyContent: "center", + marginTop: 20, + }, + buttonText: { + color: "white", + fontWeight: "bold", + textAlign: "center", + }, + userText: { + marginBottom: 10, + fontSize: 18, + }, +}); diff --git a/account-kit/rn-signer/src/client.ts b/account-kit/rn-signer/src/client.ts index 2ba464ec6d..c9d2577a34 100644 --- a/account-kit/rn-signer/src/client.ts +++ b/account-kit/rn-signer/src/client.ts @@ -36,12 +36,19 @@ export class RNSignerClient extends BaseSignerClient { connection, }); } - // TODO: implement OTP + override async submitOtpCode( args: Omit ): Promise<{ bundle: string }> { - console.log("submitOtpCode", args); - throw new Error("Method not implemented."); + this.eventEmitter.emit("authenticating", { type: "otpVerify" }); + const publicKey = await this.stamper.init(); + + const { credentialBundle } = await this.request("/v1/otp", { + ...args, + targetPublicKey: publicKey, + }); + + return { bundle: credentialBundle }; } override async createAccount( @@ -57,7 +64,7 @@ export class RNSignerClient extends BaseSignerClient { const response = await this.request("/v1/signup", { email, - emailMode: "magicLink", + emailMode: params.emailMode, targetPublicKey: publicKey, expirationSeconds, redirectParams: params.redirectParams?.toString(), @@ -72,11 +79,13 @@ export class RNSignerClient extends BaseSignerClient { this.eventEmitter.emit("authenticating", { type: "email" }); let targetPublicKey = await this.stamper.init(); - return this.request("/v1/auth", { + const response = await this.request("/v1/auth", { email: params.email, - emailMode: "magicLink", + emailMode: params.emailMode, targetPublicKey, }); + + return response; } override async completeAuthWithBundle(params: { @@ -86,7 +95,10 @@ export class RNSignerClient extends BaseSignerClient { authenticatingType: AuthenticatingEventMetadata["type"]; idToken?: string; }): Promise { - if (params.authenticatingType !== "email") { + if ( + params.authenticatingType !== "email" && + params.authenticatingType !== "otp" + ) { throw new Error("Unsupported authenticating type"); } diff --git a/account-kit/rn-signer/src/signer.ts b/account-kit/rn-signer/src/signer.ts index 0fbced4403..8c6e284556 100644 --- a/account-kit/rn-signer/src/signer.ts +++ b/account-kit/rn-signer/src/signer.ts @@ -19,8 +19,14 @@ const RNAlchemySignerParamsSchema = z export type RNAlchemySignerParams = z.input; -export class RNAlchemySigner extends BaseAlchemySigner { - constructor(params: RNAlchemySignerParams) { +class RNAlchemySignerSingleton extends BaseAlchemySigner { + private static instance: BaseAlchemySigner; + + private constructor(params: RNAlchemySignerParams) { + if (!!RNAlchemySignerSingleton.instance) { + return RNAlchemySignerSingleton.instance; + } + const { sessionConfig, ...params_ } = RNAlchemySignerParamsSchema.parse(params); @@ -36,4 +42,17 @@ export class RNAlchemySigner extends BaseAlchemySigner { sessionConfig, }); } + + public static getInstance(params: RNAlchemySignerParams) { + if (!this.instance) { + this.instance = new RNAlchemySignerSingleton(params); + } + return this.instance; + } +} + +export function RNAlchemySigner(params: RNAlchemySignerParams) { + const instance = RNAlchemySignerSingleton.getInstance(params); + + return instance; } diff --git a/yarn.lock b/yarn.lock index 4456645797..97ecbfec6d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6239,6 +6239,14 @@ invariant "^2.2.4" nullthrows "^1.1.1" +"@react-navigation/bottom-tabs@^7.2.0": + version "7.2.0" + resolved "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.2.0.tgz#5b336b823226647a263b4fe743655462796b6aaf" + integrity sha512-1LxjgnbPyFINyf9Qr5d1YE0pYhuJayg5TCIIFQmbcX4PRhX7FKUXV7cX8OzrKXEdZi/UE/VNXugtozPAR9zgvA== + dependencies: + "@react-navigation/elements" "^2.2.5" + color "^4.2.3" + "@react-navigation/core@^7.0.3": version "7.0.3" resolved "https://registry.npmjs.org/@react-navigation/core/-/core-7.0.3.tgz#bac011a459ae62bb91e3bc8adeb862f05ac19a88" @@ -6266,6 +6274,13 @@ dependencies: color "^4.2.3" +"@react-navigation/elements@^2.2.5": + version "2.2.5" + resolved "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.2.5.tgz#0e2ca76e2003e96b417a3d7c2829bf1afd69193f" + integrity sha512-sDhE+W14P7MNWLMxXg1MEVXwkLUpMZJGflE6nQNzLmolJQIHgcia0Mrm8uRa3bQovhxYu1UzEojLZ+caoZt7Fg== + dependencies: + color "^4.2.3" + "@react-navigation/native-stack@7.1.0": version "7.1.0" resolved "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.1.0.tgz#7e4df1dc25daa9832f9677ca4c7c065287e5118f"