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
82 changes: 30 additions & 52 deletions packages/shared/src/components/auth/AuthOptionsInner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,17 @@ import {
} from '../../lib/auth';
import {
getBetterAuthErrorMessage,
getBetterAuthSocialRedirectData,
betterAuthSignInWithIdToken,
betterAuthSendVerificationOTP,
betterAuthVerifyEmailOTP,
getBetterAuthSocialRedirectData,
} from '../../lib/betterAuth';
import {
getSocialAuthCallbackError,
hasSocialAuthBootUser,
refetchSocialAuthBoot,
SOCIAL_AUTH_RETRY_MESSAGE,
} from './socialAuth';
import { webappUrl, broadcastChannel, isTesting } from '../../lib/constants';
import { getUserDefaultTimezone } from '../../lib/timezones';
import { shouldUseSocialAuthPopup } from '../../lib/func';
Expand Down Expand Up @@ -112,33 +118,6 @@ const EmailCodeVerification = dynamic(
);

const CHOSEN_PROVIDER_KEY = 'chosen_provider';
const SOCIAL_AUTH_RETRY_MESSAGE =
"We couldn't complete your social sign-in. Please try again.";

const getSocialAuthCallbackError = (data?: unknown): string | undefined => {
if (!data || typeof data !== 'object') {
return undefined;
}

const callbackData = data as Record<string, unknown>;
const { error, error_description: errorDescription, message } = callbackData;
if (typeof error === 'string' && error.trim().length > 0) {
return error;
}

if (
typeof errorDescription === 'string' &&
errorDescription.trim().length > 0
) {
return errorDescription;
}

if (typeof message === 'string' && message.trim().length > 0) {
return message;
}

return undefined;
};

function AuthOptionsInner({
onClose,
Expand Down Expand Up @@ -208,6 +187,7 @@ function AuthOptionsInner({
null,
);
const authFlowCompletedRef = useRef(false);
const authFlowSucceededRef = useRef(false);

const clearPopupCheck = () => {
if (popupCheckIntervalRef.current) {
Expand Down Expand Up @@ -422,27 +402,15 @@ function AuthOptionsInner({
}

const callbackError = getSocialAuthCallbackError(e?.data);
if (callbackError) {
setIsSocialAuthLoading(false);
logEvent({
event_name: socialErrorEventName.current,
extra: JSON.stringify({
provider: chosenProvider,
error: callbackError,
origin: 'betterauth social auth callback',
data:
typeof e?.data === 'object' ? JSON.stringify(e.data) : undefined,
}),
});
displayToast(SOCIAL_AUTH_RETRY_MESSAGE);
return;
}
const callbackData =
typeof e?.data === 'object' ? JSON.stringify(e.data) : undefined;

let boot;
try {
({ data: boot } = await refetchBoot());
boot = await refetchSocialAuthBoot(refetchBoot);
} catch (error) {
setIsSocialAuthLoading(false);
onAuthStateUpdate?.({ isLoading: false });
logEvent({
event_name: socialErrorEventName.current,
extra: JSON.stringify({
Expand All @@ -451,32 +419,41 @@ function AuthOptionsInner({
error,
'Failed to refresh Better Auth social auth state',
),
callbackError,
origin: 'betterauth social auth boot',
data:
typeof e?.data === 'object' ? JSON.stringify(e.data) : undefined,
data: callbackData,
}),
});
displayToast(SOCIAL_AUTH_RETRY_MESSAGE);
if (!authFlowSucceededRef.current) {
displayToast(SOCIAL_AUTH_RETRY_MESSAGE);
}
return;
}

if (!boot.user || !('email' in boot.user)) {
if (!hasSocialAuthBootUser(boot?.user)) {
setIsSocialAuthLoading(false);
onAuthStateUpdate?.({ isLoading: false });
logEvent({
event_name: socialErrorEventName.current,
extra: JSON.stringify({
provider: chosenProvider,
error:
callbackError ||
'Could not find authenticated user after social authentication',
origin: 'betterauth social auth boot',
data:
typeof e?.data === 'object' ? JSON.stringify(e.data) : undefined,
origin: callbackError
? 'betterauth social auth callback'
: 'betterauth social auth boot',
data: callbackData,
}),
});
displayToast(SOCIAL_AUTH_RETRY_MESSAGE);
if (!authFlowSucceededRef.current) {
displayToast(SOCIAL_AUTH_RETRY_MESSAGE);
}
return;
}

authFlowSucceededRef.current = true;

// If user is confirmed we can proceed with logging them in
if ('infoConfirmed' in boot.user && boot.user.infoConfirmed) {
setIsSocialAuthLoading(false);
Expand Down Expand Up @@ -524,6 +501,7 @@ function AuthOptionsInner({
extra: JSON.stringify({ trigger }),
});
socialErrorEventName.current = authErrorEventName;
authFlowSucceededRef.current = false;
setIsSocialAuthLoading(true);

const additionalData = { timezone: getUserDefaultTimezone() };
Expand Down
73 changes: 73 additions & 0 deletions packages/shared/src/components/auth/socialAuth.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import {
getSocialAuthCallbackError,
hasSocialAuthBootUser,
refetchSocialAuthBoot,
} from './socialAuth';

describe('socialAuth', () => {
describe('getSocialAuthCallbackError', () => {
it('returns the first callback error message', () => {
expect(
getSocialAuthCallbackError({
error: 'access_denied',
error_description: 'User denied access',
message: 'Fallback message',
}),
).toBe('access_denied');
});

it('ignores empty callback error fields', () => {
expect(
getSocialAuthCallbackError({
error: ' ',
error_description: '',
message: 'Fallback message',
}),
).toBe('Fallback message');
});
});

describe('hasSocialAuthBootUser', () => {
it('requires a boot user with an email field', () => {
expect(hasSocialAuthBootUser({ id: 'anonymous' })).toBe(false);
expect(
hasSocialAuthBootUser({
id: 'user',
email: 'user@daily.dev',
}),
).toBe(true);
});
});

describe('refetchSocialAuthBoot', () => {
it('retries until boot contains the authenticated user', async () => {
const refetchBoot = jest
.fn()
.mockResolvedValueOnce({ data: { user: { id: 'anonymous' } } })
.mockResolvedValueOnce({
data: { user: { id: 'user', email: 'user@daily.dev' } },
});

await expect(refetchSocialAuthBoot(refetchBoot, [0, 0])).resolves.toEqual(
{
user: { id: 'user', email: 'user@daily.dev' },
},
);
expect(refetchBoot).toHaveBeenCalledTimes(2);
});

it('returns the last boot response when auth never completes', async () => {
const refetchBoot = jest
.fn()
.mockResolvedValueOnce({ data: { user: { id: 'anonymous-1' } } })
.mockResolvedValueOnce({ data: { user: { id: 'anonymous-2' } } });

await expect(refetchSocialAuthBoot(refetchBoot, [0, 0])).resolves.toEqual(
{
user: { id: 'anonymous-2' },
},
);
expect(refetchBoot).toHaveBeenCalledTimes(2);
});
});
});
82 changes: 82 additions & 0 deletions packages/shared/src/components/auth/socialAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { Boot } from '../../lib/boot';

export const SOCIAL_AUTH_RETRY_MESSAGE =
"We couldn't complete your social sign-in. Please try again.";

const SOCIAL_AUTH_BOOT_RETRY_DELAYS_MS = [0, 250, 750] as const;

type BootUser = Partial<Boot>['user'];
type RefetchBoot = () => Promise<{ data?: Partial<Boot> }>;

const delay = (delayMs: number): Promise<void> =>
new Promise((resolve) => {
setTimeout(resolve, delayMs);
});

export const hasSocialAuthBootUser = (
user?: BootUser,
): user is NonNullable<BootUser> =>
!!user && typeof user === 'object' && 'email' in user;

const refetchSocialAuthBootAttempt = async (
refetchBoot: RefetchBoot,
retryDelaysMs: readonly number[],
attempt = 0,
lastBoot?: Partial<Boot>,
): Promise<Partial<Boot> | undefined> => {
const delayMs = retryDelaysMs[attempt];

if (typeof delayMs === 'undefined') {
return lastBoot;
}

if (delayMs > 0) {
await delay(delayMs);
}

const { data: boot } = await refetchBoot();

if (hasSocialAuthBootUser(boot?.user)) {
return boot;
}

return refetchSocialAuthBootAttempt(
refetchBoot,
retryDelaysMs,
attempt + 1,
boot,
);
};

export const getSocialAuthCallbackError = (
data?: unknown,
): string | undefined => {
if (!data || typeof data !== 'object') {
return undefined;
}

const callbackData = data as Record<string, unknown>;
const { error, error_description: errorDescription, message } = callbackData;
if (typeof error === 'string' && error.trim().length > 0) {
return error;
}

if (
typeof errorDescription === 'string' &&
errorDescription.trim().length > 0
) {
return errorDescription;
}

if (typeof message === 'string' && message.trim().length > 0) {
return message;
}

return undefined;
};

export const refetchSocialAuthBoot = async (
refetchBoot: RefetchBoot,
retryDelaysMs: readonly number[] = SOCIAL_AUTH_BOOT_RETRY_DELAYS_MS,
): Promise<Partial<Boot> | undefined> =>
refetchSocialAuthBootAttempt(refetchBoot, retryDelaysMs);
Loading