Add skipLocalPkceValidation param and create providers folder

This commit is contained in:
Allen Zhou 2025-02-28 13:59:23 -08:00
commit b90192a020
No known key found for this signature in database
GPG key ID: 5817C145DE5C5556
5 changed files with 24 additions and 23 deletions

View file

@ -7,7 +7,7 @@ import supertest from 'supertest';
import * as pkceChallenge from 'pkce-challenge';
import { InvalidGrantError, InvalidTokenError } from '../errors.js';
import { AuthInfo } from '../types.js';
import { ProxyOAuthServerProvider } from '../proxyProvider.js';
import { ProxyOAuthServerProvider } from '../providers/proxyProvider.js';
// Mock pkce-challenge
jest.mock('pkce-challenge', () => ({

View file

@ -14,7 +14,6 @@ import {
TooManyRequestsError,
OAuthError
} from "../errors.js";
import { ProxyOAuthServerProvider } from "../proxyProvider.js";
export type TokenHandlerOptions = {
provider: OAuthServerProvider;
@ -91,9 +90,10 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
const { code, code_verifier } = parseResult.data;
const skipLocalPkceValidation = provider instanceof ProxyOAuthServerProvider ? provider.skipLocalPkceValidation : false;
const skipLocalPkceValidation = provider.skipLocalPkceValidation;
// Perform local PKCE validation unless explicitly delegated (e.g. by proxy provider)
// Perform local PKCE validation unless explicitly skipped
// (e.g. to validate code_verifier in upstream server)
if (!skipLocalPkceValidation) {
const codeChallenge = await provider.challengeForAuthorizationCode(client, code);
if (!(await verifyChallenge(code_verifier, codeChallenge))) {

View file

@ -54,4 +54,13 @@ 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;
}

View file

@ -0,0 +1,286 @@
import { Response } from "express";
import { ProxyOAuthServerProvider, ProxyOptions } from "./proxyProvider.js";
import { AuthInfo } from "../types.js";
import { OAuthClientInformationFull, OAuthTokens } from "../../../shared/auth.js";
import { ServerError } from "../errors.js";
import { InvalidTokenError } from "../errors.js";
import { InsufficientScopeError } from "../errors.js";
describe("Proxy OAuth Server Provider", () => {
// Mock client data
const validClient: OAuthClientInformationFull = {
client_id: "test-client",
client_secret: "test-secret",
redirect_uris: ["https://example.com/callback"],
};
// Mock response object
const mockResponse = {
redirect: jest.fn(),
} as unknown as Response;
// Mock provider functions
const mockVerifyToken = jest.fn();
const mockGetClient = jest.fn();
// Base provider options
const baseOptions: ProxyOptions = {
endpoints: {
authorizationUrl: "https://auth.example.com/authorize",
tokenUrl: "https://auth.example.com/token",
revocationUrl: "https://auth.example.com/revoke",
registrationUrl: "https://auth.example.com/register",
},
verifyAccessToken: mockVerifyToken,
getClient: mockGetClient,
};
let provider: ProxyOAuthServerProvider;
let originalFetch: typeof global.fetch;
beforeEach(() => {
provider = new ProxyOAuthServerProvider(baseOptions);
originalFetch = global.fetch;
global.fetch = jest.fn();
// Setup mock implementations
mockVerifyToken.mockImplementation(async (token: string) => {
if (token === "valid-token") {
return {
token,
clientId: "test-client",
scopes: ["read", "write"],
expiresAt: Date.now() / 1000 + 3600,
} as AuthInfo;
}
throw new InvalidTokenError("Invalid token");
});
mockGetClient.mockImplementation(async (clientId: string) => {
if (clientId === "test-client") {
return validClient;
}
return undefined;
});
});
// Add helper function for failed responses
const mockFailedResponse = () => {
(global.fetch as jest.Mock).mockImplementation(() =>
Promise.resolve({
ok: false,
status: 400,
})
);
};
afterEach(() => {
global.fetch = originalFetch;
jest.clearAllMocks();
});
describe("authorization", () => {
it("redirects to authorization endpoint with correct parameters", async () => {
await provider.authorize(
validClient,
{
redirectUri: "https://example.com/callback",
codeChallenge: "test-challenge",
state: "test-state",
scopes: ["read", "write"],
},
mockResponse
);
const expectedUrl = new URL("https://auth.example.com/authorize");
expectedUrl.searchParams.set("client_id", "test-client");
expectedUrl.searchParams.set("response_type", "code");
expectedUrl.searchParams.set("redirect_uri", "https://example.com/callback");
expectedUrl.searchParams.set("code_challenge", "test-challenge");
expectedUrl.searchParams.set("code_challenge_method", "S256");
expectedUrl.searchParams.set("state", "test-state");
expectedUrl.searchParams.set("scope", "read write");
expect(mockResponse.redirect).toHaveBeenCalledWith(expectedUrl.toString());
});
});
describe("token exchange", () => {
const mockTokenResponse: OAuthTokens = {
access_token: "new-access-token",
token_type: "Bearer",
expires_in: 3600,
refresh_token: "new-refresh-token",
};
beforeEach(() => {
(global.fetch as jest.Mock).mockImplementation(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockTokenResponse),
})
);
});
it("exchanges authorization code for tokens", async () => {
const tokens = await provider.exchangeAuthorizationCode(
validClient,
"test-code",
"test-verifier"
);
expect(global.fetch).toHaveBeenCalledWith(
"https://auth.example.com/token",
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: expect.stringContaining("grant_type=authorization_code")
})
);
expect(tokens).toEqual(mockTokenResponse);
});
it("exchanges refresh token for new tokens", async () => {
const tokens = await provider.exchangeRefreshToken(
validClient,
"test-refresh-token",
["read", "write"]
);
expect(global.fetch).toHaveBeenCalledWith(
"https://auth.example.com/token",
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: expect.stringContaining("grant_type=refresh_token")
})
);
expect(tokens).toEqual(mockTokenResponse);
});
});
describe("client registration", () => {
it("registers new client", async () => {
const newClient: OAuthClientInformationFull = {
client_id: "new-client",
redirect_uris: ["https://new-client.com/callback"],
};
(global.fetch as jest.Mock).mockImplementation(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(newClient),
})
);
const result = await provider.clientsStore.registerClient!(newClient);
expect(global.fetch).toHaveBeenCalledWith(
"https://auth.example.com/register",
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(newClient),
})
);
expect(result).toEqual(newClient);
});
it("handles registration failure", async () => {
mockFailedResponse();
const newClient: OAuthClientInformationFull = {
client_id: "new-client",
redirect_uris: ["https://new-client.com/callback"],
};
await expect(
provider.clientsStore.registerClient!(newClient)
).rejects.toThrow(ServerError);
});
});
describe("token revocation", () => {
it("revokes token", async () => {
(global.fetch as jest.Mock).mockImplementation(() =>
Promise.resolve({
ok: true,
})
);
await provider.revokeToken!(validClient, {
token: "token-to-revoke",
token_type_hint: "access_token",
});
expect(global.fetch).toHaveBeenCalledWith(
"https://auth.example.com/revoke",
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: expect.stringContaining("token=token-to-revoke"),
})
);
});
it("handles revocation failure", async () => {
mockFailedResponse();
await expect(
provider.revokeToken!(validClient, {
token: "invalid-token",
})
).rejects.toThrow(ServerError);
});
});
describe("token verification", () => {
it("verifies valid token", async () => {
const validAuthInfo: AuthInfo = {
token: "valid-token",
clientId: "test-client",
scopes: ["read", "write"],
expiresAt: Date.now() / 1000 + 3600,
};
mockVerifyToken.mockResolvedValue(validAuthInfo);
const authInfo = await provider.verifyAccessToken("valid-token");
expect(authInfo).toEqual(validAuthInfo);
expect(mockVerifyToken).toHaveBeenCalledWith("valid-token");
});
it("passes through InvalidTokenError", async () => {
const error = new InvalidTokenError("Token expired");
mockVerifyToken.mockRejectedValue(error);
await expect(provider.verifyAccessToken("invalid-token"))
.rejects.toBe(error);
expect(mockVerifyToken).toHaveBeenCalledWith("invalid-token");
});
it("passes through InsufficientScopeError", async () => {
const error = new InsufficientScopeError("Required scopes: read, write");
mockVerifyToken.mockRejectedValue(error);
await expect(provider.verifyAccessToken("token-with-insufficient-scope"))
.rejects.toBe(error);
expect(mockVerifyToken).toHaveBeenCalledWith("token-with-insufficient-scope");
});
it("passes through unexpected errors", async () => {
const error = new Error("Unexpected error");
mockVerifyToken.mockRejectedValue(error);
await expect(provider.verifyAccessToken("valid-token"))
.rejects.toBe(error);
expect(mockVerifyToken).toHaveBeenCalledWith("valid-token");
});
});
});

View file

@ -0,0 +1,226 @@
import { Response } from "express";
import { OAuthRegisteredClientsStore } from "../clients.js";
import {
OAuthClientInformationFull,
OAuthClientInformationFullSchema,
OAuthTokenRevocationRequest,
OAuthTokens,
OAuthTokensSchema,
} from "../../../shared/auth.js";
import { AuthInfo } from "../types.js";
import { AuthorizationParams, OAuthServerProvider } from "../provider.js";
import { ServerError } from "../errors.js";
export type ProxyEndpoints = {
authorizationUrl: string;
tokenUrl: string;
revocationUrl?: string;
registrationUrl?: string;
};
export type ProxyOptions = {
/**
* Individual endpoint URLs for proxying specific OAuth operations
*/
endpoints: ProxyEndpoints;
/**
* Function to verify access tokens and return auth info
*/
verifyAccessToken: (token: string) => Promise<AuthInfo>;
/**
* Function to fetch client information from the upstream server
*/
getClient: (clientId: string) => Promise<OAuthClientInformationFull | undefined>;
};
/**
* Implements an OAuth server that proxies requests to another OAuth server.
*/
export class ProxyOAuthServerProvider implements OAuthServerProvider {
protected readonly _endpoints: ProxyEndpoints;
protected readonly _verifyAccessToken: (token: string) => Promise<AuthInfo>;
protected readonly _getClient: (clientId: string) => Promise<OAuthClientInformationFull | undefined>;
skipLocalPkceValidation = true;
revokeToken?: (
client: OAuthClientInformationFull,
request: OAuthTokenRevocationRequest
) => Promise<void>;
constructor(options: ProxyOptions) {
this._endpoints = options.endpoints;
this._verifyAccessToken = options.verifyAccessToken;
this._getClient = options.getClient;
if (options.endpoints?.revocationUrl) {
this.revokeToken = async (
client: OAuthClientInformationFull,
request: OAuthTokenRevocationRequest
) => {
const revocationUrl = this._endpoints.revocationUrl;
if (!revocationUrl) {
throw new Error("No revocation endpoint configured");
}
const params = new URLSearchParams();
params.set("token", request.token);
params.set("client_id", client.client_id);
if (client.client_secret) {
params.set("client_secret", client.client_secret);
}
if (request.token_type_hint) {
params.set("token_type_hint", request.token_type_hint);
}
const response = await fetch(revocationUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params.toString(),
});
if (!response.ok) {
throw new ServerError(`Token revocation failed: ${response.status}`);
}
}
}
}
get clientsStore(): OAuthRegisteredClientsStore {
const registrationUrl = this._endpoints.registrationUrl;
return {
getClient: this._getClient,
...(registrationUrl && {
registerClient: async (client: OAuthClientInformationFull) => {
const response = await fetch(registrationUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(client),
});
if (!response.ok) {
throw new ServerError(`Client registration failed: ${response.status}`);
}
const data = await response.json();
return OAuthClientInformationFullSchema.parse(data);
}
})
}
}
async authorize(
client: OAuthClientInformationFull,
params: AuthorizationParams,
res: Response
): Promise<void> {
// Start with required OAuth parameters
const targetUrl = new URL(this._endpoints.authorizationUrl);
const searchParams = new URLSearchParams({
client_id: client.client_id,
response_type: "code",
redirect_uri: params.redirectUri,
code_challenge: params.codeChallenge,
code_challenge_method: "S256"
});
// Add optional standard OAuth parameters
if (params.state) searchParams.set("state", params.state);
if (params.scopes?.length) searchParams.set("scope", params.scopes.join(" "));
targetUrl.search = searchParams.toString();
res.redirect(targetUrl.toString());
}
async challengeForAuthorizationCode(
_client: OAuthClientInformationFull,
_authorizationCode: string
): Promise<string> {
// In a proxy setup, we don't store the code challenge ourselves
// Instead, we proxy the token request and let the upstream server validate it
return "";
}
async exchangeAuthorizationCode(
client: OAuthClientInformationFull,
authorizationCode: string,
codeVerifier?: string
): Promise<OAuthTokens> {
const params = new URLSearchParams({
grant_type: "authorization_code",
client_id: client.client_id,
code: authorizationCode,
});
if (client.client_secret) {
params.append("client_secret", client.client_secret);
}
if (codeVerifier) {
params.append("code_verifier", codeVerifier);
}
const response = await fetch(this._endpoints.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params.toString(),
});
if (!response.ok) {
throw new ServerError(`Token exchange failed: ${response.status}`);
}
const data = await response.json();
return OAuthTokensSchema.parse(data);
}
async exchangeRefreshToken(
client: OAuthClientInformationFull,
refreshToken: string,
scopes?: string[]
): Promise<OAuthTokens> {
const params = new URLSearchParams({
grant_type: "refresh_token",
client_id: client.client_id,
refresh_token: refreshToken,
});
if (client.client_secret) {
params.set("client_secret", client.client_secret);
}
if (scopes?.length) {
params.set("scope", scopes.join(" "));
}
const response = await fetch(this._endpoints.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params.toString(),
});
if (!response.ok) {
throw new ServerError(`Token refresh failed: ${response.status}`);
}
const data = await response.json();
return OAuthTokensSchema.parse(data);
}
async verifyAccessToken(token: string): Promise<AuthInfo> {
return this._verifyAccessToken(token);
}
}