From 785569bb1f865ef1ee4ca76f0b05063013ae96fa Mon Sep 17 00:00:00 2001 From: WilsonLe Date: Mon, 23 Jun 2025 23:01:59 -0400 Subject: [PATCH] improve login with apple example --- .gitignore | 1 + bin/generate-apple-client-secret.mjs | 57 +++++++++++++ dev/.env.example | 2 + dev/src/app/(payload)/admin/importMap.js | 2 + dev/src/components/AppleOAuthLoginButton.tsx | 11 +++ dev/src/payload.config.ts | 4 +- examples/apple.ts | 85 +++++++++++++++++--- package.json | 9 ++- pnpm-lock.yaml | 16 ++-- src/generate-apple-client-secret.ts | 79 ++++++++++++++++++ 10 files changed, 243 insertions(+), 23 deletions(-) create mode 100644 bin/generate-apple-client-secret.mjs create mode 100644 dev/src/components/AppleOAuthLoginButton.tsx create mode 100644 src/generate-apple-client-secret.ts 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; +}