diff --git a/README.md b/README.md index 0055eac..32f8415 100644 --- a/README.md +++ b/README.md @@ -411,26 +411,38 @@ 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)); }, }), }, @@ -438,11 +450,14 @@ export const Route = createFileRoute('/api/auth/callback')({ }); ``` +`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` - 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` — 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 diff --git a/src/server/server.spec.ts b/src/server/server.spec.ts index b1f2cf6..d5fa597 100644 --- a/src/server/server.spec.ts +++ b/src/server/server.spec.ts @@ -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); @@ -56,6 +57,8 @@ const successResult = (overrides: Record = {}) => ({ }); describe('handleCallbackRoute', () => { + let consoleErrorSpy: ReturnType; + beforeEach(() => { vi.clearAllMocks(); mockRedirectUriFromContext = undefined; @@ -66,6 +69,11 @@ describe('handleCallbackRoute', () => { createSignIn: mockCreateSignIn, clearPendingVerifier: mockClearPendingVerifier, }); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); }); describe('missing code', () => { @@ -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 }); @@ -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 () => { @@ -264,7 +269,6 @@ describe('handleCallbackRoute', () => { headers: { 'X-Custom': 'preserved' }, }), ); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const response = await handleCallbackRoute({ onError })({ request }); @@ -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 () => { @@ -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 }); @@ -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'); }); }); @@ -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 () => { diff --git a/src/server/server.ts b/src/server/server.ts index d4c585a..502de52 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -103,10 +103,11 @@ async function buildVerifierDeleteHeaders( async function handleCallbackInternal(request: Request, options: HandleCallbackOptions): Promise { let authkit: Awaited> | undefined; + let setupError: unknown; try { authkit = await getAuthkit(); - } catch (setupError) { - console.error('[authkit-tanstack-react-start] Callback setup failed:', setupError); + } catch (error) { + setupError = error; } const url = new URL(request.url); @@ -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 { @@ -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); } } @@ -161,6 +161,8 @@ async function errorResponse( state: string | null, defaultStatus: number, ): Promise { + 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); @@ -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, + ); + // 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( diff --git a/src/server/types.ts b/src/server/types.ts index d5259d5..3393c6f 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -11,7 +11,37 @@ export interface OauthTokens { export interface HandleCallbackOptions { returnPathname?: string; onSuccess?: (data: HandleAuthSuccessData) => void | Promise; + /** + * Custom error handler. Receives the underlying error and the original + * request, returns a Response. Errors thrown from inside `onError` are + * NOT caught by the SDK — they propagate up to the runtime. Wrap your + * `onError` body in a try/catch if you want different behavior. + * + * If both `onError` and `errorRedirectUrl` are provided, `onError` wins + * and `errorRedirectUrl` is ignored. + */ onError?: (params: { error?: unknown; request: Request }) => Response | Promise; + /** + * Optional URL to redirect the user to when the callback fails. Accepts + * absolute URLs (`https://example.com/sign-in`) or relative paths + * (`/sign-in?error=auth_failed`); relative values resolve against the + * request origin. + * + * When set and `onError` is not, the SDK responds with a 302 Location + * redirect plus the verifier-delete cookies. When `onError` is also + * set, this option is ignored. + * + * The redirect URL is set at route-construction time by application + * code, not derived from request input. Do not pass user-controlled + * values here. The SDK does not validate the URL scheme; any value the + * URL constructor accepts is accepted (including `javascript:` and + * `data:`). + * + * If the value is malformed and the URL constructor throws, the SDK + * logs a config warning and falls back to the path-dependent JSON + * error response (400 or 500) with delete-cookies. + */ + errorRedirectUrl?: string; } export interface HandleAuthSuccessData {