diff --git a/dev/.env.example b/dev/.env.example index 475d8eb..d18373b 100644 --- a/dev/.env.example +++ b/dev/.env.example @@ -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 ################################################################################ @@ -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 ################################################################################ @@ -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/ diff --git a/examples/microsoft-entra-id.ts b/examples/microsoft-entra-id.ts index 9ab969c..7a24a51 100644 --- a/examples/microsoft-entra-id.ts +++ b/examples/microsoft-entra-id.ts @@ -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" && diff --git a/package.json b/package.json index 9486b72..d317b20 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/callback-endpoint.ts b/src/callback-endpoint.ts index 4451e27..16139a4 100644 --- a/src/callback-endpoint.ts +++ b/src/callback-endpoint.ts @@ -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"; @@ -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 // ///////////////////////////////////// @@ -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 diff --git a/src/default-callback-extract-token.ts b/src/default-callback-extract-token.ts new file mode 100644 index 0000000..4bd3563 --- /dev/null +++ b/src/default-callback-extract-token.ts @@ -0,0 +1,45 @@ +import { PayloadRequest } from "payload"; + +export const defaultCallbackExtractToken = async ( + req: PayloadRequest, +): Promise => { + 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"); +}; diff --git a/src/default-get-token.ts b/src/default-get-token.ts index 88c88c9..f0c626c 100644 --- a/src/default-get-token.ts +++ b/src/default-get-token.ts @@ -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; }; diff --git a/src/types.ts b/src/types.ts index 147284d..c04a41d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -113,6 +113,14 @@ export interface PluginOptions { req: PayloadRequest, ) => Promise>; + /** + * 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; + /** * Behavior when a user is not found in the database. * If set to "create", a new user will be created with the information