Jeremiah Lowin commited on
Commit
7bdb963
·
1 Parent(s): 562e009

Add auth support

Browse files
.github/workflows/run-tests.yml CHANGED
@@ -59,4 +59,4 @@ jobs:
59
  uv pip install pyreadline3
60
 
61
  - name: Run tests
62
- run: uv run pytest -vv
 
59
  uv pip install pyreadline3
60
 
61
  - name: Run tests
62
+ run: uv run --frozen pytest -vv
README.md CHANGED
@@ -760,7 +760,7 @@ Contributions make the open-source community vibrant! We welcome improvements an
760
 
761
  Run the test suite:
762
  ```bash
763
- uv run pytest -vv
764
  ```
765
 
766
  #### Formatting & Linting
 
760
 
761
  Run the test suite:
762
  ```bash
763
+ uv run --frozen pytest -vv
764
  ```
765
 
766
  #### Formatting & Linting
docs/servers/fastmcp.mdx CHANGED
@@ -292,4 +292,38 @@ print(mcp.settings.on_duplicate_tools) # Output: "error"
292
  - **`on_duplicate_resources`**: How to handle duplicate resource registrations
293
  - **`on_duplicate_prompts`**: How to handle duplicate prompt registrations
294
 
295
- All of these can be configured directly as parameters when creating the `FastMCP` instance.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  - **`on_duplicate_resources`**: How to handle duplicate resource registrations
293
  - **`on_duplicate_prompts`**: How to handle duplicate prompt registrations
294
 
295
+ All of these can be configured directly as parameters when creating the `FastMCP` instance.
296
+
297
+ ## Authentication
298
+
299
+ <VersionBadge version="2.3.0" />
300
+
301
+ FastMCP inherits support for OAuth 2.0 authentication from the MCP protocol, allowing servers to protect their tools and resources behind authentication.
302
+
303
+ ### OAuth 2.0 Support
304
+
305
+ The `mcp.server.auth` module implements an OAuth 2.0 server interface that servers can use by providing an implementation of the `OAuthServerProvider` protocol.
306
+
307
+ ```python
308
+ from fastmcp import FastMCP
309
+ from mcp.server.auth.settings import RevocationOptions, ClientRegistrationOptions, AuthSettings
310
+
311
+
312
+ # Create a server with authentication
313
+ mcp = FastMCP(
314
+ name="SecureApp",
315
+ auth_provider=MyOAuthServerProvider(),
316
+ auth=AuthSettings(
317
+ issuer_url="https://myapp.com",
318
+ revocation_options=RevocationOptions(
319
+ enabled=True,
320
+ ),
321
+ client_registration_options=ClientRegistrationOptions(
322
+ enabled=True,
323
+ valid_scopes=["myscope", "myotherscope"],
324
+ default_scopes=["myscope"],
325
+ ),
326
+ required_scopes=["myscope"],
327
+ ),
328
+ )
329
+ ```
justfile CHANGED
@@ -6,4 +6,4 @@ test: build
6
 
7
  # Run pyright on all files
8
  typecheck:
9
- uv run pyright
 
6
 
7
  # Run pyright on all files
8
  typecheck:
9
+ uv run --frozen pyright
src/fastmcp/server/server.py CHANGED
@@ -15,6 +15,12 @@ from typing import TYPE_CHECKING, Any, Generic, Literal
15
  import anyio
16
  import httpx
17
  import uvicorn
 
 
 
 
 
 
18
  from mcp.server.lowlevel.helper_types import ReadResourceContents
19
  from mcp.server.lowlevel.server import LifespanResultT
20
  from mcp.server.lowlevel.server import Server as MCPServer
@@ -36,8 +42,12 @@ from mcp.types import ResourceTemplate as MCPResourceTemplate
36
  from mcp.types import Tool as MCPTool
37
  from pydantic.networks import AnyUrl
38
  from starlette.applications import Starlette
 
 
39
  from starlette.requests import Request
 
40
  from starlette.routing import Mount, Route
 
41
 
42
  import fastmcp
43
  import fastmcp.settings
@@ -184,6 +194,8 @@ class FastMCP(Generic[LifespanResultT]):
184
  self,
185
  name: str | None = None,
186
  instructions: str | None = None,
 
 
187
  lifespan: (
188
  Callable[
189
  [FastMCP[LifespanResultT]],
@@ -221,6 +233,15 @@ class FastMCP(Generic[LifespanResultT]):
221
  self._prompt_manager = PromptManager(
222
  duplicate_behavior=self.settings.on_duplicate_prompts
223
  )
 
 
 
 
 
 
 
 
 
224
  self.dependencies = self.settings.dependencies
225
 
226
  # Set up MCP protocol handlers
@@ -340,6 +361,50 @@ class FastMCP(Generic[LifespanResultT]):
340
  self._cache.set("prompts", prompts)
341
  return prompts
342
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  async def _mcp_list_tools(self) -> list[MCPTool]:
344
  """
345
  List all available tools, in the format expected by the low-level MCP
@@ -770,26 +835,104 @@ class FastMCP(Generic[LifespanResultT]):
770
 
771
  def sse_app(self) -> Starlette:
772
  """Return an instance of the SSE server app."""
 
 
 
 
 
773
  sse = SseServerTransport(self.settings.message_path)
774
 
775
- async def handle_sse(request: Request) -> None:
 
 
776
  async with sse.connect_sse(
777
- request.scope,
778
- request.receive,
779
- request._send, # type: ignore[reportPrivateUsage]
780
  ) as streams:
781
  await self._mcp_server.run(
782
  streams[0],
783
  streams[1],
784
  self._mcp_server.create_initialization_options(),
785
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
786
 
 
787
  return Starlette(
788
- debug=self.settings.debug,
789
- routes=[
790
- Route(self.settings.sse_path, endpoint=handle_sse),
791
- Mount(self.settings.message_path, app=sse.handle_post_message),
792
- ],
793
  )
794
 
795
  def mount(
 
15
  import anyio
16
  import httpx
17
  import uvicorn
18
+ from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
19
+ from mcp.server.auth.middleware.bearer_auth import (
20
+ BearerAuthBackend,
21
+ RequireAuthMiddleware,
22
+ )
23
+ from mcp.server.auth.provider import OAuthAuthorizationServerProvider
24
  from mcp.server.lowlevel.helper_types import ReadResourceContents
25
  from mcp.server.lowlevel.server import LifespanResultT
26
  from mcp.server.lowlevel.server import Server as MCPServer
 
42
  from mcp.types import Tool as MCPTool
43
  from pydantic.networks import AnyUrl
44
  from starlette.applications import Starlette
45
+ from starlette.middleware import Middleware
46
+ from starlette.middleware.authentication import AuthenticationMiddleware
47
  from starlette.requests import Request
48
+ from starlette.responses import Response
49
  from starlette.routing import Mount, Route
50
+ from starlette.types import Receive, Scope, Send
51
 
52
  import fastmcp
53
  import fastmcp.settings
 
194
  self,
195
  name: str | None = None,
196
  instructions: str | None = None,
197
+ auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any]
198
+ | None = None,
199
  lifespan: (
200
  Callable[
201
  [FastMCP[LifespanResultT]],
 
233
  self._prompt_manager = PromptManager(
234
  duplicate_behavior=self.settings.on_duplicate_prompts
235
  )
236
+
237
+ if (self.settings.auth is not None) != (auth_server_provider is not None):
238
+ # TODO: after we support separate authorization servers (see
239
+ raise ValueError(
240
+ "settings.auth must be specified if and only if auth_server_provider "
241
+ "is specified"
242
+ )
243
+ self._auth_server_provider = auth_server_provider
244
+ self._custom_starlette_routes: list[Route] = []
245
  self.dependencies = self.settings.dependencies
246
 
247
  # Set up MCP protocol handlers
 
361
  self._cache.set("prompts", prompts)
362
  return prompts
363
 
364
+ def custom_route(
365
+ self,
366
+ path: str,
367
+ methods: list[str],
368
+ name: str | None = None,
369
+ include_in_schema: bool = True,
370
+ ):
371
+ """
372
+ Decorator to register a custom HTTP route on the FastMCP server.
373
+
374
+ Allows adding arbitrary HTTP endpoints outside the standard MCP protocol,
375
+ which can be useful for OAuth callbacks, health checks, or admin APIs.
376
+ The handler function must be an async function that accepts a Starlette
377
+ Request and returns a Response.
378
+
379
+ Args:
380
+ path: URL path for the route (e.g., "/oauth/callback")
381
+ methods: List of HTTP methods to support (e.g., ["GET", "POST"])
382
+ name: Optional name for the route (to reference this route with
383
+ Starlette's reverse URL lookup feature)
384
+ include_in_schema: Whether to include in OpenAPI schema, defaults to True
385
+
386
+ Example:
387
+ @server.custom_route("/health", methods=["GET"])
388
+ async def health_check(request: Request) -> Response:
389
+ return JSONResponse({"status": "ok"})
390
+ """
391
+
392
+ def decorator(
393
+ func: Callable[[Request], Awaitable[Response]],
394
+ ) -> Callable[[Request], Awaitable[Response]]:
395
+ self._custom_starlette_routes.append(
396
+ Route(
397
+ path,
398
+ endpoint=func,
399
+ methods=methods,
400
+ name=name,
401
+ include_in_schema=include_in_schema,
402
+ )
403
+ )
404
+ return func
405
+
406
+ return decorator
407
+
408
  async def _mcp_list_tools(self) -> list[MCPTool]:
409
  """
410
  List all available tools, in the format expected by the low-level MCP
 
835
 
836
  def sse_app(self) -> Starlette:
837
  """Return an instance of the SSE server app."""
838
+ from starlette.middleware import Middleware
839
+ from starlette.routing import Mount, Route
840
+
841
+ # Set up auth context and dependencies
842
+
843
  sse = SseServerTransport(self.settings.message_path)
844
 
845
+ async def handle_sse(scope: Scope, receive: Receive, send: Send):
846
+ # Add client ID from auth context into request context if available
847
+
848
  async with sse.connect_sse(
849
+ scope,
850
+ receive,
851
+ send,
852
  ) as streams:
853
  await self._mcp_server.run(
854
  streams[0],
855
  streams[1],
856
  self._mcp_server.create_initialization_options(),
857
  )
858
+ return Response()
859
+
860
+ # Create routes
861
+ routes: list[Route | Mount] = []
862
+ middleware: list[Middleware] = []
863
+ required_scopes = []
864
+
865
+ # Add auth endpoints if auth provider is configured
866
+ if self._auth_server_provider:
867
+ assert self.settings.auth
868
+ from mcp.server.auth.routes import create_auth_routes
869
+
870
+ required_scopes = self.settings.auth.required_scopes or []
871
+
872
+ middleware = [
873
+ # extract auth info from request (but do not require it)
874
+ Middleware(
875
+ AuthenticationMiddleware,
876
+ backend=BearerAuthBackend(
877
+ provider=self._auth_server_provider,
878
+ ),
879
+ ),
880
+ # Add the auth context middleware to store
881
+ # authenticated user in a contextvar
882
+ Middleware(AuthContextMiddleware),
883
+ ]
884
+ routes.extend(
885
+ create_auth_routes(
886
+ provider=self._auth_server_provider,
887
+ issuer_url=self.settings.auth.issuer_url,
888
+ service_documentation_url=self.settings.auth.service_documentation_url,
889
+ client_registration_options=self.settings.auth.client_registration_options,
890
+ revocation_options=self.settings.auth.revocation_options,
891
+ )
892
+ )
893
+
894
+ # When auth is not configured, we shouldn't require auth
895
+ if self._auth_server_provider:
896
+ # Auth is enabled, wrap the endpoints with RequireAuthMiddleware
897
+ routes.append(
898
+ Route(
899
+ self.settings.sse_path,
900
+ endpoint=RequireAuthMiddleware(handle_sse, required_scopes),
901
+ methods=["GET"],
902
+ )
903
+ )
904
+ routes.append(
905
+ Mount(
906
+ self.settings.message_path,
907
+ app=RequireAuthMiddleware(sse.handle_post_message, required_scopes),
908
+ )
909
+ )
910
+ else:
911
+ # Auth is disabled, no need for RequireAuthMiddleware
912
+ # Since handle_sse is an ASGI app, we need to create a compatible endpoint
913
+ async def sse_endpoint(request: Request) -> None:
914
+ # Convert the Starlette request to ASGI parameters
915
+ await handle_sse(request.scope, request.receive, request._send) # type: ignore[reportPrivateUsage]
916
+
917
+ routes.append(
918
+ Route(
919
+ self.settings.sse_path,
920
+ endpoint=sse_endpoint,
921
+ methods=["GET"],
922
+ )
923
+ )
924
+ routes.append(
925
+ Mount(
926
+ self.settings.message_path,
927
+ app=sse.handle_post_message,
928
+ )
929
+ )
930
+ # mount these routes last, so they have the lowest route matching precedence
931
+ routes.extend(self._custom_starlette_routes)
932
 
933
+ # Create Starlette app with routes and middleware
934
  return Starlette(
935
+ debug=self.settings.debug, routes=routes, middleware=middleware
 
 
 
 
936
  )
937
 
938
  def mount(
src/fastmcp/settings.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations as _annotations
2
 
3
  from typing import TYPE_CHECKING, Literal
4
 
 
5
  from pydantic import Field
6
  from pydantic_settings import BaseSettings, SettingsConfigDict
7
 
@@ -20,6 +21,8 @@ class Settings(BaseSettings):
20
  env_prefix="FASTMCP_",
21
  env_file=".env",
22
  extra="ignore",
 
 
23
  )
24
 
25
  test_mode: bool = False
@@ -37,6 +40,8 @@ class ServerSettings(BaseSettings):
37
  env_prefix="FASTMCP_SERVER_",
38
  env_file=".env",
39
  extra="ignore",
 
 
40
  )
41
 
42
  log_level: LOG_LEVEL = Field(default_factory=lambda: Settings().log_level)
@@ -65,6 +70,8 @@ class ServerSettings(BaseSettings):
65
  # cache settings (for checking mounted servers)
66
  cache_expiration_seconds: float = 0
67
 
 
 
68
 
69
  class ClientSettings(BaseSettings):
70
  """FastMCP client settings."""
 
2
 
3
  from typing import TYPE_CHECKING, Literal
4
 
5
+ from mcp.server.auth.settings import AuthSettings
6
  from pydantic import Field
7
  from pydantic_settings import BaseSettings, SettingsConfigDict
8
 
 
21
  env_prefix="FASTMCP_",
22
  env_file=".env",
23
  extra="ignore",
24
+ env_nested_delimiter="__",
25
+ nested_model_default_partial_update=True,
26
  )
27
 
28
  test_mode: bool = False
 
40
  env_prefix="FASTMCP_SERVER_",
41
  env_file=".env",
42
  extra="ignore",
43
+ env_nested_delimiter="__",
44
+ nested_model_default_partial_update=True,
45
  )
46
 
47
  log_level: LOG_LEVEL = Field(default_factory=lambda: Settings().log_level)
 
70
  # cache settings (for checking mounted servers)
71
  cache_expiration_seconds: float = 0
72
 
73
+ auth: AuthSettings | None = None
74
+
75
 
76
  class ClientSettings(BaseSettings):
77
  """FastMCP client settings."""
tests/server/test_auth_integration.py ADDED
@@ -0,0 +1,1263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import hashlib
3
+ import secrets
4
+ import time
5
+ import unittest.mock
6
+ from urllib.parse import parse_qs, urlparse
7
+
8
+ import httpx
9
+ import pytest
10
+ from mcp.server.auth.provider import (
11
+ AccessToken,
12
+ AuthorizationCode,
13
+ AuthorizationParams,
14
+ OAuthAuthorizationServerProvider,
15
+ RefreshToken,
16
+ construct_redirect_uri,
17
+ )
18
+ from mcp.server.auth.routes import (
19
+ create_auth_routes,
20
+ )
21
+ from mcp.server.auth.settings import (
22
+ ClientRegistrationOptions,
23
+ RevocationOptions,
24
+ )
25
+ from mcp.shared.auth import (
26
+ OAuthClientInformationFull,
27
+ OAuthToken,
28
+ )
29
+ from pydantic import AnyHttpUrl
30
+ from starlette.applications import Starlette
31
+
32
+
33
+ # Mock OAuth provider for testing
34
+ class MockOAuthProvider(OAuthAuthorizationServerProvider):
35
+ def __init__(self):
36
+ self.clients = {}
37
+ self.auth_codes = {} # code -> {client_id, code_challenge, redirect_uri}
38
+ self.tokens = {} # token -> {client_id, scopes, expires_at}
39
+ self.refresh_tokens = {} # refresh_token -> access_token
40
+
41
+ async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
42
+ return self.clients.get(client_id)
43
+
44
+ async def register_client(self, client_info: OAuthClientInformationFull):
45
+ self.clients[client_info.client_id] = client_info
46
+
47
+ async def authorize(
48
+ self, client: OAuthClientInformationFull, params: AuthorizationParams
49
+ ) -> str:
50
+ # toy authorize implementation which just immediately generates an authorization
51
+ # code and completes the redirect
52
+ code = AuthorizationCode(
53
+ code=f"code_{int(time.time())}",
54
+ client_id=client.client_id,
55
+ code_challenge=params.code_challenge,
56
+ redirect_uri=params.redirect_uri,
57
+ redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
58
+ expires_at=time.time() + 300,
59
+ scopes=params.scopes or ["read", "write"],
60
+ )
61
+ self.auth_codes[code.code] = code
62
+
63
+ return construct_redirect_uri(
64
+ str(params.redirect_uri), code=code.code, state=params.state
65
+ )
66
+
67
+ async def load_authorization_code(
68
+ self, client: OAuthClientInformationFull, authorization_code: str
69
+ ) -> AuthorizationCode | None:
70
+ return self.auth_codes.get(authorization_code)
71
+
72
+ async def exchange_authorization_code(
73
+ self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
74
+ ) -> OAuthToken:
75
+ assert authorization_code.code in self.auth_codes
76
+
77
+ # Generate an access token and refresh token
78
+ access_token = f"access_{secrets.token_hex(32)}"
79
+ refresh_token = f"refresh_{secrets.token_hex(32)}"
80
+
81
+ # Store the tokens
82
+ self.tokens[access_token] = AccessToken(
83
+ token=access_token,
84
+ client_id=client.client_id,
85
+ scopes=authorization_code.scopes,
86
+ expires_at=int(time.time()) + 3600,
87
+ )
88
+
89
+ self.refresh_tokens[refresh_token] = access_token
90
+
91
+ # Remove the used code
92
+ del self.auth_codes[authorization_code.code]
93
+
94
+ return OAuthToken(
95
+ access_token=access_token,
96
+ token_type="bearer",
97
+ expires_in=3600,
98
+ scope="read write",
99
+ refresh_token=refresh_token,
100
+ )
101
+
102
+ async def load_refresh_token(
103
+ self, client: OAuthClientInformationFull, refresh_token: str
104
+ ) -> RefreshToken | None:
105
+ old_access_token = self.refresh_tokens.get(refresh_token)
106
+ if old_access_token is None:
107
+ return None
108
+ token_info = self.tokens.get(old_access_token)
109
+ if token_info is None:
110
+ return None
111
+
112
+ # Create a RefreshToken object that matches what is expected in later code
113
+ refresh_obj = RefreshToken(
114
+ token=refresh_token,
115
+ client_id=token_info.client_id,
116
+ scopes=token_info.scopes,
117
+ expires_at=token_info.expires_at,
118
+ )
119
+
120
+ return refresh_obj
121
+
122
+ async def exchange_refresh_token(
123
+ self,
124
+ client: OAuthClientInformationFull,
125
+ refresh_token: RefreshToken,
126
+ scopes: list[str],
127
+ ) -> OAuthToken:
128
+ # Check if refresh token exists
129
+ assert refresh_token.token in self.refresh_tokens
130
+
131
+ old_access_token = self.refresh_tokens[refresh_token.token]
132
+
133
+ # Check if the access token exists
134
+ assert old_access_token in self.tokens
135
+
136
+ # Check if the token was issued to this client
137
+ token_info = self.tokens[old_access_token]
138
+ assert token_info.client_id == client.client_id
139
+
140
+ # Generate a new access token and refresh token
141
+ new_access_token = f"access_{secrets.token_hex(32)}"
142
+ new_refresh_token = f"refresh_{secrets.token_hex(32)}"
143
+
144
+ # Store the new tokens
145
+ self.tokens[new_access_token] = AccessToken(
146
+ token=new_access_token,
147
+ client_id=client.client_id,
148
+ scopes=scopes or token_info.scopes,
149
+ expires_at=int(time.time()) + 3600,
150
+ )
151
+
152
+ self.refresh_tokens[new_refresh_token] = new_access_token
153
+
154
+ # Remove the old tokens
155
+ del self.refresh_tokens[refresh_token.token]
156
+ del self.tokens[old_access_token]
157
+
158
+ return OAuthToken(
159
+ access_token=new_access_token,
160
+ token_type="bearer",
161
+ expires_in=3600,
162
+ scope=" ".join(scopes) if scopes else " ".join(token_info.scopes),
163
+ refresh_token=new_refresh_token,
164
+ )
165
+
166
+ async def load_access_token(self, token: str) -> AccessToken | None:
167
+ token_info = self.tokens.get(token)
168
+
169
+ # Check if token is expired
170
+ # if token_info.expires_at < int(time.time()):
171
+ # raise InvalidTokenError("Access token has expired")
172
+
173
+ return token_info and AccessToken(
174
+ token=token,
175
+ client_id=token_info.client_id,
176
+ scopes=token_info.scopes,
177
+ expires_at=token_info.expires_at,
178
+ )
179
+
180
+ async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
181
+ match token:
182
+ case RefreshToken():
183
+ # Remove the refresh token
184
+ del self.refresh_tokens[token.token]
185
+
186
+ case AccessToken():
187
+ # Remove the access token
188
+ del self.tokens[token.token]
189
+
190
+ # Also remove any refresh tokens that point to this access token
191
+ for refresh_token, access_token in list(self.refresh_tokens.items()):
192
+ if access_token == token.token:
193
+ del self.refresh_tokens[refresh_token]
194
+
195
+
196
+ @pytest.fixture
197
+ def mock_oauth_provider():
198
+ return MockOAuthProvider()
199
+
200
+
201
+ @pytest.fixture
202
+ def auth_app(mock_oauth_provider):
203
+ # Create auth router
204
+ auth_routes = create_auth_routes(
205
+ mock_oauth_provider,
206
+ AnyHttpUrl("https://auth.example.com"),
207
+ AnyHttpUrl("https://docs.example.com"),
208
+ client_registration_options=ClientRegistrationOptions(
209
+ enabled=True,
210
+ valid_scopes=["read", "write", "profile"],
211
+ default_scopes=["read", "write"],
212
+ ),
213
+ revocation_options=RevocationOptions(enabled=True),
214
+ )
215
+
216
+ # Create Starlette app
217
+ app = Starlette(routes=auth_routes)
218
+
219
+ return app
220
+
221
+
222
+ @pytest.fixture
223
+ async def test_client(auth_app):
224
+ async with httpx.AsyncClient(
225
+ transport=httpx.ASGITransport(app=auth_app), base_url="https://mcptest.com"
226
+ ) as client:
227
+ yield client
228
+
229
+
230
+ @pytest.fixture
231
+ async def registered_client(test_client: httpx.AsyncClient, request):
232
+ """Create and register a test client.
233
+
234
+ Parameters can be customized via indirect parameterization:
235
+ @pytest.mark.parametrize("registered_client",
236
+ [{"grant_types": ["authorization_code"]}],
237
+ indirect=True)
238
+ """
239
+ # Default client metadata
240
+ client_metadata = {
241
+ "redirect_uris": ["https://client.example.com/callback"],
242
+ "client_name": "Test Client",
243
+ "grant_types": ["authorization_code", "refresh_token"],
244
+ }
245
+
246
+ # Override with any parameters from the test
247
+ if hasattr(request, "param") and request.param:
248
+ client_metadata.update(request.param)
249
+
250
+ response = await test_client.post("/register", json=client_metadata)
251
+ assert response.status_code == 201, f"Failed to register client: {response.content}"
252
+
253
+ client_info = response.json()
254
+ return client_info
255
+
256
+
257
+ @pytest.fixture
258
+ def pkce_challenge():
259
+ """Create a PKCE challenge with code_verifier and code_challenge."""
260
+ code_verifier = "some_random_verifier_string"
261
+ code_challenge = (
262
+ base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest())
263
+ .decode()
264
+ .rstrip("=")
265
+ )
266
+
267
+ return {"code_verifier": code_verifier, "code_challenge": code_challenge}
268
+
269
+
270
+ @pytest.fixture
271
+ async def auth_code(test_client, registered_client, pkce_challenge, request):
272
+ """Get an authorization code.
273
+
274
+ Parameters can be customized via indirect parameterization:
275
+ @pytest.mark.parametrize("auth_code",
276
+ [{"redirect_uri": "https://client.example.com/other-callback"}],
277
+ indirect=True)
278
+ """
279
+ # Default authorize params
280
+ auth_params = {
281
+ "response_type": "code",
282
+ "client_id": registered_client["client_id"],
283
+ "redirect_uri": "https://client.example.com/callback",
284
+ "code_challenge": pkce_challenge["code_challenge"],
285
+ "code_challenge_method": "S256",
286
+ "state": "test_state",
287
+ }
288
+
289
+ # Override with any parameters from the test
290
+ if hasattr(request, "param") and request.param:
291
+ auth_params.update(request.param)
292
+
293
+ response = await test_client.get("/authorize", params=auth_params)
294
+ assert response.status_code == 302, f"Failed to get auth code: {response.content}"
295
+
296
+ # Extract the authorization code
297
+ redirect_url = response.headers["location"]
298
+ parsed_url = urlparse(redirect_url)
299
+ query_params = parse_qs(parsed_url.query)
300
+
301
+ assert "code" in query_params, f"No code in response: {query_params}"
302
+ auth_code = query_params["code"][0]
303
+
304
+ return {
305
+ "code": auth_code,
306
+ "redirect_uri": auth_params["redirect_uri"],
307
+ "state": query_params.get("state", [None])[0],
308
+ }
309
+
310
+
311
+ @pytest.fixture
312
+ async def tokens(test_client, registered_client, auth_code, pkce_challenge, request):
313
+ """Exchange authorization code for tokens.
314
+
315
+ Parameters can be customized via indirect parameterization:
316
+ @pytest.mark.parametrize("tokens",
317
+ [{"code_verifier": "wrong_verifier"}],
318
+ indirect=True)
319
+ """
320
+ # Default token request params
321
+ token_params = {
322
+ "grant_type": "authorization_code",
323
+ "client_id": registered_client["client_id"],
324
+ "client_secret": registered_client["client_secret"],
325
+ "code": auth_code["code"],
326
+ "code_verifier": pkce_challenge["code_verifier"],
327
+ "redirect_uri": auth_code["redirect_uri"],
328
+ }
329
+
330
+ # Override with any parameters from the test
331
+ if hasattr(request, "param") and request.param:
332
+ token_params.update(request.param)
333
+
334
+ response = await test_client.post("/token", data=token_params)
335
+
336
+ # Don't assert success here since some tests will intentionally cause errors
337
+ return {
338
+ "response": response,
339
+ "params": token_params,
340
+ }
341
+
342
+
343
+ class TestAuthEndpoints:
344
+ @pytest.mark.anyio
345
+ async def test_metadata_endpoint(self, test_client: httpx.AsyncClient):
346
+ """Test the OAuth 2.0 metadata endpoint."""
347
+ print("Sending request to metadata endpoint")
348
+ response = await test_client.get("/.well-known/oauth-authorization-server")
349
+ print(f"Got response: {response.status_code}")
350
+ if response.status_code != 200:
351
+ print(f"Response content: {response.content}")
352
+ assert response.status_code == 200
353
+
354
+ metadata = response.json()
355
+ assert metadata["issuer"] == "https://auth.example.com/"
356
+ assert (
357
+ metadata["authorization_endpoint"] == "https://auth.example.com/authorize"
358
+ )
359
+ assert metadata["token_endpoint"] == "https://auth.example.com/token"
360
+ assert metadata["registration_endpoint"] == "https://auth.example.com/register"
361
+ assert metadata["revocation_endpoint"] == "https://auth.example.com/revoke"
362
+ assert metadata["response_types_supported"] == ["code"]
363
+ assert metadata["code_challenge_methods_supported"] == ["S256"]
364
+ assert metadata["token_endpoint_auth_methods_supported"] == [
365
+ "client_secret_post"
366
+ ]
367
+ assert metadata["grant_types_supported"] == [
368
+ "authorization_code",
369
+ "refresh_token",
370
+ ]
371
+ assert metadata["service_documentation"] == "https://docs.example.com/"
372
+
373
+ @pytest.mark.anyio
374
+ async def test_token_validation_error(self, test_client: httpx.AsyncClient):
375
+ """Test token endpoint error - validation error."""
376
+ # Missing required fields
377
+ response = await test_client.post(
378
+ "/token",
379
+ data={
380
+ "grant_type": "authorization_code",
381
+ # Missing code, code_verifier, client_id, etc.
382
+ },
383
+ )
384
+ error_response = response.json()
385
+ assert error_response["error"] == "invalid_request"
386
+ assert (
387
+ "error_description" in error_response
388
+ ) # Contains validation error messages
389
+
390
+ @pytest.mark.anyio
391
+ async def test_token_invalid_auth_code(
392
+ self, test_client, registered_client, pkce_challenge
393
+ ):
394
+ """Test token endpoint error - authorization code does not exist."""
395
+ # Try to use a non-existent authorization code
396
+ response = await test_client.post(
397
+ "/token",
398
+ data={
399
+ "grant_type": "authorization_code",
400
+ "client_id": registered_client["client_id"],
401
+ "client_secret": registered_client["client_secret"],
402
+ "code": "non_existent_auth_code",
403
+ "code_verifier": pkce_challenge["code_verifier"],
404
+ "redirect_uri": "https://client.example.com/callback",
405
+ },
406
+ )
407
+ print(f"Status code: {response.status_code}")
408
+ print(f"Response body: {response.content}")
409
+ print(f"Response JSON: {response.json()}")
410
+ assert response.status_code == 400
411
+ error_response = response.json()
412
+ assert error_response["error"] == "invalid_grant"
413
+ assert (
414
+ "authorization code does not exist" in error_response["error_description"]
415
+ )
416
+
417
+ @pytest.mark.anyio
418
+ async def test_token_expired_auth_code(
419
+ self,
420
+ test_client,
421
+ registered_client,
422
+ auth_code,
423
+ pkce_challenge,
424
+ mock_oauth_provider,
425
+ ):
426
+ """Test token endpoint error - authorization code has expired."""
427
+ # Get the current time for our time mocking
428
+ current_time = time.time()
429
+
430
+ # Find the auth code object
431
+ code_value = auth_code["code"]
432
+ found_code = None
433
+ for code_obj in mock_oauth_provider.auth_codes.values():
434
+ if code_obj.code == code_value:
435
+ found_code = code_obj
436
+ break
437
+
438
+ assert found_code is not None
439
+
440
+ # Authorization codes are typically short-lived (5 minutes = 300 seconds)
441
+ # So we'll mock time to be 10 minutes (600 seconds) in the future
442
+ with unittest.mock.patch("time.time", return_value=current_time + 600):
443
+ # Try to use the expired authorization code
444
+ response = await test_client.post(
445
+ "/token",
446
+ data={
447
+ "grant_type": "authorization_code",
448
+ "client_id": registered_client["client_id"],
449
+ "client_secret": registered_client["client_secret"],
450
+ "code": code_value,
451
+ "code_verifier": pkce_challenge["code_verifier"],
452
+ "redirect_uri": auth_code["redirect_uri"],
453
+ },
454
+ )
455
+ assert response.status_code == 400
456
+ error_response = response.json()
457
+ assert error_response["error"] == "invalid_grant"
458
+ assert (
459
+ "authorization code has expired" in error_response["error_description"]
460
+ )
461
+
462
+ @pytest.mark.anyio
463
+ @pytest.mark.parametrize(
464
+ "registered_client",
465
+ [
466
+ {
467
+ "redirect_uris": [
468
+ "https://client.example.com/callback",
469
+ "https://client.example.com/other-callback",
470
+ ]
471
+ }
472
+ ],
473
+ indirect=True,
474
+ )
475
+ async def test_token_redirect_uri_mismatch(
476
+ self, test_client, registered_client, auth_code, pkce_challenge
477
+ ):
478
+ """Test token endpoint error - redirect URI mismatch."""
479
+ # Try to use the code with a different redirect URI
480
+ response = await test_client.post(
481
+ "/token",
482
+ data={
483
+ "grant_type": "authorization_code",
484
+ "client_id": registered_client["client_id"],
485
+ "client_secret": registered_client["client_secret"],
486
+ "code": auth_code["code"],
487
+ "code_verifier": pkce_challenge["code_verifier"],
488
+ # Different from the one used in /authorize
489
+ "redirect_uri": "https://client.example.com/other-callback",
490
+ },
491
+ )
492
+ assert response.status_code == 400
493
+ error_response = response.json()
494
+ assert error_response["error"] == "invalid_request"
495
+ assert "redirect_uri did not match" in error_response["error_description"]
496
+
497
+ @pytest.mark.anyio
498
+ async def test_token_code_verifier_mismatch(
499
+ self, test_client, registered_client, auth_code
500
+ ):
501
+ """Test token endpoint error - PKCE code verifier mismatch."""
502
+ # Try to use the code with an incorrect code verifier
503
+ response = await test_client.post(
504
+ "/token",
505
+ data={
506
+ "grant_type": "authorization_code",
507
+ "client_id": registered_client["client_id"],
508
+ "client_secret": registered_client["client_secret"],
509
+ "code": auth_code["code"],
510
+ # Different from the one used to create challenge
511
+ "code_verifier": "incorrect_code_verifier",
512
+ "redirect_uri": auth_code["redirect_uri"],
513
+ },
514
+ )
515
+ assert response.status_code == 400
516
+ error_response = response.json()
517
+ assert error_response["error"] == "invalid_grant"
518
+ assert "incorrect code_verifier" in error_response["error_description"]
519
+
520
+ @pytest.mark.anyio
521
+ async def test_token_invalid_refresh_token(self, test_client, registered_client):
522
+ """Test token endpoint error - refresh token does not exist."""
523
+ # Try to use a non-existent refresh token
524
+ response = await test_client.post(
525
+ "/token",
526
+ data={
527
+ "grant_type": "refresh_token",
528
+ "client_id": registered_client["client_id"],
529
+ "client_secret": registered_client["client_secret"],
530
+ "refresh_token": "non_existent_refresh_token",
531
+ },
532
+ )
533
+ assert response.status_code == 400
534
+ error_response = response.json()
535
+ assert error_response["error"] == "invalid_grant"
536
+ assert "refresh token does not exist" in error_response["error_description"]
537
+
538
+ @pytest.mark.anyio
539
+ async def test_token_expired_refresh_token(
540
+ self,
541
+ test_client,
542
+ registered_client,
543
+ auth_code,
544
+ pkce_challenge,
545
+ mock_oauth_provider,
546
+ ):
547
+ """Test token endpoint error - refresh token has expired."""
548
+ # Step 1: First, let's create a token and refresh token at the current time
549
+ current_time = time.time()
550
+
551
+ # Exchange authorization code for tokens normally
552
+ token_response = await test_client.post(
553
+ "/token",
554
+ data={
555
+ "grant_type": "authorization_code",
556
+ "client_id": registered_client["client_id"],
557
+ "client_secret": registered_client["client_secret"],
558
+ "code": auth_code["code"],
559
+ "code_verifier": pkce_challenge["code_verifier"],
560
+ "redirect_uri": auth_code["redirect_uri"],
561
+ },
562
+ )
563
+ assert token_response.status_code == 200
564
+ tokens = token_response.json()
565
+ refresh_token = tokens["refresh_token"]
566
+
567
+ # Step 2: Time travel forward 4 hours (tokens expire in 1 hour by default)
568
+ # Mock the time.time() function to return a value 4 hours in the future
569
+ with unittest.mock.patch(
570
+ "time.time", return_value=current_time + 14400
571
+ ): # 4 hours = 14400 seconds
572
+ # Try to use the refresh token which should now be considered expired
573
+ response = await test_client.post(
574
+ "/token",
575
+ data={
576
+ "grant_type": "refresh_token",
577
+ "client_id": registered_client["client_id"],
578
+ "client_secret": registered_client["client_secret"],
579
+ "refresh_token": refresh_token,
580
+ },
581
+ )
582
+
583
+ # In the "future", the token should be considered expired
584
+ assert response.status_code == 400
585
+ error_response = response.json()
586
+ assert error_response["error"] == "invalid_grant"
587
+ assert "refresh token has expired" in error_response["error_description"]
588
+
589
+ @pytest.mark.anyio
590
+ async def test_token_invalid_scope(
591
+ self, test_client, registered_client, auth_code, pkce_challenge
592
+ ):
593
+ """Test token endpoint error - invalid scope in refresh token request."""
594
+ # Exchange authorization code for tokens
595
+ token_response = await test_client.post(
596
+ "/token",
597
+ data={
598
+ "grant_type": "authorization_code",
599
+ "client_id": registered_client["client_id"],
600
+ "client_secret": registered_client["client_secret"],
601
+ "code": auth_code["code"],
602
+ "code_verifier": pkce_challenge["code_verifier"],
603
+ "redirect_uri": auth_code["redirect_uri"],
604
+ },
605
+ )
606
+ assert token_response.status_code == 200
607
+
608
+ tokens = token_response.json()
609
+ refresh_token = tokens["refresh_token"]
610
+
611
+ # Try to use refresh token with an invalid scope
612
+ response = await test_client.post(
613
+ "/token",
614
+ data={
615
+ "grant_type": "refresh_token",
616
+ "client_id": registered_client["client_id"],
617
+ "client_secret": registered_client["client_secret"],
618
+ "refresh_token": refresh_token,
619
+ "scope": "read write invalid_scope", # Adding an invalid scope
620
+ },
621
+ )
622
+ assert response.status_code == 400
623
+ error_response = response.json()
624
+ assert error_response["error"] == "invalid_scope"
625
+ assert "cannot request scope" in error_response["error_description"]
626
+
627
+ @pytest.mark.anyio
628
+ async def test_client_registration(
629
+ self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider
630
+ ):
631
+ """Test client registration."""
632
+ client_metadata = {
633
+ "redirect_uris": ["https://client.example.com/callback"],
634
+ "client_name": "Test Client",
635
+ "client_uri": "https://client.example.com",
636
+ }
637
+
638
+ response = await test_client.post(
639
+ "/register",
640
+ json=client_metadata,
641
+ )
642
+ assert response.status_code == 201, response.content
643
+
644
+ client_info = response.json()
645
+ assert "client_id" in client_info
646
+ assert "client_secret" in client_info
647
+ assert client_info["client_name"] == "Test Client"
648
+ assert client_info["redirect_uris"] == ["https://client.example.com/callback"]
649
+
650
+ # Verify that the client was registered
651
+ # assert await mock_oauth_provider.clients_store.get_client(
652
+ # client_info["client_id"]
653
+ # ) is not None
654
+
655
+ @pytest.mark.anyio
656
+ async def test_client_registration_missing_required_fields(
657
+ self, test_client: httpx.AsyncClient
658
+ ):
659
+ """Test client registration with missing required fields."""
660
+ # Missing redirect_uris which is a required field
661
+ client_metadata = {
662
+ "client_name": "Test Client",
663
+ "client_uri": "https://client.example.com",
664
+ }
665
+
666
+ response = await test_client.post(
667
+ "/register",
668
+ json=client_metadata,
669
+ )
670
+ assert response.status_code == 400
671
+ error_data = response.json()
672
+ assert "error" in error_data
673
+ assert error_data["error"] == "invalid_client_metadata"
674
+ assert error_data["error_description"] == "redirect_uris: Field required"
675
+
676
+ @pytest.mark.anyio
677
+ async def test_client_registration_invalid_uri(
678
+ self, test_client: httpx.AsyncClient
679
+ ):
680
+ """Test client registration with invalid URIs."""
681
+ # Invalid redirect_uri format
682
+ client_metadata = {
683
+ "redirect_uris": ["not-a-valid-uri"],
684
+ "client_name": "Test Client",
685
+ }
686
+
687
+ response = await test_client.post(
688
+ "/register",
689
+ json=client_metadata,
690
+ )
691
+ assert response.status_code == 400
692
+ error_data = response.json()
693
+ assert "error" in error_data
694
+ assert error_data["error"] == "invalid_client_metadata"
695
+ assert error_data["error_description"] == (
696
+ "redirect_uris.0: Input should be a valid URL, relative URL without a base"
697
+ )
698
+
699
+ @pytest.mark.anyio
700
+ async def test_client_registration_empty_redirect_uris(
701
+ self, test_client: httpx.AsyncClient
702
+ ):
703
+ """Test client registration with empty redirect_uris array."""
704
+ client_metadata = {
705
+ "redirect_uris": [], # Empty array
706
+ "client_name": "Test Client",
707
+ }
708
+
709
+ response = await test_client.post(
710
+ "/register",
711
+ json=client_metadata,
712
+ )
713
+ assert response.status_code == 400
714
+ error_data = response.json()
715
+ assert "error" in error_data
716
+ assert error_data["error"] == "invalid_client_metadata"
717
+ assert (
718
+ error_data["error_description"]
719
+ == "redirect_uris: List should have at least 1 item after validation, not 0"
720
+ )
721
+
722
+ @pytest.mark.anyio
723
+ async def test_authorize_form_post(
724
+ self,
725
+ test_client: httpx.AsyncClient,
726
+ mock_oauth_provider: MockOAuthProvider,
727
+ pkce_challenge,
728
+ ):
729
+ """Test the authorization endpoint using POST with form-encoded data."""
730
+ # Register a client
731
+ client_metadata = {
732
+ "redirect_uris": ["https://client.example.com/callback"],
733
+ "client_name": "Test Client",
734
+ "grant_types": ["authorization_code", "refresh_token"],
735
+ }
736
+
737
+ response = await test_client.post(
738
+ "/register",
739
+ json=client_metadata,
740
+ )
741
+ assert response.status_code == 201
742
+ client_info = response.json()
743
+
744
+ # Use POST with form-encoded data for authorization
745
+ response = await test_client.post(
746
+ "/authorize",
747
+ data={
748
+ "response_type": "code",
749
+ "client_id": client_info["client_id"],
750
+ "redirect_uri": "https://client.example.com/callback",
751
+ "code_challenge": pkce_challenge["code_challenge"],
752
+ "code_challenge_method": "S256",
753
+ "state": "test_form_state",
754
+ },
755
+ )
756
+ assert response.status_code == 302
757
+
758
+ # Extract the authorization code from the redirect URL
759
+ redirect_url = response.headers["location"]
760
+ parsed_url = urlparse(redirect_url)
761
+ query_params = parse_qs(parsed_url.query)
762
+
763
+ assert "code" in query_params
764
+ assert query_params["state"][0] == "test_form_state"
765
+
766
+ @pytest.mark.anyio
767
+ async def test_authorization_get(
768
+ self,
769
+ test_client: httpx.AsyncClient,
770
+ mock_oauth_provider: MockOAuthProvider,
771
+ pkce_challenge,
772
+ ):
773
+ """Test the full authorization flow."""
774
+ # 1. Register a client
775
+ client_metadata = {
776
+ "redirect_uris": ["https://client.example.com/callback"],
777
+ "client_name": "Test Client",
778
+ "grant_types": ["authorization_code", "refresh_token"],
779
+ }
780
+
781
+ response = await test_client.post(
782
+ "/register",
783
+ json=client_metadata,
784
+ )
785
+ assert response.status_code == 201
786
+ client_info = response.json()
787
+
788
+ # 2. Request authorization using GET with query params
789
+ response = await test_client.get(
790
+ "/authorize",
791
+ params={
792
+ "response_type": "code",
793
+ "client_id": client_info["client_id"],
794
+ "redirect_uri": "https://client.example.com/callback",
795
+ "code_challenge": pkce_challenge["code_challenge"],
796
+ "code_challenge_method": "S256",
797
+ "state": "test_state",
798
+ },
799
+ )
800
+ assert response.status_code == 302
801
+
802
+ # 3. Extract the authorization code from the redirect URL
803
+ redirect_url = response.headers["location"]
804
+ parsed_url = urlparse(redirect_url)
805
+ query_params = parse_qs(parsed_url.query)
806
+
807
+ assert "code" in query_params
808
+ assert query_params["state"][0] == "test_state"
809
+ auth_code = query_params["code"][0]
810
+
811
+ # 4. Exchange the authorization code for tokens
812
+ response = await test_client.post(
813
+ "/token",
814
+ data={
815
+ "grant_type": "authorization_code",
816
+ "client_id": client_info["client_id"],
817
+ "client_secret": client_info["client_secret"],
818
+ "code": auth_code,
819
+ "code_verifier": pkce_challenge["code_verifier"],
820
+ "redirect_uri": "https://client.example.com/callback",
821
+ },
822
+ )
823
+ assert response.status_code == 200
824
+
825
+ token_response = response.json()
826
+ assert "access_token" in token_response
827
+ assert "token_type" in token_response
828
+ assert "refresh_token" in token_response
829
+ assert "expires_in" in token_response
830
+ assert token_response["token_type"] == "bearer"
831
+
832
+ # 5. Verify the access token
833
+ access_token = token_response["access_token"]
834
+ refresh_token = token_response["refresh_token"]
835
+
836
+ # Create a test client with the token
837
+ auth_info = await mock_oauth_provider.load_access_token(access_token)
838
+ assert auth_info
839
+ assert auth_info.client_id == client_info["client_id"]
840
+ assert "read" in auth_info.scopes
841
+ assert "write" in auth_info.scopes
842
+
843
+ # 6. Refresh the token
844
+ response = await test_client.post(
845
+ "/token",
846
+ data={
847
+ "grant_type": "refresh_token",
848
+ "client_id": client_info["client_id"],
849
+ "client_secret": client_info["client_secret"],
850
+ "refresh_token": refresh_token,
851
+ "redirect_uri": "https://client.example.com/callback",
852
+ },
853
+ )
854
+ assert response.status_code == 200
855
+
856
+ new_token_response = response.json()
857
+ assert "access_token" in new_token_response
858
+ assert "refresh_token" in new_token_response
859
+ assert new_token_response["access_token"] != access_token
860
+ assert new_token_response["refresh_token"] != refresh_token
861
+
862
+ # 7. Revoke the token
863
+ response = await test_client.post(
864
+ "/revoke",
865
+ data={
866
+ "client_id": client_info["client_id"],
867
+ "client_secret": client_info["client_secret"],
868
+ "token": new_token_response["access_token"],
869
+ },
870
+ )
871
+ assert response.status_code == 200
872
+
873
+ # Verify that the token was revoked
874
+ assert (
875
+ await mock_oauth_provider.load_access_token(
876
+ new_token_response["access_token"]
877
+ )
878
+ is None
879
+ )
880
+
881
+ @pytest.mark.anyio
882
+ async def test_revoke_invalid_token(self, test_client, registered_client):
883
+ """Test revoking an invalid token."""
884
+ response = await test_client.post(
885
+ "/revoke",
886
+ data={
887
+ "client_id": registered_client["client_id"],
888
+ "client_secret": registered_client["client_secret"],
889
+ "token": "invalid_token",
890
+ },
891
+ )
892
+ # per RFC, this should return 200 even if the token is invalid
893
+ assert response.status_code == 200
894
+
895
+ @pytest.mark.anyio
896
+ async def test_revoke_with_malformed_token(self, test_client, registered_client):
897
+ response = await test_client.post(
898
+ "/revoke",
899
+ data={
900
+ "client_id": registered_client["client_id"],
901
+ "client_secret": registered_client["client_secret"],
902
+ "token": 123,
903
+ "token_type_hint": "asdf",
904
+ },
905
+ )
906
+ assert response.status_code == 400
907
+ error_response = response.json()
908
+ assert error_response["error"] == "invalid_request"
909
+ assert "token_type_hint" in error_response["error_description"]
910
+
911
+ @pytest.mark.anyio
912
+ async def test_client_registration_disallowed_scopes(
913
+ self, test_client: httpx.AsyncClient
914
+ ):
915
+ """Test client registration with scopes that are not allowed."""
916
+ client_metadata = {
917
+ "redirect_uris": ["https://client.example.com/callback"],
918
+ "client_name": "Test Client",
919
+ "scope": "read write profile admin", # 'admin' is not in valid_scopes
920
+ }
921
+
922
+ response = await test_client.post(
923
+ "/register",
924
+ json=client_metadata,
925
+ )
926
+ assert response.status_code == 400
927
+ error_data = response.json()
928
+ assert "error" in error_data
929
+ assert error_data["error"] == "invalid_client_metadata"
930
+ assert "scope" in error_data["error_description"]
931
+ assert "admin" in error_data["error_description"]
932
+
933
+ @pytest.mark.anyio
934
+ async def test_client_registration_default_scopes(
935
+ self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider
936
+ ):
937
+ client_metadata = {
938
+ "redirect_uris": ["https://client.example.com/callback"],
939
+ "client_name": "Test Client",
940
+ # No scope specified
941
+ }
942
+
943
+ response = await test_client.post(
944
+ "/register",
945
+ json=client_metadata,
946
+ )
947
+ assert response.status_code == 201
948
+ client_info = response.json()
949
+
950
+ # Verify client was registered successfully
951
+ assert client_info["scope"] == "read write"
952
+
953
+ # Retrieve the client from the store to verify default scopes
954
+ registered_client = await mock_oauth_provider.get_client(
955
+ client_info["client_id"]
956
+ )
957
+ assert registered_client is not None
958
+
959
+ # Check that default scopes were applied
960
+ assert registered_client.scope == "read write"
961
+
962
+ @pytest.mark.anyio
963
+ async def test_client_registration_invalid_grant_type(
964
+ self, test_client: httpx.AsyncClient
965
+ ):
966
+ client_metadata = {
967
+ "redirect_uris": ["https://client.example.com/callback"],
968
+ "client_name": "Test Client",
969
+ "grant_types": ["authorization_code"],
970
+ }
971
+
972
+ response = await test_client.post(
973
+ "/register",
974
+ json=client_metadata,
975
+ )
976
+ assert response.status_code == 400
977
+ error_data = response.json()
978
+ assert "error" in error_data
979
+ assert error_data["error"] == "invalid_client_metadata"
980
+ assert (
981
+ error_data["error_description"]
982
+ == "grant_types must be authorization_code and refresh_token"
983
+ )
984
+
985
+
986
+ class TestAuthorizeEndpointErrors:
987
+ """Test error handling in the OAuth authorization endpoint."""
988
+
989
+ @pytest.mark.anyio
990
+ async def test_authorize_missing_client_id(
991
+ self, test_client: httpx.AsyncClient, pkce_challenge
992
+ ):
993
+ """Test authorization endpoint with missing client_id.
994
+
995
+ According to the OAuth2.0 spec, if client_id is missing, the server should
996
+ inform the resource owner and NOT redirect.
997
+ """
998
+ response = await test_client.get(
999
+ "/authorize",
1000
+ params={
1001
+ "response_type": "code",
1002
+ # Missing client_id
1003
+ "redirect_uri": "https://client.example.com/callback",
1004
+ "state": "test_state",
1005
+ "code_challenge": pkce_challenge["code_challenge"],
1006
+ "code_challenge_method": "S256",
1007
+ },
1008
+ )
1009
+
1010
+ # Should NOT redirect, should show an error page
1011
+ assert response.status_code == 400
1012
+ # The response should include an error message about missing client_id
1013
+ assert "client_id" in response.text.lower()
1014
+
1015
+ @pytest.mark.anyio
1016
+ async def test_authorize_invalid_client_id(
1017
+ self, test_client: httpx.AsyncClient, pkce_challenge
1018
+ ):
1019
+ """Test authorization endpoint with invalid client_id.
1020
+
1021
+ According to the OAuth2.0 spec, if client_id is invalid, the server should
1022
+ inform the resource owner and NOT redirect.
1023
+ """
1024
+ response = await test_client.get(
1025
+ "/authorize",
1026
+ params={
1027
+ "response_type": "code",
1028
+ "client_id": "invalid_client_id_that_does_not_exist",
1029
+ "redirect_uri": "https://client.example.com/callback",
1030
+ "state": "test_state",
1031
+ "code_challenge": pkce_challenge["code_challenge"],
1032
+ "code_challenge_method": "S256",
1033
+ },
1034
+ )
1035
+
1036
+ # Should NOT redirect, should show an error page
1037
+ assert response.status_code == 400
1038
+ # The response should include an error message about invalid client_id
1039
+ assert "client" in response.text.lower()
1040
+
1041
+ @pytest.mark.anyio
1042
+ async def test_authorize_missing_redirect_uri(
1043
+ self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
1044
+ ):
1045
+ """Test authorization endpoint with missing redirect_uri.
1046
+
1047
+ If client has only one registered redirect_uri, it can be omitted.
1048
+ """
1049
+
1050
+ response = await test_client.get(
1051
+ "/authorize",
1052
+ params={
1053
+ "response_type": "code",
1054
+ "client_id": registered_client["client_id"],
1055
+ # Missing redirect_uri
1056
+ "code_challenge": pkce_challenge["code_challenge"],
1057
+ "code_challenge_method": "S256",
1058
+ "state": "test_state",
1059
+ },
1060
+ )
1061
+
1062
+ # Should redirect to the registered redirect_uri
1063
+ assert response.status_code == 302, response.content
1064
+ redirect_url = response.headers["location"]
1065
+ assert redirect_url.startswith("https://client.example.com/callback")
1066
+
1067
+ @pytest.mark.anyio
1068
+ async def test_authorize_invalid_redirect_uri(
1069
+ self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
1070
+ ):
1071
+ """Test authorization endpoint with invalid redirect_uri.
1072
+
1073
+ According to the OAuth2.0 spec, if redirect_uri is invalid or doesn't match,
1074
+ the server should inform the resource owner and NOT redirect.
1075
+ """
1076
+
1077
+ response = await test_client.get(
1078
+ "/authorize",
1079
+ params={
1080
+ "response_type": "code",
1081
+ "client_id": registered_client["client_id"],
1082
+ # Non-matching URI
1083
+ "redirect_uri": "https://attacker.example.com/callback",
1084
+ "code_challenge": pkce_challenge["code_challenge"],
1085
+ "code_challenge_method": "S256",
1086
+ "state": "test_state",
1087
+ },
1088
+ )
1089
+
1090
+ # Should NOT redirect, should show an error page
1091
+ assert response.status_code == 400, response.content
1092
+ # The response should include an error message about redirect_uri mismatch
1093
+ assert "redirect" in response.text.lower()
1094
+
1095
+ @pytest.mark.anyio
1096
+ @pytest.mark.parametrize(
1097
+ "registered_client",
1098
+ [
1099
+ {
1100
+ "redirect_uris": [
1101
+ "https://client.example.com/callback",
1102
+ "https://client.example.com/other-callback",
1103
+ ]
1104
+ }
1105
+ ],
1106
+ indirect=True,
1107
+ )
1108
+ async def test_authorize_missing_redirect_uri_multiple_registered(
1109
+ self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
1110
+ ):
1111
+ """Test endpoint with missing redirect_uri with multiple registered URIs.
1112
+
1113
+ If client has multiple registered redirect_uris, redirect_uri must be provided.
1114
+ """
1115
+
1116
+ response = await test_client.get(
1117
+ "/authorize",
1118
+ params={
1119
+ "response_type": "code",
1120
+ "client_id": registered_client["client_id"],
1121
+ # Missing redirect_uri
1122
+ "code_challenge": pkce_challenge["code_challenge"],
1123
+ "code_challenge_method": "S256",
1124
+ "state": "test_state",
1125
+ },
1126
+ )
1127
+
1128
+ # Should NOT redirect, should return a 400 error
1129
+ assert response.status_code == 400
1130
+ # The response should include an error message about missing redirect_uri
1131
+ assert "redirect_uri" in response.text.lower()
1132
+
1133
+ @pytest.mark.anyio
1134
+ async def test_authorize_unsupported_response_type(
1135
+ self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
1136
+ ):
1137
+ """Test authorization endpoint with unsupported response_type.
1138
+
1139
+ According to the OAuth2.0 spec, for other errors like unsupported_response_type,
1140
+ the server should redirect with error parameters.
1141
+ """
1142
+
1143
+ response = await test_client.get(
1144
+ "/authorize",
1145
+ params={
1146
+ "response_type": "token", # Unsupported (we only support "code")
1147
+ "client_id": registered_client["client_id"],
1148
+ "redirect_uri": "https://client.example.com/callback",
1149
+ "code_challenge": pkce_challenge["code_challenge"],
1150
+ "code_challenge_method": "S256",
1151
+ "state": "test_state",
1152
+ },
1153
+ )
1154
+
1155
+ # Should redirect with error parameters
1156
+ assert response.status_code == 302
1157
+ redirect_url = response.headers["location"]
1158
+ parsed_url = urlparse(redirect_url)
1159
+ query_params = parse_qs(parsed_url.query)
1160
+
1161
+ assert "error" in query_params
1162
+ assert query_params["error"][0] == "unsupported_response_type"
1163
+ # State should be preserved
1164
+ assert "state" in query_params
1165
+ assert query_params["state"][0] == "test_state"
1166
+
1167
+ @pytest.mark.anyio
1168
+ async def test_authorize_missing_response_type(
1169
+ self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
1170
+ ):
1171
+ """Test authorization endpoint with missing response_type.
1172
+
1173
+ Missing required parameter should result in invalid_request error.
1174
+ """
1175
+
1176
+ response = await test_client.get(
1177
+ "/authorize",
1178
+ params={
1179
+ # Missing response_type
1180
+ "client_id": registered_client["client_id"],
1181
+ "redirect_uri": "https://client.example.com/callback",
1182
+ "code_challenge": pkce_challenge["code_challenge"],
1183
+ "code_challenge_method": "S256",
1184
+ "state": "test_state",
1185
+ },
1186
+ )
1187
+
1188
+ # Should redirect with error parameters
1189
+ assert response.status_code == 302
1190
+ redirect_url = response.headers["location"]
1191
+ parsed_url = urlparse(redirect_url)
1192
+ query_params = parse_qs(parsed_url.query)
1193
+
1194
+ assert "error" in query_params
1195
+ assert query_params["error"][0] == "invalid_request"
1196
+ # State should be preserved
1197
+ assert "state" in query_params
1198
+ assert query_params["state"][0] == "test_state"
1199
+
1200
+ @pytest.mark.anyio
1201
+ async def test_authorize_missing_pkce_challenge(
1202
+ self, test_client: httpx.AsyncClient, registered_client
1203
+ ):
1204
+ """Test authorization endpoint with missing PKCE code_challenge.
1205
+
1206
+ Missing PKCE parameters should result in invalid_request error.
1207
+ """
1208
+ response = await test_client.get(
1209
+ "/authorize",
1210
+ params={
1211
+ "response_type": "code",
1212
+ "client_id": registered_client["client_id"],
1213
+ # Missing code_challenge
1214
+ "state": "test_state",
1215
+ # using default URL
1216
+ },
1217
+ )
1218
+
1219
+ # Should redirect with error parameters
1220
+ assert response.status_code == 302
1221
+ redirect_url = response.headers["location"]
1222
+ parsed_url = urlparse(redirect_url)
1223
+ query_params = parse_qs(parsed_url.query)
1224
+
1225
+ assert "error" in query_params
1226
+ assert query_params["error"][0] == "invalid_request"
1227
+ # State should be preserved
1228
+ assert "state" in query_params
1229
+ assert query_params["state"][0] == "test_state"
1230
+
1231
+ @pytest.mark.anyio
1232
+ async def test_authorize_invalid_scope(
1233
+ self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
1234
+ ):
1235
+ """Test authorization endpoint with invalid scope.
1236
+
1237
+ Invalid scope should redirect with invalid_scope error.
1238
+ """
1239
+
1240
+ response = await test_client.get(
1241
+ "/authorize",
1242
+ params={
1243
+ "response_type": "code",
1244
+ "client_id": registered_client["client_id"],
1245
+ "redirect_uri": "https://client.example.com/callback",
1246
+ "code_challenge": pkce_challenge["code_challenge"],
1247
+ "code_challenge_method": "S256",
1248
+ "scope": "invalid_scope_that_does_not_exist",
1249
+ "state": "test_state",
1250
+ },
1251
+ )
1252
+
1253
+ # Should redirect with error parameters
1254
+ assert response.status_code == 302
1255
+ redirect_url = response.headers["location"]
1256
+ parsed_url = urlparse(redirect_url)
1257
+ query_params = parse_qs(parsed_url.query)
1258
+
1259
+ assert "error" in query_params
1260
+ assert query_params["error"][0] == "invalid_scope"
1261
+ # State should be preserved
1262
+ assert "state" in query_params
1263
+ assert query_params["state"][0] == "test_state"