[auth]: support oauth client_secret_basic / none / custom methods (#720)
* Allow OAuthClientProvider to control authentication to token endpoint w/ addClientAuthentication (exchange / refresh) * Include mockProvider in exchangeAuthorization tests. * feature. Add client_secret_basic and none authentication methods --------- Co-authored-by: Jared Hanson <jaredhanson@gmail.com> Co-authored-by: SightStudio <sdm3285@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
8714f21541
commit
031dfc2835
2 changed files with 569 additions and 42 deletions
|
|
@ -10,6 +10,7 @@ import {
|
|||
auth,
|
||||
type OAuthClientProvider,
|
||||
} from "./auth.js";
|
||||
import { OAuthMetadata } from '../shared/auth.js';
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = jest.fn();
|
||||
|
|
@ -231,7 +232,7 @@ describe("OAuth Authorization", () => {
|
|||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
|
||||
// Second call (root fallback) succeeds
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
|
@ -241,17 +242,17 @@ describe("OAuth Authorization", () => {
|
|||
|
||||
const metadata = await discoverOAuthMetadata("https://auth.example.com/path/name");
|
||||
expect(metadata).toEqual(validMetadata);
|
||||
|
||||
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls.length).toBe(2);
|
||||
|
||||
|
||||
// First call should be path-aware
|
||||
const [firstUrl, firstOptions] = calls[0];
|
||||
expect(firstUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/path/name");
|
||||
expect(firstOptions.headers).toEqual({
|
||||
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION
|
||||
});
|
||||
|
||||
|
||||
// Second call should be root fallback
|
||||
const [secondUrl, secondOptions] = calls[1];
|
||||
expect(secondUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
|
||||
|
|
@ -266,7 +267,7 @@ describe("OAuth Authorization", () => {
|
|||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
|
||||
// Second call (root fallback) also returns 404
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
|
|
@ -275,7 +276,7 @@ describe("OAuth Authorization", () => {
|
|||
|
||||
const metadata = await discoverOAuthMetadata("https://auth.example.com/path/name");
|
||||
expect(metadata).toBeUndefined();
|
||||
|
||||
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls.length).toBe(2);
|
||||
});
|
||||
|
|
@ -289,10 +290,10 @@ describe("OAuth Authorization", () => {
|
|||
|
||||
const metadata = await discoverOAuthMetadata("https://auth.example.com/");
|
||||
expect(metadata).toBeUndefined();
|
||||
|
||||
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls.length).toBe(1); // Should not attempt fallback
|
||||
|
||||
|
||||
const [url] = calls[0];
|
||||
expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
|
||||
});
|
||||
|
|
@ -306,10 +307,10 @@ describe("OAuth Authorization", () => {
|
|||
|
||||
const metadata = await discoverOAuthMetadata("https://auth.example.com");
|
||||
expect(metadata).toBeUndefined();
|
||||
|
||||
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls.length).toBe(1); // Should not attempt fallback
|
||||
|
||||
|
||||
const [url] = calls[0];
|
||||
expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
|
||||
});
|
||||
|
|
@ -317,13 +318,13 @@ describe("OAuth Authorization", () => {
|
|||
it("falls back when path-aware discovery encounters CORS error", async () => {
|
||||
// First call (path-aware) fails with TypeError (CORS)
|
||||
mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError("CORS error")));
|
||||
|
||||
|
||||
// Retry path-aware without headers (simulating CORS retry)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
|
||||
// Second call (root fallback) succeeds
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
|
@ -333,10 +334,10 @@ describe("OAuth Authorization", () => {
|
|||
|
||||
const metadata = await discoverOAuthMetadata("https://auth.example.com/deep/path");
|
||||
expect(metadata).toEqual(validMetadata);
|
||||
|
||||
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls.length).toBe(3);
|
||||
|
||||
|
||||
// Final call should be root fallback
|
||||
const [lastUrl, lastOptions] = calls[2];
|
||||
expect(lastUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
|
||||
|
|
@ -600,6 +601,13 @@ describe("OAuth Authorization", () => {
|
|||
refresh_token: "refresh123",
|
||||
};
|
||||
|
||||
const validMetadata = {
|
||||
issuer: "https://auth.example.com",
|
||||
authorization_endpoint: "https://auth.example.com/authorize",
|
||||
token_endpoint: "https://auth.example.com/token",
|
||||
response_types_supported: ["code"]
|
||||
};
|
||||
|
||||
const validClientInfo = {
|
||||
client_id: "client123",
|
||||
client_secret: "secret123",
|
||||
|
|
@ -629,9 +637,9 @@ describe("OAuth Authorization", () => {
|
|||
}),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: {
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
|
|
@ -645,6 +653,52 @@ describe("OAuth Authorization", () => {
|
|||
expect(body.get("resource")).toBe("https://api.example.com/mcp-server");
|
||||
});
|
||||
|
||||
it("exchanges code for tokens with auth", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => validTokens,
|
||||
});
|
||||
|
||||
const tokens = await exchangeAuthorization("https://auth.example.com", {
|
||||
metadata: validMetadata,
|
||||
clientInformation: validClientInfo,
|
||||
authorizationCode: "code123",
|
||||
codeVerifier: "verifier123",
|
||||
redirectUri: "http://localhost:3000/callback",
|
||||
addClientAuthentication: (headers: Headers, params: URLSearchParams, url: string | URL, metadata: OAuthMetadata) => {
|
||||
headers.set("Authorization", "Basic " + btoa(validClientInfo.client_id + ":" + validClientInfo.client_secret));
|
||||
params.set("example_url", typeof url === 'string' ? url : url.toString());
|
||||
params.set("example_metadata", metadata.authorization_endpoint);
|
||||
params.set("example_param", "example_value");
|
||||
},
|
||||
});
|
||||
|
||||
expect(tokens).toEqual(validTokens);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
href: "https://auth.example.com/token",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
})
|
||||
);
|
||||
|
||||
const headers = mockFetch.mock.calls[0][1].headers as Headers;
|
||||
expect(headers.get("Content-Type")).toBe("application/x-www-form-urlencoded");
|
||||
expect(headers.get("Authorization")).toBe("Basic Y2xpZW50MTIzOnNlY3JldDEyMw==");
|
||||
const body = mockFetch.mock.calls[0][1].body as URLSearchParams;
|
||||
expect(body.get("grant_type")).toBe("authorization_code");
|
||||
expect(body.get("code")).toBe("code123");
|
||||
expect(body.get("code_verifier")).toBe("verifier123");
|
||||
expect(body.get("client_id")).toBeNull();
|
||||
expect(body.get("redirect_uri")).toBe("http://localhost:3000/callback");
|
||||
expect(body.get("example_url")).toBe("https://auth.example.com");
|
||||
expect(body.get("example_metadata")).toBe("https://auth.example.com/authorize");
|
||||
expect(body.get("example_param")).toBe("example_value");
|
||||
expect(body.get("client_secret")).toBeNull();
|
||||
});
|
||||
|
||||
it("validates token response schema", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
|
@ -693,6 +747,13 @@ describe("OAuth Authorization", () => {
|
|||
refresh_token: "newrefresh123",
|
||||
};
|
||||
|
||||
const validMetadata = {
|
||||
issuer: "https://auth.example.com",
|
||||
authorization_endpoint: "https://auth.example.com/authorize",
|
||||
token_endpoint: "https://auth.example.com/token",
|
||||
response_types_supported: ["code"]
|
||||
};
|
||||
|
||||
const validClientInfo = {
|
||||
client_id: "client123",
|
||||
client_secret: "secret123",
|
||||
|
|
@ -720,9 +781,9 @@ describe("OAuth Authorization", () => {
|
|||
}),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: {
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
|
|
@ -734,6 +795,48 @@ describe("OAuth Authorization", () => {
|
|||
expect(body.get("resource")).toBe("https://api.example.com/mcp-server");
|
||||
});
|
||||
|
||||
it("exchanges refresh token for new tokens with auth", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => validTokensWithNewRefreshToken,
|
||||
});
|
||||
|
||||
const tokens = await refreshAuthorization("https://auth.example.com", {
|
||||
metadata: validMetadata,
|
||||
clientInformation: validClientInfo,
|
||||
refreshToken: "refresh123",
|
||||
addClientAuthentication: (headers: Headers, params: URLSearchParams, url: string | URL, metadata?: OAuthMetadata) => {
|
||||
headers.set("Authorization", "Basic " + btoa(validClientInfo.client_id + ":" + validClientInfo.client_secret));
|
||||
params.set("example_url", typeof url === 'string' ? url : url.toString());
|
||||
params.set("example_metadata", metadata?.authorization_endpoint ?? '?');
|
||||
params.set("example_param", "example_value");
|
||||
},
|
||||
});
|
||||
|
||||
expect(tokens).toEqual(validTokensWithNewRefreshToken);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
href: "https://auth.example.com/token",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
})
|
||||
);
|
||||
|
||||
const headers = mockFetch.mock.calls[0][1].headers as Headers;
|
||||
expect(headers.get("Content-Type")).toBe("application/x-www-form-urlencoded");
|
||||
expect(headers.get("Authorization")).toBe("Basic Y2xpZW50MTIzOnNlY3JldDEyMw==");
|
||||
const body = mockFetch.mock.calls[0][1].body as URLSearchParams;
|
||||
expect(body.get("grant_type")).toBe("refresh_token");
|
||||
expect(body.get("refresh_token")).toBe("refresh123");
|
||||
expect(body.get("client_id")).toBeNull();
|
||||
expect(body.get("example_url")).toBe("https://auth.example.com");
|
||||
expect(body.get("example_metadata")).toBe("https://auth.example.com/authorize");
|
||||
expect(body.get("example_param")).toBe("example_value");
|
||||
expect(body.get("client_secret")).toBeNull();
|
||||
});
|
||||
|
||||
it("exchanges refresh token for new tokens and keep existing refresh token if none is returned", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
|
@ -1477,4 +1580,267 @@ describe("OAuth Authorization", () => {
|
|||
expect(body.get("refresh_token")).toBe("refresh123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exchangeAuthorization with multiple client authentication methods", () => {
|
||||
const validTokens = {
|
||||
access_token: "access123",
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
refresh_token: "refresh123",
|
||||
};
|
||||
|
||||
const validClientInfo = {
|
||||
client_id: "client123",
|
||||
client_secret: "secret123",
|
||||
redirect_uris: ["http://localhost:3000/callback"],
|
||||
client_name: "Test Client",
|
||||
};
|
||||
|
||||
const metadataWithBasicOnly = {
|
||||
issuer: "https://auth.example.com",
|
||||
authorization_endpoint: "https://auth.example.com/auth",
|
||||
token_endpoint: "https://auth.example.com/token",
|
||||
response_types_supported: ["code"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
token_endpoint_auth_methods_supported: ["client_secret_basic"],
|
||||
};
|
||||
|
||||
const metadataWithPostOnly = {
|
||||
...metadataWithBasicOnly,
|
||||
token_endpoint_auth_methods_supported: ["client_secret_post"],
|
||||
};
|
||||
|
||||
const metadataWithNoneOnly = {
|
||||
...metadataWithBasicOnly,
|
||||
token_endpoint_auth_methods_supported: ["none"],
|
||||
};
|
||||
|
||||
const metadataWithAllBuiltinMethods = {
|
||||
...metadataWithBasicOnly,
|
||||
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
|
||||
};
|
||||
|
||||
it("uses HTTP Basic authentication when client_secret_basic is supported", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => validTokens,
|
||||
});
|
||||
|
||||
const tokens = await exchangeAuthorization("https://auth.example.com", {
|
||||
metadata: metadataWithBasicOnly,
|
||||
clientInformation: validClientInfo,
|
||||
authorizationCode: "code123",
|
||||
redirectUri: "http://localhost:3000/callback",
|
||||
codeVerifier: "verifier123",
|
||||
});
|
||||
|
||||
expect(tokens).toEqual(validTokens);
|
||||
const request = mockFetch.mock.calls[0][1];
|
||||
|
||||
// Check Authorization header
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
const expected = "Basic " + btoa("client123:secret123");
|
||||
expect(authHeader).toBe(expected);
|
||||
|
||||
const body = request.body as URLSearchParams;
|
||||
expect(body.get("client_id")).toBeNull();
|
||||
expect(body.get("client_secret")).toBeNull();
|
||||
});
|
||||
|
||||
it("includes credentials in request body when client_secret_post is supported", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => validTokens,
|
||||
});
|
||||
|
||||
const tokens = await exchangeAuthorization("https://auth.example.com", {
|
||||
metadata: metadataWithPostOnly,
|
||||
clientInformation: validClientInfo,
|
||||
authorizationCode: "code123",
|
||||
redirectUri: "http://localhost:3000/callback",
|
||||
codeVerifier: "verifier123",
|
||||
});
|
||||
|
||||
expect(tokens).toEqual(validTokens);
|
||||
const request = mockFetch.mock.calls[0][1];
|
||||
|
||||
// Check no Authorization header
|
||||
expect(request.headers.get("Authorization")).toBeNull();
|
||||
|
||||
const body = request.body as URLSearchParams;
|
||||
expect(body.get("client_id")).toBe("client123");
|
||||
expect(body.get("client_secret")).toBe("secret123");
|
||||
});
|
||||
|
||||
it("it picks client_secret_basic when all builtin methods are supported", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => validTokens,
|
||||
});
|
||||
|
||||
const tokens = await exchangeAuthorization("https://auth.example.com", {
|
||||
metadata: metadataWithAllBuiltinMethods,
|
||||
clientInformation: validClientInfo,
|
||||
authorizationCode: "code123",
|
||||
redirectUri: "http://localhost:3000/callback",
|
||||
codeVerifier: "verifier123",
|
||||
});
|
||||
|
||||
expect(tokens).toEqual(validTokens);
|
||||
const request = mockFetch.mock.calls[0][1];
|
||||
|
||||
// Check Authorization header - should use Basic auth as it's the most secure
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
const expected = "Basic " + btoa("client123:secret123");
|
||||
expect(authHeader).toBe(expected);
|
||||
|
||||
// Credentials should not be in body when using Basic auth
|
||||
const body = request.body as URLSearchParams;
|
||||
expect(body.get("client_id")).toBeNull();
|
||||
expect(body.get("client_secret")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses public client authentication when none method is specified", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => validTokens,
|
||||
});
|
||||
|
||||
const clientInfoWithoutSecret = {
|
||||
client_id: "client123",
|
||||
redirect_uris: ["http://localhost:3000/callback"],
|
||||
client_name: "Test Client",
|
||||
};
|
||||
|
||||
const tokens = await exchangeAuthorization("https://auth.example.com", {
|
||||
metadata: metadataWithNoneOnly,
|
||||
clientInformation: clientInfoWithoutSecret,
|
||||
authorizationCode: "code123",
|
||||
redirectUri: "http://localhost:3000/callback",
|
||||
codeVerifier: "verifier123",
|
||||
});
|
||||
|
||||
expect(tokens).toEqual(validTokens);
|
||||
const request = mockFetch.mock.calls[0][1];
|
||||
|
||||
// Check no Authorization header
|
||||
expect(request.headers.get("Authorization")).toBeNull();
|
||||
|
||||
const body = request.body as URLSearchParams;
|
||||
expect(body.get("client_id")).toBe("client123");
|
||||
expect(body.get("client_secret")).toBeNull();
|
||||
});
|
||||
|
||||
it("defaults to client_secret_post when no auth methods specified", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => validTokens,
|
||||
});
|
||||
|
||||
const tokens = await exchangeAuthorization("https://auth.example.com", {
|
||||
clientInformation: validClientInfo,
|
||||
authorizationCode: "code123",
|
||||
redirectUri: "http://localhost:3000/callback",
|
||||
codeVerifier: "verifier123",
|
||||
});
|
||||
|
||||
expect(tokens).toEqual(validTokens);
|
||||
const request = mockFetch.mock.calls[0][1];
|
||||
|
||||
// Check headers
|
||||
expect(request.headers.get("Content-Type")).toBe("application/x-www-form-urlencoded");
|
||||
expect(request.headers.get("Authorization")).toBeNull();
|
||||
|
||||
const body = request.body as URLSearchParams;
|
||||
expect(body.get("client_id")).toBe("client123");
|
||||
expect(body.get("client_secret")).toBe("secret123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshAuthorization with multiple client authentication methods", () => {
|
||||
const validTokens = {
|
||||
access_token: "newaccess123",
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
refresh_token: "newrefresh123",
|
||||
};
|
||||
|
||||
const validClientInfo = {
|
||||
client_id: "client123",
|
||||
client_secret: "secret123",
|
||||
redirect_uris: ["http://localhost:3000/callback"],
|
||||
client_name: "Test Client",
|
||||
};
|
||||
|
||||
const metadataWithBasicOnly = {
|
||||
issuer: "https://auth.example.com",
|
||||
authorization_endpoint: "https://auth.example.com/auth",
|
||||
token_endpoint: "https://auth.example.com/token",
|
||||
response_types_supported: ["code"],
|
||||
token_endpoint_auth_methods_supported: ["client_secret_basic"],
|
||||
};
|
||||
|
||||
const metadataWithPostOnly = {
|
||||
...metadataWithBasicOnly,
|
||||
token_endpoint_auth_methods_supported: ["client_secret_post"],
|
||||
};
|
||||
|
||||
it("uses client_secret_basic for refresh token", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => validTokens,
|
||||
});
|
||||
|
||||
const tokens = await refreshAuthorization("https://auth.example.com", {
|
||||
metadata: metadataWithBasicOnly,
|
||||
clientInformation: validClientInfo,
|
||||
refreshToken: "refresh123",
|
||||
});
|
||||
|
||||
expect(tokens).toEqual(validTokens);
|
||||
const request = mockFetch.mock.calls[0][1];
|
||||
|
||||
// Check Authorization header
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
const expected = "Basic " + btoa("client123:secret123");
|
||||
expect(authHeader).toBe(expected);
|
||||
|
||||
const body = request.body as URLSearchParams;
|
||||
expect(body.get("client_id")).toBeNull(); // should not be in body
|
||||
expect(body.get("client_secret")).toBeNull(); // should not be in body
|
||||
expect(body.get("refresh_token")).toBe("refresh123");
|
||||
});
|
||||
|
||||
it("uses client_secret_post for refresh token", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => validTokens,
|
||||
});
|
||||
|
||||
const tokens = await refreshAuthorization("https://auth.example.com", {
|
||||
metadata: metadataWithPostOnly,
|
||||
clientInformation: validClientInfo,
|
||||
refreshToken: "refresh123",
|
||||
});
|
||||
|
||||
expect(tokens).toEqual(validTokens);
|
||||
const request = mockFetch.mock.calls[0][1];
|
||||
|
||||
// Check no Authorization header
|
||||
expect(request.headers.get("Authorization")).toBeNull();
|
||||
|
||||
const body = request.body as URLSearchParams;
|
||||
expect(body.get("client_id")).toBe("client123");
|
||||
expect(body.get("client_secret")).toBe("secret123");
|
||||
expect(body.get("refresh_token")).toBe("refresh123");
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -73,6 +73,26 @@ export interface OAuthClientProvider {
|
|||
*/
|
||||
codeVerifier(): string | Promise<string>;
|
||||
|
||||
/**
|
||||
* Adds custom client authentication to OAuth token requests.
|
||||
*
|
||||
* This optional method allows implementations to customize how client credentials
|
||||
* are included in token exchange and refresh requests. When provided, this method
|
||||
* is called instead of the default authentication logic, giving full control over
|
||||
* the authentication mechanism.
|
||||
*
|
||||
* Common use cases include:
|
||||
* - Supporting authentication methods beyond the standard OAuth 2.0 methods
|
||||
* - Adding custom headers for proprietary authentication schemes
|
||||
* - Implementing client assertion-based authentication (e.g., JWT bearer tokens)
|
||||
*
|
||||
* @param headers - The request headers (can be modified to add authentication)
|
||||
* @param params - The request body parameters (can be modified to add credentials)
|
||||
* @param url - The token endpoint URL being called
|
||||
* @param metadata - Optional OAuth metadata for the server, which may include supported authentication methods
|
||||
*/
|
||||
addClientAuthentication?(headers: Headers, params: URLSearchParams, url: string | URL, metadata?: OAuthMetadata): void | Promise<void>;
|
||||
|
||||
/**
|
||||
* If defined, overrides the selection and validation of the
|
||||
* RFC 8707 Resource Indicator. If left undefined, default
|
||||
|
|
@ -91,6 +111,114 @@ export class UnauthorizedError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
type ClientAuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none';
|
||||
|
||||
/**
|
||||
* Determines the best client authentication method to use based on server support and client configuration.
|
||||
*
|
||||
* Priority order (highest to lowest):
|
||||
* 1. client_secret_basic (if client secret is available)
|
||||
* 2. client_secret_post (if client secret is available)
|
||||
* 3. none (for public clients)
|
||||
*
|
||||
* @param clientInformation - OAuth client information containing credentials
|
||||
* @param supportedMethods - Authentication methods supported by the authorization server
|
||||
* @returns The selected authentication method
|
||||
*/
|
||||
function selectClientAuthMethod(
|
||||
clientInformation: OAuthClientInformation,
|
||||
supportedMethods: string[]
|
||||
): ClientAuthMethod {
|
||||
const hasClientSecret = clientInformation.client_secret !== undefined;
|
||||
|
||||
// If server doesn't specify supported methods, use RFC 6749 defaults
|
||||
if (supportedMethods.length === 0) {
|
||||
return hasClientSecret ? "client_secret_post" : "none";
|
||||
}
|
||||
|
||||
// Try methods in priority order (most secure first)
|
||||
if (hasClientSecret && supportedMethods.includes("client_secret_basic")) {
|
||||
return "client_secret_basic";
|
||||
}
|
||||
|
||||
if (hasClientSecret && supportedMethods.includes("client_secret_post")) {
|
||||
return "client_secret_post";
|
||||
}
|
||||
|
||||
if (supportedMethods.includes("none")) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
// Fallback: use what we have
|
||||
return hasClientSecret ? "client_secret_post" : "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies client authentication to the request based on the specified method.
|
||||
*
|
||||
* Implements OAuth 2.1 client authentication methods:
|
||||
* - client_secret_basic: HTTP Basic authentication (RFC 6749 Section 2.3.1)
|
||||
* - client_secret_post: Credentials in request body (RFC 6749 Section 2.3.1)
|
||||
* - none: Public client authentication (RFC 6749 Section 2.1)
|
||||
*
|
||||
* @param method - The authentication method to use
|
||||
* @param clientInformation - OAuth client information containing credentials
|
||||
* @param headers - HTTP headers object to modify
|
||||
* @param params - URL search parameters to modify
|
||||
* @throws {Error} When required credentials are missing
|
||||
*/
|
||||
function applyClientAuthentication(
|
||||
method: ClientAuthMethod,
|
||||
clientInformation: OAuthClientInformation,
|
||||
headers: Headers,
|
||||
params: URLSearchParams
|
||||
): void {
|
||||
const { client_id, client_secret } = clientInformation;
|
||||
|
||||
switch (method) {
|
||||
case "client_secret_basic":
|
||||
applyBasicAuth(client_id, client_secret, headers);
|
||||
return;
|
||||
case "client_secret_post":
|
||||
applyPostAuth(client_id, client_secret, params);
|
||||
return;
|
||||
case "none":
|
||||
applyPublicAuth(client_id, params);
|
||||
return;
|
||||
default:
|
||||
throw new Error(`Unsupported client authentication method: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies HTTP Basic authentication (RFC 6749 Section 2.3.1)
|
||||
*/
|
||||
function applyBasicAuth(clientId: string, clientSecret: string | undefined, headers: Headers): void {
|
||||
if (!clientSecret) {
|
||||
throw new Error("client_secret_basic authentication requires a client_secret");
|
||||
}
|
||||
|
||||
const credentials = btoa(`${clientId}:${clientSecret}`);
|
||||
headers.set("Authorization", `Basic ${credentials}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies POST body authentication (RFC 6749 Section 2.3.1)
|
||||
*/
|
||||
function applyPostAuth(clientId: string, clientSecret: string | undefined, params: URLSearchParams): void {
|
||||
params.set("client_id", clientId);
|
||||
if (clientSecret) {
|
||||
params.set("client_secret", clientSecret);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies public client authentication (RFC 6749 Section 2.1)
|
||||
*/
|
||||
function applyPublicAuth(clientId: string, params: URLSearchParams): void {
|
||||
params.set("client_id", clientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates the full auth flow with a server.
|
||||
*
|
||||
|
|
@ -154,6 +282,7 @@ export async function auth(
|
|||
codeVerifier,
|
||||
redirectUri: provider.redirectUrl,
|
||||
resource,
|
||||
addClientAuthentication: provider.addClientAuthentication,
|
||||
});
|
||||
|
||||
await provider.saveTokens(tokens);
|
||||
|
|
@ -171,6 +300,7 @@ export async function auth(
|
|||
clientInformation,
|
||||
refreshToken: tokens.refresh_token,
|
||||
resource,
|
||||
addClientAuthentication: provider.addClientAuthentication,
|
||||
});
|
||||
|
||||
await provider.saveTokens(newTokens);
|
||||
|
|
@ -460,6 +590,15 @@ export async function startAuthorization(
|
|||
|
||||
/**
|
||||
* Exchanges an authorization code for an access token with the given server.
|
||||
*
|
||||
* Supports multiple client authentication methods as specified in OAuth 2.1:
|
||||
* - Automatically selects the best authentication method based on server support
|
||||
* - Falls back to appropriate defaults when server metadata is unavailable
|
||||
*
|
||||
* @param authorizationServerUrl - The authorization server's base URL
|
||||
* @param options - Configuration object containing client info, auth code, etc.
|
||||
* @returns Promise resolving to OAuth tokens
|
||||
* @throws {Error} When token exchange fails or authentication is invalid
|
||||
*/
|
||||
export async function exchangeAuthorization(
|
||||
authorizationServerUrl: string | URL,
|
||||
|
|
@ -470,6 +609,7 @@ export async function exchangeAuthorization(
|
|||
codeVerifier,
|
||||
redirectUri,
|
||||
resource,
|
||||
addClientAuthentication
|
||||
}: {
|
||||
metadata?: OAuthMetadata;
|
||||
clientInformation: OAuthClientInformation;
|
||||
|
|
@ -477,37 +617,43 @@ export async function exchangeAuthorization(
|
|||
codeVerifier: string;
|
||||
redirectUri: string | URL;
|
||||
resource?: URL;
|
||||
addClientAuthentication?: OAuthClientProvider["addClientAuthentication"];
|
||||
},
|
||||
): Promise<OAuthTokens> {
|
||||
const grantType = "authorization_code";
|
||||
|
||||
let tokenUrl: URL;
|
||||
if (metadata) {
|
||||
tokenUrl = new URL(metadata.token_endpoint);
|
||||
const tokenUrl = metadata?.token_endpoint
|
||||
? new URL(metadata.token_endpoint)
|
||||
: new URL("/token", authorizationServerUrl);
|
||||
|
||||
if (
|
||||
metadata.grant_types_supported &&
|
||||
if (
|
||||
metadata?.grant_types_supported &&
|
||||
!metadata.grant_types_supported.includes(grantType)
|
||||
) {
|
||||
throw new Error(
|
||||
) {
|
||||
throw new Error(
|
||||
`Incompatible auth server: does not support grant type ${grantType}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tokenUrl = new URL("/token", authorizationServerUrl);
|
||||
);
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
const headers = new Headers({
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
});
|
||||
const params = new URLSearchParams({
|
||||
grant_type: grantType,
|
||||
client_id: clientInformation.client_id,
|
||||
code: authorizationCode,
|
||||
code_verifier: codeVerifier,
|
||||
redirect_uri: String(redirectUri),
|
||||
});
|
||||
|
||||
if (clientInformation.client_secret) {
|
||||
params.set("client_secret", clientInformation.client_secret);
|
||||
if (addClientAuthentication) {
|
||||
addClientAuthentication(headers, params, authorizationServerUrl, metadata);
|
||||
} else {
|
||||
// Determine and apply client authentication method
|
||||
const supportedMethods = metadata?.token_endpoint_auth_methods_supported ?? [];
|
||||
const authMethod = selectClientAuthMethod(clientInformation, supportedMethods);
|
||||
|
||||
applyClientAuthentication(authMethod, clientInformation, headers, params);
|
||||
}
|
||||
|
||||
if (resource) {
|
||||
|
|
@ -516,9 +662,7 @@ export async function exchangeAuthorization(
|
|||
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
headers,
|
||||
body: params,
|
||||
});
|
||||
|
||||
|
|
@ -531,6 +675,15 @@ export async function exchangeAuthorization(
|
|||
|
||||
/**
|
||||
* Exchange a refresh token for an updated access token.
|
||||
*
|
||||
* Supports multiple client authentication methods as specified in OAuth 2.1:
|
||||
* - Automatically selects the best authentication method based on server support
|
||||
* - Preserves the original refresh token if a new one is not returned
|
||||
*
|
||||
* @param authorizationServerUrl - The authorization server's base URL
|
||||
* @param options - Configuration object containing client info, refresh token, etc.
|
||||
* @returns Promise resolving to OAuth tokens (preserves original refresh_token if not replaced)
|
||||
* @throws {Error} When token refresh fails or authentication is invalid
|
||||
*/
|
||||
export async function refreshAuthorization(
|
||||
authorizationServerUrl: string | URL,
|
||||
|
|
@ -539,12 +692,14 @@ export async function refreshAuthorization(
|
|||
clientInformation,
|
||||
refreshToken,
|
||||
resource,
|
||||
addClientAuthentication,
|
||||
}: {
|
||||
metadata?: OAuthMetadata;
|
||||
clientInformation: OAuthClientInformation;
|
||||
refreshToken: string;
|
||||
resource?: URL;
|
||||
},
|
||||
addClientAuthentication?: OAuthClientProvider["addClientAuthentication"];
|
||||
}
|
||||
): Promise<OAuthTokens> {
|
||||
const grantType = "refresh_token";
|
||||
|
||||
|
|
@ -565,14 +720,22 @@ export async function refreshAuthorization(
|
|||
}
|
||||
|
||||
// Exchange refresh token
|
||||
const headers = new Headers({
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
});
|
||||
const params = new URLSearchParams({
|
||||
grant_type: grantType,
|
||||
client_id: clientInformation.client_id,
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
if (clientInformation.client_secret) {
|
||||
params.set("client_secret", clientInformation.client_secret);
|
||||
if (addClientAuthentication) {
|
||||
addClientAuthentication(headers, params, authorizationServerUrl, metadata);
|
||||
} else {
|
||||
// Determine and apply client authentication method
|
||||
const supportedMethods = metadata?.token_endpoint_auth_methods_supported ?? [];
|
||||
const authMethod = selectClientAuthMethod(clientInformation, supportedMethods);
|
||||
|
||||
applyClientAuthentication(authMethod, clientInformation, headers, params);
|
||||
}
|
||||
|
||||
if (resource) {
|
||||
|
|
@ -581,9 +744,7 @@ export async function refreshAuthorization(
|
|||
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
headers,
|
||||
body: params,
|
||||
});
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue