diff --git a/dev/.env.example b/dev/.env.example index 9f07544..b9f1bc5 100644 --- a/dev/.env.example +++ b/dev/.env.example @@ -27,3 +27,19 @@ NEXT_PUBLIC_URL=http://localhost:3000 # optional: google oauth2 client secret, not activated if not set # ZITADEL_CLIENT_SECRET= + +################################################################################ +# Apple OAuth Config +################################################################################ +# Optional: Apple OAuth2 Client ID (Services ID), not activated if not set +# APPLE_CLIENT_ID=com.your.app.id + +# Optional: Apple OAuth2 Client Secret (Generated from Apple Developer Portal) +# APPLE_CLIENT_SECRET=your-generated-secret + +# Note: For Apple OAuth, you need to: +# 1. Create an App ID in Apple Developer Portal +# 2. Create a Services ID +# 3. Configure domain association +# 4. Generate a Client Secret +# See: https://developer.apple.com/sign-in-with-apple/get-started/ diff --git a/examples/apple.ts b/examples/apple.ts new file mode 100644 index 0000000..f440c0f --- /dev/null +++ b/examples/apple.ts @@ -0,0 +1,100 @@ +import { PayloadRequest } from "payload"; +import { OAuth2Plugin } from "../src/index"; + +//////////////////////////////////////////////////////////////////////////////// +// Apple OAuth +//////////////////////////////////////////////////////////////////////////////// +export const appleOAuth = OAuth2Plugin({ + enabled: + typeof process.env.APPLE_CLIENT_ID === "string" && + typeof process.env.APPLE_CLIENT_SECRET === "string", + strategyName: "apple", + useEmailAsIdentity: true, + serverURL: process.env.NEXT_PUBLIC_URL || "http://localhost:3000", + clientId: process.env.APPLE_CLIENT_ID || "", + clientSecret: process.env.APPLE_CLIENT_SECRET || "", + authorizePath: "/oauth/apple", + callbackPath: "/oauth/apple/callback", + authCollection: "users", + tokenEndpoint: "https://appleid.apple.com/auth/token", + scopes: ["name", "email"], + providerAuthorizationUrl: "https://appleid.apple.com/auth/authorize", + // Required for Apple OAuth when requesting name or email scopes + responseMode: "form_post", + getUserInfo: async (accessToken: string, req: PayloadRequest) => { + try { + // For Apple, the ID token is a JWT that contains user info + const tokenParts = accessToken.split("."); + if (tokenParts.length !== 3) { + throw new Error("Invalid ID token format"); + } + + // Decode the base64 payload + const payload = JSON.parse(Buffer.from(tokenParts[1], "base64").toString()); + + if (!payload.email) { + throw new Error("No email found in payload"); + } + + return { + email: payload.email, + sub: payload.sub, + // Apple provides name only on first login + firstName: payload.given_name || "", + lastName: payload.family_name || "", + }; + } catch (error) { + req.payload.logger.error("Error parsing Apple token:", error); + throw error; + } + }, + getToken: async (code: string, req: PayloadRequest) => { + try { + const redirectUri = `${process.env.NEXT_PUBLIC_URL || "http://localhost:3000"}/api/users/oauth/apple/callback`; + + // Make the token exchange request + const params = new URLSearchParams({ + client_id: process.env.APPLE_CLIENT_ID || "", + client_secret: process.env.APPLE_CLIENT_SECRET || "", + code: code, + grant_type: "authorization_code", + redirect_uri: redirectUri, + }); + + const response = await fetch("https://appleid.apple.com/auth/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params.toString(), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + const tokenResponse = await response.json(); + + // Return the id_token which contains the user info + return tokenResponse.id_token; + } catch (error) { + req.payload.logger.error("Error in getToken:", error); + throw error; + } + }, + successRedirect: (req) => { + // Check user roles to determine redirect + const user = req.user; + if (user && Array.isArray(user.roles)) { + if (user.roles.includes("admin")) { + return "/admin"; + } + } + return "/"; // Default redirect for customers + }, + failureRedirect: (req, err) => { + req.payload.logger.error(err); + return "/login?error=apple-auth-failed"; + }, +}); diff --git a/package.json b/package.json index 576e8f3..7461c27 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "type": "module", "homepage:": "https://payloadcms.com", "repository": "https://github.com/WilsonLe/payload-oauth2", - "description": "OAuth2 plugin for Payload CMS", + "description": "OAuth2 plugin for Payload CMS with Apple Sign In support", "main": "dist/index.js", "types": "dist/index.d.ts", "keywords": [ @@ -14,7 +14,8 @@ "typescript", "react", "oauth2", - "payload-plugin" + "payload-plugin", + "apple-sign-in" ], "files": [ "dist" diff --git a/src/authorize-endpoint.ts b/src/authorize-endpoint.ts index 168ff0a..fc722b0 100644 --- a/src/authorize-endpoint.ts +++ b/src/authorize-endpoint.ts @@ -1,5 +1,5 @@ -import { Endpoint } from "payload"; -import { PluginTypes } from "./types"; +import type { Endpoint } from "payload"; +import type { PluginTypes } from "./types"; export const createAuthorizeEndpoint = ( pluginOptions: PluginTypes, @@ -20,7 +20,15 @@ export const createAuthorizeEndpoint = ( const responseType = "code"; const accessType = "offline"; - const authorizeUrl = `${pluginOptions.providerAuthorizationUrl}?client_id=${clientId}&redirect_uri=${redirectUri}&scope=${scope}&response_type=${responseType}&access_type=${accessType}${prompt}`; + + // Add response_mode if specified (required for Apple OAuth with name/email scopes) + const responseMode = pluginOptions.responseMode + ? `&response_mode=${pluginOptions.responseMode}` + : ""; + + const authorizeUrl = `${ + pluginOptions.providerAuthorizationUrl + }?client_id=${clientId}&redirect_uri=${redirectUri}&scope=${scope}&response_type=${responseType}&access_type=${accessType}${prompt}${responseMode}`; return Response.redirect(authorizeUrl); }, diff --git a/src/callback-endpoint.ts b/src/callback-endpoint.ts index 1362fe9..3f5c488 100644 --- a/src/callback-endpoint.ts +++ b/src/callback-endpoint.ts @@ -1,26 +1,51 @@ -import crypto from "crypto"; +import crypto from "node:crypto"; import { SignJWT } from "jose"; -import { +import type { CollectionSlug, Endpoint, - generatePayloadCookie, - getFieldsToSign, + PayloadHandler, + PayloadRequest, + RequestContext, + User, } from "payload"; +import { generatePayloadCookie, getFieldsToSign } from "payload"; import { defaultGetToken } from "./default-get-token"; -import { PluginTypes } from "./types"; +import type { PluginTypes } from "./types"; export const createCallbackEndpoint = ( pluginOptions: PluginTypes, -): Endpoint => ({ - method: "get", - path: pluginOptions.callbackPath || "/oauth/callback", - handler: async (req) => { +): Endpoint => { + const handler: PayloadHandler = async (req: PayloadRequest) => { try { - const { code } = req.query; - if (typeof code !== "string") + // 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: string | undefined; + + 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 as unknown as Request).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 as { code?: string }).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 in query string: ${JSON.stringify(req.query)}`, + `Code not found in ${req.method === "POST" ? "body" : "query"}: ${ + req.method === "POST" + ? "form-data" + : JSON.stringify(req.query) + }`, ); + } // ///////////////////////////////////// // shorthands @@ -37,18 +62,17 @@ export const createCallbackEndpoint = ( // ///////////////////////////////////// // beforeOperation - Collection // ///////////////////////////////////// - // Not implemented + // Not implemented - reserved for future use // ///////////////////////////////////// - // obtain access token + // obtain access token or id_token // ///////////////////////////////////// - - let access_token: string; + let token: string; if (pluginOptions.getToken) { - access_token = await pluginOptions.getToken(code, req); + token = await pluginOptions.getToken(code, req); } else { - access_token = await defaultGetToken( + token = await defaultGetToken( pluginOptions.tokenEndpoint, pluginOptions.clientId, pluginOptions.clientSecret, @@ -57,19 +81,21 @@ export const createCallbackEndpoint = ( ); } - if (typeof access_token !== "string") - throw new Error(`No access token: ${access_token}`); + if (typeof token !== "string") { + throw new Error(`Invalid token response: ${token}`); + } // ///////////////////////////////////// // get user info // ///////////////////////////////////// - const userInfo = await pluginOptions.getUserInfo(access_token, req); + const userInfo = await pluginOptions.getUserInfo(token, req); // ///////////////////////////////////// // ensure user exists // ///////////////////////////////////// - let existingUser: any; + let existingUser: { docs: Array> }; if (useEmailAsIdentity) { + // Use email as the unique identifier existingUser = await req.payload.find({ req, collection: authCollection, @@ -78,6 +104,7 @@ export const createCallbackEndpoint = ( limit: 1, }); } else { + // Use provider's sub field as the unique identifier existingUser = await req.payload.find({ req, collection: authCollection, @@ -87,43 +114,53 @@ export const createCallbackEndpoint = ( }); } - let user: any; - if (existingUser.docs.length === 0) { - user = await req.payload.create({ + let user = existingUser.docs[0] as User; + if (!user) { + // Create new user if they don't exist + const result = await req.payload.create({ req, collection: authCollection, data: { ...userInfo, - // Stuff breaks when password is missing + collection: authCollection, + // Generate secure random password for OAuth users password: crypto.randomBytes(32).toString("hex"), }, showHiddenFields: true, }); + user = result as User; } else { - user = await req.payload.update({ + // Update existing user with latest info from provider + const result = await req.payload.update({ req, collection: authCollection, - id: existingUser.docs[0].id, - data: userInfo, + id: user.id, + data: { + ...userInfo, + collection: authCollection, + }, showHiddenFields: true, }); + user = result as User; } // ///////////////////////////////////// // beforeLogin - Collection // ///////////////////////////////////// - await collectionConfig.hooks.beforeLogin.reduce( async (priorHook, hook) => { await priorHook; - user = - (await hook({ - collection: collectionConfig, - context: req.context, - req, - user, - })) || user; + const hookResult = await hook({ + collection: collectionConfig, + context: req.context || {} as RequestContext, + req, + user, + }); + + if (hookResult) { + user = hookResult as User; + } }, Promise.resolve(), ); @@ -133,11 +170,11 @@ export const createCallbackEndpoint = ( // ///////////////////////////////////// const fieldsToSign = getFieldsToSign({ collectionConfig, - email: user.email, + email: user.email || "", user, }); - const token = await new SignJWT(fieldsToSign) + const jwtToken = await new SignJWT(fieldsToSign) .setProtectedHeader({ alg: "HS256" }) .setExpirationTime(`${collectionConfig.auth.tokenExpiration} secs`) .sign(new TextEncoder().encode(req.payload.secret)); @@ -146,19 +183,21 @@ export const createCallbackEndpoint = ( // ///////////////////////////////////// // afterLogin - Collection // ///////////////////////////////////// - await collectionConfig.hooks.afterLogin.reduce( async (priorHook, hook) => { await priorHook; - user = - (await hook({ - collection: collectionConfig, - context: req.context, - req, - token, - user, - })) || user; + const hookResult = await hook({ + collection: collectionConfig, + context: req.context || {} as RequestContext, + req, + token: jwtToken, + user, + }); + + if (hookResult) { + user = hookResult as User; + } }, Promise.resolve(), ); @@ -166,7 +205,7 @@ export const createCallbackEndpoint = ( // ///////////////////////////////////// // afterRead - Fields // ///////////////////////////////////// - // Not implemented + // Not implemented - reserved for future use // ///////////////////////////////////// // generate and set cookie @@ -174,7 +213,7 @@ export const createCallbackEndpoint = ( const cookie = generatePayloadCookie({ collectionAuthConfig: collectionConfig.auth, cookiePrefix: payloadConfig.cookiePrefix, - token, + token: jwtToken, }); // ///////////////////////////////////// @@ -199,5 +238,13 @@ export const createCallbackEndpoint = ( status: 302, }); } - }, -}); + }; + + return { + // We use GET as the primary method since that's what most OAuth providers use + // The handler itself will accept both GET and POST internally + method: "get", + path: pluginOptions.callbackPath || "/oauth/callback", + handler, + }; +}; diff --git a/src/types.ts b/src/types.ts index 1e1eeef..4921ba2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import { PayloadRequest } from "payload"; +import type { PayloadRequest } from "payload"; export interface PluginTypes { /** @@ -27,6 +27,23 @@ export interface PluginTypes { */ serverURL: string; + /** + * Response mode for the OAuth provider. + * Specifies how the authorization response should be returned. + * + * Required for Apple OAuth when requesting name or email scopes. + * Apple requires 'form_post' when requesting these scopes to ensure + * secure transmission of user data. + * + * Common values: + * - 'form_post': Response parameters encoded in POST body (required for Apple with name/email scope) + * - 'query': Response parameters encoded in URL query string (default for most providers) + * - 'fragment': Response parameters encoded in URL fragment + * + * @default undefined + */ + responseMode?: string; + /** * Slug of the collection where user information will be stored * @default "users" @@ -54,6 +71,7 @@ export interface PluginTypes { * URL to the token endpoint. * The following are token endpoints for popular OAuth providers: * - Google: https://oauth2.googleapis.com/token + * - Apple: https://appleid.apple.com/auth/token */ tokenEndpoint: string; @@ -62,6 +80,7 @@ export interface PluginTypes { * Must not have trailing slash. * The following are authorization endpoints for popular OAuth providers: * - Google: https://accounts.google.com/o/oauth2/v2/auth + * - Apple: https://appleid.apple.com/auth/authorize */ providerAuthorizationUrl: string; @@ -69,10 +88,14 @@ export interface PluginTypes { * Function to get user information from the OAuth provider. * This function should return a promise that resolves to the user * information that will be stored in database. - * @param accessToken Access token obtained from OAuth provider + * + * For providers that return user info in the ID token (like Apple), + * you can decode the JWT token here to extract user information. + * + * @param accessToken Access token or ID token obtained from OAuth provider * @param req PayloadRequest object */ - getUserInfo: (accessToken: string, req: PayloadRequest) => Promise | any; + getUserInfo: (accessToken: string, req: PayloadRequest) => Promise>; /** * Scope for the OAuth provider. @@ -81,6 +104,9 @@ export interface PluginTypes { * + https://www.googleapis.com/auth/userinfo.email * + https://www.googleapis.com/auth/userinfo.profile * + openid + * - Apple: + * + name + * + email */ scopes: string[]; @@ -122,7 +148,7 @@ export interface PluginTypes { * @param code Code obtained from the OAuth provider, used to exchange for access token * @param req PayloadRequest object */ - getToken?: (code: string, req: PayloadRequest) => string | Promise; + getToken?: (code: string, req: PayloadRequest) => Promise; /** * Redirect users after successful login.