Skip to content

Commit df545bf

Browse files
committed
feat: add credentials to the auth provider and supporting fns
1 parent 07ae8bb commit df545bf

7 files changed

Lines changed: 101 additions & 9 deletions

File tree

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,

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: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ 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';
1414

1515
const RegisterPasskey: React.FC = () => {
@@ -27,6 +27,11 @@ const RegisterPasskey: React.FC = () => {
2727

2828
const handlePasskeyRegister = async () => {
2929
setStatus('loading');
30+
const friendlyName = prompt(
31+
"Name this device (e.g., 'MacBook Pro', 'iPhone', 'YubiKey')"
32+
);
33+
34+
const { platform, browser, deviceInfo } = parseUserAgent();
3035

3136
try {
3237
const challengeRes = await fetchWithAuth(`/webAuthn/register/start`, {
@@ -49,7 +54,7 @@ const RegisterPasskey: React.FC = () => {
4954
try {
5055
attResp = await startRegistration({ optionsJSON: options });
5156

52-
await verifyPassKey(attResp);
57+
await verifyPassKey(attResp, { friendlyName, platform, browser, deviceInfo });
5358
} catch (error) {
5459
if (error instanceof WebAuthnError) {
5560
console.error(
@@ -75,7 +80,15 @@ const RegisterPasskey: React.FC = () => {
7580
}
7681
};
7782

78-
const verifyPassKey = async (attResp: RegistrationResponseJSON) => {
83+
const verifyPassKey = async (
84+
attResp: RegistrationResponseJSON,
85+
metadata: {
86+
friendlyName: string | null;
87+
platform: string;
88+
browser: string;
89+
deviceInfo: string;
90+
}
91+
) => {
7992
try {
8093
const verificationResp = await fetchWithAuth(`/webAuthn/register/finish`, {
8194
method: 'POST',
@@ -84,6 +97,7 @@ const RegisterPasskey: React.FC = () => {
8497
},
8598
body: JSON.stringify({
8699
attestationResponse: attResp,
100+
metadata,
87101
}),
88102
credentials: 'include',
89103
});

src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AuthContextType, AuthProvider, useAuth } from '@/AuthProvider';
1+
import { AuthContextType, AuthProvider, useAuth, Credential, User } from '@/AuthProvider';
22
import { AuthRoutes } from '@/AuthRoutes';
33
export { AuthProvider, AuthRoutes, useAuth };
4-
export type { AuthContextType };
4+
export type { AuthContextType, Credential, User };

src/utils.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,25 @@ export async function isPasskeySupported(): Promise<boolean> {
7979
}
8080
return false;
8181
}
82+
83+
export function parseUserAgent() {
84+
const ua = navigator.userAgent.toLowerCase();
85+
86+
let platform = 'unknown';
87+
let browser = 'unknown';
88+
89+
if (/iphone|ipad|ipod/.test(ua)) platform = 'ios';
90+
else if (/android/.test(ua)) platform = 'android';
91+
else if (/mac os/.test(ua)) platform = 'mac';
92+
else if (/windows/.test(ua)) platform = 'windows';
93+
else if (/linux/.test(ua)) platform = 'linux';
94+
95+
if (/chrome/.test(ua)) browser = 'chrome';
96+
if (/safari/.test(ua) && !/chrome/.test(ua)) browser = 'safari';
97+
if (/firefox/.test(ua)) browser = 'firefox';
98+
if (/edg/.test(ua)) browser = 'edge';
99+
100+
const deviceInfo = `${platform}${browser}`;
101+
102+
return { platform, browser, deviceInfo };
103+
}

tests/RegisterPassKey.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ jest.mock('@/context/InternalAuthContext', () => ({
2626

2727
jest.mock('../src/utils', () => ({
2828
isPasskeySupported: () => jest.fn().mockResolvedValue(true),
29+
parseUserAgent: () => jest.fn(),
2930
}));
3031

3132
let consoleErrorSpy: jest.SpyInstance;

0 commit comments

Comments
 (0)