Skip to content

Commit b36d3d7

Browse files
authored
improve login with apple example (#46)
1 parent 5b77912 commit b36d3d7

File tree

10 files changed

+243
-23
lines changed

10 files changed

+243
-23
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,3 +249,4 @@ dist
249249
dev/src/uploads
250250

251251
*.db*
252+
*.p8*

bin/generate-apple-client-secret.mjs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
#!/usr/bin/env node
2+
3+
import fs from "fs";
4+
import { hideBin } from "yargs/helpers";
5+
import yargs from "yargs/yargs";
6+
import { generateAppleClientSecret } from "../dist/generate-apple-client-secret.js";
7+
8+
(async () => {
9+
const argv = yargs(hideBin(process.argv))
10+
.option("team-id", {
11+
type: "string",
12+
demandOption: true,
13+
describe: "Apple Developer Team ID",
14+
})
15+
.option("client-id", {
16+
type: "string",
17+
demandOption: true,
18+
describe: "Apple Service Client ID",
19+
})
20+
.option("key-id", {
21+
type: "string",
22+
demandOption: true,
23+
describe: "Apple Key ID",
24+
})
25+
.option("private-key-path", {
26+
type: "string",
27+
demandOption: true,
28+
describe: "Path to .p8 private key file",
29+
})
30+
.option("exp", {
31+
type: "number",
32+
describe: "Expiration time (seconds since epoch)",
33+
})
34+
.help().argv;
35+
36+
let authKeyContent;
37+
try {
38+
authKeyContent = fs.readFileSync(argv["private-key-path"], "utf8");
39+
} catch (err) {
40+
console.error("Failed to read private key file:", err.message);
41+
process.exit(1);
42+
}
43+
44+
try {
45+
const jwt = await generateAppleClientSecret({
46+
teamId: argv["team-id"],
47+
clientId: argv["client-id"],
48+
keyId: argv["key-id"],
49+
authKeyContent,
50+
exp: argv.exp,
51+
});
52+
console.log(jwt);
53+
} catch (err) {
54+
console.error("Failed to generate client secret:", err.message);
55+
process.exit(1);
56+
}
57+
})();

dev/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ NEXT_PUBLIC_URL=http://localhost:3000
6565

6666
# Note: For Apple OAuth, you need to:
6767
# 1. Create an App ID in Apple Developer Portal
68+
# Quick link: https://developer.apple.com/account/resources/identifiers/bundleId/add/bundle
69+
# Long instruction
6870
# 2. Create a Services ID
6971
# 3. Configure domain association
7072
# 4. Generate a Client Secret
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { GoogleOAuthLoginButton as GoogleOAuthLoginButton_143f92647bcb7528bfe1082a22fc4d4e } from 'src/components/GoogleOAuthLoginButton'
22
import { ZitadelOAuthLoginButton as ZitadelOAuthLoginButton_2b344d0256ae0172631ef421761722bb } from 'src/components/ZitadelOAuthLoginButton'
3+
import { AppleOAuthLoginButton as AppleOAuthLoginButton_c5ad5bdad6b9933330a83e7b0bcc0110 } from 'src/components/AppleOAuthLoginButton'
34
import { MicrosoftEntraIdOAuthLoginButton as MicrosoftEntraIdOAuthLoginButton_d181f47f7889616c8bdc074b0a96538d } from 'src/components/MicrosoftEntraIdOAuthLoginButton'
45

56
export const importMap = {
67
"src/components/GoogleOAuthLoginButton#GoogleOAuthLoginButton": GoogleOAuthLoginButton_143f92647bcb7528bfe1082a22fc4d4e,
78
"src/components/ZitadelOAuthLoginButton#ZitadelOAuthLoginButton": ZitadelOAuthLoginButton_2b344d0256ae0172631ef421761722bb,
9+
"src/components/AppleOAuthLoginButton#AppleOAuthLoginButton": AppleOAuthLoginButton_c5ad5bdad6b9933330a83e7b0bcc0110,
810
"src/components/MicrosoftEntraIdOAuthLoginButton#MicrosoftEntraIdOAuthLoginButton": MicrosoftEntraIdOAuthLoginButton_d181f47f7889616c8bdc074b0a96538d
911
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"use client";
2+
export const AppleOAuthLoginButton: React.FC = () => (
3+
<a href="/api/users/oauth/apple">
4+
<button
5+
className="btn btn--icon-style-without-border btn--size-large btn--withoutPopup btn--style-primary btn--withoutPopup"
6+
style={{ width: "100%" }}
7+
>
8+
Continue With Apple
9+
</button>
10+
</a>
11+
);

dev/src/payload.config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import path from "path";
44
import { buildConfig } from "payload";
55
import sharp from "sharp";
66
import { fileURLToPath } from "url";
7+
import { appleOAuth } from "../../examples/apple";
78
import { googleOAuth } from "../../examples/google";
89
import { microsoftEntraIdOAuth } from "../../examples/microsoft-entra-id";
910
import { zitadelOAuth } from "../../examples/zitadel";
@@ -24,6 +25,7 @@ export default buildConfig({
2425
afterLogin: [
2526
"src/components/GoogleOAuthLoginButton#GoogleOAuthLoginButton",
2627
"src/components/ZitadelOAuthLoginButton#ZitadelOAuthLoginButton",
28+
"src/components/AppleOAuthLoginButton#AppleOAuthLoginButton",
2729
"src/components/MicrosoftEntraIdOAuthLoginButton#MicrosoftEntraIdOAuthLoginButton",
2830
],
2931
},
@@ -37,6 +39,6 @@ export default buildConfig({
3739
editor: lexicalEditor({}),
3840
collections: [Users, LocalUsers],
3941
typescript: { outputFile: path.resolve(dirname, "payload-types.ts") },
40-
plugins: [googleOAuth, zitadelOAuth, microsoftEntraIdOAuth],
42+
plugins: [googleOAuth, zitadelOAuth, microsoftEntraIdOAuth, appleOAuth],
4143
sharp,
4244
});

examples/apple.ts

Lines changed: 75 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,85 @@
11
import { PayloadRequest } from "payload";
22
import { OAuth2Plugin } from "../src/index";
3+
/**
4+
To setup Apple OAuth, refer to official documentation:
5+
https://developer.apple.com/sign-in-with-apple/get-started/
6+
7+
However, the process is quite complex and requires several steps, so here's a quick start guide:
8+
9+
To setup Apple OAuth in Payload CMS, you need the following 4 values:
10+
1. APPLE_CLIENT_ID: Your Service ID from the Apple Developer portal (e.g. com.example.myapp)
11+
2. APPLE_CLIENT_SECRET: Your client secret, which is a JWT signed with your private key. This requires value 3 and 4 to generate:
12+
3. APPLE_KEY_ID: The Key ID from the Apple Developer portal
13+
4. APPLE_TEAM_ID: Your Apple Developer Team ID, which can be found in the Apple Developer portal.
14+
15+
Prerequisites: Have a valid Apple Developer account and access to the Apple Developer portal.
16+
17+
1. Create an App ID in the Apple Developer portal
18+
- Quick links: https://developer.apple.com/account/resources/identifiers/bundleId/add/bundle
19+
- Step by step instruction:
20+
> https://developer.apple.com/account
21+
> Certificates, IDs & Profiles
22+
> Identifiers
23+
> Create new identifiders
24+
> Select App IDs
25+
> Select App
26+
> Arbitrary description, explicit bundle ID (e.g. com.example.myapp)
27+
> Capabilities: Enable Sign In with Apple > Save (ignore Server-to-Server Notification Endpoint)
28+
> Continue/Register
29+
2. Create a service ID in the Apple Developer portal
30+
- Quick links: https://developer.apple.com/account/resources/identifiers/serviceId/add
31+
- Step by step instruction:
32+
> https://developer.apple.com/account
33+
> Certificates, IDs & Profiles
34+
> Identifiers
35+
> Create new identifiders
36+
> Select Service IDs
37+
> 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.
38+
> Continue/Register
39+
> Value (1) APPLE_CLIENT_ID should be the identifier you just created (e.g. com.example.myapp.si)
40+
3. Create a new key in the Apple Developer portal
41+
- Quick links: https://developer.apple.com/account/resources/authkeys/add
42+
- Step by step instruction:
43+
> https://developer.apple.com/account
44+
> Certificates, IDs & Profiles
45+
> Keys
46+
> Create new key
47+
> Arbitrary key name and key usage description.
48+
> Enable Sign In with Apple
49+
> Configure
50+
> Select the App ID you created in step 1
51+
> Continue/Register
52+
> Download the key file, which is a .p8 file. This file contains your private key.
53+
> Value (3) APPLE_KEY_ID is the Key ID from the key you just created.
54+
4. Obtain your Apple Developer Team ID
55+
> https://developer.apple.com/account
56+
> Membership details
57+
> Your Team ID is listed there, this should be value (4) APPLE_TEAM_ID.
58+
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:
59+
```sh
60+
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
61+
```
62+
63+
In the example below:
64+
- `process.env.APPLE_CLIENT_ID` is (1) APPLE_CLIENT_ID
65+
- `process.env.APPLE_CLIENT_SECRET` is (2) APPLE_CLIENT_SECRET,
66+
67+
Dev Note:
68+
- 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.
69+
- 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`.
70+
*/
371

472
////////////////////////////////////////////////////////////////////////////////
573
// Apple OAuth
674
////////////////////////////////////////////////////////////////////////////////
75+
776
export const appleOAuth = OAuth2Plugin({
877
enabled:
978
typeof process.env.APPLE_CLIENT_ID === "string" &&
10-
typeof process.env.APPLE_CLIENT_SECRET === "string",
79+
typeof process.env.APPLE_TEAM_ID === "string" &&
80+
typeof process.env.APPLE_KEY_ID === "string" &&
81+
(typeof process.env.APPLE_CLIENT_SECRET === "string" ||
82+
typeof process.env.APPLE_CLIENT_AUTH_KEY_CONTENT === "string"),
1183
strategyName: "apple",
1284
useEmailAsIdentity: true,
1385
serverURL: process.env.NEXT_PUBLIC_URL || "http://localhost:3000",
@@ -86,17 +158,10 @@ export const appleOAuth = OAuth2Plugin({
86158
}
87159
},
88160
successRedirect: (req: PayloadRequest, token?: string) => {
89-
// Check user roles to determine redirect
90-
const user = req.user;
91-
if (user && Array.isArray(user.roles)) {
92-
if (user.roles.includes("admin")) {
93-
return "/admin";
94-
}
95-
}
96-
return "/"; // Default redirect for customers
161+
return "/admin";
97162
},
98163
failureRedirect: (req, err) => {
99164
req.payload.logger.error(err);
100-
return "/login?error=apple-auth-failed";
165+
return `/admin/login?error=${JSON.stringify(err)}`;
101166
},
102167
});

package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@
3636
"reinstall": "cross-env NODE_OPTIONS=--no-deprecation rimraf node_modules && rimraf pnpm-lock.yaml && pnpm --ignore-workspace install",
3737
"clean": "rimraf dist && rimraf dev/.next",
3838
"prepublishOnly": "pnpm clean && pnpm build",
39-
"prepare": "tsc"
39+
"prepare": "tsc",
40+
"payload-oauth2:generate-apple-client-secret": "node ./bin/generate-apple-client-secret.mjs"
41+
},
42+
"bin": {
43+
"payload-oauth2:generate-apple-client-secret": "./bin/generate-apple-client-secret.mjs"
4044
},
4145
"author": "wilsonle2907@gmail.com",
4246
"license": "MIT",
@@ -76,7 +80,8 @@
7680
"rimraf": "5.0.7",
7781
"sharp": "0.33.4",
7882
"tree-kill": "^1.2.2",
79-
"typescript": "5.4.5"
83+
"typescript": "5.4.5",
84+
"yargs": "17.7.2"
8085
},
8186
"packageManager": "pnpm@10.8.0+sha512.0e82714d1b5b43c74610193cb20734897c1d00de89d0e18420aebc5977fa13d780a9cb05734624e81ebd81cc876cd464794850641c48b9544326b5622ca29971"
8287
}

pnpm-lock.yaml

Lines changed: 6 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)