Skip to content
Merged
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
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,9 @@ WebAuthn PRF lets a compatible passkey and browser derive local key material dur

Browser and authenticator support is not universal. Call `isPasskeyPrfSupported()` before offering PRF-required flows, and keep a fallback for passkeys that authenticate successfully without returning PRF output.

Treat PRF salts as sensitive in client logs. PRF output is browser-local key material; keep it in
memory only as long as your application needs it and do not send it to Seamless Auth or your own API.

```ts
import { createSeamlessAuthClient } from '@seamless-auth/react';

Expand Down Expand Up @@ -275,7 +278,7 @@ function OAuthButtons() {
const [providers, setProviders] = useState<OAuthProvider[]>([]);

useEffect(() => {
void listOAuthProviders().then((result) => setProviders(result.providers));
void listOAuthProviders().then(result => setProviders(result.providers));
}, [listOAuthProviders]);

async function signIn(providerId: string) {
Expand All @@ -290,7 +293,7 @@ function OAuthButtons() {

return (
<div>
{providers.map((provider) => (
{providers.map(provider => (
<button key={provider.id} onClick={() => void signIn(provider.id)}>
Continue with {provider.name}
</button>
Expand Down Expand Up @@ -345,6 +348,9 @@ OAuth must be enabled on the Seamless Auth API with `LOGIN_METHODS` including `o
one configured `oauth_providers` entry. Provider client secrets live on the server and are referenced
by environment variable name; they are never passed through this SDK.

The built-in views avoid logging OTPs, magic-link tokens, bootstrap tokens, PRF salts, or raw
exception payloads that may contain sensitive request URLs.

## Headless Client

For custom auth UIs, use the exported client directly:
Expand Down
6 changes: 3 additions & 3 deletions src/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
return true;
}

console.error('Passkey login failed:', result.message);
console.error('Passkey login failed.');
return false;
};

Expand Down Expand Up @@ -164,8 +164,8 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
} else {
throw new Error('Could not delete user.');
}
} catch (error) {
console.error('Something went wrong deleting user:', error);
} catch {
console.error('Something went wrong deleting user.');
throw new Error('Could not delete user.');
}
};
Expand Down
7 changes: 2 additions & 5 deletions src/client/createSeamlessAuthClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,10 +229,7 @@ export interface SeamlessAuthClient {
userId: string,
input: OrganizationMemberUpdateInput
) => Promise<Response>;
removeOrganizationMember: (
organizationId: string,
userId: string
) => Promise<Response>;
removeOrganizationMember: (organizationId: string, userId: string) => Promise<Response>;
}

const staleStepUpResult = (message: string): StepUpVerificationResult => ({
Expand Down Expand Up @@ -544,7 +541,7 @@ export const createSeamlessAuthClient = (
};
}

console.error('Passkey registration error:', error);
console.error('Passkey registration error.');
return {
success: false,
message: 'Passkey registration failed.',
Expand Down
11 changes: 6 additions & 5 deletions src/client/webauthnPrf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export async function isPasskeyPrfSupported(): Promise<boolean> {
}

export function preparePrfRequestOptions(
optionsJSON: PublicKeyCredentialRequestOptionsJSON,
optionsJSON: PublicKeyCredentialRequestOptionsJSON
): PublicKeyCredentialRequestOptionsJSON {
const options = optionsJSON as PrfOptionsJSON;
const prfEval = options.extensions?.prf?.eval;
Expand All @@ -138,7 +138,7 @@ export function preparePrfRequestOptions(
}

export function extractPasskeyPrfResult(
credential: AuthenticationResponseJSON,
credential: AuthenticationResponseJSON
): PasskeyPrfResult | null {
const extensionResults =
credential.clientExtensionResults as unknown as PrfClientExtensionResults;
Expand All @@ -158,7 +158,7 @@ export function extractPasskeyPrfResult(
}

export function stripPrfResultsFromAssertion(
credential: AuthenticationResponseJSON,
credential: AuthenticationResponseJSON
): AuthenticationResponseJSON {
const extensionResults =
credential.clientExtensionResults as unknown as PrfClientExtensionResults;
Expand All @@ -182,8 +182,9 @@ export function stripPrfResultsFromAssertion(
export function getRegistrationPrfCapable(attestationResponse: {
clientExtensionResults?: unknown;
}) {
const extensionResults =
attestationResponse.clientExtensionResults as PrfClientExtensionResults | undefined;
const extensionResults = attestationResponse.clientExtensionResults as
| PrfClientExtensionResults
| undefined;

return extensionResults?.prf?.enabled === true;
}
4 changes: 1 addition & 3 deletions src/components/AuthFallbackOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,7 @@ const AuthFallbackOptions: React.FC<AuthFallbackOptionsProps> = ({
onClick={onEmailOtp}
>
<span className={styles.actionTitle}>Email Code</span>
<span className={styles.actionSubtext}>
Receive a one-time code by email
</span>
<span className={styles.actionSubtext}>Receive a one-time code by email</span>
</button>
)}

Expand Down
2 changes: 1 addition & 1 deletion src/fetchWithAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const createFetchWithAuth = (opts: FetchWithAuthOptions) => {
const response = await fetch(url, requestInit);

if (!response.ok) {
console.warn('Auth fetch failed:', response.status, url);
console.warn('Auth fetch failed:', response.status);
}

return response;
Expand Down
4 changes: 1 addition & 3 deletions src/scopedRoles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,7 @@ export function hasScopedRole(
return false;
}

const granted = grantedRoles.filter(
(role): role is string => typeof role === 'string'
);
const granted = grantedRoles.filter((role): role is string => typeof role === 'string');
const required = Array.isArray(requiredRoles) ? requiredRoles : [requiredRoles];

return required.some(requiredRole =>
Expand Down
4 changes: 2 additions & 2 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ export async function isPasskeySupported(): Promise<boolean> {
) {
try {
return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
} catch (error) {
console.error('Error checking passkey support:', error);
} catch {
console.error('Error checking passkey support.');
return false;
}
}
Expand Down
7 changes: 3 additions & 4 deletions src/views/EmailRegistration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ const EmailRegistration: React.FC = () => {
const { refreshSession } = useAuth();
const authClient = useAuthClient();
const { passkeySupported } = usePasskeySupport();
const isLoginFlow =
(location.state as { flow?: string } | null)?.flow === 'login';
const isLoginFlow = (location.state as { flow?: string } | null)?.flow === 'login';

const [loading, setLoading] = useState(false);
const [emailOtp, setEmailOtp] = useState('');
Expand Down Expand Up @@ -79,8 +78,8 @@ const EmailRegistration: React.FC = () => {
await refreshSession();
navigate('/');
}
} catch (err) {
console.error(err);
} catch {
console.error('Email OTP verification failed.');
setError('Verification failed.');
} finally {
setLoading(false);
Expand Down
30 changes: 12 additions & 18 deletions src/views/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,7 @@ const Login: React.FC = () => {
const [emailError, setEmailError] = useState<string>('');
const [identifierError, setIdentifierError] = useState<string>('');
const [showFallbackOptions, setShowFallbackOptions] = useState(false);
const [loginMethods, setLoginMethods] =
useState<LoginMethod[]>(DEFAULT_LOGIN_METHODS);
const [loginMethods, setLoginMethods] = useState<LoginMethod[]>(DEFAULT_LOGIN_METHODS);
const [bootstrapToken, setBootstrapToken] = useState<string | null>(null);

useEffect(() => {
Expand All @@ -59,7 +58,6 @@ const Login: React.FC = () => {

if (token && token.length > 10) {
setBootstrapToken(token);
console.log('Bootstrap token detected in URL');
}
}, [hasSignedInBefore]);

Expand Down Expand Up @@ -105,8 +103,8 @@ const Login: React.FC = () => {
setFormErrors(
'An unexpected error occurred. Try again. If the problem persists, try resetting your password.'
);
} catch (err) {
console.error('Unexpected login error', err);
} catch {
console.error('Unexpected login error.');
setFormErrors(
'An unexpected error occurred. Try again. If the problem persists, try resetting your password.'
);
Expand All @@ -123,8 +121,8 @@ const Login: React.FC = () => {
}

navigate('/magiclinks-sent', { state: { identifier } });
} catch (err) {
console.error(err);
} catch {
console.error('Failed to send magic link.');
setFormErrors('Failed to send magic link.');
}
};
Expand All @@ -139,8 +137,8 @@ const Login: React.FC = () => {
}

navigate('/verifyPhoneOTP', { state: { flow: 'login' } });
} catch (err) {
console.error(err);
} catch {
console.error('Failed to send phone OTP.');
setFormErrors('Failed to send OTP.');
}
};
Expand All @@ -155,8 +153,8 @@ const Login: React.FC = () => {
}

navigate('/verifyEmailOTP', { state: { flow: 'login' } });
} catch (err) {
console.error(err);
} catch {
console.error('Failed to send email OTP.');
setFormErrors('Failed to send email code.');
}
};
Expand All @@ -175,11 +173,7 @@ const Login: React.FC = () => {
: DEFAULT_LOGIN_METHODS;
setLoginMethods(availableMethods);

if (
loginRes?.ok &&
passkeySupported &&
availableMethods.includes('passkey')
) {
if (loginRes?.ok && passkeySupported && availableMethods.includes('passkey')) {
const passkeyResult = await handlePasskeyLogin();
if (passkeyResult) {
navigate('/');
Expand All @@ -205,8 +199,8 @@ const Login: React.FC = () => {
if (mode === 'register') {
await register();
}
} catch (err) {
console.error(err);
} catch {
console.error('Failed to continue sign-in.');
setFormErrors('Failed to continue sign-in. Please try again.');
}
};
Expand Down
4 changes: 2 additions & 2 deletions src/views/PassKeyRegistration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ const PasskeyRegistration: React.FC = () => {
setStatus('success');
setMessage(result.message);
navigate('/');
} catch (error) {
console.error(error);
} catch {
console.error('Passkey registration failed.');
setStatus('error');
setMessage('Error registering passkey.');
} finally {
Expand Down
13 changes: 3 additions & 10 deletions src/views/PhoneRegistration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ const PhoneRegistration: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { refreshSession } = useAuth();
const isLoginFlow =
(location.state as { flow?: string } | null)?.flow === 'login';
const isLoginFlow = (location.state as { flow?: string } | null)?.flow === 'login';

const [phoneOtp, setPhoneOtp] = useState('');
const [phoneVerified, setPhoneVerified] = useState<boolean | null>(null);
Expand Down Expand Up @@ -67,8 +66,8 @@ const PhoneRegistration: React.FC = () => {
setPhoneVerified(true);
}
}
} catch (error: unknown) {
console.error(error);
} catch {
console.error('Phone OTP verification failed.');
setError('Verification failed.');
}

Expand All @@ -81,18 +80,12 @@ const PhoneRegistration: React.FC = () => {
? await authClient.requestLoginPhoneOtp()
: await authClient.requestPhoneOtp();

const data = await response.json();

if (!response.ok) {
setError(
'Failed to send SMS code. If this persists, refresh the page and try again.'
);
return;
} else {
if (data.token) {
// Set a new token
localStorage.setItem('token', data.token);
}
setResendMsg('Verification SMS has been resent.');
}
};
Expand Down
6 changes: 3 additions & 3 deletions src/views/VerifyMagicLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const VerifyMagicLink: React.FC = () => {
if (mounted) {
setError('Missing token for verification.');
}
console.error('No token found', token);
console.error('No magic-link token found.');
return;
}

Expand All @@ -44,8 +44,8 @@ const VerifyMagicLink: React.FC = () => {
}
return;
}
} catch (error) {
console.error(error);
} catch {
console.error('Failed to verify magic-link token.');
}

if (!mounted) {
Expand Down
4 changes: 2 additions & 2 deletions tests/PhoneRegistration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ describe('PhoneRegistration', () => {
expect(mockAuthClient.requestPhoneOtp).toHaveBeenCalled();
});

test('resend stores token if returned', async () => {
test('resend does not persist returned tokens', async () => {
mockAuthClient.requestPhoneOtp.mockResolvedValueOnce({
ok: true,
json: async () => ({ token: 'abc123' }),
Expand All @@ -119,7 +119,7 @@ describe('PhoneRegistration', () => {
fireEvent.click(screen.getByRole('button', { name: /resend code to phone/i }));
});

expect(localStorage.getItem('token')).toBe('abc123');
expect(localStorage.getItem('token')).toBeNull();
});

test('timer counts down', () => {
Expand Down
4 changes: 3 additions & 1 deletion tests/RegisterPassKey.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ describe('RegisterPasskey', () => {

render(<RegisterPasskey />);

expect(screen.getByText(/passkeys are not supported on this device/i)).toBeInTheDocument();
expect(
screen.getByText(/passkeys are not supported on this device/i)
).toBeInTheDocument();
});
});
10 changes: 7 additions & 3 deletions tests/createSeamlessAuthClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,13 @@ describe('createSeamlessAuthClient', () => {
await expect(client.requestLoginPhoneOtp()).resolves.toBe(response);
await expect(client.verifyLoginPhoneOtp('123456')).resolves.toBe(response);

expect(mockFetchWithAuth).toHaveBeenNthCalledWith(1, '/otp/generate-login-phone-otp', {
method: 'GET',
});
expect(mockFetchWithAuth).toHaveBeenNthCalledWith(
1,
'/otp/generate-login-phone-otp',
{
method: 'GET',
}
);
expect(mockFetchWithAuth).toHaveBeenNthCalledWith(
2,
'/otp/verify-login-phone-otp',
Expand Down
1 change: 0 additions & 1 deletion tests/login.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -298,5 +298,4 @@ describe('Login', () => {

expect(navigate).toHaveBeenCalledWith('/verifyPhoneOTP');
});

});
3 changes: 1 addition & 2 deletions tests/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,7 @@ describe('isPasskeySupported', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
await expect(isPasskeySupported()).resolves.toBe(false);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Error checking passkey support:'),
expect.any(Error)
expect.stringContaining('Error checking passkey support.')
);
consoleSpy.mockRestore();
});
Expand Down
Loading
Loading