From 30270b5c3058dfe5e28344b6ac932a16c16619de Mon Sep 17 00:00:00 2001 From: Xiyuan Chen <52963600+GareArc@users.noreply.github.com> Date: Thu, 28 May 2026 23:51:34 -0700 Subject: [PATCH] fix(device): surface SSO errors on /device and fix CLI null-account crash on external-SSO login (#36781) --- cli/src/api/oauth-device.ts | 2 +- cli/src/commands/auth/login/login.test.ts | 2 + cli/src/commands/auth/login/login.ts | 6 +-- cli/test/fixtures/dify-mock/server.ts | 3 ++ .../device/__tests__/page-terminal.spec.tsx | 47 ++++++++++++++++++- web/app/device/page.tsx | 9 +++- web/app/device/utils/error-copy.ts | 12 +++++ 7 files changed, 75 insertions(+), 6 deletions(-) diff --git a/cli/src/api/oauth-device.ts b/cli/src/api/oauth-device.ts index 1368a7617b..582b5f06b2 100644 --- a/cli/src/api/oauth-device.ts +++ b/cli/src/api/oauth-device.ts @@ -40,7 +40,7 @@ export type PollSuccess = { subject_type?: string subject_email?: string subject_issuer?: string - account?: PollAccount + account?: PollAccount | null workspaces?: readonly PollWorkspace[] default_workspace_id?: string token_id?: string diff --git a/cli/src/commands/auth/login/login.test.ts b/cli/src/commands/auth/login/login.test.ts index c93a2a824f..8436473634 100644 --- a/cli/src/commands/auth/login/login.test.ts +++ b/cli/src/commands/auth/login/login.test.ts @@ -106,6 +106,8 @@ describe('runLogin', () => { expect(bundle.account).toBeUndefined() expect(bundle.external_subject?.email).toBe('sso@dify.ai') expect(bundle.external_subject?.issuer).toBe('https://issuer.example') + const stored = await store.get(bundle.current_host, 'sso@dify.ai') + expect(stored).toBe('dfoe_test') expect(io.outBuf()).toContain('external SSO') expect(io.outBuf()).toContain('sso@dify.ai') }) diff --git a/cli/src/commands/auth/login/login.ts b/cli/src/commands/auth/login/login.ts index b06dca24f3..d30ebee26a 100644 --- a/cli/src/commands/auth/login/login.ts +++ b/cli/src/commands/auth/login/login.ts @@ -99,7 +99,7 @@ function renderCodePrompt(w: NodeJS.WritableStream, cs: ReturnType, host: string, s: PollSuccess): void { const display = bareHost(host) - if (s.account !== undefined && s.account.email !== '') { + if (s.account && s.account.email !== '') { w.write(`${cs.successIcon()} Logged in to ${display} as ${cs.bold(s.account.email)} (${s.account.name})\n`) const ws = findDefaultWorkspace(s) if (ws !== undefined) @@ -139,11 +139,11 @@ function bundleFromSuccess(host: string, s: PollSuccess, mode: StorageMode): Hos token_id: s.token_id, tokens: { bearer: s.token }, } - if (s.account !== undefined) { + if (s.account) { bundle.account = { id: s.account.id, email: s.account.email, name: s.account.name } } if (s.subject_email !== undefined && s.subject_email !== '' - && (s.account === undefined || s.account.id === '')) { + && (!s.account || s.account.id === '')) { bundle.external_subject = { email: s.subject_email, issuer: s.subject_issuer ?? '', diff --git a/cli/test/fixtures/dify-mock/server.ts b/cli/test/fixtures/dify-mock/server.ts index b4c5ac6426..628272224b 100644 --- a/cli/test/fixtures/dify-mock/server.ts +++ b/cli/test/fixtures/dify-mock/server.ts @@ -356,6 +356,9 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { subject_type: 'external_sso', subject_email: 'sso@dify.ai', subject_issuer: 'https://issuer.example', + account: null, + workspaces: [], + default_workspace_id: null, token_id: 'tok-sso-1', }) } diff --git a/web/app/device/__tests__/page-terminal.spec.tsx b/web/app/device/__tests__/page-terminal.spec.tsx index 57d749897c..2ae450011e 100644 --- a/web/app/device/__tests__/page-terminal.spec.tsx +++ b/web/app/device/__tests__/page-terminal.spec.tsx @@ -6,9 +6,10 @@ import DevicePage from '../page' const mockPush = vi.fn() const mockReplace = vi.fn() const mockDeviceLookup = vi.fn() +let mockSearchParams: Record = {} vi.mock('@/next/navigation', () => ({ - useSearchParams: () => ({ get: () => null }), + useSearchParams: () => ({ get: (key: string) => mockSearchParams[key] ?? null }), useRouter: () => ({ push: mockPush, replace: mockReplace }), usePathname: () => '/device', })) @@ -53,6 +54,12 @@ let MockDeviceFlowError: MockDeviceFlowErrorCtor beforeEach(async () => { vi.clearAllMocks() + mockSearchParams = {} + // router.replace(pathname) in the real app drops the query string; mirror + // that so useSearchParams reflects the cleared URL on the next render. + mockReplace.mockImplementation(() => { + mockSearchParams = {} + }) mockUseQuery.mockReturnValue({ data: undefined, isError: false } as ReturnType) const mod = await import('@/service/device-flow') as { DeviceFlowError: MockDeviceFlowErrorCtor } MockDeviceFlowError = mod.DeviceFlowError @@ -110,3 +117,41 @@ describe('error_lookup_failed terminal state', () => { expect(screen.queryByText('Could not verify the code')).not.toBeInTheDocument() }) }) + +describe('sso_error inline banner on the code-entry page', () => { + const SSO_BANNER_COPY = /identity is linked to a Dify account/i + + it('shows the error banner with friendly copy when sso_error is present', async () => { + mockSearchParams = { sso_error: 'email_belongs_to_dify_account' } + render() + expect(await screen.findByText(SSO_BANNER_COPY)).toBeInTheDocument() + }) + + it('keeps the code-entry screen visible (error on main page, not a separate view)', async () => { + mockSearchParams = { sso_error: 'email_belongs_to_dify_account' } + render() + await screen.findByText(SSO_BANNER_COPY) + expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Continue/i })).toBeInTheDocument() + }) + + it('does not surface the raw backend error code', async () => { + mockSearchParams = { sso_error: 'email_belongs_to_dify_account' } + render() + await screen.findByText(SSO_BANNER_COPY) + expect(screen.queryByText('email_belongs_to_dify_account')).not.toBeInTheDocument() + }) + + it('does not scrub the param on mount (regression: error was wiped by router.replace)', async () => { + mockSearchParams = { sso_error: 'email_belongs_to_dify_account' } + render() + await screen.findByText(SSO_BANNER_COPY) + expect(mockReplace).not.toHaveBeenCalled() + }) + + it('shows no banner when sso_error is absent', () => { + render() + expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.queryByText(SSO_BANNER_COPY)).not.toBeInTheDocument() + }) +}) diff --git a/web/app/device/page.tsx b/web/app/device/page.tsx index 83def36a75..1033eb228e 100644 --- a/web/app/device/page.tsx +++ b/web/app/device/page.tsx @@ -14,7 +14,7 @@ import AuthorizeAccount from './components/authorize-account' import AuthorizeSSO from './components/authorize-sso' import Chooser from './components/chooser' import CodeInput from './components/code-input' -import { classifyLookupError } from './utils/error-copy' +import { classifyLookupError, ssoErrorCopy } from './utils/error-copy' import { isValidUserCode } from './utils/user-code' type View @@ -33,6 +33,7 @@ export default function DevicePage() { const pathname = usePathname() const urlUserCode = (searchParams.get('user_code') || '').trim().toUpperCase() const ssoVerified = searchParams.get('sso_verified') === '1' + const ssoError = searchParams.get('sso_error') || '' const [typed, setTyped] = useState('') const [view, setView] = useState({ kind: 'code_entry' }) @@ -125,6 +126,12 @@ export default function DevicePage() { <> {view.kind === 'code_entry' && (
+ {ssoError && ( +
+ +

{ssoErrorCopy(ssoError)}

+
+ )}

Authorize Dify CLI

diff --git a/web/app/device/utils/error-copy.ts b/web/app/device/utils/error-copy.ts index 9360fb167e..d0184dad7b 100644 --- a/web/app/device/utils/error-copy.ts +++ b/web/app/device/utils/error-copy.ts @@ -30,6 +30,18 @@ export function approveErrorCopy(err: unknown): string { return DEFAULT_MESSAGE } +// SSO-branch failures arrive as a `sso_error` query param set by the backend +// (oauth_device_sso sso-complete) when it redirects back to /device. +const SSO_ERROR_COPY: Record = { + email_belongs_to_dify_account: 'This identity is linked to a Dify account. Use “Sign in with Dify account” instead.', +} + +const DEFAULT_SSO_ERROR_MESSAGE = 'Single sign-on could not be completed. Try again.' + +export function ssoErrorCopy(code: string): string { + return SSO_ERROR_COPY[code] ?? DEFAULT_SSO_ERROR_MESSAGE +} + export type LookupOutcome = 'expired' | 'rate_limited' | 'failed' export function classifyLookupError(err: unknown): LookupOutcome {