├── .github └── workflows │ └── publish.yaml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── OAuthButton.tsx ├── index.ts └── types.ts ├── test ├── integration.test.ts └── payload │ ├── payload.config.js │ ├── server.js │ └── tsconfig.json └── tsconfig.json /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | publish: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 20 17 | registry-url: 'https://registry.npmjs.org' 18 | - run: npm ci 19 | - run: npm run build 20 | - run: npm test 21 | - run: npm publish 22 | env: 23 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { "singleQuote": true, "semi": false } 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Thomas Ghysels 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # oAuth plugin for Payload CMS 2 | 3 | 4 | Software License 5 | 6 | 7 | Issues 8 | 9 | 10 | NPM 11 | 12 | 13 | ## Features 14 | 15 | - Configures passport-oauth2 16 | - Mounts authorize & callback route 17 | - Adds sign in button on login page 18 | 19 | ## Installation 20 | 21 | Payload v2 22 | 23 | ``` 24 | npm install payload-plugin-oauth@^2 25 | # or 26 | yarn add payload-plugin-oauth@^2 27 | ``` 28 | 29 | Payload v1 30 | 31 | ``` 32 | npm install payload-plugin-oauth@^1 33 | # or 34 | yarn add payload-plugin-oauth@^1 35 | ``` 36 | 37 | ## Usage 38 | 39 | ```js 40 | // payload.config.ts 41 | import path from 'path' 42 | 43 | import { webpackBundler } from '@payloadcms/bundler-webpack' 44 | import { mongooseAdapter } from '@payloadcms/db-mongodb' 45 | import { slateEditor } from '@payloadcms/richtext-slate' 46 | import axios from 'axios' 47 | import { oAuthPlugin } from 'payload-plugin-oauth' 48 | import { buildConfig } from 'payload/config' 49 | import Users from './collections/Users' 50 | 51 | export default buildConfig({ 52 | admin: { 53 | user: Users.slug, 54 | bundler: webpackBundler(), 55 | }, 56 | editor: slateEditor({}), 57 | collections: [Users], 58 | typescript: { 59 | outputFile: path.resolve(__dirname, 'payload-types.ts'), 60 | }, 61 | graphQL: { 62 | schemaOutputFile: path.resolve(__dirname, 'generated-schema.graphql'), 63 | }, 64 | plugins: [ 65 | payloadCloud(), 66 | oAuthPlugin({ 67 | buttonLabel: 'Sign in with oAuth', 68 | databaseUri: process.env.DATABASE_URI, 69 | clientID: process.env.OAUTH_CLIENT_ID, 70 | clientSecret: process.env.OAUTH_CLIENT_SECRET, 71 | authorizationURL: process.env.OAUTH_AUTH_ENDPOINT, 72 | tokenURL: process.env.OAUTH_TOKEN_ENDPOINT, 73 | authorizePath: '/oauth/authorize1', 74 | callbackURL: process.env.ORIGIN + '/oauth/callback1', 75 | async userinfo(accessToken) { 76 | const { data: user } = await axios.get( 77 | process.env.OAUTH_USERINFO_ENDPOINT, 78 | { headers: { Authorization: `Bearer ${accessToken}` } } 79 | ) 80 | return { 81 | sub: user.ID, 82 | username: user.preferred_username, 83 | } 84 | }, 85 | }), 86 | // Another oAuth provider 87 | oAuthPlugin({ 88 | buttonLabel: 'Sign in with Alternative', 89 | // These paths must be unique per provider 90 | authorizePath: '/oauth/authorize2', 91 | callbackURL: process.env.ORIGIN + '/oauth/callback2', 92 | 93 | ...rest, 94 | }), 95 | ], 96 | db: mongooseAdapter({ 97 | url: process.env.DATABASE_URI, 98 | }), 99 | }) 100 | ``` 101 | 102 | ## Changelog 103 | 104 | Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. 105 | 106 | ## Contributing 107 | 108 | Contributions and feedback are very welcome. 109 | 110 | To get it running: 111 | 112 | 1. Clone the project. 113 | 2. `npm install` 114 | 3. `npm run build` 115 | 116 | ## Publishing process 117 | 118 | 1. Run `npm run fix` 119 | 2. Run `npm version minor` 120 | 3. Push to Github and let CI publish to NPM 121 | 122 | ## Credits 123 | 124 | - [Thomas Ghysels](https://github.com/thgh) 125 | - [Wilson Le](https://github.com/wilsonle) 126 | - [All Contributors][link-contributors] 127 | 128 | ## License 129 | 130 | The MIT License (MIT). Please see [License File](LICENSE) for more information. 131 | 132 | [link-contributors]: ../../contributors 133 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "payload-plugin-oauth", 3 | "version": "2.2.0", 4 | "license": "MIT", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "clean": "rm -rf dist", 9 | "build": "tsc", 10 | "fix": "npx prettier src --write", 11 | "prepare": "yarn fix && yarn clean && yarn build", 12 | "test": "vitest" 13 | }, 14 | "exports": { 15 | ".": { 16 | "types": "./dist/index.d.ts", 17 | "default": "./dist/index.js" 18 | }, 19 | "./dist/*": "./dist/*" 20 | }, 21 | "files": [ 22 | "dist" 23 | ], 24 | "dependencies": { 25 | "@bothrs/util": "^3", 26 | "connect-mongo": "^4", 27 | "debug": "^4", 28 | "express-session": "^1", 29 | "passport-oauth2": "^1" 30 | }, 31 | "devDependencies": { 32 | "@payloadcms/richtext-slate": "^1.0.0", 33 | "express": "^4.19.2", 34 | "mongodb-memory-server": "^9.4.0", 35 | "@types/express": "^4.17.9", 36 | "@payloadcms/db-mongodb": "^1.5.2", 37 | "@types/debug": "^4.1.7", 38 | "@types/express-session": "^1", 39 | "@types/jsonwebtoken": "^9.0.1", 40 | "@types/passport": "^1", 41 | "@types/passport-oauth2": "^1", 42 | "@types/react": "^18.0.33", 43 | "axios": "^1.7.2", 44 | "passport": "^0.6", 45 | "payload": "^2", 46 | "react": "^18", 47 | "typescript": "^4", 48 | "vitest": "^1.6.0" 49 | }, 50 | "peerDependencies": { 51 | "passport": "^0.6", 52 | "payload": "^2", 53 | "react": "^18" 54 | }, 55 | "author": "Thomas Ghysels ", 56 | "homepage": "https://github.com/thgh/payload-plugin-oauth", 57 | "bugs": { 58 | "url": "https://github.com/thgh/payload-plugin-oauth/issues" 59 | }, 60 | "repository": { 61 | "type": "git", 62 | "url": "https://github.com/thgh/payload-plugin-oauth" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/OAuthButton.tsx: -------------------------------------------------------------------------------- 1 | import Button from 'payload/dist/admin/components/elements/Button' 2 | import React from 'react' 3 | import { ButtonProps } from './types' 4 | 5 | export default function OAuthButton(props: ButtonProps) { 6 | return ( 7 |
8 | 11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { str62 } from '@bothrs/util/random' 2 | import MongoStore from 'connect-mongo' 3 | import session from 'express-session' 4 | import jwt from 'jsonwebtoken' 5 | import passport from 'passport' 6 | import OAuth2Strategy, { VerifyCallback } from 'passport-oauth2' 7 | import debug from 'debug' 8 | import payload from 'payload' 9 | import { Config } from 'payload/config' 10 | import { 11 | Field, 12 | fieldAffectsData, 13 | fieldHasSubFields, 14 | } from 'payload/dist/fields/config/types' 15 | import { PaginatedDocs } from 'payload/dist/database/types' 16 | import getCookieExpiration from 'payload/dist/utilities/getCookieExpiration' 17 | import { TextField } from 'payload/types' 18 | 19 | import OAuthButton from './OAuthButton' 20 | import type { oAuthPluginOptions } from './types' 21 | import { createElement } from 'react' 22 | 23 | export { OAuthButton, oAuthPluginOptions } 24 | 25 | interface User { 26 | id: string | number 27 | } 28 | 29 | const log = debug('plugin:oauth') 30 | 31 | // Detect client side because some dependencies may be nullified 32 | const CLIENTSIDE = typeof session !== 'function' 33 | 34 | /** 35 | * Example for Wordpress: 36 | * 37 | * ``` 38 | * export function mijnNederlandsAuth() { 39 | * return oAuthPlugin({ 40 | * databaseUri: process.env.MONGO_URL, 41 | * clientID: process.env.OAUTH_CLIENT_ID, 42 | * clientSecret: process.env.OAUTH_CLIENT_SECRET, 43 | * authorizationURL: process.env.OAUTH_SERVER + '/oauth/authorize', 44 | * tokenURL: process.env.OAUTH_SERVER + '/oauth/token', 45 | * callbackURL: process.env.ORIGIN + '/oauth/callback', 46 | * scope: 'basic', 47 | * async userinfo(accessToken) { 48 | * const { data: user } = await axios.get(process.env.OAUTH_SERVER + '/oauth/me', { 49 | * params: { access_token: accessToken }, 50 | * }) 51 | * return { 52 | * sub: user.ID, 53 | * 54 | * // Fields to fill in if user is created 55 | * name: user.display_name || user.user_nicename || 'Nameless', 56 | * email: user.user_email, 57 | * role: user.capabilities?.administrator ? 'admin' : 'user', 58 | * } 59 | * }, 60 | * }) 61 | * } 62 | * ``` 63 | */ 64 | export const oAuthPlugin = 65 | (options: oAuthPluginOptions) => 66 | (incoming: Config): Config => { 67 | // Shorthands 68 | const collectionSlug = options.userCollection?.slug || 'users' 69 | const sub = options.subField?.name || 'sub' 70 | 71 | // Spread the existing config 72 | const config: Config = { 73 | ...incoming, 74 | collections: (incoming.collections || []).map((c) => { 75 | // Let's track the oAuth id (sub) 76 | if ( 77 | c.slug === collectionSlug && 78 | !c.fields.some((f) => (f as TextField).name === sub) 79 | ) { 80 | c.fields.push({ 81 | name: sub, 82 | type: 'text', 83 | admin: { readOnly: true }, 84 | access: { update: () => false }, 85 | }) 86 | } 87 | return c 88 | }), 89 | } 90 | 91 | return CLIENTSIDE 92 | ? oAuthPluginClient(config, options) 93 | : oAuthPluginServer(config, options) 94 | } 95 | 96 | function oAuthPluginClient( 97 | incoming: Config, 98 | options: oAuthPluginOptions 99 | ): Config { 100 | const button = options.components?.Button ?? OAuthButton 101 | return button 102 | ? { 103 | ...incoming, 104 | admin: { 105 | ...incoming.admin, 106 | components: { 107 | ...incoming.admin?.components, 108 | beforeLogin: (incoming.admin?.components?.beforeLogin || []).concat( 109 | () => 110 | createElement(button, { 111 | authorizePath: options.authorizePath || '/oauth2/authorize', 112 | buttonLabel: options.buttonLabel || `Sign in with oAuth`, 113 | }) 114 | ), 115 | }, 116 | }, 117 | } 118 | : incoming 119 | } 120 | 121 | function oAuthPluginServer( 122 | incoming: Config, 123 | options: oAuthPluginOptions 124 | ): Config { 125 | // Shorthands 126 | const callbackPath = 127 | options.callbackPath || 128 | (options.callbackURL && new URL(options.callbackURL).pathname) || 129 | '/oauth2/callback' 130 | const authorizePath = options.authorizePath ?? '/oauth2/authorize' 131 | const collectionSlug = (options.userCollection?.slug as 'users') || 'users' 132 | const sub = options.subField?.name || 'sub' 133 | const oAuthStrategyCount = (incoming.custom?.oAuthStrategyCount || 0) + 1 134 | const strategyName = `oauth2-${oAuthStrategyCount}` 135 | 136 | if (options.clientID) { 137 | // Validate paths, they must be unique 138 | const oAuthPaths = incoming.custom?.oAuthPaths || new Set() 139 | if (oAuthPaths.has(authorizePath)) 140 | throw new Error( 141 | `Choose a unique authorizePath for oAuth strategy ${oAuthStrategyCount} (not ${options.authorizePath})` 142 | ) 143 | oAuthPaths.add(authorizePath) 144 | if (oAuthPaths.has(callbackPath)) 145 | throw new Error( 146 | `Choose a unique callbackPath for oAuth strategy ${oAuthStrategyCount} (not ${options.callbackPath})` 147 | ) 148 | 149 | // Passport strategy 150 | const strategy = new OAuth2Strategy(options, async function ( 151 | accessToken: string, 152 | refreshToken: string, 153 | profile: {}, 154 | cb: VerifyCallback 155 | ) { 156 | let info: { 157 | sub: string 158 | email?: string 159 | password?: string 160 | name?: string 161 | } 162 | let user: User & { collection?: any; _strategy?: any } 163 | let users: PaginatedDocs 164 | try { 165 | // Get the userinfo 166 | info = await options.userinfo?.(accessToken, refreshToken) 167 | if (!info) throw new Error('Failed to get userinfo') 168 | 169 | // Match existing user 170 | users = await payload.find({ 171 | collection: collectionSlug, 172 | where: { [sub]: { equals: info[sub as 'sub'] } }, 173 | showHiddenFields: true, 174 | }) 175 | 176 | if (users.docs && users.docs.length) { 177 | user = users.docs[0] 178 | if (options.updateOnLogin) { 179 | user = await payload.update({ 180 | collection: collectionSlug, 181 | id: user.id, 182 | data: info, 183 | showHiddenFields: true, 184 | }) 185 | } 186 | user.collection = collectionSlug 187 | user._strategy = strategyName 188 | } else { 189 | // Register new user 190 | user = await payload.create({ 191 | collection: collectionSlug, 192 | data: { 193 | ...info, 194 | // Stuff breaks when password is missing 195 | password: info.password || str62(20), 196 | }, 197 | showHiddenFields: true, 198 | }) 199 | log('signin.user', user) 200 | user.collection = collectionSlug 201 | user._strategy = strategyName 202 | } 203 | 204 | cb(null, user) 205 | } catch (error: any) { 206 | log('signin.fail', error.message, error.trace) 207 | cb(error) 208 | } 209 | }) 210 | 211 | // Alternative? 212 | // strategy.userProfile = async (accessToken, cb) => { 213 | // const user = await options.userinfo?.(accessToken) 214 | // if (!user) cb(new Error('Failed to get userinfo')) 215 | // else cb(null, user) 216 | // } 217 | 218 | passport.use(strategyName, strategy) 219 | // passport.serializeUser((user: Express.User, done) => { 220 | passport.serializeUser((user: any, done) => { 221 | done(null, user.id) 222 | }) 223 | passport.deserializeUser(async (id: string, done) => { 224 | const ok = await payload.findByID({ collection: collectionSlug, id }) 225 | done(null, ok) 226 | }) 227 | } else { 228 | console.warn('No client id, oauth disabled') 229 | } 230 | 231 | return { 232 | ...incoming, 233 | custom: { ...incoming.custom, oAuthStrategyCount }, 234 | admin: { 235 | ...incoming.admin, 236 | webpack: (webpackConfig) => { 237 | const config = incoming.admin?.webpack?.(webpackConfig) || webpackConfig 238 | return { 239 | ...config, 240 | resolve: { 241 | ...config.resolve, 242 | alias: { 243 | ...config.resolve?.alias, 244 | 'connect-mongo': false, 245 | 'express-session': false, 246 | 'passport-oauth2': false, 247 | jsonwebtoken: false, 248 | passport: false, 249 | }, 250 | }, 251 | } 252 | }, 253 | }, 254 | endpoints: (incoming.endpoints || []).concat([ 255 | { 256 | path: authorizePath, 257 | method: 'get', 258 | root: true, 259 | handler: passport.authenticate(strategyName), 260 | }, 261 | { 262 | path: callbackPath, 263 | method: 'get', 264 | root: true, 265 | handler: session( 266 | options.sessionOptions ?? { 267 | resave: false, 268 | saveUninitialized: false, 269 | secret: 270 | process.env.PAYLOAD_SECRET || 271 | log('Missing process.env.PAYLOAD_SECRET') || 272 | 'unsafe', 273 | store: options.databaseUri 274 | ? MongoStore.create({ mongoUrl: options.databaseUri }) 275 | : undefined, 276 | } 277 | ), 278 | }, 279 | { 280 | path: callbackPath, 281 | method: 'get', 282 | root: true, 283 | handler: passport.authenticate(strategyName, { failureRedirect: '/' }), 284 | }, 285 | { 286 | path: callbackPath, 287 | method: 'get', 288 | root: true, 289 | async handler(req, res) { 290 | // Get the Mongoose user 291 | const collectionConfig = payload.collections[collectionSlug].config 292 | 293 | // Sanitize the user object 294 | // let user = userDoc.toJSON({ virtuals: true }) 295 | let user = JSON.parse(JSON.stringify(req.user)) 296 | 297 | // Decide which user fields to include in the JWT 298 | const fieldsToSign = collectionConfig.fields.reduce( 299 | (signedFields, field: Field) => { 300 | const result = { 301 | ...signedFields, 302 | } 303 | 304 | if (!fieldAffectsData(field) && fieldHasSubFields(field)) { 305 | field.fields.forEach((subField) => { 306 | if (fieldAffectsData(subField) && subField.saveToJWT) { 307 | result[subField.name] = user[subField.name] 308 | } 309 | }) 310 | } 311 | 312 | if (fieldAffectsData(field) && field.saveToJWT) { 313 | result[field.name] = user[field.name] 314 | } 315 | 316 | return result 317 | }, 318 | { 319 | email: user.email, 320 | id: user.id, 321 | collection: collectionConfig.slug, 322 | } as any 323 | ) 324 | 325 | // Sign the JWT 326 | const token = jwt.sign(fieldsToSign, payload.secret, { 327 | expiresIn: collectionConfig.auth.tokenExpiration, 328 | }) 329 | 330 | // Set cookie 331 | res.cookie(`${payload.config.cookiePrefix}-token`, token, { 332 | path: '/', 333 | httpOnly: true, 334 | expires: getCookieExpiration(collectionConfig.auth.tokenExpiration), 335 | secure: collectionConfig.auth.cookies.secure, 336 | sameSite: collectionConfig.auth.cookies.sameSite, 337 | domain: collectionConfig.auth.cookies.domain || undefined, 338 | }) 339 | 340 | // Redirect to the defined path or default to the admin dashboard 341 | res.redirect(options.successRedirect || '/admin') 342 | }, 343 | }, 344 | ]), 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { type SessionOptions } from 'express-session' 2 | import type { StrategyOptions } from 'passport-oauth2' 3 | import type { ComponentType } from 'react' 4 | 5 | export interface oAuthPluginOptions extends StrategyOptions { 6 | /** Database connection URI in case the lib needs access to database */ 7 | databaseUri: string 8 | 9 | /** Options to pass to express-session 10 | * @default 11 | * ```js 12 | * { 13 | * resave: false, 14 | * saveUninitialized: false, 15 | * secret: process.env.PAYLOAD_SECRET, 16 | * store: options.databaseUri 17 | * ? MongoStore.create({ mongoUrl: options.databaseUri }) 18 | * : undefined, 19 | * }), 20 | * ``` 21 | * 22 | */ 23 | sessionOptions?: SessionOptions 24 | 25 | /** Endpoint to handle callback from oauth provider 26 | * Defaults to /oauth/authorize 27 | * Note that this will have /api prepended to it. 28 | * So the default value is actually /api/oauth/authorize 29 | * 30 | * @default /oauth/authorize 31 | */ 32 | authorizePath?: string 33 | 34 | /** Map an authentication result to a user */ 35 | userinfo: ( 36 | accessToken: string, 37 | refreshToken?: string 38 | ) => Promise<{ 39 | /** Unique identifier for the linked account */ 40 | sub: string 41 | /** Unique identifier for the linked account */ 42 | email?: string 43 | /** A password will be generated for new users */ 44 | password?: string 45 | /** Example of a custom field */ 46 | name?: string 47 | }> 48 | 49 | /** Which path to mount in express, defaults to the path in callbackURL */ 50 | callbackPath?: string 51 | 52 | /** 53 | * Text on the sign in button 54 | * @default "Sign in with oAuth" 55 | */ 56 | buttonLabel?: string 57 | 58 | components?: { 59 | Button?: false | ((props: ButtonProps) => JSX.Element) 60 | } 61 | userCollection?: { 62 | /** @default "users" */ 63 | slug?: string 64 | } 65 | /** If the collection does not have a field with name "sub", it will be created */ 66 | subField?: { 67 | /** @default "sub" */ 68 | name?: string 69 | } 70 | /** Path or URL to redirect the authenticated user to 71 | * @default /admin 72 | */ 73 | successRedirect?: string 74 | 75 | /** 76 | * Boolean to allow updating users on login 77 | * @default false 78 | */ 79 | updateOnLogin?: boolean 80 | } 81 | 82 | export type ButtonProps = { 83 | /** Path that initiates the oAuth flow */ 84 | authorizePath: string 85 | /** Text on the sign in button */ 86 | buttonLabel: string 87 | } 88 | -------------------------------------------------------------------------------- /test/integration.test.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import express from 'express' 3 | import { Server } from 'node:http' 4 | import { afterAll, beforeAll, expect, test } from 'vitest' 5 | import { spawn } from 'node:child_process' 6 | 7 | const oauth = getOAuthProvider() 8 | const app = getPayloadServer(oauth) 9 | 10 | test('basic sign in', async () => { 11 | // Sanity check 12 | expect(app.url).toMatch(/http:\/\/localhost:\d+/) 13 | 14 | // Build authorize URL 15 | const authorize = 16 | oauth.url + 17 | '/authorize?' + 18 | new URLSearchParams({ 19 | redirect_uri: app.url + '/oauth2/callback', 20 | response_type: 'code', 21 | client_id: 'client_id', 22 | }).toString() 23 | const callbackResponse = await axios.get(authorize, { 24 | validateStatus: () => true, 25 | maxRedirects: 0, 26 | }) 27 | const callback = callbackResponse.headers.location 28 | 29 | // Authorize should redirect to callback URL with code 30 | expect(callback).toMatch(/http:\/\/localhost:\d+\/oauth2\/callback/) 31 | const code = new URL(callback).searchParams.get('code') 32 | expect(code).toBe('testCode') 33 | 34 | // Exchange code for cookie, redirecting to /admin 35 | const done = await axios.get(callback, { 36 | validateStatus: () => true, 37 | maxRedirects: 0, 38 | }) 39 | expect(done.status, callback).toBe(302) 40 | expect(done.headers.location).toBe('/admin') 41 | 42 | // Validate cookie contents 43 | let decoded: any = {} 44 | try { 45 | decoded = done.headers['set-cookie'] 46 | decoded = decoded.find((c) => c.startsWith('payload-token=')) 47 | decoded = decoded.split(';')[0].split('=')[1].split('.')[1] 48 | decoded = JSON.parse(Buffer.from(decoded, 'base64').toString()) 49 | } catch (error) { 50 | console.log('decoded', decoded) 51 | } 52 | expect(decoded).toMatchObject({ 53 | email: 'test@example.org', 54 | name: 'existing', 55 | collection: 'users', 56 | }) 57 | }) 58 | 59 | function getPayloadServer(oauth) { 60 | const ctx: { 61 | url: string 62 | process: ReturnType 63 | } = { 64 | url: '', 65 | // @ts-expect-error 66 | process: null, 67 | } 68 | beforeAll(async () => { 69 | await oauth.ready 70 | await new Promise((resolve) => { 71 | const dev = spawn('node', ['server.js'], { 72 | stdio: 'pipe', 73 | cwd: __dirname + '/payload', 74 | env: { 75 | ...process.env, 76 | DEBUG: 'plugin:oauth:*', 77 | CLIENT_ID: 'client_id', 78 | CLIENT_SECRET: 'client_secret', 79 | AUTHORIZATION_URL: oauth.url + '/authorize', 80 | TOKEN_URL: oauth.url + '/token', 81 | USERINFO_URL: oauth.url + '/userinfo', 82 | }, 83 | }) 84 | dev.stdout.setEncoding('utf8') 85 | dev.stdout.on('data', (data) => { 86 | const line = data.toString().trim() 87 | if (line.startsWith('http://localhost:')) { 88 | ctx.url = line 89 | resolve(ctx.url) 90 | } else if ( 91 | // Reduce clutter while showing unexpected output 92 | !line.includes('in-memory Mongo') && 93 | !line.includes('webpack') && 94 | !line.includes('Starting Payload') 95 | ) { 96 | console.log('dev.stdout', data.toString()) 97 | } 98 | }) 99 | 100 | dev.stderr.setEncoding('utf8') 101 | dev.stderr.on('data', (data) => { 102 | console.log('dev.stderr', data.toString()) 103 | }) 104 | 105 | ctx.process = dev 106 | }) 107 | }, 30000) 108 | afterAll(async () => { 109 | ctx.process.kill() 110 | }) 111 | 112 | return ctx 113 | } 114 | 115 | function getOAuthProvider() { 116 | const ctx: { 117 | url: string 118 | server: Server 119 | ready: Promise 120 | } = { 121 | url: '', 122 | // @ts-expect-error 123 | server: null, 124 | // @ts-expect-error 125 | ready: null, 126 | } 127 | beforeAll(async () => { 128 | ctx.ready = new Promise((resolve) => { 129 | const app = express() 130 | app.use(express.json()) 131 | app.use(express.urlencoded({ extended: true })) 132 | app.get('/authorize', (req, res) => { 133 | if (req.query.response_type !== 'code') 134 | return res 135 | .status(400) 136 | .json({ error: 'invalid_request', query: req.query }) 137 | if (req.query.client_id !== 'client_id') 138 | return res 139 | .status(400) 140 | .json({ error: 'invalid_client', query: req.query }) 141 | if (!req.query.redirect_uri) 142 | return res 143 | .status(400) 144 | .json({ error: 'invalid_redirect_uri', query: req.query }) 145 | const to = req.query.redirect_uri + '?code=testCode' 146 | // console.log('authorize.redirect', to) 147 | res.redirect(to) 148 | }) 149 | app.use('/token', (req, res) => { 150 | // console.log('token', req.body, req.query) 151 | res.json({ 152 | access_token: 'a', 153 | refresh_token: 'r', 154 | token_type: 'bearer', 155 | expires_in: 3600, 156 | }) 157 | }) 158 | app.use('/userinfo', (req, res) => { 159 | if (req.headers.authorization !== 'Bearer a') 160 | return res.status(401).json({ error: 'invalid_token' }) 161 | res.json({ 162 | sub: 'existing_sub', 163 | }) 164 | }) 165 | app.get('/callback', async (req, res) => { 166 | console.log('callback on wrong server', req.query) 167 | res.json(req.query) 168 | }) 169 | app.use((req, res) => { 170 | console.log('oauth 404', req.url) 171 | res.status(404).json({ url: req.url }) 172 | }) 173 | ctx.server = app.listen(0, () => resolve(ctx.server)) 174 | }) 175 | 176 | const server = await ctx.ready 177 | 178 | // Start server 179 | const address = server.address() 180 | if (typeof address === 'string') ctx.url = address 181 | else if (address) ctx.url = 'http://localhost:' + address.port 182 | }) 183 | afterAll(async () => { 184 | // Close server 185 | ctx.server.close() 186 | }) 187 | 188 | return ctx 189 | } 190 | -------------------------------------------------------------------------------- /test/payload/payload.config.js: -------------------------------------------------------------------------------- 1 | // import { mongooseAdapter } from '@payloadcms/db-mongodb' 2 | // import { slateEditor } from '@payloadcms/richtext-slate' 3 | // import { buildConfig } from 'payload/config' 4 | // import { oAuthPlugin } from '../../src/index' 5 | const { mongooseAdapter } = require('@payloadcms/db-mongodb') 6 | const { slateEditor } = require('@payloadcms/richtext-slate') 7 | const { buildConfig } = require('payload/config') 8 | const { oAuthPlugin } = require('../..') 9 | 10 | module.exports = buildConfig({ 11 | admin: { 12 | disable: true, 13 | }, 14 | editor: slateEditor({}), 15 | collections: [ 16 | { 17 | slug: 'users', 18 | auth: true, 19 | fields: [{ name: 'name', type: 'text', saveToJWT: true }], 20 | }, 21 | ], 22 | plugins: [ 23 | oAuthPlugin({ 24 | authorizationURL: process.env.AUTHORIZATION_URL, 25 | tokenURL: process.env.TOKEN_URL, 26 | clientID: process.env.CLIENT_ID, 27 | clientSecret: process.env.CLIENT_SECRET, 28 | userinfo: async (accessToken) => 29 | fetch(process.env.USERINFO_URL, { 30 | headers: { Authorization: `Bearer ${accessToken}` }, 31 | }).then((r) => r.json()), 32 | }), 33 | ], 34 | db: mongooseAdapter({ url: '' }), 35 | }) 36 | -------------------------------------------------------------------------------- /test/payload/server.js: -------------------------------------------------------------------------------- 1 | // import express from 'express' 2 | // import payload from 'payload' 3 | const express = require('express') 4 | const payload = require('payload') 5 | 6 | const app = express() 7 | 8 | payload.init({ 9 | secret: 'test', 10 | express: app, 11 | onInit: async () => { 12 | await payload.create({ 13 | collection: 'users', 14 | data: { 15 | sub: 'existing_sub', 16 | email: 'test@example.org', 17 | password: 'password', 18 | name: 'existing', 19 | }, 20 | }) 21 | const server = app.listen(0, () => 22 | console.log(`http://localhost:${server.address().port}`) 23 | ) 24 | }, 25 | }) 26 | -------------------------------------------------------------------------------- /test/payload/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "strict": false, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "outDir": "./dist", 10 | "jsx": "react", 11 | "paths": { 12 | "payload/generated-types": ["./src/payload-types.ts"] 13 | } 14 | }, 15 | "include": ["src", "server.ts"], 16 | "exclude": ["node_modules", "dist", "build"], 17 | "ts-node": { 18 | "transpileOnly": true, 19 | "swc": true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "module": "commonjs", 5 | "allowJs": true /* Allow javascript files to be compiled. */, 6 | "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, 7 | "declaration": true /* Generates corresponding '.d.ts' file. */, 8 | "outDir": "./dist" /* Redirect output structure to the directory. */, 9 | "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 10 | "strict": true /* Enable all strict type-checking options. */, 11 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 12 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 13 | "skipLibCheck": true /* Skip type checking of declaration files. */, 14 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 15 | }, 16 | "include": ["src"], 17 | "exclude": [ 18 | "dist", 19 | "build", 20 | "tests", 21 | "**/*.spec.js", 22 | "**/*.spec.ts", 23 | "node_modules", 24 | ".eslintrc.js" 25 | ] 26 | } 27 | --------------------------------------------------------------------------------