Skip to content
Open
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
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,43 @@ In the [WorkOS dashboard](https://dashboard.workos.com), go to **Redirects** and
> [!IMPORTANT]
> The Sign-in URL is required for features like [impersonation](https://workos.com/docs/user-management/impersonation) to work correctly. Without it, WorkOS-initiated flows (such as impersonating a user from the dashboard) will fail because they cannot complete the PKCE/CSRF verification that this library enforces on every callback.

### Google One Tap

Google One Tap posts its signed ID token to your application so the token and the resulting WorkOS session stay server-side.

Configure Google OAuth with your own credentials in the WorkOS Dashboard. In Google Cloud, add your application origin to **Authorized JavaScript origins** and the handler URL below to **Authorized redirect URIs**. WorkOS sandbox demo credentials cannot be used for One Tap. This flow requires `@workos-inc/node` 10.12 or newer.

```tsx
// app/page.tsx
import { GoogleOneTap } from '@workos-inc/authkit-nextjs/components';

export default function Page() {
return (
<GoogleOneTap
clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!}
loginUri="https://example.com/auth/google-one-tap"
/>
);
}
```

```ts
// app/auth/google-one-tap/route.ts
import { handleGoogleOneTap } from '@workos-inc/authkit-nextjs';

export const POST = handleGoogleOneTap({
returnPathname: '/dashboard',
// Set this when request.url uses an internal proxy or container hostname.
baseURL: 'https://example.com',
});
```

`handleGoogleOneTap` verifies Google's double-submit CSRF token, exchanges the ID token through WorkOS, and stores the normal encrypted AuthKit session. The token never enters a URL. If One Tap cannot complete—for example, because another authentication step is required—the handler sets the PKCE cookie and falls back to Hosted AuthKit. Authentication errors are logged with the `[AuthKit Google One Tap error]` prefix and can be handled with the `onError` option.

Google's HTML API scans the fixed `#g_id_onload` element when its script executes. Render one `GoogleOneTap` instance in an initially loaded page or layout; mounting it only after a client-side navigation may not show the prompt. The optional `nonce` prop is passed to the Google script for strict CSP deployments.

Keep the standard sign-in button visible because browsers can suppress the prompt, and this identity-only flow does not return Google access or refresh tokens for additional scopes.

### Proxy / Middleware

This library relies on Next.js proxy (called "middleware" in Next.js ≤15) to provide session management for routes.
Expand Down
4 changes: 3 additions & 1 deletion examples/next/.env.local.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
WORKOS_CLIENT_ID=
WORKOS_API_KEY=
NEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:3000/callback
WORKOS_COOKIE_PASSWORD=
NEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:3000/callback
NEXT_PUBLIC_APP_URL=https://your-app.example
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
6 changes: 5 additions & 1 deletion examples/next/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,13 @@ You will need a [WorkOS account](https://dashboard.workos.com/signup).
WORKOS_COOKIE_PASSWORD=<YOUR_COOKIE_PASSWORD>

NEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:3000/callback
NEXT_PUBLIC_APP_URL=https://your-app.example
NEXT_PUBLIC_GOOGLE_CLIENT_ID=<YOUR_GOOGLE_CLIENT_ID>
```

5. Run the following command and navigate to [http://localhost:3000](http://localhost:3000).
5. To enable Google One Tap, configure Google OAuth with your own credentials in the WorkOS Dashboard. In Google Cloud, add your HTTPS application origin as an **Authorized JavaScript origin** and `<NEXT_PUBLIC_APP_URL>/auth/google-one-tap` as an **Authorized redirect URI**. Copy the same Google client ID into `NEXT_PUBLIC_GOOGLE_CLIENT_ID`. For local testing, expose the application through an HTTPS development tunnel and use that origin for `NEXT_PUBLIC_APP_URL`.

6. Run the following command and navigate to [http://localhost:3000](http://localhost:3000).

```bash
pnpm dev
Expand Down
3 changes: 3 additions & 0 deletions examples/next/src/app/auth/google-one-tap/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { handleGoogleOneTap } from '@workos-inc/authkit-nextjs';

export const POST = handleGoogleOneTap({ returnPathname: '/account' });
4 changes: 4 additions & 0 deletions examples/next/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import NextLink from 'next/link';
import { withAuth } from '@workos-inc/authkit-nextjs';
import { Button, Flex, Heading, Text } from '@radix-ui/themes';
import { GoogleOneTap } from '@workos-inc/authkit-nextjs/components';
import { SignInButton } from './components/sign-in-button';

export default async function HomePage() {
const { user } = await withAuth();
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000';
return (
<Flex direction="column" align="center" gap="2">
{user ? (
Expand All @@ -27,6 +30,7 @@ export default async function HomePage() {
Sign in to view your account details
</Text>
<SignInButton large />
{googleClientId && <GoogleOneTap clientId={googleClientId} loginUri={`${appUrl}/auth/google-one-tap`} />}
</>
)}
</Flex>
Expand Down
28 changes: 28 additions & 0 deletions src/components/google-one-tap.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import '@testing-library/jest-dom';
import { render } from '@testing-library/react';
import React from 'react';
import { GoogleOneTap } from './google-one-tap.js';

describe('GoogleOneTap', () => {
it('renders the Google Identity Services configuration', () => {
const { container } = render(
<GoogleOneTap
clientId="google-client-id"
loginUri="https://example.com/auth/google-one-tap"
context="signup"
cancelOnTapOutside={false}
nonce="csp-nonce"
/>,
);

expect(document.querySelector('script')).toHaveAttribute('src', 'https://accounts.google.com/gsi/client');
expect(document.querySelector('script')).toHaveAttribute('nonce', 'csp-nonce');
expect(container.querySelector('#g_id_onload')).toHaveAttribute('data-client_id', 'google-client-id');
expect(container.querySelector('#g_id_onload')).toHaveAttribute(
'data-login_uri',
'https://example.com/auth/google-one-tap',
);
expect(container.querySelector('#g_id_onload')).toHaveAttribute('data-context', 'signup');
expect(container.querySelector('#g_id_onload')).toHaveAttribute('data-cancel_on_tap_outside', 'false');
});
});
33 changes: 33 additions & 0 deletions src/components/google-one-tap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
'use client';

import React from 'react';

export interface GoogleOneTapProps {
clientId: string;
loginUri: string;
context?: 'signin' | 'signup' | 'use';
cancelOnTapOutside?: boolean;
nonce?: string;
}

export function GoogleOneTap({
clientId,
loginUri,
context = 'signin',
cancelOnTapOutside = true,
nonce,
}: GoogleOneTapProps) {
return (
<>
<script async nonce={nonce} src="https://accounts.google.com/gsi/client" />
<div
id="g_id_onload"
data-cancel_on_tap_outside={String(cancelOnTapOutside)}
data-client_id={clientId}
data-context={context}
data-itp_support="true"
data-login_uri={loginUri}
/>
</>
);
}
4 changes: 3 additions & 1 deletion src/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { GoogleOneTap } from './google-one-tap.js';
import { Impersonation } from './impersonation.js';
import { AuthKitProvider, useAuth } from './authkit-provider.js';
import { useAccessToken } from './useAccessToken.js';
import { useTokenClaims } from './useTokenClaims.js';
import { useRecentAuth } from './useRecentAuth.js';

export { Impersonation, AuthKitProvider, useAuth, useAccessToken, useTokenClaims, useRecentAuth };
export { GoogleOneTap, Impersonation, AuthKitProvider, useAuth, useAccessToken, useTokenClaims, useRecentAuth };
export type { GoogleOneTapProps } from './google-one-tap.js';
201 changes: 201 additions & 0 deletions src/google-one-tap-route.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { NextRequest } from 'next/server';
import { getAuthorizationUrl } from './get-authorization-url.js';
import { handleGoogleOneTap } from './google-one-tap-route.js';
import { getPKCECookieNameForState } from './pkce.js';
import { saveSession } from './session.js';

const { fakeWorkosInstance } = vi.hoisted(() => ({
fakeWorkosInstance: {
userManagement: {
authenticateWithGoogleIdToken: vi.fn(),
},
},
}));

vi.mock('./workos', () => ({
getWorkOS: vi.fn(() => fakeWorkosInstance),
}));

vi.mock('./session', () => ({
saveSession: vi.fn(),
}));

vi.mock('./get-authorization-url', () => ({
getAuthorizationUrl: vi.fn().mockResolvedValue({
url: 'https://auth.example.com/authorize',
sealedState: 'sealed-state',
}),
}));

const authenticationResponse = {
accessToken: 'access-token',
refreshToken: 'refresh-token',
user: {
id: 'user_123',
email: 'ada@example.com',
emailVerified: true,
profilePictureUrl: null,
name: 'Ada Lovelace',
firstName: 'Ada',
lastName: 'Lovelace',
object: 'user' as const,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
lastSignInAt: '2024-01-01T00:00:00Z',
externalId: null,
metadata: {},
locale: null,
},
};

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<string, string> = {
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,
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();

const response = await handleGoogleOneTap({
returnPathname: '/dashboard?from=one-tap',
onSuccess,
})(request());

expect(fakeWorkosInstance.userManagement.authenticateWithGoogleIdToken).toHaveBeenCalledWith({
clientId: process.env.WORKOS_CLIENT_ID,
token: 'google-id-token',
ipAddress: '203.0.113.42',
userAgent: 'Mozilla/5.0',
});
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({ csrfCookie: 'cookie', csrfBody: 'body' }));

expect(response.status).toBe(400);
expect(fakeWorkosInstance.userManagement.authenticateWithGoogleIdToken).not.toHaveBeenCalled();
expect(saveSession).not.toHaveBeenCalled();
});

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();
});
});
Loading