payload-oauth2 Plugin

repository·main·Indexed 41 days ago

https://github.com/wilsonle/payload-oauth2

A zero-dependency, highly customizable OAuth2 authentication plugin for Payload CMS v3. It supports generic OAuth2 providers with specific tested implementations for Google, Zitadel, Apple, and Microsoft Entra ID. The plugin provides tools for managing OAuth flows, custom auth strategies via createAuthStrategy, and integration with Payload's User and LocalUser data structures.

Tokens
8.7K
Snippets
20
Records
31
Agent score
51%

What's inside payload-oauth2

  1. Start the OAuth flow in your application

    main

    To initiate the OAuth process, create a standard browser navigation (a direct link) to the Payload authorize endpoint from your login UI.

    Important for Next.js App Router users: Do not use next/link or router.push() to navigate to the authorize endpoint. Because the endpoint returns a redirect to the OAuth provider rather than a React Server Component (RSC) payload, using client-side routing can cause errors. While the plugin attempts to handle RSC navigation probes gracefully, using a standard <a> tag for document navigation is the recommended and most direct method.

    export function GoogleLoginButton() {
      return <a href="/api/users/oauth/google">Continue with Google</a>
    }
  2. Testing conventions for the Payload OAuth2 Plugin

    main

    When testing the plugin, follow these core principles to ensure deterministic and reliable test suites:

    • Avoid Live Calls: Do not use live provider calls, browser automation, or real OAuth secrets. Instead, model provider behavior using mocks.
    • Test Callback Logic: Exercise callback behavior by calling createCallbackEndpoint(...).handler(req). Do not bypass this by replacing callback assertions with direct Payload create or update calls.
    • Provider Coverage: Maintain coverage for the supported provider matrix: Google, Zitadel, Apple, and Microsoft Entra ID. Use shared contract helpers for common logic, but isolate provider-specific quirks within individual fixtures.
    • Integration Testing Layers: Use mocked external-provider integration to test:
      • Authorize and callback happy paths.
      • create and update logic branches.
      • Provider response failures.
      • Token request bodies.
      • PKCE authorize behavior.
      • PKCE callback code_verifier exchange.
    • Authentication & Idempotency:
      • Perform roundtrip tests to ensure callback-issued JWTs successfully authenticate through the auth strategy.
      • Run idempotency tests for plugin collection wiring.
  3. Configure user identity and field mapping

    main

    The callback endpoint determines if a user exists in your Payload collection based on two strategies:

    1. Email Identity: If useEmailAsIdentity is set to true, the plugin searches for an existing user where the email field matches the email provided in the userInfo object.
    2. Subject Identity (Default): The plugin searches for a user using a unique identifier from the provider (the sub field). You can customize which field this maps to using subField.name or subFieldName in your plugin options.

    If the user is not found, the plugin follows the onUserNotFoundBehavior setting ('create' or 'error').

  4. How user identity and mapping works

    main

    The plugin provides several ways to manage how OAuth users are identified and stored in your Payload collection:

    • Identity via Email: If useEmailAsIdentity is set to true, the user's email becomes the unique identifier. This allows a single user to log in via multiple different OAuth providers (e.g., Google and GitHub) and be mapped to the same Payload user account, provided the emails match.
    • Provider ID Storage: The unique ID from the OAuth provider (the sub claim) is stored in your auth collection. By default, this is stored in a field named sub. You can customize this field name using subFieldName or provide a custom Payload TextField via subField.
    • User Creation: When an OAuth login occurs, the onUserNotFoundBehavior setting determines what happens if the provider's ID doesn't match an existing user. Setting it to "create" (default) will automatically create a new user in your authCollection using the data returned by your getUserInfo function. Setting it to "error" will block the login.
  5. Configure OAuth response modes and Apple OAuth

    main

    When working with certain providers like Apple, the responseMode option is critical. Apple requires the form_post mode when requesting name or email scopes to ensure secure transmission of user data via a POST body. For most other providers, the default behavior is typically query (parameters in the URL).

    Available values for responseMode:

    • form_post: Parameters encoded in the POST body.
    • query: Parameters encoded in the URL query string.
    • fragment: Parameters encoded in the URL fragment.
  6. Fix Next.js RSC fetch failures when starting OAuth authorize flow

    main

    When using Next.js App Router, attempting to start an OAuth flow using client-side routing (like next/link or router.push) to the Payload authorize endpoint (/api/<auth-collection>/oauth/authorize) can cause RSC fetch failures.

    Symptoms

    • The browser console logs: Failed to fetch RSC payload for .../api/<auth-collection>/oauth/authorize. Falling back to browser navigation.
    • The OAuth flow usually continues via fallback, but the error is noisy and indicates an incorrect navigation method.

    Root Cause

    The authorize endpoint is a Payload API endpoint that returns an HTTP 302 redirect to an external provider. Next.js App Router attempts to fetch an RSC payload when it detects a client-side navigation to a same-origin URL. If this fetch follows the redirect, it fails because the destination is not an RSC payload.

    Instead of using client-side routing, use document navigation to trigger the OAuth flow. This ensures the browser performs a full request to the API endpoint rather than an RSC fetch probe.

    Use one of the following methods:

    • A standard HTML anchor tag: <a href="/api/users/oauth/authorize">Login</a>
    • A standard HTML <form> or Server Action.
    • Programmatic navigation via window.location.assign('/api/users/oauth/authorize').

    Note: Setting prefetch={false} on a next/link component may reduce prefetching but does not prevent the initial RSC fetch probe upon clicking the link.

    // ❌ AVOID: This triggers an RSC fetch probe and causes console errors
    import Link from 'next/link';
    <Link href="/api/users/oauth/authorize">Login</Link>
    
    // ✅ PREFERRED: Use standard document navigation
    <a href="/api/users/oauth/authorize">Login</a>
    
    // ✅ PREFERRED: Programmatic document navigation
    const handleLogin = () => {
      window.location.assign('/api/users/oauth/authorize');
    };
  7. Troubleshoot Payload v3 auth session issues with OAuth2

    main

    If you are using Payload v3 with auth.useSessions enabled and encounter issues where OAuth2 logins do not respect session revocation, follow this guide.

    Symptoms

    • Payload refresh fails or returns forbidden after OAuth login.
    • Logging out removes the Payload session, but an old payload-token cookie still allows authentication.
    • Captured JWTs remain valid until tokenExpiration even after a logout event.

    Root Cause

    In Payload v3, session-backed JWTs require a sid (session ID) claim that must match an entry in the user.sessions collection. If the OAuth2 callback signs a JWT without creating a session or including the sid, the OAuth2 auth strategy might verify the JWT independently and return the user without validating the session status. This allows the OAuth2 strategy to bypass Payload's native session revocation because custom auth strategies run before the built-in local-jwt strategy.

    Required Fixes for Implementation

    To ensure OAuth2 works correctly with Payload sessions, your implementation must:

    1. Create a session during callback: When Payload sessions are active for the auth collection, the OAuth2 callback must create a Payload session and include its sid in the signed JWT.
    2. Validate sid in the auth strategy: The OAuth2 auth strategy must require the sid for session-backed collections and validate it against user.sessions before returning a user.
    3. Set user._sid: After successful validation, set user._sid so that Payload's native refresh and logout operations can correctly identify and manage the session.
    4. Avoid user creation from JWT: Do not create new users directly from JWT authentication if sessions are active; ensure the session lifecycle is managed.

    Verification Steps

    • Callback Test: Confirm the OAuth callback persists a session and the generated JWT contains the sid claim.
    • Strategy Test: Test the auth strategy with a valid sid, a missing sid, and a revoked sid.
    • Refresh Test: Confirm Payload refresh succeeds after OAuth login when sessions are enabled.
    • Logout Test: Confirm logout removes the session and that a restored old cookie no longer authenticates.
  8. Configure the Payload OAuth2 plugin in payload.config.ts

    main

    To enable OAuth2 authentication in your Payload project, add the desired OAuth provider plugins to the plugins array within the buildConfig object. The plugin instances (e.g., googleOAuth, zitadelOAuth, microsoftEntraIdOAuth, appleOAuth) are typically imported from their respective configuration files and passed directly to the configuration.

    import { googleOAuth } from "../../examples/google";
    import { zitadelOAuth } from "../../examples/zitadel";
    import { microsoftEntraIdOAuth } from "../../examples/microsoft-entra-id";
    import { appleOAuth } from "../../examples/apple";
    
    export default buildConfig({
      // ... other config
      plugins: [googleOAuth, zitadelOAuth, microsoftEntraIdOAuth, appleOAuth],
    });
  9. Configure the OAuth2Plugin authentication collection

    main

    The OAuth2Plugin requires an existing authentication collection to modify.

    • authCollection: Specify the slug of the collection you want to use for authentication. If not provided, it defaults to "users".
    • Error Behavior: If the specified authCollection slug cannot be found in your Payload configuration, the plugin will throw an error: The collection with the slug "<slug>" was not found.
  10. Configure Zitadel OAuth2

    main

    To enable Zitadel OAuth2, provide the ZITADEL_CLIENT_ID and ZITADEL_CLIENT_SECRET. You must also provide the specific Zitadel endpoints for token exchange, authorization, and user information. If these are not set, Zitadel OAuth will not be activated.

    # optional: zitadel oauth2 client id
    ZITADEL_CLIENT_ID=
    
    # optional: zitadel oauth2 client secret
    ZITADEL_CLIENT_SECRET=
    
    # optional: zitadel oauth2 token endpoint
    ZITADEL_TOKEN_ENDPOINT=
    
    # optional: zitadel oauth2 authorization url
    ZITADEL_AUTHORIZATION_URL=
    
    # optional: zitadel oauth2 userinfo endpoint
    ZITADEL_USERINFO_ENDPOINT=
  11. Configure Microsoft Entra ID OAuth 2.0

    main

    To enable Microsoft Entra ID (formerly Azure AD), provide the client ID, client secret, and tenant ID. You can optionally specify a MICROSOFT_ENTRA_ID_ADMINISTRATOR_GROUP_ID to manage administrative permissions.

    # optional: Microsoft Entra ID client id
    MICROSOFT_ENTRA_ID_CLIENT_ID=
    
    # optional: Microsoft Entra ID client secret
    MICROSOFT_ENTRA_ID_CLIENT_SECRET=
    
    # optional: Microsoft Entra ID tenant id
    MICROSOFT_ENTRA_ID_TENANT_ID=
    
    # optional: Microsoft Entra ID administrator group id
    MICROSOFT_ENTRA_ID_ADMINISTRATOR_GROUP_ID=