From 7f03f13072446ff90703761945c504a9174d2132 Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Mon, 24 Feb 2025 16:07:54 -0800 Subject: [PATCH] Add ProxyOAuthServerProvider --- src/server/auth/handlers/token.ts | 14 +- src/server/auth/provider.ts | 2 +- src/server/auth/proxyProvider.ts | 224 ++++++++++++++++++++++++++++++ 3 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 src/server/auth/proxyProvider.ts diff --git a/src/server/auth/handlers/token.ts b/src/server/auth/handlers/token.ts index 79312068..3a29bb55 100644 --- a/src/server/auth/handlers/token.ts +++ b/src/server/auth/handlers/token.ts @@ -14,6 +14,7 @@ import { TooManyRequestsError, OAuthError } from "../errors.js"; +import { ProxyOAuthServerProvider } from "../proxyProvider.js"; export type TokenHandlerOptions = { provider: OAuthServerProvider; @@ -90,13 +91,16 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand const { code, code_verifier } = parseResult.data; - // Verify PKCE challenge - const codeChallenge = await provider.challengeForAuthorizationCode(client, code); - if (!(await verifyChallenge(code_verifier, codeChallenge))) { - throw new InvalidGrantError("code_verifier does not match the challenge"); + // Skip local PKCE verification for proxy providers since the code_challenge is stored on the upstream server. + // The code_verifier will be passed to the upstream server during token exchange. + if (!(provider instanceof ProxyOAuthServerProvider)) { + const codeChallenge = await provider.challengeForAuthorizationCode(client, code); + if (!(await verifyChallenge(code_verifier, codeChallenge))) { + throw new InvalidGrantError("code_verifier does not match the challenge"); + } } - const tokens = await provider.exchangeAuthorizationCode(client, code); + const tokens = await provider.exchangeAuthorizationCode(client, code, code_verifier); res.status(200).json(tokens); break; } diff --git a/src/server/auth/provider.ts b/src/server/auth/provider.ts index 7416c554..aacd308b 100644 --- a/src/server/auth/provider.ts +++ b/src/server/auth/provider.ts @@ -36,7 +36,7 @@ export interface OAuthServerProvider { /** * Exchanges an authorization code for an access token. */ - exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise; + exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string, codeVerifier?: string): Promise; /** * Exchanges a refresh token for an access token. diff --git a/src/server/auth/proxyProvider.ts b/src/server/auth/proxyProvider.ts new file mode 100644 index 00000000..3771fb2f --- /dev/null +++ b/src/server/auth/proxyProvider.ts @@ -0,0 +1,224 @@ +import { Response } from "express"; +import { OAuthRegisteredClientsStore } from "./clients.js"; +import { + OAuthClientInformationFull, + 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 + */ + verifyToken: (token: string) => Promise; + + /** + * Function to fetch client information from the upstream server + */ + getClient: (clientId: string) => Promise; + +}; + +/** + * Implements an OAuth server that proxies requests to another OAuth server. + */ +export class ProxyOAuthServerProvider implements OAuthServerProvider { + private readonly _endpoints: ProxyEndpoints; + private readonly _verifyToken: (token: string) => Promise; + private readonly _getClient: (clientId: string) => Promise; + + public revokeToken?: ( + client: OAuthClientInformationFull, + request: OAuthTokenRevocationRequest + ) => Promise; + + constructor(options: ProxyOptions) { + this._endpoints = options.endpoints; + this._verifyToken = options.verifyToken; + 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); + 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, + }); + + 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}`); + } + + return response.json(); + } + }) + } + } + + async authorize( + client: OAuthClientInformationFull, + params: AuthorizationParams, + res: Response + ): Promise { + const authorizationUrl = this._endpoints.authorizationUrl; + + if (!authorizationUrl) { + throw new Error("No authorization endpoint configured"); + } + + // Start with required OAuth parameters + const targetUrl = new URL(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 { + // 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 { + const tokenUrl = this._endpoints.tokenUrl; + + if (!tokenUrl) { + throw new Error("No token endpoint configured"); + } + const response = await fetch(tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: client.client_id, + client_secret: client.client_secret || "", + code: authorizationCode, + code_verifier: codeVerifier || "", + }), + }); + + 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 { + const tokenUrl = this._endpoints.tokenUrl; + + if (!tokenUrl) { + throw new Error("No token endpoint configured"); + } + + const params = new URLSearchParams({ + grant_type: "refresh_token", + client_id: client.client_id, + client_secret: client.client_secret || "", + refresh_token: refreshToken, + }); + + if (scopes?.length) { + params.set("scope", scopes.join(" ")); + } + + const response = await fetch(tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params, + }); + + 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 { + return this._verifyToken(token); + } +} \ No newline at end of file