Skip to content

feat: improve login with apple example #46

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -249,3 +249,4 @@ dist
dev/src/uploads

*.db*
*.p8*
57 changes: 57 additions & 0 deletions bin/generate-apple-client-secret.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
})();
2 changes: 2 additions & 0 deletions dev/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions dev/src/app/(payload)/admin/importMap.js
Original file line number Diff line number Diff line change
@@ -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
}
11 changes: 11 additions & 0 deletions dev/src/components/AppleOAuthLoginButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"use client";
export const AppleOAuthLoginButton: React.FC = () => (
<a href="/api/users/oauth/apple">
<button
className="btn btn--icon-style-without-border btn--size-large btn--withoutPopup btn--style-primary btn--withoutPopup"
style={{ width: "100%" }}
>
Continue With Apple
</button>
</a>
);
4 changes: 3 additions & 1 deletion dev/src/payload.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -24,6 +25,7 @@ export default buildConfig({
afterLogin: [
"src/components/GoogleOAuthLoginButton#GoogleOAuthLoginButton",
"src/components/ZitadelOAuthLoginButton#ZitadelOAuthLoginButton",
"src/components/AppleOAuthLoginButton#AppleOAuthLoginButton",
"src/components/MicrosoftEntraIdOAuthLoginButton#MicrosoftEntraIdOAuthLoginButton",
],
},
Expand All @@ -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,
});
85 changes: 75 additions & 10 deletions examples/apple.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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)}`;
},
});
9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
16 changes: 6 additions & 10 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading