Skip to content

Commit d950cd1

Browse files
committed
feat: add passkey login option to the login screen
New AuthorizerPasskeyLogin component offers a full passwordless, usernameless "Sign in with a passkey" button (discoverable-credential login) above the existing login methods, wired via authorizer-js's loginWithPasskey() (authorizerdev/authorizer-js#40 / backend: authorizerdev/authorizer#671). Renders nothing when isWebauthnSupported() is false - there's no server config flag for passkeys (unlike social login), it's purely a browser capability check. Passkey management (list/add/delete existing passkeys) is out of scope here: this library only ever contained login-flow components, no account/settings page exists to extend, and the raw SDK methods (webauthnCredentials, webauthnDeleteCredential, registerPasskey) are already exported for a consuming app to build that itself. Live-verified in the example app against a real backend: the button renders, click fires webauthn_login_options over GraphQL and invokes the browser's native navigator.credentials.get() correctly (confirmed via the loading state and no console errors). Completing an actual ceremony needs real biometric hardware or a manually-configured Chrome DevTools virtual authenticator, neither of which is scriptable through browser automation - the ceremony logic itself is already proven correct by a Go integration test that simulates a real authenticator via github.com/descope/virtualwebauthn through the actual server code path.
1 parent 40900ee commit d950cd1

3 files changed

Lines changed: 75 additions & 0 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { FC, useState } from 'react';
2+
import { AuthToken, isWebauthnSupported } from '@authorizerdev/authorizer-js';
3+
4+
import '../styles/default.css';
5+
import { ButtonAppearance, MessageType } from '../constants';
6+
import { useAuthorizer } from '../contexts/AuthorizerContext';
7+
import { StyledButton, StyledSeparator } from '../styledComponents';
8+
import { Message } from './Message';
9+
10+
// AuthorizerPasskeyLogin offers a full passwordless, usernameless "Sign in
11+
// with a passkey" option (discoverable-credential login) alongside the other
12+
// login methods. It only renders when the browser actually supports the
13+
// WebAuthn JSON ceremony APIs the SDK relies on - there is no server-side
14+
// config flag for passkeys (unlike social login), it's purely a browser
15+
// capability.
16+
export const AuthorizerPasskeyLogin: FC<{
17+
onLogin?: (data: AuthToken | void) => void;
18+
}> = ({ onLogin }) => {
19+
const [error, setError] = useState(``);
20+
const [loading, setLoading] = useState(false);
21+
const { setAuthData, config, authorizerRef } = useAuthorizer();
22+
23+
if (!isWebauthnSupported()) {
24+
return null;
25+
}
26+
27+
const onClick = async () => {
28+
setError(``);
29+
try {
30+
setLoading(true);
31+
const { data: res, errors } = await authorizerRef.loginWithPasskey();
32+
if (errors && errors.length) {
33+
setError(errors[0]?.message || ``);
34+
return;
35+
}
36+
if (res) {
37+
setAuthData({
38+
user: res.user || null,
39+
token: res,
40+
config,
41+
loading: false,
42+
});
43+
}
44+
if (onLogin) {
45+
onLogin(res);
46+
}
47+
} catch (err) {
48+
setError((err as Error).message);
49+
} finally {
50+
setLoading(false);
51+
}
52+
};
53+
54+
const onErrorClose = () => setError(``);
55+
56+
return (
57+
<>
58+
{error && (
59+
<Message type={MessageType.Error} text={error} onClose={onErrorClose} />
60+
)}
61+
<StyledButton
62+
onClick={onClick}
63+
disabled={loading}
64+
appearance={ButtonAppearance.Default}
65+
>
66+
{loading ? `Waiting for passkey ...` : `Sign in with a passkey`}
67+
</StyledButton>
68+
<StyledSeparator>OR</StyledSeparator>
69+
</>
70+
);
71+
};

src/components/AuthorizerRoot.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { AuthorizerSignup } from './AuthorizerSignup';
99
import type { FormFieldsOverrides } from './AuthorizerSignup';
1010
import { AuthorizerForgotPassword } from './AuthorizerForgotPassword';
1111
import { AuthorizerSocialLogin } from './AuthorizerSocialLogin';
12+
import { AuthorizerPasskeyLogin } from './AuthorizerPasskeyLogin';
1213
import { AuthorizerMagicLinkLogin } from './AuthorizerMagicLinkLogin';
1314
import { createRandomString } from '../utils/common';
1415
import { hasWindow } from '../utils/window';
@@ -60,6 +61,7 @@ export const AuthorizerRoot: FC<{
6061
return (
6162
<StyledWrapper>
6263
<AuthorizerSocialLogin urlProps={urlProps} roles={roles} />
64+
{view === Views.Login && <AuthorizerPasskeyLogin onLogin={onLogin} />}
6365
{view === Views.Login &&
6466
(config.is_basic_authentication_enabled ||
6567
config.is_mobile_basic_authentication_enabled) &&

src/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { AuthorizerResetPassword } from './components/AuthorizerResetPassword';
1414
import { AuthorizerVerifyOtp } from './components/AuthorizerVerifyOtp';
1515
import { AuthorizerRoot as Authorizer } from './components/AuthorizerRoot';
1616
import { AuthorizerTOTPScanner } from './components/AuthorizerTOTPScanner';
17+
import { AuthorizerPasskeyLogin } from './components/AuthorizerPasskeyLogin';
1718

1819
export {
1920
useAuthorizer,
@@ -27,4 +28,5 @@ export {
2728
AuthorizerResetPassword,
2829
AuthorizerVerifyOtp,
2930
AuthorizerTOTPScanner,
31+
AuthorizerPasskeyLogin,
3032
};

0 commit comments

Comments
 (0)