Skip to content
Open
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
5 changes: 4 additions & 1 deletion src/components/AuthorizerBasicAuthLogin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type MfaOfferData = {
email: string;
phone_number: string;
totpEnrollment: TotpEnrollment | null;
passkey: boolean;
emailOtp: boolean;
smsOtp: boolean;
state?: string;
Expand All @@ -36,6 +37,7 @@ const initMfaOfferData: MfaOfferData = {
email: '',
phone_number: '',
totpEnrollment: null,
passkey: false,
emailOtp: false,
smsOtp: false,
};
Expand Down Expand Up @@ -124,6 +126,7 @@ export const AuthorizerBasicAuthLogin: FC<{
email: data.email || ``,
phone_number: data.phone_number || ``,
totpEnrollment: step.totpEnrollment,
passkey: step.passkey,
emailOtp: step.emailOtp,
smsOtp: step.smsOtp,
state: urlProps?.state,
Expand Down Expand Up @@ -204,7 +207,7 @@ export const AuthorizerBasicAuthLogin: FC<{
<AuthorizerMFASetup
availableMfaMethods={{
totp: !!mfaOfferData.totpEnrollment || config.is_totp_mfa_enabled,
passkey: false,
passkey: mfaOfferData.passkey,
emailOtp: mfaOfferData.emailOtp,
smsOtp: mfaOfferData.smsOtp,
}}
Expand Down
24 changes: 22 additions & 2 deletions src/components/AuthorizerMFASetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ export const AuthorizerMFASetup: FC<{
},
{
key: 'passkey',
available: !!availableMfaMethods.passkey && !loginContext,
available: !!availableMfaMethods.passkey,
icon: <IconPasskey />,
title: 'Passkey',
description: 'Sign in with your fingerprint, face, or device PIN.',
Expand Down Expand Up @@ -308,7 +308,27 @@ export const AuthorizerMFASetup: FC<{
<>
<BackLink onClick={backToList} />
<p style={{ margin: '10px 0px', fontWeight: 'bold' }}>Add a passkey</p>
<AuthorizerPasskeyRegister onSuccess={backToList} showCredentials />
<AuthorizerPasskeyRegister
// showCredentials calls webauthn_credentials, which requires a
// bearer token - never available yet during a login-time offer.
showCredentials={!loginContext}
mfaSetup={
loginContext
? {
email: loginContext.email,
phoneNumber: loginContext.phone_number,
state: loginContext.state,
}
: undefined
}
onSuccess={(data) => {
if (loginContext && data && (data as AuthTokenLike).access_token) {
loginContext.onComplete(data as AuthTokenLike);
return;
}
backToList();
}}
/>
</>
);
}
Expand Down
31 changes: 21 additions & 10 deletions src/components/AuthorizerPasskeyLogin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { StyledButton, StyledSeparator } from '../styledComponents';
import { Message } from './Message';
import { AuthorizerMFASetup } from './AuthorizerMFASetup';
import { AuthorizerMfaLocked } from './AuthorizerMfaLocked';
import { AuthorizerVerifyOtp } from './AuthorizerVerifyOtp';
import { resolveAuthStep, AuthStep } from '../utils/mfaTriage';

// AuthorizerPasskeyLogin offers a full passwordless, usernameless "Sign in
Expand Down Expand Up @@ -142,7 +143,7 @@ export const AuthorizerPasskeyLogin: FC<{
<AuthorizerMFASetup
availableMfaMethods={{
totp: !!mfaStep.totpEnrollment || config.is_totp_mfa_enabled,
passkey: false,
passkey: mfaStep.passkey,
emailOtp: mfaStep.emailOtp,
smsOtp: mfaStep.smsOtp,
}}
Expand All @@ -166,16 +167,26 @@ export const AuthorizerPasskeyLogin: FC<{
}
if (mfaStep?.kind === 'verify') {
// A passkey-primary login that still needs a second factor has no
// email/phone_number in hand (usernameless login) - the existing
// AuthorizerVerifyOtp component requires one of those to identify the
// pending user via the MFA session cookie, so TOTP/email/SMS verify
// can't be completed from here. This is a real, narrower gap than the
// password-login path (which always has an email/phone from the form) -
// report it plainly rather than rendering a broken form.
// email/phone_number in hand (usernameless login). AuthorizerVerifyOtp
// still works here: verify_otp/resend_otp resolve the pending user from
// the MFA session cookie alone when no identifier is supplied (the same
// session-only path the OAuth-return continuation uses).
return (
<Message
type={MessageType.Error}
text="Additional verification is required. Please sign in with your password instead to continue."
<AuthorizerVerifyOtp
is_totp={mfaStep.totp}
offerWebauthnVerify={mfaStep.webauthn}
hasCodeFactor={mfaStep.totp || mfaStep.email || mfaStep.mobile}
onLogin={(data) => {
setAuthData({
user: data?.user || null,
token: data,
config,
loading: false,
});
if (onLogin) {
onLogin(data);
}
}}
/>
);
}
Expand Down
17 changes: 12 additions & 5 deletions src/components/AuthorizerPasskeyRegister.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { FC, useEffect, useState } from 'react';
import {
GenericResponse,
AuthToken,
WebauthnCredentialInfo,
isWebauthnSupported,
} from '@authorizerdev/authorizer-js';
Expand All @@ -21,16 +21,22 @@ import { Message } from './Message';
// browser can't run the WebAuthn JSON ceremony, since in a settings context
// the user needs to know why the option is missing.
export const AuthorizerPasskeyRegister: FC<{
// Called after a passkey is successfully registered.
onSuccess?: (data: GenericResponse | void) => void;
// Called after a passkey is successfully registered. On the mfaSetup path
// (below) this carries access_token when registration also completed a
// withheld MFA offer - a plain settings-page add never sets it.
onSuccess?: (data: AuthToken | void) => void;
// Optional friendly name for the credential (e.g. "MacBook Touch ID").
// When omitted a small inline field is shown so the user can name it.
name?: string;
// Show the list of already-registered passkeys above the add button.
// Requires an authenticated session; off by default so Storybook and
// unauthenticated hosts don't trigger a failing request.
showCredentials?: boolean;
}> = ({ onSuccess, name, showCredentials = false }) => {
// Present only during a token-withheld login-time MFA offer: authenticates
// the ceremony via the MFA session cookie instead of a bearer token (there
// isn't one yet), and completing it issues the previously-withheld token.
mfaSetup?: { email?: string; phoneNumber?: string; state?: string };
}> = ({ onSuccess, name, showCredentials = false, mfaSetup }) => {
const { authorizerRef } = useAuthorizer();
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
Expand Down Expand Up @@ -76,7 +82,8 @@ export const AuthorizerPasskeyRegister: FC<{
setLoading(true);
const trimmed = credentialName.trim();
const { data, errors } = await authorizerRef.registerPasskey(
trimmed || undefined
trimmed || undefined,
mfaSetup
);
if (errors && errors.length) {
setError(errors[0]?.message || 'Could not add passkey.');
Expand Down
25 changes: 23 additions & 2 deletions src/components/AuthorizerRoot.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { FC, useState } from 'react';
import { AuthToken, parseMfaRedirectParams } from '@authorizerdev/authorizer-js';

Check failure on line 2 in src/components/AuthorizerRoot.tsx

View workflow job for this annotation

GitHub Actions / build (20.x)

Module '"@authorizerdev/authorizer-js"' has no exported member 'parseMfaRedirectParams'.

Check failure on line 2 in src/components/AuthorizerRoot.tsx

View workflow job for this annotation

GitHub Actions / build (18.x)

Module '"@authorizerdev/authorizer-js"' has no exported member 'parseMfaRedirectParams'.

Check failure on line 2 in src/components/AuthorizerRoot.tsx

View workflow job for this annotation

GitHub Actions / size

Module '"@authorizerdev/authorizer-js"' has no exported member 'parseMfaRedirectParams'.

Check failure on line 2 in src/components/AuthorizerRoot.tsx

View workflow job for this annotation

GitHub Actions / build (20.x)

Module '"@authorizerdev/authorizer-js"' has no exported member 'parseMfaRedirectParams'.

Check failure on line 2 in src/components/AuthorizerRoot.tsx

View workflow job for this annotation

GitHub Actions / build (18.x)

Module '"@authorizerdev/authorizer-js"' has no exported member 'parseMfaRedirectParams'.

import { AuthorizerBasicAuthLogin } from './AuthorizerBasicAuthLogin';
import { useAuthorizer } from '../contexts/AuthorizerContext';
Expand All @@ -12,6 +12,7 @@
import { AuthorizerPasskeyLogin } from './AuthorizerPasskeyLogin';
import { AuthorizerMagicLinkLogin } from './AuthorizerMagicLinkLogin';
import { AuthorizerMFASetup } from './AuthorizerMFASetup';
import { AuthorizerVerifyOtp } from './AuthorizerVerifyOtp';
import { Message } from './Message';
import { createRandomString } from '../utils/common';
import { hasWindow } from '../utils/window';
Expand Down Expand Up @@ -71,11 +72,31 @@
text={`Unable to reach the Authorizer server (${configLoadError}). Login methods that depend on it - such as basic auth, signup, and social login - won't appear until it's reachable.`}
/>
)}
{mfaRedirect && (
{mfaRedirect && mfaRedirect.mfaGate === 'verify' && (
// An already-configured factor must be challenged, not offered setup
// again - no email/phone_number in hand (OAuth/magic-link return),
// but verify_otp resolves the pending user from the MFA session
// cookie alone, same as the passkey-primary-login continuation.
<AuthorizerVerifyOtp
is_totp={mfaRedirect.mfaMethods.includes('totp')}
offerWebauthnVerify={mfaRedirect.mfaMethods.includes('webauthn')}
hasCodeFactor={
mfaRedirect.mfaMethods.includes('totp') ||
mfaRedirect.mfaMethods.includes('email_otp') ||
mfaRedirect.mfaMethods.includes('sms_otp')
}
onLogin={(data: any) => {
if (onLogin) {
onLogin(data);
}
}}
/>
)}
{mfaRedirect && mfaRedirect.mfaGate === 'offer' && (
<AuthorizerMFASetup
availableMfaMethods={{
totp: mfaRedirect.mfaMethods.includes('totp'),
passkey: false,
passkey: mfaRedirect.mfaMethods.includes('webauthn'),
emailOtp: mfaRedirect.mfaMethods.includes('email_otp'),
smsOtp: mfaRedirect.mfaMethods.includes('sms_otp'),
}}
Expand Down
5 changes: 4 additions & 1 deletion src/components/AuthorizerSignup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ type MfaOfferData = {
email: string;
phone_number: string;
totpEnrollment: TotpEnrollment | null;
passkey: boolean;
emailOtp: boolean;
smsOtp: boolean;
state?: string;
Expand All @@ -60,6 +61,7 @@ const initMfaOfferData: MfaOfferData = {
email: '',
phone_number: '',
totpEnrollment: null,
passkey: false,
emailOtp: false,
smsOtp: false,
};
Expand Down Expand Up @@ -173,6 +175,7 @@ export const AuthorizerSignup: FC<{
email: data.email || ``,
phone_number: data.phone_number || ``,
totpEnrollment: step.totpEnrollment,
passkey: step.passkey,
emailOtp: step.emailOtp,
smsOtp: step.smsOtp,
state: urlProps?.state,
Expand Down Expand Up @@ -315,7 +318,7 @@ export const AuthorizerSignup: FC<{
<AuthorizerMFASetup
availableMfaMethods={{
totp: !!mfaOfferData.totpEnrollment || config.is_totp_mfa_enabled,
passkey: false,
passkey: mfaOfferData.passkey,
emailOtp: mfaOfferData.emailOtp,
smsOtp: mfaOfferData.smsOtp,
}}
Expand Down
10 changes: 5 additions & 5 deletions src/components/AuthorizerVerifyOtp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,11 @@ export const AuthorizerVerifyOtp: FC<{
otp: null,
});
const { authorizerRef, config, setAuthData } = useAuthorizer();
useEffect(() => {
if (!email && !phone_number) {
setError(`Email or Phone Number is required`);
}
}, []);
// No email/phone_number is a legitimate state here, not an error: the
// OAuth-return and passkey-primary-login MFA continuations resolve the
// pending user from the MFA session cookie alone (see verify_otp.go's
// sessionResolved path) - the frontend never learns their email/phone in
// those flows.

const [webauthnError, setWebauthnError] = useState(``);
const [webauthnLoading, setWebauthnLoading] = useState(false);
Expand Down
12 changes: 7 additions & 5 deletions src/utils/mfaTriage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ export type AuthStep =
| {
kind: 'offer';
totpEnrollment: TotpEnrollment | null;
// Passkey is intentionally excluded from the offer surface here - see
// this plan's Global Constraints: webauthn_registration_options/verify
// require a bearer token that doesn't exist in this withheld-token
// state, a known backend gap tracked separately, not fixed by this
// component.
// should_offer_webauthn_mfa_setup: the server-authenticated signal that
// passkey enrollment is available here. webauthn_registration_options/
// verify authenticate via the MFA session cookie on this path (no
// bearer token yet) - see AuthorizerMFASetup's mfaSetup prop.
passkey: boolean;
emailOtp: boolean;
smsOtp: boolean;
}
Expand Down Expand Up @@ -100,6 +100,7 @@ export function resolveAuthStep(
}
if (
hasTotpEnrollment ||
res.should_offer_webauthn_mfa_setup ||
res.should_offer_email_otp_mfa_setup ||
res.should_offer_sms_otp_mfa_setup
) {
Expand All @@ -112,6 +113,7 @@ export function resolveAuthStep(
authenticator_recovery_codes: res.authenticator_recovery_codes as string[],
}
: null,
passkey: !!res.should_offer_webauthn_mfa_setup,
emailOtp: !!res.should_offer_email_otp_mfa_setup,
smsOtp: !!res.should_offer_sms_otp_mfa_setup,
};
Expand Down
Loading