diff --git a/.changeset/oidc-signin-social-route.md b/.changeset/oidc-signin-social-route.md new file mode 100644 index 000000000..337df201b --- /dev/null +++ b/.changeset/oidc-signin-social-route.md @@ -0,0 +1,15 @@ +--- +'@object-ui/auth': patch +--- + +`signInWithProvider` with `type: 'oidc'` now signs in through better-auth's +core social route (`POST /sign-in/social`) and only falls back to the legacy +`POST /sign-in/oauth2` endpoint when the social route rejects the provider. + +better-auth ≥ 1.7 restructured the `genericOAuth` plugin: generic OAuth/OIDC +providers are injected into the core social sign-in flow and the dedicated +`/sign-in/oauth2` endpoint no longer exists. The old client therefore 404'd on +every "Continue with ObjectStack" click (platform SSO broken end-to-end on +current framework). The fallback keeps the button working against older +(< 1.7) servers during the coordinated rollout; when both routes fail, the +social-route error is surfaced since on a ≥ 1.7 server it is the real failure. diff --git a/packages/auth/src/__tests__/createAuthClient.test.ts b/packages/auth/src/__tests__/createAuthClient.test.ts index 156d43fd2..3641d3b88 100644 --- a/packages/auth/src/__tests__/createAuthClient.test.ts +++ b/packages/auth/src/__tests__/createAuthClient.test.ts @@ -397,17 +397,44 @@ describe('createAuthClient — signInWithProvider redirect contract (objectui#24 // silently — the user clicked "Continue with …" and nothing happened. The // client must throw so the buttons can surface an inline error. - it('oidc: rejects when /sign-in/oauth2 returns no url', async () => { + it('oidc: signs in through /sign-in/social (better-auth ≥ 1.7 servers)', async () => { const { mockFn, calls } = createMockFetch({ - '/sign-in/oauth2': { body: { redirect: false } }, + '/sign-in/social': { body: { url: 'https://cloud.example.com/oauth2/authorize?x=1', redirect: true } }, }); const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn }); await expect( client.signInWithProvider('objectstack-cloud', { type: 'oidc', callbackURL: '/x' }), - ).rejects.toThrow(/did not return a redirect URL/); + ).resolves.toBeUndefined(); + expect(calls.some((c) => c.url.includes('/sign-in/social') && c.method === 'POST')).toBe(true); + // The legacy route is only a fallback — it must NOT be hit when the + // social route succeeds. + expect(calls.some((c) => c.url.includes('/sign-in/oauth2'))).toBe(false); + }); + + it('oidc: falls back to /sign-in/oauth2 when /sign-in/social rejects (< 1.7 servers)', async () => { + const { mockFn, calls } = createMockFetch({ + '/sign-in/social': { status: 404, body: { message: 'Not found' } }, + '/sign-in/oauth2': { body: { url: 'https://cloud.example.com/oauth2/authorize?x=1', redirect: true } }, + }); + const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn }); + await expect( + client.signInWithProvider('objectstack-cloud', { type: 'oidc', callbackURL: '/x' }), + ).resolves.toBeUndefined(); + expect(calls.some((c) => c.url.includes('/sign-in/social') && c.method === 'POST')).toBe(true); expect(calls.some((c) => c.url.includes('/sign-in/oauth2') && c.method === 'POST')).toBe(true); }); + it('oidc: rejects when neither route returns a url', async () => { + const { mockFn, calls } = createMockFetch({ + '/sign-in/social': { body: { redirect: false } }, + }); + const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn }); + await expect( + client.signInWithProvider('objectstack-cloud', { type: 'oidc', callbackURL: '/x' }), + ).rejects.toThrow(/did not return a redirect URL/); + expect(calls.some((c) => c.url.includes('/sign-in/social') && c.method === 'POST')).toBe(true); + }); + it('social: rejects when /sign-in/social returns no url', async () => { const { mockFn } = createMockFetch({ '/sign-in/social': { body: {} }, @@ -418,9 +445,10 @@ describe('createAuthClient — signInWithProvider redirect contract (objectui#24 ).rejects.toThrow(/did not return a redirect URL/); }); - it('oidc: rejects with the server message when the endpoint errors', async () => { + it('oidc: surfaces the social-route error when the fallback also fails', async () => { const { mockFn } = createMockFetch({ - '/sign-in/oauth2': { status: 400, body: { message: 'Provider not found' } }, + '/sign-in/social': { status: 400, body: { message: 'Provider not found' } }, + '/sign-in/oauth2': { status: 404, body: { message: 'Not found' } }, }); const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn }); await expect( diff --git a/packages/auth/src/createAuthClient.ts b/packages/auth/src/createAuthClient.ts index c8847f17e..e3dda7d30 100644 --- a/packages/auth/src/createAuthClient.ts +++ b/packages/auth/src/createAuthClient.ts @@ -574,41 +574,46 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { // navigates the browser to `data.url` when the server responds with // `{ url, redirect: true }`). The server still returns that payload // for OAuth/OIDC providers — we just have to honour it ourselves. - if (type === 'oidc') { - const oauth2 = (betterAuth as unknown as { - signIn: { oauth2?: (args: Record) => Promise<{ data: { url?: string; redirect?: boolean } | null; error: { message?: string; status: number } | null }> }; - }).signIn.oauth2; - if (!oauth2) { - throw new Error('OIDC sign-in is not supported by this auth client build'); - } - const { data, error } = await oauth2({ providerId, callbackURL, errorCallbackURL }); - if (error) { - throw new Error(error.message ?? `Auth request failed with status ${error.status}`); - } - // A missing redirect URL means the flow CANNOT continue — surface it - // instead of resolving silently (the user would see a dead button). - if (!data?.url) { - throw new Error(`Sign-in with "${providerId}" did not return a redirect URL — please try again or contact your administrator.`); - } - if (typeof window !== 'undefined') { - window.location.href = data.url; - } - return; - } - const { data, error } = await betterAuth.signIn.social({ + const signInSocial = async () => await betterAuth.signIn.social({ provider: providerId as Parameters[0]['provider'], callbackURL, errorCallbackURL, - }); + }) as { data: { url?: string; redirect?: boolean } | null; error: { message?: string; status: number } | null }; + let result: Awaited>; + if (type === 'oidc') { + // better-auth ≥ 1.7 registers generic OAuth/OIDC providers through + // the core social sign-in flow — `POST /sign-in/social` with the + // provider id — and no longer mounts the dedicated + // `/sign-in/oauth2` endpoint. Try the social route first; when it + // rejects the provider (an older < 1.7 server that only knows OIDC + // providers via `/sign-in/oauth2`), fall back to the legacy route. + result = await signInSocial(); + if (result.error) { + const oauth2 = (betterAuth as unknown as { + signIn: { oauth2?: (args: Record) => Promise<{ data: { url?: string; redirect?: boolean } | null; error: { message?: string; status: number } | null }> }; + }).signIn.oauth2; + const legacy = oauth2 ? await oauth2({ providerId, callbackURL, errorCallbackURL }) : null; + if (legacy && !legacy.error) { + result = legacy; + } + // Legacy also failed (or is unavailable) — keep the social-route + // error below: on a ≥ 1.7 server it is the real failure, while the + // legacy route only 404s. + } + } else { + result = await signInSocial(); + } + const { data, error } = result; if (error) { throw new Error(error.message ?? `Auth request failed with status ${error.status}`); } - const socialUrl = (data as { url?: string; redirect?: boolean } | null)?.url; - if (!socialUrl) { + // A missing redirect URL means the flow CANNOT continue — surface it + // instead of resolving silently (the user would see a dead button). + if (!data?.url) { throw new Error(`Sign-in with "${providerId}" did not return a redirect URL — please try again or contact your administrator.`); } if (typeof window !== 'undefined') { - window.location.href = socialUrl; + window.location.href = data.url; } },