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
39 changes: 27 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,38 +411,53 @@ export const Route = createFileRoute('/api/auth/callback')({
});
```

**With hooks for custom logic:**
**With a sign-in error page (browser-friendly default):**

```typescript
export const Route = createFileRoute('/api/auth/callback')({
server: {
handlers: {
GET: handleCallbackRoute({
errorRedirectUrl: '/sign-in?error=auth_failed',
}),
},
},
});
```

The user lands on `/sign-in?error=auth_failed` (a route you own) instead of seeing raw JSON. Verifier-delete cookies are still attached.

**With Sentry capture:**

```typescript
import * as Sentry from '@sentry/node';

export const Route = createFileRoute('/api/auth/callback')({
server: {
handlers: {
GET: handleCallbackRoute({
onSuccess: async ({ user, authenticationMethod }) => {
// Create user record in your database
await db.users.upsert({ id: user.id, email: user.email });
// Track analytics
analytics.track('User Signed In', { method: authenticationMethod });
},
onError: ({ error, request }) => {
// Custom error handling
console.error('Auth failed:', error);
return new Response(JSON.stringify({ error: 'Authentication failed' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
Sentry.captureException(error, { extra: { url: request.url } });
return Response.redirect(new URL('/sign-in?error=auth_failed', request.url));
},
}),
},
},
});
```

`onError` runs for every callback failure (missing `code`, state mismatch, token exchange failure, `onSuccess` throws). The SDK already emits a `console.error` for every failure, so if you wire Sentry's `console.error` ingestion you don't need to call `Sentry.captureException` yourself.

**Options:**

- `onSuccess?: (data) => Promise<void>` - Called after successful authentication with user data, tokens, and authentication method
- `onError?: ({ error, request }) => Response` - Custom error handler that returns a Response
- `returnPathname?: string` - Override the redirect path after authentication (defaults to state or `/`)
- `onSuccess?: (data) => Promise<void>` — Called after successful authentication with user data, tokens, and authentication method.
- `onError?: ({ error, request }) => Response` — Custom error handler that returns a Response. Errors thrown from inside `onError` are NOT caught by the SDK.
- `errorRedirectUrl?: string` — URL (absolute or relative) to redirect to on callback failure when `onError` is not set. If both are set, `onError` wins. Set this at route-construction time only — do not derive from request input (it would be an open-redirect vector).
- `returnPathname?: string` — Override the success-path redirect after authentication. Does not apply to errors.

### Client Hooks

Expand Down
199 changes: 187 additions & 12 deletions src/server/server.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getPKCECookieNameForState } from '@workos/authkit-session';
import type { HandleCallbackOptions } from './types';

const SEALED_STATE = 'sealed-state-fixture';
const PKCE_COOKIE_NAME = getPKCECookieNameForState(SEALED_STATE);
Expand Down Expand Up @@ -56,6 +57,8 @@ const successResult = (overrides: Record<string, unknown> = {}) => ({
});

describe('handleCallbackRoute', () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
vi.clearAllMocks();
mockRedirectUriFromContext = undefined;
Expand All @@ -66,6 +69,11 @@ describe('handleCallbackRoute', () => {
createSignIn: mockCreateSignIn,
clearPendingVerifier: mockClearPendingVerifier,
});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
consoleErrorSpy.mockRestore();
});

describe('missing code', () => {
Expand Down Expand Up @@ -240,7 +248,6 @@ describe('handleCallbackRoute', () => {
it('returns 500 with generic body on handleCallback failure', async () => {
const request = new Request(`http://example.com/callback?code=invalid&state=${encodeURIComponent(SEALED_STATE)}`);
mockHandleCallback.mockRejectedValue(new Error('Invalid code'));
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

const response = await handleCallbackRoute()({ request });

Expand All @@ -250,8 +257,6 @@ describe('handleCallbackRoute', () => {
expect(body.error.description).toContain("Couldn't sign in");
expect(body.error).not.toHaveProperty('details');
expect(response.headers.getSetCookie().some((c) => c.startsWith(`${PKCE_COOKIE_NAME}=`))).toBe(true);

consoleSpy.mockRestore();
});

it('calls onError with the underlying error and appends delete-cookie', async () => {
Expand All @@ -264,7 +269,6 @@ describe('handleCallbackRoute', () => {
headers: { 'X-Custom': 'preserved' },
}),
);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

const response = await handleCallbackRoute({ onError })({ request });

Expand All @@ -273,8 +277,6 @@ describe('handleCallbackRoute', () => {
expect(response.headers.get('X-Custom')).toBe('preserved');
expect(await response.text()).toBe('Custom error page');
expect(response.headers.getSetCookie().some((c) => c.startsWith(`${PKCE_COOKIE_NAME}=`))).toBe(true);

consoleSpy.mockRestore();
});

it('reads verifier-delete header from clearPendingVerifier response when headers bag is empty', async () => {
Expand Down Expand Up @@ -322,7 +324,6 @@ describe('handleCallbackRoute', () => {
`http://example.com/callback?code=auth_123&state=${encodeURIComponent(SEALED_STATE)}`,
);
mockGetAuthkitImpl = () => Promise.reject(new Error('Config missing'));
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

const response = await handleCallbackRoute()({ request });

Expand All @@ -334,8 +335,185 @@ describe('handleCallbackRoute', () => {
expect(setCookies[1]).toContain('SameSite=None');
expect(setCookies[1]).toContain('Secure');
expect(setCookies.every((c) => c.includes('Max-Age=0'))).toBe(true);
});

consoleSpy.mockRestore();
it('logs the original setup error when getAuthkit() rejects', async () => {
const originalError = new Error('Config missing');
mockGetAuthkitImpl = () => Promise.reject(originalError);
const request = new Request(
`http://example.com/callback?code=auth_123&state=${encodeURIComponent(SEALED_STATE)}`,
);

await handleCallbackRoute()({ request });

const callbackErrorLogs = consoleErrorSpy.mock.calls.filter(
(args) => typeof args[0] === 'string' && args[0].includes('OAuth callback failed'),
);
expect(callbackErrorLogs).toHaveLength(1);
expect(callbackErrorLogs[0]![1]).toBe(originalError);
});

it.each<{ label: string; arrange: () => Request; options?: HandleCallbackOptions }>([
{
label: 'missing code',
arrange: () => new Request(`http://example.com/callback?state=${encodeURIComponent(SEALED_STATE)}`),
},
{
label: 'getAuthkit throws',
arrange: () => {
mockGetAuthkitImpl = () => Promise.reject(new Error('Config missing'));
return new Request(`http://example.com/callback?code=auth_123&state=${encodeURIComponent(SEALED_STATE)}`);
},
},
{
label: 'handleCallback throws',
arrange: () => {
mockHandleCallback.mockRejectedValue(new Error('Token exchange failure'));
return new Request(`http://example.com/callback?code=auth_123&state=${encodeURIComponent(SEALED_STATE)}`);
},
},
{
label: 'onSuccess throws',
arrange: () => {
mockHandleCallback.mockResolvedValue(successResult());
return new Request(`http://example.com/callback?code=auth_123&state=${encodeURIComponent(SEALED_STATE)}`);
},
options: {
onSuccess: () => {
throw new Error('db failure');
},
},
},
])('central console.error fires exactly once on $label', async ({ arrange, options }) => {
const request = arrange();
await handleCallbackRoute(options ?? {})({ request });

const callbackErrorLogs = consoleErrorSpy.mock.calls.filter(
(args) => typeof args[0] === 'string' && args[0].includes('OAuth callback failed'),
);
expect(callbackErrorLogs).toHaveLength(1);
});

it('routes onSuccess throws through errorResponse with delete-cookies', async () => {
const request = new Request(
`http://example.com/callback?code=auth_123&state=${encodeURIComponent(SEALED_STATE)}`,
);
mockHandleCallback.mockResolvedValue(successResult());
const onSuccess = vi.fn(() => {
throw new Error('db failure');
});

const response = await handleCallbackRoute({ onSuccess })({ request });

expect(response.status).toBe(500);
expect(response.headers.getSetCookie().some((c) => c.startsWith(`${PKCE_COOKIE_NAME}=`))).toBe(true);
});

it('does not catch errors thrown inside onError', async () => {
const request = new Request(
`http://example.com/callback?code=auth_123&state=${encodeURIComponent(SEALED_STATE)}`,
);
mockHandleCallback.mockRejectedValue(new Error('callback failure'));
const onError = vi.fn(() => {
throw new Error('user-side failure');
});

await expect(handleCallbackRoute({ onError, errorRedirectUrl: '/fallback' })({ request })).rejects.toThrow(
'user-side failure',
);
});
});

describe('errorRedirectUrl path', () => {
it('returns 302 to absolute errorRedirectUrl with delete-cookies', async () => {
const request = new Request(`http://example.com/callback?code=invalid&state=${encodeURIComponent(SEALED_STATE)}`);
mockHandleCallback.mockRejectedValue(new Error('boom'));

const response = await handleCallbackRoute({ errorRedirectUrl: 'https://example.com/sign-in?error=auth' })({
request,
});

expect(response.status).toBe(302);
expect(response.headers.get('Location')).toBe('https://example.com/sign-in?error=auth');
expect(response.headers.getSetCookie().some((c) => c.startsWith(`${PKCE_COOKIE_NAME}=`))).toBe(true);
});

it('resolves relative errorRedirectUrl against request origin', async () => {
const request = new Request(`http://example.com/callback?code=invalid&state=${encodeURIComponent(SEALED_STATE)}`);
mockHandleCallback.mockRejectedValue(new Error('boom'));

const response = await handleCallbackRoute({ errorRedirectUrl: '/sign-in?error=auth_failed' })({ request });

expect(response.status).toBe(302);
expect(response.headers.get('Location')).toBe('http://example.com/sign-in?error=auth_failed');
});

it('runs onError and ignores errorRedirectUrl when both are set', async () => {
const request = new Request(`http://example.com/callback?code=invalid&state=${encodeURIComponent(SEALED_STATE)}`);
mockHandleCallback.mockRejectedValue(new Error('boom'));
const onError = vi.fn(() => new Response('custom', { status: 418 }));

const response = await handleCallbackRoute({ onError, errorRedirectUrl: '/should-not-be-used' })({ request });

expect(onError).toHaveBeenCalledTimes(1);
expect(response.status).toBe(418);
expect(response.headers.get('Location')).toBeNull();

const callbackErrorLogs = consoleErrorSpy.mock.calls.filter(
(args) => typeof args[0] === 'string' && args[0].includes('OAuth callback failed'),
);
expect(callbackErrorLogs).toHaveLength(1);
});

it('falls back to JSON when errorRedirectUrl is malformed', async () => {
const request = new Request(`http://example.com/callback?code=invalid&state=${encodeURIComponent(SEALED_STATE)}`);
mockHandleCallback.mockRejectedValue(new Error('boom'));

const response = await handleCallbackRoute({ errorRedirectUrl: 'http://[::1' })({ request });

expect(response.status).toBe(500);
expect(response.headers.get('Content-Type')).toBe('application/json');
const body = await response.json();
expect(body.error.message).toBe('Authentication failed');
expect(response.headers.getSetCookie().some((c) => c.startsWith(`${PKCE_COOKIE_NAME}=`))).toBe(true);

const callbackLogs = consoleErrorSpy.mock.calls.filter(
(args) => typeof args[0] === 'string' && args[0].includes('OAuth callback failed'),
);
const malformedLogs = consoleErrorSpy.mock.calls.filter(
(args) => typeof args[0] === 'string' && args[0].includes('errorRedirectUrl is malformed'),
);
expect(callbackLogs).toHaveLength(1);
expect(malformedLogs).toHaveLength(1);
});

it('falls back to JSON 400 when errorRedirectUrl is malformed on missing-code path', async () => {
const request = new Request(`http://example.com/callback?state=${encodeURIComponent(SEALED_STATE)}`);

const response = await handleCallbackRoute({ errorRedirectUrl: 'http://[::1' })({ request });

expect(response.status).toBe(400);
expect(response.headers.get('Content-Type')).toBe('application/json');

const malformedLogs = consoleErrorSpy.mock.calls.filter(
(args) => typeof args[0] === 'string' && args[0].includes('errorRedirectUrl is malformed'),
);
expect(malformedLogs).toHaveLength(1);
expect(malformedLogs[0]![0]).toContain('falling back to JSON 400');
});

it('ignores returnPathname on the error path', async () => {
const request = new Request(`http://example.com/callback?code=invalid&state=${encodeURIComponent(SEALED_STATE)}`);
mockHandleCallback.mockRejectedValue(new Error('boom'));

const response = await handleCallbackRoute({
returnPathname: '/dashboard',
errorRedirectUrl: '/sign-in?error=auth',
})({ request });

expect(response.status).toBe(302);
expect(response.headers.get('Location')).toContain('/sign-in?error=auth');
expect(response.headers.get('Location')).not.toContain('/dashboard');
});
});

Expand All @@ -346,13 +524,10 @@ describe('handleCallbackRoute', () => {
// Force the error path so errorResponse runs. `code=bad` with the mock
// rejecting triggers the catch branch.
mockHandleCallback.mockRejectedValue(new Error('boom'));
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

const res = await handleCallbackRoute()({ request });
const setCookies = res.headers.getSetCookie();
expect(setCookies.some((c) => c.startsWith(`${expected}=`))).toBe(true);

consoleSpy.mockRestore();
});

it('emits no Set-Cookie delete when state is absent', async () => {
Expand Down
25 changes: 21 additions & 4 deletions src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,11 @@ async function buildVerifierDeleteHeaders(
async function handleCallbackInternal(request: Request, options: HandleCallbackOptions): Promise<Response> {
let authkit: Awaited<ReturnType<typeof getAuthkit>> | undefined;

let setupError: unknown;
try {
authkit = await getAuthkit();
} catch (setupError) {
console.error('[authkit-tanstack-react-start] Callback setup failed:', setupError);
} catch (error) {
setupError = error;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

const url = new URL(request.url);
Expand All @@ -117,7 +118,7 @@ async function handleCallbackInternal(request: Request, options: HandleCallbackO
return errorResponse(new Error('Missing authorization code'), request, options, authkit, state, 400);
}
if (!authkit) {
return errorResponse(new Error('AuthKit not initialized'), request, options, authkit, state, 500);
return errorResponse(setupError ?? new Error('AuthKit not initialized'), request, options, authkit, state, 500);
}

try {
Expand Down Expand Up @@ -148,7 +149,6 @@ async function handleCallbackInternal(request: Request, options: HandleCallbackO

return new Response(null, { status: 307, headers });
} catch (error) {
console.error('OAuth callback failed:', error);
return errorResponse(error, request, options, authkit, state, 500);
}
}
Expand All @@ -161,6 +161,8 @@ async function errorResponse(
state: string | null,
defaultStatus: number,
): Promise<Response> {
console.error('[authkit-tanstack-react-start] OAuth callback failed:', error);

// Only the error path needs delete-cookie headers, so skip the
// clearPendingVerifier round-trip on the happy path.
const deleteCookieHeaders = await buildVerifierDeleteHeaders(authkit, state);
Expand All @@ -176,6 +178,21 @@ async function errorResponse(
});
}

if (options.errorRedirectUrl) {
try {
const target = new URL(options.errorRedirectUrl, request.url);
const headers = new Headers({ Location: target.toString() });
for (const h of deleteCookieHeaders) headers.append('Set-Cookie', h);
return new Response(null, { status: 302, headers });
} catch (urlError) {
console.error(
`[authkit-tanstack-react-start] errorRedirectUrl is malformed; falling back to JSON ${defaultStatus}:`,
urlError,
);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
// fall through to JSON
}
}

const headers = new Headers({ 'Content-Type': 'application/json' });
for (const h of deleteCookieHeaders) headers.append('Set-Cookie', h);
return new Response(
Expand Down
Loading
Loading