diff --git a/README.md b/README.md index c9e27c27..09549221 100644 --- a/README.md +++ b/README.md @@ -725,11 +725,42 @@ app.use(mcpAuthRouter({ })) ``` +#### Stateless Proxy Configuration + +For stateless proxy setups where you don't have local client information stored, you can return `undefined` from `getClient` and set the `skipLocalClientValidation` flag: + +```typescript +const statelessProxyProvider = new ProxyOAuthServerProvider({ + endpoints: { + authorizationUrl: "https://auth.external.com/oauth2/v1/authorize", + tokenUrl: "https://auth.external.com/oauth2/v1/token", + }, + verifyAccessToken: async (token) => { + // Your token verification logic + return { + token, + clientId: "123", + scopes: ["openid", "email", "profile"], + } + }, + getClient: async (client_id) => { + // Return undefined for stateless operation + return undefined; + }, + // Skip local client validation since validation is done upstream + skipLocalClientValidation: true, + // Skip local PKCE validation since validation is done upstream + skipLocalPkceValidation: true +}) +```` + +With this configuration, client validation and PKCE validation are delegated entirely to the upstream OAuth server, allowing for a fully stateless proxy implementation. + This setup allows you to: - Forward OAuth requests to an external provider - Add custom token validation logic -- Manage client registrations +- Manage client registrations (or operate statelessly) - Provide custom documentation URLs - Maintain control over the OAuth flow while delegating to an external provider diff --git a/package.json b/package.json index 6b184f31..e9263c1c 100644 --- a/package.json +++ b/package.json @@ -31,9 +31,6 @@ ] } }, - "files": [ - "dist" - ], "scripts": { "build": "npm run build:esm && npm run build:cjs", "build:esm": "tsc -p tsconfig.prod.json && echo '{\"type\": \"module\"}' > dist/esm/package.json", diff --git a/src/server/auth/handlers/authorize.ts b/src/server/auth/handlers/authorize.ts index 3e9a336b..14562e7c 100644 --- a/src/server/auth/handlers/authorize.ts +++ b/src/server/auth/handlers/authorize.ts @@ -6,12 +6,12 @@ import { rateLimit, Options as RateLimitOptions } from "express-rate-limit"; import { allowedMethods } from "../middleware/allowedMethods.js"; import { InvalidRequestError, - InvalidClientError, InvalidScopeError, ServerError, TooManyRequestsError, OAuthError } from "../errors.js"; +import { OAuthClientInformationFull } from "../../../shared/auth.js"; export type AuthorizationHandlerOptions = { provider: OAuthServerProvider; @@ -74,8 +74,14 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A redirect_uri = result.data.redirect_uri; client = await provider.clientsStore.getClient(client_id); + // If getClient returns undefined, we create a new client with the details from the request + // This skips the client validation, which is useful for proxy providers in which the validation is done by the upstream server if (!client) { - throw new InvalidClientError("Invalid client_id"); + if (provider.skipLocalClientValidation) { + client = { client_id, redirect_uris: [redirect_uri], scope: "" } as OAuthClientInformationFull; + } else { + throw new InvalidRequestError("Invalid client_id"); + } } if (redirect_uri !== undefined) { diff --git a/src/server/auth/handlers/revoke.ts b/src/server/auth/handlers/revoke.ts index 95e8b4b3..8d953a34 100644 --- a/src/server/auth/handlers/revoke.ts +++ b/src/server/auth/handlers/revoke.ts @@ -48,7 +48,10 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo } // Authenticate and extract client details - router.use(authenticateClient({ clientsStore: provider.clientsStore })); + router.use(authenticateClient({ + clientsStore: provider.clientsStore, + allowFallbackClient: provider.skipLocalClientValidation ?? false + })); router.post("/", async (req, res) => { res.setHeader('Cache-Control', 'no-store'); diff --git a/src/server/auth/handlers/token.ts b/src/server/auth/handlers/token.ts index eadbd751..4f6cca2c 100644 --- a/src/server/auth/handlers/token.ts +++ b/src/server/auth/handlers/token.ts @@ -62,7 +62,10 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand } // Authenticate and extract client details - router.use(authenticateClient({ clientsStore: provider.clientsStore })); + router.use(authenticateClient({ + clientsStore: provider.clientsStore, + allowFallbackClient: provider.skipLocalClientValidation ?? false + })); router.post("/", async (req, res) => { res.setHeader('Cache-Control', 'no-store'); diff --git a/src/server/auth/middleware/clientAuth.ts b/src/server/auth/middleware/clientAuth.ts index 76049c11..97d05f9b 100644 --- a/src/server/auth/middleware/clientAuth.ts +++ b/src/server/auth/middleware/clientAuth.ts @@ -9,6 +9,13 @@ export type ClientAuthenticationMiddlewareOptions = { * A store used to read information about registered OAuth clients. */ clientsStore: OAuthRegisteredClientsStore; + + /** + * If true, allows creating clients from request data when they don't exist in the store. + * This is useful for proxy providers where client validation is done upstream. + * Defaults to false for security. + */ + allowFallbackClient?: boolean; } const ClientAuthenticatedRequestSchema = z.object({ @@ -25,7 +32,7 @@ declare module "express-serve-static-core" { } } -export function authenticateClient({ clientsStore }: ClientAuthenticationMiddlewareOptions): RequestHandler { +export function authenticateClient({ clientsStore, allowFallbackClient = false }: ClientAuthenticationMiddlewareOptions): RequestHandler { return async (req, res, next) => { try { const result = ClientAuthenticatedRequestSchema.safeParse(req.body); @@ -34,9 +41,20 @@ export function authenticateClient({ clientsStore }: ClientAuthenticationMiddlew } const { client_id, client_secret } = result.data; - const client = await clientsStore.getClient(client_id); + let client = await clientsStore.getClient(client_id); + if (!client) { - throw new InvalidClientError("Invalid client_id"); + if (allowFallbackClient) { + // Create a minimal client from request data for proxy scenarios + client = { + client_id, + client_secret, + redirect_uris: [], + scope: "" + } as OAuthClientInformationFull; + } else { + throw new InvalidClientError("Invalid client_id"); + } } // If client has a secret, validate it diff --git a/src/server/auth/provider.ts b/src/server/auth/provider.ts index 7815b713..1a269300 100644 --- a/src/server/auth/provider.ts +++ b/src/server/auth/provider.ts @@ -19,6 +19,19 @@ export interface OAuthServerProvider { */ get clientsStore(): OAuthRegisteredClientsStore; + /** + * If true, skips local PKCE validation (useful for proxy providers where validation is done upstream). + * Defaults to false. + */ + skipLocalPkceValidation?: boolean; + + /** + * If true, allows creating clients from request data when they don't exist in the store. + * This is useful for proxy providers where client validation is done upstream. + * Defaults to false for security. + */ + skipLocalClientValidation?: boolean; + /** * Begins the authorization flow, which can either be implemented by this server itself or via redirection to a separate authorization server. * @@ -59,15 +72,6 @@ export interface OAuthServerProvider { * If the given token is invalid or already revoked, this method should do nothing. */ revokeToken?(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise; - - /** - * Whether to skip local PKCE validation. - * - * If true, the server will not perform PKCE validation locally and will pass the code_verifier to the upstream server. - * - * NOTE: This should only be true if the upstream server is performing the actual PKCE validation. - */ - skipLocalPkceValidation?: boolean; } diff --git a/src/server/auth/providers/proxyProvider.ts b/src/server/auth/providers/proxyProvider.ts index db7460e5..2dd55ebd 100644 --- a/src/server/auth/providers/proxyProvider.ts +++ b/src/server/auth/providers/proxyProvider.ts @@ -32,8 +32,20 @@ export type ProxyOptions = { /** * Function to fetch client information from the upstream server */ - getClient: (clientId: string) => Promise; + getClient?: (clientId: string) => Promise; + /** + * If true, skips local PKCE validation (useful for proxy providers where validation is done upstream). + * Defaults to true for proxy providers. + */ + skipLocalPkceValidation?: boolean; + + /** + * If true, allows creating clients from request data when they don't exist in the store. + * This is useful for proxy providers where client validation is done upstream. + * Defaults to true for proxy providers. + */ + skipLocalClientValidation?: boolean; }; /** @@ -44,7 +56,8 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { protected readonly _verifyAccessToken: (token: string) => Promise; protected readonly _getClient: (clientId: string) => Promise; - skipLocalPkceValidation = true; + skipLocalPkceValidation: boolean; + skipLocalClientValidation: boolean; revokeToken?: ( client: OAuthClientInformationFull, @@ -54,7 +67,13 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { constructor(options: ProxyOptions) { this._endpoints = options.endpoints; this._verifyAccessToken = options.verifyAccessToken; - this._getClient = options.getClient; + this._getClient = !options.skipLocalClientValidation + ? options.getClient! : (async () => undefined); + + // Default to true for proxy providers since validation is typically done upstream + this.skipLocalPkceValidation = options.skipLocalPkceValidation ?? true; + this.skipLocalClientValidation = options.skipLocalClientValidation ?? true; + if (options.endpoints?.revocationUrl) { this.revokeToken = async ( client: OAuthClientInformationFull,