UNPKG

@omnipixel/payload-oauth2-plus

Version:

Enhanced OAuth2 plugin for Payload CMS with robust Apple OAuth support

211 lines 9.14 kB
import crypto from "node:crypto"; import { SignJWT } from "jose"; import { generatePayloadCookie, getFieldsToSign } from "payload"; import { defaultGetToken } from "./default-get-token"; export const createCallbackEndpoint = (pluginOptions) => { const handler = async (req) => { try { // Handle authorization code from both GET query params and POST body // This enables support for Apple's form_post response mode while maintaining // compatibility with traditional OAuth2 GET responses let code; if (req.method === "POST") { // Handle form data from POST request (used by Apple OAuth) const contentType = req.headers.get("content-type"); if (contentType?.includes("application/x-www-form-urlencoded")) { const text = await req.text(); const formData = new URLSearchParams(text); code = formData.get("code") || undefined; } } else if (req.method === "GET") { // Handle query parameters (used by Google OAuth) code = typeof req.query === "object" && req.query ? req.query.code : undefined; } // Improved error handling to clearly indicate whether we're missing the code // from POST body (Apple OAuth) or GET query parameters (standard OAuth) if (typeof code !== "string") { throw new Error(`Code not found in ${req.method === "POST" ? "body" : "query"}: ${req.method === "POST" ? "form-data" : JSON.stringify(req.query)}`); } // ///////////////////////////////////// // shorthands // ///////////////////////////////////// const subFieldName = pluginOptions.subFieldName || "sub"; const authCollection = (pluginOptions.authCollection || "users"); const collectionConfig = req.payload.collections[authCollection].config; const payloadConfig = req.payload.config; const callbackPath = pluginOptions.callbackPath || "/oauth/callback"; const redirectUri = `${pluginOptions.serverURL}/api/${authCollection}${callbackPath}`; const useEmailAsIdentity = pluginOptions.useEmailAsIdentity ?? false; // ///////////////////////////////////// // beforeOperation - Collection // ///////////////////////////////////// // Not implemented - reserved for future use // ///////////////////////////////////// // obtain access token or id_token // ///////////////////////////////////// let token; if (pluginOptions.getToken) { token = await pluginOptions.getToken(code, req); } else { token = await defaultGetToken(pluginOptions.tokenEndpoint, pluginOptions.clientId, pluginOptions.clientSecret, redirectUri, code); } if (typeof token !== "string") { throw new Error(`Invalid token response: ${token}`); } // ///////////////////////////////////// // get user info // ///////////////////////////////////// const userInfo = await pluginOptions.getUserInfo(token, req); // ///////////////////////////////////// // ensure user exists // ///////////////////////////////////// let existingUser; if (useEmailAsIdentity) { // Use email as the unique identifier existingUser = await req.payload.find({ req, collection: authCollection, where: { email: { equals: userInfo.email } }, showHiddenFields: true, limit: 1, }); } else { // Use provider's sub field as the unique identifier existingUser = await req.payload.find({ req, collection: authCollection, where: { [subFieldName]: { equals: userInfo[subFieldName] } }, showHiddenFields: true, limit: 1, }); } let user = existingUser.docs[0]; if (!user) { // Create new user if they don't exist const result = await req.payload.create({ req, collection: authCollection, data: { ...userInfo, collection: authCollection, // Generate secure random password for OAuth users password: crypto.randomBytes(32).toString("hex"), }, showHiddenFields: true, }); user = result; } else { // Update existing user with latest info from provider const result = await req.payload.update({ req, collection: authCollection, id: user.id, data: { ...userInfo, collection: authCollection, }, showHiddenFields: true, }); user = result; } // ///////////////////////////////////// // beforeLogin - Collection // ///////////////////////////////////// await collectionConfig.hooks.beforeLogin.reduce(async (priorHook, hook) => { await priorHook; const hookResult = await hook({ collection: collectionConfig, context: req.context || {}, req, user, }); if (hookResult) { user = hookResult; } }, Promise.resolve()); // ///////////////////////////////////// // login - OAuth2 // ///////////////////////////////////// const fieldsToSign = getFieldsToSign({ collectionConfig, email: user.email || "", user, }); const jwtToken = await new SignJWT(fieldsToSign) .setProtectedHeader({ alg: "HS256" }) .setExpirationTime(`${collectionConfig.auth.tokenExpiration} secs`) .sign(new TextEncoder().encode(req.payload.secret)); req.user = user; // ///////////////////////////////////// // afterLogin - Collection // ///////////////////////////////////// await collectionConfig.hooks.afterLogin.reduce(async (priorHook, hook) => { await priorHook; const hookResult = await hook({ collection: collectionConfig, context: req.context || {}, req, token: jwtToken, user, }); if (hookResult) { user = hookResult; } }, Promise.resolve()); // ///////////////////////////////////// // afterRead - Fields // ///////////////////////////////////// // Not implemented - reserved for future use // ///////////////////////////////////// // generate and set cookie // ///////////////////////////////////// const cookie = generatePayloadCookie({ collectionAuthConfig: collectionConfig.auth, cookiePrefix: payloadConfig.cookiePrefix, token: jwtToken, }); // ///////////////////////////////////// // success redirect // ///////////////////////////////////// return new Response(null, { headers: { "Set-Cookie": cookie, Location: await pluginOptions.successRedirect(req), }, status: 302, }); } catch (error) { // ///////////////////////////////////// // failure redirect // ///////////////////////////////////// return new Response(null, { headers: { "Content-Type": "application/json", Location: await pluginOptions.failureRedirect(req, error), }, status: 302, }); } }; return [ { method: "get", path: pluginOptions.callbackPath || "/oauth/callback", handler, }, { method: "post", path: pluginOptions.callbackPath || "/oauth/callback", handler, }, ]; }; //# sourceMappingURL=callback-endpoint.js.map