MCP Scope Challenge: Step-Up Authentication Guide

by Admin 50 views
MCP Scope Challenge: Step-Up Authentication Guide

Alright, guys, buckle up! We're diving deep into the world of MCP (Model Context Protocol) and how we're tackling scope challenges with step-up authentication. This is all about making sure your users get the right access, right when they need it. We're going to break down the new MCP specification (2025-11-25) and how we're implementing it in FastMCP and mcp-proxy. Let's get started!

Overview

The MCP specification (2025-11-25) introduces scope challenge handling, which is basically a fancy way of saying we can now ask users for more permissions (OAuth scopes) when they try to access something that requires it. Think of it like needing a higher security clearance to enter a specific area – that's step-up authentication in action.

This document will walk you through the changes needed in FastMCP and mcp-proxy to support this awesome new feature. We'll cover everything from HTTP status codes to header formats and even look at different scope management strategies. So, grab your coffee and let's dive in!

MCP Specification Requirements

According to the MCP Spec Section: Scope Challenge Handling, here's what we need to keep in mind:

HTTP Status Codes

  • 403 Forbidden: This is what we'll use when a user doesn't have enough scope.
  • 401 Unauthorized: This is for when authorization is totally missing or the token is invalid.

WWW-Authenticate Header Format (403 Response)

When we send back a 403, we need to include a WWW-Authenticate header that looks something like this:

WWW-Authenticate: Bearer error="insufficient_scope",
                         scope="required_scope1 required_scope2",
                         resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
                         error_description="Additional permission required"

Let's break down what each part means:

  • error="insufficient_scope" - This is a fixed error code that tells the client exactly what's wrong: they don't have the right scopes.
  • scope="..." - A space-separated list of the scopes needed to access the resource. This is crucial for the client to know what permissions to request.
  • resource_metadata - A URI pointing to the OAuth protected resource metadata. This helps the client understand more about the resource they're trying to access.

Optional Parameter:

  • error_description - A human-readable description of the error. This is super helpful for developers (and sometimes even users) to understand what's going on.

Scope Management Strategies

When it comes to listing the required scopes, we have a few options:

  1. Minimum approach: Only include the newly required scopes. This keeps the request minimal but might be confusing if the user already has some scopes.
  2. Recommended approach: Include existing + new scopes. This gives the client a complete picture of all the scopes needed.
  3. Extended approach: Include existing + new + related scopes. This can be helpful for providing context but might be overkill in some cases.

Current Implementation Analysis

Before we start making changes, let's take a look at where we're currently at with FastMCP and mcp-proxy.

FastMCP Current State

Location: src/FastMCP.ts

// Line 878: Tool definition
interface Tool<T> {
  canAccess?: (auth: T) => boolean;  // Current signature
  // ... other properties
}

// Line 1261: Tools filtered at session creation
const allowedTools = auth
  ? this.#tools.filter((tool) =>
      tool.canAccess ? tool.canAccess(auth) : true,
    )
  : this.#tools;

// Line 1756: Tool execution handler
this.#server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const tool = tools.find((tool) => tool.name === request.params.name);

  if (!tool) {
    throw new McpError(
      ErrorCode.MethodNotFound,
      `Unknown tool: ${request.params.name}`,
    );
  }

  // NO canAccess check here - tool already filtered out!
  // ...
});

Current Behavior:

  • Tools that fail canAccess are hidden from tool lists. This means the user doesn't even know they exist if they don't have the right permissions.
  • No error is thrown when a client tries to use a tool they can't access. This is bad because the client gets no feedback about what's going wrong.
  • Client never knows the tool exists or what scopes are needed. This is the worst part – the client is completely in the dark!

mcp-proxy Current State

Location: node_modules/mcp-proxy/src/authentication.ts

// Lines 15-36
getUnauthorizedResponse(): { body: string; headers: Record<string, string> } {
  const headers: Record<string, string> = {
    "Content-Type": "application/json",
  };

  // Add WWW-Authenticate header if OAuth config is available
  if (this.config.oauth?.protectedResource?.resource) {
    headers["WWW-Authenticate"] = `Bearer resource_metadata="${this.config.oauth.protectedResource.resource}/.well-known/oauth-protected-resource"`;
  }

  return {
    body: JSON.stringify({
      error: {
        code: 401,  // Always 401
        message: "Unauthorized: Invalid or missing API key",
      },
      id: null,
      jsonrpc: "2.0",
    }),
    headers,
  };
}

Location: node_modules/mcp-proxy/src/startHTTPServer.ts

// Lines 61-70: Helper function for WWW-Authenticate header
const getWWWAuthenticateHeader = (
  oauth?: AuthConfig["oauth"],
): string | undefined => {
  if (!oauth?.protectedResource?.resource) {
    return undefined;
  }

  return `Bearer resource_metadata="${oauth.protectedResource.resource}/.well-known/oauth-protected-resource"`;
};

// Lines 248-278: Authentication handling (stateless mode)
if (stateless && authenticate) {
  try {
    const authResult = await authenticate(req);

    if (!authResult || ...) {
      // Returns 401 only
      res.setHeader("WWW-Authenticate", wwwAuthHeader);
      res.writeHead(401).end(...);
    }
  }
}

Current Limitations:

  • Only supports 401 responses. We need to handle 403s for scope challenges.
  • WWW-Authenticate header only includes resource_metadata. We need to add error, scope, and error_description.
  • No support for error, scope, or error_description parameters. These are required for scope challenge handling.
  • No 403 response handling. We need to be able to send back a 403 when the user doesn't have enough scope.

Required Changes

Okay, so now we know what's missing. Let's talk about the changes we need to make to FastMCP and mcp-proxy to support scope challenge handling.

1. FastMCP Changes

1.1 Enhance canAccess API

File: src/FastMCP.ts:878

// Current
interface Tool<T> {
  canAccess?: (auth: T) => boolean;
}

// Proposed
interface CanAccessResult {
  allowed: boolean;
  requiredScopes?: string[];
  errorDescription?: string;
}

interface Tool<T> {
  // Support both for backward compatibility
  canAccess?: (auth: T) => boolean | CanAccessResult;
}

What we're doing here is making the canAccess function more flexible. Instead of just returning a boolean, it can now return a CanAccessResult object that includes the required scopes and an error description. This gives us much more control over the scope challenge process.

1.2 Add Scope Challenge Error Type

File: New class in src/FastMCP.ts

export class InsufficientScopeError extends McpError {
  constructor(
    public toolName: string,
    public requiredScopes: string[],
    public errorDescription?: string,
  ) {
    super(
      ErrorCode.InvalidRequest,  // Or add new ErrorCode.InsufficientScope
      `Insufficient scope for tool '${toolName}': requires scopes [${requiredScopes.join(', ')}]`,
    );
    this.name = "InsufficientScopeError";
  }

  toJSON() {
    return {
      code: -32001,  // Custom JSON-RPC error code
      message: this.message,
      data: {
        error: "insufficient_scope",
        requiredScopes: this.requiredScopes,
        toolName: this.toolName,
        errorDescription: this.errorDescription,
      },
    };
  }
}

This new InsufficientScopeError class will be thrown when a user tries to access a tool without the required scopes. It includes all the information needed to generate the correct 403 response, including the tool name, required scopes, and error description.

1.3 Add Runtime Access Check in Tool Execution

File: src/FastMCP.ts:1756

this.#server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const tool = tools.find((tool) => tool.name === request.params.name);

  if (!tool) {
    throw new McpError(
      ErrorCode.MethodNotFound,
      `Unknown tool: ${request.params.name}`,
    );
  }

  // NEW: Runtime access check with scope challenge support
  if (tool.canAccess && this.#auth) {
    const accessResult = tool.canAccess(this.#auth);

    // Handle both boolean and CanAccessResult returns
    const allowed = typeof accessResult === 'boolean'
      ? accessResult
      : accessResult.allowed;

    if (!allowed) {
      // Extract scope information if available
      const requiredScopes = typeof accessResult === 'object' && accessResult.requiredScopes
        ? accessResult.requiredScopes
        : [];

      const errorDescription = typeof accessResult === 'object' && accessResult.errorDescription
        ? accessResult.errorDescription
        : `Tool '${request.params.name}' requires additional permissions`;

      throw new InsufficientScopeError(
        request.params.name,
        requiredScopes,
        errorDescription,
      );
    }
  }

  // Continue with existing tool execution logic...
});

This is where the magic happens! We're adding a runtime access check to the tool execution handler. This check will call the canAccess function and, if it returns false or a CanAccessResult with allowed: false, it will throw the InsufficientScopeError. This ensures that the user has the required scopes before the tool is executed.

1.4 Add OAuth Config to Session

File: src/FastMCP.ts

// Add to FastMCP options
interface FastMCPOptions {
  // ... existing options
  oauth?: {
    enabled: boolean;
    protectedResource?: {
      resource?: string;
    };
  };
}

We're adding an oauth configuration option to FastMCP. This allows us to specify whether OAuth is enabled and to provide the resource URI, which is needed for the resource_metadata parameter in the WWW-Authenticate header.

2. mcp-proxy Changes

2.1 Enhance AuthenticationMiddleware

File: node_modules/mcp-proxy/src/authentication.ts

export interface AuthConfig {
  apiKey?: string;
  oauth?: {
    protectedResource?: {
      resource?: string;
    };
  };
}

export class AuthenticationMiddleware {
  constructor(private config: AuthConfig = {}) {}

  // Existing method - keep for 401 responses
  getUnauthorizedResponse(): { body: string; headers: Record<string, string> } {
    // ... existing implementation
  }

  // NEW: Generate scope challenge response for 403
  getScopeChallengeResponse(
    requiredScopes: string[],
    errorDescription?: string,
    requestId?: unknown,
  ): { body: string; headers: Record<string, string>; statusCode: number } {
    const headers: Record<string, string> = {
      "Content-Type": "application/json",
    };

    // Build WWW-Authenticate header with all required parameters
    if (this.config.oauth?.protectedResource?.resource) {
      const parts = [
        'Bearer',
        'error="insufficient_scope"',
        `scope="${requiredScopes.join(' ')}"`,
        `resource_metadata="${this.config.oauth.protectedResource.resource}/.well-known/oauth-protected-resource"`,
      ];

      if (errorDescription) {
        // Escape quotes in description
        const escaped = errorDescription.replace(/"/g, '\\"');
        parts.push(`error_description="${escaped}"`);
      }

      headers["WWW-Authenticate"] = parts.join(', ');
    }

    return {
      statusCode: 403,
      headers,
      body: JSON.stringify({
        jsonrpc: "2.0",
        id: requestId ?? null,
        error: {
          code: -32001,  // Custom error code for insufficient scope
          message: errorDescription || "Insufficient scope",
          data: {
            error: "insufficient_scope",
            required_scopes: requiredScopes,
          },
        },
      }),
    };
  }

  validateRequest(req: IncomingMessage): boolean {
    // ... existing implementation
  }
}

We're adding a new getScopeChallengeResponse method to the AuthenticationMiddleware. This method will generate the 403 response with the WWW-Authenticate header, including all the required parameters: error, scope, resource_metadata, and error_description. It also handles escaping quotes in the error description to prevent header injection attacks.

2.2 Update Error Handling in startHTTPServer

File: node_modules/mcp-proxy/src/startHTTPServer.ts

// Add helper to detect scope challenge errors
const isScopeChallengeError = (error: unknown): error is {
  name: string;
  data: {
    error: string;
    requiredScopes: string[];
    errorDescription?: string;
  };
} => {
  return (
    typeof error === 'object' &&
    error !== null &&
    'name' in error &&
    error.name === 'InsufficientScopeError' &&
    'data' in error &&
    typeof error.data === 'object' &&
    error.data !== null &&
    'error' in error.data &&
    error.data.error === 'insufficient_scope'
  );
};

// Update handleStreamRequest to catch scope challenges
const handleStreamRequest = async <T extends ServerLike>({
  // ... existing params
  authMiddleware,  // NEW: Pass authMiddleware instance
}: {
  // ... existing params
  authMiddleware: AuthenticationMiddleware;  // NEW
}) => {
  // ... existing code

  try {
    // ... existing request handling
    await transport.handleRequest(req, res, body);
    return true;
  } catch (error) {
    // NEW: Check for scope challenge errors
    if (isScopeChallengeError(error)) {
      const response = authMiddleware.getScopeChallengeResponse(
        error.data.requiredScopes,
        error.data.errorDescription,
        (body as { id?: unknown })?.id,
      );

      res.writeHead(response.statusCode, response.headers);
      res.end(response.body);
      return true;
    }

    // ... existing error handling
    console.error("[mcp-proxy] error handling request", error);
    res.setHeader("Content-Type", "application/json");
    res.writeHead(500).end(createJsonRpcErrorResponse(-32603, "Internal Server Error"));
  }
  return true;
};

We're updating the handleStreamRequest function to catch InsufficientScopeError exceptions. When it catches one, it calls the getScopeChallengeResponse method on the AuthenticationMiddleware to generate the 403 response and send it back to the client.

2.3 Update startHTTPServer Function Signature

File: node_modules/mcp-proxy/src/startHTTPServer.ts:683

export const startHTTPServer = async <T extends ServerLike>({
  // ... existing params
}: {
  // ... existing params
}): Promise<SSEServer> => {
  // ... existing setup

  const authMiddleware = new AuthenticationMiddleware({ apiKey, oauth });

  const httpServer = http.createServer(async (req, res) => {
    // ... existing CORS and auth checks

    if (
      streamEndpoint &&
      (await handleStreamRequest({
        activeTransports: activeStreamTransports,
        authenticate,
        createServer,
        enableJsonResponse,
        endpoint: streamEndpoint,
        eventStore,
        oauth,
        onClose,
        onConnect,
        req,
        res,
        stateless,
        authMiddleware,  // NEW: Pass authMiddleware
      }))
    ) {
      return;
    }

    // ... rest of handler
  });

  // ... rest of function
};

Finally, we're updating the startHTTPServer function to pass the AuthenticationMiddleware instance to the handleStreamRequest function. This allows the handleStreamRequest function to use the AuthenticationMiddleware to generate the 403 response.

Error Flow

To give you a clear picture of how everything works together, here's a diagram of the error flow:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 1. Client calls tool via MCP                                β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                        β”‚
                        β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 2. mcp-proxy receives request                               β”‚
β”‚    - Authenticates user (401 if invalid token)              β”‚
β”‚    - Forwards to FastMCP session                            β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                        β”‚
                        β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 3. FastMCP CallToolRequestSchema handler                    β”‚
β”‚    - Finds tool                                             β”‚
β”‚    - Calls tool.canAccess(auth)                             β”‚
β”‚    - Returns { allowed: false, requiredScopes: [...] }     β”‚
β”‚    - Throws InsufficientScopeError                          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                        β”‚
                        β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 4. Error propagates to mcp-proxy                            β”‚
β”‚    - Catches InsufficientScopeError                         β”‚
β”‚    - Calls authMiddleware.getScopeChallengeResponse()       β”‚
β”‚    - Returns 403 with WWW-Authenticate header               β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                        β”‚
                        β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 5. Client receives 403 response                             β”‚
β”‚    HTTP/1.1 403 Forbidden                                   β”‚
β”‚    WWW-Authenticate: Bearer error="insufficient_scope",     β”‚
β”‚                      scope="files:write admin:read",        β”‚
β”‚                      resource_metadata="...",               β”‚
β”‚                      error_description="..."                β”‚
β”‚                                                             β”‚
β”‚    Body: {                                                  β”‚
β”‚      "jsonrpc": "2.0",                                      β”‚
β”‚      "id": 1,                                               β”‚
β”‚      "error": {                                             β”‚
β”‚        "code": -32001,                                      β”‚
β”‚        "message": "Insufficient scope",                     β”‚
β”‚        "data": {                                            β”‚
β”‚          "error": "insufficient_scope",                     β”‚
β”‚          "required_scopes": ["files:write", "admin:read"]  β”‚
β”‚        }                                                    β”‚
β”‚      }                                                      β”‚
β”‚    }                                                        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                        β”‚
                        β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 6. Client initiates re-authorization                        β”‚
β”‚    - Parses WWW-Authenticate header                         β”‚
β”‚    - Requests additional scopes from OAuth provider         β”‚
β”‚    - Retries tool call with new token                       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Example Usage

Here's an example of how you might use the new canAccess API in a FastMCP server:

// In a FastMCP server
const mcp = new FastMCP({
  name: "File Manager",
  oauth: {
    enabled: true,
    protectedResource: {
      resource: "mcp://file-manager",
    },
  },
});

mcp.tool({
  name: "write_file",
  description: "Write content to a file",
  parameters: z.object({
    path: z.string(),
    content: z.string(),
  }),

  // Scope-aware access control
  canAccess: (auth) => {
    const userScopes = auth.scopes || [];

    if (userScopes.includes("files:write")) {
      return true;  // Backward compatible boolean
    }

    // Return scope challenge
    return {
      allowed: false,
      requiredScopes: ["files:write"],
      errorDescription: "Writing files requires the 'files:write' scope",
    };
  },

  execute: async ({ path, content }) => {
    // Execute only if canAccess passed
    await fs.writeFile(path, content);
    return `File written: ${path}`;
  },
});

In this example, the write_file tool requires the files:write scope. If the user doesn't have this scope, the canAccess function returns a CanAccessResult that tells the client what scope is needed and why.

Testing Strategy

To make sure everything is working correctly, we need a comprehensive testing strategy.

Unit Tests

  1. FastMCP canAccess Tests
    • Test backward compatibility (boolean return)
    • Test new object return format
    • Test scope challenge error throwing
  2. InsufficientScopeError Tests
    • Test error construction
    • Test JSON serialization
    • Test error metadata
  3. mcp-proxy AuthenticationMiddleware Tests
    • Test getScopeChallengeResponse header format
    • Test error description escaping
    • Test backward compatibility with 401 responses

Integration Tests

  1. End-to-End Scope Challenge
    • Client calls tool without required scope
    • Receives 403 with proper WWW-Authenticate header
    • Re-authenticates with required scope
    • Successfully calls tool
  2. WWW-Authenticate Header Format
    • Validate header conforms to RFC 9728
    • Test with/without error_description
    • Test scope list formatting
  3. Error Propagation
    • Verify InsufficientScopeError propagates through mcp-proxy
    • Verify 403 status code (not 401)
    • Verify JSON-RPC error format

Migration Guide

If you're already using FastMCP and mcp-proxy, here's how to migrate to the new scope challenge handling.

For FastMCP Server Developers

Before:

canAccess: (auth) => auth.scopes?.includes("admin")

After (backward compatible):

// Option 1: Keep existing boolean (tool hidden if false)
canAccess: (auth) => auth.scopes?.includes("admin")

// Option 2: Use scope challenge (client gets 403 with scope info)
canAccess: (auth) => {
  if (auth.scopes?.includes("admin")) {
    return true;
  }
  return {
    allowed: false,
    requiredScopes: ["admin"],
    errorDescription: "Admin access required",
  };
}

For MCP Client Developers

Before:

  • Tools were filtered out silently
  • No way to know what scopes were needed

After:

  • Receive 403 response with WWW-Authenticate header
  • Parse scope parameter to know what's needed
  • Re-authenticate with additional scopes
  • Retry request

References