Skip to content

Commit 7f03f13

Browse files
committed
Add ProxyOAuthServerProvider
1 parent 5c07636 commit 7f03f13

File tree

3 files changed

+234
-6
lines changed

3 files changed

+234
-6
lines changed

src/server/auth/handlers/token.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
TooManyRequestsError,
1515
OAuthError
1616
} from "../errors.js";
17+
import { ProxyOAuthServerProvider } from "../proxyProvider.js";
1718

1819
export type TokenHandlerOptions = {
1920
provider: OAuthServerProvider;
@@ -90,13 +91,16 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
9091

9192
const { code, code_verifier } = parseResult.data;
9293

93-
// Verify PKCE challenge
94-
const codeChallenge = await provider.challengeForAuthorizationCode(client, code);
95-
if (!(await verifyChallenge(code_verifier, codeChallenge))) {
96-
throw new InvalidGrantError("code_verifier does not match the challenge");
94+
// Skip local PKCE verification for proxy providers since the code_challenge is stored on the upstream server.
95+
// The code_verifier will be passed to the upstream server during token exchange.
96+
if (!(provider instanceof ProxyOAuthServerProvider)) {
97+
const codeChallenge = await provider.challengeForAuthorizationCode(client, code);
98+
if (!(await verifyChallenge(code_verifier, codeChallenge))) {
99+
throw new InvalidGrantError("code_verifier does not match the challenge");
100+
}
97101
}
98102

99-
const tokens = await provider.exchangeAuthorizationCode(client, code);
103+
const tokens = await provider.exchangeAuthorizationCode(client, code, code_verifier);
100104
res.status(200).json(tokens);
101105
break;
102106
}

src/server/auth/provider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export interface OAuthServerProvider {
3636
/**
3737
* Exchanges an authorization code for an access token.
3838
*/
39-
exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<OAuthTokens>;
39+
exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string, codeVerifier?: string): Promise<OAuthTokens>;
4040

4141
/**
4242
* Exchanges a refresh token for an access token.

src/server/auth/proxyProvider.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import { Response } from "express";
2+
import { OAuthRegisteredClientsStore } from "./clients.js";
3+
import {
4+
OAuthClientInformationFull,
5+
OAuthTokenRevocationRequest,
6+
OAuthTokens,
7+
OAuthTokensSchema,
8+
} from "./../../shared/auth.js";
9+
import { AuthInfo } from "./types.js";
10+
import { AuthorizationParams, OAuthServerProvider } from "./provider.js";
11+
import { ServerError } from "./errors.js";
12+
13+
export type ProxyEndpoints = {
14+
authorizationUrl?: string;
15+
tokenUrl?: string;
16+
revocationUrl?: string;
17+
registrationUrl?: string;
18+
};
19+
20+
export type ProxyOptions = {
21+
/**
22+
* Individual endpoint URLs for proxying specific OAuth operations
23+
*/
24+
endpoints: ProxyEndpoints;
25+
26+
/**
27+
* Function to verify access tokens and return auth info
28+
*/
29+
verifyToken: (token: string) => Promise<AuthInfo>;
30+
31+
/**
32+
* Function to fetch client information from the upstream server
33+
*/
34+
getClient: (clientId: string) => Promise<OAuthClientInformationFull | undefined>;
35+
36+
};
37+
38+
/**
39+
* Implements an OAuth server that proxies requests to another OAuth server.
40+
*/
41+
export class ProxyOAuthServerProvider implements OAuthServerProvider {
42+
private readonly _endpoints: ProxyEndpoints;
43+
private readonly _verifyToken: (token: string) => Promise<AuthInfo>;
44+
private readonly _getClient: (clientId: string) => Promise<OAuthClientInformationFull | undefined>;
45+
46+
public revokeToken?: (
47+
client: OAuthClientInformationFull,
48+
request: OAuthTokenRevocationRequest
49+
) => Promise<void>;
50+
51+
constructor(options: ProxyOptions) {
52+
this._endpoints = options.endpoints;
53+
this._verifyToken = options.verifyToken;
54+
this._getClient = options.getClient;
55+
if (options.endpoints?.revocationUrl) {
56+
this.revokeToken = async (
57+
client: OAuthClientInformationFull,
58+
request: OAuthTokenRevocationRequest
59+
) => {
60+
const revocationUrl = this._endpoints.revocationUrl;
61+
62+
if (!revocationUrl) {
63+
throw new Error("No revocation endpoint configured");
64+
}
65+
66+
const params = new URLSearchParams();
67+
params.set("token", request.token);
68+
params.set("client_id", client.client_id);
69+
params.set("client_secret", client.client_secret || "");
70+
if (request.token_type_hint) {
71+
params.set("token_type_hint", request.token_type_hint);
72+
}
73+
74+
const response = await fetch(revocationUrl, {
75+
method: "POST",
76+
headers: {
77+
"Content-Type": "application/x-www-form-urlencoded",
78+
},
79+
body: params,
80+
});
81+
82+
if (!response.ok) {
83+
throw new ServerError(`Token revocation failed: ${response.status}`);
84+
}
85+
}
86+
}
87+
}
88+
89+
get clientsStore(): OAuthRegisteredClientsStore {
90+
const registrationUrl = this._endpoints.registrationUrl;
91+
return {
92+
getClient: this._getClient,
93+
...(registrationUrl && {
94+
registerClient: async (client: OAuthClientInformationFull) => {
95+
const response = await fetch(registrationUrl, {
96+
method: "POST",
97+
headers: {
98+
"Content-Type": "application/json",
99+
},
100+
body: JSON.stringify(client),
101+
});
102+
103+
if (!response.ok) {
104+
throw new ServerError(`Client registration failed: ${response.status}`);
105+
}
106+
107+
return response.json();
108+
}
109+
})
110+
}
111+
}
112+
113+
async authorize(
114+
client: OAuthClientInformationFull,
115+
params: AuthorizationParams,
116+
res: Response
117+
): Promise<void> {
118+
const authorizationUrl = this._endpoints.authorizationUrl;
119+
120+
if (!authorizationUrl) {
121+
throw new Error("No authorization endpoint configured");
122+
}
123+
124+
// Start with required OAuth parameters
125+
const targetUrl = new URL(authorizationUrl);
126+
const searchParams = new URLSearchParams({
127+
client_id: client.client_id,
128+
response_type: "code",
129+
redirect_uri: params.redirectUri,
130+
code_challenge: params.codeChallenge,
131+
code_challenge_method: "S256"
132+
});
133+
134+
// Add optional standard OAuth parameters
135+
if (params.state) searchParams.set("state", params.state);
136+
if (params.scopes?.length) searchParams.set("scope", params.scopes.join(" "));
137+
138+
targetUrl.search = searchParams.toString();
139+
res.redirect(targetUrl.toString());
140+
}
141+
142+
async challengeForAuthorizationCode(
143+
_client: OAuthClientInformationFull,
144+
_authorizationCode: string
145+
): Promise<string> {
146+
// In a proxy setup, we don't store the code challenge ourselves
147+
// Instead, we proxy the token request and let the upstream server validate it
148+
return "";
149+
}
150+
151+
async exchangeAuthorizationCode(
152+
client: OAuthClientInformationFull,
153+
authorizationCode: string,
154+
codeVerifier?: string
155+
): Promise<OAuthTokens> {
156+
const tokenUrl = this._endpoints.tokenUrl;
157+
158+
if (!tokenUrl) {
159+
throw new Error("No token endpoint configured");
160+
}
161+
const response = await fetch(tokenUrl, {
162+
method: "POST",
163+
headers: {
164+
"Content-Type": "application/x-www-form-urlencoded",
165+
},
166+
body: new URLSearchParams({
167+
grant_type: "authorization_code",
168+
client_id: client.client_id,
169+
client_secret: client.client_secret || "",
170+
code: authorizationCode,
171+
code_verifier: codeVerifier || "",
172+
}),
173+
});
174+
175+
if (!response.ok) {
176+
throw new ServerError(`Token exchange failed: ${response.status}`);
177+
}
178+
179+
const data = await response.json();
180+
return OAuthTokensSchema.parse(data);
181+
}
182+
183+
async exchangeRefreshToken(
184+
client: OAuthClientInformationFull,
185+
refreshToken: string,
186+
scopes?: string[]
187+
): Promise<OAuthTokens> {
188+
const tokenUrl = this._endpoints.tokenUrl;
189+
190+
if (!tokenUrl) {
191+
throw new Error("No token endpoint configured");
192+
}
193+
194+
const params = new URLSearchParams({
195+
grant_type: "refresh_token",
196+
client_id: client.client_id,
197+
client_secret: client.client_secret || "",
198+
refresh_token: refreshToken,
199+
});
200+
201+
if (scopes?.length) {
202+
params.set("scope", scopes.join(" "));
203+
}
204+
205+
const response = await fetch(tokenUrl, {
206+
method: "POST",
207+
headers: {
208+
"Content-Type": "application/x-www-form-urlencoded",
209+
},
210+
body: params,
211+
});
212+
213+
if (!response.ok) {
214+
throw new ServerError(`Token refresh failed: ${response.status}`);
215+
}
216+
217+
const data = await response.json();
218+
return OAuthTokensSchema.parse(data);
219+
}
220+
221+
async verifyAccessToken(token: string): Promise<AuthInfo> {
222+
return this._verifyToken(token);
223+
}
224+
}

0 commit comments

Comments
 (0)