",
8 | "license": "MIT",
9 | "repository": "git@github.com:ScottEAdams/payload-plugin-google-one-tap.git",
10 | "scripts": {
11 | "build": "tsc",
12 | "test": "echo \"Error: no test specified\" && exit 1"
13 | },
14 | "files": [
15 | "dist"
16 | ],
17 | "dependencies": {
18 | "@react-oauth/google": "^0.2.8",
19 | "jsonwebtoken": "^8.5.1",
20 | "passport-google-one-tap": "^1.0.0",
21 | "stream-browserify": "^3.0.0",
22 | "util": "^0.12.5"
23 | },
24 | "devDependencies": {
25 | "@types/express": "^4.17.14",
26 | "@types/jsonwebtoken": "^8.5.9",
27 | "@types/react-router-dom": "^5.3.3",
28 | "payload": "^1.1.15",
29 | "prettier": "^2.7.1",
30 | "typescript": "^4.6.2"
31 | },
32 | "peerDependencies": {
33 | "payload": "^1.1.15"
34 | },
35 | "keywords": [
36 | "payload",
37 | "plugin",
38 | "google",
39 | "login"
40 | ]
41 | }
42 |
--------------------------------------------------------------------------------
/src/payload/googleOneTap.ts:
--------------------------------------------------------------------------------
1 | import { Config } from 'payload/config'
2 |
3 | import GoogleOneTapButton from '../components/button'
4 | import GoogleOneTapEndpoint from './endpoint'
5 | import GoogleOneTapProvider from '../components/provider'
6 | import GoogleOneTapStrategy from './strategy'
7 | import { addBeforeLogin, addEndpointsToConfig, addProvider, addStrategyToCollection } from './utils'
8 | import { CollectionConfig } from 'payload/types'
9 |
10 | const googleOneTap =
11 | (buttonProps = {}) =>
12 | (incomingConfig: Config): Config => {
13 | let config: Config = {
14 | ...incomingConfig
15 | }
16 |
17 | // add the strategy
18 | const userSlug = config?.admin?.user || 'users'
19 |
20 | config.collections = config?.collections?.map((collectionConfig: CollectionConfig) => {
21 | if (collectionConfig.slug === userSlug) {
22 | const strategy = { strategy: GoogleOneTapStrategy, name: 'google-one-tap' }
23 | collectionConfig = addStrategyToCollection(collectionConfig, strategy)
24 | }
25 | return collectionConfig
26 | })
27 |
28 | // add the endpoint
29 | config = addEndpointsToConfig(config, GoogleOneTapEndpoint)
30 |
31 | // add the components
32 | config = addBeforeLogin(config, GoogleOneTapButton(buttonProps))
33 | config = addProvider(config, GoogleOneTapProvider)
34 |
35 | return config
36 | }
37 |
38 | export default googleOneTap
39 |
--------------------------------------------------------------------------------
/src/payload/finalise.ts:
--------------------------------------------------------------------------------
1 | import { CookieOptions, Response } from 'express'
2 | import jwt from 'jsonwebtoken'
3 |
4 | import { getCookieExpiration, getFieldsToSign, sanitizeInternalFields } from './utils'
5 | import { PayloadRequest } from 'payload/types'
6 |
7 | const finalise = (req: PayloadRequest, res: Response) => {
8 | let user = req.user.toJSON({ virtuals: true })
9 | user = JSON.parse(JSON.stringify(user))
10 | user = sanitizeInternalFields(user)
11 |
12 | const collections = req.payload.collections
13 | const userCollection = collections[req.payload.config.admin.user]
14 | const collectionConfig = userCollection.config
15 |
16 | const fieldsToSign = getFieldsToSign(collectionConfig, user)
17 |
18 | const token = jwt.sign(fieldsToSign, req.payload.secret, {
19 | expiresIn: collectionConfig.auth.tokenExpiration
20 | })
21 |
22 | const cookieOptions: CookieOptions = {
23 | path: '/',
24 | httpOnly: true,
25 | expires: getCookieExpiration(collectionConfig.auth.tokenExpiration),
26 | secure: collectionConfig.auth.cookies.secure,
27 | sameSite: collectionConfig.auth.cookies.sameSite,
28 | domain: undefined
29 | }
30 |
31 | if (collectionConfig.auth.cookies.domain) {
32 | cookieOptions.domain = collectionConfig.auth.cookies.domain
33 | }
34 |
35 | res.cookie(`payload-token`, token, cookieOptions)
36 |
37 | req.user = user
38 |
39 | return { req, res, token }
40 | }
41 |
42 | export default finalise
43 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # payload-plugin-google-one-tap
2 |
3 | Adds google-one-tap functionality to the login page.
4 |
5 | ## Installation
6 |
7 | ```
8 | yarn add payload-plugin-google-one-tap
9 | ```
10 |
11 | You will need to add your google credentials as environment variables:
12 | ```dotenv
13 | PAYLOAD_PUBLIC_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
14 | GOOGLE_CLIENT_SECRET=your-google-secret
15 | ```
16 |
17 | Then you can add the plugin to your payload.config:
18 |
19 | ```js
20 | import { googleOneTap } from 'payload-plugin-google-one-tap'
21 |
22 | export default buildConfig({
23 | ...
24 | plugins: [googleOneTap()],
25 | ...
26 | })
27 | ```
28 |
29 | This package uses the following libraries:
30 |
31 | https://github.com/MomenSherif/react-oauth
32 |
33 | https://github.com/Genially/passport-google-one-tap
34 |
35 | You will need to add the following webpack config:
36 |
37 | ```js
38 | admin: {
39 | webpack: (config) => ({
40 | ...config,
41 | resolve: {
42 | ...config.resolve,
43 | fallback: {
44 | ...config.resolve.fallback,
45 | util: require.resolve('util'),
46 | stream: require.resolve('stream-browserify'),
47 | fs: false,
48 | url: false,
49 | querystring: false,
50 | child_process: false,
51 | assert: false,
52 | tls: false,
53 | net: false,
54 | os: false
55 | }
56 | }
57 | })
58 | }
59 | ```
60 |
61 | And finally you will need to add the following to your server.ts file:
62 | ```js
63 | const app = express()
64 |
65 | app.use(express.json())
66 | ```
67 |
68 | You can customise the login button if you like by passing in some props in your payload.config.ts. Heres the defaults:
69 |
70 | ```js
71 | plugins: [
72 | googleOneTap({
73 | type: 'standard',
74 | theme: 'outline',
75 | size: 'large',
76 | text: 'signin_with',
77 | shape: 'rectangular',
78 | logo_alignment: 'left',
79 | width: undefined, // auto,
80 | locale: undefined,
81 | useOneTap: true,
82 | auto_select: false,
83 | ux_mode: 'popup'
84 | })
85 | ]
86 | ```
87 |
88 |
--------------------------------------------------------------------------------
/src/payload/strategy.ts:
--------------------------------------------------------------------------------
1 | import { GoogleOneTapStrategy as GOTStrategy } from 'passport-google-one-tap'
2 | import { Payload } from 'payload'
3 | import { Forbidden, LockedAuth } from 'payload/errors'
4 | import { PayloadRequest } from 'payload/types'
5 |
6 | import { makeRandomPassword } from './utils'
7 |
8 | interface GoogleOneTapStrategyOptionWithRequest {
9 | clientID?: string
10 | clientSecret?: string
11 | verifyCsrfToken?: boolean
12 | passReqToCallback: true
13 | }
14 |
15 | const GoogleOneTapStrategy = ({ config, collections }: Payload) => {
16 | const opts: GoogleOneTapStrategyOptionWithRequest = {
17 | clientID: process.env.PAYLOAD_PUBLIC_GOOGLE_CLIENT_ID,
18 | clientSecret: process.env.GOOGLE_CLIENT_SECRET,
19 | verifyCsrfToken: false,
20 | passReqToCallback: true
21 | }
22 |
23 | // @ts-ignore
24 | return new GOTStrategy(opts, async (req: PayloadRequest, profile, done) => {
25 | if (req.user) {
26 | done(null, req.user)
27 | }
28 |
29 | const email = profile?.emails?.[0]?.value
30 | if (!email) {
31 | done(Forbidden, false)
32 | }
33 |
34 | try {
35 | const userCollection = collections[config.admin.user]
36 | const collectionConfig = userCollection.config
37 | const slug = collectionConfig.slug
38 |
39 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
40 | // @ts-ignore Improper typing in library, additional args should be optional
41 | let userDoc = await userCollection.Model.findByUsername(email)
42 |
43 | const isLocked = (date: number): boolean => !!(date && date > Date.now())
44 | if (userDoc && isLocked(userDoc.lockUntil)) {
45 | throw new LockedAuth()
46 | }
47 |
48 | if (!userDoc) {
49 | const password = makeRandomPassword(10)
50 | userDoc = await req.payload.create({
51 | req,
52 | collection: slug,
53 | data: { email: profile.emails[0].value, password: password, _verified: true }
54 | })
55 | }
56 |
57 | if (userDoc) {
58 | userDoc.collection = slug
59 | userDoc._strategy = 'google-one-tap'
60 | done(null, userDoc)
61 | } else {
62 | done(null, false)
63 | }
64 | } catch (err) {
65 | done(err, false)
66 | }
67 | })
68 | }
69 |
70 | export default GoogleOneTapStrategy
71 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ### Node ###
2 | # Logs
3 | logs
4 | *.log
5 | npm-debug.log*
6 | yarn-debug.log*
7 | yarn-error.log*
8 | lerna-debug.log*
9 | .pnpm-debug.log*
10 |
11 | # Diagnostic reports (https://nodejs.org/api/report.html)
12 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
13 |
14 | # Runtime data
15 | pids
16 | *.pid
17 | *.seed
18 | *.pid.lock
19 |
20 | # Directory for instrumented libs generated by jscoverage/JSCover
21 | lib-cov
22 |
23 | # Coverage directory used by tools like istanbul
24 | coverage
25 | *.lcov
26 |
27 | # nyc test coverage
28 | .nyc_output
29 |
30 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
31 | .grunt
32 |
33 | # Bower dependency directory (https://bower.io/)
34 | bower_components
35 |
36 | # node-waf configuration
37 | .lock-wscript
38 |
39 | # Compiled binary addons (https://nodejs.org/api/addons.html)
40 | build/Release
41 |
42 | # Dependency directories
43 | node_modules/
44 | jspm_packages/
45 |
46 | # Snowpack dependency directory (https://snowpack.dev/)
47 | web_modules/
48 |
49 | # TypeScript cache
50 | *.tsbuildinfo
51 |
52 | # Optional npm cache directory
53 | .npm
54 |
55 | # Optional eslint cache
56 | .eslintcache
57 |
58 | # Optional stylelint cache
59 | .stylelintcache
60 |
61 | # Microbundle cache
62 | .rpt2_cache/
63 | .rts2_cache_cjs/
64 | .rts2_cache_es/
65 | .rts2_cache_umd/
66 |
67 | # Optional REPL history
68 | .node_repl_history
69 |
70 | # Output of 'npm pack'
71 | *.tgz
72 |
73 | # Yarn Integrity file
74 | .yarn-integrity
75 |
76 | # dotenv environment variable files
77 | .env
78 | .env.development.local
79 | .env.test.local
80 | .env.production.local
81 | .env.local
82 |
83 | # parcel-bundler cache (https://parceljs.org/)
84 | .cache
85 | .parcel-cache
86 |
87 | # Next.js build output
88 | .next
89 | out
90 |
91 | # Nuxt.js build / generate output
92 | .nuxt
93 | dist
94 |
95 | # Gatsby files
96 | .cache/
97 | # Comment in the public line in if your project uses Gatsby and not Next.js
98 | # https://nextjs.org/blog/next-9-1#public-directory-support
99 | # public
100 |
101 | # vuepress build output
102 | .vuepress/dist
103 |
104 | # vuepress v2.x temp and cache directory
105 | .temp
106 |
107 | # Docusaurus cache and generated files
108 | .docusaurus
109 |
110 | # Serverless directories
111 | .serverless/
112 |
113 | # FuseBox cache
114 | .fusebox/
115 |
116 | # DynamoDB Local files
117 | .dynamodb/
118 |
119 | # TernJS port file
120 | .tern-port
121 |
122 | # Stores VSCode versions used for testing VSCode extensions
123 | .vscode-test
124 |
125 | # yarn v2
126 | .yarn/cache
127 | .yarn/unplugged
128 | .yarn/build-state.yml
129 | .yarn/install-state.gz
130 | .pnp.*
131 |
132 | ### Node Patch ###
133 | # Serverless Webpack directories
134 | .webpack/
135 |
136 | # Optional stylelint cache
137 |
138 | # SvelteKit build / generate output
139 | .svelte-kit
140 |
141 | ### VisualStudioCode ###
142 | .vscode/*
143 | !.vscode/settings.json
144 | !.vscode/tasks.json
145 | !.vscode/launch.json
146 | !.vscode/extensions.json
147 | !.vscode/*.code-snippets
148 |
149 | # Local History for Visual Studio Code
150 | .history/
151 |
152 | # Built Visual Studio Code Extensions
153 | *.vsix
154 |
155 | ### VisualStudioCode Patch ###
156 | # Ignore all local history of files
157 | .history
158 | .ionide
159 |
160 | # Support for Project snippet scope
161 | .vscode/*.code-snippets
162 |
163 | # Ignore code-workspaces
164 | *.code-workspace
165 |
166 | # End of https://www.toptal.com/developers/gitignore/api/node,visualstudiocode
167 | .idea
--------------------------------------------------------------------------------
/src/components/button.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {useHistory} from 'react-router-dom'
3 | import {GoogleLogin} from '@react-oauth/google'
4 | import {useAuth, useConfig} from 'payload/components/utilities'
5 |
6 | export enum GType {
7 | standard = 'standard',
8 | icon = 'icon'
9 | }
10 |
11 | export enum GTheme {
12 | outline = 'outline',
13 | filled_blue = 'filled_blue',
14 | filled_black = 'filled_black'
15 | }
16 |
17 | export enum GSize {
18 | large = 'large',
19 | medium = 'medium',
20 | small = 'small'
21 | }
22 |
23 | export enum GText {
24 | signin_with = 'signin_with',
25 | signup_with = 'signup_with',
26 | continue_with = 'continue_with',
27 | signin = 'signin'
28 | }
29 |
30 | export enum GShape {
31 | rectangular = 'rectangular',
32 | pill = 'pill',
33 | circle = 'circle',
34 | square = 'square'
35 | }
36 |
37 | export enum GAlignment {
38 | left = 'left',
39 | center = 'center'
40 | }
41 |
42 | export enum GUxMode {
43 | popup = 'popup',
44 | redirect = 'redirect'
45 | }
46 |
47 | export interface GoogleOneTapButtonProps {
48 | type?: GType
49 | theme?: GTheme
50 | size?: GSize
51 | text?: GText
52 | shape?: GShape
53 | logo_alignment?: GAlignment
54 | width?: string | undefined
55 | locale?: string | undefined
56 | useOneTap?: boolean
57 | auto_select?: boolean
58 | ux_mode?: GUxMode
59 | }
60 |
61 | interface sucecssJson {
62 | token?: string
63 | }
64 |
65 | const GoogleOneTapButton = (props?: GoogleOneTapButtonProps) => {
66 | return () => {
67 | const {
68 | type = GType.standard,
69 | theme = GTheme.outline,
70 | size = GSize.large,
71 | text = GText.signin_with,
72 | shape = GShape.rectangular,
73 | logo_alignment = GAlignment.left,
74 | width = undefined,
75 | locale = undefined,
76 | useOneTap = true,
77 | auto_select = false,
78 | ux_mode = GUxMode.popup
79 | } = props || {}
80 | const {setToken} = useAuth()
81 | const {
82 | routes: {admin}
83 | } = useConfig()
84 | const history = useHistory()
85 |
86 | const onSuccess = (data: sucecssJson) => {
87 | if (data.token) {
88 | setToken(data.token)
89 | history.push(admin)
90 | }
91 | }
92 |
93 | return (
94 |
95 | {
108 | try {
109 | const response = await fetch(
110 | `${window.location.protocol}//${window.location.host}/oauth2/callback/google`,
111 | {
112 | method: 'POST',
113 | body: JSON.stringify(credentialResponse),
114 | headers: {'Content-type': 'application/json; charset=UTF-8'}
115 | }
116 | )
117 | const authed = await response.json()
118 | onSuccess(authed)
119 | } catch (e) {
120 | console.error(e)
121 | }
122 | }}
123 | onError={() => {
124 | console.error('Login Failed')
125 | }}
126 | />
127 |
128 | )
129 | }
130 | }
131 |
132 | export default GoogleOneTapButton
133 |
--------------------------------------------------------------------------------
/src/payload/utils.ts:
--------------------------------------------------------------------------------
1 | import crypto from 'crypto'
2 |
3 | import {
4 | ArrayField,
5 | BlockField,
6 | CheckboxField,
7 | CodeField,
8 | CollapsibleField,
9 | CollectionConfig,
10 | DateField,
11 | EmailField,
12 | Field,
13 | GroupField,
14 | NumberField,
15 | PointField,
16 | RadioField,
17 | RelationshipField,
18 | RichTextField,
19 | RowField,
20 | SelectField,
21 | TextareaField,
22 | TextField,
23 | UIField,
24 | UploadField
25 | } from 'payload/types'
26 | import { Config, Endpoint } from 'payload/config'
27 | import React from 'react'
28 |
29 | export const makeRandomPassword = (length = 20) => {
30 | const characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz~!@-#$'
31 | return Array.from(crypto.randomFillSync(new Uint32Array(length)))
32 | .map((x) => characters[x % characters.length])
33 | .join('')
34 | }
35 |
36 | export const addStrategyToCollection = (collectionConfig: CollectionConfig, strategy: any) => {
37 | if (!collectionConfig?.auth || typeof collectionConfig.auth === 'boolean') {
38 | collectionConfig.auth = {}
39 | }
40 | const existingStrategies = collectionConfig?.auth?.strategies || []
41 | collectionConfig.auth.strategies = [...existingStrategies, strategy]
42 | return collectionConfig
43 | }
44 |
45 | export const addEndpointsToConfig = (config: Config, endpoint: Endpoint) => {
46 | const existingEndpoints = config?.endpoints || []
47 | config.endpoints = [...existingEndpoints, endpoint]
48 | return config
49 | }
50 |
51 | export const addBeforeLogin = (config: Config, component: React.ComponentType) => {
52 | if (!config?.admin) {
53 | config.admin = {}
54 | }
55 | if (!config?.admin?.components) {
56 | config.admin.components = {}
57 | }
58 | const existingComponents = config?.admin?.components?.beforeLogin || []
59 | config.admin.components.beforeLogin = [...existingComponents, component]
60 | return config
61 | }
62 |
63 | export const addProvider = (config: Config, component: React.ComponentType) => {
64 | if (!config?.admin) {
65 | config.admin = {}
66 | }
67 | if (!config?.admin?.components) {
68 | config.admin.components = {}
69 | }
70 | const existingComponents = config?.admin?.components?.providers || []
71 | config.admin.components.providers = [...existingComponents, component]
72 | return config
73 | }
74 |
75 | export const getCookieExpiration = (seconds = 7200) => {
76 | const currentTime = new Date()
77 | currentTime.setSeconds(currentTime.getSeconds() + seconds)
78 | return currentTime
79 | }
80 |
81 | const internalFields = ['__v', 'salt', 'hash']
82 | export const sanitizeInternalFields = (incomingDoc: any) =>
83 | Object.entries(incomingDoc).reduce((newDoc, [key, val]) => {
84 | if (key === '_id') {
85 | return {
86 | ...newDoc,
87 | id: val
88 | }
89 | }
90 |
91 | if (internalFields.indexOf(key) > -1) {
92 | return newDoc
93 | }
94 |
95 | return {
96 | ...newDoc,
97 | [key]: val
98 | }
99 | }, {})
100 |
101 | export const getFieldsToSign = (collectionConfig: any, user: any) => {
102 | type FieldWithSubFields = GroupField | ArrayField | RowField | CollapsibleField
103 |
104 | function fieldHasSubFields(field: Field): field is FieldWithSubFields {
105 | return (
106 | field.type === 'group' ||
107 | field.type === 'array' ||
108 | field.type === 'row' ||
109 | field.type === 'collapsible'
110 | )
111 | }
112 |
113 | function fieldIsPresentationalOnly(field: Field): field is UIField {
114 | return field.type === 'ui'
115 | }
116 |
117 | type FieldAffectingData =
118 | | TextField
119 | | NumberField
120 | | EmailField
121 | | TextareaField
122 | | CheckboxField
123 | | DateField
124 | | BlockField
125 | | GroupField
126 | | RadioField
127 | | RelationshipField
128 | | ArrayField
129 | | RichTextField
130 | | SelectField
131 | | UploadField
132 | | CodeField
133 | | PointField
134 |
135 | function fieldAffectsData(field: Field): field is FieldAffectingData {
136 | return 'name' in field && !fieldIsPresentationalOnly(field)
137 | }
138 |
139 | return collectionConfig.fields.reduce(
140 | (signedFields: any, field: Field) => {
141 | const result = {
142 | ...signedFields
143 | }
144 |
145 | if (!fieldAffectsData(field) && fieldHasSubFields(field)) {
146 | field.fields.forEach((subField) => {
147 | if (fieldAffectsData(subField) && subField.saveToJWT) {
148 | result[subField.name] = user[subField.name]
149 | }
150 | })
151 | }
152 |
153 | if (fieldAffectsData(field) && field.saveToJWT) {
154 | result[field.name] = user[field.name]
155 | }
156 |
157 | return result
158 | },
159 | {
160 | email: user.email,
161 | id: user.id,
162 | collection: collectionConfig.slug
163 | }
164 | )
165 | }
166 |
--------------------------------------------------------------------------------