fix(auth): recover from SSO provider lookup errors (#6966)

Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
This commit is contained in:
Bill Leoutsakos
2026-08-22 10:27:37 -07:00
committed by GitHub
parent 4b4ab2479f
commit 87ceaf2e20
2 changed files with 104 additions and 28 deletions
+94 -8
View File
@@ -1,11 +1,13 @@
/**
* @vitest-environment jsdom
*/
import type { ReactNode } from 'react'
import { act, type ButtonHTMLAttributes, type InputHTMLAttributes, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { renderToString } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockUseSearchParams } = vi.hoisted(() => ({
const { mockSsoSignIn, mockUseSearchParams } = vi.hoisted(() => ({
mockSsoSignIn: vi.fn(),
mockUseSearchParams: vi.fn(),
}))
@@ -21,19 +23,35 @@ vi.mock('next/link', () => ({
}))
vi.mock('@sim/emcn', () => ({
Button: ({ children }: { children?: ReactNode }) => <button type='button'>{children}</button>,
Input: () => <input />,
Button: ({ children, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type='button' {...props}>
{children}
</button>
),
Input: (props: InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
Label: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
cn: (...values: unknown[]) => values.filter(Boolean).join(' '),
}))
vi.mock('@/lib/auth/auth-client', () => ({
client: { signIn: { sso: vi.fn() } },
client: { signIn: { sso: mockSsoSignIn } },
}))
vi.mock('@/app/(auth)/components', () => ({
AuthSubmitButton: ({ children }: { children?: ReactNode }) => (
<button type='submit'>{children}</button>
AuthSubmitButton: ({
children,
disabled = false,
loading = false,
loadingLabel,
}: {
children?: ReactNode
disabled?: boolean
loading?: boolean
loadingLabel: string
}) => (
<button type='submit' disabled={disabled || loading}>
{loading ? loadingLabel : children}
</button>
),
}))
@@ -49,6 +67,22 @@ function renderFirstFrame(search: string, registrationDisabled = false): string
return renderToString(<SSOForm registrationDisabled={registrationDisabled} />)
}
let container: HTMLDivElement
let root: Root
function renderInteractive(search = '') {
mockUseSearchParams.mockReturnValue(new URLSearchParams(search))
act(() => root.render(<SSOForm registrationDisabled={false} />))
}
async function submitForm() {
const form = container.querySelector('form')
expect(form).not.toBeNull()
await act(async () => {
form?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
})
}
/**
* `renderToString` produces the markup of the first frame with no effects run,
* which is exactly the window in which a callback URL seeded from an effect is
@@ -99,3 +133,55 @@ describe('SSOForm signup cross-link', () => {
expect(html).not.toContain('/signup')
})
})
describe('SSOForm sign-in errors', () => {
beforeEach(() => {
mockSsoSignIn.mockReset()
mockUseSearchParams.mockReset()
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
it('shows a generic retryable error when Better Auth resolves with a 404', async () => {
mockSsoSignIn.mockResolvedValue({
data: null,
error: {
message: 'No provider found for the issuer',
status: 404,
statusText: 'Not Found',
},
})
renderInteractive('email=user%40example.com')
await submitForm()
expect(container).toHaveTextContent('Unable to start SSO. Check your email and try again.')
expect(container).not.toHaveTextContent('No provider found for the issuer')
const submitButton = container.querySelector<HTMLButtonElement>('button[type="submit"]')
expect(submitButton?.disabled).toBe(false)
expect(submitButton).toHaveTextContent('Continue with SSO')
await submitForm()
expect(mockSsoSignIn).toHaveBeenCalledTimes(2)
})
it('does not expose the message from a rejected sign-in request', async () => {
mockSsoSignIn.mockRejectedValue(new Error('INVALID_EMAIL_DOMAIN'))
renderInteractive('email=user%40example.com')
await submitForm()
expect(container).toHaveTextContent('Unable to start SSO. Check your email and try again.')
expect(container).not.toHaveTextContent('INVALID_EMAIL_DOMAIN')
const submitButton = container.querySelector<HTMLButtonElement>('button[type="submit"]')
expect(submitButton?.disabled).toBe(false)
expect(submitButton).toHaveTextContent('Continue with SSO')
})
})
+10 -20
View File
@@ -12,6 +12,7 @@ import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { AuthSubmitButton } from '@/app/(auth)/components'
const logger = createLogger('SSOForm')
const SSO_SIGN_IN_ERROR = 'Unable to start SSO. Check your email and try again.'
const validateEmailField = (emailValue: string): string[] => {
const errors: string[] = []
@@ -110,33 +111,22 @@ export default function SSOForm({ registrationDisabled }: SSOFormProps) {
try {
const safeCallbackUrl = callbackUrl
await client.signIn.sso({
const result = await client.signIn.sso({
email: emailValue,
callbackURL: safeCallbackUrl,
errorCallbackURL: `/sso?error=sso_failed&callbackUrl=${encodeURIComponent(safeCallbackUrl)}`,
})
if (!result || result.error) {
logger.error('SSO sign-in failed', { error: result?.error, email: emailValue })
setEmailErrors([SSO_SIGN_IN_ERROR])
setShowEmailValidationError(true)
}
} catch (err) {
logger.error('SSO sign-in failed', { error: err, email: emailValue })
let errorMessage = 'SSO sign-in failed. Please try again.'
if (err instanceof Error) {
if (err.message.includes('NO_PROVIDER_FOUND')) {
errorMessage = 'SSO provider not found. Please check your configuration.'
} else if (err.message.includes('INVALID_EMAIL_DOMAIN')) {
errorMessage = 'Email domain not configured for SSO. Please contact your administrator.'
} else if (err.message.includes('network')) {
errorMessage = 'Network error. Please check your connection and try again.'
} else if (err.message.includes('rate limit')) {
errorMessage = 'Too many requests. Please wait a moment before trying again.'
} else if (err.message.includes('SSO_DISABLED')) {
errorMessage = 'SSO authentication is disabled. Please use another sign-in method.'
} else {
errorMessage = err.message
}
}
setEmailErrors([errorMessage])
setEmailErrors([SSO_SIGN_IN_ERROR])
setShowEmailValidationError(true)
} finally {
setIsLoading(false)
}
}