From 2e62c9e66838b58c17b6fa1c27f644c5d7bdb3c1 Mon Sep 17 00:00:00 2001 From: Corfitz Date: Fri, 13 Oct 2023 16:58:31 +0200 Subject: [PATCH 01/12] Removing cookies from plugin feature --- .changeset/cool-moose-breathe.md | 5 ++++ README.md | 11 ++++---- TODO.md | 1 - src/collections/OAuthApps/index.ts | 15 ----------- .../handlers/authorize/credentials.ts | 23 ---------------- src/endpoints/oauth/refresh-token.ts | 16 ------------ src/endpoints/oauth/verify.ts | 26 ------------------- src/express/middleware/csrf.ts | 4 +-- src/types.ts | 1 - 9 files changed, 11 insertions(+), 91 deletions(-) create mode 100644 .changeset/cool-moose-breathe.md diff --git a/.changeset/cool-moose-breathe.md b/.changeset/cool-moose-breathe.md new file mode 100644 index 0000000..247032d --- /dev/null +++ b/.changeset/cool-moose-breathe.md @@ -0,0 +1,5 @@ +--- +"@imcorfitz/payload-plugin-oauth-apps": minor +--- + +Removing cookies from plugin feature diff --git a/README.md b/README.md index 0ea8391..5cd87fe 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,6 @@ - `Session management` on User collections with ability to revoke active sessions - `Passwordless authentication` using One-time password (OTP) or Magiclink (Coming soon) - Automatically adds registered OAuth apps to `CSRF` and `CORS` config in Payload -- Full support of native Payload Auth cookies and JWT passport strategy ## Installation @@ -176,11 +175,11 @@ const Admins: CollectionConfig = { > Note: Don't ever expose your client id or client secret to the client. These operations should always be made securely from server-side. - | Parameter | Description | - | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | - | refreshToken | Only `required` if cookie authentication isn't enable for the OAuth app. Otherwise passing the cookies with the request will suffice | - | clientId `required` | The client id of the OAuth App performing the operation | - | clientSecret `required` | The client secret of the OAuth App performing the operation | + | Parameter | Description | + | ----------------------- | ----------------------------------------------------------- | + | refreshToken `required` | The refresh token issued at authorization | + | clientId `required` | The client id of the OAuth App performing the operation | + | clientSecret `required` | The client secret of the OAuth App performing the operation | ```ts // Request diff --git a/TODO.md b/TODO.md index fb406e5..404ea70 100644 --- a/TODO.md +++ b/TODO.md @@ -9,4 +9,3 @@ - [ ] Write more documentation - [ ] Test support for vite bundler and Postgres DB adapter - [ ] Write tests -- [ ] Bin cookies from the auth process diff --git a/src/collections/OAuthApps/index.ts b/src/collections/OAuthApps/index.ts index b7fafaa..bece327 100644 --- a/src/collections/OAuthApps/index.ts +++ b/src/collections/OAuthApps/index.ts @@ -85,21 +85,6 @@ export const OAuthApps: CollectionConfig = { 'When using magiclink, this is the URL that the user will be redirected to after they have authenticated. The callback URL will receive a query parameter called `token` which can be used in exchange for an access and refresh token.', }, }, - { - type: 'row', - fields: [ - { - type: 'checkbox', - name: 'enableCookies', - label: 'Enable Cookies', - defaultValue: false, - admin: { - description: - 'This will create responsoe cookies when the user authenticates as well as add the hostname to the list of allowed origins for CSRF.', - }, - }, - ], - }, { type: 'group', name: 'credentials', diff --git a/src/endpoints/handlers/authorize/credentials.ts b/src/endpoints/handlers/authorize/credentials.ts index d3afb9d..4bc500b 100644 --- a/src/endpoints/handlers/authorize/credentials.ts +++ b/src/endpoints/handlers/authorize/credentials.ts @@ -4,7 +4,6 @@ import unlock from 'payload/dist/auth/operations/unlock' import { authenticateLocalStrategy } from 'payload/dist/auth/strategies/local/authenticate' import { incrementLoginAttempts } from 'payload/dist/auth/strategies/local/incrementLoginAttempts' import { AuthenticationError, LockedAuth } from 'payload/dist/errors' -import getCookieExpiration from 'payload/dist/utilities/getCookieExpiration' import sanitizeInternalFields from 'payload/dist/utilities/sanitizeInternalFields' import generateAccessToken from '../../../token/generate-access-token' @@ -101,28 +100,6 @@ const handler: (config: EndpointConfig) => PayloadHandler = config => async (req sessionId: refreshData.sessionId, }) - if (client.enableCookies) { - // Set cookie - res.cookie(`${payload.config.cookiePrefix}-token`, accessToken, { - path: '/', - httpOnly: true, - expires: getCookieExpiration(collection.config.auth.tokenExpiration || 60 * 60), - secure: collection.config.auth.cookies.secure, - sameSite: collection.config.auth.cookies.sameSite, - domain: collection.config.auth.cookies.domain || undefined, - }) - - // Set cookie - res.cookie(`${payload.config.cookiePrefix}-refresh`, refreshData.refreshToken, { - path: '/', - httpOnly: true, - expires: getCookieExpiration(refreshData.expiresIn), - secure: collection.config.auth.cookies.secure, - sameSite: collection.config.auth.cookies.sameSite, - domain: collection.config.auth.cookies.domain || undefined, - }) - } - res.send({ accessToken, accessExpiration: expiresIn, diff --git a/src/endpoints/oauth/refresh-token.ts b/src/endpoints/oauth/refresh-token.ts index 7096c14..1430898 100644 --- a/src/endpoints/oauth/refresh-token.ts +++ b/src/endpoints/oauth/refresh-token.ts @@ -1,6 +1,4 @@ import type { Endpoint } from 'payload/config' -import type { IncomingAuthType } from 'payload/dist/auth' -import getCookieExpiration from 'payload/dist/utilities/getCookieExpiration' import generateAccessToken from '../../token/generate-access-token' import type { EndpointConfig, MaybeUser } from '../../types' @@ -96,20 +94,6 @@ export const refreshToken: (config: EndpointConfig) => Endpoint[] = config => { sessionId, }) - const collectionAuthConfig = config.endpointCollection.auth as IncomingAuthType - - if (client.enableCookies) { - // Set cookie - res.cookie(`${payload.config.cookiePrefix}-token`, accessToken, { - path: '/', - httpOnly: true, - expires: getCookieExpiration(collectionAuthConfig.tokenExpiration || 60 * 60), - secure: collectionAuthConfig.cookies?.secure, - sameSite: collectionAuthConfig.cookies?.sameSite, - domain: collectionAuthConfig.cookies?.domain || undefined, - }) - } - res.send({ accessToken, accessExpiration: expiresIn, diff --git a/src/endpoints/oauth/verify.ts b/src/endpoints/oauth/verify.ts index da99149..9007f7f 100644 --- a/src/endpoints/oauth/verify.ts +++ b/src/endpoints/oauth/verify.ts @@ -1,6 +1,4 @@ import type { Endpoint } from 'payload/config' -import type { IncomingAuthType } from 'payload/dist/auth' -import getCookieExpiration from 'payload/dist/utilities/getCookieExpiration' import generateAccessToken from '../../token/generate-access-token' import generateRefreshToken from '../../token/generate-refresh-token' @@ -106,30 +104,6 @@ export const verify: (config: EndpointConfig) => Endpoint[] = config => { sessionId: refreshData.sessionId, }) - const collectionAuthConfig = config.endpointCollection.auth as IncomingAuthType - - if (client.enableCookies) { - // Set cookie - res.cookie(`${payload.config.cookiePrefix}-token`, accessToken, { - path: '/', - httpOnly: true, - expires: getCookieExpiration(collectionAuthConfig.tokenExpiration || 60 * 60), - secure: collectionAuthConfig.cookies?.secure, - sameSite: collectionAuthConfig.cookies?.sameSite, - domain: collectionAuthConfig.cookies?.domain || undefined, - }) - - // Set cookie - res.cookie(`${payload.config.cookiePrefix}-refresh`, refreshData.refreshToken, { - path: '/', - httpOnly: true, - expires: getCookieExpiration(refreshData.expiresIn), - secure: collectionAuthConfig.cookies?.secure, - sameSite: collectionAuthConfig.cookies?.sameSite, - domain: collectionAuthConfig.cookies?.domain || undefined, - }) - } - res.send({ accessToken, accessExpiration: expiresIn, diff --git a/src/express/middleware/csrf.ts b/src/express/middleware/csrf.ts index 424c9b4..2a2c2ef 100644 --- a/src/express/middleware/csrf.ts +++ b/src/express/middleware/csrf.ts @@ -14,9 +14,7 @@ export default async function oAuthCsrf(req: PayloadRequest, _res: Response, nex depth: 0, })) as unknown as { docs: OAuthApp[] } - const origins = apps.docs - .filter((app: OAuthApp) => app.enableCookies) - .map((app: OAuthApp) => app.homepageUrl) + const origins = apps.docs.map((app: OAuthApp) => app.homepageUrl) config.csrf = [...config.csrf, ...origins].filter( (value, index, self) => self.indexOf(value) === index, diff --git a/src/types.ts b/src/types.ts index 007b7fe..266b16a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -61,7 +61,6 @@ export interface OAuthApp { description: string homepageUrl: string callbackUrl: string - enableCookies: boolean credentials?: { clientId?: string clientSecret?: string From 5a271828fcbdc00e5292f49b126f7f7bb918ade1 Mon Sep 17 00:00:00 2001 From: Corfitz Date: Fri, 13 Oct 2023 16:59:42 +0200 Subject: [PATCH 02/12] Introducing new email templating --- src/collections/OAuthApps/index.ts | 61 ++++++++++++++++++++++++++++++ src/types.ts | 15 ++++++++ 2 files changed, 76 insertions(+) diff --git a/src/collections/OAuthApps/index.ts b/src/collections/OAuthApps/index.ts index bece327..2d3a4fd 100644 --- a/src/collections/OAuthApps/index.ts +++ b/src/collections/OAuthApps/index.ts @@ -130,5 +130,66 @@ export const OAuthApps: CollectionConfig = { }, ], }, + { + type: 'group', + name: 'settings', + label: 'Settings', + fields: [ + { + type: 'checkbox', + name: 'customizeOtpEmail', + label: 'Customize OTP Email', + defaultValue: false, + }, + { + type: 'text', + name: 'otpEmailSubject', + label: 'OTP Email Subject', + admin: { + condition: (_, siblingData) => siblingData?.customizeOtpEmail, + description: + 'This is the subject that will be sent in the email when using OTP authentication. You have access to the variables `{{otp}}` and `{{email}}` - and any additional variables made available by the administrator.', + }, + }, + { + type: 'code', + name: 'otpEmail', + label: 'OTP Email', + admin: { + language: 'html', + condition: (_, siblingData) => siblingData?.customizeOtpEmail, + description: + 'This is the HTML that will be sent in the email when using OTP authentication. You have access to the variables `{{otp}}` and `{{email}}` - and any additional variables made available by the administrator.', + }, + }, + { + type: 'checkbox', + name: 'customizeMagiclinkEmail', + label: 'Customize Magiclink Email', + defaultValue: false, + }, + { + type: 'text', + name: 'magiclinkEmailSubject', + label: 'Magiclink Email Subject', + admin: { + condition: (_, siblingData) => siblingData?.customizeMagiclinkEmail, + description: + 'This is the subject that will be sent in the email when using magiclink authentication. You have access to the variables `{{magiclink}}` and `{{email}}` - and any additional variables made available by the administrator.', + }, + }, + { + type: 'code', + name: 'magiclinkEmail', + label: 'Magiclink Email', + admin: { + language: 'html', + condition: (_, siblingData) => siblingData?.customizeMagiclinkEmail, + description: + 'This is the HTML that will be sent in the email when using magiclink authentication. You have access to the variables `{{magiclink}}` and `{{email}}` - and any additional variables made available by the administrator.', + }, + }, + ], + }, ], } diff --git a/src/types.ts b/src/types.ts index 266b16a..5af8a35 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,6 +27,21 @@ export interface PluginConfig { token?: string user?: unknown }) => string | Promise + generateEmailVariables?: (args?: { + req?: PayloadRequest + variables?: + | { + __method: 'magiclink' + token?: string + magiclink?: string + } + | { + __method: 'otp' + otp?: string + } + user?: unknown + client?: Omit + }) => Record | Promise> } sessions?: { limit?: number From 03f4722f1bd1821c1d3a455432767db7cff0c7cc Mon Sep 17 00:00:00 2001 From: Corfitz Date: Sat, 14 Oct 2023 09:12:08 +0200 Subject: [PATCH 03/12] Using mail settings from collection --- src/endpoints/handlers/authorize/otp.ts | 46 ++++++++++++++++--------- src/types.ts | 20 +++++------ 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/src/endpoints/handlers/authorize/otp.ts b/src/endpoints/handlers/authorize/otp.ts index a42a2ab..ca5725c 100644 --- a/src/endpoints/handlers/authorize/otp.ts +++ b/src/endpoints/handlers/authorize/otp.ts @@ -78,25 +78,37 @@ const handler: (config: EndpointConfig) => PayloadHandler = config => async (req })) as GenericUser let html = `

Here is your one-time password: ${authCode}

` - - // Allow config to override email content - if (typeof config.authorization?.generateEmailHTML === 'function') { - html = await config.authorization.generateEmailHTML({ - req, - token: authCode, - user, - }) - } - let subject = 'Your one-time password' - // Allow config to override email subject - if (typeof config.authorization?.generateEmailSubject === 'function') { - subject = await config.authorization.generateEmailSubject({ - req, - token: authCode, - user, - }) + if (client.settings?.customizeOtpEmail) { + const variables: Record = { + email, + otp: authCode, + ...(await config.authorization?.generateEmailVariables?.({ + req, + variables: { + __method: 'otp', + otp: authCode, + }, + user, + client, + })), + } + + // Replace all variables in the email subject and body {{variable}} + if (client.settings?.otpEmail) { + html = client.settings.otpEmail.replace( + /{{\s*([^}]+)\s*}}/g, + (_, variable) => variables[variable.trim()] || '', + ) + } + + if (client.settings?.otpEmailSubject) { + subject = client.settings.otpEmailSubject.replace( + /{{\s*([^}]+)\s*}}/g, + (_, variable) => variables[variable.trim()] || '', + ) + } } void sendEmail({ diff --git a/src/types.ts b/src/types.ts index 5af8a35..708226d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,16 +17,6 @@ export interface PluginConfig { customHandler?: EndpointHandler otpExpiration?: number generateOTP?: (args?: { req?: PayloadRequest; user?: unknown }) => string | Promise - generateEmailHTML?: (args?: { - req?: PayloadRequest - token?: string - user?: unknown - }) => string | Promise - generateEmailSubject?: (args?: { - req?: PayloadRequest - token?: string - user?: unknown - }) => string | Promise generateEmailVariables?: (args?: { req?: PayloadRequest variables?: @@ -41,7 +31,7 @@ export interface PluginConfig { } user?: unknown client?: Omit - }) => Record | Promise> + }) => Record | Promise> } sessions?: { limit?: number @@ -80,6 +70,14 @@ export interface OAuthApp { clientId?: string clientSecret?: string } + settings?: { + customizeOtpEmail?: boolean + otpEmail?: string + otpEmailSubject?: string + customizeMagiclinkEmail?: boolean + magiclinkEmail?: string + magiclinkEmailSubject?: string + } } export interface EndpointConfig extends PluginConfig { From 91087546ead62744f7c7f034af2c0bfd4e7732e5 Mon Sep 17 00:00:00 2001 From: Corfitz Date: Sat, 14 Oct 2023 09:14:32 +0200 Subject: [PATCH 04/12] Documentation update --- .changeset/gold-rats-impress.md | 5 +++++ README.md | 3 +-- 2 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 .changeset/gold-rats-impress.md diff --git a/.changeset/gold-rats-impress.md b/.changeset/gold-rats-impress.md new file mode 100644 index 0000000..df787ff --- /dev/null +++ b/.changeset/gold-rats-impress.md @@ -0,0 +1,5 @@ +--- +"@imcorfitz/payload-plugin-oauth-apps": minor +--- + +Using email settings from Oauth client settings instead of plugin config diff --git a/README.md b/README.md index 5cd87fe..e217c5a 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,7 @@ export default buildConfig({ - `customHandler`: EndpointHandler | optional - `otpExpiration`: number | optional - `generateOTP`: method | optional - - `generateEmailHTML`: method | optional - - `generateEmailSubject`: method | optional + - `generateEmailVariables`: method | optional When using `otp` and authorization method, you can set the expiration (`otpExpiration` - defaults to 10 minutes) and customise how you want the one-time password to be generated (`generateOTP` - defaults to generating a 6-digit number). From 4559a135da83ea0531a2eab98a26a0f2f2a2ebd7 Mon Sep 17 00:00:00 2001 From: Corfitz Date: Sat, 14 Oct 2023 12:57:58 +0200 Subject: [PATCH 05/12] separated operations from handlers --- README.md | 6 +- demo/src/payload.config.ts | 29 ++-- .../handlers/authorize/credentials.ts | 92 ++---------- src/endpoints/handlers/authorize/magiclink.ts | 4 +- src/endpoints/handlers/authorize/otp.ts | 104 ++----------- src/endpoints/handlers/verify/otp.ts | 65 ++++++++ src/endpoints/oauth/authorize.ts | 48 +++--- src/endpoints/oauth/index.ts | 4 +- src/endpoints/oauth/refresh-token.ts | 10 +- src/endpoints/oauth/verify.ts | 114 +------------- src/hooks/after-logout.ts | 4 +- src/hooks/before-login.ts | 4 +- src/hooks/before-refresh.ts | 4 +- src/index.ts | 4 +- src/operations/authorize/login.ts | 134 +++++++++++++++++ src/operations/authorize/send-magiclink.ts | 10 ++ src/operations/authorize/send-otp.ts | 139 +++++++++++++++++ src/operations/verify/otp.ts | 141 ++++++++++++++++++ src/token/generate-refresh-token.ts | 6 +- src/types.ts | 7 +- 20 files changed, 592 insertions(+), 337 deletions(-) create mode 100644 src/endpoints/handlers/verify/otp.ts create mode 100644 src/operations/authorize/login.ts create mode 100644 src/operations/authorize/send-magiclink.ts create mode 100644 src/operations/authorize/send-otp.ts create mode 100644 src/operations/verify/otp.ts diff --git a/README.md b/README.md index e217c5a..29a56e9 100644 --- a/README.md +++ b/README.md @@ -71,8 +71,8 @@ export default buildConfig({ Configure how `OAuth Apps` authorize users and initialize new sessions. The default `method` is 'crednetials'. - - `method`: 'credentials' | 'otp' | 'magiclink' | 'custom' | optional - - `customHandler`: EndpointHandler | optional + - `method`: 'credentials' | 'otp' | 'magiclink' | '' | optional + - `customHandlers`: {: EndpointHandler} | optional - `otpExpiration`: number | optional - `generateOTP`: method | optional - `generateEmailVariables`: method | optional @@ -85,7 +85,7 @@ export default buildConfig({ - `token`: The generated OTP or an encrypted token depending on the set method - `user`: Information about the user to be authenticated - > Note: `customHandler` should be set if `method` is set to 'custom' and allows you to perform the entire authentication flow yourself. Note that the plugin does expose the generateAccessToken and generateRefreshToken methods, however this goes beyond the scope of this documentation, and should be used in advance cases only. + > Note: `customHandlers` should be set if you wish to create your own `method` and allows you to perform the entire authentication flow yourself. Note that the plugin does expose the generateAccessToken and generateRefreshToken methods, however this goes beyond the scope of this documentation, and should be used in advance cases only. - `sessions`: object | optional diff --git a/demo/src/payload.config.ts b/demo/src/payload.config.ts index 357ec42..f1e6a6c 100644 --- a/demo/src/payload.config.ts +++ b/demo/src/payload.config.ts @@ -1,24 +1,24 @@ -import { mongooseAdapter } from '@payloadcms/db-mongodb' -import { slateEditor } from '@payloadcms/richtext-slate' -import { webpackBundler } from '@payloadcms/bundler-webpack' -import { buildConfig } from 'payload/config' -import path from 'path' +import { mongooseAdapter } from "@payloadcms/db-mongodb"; +import { slateEditor } from "@payloadcms/richtext-slate"; +import { webpackBundler } from "@payloadcms/bundler-webpack"; +import { buildConfig } from "payload/config"; +import path from "path"; // import Examples from './collections/Examples'; -import Users from './collections/Users' +import Users from "./collections/Users"; // import { oAuthApps } from '../../dist'; // eslint-disable-next-line import/no-relative-packages -import { oAuthApps } from '../../src' +import { oAuthApps } from "../../src"; export default buildConfig({ - serverURL: 'http://localhost:3030', + serverURL: "http://localhost:3030", admin: { user: Users.slug, bundler: webpackBundler(), }, email: { - fromName: 'Admin', - fromAddress: 'admin@example.com', + fromName: "Admin", + fromAddress: "admin@example.com", logMockCredentials: true, // Optional }, editor: slateEditor({}), @@ -30,10 +30,10 @@ export default buildConfig({ // Examples, ], typescript: { - outputFile: path.resolve(__dirname, 'payload-types.ts'), + outputFile: path.resolve(__dirname, "payload-types.ts"), }, graphQL: { - schemaOutputFile: path.resolve(__dirname, 'generated-schema.graphql'), + schemaOutputFile: path.resolve(__dirname, "generated-schema.graphql"), }, db: mongooseAdapter({ url: process.env.MONGODB_URI, @@ -45,9 +45,6 @@ export default buildConfig({ limit: 4, ipinfoApiKey: process.env.IPINFO_API_KEY, }, - // authorization: { - // method: "otp", - // }, access: { sessions: { read: () => true, @@ -56,4 +53,4 @@ export default buildConfig({ }, }), ], -}) +}); diff --git a/src/endpoints/handlers/authorize/credentials.ts b/src/endpoints/handlers/authorize/credentials.ts index 4bc500b..77ba63d 100644 --- a/src/endpoints/handlers/authorize/credentials.ts +++ b/src/endpoints/handlers/authorize/credentials.ts @@ -1,17 +1,11 @@ +import httpStatus from 'http-status' import type { PayloadHandler } from 'payload/config' -import isLocked from 'payload/dist/auth/isLocked' -import unlock from 'payload/dist/auth/operations/unlock' -import { authenticateLocalStrategy } from 'payload/dist/auth/strategies/local/authenticate' -import { incrementLoginAttempts } from 'payload/dist/auth/strategies/local/incrementLoginAttempts' -import { AuthenticationError, LockedAuth } from 'payload/dist/errors' -import sanitizeInternalFields from 'payload/dist/utilities/sanitizeInternalFields' -import generateAccessToken from '../../../token/generate-access-token' -import generateRefreshToken from '../../../token/generate-refresh-token' -import type { EndpointConfig } from '../../../types' +import login from '../../../operations/authorize/login' +import type { OperationConfig } from '../../../types' import verifyClientCredentials from '../../../utils/verify-client-credentials' -const handler: (config: EndpointConfig) => PayloadHandler = config => async (req, res) => { +const handler: (config: OperationConfig) => PayloadHandler = config => async (req, res) => { const { payload } = req const collection = payload.collections[config.endpointCollection.slug] @@ -24,7 +18,7 @@ const handler: (config: EndpointConfig) => PayloadHandler = config => async (req } if (!email || !password || !clientId || !clientSecret) { - res.status(400).send('Bad Request') + res.status(httpStatus.BAD_REQUEST).send('Bad Request: Missing required fields') return } @@ -36,75 +30,21 @@ const handler: (config: EndpointConfig) => PayloadHandler = config => async (req return } - let user = await payload.db.findOne({ - collection: collection.config.slug, - req, - where: { email: { equals: email.toLowerCase() } }, - }) - - if (!user || (collection.config.auth.verify && user._verified === false)) { - throw new AuthenticationError(req.t) - } - - if (user && isLocked(user.lockUntil)) { - throw new LockedAuth(req.t) - } - - const authResult = await authenticateLocalStrategy({ doc: user, password }) - - user = sanitizeInternalFields(user) - - const maxLoginAttemptsEnabled = (collection.config.auth.maxLoginAttempts || 0) > 0 - - if (!authResult) { - if (maxLoginAttemptsEnabled) { - await incrementLoginAttempts({ - collection: collection.config, - doc: user, - payload: req.payload, - req, - }) - } - - throw new AuthenticationError(req.t) - } - - if (maxLoginAttemptsEnabled) { - await unlock({ - collection, - data: { - email, - }, - overrideAccess: true, - req, - }) - } - - // Generate the token - const refreshData = await generateRefreshToken({ - app: client, - user, + const result = await login({ + collection, req, + res: res as unknown as Response, + data: { + email, + password, + }, + client, config, }) - if (!refreshData) { - res.status(500).send('Internal Server Error') - return - } - - const { expiresIn, accessToken } = generateAccessToken({ - user, - payload, - collection: collection.config, - sessionId: refreshData.sessionId, - }) - - res.send({ - accessToken, - accessExpiration: expiresIn, - refreshToken: refreshData.refreshToken, - refreshExpiration: refreshData.expiresIn, + res.status(httpStatus.OK).send({ + ...result, + message: 'Auth Passed', }) } diff --git a/src/endpoints/handlers/authorize/magiclink.ts b/src/endpoints/handlers/authorize/magiclink.ts index 9c352d0..65a2b28 100644 --- a/src/endpoints/handlers/authorize/magiclink.ts +++ b/src/endpoints/handlers/authorize/magiclink.ts @@ -1,8 +1,8 @@ import type { PayloadHandler } from 'payload/config' -import type { EndpointConfig } from '../../../types' +import type { OperationConfig } from '../../../types' -const handler: (config: EndpointConfig) => PayloadHandler = () => (req, res) => { +const handler: (config: OperationConfig) => PayloadHandler = () => (req, res) => { const method = req.method res.send(`Hello World - ${method}`) } diff --git a/src/endpoints/handlers/authorize/otp.ts b/src/endpoints/handlers/authorize/otp.ts index ca5725c..72d21b7 100644 --- a/src/endpoints/handlers/authorize/otp.ts +++ b/src/endpoints/handlers/authorize/otp.ts @@ -1,13 +1,14 @@ +import httpStatus from 'http-status' import type { PayloadHandler } from 'payload/config' -import type { EndpointConfig, GenericUser } from '../../../types' -import generateAuthCode from '../../../utils/generate-auth-code' +import sendOtp from '../../../operations/authorize/send-otp' +import type { OperationConfig } from '../../../types' import verifyClientCredentials from '../../../utils/verify-client-credentials' -const handler: (config: EndpointConfig) => PayloadHandler = config => async (req, res) => { +const handler: (config: OperationConfig) => PayloadHandler = config => async (req, res) => { const { payload } = req - const { sendEmail, emailOptions } = payload + const collection = payload.collections[config.endpointCollection.slug] const { email, clientId, clientSecret } = req.body as { email?: string @@ -16,7 +17,7 @@ const handler: (config: EndpointConfig) => PayloadHandler = config => async (req } if (!email || !clientId || !clientSecret) { - res.status(400).send('Bad Request') + res.status(httpStatus.BAD_REQUEST).send('Bad Request: Missing required fields') return } @@ -28,94 +29,15 @@ const handler: (config: EndpointConfig) => PayloadHandler = config => async (req return } - let user = ( - await payload.find({ - collection: config.endpointCollection.slug, - depth: 1, - limit: 1, - showHiddenFields: true, - where: { email: { equals: email.toLowerCase() } }, - }) - ).docs[0] as GenericUser | null - - if (!user) { - // No such user - res.status(401).send('Unauthorized: Invalid user credentials') - return - } - - let authCode = generateAuthCode() - - // Allow config to override auth code - if (typeof config.authorization?.generateOTP === 'function') { - authCode = await config.authorization.generateOTP({ - req, - user, - }) - } - - const exp = new Date(Date.now() + (config.authorization?.otpExpiration || 600) * 1000).getTime() // 10 minutes - - const otps = JSON.parse(user.oAuth._otp || '[]').filter( - (otp: { exp: number }) => otp.exp > Date.now(), // Remove expired OTPs - ) - - user = (await payload.update({ - id: user.id, - collection: config.endpointCollection.slug, + await sendOtp({ + client, + collection, + config, data: { - oAuth: { - _otp: JSON.stringify([ - ...otps, - { - otp: authCode, - exp, - app: client.id, - }, - ]), - }, - }, - })) as GenericUser - - let html = `

Here is your one-time password: ${authCode}

` - let subject = 'Your one-time password' - - if (client.settings?.customizeOtpEmail) { - const variables: Record = { email, - otp: authCode, - ...(await config.authorization?.generateEmailVariables?.({ - req, - variables: { - __method: 'otp', - otp: authCode, - }, - user, - client, - })), - } - - // Replace all variables in the email subject and body {{variable}} - if (client.settings?.otpEmail) { - html = client.settings.otpEmail.replace( - /{{\s*([^}]+)\s*}}/g, - (_, variable) => variables[variable.trim()] || '', - ) - } - - if (client.settings?.otpEmailSubject) { - subject = client.settings.otpEmailSubject.replace( - /{{\s*([^}]+)\s*}}/g, - (_, variable) => variables[variable.trim()] || '', - ) - } - } - - void sendEmail({ - from: `"${emailOptions.fromName}" <${emailOptions.fromAddress}>`, - to: email, - subject, - html, + }, + req, + res: res as unknown as Response, }) res.send({ diff --git a/src/endpoints/handlers/verify/otp.ts b/src/endpoints/handlers/verify/otp.ts new file mode 100644 index 0000000..9c83ea0 --- /dev/null +++ b/src/endpoints/handlers/verify/otp.ts @@ -0,0 +1,65 @@ +import httpStatus from 'http-status' +import type { PayloadHandler } from 'payload/config' + +import verifyOtp from '../../../operations/verify/otp' +import type { OperationConfig } from '../../../types' +import verifyClientCredentials from '../../../utils/verify-client-credentials' + +const handler: (config: OperationConfig) => PayloadHandler = config => async (req, res, next) => { + try { + const { payload } = req + + const collection = payload.collections[config.endpointCollection.slug] + + const { otp, email, clientId, clientSecret } = req.body as { + otp?: string + email?: string + clientId?: string + clientSecret?: string + } + + if (!otp) { + res.status(400).send('Bad Request: Missing OTP') + return + } + + if (!email) { + res.status(400).send('Bad Request: Missing email') + return + } + + if (!clientId || !clientSecret) { + res.status(400).send('Bad Request: Missing client credentials') + return + } + + // Validate the client credentials + const client = await verifyClientCredentials(clientId, clientSecret, payload) + + if (!client) { + res.status(401).send('Unauthorized: Invalid client credentials') + return + } + + const result = await verifyOtp({ + collection, + req, + data: { + email, + otp, + }, + res: res as unknown as Response, + client, + config, + }) + + res.status(httpStatus.OK).send({ + ...result, + message: 'Auth Passed', + }) + } catch (error) { + next(error) + } +} + +export default handler diff --git a/src/endpoints/oauth/authorize.ts b/src/endpoints/oauth/authorize.ts index c924045..442d23c 100644 --- a/src/endpoints/oauth/authorize.ts +++ b/src/endpoints/oauth/authorize.ts @@ -1,6 +1,6 @@ import type { Endpoint } from 'payload/config' -import type { EndpointConfig, EndpointHandler } from '../../types' +import type { OperationConfig } from '../../types' import credentials from '../handlers/authorize/credentials' import magiclink from '../handlers/authorize/magiclink' import otp from '../handlers/authorize/otp' @@ -11,26 +11,40 @@ const handlers = { magiclink, } -export const authorize: (endpointConfig: EndpointConfig) => Endpoint[] = endpointConfig => { - const authMethod = endpointConfig.authorization?.method || 'credentials' - - let handler: EndpointHandler | undefined - - if (authMethod === 'custom') { - handler = endpointConfig.authorization?.customHandler - } else { - handler = handlers[authMethod] - } - - if (!handler) { - throw new Error(`No handler found for authorization method: ${authMethod}`) - } - +export const authorize: (config: OperationConfig) => Endpoint[] = config => { return [ { path: '/oauth/authorize', method: 'post', - handler: handler(endpointConfig), + async handler(req, res, next) { + try { + const { method: requestedMethod } = req.body as { + method?: string + } + + const method = requestedMethod || 'credentials' + + const authHandlers = { ...handlers, ...config.authorization?.customHandlers } + + const methodIsSupported = Object.keys(authHandlers).includes(method) + + if (!methodIsSupported) { + res.status(400).send('Bad Request: Invalid authorization method') + return + } + + const authHandler = authHandlers[method as keyof typeof authHandlers] + + if (!authHandler) { + res.status(400).send('Bad Request: Invalid authorization method') + return + } + + return authHandler(config)(req, res, next) + } catch (error) { + next(error) + } + }, }, ] } diff --git a/src/endpoints/oauth/index.ts b/src/endpoints/oauth/index.ts index 2c6267c..8261e76 100644 --- a/src/endpoints/oauth/index.ts +++ b/src/endpoints/oauth/index.ts @@ -1,10 +1,10 @@ import type { Endpoint } from 'payload/config' -import type { EndpointConfig } from '../../types' +import type { OperationConfig } from '../../types' import { authorize } from './authorize' import { refreshToken } from './refresh-token' import { verify } from './verify' -export const oAuthEndpoints: (endpointConfig: EndpointConfig) => Endpoint[] = endpointConfig => { +export const oAuthEndpoints: (endpointConfig: OperationConfig) => Endpoint[] = endpointConfig => { return [...authorize(endpointConfig), ...refreshToken(endpointConfig), ...verify(endpointConfig)] } diff --git a/src/endpoints/oauth/refresh-token.ts b/src/endpoints/oauth/refresh-token.ts index 1430898..dad238e 100644 --- a/src/endpoints/oauth/refresh-token.ts +++ b/src/endpoints/oauth/refresh-token.ts @@ -1,10 +1,10 @@ import type { Endpoint } from 'payload/config' import generateAccessToken from '../../token/generate-access-token' -import type { EndpointConfig, MaybeUser } from '../../types' +import type { MaybeUser, OperationConfig } from '../../types' import verifyClientCredentials from '../../utils/verify-client-credentials' -export const refreshToken: (config: EndpointConfig) => Endpoint[] = config => { +export const refreshToken: (config: OperationConfig) => Endpoint[] = config => { return [ { path: '/oauth/refresh-token', @@ -98,11 +98,11 @@ export const refreshToken: (config: EndpointConfig) => Endpoint[] = config => { accessToken, accessExpiration: expiresIn, }) - } catch (error) { - // req.payload.logger.error(error) + } catch (error: unknown) { + req.payload.logger.error(error) const message = String(error).includes('Invalid initialization vector') ? 'Bad Request: Refresh Token not valid' - : 'Internal Server Error' + : (error as any)?.message || 'Internal Server Error' res.status(500).send(message) } }, diff --git a/src/endpoints/oauth/verify.ts b/src/endpoints/oauth/verify.ts index 9007f7f..45752db 100644 --- a/src/endpoints/oauth/verify.ts +++ b/src/endpoints/oauth/verify.ts @@ -1,120 +1,14 @@ import type { Endpoint } from 'payload/config' -import generateAccessToken from '../../token/generate-access-token' -import generateRefreshToken from '../../token/generate-refresh-token' -import type { EndpointConfig, GenericUser } from '../../types' -import verifyClientCredentials from '../../utils/verify-client-credentials' +import type { OperationConfig } from '../../types' +import verifyOtpHandler from '../handlers/verify/otp' -export const verify: (config: EndpointConfig) => Endpoint[] = config => { +export const verify: (config: OperationConfig) => Endpoint[] = config => { return [ { path: '/oauth/verify-otp', method: 'post', - async handler(req, res) { - try { - const { payload } = req - - const { otp, email, clientId, clientSecret } = req.body as { - otp?: string - email?: string - clientId?: string - clientSecret?: string - } - - if (!otp) { - res.status(400).send('Bad Request: Missing OTP') - return - } - - if (!email) { - res.status(400).send('Bad Request: Missing email') - return - } - - if (!clientId || !clientSecret) { - res.status(400).send('Bad Request: Missing client credentials') - return - } - - // Validate the client credentials - const client = await verifyClientCredentials(clientId, clientSecret, payload) - - if (!client) { - res.status(401).send('Unauthorized: Invalid client credentials') - return - } - - let user = ( - await payload.find({ - collection: config.endpointCollection.slug, - depth: 1, - limit: 1, - showHiddenFields: true, - where: { email: { equals: email.toLowerCase() } }, - }) - ).docs[0] as GenericUser | null - - if (!user) { - // No such user - res.status(401).send('Unauthorized: Invalid user credentials') - return - } - - const otps = JSON.parse(user.oAuth._otp || '[]').filter( - (o: { exp: number }) => o.exp > Date.now(), // Remove expired OTPs - ) - - const otpIndex = otps.findIndex((o: { otp: string }) => o.otp === otp) - - if (otpIndex === -1) { - res.status(401).send('Unauthorized: Invalid OTP') - return - } - - const remainingOTPs = otps.filter((o: { otp: string }) => o.otp !== otp) - - user = (await payload.update({ - id: user.id, - collection: config.endpointCollection.slug, - depth: 1, - data: { - oAuth: { - _otp: JSON.stringify(remainingOTPs), - }, - }, - })) as GenericUser - - // Generate the token - const refreshData = await generateRefreshToken({ - app: client, - user, - req, - config, - }) - - if (!refreshData) { - res.status(500).send('Internal Server Error') - return - } - - const { expiresIn, accessToken } = generateAccessToken({ - user, - payload, - collection: config.endpointCollection, - sessionId: refreshData.sessionId, - }) - - res.send({ - accessToken, - accessExpiration: expiresIn, - refreshToken: refreshData.refreshToken, - refreshExpiration: refreshData.expiresIn, - }) - } catch (error) { - req.payload.logger.error(error) - res.status(500).send('Internal Server Error') - } - }, + handler: verifyOtpHandler(config), }, ] } diff --git a/src/hooks/after-logout.ts b/src/hooks/after-logout.ts index 29566ae..4bd5eb4 100644 --- a/src/hooks/after-logout.ts +++ b/src/hooks/after-logout.ts @@ -2,7 +2,7 @@ import type { IncomingAuthType } from 'payload/dist/auth' import type { AfterLogoutHook } from 'payload/dist/collections/config/types' import type { Collection, PayloadRequest } from 'payload/types' -import type { EndpointConfig, GenericUser } from '../types' +import type { GenericUser, OperationConfig } from '../types' export interface Arguments { collection: Collection @@ -11,7 +11,7 @@ export interface Arguments { token: string } -export const afterLogoutHook: (config: EndpointConfig) => AfterLogoutHook = +export const afterLogoutHook: (config: OperationConfig) => AfterLogoutHook = config => async args => { const { req, res } = args diff --git a/src/hooks/before-login.ts b/src/hooks/before-login.ts index c276d30..0810981 100644 --- a/src/hooks/before-login.ts +++ b/src/hooks/before-login.ts @@ -1,7 +1,7 @@ import APIError from 'payload/dist/errors/APIError' import type { Collection, CollectionBeforeOperationHook, PayloadRequest } from 'payload/types' -import type { EndpointConfig } from '../types' +import type { OperationConfig } from '../types' export interface Arguments { collection: Collection @@ -10,7 +10,7 @@ export interface Arguments { token: string } -export const beforeLoginOperationHook: (config: EndpointConfig) => CollectionBeforeOperationHook = +export const beforeLoginOperationHook: (config: OperationConfig) => CollectionBeforeOperationHook = () => ({ args, // original arguments passed into the operation diff --git a/src/hooks/before-refresh.ts b/src/hooks/before-refresh.ts index aa05ccb..bc06091 100644 --- a/src/hooks/before-refresh.ts +++ b/src/hooks/before-refresh.ts @@ -1,7 +1,7 @@ import APIError from 'payload/dist/errors/APIError' import type { Collection, CollectionBeforeOperationHook, PayloadRequest } from 'payload/types' -import type { EndpointConfig } from '../types' +import type { OperationConfig } from '../types' export interface Arguments { collection: Collection @@ -11,7 +11,7 @@ export interface Arguments { } export const beforeRefreshOperationHook: ( - config: EndpointConfig, + config: OperationConfig, ) => CollectionBeforeOperationHook = () => ({ diff --git a/src/index.ts b/src/index.ts index 40ccfb4..fde94f3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import { OAuthGroup } from './fields/oauth-group' import { afterLogoutHook } from './hooks/after-logout' import { beforeLoginOperationHook } from './hooks/before-login' import { beforeRefreshOperationHook } from './hooks/before-refresh' -import type { EndpointConfig, PluginConfig } from './types' +import type { OperationConfig, PluginConfig } from './types' export { oAuthManager } from './fields/oauth-manager' @@ -45,7 +45,7 @@ export const oAuthApps = */ collection.fields = [...collection.fields, OAuthGroup(pluginConfig)] - const endpointConfig: EndpointConfig = { + const endpointConfig: OperationConfig = { ...pluginConfig, endpointCollection: collection, } diff --git a/src/operations/authorize/login.ts b/src/operations/authorize/login.ts new file mode 100644 index 0000000..589f90c --- /dev/null +++ b/src/operations/authorize/login.ts @@ -0,0 +1,134 @@ +import isLocked from 'payload/dist/auth/isLocked' +import unlock from 'payload/dist/auth/operations/unlock' +import { authenticateLocalStrategy } from 'payload/dist/auth/strategies/local/authenticate' +import { incrementLoginAttempts } from 'payload/dist/auth/strategies/local/incrementLoginAttempts' +import { APIError, AuthenticationError, LockedAuth } from 'payload/dist/errors' +import { initTransaction } from 'payload/dist/utilities/initTransaction' +import { killTransaction } from 'payload/dist/utilities/killTransaction' +import sanitizeInternalFields from 'payload/dist/utilities/sanitizeInternalFields' +import type { Collection, PayloadRequest } from 'payload/types' + +import generateAccessToken from '../../token/generate-access-token' +import generateRefreshToken from '../../token/generate-refresh-token' +import type { OAuthApp, OperationConfig } from '../../types' + +export interface Result { + exp?: number + token?: string + refreshToken?: string + refreshExp?: number +} + +export interface Arguments { + collection: Collection + req: PayloadRequest + res?: Response + data: { + email: string + password: string + } + client: OAuthApp + config: OperationConfig +} + +async function login(incomingArgs: Arguments): Promise { + let args = incomingArgs + + const { + collection: { config: collectionConfig }, + req, + req: { payload }, + data, + client, + config, + } = args + + try { + const shouldCommit = await initTransaction(req) + + const { email: unsanitizedEmail, password } = data + + const email = unsanitizedEmail.toLowerCase().trim() + + let user = await payload.db.findOne({ + collection: collectionConfig.slug, + req, + where: { email: { equals: email.toLowerCase() } }, + }) + + if (!user || (args.collection.config.auth.verify && user._verified === false)) { + throw new AuthenticationError(req.t) + } + + if (user && isLocked(user.lockUntil)) { + throw new LockedAuth(req.t) + } + + const authResult = await authenticateLocalStrategy({ doc: user, password }) + + user = sanitizeInternalFields(user) + + const maxLoginAttemptsEnabled = args.collection.config.auth.maxLoginAttempts > 0 + + if (!authResult) { + if (maxLoginAttemptsEnabled) { + await incrementLoginAttempts({ + collection: collectionConfig, + doc: user, + payload: req.payload, + req, + }) + } + + throw new AuthenticationError(req.t) + } + + if (maxLoginAttemptsEnabled) { + await unlock({ + collection: { + config: collectionConfig, + }, + data: { + email, + }, + overrideAccess: true, + req, + }) + } + + // Generate the token + const refreshData = await generateRefreshToken({ + app: client, + user, + req, + config, + }) + + if (!refreshData) { + throw new APIError('Unable to generate refresh token') + } + + const { expiresIn, accessToken } = generateAccessToken({ + user, + payload, + collection: collectionConfig, + sessionId: refreshData.sessionId, + }) + + const result: Result = { + exp: expiresIn, + token: accessToken, + refreshToken: refreshData.refreshToken, + refreshExp: refreshData.expiresIn, + } + + if (shouldCommit && req.transactionID) await payload.db.commitTransaction?.(req.transactionID) + + return result + } catch (error: unknown) { + await killTransaction(req) + throw error + } +} + +export default login diff --git a/src/operations/authorize/send-magiclink.ts b/src/operations/authorize/send-magiclink.ts new file mode 100644 index 0000000..9c352d0 --- /dev/null +++ b/src/operations/authorize/send-magiclink.ts @@ -0,0 +1,10 @@ +import type { PayloadHandler } from 'payload/config' + +import type { EndpointConfig } from '../../../types' + +const handler: (config: EndpointConfig) => PayloadHandler = () => (req, res) => { + const method = req.method + res.send(`Hello World - ${method}`) +} + +export default handler diff --git a/src/operations/authorize/send-otp.ts b/src/operations/authorize/send-otp.ts new file mode 100644 index 0000000..6fe1f49 --- /dev/null +++ b/src/operations/authorize/send-otp.ts @@ -0,0 +1,139 @@ +import isLocked from 'payload/dist/auth/isLocked' +import { AuthenticationError, LockedAuth } from 'payload/dist/errors' +import { initTransaction } from 'payload/dist/utilities/initTransaction' +import { killTransaction } from 'payload/dist/utilities/killTransaction' +import type { Collection, PayloadRequest } from 'payload/types' + +import type { GenericUser, OAuthApp, OperationConfig } from '../../types' +import generateAuthCode from '../../utils/generate-auth-code' + +export interface Arguments { + collection: Collection + req: PayloadRequest + res?: Response + data: { + email: string + } + client: OAuthApp + config: OperationConfig +} + +async function sendOtp(incomingArgs: Arguments): Promise { + let args = incomingArgs + + const { + collection: { config: collectionConfig }, + req, + req: { payload }, + data, + client, + config, + } = args + + try { + const { sendEmail, emailOptions } = payload + + const shouldCommit = await initTransaction(req) + + const { email: unsanitizedEmail } = data + + const email = unsanitizedEmail.toLowerCase().trim() + + let user = await payload.db.findOne({ + collection: collectionConfig.slug, + req, + where: { email: { equals: email.toLowerCase() } }, + }) + + if (!user || (args.collection.config.auth.verify && user._verified === false)) { + throw new AuthenticationError(req.t) + } + + if (user && isLocked(user.lockUntil)) { + throw new LockedAuth(req.t) + } + + let authCode = generateAuthCode() + + // Allow config to override auth code + if (typeof config.authorization?.generateOTP === 'function') { + authCode = await config.authorization.generateOTP({ + req, + user, + }) + } + + const exp = new Date(Date.now() + (config.authorization?.otpExpiration || 600) * 1000).getTime() // 10 minutes + + const otps = JSON.parse(user.oAuth._otp || '[]').filter( + (otp: { exp: number }) => otp.exp > Date.now(), // Remove expired OTPs + ) + + user = (await req.payload.db.updateOne({ + id: user.id, + collection: config.endpointCollection.slug, + req, + data: { + oAuth: { + ...user.oAuth, + _otp: JSON.stringify([ + ...otps, + { + otp: authCode, + exp, + app: client.id, + }, + ]), + }, + }, + })) as GenericUser + + let html = `

Here is your one-time password: ${authCode}

` + let subject = 'Your one-time password' + + if (client.settings?.customizeOtpEmail) { + const variables: Record = { + email, + otp: authCode, + ...(await config.authorization?.generateEmailVariables?.({ + req, + variables: { + __method: 'otp', + otp: authCode, + }, + user, + client, + })), + } + + // Replace all variables in the email subject and body {{variable}} + if (client.settings?.otpEmail) { + html = client.settings.otpEmail.replace( + /{{\s*([^}]+)\s*}}/g, + (_, variable) => variables[variable.trim()] || '', + ) + } + + if (client.settings?.otpEmailSubject) { + subject = client.settings.otpEmailSubject.replace( + /{{\s*([^}]+)\s*}}/g, + (_, variable) => variables[variable.trim()] || '', + ) + } + } + + void sendEmail({ + from: `"${emailOptions.fromName}" <${emailOptions.fromAddress}>`, + to: email, + subject, + html, + }) + + if (shouldCommit && req.transactionID) await payload.db.commitTransaction?.(req.transactionID) + } catch (error: unknown) { + await killTransaction(req) + throw error + } +} + +export default sendOtp diff --git a/src/operations/verify/otp.ts b/src/operations/verify/otp.ts new file mode 100644 index 0000000..8d3bd63 --- /dev/null +++ b/src/operations/verify/otp.ts @@ -0,0 +1,141 @@ +import isLocked from 'payload/dist/auth/isLocked' +import unlock from 'payload/dist/auth/operations/unlock' +import { APIError, AuthenticationError, LockedAuth } from 'payload/dist/errors' +import { initTransaction } from 'payload/dist/utilities/initTransaction' +import { killTransaction } from 'payload/dist/utilities/killTransaction' +import sanitizeInternalFields from 'payload/dist/utilities/sanitizeInternalFields' +import type { Collection, PayloadRequest } from 'payload/types' + +import generateAccessToken from '../../token/generate-access-token' +import generateRefreshToken from '../../token/generate-refresh-token' +import type { GenericUser, OAuthApp, OperationConfig } from '../../types' + +export interface Result { + exp?: number + token?: string + refreshToken?: string + refreshExp?: number +} + +export interface Arguments { + collection: Collection + data: { + email: string + otp: string + } + req: PayloadRequest + res?: Response + config: OperationConfig + client: OAuthApp +} + +async function verifyOtp(incomingArgs: Arguments): Promise { + let args = incomingArgs + + const { + collection: { config: collectionConfig }, + data, + req, + req: { payload }, + client, + config, + } = args + + try { + const shouldCommit = await initTransaction(req) + + const { email: unsanitizedEmail, otp } = data + + const email = unsanitizedEmail.toLowerCase().trim() + + let user = await payload.db.findOne({ + collection: collectionConfig.slug, + req, + where: { email: { equals: email.toLowerCase() } }, + }) + + if (!user || (args.collection.config.auth.verify && user._verified === false)) { + throw new AuthenticationError(req.t) + } + + if (user && isLocked(user.lockUntil)) { + throw new LockedAuth(req.t) + } + + const otps = JSON.parse(user.oAuth._otp || '[]').filter( + (o: { exp: number }) => o.exp > Date.now(), // Remove expired OTPs + ) + + const otpIndex = otps.findIndex((o: { otp: string }) => o.otp === otp) + + if (otpIndex === -1) { + throw new APIError('Invalid OTP', 401) + } + + const remainingOTPs = otps.filter((o: { otp: string }) => o.otp !== otp) + + user = (await payload.update({ + id: user.id, + collection: config.endpointCollection.slug, + depth: 1, + data: { + oAuth: { + ...user.oAuth, + _otp: JSON.stringify(remainingOTPs), + }, + }, + })) as GenericUser + + user = sanitizeInternalFields(user) + + const maxLoginAttemptsEnabled = args.collection.config.auth.maxLoginAttempts > 0 + + if (maxLoginAttemptsEnabled) { + await unlock({ + collection: { + config: collectionConfig, + }, + data: { + email, + }, + overrideAccess: true, + req, + }) + } + + // Generate the token + const refreshData = await generateRefreshToken({ + app: client, + user, + req, + config, + }) + + if (!refreshData) { + throw new APIError('Unable to generate refresh token') + } + + const { expiresIn, accessToken } = generateAccessToken({ + user, + payload, + collection: collectionConfig, + sessionId: refreshData.sessionId, + }) + + const result: Result = { + exp: expiresIn, + token: accessToken, + refreshToken: refreshData.refreshToken, + refreshExp: refreshData.expiresIn, + } + + if (shouldCommit && req.transactionID) await payload.db.commitTransaction?.(req.transactionID) + + return result + } catch (error: unknown) { + await killTransaction(req) + throw error + } +} + +export default verifyOtp diff --git a/src/token/generate-refresh-token.ts b/src/token/generate-refresh-token.ts index 542bb4c..1380a56 100644 --- a/src/token/generate-refresh-token.ts +++ b/src/token/generate-refresh-token.ts @@ -2,13 +2,13 @@ import type { IPinfo } from 'node-ipinfo' import IPinfoWrapperClass from 'node-ipinfo' import type { PayloadRequest } from 'payload/types' -import type { EndpointConfig, GenericUser, OAuthApp } from '../types' +import type { GenericUser, OAuthApp, OperationConfig } from '../types' interface RefreshTokenProps { req: PayloadRequest app: OAuthApp user: GenericUser - config: EndpointConfig + config: OperationConfig } export default async function generateRefreshToken({ app, req, user, config }: RefreshTokenProps) { @@ -43,7 +43,7 @@ export default async function generateRefreshToken({ app, req, user, config }: R location = locationInfo } - let currentSessions = (user.oAuth.sessions || []) + let currentSessions = (user.oAuth?.sessions || []) .map(session => { return { ...session, diff --git a/src/types.ts b/src/types.ts index 708226d..f4aaee4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,8 +13,7 @@ export interface PluginConfig { } } authorization?: { - method?: 'credentials' | 'otp' | 'magiclink' | 'custom' - customHandler?: EndpointHandler + customHandlers?: Record otpExpiration?: number generateOTP?: (args?: { req?: PayloadRequest; user?: unknown }) => string | Promise generateEmailVariables?: (args?: { @@ -80,8 +79,8 @@ export interface OAuthApp { } } -export interface EndpointConfig extends PluginConfig { +export interface OperationConfig extends PluginConfig { endpointCollection: CollectionConfig } -export type EndpointHandler = (config: EndpointConfig) => PayloadHandler | PayloadHandler[] +export type EndpointHandler = (config: OperationConfig) => PayloadHandler | PayloadHandler[] From cff0f0b57e6a4a38e2fd1e91f8ecd1c92d4dc770 Mon Sep 17 00:00:00 2001 From: Corfitz Date: Sat, 14 Oct 2023 14:05:17 +0200 Subject: [PATCH 06/12] testing custom otp mail --- demo/src/payload.config.ts | 5 +++++ src/token/generate-refresh-token.ts | 1 + 2 files changed, 6 insertions(+) diff --git a/demo/src/payload.config.ts b/demo/src/payload.config.ts index f1e6a6c..d431476 100644 --- a/demo/src/payload.config.ts +++ b/demo/src/payload.config.ts @@ -41,6 +41,11 @@ export default buildConfig({ plugins: [ oAuthApps({ userCollections: [Users.slug], + // authorization: { + // generateEmailVariables: () => ({ + // test: "testing a longer sentence.. Maybe wiht emojies? 🤣", + // }), + // }, sessions: { limit: 4, ipinfoApiKey: process.env.IPINFO_API_KEY, diff --git a/src/token/generate-refresh-token.ts b/src/token/generate-refresh-token.ts index 1380a56..2e3a35b 100644 --- a/src/token/generate-refresh-token.ts +++ b/src/token/generate-refresh-token.ts @@ -70,6 +70,7 @@ export default async function generateRefreshToken({ app, req, user, config }: R id: user.id, data: { oAuth: { + ...user.oAuth, sessions: [ ...currentSessions, { From 83f3cc2baab84f35fdcbd64f37c4a424662261a4 Mon Sep 17 00:00:00 2001 From: Corfitz Date: Sat, 14 Oct 2023 14:58:03 +0200 Subject: [PATCH 07/12] starting magiclink --- package.json | 4 +- src/endpoints/handlers/authorize/magiclink.ts | 44 +++++- src/operations/authorize/send-magiclink.ts | 132 +++++++++++++++++- yarn.lock | 60 +++++++- 4 files changed, 229 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 06c26f1..c9a8a8e 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@types/express": "^4.17.18", "@types/jsonwebtoken": "^9.0.3", "@types/react": "^18.2.17", + "@types/sentencer": "^0.2.1", "@typescript-eslint/eslint-plugin": "^6.7.5", "@typescript-eslint/parser": "^6.7.5", "copyfiles": "^2.4.1", @@ -68,7 +69,8 @@ "dependencies": { "cryptr": "^6.3.0", "debug": "^4.3.4", - "node-ipinfo": "^3.4.6" + "node-ipinfo": "^3.4.6", + "sentencer": "^0.2.1" }, "bugs": { "url": "https://github.com/imcorfitz/payload-plugin-oauth-apps/issues" diff --git a/src/endpoints/handlers/authorize/magiclink.ts b/src/endpoints/handlers/authorize/magiclink.ts index 65a2b28..f20bbc8 100644 --- a/src/endpoints/handlers/authorize/magiclink.ts +++ b/src/endpoints/handlers/authorize/magiclink.ts @@ -1,10 +1,48 @@ +import httpStatus from 'http-status' import type { PayloadHandler } from 'payload/config' +import sendMagiclink from '../../../operations/authorize/send-magiclink' import type { OperationConfig } from '../../../types' +import verifyClientCredentials from '../../../utils/verify-client-credentials' -const handler: (config: OperationConfig) => PayloadHandler = () => (req, res) => { - const method = req.method - res.send(`Hello World - ${method}`) +const handler: (config: OperationConfig) => PayloadHandler = config => async (req, res) => { + const { payload } = req + + const collection = payload.collections[config.endpointCollection.slug] + + const { email, clientId, clientSecret } = req.body as { + email?: string + clientId?: string + clientSecret?: string + } + + if (!email || !clientId || !clientSecret) { + res.status(httpStatus.BAD_REQUEST).send('Bad Request: Missing required fields') + return + } + + // Validate the client credentials + const client = await verifyClientCredentials(clientId, clientSecret, payload) + + if (!client) { + res.status(401).send('Unauthorized: Invalid client credentials') + return + } + + await sendMagiclink({ + client, + collection, + config, + data: { + email, + }, + req, + res: res as unknown as Response, + }) + + res.send({ + message: 'Magiclink sent', + }) } export default handler diff --git a/src/operations/authorize/send-magiclink.ts b/src/operations/authorize/send-magiclink.ts index 9c352d0..a8570c9 100644 --- a/src/operations/authorize/send-magiclink.ts +++ b/src/operations/authorize/send-magiclink.ts @@ -1,10 +1,130 @@ -import type { PayloadHandler } from 'payload/config' +import isLocked from 'payload/dist/auth/isLocked' +import { AuthenticationError, LockedAuth } from 'payload/dist/errors' +import { initTransaction } from 'payload/dist/utilities/initTransaction' +import { killTransaction } from 'payload/dist/utilities/killTransaction' +import type { Collection, PayloadRequest } from 'payload/types' -import type { EndpointConfig } from '../../../types' +import type { GenericUser, OAuthApp, OperationConfig } from '../../types' +import generateAuthCode from '../../utils/generate-auth-code' -const handler: (config: EndpointConfig) => PayloadHandler = () => (req, res) => { - const method = req.method - res.send(`Hello World - ${method}`) +export interface Result { + exp?: number + token?: string } -export default handler +export interface Arguments { + collection: Collection + req: PayloadRequest + res?: Response + data: { + email: string + } + client: OAuthApp + config: OperationConfig +} + +async function sendOtp(incomingArgs: Arguments): Promise { + let args = incomingArgs + + const { + collection: { config: collectionConfig }, + req, + req: { payload }, + data, + client, + config, + } = args + + try { + const { sendEmail, emailOptions } = payload + + const shouldCommit = await initTransaction(req) + + const { email: unsanitizedEmail } = data + + const email = unsanitizedEmail.toLowerCase().trim() + + let user = await payload.db.findOne({ + collection: collectionConfig.slug, + req, + where: { email: { equals: email.toLowerCase() } }, + }) + + if (!user || (args.collection.config.auth.verify && user._verified === false)) { + throw new AuthenticationError(req.t) + } + + if (user && isLocked(user.lockUntil)) { + throw new LockedAuth(req.t) + } + + const authCode = generateAuthCode( + 16, + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', + ) + + // Allow config to override auth code + // if (typeof config.authorization?.generateOTP === 'function') { + // authCode = await config.authorization.generateOTP({ + // req, + // user, + // }) + // } + + const exp = new Date(Date.now() + (config.authorization?.otpExpiration || 600) * 1000).getTime() // 10 minutes + + const magiclink = '' + + let html = `

Here is your one-time password: ${authCode}

` + let subject = 'Your one-time password' + + if (client.settings?.customizeMagiclinkEmail) { + const variables: Record = { + email, + magiclink, + ...(await config.authorization?.generateEmailVariables?.({ + req, + variables: { + __method: 'otp', + otp: authCode, + }, + user, + client, + })), + } + + // Replace all variables in the email subject and body {{variable}} + if (client.settings?.magiclinkEmail) { + html = client.settings.magiclinkEmail.replace( + /{{\s*([^}]+)\s*}}/g, + (_, variable) => variables[variable.trim()] || '', + ) + } + + if (client.settings?.magiclinkEmailSubject) { + subject = client.settings.magiclinkEmailSubject.replace( + /{{\s*([^}]+)\s*}}/g, + (_, variable) => variables[variable.trim()] || '', + ) + } + } + + void sendEmail({ + from: `"${emailOptions.fromName}" <${emailOptions.fromAddress}>`, + to: email, + subject, + html, + }) + + if (shouldCommit && req.transactionID) await payload.db.commitTransaction?.(req.transactionID) + + return { + token: '', + } + } catch (error: unknown) { + await killTransaction(req) + throw error + } +} + +export default sendOtp diff --git a/yarn.lock b/yarn.lock index 165ed27..272e22b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -909,6 +909,11 @@ "@types/mime" "^1" "@types/node" "*" +"@types/sentencer@^0.2.1": + version "0.2.1" + resolved "https://registry.yarnpkg.com/@types/sentencer/-/sentencer-0.2.1.tgz#67fb36454def62abad496592e4fb009cb4caecba" + integrity sha512-psoLHMlTnUEXMGw3JcI83YiNCkdNqR66TeCjT4B+GGyPr+HLZXafG8okqK1rNo4EWTvWS7SEB2TQ+9MfGGIiAA== + "@types/serve-static@*": version "1.15.3" resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.3.tgz#2cfacfd1fd4520bbc3e292cca432d5e8e2e3ee61" @@ -1112,6 +1117,13 @@ anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" +"apparatus@>= 0.0.6": + version "0.0.10" + resolved "https://registry.yarnpkg.com/apparatus/-/apparatus-0.0.10.tgz#81ea756772ada77863db54ceee8202c109bdca3e" + integrity sha512-KLy/ugo33KZA7nugtQ7O0E1c8kQ52N3IvD/XgIh4w/Nr28ypfkwDfA67F1ev4N1m5D+BOk1+b2dEJDfpj/VvZg== + dependencies: + sylvester ">= 0.0.8" + arg@^4.1.0: version "4.1.3" resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" @@ -1186,6 +1198,11 @@ arrify@^1.0.1: resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" integrity sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA== +articles@~0.2.1: + version "0.2.2" + resolved "https://registry.yarnpkg.com/articles/-/articles-0.2.2.tgz#cc6b429f8cfa811f41e7a08505abbb4e45503197" + integrity sha512-S3Y4MPp+LD/l0HHm/4yrr6MoXhUkKT98ZdsV2tkTuBNywqUXEtvJT+NBO3KTSQEttc5EOwEJe2Xw8cZ9TI5Hrw== + atomic-sleep@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b" @@ -3694,7 +3711,7 @@ lodash.upperfirst@4.3.1: resolved "https://registry.yarnpkg.com/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz#1365edf431480481ef0d1c68957a5ed99d49f7ce" integrity sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg== -lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21: +lodash@^4.17.11, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -3962,6 +3979,15 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +natural@~0.1.28: + version "0.1.29" + resolved "https://registry.yarnpkg.com/natural/-/natural-0.1.29.tgz#59a37a6cd86d55e904b656d3b9da43fd2dc9e04a" + integrity sha512-l/lXX1uYFvPDp7MnAvPOf9Fz/7s+C2e8GECeyG1vXyMFq5KHoMRoaVgZbPuMfB0I0zHkG/jBCk+je7feSdYkpw== + dependencies: + apparatus ">= 0.0.6" + sylvester ">= 0.0.12" + underscore ">=1.3.1" + needle@^2.5.2: version "2.9.1" resolved "https://registry.yarnpkg.com/needle/-/needle-2.9.1.tgz#22d1dffbe3490c2b83e301f7709b6736cd8f2684" @@ -4600,6 +4626,11 @@ pretty-error@^4.0.0: lodash "^4.17.20" renderkid "^3.0.0" +prng-well1024a@~1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/prng-well1024a/-/prng-well1024a-1.0.1.tgz#05e8ed923e4ea2b3f78af5ee94f056b4d5cfab24" + integrity sha512-lBXfAW5Vgpej/QVHNYhTSsiz1IIlgo7kv8zzQL7v5crD8jgA4Fk3axwb9aCrDHUqJ4zKXsb3U3m6sw21165Trg== + probe-image-size@6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/probe-image-size/-/probe-image-size-6.0.0.tgz#4a85b19d5af4e29a8de7d53a9aa036f6fd02f5f4" @@ -4712,6 +4743,13 @@ randombytes@^2.1.0: dependencies: safe-buffer "^5.1.0" +randy@~1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/randy/-/randy-1.5.1.tgz#e7dc086a0ecb8bef7d67356642cd2a33f962465c" + integrity sha512-xCxHBrX08xQo8KoyZgOlM9fQbHK0oDvBl/k4+kPVGBDqfbL4c7N6uxiWTJnkJRkkg4hRrf/3CH8Vt1HiaQ2IVQ== + dependencies: + prng-well1024a "~1.0.0" + range-parser@~1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" @@ -5232,6 +5270,16 @@ send@0.18.0: range-parser "~1.2.1" statuses "2.0.1" +sentencer@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/sentencer/-/sentencer-0.2.1.tgz#88a1f4767c14bb8cd148b07822e13b8b55897956" + integrity sha512-hDLHIc7DTdZASPJhL2IZChmUq9tTUsOaAlK2kNPYI9KSdrPeqNZfjJfTCEoqcR/IlLlIfngi7Wkx/KLPqqNtyQ== + dependencies: + articles "~0.2.1" + lodash "^4.17.11" + natural "~0.1.28" + randy "~1.5.1" + serialize-javascript@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.1.tgz#b206efb27c3da0b0ab6b52f48d170b7996458e5c" @@ -5620,6 +5668,11 @@ swc-loader@0.2.3: resolved "https://registry.yarnpkg.com/swc-loader/-/swc-loader-0.2.3.tgz#6792f1c2e4c9ae9bf9b933b3e010210e270c186d" integrity sha512-D1p6XXURfSPleZZA/Lipb3A8pZ17fP4NObZvFCDjK/OKljroqDpPmsBdTraWhVBqUNpcWBQY1imWdoPScRlQ7A== +"sylvester@>= 0.0.12", "sylvester@>= 0.0.8": + version "0.0.21" + resolved "https://registry.yarnpkg.com/sylvester/-/sylvester-0.0.21.tgz#2987b1ce2bd2f38b0dce2a34388884bfa4400ea7" + integrity sha512-yUT0ukFkFEt4nb+NY+n2ag51aS/u9UHXoZw+A4jgD77/jzZsBoSDHuqysrVCBC4CYR4TYvUJq54ONpXgDBH8tA== + tabbable@^5.3.3: version "5.3.3" resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-5.3.3.tgz#aac0ff88c73b22d6c3c5a50b1586310006b47fbf" @@ -5956,6 +6009,11 @@ undefsafe@^2.0.5: resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c" integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA== +underscore@>=1.3.1: + version "1.13.6" + resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.6.tgz#04786a1f589dc6c09f761fc5f45b89e935136441" + integrity sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A== + universalify@^0.1.0: version "0.1.2" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" From 6b683c3082d531c4aa3557f5e9fe8f96662f8a59 Mon Sep 17 00:00:00 2001 From: Corfitz Date: Sat, 14 Oct 2023 17:12:49 +0200 Subject: [PATCH 08/12] Adding send magic link handler and operation --- package.json | 4 +- src/endpoints/handlers/authorize/magiclink.ts | 3 +- src/lib/txtgen/index.ts | 91 ++++ src/lib/txtgen/sample.ts | 404 ++++++++++++++++++ src/lib/txtgen/util.ts | 71 +++ src/operations/authorize/send-magiclink.ts | 58 ++- src/types.ts | 4 + tsconfig.json | 6 +- yarn.lock | 60 +-- 9 files changed, 617 insertions(+), 84 deletions(-) create mode 100644 src/lib/txtgen/index.ts create mode 100644 src/lib/txtgen/sample.ts create mode 100644 src/lib/txtgen/util.ts diff --git a/package.json b/package.json index c9a8a8e..06c26f1 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,6 @@ "@types/express": "^4.17.18", "@types/jsonwebtoken": "^9.0.3", "@types/react": "^18.2.17", - "@types/sentencer": "^0.2.1", "@typescript-eslint/eslint-plugin": "^6.7.5", "@typescript-eslint/parser": "^6.7.5", "copyfiles": "^2.4.1", @@ -69,8 +68,7 @@ "dependencies": { "cryptr": "^6.3.0", "debug": "^4.3.4", - "node-ipinfo": "^3.4.6", - "sentencer": "^0.2.1" + "node-ipinfo": "^3.4.6" }, "bugs": { "url": "https://github.com/imcorfitz/payload-plugin-oauth-apps/issues" diff --git a/src/endpoints/handlers/authorize/magiclink.ts b/src/endpoints/handlers/authorize/magiclink.ts index f20bbc8..515f879 100644 --- a/src/endpoints/handlers/authorize/magiclink.ts +++ b/src/endpoints/handlers/authorize/magiclink.ts @@ -29,7 +29,7 @@ const handler: (config: OperationConfig) => PayloadHandler = config => async (re return } - await sendMagiclink({ + const result = await sendMagiclink({ client, collection, config, @@ -41,6 +41,7 @@ const handler: (config: OperationConfig) => PayloadHandler = config => async (re }) res.send({ + ...result, message: 'Magiclink sent', }) } diff --git a/src/lib/txtgen/index.ts b/src/lib/txtgen/index.ts new file mode 100644 index 0000000..d3899a3 --- /dev/null +++ b/src/lib/txtgen/index.ts @@ -0,0 +1,91 @@ +// main + +import { phrases, sentenceTemplates } from './sample' +import { generator, pickLastPunc, rand, randfloat, randint, setRandom } from './util' + +export { + addAdjectives, + addNouns, + addTemplates, + getAdjectives, + getNouns, + getTemplates, + setAdjectives, + setNouns, + setTemplates, +} from './sample' + +const actions = ['noun', 'a_noun', 'nouns', 'adjective', 'an_adjective'] + +const trim = (s: string) => { + return s + .replace(/^[\s\xa0]+|[\s\xa0]+$/g, '') + .replace(/\r?\n|\r/g, ' ') + .replace(/\s\s+|\r/g, ' ') +} + +const make = (template: string) => { + let sentence = template + const occurrences = template.match(/\{\{(.+?)\}\}/g) + + if (occurrences?.length) { + for (const occurrence of occurrences) { + const action = trim(occurrence.replace('{{', '').replace('}}', '')) + let result = '' + if (actions.includes(action)) { + result = generator[action as keyof typeof generator]() + } + sentence = sentence.replace(occurrence, result) + } + } + return sentence +} + +const randomStartingPhrase = () => { + if (randfloat() < 0.33) { + return rand(phrases) + } + return '' +} + +const makeSentenceFromTemplate = () => { + return make(rand(sentenceTemplates)) +} + +export { setRandom } + +export const sentence = (ignoreStartingPhrase = false, ignoreLastPunctuation = false) => { + const phrase = ignoreStartingPhrase ? '' : randomStartingPhrase() + let s = phrase + makeSentenceFromTemplate() + s = s.charAt(0).toUpperCase() + s.slice(1) + if (!ignoreLastPunctuation) { + s += pickLastPunc() + } + return s +} + +export const paragraph = (len = 0) => { + if (!len) { + len = randint(3, 10) + } + const t = Math.min(len, 15) + const a = [] + while (a.length < t) { + const s = sentence() + a.push(s) + } + return a.join(' ') +} + +export const article = (len = 0) => { + if (!len) { + len = randint(3, 10) + } + const t = Math.min(len, 15) + const a = [] + while (a.length < t) { + const s = paragraph() + a.push(s) + } + return a.join('\n\n') +} diff --git a/src/lib/txtgen/sample.ts b/src/lib/txtgen/sample.ts new file mode 100644 index 0000000..3a60e76 --- /dev/null +++ b/src/lib/txtgen/sample.ts @@ -0,0 +1,404 @@ +// samples + +/* eslint-disable */ + +export let nouns = [ + 'alligator', + 'ant', + 'bear', + 'bee', + 'bird', + 'camel', + 'cat', + 'cheetah', + 'chicken', + 'chimpanzee', + 'cow', + 'crocodile', + 'deer', + 'dog', + 'dolphin', + 'duck', + 'eagle', + 'elephant', + 'fish', + 'fly', + 'fox', + 'frog', + 'giraffe', + 'goat', + 'goldfish', + 'hamster', + 'hippopotamus', + 'horse', + 'kangaroo', + 'kitten', + 'lion', + 'lobster', + 'monkey', + 'octopus', + 'owl', + 'panda', + 'pig', + 'puppy', + 'rabbit', + 'rat', + 'scorpion', + 'seal', + 'shark', + 'sheep', + 'snail', + 'snake', + 'spider', + 'squirrel', + 'tiger', + 'turtle', + 'wolf', + 'zebra', + 'apple', + 'apricot', + 'banana', + 'blackberry', + 'blueberry', + 'cherry', + 'cranberry', + 'currant', + 'fig', + 'grape', + 'grapefruit', + 'grapes', + 'kiwi', + 'kumquat', + 'lemon', + 'lime', + 'melon', + 'nectarine', + 'orange', + 'peach', + 'pear', + 'persimmon', + 'pineapple', + 'plum', + 'pomegranate', + 'prune', + 'raspberry', + 'strawberry', + 'tangerine', + 'watermelon', +] +export let adjectives = [ + 'adaptable', + 'adventurous', + 'affable', + 'affectionate', + 'agreeable', + 'alert', + 'alluring', + 'ambitious', + 'ambitious', + 'amiable', + 'amicable', + 'amused', + 'amusing', + 'boundless', + 'brave', + 'brave', + 'bright', + 'bright', + 'broad-minded', + 'calm', + 'calm', + 'capable', + 'careful', + 'charming', + 'charming', + 'cheerful', + 'coherent', + 'comfortable', + 'communicative', + 'compassionate', + 'confident', + 'conscientious', + 'considerate', + 'convivial', + 'cooperative', + 'courageous', + 'courageous', + 'courteous', + 'creative', + 'credible', + 'cultured', + 'dashing', + 'dazzling', + 'debonair', + 'decisive', + 'decisive', + 'decorous', + 'delightful', + 'detailed', + 'determined', + 'determined', + 'diligent', + 'diligent', + 'diplomatic', + 'discreet', + 'discreet', + 'dynamic', + 'dynamic', + 'eager', + 'easygoing', + 'efficient', + 'elated', + 'eminent', + 'emotional', + 'enchanting', + 'encouraging', + 'endurable', + 'energetic', + 'energetic', + 'entertaining', + 'enthusiastic', + 'enthusiastic', + 'excellent', + 'excited', + 'exclusive', + 'exuberant', + 'exuberant', + 'fabulous', + 'fair', + 'fair-minded', + 'faithful', + 'faithful', + 'fantastic', + 'fearless', + 'fearless', + 'fine', + 'forceful', + 'frank', + 'frank', + 'friendly', + 'friendly', + 'funny', + 'funny', + 'generous', + 'generous', + 'gentle', + 'gentle', + 'glorious', + 'good', + 'good', + 'gregarious', + 'happy', + 'hard-working', + 'harmonious', + 'helpful', + 'helpful', + 'hilarious', + 'honest', + 'honorable', + 'humorous', + 'imaginative', + 'impartial', + 'impartial', + 'independent', + 'industrious', + 'instinctive', + 'intellectual', + 'intelligent', + 'intuitive', + 'inventive', + 'jolly', + 'joyous', + 'kind', + 'kind', + 'kind-hearted', + 'knowledgeable', + 'level', + 'likeable', + 'lively', + 'lovely', + 'loving', + 'loving', + 'loyal', + 'lucky', + 'mature', + 'modern', + 'modest', + 'neat', + 'nice', + 'nice', + 'obedient', + 'optimistic', + 'painstaking', + 'passionate', + 'patient', + 'peaceful', + 'perfect', + 'persistent', + 'philosophical', + 'pioneering', + 'placid', + 'placid', + 'plausible', + 'pleasant', + 'plucky', + 'plucky', + 'polite', + 'powerful', + 'practical', + 'pro-active', + 'productive', + 'protective', + 'proud', + 'punctual', + 'quick-witted', + 'quiet', + 'quiet', + 'rational', + 'receptive', + 'reflective', + 'reliable', + 'relieved', + 'reserved', + 'resolute', + 'resourceful', + 'responsible', + 'rhetorical', + 'righteous', + 'romantic', + 'romantic', + 'sedate', + 'seemly', + 'selective', + 'self-assured', + 'self-confident', + 'self-disciplined', + 'sensible', + 'sensitive', + 'sensitive', + 'shrewd', + 'shy', + 'silly', + 'sincere', + 'sincere', + 'skillful', + 'smiling', + 'sociable', + 'splendid', + 'steadfast', + 'stimulating', + 'straightforward', + 'successful', + 'succinct', + 'sympathetic', + 'talented', + 'thoughtful', + 'thoughtful', + 'thrifty', + 'tidy', + 'tough', + 'tough', + 'trustworthy', + 'unassuming', + 'unbiased', + 'understanding', + 'unusual', + 'upbeat', + 'versatile', + 'vigorous', + 'vivacious', + 'warm', + 'warmhearted', + 'willing', + 'willing', + 'wise', + 'witty', + 'witty', + 'wonderful', +] + +export const vowels = ['a', 'e', 'i', 'o', 'u', 'y'] + +export let sentenceTemplates = [ + 'however, {{nouns}} have begun to rent {{nouns}} over the past few months, specifically for {{nouns}} associated with their {{nouns}}', +] + +export const phrases = [ + 'to be more specific, ', + 'in recent years, ', + 'however, ', + 'by the way', + 'of course, ', + 'some assert that ', + 'if this was somewhat unclear, ', + 'unfortunately, that is wrong; on the contrary, ', + "it's very tricky, if not impossible, ", + 'this could be, or perhaps ', + 'this is not to discredit the idea that ', + 'we know that ', + "it's an undeniable fact, really; ", + 'framed in a different way, ', + "what we don't know for sure is whether or not ", + 'as far as we can estimate, ', + 'as far as he is concerned, ', + 'the zeitgeist contends that ', + 'though we assume the latter, ', + 'far from the truth, ', + 'extending this logic, ', + 'nowhere is it disputed that ', + 'in modern times ', + 'in ancient times ', + 'recent controversy aside, ', + 'washing and polishing the car,', + 'having been a gymnast, ', + 'after a long day at school and work, ', + 'waking to the buzz of the alarm clock, ', + 'draped neatly on a hanger, ', + 'shouting with happiness, ', +] + +const mergeArray = (a: string[] = [], b: string[] = []) => { + return [...a, ...b].filter((v, i, a) => a.indexOf(v) === i) +} + +export const addNouns = (ls: string[] = []) => { + nouns = mergeArray(nouns, ls) + return nouns.length +} + +export const addAdjectives = (ls: string[] = []) => { + adjectives = mergeArray(adjectives, ls) + return adjectives.length +} + +export const addTemplates = (ls: string[] = []) => { + sentenceTemplates = mergeArray(sentenceTemplates, ls) + return sentenceTemplates.length +} + +export const setNouns = (ls: string[] = []) => { + nouns = [...ls].filter((v, i, a) => a.indexOf(v) === i) + return nouns.length +} + +export const setAdjectives = (ls: string[] = []) => { + adjectives = [...ls].filter((v, i, a) => a.indexOf(v) === i) + return adjectives.length +} + +export const setTemplates = (ls: string[] = []) => { + sentenceTemplates = [...ls].filter((v, i, a) => a.indexOf(v) === i) + return sentenceTemplates.length +} + +export const getNouns = () => { + return [...nouns] +} + +export const getAdjectives = () => { + return [...adjectives] +} + +export const getTemplates = () => { + return [...sentenceTemplates] +} diff --git a/src/lib/txtgen/util.ts b/src/lib/txtgen/util.ts new file mode 100644 index 0000000..16ae77a --- /dev/null +++ b/src/lib/txtgen/util.ts @@ -0,0 +1,71 @@ +// utils + +import { adjectives, nouns, vowels } from './sample' + +let random: () => number + +export const setRandom = (newRandom: () => number) => { + random = newRandom +} + +setRandom(Math.random) + +export const randfloat = () => random() + +export const randint = (min: number, max: number) => { + const offset = min + const range = max - min + 1 + return Math.floor(randfloat() * range) + offset +} + +export const rand = (a: string[]) => { + let w + while (!w) { + w = a[randint(0, a.length - 1)] + } + return w +} + +export const pickLastPunc = () => { + const a = '.......!?!?;...'.split('') + return rand(a) +} + +export const pluralize = (word: string) => { + if (word.endsWith('s')) { + return word + } + if (word.match(/(ss|ish|ch|x|us)$/)) { + word += 'e' + } else if (word.endsWith('y') && !vowels.includes(word.charAt(word.length - 2))) { + word = word.slice(0, word.length - 1) + word += 'ie' + } + return word + 's' +} + +export const normalize = (word: string) => { + let a = 'a' + if (word.match(/^(a|e|heir|herb|hour|i|o)/)) { + a = 'an' + } + return `${a} ${word}` +} + +export const generator = { + noun: () => { + return rand(nouns) + }, + a_noun: () => { + return normalize(rand(nouns)) + }, + nouns: () => { + return pluralize(rand(nouns)) + }, + adjective: () => { + return rand(adjectives) + }, + an_adjective: () => { + return normalize(rand(adjectives)) + }, +} diff --git a/src/operations/authorize/send-magiclink.ts b/src/operations/authorize/send-magiclink.ts index a8570c9..ef5a712 100644 --- a/src/operations/authorize/send-magiclink.ts +++ b/src/operations/authorize/send-magiclink.ts @@ -4,12 +4,14 @@ import { initTransaction } from 'payload/dist/utilities/initTransaction' import { killTransaction } from 'payload/dist/utilities/killTransaction' import type { Collection, PayloadRequest } from 'payload/types' -import type { GenericUser, OAuthApp, OperationConfig } from '../../types' +import { sentence, setAdjectives, setNouns, setTemplates } from '../../lib/txtgen' +import type { OAuthApp, OperationConfig } from '../../types' import generateAuthCode from '../../utils/generate-auth-code' export interface Result { exp?: number token?: string + verificationPhrase?: string } export interface Arguments { @@ -58,35 +60,55 @@ async function sendOtp(incomingArgs: Arguments): Promise { throw new LockedAuth(req.t) } + setTemplates([config.authorization?.verificationPhraseTemplate || '{{ adjective }} {{ noun }}']) + + if ( + config.authorization?.verificationPhraseAdjectives && + config.authorization?.verificationPhraseAdjectives?.length > 0 + ) { + setAdjectives(config.authorization?.verificationPhraseAdjectives) + } + + if ( + config.authorization?.verificationPhraseNouns && + config.authorization?.verificationPhraseNouns?.length > 0 + ) { + setNouns(config.authorization?.verificationPhraseNouns) + } + + const verificationPhrase = sentence(true, true) + const authCode = generateAuthCode( - 16, + 12, 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', ) - // Allow config to override auth code - // if (typeof config.authorization?.generateOTP === 'function') { - // authCode = await config.authorization.generateOTP({ - // req, - // user, - // }) - // } - - const exp = new Date(Date.now() + (config.authorization?.otpExpiration || 600) * 1000).getTime() // 10 minutes + const exp = new Date( + Date.now() + (config.authorization?.magicLinkExpiration || 60 * 60 * 2) * 1000, + ).getTime() // 2 hours - const magiclink = '' + const token = payload.encrypt(`${user.id}::${authCode}::${exp}::${client.id}`) + const magiclink = `${payload.config.serverURL}?email=${user.email}&token=${token}` - let html = `

Here is your one-time password: ${authCode}

` - let subject = 'Your one-time password' + let html = `

We have received a login attempt with the following code:

+

${verificationPhrase}

+

To complete the login process, please click on following link:

+

${magiclink}

+

 

+

If you didn't attempt to log in but received this email, please ignore this email.

` + let subject = 'Login verification' if (client.settings?.customizeMagiclinkEmail) { const variables: Record = { email, magiclink, + token, ...(await config.authorization?.generateEmailVariables?.({ req, variables: { - __method: 'otp', - otp: authCode, + __method: 'magiclink', + magiclink, + token, }, user, client, @@ -119,7 +141,9 @@ async function sendOtp(incomingArgs: Arguments): Promise { if (shouldCommit && req.transactionID) await payload.db.commitTransaction?.(req.transactionID) return { - token: '', + token: authCode, + exp, + verificationPhrase, } } catch (error: unknown) { await killTransaction(req) diff --git a/src/types.ts b/src/types.ts index f4aaee4..e88934f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,6 +31,10 @@ export interface PluginConfig { user?: unknown client?: Omit }) => Record | Promise> + magicLinkExpiration?: number + verificationPhraseTemplate?: string + verificationPhraseNouns?: string[] + verificationPhraseAdjectives?: string[] } sessions?: { limit?: number diff --git a/tsconfig.json b/tsconfig.json index b7895a7..7f2d374 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,9 +10,7 @@ "declaration": true, "declarationDir": "./dist", "skipLibCheck": true, - "strict": true, + "strict": true }, - "include": [ - "src/**/*" - ], + "include": ["src/**/*"] } diff --git a/yarn.lock b/yarn.lock index 272e22b..165ed27 100644 --- a/yarn.lock +++ b/yarn.lock @@ -909,11 +909,6 @@ "@types/mime" "^1" "@types/node" "*" -"@types/sentencer@^0.2.1": - version "0.2.1" - resolved "https://registry.yarnpkg.com/@types/sentencer/-/sentencer-0.2.1.tgz#67fb36454def62abad496592e4fb009cb4caecba" - integrity sha512-psoLHMlTnUEXMGw3JcI83YiNCkdNqR66TeCjT4B+GGyPr+HLZXafG8okqK1rNo4EWTvWS7SEB2TQ+9MfGGIiAA== - "@types/serve-static@*": version "1.15.3" resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.3.tgz#2cfacfd1fd4520bbc3e292cca432d5e8e2e3ee61" @@ -1117,13 +1112,6 @@ anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" -"apparatus@>= 0.0.6": - version "0.0.10" - resolved "https://registry.yarnpkg.com/apparatus/-/apparatus-0.0.10.tgz#81ea756772ada77863db54ceee8202c109bdca3e" - integrity sha512-KLy/ugo33KZA7nugtQ7O0E1c8kQ52N3IvD/XgIh4w/Nr28ypfkwDfA67F1ev4N1m5D+BOk1+b2dEJDfpj/VvZg== - dependencies: - sylvester ">= 0.0.8" - arg@^4.1.0: version "4.1.3" resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" @@ -1198,11 +1186,6 @@ arrify@^1.0.1: resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" integrity sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA== -articles@~0.2.1: - version "0.2.2" - resolved "https://registry.yarnpkg.com/articles/-/articles-0.2.2.tgz#cc6b429f8cfa811f41e7a08505abbb4e45503197" - integrity sha512-S3Y4MPp+LD/l0HHm/4yrr6MoXhUkKT98ZdsV2tkTuBNywqUXEtvJT+NBO3KTSQEttc5EOwEJe2Xw8cZ9TI5Hrw== - atomic-sleep@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b" @@ -3711,7 +3694,7 @@ lodash.upperfirst@4.3.1: resolved "https://registry.yarnpkg.com/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz#1365edf431480481ef0d1c68957a5ed99d49f7ce" integrity sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg== -lodash@^4.17.11, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21: +lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -3979,15 +3962,6 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== -natural@~0.1.28: - version "0.1.29" - resolved "https://registry.yarnpkg.com/natural/-/natural-0.1.29.tgz#59a37a6cd86d55e904b656d3b9da43fd2dc9e04a" - integrity sha512-l/lXX1uYFvPDp7MnAvPOf9Fz/7s+C2e8GECeyG1vXyMFq5KHoMRoaVgZbPuMfB0I0zHkG/jBCk+je7feSdYkpw== - dependencies: - apparatus ">= 0.0.6" - sylvester ">= 0.0.12" - underscore ">=1.3.1" - needle@^2.5.2: version "2.9.1" resolved "https://registry.yarnpkg.com/needle/-/needle-2.9.1.tgz#22d1dffbe3490c2b83e301f7709b6736cd8f2684" @@ -4626,11 +4600,6 @@ pretty-error@^4.0.0: lodash "^4.17.20" renderkid "^3.0.0" -prng-well1024a@~1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/prng-well1024a/-/prng-well1024a-1.0.1.tgz#05e8ed923e4ea2b3f78af5ee94f056b4d5cfab24" - integrity sha512-lBXfAW5Vgpej/QVHNYhTSsiz1IIlgo7kv8zzQL7v5crD8jgA4Fk3axwb9aCrDHUqJ4zKXsb3U3m6sw21165Trg== - probe-image-size@6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/probe-image-size/-/probe-image-size-6.0.0.tgz#4a85b19d5af4e29a8de7d53a9aa036f6fd02f5f4" @@ -4743,13 +4712,6 @@ randombytes@^2.1.0: dependencies: safe-buffer "^5.1.0" -randy@~1.5.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/randy/-/randy-1.5.1.tgz#e7dc086a0ecb8bef7d67356642cd2a33f962465c" - integrity sha512-xCxHBrX08xQo8KoyZgOlM9fQbHK0oDvBl/k4+kPVGBDqfbL4c7N6uxiWTJnkJRkkg4hRrf/3CH8Vt1HiaQ2IVQ== - dependencies: - prng-well1024a "~1.0.0" - range-parser@~1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" @@ -5270,16 +5232,6 @@ send@0.18.0: range-parser "~1.2.1" statuses "2.0.1" -sentencer@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/sentencer/-/sentencer-0.2.1.tgz#88a1f4767c14bb8cd148b07822e13b8b55897956" - integrity sha512-hDLHIc7DTdZASPJhL2IZChmUq9tTUsOaAlK2kNPYI9KSdrPeqNZfjJfTCEoqcR/IlLlIfngi7Wkx/KLPqqNtyQ== - dependencies: - articles "~0.2.1" - lodash "^4.17.11" - natural "~0.1.28" - randy "~1.5.1" - serialize-javascript@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.1.tgz#b206efb27c3da0b0ab6b52f48d170b7996458e5c" @@ -5668,11 +5620,6 @@ swc-loader@0.2.3: resolved "https://registry.yarnpkg.com/swc-loader/-/swc-loader-0.2.3.tgz#6792f1c2e4c9ae9bf9b933b3e010210e270c186d" integrity sha512-D1p6XXURfSPleZZA/Lipb3A8pZ17fP4NObZvFCDjK/OKljroqDpPmsBdTraWhVBqUNpcWBQY1imWdoPScRlQ7A== -"sylvester@>= 0.0.12", "sylvester@>= 0.0.8": - version "0.0.21" - resolved "https://registry.yarnpkg.com/sylvester/-/sylvester-0.0.21.tgz#2987b1ce2bd2f38b0dce2a34388884bfa4400ea7" - integrity sha512-yUT0ukFkFEt4nb+NY+n2ag51aS/u9UHXoZw+A4jgD77/jzZsBoSDHuqysrVCBC4CYR4TYvUJq54ONpXgDBH8tA== - tabbable@^5.3.3: version "5.3.3" resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-5.3.3.tgz#aac0ff88c73b22d6c3c5a50b1586310006b47fbf" @@ -6009,11 +5956,6 @@ undefsafe@^2.0.5: resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c" integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA== -underscore@>=1.3.1: - version "1.13.6" - resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.6.tgz#04786a1f589dc6c09f761fc5f45b89e935136441" - integrity sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A== - universalify@^0.1.0: version "0.1.2" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" From f81c4f52e46f966b17076b891760c43721431c24 Mon Sep 17 00:00:00 2001 From: Corfitz Date: Sat, 14 Oct 2023 18:09:06 +0200 Subject: [PATCH 09/12] Finalising magiclink authorisation --- .changeset/red-numbers-fold.md | 5 + TODO.md | 1 - src/collections/OAuthApps/index.ts | 2 +- .../handlers/authorize/credentials.ts | 2 +- src/endpoints/handlers/authorize/magiclink.ts | 2 +- src/endpoints/handlers/authorize/otp.ts | 2 +- src/endpoints/handlers/verify/code.ts | 65 ++++++++ src/endpoints/handlers/verify/magiclink.ts | 57 +++++++ src/endpoints/handlers/verify/otp.ts | 8 +- src/endpoints/oauth/authorize.ts | 5 +- src/endpoints/oauth/refresh-token.ts | 17 ++- src/endpoints/oauth/verify.ts | 17 +++ src/fields/oauth-group.ts | 12 ++ src/operations/authorize/login.ts | 3 +- src/operations/authorize/send-magiclink.ts | 6 +- src/operations/verify/code.ts | 142 ++++++++++++++++++ src/operations/verify/magiclink.ts | 108 +++++++++++++ src/operations/verify/otp.ts | 5 +- src/types.ts | 1 + 19 files changed, 435 insertions(+), 25 deletions(-) create mode 100644 .changeset/red-numbers-fold.md create mode 100644 src/endpoints/handlers/verify/code.ts create mode 100644 src/endpoints/handlers/verify/magiclink.ts create mode 100644 src/operations/verify/code.ts create mode 100644 src/operations/verify/magiclink.ts diff --git a/.changeset/red-numbers-fold.md b/.changeset/red-numbers-fold.md new file mode 100644 index 0000000..f23b4cd --- /dev/null +++ b/.changeset/red-numbers-fold.md @@ -0,0 +1,5 @@ +--- +"@imcorfitz/payload-plugin-oauth-apps": minor +--- + +Added magiclink auth flow diff --git a/TODO.md b/TODO.md index 404ea70..986d1db 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,6 @@ # To Do - [ ] Create magiclink auth flow -- [ ] Add custom generate security pass phrase function - [ ] Overwrite Graphql APIs / Introduce new ones - [ ] Login - [ ] Logout diff --git a/src/collections/OAuthApps/index.ts b/src/collections/OAuthApps/index.ts index 2d3a4fd..c7d2759 100644 --- a/src/collections/OAuthApps/index.ts +++ b/src/collections/OAuthApps/index.ts @@ -82,7 +82,7 @@ export const OAuthApps: CollectionConfig = { required: true, admin: { description: - 'When using magiclink, this is the URL that the user will be redirected to after they have authenticated. The callback URL will receive a query parameter called `token` which can be used in exchange for an access and refresh token.', + 'When using magiclink, this is the URL that the user will be redirected to after they have authenticated.', }, }, { diff --git a/src/endpoints/handlers/authorize/credentials.ts b/src/endpoints/handlers/authorize/credentials.ts index 77ba63d..ae33b06 100644 --- a/src/endpoints/handlers/authorize/credentials.ts +++ b/src/endpoints/handlers/authorize/credentials.ts @@ -26,7 +26,7 @@ const handler: (config: OperationConfig) => PayloadHandler = config => async (re const client = await verifyClientCredentials(clientId, clientSecret, payload) if (!client) { - res.status(401).send('Unauthorized: Invalid client credentials') + res.status(httpStatus.UNAUTHORIZED).send('Unauthorized: Invalid client credentials') return } diff --git a/src/endpoints/handlers/authorize/magiclink.ts b/src/endpoints/handlers/authorize/magiclink.ts index 515f879..6ae965b 100644 --- a/src/endpoints/handlers/authorize/magiclink.ts +++ b/src/endpoints/handlers/authorize/magiclink.ts @@ -25,7 +25,7 @@ const handler: (config: OperationConfig) => PayloadHandler = config => async (re const client = await verifyClientCredentials(clientId, clientSecret, payload) if (!client) { - res.status(401).send('Unauthorized: Invalid client credentials') + res.status(httpStatus.UNAUTHORIZED).send('Unauthorized: Invalid client credentials') return } diff --git a/src/endpoints/handlers/authorize/otp.ts b/src/endpoints/handlers/authorize/otp.ts index 72d21b7..48c58b8 100644 --- a/src/endpoints/handlers/authorize/otp.ts +++ b/src/endpoints/handlers/authorize/otp.ts @@ -25,7 +25,7 @@ const handler: (config: OperationConfig) => PayloadHandler = config => async (re const client = await verifyClientCredentials(clientId, clientSecret, payload) if (!client) { - res.status(401).send('Unauthorized: Invalid client credentials') + res.status(httpStatus.UNAUTHORIZED).send('Unauthorized: Invalid client credentials') return } diff --git a/src/endpoints/handlers/verify/code.ts b/src/endpoints/handlers/verify/code.ts new file mode 100644 index 0000000..1a3b1cf --- /dev/null +++ b/src/endpoints/handlers/verify/code.ts @@ -0,0 +1,65 @@ +import httpStatus from 'http-status' +import type { PayloadHandler } from 'payload/config' + +import verifyCode from '../../../operations/verify/code' +import type { OperationConfig } from '../../../types' +import verifyClientCredentials from '../../../utils/verify-client-credentials' + +const handler: (config: OperationConfig) => PayloadHandler = config => async (req, res, next) => { + try { + const { payload } = req + + const collection = payload.collections[config.endpointCollection.slug] + + const { code, email, clientId, clientSecret } = req.body as { + code?: string + email?: string + clientId?: string + clientSecret?: string + } + + if (!code) { + res.status(httpStatus.BAD_REQUEST).send('Bad Request: Missing code') + return + } + + if (!email) { + res.status(httpStatus.BAD_REQUEST).send('Bad Request: Missing email') + return + } + + if (!clientId || !clientSecret) { + res.status(httpStatus.BAD_REQUEST).send('Bad Request: Missing client credentials') + return + } + + // Validate the client credentials + const client = await verifyClientCredentials(clientId, clientSecret, payload) + + if (!client) { + res.status(httpStatus.UNAUTHORIZED).send('Unauthorized: Invalid client credentials') + return + } + + const result = await verifyCode({ + collection, + req, + data: { + email, + code, + }, + res: res as unknown as Response, + client, + config, + }) + + res.status(httpStatus.OK).send({ + ...result, + message: 'Auth Passed', + }) + } catch (error) { + next(error) + } +} + +export default handler diff --git a/src/endpoints/handlers/verify/magiclink.ts b/src/endpoints/handlers/verify/magiclink.ts new file mode 100644 index 0000000..e58be1b --- /dev/null +++ b/src/endpoints/handlers/verify/magiclink.ts @@ -0,0 +1,57 @@ +import httpStatus from 'http-status' +import type { PayloadHandler } from 'payload/config' + +import verifyMagiclink from '../../../operations/verify/magiclink' +import type { OperationConfig } from '../../../types' + +const handler: (config: OperationConfig) => PayloadHandler = config => async (req, res, next) => { + try { + const { payload, method } = req + + const collection = payload.collections[config.endpointCollection.slug] + + let token + + if (method === 'POST') { + const { token: requestedToken } = req.body as { + token?: string + } + + token = requestedToken + } else if (method === 'GET') { + const { token: requestedToken } = req.query as { + token?: string + } + + token = requestedToken + } + + if (!token) { + res.status(httpStatus.BAD_REQUEST).send('Bad Request: Missing token') + return + } + + const result = await verifyMagiclink({ + collection, + config, + req, + res: res as unknown as Response, + data: { + token, + }, + }) + + if (method === 'GET') { + res.redirect(result.callbackUrl) + return + } + + res.send({ + message: 'Magiclink verified', + }) + } catch (error) { + next(error) + } +} + +export default handler diff --git a/src/endpoints/handlers/verify/otp.ts b/src/endpoints/handlers/verify/otp.ts index 9c83ea0..8ae8f6c 100644 --- a/src/endpoints/handlers/verify/otp.ts +++ b/src/endpoints/handlers/verify/otp.ts @@ -19,17 +19,17 @@ const handler: (config: OperationConfig) => PayloadHandler = config => async (re } if (!otp) { - res.status(400).send('Bad Request: Missing OTP') + res.status(httpStatus.BAD_REQUEST).send('Bad Request: Missing OTP') return } if (!email) { - res.status(400).send('Bad Request: Missing email') + res.status(httpStatus.BAD_REQUEST).send('Bad Request: Missing email') return } if (!clientId || !clientSecret) { - res.status(400).send('Bad Request: Missing client credentials') + res.status(httpStatus.BAD_REQUEST).send('Bad Request: Missing client credentials') return } @@ -37,7 +37,7 @@ const handler: (config: OperationConfig) => PayloadHandler = config => async (re const client = await verifyClientCredentials(clientId, clientSecret, payload) if (!client) { - res.status(401).send('Unauthorized: Invalid client credentials') + res.status(httpStatus.UNAUTHORIZED).send('Unauthorized: Invalid client credentials') return } diff --git a/src/endpoints/oauth/authorize.ts b/src/endpoints/oauth/authorize.ts index 442d23c..5a82269 100644 --- a/src/endpoints/oauth/authorize.ts +++ b/src/endpoints/oauth/authorize.ts @@ -1,3 +1,4 @@ +import httpStatus from 'http-status' import type { Endpoint } from 'payload/config' import type { OperationConfig } from '../../types' @@ -29,14 +30,14 @@ export const authorize: (config: OperationConfig) => Endpoint[] = config => { const methodIsSupported = Object.keys(authHandlers).includes(method) if (!methodIsSupported) { - res.status(400).send('Bad Request: Invalid authorization method') + res.status(httpStatus.BAD_REQUEST).send('Bad Request: Invalid authorization method') return } const authHandler = authHandlers[method as keyof typeof authHandlers] if (!authHandler) { - res.status(400).send('Bad Request: Invalid authorization method') + res.status(httpStatus.BAD_REQUEST).send('Bad Request: Invalid authorization method') return } diff --git a/src/endpoints/oauth/refresh-token.ts b/src/endpoints/oauth/refresh-token.ts index dad238e..7ba7b45 100644 --- a/src/endpoints/oauth/refresh-token.ts +++ b/src/endpoints/oauth/refresh-token.ts @@ -1,3 +1,4 @@ +import httpStatus from 'http-status' import type { Endpoint } from 'payload/config' import generateAccessToken from '../../token/generate-access-token' @@ -24,7 +25,7 @@ export const refreshToken: (config: OperationConfig) => Endpoint[] = config => { } if (!clientId || !clientSecret) { - res.status(400).send('Bad Request: Missing client credentials') + res.status(httpStatus.BAD_REQUEST).send('Bad Request: Missing client credentials') return } @@ -32,7 +33,7 @@ export const refreshToken: (config: OperationConfig) => Endpoint[] = config => { const client = await verifyClientCredentials(clientId, clientSecret, payload) if (!client) { - res.status(401).send('Unauthorized: Invalid client credentials') + res.status(httpStatus.UNAUTHORIZED).send('Unauthorized: Invalid client credentials') return } @@ -49,7 +50,7 @@ export const refreshToken: (config: OperationConfig) => Endpoint[] = config => { cookies?.find(cookie => cookie.name === `${payload.config.cookiePrefix}-refresh`)?.value if (!token) { - res.status(400).send('Bad Request: Missing refresh token') + res.status(httpStatus.BAD_REQUEST).send('Bad Request: Missing refresh token') return } @@ -58,7 +59,7 @@ export const refreshToken: (config: OperationConfig) => Endpoint[] = config => { const expiresAt = new Date(Number(expiration)) if (expiresAt < new Date(Date.now())) { - res.status(401).send('Unauthorized: Token expired') + res.status(httpStatus.UNAUTHORIZED).send('Unauthorized: Token expired') return } @@ -69,21 +70,21 @@ export const refreshToken: (config: OperationConfig) => Endpoint[] = config => { })) as MaybeUser if (!user) { - res.status(404).send('User not Found') + res.status(httpStatus.NOT_FOUND).send('User not Found') return } const session = user.oAuth.sessions?.find(ses => ses.id === sessionId) if (!session) { - res.status(404).send('No active session found') + res.status(httpStatus.NOT_FOUND).send('No active session found') return } const sessionAppId = typeof session.app === 'string' ? session.app : session.app.id if (sessionAppId !== client.id) { - res.status(401).send('Unauthorized: Invalid client credentials') + res.status(httpStatus.UNAUTHORIZED).send('Unauthorized: Invalid client credentials') return } @@ -103,7 +104,7 @@ export const refreshToken: (config: OperationConfig) => Endpoint[] = config => { const message = String(error).includes('Invalid initialization vector') ? 'Bad Request: Refresh Token not valid' : (error as any)?.message || 'Internal Server Error' - res.status(500).send(message) + res.status(httpStatus.INTERNAL_SERVER_ERROR).send(message) } }, }, diff --git a/src/endpoints/oauth/verify.ts b/src/endpoints/oauth/verify.ts index 45752db..271bdc2 100644 --- a/src/endpoints/oauth/verify.ts +++ b/src/endpoints/oauth/verify.ts @@ -1,6 +1,8 @@ import type { Endpoint } from 'payload/config' import type { OperationConfig } from '../../types' +import verifyCodeHandler from '../handlers/verify/code' +import verifyMagiclinkHandler from '../handlers/verify/magiclink' import verifyOtpHandler from '../handlers/verify/otp' export const verify: (config: OperationConfig) => Endpoint[] = config => { @@ -10,5 +12,20 @@ export const verify: (config: OperationConfig) => Endpoint[] = config => { method: 'post', handler: verifyOtpHandler(config), }, + { + path: '/oauth/verify-magiclink', + method: 'get', + handler: verifyMagiclinkHandler(config), + }, + { + path: '/oauth/verify-magiclink', + method: 'post', + handler: verifyMagiclinkHandler(config), + }, + { + path: '/oauth/verify-code', + method: 'post', + handler: verifyCodeHandler(config), + }, ] } diff --git a/src/fields/oauth-group.ts b/src/fields/oauth-group.ts index db2c4ef..f92a7cd 100644 --- a/src/fields/oauth-group.ts +++ b/src/fields/oauth-group.ts @@ -21,6 +21,18 @@ export const OAuthGroup: (pluginConfig: PluginConfig) => GroupField = pluginConf update: () => false, }, }, + { + name: '_magiclinks', + type: 'text', + label: 'Magic Links', + hidden: true, + index: false, + access: { + read: () => false, + create: () => false, + update: () => false, + }, + }, { type: 'array', name: 'sessions', diff --git a/src/operations/authorize/login.ts b/src/operations/authorize/login.ts index 589f90c..cedac7c 100644 --- a/src/operations/authorize/login.ts +++ b/src/operations/authorize/login.ts @@ -1,3 +1,4 @@ +import httpStatus from 'http-status' import isLocked from 'payload/dist/auth/isLocked' import unlock from 'payload/dist/auth/operations/unlock' import { authenticateLocalStrategy } from 'payload/dist/auth/strategies/local/authenticate' @@ -105,7 +106,7 @@ async function login(incomingArgs: Arguments): Promise { }) if (!refreshData) { - throw new APIError('Unable to generate refresh token') + throw new APIError('Unable to generate refresh token', httpStatus.INTERNAL_SERVER_ERROR) } const { expiresIn, accessToken } = generateAccessToken({ diff --git a/src/operations/authorize/send-magiclink.ts b/src/operations/authorize/send-magiclink.ts index ef5a712..8c2cda7 100644 --- a/src/operations/authorize/send-magiclink.ts +++ b/src/operations/authorize/send-magiclink.ts @@ -10,7 +10,7 @@ import generateAuthCode from '../../utils/generate-auth-code' export interface Result { exp?: number - token?: string + code?: string verificationPhrase?: string } @@ -88,7 +88,7 @@ async function sendOtp(incomingArgs: Arguments): Promise { ).getTime() // 2 hours const token = payload.encrypt(`${user.id}::${authCode}::${exp}::${client.id}`) - const magiclink = `${payload.config.serverURL}?email=${user.email}&token=${token}` + const magiclink = `${payload.config.serverURL}/api/${collectionConfig.slug}/oauth/verify?email=${user.email}&token=${token}` let html = `

We have received a login attempt with the following code:

${verificationPhrase}

@@ -141,7 +141,7 @@ async function sendOtp(incomingArgs: Arguments): Promise { if (shouldCommit && req.transactionID) await payload.db.commitTransaction?.(req.transactionID) return { - token: authCode, + code: authCode, exp, verificationPhrase, } diff --git a/src/operations/verify/code.ts b/src/operations/verify/code.ts new file mode 100644 index 0000000..cc36fee --- /dev/null +++ b/src/operations/verify/code.ts @@ -0,0 +1,142 @@ +import httpStatus from 'http-status' +import isLocked from 'payload/dist/auth/isLocked' +import unlock from 'payload/dist/auth/operations/unlock' +import { APIError, AuthenticationError, LockedAuth } from 'payload/dist/errors' +import { initTransaction } from 'payload/dist/utilities/initTransaction' +import { killTransaction } from 'payload/dist/utilities/killTransaction' +import sanitizeInternalFields from 'payload/dist/utilities/sanitizeInternalFields' +import type { Collection, PayloadRequest } from 'payload/types' + +import generateAccessToken from '../../token/generate-access-token' +import generateRefreshToken from '../../token/generate-refresh-token' +import type { GenericUser, OAuthApp, OperationConfig } from '../../types' + +export interface Result { + exp?: number + token?: string + refreshToken?: string + refreshExp?: number +} + +export interface Arguments { + collection: Collection + data: { + email: string + code: string + } + req: PayloadRequest + res?: Response + config: OperationConfig + client: OAuthApp +} + +async function verifyCode(incomingArgs: Arguments): Promise { + let args = incomingArgs + + const { + collection: { config: collectionConfig }, + data, + req, + req: { payload }, + client, + config, + } = args + + try { + const shouldCommit = await initTransaction(req) + + const { email: unsanitizedEmail, code } = data + + const email = unsanitizedEmail.toLowerCase().trim() + + let user = await payload.db.findOne({ + collection: collectionConfig.slug, + req, + where: { email: { equals: email.toLowerCase() } }, + }) + + if (!user || (args.collection.config.auth.verify && user._verified === false)) { + throw new AuthenticationError(req.t) + } + + if (user && isLocked(user.lockUntil)) { + throw new LockedAuth(req.t) + } + + const magiclinks = JSON.parse(user.oAuth._magiclinks || '[]').filter( + (o: { exp: number }) => o.exp > Date.now(), // Remove expired Magic Links + ) + + const linkIndex = magiclinks.findIndex((o: { code: string }) => o.code === code) + + if (linkIndex === -1) { + throw new APIError('Invalid token', httpStatus.UNAUTHORIZED) + } + + const remainingMagiclinks = magiclinks.filter((o: { code: string }) => o.code !== code) + + user = (await payload.update({ + id: user.id, + collection: config.endpointCollection.slug, + depth: 1, + data: { + oAuth: { + ...user.oAuth, + _magiclinks: JSON.stringify(remainingMagiclinks), + }, + }, + })) as GenericUser + + user = sanitizeInternalFields(user) + + const maxLoginAttemptsEnabled = args.collection.config.auth.maxLoginAttempts > 0 + + if (maxLoginAttemptsEnabled) { + await unlock({ + collection: { + config: collectionConfig, + }, + data: { + email, + }, + overrideAccess: true, + req, + }) + } + + // Generate the token + const refreshData = await generateRefreshToken({ + app: client, + user, + req, + config, + }) + + if (!refreshData) { + throw new APIError('Unable to generate refresh token', httpStatus.INTERNAL_SERVER_ERROR) + } + + const { expiresIn, accessToken } = generateAccessToken({ + user, + payload, + collection: collectionConfig, + sessionId: refreshData.sessionId, + }) + + const result: Result = { + exp: expiresIn, + token: accessToken, + refreshToken: refreshData.refreshToken, + refreshExp: refreshData.expiresIn, + } + + if (shouldCommit && req.transactionID) await payload.db.commitTransaction?.(req.transactionID) + + return result + } catch (error: unknown) { + await killTransaction(req) + throw error + } +} + +export default verifyCode diff --git a/src/operations/verify/magiclink.ts b/src/operations/verify/magiclink.ts new file mode 100644 index 0000000..67bbc49 --- /dev/null +++ b/src/operations/verify/magiclink.ts @@ -0,0 +1,108 @@ +import httpStatus from 'http-status' +import { APIError } from 'payload/dist/errors' +import { initTransaction } from 'payload/dist/utilities/initTransaction' +import { killTransaction } from 'payload/dist/utilities/killTransaction' +import type { Collection, PayloadRequest } from 'payload/types' + +import type { OAuthApp, OperationConfig } from '../../types' + +export interface Result { + callbackUrl: string +} + +export interface Arguments { + collection: Collection + data: { + token: string + } + req: PayloadRequest + res?: Response + config: OperationConfig +} + +async function verifyMagiclink(incomingArgs: Arguments): Promise { + let args = incomingArgs + + const { + collection: { config: collectionConfig }, + data, + req, + req: { payload }, + } = args + + try { + const shouldCommit = await initTransaction(req) + + const { token } = data + + if (!token) { + throw new APIError('Bad Request: Missing token', httpStatus.BAD_REQUEST) + } + + const [userId, authCode, exp, clientId] = payload.decrypt(String(token)).split('::') + + if (!userId || !authCode || !exp || !clientId) { + throw new APIError('Bad Request: Invalid token', httpStatus.BAD_REQUEST) + } + + if (parseInt(exp) < Date.now()) { + throw new APIError('Unauthorized: Expired token', httpStatus.UNAUTHORIZED) + } + + const user = await payload.db.findOne({ + collection: collectionConfig.slug, + req, + where: { id: { equals: userId } }, + }) + + if (!user) { + throw new APIError('Unauthorized: Invalid token', httpStatus.UNAUTHORIZED) + } + + const magiclinks = JSON.parse(user.oAuth._magiclinks || '[]').filter( + (o: { exp: number }) => o.exp > Date.now(), // Remove expired magiclinks + ) + + const client = (await payload.db.findOne({ + collection: 'oAuthApps', + req, + where: { id: { equals: clientId } }, + })) as OAuthApp + + if (!client) { + throw new APIError('Unauthorized: Invalid token', httpStatus.UNAUTHORIZED) + } + + await payload.db.updateOne({ + collection: collectionConfig.slug, + req, + id: user.id, + data: { + oAuth: { + ...user.oAuth, + _magiclinks: JSON.stringify([ + ...magiclinks, + { + exp, + code: authCode, + app: client.id, + }, + ]), + }, + }, + }) + + const result: Result = { + callbackUrl: client.callbackUrl, + } + + if (shouldCommit && req.transactionID) await payload.db.commitTransaction?.(req.transactionID) + + return result + } catch (error: unknown) { + await killTransaction(req) + throw error + } +} + +export default verifyMagiclink diff --git a/src/operations/verify/otp.ts b/src/operations/verify/otp.ts index 8d3bd63..418b263 100644 --- a/src/operations/verify/otp.ts +++ b/src/operations/verify/otp.ts @@ -1,3 +1,4 @@ +import httpStatus from 'http-status' import isLocked from 'payload/dist/auth/isLocked' import unlock from 'payload/dist/auth/operations/unlock' import { APIError, AuthenticationError, LockedAuth } from 'payload/dist/errors' @@ -69,7 +70,7 @@ async function verifyOtp(incomingArgs: Arguments): Promise { const otpIndex = otps.findIndex((o: { otp: string }) => o.otp === otp) if (otpIndex === -1) { - throw new APIError('Invalid OTP', 401) + throw new APIError('Invalid OTP', httpStatus.UNAUTHORIZED) } const remainingOTPs = otps.filter((o: { otp: string }) => o.otp !== otp) @@ -112,7 +113,7 @@ async function verifyOtp(incomingArgs: Arguments): Promise { }) if (!refreshData) { - throw new APIError('Unable to generate refresh token') + throw new APIError('Unable to generate refresh token', httpStatus.INTERNAL_SERVER_ERROR) } const { expiresIn, accessToken } = generateAccessToken({ diff --git a/src/types.ts b/src/types.ts index e88934f..8f0dd1e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -50,6 +50,7 @@ export interface PluginConfig { export interface GenericUser extends User { oAuth: { _otp?: string + _magiclinks?: string sessions?: Array<{ app: string | { id: string } userAgent?: string From 98f5af511453013774c2e7b41fea9941d559b588 Mon Sep 17 00:00:00 2001 From: Corfitz Date: Sat, 14 Oct 2023 19:27:08 +0200 Subject: [PATCH 10/12] Adding logout, documentation and more magiclink auth --- CHANGELOG.md | 1 - README.md | 72 ++++++++++++++++--- TODO.md | 1 - src/endpoints/oauth/index.ts | 8 ++- src/endpoints/oauth/logout.ts | 48 +++++++++++++ src/endpoints/oauth/refresh-token.ts | 16 +---- src/hooks/after-logout.ts | 65 ----------------- src/index.ts | 5 -- src/operations/authorize/send-magiclink.ts | 9 ++- src/operations/logout/index.ts | 82 ++++++++++++++++++++++ src/operations/verify/code.ts | 6 +- src/operations/verify/magiclink.ts | 10 +++ 12 files changed, 221 insertions(+), 102 deletions(-) create mode 100644 src/endpoints/oauth/logout.ts delete mode 100644 src/hooks/after-logout.ts create mode 100644 src/operations/logout/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e1af05e..0583704 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,6 @@ - `Session management` on User collections with ability to revoke active sessions - `Passwordless authentication` using One-time password (OTP) or Magiclink - Automatically adds registered OAuth apps to `CSRF` and `CORS` config in Payload -- Full support of native Payload Auth cookies and JWT passport strategy [0.1.1]: https://github.com/imcorfitz/payload-plugin-oauth-apps diff --git a/README.md b/README.md index 29a56e9..0b7c684 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ - Ability to create multiple `OAuth Apps` with individual client credentials - Better session flow using revokable longer-lived `refresh tokens` - `Session management` on User collections with ability to revoke active sessions -- `Passwordless authentication` using One-time password (OTP) or Magiclink (Coming soon) +- `Passwordless authentication` using One-time password (OTP) or Magiclink - Automatically adds registered OAuth apps to `CSRF` and `CORS` config in Payload ## Installation @@ -69,9 +69,8 @@ export default buildConfig({ - `authorization`: object | optional - Configure how `OAuth Apps` authorize users and initialize new sessions. The default `method` is 'crednetials'. + Configure how `OAuth Apps` authorize users and initialize new sessions. - - `method`: 'credentials' | 'otp' | 'magiclink' | '' | optional - `customHandlers`: {: EndpointHandler} | optional - `otpExpiration`: number | optional - `generateOTP`: method | optional @@ -79,11 +78,12 @@ export default buildConfig({ When using `otp` and authorization method, you can set the expiration (`otpExpiration` - defaults to 10 minutes) and customise how you want the one-time password to be generated (`generateOTP` - defaults to generating a 6-digit number). - Both `magiclink` (Coming soon) and `otp` allows you to set the `generateEmailHTML` and `generateEmailSubject` methods to customise the email sent to the user for authentication. In both method you will have access to following properties: + Both `magiclink` and `otp` allows you to set the `generateEmailVariables` method to customise the email variables available in the OAuth App settings. In both method you will have access to following properties: - `req`: PayloadRequest - - `token`: The generated OTP or an encrypted token depending on the set method + - `variables`: An object containing a magiclink and token, or an OTP, depending on the `method` - `user`: Information about the user to be authenticated + - `client`: Details about the OAuth App making the auth request > Note: `customHandlers` should be set if you wish to create your own `method` and allows you to perform the entire authentication flow yourself. Note that the plugin does expose the generateAccessToken and generateRefreshToken methods, however this goes beyond the scope of this documentation, and should be used in advance cases only. @@ -136,22 +136,24 @@ const Admins: CollectionConfig = { - [POST] `oauth/authorize`: - Used by OAuth apps to log in users. Upon sucessful login, the response will contain an access token and a refresh token. + Used by OAuth apps to log in users. Upon sucessful login, the response will contain an access token and a refresh token. By passing `method` as part of the body, you can tell Payload CMS how you wish to authenticate the user. The plugin support `credentials`, `otp`, and `magiclink` out of the box. > Note: Don't ever expose your client id or client secret to the client. These operations should always be made securely from server-side. | Parameter | Description | | ----------------------- | ------------------------------------------------------------------------------------------------------------ | - | email `required` | The email address of the user to be logged in. | - | password | The password of the user to be logged in. _NB: `required` if `authorization.method` is set to 'credentials'_ | + | email `required` | The email address of the user to be logged in | | clientId `required` | The client id of the OAuth App performing the operation | | clientSecret `required` | The client secret of the OAuth App performing the operation | + | method | `'credentials' \| 'otp' \| 'magiclink' \| .` The default `method` is 'credentials' | + | password | The password of the user to be logged in. _NB: `required` if `authorization.method` is set to 'credentials'_ | ```ts // Request const response = await fetch(`https://my.payloadcms.tld//oauth/authorize`, { method: 'POST', body: JSON.stringify({ + method: "credentials", email: "user@payloadcms.com", password: "very-safe-password-1234", clientId: "CID_s3o8y384y5...", @@ -200,13 +202,13 @@ const Admins: CollectionConfig = { - [POST] `oauth/verify-otp`: - When `authorization.method` is set to 'otp', the user will receive an email with a one-time password. Use this endpoint to finalize the authentication process and receive an access and refresh token. + When `method` is set to 'otp', the user will receive an email with a one-time password. Use this endpoint to finalize the authentication process and receive an access and refresh token. > Note: Don't ever expose your client id or client secret to the client. These operations should always be made securely from server-side. | Parameter | Description | | ----------------------- | ----------------------------------------------------------- | - | email `required` | The email address of the user to be logged in. | + | email `required` | The email address of the user to be logged in | | otp `required` | The one-time password received by the user by email | | clientId `required` | The client id of the OAuth App performing the operation | | clientSecret `required` | The client secret of the OAuth App performing the operation | @@ -232,6 +234,56 @@ const Admins: CollectionConfig = { } ``` +- [GET] `oauth/verify-magiclink`: + + When `method` is set to 'magiclink', the user will receive an email with a link. The link is directing the user to this endpoint by default (if not overridden in OAuth App settings). When validated, the user will be redirected to the callbackUrl registered for the OAuth App. + + | Query Parameter | Description | + | ---------------- | --------------------------------------- | + | token `required` | The token received by the user by email | + +- [POST] `oauth/verify-magiclink`: + + Same endpoint can also be used by an OAuth app to post the user's token for validation. Same process applies, but instead of a redirect, the call will output a JSON object with the status of the validation. + + | Parameter | Description | + | ---------------- | --------------------------------------- | + | token `required` | The token received by the user by email | + +- [POST] `oauth/verify-code`: + + When `method` is set to 'magiclink' and the user has clicked the link they've received calling this endpoint with the code received at the authentication call, this endpoint will verify your code and finalize the authentication process and issue an access and refresh token. + + > Note: Don't ever expose your client id or client secret to the client. These operations should always be made securely from server-side. + + | Parameter | Description | + | ----------------------- | ----------------------------------------------------------- | + | email `required` | The email address of the user to be logged in | + | code `required` | The code received during authentication call | + | clientId `required` | The client id of the OAuth App performing the operation | + | clientSecret `required` | The client secret of the OAuth App performing the operation | + + ```ts + // Request + const response = await fetch(`https://my.payloadcms.tld//oauth/verify-code`, { + method: 'POST', + body: JSON.stringify({ + email: "user@payloadcms.com", + code: "AbCdEf123456", + clientId: "CID_s3o8y384y5...", + clientSecret: "CS_skijorintg..." + }) + }) + + // Successful Response + { + "accessToken": "eyJhbGciOiJIUzI1N...XMnxpb1NTK9K0", + "accessExpiration": 3600, + "refreshToken": "43d5cc1ee66ac880...94b8f2df", + "refreshExpiration": 2592000 + } + ``` + ## Changelog Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. diff --git a/TODO.md b/TODO.md index 986d1db..7c2f3ae 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,5 @@ # To Do -- [ ] Create magiclink auth flow - [ ] Overwrite Graphql APIs / Introduce new ones - [ ] Login - [ ] Logout diff --git a/src/endpoints/oauth/index.ts b/src/endpoints/oauth/index.ts index 8261e76..f19937f 100644 --- a/src/endpoints/oauth/index.ts +++ b/src/endpoints/oauth/index.ts @@ -2,9 +2,15 @@ import type { Endpoint } from 'payload/config' import type { OperationConfig } from '../../types' import { authorize } from './authorize' +import { logout } from './logout' import { refreshToken } from './refresh-token' import { verify } from './verify' export const oAuthEndpoints: (endpointConfig: OperationConfig) => Endpoint[] = endpointConfig => { - return [...authorize(endpointConfig), ...refreshToken(endpointConfig), ...verify(endpointConfig)] + return [ + ...authorize(endpointConfig), + ...refreshToken(endpointConfig), + ...verify(endpointConfig), + ...logout(endpointConfig), + ] } diff --git a/src/endpoints/oauth/logout.ts b/src/endpoints/oauth/logout.ts new file mode 100644 index 0000000..98e6184 --- /dev/null +++ b/src/endpoints/oauth/logout.ts @@ -0,0 +1,48 @@ +import httpStatus from 'http-status' +import type { Endpoint } from 'payload/config' + +import logoutUser from '../../operations/logout' +import type { OperationConfig } from '../../types' + +export const logout: (config: OperationConfig) => Endpoint[] = config => { + return [ + { + path: '/oauth/logout', + method: 'post', + async handler(req, res, next) { + try { + const { payload } = req + + const collection = payload.collections[config.endpointCollection.slug] + + const { refreshToken, accessToken } = req.body as { + refreshToken?: string + accessToken?: string + } + + if (!refreshToken && !accessToken) { + res.status(httpStatus.BAD_REQUEST).send('Bad Request: Missing token') + return + } + + await logoutUser({ + collection, + req, + res: res as unknown as Response, + data: { + refreshToken, + accessToken, + }, + config, + }) + + res.send({ + message: 'Logged out', + }) + } catch (error) { + next(error) + } + }, + }, + ] +} diff --git a/src/endpoints/oauth/refresh-token.ts b/src/endpoints/oauth/refresh-token.ts index 7ba7b45..7471607 100644 --- a/src/endpoints/oauth/refresh-token.ts +++ b/src/endpoints/oauth/refresh-token.ts @@ -12,10 +12,10 @@ export const refreshToken: (config: OperationConfig) => Endpoint[] = config => { method: 'post', async handler(req, res) { try { - const { headers, payload } = req + const { payload } = req const { - refreshToken: rftoken, + refreshToken: token, clientId, clientSecret, } = req.body as { @@ -37,18 +37,6 @@ export const refreshToken: (config: OperationConfig) => Endpoint[] = config => { return } - const cookies: Array<{ name: string; value: string }> | undefined = headers.cookie - ?.split(';') - .map((cookie: string) => cookie.trim()) - .map((cookie: string) => { - const [name, value] = cookie.split('=') - return { name, value } - }) - - const token = - rftoken || - cookies?.find(cookie => cookie.name === `${payload.config.cookiePrefix}-refresh`)?.value - if (!token) { res.status(httpStatus.BAD_REQUEST).send('Bad Request: Missing refresh token') return diff --git a/src/hooks/after-logout.ts b/src/hooks/after-logout.ts deleted file mode 100644 index 4bd5eb4..0000000 --- a/src/hooks/after-logout.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { IncomingAuthType } from 'payload/dist/auth' -import type { AfterLogoutHook } from 'payload/dist/collections/config/types' -import type { Collection, PayloadRequest } from 'payload/types' - -import type { GenericUser, OperationConfig } from '../types' - -export interface Arguments { - collection: Collection - req: PayloadRequest - res?: Response - token: string -} - -export const afterLogoutHook: (config: OperationConfig) => AfterLogoutHook = - config => async args => { - const { req, res } = args - - const { headers, payload } = req - - const cookies: Array<{ name: string; value: string }> | undefined = headers.cookie - ?.split(';') - .map((cookie: string) => cookie.trim()) - .map((cookie: string) => { - const [name, value] = cookie.split('=') - return { name, value } - }) - - const token = cookies?.find( - cookie => cookie.name === `${payload.config.cookiePrefix}-refresh`, - )?.value - - if (token) { - const [sessionId, userId] = payload.decrypt(String(token)).split('::') - - const user = (await payload.findByID({ - collection: config.endpointCollection.slug, - id: userId, - depth: 0, - })) as GenericUser - - await payload.update({ - collection: config.endpointCollection.slug, - id: userId, - data: { - oAuth: { - ...user.oAuth, - sessions: [...(user.oAuth.sessions || []).filter(session => session.id !== sessionId)], - }, - }, - }) - - const collectionAuthConfig = config.endpointCollection.auth as IncomingAuthType - - res.clearCookie(`${payload.config.cookiePrefix}-refresh`, { - domain: undefined, - httpOnly: true, - path: '/', - sameSite: collectionAuthConfig.cookies?.sameSite, - secure: collectionAuthConfig.cookies?.secure, - }) - return - } - - return args // return modified operation arguments as necessary - } diff --git a/src/index.ts b/src/index.ts index fde94f3..4ec129f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,6 @@ import { oAuthEndpoints } from './endpoints/oauth' import oAuthCorsHeaders from './express/middleware/cors' import oAuthCsrf from './express/middleware/csrf' import { OAuthGroup } from './fields/oauth-group' -import { afterLogoutHook } from './hooks/after-logout' import { beforeLoginOperationHook } from './hooks/before-login' import { beforeRefreshOperationHook } from './hooks/before-refresh' import type { OperationConfig, PluginConfig } from './types' @@ -67,10 +66,6 @@ export const oAuthApps = beforeRefreshOperationHook(endpointConfig), beforeLoginOperationHook(endpointConfig), ], - afterLogout: [ - ...(collection.hooks?.afterLogout || []), - afterLogoutHook(endpointConfig), - ], } } diff --git a/src/operations/authorize/send-magiclink.ts b/src/operations/authorize/send-magiclink.ts index 8c2cda7..085f16a 100644 --- a/src/operations/authorize/send-magiclink.ts +++ b/src/operations/authorize/send-magiclink.ts @@ -88,7 +88,7 @@ async function sendOtp(incomingArgs: Arguments): Promise { ).getTime() // 2 hours const token = payload.encrypt(`${user.id}::${authCode}::${exp}::${client.id}`) - const magiclink = `${payload.config.serverURL}/api/${collectionConfig.slug}/oauth/verify?email=${user.email}&token=${token}` + const magiclink = `${payload.config.serverURL}/api/${collectionConfig.slug}/oauth/verify-magiclink?email=${user.email}&token=${token}` let html = `

We have received a login attempt with the following code:

${verificationPhrase}

@@ -99,6 +99,11 @@ async function sendOtp(incomingArgs: Arguments): Promise { let subject = 'Login verification' if (client.settings?.customizeMagiclinkEmail) { + // Clone client and remove sensitive data + const emailClient: Partial = { ...client } + delete emailClient.credentials + delete emailClient.id + const variables: Record = { email, magiclink, @@ -111,7 +116,7 @@ async function sendOtp(incomingArgs: Arguments): Promise { token, }, user, - client, + client: emailClient as Omit, })), } diff --git a/src/operations/logout/index.ts b/src/operations/logout/index.ts new file mode 100644 index 0000000..994a86e --- /dev/null +++ b/src/operations/logout/index.ts @@ -0,0 +1,82 @@ +import httpStatus from 'http-status' +import { decode } from 'jsonwebtoken' +import { APIError } from 'payload/dist/errors' +import { initTransaction } from 'payload/dist/utilities/initTransaction' +import { killTransaction } from 'payload/dist/utilities/killTransaction' +import type { Collection, PayloadRequest } from 'payload/types' + +import type { MaybeUser, OperationConfig } from '../../types' + +export interface Arguments { + collection: Collection + req: PayloadRequest + res?: Response + data: { + refreshToken?: string + accessToken?: string + } + config: OperationConfig +} + +async function logout(incomingArgs: Arguments): Promise { + let args = incomingArgs + + const { + collection: { config: collectionConfig }, + req, + req: { payload }, + data, + config, + } = args + + try { + const shouldCommit = await initTransaction(req) + const { accessToken, refreshToken } = data + + let userId: string | undefined, sessionId: string | undefined + + if (refreshToken) { + ;[sessionId, userId] = payload.decrypt(String(refreshToken)).split('::') + } + + if (accessToken) { + const decoded = decode(accessToken) as { id: string; __ses: string } | null + if (decoded) { + userId = decoded.id + sessionId = decoded.__ses + } + } + + if (!userId || !sessionId) { + throw new APIError('Bad Request: Invalid token', httpStatus.BAD_REQUEST) + } + + const user = (await payload.db.findOne({ + collection: collectionConfig.slug, + req, + where: { id: { equals: userId } }, + })) as MaybeUser + + if (!user) { + throw new APIError('User not Found', httpStatus.NOT_FOUND) + } + + await payload.update({ + collection: config.endpointCollection.slug, + id: userId, + data: { + oAuth: { + ...user.oAuth, + sessions: [...(user.oAuth.sessions || []).filter(session => session.id !== sessionId)], + }, + }, + }) + + if (shouldCommit && req.transactionID) await payload.db.commitTransaction?.(req.transactionID) + } catch (error: unknown) { + await killTransaction(req) + throw error + } +} + +export default logout diff --git a/src/operations/verify/code.ts b/src/operations/verify/code.ts index cc36fee..e8a912c 100644 --- a/src/operations/verify/code.ts +++ b/src/operations/verify/code.ts @@ -69,11 +69,11 @@ async function verifyCode(incomingArgs: Arguments): Promise { const linkIndex = magiclinks.findIndex((o: { code: string }) => o.code === code) - if (linkIndex === -1) { + if (linkIndex === -1 || magiclinks[linkIndex].claimed) { throw new APIError('Invalid token', httpStatus.UNAUTHORIZED) } - const remainingMagiclinks = magiclinks.filter((o: { code: string }) => o.code !== code) + magiclinks[linkIndex].claimed = true user = (await payload.update({ id: user.id, @@ -82,7 +82,7 @@ async function verifyCode(incomingArgs: Arguments): Promise { data: { oAuth: { ...user.oAuth, - _magiclinks: JSON.stringify(remainingMagiclinks), + _magiclinks: JSON.stringify(magiclinks), }, }, })) as GenericUser diff --git a/src/operations/verify/magiclink.ts b/src/operations/verify/magiclink.ts index 67bbc49..c823fab 100644 --- a/src/operations/verify/magiclink.ts +++ b/src/operations/verify/magiclink.ts @@ -63,6 +63,15 @@ async function verifyMagiclink(incomingArgs: Arguments): Promise { (o: { exp: number }) => o.exp > Date.now(), // Remove expired magiclinks ) + // Check if the token already has been verified once before. + const linkIndex = magiclinks.findIndex((o: { code: string }) => o.code === authCode) + + // If link exists already, throw error + if (linkIndex !== -1) { + // Token has already been used + throw new APIError('Unauthorized: Invalid token', httpStatus.UNAUTHORIZED) + } + const client = (await payload.db.findOne({ collection: 'oAuthApps', req, @@ -86,6 +95,7 @@ async function verifyMagiclink(incomingArgs: Arguments): Promise { exp, code: authCode, app: client.id, + claimed: false, }, ]), }, From eb7abb6895f6401d9a966d55f3bbfc5fab3c1735 Mon Sep 17 00:00:00 2001 From: Corfitz Date: Sat, 14 Oct 2023 19:28:20 +0200 Subject: [PATCH 11/12] Updating changeset --- .changeset/eighty-pigs-bathe.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/eighty-pigs-bathe.md diff --git a/.changeset/eighty-pigs-bathe.md b/.changeset/eighty-pigs-bathe.md new file mode 100644 index 0000000..2b16093 --- /dev/null +++ b/.changeset/eighty-pigs-bathe.md @@ -0,0 +1,5 @@ +--- +"@imcorfitz/payload-plugin-oauth-apps": minor +--- + +Added logout route and operation From f4eb9e5ebb3afd5a20d2987e6e991a8e42680cac Mon Sep 17 00:00:00 2001 From: Corfitz Date: Sun, 15 Oct 2023 06:57:05 +0200 Subject: [PATCH 12/12] Adding source for txtgen package --- src/lib/txtgen/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/lib/txtgen/index.ts b/src/lib/txtgen/index.ts index d3899a3..597d8ee 100644 --- a/src/lib/txtgen/index.ts +++ b/src/lib/txtgen/index.ts @@ -1,3 +1,8 @@ +/** + * This is copied from the txtgen package, which caused errors when trying to import it. + * https://github.com/ndaidong/txtgen + */ + // main import { phrases, sentenceTemplates } from './sample'