Adding invalidateCredentials() to OAuthClientProvider (#570)

Co-authored-by: Paul Carleton <paulc@anthropic.com>
This commit is contained in:
Glen Maddern 2025-07-15 02:11:52 +10:00 • committed by GitHub
commit 4b2ad0385d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 508 additions and 83 deletions

View file

@ -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", {

View file

@ -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());

View file

@ -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');
});
});
});

View file

@ -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');
});
});

View file

@ -4,8 +4,9 @@ import { OAuthErrorResponse } from "../../shared/auth.js";
* Base class for all OAuth errors
*/
export class OAuthError extends Error {
static errorCode: string;
constructor(
public readonly errorCode: string,
message: string,
public readonly errorUri?: string
) {
@ -28,6 +29,10 @@ export class OAuthError extends Error {
return response;
}
get errorCode(): string {
return (this.constructor as typeof OAuthError).errorCode
}
}
/**
@ -36,9 +41,7 @@ export class OAuthError extends Error {
* or is otherwise malformed.
*/
export class InvalidRequestError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("invalid_request", message, errorUri);
}
static errorCode = "invalid_request";
}
/**
@ -46,9 +49,7 @@ export class InvalidRequestError extends OAuthError {
* authentication included, or unsupported authentication method).
*/
export class InvalidClientError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("invalid_client", message, errorUri);
}
static errorCode = "invalid_client";
}
/**
@ -57,9 +58,7 @@ export class InvalidClientError extends OAuthError {
* authorization request, or was issued to another client.
*/
export class InvalidGrantError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("invalid_grant", message, errorUri);
}
static errorCode = "invalid_grant";
}
/**
@ -67,9 +66,7 @@ export class InvalidGrantError extends OAuthError {
* this authorization grant type.
*/
export class UnauthorizedClientError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("unauthorized_client", message, errorUri);
}
static errorCode = "unauthorized_client";
}
/**
@ -77,9 +74,7 @@ export class UnauthorizedClientError extends OAuthError {
* by the authorization server.
*/
export class UnsupportedGrantTypeError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("unsupported_grant_type", message, errorUri);
}
static errorCode = "unsupported_grant_type";
}
/**
@ -87,18 +82,14 @@ export class UnsupportedGrantTypeError extends OAuthError {
* exceeds the scope granted by the resource owner.
*/
export class InvalidScopeError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("invalid_scope", message, errorUri);
}
static errorCode = "invalid_scope";
}
/**
* Access denied error - The resource owner or authorization server denied the request.
*/
export class AccessDeniedError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("access_denied", message, errorUri);
}
static errorCode = "access_denied";
}
/**
@ -106,9 +97,7 @@ export class AccessDeniedError extends OAuthError {
* that prevented it from fulfilling the request.
*/
export class ServerError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("server_error", message, errorUri);
}
static errorCode = "server_error";
}
/**
@ -116,9 +105,7 @@ export class ServerError extends OAuthError {
* handle the request due to a temporary overloading or maintenance of the server.
*/
export class TemporarilyUnavailableError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("temporarily_unavailable", message, errorUri);
}
static errorCode = "temporarily_unavailable";
}
/**
@ -126,9 +113,7 @@ export class TemporarilyUnavailableError extends OAuthError {
* obtaining an authorization code using this method.
*/
export class UnsupportedResponseTypeError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("unsupported_response_type", message, errorUri);
}
static errorCode = "unsupported_response_type";
}
/**
@ -136,9 +121,7 @@ export class UnsupportedResponseTypeError extends OAuthError {
* the requested token type.
*/
export class UnsupportedTokenTypeError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("unsupported_token_type", message, errorUri);
}
static errorCode = "unsupported_token_type";
}
/**
@ -146,9 +129,7 @@ export class UnsupportedTokenTypeError extends OAuthError {
* or invalid for other reasons.
*/
export class InvalidTokenError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("invalid_token", message, errorUri);
}
static errorCode = "invalid_token";
}
/**
@ -156,9 +137,7 @@ export class InvalidTokenError extends OAuthError {
* (Custom, non-standard error)
*/
export class MethodNotAllowedError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("method_not_allowed", message, errorUri);
}
static errorCode = "method_not_allowed";
}
/**
@ -166,9 +145,7 @@ export class MethodNotAllowedError extends OAuthError {
* (Custom, non-standard error based on RFC 6585)
*/
export class TooManyRequestsError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("too_many_requests", message, errorUri);
}
static errorCode = "too_many_requests";
}
/**
@ -176,16 +153,47 @@ export class TooManyRequestsError extends OAuthError {
* (Custom error for dynamic client registration - RFC 7591)
*/
export class InvalidClientMetadataError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("invalid_client_metadata", message, errorUri);
}
static errorCode = "invalid_client_metadata";
}
/**
* Insufficient scope error - The request requires higher privileges than provided by the access token.
*/
export class InsufficientScopeError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("insufficient_scope", message, errorUri);
static errorCode = "insufficient_scope";
}
/**
* A utility class for defining one-off error codes
*/
export class CustomOAuthError extends OAuthError {
constructor(private readonly customErrorCode: string, message: string, errorUri?: string) {
super(message, errorUri);
}
get errorCode(): string {
return this.customErrorCode;
}
}
/**
* A full list of all OAuthErrors, enabling parsing from error responses
*/
export const OAUTH_ERRORS = {
[InvalidRequestError.errorCode]: InvalidRequestError,
[InvalidClientError.errorCode]: InvalidClientError,
[InvalidGrantError.errorCode]: InvalidGrantError,
[UnauthorizedClientError.errorCode]: UnauthorizedClientError,
[UnsupportedGrantTypeError.errorCode]: UnsupportedGrantTypeError,
[InvalidScopeError.errorCode]: InvalidScopeError,
[AccessDeniedError.errorCode]: AccessDeniedError,
[ServerError.errorCode]: ServerError,
[TemporarilyUnavailableError.errorCode]: TemporarilyUnavailableError,
[UnsupportedResponseTypeError.errorCode]: UnsupportedResponseTypeError,
[UnsupportedTokenTypeError.errorCode]: UnsupportedTokenTypeError,
[InvalidTokenError.errorCode]: InvalidTokenError,
[MethodNotAllowedError.errorCode]: MethodNotAllowedError,
[TooManyRequestsError.errorCode]: TooManyRequestsError,
[InvalidClientMetadataError.errorCode]: InvalidClientMetadataError,
[InsufficientScopeError.errorCode]: InsufficientScopeError,
} as const;

View file

@ -1,7 +1,7 @@
import { Request, Response } from "express";
import { requireBearerAuth } from "./bearerAuth.js";
import { AuthInfo } from "../types.js";
import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from "../errors.js";
import { InsufficientScopeError, InvalidTokenError, CustomOAuthError, ServerError } from "../errors.js";
import { OAuthTokenVerifier } from "../provider.js";
// Mock verifier
@ -305,7 +305,7 @@ describe("requireBearerAuth middleware", () => {
authorization: "Bearer valid-token",
};
mockVerifyAccessToken.mockRejectedValue(new OAuthError("custom_error", "Some OAuth error"));
mockVerifyAccessToken.mockRejectedValue(new CustomOAuthError("custom_error", "Some OAuth error"));
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);