Add ProxyOAuthServerProvider
This commit is contained in:
parent
5c07636405
commit
7f03f13072
3 changed files with 234 additions and 6 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export interface OAuthServerProvider {
|
|||
/**
|
||||
* Exchanges an authorization code for an access token.
|
||||
*/
|
||||
exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<OAuthTokens>;
|
||||
exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string, codeVerifier?: string): Promise<OAuthTokens>;
|
||||
|
||||
/**
|
||||
* Exchanges a refresh token for an access token.
|
||||
|
|
|
|||
224
src/server/auth/proxyProvider.ts
Normal file
224
src/server/auth/proxyProvider.ts
Normal file
|
|
@ -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<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 {
|
||||
private readonly _endpoints: ProxyEndpoints;
|
||||
private readonly _verifyToken: (token: string) => Promise<AuthInfo>;
|
||||
private readonly _getClient: (clientId: string) => Promise<OAuthClientInformationFull | undefined>;
|
||||
|
||||
public revokeToken?: (
|
||||
client: OAuthClientInformationFull,
|
||||
request: OAuthTokenRevocationRequest
|
||||
) => Promise<void>;
|
||||
|
||||
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<void> {
|
||||
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<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 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<OAuthTokens> {
|
||||
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<AuthInfo> {
|
||||
return this._verifyToken(token);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue