Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { InternalAuthProvider } from '@/context/InternalAuthContext';
import { startAuthentication } from '@simplewebauthn/browser';
import React, {
createContext,
ReactNode,
Expand Down Expand Up @@ -42,6 +43,8 @@ export interface AuthContextType {
credentials: Credential[];
updateCredential: (credential: Credential) => Promise<Credential>;
deleteCredential: (credentialId: string) => Promise<void>;
login: (identifier: string, passkeyAvailable: boolean) => Promise<Response>;
handlePasskeyLogin: () => Promise<boolean>;
}

export interface Credential {
Expand Down Expand Up @@ -97,6 +100,54 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
authHost: apiHost,
});

const login = async (
identifier: string,
passkeyAvailable: boolean
): Promise<Response> => {
const response = await fetchWithAuth(`/login`, {
method: 'POST',
body: JSON.stringify({ identifier, passkeyAvailable }),
});

return response;
};

const handlePasskeyLogin = async () => {
try {
const response = await fetchWithAuth(`/webAuthn/login/start`, {
method: 'POST',
});

const options = await response.json();
const credential = await startAuthentication({ optionsJSON: options });

const verificationResponse = await fetchWithAuth(`/webAuthn/login/finish`, {
method: 'POST',
body: JSON.stringify({ assertionResponse: credential }),
});

if (!verificationResponse.ok) {
console.error('Failed to verify passkey');
}

const verificationResult = await verificationResponse.json();

if (verificationResult.message === 'Success') {
if (verificationResult.mfaLogin) {
return true;
}
await validateToken();
return false;
} else {
console.error('Passkey login failed:', verificationResult.message);
return false;
}
} catch (error) {
console.error('Passkey login error:', error);
return false;
}
};

const logout = useCallback(async () => {
if (user) {
try {
Expand Down Expand Up @@ -221,6 +272,8 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
credentials,
updateCredential,
deleteCredential,
login,
handlePasskeyLogin,
}}
>
<InternalAuthProvider value={{ validateToken, setLoading }}>
Expand Down
2 changes: 1 addition & 1 deletion src/AuthRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const AuthRoutes = () => (
<Route path="/verifyEmailOTP" element={<EmailRegistration />} />
<Route path="/verify-magiclink" element={<VerifyMagicLink />} />
<Route path="/registerPasskey" element={<PasskeyRegistration />} />
<Route path="/magic-link-sent" element={<MagicLinkSent />} />
<Route path="/magiclinks-sent" element={<MagicLinkSent />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
);
1 change: 0 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import parsePhoneNumberFromString from 'libphonenumber-js';

/**
* isValidEmail
*
Expand Down
94 changes: 20 additions & 74 deletions src/views/Login.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import { startAuthentication } from '@simplewebauthn/browser';
import { useAuth } from '@/AuthProvider';
import PhoneInputWithCountryCode from '@/components/phoneInput';
import { useInternalAuth } from '@/context/InternalAuthContext';
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';

import styles from '@/styles/login.module.css';
import { isPasskeySupported, isValidEmail, isValidPhoneNumber } from '../utils';
import { createFetchWithAuth } from '@/fetchWithAuth';
import AuthFallbackOptions from '@/components/AuthFallbackOptions';

const Login: React.FC = () => {
const navigate = useNavigate();
const { apiHost, hasSignedInBefore, mode: authMode } = useAuth();
const { validateToken } = useInternalAuth();
const {
apiHost,
hasSignedInBefore,
mode: authMode,
login,
handlePasskeyLogin,
} = useAuth();
const [identifier, setIdentifier] = useState<string>('');
const [email, setEmail] = useState<string>('');
const [mode, setMode] = useState<'login' | 'register'>('register');
Expand Down Expand Up @@ -61,73 +63,6 @@ const Login: React.FC = () => {
return isValidEmail(email) && isValidPhoneNumber(phone);
};

const handlePasskeyLogin = async () => {
try {
const response = await fetchWithAuth(`/webAuthn/login/start`, {
method: 'POST',
});

if (!response.ok) {
console.error('Something went wrong getting webauthn options');
return;
}

const options = await response.json();
const credential = await startAuthentication({ optionsJSON: options });

const verificationResponse = await fetchWithAuth(`/webAuthn/login/finish`, {
method: 'POST',
body: JSON.stringify({ assertionResponse: credential }),
});

if (!verificationResponse.ok) {
console.error('Failed to verify passkey');
}

const verificationResult = await verificationResponse.json();

if (verificationResult.message === 'Success') {
if (verificationResult.mfaLogin) {
navigate('/mfaLogin');
return;
}
await validateToken();
navigate('/');
return;
} else {
console.error('Passkey login failed:', verificationResult.message);
}
} catch (error) {
console.error('Passkey login error:', error);
}
};

const login = async () => {
setFormErrors('');

const response = await fetchWithAuth(`/login`, {
method: 'POST',
body: JSON.stringify({ identifier, passkeyAvailable }),
});

if (!response.ok) {
setFormErrors('Failed to send login link. Please try again.');
return;
}

if (!passkeyAvailable) {
setShowFallbackOptions(true);
return;
}

try {
await handlePasskeyLogin();
} catch (err) {
console.error('Passkey login failed', err);
setShowFallbackOptions(true);
}
};

const register = async () => {
setFormErrors('');

Expand Down Expand Up @@ -169,7 +104,7 @@ const Login: React.FC = () => {
return;
}

navigate('/magic-link-sent');
navigate('/magiclinks-sent');
} catch (err) {
console.error(err);
setFormErrors('Failed to send magic link.');
Expand Down Expand Up @@ -198,7 +133,18 @@ const Login: React.FC = () => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();

if (mode === 'login') login();
if (mode === 'login') {
const loginRes = await login(identifier, passkeyAvailable);

if (loginRes.ok && passkeyAvailable) {
const passkeyResult = await handlePasskeyLogin();
if (passkeyResult) {
navigate('/');
}
} else {
setShowFallbackOptions(true);
}
}
if (mode === 'register') register();
};

Expand Down
26 changes: 15 additions & 11 deletions src/views/VerifyMagicLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,21 @@ const VerifyMagicLink: React.FC = () => {

useEffect(() => {
const verify = async () => {
const response = await fetchWithAuth(`/magic-link/verify/${token}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});

if (!response.ok) {
console.error('Failed to verify token');
setError('Failed to verify token');
return;
try {
const response = await fetchWithAuth(`/magic-link/verify/${token}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});

if (!response.ok) {
console.error('Failed to verify token');
setError('Failed to verify token');
return;
}
} catch (error) {
console.error(error);
}

const channel = new BroadcastChannel('seamless-auth');
Expand Down