Skip to content
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
16 changes: 16 additions & 0 deletions dev/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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/
100 changes: 100 additions & 0 deletions examples/apple.ts
Original file line number Diff line number Diff line change
@@ -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";
},
});
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -14,7 +14,8 @@
"typescript",
"react",
"oauth2",
"payload-plugin"
"payload-plugin",
"apple-sign-in"
],
"files": [
"dist"
Expand Down
14 changes: 11 additions & 3 deletions src/authorize-endpoint.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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);
},
Expand Down
Loading
Loading