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
25 changes: 25 additions & 0 deletions .changeset/skip-passkey-registration.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions src/client/createSeamlessAuthClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type {
OrganizationMembershipEnvelopeResponse,
OrganizationSwitchResponse,
PublicOAuthProvider,
PublicSystemConfigResponse,
RegistrationRequest,
StartOAuthLoginResponse,
StepUpMethod as StepUpMethodShape,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -220,6 +228,7 @@ export interface SeamlessAuthClient {
checkMagicLink: () => Promise<SeamlessAuthResult<MessageResult>>;
verifyMagicLink: (token: string) => Promise<SeamlessAuthResult<MessageResult>>;
listOAuthProviders: () => Promise<SeamlessAuthResult<OAuthProvidersResult>>;
getPublicSystemConfig: () => Promise<SeamlessAuthResult<PublicSystemConfigResult>>;
startOAuthLogin: (
input: StartOAuthLoginInput
) => Promise<SeamlessAuthResult<StartOAuthLoginResult>>;
Expand Down Expand Up @@ -575,6 +584,12 @@ export const createSeamlessAuthClient = (
'Failed to list OAuth providers.'
),

getPublicSystemConfig: () =>
requestResult<PublicSystemConfigResult>(
fetchWithAuth(`/system-config/public`, { method: 'GET' }),
'Failed to read the public system configuration.'
),

startOAuthLogin: input =>
requestResult<StartOAuthLoginResult>(
fetchWithAuth(`/oauth/${encodeURIComponent(input.providerId)}/start`, {
Expand Down
5 changes: 4 additions & 1 deletion src/components/AuthFallbackOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ interface AuthFallbackOptionsProps {
onEmailOtp?: () => void;
onPhoneOtp: () => void;
onPasskeyRetry?: () => void;
loginMethods?: LoginMethod[];
loginMethods?: LoginMethod[] | null;
}

const AuthFallbackOptions: React.FC<AuthFallbackOptionsProps> = ({
Expand All @@ -27,6 +27,9 @@ const AuthFallbackOptions: React.FC<AuthFallbackOptionsProps> = ({
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<LoginMethod>(
loginMethods ?? ['passkey', 'magic_link', 'phone_otp']
);
Expand Down
74 changes: 74 additions & 0 deletions src/hooks/useLoginMethods.ts
Original file line number Diff line number Diff line change
@@ -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<LoginMethod[] | null>(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'));
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
MessageResult,
OAuthProvider,
OAuthProvidersResult,
PublicSystemConfigResult,
OrganizationMemberInput,
OrganizationMemberUpdateInput,
OrganizationMembersResult,
Expand Down Expand Up @@ -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';
Expand All @@ -69,12 +71,14 @@ export {
extractPasskeyPrfResult,
getOAuthErrorCode,
getWebAuthnErrorDetail,
hasNonPasskeyLoginMethod,
hasScopedRole,
isPasskeyPrfSupported,
roleGrantsAccess,
SeamlessAuthError,
useAuth,
useAuthClient,
useLoginMethods,
usePasskeySupport,
};
export type {
Expand Down Expand Up @@ -105,6 +109,7 @@ export type {
PasskeyPrfInput,
PasskeyPrfResult,
PasskeyRegistrationData,
PublicSystemConfigResult,
RegisterInput,
RegisterPasskeyOptions,
SeamlessAuthClient,
Expand Down
16 changes: 16 additions & 0 deletions src/styles/registerPasskey.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
11 changes: 7 additions & 4 deletions src/views/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -16,21 +17,20 @@ 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<string>('');
const [email, setEmail] = useState<string>('');
const [mode, setMode] = useState<'login' | 'register'>('register');
const [formErrors, setFormErrors] = useState<string>('');
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[] | null>(null);

useEffect(() => {
if (hasSignedInBefore) {
Expand Down Expand Up @@ -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')) {
Expand Down
Loading
Loading