({
@@ -46,26 +48,49 @@ const authenticationResponse = {
},
};
-const request = (csrfCookie = 'csrf-token', csrfBody = 'csrf-token') =>
- new NextRequest('https://example.com/auth/google-one-tap', {
+type RequestOptions = {
+ csrfCookie?: string | null;
+ csrfBody?: string | null;
+ credential?: string | null;
+};
+
+const request = ({
+ csrfCookie = 'csrf-token',
+ csrfBody = 'csrf-token',
+ credential = 'google-id-token',
+}: RequestOptions = {}) => {
+ const headers: Record
= {
+ accept: 'text/html',
+ 'content-type': 'application/x-www-form-urlencoded',
+ 'user-agent': 'Mozilla/5.0',
+ 'x-forwarded-for': '203.0.113.42, 10.0.0.1',
+ };
+ if (csrfCookie !== null) headers.cookie = `g_csrf_token=${csrfCookie}`;
+
+ const body = new URLSearchParams();
+ if (credential !== null) body.set('credential', credential);
+ if (csrfBody !== null) body.set('g_csrf_token', csrfBody);
+
+ return new NextRequest('https://example.com/auth/google-one-tap', {
method: 'POST',
- headers: {
- cookie: `g_csrf_token=${csrfCookie}`,
- 'content-type': 'application/x-www-form-urlencoded',
- 'user-agent': 'Mozilla/5.0',
- 'x-forwarded-for': '203.0.113.42, 10.0.0.1',
- },
- body: new URLSearchParams({
- credential: 'google-id-token',
- g_csrf_token: csrfBody,
- }),
+ headers,
+ body,
});
+};
describe('handleGoogleOneTap', () => {
+ beforeAll(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
beforeEach(() => {
vi.clearAllMocks();
});
+ afterAll(() => {
+ vi.restoreAllMocks();
+ });
+
it('validates CSRF, authenticates, saves the session, and redirects', async () => {
fakeWorkosInstance.userManagement.authenticateWithGoogleIdToken.mockResolvedValue(authenticationResponse);
const onSuccess = vi.fn();
@@ -83,24 +108,94 @@ describe('handleGoogleOneTap', () => {
});
expect(saveSession).toHaveBeenCalledWith(authenticationResponse, expect.any(NextRequest));
expect(onSuccess).toHaveBeenCalledWith(authenticationResponse);
+ expect(response.status).toBe(303);
expect(response.headers.get('location')).toBe('https://example.com/dashboard?from=one-tap');
expect(response.headers.get('cache-control')).toContain('no-store');
});
+ it('uses baseURL for the success redirect', async () => {
+ fakeWorkosInstance.userManagement.authenticateWithGoogleIdToken.mockResolvedValue(authenticationResponse);
+
+ const response = await handleGoogleOneTap({ baseURL: 'https://public.example.com', returnPathname: '/dashboard' })(
+ request(),
+ );
+
+ expect(response.headers.get('location')).toBe('https://public.example.com/dashboard');
+ });
+
+ it('rejects an invalid baseURL before handling requests', () => {
+ expect(() => handleGoogleOneTap({ baseURL: 'invalid-url' })).toThrow('Invalid baseURL: invalid-url');
+ });
+
it('rejects a mismatched CSRF token before authentication', async () => {
- const response = await handleGoogleOneTap()(request('cookie', 'body'));
+ const response = await handleGoogleOneTap()(request({ csrfCookie: 'cookie', csrfBody: 'body' }));
expect(response.status).toBe(400);
expect(fakeWorkosInstance.userManagement.authenticateWithGoogleIdToken).not.toHaveBeenCalled();
expect(saveSession).not.toHaveBeenCalled();
});
- it('falls back to Hosted AuthKit when One Tap cannot complete', async () => {
+ it('rejects a missing CSRF cookie before authentication', async () => {
+ const response = await handleGoogleOneTap()(request({ csrfCookie: null }));
+
+ expect(response.status).toBe(400);
+ expect(fakeWorkosInstance.userManagement.authenticateWithGoogleIdToken).not.toHaveBeenCalled();
+ });
+
+ it('rejects a missing credential before authentication', async () => {
+ const response = await handleGoogleOneTap()(request({ credential: null }));
+
+ expect(response.status).toBe(400);
+ expect(fakeWorkosInstance.userManagement.authenticateWithGoogleIdToken).not.toHaveBeenCalled();
+ });
+
+ it('sets the PKCE cookie before falling back to Hosted AuthKit', async () => {
fakeWorkosInstance.userManagement.authenticateWithGoogleIdToken.mockRejectedValue(new Error('MFA required'));
const response = await handleGoogleOneTap({ returnPathname: '/dashboard' })(request());
+ expect(response.status).toBe(303);
expect(response.headers.get('location')).toBe('https://auth.example.com/authorize');
+ expect(response.headers.get('set-cookie')).toContain(`${getPKCECookieNameForState('sealed-state')}=sealed-state`);
+ expect(getAuthorizationUrl).toHaveBeenCalledWith({ returnPathname: '/dashboard' });
expect(saveSession).not.toHaveBeenCalled();
+ expect(console.error).toHaveBeenCalledWith('[AuthKit Google One Tap error]', expect.any(Error));
+ });
+
+ it('uses onError instead of the Hosted AuthKit fallback when provided', async () => {
+ const error = new Error('Authentication failed');
+ fakeWorkosInstance.userManagement.authenticateWithGoogleIdToken.mockRejectedValue(error);
+ const onError = vi.fn(() => new Response('custom error', { status: 418 }));
+
+ const response = await handleGoogleOneTap({ onError })(request());
+
+ expect(response.status).toBe(418);
+ expect(onError).toHaveBeenCalledWith({ error, request: expect.any(NextRequest) });
+ expect(getAuthorizationUrl).not.toHaveBeenCalled();
+ });
+
+ it('surfaces an unsupported Node SDK instead of silently falling back', async () => {
+ const authenticateWithGoogleIdToken = fakeWorkosInstance.userManagement.authenticateWithGoogleIdToken;
+ Object.assign(fakeWorkosInstance.userManagement, { authenticateWithGoogleIdToken: undefined });
+
+ try {
+ await expect(handleGoogleOneTap()(request())).rejects.toThrow(
+ '@workos-inc/node 10.12 or newer is required for Google One Tap.',
+ );
+ } finally {
+ Object.assign(fakeWorkosInstance.userManagement, { authenticateWithGoogleIdToken });
+ }
+
+ expect(getAuthorizationUrl).not.toHaveBeenCalled();
+ });
+
+ it('does not turn an onSuccess failure into a second authentication flow', async () => {
+ fakeWorkosInstance.userManagement.authenticateWithGoogleIdToken.mockResolvedValue(authenticationResponse);
+ const error = new Error('Side effect failed');
+
+ await expect(handleGoogleOneTap({ onSuccess: () => Promise.reject(error) })(request())).rejects.toThrow(error);
+
+ expect(saveSession).toHaveBeenCalled();
+ expect(getAuthorizationUrl).not.toHaveBeenCalled();
});
});
diff --git a/src/google-one-tap-route.ts b/src/google-one-tap-route.ts
index cb03388..dd6de6a 100644
--- a/src/google-one-tap-route.ts
+++ b/src/google-one-tap-route.ts
@@ -2,37 +2,57 @@ import type { AuthenticationResponse } from '@workos-inc/node';
import { NextRequest } from 'next/server';
import { WORKOS_CLIENT_ID } from './env-variables.js';
import { getAuthorizationUrl } from './get-authorization-url.js';
+import type { HandleGoogleOneTapOptions } from './interfaces.js';
+import { appendPKCESetCookieHeader } from './pkce.js';
import { saveSession } from './session.js';
-import { redirectWithFallback, setCachePreventionHeaders } from './utils.js';
+import { setCachePreventionHeaders } from './utils.js';
import { getWorkOS } from './workos.js';
-export interface HandleGoogleOneTapOptions {
- returnPathname?: string;
- onError?: (params: { error?: unknown; request: NextRequest }) => Response | Promise;
- onSuccess?: (data: Awaited>) => void | Promise;
-}
+const nodeSdkVersionError = '@workos-inc/node 10.12 or newer is required for Google One Tap.';
+
+class UnsupportedNodeSdkError extends Error {}
+
+type AuthenticateWithGoogleIdToken = (options: {
+ clientId: string;
+ token: string;
+ ipAddress?: string;
+ userAgent?: string;
+}) => Promise;
const authenticate = async (request: NextRequest, token: string): Promise => {
const userManagement = getWorkOS().userManagement;
- const authenticateWithGoogleIdToken = Reflect.get(userManagement, 'authenticateWithGoogleIdToken');
+ const authenticateWithGoogleIdToken = (
+ userManagement as typeof userManagement & {
+ authenticateWithGoogleIdToken?: AuthenticateWithGoogleIdToken;
+ }
+ ).authenticateWithGoogleIdToken;
+
if (typeof authenticateWithGoogleIdToken !== 'function') {
- throw new Error('@workos-inc/node 10.12 or newer is required for Google One Tap.');
+ throw new UnsupportedNodeSdkError(nodeSdkVersionError);
}
- return Reflect.apply(authenticateWithGoogleIdToken, userManagement, [
- {
- clientId: WORKOS_CLIENT_ID,
- token,
- ipAddress: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim(),
- userAgent: request.headers.get('user-agent') ?? undefined,
- },
- ]);
+ return authenticateWithGoogleIdToken.call(userManagement, {
+ clientId: WORKOS_CLIENT_ID,
+ token,
+ ipAddress: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim(),
+ userAgent: request.headers.get('user-agent') ?? undefined,
+ });
};
export function handleGoogleOneTap(options: HandleGoogleOneTapOptions = {}) {
- const { returnPathname = '/', onError, onSuccess } = options;
+ const { returnPathname = '/', baseURL, onError, onSuccess } = options;
+
+ if (baseURL) {
+ try {
+ new URL(baseURL);
+ } catch (error) {
+ throw new Error(`Invalid baseURL: ${baseURL}`, { cause: error });
+ }
+ }
return async function POST(request: NextRequest): Promise {
+ let authenticationResponse: AuthenticationResponse;
+
try {
const formData = await request.formData();
const token = formData.get('credential');
@@ -48,26 +68,39 @@ export function handleGoogleOneTap(options: HandleGoogleOneTapOptions = {}) {
return noStore(new Response('Invalid Google One Tap request.', { status: 400 }));
}
- const authenticationResponse = await authenticate(request, token);
+ authenticationResponse = await authenticate(request, token);
await saveSession(authenticationResponse, request);
- await onSuccess?.(authenticationResponse);
-
- const redirectUrl = new URL(request.url);
- const parsedReturnUrl = new URL(returnPathname, 'https://placeholder.com');
- redirectUrl.pathname = parsedReturnUrl.pathname;
- redirectUrl.search = parsedReturnUrl.search;
- return noStore(redirectWithFallback(redirectUrl.toString()));
} catch (error) {
+ console.error('[AuthKit Google One Tap error]', error);
+
if (onError) {
return noStore(await onError({ error, request }));
}
- const { url } = await getAuthorizationUrl({ returnPathname });
- return noStore(redirectWithFallback(url));
+ if (error instanceof UnsupportedNodeSdkError) {
+ throw error;
+ }
+
+ const { url, sealedState } = await getAuthorizationUrl({ returnPathname });
+ const response = noStore(redirectAfterPost(url));
+ appendPKCESetCookieHeader(request, response.headers, sealedState);
+ return response;
}
+
+ await onSuccess?.(authenticationResponse);
+
+ const redirectUrl = baseURL ? new URL(baseURL) : new URL(request.url);
+ const parsedReturnUrl = new URL(returnPathname, 'https://placeholder.com');
+ redirectUrl.pathname = parsedReturnUrl.pathname;
+ redirectUrl.search = parsedReturnUrl.search;
+ return noStore(redirectAfterPost(redirectUrl.toString()));
};
}
+function redirectAfterPost(url: string): Response {
+ return new Response(null, { status: 303, headers: { Location: url } });
+}
+
function noStore(response: Response): Response {
response.headers.set('Vary', 'Cookie');
setCachePreventionHeaders(response.headers);
diff --git a/src/index.ts b/src/index.ts
index 647df41..2a74b5a 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -21,7 +21,6 @@ import { getFeatureFlagsRuntimeClient } from './feature-flags.js';
import { getWorkOS } from './workos.js';
export * from './interfaces.js';
-export type { HandleGoogleOneTapOptions } from './google-one-tap-route.js';
export type { CallbackErrorCode, CallbackErrorContext } from './errors.js';
diff --git a/src/interfaces.ts b/src/interfaces.ts
index c0d05b2..abe6434 100644
--- a/src/interfaces.ts
+++ b/src/interfaces.ts
@@ -21,6 +21,13 @@ export interface HandleAuthOptions {
onError?: (params: { error?: unknown; request: NextRequest }) => Response | Promise;
}
+export interface HandleGoogleOneTapOptions {
+ returnPathname?: string;
+ baseURL?: string;
+ onSuccess?: (data: AuthenticationResponse) => void | Promise;
+ onError?: (params: { error?: unknown; request: NextRequest }) => Response | Promise;
+}
+
export interface HandleAuthSuccessData extends Session {
oauthTokens?: OauthTokens;
organizationId?: string;