Adding invalidateCredentials() to OAuthClientProvider (#570)
Co-authored-by: Paul Carleton <paulc@anthropic.com>
This commit is contained in:
parent
cc9ea7fd21
commit
4b2ad0385d
4 changed files with 508 additions and 83 deletions
|
|
@ -10,6 +10,7 @@ import {
|
|||
auth,
|
||||
type OAuthClientProvider,
|
||||
} from "./auth.js";
|
||||
import {ServerError} from "../server/auth/errors.js";
|
||||
import { OAuthMetadata } from '../shared/auth.js';
|
||||
|
||||
// Mock fetch globally
|
||||
|
|
@ -596,10 +597,7 @@ describe("OAuth Authorization", () => {
|
|||
});
|
||||
|
||||
it("throws on non-404 errors", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
});
|
||||
mockFetch.mockResolvedValueOnce(new Response(null, { status: 500 }));
|
||||
|
||||
await expect(
|
||||
discoverOAuthMetadata("https://auth.example.com")
|
||||
|
|
@ -607,14 +605,15 @@ describe("OAuth Authorization", () => {
|
|||
});
|
||||
|
||||
it("validates metadata schema", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
// Missing required fields
|
||||
issuer: "https://auth.example.com",
|
||||
}),
|
||||
});
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
Response.json(
|
||||
{
|
||||
// Missing required fields
|
||||
issuer: "https://auth.example.com",
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
);
|
||||
|
||||
await expect(
|
||||
discoverOAuthMetadata("https://auth.example.com")
|
||||
|
|
@ -902,10 +901,12 @@ describe("OAuth Authorization", () => {
|
|||
});
|
||||
|
||||
it("throws on error response", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
});
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
Response.json(
|
||||
new ServerError("Token exchange failed").toResponseObject(),
|
||||
{ status: 400 }
|
||||
)
|
||||
);
|
||||
|
||||
await expect(
|
||||
exchangeAuthorization("https://auth.example.com", {
|
||||
|
|
@ -1054,10 +1055,12 @@ describe("OAuth Authorization", () => {
|
|||
});
|
||||
|
||||
it("throws on error response", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
});
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
Response.json(
|
||||
new ServerError("Token refresh failed").toResponseObject(),
|
||||
{ status: 400 }
|
||||
)
|
||||
);
|
||||
|
||||
await expect(
|
||||
refreshAuthorization("https://auth.example.com", {
|
||||
|
|
@ -1142,10 +1145,12 @@ describe("OAuth Authorization", () => {
|
|||
});
|
||||
|
||||
it("throws on error response", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
});
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
Response.json(
|
||||
new ServerError("Dynamic client registration failed").toResponseObject(),
|
||||
{ status: 400 }
|
||||
)
|
||||
);
|
||||
|
||||
await expect(
|
||||
registerClient("https://auth.example.com", {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,24 @@
|
|||
import pkceChallenge from "pkce-challenge";
|
||||
import { LATEST_PROTOCOL_VERSION } from "../types.js";
|
||||
import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull, OAuthProtectedResourceMetadata } from "../shared/auth.js";
|
||||
import {
|
||||
OAuthClientMetadata,
|
||||
OAuthClientInformation,
|
||||
OAuthTokens,
|
||||
OAuthMetadata,
|
||||
OAuthClientInformationFull,
|
||||
OAuthProtectedResourceMetadata,
|
||||
OAuthErrorResponseSchema
|
||||
} from "../shared/auth.js";
|
||||
import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema } from "../shared/auth.js";
|
||||
import { checkResourceAllowed, resourceUrlFromServerUrl } from "../shared/auth-utils.js";
|
||||
import {
|
||||
InvalidClientError,
|
||||
InvalidGrantError,
|
||||
OAUTH_ERRORS,
|
||||
OAuthError,
|
||||
ServerError,
|
||||
UnauthorizedClientError
|
||||
} from "../server/auth/errors.js";
|
||||
|
||||
/**
|
||||
* Implements an end-to-end OAuth client to be used with one MCP server.
|
||||
|
|
@ -101,6 +117,13 @@ export interface OAuthClientProvider {
|
|||
* Implementations must verify the returned resource matches the MCP server.
|
||||
*/
|
||||
validateResourceURL?(serverUrl: string | URL, resource?: string): Promise<URL | undefined>;
|
||||
|
||||
/**
|
||||
* If implemented, provides a way for the client to invalidate (e.g. delete) the specified
|
||||
* credentials, in the case where the server has indicated that they are no longer valid.
|
||||
* This avoids requiring the user to intervene manually.
|
||||
*/
|
||||
invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier'): void | Promise<void>;
|
||||
}
|
||||
|
||||
export type AuthResult = "AUTHORIZED" | "REDIRECT";
|
||||
|
|
@ -219,6 +242,33 @@ function applyPublicAuth(clientId: string, params: URLSearchParams): void {
|
|||
params.set("client_id", clientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an OAuth error response from a string or Response object.
|
||||
*
|
||||
* If the input is a standard OAuth2.0 error response, it will be parsed according to the spec
|
||||
* and an instance of the appropriate OAuthError subclass will be returned.
|
||||
* If parsing fails, it falls back to a generic ServerError that includes
|
||||
* the response status (if available) and original content.
|
||||
*
|
||||
* @param input - A Response object or string containing the error response
|
||||
* @returns A Promise that resolves to an OAuthError instance
|
||||
*/
|
||||
export async function parseErrorResponse(input: Response | string): Promise<OAuthError> {
|
||||
const statusCode = input instanceof Response ? input.status : undefined;
|
||||
const body = input instanceof Response ? await input.text() : input;
|
||||
|
||||
try {
|
||||
const result = OAuthErrorResponseSchema.parse(JSON.parse(body));
|
||||
const { error, error_description, error_uri } = result;
|
||||
const errorClass = OAUTH_ERRORS[error] || ServerError;
|
||||
return new errorClass(error_description || '', error_uri);
|
||||
} catch (error) {
|
||||
// Not a valid OAuth error response, but try to inform the user of the raw data anyway
|
||||
const errorMessage = `${statusCode ? `HTTP ${statusCode}: ` : ''}Invalid OAuth error response: ${error}. Raw body: ${body}`;
|
||||
return new ServerError(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates the full auth flow with a server.
|
||||
*
|
||||
|
|
@ -226,6 +276,31 @@ function applyPublicAuth(clientId: string, params: URLSearchParams): void {
|
|||
* instead of linking together the other lower-level functions in this module.
|
||||
*/
|
||||
export async function auth(
|
||||
provider: OAuthClientProvider,
|
||||
options: {
|
||||
serverUrl: string | URL;
|
||||
authorizationCode?: string;
|
||||
scope?: string;
|
||||
resourceMetadataUrl?: URL }): Promise<AuthResult> {
|
||||
|
||||
try {
|
||||
return await authInternal(provider, options);
|
||||
} catch (error) {
|
||||
// Handle recoverable error types by invalidating credentials and retrying
|
||||
if (error instanceof InvalidClientError || error instanceof UnauthorizedClientError) {
|
||||
await provider.invalidateCredentials?.('all');
|
||||
return await authInternal(provider, options);
|
||||
} else if (error instanceof InvalidGrantError) {
|
||||
await provider.invalidateCredentials?.('tokens');
|
||||
return await authInternal(provider, options);
|
||||
}
|
||||
|
||||
// Throw otherwise
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function authInternal(
|
||||
provider: OAuthClientProvider,
|
||||
{ serverUrl,
|
||||
authorizationCode,
|
||||
|
|
@ -289,7 +364,7 @@ export async function auth(
|
|||
});
|
||||
|
||||
await provider.saveTokens(tokens);
|
||||
return "AUTHORIZED";
|
||||
return "AUTHORIZED"
|
||||
}
|
||||
|
||||
const tokens = await provider.tokens();
|
||||
|
|
@ -307,9 +382,15 @@ export async function auth(
|
|||
});
|
||||
|
||||
await provider.saveTokens(newTokens);
|
||||
return "AUTHORIZED";
|
||||
} catch {
|
||||
// Could not refresh OAuth tokens
|
||||
return "AUTHORIZED"
|
||||
} catch (error) {
|
||||
// If this is a ServerError, or an unknown type, log it out and try to continue. Otherwise, escalate so we can fix things and retry.
|
||||
if (!(error instanceof OAuthError) || error instanceof ServerError) {
|
||||
// Could not refresh OAuth tokens
|
||||
} else {
|
||||
// Refresh failed for another reason, re-throw
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -327,7 +408,7 @@ export async function auth(
|
|||
|
||||
await provider.saveCodeVerifier(codeVerifier);
|
||||
await provider.redirectToAuthorization(authorizationUrl);
|
||||
return "REDIRECT";
|
||||
return "REDIRECT"
|
||||
}
|
||||
|
||||
export async function selectResourceURL(serverUrl: string | URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise<URL | undefined> {
|
||||
|
|
@ -707,7 +788,7 @@ export async function exchangeAuthorization(
|
|||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Token exchange failed: HTTP ${response.status}`);
|
||||
throw await parseErrorResponse(response);
|
||||
}
|
||||
|
||||
return OAuthTokensSchema.parse(await response.json());
|
||||
|
|
@ -788,7 +869,7 @@ export async function refreshAuthorization(
|
|||
body: params,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Token refresh failed: HTTP ${response.status}`);
|
||||
throw await parseErrorResponse(response);
|
||||
}
|
||||
|
||||
return OAuthTokensSchema.parse({ refresh_token: refreshToken, ...(await response.json()) });
|
||||
|
|
@ -828,7 +909,7 @@ export async function registerClient(
|
|||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Dynamic client registration failed: HTTP ${response.status}`);
|
||||
throw await parseErrorResponse(response);
|
||||
}
|
||||
|
||||
return OAuthClientInformationFullSchema.parse(await response.json());
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { JSONRPCMessage } from "../types.js";
|
|||
import { SSEClientTransport } from "./sse.js";
|
||||
import { OAuthClientProvider, UnauthorizedError } from "./auth.js";
|
||||
import { OAuthTokens } from "../shared/auth.js";
|
||||
import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from "../server/auth/errors.js";
|
||||
|
||||
describe("SSEClientTransport", () => {
|
||||
let resourceServer: Server;
|
||||
|
|
@ -363,6 +364,7 @@ describe("SSEClientTransport", () => {
|
|||
redirectToAuthorization: jest.fn(),
|
||||
saveCodeVerifier: jest.fn(),
|
||||
codeVerifier: jest.fn(),
|
||||
invalidateCredentials: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -934,5 +936,176 @@ describe("SSEClientTransport", () => {
|
|||
await expect(() => transport.start()).rejects.toThrow(UnauthorizedError);
|
||||
expect(mockAuthProvider.redirectToAuthorization).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("invalidates all credentials on InvalidClientError during token refresh", async () => {
|
||||
// Mock tokens() to return token with refresh token
|
||||
mockAuthProvider.tokens.mockResolvedValue({
|
||||
access_token: "expired-token",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "refresh-token"
|
||||
});
|
||||
|
||||
let baseUrl = resourceBaseUrl;
|
||||
|
||||
// Create server that returns InvalidClientError on token refresh
|
||||
const server = createServer((req, res) => {
|
||||
lastServerRequest = req;
|
||||
|
||||
// Handle OAuth metadata discovery
|
||||
if (req.url === "/.well-known/oauth-authorization-server" && req.method === "GET") {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
issuer: baseUrl.href,
|
||||
authorization_endpoint: `${baseUrl.href}authorize`,
|
||||
token_endpoint: `${baseUrl.href}token`,
|
||||
response_types_supported: ["code"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === "/token" && req.method === "POST") {
|
||||
// Handle token refresh request - return InvalidClientError
|
||||
const error = new InvalidClientError("Client authentication failed");
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' })
|
||||
.end(JSON.stringify(error.toResponseObject()));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url !== "/") {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
res.writeHead(401).end();
|
||||
});
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address() as AddressInfo;
|
||||
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
transport = new SSEClientTransport(baseUrl, {
|
||||
authProvider: mockAuthProvider,
|
||||
});
|
||||
|
||||
await expect(() => transport.start()).rejects.toThrow(InvalidClientError);
|
||||
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all');
|
||||
});
|
||||
|
||||
it("invalidates all credentials on UnauthorizedClientError during token refresh", async () => {
|
||||
// Mock tokens() to return token with refresh token
|
||||
mockAuthProvider.tokens.mockResolvedValue({
|
||||
access_token: "expired-token",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "refresh-token"
|
||||
});
|
||||
|
||||
let baseUrl = resourceBaseUrl;
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
lastServerRequest = req;
|
||||
|
||||
// Handle OAuth metadata discovery
|
||||
if (req.url === "/.well-known/oauth-authorization-server" && req.method === "GET") {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
issuer: baseUrl.href,
|
||||
authorization_endpoint: `${baseUrl.href}authorize`,
|
||||
token_endpoint: `${baseUrl.href}token`,
|
||||
response_types_supported: ["code"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === "/token" && req.method === "POST") {
|
||||
// Handle token refresh request - return UnauthorizedClientError
|
||||
const error = new UnauthorizedClientError("Client not authorized");
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' })
|
||||
.end(JSON.stringify(error.toResponseObject()));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url !== "/") {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
res.writeHead(401).end();
|
||||
});
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address() as AddressInfo;
|
||||
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
transport = new SSEClientTransport(baseUrl, {
|
||||
authProvider: mockAuthProvider,
|
||||
});
|
||||
|
||||
await expect(() => transport.start()).rejects.toThrow(UnauthorizedClientError);
|
||||
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all');
|
||||
});
|
||||
|
||||
it("invalidates tokens on InvalidGrantError during token refresh", async () => {
|
||||
// Mock tokens() to return token with refresh token
|
||||
mockAuthProvider.tokens.mockResolvedValue({
|
||||
access_token: "expired-token",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "refresh-token"
|
||||
});
|
||||
let baseUrl = resourceBaseUrl;
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
lastServerRequest = req;
|
||||
|
||||
// Handle OAuth metadata discovery
|
||||
if (req.url === "/.well-known/oauth-authorization-server" && req.method === "GET") {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
issuer: baseUrl.href,
|
||||
authorization_endpoint: `${baseUrl.href}authorize`,
|
||||
token_endpoint: `${baseUrl.href}token`,
|
||||
response_types_supported: ["code"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === "/token" && req.method === "POST") {
|
||||
// Handle token refresh request - return InvalidGrantError
|
||||
const error = new InvalidGrantError("Invalid refresh token");
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' })
|
||||
.end(JSON.stringify(error.toResponseObject()));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url !== "/") {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
res.writeHead(401).end();
|
||||
});
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address() as AddressInfo;
|
||||
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
transport = new SSEClientTransport(baseUrl, {
|
||||
authProvider: mockAuthProvider,
|
||||
});
|
||||
|
||||
await expect(() => transport.start()).rejects.toThrow(InvalidGrantError);
|
||||
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('tokens');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions, StartSSEOptions } from "./streamableHttp.js";
|
||||
import { OAuthClientProvider, UnauthorizedError } from "./auth.js";
|
||||
import { JSONRPCMessage } from "../types.js";
|
||||
import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from "../server/auth/errors.js";
|
||||
|
||||
|
||||
describe("StreamableHTTPClientTransport", () => {
|
||||
|
|
@ -17,6 +18,7 @@ describe("StreamableHTTPClientTransport", () => {
|
|||
redirectToAuthorization: jest.fn(),
|
||||
saveCodeVerifier: jest.fn(),
|
||||
codeVerifier: jest.fn(),
|
||||
invalidateCredentials: jest.fn(),
|
||||
};
|
||||
transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { authProvider: mockAuthProvider });
|
||||
jest.spyOn(global, "fetch");
|
||||
|
|
@ -592,4 +594,160 @@ describe("StreamableHTTPClientTransport", () => {
|
|||
await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);
|
||||
expect(mockAuthProvider.redirectToAuthorization.mock.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("invalidates all credentials on InvalidClientError during auth", async () => {
|
||||
const message: JSONRPCMessage = {
|
||||
jsonrpc: "2.0",
|
||||
method: "test",
|
||||
params: {},
|
||||
id: "test-id"
|
||||
};
|
||||
|
||||
mockAuthProvider.tokens.mockResolvedValue({
|
||||
access_token: "test-token",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "test-refresh"
|
||||
});
|
||||
|
||||
const unauthedResponse = {
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
headers: new Headers()
|
||||
};
|
||||
(global.fetch as jest.Mock)
|
||||
// Initial connection
|
||||
.mockResolvedValueOnce(unauthedResponse)
|
||||
// Resource discovery
|
||||
.mockResolvedValueOnce(unauthedResponse)
|
||||
// OAuth metadata discovery
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
issuer: "http://localhost:1234",
|
||||
authorization_endpoint: "http://localhost:1234/authorize",
|
||||
token_endpoint: "http://localhost:1234/token",
|
||||
response_types_supported: ["code"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
}),
|
||||
})
|
||||
// Token refresh fails with InvalidClientError
|
||||
.mockResolvedValueOnce(Response.json(
|
||||
new InvalidClientError("Client authentication failed").toResponseObject(),
|
||||
{ status: 400 }
|
||||
))
|
||||
// Fallback should fail to complete the flow
|
||||
.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404
|
||||
});
|
||||
|
||||
await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);
|
||||
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all');
|
||||
});
|
||||
|
||||
it("invalidates all credentials on UnauthorizedClientError during auth", async () => {
|
||||
const message: JSONRPCMessage = {
|
||||
jsonrpc: "2.0",
|
||||
method: "test",
|
||||
params: {},
|
||||
id: "test-id"
|
||||
};
|
||||
|
||||
mockAuthProvider.tokens.mockResolvedValue({
|
||||
access_token: "test-token",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "test-refresh"
|
||||
});
|
||||
|
||||
const unauthedResponse = {
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
headers: new Headers()
|
||||
};
|
||||
(global.fetch as jest.Mock)
|
||||
// Initial connection
|
||||
.mockResolvedValueOnce(unauthedResponse)
|
||||
// Resource discovery
|
||||
.mockResolvedValueOnce(unauthedResponse)
|
||||
// OAuth metadata discovery
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
issuer: "http://localhost:1234",
|
||||
authorization_endpoint: "http://localhost:1234/authorize",
|
||||
token_endpoint: "http://localhost:1234/token",
|
||||
response_types_supported: ["code"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
}),
|
||||
})
|
||||
// Token refresh fails with UnauthorizedClientError
|
||||
.mockResolvedValueOnce(Response.json(
|
||||
new UnauthorizedClientError("Client not authorized").toResponseObject(),
|
||||
{ status: 400 }
|
||||
))
|
||||
// Fallback should fail to complete the flow
|
||||
.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404
|
||||
});
|
||||
|
||||
await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);
|
||||
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all');
|
||||
});
|
||||
|
||||
it("invalidates tokens on InvalidGrantError during auth", async () => {
|
||||
const message: JSONRPCMessage = {
|
||||
jsonrpc: "2.0",
|
||||
method: "test",
|
||||
params: {},
|
||||
id: "test-id"
|
||||
};
|
||||
|
||||
mockAuthProvider.tokens.mockResolvedValue({
|
||||
access_token: "test-token",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "test-refresh"
|
||||
});
|
||||
|
||||
const unauthedResponse = {
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
headers: new Headers()
|
||||
};
|
||||
(global.fetch as jest.Mock)
|
||||
// Initial connection
|
||||
.mockResolvedValueOnce(unauthedResponse)
|
||||
// Resource discovery
|
||||
.mockResolvedValueOnce(unauthedResponse)
|
||||
// OAuth metadata discovery
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
issuer: "http://localhost:1234",
|
||||
authorization_endpoint: "http://localhost:1234/authorize",
|
||||
token_endpoint: "http://localhost:1234/token",
|
||||
response_types_supported: ["code"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
}),
|
||||
})
|
||||
// Token refresh fails with InvalidGrantError
|
||||
.mockResolvedValueOnce(Response.json(
|
||||
new InvalidGrantError("Invalid refresh token").toResponseObject(),
|
||||
{ status: 400 }
|
||||
))
|
||||
// Fallback should fail to complete the flow
|
||||
.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404
|
||||
});
|
||||
|
||||
await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);
|
||||
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('tokens');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue