Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/instructions/oauth-architecture.instructions.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
197 changes: 82 additions & 115 deletions src/auth-strategy.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<AuthStrategyResult> => {
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;
Expand Down
118 changes: 52 additions & 66 deletions src/authorize-endpoint.ts
Original file line number Diff line number Diff line change
@@ -1,83 +1,69 @@
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" ||
req.headers.has("Next-Router-State-Tree") ||
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());
},
};
};
Loading
Loading