Skip to content

Commit 79f637f

Browse files
authored
Merge pull request #7 from fells-code/dev
Credential improvements, passkey friendly names, and registering passkey flow
2 parents a516059 + 2eb50ae commit 79f637f

14 files changed

Lines changed: 1291 additions & 1700 deletions

jest.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export default {
1818
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'],
1919
coverageThreshold: {
2020
global: {
21-
branches: 65,
21+
branches: 60,
2222
functions: 80,
2323
lines: 80,
2424
statements: 80,

package-lock.json

Lines changed: 716 additions & 1541 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@seamless-auth/react",
3-
"version": "0.0.4-beta.17",
3+
"version": "0.0.6-beta.4",
44
"description": "A drop-in authentication solution for modern React applications.",
55
"type": "module",
66
"exports": {

src/AuthProvider.tsx

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,12 @@ import React, {
1111
import { AuthMode, createFetchWithAuth } from './fetchWithAuth';
1212
import LoadingSpinner from './LoadingSpinner';
1313
import { usePreviousSignIn } from './hooks/usePreviousSignIn';
14+
import {
15+
AuthenticatorTransportFuture,
16+
CredentialDeviceType,
17+
} from '@simplewebauthn/browser';
1418

15-
interface User {
19+
export interface User {
1620
id: string;
1721
email: string;
1822
phone: string;
@@ -35,6 +39,22 @@ export interface AuthContextType {
3539
markSignedIn: () => void;
3640
hasSignedInBefore: boolean;
3741
mode: AuthMode;
42+
credentials: Credential[];
43+
updateCredential: (credential: Credential) => Promise<Credential>;
44+
deleteCredential: (credentialId: string) => Promise<void>;
45+
}
46+
47+
export interface Credential {
48+
id: string;
49+
counter: number;
50+
transports?: AuthenticatorTransportFuture[];
51+
deviceType: CredentialDeviceType;
52+
backedup: boolean;
53+
friendlyName: string | null;
54+
lastUsedAt: Date | null;
55+
platform: string | null;
56+
browser: string | null;
57+
deviceInfo: string | null;
3858
}
3959

4060
const AuthContext = createContext<AuthContextType | undefined>(undefined);
@@ -65,6 +85,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
6585
mode = 'web',
6686
}) => {
6787
const [user, setUser] = useState<User | null>(null);
88+
const [credentials, setCredentials] = useState<Credential[]>([]);
6889
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
6990
const [loading, setLoading] = useState<boolean>(true);
7091
const [token, setToken] = useState<AuthToken | null>(null);
@@ -121,8 +142,10 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
121142
});
122143

123144
if (response.ok) {
124-
const { user } = await response.json();
145+
const { user, credentials } = await response.json();
125146
setUser(user);
147+
setCredentials(credentials);
148+
126149
setIsAuthenticated(true);
127150
} else {
128151
logout();
@@ -134,6 +157,36 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
134157
}
135158
};
136159

160+
const updateCredential = async (credential: Credential) => {
161+
const response = await fetchWithAuth(`users/credentials`, {
162+
method: 'POST',
163+
credentials: 'include',
164+
headers: { 'Content-Type': 'application/json' },
165+
body: JSON.stringify({ friendlyName: credential.friendlyName, id: credential.id }),
166+
});
167+
168+
if (response.ok) {
169+
return response.json();
170+
}
171+
172+
throw new Error('Failed to update credential');
173+
};
174+
175+
const deleteCredential = async (credentialId: string) => {
176+
const response = await fetchWithAuth(`users/credentials`, {
177+
method: 'DELETE',
178+
credentials: 'include',
179+
headers: { 'Content-Type': 'application/json' },
180+
body: JSON.stringify({ id: credentialId }),
181+
});
182+
183+
if (response.ok) {
184+
return response.json();
185+
}
186+
187+
throw new Error('Failed to update credential');
188+
};
189+
137190
useEffect(() => {
138191
validateToken();
139192
}, []);
@@ -165,6 +218,9 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
165218
markSignedIn,
166219
hasSignedInBefore: autoDetectPreviousSignin ? hasSignedInBefore : false,
167220
mode,
221+
credentials,
222+
updateCredential,
223+
deleteCredential,
168224
}}
169225
>
170226
<InternalAuthProvider value={{ validateToken, setLoading }}>

src/Login.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,6 @@ const Login: React.FC = () => {
8989
navigate('/mfaLogin');
9090
return;
9191
}
92-
console.log('Verified...', JSON.stringify(verificationResponse));
9392
await validateToken();
9493
navigate('/');
9594
return;

src/RegisterPassKey.tsx

Lines changed: 95 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -9,132 +9,148 @@ import React, { useEffect, useState } from 'react';
99
import { useNavigate } from 'react-router-dom';
1010

1111
import styles from '@/styles/registerPasskey.module.css';
12-
import { isPasskeySupported } from './utils';
12+
import { isPasskeySupported, parseUserAgent } from './utils';
1313
import { createFetchWithAuth } from './fetchWithAuth';
14+
import DeviceNameModal from './components/DeviceNameModal';
1415

1516
const RegisterPasskey: React.FC = () => {
1617
const { apiHost, mode } = useAuth();
1718
const { validateToken } = useInternalAuth();
1819
const navigate = useNavigate();
20+
1921
const [status, setStatus] = useState<'idle' | 'success' | 'error' | 'loading'>('idle');
2022
const [message, setMessage] = useState('');
2123
const [passkeyAvailable, setPasskeyAvailable] = useState(false);
2224

25+
const [showDeviceModal, setShowDeviceModal] = useState(false);
26+
const [pendingMetadata, setPendingMetadata] = useState<{
27+
platform: string;
28+
browser: string;
29+
deviceInfo: string;
30+
} | null>(null);
31+
2332
const fetchWithAuth = createFetchWithAuth({
2433
authMode: mode,
2534
authHost: apiHost,
2635
});
2736

28-
const handlePasskeyRegister = async () => {
37+
useEffect(() => {
38+
async function checkSupport() {
39+
const supported = await isPasskeySupported();
40+
setPasskeyAvailable(supported);
41+
}
42+
43+
checkSupport();
44+
}, []);
45+
46+
const openDeviceModal = () => {
47+
const { platform, browser, deviceInfo } = parseUserAgent();
48+
49+
setPendingMetadata({ platform, browser, deviceInfo });
50+
setShowDeviceModal(true);
51+
};
52+
53+
const continueRegistration = async (friendlyName: string) => {
54+
if (!pendingMetadata) return;
55+
56+
const { platform, browser, deviceInfo } = pendingMetadata;
57+
2958
setStatus('loading');
3059

3160
try {
3261
const challengeRes = await fetchWithAuth(`/webAuthn/register/start`, {
3362
method: 'GET',
34-
headers: {
35-
'Content-Type': 'application/json',
36-
},
63+
headers: { 'Content-Type': 'application/json' },
3764
credentials: 'include',
3865
});
3966

4067
if (!challengeRes.ok) {
41-
setStatus('error');
42-
setMessage('Something went wrong registering passkey.');
43-
return;
68+
throw new Error('Failed to fetch challenge');
4469
}
4570

4671
const options = await challengeRes.json();
4772

4873
let attResp: RegistrationResponseJSON;
74+
4975
try {
5076
attResp = await startRegistration({ optionsJSON: options });
51-
52-
await verifyPassKey(attResp);
5377
} catch (error) {
5478
if (error instanceof WebAuthnError) {
55-
console.error(
56-
`Error occurred with webAuthn, ${error.name} - ${error.code}-${error.stack}`
57-
);
58-
setStatus('error');
59-
setMessage(`Error: ${error.name}`);
60-
} else {
61-
console.error('A problem happened.', error);
62-
setStatus('error');
63-
setMessage(`Error: ${error}`);
79+
throw new Error(error.name);
6480
}
65-
return;
81+
throw error;
6682
}
6783

84+
await verifyPassKey(attResp, {
85+
friendlyName,
86+
platform,
87+
browser,
88+
deviceInfo,
89+
});
90+
6891
setStatus('success');
6992
setMessage('Passkey registered successfully.');
7093
navigate('/');
71-
} catch (err) {
72-
console.error(err);
94+
} catch (error) {
95+
console.error(error);
7396
setStatus('error');
74-
setMessage('Something went wrong registering passkey.');
97+
setMessage('Error registering passkey.');
98+
} finally {
99+
setShowDeviceModal(false);
100+
setPendingMetadata(null);
75101
}
76102
};
77103

78-
const verifyPassKey = async (attResp: RegistrationResponseJSON) => {
79-
try {
80-
const verificationResp = await fetchWithAuth(`/webAuthn/register/finish`, {
81-
method: 'POST',
82-
headers: {
83-
'Content-Type': 'application/json',
84-
},
85-
body: JSON.stringify({
86-
attestationResponse: attResp,
87-
}),
88-
credentials: 'include',
89-
});
90-
91-
const verificationJSON = await verificationResp.json();
92-
93-
if (!verificationResp.ok) {
94-
setStatus('error');
95-
setMessage('Something went wrong registering passkey.');
96-
return;
97-
}
98-
99-
if (verificationJSON?.verified) {
100-
await validateToken();
101-
}
102-
} catch (error) {
103-
console.error(`An error occurred: ${error}`);
104+
const verifyPassKey = async (
105+
attResp: RegistrationResponseJSON,
106+
metadata: {
107+
friendlyName: string;
108+
platform: string;
109+
browser: string;
110+
deviceInfo: string;
104111
}
105-
};
106-
107-
useEffect(() => {
108-
async function checkSupport() {
109-
const supported = await isPasskeySupported();
110-
setPasskeyAvailable(supported);
112+
) => {
113+
const verificationResp = await fetchWithAuth(`/webAuthn/register/finish`, {
114+
method: 'POST',
115+
headers: { 'Content-Type': 'application/json' },
116+
body: JSON.stringify({
117+
attestationResponse: attResp,
118+
metadata,
119+
}),
120+
credentials: 'include',
121+
});
122+
123+
if (!verificationResp.ok) {
124+
throw new Error('Verification failed');
111125
}
112126

113-
checkSupport();
114-
}, []);
127+
await validateToken();
128+
};
115129

116130
return (
117-
<div className={styles.container}>
118-
<div className={styles.card}>
119-
{!passkeyAvailable ? (
120-
<div className={styles.loading}>
121-
<div className={styles.spinner}></div>
122-
<span>Checking for Passkey Support...</span>
123-
</div>
124-
) : (
125-
passkeyAvailable && (
131+
<>
132+
<div className={styles.container}>
133+
<div className={styles.card}>
134+
{!passkeyAvailable ? (
135+
<div className={styles.loading}>
136+
<div className={styles.spinner}></div>
137+
<span>Checking for Passkey Support...</span>
138+
</div>
139+
) : (
126140
<div className={styles.supported}>
127141
<h2 className={styles.title}>Secure Your Account with a Passkey</h2>
128142
<p className={styles.description}>
129143
Your device supports passkeys! Register one to skip passwords forever.
130144
</p>
145+
131146
<button
132-
onClick={handlePasskeyRegister}
147+
onClick={openDeviceModal}
133148
disabled={status === 'loading'}
134149
className={styles.button}
135150
>
136151
{status === 'loading' ? 'Registering...' : 'Register Passkey'}
137152
</button>
153+
138154
{message && (
139155
<p
140156
className={`${styles.message} ${
@@ -145,10 +161,19 @@ const RegisterPasskey: React.FC = () => {
145161
</p>
146162
)}
147163
</div>
148-
)
149-
)}
164+
)}
165+
</div>
150166
</div>
151-
</div>
167+
168+
<DeviceNameModal
169+
isOpen={showDeviceModal}
170+
onCancel={() => {
171+
setShowDeviceModal(false);
172+
setPendingMetadata(null);
173+
}}
174+
onConfirm={continueRegistration}
175+
/>
176+
</>
152177
);
153178
};
154179

src/TermsModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ const TermsModal: React.FC<TermsModalProps> = ({ isOpen, onClose }) => {
6363
<strong>3. Opt-Out:</strong> Contact support@seamlessauth.com to opt out.
6464
</p>
6565
<p>
66-
<strong>4. Fees:</strong> Standard carrier rates may apply.
66+
<strong>4. Fees:</strong> Message and data rates may apply.
6767
</p>
6868
<p>
6969
<strong>5. Supported Carriers:</strong> Most major providers supported,

0 commit comments

Comments
 (0)