Skip to content

Fix OAuthProxyProviderClient validation #620

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 0 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 8 additions & 2 deletions src/server/auth/handlers/authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion src/server/auth/handlers/revoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
5 changes: 4 additions & 1 deletion src/server/auth/handlers/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
24 changes: 21 additions & 3 deletions src/server/auth/middleware/clientAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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);
Expand All @@ -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
Expand Down
22 changes: 13 additions & 9 deletions src/server/auth/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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<void>;

/**
* 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;
}


Expand Down
25 changes: 22 additions & 3 deletions src/server/auth/providers/proxyProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,20 @@ export type ProxyOptions = {
/**
* Function to fetch client information from the upstream server
*/
getClient: (clientId: string) => Promise<OAuthClientInformationFull | undefined>;
getClient?: (clientId: string) => Promise<OAuthClientInformationFull | undefined>;

/**
* 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;
};

/**
Expand All @@ -44,7 +56,8 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider {
protected readonly _verifyAccessToken: (token: string) => Promise<AuthInfo>;
protected readonly _getClient: (clientId: string) => Promise<OAuthClientInformationFull | undefined>;

skipLocalPkceValidation = true;
skipLocalPkceValidation: boolean;
skipLocalClientValidation: boolean;

revokeToken?: (
client: OAuthClientInformationFull,
Expand All @@ -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,
Expand Down