diff --git a/packages/connect-react/src/components/append/AppendInitScreen.tsx b/packages/connect-react/src/components/append/AppendInitScreen.tsx index c53d96c35..8a6916eaf 100644 --- a/packages/connect-react/src/components/append/AppendInitScreen.tsx +++ b/packages/connect-react/src/components/append/AppendInitScreen.tsx @@ -154,7 +154,6 @@ const AppendInitScreen = () => { const handleSubmit = useCallback( async (attestationOptions: string, showErrorIfCancelled: boolean) => { - console.log('handleSubmit', attestationOptions); if (appendLoading || skipping) { return; } diff --git a/packages/connect-react/src/components/login-second-factor/InitScreen.tsx b/packages/connect-react/src/components/login-second-factor/InitScreen.tsx index 334a0f74f..1d6627d1f 100644 --- a/packages/connect-react/src/components/login-second-factor/InitScreen.tsx +++ b/packages/connect-react/src/components/login-second-factor/InitScreen.tsx @@ -113,8 +113,6 @@ const InitScreen = () => { return handleSituation(LoginSituationCode.CboApiNotAvailablePostAuthenticator); } - console.log('loginContinue', res.val); - try { await config.onComplete(res.val.session); } catch { diff --git a/packages/connect-react/src/components/passkeyList/PasskeyListScreen.tsx b/packages/connect-react/src/components/passkeyList/PasskeyListScreen.tsx index 72750265c..644198235 100644 --- a/packages/connect-react/src/components/passkeyList/PasskeyListScreen.tsx +++ b/packages/connect-react/src/components/passkeyList/PasskeyListScreen.tsx @@ -166,7 +166,6 @@ const PasskeyListScreen = () => { return handleSituation(PasskeyListSituationCode.CboApiNotAvailableDuringInitialLoad, passkeyList.val); } - console.log('passkeyList', passkeyList.val.passkeys); setPasskeyListToken(listTokenRes); setPasskeyList(passkeyList.val.passkeys); statefulLoader.current.finish(); diff --git a/playground/connect-next/app/(api)/connectToken/route.ts b/playground/connect-next/app/(api)/connectToken/route.ts new file mode 100644 index 000000000..891a0b6d4 --- /dev/null +++ b/playground/connect-next/app/(api)/connectToken/route.ts @@ -0,0 +1,25 @@ +import { NextRequest } from 'next/server'; +import { getCorbadoConnectToken, verifyAmplifyToken } from '@/lib/utils'; + +type Payload = { + idToken: string; + connectTokenType: string; +}; + +export async function POST(req: NextRequest) { + const body = (await req.json()) as Payload; + + const { idToken, connectTokenType } = body; + + const { displayName, identifier } = await verifyAmplifyToken(idToken); + + const connectToken = await getCorbadoConnectToken(connectTokenType, { + displayName: displayName, + identifier: identifier, + }); + + return new Response(JSON.stringify({ token: connectToken }), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }); +} diff --git a/playground/connect-next/app/(api)/connectTokenExternal/route.ts b/playground/connect-next/app/(api)/connectTokenExternal/route.ts new file mode 100644 index 000000000..5bb84f008 --- /dev/null +++ b/playground/connect-next/app/(api)/connectTokenExternal/route.ts @@ -0,0 +1,58 @@ +import { NextRequest } from 'next/server'; +import { getCorbadoConnectTokenExternal, verifyAmplifyTokenExternal } from '@/lib/utils'; + +type Payload = { + idToken: string; + connectTokenType: string; +}; + +export async function POST(req: NextRequest) { + const body = (await req.json()) as Payload; + + const { idToken, connectTokenType } = body; + console.log('creating connectTokenType', connectTokenType); + + try { + const { displayName, identifier } = await verifyAmplifyTokenExternal(idToken); + + const connectToken = await getCorbadoConnectTokenExternal(connectTokenType, { + displayName: displayName, + identifier: identifier, + }); + + const simulateError = process.env.SIMULATE_ERROR; + if (simulateError && displayName.endsWith('@corbado.com')) { + console.warn('Simulating error for testing purposes'); + + switch (simulateError) { + case 'error_response': + return new Response(JSON.stringify({ error: 'Simulated error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + case 'invalid_token': + return new Response(JSON.stringify({ token: 'invalid_token' }), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }); + case 'empty_token': + return new Response(JSON.stringify({ token: '' }), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }); + } + } + + return new Response(JSON.stringify({ token: connectToken }), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }); + } catch (e) { + console.error('Error verifying token or getting connect token', e); + + return new Response(JSON.stringify({ error: 'Failed to verify token or get connect token' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +} diff --git a/playground/connect-next/app/home/client.tsx b/playground/connect-next/app/(auth-required)/home/client.tsx similarity index 88% rename from playground/connect-next/app/home/client.tsx rename to playground/connect-next/app/(auth-required)/home/client.tsx index 991fa5117..17fe2dacf 100644 --- a/playground/connect-next/app/home/client.tsx +++ b/playground/connect-next/app/(auth-required)/home/client.tsx @@ -1,6 +1,7 @@ 'use client'; import { useRouter } from 'next/navigation'; +import { signOut } from 'aws-amplify/auth'; type Props = { maybeSecretCode?: string; @@ -9,6 +10,11 @@ type Props = { export default function Home({ maybeSecretCode }: Props) { const router = useRouter(); + const logout = async () => { + await signOut(); + router.push('/login'); + }; + return ( <>
@@ -27,9 +33,7 @@ export default function Home({ maybeSecretCode }: Props) { diff --git a/playground/connect-next/app/home/page.tsx b/playground/connect-next/app/(auth-required)/home/page.tsx similarity index 81% rename from playground/connect-next/app/home/page.tsx rename to playground/connect-next/app/(auth-required)/home/page.tsx index 9a28dbb27..cbf7e565d 100644 --- a/playground/connect-next/app/home/page.tsx +++ b/playground/connect-next/app/(auth-required)/home/page.tsx @@ -1,5 +1,5 @@ import { cookies } from 'next/headers'; -import Home from '@/app/home/client'; +import Home from '@/app/(auth-required)/home/client'; export default async function Page() { const cookieStore = await cookies(); diff --git a/playground/connect-next/app/(auth-required)/layout.tsx b/playground/connect-next/app/(auth-required)/layout.tsx new file mode 100644 index 000000000..0228d012a --- /dev/null +++ b/playground/connect-next/app/(auth-required)/layout.tsx @@ -0,0 +1,7 @@ +'use client'; + +import { ProtectedRoute } from '@/components/ProtectedRoute'; + +export default function AuthenticatedLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/playground/connect-next/app/(auth-required)/passkey-list/actions.ts b/playground/connect-next/app/(auth-required)/passkey-list/actions.ts new file mode 100644 index 000000000..ae9546d3e --- /dev/null +++ b/playground/connect-next/app/(auth-required)/passkey-list/actions.ts @@ -0,0 +1,17 @@ +'use server'; + +import { ConnectTokenType } from '@corbado/types'; +import { getCorbadoConnectToken, verifyAmplifyToken } from '@/lib/utils'; + +export const getCorbadoToken = async (tokenType: ConnectTokenType, idToken?: string) => { + if (!idToken) { + throw new Error('idToken is required'); + } + + const { displayName, identifier } = await verifyAmplifyToken(idToken); + + return getCorbadoConnectToken(tokenType, { + displayName: displayName, + identifier: identifier, + }); +}; diff --git a/playground/connect-next/app/passkey-list/page.tsx b/playground/connect-next/app/(auth-required)/passkey-list/page.tsx similarity index 57% rename from playground/connect-next/app/passkey-list/page.tsx rename to playground/connect-next/app/(auth-required)/passkey-list/page.tsx index 93a731dbb..03e85d21a 100644 --- a/playground/connect-next/app/passkey-list/page.tsx +++ b/playground/connect-next/app/(auth-required)/passkey-list/page.tsx @@ -1,22 +1,21 @@ 'use client'; -export const runtime = 'edge'; +import { fetchAuthSession } from 'aws-amplify/auth'; import { CorbadoConnectPasskeyList } from '@corbado/connect-react'; -import { useRouter } from 'next/navigation'; import { getCorbadoToken } from './actions'; -import { getAppendToken } from '../actions'; export default function PasskeyListPage() { - const router = useRouter(); - return (
- tokenType === 'passkey-append' ? await getAppendToken() : await getCorbadoToken(tokenType) - } + connectTokenProvider={async tokenType => { + const session = await fetchAuthSession(); + const idToken = session.tokens?.idToken?.toString(); + + return getCorbadoToken(tokenType, idToken); + }} />
diff --git a/playground/connect-next/app/(auth-required)/post-login/actions.ts b/playground/connect-next/app/(auth-required)/post-login/actions.ts new file mode 100644 index 000000000..fc26a4b99 --- /dev/null +++ b/playground/connect-next/app/(auth-required)/post-login/actions.ts @@ -0,0 +1,32 @@ +'use server'; + +import { AppendStatus, ConnectTokenType } from '@corbado/types'; +import { cookies } from 'next/headers'; +import { getCorbadoConnectToken, verifyAmplifyToken } from '@/lib/utils'; + +export const getCorbadoToken = async (idToken?: string) => { + if (!idToken) { + throw new Error('idToken is required'); + } + + const { displayName, identifier } = await verifyAmplifyToken(idToken); + + return getCorbadoConnectToken('passkey-append' as ConnectTokenType, { + displayName: displayName, + identifier: identifier, + }); +}; + +export async function postPasskeyAppend(appendStatus: AppendStatus, clientState: string) { + // update client side state + console.log(appendStatus); + if (appendStatus === 'complete' || appendStatus === 'complete-noop') { + const cookieStore = await cookies(); + cookieStore.set({ + name: 'cbo_client_state', + value: clientState, + httpOnly: true, + expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), + }); + } +} diff --git a/playground/connect-next/app/(auth-required)/post-login/page.tsx b/playground/connect-next/app/(auth-required)/post-login/page.tsx new file mode 100644 index 000000000..7e0ba3c24 --- /dev/null +++ b/playground/connect-next/app/(auth-required)/post-login/page.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { CorbadoConnectAppend } from '@corbado/connect-react'; +import { useRouter } from 'next/navigation'; +import { getCorbadoToken, postPasskeyAppend } from '@/app/(auth-required)/post-login/actions'; +import { fetchAuthSession } from 'aws-amplify/auth'; +import { AppendStatus } from '@corbado/types'; + +export default function Page() { + const router = useRouter(); + + return ( +
+
+
+ router.push('/home')} + appendTokenProvider={async () => { + const session = await fetchAuthSession(); + const idToken = session.tokens?.idToken?.toString(); + + return await getCorbadoToken(idToken); + }} + onComplete={async (appendStatus: AppendStatus, clientState: string) => { + await postPasskeyAppend(appendStatus, clientState); + router.push('/home'); + }} + /> +
+
+
+ ); +} diff --git a/playground/connect-next/app/actions.ts b/playground/connect-next/app/actions.ts deleted file mode 100644 index 7e81bf958..000000000 --- a/playground/connect-next/app/actions.ts +++ /dev/null @@ -1,52 +0,0 @@ -'use server'; - -import { cookies } from 'next/headers'; - -export async function getAppendToken() { - const cookieStore = await cookies(); - const displayName = cookieStore.get('displayName'); - if (!displayName) { - return null; - } - - const identifier = cookieStore.get('identifier'); - if (!identifier) { - return null; - } - - console.log(displayName, identifier); - - // call backend API to get token - const payload = { - type: 'passkey-append', - data: { - displayName: displayName.value, - identifier: identifier.value, - }, - }; - - const body = JSON.stringify(payload); - - const url = `${process.env.CORBADO_BACKEND_API_URL}/v2/connectTokens`; - const response = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Basic ${process.env.CORBADO_BACKEND_API_BASIC_AUTH}`, - 'Content-Type': 'application/json', - }, - cache: 'no-cache', - body: body, - }); - - const out = await response.json(); - - return out.secret; -} - -export async function hello() { - return 'Hello, World!'; -} - -export async function hello2() { - return 'Hello, World2!'; -} diff --git a/playground/connect-next/app/globals.css b/playground/connect-next/app/globals.css index 51f319668..cdd296bfd 100644 --- a/playground/connect-next/app/globals.css +++ b/playground/connect-next/app/globals.css @@ -11,3 +11,68 @@ html { font-family: Verdana, Helvetica, sans-serif; font-size: 14px; } + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + } + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/playground/connect-next/app/layout.tsx b/playground/connect-next/app/layout.tsx index 96e27fc1e..52aaa8c10 100644 --- a/playground/connect-next/app/layout.tsx +++ b/playground/connect-next/app/layout.tsx @@ -2,7 +2,7 @@ import { Inter } from 'next/font/google'; import './globals.css'; -import { CorbadoConnectProvider } from '@corbado/connect-react'; +import WrappedCorbadoConnectProvider from '@/components/ClientWrapper'; const inter = Inter({ subsets: ['latin'] }); @@ -14,13 +14,7 @@ export default function RootLayout({ return ( - - {children} - + {children} ); diff --git a/playground/connect-next/app/login/ConventionalLogin.tsx b/playground/connect-next/app/login/ConventionalLogin.tsx index 69b01c4fa..7077b14f4 100644 --- a/playground/connect-next/app/login/ConventionalLogin.tsx +++ b/playground/connect-next/app/login/ConventionalLogin.tsx @@ -1,76 +1,122 @@ -import { useState } from 'react'; +import React, { useState } from 'react'; +import PasswordForm from '@/app/login/PasswordForm'; +import { confirmSignIn, signIn } from 'aws-amplify/auth'; import { useRouter } from 'next/navigation'; -import Link from 'next/link'; -import { startConventionalLogin } from './actions'; +import ConfirmOTP from '@/components/ConfirmOTP'; +import { cookies } from 'next/headers'; +import { TOTP } from 'totp-generator'; +import { autoFillTOTP } from '@/app/login/actions'; -export type Props = { - initialEmail: string; - initialError: string; +type Props = { + initialUserProvidedIdentifier: string; }; -export default function ConventionalLogin({ initialEmail, initialError }: Props) { - const [password, setPassword] = useState(''); - const [error, setError] = useState(initialError); - const [email, setEmail] = useState(initialEmail); +enum State { + ProvidePassword, + ProvideSMSCode, + ProvideTOTPCode, +} + +export const ConventionalLogin = ({ initialUserProvidedIdentifier }: Props) => { + const [state, setState] = useState(State.ProvidePassword); const router = useRouter(); - const onSubmit = async () => { - setError(''); - const res = await startConventionalLogin(email, password); - console.log(res); + const handleConventionalLogin = async (username: string, password: string): Promise => { + try { + const result = await signIn({ + username, + password, + }); + + switch (result.nextStep.signInStep) { + case 'CONFIRM_SIGN_IN_WITH_SMS_CODE': + setState(State.ProvideSMSCode); + break; + + case 'CONFIRM_SIGN_IN_WITH_TOTP_CODE': + setState(State.ProvideTOTPCode); + break; + + case 'DONE': + router.push('/post-login'); - if (!res.success) { - setError(res.message ?? 'An unknown error occurred. Please try again later.'); + break; - return; + default: + console.error('Unexpected next step', result.nextStep); + break; + } + } catch (e) { + if (e instanceof Error) { + return e.message; + } + + return 'An error occurred'; } + }; + + const handleConfirmCode = async (code: string): Promise => { + try { + const res = await confirmSignIn({ + challengeResponse: code, + }); + + if (res.isSignedIn) { + router.push('/post-login'); + } + } catch (e) { + if (e instanceof Error) { + return e.message; + } - if (res.screen === 'MFA_SOFTWARE_TOKEN') { - router.push('/mfa-software-token'); - } else { - router.push('/post-login'); + return 'An error occurred'; } }; + let headline, sub: string; + let content: React.ReactNode; + switch (state) { + case State.ProvidePassword: + headline = 'Login'; + sub = 'Use your email to log into you Example Corp account.'; + content = ( + + ); + + break; + case State.ProvideSMSCode: + headline = 'Check your phone'; + sub = 'We have sent an SMS to your phone.'; + content = ; + + break; + case State.ProvideTOTPCode: + headline = 'Check your authenticator'; + sub = 'Please enter the code from your authenticator app.'; + content = ( + + ); + + break; + default: + throw new Error(`Invalid state: ${state}`); + } + return ( -
-
Login
- {error &&
{error}
} - setEmail(e.target.value)} - /> - setPassword(e.target.value)} - /> -
- + <> +
+

{headline}

+

{sub}

-
- - Signup for an account - -
-
+
{content}
+ ); -} +}; + +export default ConventionalLogin; diff --git a/playground/connect-next/app/login/LoginComponent.tsx b/playground/connect-next/app/login/LoginComponent.tsx index ea2b4d336..9cbf5c4d7 100644 --- a/playground/connect-next/app/login/LoginComponent.tsx +++ b/playground/connect-next/app/login/LoginComponent.tsx @@ -4,30 +4,56 @@ import { useRouter } from 'next/navigation'; import { CorbadoConnectLogin } from '@corbado/connect-react'; import { useState } from 'react'; import ConventionalLogin from '@/app/login/ConventionalLogin'; -import { postPasskeyLoginNew } from '@/app/login/actions'; +import { postPasskeyLogin } from './actions'; +import { confirmSignIn, signIn } from 'aws-amplify/auth'; export type Props = { clientState: string | undefined; }; +const decodeJwt = (token: string) => { + const [, payload] = token.split('.'); + return JSON.parse(atob(payload)); +}; + +type WithWebauthnId = { + webauthnId: string; +}; + export default function LoginComponent({ clientState }: Props) { const router = useRouter(); const [conventionalLoginVisible, setConventionalLoginVisible] = useState(false); const [email, setEmail] = useState(''); const [fallbackErrorMessage, setFallbackErrorMessage] = useState(''); - console.log('conventionalLoginVisible', conventionalLoginVisible); + const postPasskeyLoginNew = async (signedPasskeyData: string, clientState: string) => { + // decode JWT + const decoded = decodeJwt(signedPasskeyData) as WithWebauthnId; + + try { + await signIn({ + username: decoded.webauthnId, + options: { authFlowType: 'CUSTOM_WITHOUT_SRP' }, + }); + + const resultConfirm = await confirmSignIn({ + challengeResponse: signedPasskeyData, + }); + console.log('resultConfirm', resultConfirm); + + await postPasskeyLogin(clientState); + + router.push('/post-login'); + } catch (e) { + console.error(e); + } + }; return (
- {conventionalLoginVisible ? ( - - ) : null} + {conventionalLoginVisible ? : null}
{ @@ -44,9 +70,8 @@ export default function LoginComponent({ clientState }: Props) { }} onError={(error: string) => console.log('error', error)} onLoaded={(msg: string) => console.log('component has loaded: ' + msg)} - onComplete={async (signedPasskeyData: string, clientState: string) => { - await postPasskeyLoginNew(signedPasskeyData, clientState); - router.push('/post-login'); + onComplete={async (signedPasskeyData: string, newClientState: string) => { + await postPasskeyLoginNew(signedPasskeyData, newClientState); }} onSignupClick={() => router.push('/signup')} onHelpClick={() => alert('help requested')} diff --git a/playground/connect-next/app/login/PasswordForm.tsx b/playground/connect-next/app/login/PasswordForm.tsx new file mode 100644 index 000000000..0c0f65e31 --- /dev/null +++ b/playground/connect-next/app/login/PasswordForm.tsx @@ -0,0 +1,72 @@ +import { FormEvent, useState } from 'react'; + +type Props = { + initialUserProvidedIdentifier: string; + initialError?: string; + onClick: (username: string, password: string) => Promise; +}; + +export const PasswordForm = ({ onClick, initialUserProvidedIdentifier }: Props) => { + const [username, setUsername] = useState(initialUserProvidedIdentifier); + const [password, setPassword] = useState(''); + const [message, setMessage] = useState(''); + + const handleLogin = async (e: FormEvent) => { + e.preventDefault(); + setMessage('Loading...'); + const maybeError = await onClick(username, password); + if (maybeError) { + setMessage(maybeError); + } + }; + + return ( +
+
+ + setUsername(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+
{message}
+ +
+ ); +}; + +export default PasswordForm; diff --git a/playground/connect-next/app/login/actions.ts b/playground/connect-next/app/login/actions.ts index 9ccb62476..10379a604 100644 --- a/playground/connect-next/app/login/actions.ts +++ b/playground/connect-next/app/login/actions.ts @@ -1,141 +1,26 @@ 'use server'; import { cookies } from 'next/headers'; -import { - AdminGetUserCommand, - CognitoIdentityProviderClient, - InitiateAuthCommand, -} from '@aws-sdk/client-cognito-identity-provider'; -import crypto from 'crypto'; -import { TokenWrapper, verifyToken } from '@/app/utils'; +import { TOTP } from 'totp-generator'; -// Here we validate the JWT token (validation is too simple, don't use this in production) -// Then we extract the cognitoID and retrieve the user's email from the user pool -// Both values will then be set as a cookie -export async function postPasskeyLogin(session: string) { - console.log('postPasskeyLogin', session); - const tokenWrapper = JSON.parse(session) as TokenWrapper; - const decoded = await verifyToken(tokenWrapper.AccessToken); - const username = decoded.username; - - // create client that loads profile from ~/.aws/credentials or environment variables - const client = new CognitoIdentityProviderClient({ - region: process.env.AWS_REGION!, - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID!, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, - }, - }); - - const command = new AdminGetUserCommand({ - UserPoolId: process.env.AWS_COGNITO_USER_POOL_ID!, - Username: username, +export async function postPasskeyLogin(clientState: string) { + const cookieStore = await cookies(); + cookieStore.set({ + name: 'cbo_client_state', + value: clientState, + httpOnly: true, + expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), }); - - const response = await client.send(command); - - const email = response.UserAttributes?.find(attr => attr.Name === 'email')?.Value; - if (email) { - const cookieStore = await cookies(); - cookieStore.set('displayName', email); - cookieStore.set('identifier', username); - } - - return; } -export async function postPasskeyLoginNew(signedPasskeyData: string, clientState: string) { - const url = `${process.env.CORBADO_BACKEND_API_URL}/v2/passkey/postLogin`; - const body = JSON.stringify({ - signedPasskeyData: signedPasskeyData, - }); - - const response = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Basic ${process.env.CORBADO_BACKEND_API_BASIC_AUTH}`, - 'Content-Type': 'application/json', - }, - cache: 'no-cache', - body: body, - }); - - const out = await response.json(); - - await postPasskeyLogin(out.session); - - // update client side state +export async function autoFillTOTP() { const cookieStore = await cookies(); - cookieStore.set({ name: 'cbo_client_state', value: clientState, httpOnly: true }); -} - -function createSecretHash(username: string, clientId: string, clientSecret: string) { - return crypto - .createHmac('sha256', clientSecret) - .update(username + clientId) - .digest('base64'); -} - -export async function startConventionalLogin(email: string, password: string) { - try { - if (!email || !password) { - throw new Error('Email and password are required.'); - } - - const cookieStore = await cookies(); - cookieStore.set('displayName', email); - - const client = new CognitoIdentityProviderClient({ - region: process.env.AWS_REGION!, - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID!, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, - }, - }); - - const command = new InitiateAuthCommand({ - AuthFlow: 'USER_PASSWORD_AUTH', - ClientId: process.env.AWS_COGNITO_CLIENT_ID!, - AuthParameters: { - USERNAME: email, - PASSWORD: password, - SECRET_HASH: createSecretHash( - email, - process.env.AWS_COGNITO_CLIENT_ID!, - process.env.AWS_COGNITO_CLIENT_SECRET!, - ), - }, - }); - - const response = await client.send(command); - console.log(response); - - if (response.AuthenticationResult?.AccessToken) { - // no MFA has been set up yet - - const decoded = await verifyToken(response.AuthenticationResult.AccessToken); - const username = decoded.username; - if (email) { - cookieStore.set('identifier', username); - } - - return { success: true }; - } - - if (response.Session && response.ChallengeName === 'SOFTWARE_TOKEN_MFA') { - cookieStore.set('mfa_session', response.Session); - - return { success: true, screen: 'MFA_SOFTWARE_TOKEN' }; - } - - return { success: false, message: 'An error occurred. Please try again later.' }; - } catch (err) { - if (err instanceof Error) { - if (err.name === 'NotAuthorizedException') return { success: false, message: 'Incorrect username or password.' }; + const maybeSecretCode = cookieStore.get('secretCode'); + if (!maybeSecretCode) { + return; + } - return { success: false, message: err.message }; - } + const { otp } = TOTP.generate(maybeSecretCode.value); - return { success: false, message: 'An error occurred. Please try again later.' }; - } + return otp; } diff --git a/playground/connect-next/app/mfa-software-token/actions.ts b/playground/connect-next/app/mfa-software-token/actions.ts deleted file mode 100644 index 743663b5c..000000000 --- a/playground/connect-next/app/mfa-software-token/actions.ts +++ /dev/null @@ -1,91 +0,0 @@ -'use server'; - -import { cookies } from 'next/headers'; -import { - CognitoIdentityProviderClient, - RespondToAuthChallengeCommand, -} from '@aws-sdk/client-cognito-identity-provider'; -import crypto from 'crypto'; -import { verifyToken } from '@/app/utils'; -import { TOTP } from 'totp-generator'; - -function createSecretHash(username: string, clientId: string, clientSecret: string) { - return crypto - .createHmac('sha256', clientSecret) - .update(username + clientId) - .digest('base64'); -} - -export async function startMFASoftwareToken(totp: string) { - try { - const cookieStore = await cookies(); - const session = cookieStore.get('mfa_session'); - const displayName = cookieStore.get('displayName'); - - if (!totp || !session || !displayName) { - throw new Error('Missing required fields.'); - } - - const client = new CognitoIdentityProviderClient({ - region: process.env.AWS_REGION!, - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID!, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, - }, - }); - - const challengeResponseCommand = new RespondToAuthChallengeCommand({ - ClientId: process.env.AWS_COGNITO_CLIENT_ID!, - ChallengeName: 'SOFTWARE_TOKEN_MFA', - Session: session.value, - ChallengeResponses: { - USERNAME: displayName.value, - SOFTWARE_TOKEN_MFA_CODE: totp, - SECRET_HASH: createSecretHash( - displayName.value, - process.env.AWS_COGNITO_CLIENT_ID!, - process.env.AWS_COGNITO_CLIENT_SECRET!, - ), - }, - }); - - const mfaResult = await client.send(challengeResponseCommand); - console.log('MFA login complete', mfaResult); - - if (mfaResult.AuthenticationResult?.AccessToken) { - // no MFA has been set up yet - - const decoded = await verifyToken(mfaResult.AuthenticationResult.AccessToken); - if (decoded.username) { - return { success: true }; - } - - return { success: false, message: 'An error occurred. Please try again later.' }; - } - - return { success: true, screen: 'MFA_SOFTWARE_TOKEN' }; - } catch (err) { - if (err instanceof Error) { - if (err.name === 'NotAuthorizedException') return { success: false, message: 'Incorrect username or password.' }; - - return { success: false, message: err.message }; - } - - return { success: false, message: 'An error occurred. Please try again later.' }; - } -} - -export async function generateTOTP() { - const cookieStore = await cookies(); - const secretCode = cookieStore.get('secretCode'); - if (!secretCode) { - return { - success: false, - message: 'Secret code not found. Autofill only works as long as the cookie set during signup is still there.', - }; - } - - const { otp } = TOTP.generate(secretCode.value!); - - return { success: true, otp }; -} diff --git a/playground/connect-next/app/mfa-software-token/page.tsx b/playground/connect-next/app/mfa-software-token/page.tsx deleted file mode 100644 index f244a2029..000000000 --- a/playground/connect-next/app/mfa-software-token/page.tsx +++ /dev/null @@ -1,80 +0,0 @@ -'use client'; - -import { useRouter } from 'next/navigation'; -import { useState } from 'react'; -import { generateTOTP, startMFASoftwareToken } from '@/app/mfa-software-token/actions'; - -export default function LoginPage() { - const router = useRouter(); - const [conventionalLoginVisible, setConventionalLoginVisible] = useState(false); - const [totp, setTotp] = useState(''); - const [error, setError] = useState(''); - - const onSubmit = async () => { - setError(''); - const res = await startMFASoftwareToken(totp); - - if (!res.success) { - setError(res.message ?? 'An unknown error occurred. Please try again later.'); - - return; - } - - if (res.screen === 'MFA_SOFTWARE_TOKEN') { - router.push('/mfa-software-token'); - } else { - router.push('/post-login'); - } - }; - - const onAutofillTOTP = async () => { - setError(''); - const res = await generateTOTP(); - - if (!res.success) { - setError(res.message ?? 'An unknown error occurred. Please try again later.'); - - return; - } - - setTotp(res.otp ?? ''); - }; - - return ( -
-
-
-
MFA
- {error &&
{error}
} - setTotp(e.target.value)} - /> -
- -
-
- -
-
-
-
- ); -} diff --git a/playground/connect-next/app/page.tsx b/playground/connect-next/app/page.tsx index 9b0bb393e..576df74fd 100644 --- a/playground/connect-next/app/page.tsx +++ b/playground/connect-next/app/page.tsx @@ -1,8 +1,6 @@ 'use client'; -export const runtime = 'edge'; import Link from 'next/link'; -import { hello, hello2 } from '@/app/actions'; export default function Home() { return ( diff --git a/playground/connect-next/app/passkey-list-wv/actions.ts b/playground/connect-next/app/passkey-list-wv/actions.ts new file mode 100644 index 000000000..bedbc4f2a --- /dev/null +++ b/playground/connect-next/app/passkey-list-wv/actions.ts @@ -0,0 +1,20 @@ +'use server'; + +import { cookies } from 'next/headers'; +import { ConnectTokenType } from '@corbado/types'; +import { getCorbadoConnectToken, verifyAmplifyToken } from '@/lib/utils'; + +export const getCorbadoToken = async (tokenType: ConnectTokenType) => { + const cookieStore = await cookies(); + const idToken = cookieStore.get('idToken'); + if (!idToken) { + throw new Error('idToken is required'); + } + + const { displayName, identifier } = await verifyAmplifyToken(idToken.value); + + return getCorbadoConnectToken(tokenType, { + displayName: displayName, + identifier: identifier, + }); +}; diff --git a/playground/connect-next/app/passkey-list-wv/page.tsx b/playground/connect-next/app/passkey-list-wv/page.tsx new file mode 100644 index 000000000..4e6703b18 --- /dev/null +++ b/playground/connect-next/app/passkey-list-wv/page.tsx @@ -0,0 +1,15 @@ +'use client'; +import { CorbadoConnectPasskeyList } from '@corbado/connect-react'; +import { getCorbadoToken } from './actions'; + +export default function PasskeyListPage() { + return ( +
+
+
+ getCorbadoToken(tokenType)} /> +
+
+
+ ); +} diff --git a/playground/connect-next/app/passkey-list/actions.ts b/playground/connect-next/app/passkey-list/actions.ts deleted file mode 100644 index 6f333ba94..000000000 --- a/playground/connect-next/app/passkey-list/actions.ts +++ /dev/null @@ -1,38 +0,0 @@ -'use server'; - -import { cookies } from 'next/headers'; -import { ConnectTokenType } from '@corbado/types'; - -export async function getCorbadoToken(tokenType: ConnectTokenType) { - const cookieStore = await cookies(); - const identifier = cookieStore.get('identifier'); - if (!identifier) { - return null; - } - - // call backend API to get token - const payload = { - type: tokenType, - data: { - identifier: identifier.value, - }, - }; - - const body = JSON.stringify(payload); - - const url = `${process.env.CORBADO_BACKEND_API_URL}/v2/connectTokens`; - const response = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Basic ${process.env.CORBADO_BACKEND_API_BASIC_AUTH}`, - 'Content-Type': 'application/json', - }, - cache: 'no-cache', - body: body, - }); - - const out = await response.json(); - console.log(out); - - return out.secret; -} diff --git a/playground/connect-next/app/post-login-wv/actions.ts b/playground/connect-next/app/post-login-wv/actions.ts new file mode 100644 index 000000000..6285d3f9a --- /dev/null +++ b/playground/connect-next/app/post-login-wv/actions.ts @@ -0,0 +1,34 @@ +'use server'; + +import { AppendStatus, ConnectTokenType } from '@corbado/types'; +import { cookies } from 'next/headers'; +import { getCorbadoConnectToken, verifyAmplifyToken } from '@/lib/utils'; + +export const getCorbadoToken = async () => { + const cookieStore = await cookies(); + const idToken = cookieStore.get('idToken'); + if (!idToken) { + throw new Error('idToken is required'); + } + + const { displayName, identifier } = await verifyAmplifyToken(idToken.value); + + return getCorbadoConnectToken('passkey-append' as ConnectTokenType, { + displayName: displayName, + identifier: identifier, + }); +}; + +export async function postPasskeyAppend(appendStatus: AppendStatus, clientState: string) { + // update client side state + console.log(appendStatus); + if (appendStatus === 'complete' || appendStatus === 'complete-noop') { + const cookieStore = await cookies(); + cookieStore.set({ + name: 'cbo_client_state', + value: clientState, + httpOnly: true, + expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), + }); + } +} diff --git a/playground/connect-next/app/post-login-wv/page.tsx b/playground/connect-next/app/post-login-wv/page.tsx new file mode 100644 index 000000000..bbbcb7788 --- /dev/null +++ b/playground/connect-next/app/post-login-wv/page.tsx @@ -0,0 +1,27 @@ +'use client'; +import { CorbadoConnectAppend } from '@corbado/connect-react'; +import { getCorbadoToken, postPasskeyAppend } from '@/app/post-login-wv/actions'; +import { AppendStatus } from '@corbado/types'; + +export default function PostLoginPage() { + return ( +
+
+
+ { + window.location.href = `auth://callback?status=${status}`; + }} + appendTokenProvider={async () => { + return await getCorbadoToken(); + }} + onComplete={async (status: AppendStatus, clientSideState: string) => { + await postPasskeyAppend(status, clientSideState); + window.location.href = `auth://callback?status=${status}`; + }} + /> +
+
+
+ ); +} diff --git a/playground/connect-next/app/post-login/actions.ts b/playground/connect-next/app/post-login/actions.ts deleted file mode 100644 index 9a86aef84..000000000 --- a/playground/connect-next/app/post-login/actions.ts +++ /dev/null @@ -1,8 +0,0 @@ -'use server'; - -import { cookies } from 'next/headers'; - -export async function postPasskeyAppend(_: string, clientState: string) { - const cookieStore = await cookies(); - cookieStore.set({ name: 'cbo_client_state', value: clientState, httpOnly: true }); -} diff --git a/playground/connect-next/app/post-login/page.tsx b/playground/connect-next/app/post-login/page.tsx deleted file mode 100644 index 6d7e1d35a..000000000 --- a/playground/connect-next/app/post-login/page.tsx +++ /dev/null @@ -1,35 +0,0 @@ -'use client'; -import { postPasskeyAppend } from '@/app/post-login/actions'; - -export const runtime = 'edge'; - -import { CorbadoConnectAppend } from '@corbado/connect-react'; -import { useRouter } from 'next/navigation'; -import { getAppendToken } from '../actions'; - -export default function PostLoginPage() { - const router = useRouter(); - - return ( -
-
-
- router.push('/home')} - appendTokenProvider={async () => { - const t = await getAppendToken(); - console.log(t); - - return t; - }} - onComplete={async (_, clientSideState: string) => { - console.log('onComplete', clientSideState); - await postPasskeyAppend('', clientSideState); - router.push('/home'); - }} - /> -
-
-
- ); -} diff --git a/playground/connect-next/app/redirect/actions.ts b/playground/connect-next/app/redirect/actions.ts new file mode 100644 index 000000000..0c3ea693b --- /dev/null +++ b/playground/connect-next/app/redirect/actions.ts @@ -0,0 +1,12 @@ +'use server'; + +import { cookies } from 'next/headers'; + +export const setIdToken = async (token: string) => { + const cookieStore = await cookies(); + cookieStore.set({ + name: 'idToken', + value: token, + httpOnly: true, + }); +}; diff --git a/playground/connect-next/app/redirect/page.tsx b/playground/connect-next/app/redirect/page.tsx new file mode 100644 index 000000000..8e0382ac4 --- /dev/null +++ b/playground/connect-next/app/redirect/page.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import { setIdToken } from './actions'; +import { Suspense, useEffect, useState } from 'react'; + +export default function Page() { + return ( + + + + ); +} + +function Redirecting() { + const searchParams = useSearchParams(); + const token = searchParams.get('token'); + const redirectUrl = searchParams.get('redirectUrl'); + const [loading, setLoading] = useState(true); + const router = useRouter(); + + useEffect(() => { + const init = async () => { + if (!token || !redirectUrl) { + return; + } + + await setIdToken(token); + setLoading(false); + console.log('pushing redirectUrl', redirectUrl); + router.push(redirectUrl); + }; + + init(); + }, []); + + if (loading) { + return
Loading...
; + } + + return
Redirecting...
; +} diff --git a/playground/connect-next/app/signup/actions.ts b/playground/connect-next/app/signup/actions.ts index 8e9338feb..e683bedae 100644 --- a/playground/connect-next/app/signup/actions.ts +++ b/playground/connect-next/app/signup/actions.ts @@ -1,125 +1,8 @@ 'use server'; import { cookies } from 'next/headers'; -import { generateRandomString } from '@/utils/random'; -import { - AdminCreateUserCommand, - AdminInitiateAuthCommand, - AdminSetUserMFAPreferenceCommand, - AdminSetUserPasswordCommand, - AssociateSoftwareTokenCommand, - CognitoIdentityProviderClient, - VerifySoftwareTokenCommand, -} from '@aws-sdk/client-cognito-identity-provider'; -import { TOTP } from 'totp-generator'; -import CryptoJS from 'crypto-js'; -const cognitoUserPoolId = process.env.AWS_COGNITO_USER_POOL_ID!; -const cognitoClientId = process.env.AWS_COGNITO_CLIENT_ID!; -const cognitoClientSecret = process.env.AWS_COGNITO_CLIENT_SECRET!; -const awsRegion = process.env.AWS_REGION!; -const awsAccessKeyId = process.env.AWS_ACCESS_KEY_ID!; -const awsSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY!; - -export const createAccount = async (email: string, phone: string, password: string) => { - // of course this is not secure, but it's just a demo ;) - - const randomUsername = generateRandomString(10); +export const setTOTPSecretCode = async (secretCode: string) => { const cookieStore = await cookies(); - - cookieStore.set('displayName', email); - cookieStore.set('identifier', randomUsername); - - // create client that loads profile from ~/.aws/credentials or environment variables - const client = new CognitoIdentityProviderClient({ - region: awsRegion, - credentials: { - accessKeyId: awsAccessKeyId, - secretAccessKey: awsSecretAccessKey, - }, - }); - - const command = new AdminCreateUserCommand({ - UserPoolId: cognitoUserPoolId, - Username: randomUsername, - ForceAliasCreation: true, - MessageAction: 'SUPPRESS', - UserAttributes: [ - { - Name: 'email', - Value: email, - }, - { - Name: 'email_verified', - Value: 'true', - }, - { - Name: 'phone_number', - Value: phone, - }, - ], - }); - - await client.send(command); - - const passwordCommand = new AdminSetUserPasswordCommand({ - UserPoolId: cognitoUserPoolId, - Username: randomUsername, - Password: password, - Permanent: true, - }); - - await client.send(passwordCommand); - - const initiateAuthCommand = new AdminInitiateAuthCommand({ - AuthFlow: 'ADMIN_USER_PASSWORD_AUTH', - ClientId: cognitoClientId, - UserPoolId: cognitoUserPoolId, - AuthParameters: { - USERNAME: randomUsername, - PASSWORD: password, - SECRET_HASH: await createSecretHash(randomUsername, cognitoClientId, cognitoClientSecret), - }, - }); - - const initiateAuthRes = await client.send(initiateAuthCommand); - - const associateSoftwareTokenCommand = new AssociateSoftwareTokenCommand({ - Session: initiateAuthRes.Session, - AccessToken: initiateAuthRes.AuthenticationResult?.AccessToken, - }); - - const associateSoftwareTokenRes = await client.send(associateSoftwareTokenCommand); - console.log('associateSoftwareTokenRes', associateSoftwareTokenRes); - - cookieStore.set('secretCode', associateSoftwareTokenRes.SecretCode!); - - const { otp } = TOTP.generate(associateSoftwareTokenRes.SecretCode!); - console.log('otp', otp); - const verifySoftwareTokenCommand = new VerifySoftwareTokenCommand({ - Session: initiateAuthRes.Session, - AccessToken: initiateAuthRes.AuthenticationResult?.AccessToken, - UserCode: otp, - }); - - const verifySoftwareTokenRes = await client.send(verifySoftwareTokenCommand); - console.log('verifySoftwareTokenRes', verifySoftwareTokenRes); - - const setMfaPreferenceCommand = new AdminSetUserMFAPreferenceCommand({ - UserPoolId: cognitoUserPoolId, - Username: randomUsername, - SoftwareTokenMfaSettings: { - Enabled: true, - PreferredMfa: true, - }, - }); - const setMfaPreferenceCommandRes = await client.send(setMfaPreferenceCommand); - console.log('setMfaPreferenceCommandRes', setMfaPreferenceCommandRes); - - return; -}; - -const createSecretHash = async (username: string, clientId: string, clientSecret: string) => { - const hmac = CryptoJS.HmacSHA256(username + clientId, clientSecret); - return hmac.toString(CryptoJS.enc.Base64); + cookieStore.set('secretCode', secretCode); }; diff --git a/playground/connect-next/app/signup/page.tsx b/playground/connect-next/app/signup/page.tsx index 0a74d50bd..f2cf3c993 100644 --- a/playground/connect-next/app/signup/page.tsx +++ b/playground/connect-next/app/signup/page.tsx @@ -1,11 +1,12 @@ 'use client'; -export const runtime = 'edge'; +import { TOTP } from 'totp-generator'; import { useState } from 'react'; import { useRouter } from 'next/navigation'; -import { createAccount } from './actions'; -import { generateRandomString } from '@/utils/random'; +import { generateRandomString } from '@/lib/random'; +import { signUp, signIn, setUpTOTP, verifyTOTPSetup, updateMFAPreference } from 'aws-amplify/auth'; +import { setTOTPSecretCode } from '@/app/signup/actions'; export default function SignupPage() { const router = useRouter(); @@ -13,8 +14,43 @@ export default function SignupPage() { const [phone, setPhone] = useState(''); const [password, setPassword] = useState(''); - const signUp = async (email: string, phone: string, password: string) => { - await createAccount(email, phone, password); + const onClickSignUp = async (email: string, phone: string, password: string) => { + const username = generateRandomString(10); + + try { + const resSignUp = await signUp({ + username: username, + password, + options: { + userAttributes: { + email: email, + phone_number: phone, + }, + }, + }); + + console.log(resSignUp); + + const resLogin = await signIn({ username, password }); + console.log(resLogin); + + const setupRes = await setUpTOTP(); + console.log('setupRes', setupRes); + + await setTOTPSecretCode(setupRes.sharedSecret); + + const { otp } = TOTP.generate(setupRes.sharedSecret); + console.log('otp', otp); + + await verifyTOTPSetup({ code: otp }); + await updateMFAPreference({ + totp: 'PREFERRED', + }); + + router.push('/post-login'); + } catch (err) { + console.error('Error during signup:', err); + } }; return ( @@ -64,7 +100,7 @@ export default function SignupPage() { +
+ )} + {onCancel && ( +
+ +
+ )} +
+ ); +}; + +export default ConfirmOTP; diff --git a/playground/connect-next/components/ProtectedRoute.tsx b/playground/connect-next/components/ProtectedRoute.tsx new file mode 100644 index 000000000..60ce1ca08 --- /dev/null +++ b/playground/connect-next/components/ProtectedRoute.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { fetchAuthSession } from 'aws-amplify/auth'; + +const isUserSignedIn = async (): Promise => { + try { + const session = await fetchAuthSession(); + return session?.tokens?.idToken != null; + } catch { + return false; + } +}; + +export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { + const router = useRouter(); + const [loading, setLoading] = useState(true); + + useEffect(() => { + isUserSignedIn().then(isSignedIn => { + if (!isSignedIn) { + router.replace('/login'); + } else { + setLoading(false); + } + }); + }, [router]); + + if (loading) return null; // or loading spinner + + return <>{children}; +}; + +export default ProtectedRoute; diff --git a/playground/connect-next/components/ui/button.tsx b/playground/connect-next/components/ui/button.tsx new file mode 100644 index 000000000..6c51b8b01 --- /dev/null +++ b/playground/connect-next/components/ui/button.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', + destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', + outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', + secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2', + sm: 'h-8 rounded-md px-3 text-xs', + lg: 'h-10 rounded-md px-8', + icon: 'h-9 w-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button'; + return ( + + ); + }, +); +Button.displayName = 'Button'; + +export { Button, buttonVariants }; diff --git a/playground/connect-next/components/ui/input-otp.tsx b/playground/connect-next/components/ui/input-otp.tsx new file mode 100644 index 000000000..18c8fa106 --- /dev/null +++ b/playground/connect-next/components/ui/input-otp.tsx @@ -0,0 +1,73 @@ +'use client'; + +import * as React from 'react'; +import { OTPInput, OTPInputContext } from 'input-otp'; +import { Minus } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const InputOTP = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, containerClassName, ...props }, ref) => ( + + ), +); +InputOTP.displayName = 'InputOTP'; + +const InputOTPGroup = React.forwardRef, React.ComponentPropsWithoutRef<'div'>>( + ({ className, ...props }, ref) => ( +
+ ), +); +InputOTPGroup.displayName = 'InputOTPGroup'; + +const InputOTPSlot = React.forwardRef< + React.ElementRef<'div'>, + React.ComponentPropsWithoutRef<'div'> & { index: number } +>(({ index, className, ...props }, ref) => { + const inputOTPContext = React.useContext(OTPInputContext); + const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]; + + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ); +}); +InputOTPSlot.displayName = 'InputOTPSlot'; + +const InputOTPSeparator = React.forwardRef, React.ComponentPropsWithoutRef<'div'>>( + ({ ...props }, ref) => ( +
+ +
+ ), +); +InputOTPSeparator.displayName = 'InputOTPSeparator'; + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; diff --git a/playground/connect-next/lib/amplify-config.ts b/playground/connect-next/lib/amplify-config.ts new file mode 100644 index 000000000..e21887b9e --- /dev/null +++ b/playground/connect-next/lib/amplify-config.ts @@ -0,0 +1,15 @@ +import { Amplify } from 'aws-amplify'; + +export const configureAmplify = () => { + Amplify.configure( + { + Auth: { + Cognito: { + userPoolId: process.env.NEXT_PUBLIC_AWS_COGNITO_USER_POOL_ID!, + userPoolClientId: process.env.NEXT_PUBLIC_AWS_COGNITO_CLIENT_ID!, + }, + }, + }, + { ssr: true }, + ); +}; diff --git a/playground/connect-next/utils/random.ts b/playground/connect-next/lib/random.ts similarity index 100% rename from playground/connect-next/utils/random.ts rename to playground/connect-next/lib/random.ts diff --git a/playground/connect-next/lib/utils.ts b/playground/connect-next/lib/utils.ts new file mode 100644 index 000000000..98e6b4264 --- /dev/null +++ b/playground/connect-next/lib/utils.ts @@ -0,0 +1,118 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; +import { CognitoJwtVerifier } from 'aws-jwt-verify'; +import { fetchUserAttributes, getCurrentUser } from 'aws-amplify/auth'; + +type TokenData = { + displayName: string; + identifier: string; +}; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +const verifier = CognitoJwtVerifier.create({ + userPoolId: process.env.NEXT_PUBLIC_AWS_COGNITO_USER_POOL_ID!, + tokenUse: 'id', + clientId: process.env.NEXT_PUBLIC_AWS_COGNITO_CLIENT_ID!, +}); + +export const verifyAmplifyToken = async (idToken: string): Promise => { + const verifiedToken = await verifier.verify(idToken); + const displayName: string = verifiedToken.email as string; + const identifier = verifiedToken['cognito:username']; + + return { displayName, identifier }; +}; + +export const verifyAmplifyTokenExternal = async (idToken: string): Promise => { + const verifier = CognitoJwtVerifier.create({ + userPoolId: process.env.AWS_COGNITO_USER_POOL_ID_EXTERNAL!, + tokenUse: 'id', + clientId: process.env.AWS_COGNITO_CLIENT_ID_EXTERNAL!, + }); + + console.log( + 'verifying token with external verifier', + idToken, + process.env.AWS_COGNITO_USER_POOL_ID_EXTERNAL, + process.env.AWS_COGNITO_CLIENT_ID_EXTERNAL, + ); + + const verifiedToken = await verifier.verify(idToken); + const displayName: string = verifiedToken.email as string; + const identifier = verifiedToken['cognito:username']; + + return { displayName, identifier }; +}; + +export type CognitoUserInfo = { + username: string; + email: string; + phoneNumber: string; + emailVerified: boolean; +}; + +export const getCognitoUserInfo = async (): Promise => { + const user = await getCurrentUser(); + const attributes = await fetchUserAttributes(); + + return { + username: user.username, + email: attributes.email, + phoneNumber: attributes.phone_number, + emailVerified: attributes.email_verified === 'true', + } as CognitoUserInfo; +}; + +export const getCorbadoConnectToken = async (connectTokenType: string, connectTokenData: any): Promise => { + const payload = { + type: connectTokenType, + data: connectTokenData, + }; + + const body = JSON.stringify(payload); + + const url = `${process.env.CORBADO_BACKEND_API_URL}/v2/connectTokens`; + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Basic ${process.env.CORBADO_BACKEND_API_BASIC_AUTH}`, + 'Content-Type': 'application/json', + }, + cache: 'no-cache', + body: body, + }); + + const out = await response.json(); + + return out.secret; +}; + +export const getCorbadoConnectTokenExternal = async ( + connectTokenType: string, + connectTokenData: any, +): Promise => { + const payload = { + type: connectTokenType, + data: connectTokenData, + }; + + const body = JSON.stringify(payload); + + const url = `${process.env.CORBADO_BACKEND_API_URL_EXTERNAL}/v2/connectTokens`; + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Basic ${process.env.CORBADO_BACKEND_API_BASIC_AUTH_EXTERNAL}`, + 'Content-Type': 'application/json', + }, + cache: 'no-cache', + body: body, + }); + + const out = await response.json(); + + return out.secret; +}; diff --git a/playground/connect-next/package.json b/playground/connect-next/package.json index 50c01549b..bbfb481db 100644 --- a/playground/connect-next/package.json +++ b/playground/connect-next/package.json @@ -9,25 +9,35 @@ "lint": "next lint" }, "dependencies": { - "@aws-sdk/client-cognito-identity-provider": "^3.423.0", + "@aws-sdk/client-cognito-identity-provider": "^3.799.0", "@aws-sdk/credential-providers": "^3.624.0", "@corbado/connect-react": "*", + "@radix-ui/react-slot": "^1.2.0", + "aws-amplify": "^6.14.4", + "aws-jwt-verify": "^5.0.0", "aws-sdk": "^2.1646.0", "axios": "^1.7.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "crypto-js": "^4.2.0", + "input-otp": "^1.4.2", + "jose": "^6.0.10", "jsonwebtoken": "^9.0.2", "jwks-rsa": "^3.1.0", + "lucide-react": "^0.507.0", "next": "15.2.4", "react": "^18", "react-dom": "^18", + "tailwind-merge": "^3.2.0", + "tailwindcss-animate": "^1.0.7", "totp-generator": "^1.0.0" }, "devDependencies": { "@tailwindcss/forms": "^0.5.7", + "@types/crypto-js": "^4.2.2", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", - "@types/crypto-js": "^4.2.2", "postcss": "^8", "tailwindcss": "^3.4.1", "typescript": "^5" diff --git a/playground/connect-next/public/.well-known/apple-app-site-association b/playground/connect-next/public/.well-known/apple-app-site-association new file mode 100644 index 000000000..d1561dfdd --- /dev/null +++ b/playground/connect-next/public/.well-known/apple-app-site-association @@ -0,0 +1 @@ +{"appclips":{"apps":[]},"applinks":{"details":[{"appID":"T9A667JL6T.com.corbado.ios.ConnectExample","paths":["*"]}]},"webcredentials":{"apps":["T9A667JL6T.com.corbado.ios.ConnectExample"]}} diff --git a/playground/connect-next/tailwind.config.ts b/playground/connect-next/tailwind.config.ts index 4459e6386..5d8b8db3c 100644 --- a/playground/connect-next/tailwind.config.ts +++ b/playground/connect-next/tailwind.config.ts @@ -1,6 +1,7 @@ import type { Config } from 'tailwindcss'; const config: Config = { + darkMode: ['class'], content: [ './pages/**/*.{js,ts,jsx,tsx,mdx}', './components/**/*.{js,ts,jsx,tsx,mdx}', @@ -12,8 +13,55 @@ const config: Config = { 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + colors: { + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + chart: { + '1': 'hsl(var(--chart-1))', + '2': 'hsl(var(--chart-2))', + '3': 'hsl(var(--chart-3))', + '4': 'hsl(var(--chart-4))', + '5': 'hsl(var(--chart-5))', + }, + }, }, }, - plugins: [require('@tailwindcss/forms')], + plugins: [require('@tailwindcss/forms'), require('tailwindcss-animate')], }; export default config;