diff --git a/.changeset/skip-passkey-registration.md b/.changeset/skip-passkey-registration.md new file mode 100644 index 0000000..16aa436 --- /dev/null +++ b/.changeset/skip-passkey-registration.md @@ -0,0 +1,25 @@ +--- +'@seamless-auth/react': minor +--- + +Offer a way past passkey registration when another login method is enabled. + +Registration used to end on a screen with one control on it. A user who did not want a passkey, or +whose device could not make one, had no way forward: the unsupported branch rendered a message and +nothing else, on a screen with no exit. The session already exists by then, since the OTP step that +leads here establishes it, so leaving without a passkey was always a legitimate way to finish. + +`useLoginMethods` reads the instance configuration from the auth server, and the skip only appears +when a method other than `passkey` is enabled. With passkey as the only method a skip would leave a +user unable to sign back into the account they just created, so the control is not rendered at all. +Unknown counts as unsafe: a failed or in-flight read shows no skip rather than guessing. + +The unsupported-device branch now says which case it is, and offers the same way forward when one +exists. + +`Login` no longer starts from a hardcoded `['passkey', 'magic_link', 'phone_otp']`. It uses the +methods the instance reports, falling back to the narrower `['passkey', 'magic_link']` that matches +the auth server's own defaults. The login response stays authoritative when it carries methods of +its own. + +Requires an auth server serving `GET /system-config/public`, and an adapter that proxies it. diff --git a/package-lock.json b/package-lock.json index 1738812..6533824 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@seamless-auth/react", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@seamless-auth/react", - "version": "0.6.0", + "version": "0.7.0", "license": "AGPL-3.0-only", "dependencies": { - "@seamless-auth/types": "^0.4.0", + "@seamless-auth/types": "^0.5.0", "@simplewebauthn/browser": "^13.1.0", "eslint-plugin-license-header": "^0.9.0", "libphonenumber-js": "^1.12.7", @@ -3066,9 +3066,9 @@ "license": "MIT" }, "node_modules/@seamless-auth/types": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.4.0.tgz", - "integrity": "sha512-1WhLjgyCN9UdX6TEzKbbASROeLMxZmo2LiAYOlYE2sGIPojnbAE/yFbp5Ydt13GibiZ6MwYlHMLcsBVQMeelWA==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.5.0.tgz", + "integrity": "sha512-T566YQu8FqUmqgte0A0iJpjTDXUKq/d/7uvLlXGXKtdl52Edo0r3iafL3kx4OM0cRJM63tBGe9s4MTjRUth6yA==", "license": "AGPL-3.0-only", "dependencies": { "zod": "^4.3.6" diff --git a/package.json b/package.json index 49ceaa2..d757737 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "typescript-eslint": "^8.46.1" }, "dependencies": { - "@seamless-auth/types": "^0.4.0", + "@seamless-auth/types": "^0.5.0", "@simplewebauthn/browser": "^13.1.0", "eslint-plugin-license-header": "^0.9.0", "libphonenumber-js": "^1.12.7", diff --git a/src/client/createSeamlessAuthClient.ts b/src/client/createSeamlessAuthClient.ts index 8cfed63..ebf03c8 100644 --- a/src/client/createSeamlessAuthClient.ts +++ b/src/client/createSeamlessAuthClient.ts @@ -30,6 +30,7 @@ import type { OrganizationMembershipEnvelopeResponse, OrganizationSwitchResponse, PublicOAuthProvider, + PublicSystemConfigResponse, RegistrationRequest, StartOAuthLoginResponse, StepUpMethod as StepUpMethodShape, @@ -121,6 +122,13 @@ export type OAuthProvider = PublicOAuthProvider; export type OAuthProvidersResult = OAuthProvidersResponse; +/** + * The configuration a signed-out client may read. Fetched before there is a + * session, so the sign-in screens can match how this instance is configured + * instead of assuming a default set of methods. + */ +export type PublicSystemConfigResult = PublicSystemConfigResponse; + export interface StartOAuthLoginInput { providerId: string; redirectUri?: string; @@ -220,6 +228,7 @@ export interface SeamlessAuthClient { checkMagicLink: () => Promise>; verifyMagicLink: (token: string) => Promise>; listOAuthProviders: () => Promise>; + getPublicSystemConfig: () => Promise>; startOAuthLogin: ( input: StartOAuthLoginInput ) => Promise>; @@ -575,6 +584,12 @@ export const createSeamlessAuthClient = ( 'Failed to list OAuth providers.' ), + getPublicSystemConfig: () => + requestResult( + fetchWithAuth(`/system-config/public`, { method: 'GET' }), + 'Failed to read the public system configuration.' + ), + startOAuthLogin: input => requestResult( fetchWithAuth(`/oauth/${encodeURIComponent(input.providerId)}/start`, { diff --git a/src/components/AuthFallbackOptions.tsx b/src/components/AuthFallbackOptions.tsx index fb7e24b..f103029 100644 --- a/src/components/AuthFallbackOptions.tsx +++ b/src/components/AuthFallbackOptions.tsx @@ -16,7 +16,7 @@ interface AuthFallbackOptionsProps { onEmailOtp?: () => void; onPhoneOtp: () => void; onPasskeyRetry?: () => void; - loginMethods?: LoginMethod[]; + loginMethods?: LoginMethod[] | null; } const AuthFallbackOptions: React.FC = ({ @@ -27,6 +27,9 @@ const AuthFallbackOptions: React.FC = ({ onPasskeyRetry, loginMethods, }) => { + // Null means the caller has not resolved the methods yet. This component is + // the last step of a fallback flow, so it stays permissive and lets the + // handlers it was given decide, rather than hiding an option a caller wired up. const allowedMethods = new Set( loginMethods ?? ['passkey', 'magic_link', 'phone_otp'] ); diff --git a/src/hooks/useLoginMethods.ts b/src/hooks/useLoginMethods.ts new file mode 100644 index 0000000..a57bfd0 --- /dev/null +++ b/src/hooks/useLoginMethods.ts @@ -0,0 +1,74 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { useEffect, useState } from 'react'; + +import type { LoginMethod } from '@/client/createSeamlessAuthClient'; +import { useAuthClient } from '@/hooks/useAuthClient'; + +/** + * Used only until the instance answers, and if it never does. + * + * Deliberately the narrowest useful set, and matching the auth server's own + * defaults. Offering a method that turns out to be disabled sends a user down a + * path that fails, which is worse than showing one option too few. + */ +export const FALLBACK_LOGIN_METHODS: LoginMethod[] = ['passkey', 'magic_link']; + +/** + * Which sign-in methods this instance has enabled, read from the auth server + * rather than assumed. + * + * `loginMethods` stays null until the answer arrives, and stays null if the + * request fails. A caller must treat that as "unknown" and not as "none": the + * screens use it to decide what is safe to offer, and guessing in either + * direction is worse than waiting. `loading` is what a caller renders against. + */ +export const useLoginMethods = () => { + const authClient = useAuthClient(); + const [loginMethods, setLoginMethods] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let active = true; + + const read = async () => { + try { + const { data, error } = await authClient.getPublicSystemConfig(); + + if (active && !error && data?.loginMethods?.length) { + setLoginMethods(data.loginMethods); + } + } catch { + // Backstop only. The client reports request failures through `error`, + // not by throwing, and either way the methods stay unknown. Leaving + // `loading` true here would hang every screen that waits on it. + } finally { + if (active) { + setLoading(false); + } + } + }; + + void read(); + + return () => { + active = false; + }; + }, [authClient]); + + return { loginMethods, loading }; +}; + +/** + * Whether a user who declines a passkey would still have a way to sign in. + * + * Returns false while the methods are unknown, so a failed or in-flight request + * never produces a skip control that could strand someone in an account they + * cannot get back into. + */ +export const hasNonPasskeyLoginMethod = (loginMethods: LoginMethod[] | null) => + Boolean(loginMethods?.some(method => method !== 'passkey')); diff --git a/src/index.ts b/src/index.ts index 629e95c..5dc286f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { MessageResult, OAuthProvider, OAuthProvidersResult, + PublicSystemConfigResult, OrganizationMemberInput, OrganizationMemberUpdateInput, OrganizationMembersResult, @@ -57,6 +58,7 @@ import { PasskeyPrfResult, } from '@/client/webauthnPrf'; import { useAuthClient } from '@/hooks/useAuthClient'; +import { hasNonPasskeyLoginMethod, useLoginMethods } from '@/hooks/useLoginMethods'; import { usePasskeySupport } from '@/hooks/usePasskeySupport'; import { hasScopedRole, roleGrantsAccess } from '@/scopedRoles'; import { Credential, Organization, OrganizationMembership, User } from '@/types'; @@ -69,12 +71,14 @@ export { extractPasskeyPrfResult, getOAuthErrorCode, getWebAuthnErrorDetail, + hasNonPasskeyLoginMethod, hasScopedRole, isPasskeyPrfSupported, roleGrantsAccess, SeamlessAuthError, useAuth, useAuthClient, + useLoginMethods, usePasskeySupport, }; export type { @@ -105,6 +109,7 @@ export type { PasskeyPrfInput, PasskeyPrfResult, PasskeyRegistrationData, + PublicSystemConfigResult, RegisterInput, RegisterPasskeyOptions, SeamlessAuthClient, diff --git a/src/styles/registerPasskey.module.css b/src/styles/registerPasskey.module.css index 9a352c5..869ce80 100644 --- a/src/styles/registerPasskey.module.css +++ b/src/styles/registerPasskey.module.css @@ -91,3 +91,19 @@ .error { color: #f87171; } + +.skip { + margin-top: 1.5rem; + width: 100%; + font-size: 0.875rem; + color: #60a5fa; + text-align: center; + background: none; + border: none; + cursor: pointer; +} + +.skip:disabled { + opacity: 0.6; + cursor: default; +} diff --git a/src/views/Login.tsx b/src/views/Login.tsx index c5ef567..425fdbe 100644 --- a/src/views/Login.tsx +++ b/src/views/Login.tsx @@ -7,6 +7,7 @@ import { useAuth } from '@/AuthProvider'; import React, { useEffect, useState } from 'react'; import { useAuthClient } from '@/hooks/useAuthClient'; +import { FALLBACK_LOGIN_METHODS, useLoginMethods } from '@/hooks/useLoginMethods'; import { usePasskeySupport } from '@/hooks/usePasskeySupport'; import { useNavigate } from 'react-router-dom'; import { authRoutePaths } from '@/routes'; @@ -16,13 +17,12 @@ import AuthFallbackOptions from '@/components/AuthFallbackOptions'; import OAuthProviderButtons from '@/components/OAuthProviderButtons'; import type { LoginMethod } from '@/client/createSeamlessAuthClient'; -const DEFAULT_LOGIN_METHODS: LoginMethod[] = ['passkey', 'magic_link', 'phone_otp']; - const Login: React.FC = () => { const navigate = useNavigate(); const { hasSignedInBefore, login, handlePasskeyLogin } = useAuth(); const authClient = useAuthClient(); const { passkeySupported } = usePasskeySupport(); + const { loginMethods: configuredMethods } = useLoginMethods(); const [identifier, setIdentifier] = useState(''); const [email, setEmail] = useState(''); const [mode, setMode] = useState<'login' | 'register'>('register'); @@ -30,7 +30,7 @@ const Login: React.FC = () => { const [emailError, setEmailError] = useState(''); const [identifierError, setIdentifierError] = useState(''); const [showFallbackOptions, setShowFallbackOptions] = useState(false); - const [loginMethods, setLoginMethods] = useState(DEFAULT_LOGIN_METHODS); + const [loginMethods, setLoginMethods] = useState(null); useEffect(() => { if (hasSignedInBefore) { @@ -126,9 +126,12 @@ const Login: React.FC = () => { return; } + // The login response is per-user and authoritative when present. The + // instance configuration is the better fallback than a hardcoded list, + // because it at least reflects this deployment. const availableMethods = loginStart?.loginMethods?.length ? loginStart.loginMethods - : DEFAULT_LOGIN_METHODS; + : (configuredMethods ?? FALLBACK_LOGIN_METHODS); setLoginMethods(availableMethods); if (passkeySupported && availableMethods.includes('passkey')) { diff --git a/src/views/PassKeyRegistration.tsx b/src/views/PassKeyRegistration.tsx index 687c1ad..580abeb 100644 --- a/src/views/PassKeyRegistration.tsx +++ b/src/views/PassKeyRegistration.tsx @@ -8,6 +8,7 @@ import { useAuth } from '@/AuthProvider'; import { PasskeyMetadata } from '@/client/createSeamlessAuthClient'; import React, { useState } from 'react'; import { useAuthClient } from '@/hooks/useAuthClient'; +import { hasNonPasskeyLoginMethod, useLoginMethods } from '@/hooks/useLoginMethods'; import { usePasskeySupport } from '@/hooks/usePasskeySupport'; import { useNavigate } from 'react-router-dom'; @@ -19,6 +20,7 @@ const PasskeyRegistration: React.FC = () => { const { refreshSession } = useAuth(); const authClient = useAuthClient(); const { passkeySupported, loading: passkeySupportLoading } = usePasskeySupport(); + const { loginMethods, loading: loginMethodsLoading } = useLoginMethods(); const navigate = useNavigate(); const [status, setStatus] = useState<'idle' | 'success' | 'error' | 'loading'>('idle'); @@ -31,6 +33,20 @@ const PasskeyRegistration: React.FC = () => { deviceInfo: string; } | null>(null); + // The session already exists by the time this screen renders: the OTP step + // that led here established it. A passkey is an addition to that session + // rather than what completes registration, which is what makes leaving + // without one a legitimate way to finish and not an escape hatch. + // + // Gated on another method being enabled. With passkey as the only one, a user + // who skipped would have no way back into the account they just made. + const canSkip = hasNonPasskeyLoginMethod(loginMethods); + + const finishWithoutPasskey = async () => { + await refreshSession(); + navigate('/'); + }; + const openDeviceModal = () => { const { platform, browser, deviceInfo } = parseUserAgent(); @@ -73,14 +89,32 @@ const PasskeyRegistration: React.FC = () => { <>
- {passkeySupportLoading || !passkeySupported ? ( + {passkeySupportLoading || loginMethodsLoading ? (
- - {passkeySupportLoading - ? 'Checking for Passkey Support...' - : 'Passkeys are not supported on this device.'} - + Checking for Passkey Support... +
+ ) : !passkeySupported ? ( + // This used to be the end of the road: a message and no control of + // any kind, on a screen the user could not leave. Whether there is a + // way forward depends on the instance, so say which case this is. +
+

Passkeys are not available here

+

+ {canSkip + ? 'This device does not support passkeys. You can continue without one and add a passkey later from a device that does.' + : 'This device does not support passkeys, and this application requires one to sign in. Try again from a device or browser that supports them.'} +

+ + {canSkip && ( + + )}
) : (
@@ -106,6 +140,17 @@ const PasskeyRegistration: React.FC = () => { {message}

)} + + {canSkip && ( + + )}
)}
diff --git a/tests/RegisterPassKey.test.tsx b/tests/RegisterPassKey.test.tsx index 3af1608..bf51cbd 100644 --- a/tests/RegisterPassKey.test.tsx +++ b/tests/RegisterPassKey.test.tsx @@ -7,6 +7,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import RegisterPasskey from '../src/views/PassKeyRegistration'; import { useAuthClient } from '@/hooks/useAuthClient'; +import { useLoginMethods } from '@/hooks/useLoginMethods'; import { usePasskeySupport } from '@/hooks/usePasskeySupport'; const mockNavigate = jest.fn(); @@ -27,6 +28,13 @@ jest.mock('@/AuthProvider', () => ({ jest.mock('@/hooks/useAuthClient'); jest.mock('@/hooks/usePasskeySupport'); +// hasNonPasskeyLoginMethod stays real: it encodes the rule that decides whether +// a skip is safe to offer, so mocking it would test nothing. +jest.mock('@/hooks/useLoginMethods', () => ({ + ...jest.requireActual('@/hooks/useLoginMethods'), + useLoginMethods: jest.fn(), +})); + jest.mock('@/utils', () => ({ parseUserAgent: jest.fn().mockReturnValue({ platform: 'macOS', @@ -55,6 +63,10 @@ beforeEach(() => { passkeySupported: true, loading: false, }); + (useLoginMethods as jest.Mock).mockReturnValue({ + loginMethods: ['passkey', 'magic_link'], + loading: false, + }); }); describe('RegisterPasskey', () => { @@ -161,8 +173,83 @@ describe('RegisterPasskey', () => { render(); + expect(screen.getByText(/Passkeys are not available here/i)).toBeInTheDocument(); expect( - screen.getByText(/passkeys are not supported on this device/i) + screen.getByText(/This device does not support passkeys/i) ).toBeInTheDocument(); }); }); + +describe('RegisterPasskey skip control', () => { + it('offers a skip when another login method is enabled', async () => { + render(); + + fireEvent.click(await screen.findByText(/Skip for now/i)); + + await waitFor(() => { + expect(mockRefreshSession).toHaveBeenCalled(); + }); + + expect(mockNavigate).toHaveBeenCalledWith('/'); + expect(mockRegisterPasskey).not.toHaveBeenCalled(); + }); + + // Skipping here would leave the user with no way back into the account they + // just created, so the control must not exist at all. + it('offers no skip when passkey is the only login method', async () => { + (useLoginMethods as jest.Mock).mockReturnValue({ + loginMethods: ['passkey'], + loading: false, + }); + + render(); + + await screen.findByText(/Secure Your Account/i); + expect(screen.queryByText(/Skip for now/i)).not.toBeInTheDocument(); + }); + + it('offers no skip while the login methods are still unknown', async () => { + (useLoginMethods as jest.Mock).mockReturnValue({ + loginMethods: null, + loading: false, + }); + + render(); + + await screen.findByText(/Secure Your Account/i); + expect(screen.queryByText(/Skip for now/i)).not.toBeInTheDocument(); + }); + + it('gives an unsupported device a way forward when another method is enabled', async () => { + (usePasskeySupport as jest.Mock).mockReturnValue({ + passkeySupported: false, + loading: false, + }); + + render(); + + fireEvent.click(await screen.findByText(/^Continue$/i)); + + await waitFor(() => { + expect(mockRefreshSession).toHaveBeenCalled(); + }); + + expect(mockNavigate).toHaveBeenCalledWith('/'); + }); + + it('tells an unsupported device it is stuck when passkey is the only method', async () => { + (usePasskeySupport as jest.Mock).mockReturnValue({ + passkeySupported: false, + loading: false, + }); + (useLoginMethods as jest.Mock).mockReturnValue({ + loginMethods: ['passkey'], + loading: false, + }); + + render(); + + expect(await screen.findByText(/requires one to sign in/i)).toBeInTheDocument(); + expect(screen.queryByText(/^Continue$/i)).not.toBeInTheDocument(); + }); +}); diff --git a/tests/login.test.tsx b/tests/login.test.tsx index f53f532..9b12e2f 100644 --- a/tests/login.test.tsx +++ b/tests/login.test.tsx @@ -45,6 +45,7 @@ describe('Login', () => { requestPhoneOtp: jest.fn(), requestLoginPhoneOtp: jest.fn(), requestLoginEmailOtp: jest.fn(), + getPublicSystemConfig: jest.fn(), }; beforeEach(() => { @@ -64,6 +65,11 @@ describe('Login', () => { loading: false, }); + mockAuthClient.getPublicSystemConfig.mockResolvedValue({ + data: { loginMethods: ['passkey', 'magic_link', 'phone_otp'] }, + error: null, + }); + (isValidEmail as jest.Mock).mockReturnValue(true); (isValidPhoneNumber as jest.Mock).mockReturnValue(false); diff --git a/tests/useLoginMethods.test.tsx b/tests/useLoginMethods.test.tsx new file mode 100644 index 0000000..01ee20d --- /dev/null +++ b/tests/useLoginMethods.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { renderHook, waitFor } from '@testing-library/react'; + +import { useAuthClient } from '@/hooks/useAuthClient'; +import { hasNonPasskeyLoginMethod, useLoginMethods } from '@/hooks/useLoginMethods'; + +const mockGetPublicSystemConfig = jest.fn(); + +jest.mock('@/hooks/useAuthClient'); + +beforeEach(() => { + jest.clearAllMocks(); + (useAuthClient as jest.Mock).mockReturnValue({ + getPublicSystemConfig: mockGetPublicSystemConfig, + }); +}); + +describe('useLoginMethods', () => { + it('returns the methods the instance reports', async () => { + mockGetPublicSystemConfig.mockResolvedValue({ + data: { loginMethods: ['passkey', 'phone_otp'] }, + error: null, + }); + + const { result } = renderHook(() => useLoginMethods()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.loginMethods).toEqual(['passkey', 'phone_otp']); + }); + + // Null is "unknown", never "none". A caller that treated a failed read as an + // empty list would draw conclusions the server never sent. + it('leaves the methods unknown when the read fails', async () => { + mockGetPublicSystemConfig.mockResolvedValue({ + data: null, + error: new Error('network'), + }); + + const { result } = renderHook(() => useLoginMethods()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.loginMethods).toBeNull(); + }); + + it('leaves the methods unknown when the instance reports an empty list', async () => { + mockGetPublicSystemConfig.mockResolvedValue({ + data: { loginMethods: [] }, + error: null, + }); + + const { result } = renderHook(() => useLoginMethods()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.loginMethods).toBeNull(); + }); +}); + +describe('hasNonPasskeyLoginMethod', () => { + it.each([ + ['another method is enabled', ['passkey', 'magic_link'], true], + ['passkey is the only method', ['passkey'], false], + ['the methods are unknown', null, false], + ['the list is empty', [], false], + ])('is %s', (_label, methods, expected) => { + expect(hasNonPasskeyLoginMethod(methods as never)).toBe(expected); + }); +});