From 6821f015992e2883e3fc7c129bf036fb185bc4fa Mon Sep 17 00:00:00 2001 From: WilsonLe Date: Tue, 12 May 2026 20:32:33 +1000 Subject: [PATCH] refactor(oauth): deepen callback identity flow --- .../oauth-architecture.instructions.md | 14 + package.json | 4 +- src/auth-strategy.ts | 197 +++---- src/authorize-endpoint.ts | 118 ++--- src/callback-endpoint.ts | 256 +-------- src/modify-auth-collection.ts | 30 +- src/oauth-callback-transaction.ts | 151 ++++++ src/oauth-config.ts | 201 ++++++++ src/oauth-identity.ts | 123 +++++ src/plugin.ts | 16 +- test/__mocks__/payload.ts | 4 +- test/callback-endpoint.spec.ts | 159 +++++- test/google-oauth-test.ts | 150 ------ test/google.spec.ts | 421 --------------- test/mocked-provider-integration.spec.ts | 12 +- test/plugin-wiring.spec.ts | 53 ++ test/test-utils.ts | 42 -- test/zitadel-oauth-test.ts | 153 ------ test/zitadel.spec.ts | 488 ------------------ 19 files changed, 867 insertions(+), 1725 deletions(-) create mode 100644 docs/instructions/oauth-architecture.instructions.md create mode 100644 src/oauth-callback-transaction.ts create mode 100644 src/oauth-config.ts create mode 100644 src/oauth-identity.ts delete mode 100644 test/google-oauth-test.ts delete mode 100644 test/google.spec.ts delete mode 100644 test/test-utils.ts delete mode 100644 test/zitadel-oauth-test.ts delete mode 100644 test/zitadel.spec.ts diff --git a/docs/instructions/oauth-architecture.instructions.md b/docs/instructions/oauth-architecture.instructions.md new file mode 100644 index 0000000..a081f7e --- /dev/null +++ b/docs/instructions/oauth-architecture.instructions.md @@ -0,0 +1,14 @@ +--- +description: "OAuth plugin architecture seams, identity policy, and public interface compatibility rules." +applyTo: "src/**,test/**,examples/**" +--- + +# OAuth Architecture + +- Keep the package generic and zero-dependency; do not add built-in provider adapters unless the public direction changes. +- Preserve the public `PluginOptions` interface. Normalize defaults and validate invariants inside implementation modules; warn and keep legacy values rather than throwing for newly detected invalid config. +- When `useEmailAsIdentity` is true, always sign email into the JWT even if `excludeEmailFromJwtToken` is true; warn about the conflict so callback-issued JWTs still authenticate. +- Treat the callback flow as the only module that may create OAuth users. It respects `onUserNotFoundBehavior`, creates on first OAuth login, fails closed for invalid missing-user behavior, and reuses existing users without updating provider profile data. +- Preserve Payload session semantics. Callback transactions add Payload sessions before signing JWTs, and auth strategy validates `sid` for session-backed collections before normal identity lookup. +- Treat the auth strategy as authentication-only. A valid JWT that references no Payload user returns `{ user: null }` with a warning; invalid JWT verification errors surface to the caller. +- Keep provider behavior deterministic in tests. Use the provider matrix for Google, Zitadel, Apple, and Microsoft Entra ID instead of shallow per-provider specs that only assert copied options or mocks. diff --git a/package.json b/package.json index b82f1b6..8906b25 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,8 @@ "dev:start": "cross-env NODE_OPTIONS=--no-deprecation next start dev", "build": "tsc", "test": "cd test && jest --config=./jest.config.js --runInBand", - "test:google": "cd test && jest --config=./jest.config.js google.spec.ts --runInBand", - "test:zitadel": "cd test && jest --config=./jest.config.js zitadel.spec.ts --runInBand", + "test:google": "cd test && jest --config=./jest.config.js mocked-provider-integration.spec.ts --runInBand --testNamePattern=Google", + "test:zitadel": "cd test && jest --config=./jest.config.js mocked-provider-integration.spec.ts --runInBand --testNamePattern=Zitadel", "test:watch": "cd test && jest --config=./jest.config.js --watch", "format": "prettier --write src dev test", "payload": "cd dev && cross-env NODE_OPTIONS=--no-deprecation payload", diff --git a/src/auth-strategy.ts b/src/auth-strategy.ts index bfe0ff2..1df0174 100644 --- a/src/auth-strategy.ts +++ b/src/auth-strategy.ts @@ -1,16 +1,18 @@ -import { JWTPayload, jwtVerify } from "jose"; +import type { JWTPayload } from "jose"; +import { jwtVerify } from "jose"; import { - AuthStrategy, - AuthStrategyResult, - CollectionSlug, - User, extractJWT, + type AuthStrategy, + type AuthStrategyResult, + type CollectionSlug, + type User, } from "payload"; import { shouldUsePayloadSessions, userHasPayloadSession, } from "./auth-sessions"; -import { PluginOptions } from "./types"; +import { resolveOAuthConfig, type OAuthConfigInput } from "./oauth-config"; +import { findUserByOAuthIdentity, tryGetOAuthIdentity } from "./oauth-identity"; const getStringClaim = ( jwtUser: JWTPayload, @@ -28,126 +30,91 @@ const getJWTUserID = (jwtUser: JWTPayload): number | string | undefined => { }; export const createAuthStrategy = ( - pluginOptions: PluginOptions, - subFieldName: string, + input: OAuthConfigInput, + subFieldName?: string, ): AuthStrategy => { + const config = resolveOAuthConfig(input, { subFieldName }); + const authStrategy: AuthStrategy = { - name: pluginOptions.strategyName, + name: config.strategyName, authenticate: async ({ headers, payload }): Promise => { - try { - const token = extractJWT({ headers, payload }); - if (!token) return { user: null }; - - let jwtUser: JWTPayload | null = null; - try { - const { payload: verifiedPayload } = await jwtVerify( - token, - new TextEncoder().encode(payload.secret), - { algorithms: ["HS256"] }, - ); - jwtUser = verifiedPayload; - } catch (e: any) { - // Handle token expiration - if (e.code === "ERR_JWT_EXPIRED") return { user: null }; - throw e; - } - if (!jwtUser) return { user: null }; - - // Find the user by email from the verified jwt token - // coerce userCollection to CollectionSlug because it is already checked - // in `modify-auth-collection.ts` that it is a valud collection slug - const userCollection = ((typeof jwtUser.collection === "string" && - jwtUser.collection) || - pluginOptions.authCollection || - "users") as CollectionSlug; - const collectionConfig = payload.collections[userCollection]?.config; - if (!collectionConfig) return { user: null }; - - if (shouldUsePayloadSessions(collectionConfig)) { - const sid = getStringClaim(jwtUser, "sid"); - const userID = getJWTUserID(jwtUser); - if (!sid || userID === undefined) return { user: null }; - - const user = (await payload.findByID({ - collection: userCollection, - disableErrors: true, - id: userID, - showHiddenFields: true, - })) as User | null; - - if (!user || !userHasPayloadSession(user, sid)) { - return { user: null }; - } - - if ( - typeof collectionConfig.auth === "object" && - collectionConfig.auth.verify && - !user._verified - ) { - return { user: null }; - } - - user.collection = userCollection; - user._sid = sid; - user._strategy = pluginOptions.strategyName; - - return { user }; + const token = extractJWT({ headers, payload }); + if (!token) return { user: null }; + + const { payload: jwtUser } = await jwtVerify( + token, + new TextEncoder().encode(payload.secret), + { algorithms: ["HS256"] }, + ); + + const userCollection = ((typeof jwtUser.collection === "string" && + jwtUser.collection) || + config.authCollection) as CollectionSlug; + const collectionConfig = payload.collections[userCollection]?.config; + if (!collectionConfig) return { user: null }; + + if (shouldUsePayloadSessions(collectionConfig)) { + const sid = getStringClaim(jwtUser, "sid"); + const userID = getJWTUserID(jwtUser); + if (!sid || userID === undefined) return { user: null }; + + const user = (await payload.findByID({ + collection: userCollection, + disableErrors: true, + id: userID, + showHiddenFields: true, + })) as User | null; + + if (!user || !userHasPayloadSession(user, sid)) { + return { user: null }; } - let user: User | null = null; - - if (pluginOptions.useEmailAsIdentity) { - if (!jwtUser.email || typeof jwtUser.email !== "string") { - payload.logger.warn( - "Using email as identity but no email is found in jwt token", - ); - return { user: null }; - } - const usersQuery = await payload.find({ - collection: userCollection, - where: { email: { equals: jwtUser.email } }, - }); - if (usersQuery.docs.length === 0) { - // coerce to User because `userCollection` is a valid auth collection, checked by `modify-auth-collection.ts` already - user = (await payload.create({ - collection: userCollection, - data: jwtUser as any, - })) as unknown as User; - } else { - // coerce to User because payload warns that some collection may not have property `collection` - i.e. `PayloadMigration; - user = usersQuery.docs[0] as unknown as User; - } - } else { - if (typeof jwtUser[subFieldName] !== "string") { - payload.logger.warn( - `No ${subFieldName} found in jwt token. Make sure the jwt token contains the ${subFieldName} field`, - ); - return { user: null }; - } - const usersQuery = await payload.find({ - collection: userCollection, - where: { [subFieldName]: { equals: jwtUser[subFieldName] } }, - }); - if (usersQuery.docs.length === 0) { - // coerce to User because payload warns that some collection may not have property `collection` - i.e. `PayloadMigration; - user = (await payload.create({ - collection: userCollection, - data: jwtUser as any, - })) as unknown as User; - } else { - // coerce to User because payload warns that some collection may not have property `collection` - i.e. `PayloadMigration; - user = usersQuery.docs[0] as unknown as User; - } + if ( + typeof collectionConfig.auth === "object" && + collectionConfig.auth.verify && + !user._verified + ) { + return { user: null }; } + user.collection = userCollection; - user._strategy = pluginOptions.strategyName; + user._sid = sid; + user._strategy = config.strategyName; - // Return the user object return { user }; - } catch (e) { - payload.logger.error(e); + } + + const identity = tryGetOAuthIdentity( + config, + jwtUser as JWTPayload, + "jwt token", + ); + if (!identity) { + payload.logger.warn( + config.useEmailAsIdentity + ? "Using email as identity but no email is found in jwt token" + : `No ${config.subFieldName} found in jwt token. Make sure the jwt token contains the ${config.subFieldName} field`, + ); return { user: null }; } + + const usersQuery = await findUserByOAuthIdentity({ + payload, + collection: userCollection, + identity, + }); + const user = usersQuery.docs[0] as User | undefined; + + if (!user) { + payload.logger.warn( + `OAuth user not found in ${userCollection} for ${identity.field}: ${identity.value}`, + ); + return { user: null }; + } + + user.collection = userCollection; + user._strategy = config.strategyName; + return { user }; }, }; return authStrategy; diff --git a/src/authorize-endpoint.ts b/src/authorize-endpoint.ts index 4bd7be5..dcba283 100644 --- a/src/authorize-endpoint.ts +++ b/src/authorize-endpoint.ts @@ -1,8 +1,7 @@ import crypto from "crypto"; import type { Endpoint, PayloadRequest } from "payload"; import { generateCookie } from "payload"; -import { defaultGetPkceCodes } from "./default-get-pkce-codes"; -import type { PluginOptions } from "./types"; +import { resolveOAuthConfig, type OAuthConfigInput } from "./oauth-config"; const isNextRscRequest = (req: PayloadRequest): boolean => req.headers.get("RSC") === "1" || @@ -10,74 +9,61 @@ const isNextRscRequest = (req: PayloadRequest): boolean => req.headers.has("Next-Router-Prefetch") || req.searchParams.has("_rsc"); -export const createAuthorizeEndpoint = ( - pluginOptions: PluginOptions, -): Endpoint => ({ - method: "get", - path: pluginOptions.authorizePath || "/oauth/authorize", - handler: async (req: PayloadRequest) => { - if (isNextRscRequest(req)) { - return new Response(null, { status: 204 }); - } +export const createAuthorizeEndpoint = (input: OAuthConfigInput): Endpoint => { + const config = resolveOAuthConfig(input); - const clientId = pluginOptions.clientId; - const authCollection = pluginOptions.authCollection || "users"; - const callbackPath = pluginOptions.callbackPath || "/oauth/callback"; - const redirectUri = - pluginOptions.authorizeRedirectUri || - `${pluginOptions.serverURL}/api/${authCollection}${callbackPath}`; + return { + method: "get", + path: config.authorizePath, + handler: async (req: PayloadRequest) => { + if (isNextRscRequest(req)) { + return new Response(null, { status: 204 }); + } - const scope = pluginOptions.scopes.join(" "); - const responseType = "code"; - const accessType = "offline"; + const url = new URL(config.providerAuthorizationUrl); + url.searchParams.append("client_id", config.clientId); + url.searchParams.append("redirect_uri", config.redirectUri); + url.searchParams.append("scope", config.scope); + url.searchParams.append("response_type", "code"); + url.searchParams.append("access_type", "offline"); - // Create a URL object and set search parameters - const url = new URL(pluginOptions.providerAuthorizationUrl); - url.searchParams.append("client_id", clientId); - url.searchParams.append("redirect_uri", redirectUri); - url.searchParams.append("scope", scope); - url.searchParams.append("response_type", responseType); - url.searchParams.append("access_type", accessType); + if (config.prompt) { + url.searchParams.append("prompt", config.prompt); + } + if (config.responseMode) { + url.searchParams.append("response_mode", config.responseMode); + } + if (config.authType) { + url.searchParams.append("auth_type", config.authType); + } - if (pluginOptions.prompt) { - url.searchParams.append("prompt", pluginOptions.prompt); - } - if (pluginOptions.responseMode) { - url.searchParams.append("response_mode", pluginOptions.responseMode); - } - if (pluginOptions.authType) { - url.searchParams.append("auth_type", pluginOptions.authType); - } + // Forward state from request query if available + const state = req.searchParams.get("state"); + if (state) url.searchParams.append("state", state); - // Forward state from request query if available - const state = req.searchParams.get("state"); - if (state) url.searchParams.append("state", state); + url.searchParams.append("nonce", crypto.randomBytes(16).toString("hex")); - url.searchParams.append("nonce", crypto.randomBytes(16).toString("hex")); + if (config.pkceEnabled) { + const { challenge, challengeMethod, verifier } = config.getPkceCodes(); + url.searchParams.append("code_challenge", challenge); + url.searchParams.append("code_challenge_method", challengeMethod); + const cookie = generateCookie({ + name: "pkce_verifier", + value: verifier, + maxAge: 10 * 60, // 10 minutes + returnCookieAsObject: false, + sameSite: "Lax", + }); + return new Response(null, { + headers: { + "Set-Cookie": cookie as string, + Location: url.toString(), + }, + status: 302, + }); + } - if (pluginOptions.pkceEnabled) { - const { challenge, challengeMethod, verifier } = - typeof pluginOptions.getPkceCodes === "function" - ? pluginOptions.getPkceCodes() - : defaultGetPkceCodes(); - url.searchParams.append("code_challenge", challenge); - url.searchParams.append("code_challenge_method", challengeMethod); - const cookie = generateCookie({ - name: "pkce_verifier", - value: verifier, - maxAge: 10 * 60, // 10 minutes - returnCookieAsObject: false, - sameSite: "Lax", - }); - return new Response(null, { - headers: { - "Set-Cookie": cookie as string, - Location: url.toString(), - }, - status: 302, - }); - } - - return Response.redirect(url.toString()); - }, -}); + return Response.redirect(url.toString()); + }, + }; +}; diff --git a/src/callback-endpoint.ts b/src/callback-endpoint.ts index 5a805fd..1e29da9 100644 --- a/src/callback-endpoint.ts +++ b/src/callback-endpoint.ts @@ -1,266 +1,34 @@ -import { SignJWT } from "jose"; -import crypto from "node:crypto"; -import type { - CollectionSlug, - Endpoint, - JsonObject, - PaginatedDocs, - PayloadHandler, - PayloadRequest, - RequestContext, - TypeWithID, - User, -} from "payload"; -import { generatePayloadCookie, getFieldsToSign } from "payload"; -import { addPayloadSessionToUser } from "./auth-sessions"; -import { defaultCallbackExtractToken } from "./default-callback-extract-token"; -import { defaultGetToken } from "./default-get-token"; -import type { PluginOptions } from "./types"; +import type { Endpoint, PayloadHandler, PayloadRequest } from "payload"; +import { runOAuthCallbackTransaction } from "./oauth-callback-transaction"; +import { resolveOAuthConfig, type OAuthConfigInput } from "./oauth-config"; + +export const createCallbackEndpoint = (input: OAuthConfigInput): Endpoint[] => { + const config = resolveOAuthConfig(input); -export const createCallbackEndpoint = ( - pluginOptions: PluginOptions, -): Endpoint[] => { const handler: PayloadHandler = async (req: PayloadRequest) => { try { - // ///////////////////////////////////// - // shorthands - // ///////////////////////////////////// - const subFieldName = - pluginOptions.subField?.name || pluginOptions.subFieldName || "sub"; - const authCollection = (pluginOptions.authCollection || - "users") as CollectionSlug; - const collectionConfig = req.payload.collections[authCollection].config; - const payloadConfig = req.payload.config; - const callbackPath = pluginOptions.callbackPath || "/oauth/callback"; - const redirectUri = `${pluginOptions.serverURL}/api/${authCollection}${callbackPath}`; - const useEmailAsIdentity = pluginOptions.useEmailAsIdentity ?? false; - const excludeEmailFromJwtToken = - !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 - // ///////////////////////////////////// - // Not implemented - reserved for future use - - // ///////////////////////////////////// - // obtain access token or id_token - // ///////////////////////////////////// - let token: string; - - if (pluginOptions.getToken) { - token = await pluginOptions.getToken(code, req); - } else { - token = await defaultGetToken( - pluginOptions.tokenEndpoint, - pluginOptions.clientId, - pluginOptions.clientSecret, - redirectUri, - code, - pluginOptions.pkceEnabled, - req, - ); - } - - if (typeof token !== "string") { - throw new Error(`Invalid token response: ${token}`); - } - - // ///////////////////////////////////// - // get user info - // ///////////////////////////////////// - const userInfo = await pluginOptions.getUserInfo(token, req); - - // ///////////////////////////////////// - // ensure user exists - // ///////////////////////////////////// - let existingUser: PaginatedDocs; - if (useEmailAsIdentity) { - if (typeof userInfo.email !== "string" || userInfo.email.length === 0) { - throw new Error("Email not found in provider user info"); - } - // Use email as the unique identifier - existingUser = await req.payload.find({ - req, - collection: authCollection, - where: { email: { equals: userInfo.email } }, - showHiddenFields: true, - limit: 1, - }); - } else { - const providerSubject = userInfo[subFieldName]; - if ( - typeof providerSubject !== "string" || - providerSubject.length === 0 - ) { - throw new Error(`No ${subFieldName} found in provider user info`); - } - // Use provider's sub field as the unique identifier - existingUser = await req.payload.find({ - req, - collection: authCollection, - where: { [subFieldName]: { equals: providerSubject } }, - showHiddenFields: true, - limit: 1, - }); - } - - let user = existingUser.docs[0] as User; - if (!user) { - if (onUserNotFoundBehavior === "error") { - throw new Error( - `User not found: ${useEmailAsIdentity ? userInfo.email : userInfo[subFieldName]}`, - ); - } else if (onUserNotFoundBehavior === "create") { - // Create new user if they don't exist - // Generate secure random password for OAuth users - userInfo.password = crypto.randomBytes(32).toString("hex"); - userInfo.collection = authCollection; - const result = await req.payload.create({ - req, - collection: authCollection, - data: userInfo, - showHiddenFields: true, - }); - user = result as unknown as User; - } else { - throw new Error( - `Invalid onUserNotFoundBehavior: ${onUserNotFoundBehavior}`, - ); - } - } else { - // Update existing user with latest info from provider - userInfo.collection = authCollection; - const result = await req.payload.update({ - req, - collection: authCollection, - id: user.id, - data: userInfo, - showHiddenFields: true, - }); - user = result as unknown as User; - } - - // ///////////////////////////////////// - // beforeLogin - Collection - // ///////////////////////////////////// - await collectionConfig.hooks.beforeLogin.reduce( - async (priorHook, hook) => { - await priorHook; - - const hookResult = await hook({ - collection: collectionConfig, - context: req.context || ({} as RequestContext), - req, - user, - }); - - if (hookResult) { - user = hookResult as User; - } - }, - Promise.resolve(), - ); + const result = await runOAuthCallbackTransaction(req, config); - // ///////////////////////////////////// - // login - OAuth2 - // ///////////////////////////////////// - const sid = await addPayloadSessionToUser({ - collectionConfig, - req, - user, - }); - user.collection = authCollection; - user._strategy = pluginOptions.strategyName; - if (sid) { - user._sid = sid; - } - - const fieldsToSign = getFieldsToSign({ - collectionConfig, - email: excludeEmailFromJwtToken ? "" : user.email || "", - sid, - user: user as PayloadRequest["user"], - }); - - const jwtToken = await new SignJWT(fieldsToSign) - .setProtectedHeader({ alg: "HS256" }) - .setExpirationTime(`${collectionConfig.auth.tokenExpiration} secs`) - .sign(new TextEncoder().encode(req.payload.secret)); - req.user = user as PayloadRequest["user"]; - - // ///////////////////////////////////// - // afterLogin - Collection - // ///////////////////////////////////// - await collectionConfig.hooks.afterLogin.reduce( - async (priorHook, hook) => { - await priorHook; - - const hookResult = await hook({ - collection: collectionConfig, - context: req.context || ({} as RequestContext), - req, - token: jwtToken, - user, - }); - - if (hookResult) { - user = hookResult as User; - } - }, - Promise.resolve(), - ); - - // ///////////////////////////////////// - // afterRead - Fields - // ///////////////////////////////////// - // Not implemented - reserved for future use - - // ///////////////////////////////////// - // generate and set cookie - // ///////////////////////////////////// - const cookie = generatePayloadCookie({ - collectionAuthConfig: collectionConfig.auth, - cookiePrefix: payloadConfig.cookiePrefix, - token: jwtToken, - }); - - // ///////////////////////////////////// - // success redirect - // ///////////////////////////////////// return new Response(null, { headers: { - "Set-Cookie": cookie, - Location: await pluginOptions.successRedirect(req, jwtToken), + "Set-Cookie": result.cookie, + Location: result.location, }, status: 302, }); } catch (error) { - // ///////////////////////////////////// - // failure redirect - // ///////////////////////////////////// return new Response(null, { headers: { "Content-Type": "application/json", - Location: await pluginOptions.failureRedirect(req, error), + Location: await config.failureRedirect(req, error), }, status: 302, }); } }; - const path = pluginOptions.callbackPath || "/oauth/callback"; - return [ - { method: "get", path, handler }, - { method: "post", path, handler }, + { method: "get", path: config.callbackPath, handler }, + { method: "post", path: config.callbackPath, handler }, ]; }; diff --git a/src/modify-auth-collection.ts b/src/modify-auth-collection.ts index 9e350a1..cf217b8 100644 --- a/src/modify-auth-collection.ts +++ b/src/modify-auth-collection.ts @@ -1,14 +1,18 @@ -import { AuthStrategy, type CollectionConfig } from "payload"; +import type { AuthStrategy, CollectionConfig } from "payload"; import { createAuthStrategy } from "./auth-strategy"; import { createAuthorizeEndpoint } from "./authorize-endpoint"; import { createCallbackEndpoint } from "./callback-endpoint"; -import { PluginOptions } from "./types"; +import { resolveOAuthConfig, type OAuthConfigInput } from "./oauth-config"; export const modifyAuthCollection = ( - pluginOptions: PluginOptions, + input: OAuthConfigInput, existingCollectionConfig: CollectionConfig, - subFieldName: string, + subFieldNameOverride?: string, ): CollectionConfig => { + const config = resolveOAuthConfig(input, { + subFieldName: subFieldNameOverride, + }); + // ///////////////////////////////////// // modify fields // ///////////////////////////////////// @@ -16,14 +20,14 @@ export const modifyAuthCollection = ( // add sub fields const fields = [...(existingCollectionConfig.fields || [])]; const existingSubField = fields.find( - (field) => "name" in field && field.name === subFieldName, + (field) => "name" in field && field.name === config.subFieldName, ); if (!existingSubField) { - if (!!pluginOptions.subField) { - fields.push(pluginOptions.subField); + if (config.raw.subField) { + fields.push(config.raw.subField); } else { fields.push({ - name: subFieldName, + name: config.subFieldName, type: "text", index: true, access: { @@ -41,7 +45,7 @@ export const modifyAuthCollection = ( typeof existingCollectionConfig.auth !== "boolean" && existingCollectionConfig.auth !== undefined && existingCollectionConfig.auth.disableLocalStrategy === true && - pluginOptions.useEmailAsIdentity === true && + config.useEmailAsIdentity === true && fields.every((field: any) => field.name !== "email") ) { fields.push({ @@ -57,7 +61,7 @@ export const modifyAuthCollection = ( // modify strategies // ///////////////////////////////////// - const authStrategy = createAuthStrategy(pluginOptions, subFieldName); + const authStrategy = createAuthStrategy(config); let strategies: AuthStrategy[] = []; if ( typeof existingCollectionConfig.auth !== "boolean" && @@ -65,7 +69,7 @@ export const modifyAuthCollection = ( Array.isArray(existingCollectionConfig.auth.strategies) ) { strategies = existingCollectionConfig.auth.strategies.filter( - (strategy) => strategy.name !== pluginOptions.strategyName, + (strategy) => strategy.name !== config.strategyName, ); } strategies.push(authStrategy); @@ -75,8 +79,8 @@ export const modifyAuthCollection = ( // ///////////////////////////////////// const endpoints = [...(existingCollectionConfig.endpoints || [])]; const oauthEndpoints = [ - createAuthorizeEndpoint(pluginOptions), - ...createCallbackEndpoint(pluginOptions), + createAuthorizeEndpoint(config), + ...createCallbackEndpoint(config), ]; oauthEndpoints.forEach((oauthEndpoint) => { const existingEndpoint = endpoints.find( diff --git a/src/oauth-callback-transaction.ts b/src/oauth-callback-transaction.ts new file mode 100644 index 0000000..7edb90f --- /dev/null +++ b/src/oauth-callback-transaction.ts @@ -0,0 +1,151 @@ +import { SignJWT } from "jose"; +import type { PayloadRequest, RequestContext, User } from "payload"; +import { generatePayloadCookie, getFieldsToSign } from "payload"; +import { addPayloadSessionToUser } from "./auth-sessions"; +import { defaultGetToken } from "./default-get-token"; +import type { ResolvedOAuthConfig } from "./oauth-config"; +import { ensureCallbackUser } from "./oauth-identity"; + +export type OAuthCallbackTransactionResult = { + cookie: string; + location: string; + token: string; + user: User; +}; + +type CollectionHookName = "beforeLogin" | "afterLogin"; + +type RunHooksArgs = { + hookName: CollectionHookName; + collectionConfig: PayloadRequest["payload"]["collections"][string]["config"]; + jwtToken?: string; + req: PayloadRequest; + user: User; +}; + +const runCollectionLoginHooks = async ({ + hookName, + collectionConfig, + jwtToken, + req, + user, +}: RunHooksArgs): Promise => { + let currentUser = user; + const hooks = collectionConfig.hooks?.[hookName] || []; + + for (const hook of hooks) { + const runHook = hook as ( + args: Record, + ) => unknown | Promise; + const hookResult = await runHook({ + collection: collectionConfig, + context: req.context || ({} as RequestContext), + req, + ...(jwtToken ? { token: jwtToken } : {}), + user: currentUser, + }); + + if (hookResult) { + currentUser = hookResult as User; + } + } + + return currentUser; +}; + +const exchangeOAuthToken = async ( + req: PayloadRequest, + config: ResolvedOAuthConfig, + code: string, +): Promise => { + const token = config.getToken + ? await config.getToken(code, req) + : await defaultGetToken( + config.tokenEndpoint, + config.clientId, + config.clientSecret, + config.redirectUri, + code, + config.pkceEnabled, + req, + ); + + if (typeof token !== "string") { + throw new Error(`Invalid token response: ${token}`); + } + + return token; +}; + +export const runOAuthCallbackTransaction = async ( + req: PayloadRequest, + config: ResolvedOAuthConfig, +): Promise => { + const collectionConfig = + req.payload.collections[config.authCollection].config; + const payloadConfig = req.payload.config; + const code = await config.callbackExtractToken(req); + const providerToken = await exchangeOAuthToken(req, config, code); + const userInfo = await config.getUserInfo(providerToken, req); + + let user = await ensureCallbackUser({ req, config, userInfo }); + user = await runCollectionLoginHooks({ + hookName: "beforeLogin", + collectionConfig, + req, + user, + }); + + const sid = await addPayloadSessionToUser({ + collectionConfig, + req, + user, + }); + user.collection = config.authCollection; + user._strategy = config.strategyName; + if (sid) { + user._sid = sid; + } + + const fieldsToSign = getFieldsToSign({ + collectionConfig, + email: config.excludeEmailFromJwtToken ? "" : user.email || "", + sid, + user: user as PayloadRequest["user"], + }); + + if (!config.useEmailAsIdentity) { + const providerSubject = user[config.subFieldName]; + if (typeof providerSubject !== "string" || providerSubject.length === 0) { + throw new Error(`No ${config.subFieldName} found in Payload user`); + } + fieldsToSign[config.subFieldName] = providerSubject; + } + + const jwtToken = await new SignJWT(fieldsToSign) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime(`${collectionConfig.auth.tokenExpiration} secs`) + .sign(new TextEncoder().encode(req.payload.secret)); + req.user = user as PayloadRequest["user"]; + + user = await runCollectionLoginHooks({ + hookName: "afterLogin", + collectionConfig, + jwtToken, + req, + user, + }); + + const cookie = generatePayloadCookie({ + collectionAuthConfig: collectionConfig.auth, + cookiePrefix: payloadConfig.cookiePrefix, + token: jwtToken, + }); + + return { + cookie, + location: await config.successRedirect(req, jwtToken), + token: jwtToken, + user, + }; +}; diff --git a/src/oauth-config.ts b/src/oauth-config.ts new file mode 100644 index 0000000..f0e95e6 --- /dev/null +++ b/src/oauth-config.ts @@ -0,0 +1,201 @@ +import type { PayloadRequest } from "payload"; +import { defaultCallbackExtractToken } from "./default-callback-extract-token"; +import { defaultGetPkceCodes } from "./default-get-pkce-codes"; +import type { PluginOptions } from "./types"; + +export type OAuthConfigInput = PluginOptions | ResolvedOAuthConfig; + +export type OAuthConfigWarning = { + option: keyof PluginOptions | "authorizeRedirectUri"; + message: string; +}; + +export type ResolvedOAuthConfig = { + raw: PluginOptions; + warnings: OAuthConfigWarning[]; + strategyName: string; + useEmailAsIdentity: boolean; + excludeEmailFromJwtToken: boolean; + serverURL: string; + authCollection: string; + subFieldName: string; + clientId: string; + clientSecret: string; + tokenEndpoint: string; + providerAuthorizationUrl: string; + authorizeRedirectUri?: string; + redirectUri: string; + getUserInfo: PluginOptions["getUserInfo"]; + callbackExtractToken: (req: PayloadRequest) => Promise; + onUserNotFoundBehavior: string; + scopes: string[]; + scope: string; + authorizePath: string; + callbackPath: string; + prompt?: string; + authType?: string; + responseMode?: string; + getToken?: PluginOptions["getToken"]; + successRedirect: PluginOptions["successRedirect"]; + failureRedirect: PluginOptions["failureRedirect"]; + pkceEnabled: boolean; + getPkceCodes: () => { + verifier: string; + challenge: string; + challengeMethod: string; + }; +}; + +type ResolveOverrides = { + subFieldName?: string; +}; + +export const isResolvedOAuthConfig = ( + input: OAuthConfigInput, +): input is ResolvedOAuthConfig => + typeof input === "object" && + input !== null && + "raw" in input && + "warnings" in input && + "redirectUri" in input; + +const isAbsoluteHttpUrl = (value: string): boolean => { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +}; + +const pathWarning = ( + option: "authorizePath" | "callbackPath", + value: string, +): OAuthConfigWarning | null => { + if (!value.startsWith("/")) { + return { + option, + message: `${option} should start with "/"; keeping configured value "${value}" for backwards compatibility.`, + }; + } + + if (value.length > 1 && value.endsWith("/")) { + return { + option, + message: `${option} should not have a trailing slash; keeping configured value "${value}" for backwards compatibility.`, + }; + } + + return null; +}; + +export const resolveOAuthConfig = ( + input: OAuthConfigInput, + overrides: ResolveOverrides = {}, +): ResolvedOAuthConfig => { + if (isResolvedOAuthConfig(input) && overrides.subFieldName === undefined) { + return input; + } + + const pluginOptions = isResolvedOAuthConfig(input) ? input.raw : input; + const authCollection = pluginOptions.authCollection || "users"; + const subFieldName = + overrides.subFieldName || + pluginOptions.subField?.name || + pluginOptions.subFieldName || + "sub"; + const callbackPath = pluginOptions.callbackPath || "/oauth/callback"; + const authorizePath = pluginOptions.authorizePath || "/oauth/authorize"; + const serverURL = pluginOptions.serverURL; + const redirectUri = + pluginOptions.authorizeRedirectUri || + `${serverURL}/api/${authCollection}${callbackPath}`; + const useEmailAsIdentity = pluginOptions.useEmailAsIdentity ?? false; + const excludeEmailFromJwtToken = useEmailAsIdentity ? false : true; + + const warnings = [ + pathWarning("authorizePath", authorizePath), + pathWarning("callbackPath", callbackPath), + ].filter((warning): warning is OAuthConfigWarning => warning !== null); + + if (useEmailAsIdentity && pluginOptions.excludeEmailFromJwtToken === true) { + warnings.push({ + option: "excludeEmailFromJwtToken", + message: + "excludeEmailFromJwtToken cannot be true when useEmailAsIdentity is true; signing email into the JWT so auth strategy can identify the user.", + }); + } + + if (!isAbsoluteHttpUrl(serverURL)) { + warnings.push({ + option: "serverURL", + message: `serverURL should be an absolute http(s) URL; keeping configured value "${serverURL}" for backwards compatibility.`, + }); + } + + if (serverURL.endsWith("/")) { + warnings.push({ + option: "serverURL", + message: `serverURL should not have a trailing slash; keeping configured value "${serverURL}" for backwards compatibility.`, + }); + } + + if ( + pluginOptions.authorizeRedirectUri && + !isAbsoluteHttpUrl(pluginOptions.authorizeRedirectUri) + ) { + warnings.push({ + option: "authorizeRedirectUri", + message: `authorizeRedirectUri should be an absolute http(s) URL; keeping configured value "${pluginOptions.authorizeRedirectUri}" for backwards compatibility.`, + }); + } + + if (pluginOptions.providerAuthorizationUrl.endsWith("/")) { + warnings.push({ + option: "providerAuthorizationUrl", + message: `providerAuthorizationUrl should not have a trailing slash; keeping configured value "${pluginOptions.providerAuthorizationUrl}" for backwards compatibility.`, + }); + } + + return { + raw: pluginOptions, + warnings, + strategyName: pluginOptions.strategyName, + useEmailAsIdentity, + excludeEmailFromJwtToken, + serverURL, + authCollection, + subFieldName, + clientId: pluginOptions.clientId, + clientSecret: pluginOptions.clientSecret, + tokenEndpoint: pluginOptions.tokenEndpoint, + providerAuthorizationUrl: pluginOptions.providerAuthorizationUrl, + authorizeRedirectUri: pluginOptions.authorizeRedirectUri, + redirectUri, + getUserInfo: pluginOptions.getUserInfo, + callbackExtractToken: + pluginOptions.callbackExtractToken || defaultCallbackExtractToken, + onUserNotFoundBehavior: pluginOptions.onUserNotFoundBehavior || "create", + scopes: pluginOptions.scopes, + scope: pluginOptions.scopes.join(" "), + authorizePath, + callbackPath, + prompt: pluginOptions.prompt, + authType: pluginOptions.authType, + responseMode: pluginOptions.responseMode, + getToken: pluginOptions.getToken, + successRedirect: pluginOptions.successRedirect, + failureRedirect: pluginOptions.failureRedirect, + pkceEnabled: pluginOptions.pkceEnabled ?? false, + getPkceCodes: pluginOptions.getPkceCodes || defaultGetPkceCodes, + }; +}; + +export const warnOAuthConfig = ( + config: ResolvedOAuthConfig, + warn: (message: string) => void = console.warn, +): void => { + config.warnings.forEach((warning) => { + warn(`OAuth2Plugin config warning: ${warning.message}`); + }); +}; diff --git a/src/oauth-identity.ts b/src/oauth-identity.ts new file mode 100644 index 0000000..58ed416 --- /dev/null +++ b/src/oauth-identity.ts @@ -0,0 +1,123 @@ +import crypto from "node:crypto"; +import type { + CollectionSlug, + JsonObject, + PaginatedDocs, + Payload, + PayloadRequest, + TypeWithID, + User, +} from "payload"; +import type { ResolvedOAuthConfig } from "./oauth-config"; + +export type OAuthIdentity = { + field: string; + value: string; +}; + +type IdentitySource = "provider user info" | "jwt token"; + +type FindUserArgs = { + payload: Pick; + req?: PayloadRequest; + collection: CollectionSlug; + identity: OAuthIdentity; + showHiddenFields?: boolean; +}; + +type EnsureCallbackUserArgs = { + req: PayloadRequest; + config: ResolvedOAuthConfig; + userInfo: Record; +}; + +export const getOAuthIdentity = ( + config: ResolvedOAuthConfig, + source: Record, + sourceLabel: IdentitySource, +): OAuthIdentity => { + if (config.useEmailAsIdentity) { + if (typeof source.email !== "string" || source.email.length === 0) { + throw new Error(`Email not found in ${sourceLabel}`); + } + return { field: "email", value: source.email }; + } + + const providerSubject = source[config.subFieldName]; + if (typeof providerSubject !== "string" || providerSubject.length === 0) { + throw new Error(`No ${config.subFieldName} found in ${sourceLabel}`); + } + return { field: config.subFieldName, value: providerSubject }; +}; + +export const tryGetOAuthIdentity = ( + config: ResolvedOAuthConfig, + source: Record, + sourceLabel: IdentitySource, +): OAuthIdentity | null => { + try { + return getOAuthIdentity(config, source, sourceLabel); + } catch { + return null; + } +}; + +export const findUserByOAuthIdentity = async ({ + payload, + req, + collection, + identity, + showHiddenFields, +}: FindUserArgs): Promise> => + payload.find({ + ...(req ? { req } : {}), + collection, + where: { [identity.field]: { equals: identity.value } }, + ...(showHiddenFields === undefined ? {} : { showHiddenFields }), + limit: 1, + }); + +export const ensureCallbackUser = async ({ + req, + config, + userInfo, +}: EnsureCallbackUserArgs): Promise => { + const collection = config.authCollection as CollectionSlug; + const identity = getOAuthIdentity(config, userInfo, "provider user info"); + const existingUser = await findUserByOAuthIdentity({ + req, + payload: req.payload, + collection, + identity, + showHiddenFields: true, + }); + + const user = existingUser.docs[0] as User | undefined; + if (user) { + user.collection = collection; + return user; + } + + if (config.onUserNotFoundBehavior === "error") { + throw new Error(`User not found: ${identity.value}`); + } + + if (config.onUserNotFoundBehavior !== "create") { + throw new Error( + `Invalid onUserNotFoundBehavior: ${config.onUserNotFoundBehavior}`, + ); + } + + const result = await req.payload.create({ + req, + collection, + data: { + ...userInfo, + password: crypto.randomBytes(32).toString("hex"), + collection, + }, + showHiddenFields: true, + }); + + return result as unknown as User; +}; diff --git a/src/plugin.ts b/src/plugin.ts index ef04ea8..38207bc 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,5 +1,6 @@ import type { Plugin } from "payload"; import { modifyAuthCollection } from "./modify-auth-collection"; +import { resolveOAuthConfig, warnOAuthConfig } from "./oauth-config"; import type { PluginOptions } from "./types"; export const OAuth2Plugin = @@ -11,29 +12,28 @@ export const OAuth2Plugin = return config; } + const oauthConfig = resolveOAuthConfig(pluginOptions); + warnOAuthConfig(oauthConfig); + // ///////////////////////////////////// // Modify auth collection // ///////////////////////////////////// - const authCollectionSlug = pluginOptions.authCollection || "users"; - const subFieldName = - pluginOptions.subField?.name || pluginOptions.subFieldName || "sub"; const authCollection = config.collections?.find( - (collection) => collection.slug === authCollectionSlug, + (collection) => collection.slug === oauthConfig.authCollection, ); if (!authCollection) { throw new Error( - `The collection with the slug "${authCollectionSlug}" was not found.`, + `The collection with the slug "${oauthConfig.authCollection}" was not found.`, ); } const modifiedAuthCollection = modifyAuthCollection( - pluginOptions, + oauthConfig, authCollection, - subFieldName, ); config.collections = [ ...(config.collections?.filter( - (collection) => collection.slug !== authCollectionSlug, + (collection) => collection.slug !== oauthConfig.authCollection, ) || []), modifiedAuthCollection, ]; diff --git a/test/__mocks__/payload.ts b/test/__mocks__/payload.ts index f0c03d0..1972cad 100644 --- a/test/__mocks__/payload.ts +++ b/test/__mocks__/payload.ts @@ -160,8 +160,10 @@ export function getFieldsToSign(options: { sid?: string; user: Record | null; }): Record { + const user = options.user ?? {}; return { - ...(options.user ?? {}), + id: user.id, + collection: user.collection, email: options.email, ...(options.sid ? { sid: options.sid } : {}), }; diff --git a/test/callback-endpoint.spec.ts b/test/callback-endpoint.spec.ts index 44db9a5..60d28b9 100644 --- a/test/callback-endpoint.spec.ts +++ b/test/callback-endpoint.spec.ts @@ -141,7 +141,7 @@ describe("Callback endpoint unit flow", () => { ); }); - it("updates an existing user through the callback endpoint interface", async () => { + it("reuses an existing user without updating provider profile data", async () => { const existingUser = { id: "existing-user-id", email: "existing-user@example.com", @@ -163,23 +163,17 @@ describe("Callback endpoint unit flow", () => { expect(response.status).toBe(302); expect(req.payload.create).not.toHaveBeenCalled(); - expect(req.payload.update).toHaveBeenCalledWith( - expect.objectContaining({ - collection: "users", - id: "existing-user-id", - data: expect.objectContaining({ - email: existingUser.email, - sub: existingUser.sub, - collection: "users", - name: "Updated User", - }), - showHiddenFields: true, - }), - ); + expect(req.payload.update).not.toHaveBeenCalled(); expect(pluginOptions.successRedirect).toHaveBeenCalledWith( req, expect.any(String), ); + expect(req.user).toEqual( + expect.objectContaining({ + id: "existing-user-id", + collection: "users", + }), + ); }); it("uses the provider subject as identity when email identity is disabled", async () => { @@ -233,6 +227,45 @@ describe("Callback endpoint unit flow", () => { ); }); + it("authenticates the callback JWT when provider subject is the identity", async () => { + const context = createMockOAuthTestContext({ foundUsers: [] }); + const req = createCallbackRequest(context); + let issuedToken = ""; + const pluginOptions = basePluginOptions({ + useEmailAsIdentity: false, + successRedirect: jest.fn((_req, token) => { + issuedToken = token; + return "/admin"; + }), + }); + + const response = (await getGetCallbackHandler(pluginOptions)( + req, + )) as Response; + expect(response.status).toBe(302); + expect(context.createdUser).toBeTruthy(); + + const { payload: jwtPayload } = await jwtVerify( + issuedToken, + new TextEncoder().encode(req.payload.secret), + ); + expect(jwtPayload.sub).toBe("provider-user-123"); + + context.foundUsers = [context.createdUser!]; + const authStrategy = createAuthStrategy(pluginOptions, "sub"); + const result = await authStrategy.authenticate({ + headers: new Headers({ Authorization: `Bearer ${issuedToken}` }), + payload: req.payload, + }); + + expect(result.user).toEqual( + expect.objectContaining({ + collection: "users", + sub: "provider-user-123", + }), + ); + }); + it("creates a Payload session and signs its sid when sessions are enabled", async () => { const context = createMockOAuthTestContext({ foundUsers: [] }); const req = createCallbackRequest(context); @@ -368,6 +401,81 @@ describe("Callback endpoint unit flow", () => { expect(req.payload.create).not.toHaveBeenCalled(); }); + it("keeps email in callback JWT when email identity conflicts with email exclusion", async () => { + const context = createMockOAuthTestContext({ foundUsers: [] }); + const req = createCallbackRequest(context); + let issuedToken = ""; + const pluginOptions = basePluginOptions({ + excludeEmailFromJwtToken: true, + successRedirect: jest.fn((_req, token) => { + issuedToken = token; + return "/admin"; + }), + }); + + const response = (await getGetCallbackHandler(pluginOptions)( + req, + )) as Response; + expect(response.status).toBe(302); + expect(context.createdUser).toBeTruthy(); + + context.foundUsers = [context.createdUser!]; + const authStrategy = createAuthStrategy(pluginOptions, "sub"); + const result = await authStrategy.authenticate({ + headers: new Headers({ Authorization: `Bearer ${issuedToken}` }), + payload: req.payload, + }); + + expect(result.user).toEqual( + expect.objectContaining({ email: "new-user@example.com" }), + ); + }); + + it("does not create missing users from the auth strategy", async () => { + const context = createMockOAuthTestContext({ foundUsers: [] }); + const req = createCallbackRequest(context); + let issuedToken = ""; + const pluginOptions = basePluginOptions({ + successRedirect: jest.fn((_req, token) => { + issuedToken = token; + return "/admin"; + }), + }); + + const response = (await getGetCallbackHandler(pluginOptions)( + req, + )) as Response; + expect(response.status).toBe(302); + expect(context.createdUser).toBeTruthy(); + + context.foundUsers = []; + (req.payload.create as jest.Mock).mockClear(); + const authStrategy = createAuthStrategy(pluginOptions, "sub"); + const result = await authStrategy.authenticate({ + headers: new Headers({ Authorization: `Bearer ${issuedToken}` }), + payload: req.payload, + }); + + expect(result.user).toBeNull(); + expect(req.payload.create).not.toHaveBeenCalled(); + expect(req.payload.logger.warn).toHaveBeenCalledWith( + "OAuth user not found in users for email: new-user@example.com", + ); + }); + + it("surfaces invalid JWT errors from the auth strategy", async () => { + const context = createMockOAuthTestContext(); + const req = createCallbackRequest(context); + const authStrategy = createAuthStrategy(basePluginOptions(), "sub"); + + await expect( + authStrategy.authenticate({ + headers: new Headers({ Authorization: "Bearer invalid-jwt" }), + payload: req.payload, + }), + ).rejects.toThrow(); + }); + it("passes PKCE verifier cookie into the default token exchange", async () => { const context = createMockOAuthTestContext({ foundUsers: [] }); const req = createCallbackRequest(context); @@ -509,4 +617,27 @@ describe("Callback endpoint unit flow", () => { expect.any(Error), ); }); + + it("redirects to failure when missing user behavior is invalid", async () => { + const context = createMockOAuthTestContext({ foundUsers: [] }); + const req = createCallbackRequest(context); + const pluginOptions = basePluginOptions({ + onUserNotFoundBehavior: "skip" as PluginOptions["onUserNotFoundBehavior"], + }); + + const response = (await getGetCallbackHandler(pluginOptions)( + req, + )) as Response; + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toContain( + encodeURIComponent("Invalid onUserNotFoundBehavior: skip"), + ); + expect(req.payload.create).not.toHaveBeenCalled(); + expect(pluginOptions.successRedirect).not.toHaveBeenCalled(); + expect(pluginOptions.failureRedirect).toHaveBeenCalledWith( + req, + expect.any(Error), + ); + }); }); diff --git a/test/google-oauth-test.ts b/test/google-oauth-test.ts deleted file mode 100644 index 1468891..0000000 --- a/test/google-oauth-test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import type { PayloadRequest } from "payload"; -import type { PluginOptions } from "../src/types"; -import { - BaseOAuthTestSuite, - MockUserInfo, - OAuthTestContext, -} from "./base-oauth-test"; - -/** - * Google OAuth test configuration - */ -export const GOOGLE_TEST_CONFIG = { - clientId: "test-google-client-id", - clientSecret: "test-google-client-secret", - serverURL: "http://localhost:3000", - tokenEndpoint: "https://oauth2.googleapis.com/token", - providerAuthorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", - userinfoEndpoint: "https://www.googleapis.com/oauth2/v3/userinfo", - scopes: [ - "openid", - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/userinfo.profile", - ], -}; - -/** - * Default mock user info for Google OAuth tests - */ -export const DEFAULT_GOOGLE_MOCK_USER: MockUserInfo = { - email: "test@gmail.com", - sub: "google-user-123456789", - name: "Test User", - picture: "https://example.com/photo.jpg", - email_verified: true, -}; - -/** - * Google-specific OAuth test suite - */ -export class GoogleOAuthTestSuite extends BaseOAuthTestSuite { - protected createDefaultContext(): OAuthTestContext { - return { - mockUserInfo: { ...DEFAULT_GOOGLE_MOCK_USER }, - mockTokenResponse: { - access_token: "mock-google-access-token", - token_type: "Bearer", - expires_in: 3600, - refresh_token: "mock-google-refresh-token", - }, - mockAuthorizationCode: "mock-google-auth-code", - createdUser: null, - updatedUser: null, - foundUsers: [], - }; - } - - protected getProviderName(): string { - return "google"; - } - - protected getPluginOptions(): PluginOptions { - return { - enabled: true, - strategyName: "google", - useEmailAsIdentity: true, - serverURL: GOOGLE_TEST_CONFIG.serverURL, - clientId: GOOGLE_TEST_CONFIG.clientId, - clientSecret: GOOGLE_TEST_CONFIG.clientSecret, - authorizePath: "/oauth/google", - callbackPath: "/oauth/google/callback", - authCollection: "users", - tokenEndpoint: GOOGLE_TEST_CONFIG.tokenEndpoint, - scopes: GOOGLE_TEST_CONFIG.scopes, - providerAuthorizationUrl: GOOGLE_TEST_CONFIG.providerAuthorizationUrl, - getUserInfo: async (accessToken: string, _req: PayloadRequest) => { - // In real tests, this would call fetch which is mocked - const response = await fetch(GOOGLE_TEST_CONFIG.userinfoEndpoint, { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - const user = await response.json(); - return { email: user.email, sub: user.sub }; - }, - successRedirect: (_req: PayloadRequest, _accessToken?: string) => { - return "/admin"; - }, - failureRedirect: (_req: PayloadRequest, _err?: unknown) => { - return "/admin/login"; - }, - }; - } - - /** - * Get plugin options with custom overrides - */ - getPluginOptionsWithOverrides( - overrides: Partial, - ): PluginOptions { - return { - ...this.getPluginOptions(), - ...overrides, - }; - } -} - -/** - * Creates Google-specific mock fetch that simulates Google OAuth endpoints - */ -export function createGoogleMockFetch(context: OAuthTestContext) { - return jest - .fn() - .mockImplementation(async (url: string, options?: RequestInit) => { - // Mock Google token endpoint - if (url.includes("oauth2.googleapis.com/token")) { - // Verify the request includes required parameters - if (options?.method === "POST" && options?.body) { - const body = options.body.toString(); - if (!body.includes("code=") || !body.includes("client_id=")) { - return new Response(JSON.stringify({ error: "invalid_request" }), { - status: 400, - }); - } - } - return new Response(JSON.stringify(context.mockTokenResponse), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - - // Mock Google userinfo endpoint - if (url.includes("googleapis.com/oauth2/v3/userinfo")) { - const authHeader = (options?.headers as Record) - ?.Authorization; - if (!authHeader?.startsWith("Bearer ")) { - return new Response(JSON.stringify({ error: "unauthorized" }), { - status: 401, - }); - } - return new Response(JSON.stringify(context.mockUserInfo), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - - // Default 404 for unknown endpoints - return new Response(JSON.stringify({ error: "Not found" }), { - status: 404, - headers: { "Content-Type": "application/json" }, - }); - }); -} diff --git a/test/google.spec.ts b/test/google.spec.ts deleted file mode 100644 index 114613d..0000000 --- a/test/google.spec.ts +++ /dev/null @@ -1,421 +0,0 @@ -import type { CollectionConfig, Config } from "payload"; -import { createAuthorizeEndpoint } from "../src/authorize-endpoint"; -import { defaultCallbackExtractToken } from "../src/default-callback-extract-token"; -import { defaultGetToken } from "../src/default-get-token"; -import { OAuth2Plugin } from "../src/plugin"; -import { - assertAuthorizeRedirect, - createMockPayload, - createMockPayloadRequest, -} from "./base-oauth-test"; -import { - DEFAULT_GOOGLE_MOCK_USER, - GOOGLE_TEST_CONFIG, - GoogleOAuthTestSuite, - createGoogleMockFetch, -} from "./google-oauth-test"; - -describe("Google OAuth2 Plugin", () => { - const testSuite = new GoogleOAuthTestSuite(); - - beforeAll(() => { - testSuite.beforeAll(); - }); - - afterAll(() => { - testSuite.afterAll(); - }); - - beforeEach(() => { - testSuite.beforeEach(); - }); - - describe("Plugin Initialization", () => { - it("should return config unchanged when plugin is disabled", () => { - const mockConfig: Config = { - collections: [ - { - slug: "users", - auth: true, - fields: [], - } as unknown as CollectionConfig, - ], - } as Config; - - const plugin = OAuth2Plugin({ - ...testSuite["getPluginOptions"](), - enabled: false, - }); - - const result = plugin(mockConfig); - expect(result).toEqual(mockConfig); - }); - - it("should throw error when auth collection is not found", () => { - const mockConfig: Config = { - collections: [ - { slug: "posts", fields: [] } as unknown as CollectionConfig, - ], - } as Config; - - const plugin = OAuth2Plugin({ - ...testSuite["getPluginOptions"](), - authCollection: "users", - }); - - expect(() => plugin(mockConfig)).toThrow( - 'The collection with the slug "users" was not found.', - ); - }); - - it("should modify auth collection when plugin is enabled", () => { - const mockConfig: Config = { - collections: [ - { - slug: "users", - auth: true, - fields: [{ name: "email", type: "email" }], - } as unknown as CollectionConfig, - ], - } as Config; - - const plugin = OAuth2Plugin(testSuite["getPluginOptions"]()); - const result = plugin(mockConfig); - - const usersCollection = result.collections?.find( - (c) => c.slug === "users", - ); - expect(usersCollection).toBeDefined(); - expect(usersCollection?.endpoints).toBeDefined(); - }); - }); - - describe("Authorize Endpoint", () => { - it("should redirect to Google authorization URL", async () => { - const pluginOptions = testSuite["getPluginOptions"](); - const authorizeEndpoint = createAuthorizeEndpoint(pluginOptions); - const mockRequest = testSuite.createAuthorizeRequest(); - - const response = await authorizeEndpoint.handler(mockRequest); - - assertAuthorizeRedirect(response as Response, { - client_id: GOOGLE_TEST_CONFIG.clientId, - response_type: "code", - scope: GOOGLE_TEST_CONFIG.scopes.join(" "), - }); - }); - - it("should not redirect Next.js RSC navigation probes", async () => { - const pluginOptions = testSuite["getPluginOptions"](); - const authorizeEndpoint = createAuthorizeEndpoint(pluginOptions); - const mockRequest = testSuite.createAuthorizeRequest(); - mockRequest.headers.set("RSC", "1"); - mockRequest.headers.set("Next-Router-State-Tree", "[]"); - - const response = await authorizeEndpoint.handler(mockRequest); - - expect((response as Response).status).toBe(204); - expect((response as Response).headers.get("Location")).toBeNull(); - }); - - it("should include state parameter when provided", async () => { - const pluginOptions = testSuite["getPluginOptions"](); - const authorizeEndpoint = createAuthorizeEndpoint(pluginOptions); - const mockRequest = testSuite.createAuthorizeRequest("custom-state-123"); - - const response = await authorizeEndpoint.handler(mockRequest); - const location = (response as Response).headers.get("Location"); - const url = new URL(location!); - - expect(url.searchParams.get("state")).toBe("custom-state-123"); - }); - - it("should use correct redirect URI", async () => { - const pluginOptions = testSuite["getPluginOptions"](); - const authorizeEndpoint = createAuthorizeEndpoint(pluginOptions); - const mockRequest = testSuite.createAuthorizeRequest(); - - const response = await authorizeEndpoint.handler(mockRequest); - const location = (response as Response).headers.get("Location"); - const url = new URL(location!); - - expect(url.searchParams.get("redirect_uri")).toBe( - `${GOOGLE_TEST_CONFIG.serverURL}/api/users/oauth/google/callback`, - ); - }); - - it("should include prompt parameter when configured", async () => { - const pluginOptions = testSuite["getPluginOptionsWithOverrides"]({ - prompt: "consent", - }); - const authorizeEndpoint = createAuthorizeEndpoint(pluginOptions); - const mockRequest = testSuite.createAuthorizeRequest(); - - const response = await authorizeEndpoint.handler(mockRequest); - const location = (response as Response).headers.get("Location"); - const url = new URL(location!); - - expect(url.searchParams.get("prompt")).toBe("consent"); - }); - }); - - describe("Token Retrieval", () => { - it("should successfully retrieve access token from Google", async () => { - const mockFetch = createGoogleMockFetch(testSuite["context"]); - global.fetch = mockFetch; - - const token = await defaultGetToken( - GOOGLE_TEST_CONFIG.tokenEndpoint, - GOOGLE_TEST_CONFIG.clientId, - GOOGLE_TEST_CONFIG.clientSecret, - `${GOOGLE_TEST_CONFIG.serverURL}/api/users/oauth/google/callback`, - "mock-auth-code", - ); - - expect(token).toBe("mock-google-access-token"); - expect(mockFetch).toHaveBeenCalledWith( - GOOGLE_TEST_CONFIG.tokenEndpoint, - expect.objectContaining({ - method: "POST", - headers: expect.objectContaining({ - "Content-Type": "application/x-www-form-urlencoded", - }), - }), - ); - }); - - it("should throw error when access token is missing", async () => { - testSuite.setMockTokenResponse({ - access_token: undefined as unknown as string, - token_type: "Bearer", - }); - const mockFetch = createGoogleMockFetch(testSuite["context"]); - global.fetch = mockFetch; - - await expect( - defaultGetToken( - GOOGLE_TEST_CONFIG.tokenEndpoint, - GOOGLE_TEST_CONFIG.clientId, - GOOGLE_TEST_CONFIG.clientSecret, - `${GOOGLE_TEST_CONFIG.serverURL}/api/users/oauth/google/callback`, - "mock-auth-code", - ), - ).rejects.toThrow("No access token"); - }); - }); - - describe("Callback Code Extraction", () => { - it("should extract code from GET request query params", async () => { - const mockRequest = testSuite.createCallbackRequest("test-auth-code"); - const code = await defaultCallbackExtractToken(mockRequest); - expect(code).toBe("test-auth-code"); - }); - - it("should extract code from POST request form data", async () => { - const mockRequest = testSuite.createPostCallbackRequest("post-auth-code"); - const code = await defaultCallbackExtractToken(mockRequest); - expect(code).toBe("post-auth-code"); - }); - - it("should throw error when code is missing in GET request", async () => { - const mockPayload = createMockPayload(); - const mockRequest = { - payload: mockPayload, - headers: new Headers(), - searchParams: new URLSearchParams(), - query: {}, - method: "GET", - context: {}, - user: null, - }; - - await expect( - defaultCallbackExtractToken(mockRequest as any), - ).rejects.toThrow("Code not found"); - }); - }); - - describe("User Info Retrieval", () => { - it("should fetch user info from Google using access token", async () => { - const mockFetch = createGoogleMockFetch(testSuite["context"]); - global.fetch = mockFetch; - - const pluginOptions = testSuite["getPluginOptions"](); - const mockRequest = createMockPayloadRequest(); - - const userInfo = await pluginOptions.getUserInfo( - "mock-access-token", - mockRequest, - ); - - expect(userInfo.email).toBe(DEFAULT_GOOGLE_MOCK_USER.email); - expect(userInfo.sub).toBe(DEFAULT_GOOGLE_MOCK_USER.sub); - }); - - it("should include authorization header in userinfo request", async () => { - const mockFetch = createGoogleMockFetch(testSuite["context"]); - global.fetch = mockFetch; - - const pluginOptions = testSuite["getPluginOptions"](); - const mockRequest = createMockPayloadRequest(); - - await pluginOptions.getUserInfo("test-token-123", mockRequest); - - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining("userinfo"), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer test-token-123", - }), - }), - ); - }); - }); - - describe("User Creation and Update", () => { - it("should create new user when user does not exist", async () => { - testSuite.setExistingUsers([]); - - const mockPayload = createMockPayload(testSuite["context"]); - - // Simulate user creation - await mockPayload.create({ - collection: "users", - data: { - email: DEFAULT_GOOGLE_MOCK_USER.email, - sub: DEFAULT_GOOGLE_MOCK_USER.sub, - password: "random-password", - }, - }); - - const createdUser = testSuite.getCreatedUser(); - expect(createdUser).toBeDefined(); - expect(createdUser?.email).toBe(DEFAULT_GOOGLE_MOCK_USER.email); - }); - - it("should update existing user when user is found", async () => { - const existingUser = { - id: "existing-user-id", - email: DEFAULT_GOOGLE_MOCK_USER.email, - sub: DEFAULT_GOOGLE_MOCK_USER.sub, - }; - testSuite.setExistingUsers([existingUser]); - - const mockPayload = createMockPayload(testSuite["context"]); - - // Simulate user update - await mockPayload.update({ - collection: "users", - id: "existing-user-id", - data: { - email: DEFAULT_GOOGLE_MOCK_USER.email, - sub: DEFAULT_GOOGLE_MOCK_USER.sub, - }, - }); - - const updatedUser = testSuite.getUpdatedUser(); - expect(updatedUser).toBeDefined(); - expect(updatedUser?.id).toBe("existing-user-id"); - }); - - it("should use email as identity when configured", async () => { - const pluginOptions = testSuite["getPluginOptionsWithOverrides"]({ - useEmailAsIdentity: true, - }); - - expect(pluginOptions.useEmailAsIdentity).toBe(true); - }); - - it("should use sub field as identity when email identity is disabled", async () => { - const pluginOptions = testSuite["getPluginOptionsWithOverrides"]({ - useEmailAsIdentity: false, - }); - - expect(pluginOptions.useEmailAsIdentity).toBe(false); - }); - }); - - describe("Redirect Handling", () => { - it("should redirect to success URL after successful login", () => { - const pluginOptions = testSuite["getPluginOptions"](); - const mockRequest = createMockPayloadRequest(); - - const successUrl = pluginOptions.successRedirect( - mockRequest, - "access-token", - ); - expect(successUrl).toBe("/admin"); - }); - - it("should redirect to failure URL on error", () => { - const pluginOptions = testSuite["getPluginOptions"](); - const mockRequest = createMockPayloadRequest(); - - const failureUrl = pluginOptions.failureRedirect( - mockRequest, - new Error("Test error"), - ); - expect(failureUrl).toBe("/admin/login"); - }); - }); - - describe("PKCE Flow", () => { - it("should include PKCE parameters when enabled", async () => { - const pluginOptions = testSuite["getPluginOptionsWithOverrides"]({ - pkceEnabled: true, - }); - const authorizeEndpoint = createAuthorizeEndpoint(pluginOptions); - const mockRequest = testSuite.createAuthorizeRequest(); - - const response = await authorizeEndpoint.handler(mockRequest); - const location = (response as Response).headers.get("Location"); - const url = new URL(location!); - - expect(url.searchParams.get("code_challenge")).toBeTruthy(); - expect(url.searchParams.get("code_challenge_method")).toBe("S256"); - }); - - it("should set PKCE verifier cookie when PKCE is enabled", async () => { - const pluginOptions = testSuite["getPluginOptionsWithOverrides"]({ - pkceEnabled: true, - }); - const authorizeEndpoint = createAuthorizeEndpoint(pluginOptions); - const mockRequest = testSuite.createAuthorizeRequest(); - - const response = await authorizeEndpoint.handler(mockRequest); - const setCookie = (response as Response).headers.get("Set-Cookie"); - - expect(setCookie).toContain("pkce_verifier"); - }); - }); - - describe("Error Handling", () => { - it("should handle missing authorization code gracefully", async () => { - const mockRequest = createMockPayloadRequest({ - query: {}, - method: "GET", - }); - - await expect(defaultCallbackExtractToken(mockRequest)).rejects.toThrow(); - }); - - it("should handle token endpoint errors", async () => { - const errorFetch = jest.fn().mockResolvedValue( - new Response(JSON.stringify({ error: "invalid_grant" }), { - status: 400, - }), - ); - global.fetch = errorFetch; - - await expect( - defaultGetToken( - GOOGLE_TEST_CONFIG.tokenEndpoint, - GOOGLE_TEST_CONFIG.clientId, - GOOGLE_TEST_CONFIG.clientSecret, - `${GOOGLE_TEST_CONFIG.serverURL}/api/users/oauth/google/callback`, - "invalid-code", - ), - ).rejects.toThrow("No access token"); - }); - }); -}); diff --git a/test/mocked-provider-integration.spec.ts b/test/mocked-provider-integration.spec.ts index b4a8ae9..fca051f 100644 --- a/test/mocked-provider-integration.spec.ts +++ b/test/mocked-provider-integration.spec.ts @@ -225,7 +225,7 @@ describe("Mocked external provider integration", () => { ); }); - it("updates an existing user with mocked provider info", async () => { + it("reuses an existing user without updating mocked provider info", async () => { const usersCollection = buildPluginCollection(provider); const callbackEndpoint = findEndpoint( usersCollection.endpoints, @@ -252,15 +252,11 @@ describe("Mocked external provider integration", () => { expect(callbackResponse.status).toBe(302); expect(callbackRequest.payload.create).not.toHaveBeenCalled(); - expect(callbackRequest.payload.update).toHaveBeenCalledWith( + expect(callbackRequest.payload.update).not.toHaveBeenCalled(); + expect(callbackRequest.user).toEqual( expect.objectContaining({ - collection: "users", id: existingUser.id, - data: expect.objectContaining({ - email: provider.userInfo.email, - sub: provider.userInfo.sub, - collection: "users", - }), + collection: "users", }), ); }); diff --git a/test/plugin-wiring.spec.ts b/test/plugin-wiring.spec.ts index 4c123a7..54bb805 100644 --- a/test/plugin-wiring.spec.ts +++ b/test/plugin-wiring.spec.ts @@ -20,6 +20,17 @@ const pluginOptions = (): PluginOptions => ({ failureRedirect: jest.fn(), }); +const pluginOptionsWithInvalidLegacyValues = ( + options: PluginOptions, +): PluginOptions => ({ + ...options, + serverURL: "localhost:3000/", + authorizePath: "oauth/idempotent/", + callbackPath: "oauth/idempotent/callback/", + providerAuthorizationUrl: "https://provider.example.test/authorize/", + excludeEmailFromJwtToken: true, +}); + const authCollection = (): CollectionConfig => ({ slug: "users", @@ -80,4 +91,46 @@ describe("OAuth plugin collection wiring", () => { ).toBe(1); expect(countStrategies(usersCollection!, "idempotent-provider")).toBe(1); }); + + it("warns about invalid config invariants while keeping legacy values", () => { + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + const plugin = OAuth2Plugin( + pluginOptionsWithInvalidLegacyValues(pluginOptions()), + ); + + try { + const result = plugin({ collections: [authCollection()] } as Config); + const usersCollection = result.collections?.find( + (collection) => collection.slug === "users", + ); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('authorizePath should start with "/"'), + ); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('callbackPath should start with "/"'), + ); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + "excludeEmailFromJwtToken cannot be true when useEmailAsIdentity is true", + ), + ); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("serverURL should be an absolute http(s) URL"), + ); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + "providerAuthorizationUrl should not have a trailing slash", + ), + ); + expect(countEndpoints(usersCollection!, "oauth/idempotent/", "get")).toBe( + 1, + ); + expect( + countEndpoints(usersCollection!, "oauth/idempotent/callback/", "get"), + ).toBe(1); + } finally { + warn.mockRestore(); + } + }); }); diff --git a/test/test-utils.ts b/test/test-utils.ts deleted file mode 100644 index 0183bbb..0000000 --- a/test/test-utils.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Test Utilities - * - * This module re-exports common test utilities from the base OAuth test module. - * The new test architecture uses mock-based unit tests instead of E2E tests with Puppeteer. - * - * @see base-oauth-test.ts for the main test infrastructure - * @see google-oauth-test.ts for Google-specific test utilities - * @see zitadel-oauth-test.ts for Zitadel-specific test utilities - */ - -// Re-export all utilities from base-oauth-test -export { - BaseOAuthTestSuite, - assertAuthorizeRedirect, - assertCallbackSuccessRedirect, - assertCookieSet, - createMockFetch, - createMockPayload, - createMockPayloadRequest, -} from "./base-oauth-test"; -export type { - MockTokenResponse, - MockUserInfo, - OAuthTestContext, -} from "./base-oauth-test"; - -// Re-export Google test utilities -export { - DEFAULT_GOOGLE_MOCK_USER, - GOOGLE_TEST_CONFIG, - GoogleOAuthTestSuite, - createGoogleMockFetch, -} from "./google-oauth-test"; - -// Re-export Zitadel test utilities -export { - DEFAULT_ZITADEL_MOCK_USER, - ZITADEL_TEST_CONFIG, - ZitadelOAuthTestSuite, - createZitadelMockFetch, -} from "./zitadel-oauth-test"; diff --git a/test/zitadel-oauth-test.ts b/test/zitadel-oauth-test.ts deleted file mode 100644 index c107cb8..0000000 --- a/test/zitadel-oauth-test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import type { PayloadRequest } from "payload"; -import type { PluginOptions } from "../src/types"; -import { - BaseOAuthTestSuite, - MockUserInfo, - OAuthTestContext, -} from "./base-oauth-test"; - -/** - * Zitadel OAuth test configuration - */ -export const ZITADEL_TEST_CONFIG = { - clientId: "test-zitadel-client-id", - clientSecret: "test-zitadel-client-secret", - serverURL: "http://localhost:3000", - tokenEndpoint: "https://test.zitadel.cloud/oauth/v2/token", - providerAuthorizationUrl: "https://test.zitadel.cloud/oauth/v2/authorize", - userinfoEndpoint: "https://test.zitadel.cloud/oidc/v1/userinfo", - scopes: [ - "openid", - "profile", - "email", - "offline_access", - "urn:zitadel:iam:user:metadata", - ], -}; - -/** - * Default mock user info for Zitadel OAuth tests - */ -export const DEFAULT_ZITADEL_MOCK_USER: MockUserInfo = { - email: "test@zitadel-org.com", - sub: "zitadel-user-123456789", - name: "Test Zitadel User", - preferred_username: "testuser", - email_verified: true, -}; - -/** - * Zitadel-specific OAuth test suite - */ -export class ZitadelOAuthTestSuite extends BaseOAuthTestSuite { - protected createDefaultContext(): OAuthTestContext { - return { - mockUserInfo: { ...DEFAULT_ZITADEL_MOCK_USER }, - mockTokenResponse: { - access_token: "mock-zitadel-access-token", - token_type: "Bearer", - expires_in: 3600, - refresh_token: "mock-zitadel-refresh-token", - id_token: "mock-zitadel-id-token", - }, - mockAuthorizationCode: "mock-zitadel-auth-code", - createdUser: null, - updatedUser: null, - foundUsers: [], - }; - } - - protected getProviderName(): string { - return "zitadel"; - } - - protected getPluginOptions(): PluginOptions { - return { - enabled: true, - strategyName: "zitadel", - useEmailAsIdentity: true, - serverURL: ZITADEL_TEST_CONFIG.serverURL, - clientId: ZITADEL_TEST_CONFIG.clientId, - clientSecret: ZITADEL_TEST_CONFIG.clientSecret, - authorizePath: "/oauth/zitadel", - callbackPath: "/oauth/zitadel/callback", - authCollection: "users", - tokenEndpoint: ZITADEL_TEST_CONFIG.tokenEndpoint, - scopes: ZITADEL_TEST_CONFIG.scopes, - providerAuthorizationUrl: ZITADEL_TEST_CONFIG.providerAuthorizationUrl, - getUserInfo: async (accessToken: string, _req: PayloadRequest) => { - // In real tests, this would call fetch which is mocked - const response = await fetch(ZITADEL_TEST_CONFIG.userinfoEndpoint, { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - const user = await response.json(); - return { email: user.email, sub: user.sub }; - }, - successRedirect: (_req: PayloadRequest, _accessToken?: string) => { - return "/admin"; - }, - failureRedirect: (_req: PayloadRequest, _err?: unknown) => { - return "/admin/login"; - }, - }; - } - - /** - * Get plugin options with custom overrides - */ - getPluginOptionsWithOverrides( - overrides: Partial, - ): PluginOptions { - return { - ...this.getPluginOptions(), - ...overrides, - }; - } -} - -/** - * Creates Zitadel-specific mock fetch that simulates Zitadel OAuth endpoints - */ -export function createZitadelMockFetch(context: OAuthTestContext) { - return jest - .fn() - .mockImplementation(async (url: string, options?: RequestInit) => { - // Mock Zitadel token endpoint - if (url.includes("zitadel.cloud/oauth/v2/token")) { - // Verify the request includes required parameters - if (options?.method === "POST" && options?.body) { - const body = options.body.toString(); - if (!body.includes("code=") || !body.includes("client_id=")) { - return new Response(JSON.stringify({ error: "invalid_request" }), { - status: 400, - }); - } - } - return new Response(JSON.stringify(context.mockTokenResponse), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - - // Mock Zitadel userinfo endpoint - if (url.includes("zitadel.cloud/oidc/v1/userinfo")) { - const authHeader = (options?.headers as Record) - ?.Authorization; - if (!authHeader?.startsWith("Bearer ")) { - return new Response(JSON.stringify({ error: "unauthorized" }), { - status: 401, - }); - } - return new Response(JSON.stringify(context.mockUserInfo), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - - // Default 404 for unknown endpoints - return new Response(JSON.stringify({ error: "Not found" }), { - status: 404, - headers: { "Content-Type": "application/json" }, - }); - }); -} diff --git a/test/zitadel.spec.ts b/test/zitadel.spec.ts deleted file mode 100644 index 0e5c4a5..0000000 --- a/test/zitadel.spec.ts +++ /dev/null @@ -1,488 +0,0 @@ -import type { CollectionConfig, Config } from "payload"; -import { createAuthorizeEndpoint } from "../src/authorize-endpoint"; -import { defaultCallbackExtractToken } from "../src/default-callback-extract-token"; -import { defaultGetToken } from "../src/default-get-token"; -import { OAuth2Plugin } from "../src/plugin"; -import { - assertAuthorizeRedirect, - createMockPayload, - createMockPayloadRequest, -} from "./base-oauth-test"; -import { - DEFAULT_ZITADEL_MOCK_USER, - ZITADEL_TEST_CONFIG, - ZitadelOAuthTestSuite, - createZitadelMockFetch, -} from "./zitadel-oauth-test"; - -describe("Zitadel OAuth2 Plugin", () => { - const testSuite = new ZitadelOAuthTestSuite(); - - beforeAll(() => { - testSuite.beforeAll(); - }); - - afterAll(() => { - testSuite.afterAll(); - }); - - beforeEach(() => { - testSuite.beforeEach(); - }); - - describe("Plugin Initialization", () => { - it("should return config unchanged when plugin is disabled", () => { - const mockConfig: Config = { - collections: [ - { - slug: "users", - auth: true, - fields: [], - } as unknown as CollectionConfig, - ], - } as Config; - - const plugin = OAuth2Plugin({ - ...testSuite["getPluginOptions"](), - enabled: false, - }); - - const result = plugin(mockConfig); - expect(result).toEqual(mockConfig); - }); - - it("should throw error when auth collection is not found", () => { - const mockConfig: Config = { - collections: [ - { slug: "posts", fields: [] } as unknown as CollectionConfig, - ], - } as Config; - - const plugin = OAuth2Plugin({ - ...testSuite["getPluginOptions"](), - authCollection: "users", - }); - - expect(() => plugin(mockConfig)).toThrow( - 'The collection with the slug "users" was not found.', - ); - }); - - it("should modify auth collection when plugin is enabled", () => { - const mockConfig: Config = { - collections: [ - { - slug: "users", - auth: true, - fields: [{ name: "email", type: "email" }], - } as unknown as CollectionConfig, - ], - } as Config; - - const plugin = OAuth2Plugin(testSuite["getPluginOptions"]()); - const result = plugin(mockConfig); - - const usersCollection = result.collections?.find( - (c) => c.slug === "users", - ); - expect(usersCollection).toBeDefined(); - expect(usersCollection?.endpoints).toBeDefined(); - }); - }); - - describe("Authorize Endpoint", () => { - it("should redirect to Zitadel authorization URL", async () => { - const pluginOptions = testSuite["getPluginOptions"](); - const authorizeEndpoint = createAuthorizeEndpoint(pluginOptions); - const mockRequest = testSuite.createAuthorizeRequest(); - - const response = await authorizeEndpoint.handler(mockRequest); - - assertAuthorizeRedirect(response as Response, { - client_id: ZITADEL_TEST_CONFIG.clientId, - response_type: "code", - scope: ZITADEL_TEST_CONFIG.scopes.join(" "), - }); - }); - - it("should include state parameter when provided", async () => { - const pluginOptions = testSuite["getPluginOptions"](); - const authorizeEndpoint = createAuthorizeEndpoint(pluginOptions); - const mockRequest = testSuite.createAuthorizeRequest("custom-state-456"); - - const response = await authorizeEndpoint.handler(mockRequest); - const location = (response as Response).headers.get("Location"); - const url = new URL(location!); - - expect(url.searchParams.get("state")).toBe("custom-state-456"); - }); - - it("should use correct redirect URI", async () => { - const pluginOptions = testSuite["getPluginOptions"](); - const authorizeEndpoint = createAuthorizeEndpoint(pluginOptions); - const mockRequest = testSuite.createAuthorizeRequest(); - - const response = await authorizeEndpoint.handler(mockRequest); - const location = (response as Response).headers.get("Location"); - const url = new URL(location!); - - expect(url.searchParams.get("redirect_uri")).toBe( - `${ZITADEL_TEST_CONFIG.serverURL}/api/users/oauth/zitadel/callback`, - ); - }); - - it("should include all Zitadel-specific scopes", async () => { - const pluginOptions = testSuite["getPluginOptions"](); - const authorizeEndpoint = createAuthorizeEndpoint(pluginOptions); - const mockRequest = testSuite.createAuthorizeRequest(); - - const response = await authorizeEndpoint.handler(mockRequest); - const location = (response as Response).headers.get("Location"); - const url = new URL(location!); - - const scopeParam = url.searchParams.get("scope"); - expect(scopeParam).toContain("openid"); - expect(scopeParam).toContain("profile"); - expect(scopeParam).toContain("email"); - expect(scopeParam).toContain("offline_access"); - expect(scopeParam).toContain("urn:zitadel:iam:user:metadata"); - }); - }); - - describe("Token Retrieval", () => { - it("should successfully retrieve access token from Zitadel", async () => { - const mockFetch = createZitadelMockFetch(testSuite["context"]); - global.fetch = mockFetch; - - const token = await defaultGetToken( - ZITADEL_TEST_CONFIG.tokenEndpoint, - ZITADEL_TEST_CONFIG.clientId, - ZITADEL_TEST_CONFIG.clientSecret, - `${ZITADEL_TEST_CONFIG.serverURL}/api/users/oauth/zitadel/callback`, - "mock-auth-code", - ); - - expect(token).toBe("mock-zitadel-access-token"); - expect(mockFetch).toHaveBeenCalledWith( - ZITADEL_TEST_CONFIG.tokenEndpoint, - expect.objectContaining({ - method: "POST", - headers: expect.objectContaining({ - "Content-Type": "application/x-www-form-urlencoded", - }), - }), - ); - }); - - it("should throw error when access token is missing", async () => { - testSuite.setMockTokenResponse({ - access_token: undefined as unknown as string, - token_type: "Bearer", - }); - const mockFetch = createZitadelMockFetch(testSuite["context"]); - global.fetch = mockFetch; - - await expect( - defaultGetToken( - ZITADEL_TEST_CONFIG.tokenEndpoint, - ZITADEL_TEST_CONFIG.clientId, - ZITADEL_TEST_CONFIG.clientSecret, - `${ZITADEL_TEST_CONFIG.serverURL}/api/users/oauth/zitadel/callback`, - "mock-auth-code", - ), - ).rejects.toThrow("No access token"); - }); - - it("should handle refresh token in response", async () => { - const mockFetch = createZitadelMockFetch(testSuite["context"]); - global.fetch = mockFetch; - - // Verify the mock context has refresh token - expect(testSuite["context"].mockTokenResponse.refresh_token).toBe( - "mock-zitadel-refresh-token", - ); - }); - }); - - describe("Callback Code Extraction", () => { - it("should extract code from GET request query params", async () => { - const mockRequest = testSuite.createCallbackRequest( - "test-zitadel-auth-code", - ); - const code = await defaultCallbackExtractToken(mockRequest); - expect(code).toBe("test-zitadel-auth-code"); - }); - - it("should extract code from POST request form data", async () => { - const mockRequest = testSuite.createPostCallbackRequest( - "post-zitadel-auth-code", - ); - const code = await defaultCallbackExtractToken(mockRequest); - expect(code).toBe("post-zitadel-auth-code"); - }); - - it("should throw error when code is missing in GET request", async () => { - const mockPayload = createMockPayload(); - const mockRequest = { - payload: mockPayload, - headers: new Headers(), - searchParams: new URLSearchParams(), - query: {}, - method: "GET", - context: {}, - user: null, - }; - - await expect( - defaultCallbackExtractToken(mockRequest as any), - ).rejects.toThrow("Code not found"); - }); - }); - - describe("User Info Retrieval", () => { - it("should fetch user info from Zitadel using access token", async () => { - const mockFetch = createZitadelMockFetch(testSuite["context"]); - global.fetch = mockFetch; - - const pluginOptions = testSuite["getPluginOptions"](); - const mockRequest = createMockPayloadRequest(); - - const userInfo = await pluginOptions.getUserInfo( - "mock-access-token", - mockRequest, - ); - - expect(userInfo.email).toBe(DEFAULT_ZITADEL_MOCK_USER.email); - expect(userInfo.sub).toBe(DEFAULT_ZITADEL_MOCK_USER.sub); - }); - - it("should include authorization header in userinfo request", async () => { - const mockFetch = createZitadelMockFetch(testSuite["context"]); - global.fetch = mockFetch; - - const pluginOptions = testSuite["getPluginOptions"](); - const mockRequest = createMockPayloadRequest(); - - await pluginOptions.getUserInfo("test-zitadel-token-123", mockRequest); - - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining("userinfo"), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer test-zitadel-token-123", - }), - }), - ); - }); - - it("should handle Zitadel-specific user info fields", async () => { - testSuite.setMockUserInfo({ - email: "test@zitadel.com", - sub: "zitadel-123", - preferred_username: "testuser", - name: "Test User", - email_verified: true, - }); - const mockFetch = createZitadelMockFetch(testSuite["context"]); - global.fetch = mockFetch; - - const response = await fetch(ZITADEL_TEST_CONFIG.userinfoEndpoint, { - headers: { Authorization: "Bearer test-token" }, - }); - const userInfo = await response.json(); - - expect(userInfo.preferred_username).toBe("testuser"); - expect(userInfo.email_verified).toBe(true); - }); - }); - - describe("User Creation and Update", () => { - it("should create new user when user does not exist", async () => { - testSuite.setExistingUsers([]); - - const mockPayload = createMockPayload(testSuite["context"]); - - // Simulate user creation - await mockPayload.create({ - collection: "users", - data: { - email: DEFAULT_ZITADEL_MOCK_USER.email, - sub: DEFAULT_ZITADEL_MOCK_USER.sub, - password: "random-password", - }, - }); - - const createdUser = testSuite.getCreatedUser(); - expect(createdUser).toBeDefined(); - expect(createdUser?.email).toBe(DEFAULT_ZITADEL_MOCK_USER.email); - }); - - it("should update existing user when user is found", async () => { - const existingUser = { - id: "existing-zitadel-user-id", - email: DEFAULT_ZITADEL_MOCK_USER.email, - sub: DEFAULT_ZITADEL_MOCK_USER.sub, - }; - testSuite.setExistingUsers([existingUser]); - - const mockPayload = createMockPayload(testSuite["context"]); - - // Simulate user update - await mockPayload.update({ - collection: "users", - id: "existing-zitadel-user-id", - data: { - email: DEFAULT_ZITADEL_MOCK_USER.email, - sub: DEFAULT_ZITADEL_MOCK_USER.sub, - }, - }); - - const updatedUser = testSuite.getUpdatedUser(); - expect(updatedUser).toBeDefined(); - expect(updatedUser?.id).toBe("existing-zitadel-user-id"); - }); - - it("should use email as identity when configured", async () => { - const pluginOptions = testSuite["getPluginOptionsWithOverrides"]({ - useEmailAsIdentity: true, - }); - - expect(pluginOptions.useEmailAsIdentity).toBe(true); - }); - - it("should use sub field as identity when email identity is disabled", async () => { - const pluginOptions = testSuite["getPluginOptionsWithOverrides"]({ - useEmailAsIdentity: false, - }); - - expect(pluginOptions.useEmailAsIdentity).toBe(false); - }); - }); - - describe("Redirect Handling", () => { - it("should redirect to success URL after successful login", () => { - const pluginOptions = testSuite["getPluginOptions"](); - const mockRequest = createMockPayloadRequest(); - - const successUrl = pluginOptions.successRedirect( - mockRequest, - "access-token", - ); - expect(successUrl).toBe("/admin"); - }); - - it("should redirect to failure URL on error", () => { - const pluginOptions = testSuite["getPluginOptions"](); - const mockRequest = createMockPayloadRequest(); - - const failureUrl = pluginOptions.failureRedirect( - mockRequest, - new Error("Zitadel error"), - ); - expect(failureUrl).toBe("/admin/login"); - }); - }); - - describe("PKCE Flow", () => { - it("should include PKCE parameters when enabled", async () => { - const pluginOptions = testSuite["getPluginOptionsWithOverrides"]({ - pkceEnabled: true, - }); - const authorizeEndpoint = createAuthorizeEndpoint(pluginOptions); - const mockRequest = testSuite.createAuthorizeRequest(); - - const response = await authorizeEndpoint.handler(mockRequest); - const location = (response as Response).headers.get("Location"); - const url = new URL(location!); - - expect(url.searchParams.get("code_challenge")).toBeTruthy(); - expect(url.searchParams.get("code_challenge_method")).toBe("S256"); - }); - - it("should set PKCE verifier cookie when PKCE is enabled", async () => { - const pluginOptions = testSuite["getPluginOptionsWithOverrides"]({ - pkceEnabled: true, - }); - const authorizeEndpoint = createAuthorizeEndpoint(pluginOptions); - const mockRequest = testSuite.createAuthorizeRequest(); - - const response = await authorizeEndpoint.handler(mockRequest); - const setCookie = (response as Response).headers.get("Set-Cookie"); - - expect(setCookie).toContain("pkce_verifier"); - }); - }); - - describe("Error Handling", () => { - it("should handle missing authorization code gracefully", async () => { - const mockRequest = createMockPayloadRequest({ - query: {}, - method: "GET", - }); - - await expect(defaultCallbackExtractToken(mockRequest)).rejects.toThrow(); - }); - - it("should handle token endpoint errors", async () => { - const errorFetch = jest.fn().mockResolvedValue( - new Response(JSON.stringify({ error: "invalid_grant" }), { - status: 400, - }), - ); - global.fetch = errorFetch; - - await expect( - defaultGetToken( - ZITADEL_TEST_CONFIG.tokenEndpoint, - ZITADEL_TEST_CONFIG.clientId, - ZITADEL_TEST_CONFIG.clientSecret, - `${ZITADEL_TEST_CONFIG.serverURL}/api/users/oauth/zitadel/callback`, - "invalid-code", - ), - ).rejects.toThrow("No access token"); - }); - - it("should handle userinfo endpoint errors", async () => { - const errorFetch = jest.fn().mockResolvedValue( - new Response(JSON.stringify({ error: "unauthorized" }), { - status: 401, - }), - ); - global.fetch = errorFetch; - - const response = await fetch(ZITADEL_TEST_CONFIG.userinfoEndpoint, { - headers: { Authorization: "Bearer invalid-token" }, - }); - - expect(response.status).toBe(401); - }); - }); - - describe("Zitadel-Specific Features", () => { - it("should support custom Zitadel endpoints", () => { - const customConfig = { - tokenEndpoint: "https://custom.zitadel.cloud/oauth/v2/token", - providerAuthorizationUrl: - "https://custom.zitadel.cloud/oauth/v2/authorize", - }; - - const pluginOptions = - testSuite["getPluginOptionsWithOverrides"](customConfig); - - expect(pluginOptions.tokenEndpoint).toBe(customConfig.tokenEndpoint); - expect(pluginOptions.providerAuthorizationUrl).toBe( - customConfig.providerAuthorizationUrl, - ); - }); - - it("should support offline_access scope for refresh tokens", () => { - const pluginOptions = testSuite["getPluginOptions"](); - expect(pluginOptions.scopes).toContain("offline_access"); - }); - - it("should support Zitadel metadata scope", () => { - const pluginOptions = testSuite["getPluginOptions"](); - expect(pluginOptions.scopes).toContain("urn:zitadel:iam:user:metadata"); - }); - }); -});