@purposeinplay/payload-oauth-plugin
v0.2.1
Published
OAuth plugin for Payload CMS 3 with Google sign-in, account linking, PKCE, and CSRF protection.
Downloads
440
Readme
@purposeinplay/payload-oauth-plugin
OAuth plugin for Payload CMS 3 that adds Google sign-in (and other OAuth providers) alongside or instead of email/password login. Supports pre-approved user enforcement, account linking, domain restriction, and built-in admin UI components.
Features
- Google OAuth sign-in with PKCE (S256) and HMAC-signed CSRF state
- Login page integration: a "Sign in with Google" button is added to the admin login page automatically
- Pre-approved users: require users to be created in Payload before they can sign in with Google
- Account linking: users connect Google from their account settings after initial password login
- Domain restriction: only allow emails from specific domains (e.g.
@wild.io) - Admin UI components:
<GoogleLoginButton />and<ConnectedAccounts /> - Compatible with TOTP 2FA, audit logging, and Payload's access control
- Extensible provider interface for adding GitHub, Apple, etc.
Requirements
- Node.js >= 20
- ESM only — the package ships
"type": "module"and cannot berequire()d - Peer dependencies (your app provides these):
| Peer | Version |
|------|---------|
| payload | ^3.0.0 |
| next | ^15.0.0 \|\| ^16.0.0 |
| react | ^18.0.0 \|\| ^19.0.0 |
| @payloadcms/ui | ^3.0.0 |
The only runtime dependency is jose ^6 (JWT signing/verification).
Installation
pnpm add @purposeinplay/payload-oauth-plugin
# or
npm install @purposeinplay/payload-oauth-plugin
# or
yarn add @purposeinplay/payload-oauth-pluginQuick Start
// payload.config.ts
import { buildConfig } from 'payload'
import { oauthPlugin, googleProvider } from '@purposeinplay/payload-oauth-plugin'
export default buildConfig({
// Required: used to build the OAuth redirect_uri. Without it the
// redirect_uri is a bare path and Google rejects the request.
serverURL: process.env.SERVER_URL ?? 'http://localhost:3000',
// ... your config
plugins: [
oauthPlugin({
providers: [
googleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
prompt: 'select_account',
}),
],
allowSignUp: false,
allowedDomains: ['your-company.com'],
}),
],
})Then regenerate the Payload import map so the auto-registered login button resolves:
pnpm payload generate:importmapSetup notes
serverURLis required. Set Payload'sserverURL(or the plugin'sserverURLoption). If neither is set, the plugin falls back to''and the OAuthredirect_uribecomes a bare path, which Google rejects.- The auth collection must exist. The plugin throws
InvalidConfigurationat startup if theauthCollectionslug (default'users') is not found inconfig.collections. PAYLOAD_SECRETmatters. Payload'ssecretis used for both HMAC state signing and JWT signing — keep it stable across instances.- The plugin reads no environment variables itself except
NODE_ENV(to set theSecurecookie flag in production). Provider credentials are passed explicitly togoogleProvider(). - Setting
enabled: falsereturns your Payload config completely untouched. - Plugin options are exposed to other plugins/code at
config.custom.oauth.
What the Plugin Changes
Applying the plugin modifies your Payload config:
- Schema: adds a hidden
oauthgroup field to the auth collection, containing anaccountsarray withprovider(text, indexed),sub(text, indexed, never readable via API), andemail(email) fields. On SQL adapters this is a schema change that may require a migration. - Endpoints: registers authorize, callback, and unlink endpoints per provider on the auth collection (see API Endpoints).
- Auth strategies: registers a named auth strategy
oauth-{provider}per provider. Actual session verification is handled by Payload's default JWT strategy, since the plugin issues JWTs with the same shape. - Login page: appends
'@purposeinplay/payload-oauth-plugin/client#GoogleLoginButton'toadmin.components.afterLogin, so a "Sign in with Google" button appears below the admin login form automatically. Runpayload generate:importmapafter installing.disableLocalStrategy: truesetsauth.disableLocalStrategyon the collection.
Configuration Reference
Plugin Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| providers | OAuthProviderConfig[] | required | OAuth providers to enable |
| allowSignUp | boolean | true | Auto-create users on first OAuth login. Set false for pre-approved only |
| allowedDomains | string[] | omit/[] = any | Restrict sign-in to specific email domains (case-insensitive) |
| defaultAttributes | Record<string, unknown> | undefined | Default fields for auto-created users (e.g. { roles: ['editor'] }). Mapped profile fields override it |
| accountLinking | boolean | true | Link OAuth accounts to existing users with the same email on login |
| disableLocalStrategy | boolean | false | Disable email/password login entirely (also blocks unlinking — see Disconnect Flow) |
| authCollection | string | 'users' | Slug of the Payload auth collection |
| successRedirect | string \| (req, user) => string \| Promise<string> | '/admin' | Redirect after successful login |
| failureRedirect | string \| (req, error) => string \| Promise<string> | '/admin/login' | Redirect after failed login (?error= code appended) |
| serverURL | string | Payload serverURL, else '' | Override the server URL used for callback URIs |
| enabled | boolean | true | Kill switch; false returns the config untouched |
Google Provider Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| clientId | string | required | Google OAuth client ID |
| clientSecret | string | required | Google OAuth client secret |
| prompt | 'none' \| 'consent' \| 'select_account' \| 'select_account consent' | undefined | Google account selection / consent behavior |
| accessType | 'online' \| 'offline' | undefined | 'offline' requests a refresh token (Google issues it on the first consent only) |
| scopes | string[] | [] | Additional scopes beyond the base openid, email, profile |
| mapProfileToUser | (profile: OAuthUserInfo) => Record<string, unknown> | undefined | Map Google profile fields to Payload user fields (output is sanitized) |
| overrideUserInfoOnSignIn | boolean | false | Update user fields from Google on every login |
Exports
The package ships four entry points (see package.json exports):
| Import path | Exports | Use for |
|-------------|---------|---------|
| @purposeinplay/payload-oauth-plugin | oauthPlugin, googleProvider, types GoogleProviderOptions, OAuthAction, OAuthErrorCode, OAuthPluginOptions, OAuthProviderConfig, OAuthUserInfo | Server-side config (payload.config.ts) |
| @purposeinplay/payload-oauth-plugin/client | ConnectedAccounts, GoogleLoginButton | Client components; this is the path the plugin auto-registers in the import map |
| @purposeinplay/payload-oauth-plugin/providers | googleProvider, types GoogleProviderOptions, OAuthProviderConfig, OAuthUserInfo | Importing providers without the plugin entry |
| @purposeinplay/payload-oauth-plugin/admin | ConnectedAccounts, GoogleLoginButton (same components as /client) | Admin custom views |
Admin UI Components
Both components are 'use client' React components.
<GoogleLoginButton />
Rendered on the admin login page automatically (the plugin registers it in admin.components.afterLogin) — you normally don't mount it yourself. It renders an "or" divider plus a Google-branded button that navigates to ${apiBase}/oauth/google.
| Prop | Type | Default |
|------|------|---------|
| apiBase | string | '/api/users' |
| label | string | 'Sign in with Google' |
<ConnectedAccounts />
Connect/disconnect panel for account settings. Fetches the current user from /api/users/me, starts linking via ${apiBase}/oauth/{provider}?action=link, and disconnects via POST ${apiBase}/oauth/{provider}/unlink. Built-in labels for google, github, apple.
| Prop | Type | Default |
|------|------|---------|
| apiBase | string | '/api/users' |
| providers | string[] | ['google'] |
import { ConnectedAccounts } from '@purposeinplay/payload-oauth-plugin/admin'
export function SecurityView() {
return <ConnectedAccounts providers={['google']} />
}Google Cloud Setup
- Go to Google Cloud Console > Credentials
- Create an OAuth 2.0 Client ID (Web application)
- Add authorized redirect URIs:
- Development:
http://localhost:3000/api/users/oauth/google/callback(adjust port to match your dev server) - Production:
https://your-domain.com/api/users/oauth/google/callback
- Development:
- Copy the Client ID and Client Secret to your
.env(the names below are a convention — the plugin doesn't read them itself; you pass them togoogleProvider()):
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-your-client-secretUse Cases
Pre-approved users (admin creates first)
oauthPlugin({
providers: [googleProvider({ clientId: '...', clientSecret: '...' })],
allowSignUp: false, // user must exist in Payload
allowedDomains: ['wild.io'], // only company emails
})Flow: Admin creates user with email + password + role. User logs in with password, then connects Google from settings. Future logins work with either method.
Auto-create from allowed domain
oauthPlugin({
providers: [googleProvider({ clientId: '...', clientSecret: '...' })],
allowSignUp: true,
allowedDomains: ['wild.io'],
defaultAttributes: { roles: ['editor'] },
})Google-only (no password)
oauthPlugin({
disableLocalStrategy: true,
providers: [googleProvider({ clientId: '...', clientSecret: '...' })],
allowedDomains: ['wild.io'],
})Note: with
disableLocalStrategy: true, the unlink endpoint always rejects with400— disconnecting the only login method would lock users out. Google-only setups cannot disconnect accounts.
Account Linking
Users can connect and disconnect their Google account from the admin panel.
Connect Flow
- User is logged in (email/password)
- User navigates to security settings
- Clicks "Connect Google"
- Redirected to Google, signs in
- Google account is linked to their Payload user (the Google email must match the session user's email, case-insensitive)
Disconnect Flow
- User clicks "Disconnect" in security settings
- Plugin verifies the user has a password set (prevents lockout)
- Google link is removed
Unlinking is rejected with 400 when:
disableLocalStrategy: true(password login disabled — unlinking would lock the user out)- the user has no password hash set
- the provider is not linked
API Endpoints
The plugin registers these endpoints on the auth collection (paths shown for the default users collection):
| Endpoint | Method | Auth | Description |
|----------|--------|------|-------------|
| /api/users/oauth/google | GET | No | Start OAuth login flow |
| /api/users/oauth/google?action=link | GET | Yes | Start account linking flow (redirects to /admin/login without a session) |
| /api/users/oauth/google/callback | GET | -- | Handle OAuth callback |
| /api/users/oauth/google/unlink | POST | Yes | Remove Google link |
Unlink responses
| Status | Body | When |
|--------|------|------|
| 200 | { success: true, message: 'google account unlinked' } | Unlinked successfully |
| 400 | { error: '...' } | Local strategy disabled / no password set / provider not linked |
| 401 | { error: '...' } | Missing or invalid session token |
| 404 | { error: 'User not found' } | Session user no longer exists |
Error Codes
Failed logins redirect to failureRedirect with an ?error= query parameter:
| Code | Description |
|------|-------------|
| state_invalid | CSRF state validation failed |
| domain_not_allowed | Email domain not in allowedDomains |
| user_not_found | No matching user and allowSignUp is false |
| linking_failed | Account linking failed (email mismatch, no session) |
| provider_error | OAuth provider returned an error |
| unknown | Unexpected error |
Security
OAuth Flow
- HMAC-SHA256 signed state with nonce, timestamp, and action for CSRF protection
- PKCE (S256) on every authorization flow
- Timing-safe comparison on all signature checks
- State cookies are HttpOnly, SameSite=Lax, Secure in production, scoped to
/api, 10-minute max age - State and PKCE cookies are cleared on every callback response — success and all error paths — so a state value cannot be replayed
Account Linking
- Linking requires an active authenticated session
- Google email must match the authenticated user's email (case-insensitive)
- The action flag (
login/link) is inside the HMAC-signed state (cannot be tampered) - No new session token is issued during linking (prevents session fixation)
- Unlinking is blocked if it would lock the user out (no password set, or local strategy disabled)
Data Protection
- OAuth
subfield is never exposed via the API (read: () => false) - The
oauth.accountsarray field is readable by admins (users whoserolesinclude'admin') and by any authenticated user at the field level — Payload field access only supports booleans, so who can read the user document at all is controlled by your collection-level access, not by this plugin. The plugin does not enforce self-only visibility mapProfileToUseroutput is sanitized:roles,email,password,hash,salt,totp,oauth,id,_verified,loginAttempts,lockUntil, andcollectionare stripped- All emails normalized to lowercase before comparison and storage
Redirect Security
- All redirect URLs validated as same-origin or relative paths
- No open redirect possible through
successRedirectorfailureRedirect
Compatibility
- TOTP 2FA (
@purposeinplay/payload-totp): OAuth login produces the same JWT shape, so TOTP middleware enforcement keeps working. - Audit Logging (
@purposeinplay/payload-audit-log): the callback fires Payload'sbeforeLoginandafterLogincollection hooks, so login tracking works automatically. - Access Control: your collection-level access functions apply identically to OAuth and password users — sessions are standard Payload JWTs.
Adding Custom Providers
Implement the OAuthProviderConfig interface:
import type { OAuthProviderConfig } from '@purposeinplay/payload-oauth-plugin'
export function githubProvider(options: {
clientId: string
clientSecret: string
}): OAuthProviderConfig {
return {
name: 'github',
clientId: options.clientId,
clientSecret: options.clientSecret,
authorizationUrl: 'https://github.com/login/oauth/authorize',
tokenUrl: 'https://github.com/login/oauth/access_token',
scopes: ['read:user', 'user:email'],
async getUserInfo(accessToken) {
const res = await fetch('https://api.github.com/user', {
headers: { Authorization: `Bearer ${accessToken}` },
})
const data = await res.json()
return { email: data.email, sub: String(data.id), name: data.name, picture: data.avatar_url }
},
}
}License
MIT — see LICENSE.
Part of the purposeinplay/payload-plugins monorepo. See the CHANGELOG for release notes, and report issues on the issue tracker.
