diff --git a/README.md b/README.md index bdab4c0..d7d4ec3 100644 --- a/README.md +++ b/README.md @@ -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 ( + + ); +} +``` + +```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. diff --git a/examples/next/.env.local.example b/examples/next/.env.local.example index d742eb2..2c1d901 100644 --- a/examples/next/.env.local.example +++ b/examples/next/.env.local.example @@ -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= diff --git a/examples/next/README.md b/examples/next/README.md index 8d0b4a1..f1d3f98 100644 --- a/examples/next/README.md +++ b/examples/next/README.md @@ -26,9 +26,13 @@ You will need a [WorkOS account](https://dashboard.workos.com/signup). 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= ``` -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 `/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 diff --git a/examples/next/src/app/auth/google-one-tap/route.ts b/examples/next/src/app/auth/google-one-tap/route.ts new file mode 100644 index 0000000..11c3afa --- /dev/null +++ b/examples/next/src/app/auth/google-one-tap/route.ts @@ -0,0 +1,3 @@ +import { handleGoogleOneTap } from '@workos-inc/authkit-nextjs'; + +export const POST = handleGoogleOneTap({ returnPathname: '/account' }); diff --git a/examples/next/src/app/page.tsx b/examples/next/src/app/page.tsx index 276568d..b69bdd6 100644 --- a/examples/next/src/app/page.tsx +++ b/examples/next/src/app/page.tsx @@ -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 ( {user ? ( @@ -27,6 +30,7 @@ export default async function HomePage() { Sign in to view your account details + {googleClientId && } )} diff --git a/src/components/google-one-tap.spec.tsx b/src/components/google-one-tap.spec.tsx new file mode 100644 index 0000000..c2e66c9 --- /dev/null +++ b/src/components/google-one-tap.spec.tsx @@ -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( + , + ); + + 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'); + }); +}); diff --git a/src/components/google-one-tap.tsx b/src/components/google-one-tap.tsx new file mode 100644 index 0000000..2dcad41 --- /dev/null +++ b/src/components/google-one-tap.tsx @@ -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 ( + <> +