diff --git a/.env.example b/.env.example index 8125d31..18137f5 100644 --- a/.env.example +++ b/.env.example @@ -3,10 +3,15 @@ NEXT_PUBLIC_BASE_URL=https://api.turnkey.com NEXT_PUBLIC_ORGANIZATION_ID= NEXT_PUBLIC_RP_ID= NEXT_PUBLIC_GOOGLE_OAUTH_CLIENT_ID= +NEXT_PUBLIC_APPLE_OAUTH_CLIENT_ID= +NEXT_PUBLIC_FACEBOOK_CLIENT_ID= # Sensitive values ideally for server only ALCHEMY_API_KEY= COINGECKO_API_KEY= TURNKEY_API_PUBLIC_KEY= -TURNKEY_API_PRIVATE_KEY= \ No newline at end of file +TURNKEY_API_PRIVATE_KEY= + +# randomly generated secret +FACEBOOK_SECRET_SALT= \ No newline at end of file diff --git a/package.json b/package.json index db74f7d..2fc8191 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,9 @@ "next": "14.1.1", "next-themes": "^0.2.1", "react": "^18.3.1", + "react-apple-login": "^1.1.6", "react-dom": "^18.3.1", + "react-facebook-login": "^4.1.1", "react-hook-form": "^7.53.0", "react-jazzicon": "^1.0.4", "react-qr-code": "^2.0.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28fd57a..79e599d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,9 +92,15 @@ dependencies: react: specifier: ^18.3.1 version: 18.3.1 + react-apple-login: + specifier: ^1.1.6 + version: 1.1.6(prop-types@15.8.1)(react-dom@18.3.1)(react@18.3.1) react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + react-facebook-login: + specifier: ^4.1.1 + version: 4.1.1(react@18.3.1) react-hook-form: specifier: ^7.53.0 version: 7.53.0(react@18.3.1) @@ -8290,6 +8296,19 @@ packages: engines: {node: '>= 0.6'} dev: false + /react-apple-login@1.1.6(prop-types@15.8.1)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-ySV6ax0aB+ksA7lKzhr4MvsgjwSH068VtdHJXS+7rL380IJnNQNl14SszR31k3UqB8q8C1H1oyjJFGq4MyO6tw==} + engines: {node: '>=8', npm: '>=5'} + peerDependencies: + prop-types: ^15.5.4 + react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + dependencies: + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + /react-devtools-core@5.3.1: resolution: {integrity: sha512-7FSb9meX0btdBQLwdFOwt6bGqvRPabmVMMslv8fgoSPqXyuGpgQe36kx8gR86XPw7aV1yVouTp6fyZ0EH+NfUw==} dependencies: @@ -8310,6 +8329,14 @@ packages: scheduler: 0.23.2 dev: false + /react-facebook-login@4.1.1(react@18.3.1): + resolution: {integrity: sha512-COnHEHlYGTKipz4963safFAK9PaNTcCiXfPXMS/yxo8El+/AJL5ye8kMJf23lKSSGGPgqFQuInskIHVqGqTvSw==} + peerDependencies: + react: ^16.0.0 + dependencies: + react: 18.3.1 + dev: false + /react-hook-form@7.53.0(react@18.3.1): resolution: {integrity: sha512-M1n3HhqCww6S2hxLxciEXy2oISPnAzxY7gvwVPrtlczTM/1dDadXgUxDpHMrMTblDOcm/AXtXxHwZ3jpg1mqKQ==} engines: {node: '>=18.0.0'} diff --git a/src/app/(landing)/facebook-callback/exchange-token.tsx b/src/app/(landing)/facebook-callback/exchange-token.tsx new file mode 100644 index 0000000..a8a258a --- /dev/null +++ b/src/app/(landing)/facebook-callback/exchange-token.tsx @@ -0,0 +1,42 @@ +"use server" + +import { env } from "@/env.mjs" +import { siteConfig } from "@/config/site" + +export async function exchangeToken(code: string, codeVerifier: string) { + const url = "https://graph.facebook.com/v21.0/oauth/access_token" + + const clientID = env.NEXT_PUBLIC_FACEBOOK_CLIENT_ID + const redirectURI = `${siteConfig.url.base}/facebook-callback` + + const params = new URLSearchParams({ + client_id: clientID, + redirect_uri: redirectURI, + code: code, + code_verifier: codeVerifier, + }) + + try { + const target = `${url}?${params.toString()}` + + const response = await fetch(target, { + method: "GET", + }) + + if (!response.ok) { + throw new Error(`Token exchange failed: ${response.statusText}`) + } + + const data = await response.json() + + // Extract id_token from the response + const idToken = data.id_token + if (!idToken) { + throw new Error("id_token not found in response") + } + + return idToken + } catch (error) { + throw error + } +} diff --git a/src/app/(landing)/facebook-callback/page.tsx b/src/app/(landing)/facebook-callback/page.tsx new file mode 100644 index 0000000..1d20de0 --- /dev/null +++ b/src/app/(landing)/facebook-callback/page.tsx @@ -0,0 +1,102 @@ +"use client" + +import { Suspense, useEffect, useState } from "react" +import { useSearchParams } from "next/navigation" +import { useAuth } from "@/providers/auth-provider" +import { useTurnkey } from "@turnkey/sdk-react" +import { Loader } from "lucide-react" + +import { Card, CardHeader, CardTitle } from "@/components/ui/card" +import { Icons } from "@/components/icons" + +import { verifierSegmentToChallenge } from "../../../lib/facebook-utils" +import { exchangeToken } from "./exchange-token" + +function FacebookProcessCallback() { + const searchParams = useSearchParams() + + const { authIframeClient } = useTurnkey() + const { loginWithFacebook } = useAuth() + + const [storedCode, setStoredCode] = useState(null) + const [storedState, setStoredState] = useState(null) + const [hasLoggedIn, setHasLoggedIn] = useState(false) + + const getToken = async () => { + const verifier = await verifierSegmentToChallenge(storedState || "") + const token = await exchangeToken(storedCode || "", verifier) + + return token + } + + // Get token from query string params and store in state when available + useEffect(() => { + const code = searchParams.get("code") + const state = searchParams.get("state") + if (code) { + setStoredCode(code) + } + if (state) { + setStoredState(state) + } + }, [searchParams]) + + // Trigger loginWithOAuth when both token and iframePublicKey are available, but only once + useEffect(() => { + const handleTokenExchange = async () => { + try { + // Get the token asynchronously + const token = await getToken() + + // Perform the Facebook login with the token + loginWithFacebook(token) + + // Set flag to prevent further calls + setHasLoggedIn(true) + } catch (error) { + console.error("Error during token exchange:", error) + } + } + + if ( + storedCode && + storedState && + authIframeClient?.iframePublicKey && + !hasLoggedIn + ) { + // Call the async handler to exchange the token + handleTokenExchange() + } + }, [ + storedCode, + storedState, + authIframeClient?.iframePublicKey, + hasLoggedIn, + loginWithFacebook, + getToken, + ]) + + return ( +
+ + + + +
+ + Redirecting... +
+
+
+
+
+ ) +} + +export default function Facebook() { + return ( + Loading...}> + + + ) +} diff --git a/src/app/(landing)/oauth-callback/page.tsx b/src/app/(landing)/oauth-callback/page.tsx new file mode 100644 index 0000000..67441e9 --- /dev/null +++ b/src/app/(landing)/oauth-callback/page.tsx @@ -0,0 +1,72 @@ +"use client" + +import { Suspense, useEffect, useState } from "react" +import { useSearchParams } from "next/navigation" +import { useAuth } from "@/providers/auth-provider" +import { useTurnkey } from "@turnkey/sdk-react" +import { Loader } from "lucide-react" + +import { Card, CardHeader, CardTitle } from "@/components/ui/card" +import { Icons } from "@/components/icons" + +function OAuthProcessCallback() { + const searchParams = useSearchParams() + + const { authIframeClient } = useTurnkey() + const { loginWithApple } = useAuth() + + const [storedToken, setStoredToken] = useState(null) // Store the token locally + const [hasLoggedIn, setHasLoggedIn] = useState(false) // Track if loginWithOAuth has been called + + // Get token from query string params and store in state when available + useEffect(() => { + const fragment = window.location.hash + if (fragment) { + const params = new URLSearchParams(fragment.slice(1)) // Remove the "#" and parse parameters + const token = params.get("id_token") + if (token) { + setStoredToken(token) // Store token if available + } + } + }, [searchParams]) + + // Trigger loginWithOAuth when both token and iframePublicKey are available, but only once + useEffect(() => { + if (storedToken && authIframeClient?.iframePublicKey && !hasLoggedIn) { + // Call the OAuth login function with the stored token + loginWithApple(storedToken) + + // Set flag to prevent further calls + setHasLoggedIn(true) + } + }, [ + storedToken, + authIframeClient?.iframePublicKey, + hasLoggedIn, + loginWithApple, + ]) + + return ( +
+ + + + +
+ + Redirecting... +
+
+
+
+
+ ) +} + +export default function OAuth() { + return ( + Loading...}> + + + ) +} diff --git a/src/components/apple-auth.tsx b/src/components/apple-auth.tsx new file mode 100644 index 0000000..6f2e7c4 --- /dev/null +++ b/src/components/apple-auth.tsx @@ -0,0 +1,56 @@ +"use client" + +import { useEffect, useState } from "react" +import { useTurnkey } from "@turnkey/sdk-react" +import AppleLogin from "react-apple-login" +import { sha256 } from "viem" + +import { env } from "@/env.mjs" +import { siteConfig } from "@/config/site" + +import { Skeleton } from "./ui/skeleton" + +const AppleAuth = () => { + const clientId = env.NEXT_PUBLIC_APPLE_OAUTH_CLIENT_ID + const redirectURI = `${siteConfig.url.base}/oauth-callback` + + const { authIframeClient } = useTurnkey() + + const [nonce, setNonce] = useState("") + + // Generate nonce based on iframePublicKey + useEffect(() => { + if (authIframeClient?.iframePublicKey) { + const hashedPublicKey = sha256( + authIframeClient.iframePublicKey as `0x${string}` + ).replace(/^0x/, "") + + setNonce(hashedPublicKey) + } + }, [authIframeClient?.iframePublicKey]) + + return ( + <> + {nonce ? ( +
+ +
+ ) : ( + + )} + + ) +} + +export default AppleAuth diff --git a/src/components/auth.tsx b/src/components/auth.tsx index 75cea4e..aed23ba 100644 --- a/src/components/auth.tsx +++ b/src/components/auth.tsx @@ -22,6 +22,8 @@ import { import { Input } from "@/components/ui/input" import { Separator } from "@/components/ui/separator" +import AppleAuth from "./apple-auth" +import FacebookAuth from "./facebook-auth" import GoogleAuth from "./google-auth" import { Icons } from "./icons" import Legal from "./legal" @@ -144,6 +146,8 @@ export default function Auth() { + + diff --git a/src/components/facebook-auth.tsx b/src/components/facebook-auth.tsx new file mode 100644 index 0000000..e37c9eb --- /dev/null +++ b/src/components/facebook-auth.tsx @@ -0,0 +1,67 @@ +"use client" + +import { useEffect, useState } from "react" +import { useTurnkey } from "@turnkey/sdk-react" +import { sha256 } from "viem" + +import { env } from "@/env.mjs" +import { siteConfig } from "@/config/site" + +import { generateChallengePair } from "../lib/facebook-utils" +import { Skeleton } from "./ui/skeleton" + +const FacebookAuth = () => { + const { authIframeClient } = useTurnkey() + + const [nonce, setNonce] = useState("") + + const redirectURI = `${siteConfig.url.base}/facebook-callback` + + const clientID = env.NEXT_PUBLIC_FACEBOOK_CLIENT_ID + + // Generate nonce based on iframePublicKey + useEffect(() => { + if (authIframeClient?.iframePublicKey) { + const hashedPublicKey = sha256( + authIframeClient.iframePublicKey as `0x${string}` + ).replace(/^0x/, "") + + setNonce(hashedPublicKey) + } + }, [authIframeClient?.iframePublicKey]) + + const redirectToFacebook = async () => { + const { verifier, codeChallenge } = await generateChallengePair() + + const codeChallengeMethod = "sha256" + + // Generate the Facebook OAuth URL server-side + const params = new URLSearchParams({ + client_id: clientID, + redirect_uri: redirectURI, + state: verifier, + code_challenge: codeChallenge, + code_challenge_method: codeChallengeMethod, + nonce: nonce, + scope: "openid", + response_type: "code", + } as any) + + const facebookOAuthURL = `https://www.facebook.com/v11.0/dialog/oauth?${params.toString()}` + window.location.href = facebookOAuthURL + } + + return ( + <> + {nonce ? ( +
+ +
+ ) : ( + + )} + + ) +} + +export default FacebookAuth diff --git a/src/env.mjs b/src/env.mjs index 09417c0..d205353 100644 --- a/src/env.mjs +++ b/src/env.mjs @@ -5,17 +5,20 @@ import { z } from "zod" export const env = createEnv({ client: { NEXT_PUBLIC_GOOGLE_OAUTH_CLIENT_ID: z.string().min(1), + NEXT_PUBLIC_APPLE_OAUTH_CLIENT_ID: z.string().min(1), NEXT_PUBLIC_RP_ID: z.string().optional(), NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL: z.string().optional(), NEXT_PUBLIC_APP_URL: z.string().optional(), NEXT_PUBLIC_BASE_URL: z.string().min(1), NEXT_PUBLIC_ORGANIZATION_ID: z.string().min(1), NEXT_PUBLIC_ALCHEMY_API_KEY: z.string().min(1), + NEXT_PUBLIC_FACEBOOK_CLIENT_ID: z.string().min(1), }, server: { NEXT_PUBLIC_RP_ID: z.string().optional(), NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL: z.string().optional(), NEXT_PUBLIC_APP_URL: z.string().optional(), + FACEBOOK_SECRET_SALT: z.string().min(1), TURNKEY_API_PUBLIC_KEY: z.string().min(1), TURNKEY_API_PRIVATE_KEY: z.string().min(1), NEXT_PUBLIC_BASE_URL: z.string().min(1), @@ -26,8 +29,12 @@ export const env = createEnv({ TURNKEY_WARCHEST_API_PRIVATE_KEY: z.string().min(1), TURNKEY_WARCHEST_ORGANIZATION_ID: z.string().min(1), WARCHEST_PRIVATE_KEY_ID: z.string().min(1), + NEXT_PUBLIC_FACEBOOK_CLIENT_ID: z.string().min(1), }, runtimeEnv: { + NEXT_PUBLIC_APPLE_OAUTH_CLIENT_ID: + process.env.NEXT_PUBLIC_APPLE_OAUTH_CLIENT_ID, + NEXT_PUBLIC_FACEBOOK_CLIENT_ID: process.env.NEXT_PUBLIC_FACEBOOK_CLIENT_ID, NEXT_PUBLIC_GOOGLE_OAUTH_CLIENT_ID: process.env.NEXT_PUBLIC_GOOGLE_OAUTH_CLIENT_ID, NEXT_PUBLIC_RP_ID: process.env.NEXT_PUBLIC_RP_ID, @@ -35,6 +42,7 @@ export const env = createEnv({ process.env.NEXT_PUBLIC_VERCEL_URL, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL, + FACEBOOK_SECRET_SALT: process.env.FACEBOOK_SECRET_SALT, TURNKEY_API_PUBLIC_KEY: process.env.TURNKEY_API_PUBLIC_KEY, TURNKEY_API_PRIVATE_KEY: process.env.TURNKEY_API_PRIVATE_KEY, NEXT_PUBLIC_ORGANIZATION_ID: process.env.NEXT_PUBLIC_ORGANIZATION_ID, diff --git a/src/lib/facebook-utils.ts b/src/lib/facebook-utils.ts new file mode 100644 index 0000000..6e3edf4 --- /dev/null +++ b/src/lib/facebook-utils.ts @@ -0,0 +1,28 @@ +"use server" + +import crypto from "crypto" + +import { env } from "@/env.mjs" + +export const generateChallengePair = async (): Promise<{ + verifier: string + codeChallenge: string +}> => { + // Step 1: Generate a random 48-character verifier + const verifier = crypto.randomBytes(32).toString("base64url") // URL-safe Base64 string + + const codeChallenge = await verifierSegmentToChallenge(verifier) + + // Return both the verifier and the codeChallenge to the client + return { verifier, codeChallenge } +} + +export const verifierSegmentToChallenge = async ( + segment: string +): Promise => { + const salt = env.FACEBOOK_SECRET_SALT + const saltedVerifier = segment + salt + + // Step 3: Hash the salted verifier using SHA-256 + return crypto.createHash("sha256").update(saltedVerifier).digest("base64url") +} diff --git a/src/providers/auth-provider.tsx b/src/providers/auth-provider.tsx index 3ae9102..5f46121 100644 --- a/src/providers/auth-provider.tsx +++ b/src/providers/auth-provider.tsx @@ -98,6 +98,7 @@ const AuthContext = createContext<{ loginWithOAuth: (credential: string, providerName: string) => Promise loginWithGoogle: (credential: string) => Promise loginWithApple: (credential: string) => Promise + loginWithFacebook: (credential: string) => Promise logout: () => Promise }>({ state: initialState, @@ -107,6 +108,7 @@ const AuthContext = createContext<{ loginWithOAuth: async () => {}, loginWithGoogle: async () => {}, loginWithApple: async () => {}, + loginWithFacebook: async () => {}, logout: async () => {}, }) @@ -301,6 +303,10 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { await loginWithOAuth(credential, "Apple Auth - Embedded Wallet") } + const loginWithFacebook = async (credential: string) => { + await loginWithOAuth(credential, "Facebook Auth - Embedded Wallet") + } + const logout = async () => { await turnkey?.logoutUser() googleLogout() @@ -317,6 +323,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { loginWithOAuth, loginWithGoogle, loginWithApple, + loginWithFacebook, logout, }} >