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: 5 additions & 0 deletions .changeset/shared-sign-in-factor-helpers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': patch
---

Add an internal module with the starting sign-in factor helper used by `@clerk/ui`.
2 changes: 1 addition & 1 deletion packages/mosaic/docs/internals/sign-in-sign-up-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Sign-up:

`identifier` and `start` are what Mosaic shows before the resource exists; a sign-in with no status and a sign-up not yet created both return them. `sent` means a code or link is out, so a reload on the same `:factor` doesn't send another. Picking a different factor always sends, because `sent` can't tell two targets with the same strategy apart (see [Bridge](#bridge)).

With no `:factor`, `factorOne` starts on the factor `ui`'s `determineStartingSignInFactor` picks (`ui/src/components/SignIn/utils.ts:76`). That rule moves to `shared` so both use one copy.
With no `:factor`, `factorOne` starts on the factor `determineStartingSignInFactor` picks (`shared/src/internal/clerk-js/signInFactors.ts`), the same copy `ui` uses.

## Back, forward and reload

Expand Down
102 changes: 102 additions & 0 deletions packages/shared/src/internal/clerk-js/__tests__/signInFactors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';

import type { SignInFactor } from '@/types';

import { determineStartingSignInFactor } from '../signInFactors';

describe('determineStartingSignInFactor', () => {
describe('with password as the preferred sign in strategy', () => {
it('selects password if available', () => {
const factors: SignInFactor[] = [{ strategy: 'password' }];
expect(determineStartingSignInFactor(factors, null, 'password', false)).toEqual({ strategy: 'password' });
});

it('selects based on user input in the previous step if password is not available', () => {
const factors: SignInFactor[] = [
{ strategy: 'email_code', emailAddressId: 'idn_1', safeIdentifier: 'ccoe@example.com' },
{ strategy: 'phone_code', phoneNumberId: 'idn_2', safeIdentifier: 'jdoe@example.com' },
];
expect(determineStartingSignInFactor(factors, 'jdoe@example.com', 'password', false)).toEqual({
strategy: 'phone_code',
phoneNumberId: 'idn_2',
safeIdentifier: 'jdoe@example.com',
});
});

it('selects by prioritizing email_code if all the above fail', () => {
const factors: SignInFactor[] = [
{ strategy: 'phone_code', phoneNumberId: 'idn_2', safeIdentifier: 'jdoe@example.com' },
{ strategy: 'email_code', emailAddressId: 'idn_1', safeIdentifier: 'ccoe@example.com' },
];
expect(determineStartingSignInFactor(factors, null, 'password', false)).toEqual({
strategy: 'email_code',
emailAddressId: 'idn_1',
safeIdentifier: 'ccoe@example.com',
});
});

it('selects phone_code if all the above fail', () => {
const factors: SignInFactor[] = [
{ strategy: 'phone_code', phoneNumberId: 'idn_2', safeIdentifier: 'jdoe@example.com' },
];
expect(determineStartingSignInFactor(factors, null, 'password', false)).toEqual({
strategy: 'phone_code',
phoneNumberId: 'idn_2',
safeIdentifier: 'jdoe@example.com',
});
});

it('returns null if every other scenario', () => {
expect(determineStartingSignInFactor([], null, 'password', false)).toBeNull();
});
});

describe('with OTP as the preferred sign in strategy', () => {
it('selects based on user input in the previous step', () => {
const factors: SignInFactor[] = [
{ strategy: 'password' },
{ strategy: 'email_code', emailAddressId: 'idn_1', safeIdentifier: 'ccoe@example.com' },
{ strategy: 'phone_code', phoneNumberId: 'idn_2', safeIdentifier: 'jdoe@example.com' },
];
expect(determineStartingSignInFactor(factors, 'jdoe@example.com', 'otp', false)).toEqual({
strategy: 'phone_code',
phoneNumberId: 'idn_2',
safeIdentifier: 'jdoe@example.com',
});
});

it('selects by prioritizing email_code if the above fails', () => {
const factors: SignInFactor[] = [
{ strategy: 'password' },
{ strategy: 'phone_code', phoneNumberId: 'idn_2', safeIdentifier: 'jdoe@example.com' },
{ strategy: 'email_code', emailAddressId: 'idn_1', safeIdentifier: 'ccoe@example.com' },
];
expect(determineStartingSignInFactor(factors, null, 'otp', false)).toEqual({
strategy: 'email_code',
emailAddressId: 'idn_1',
safeIdentifier: 'ccoe@example.com',
});
});

it('selects phone_code if all the above fail', () => {
const factors: SignInFactor[] = [
{ strategy: 'password' },
{ strategy: 'phone_code', phoneNumberId: 'idn_2', safeIdentifier: 'jdoe@example.com' },
];
expect(determineStartingSignInFactor(factors, null, 'otp', false)).toEqual({
strategy: 'phone_code',
phoneNumberId: 'idn_2',
safeIdentifier: 'jdoe@example.com',
});
});

it('selects password as a last resort if available', () => {
const factors: SignInFactor[] = [{ strategy: 'password' }];
expect(determineStartingSignInFactor(factors, null, 'otp', false)).toEqual({ strategy: 'password' });
});

it('returns null if every other scenario', () => {
expect(determineStartingSignInFactor([], null, 'otp', false)).toBeNull();
});
});
});
95 changes: 95 additions & 0 deletions packages/shared/src/internal/clerk-js/signInFactors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { PreferredSignInStrategy, SignInFactor, SignInStrategy } from '../../types';

/**
* Picks the first factor a sign-in starts on, following the algorithm at
* https://www.notion.so/clerkdev/Implement-sign-in-alt-methods-e6e60ffb644645b3a0553b50556468ce
*
* @internal
*/
export function determineStartingSignInFactor(
firstFactors: SignInFactor[] | null,
identifier: string | null,
preferredSignInStrategy: PreferredSignInStrategy,
webAuthnSupported: boolean,
): SignInFactor | null | undefined {
if (!firstFactors || firstFactors.length === 0) {
return null;
}

return preferredSignInStrategy === 'password'
? determineStrategyWhenPasswordIsPreferred(firstFactors, identifier, webAuthnSupported)
: determineStrategyWhenOTPIsPreferred(firstFactors, identifier, webAuthnSupported);
}

function determineStrategyWhenPasswordIsPreferred(
factors: SignInFactor[],
identifier: string | null,
webAuthnSupported: boolean,
): SignInFactor | null {
const passkeyFactor = findPasskeyStrategy(factors, webAuthnSupported);
if (passkeyFactor) {
return passkeyFactor;
}
const selected = factors.sort(passwordPrefFactorComparator)[0];
if (selected.strategy === 'password') {
return selected;
}
return factors.find(factorForIdentifier(identifier)) || selected || null;
}

function determineStrategyWhenOTPIsPreferred(
factors: SignInFactor[],
identifier: string | null,
webAuthnSupported: boolean,
): SignInFactor | null {
const passkeyFactor = findPasskeyStrategy(factors, webAuthnSupported);
if (passkeyFactor) {
return passkeyFactor;
}
const sortedBasedOnPrefFactor = factors.sort(otpPrefFactorComparator);
const forIdentifier = sortedBasedOnPrefFactor.find(factorForIdentifier(identifier));
if (forIdentifier) {
return forIdentifier;
}
const firstBasedOnPref = sortedBasedOnPrefFactor[0];
if (firstBasedOnPref.strategy === 'email_link') {
return firstBasedOnPref;
}
return factors.find(factorForIdentifier(identifier)) || firstBasedOnPref || null;
}

function findPasskeyStrategy(factors: SignInFactor[], webAuthnSupported: boolean): SignInFactor | null {
if (webAuthnSupported) {
const passkeyFactor = factors.find(({ strategy }) => strategy === 'passkey');

if (passkeyFactor) {
return passkeyFactor;
}
}
return null;
}

const factorForIdentifier = (i: string | null) => (f: SignInFactor) => {
return 'safeIdentifier' in f && f.safeIdentifier === i;
};

const passwordPrefFactorComparator = makeSortingFunction([
'passkey',
'password',
'email_link',
'email_code',
'phone_code',
]);

const otpPrefFactorComparator = makeSortingFunction(['email_link', 'email_code', 'phone_code', 'passkey', 'password']);

function makeSortingFunction(order: SignInStrategy[]) {
return (a: SignInFactor, b: SignInFactor): number => {
const orderA = order.indexOf(a.strategy);
const orderB = order.indexOf(b.strategy);
if (orderA === -1 || orderB === -1) {
return 0;
}
return orderA - orderB;
};
}
3 changes: 2 additions & 1 deletion packages/ui/src/components/SignIn/SignInFactorOne.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { determineStartingSignInFactor } from '@clerk/shared/internal/clerk-js/signInFactors';
import { useClerk } from '@clerk/shared/react';
import type { SignInFactor } from '@clerk/shared/types';
import React from 'react';
Expand Down Expand Up @@ -26,7 +27,7 @@ import { SignInFactorOnePasswordCard } from './SignInFactorOnePasswordCard';
import { SignInFactorOnePhoneCodeCard } from './SignInFactorOnePhoneCodeCard';
import { SignInFactorOneSSOBypass } from './SignInFactorOneSSOBypass';
import { useResetPasswordFactor } from './useResetPasswordFactor';
import { determineStartingSignInFactor, factorHasLocalStrategy } from './utils';
import { factorHasLocalStrategy } from './utils';

const factorKey = (factor: SignInFactor | null | undefined) => {
Comment thread
alexcarpenter marked this conversation as resolved.
Resolved
if (!factor) {
Expand Down
Loading
Loading