Skip to content

feat: custom extract callback token #47

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 3 commits into from
Jun 25, 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
33 changes: 11 additions & 22 deletions dev/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,21 @@ NEXT_PUBLIC_URL=http://localhost:3000
################################################################################
# zitadel oauth config
################################################################################
# optional: google oauth2 client id, not activated if not set
# optional: zitadel oauth2 client id, not activated if not set
# ZITADEL_CLIENT_ID=

# optional: google oauth2 client secret, not activated if not set
# optional: zitadel oauth2 client secret, not activated if not set
# ZITADEL_CLIENT_SECRET=

# optional: zitadel oauth2 token endpoint, not activated if not set
# ZITADEL_TOKEN_ENDPOINT=

# optional: zitadel oauth2 authorization url, not activated if not set
# ZITADEL_AUTHORIZATION_URL=

# optional: zitadel oauth2 userinfo endpoint, not activated if not set
# ZITADEL_USERINFO_ENDPOINT=

################################################################################
# Microsoft Entra ID OAuth 2.0 config
################################################################################
Expand All @@ -43,17 +52,6 @@ NEXT_PUBLIC_URL=http://localhost:3000
# optional: Microsoft Entra ID administrator group id, activated if not set
# MICROSOFT_ENTRA_ID_ADMINISTRATOR_GROUP_ID=

# Note: For Microsoft Entra ID, you need to:
# 1. Create an app registration
# - Go to Azure Portal -> Microsoft Entra ID -> App Registrations -> New Registration
# - Fill in the name and select the supported account types
# - Add a "Web" redirect URI: http://localhost:3000/api/users/oauth/microsoft-entra-id/callback
# - When created, go to API Permissions -> Add a permission -> Microsoft Graph -> Delegated permissions -> Select the ones you need, e.g. email, openid, profile and offline_access -> Add permissions
# - Optional: If you do not want users to have to give consent to your app everytime they login: Click on Grant admin consent for {tenant} -> Yes
# - Optional: If you want groups to be part of your token(s), you can go to Token configuration -> Add groups claim -> Select the groups you want to add -> Save
# - Go to Certificates & secrets -> Client secrets -> New client secret -> Add a description -> Expires -> Add -> Copy the secret (it will only be shown once) -> And save the secret somewhere safe or add it to your .env file
# You can read a little about registering apps here as well: https://learn.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app

################################################################################
# Apple OAuth Config
################################################################################
Expand All @@ -62,12 +60,3 @@ NEXT_PUBLIC_URL=http://localhost:3000

# 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
# 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
# See: https://developer.apple.com/sign-in-with-apple/get-started/
14 changes: 14 additions & 0 deletions examples/microsoft-entra-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ const tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0
const authorizationUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`;
const microsoftGraphBaseUrl = "https://graph.microsoft.com/v1.0";

/**

Note: For Microsoft Entra ID, you need to xreate an app registration
- Go to Azure Portal -> Microsoft Entra ID -> App Registrations -> New Registration
- Fill in the name and select the supported account types
- Add a "Web" redirect URI: http://localhost:3000/api/users/oauth/microsoft-entra-id/callback
- When created, go to API Permissions -> Add a permission -> Microsoft Graph -> Delegated permissions -> Select the ones you need, e.g. email, openid, profile and offline_access -> Add permissions
- Optional: If you do not want users to have to give consent to your app everytime they login: Click on Grant admin consent for {tenant} -> Yes
- Optional: If you want groups to be part of your token(s), you can go to Token configuration -> Add groups claim -> Select the groups you want to add -> Save
- Go to Certificates & secrets -> Client secrets -> New client secret -> Add a description -> Expires -> Add -> Copy the secret (it will only be shown once) -> And save the secret somewhere safe or add it to your .env file

You can read a little about registering apps here as well: https://learn.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app
*/

export const microsoftEntraIdOAuth = OAuth2Plugin({
enabled:
typeof clientId === "string" &&
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "payload-oauth2",
"version": "1.0.16",
"version": "1.0.17",
"type": "module",
"homepage:": "https://github.com/WilsonLe/payload-oauth2",
"repository": "https://github.com/WilsonLe/payload-oauth2",
Expand Down
33 changes: 8 additions & 25 deletions src/callback-endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
User,
} from "payload";
import { generatePayloadCookie, getFieldsToSign } from "payload";
import { defaultCallbackExtractToken } from "./default-callback-extract-token";
import { defaultGetToken } from "./default-get-token";
import type { PluginOptions } from "./types";

Expand All @@ -20,31 +21,6 @@ export const createCallbackEndpoint = (
): Endpoint[] => {
const handler: PayloadHandler = async (req: PayloadRequest) => {
try {
// Obtain code from either POST body or GET query parameters
let code: string | undefined;
if (req.method === "POST") {
// Handle form data from POST request (used by Apple OAuth)
const contentType = req.headers.get("content-type");
if (contentType?.includes("application/x-www-form-urlencoded")) {
const text = await (req as unknown as Request).text();
const formData = new URLSearchParams(text);
code = formData.get("code") || undefined;
}
} else if (req.method === "GET") {
// Handle query parameters (used by Google OAuth)
code =
typeof req.query === "object" && req.query
? (req.query as { code?: string }).code
: undefined;
}
if (typeof code !== "string") {
throw new Error(
`Code not found in ${req.method === "POST" ? "body" : "query"}: ${
req.method === "POST" ? "form-data" : JSON.stringify(req.query)
}`,
);
}

// /////////////////////////////////////
// shorthands
// /////////////////////////////////////
Expand All @@ -61,6 +37,13 @@ export const createCallbackEndpoint = (
!useEmailAsIdentity || pluginOptions.excludeEmailFromJwtToken || false;
const onUserNotFoundBehavior =
pluginOptions.onUserNotFoundBehavior || "create";
const callbackExtractToken =
pluginOptions.callbackExtractToken || defaultCallbackExtractToken;

// /////////////////////////////////////
// extract code from request
// /////////////////////////////////////
const code = await callbackExtractToken(req);

// /////////////////////////////////////
// beforeOperation - Collection
Expand Down
45 changes: 45 additions & 0 deletions src/default-callback-extract-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { PayloadRequest } from "payload";

export const defaultCallbackExtractToken = async (
req: PayloadRequest,
): Promise<string> => {
if (req.method === "POST") {
// Handle form data from POST request (used by Apple OAuth)
const contentType = req.headers.get("content-type");
if (contentType?.includes("application/x-www-form-urlencoded")) {
const text = await (req as unknown as Request).text();
const formData = new URLSearchParams(text);
const code = formData.get("code");
if (typeof code === "string") {
return code;
} else {
throw new Error(`Code not found in POST form data: ${text}`);
}
} else if (contentType?.includes("application/json")) {
if (typeof req.json === "function") {
const body = await req.json();
if (typeof body.code === "string") {
return body.code;
} else {
throw new Error(
`Code not found in POST request body: ${JSON.stringify(body)}`,
);
}
}
} else {
throw new Error(
`Unsupported content-type: ${contentType} received in POST callback endpoint`,
);
}
} else if (req.method === "GET") {
// Handle query parameters (used by Google OAuth)
if (typeof req.query === "object" && typeof req.query.code === "string") {
return req.query.code;
} else {
throw new Error(
`Code not found in GET request query param: ${JSON.stringify(req.query)}`,
);
}
}
throw new Error("Authorization code not found in callback request");
};
2 changes: 1 addition & 1 deletion src/default-get-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,6 @@ export const defaultGetToken = async (
const tokenData = await tokenResponse.json();
const accessToken = tokenData?.access_token;
if (typeof accessToken !== "string")
throw new Error(`No access token: ${tokenData}`);
throw new Error(`No access token: ${JSON.stringify(tokenData)}`);
return accessToken;
};
8 changes: 8 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,14 @@ export interface PluginOptions {
req: PayloadRequest,
) => Promise<Record<string, unknown>>;

/**
* Function to extract authorization code from the callback request.
* @param req PayloadRequest object
* @returns Promise that resolves to the authorization code
* @default `defaultCallbackExtractToken` in `src/default-callback-extract-token.ts`
*/
callbackExtractToken?: (req: PayloadRequest) => Promise<string>;

/**
* Behavior when a user is not found in the database.
* If set to "create", a new user will be created with the information
Expand Down
Loading