diff --git a/.gitignore b/.gitignore
index 4c4fc01..85061cf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -249,3 +249,4 @@ dist
dev/src/uploads
*.db*
+*.p8*
diff --git a/bin/generate-apple-client-secret.mjs b/bin/generate-apple-client-secret.mjs
new file mode 100644
index 0000000..b49e30f
--- /dev/null
+++ b/bin/generate-apple-client-secret.mjs
@@ -0,0 +1,57 @@
+#!/usr/bin/env node
+
+import fs from "fs";
+import { hideBin } from "yargs/helpers";
+import yargs from "yargs/yargs";
+import { generateAppleClientSecret } from "../dist/generate-apple-client-secret.js";
+
+(async () => {
+ const argv = yargs(hideBin(process.argv))
+ .option("team-id", {
+ type: "string",
+ demandOption: true,
+ describe: "Apple Developer Team ID",
+ })
+ .option("client-id", {
+ type: "string",
+ demandOption: true,
+ describe: "Apple Service Client ID",
+ })
+ .option("key-id", {
+ type: "string",
+ demandOption: true,
+ describe: "Apple Key ID",
+ })
+ .option("private-key-path", {
+ type: "string",
+ demandOption: true,
+ describe: "Path to .p8 private key file",
+ })
+ .option("exp", {
+ type: "number",
+ describe: "Expiration time (seconds since epoch)",
+ })
+ .help().argv;
+
+ let authKeyContent;
+ try {
+ authKeyContent = fs.readFileSync(argv["private-key-path"], "utf8");
+ } catch (err) {
+ console.error("Failed to read private key file:", err.message);
+ process.exit(1);
+ }
+
+ try {
+ const jwt = await generateAppleClientSecret({
+ teamId: argv["team-id"],
+ clientId: argv["client-id"],
+ keyId: argv["key-id"],
+ authKeyContent,
+ exp: argv.exp,
+ });
+ console.log(jwt);
+ } catch (err) {
+ console.error("Failed to generate client secret:", err.message);
+ process.exit(1);
+ }
+})();
diff --git a/dev/.env.example b/dev/.env.example
index 20a34fd..475d8eb 100644
--- a/dev/.env.example
+++ b/dev/.env.example
@@ -65,6 +65,8 @@ NEXT_PUBLIC_URL=http://localhost:3000
# Note: For Apple OAuth, you need to:
# 1. Create an App ID in Apple Developer Portal
+# Quick link: https://developer.apple.com/account/resources/identifiers/bundleId/add/bundle
+# Long instruction
# 2. Create a Services ID
# 3. Configure domain association
# 4. Generate a Client Secret
diff --git a/dev/src/app/(payload)/admin/importMap.js b/dev/src/app/(payload)/admin/importMap.js
index f083510..2c66414 100644
--- a/dev/src/app/(payload)/admin/importMap.js
+++ b/dev/src/app/(payload)/admin/importMap.js
@@ -1,9 +1,11 @@
import { GoogleOAuthLoginButton as GoogleOAuthLoginButton_143f92647bcb7528bfe1082a22fc4d4e } from 'src/components/GoogleOAuthLoginButton'
import { ZitadelOAuthLoginButton as ZitadelOAuthLoginButton_2b344d0256ae0172631ef421761722bb } from 'src/components/ZitadelOAuthLoginButton'
+import { AppleOAuthLoginButton as AppleOAuthLoginButton_c5ad5bdad6b9933330a83e7b0bcc0110 } from 'src/components/AppleOAuthLoginButton'
import { MicrosoftEntraIdOAuthLoginButton as MicrosoftEntraIdOAuthLoginButton_d181f47f7889616c8bdc074b0a96538d } from 'src/components/MicrosoftEntraIdOAuthLoginButton'
export const importMap = {
"src/components/GoogleOAuthLoginButton#GoogleOAuthLoginButton": GoogleOAuthLoginButton_143f92647bcb7528bfe1082a22fc4d4e,
"src/components/ZitadelOAuthLoginButton#ZitadelOAuthLoginButton": ZitadelOAuthLoginButton_2b344d0256ae0172631ef421761722bb,
+ "src/components/AppleOAuthLoginButton#AppleOAuthLoginButton": AppleOAuthLoginButton_c5ad5bdad6b9933330a83e7b0bcc0110,
"src/components/MicrosoftEntraIdOAuthLoginButton#MicrosoftEntraIdOAuthLoginButton": MicrosoftEntraIdOAuthLoginButton_d181f47f7889616c8bdc074b0a96538d
}
diff --git a/dev/src/components/AppleOAuthLoginButton.tsx b/dev/src/components/AppleOAuthLoginButton.tsx
new file mode 100644
index 0000000..28522d3
--- /dev/null
+++ b/dev/src/components/AppleOAuthLoginButton.tsx
@@ -0,0 +1,11 @@
+"use client";
+export const AppleOAuthLoginButton: React.FC = () => (
+
+
+
+);
diff --git a/dev/src/payload.config.ts b/dev/src/payload.config.ts
index 64a08fd..8cb88fa 100644
--- a/dev/src/payload.config.ts
+++ b/dev/src/payload.config.ts
@@ -4,6 +4,7 @@ import path from "path";
import { buildConfig } from "payload";
import sharp from "sharp";
import { fileURLToPath } from "url";
+import { appleOAuth } from "../../examples/apple";
import { googleOAuth } from "../../examples/google";
import { microsoftEntraIdOAuth } from "../../examples/microsoft-entra-id";
import { zitadelOAuth } from "../../examples/zitadel";
@@ -24,6 +25,7 @@ export default buildConfig({
afterLogin: [
"src/components/GoogleOAuthLoginButton#GoogleOAuthLoginButton",
"src/components/ZitadelOAuthLoginButton#ZitadelOAuthLoginButton",
+ "src/components/AppleOAuthLoginButton#AppleOAuthLoginButton",
"src/components/MicrosoftEntraIdOAuthLoginButton#MicrosoftEntraIdOAuthLoginButton",
],
},
@@ -37,6 +39,6 @@ export default buildConfig({
editor: lexicalEditor({}),
collections: [Users, LocalUsers],
typescript: { outputFile: path.resolve(dirname, "payload-types.ts") },
- plugins: [googleOAuth, zitadelOAuth, microsoftEntraIdOAuth],
+ plugins: [googleOAuth, zitadelOAuth, microsoftEntraIdOAuth, appleOAuth],
sharp,
});
diff --git a/examples/apple.ts b/examples/apple.ts
index 12b2f08..96ed6b0 100644
--- a/examples/apple.ts
+++ b/examples/apple.ts
@@ -1,13 +1,85 @@
import { PayloadRequest } from "payload";
import { OAuth2Plugin } from "../src/index";
+/**
+To setup Apple OAuth, refer to official documentation:
+https://developer.apple.com/sign-in-with-apple/get-started/
+
+However, the process is quite complex and requires several steps, so here's a quick start guide:
+
+To setup Apple OAuth in Payload CMS, you need the following 4 values:
+1. APPLE_CLIENT_ID: Your Service ID from the Apple Developer portal (e.g. com.example.myapp)
+2. APPLE_CLIENT_SECRET: Your client secret, which is a JWT signed with your private key. This requires value 3 and 4 to generate:
+3. APPLE_KEY_ID: The Key ID from the Apple Developer portal
+4. APPLE_TEAM_ID: Your Apple Developer Team ID, which can be found in the Apple Developer portal.
+
+Prerequisites: Have a valid Apple Developer account and access to the Apple Developer portal.
+
+1. Create an App ID in the Apple Developer portal
+ - Quick links: https://developer.apple.com/account/resources/identifiers/bundleId/add/bundle
+ - Step by step instruction:
+ > https://developer.apple.com/account
+ > Certificates, IDs & Profiles
+ > Identifiers
+ > Create new identifiders
+ > Select App IDs
+ > Select App
+ > Arbitrary description, explicit bundle ID (e.g. com.example.myapp)
+ > Capabilities: Enable Sign In with Apple > Save (ignore Server-to-Server Notification Endpoint)
+ > Continue/Register
+2. Create a service ID in the Apple Developer portal
+ - Quick links: https://developer.apple.com/account/resources/identifiers/serviceId/add
+ - Step by step instruction:
+ > https://developer.apple.com/account
+ > Certificates, IDs & Profiles
+ > Identifiers
+ > Create new identifiders
+ > Select Service IDs
+ > Arbitrary description, identifier (e.g. com.example.myapp.si) - IMPORTANT, I have found that this must be a subdomain of your app's bundle ID, notice the ".si" suffix.
+ > Continue/Register
+ > Value (1) APPLE_CLIENT_ID should be the identifier you just created (e.g. com.example.myapp.si)
+3. Create a new key in the Apple Developer portal
+ - Quick links: https://developer.apple.com/account/resources/authkeys/add
+ - Step by step instruction:
+ > https://developer.apple.com/account
+ > Certificates, IDs & Profiles
+ > Keys
+ > Create new key
+ > Arbitrary key name and key usage description.
+ > Enable Sign In with Apple
+ > Configure
+ > Select the App ID you created in step 1
+ > Continue/Register
+ > Download the key file, which is a .p8 file. This file contains your private key.
+ > Value (3) APPLE_KEY_ID is the Key ID from the key you just created.
+4. Obtain your Apple Developer Team ID
+ > https://developer.apple.com/account
+ > Membership details
+ > Your Team ID is listed there, this should be value (4) APPLE_TEAM_ID.
+5. Based on value (3) APPLE_KEY_ID, value (4) APPLE_TEAM_ID and the private key file you downloaded in step 3, generate value (2) APPLE_CLIENT_SECRET by running:
+```sh
+pnpm payload-oauth2:generate-apple-client-secret --team-id 4659F6UUC3 --client-id com.example.app.sso --key-id XXXXXXXXXX --private-key-path AuthKey_XXXXXXXXXX.p8
+```
+
+In the example below:
+- `process.env.APPLE_CLIENT_ID` is (1) APPLE_CLIENT_ID
+- `process.env.APPLE_CLIENT_SECRET` is (2) APPLE_CLIENT_SECRET,
+
+Dev Note:
+- I consistently got an `invalid_client` error when redirecting to `https://appleid.apple.com/auth/authorize`. I noticed that newly generated keys took 2 days for it to go into effect. After waiting for 2 days, the error went away.
+- For web, I noticed that service id works if it is a subdomain of the app's bundle id. For example, if your app's bundle id is `com.example.myapp`, then your service id must be something like `com.example.myapp.sso`. I tried to use a service id that is not a subdomain of the app's bundle id, I got an `invalid_client` error when redirecting to `https://appleid.apple.com/auth/authorize`.
+ */
////////////////////////////////////////////////////////////////////////////////
// Apple OAuth
////////////////////////////////////////////////////////////////////////////////
+
export const appleOAuth = OAuth2Plugin({
enabled:
typeof process.env.APPLE_CLIENT_ID === "string" &&
- typeof process.env.APPLE_CLIENT_SECRET === "string",
+ typeof process.env.APPLE_TEAM_ID === "string" &&
+ typeof process.env.APPLE_KEY_ID === "string" &&
+ (typeof process.env.APPLE_CLIENT_SECRET === "string" ||
+ typeof process.env.APPLE_CLIENT_AUTH_KEY_CONTENT === "string"),
strategyName: "apple",
useEmailAsIdentity: true,
serverURL: process.env.NEXT_PUBLIC_URL || "http://localhost:3000",
@@ -86,17 +158,10 @@ export const appleOAuth = OAuth2Plugin({
}
},
successRedirect: (req: PayloadRequest, token?: string) => {
- // 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
+ return "/admin";
},
failureRedirect: (req, err) => {
req.payload.logger.error(err);
- return "/login?error=apple-auth-failed";
+ return `/admin/login?error=${JSON.stringify(err)}`;
},
});
diff --git a/package.json b/package.json
index 5ffb72d..e7691fc 100644
--- a/package.json
+++ b/package.json
@@ -36,7 +36,11 @@
"reinstall": "cross-env NODE_OPTIONS=--no-deprecation rimraf node_modules && rimraf pnpm-lock.yaml && pnpm --ignore-workspace install",
"clean": "rimraf dist && rimraf dev/.next",
"prepublishOnly": "pnpm clean && pnpm build",
- "prepare": "tsc"
+ "prepare": "tsc",
+ "payload-oauth2:generate-apple-client-secret": "node ./bin/generate-apple-client-secret.mjs"
+ },
+ "bin": {
+ "payload-oauth2:generate-apple-client-secret": "./bin/generate-apple-client-secret.mjs"
},
"author": "wilsonle2907@gmail.com",
"license": "MIT",
@@ -76,7 +80,8 @@
"rimraf": "5.0.7",
"sharp": "0.33.4",
"tree-kill": "^1.2.2",
- "typescript": "5.4.5"
+ "typescript": "5.4.5",
+ "yargs": "17.7.2"
},
"packageManager": "pnpm@10.8.0+sha512.0e82714d1b5b43c74610193cb20734897c1d00de89d0e18420aebc5977fa13d780a9cb05734624e81ebd81cc876cd464794850641c48b9544326b5622ca29971"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 40f5b2c..721c598 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -107,6 +107,9 @@ importers:
typescript:
specifier: 5.4.5
version: 5.4.5
+ yargs:
+ specifier: 17.7.2
+ version: 17.7.2
packages:
@@ -1264,12 +1267,10 @@ packages:
'@libsql/darwin-arm64@0.4.6':
resolution: {integrity: sha512-45i604CJ2Lubbg7NqtDodjarF6VgST8rS5R8xB++MoRqixtDns9PZ6tocT9pRJDWuTWEiy2sjthPOFWMKwYAsg==}
- cpu: [arm64]
os: [darwin]
'@libsql/darwin-x64@0.4.6':
resolution: {integrity: sha512-dRKliflhfr5zOPSNgNJ6C2nZDd4YA8bTXF3MUNqNkcxQ8BffaH9uUwL9kMq89LkFIZQHcyP75bBgZctxfJ/H5Q==}
- cpu: [x64]
os: [darwin]
'@libsql/hrana-client@0.7.0':
@@ -1284,22 +1285,18 @@ packages:
'@libsql/linux-arm64-gnu@0.4.6':
resolution: {integrity: sha512-DMPavVyY6vYPAYcQR1iOotHszg+5xSjHSg6F9kNecPX0KKdGq84zuPJmORfKOPtaWvzPewNFdML/e+s1fu09XQ==}
- cpu: [arm64]
os: [linux]
'@libsql/linux-arm64-musl@0.4.6':
resolution: {integrity: sha512-whuHSYAZyclGjM3L0mKGXyWqdAy7qYvPPn+J1ve7FtGkFlM0DiIPjA5K30aWSGJSRh72sD9DBZfnu8CpfSjT6w==}
- cpu: [arm64]
os: [linux]
'@libsql/linux-x64-gnu@0.4.6':
resolution: {integrity: sha512-0ggx+5RwEbYabIlDBBAvavdfIJCZ757u6nDZtBeQIhzW99EKbWG3lvkXHM3qudFb/pDWSUY4RFBm6vVtF1cJGA==}
- cpu: [x64]
os: [linux]
'@libsql/linux-x64-musl@0.4.6':
resolution: {integrity: sha512-SWNrv7Hz72QWlbM/ZsbL35MPopZavqCUmQz2HNDZ55t0F+kt8pXuP+bbI2KvmaQ7wdsoqAA4qBmjol0+bh4ndw==}
- cpu: [x64]
os: [linux]
'@libsql/win32-x64-msvc@0.4.6':
@@ -3378,7 +3375,6 @@ packages:
libsql@0.4.6:
resolution: {integrity: sha512-F5M+ltteK6dCcpjMahrkgT96uFJvVI8aQ4r9f2AzHQjC7BkAYtvfMSTWGvRBezRgMUIU2h1Sy0pF9nOGOD5iyA==}
- cpu: [x64, arm64, wasm32]
os: [darwin, linux, win32]
lines-and-columns@1.2.4:
@@ -7119,7 +7115,7 @@ snapshots:
eslint: 8.56.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(eslint@8.56.0))(eslint@8.56.0)
- eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.56.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.10.1)(eslint@8.56.0)
+ eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.56.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(eslint@8.56.0))(eslint@8.56.0))(eslint@8.56.0)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.56.0)
eslint-plugin-react: 7.37.5(eslint@8.56.0)
eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.56.0)
@@ -7149,7 +7145,7 @@ snapshots:
tinyglobby: 0.2.13
unrs-resolver: 1.7.2
optionalDependencies:
- eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.56.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.10.1)(eslint@8.56.0)
+ eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.56.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(eslint@8.56.0))(eslint@8.56.0))(eslint@8.56.0)
transitivePeerDependencies:
- supports-color
@@ -7164,7 +7160,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.56.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.10.1)(eslint@8.56.0):
+ eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.56.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(eslint@8.56.0))(eslint@8.56.0))(eslint@8.56.0):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.8
diff --git a/src/generate-apple-client-secret.ts b/src/generate-apple-client-secret.ts
new file mode 100644
index 0000000..9f9c70a
--- /dev/null
+++ b/src/generate-apple-client-secret.ts
@@ -0,0 +1,79 @@
+import { SignJWT, importPKCS8 } from "jose";
+
+/**
+ * Utilities for generating an Apple OAuth2 client secret JWT.
+ *
+ * - AppleClientSecretParams: Interface describing the required parameters for generating the client secret.
+ * - generateAppleClientSecret: Asynchronously generates a signed JWT to be used as the Apple OAuth2 client secret.
+ *
+ * Usage:
+ * const jwt = await generateAppleClientSecret({
+ * teamId: "...",
+ * clientId: "...",
+ * keyId: "...",
+ * privateKey: "...",
+ * exp: 1234567890, // optional, seconds since epoch
+ * });
+ */
+
+export interface AppleClientSecretParams {
+ /**
+ * Your Apple Developer Team ID.
+ */
+ teamId: string;
+ /**
+ * Your Apple Service Client ID (the identifier for your app/service).
+ */
+ clientId: string;
+ /**
+ * The Key ID from your Apple Developer account.
+ */
+ keyId: string;
+ /**
+ * The contents of your Apple private key (.p8 file).
+ */
+ authKeyContent: string;
+ /**
+ * Optional expiration time (in seconds since epoch). Defaults to 6 months from now.
+ */
+ exp?: number;
+}
+
+/**
+ * Generate a signed JWT to use as the Apple OAuth2 client secret.
+ *
+ * @param params - AppleClientSecretParams object containing required Apple OAuth credentials.
+ * @returns Promise - The signed JWT client secret.
+ * @throws Error if any required parameter is missing.
+ */
+export async function generateAppleClientSecret({
+ teamId,
+ clientId,
+ keyId,
+ authKeyContent,
+ exp,
+}: AppleClientSecretParams) {
+ if (!teamId || !clientId || !keyId || !authKeyContent) {
+ throw new Error(
+ "Missing required parameters: teamId, clientId, keyId, privateKey",
+ );
+ }
+ const _authKeyContent = authKeyContent.replace(/\\n/g, "\n").trim();
+ const now = Math.floor(Date.now() / 1000);
+ const alg = "ES256";
+ const expiresAt = exp ?? now + 86400 * 180; // default 6 months
+
+ if (expiresAt - now > 60 * 60 * 24 * 180) {
+ throw new Error("exp may not exceed 180 days from iat per Apple policy");
+ }
+ const cryptoKey = await importPKCS8(_authKeyContent.trim(), alg);
+ const jwt = await new SignJWT({})
+ .setProtectedHeader({ alg, kid: keyId, typ: "JWT" })
+ .setIssuer(teamId)
+ .setSubject(clientId)
+ .setAudience("https://appleid.apple.com")
+ .setIssuedAt()
+ .setExpirationTime(expiresAt)
+ .sign(cryptoKey);
+ return jwt;
+}