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
15 changes: 15 additions & 0 deletions .changeset/oidc-signin-social-route.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 33 additions & 5 deletions packages/auth/src/__tests__/createAuthClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} },
Expand All @@ -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(
Expand Down
57 changes: 31 additions & 26 deletions packages/auth/src/createAuthClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => 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<typeof betterAuth.signIn.social>[0]['provider'],
callbackURL,
errorCallbackURL,
});
}) as { data: { url?: string; redirect?: boolean } | null; error: { message?: string; status: number } | null };
let result: Awaited<ReturnType<typeof signInSocial>>;
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<string, unknown>) => 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;
}
},

Expand Down
Loading