authful-mcp-proxy 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of authful-mcp-proxy might be problematic. Click here for more details.
- authful_mcp_proxy/__init__.py +9 -0
- authful_mcp_proxy/__main__.py +180 -0
- authful_mcp_proxy/config.py +27 -0
- authful_mcp_proxy/external_oidc.py +435 -0
- authful_mcp_proxy/mcp_proxy.py +74 -0
- authful_mcp_proxy-0.1.0.dist-info/METADATA +646 -0
- authful_mcp_proxy-0.1.0.dist-info/RECORD +10 -0
- authful_mcp_proxy-0.1.0.dist-info/WHEEL +4 -0
- authful_mcp_proxy-0.1.0.dist-info/entry_points.txt +2 -0
- authful_mcp_proxy-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Authful MCP proxy."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("authful_mcp_proxy")
|
|
7
|
+
except PackageNotFoundError:
|
|
8
|
+
# If the package is not installed, use a development version
|
|
9
|
+
__version__ = "0.0.0-dev"
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authful MCP Proxy - Command-line interface.
|
|
3
|
+
|
|
4
|
+
This module provides the CLI entry point for running the MCP proxy server. It:
|
|
5
|
+
|
|
6
|
+
- Parses command-line arguments and environment variables
|
|
7
|
+
- Configures OIDC authentication parameters
|
|
8
|
+
- Launches the proxy server with appropriate settings
|
|
9
|
+
- Handles graceful shutdown and error reporting
|
|
10
|
+
|
|
11
|
+
The CLI supports configuration via both command-line options (--oidc-*) and
|
|
12
|
+
environment variables (OIDC_*), with CLI arguments taking precedence.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import asyncio
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
from . import __version__, mcp_proxy
|
|
22
|
+
from .config import OIDCConfig
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cli():
|
|
28
|
+
"""
|
|
29
|
+
Parse command line arguments and merge with environment variables.
|
|
30
|
+
|
|
31
|
+
Parses CLI arguments for OIDC configuration, backend URL, and logging options.
|
|
32
|
+
Falls back to environment variables when CLI arguments are not provided, with
|
|
33
|
+
CLI arguments taking precedence.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Namespace: Parsed arguments with all configuration options.
|
|
37
|
+
"""
|
|
38
|
+
parser = argparse.ArgumentParser(
|
|
39
|
+
description=f"Authful Remote-HTTP-to-Local-stdio MCP Proxy (version {__version__})"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Proxy server arguments
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"mcp_backend_url",
|
|
45
|
+
metavar="MCP_BACKEND_URL",
|
|
46
|
+
nargs="?",
|
|
47
|
+
help="URL of remote backend MCP server to be proxied (can also be set via MCP_BACKEND_URL env var)",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--no-banner",
|
|
52
|
+
action="store_true",
|
|
53
|
+
help="Don't show the proxy server banner",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# OIDC options
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--oidc-issuer-url",
|
|
59
|
+
help="OIDC issuer URL (can also be set via OIDC_ISSUER_URL env var)",
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--oidc-client-id",
|
|
63
|
+
help="OAuth client ID (can also be set via OIDC_CLIENT_ID env var)",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--oidc-client-secret",
|
|
67
|
+
help="OAuth client secret (can also be set via OIDC_CLIENT_SECRET env var, optional for public OIDC clients that don't require any such)",
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--oidc-scopes",
|
|
71
|
+
help="Space-separated OAuth scopes (can also be set via OIDC_SCOPES env var, default: 'openid profile email')",
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--oidc-redirect-url",
|
|
75
|
+
help="Localhost URL for OAuth redirect (can also be set via OIDC_REDIRECT_URL env var, default: http://localhost:8080/auth/callback)",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Logging options
|
|
79
|
+
group = parser.add_mutually_exclusive_group()
|
|
80
|
+
group.add_argument("--silent", action="store_true", help="Show only error messages")
|
|
81
|
+
group.add_argument(
|
|
82
|
+
"--debug",
|
|
83
|
+
action="store_true",
|
|
84
|
+
help="Enable debug logging (can also be set through 'MCP_PROXY_DEBUG' environment variable)",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
args = parser.parse_args()
|
|
88
|
+
|
|
89
|
+
# Fallback to environment variables (CLI args take precedence)
|
|
90
|
+
if not args.mcp_backend_url:
|
|
91
|
+
args.mcp_backend_url = os.getenv("MCP_BACKEND_URL")
|
|
92
|
+
if not args.oidc_issuer_url:
|
|
93
|
+
args.oidc_issuer_url = os.getenv("OIDC_ISSUER_URL")
|
|
94
|
+
if not args.oidc_client_id:
|
|
95
|
+
args.oidc_client_id = os.getenv("OIDC_CLIENT_ID")
|
|
96
|
+
if not args.oidc_client_secret:
|
|
97
|
+
args.oidc_client_secret = os.getenv("OIDC_CLIENT_SECRET")
|
|
98
|
+
if not args.oidc_scopes:
|
|
99
|
+
args.oidc_scopes = os.getenv("OIDC_SCOPES")
|
|
100
|
+
if not args.oidc_redirect_url:
|
|
101
|
+
args.oidc_redirect_url = os.getenv("OIDC_REDIRECT_URL")
|
|
102
|
+
if not args.debug:
|
|
103
|
+
args.debug = os.getenv("MCP_PROXY_DEBUG", "").lower() in (
|
|
104
|
+
"1",
|
|
105
|
+
"true",
|
|
106
|
+
"yes",
|
|
107
|
+
"on",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return args
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_log_level_name(args) -> str:
|
|
114
|
+
"""
|
|
115
|
+
Determine the appropriate log level based on command line arguments.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
args: Parsed command line arguments containing silent/debug flags.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
str: Log level name ('ERROR', 'DEBUG', or 'INFO').
|
|
122
|
+
"""
|
|
123
|
+
if args.silent:
|
|
124
|
+
return logging.getLevelName(logging.ERROR)
|
|
125
|
+
elif args.debug:
|
|
126
|
+
return logging.getLevelName(logging.DEBUG)
|
|
127
|
+
else:
|
|
128
|
+
return logging.getLevelName(logging.INFO)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def main():
|
|
132
|
+
"""
|
|
133
|
+
Main entry point for the Authful MCP Proxy application.
|
|
134
|
+
|
|
135
|
+
Parses configuration, creates the OIDC config object, and launches the proxy server.
|
|
136
|
+
Handles graceful shutdown and provides appropriate error messages for different
|
|
137
|
+
exception types.
|
|
138
|
+
|
|
139
|
+
Exits with status code 1 on errors, 0 on successful completion.
|
|
140
|
+
"""
|
|
141
|
+
args = cli()
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
# Create OIDC config
|
|
145
|
+
oidc_config = OIDCConfig(
|
|
146
|
+
issuer_url=args.oidc_issuer_url,
|
|
147
|
+
client_id=args.oidc_client_id,
|
|
148
|
+
client_secret=args.oidc_client_secret,
|
|
149
|
+
scopes=args.oidc_scopes,
|
|
150
|
+
redirect_url=args.oidc_redirect_url,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Start the MCP proxy
|
|
154
|
+
asyncio.run(
|
|
155
|
+
mcp_proxy.run_async(
|
|
156
|
+
backend_url=args.mcp_backend_url,
|
|
157
|
+
oidc_config=oidc_config,
|
|
158
|
+
show_banner=not args.no_banner,
|
|
159
|
+
log_level=get_log_level_name(args),
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
except KeyboardInterrupt:
|
|
163
|
+
# Graceful shutdown, suppress noisy logs resulting from asyncio.run task cancellation propagation
|
|
164
|
+
pass
|
|
165
|
+
except ValueError as e:
|
|
166
|
+
# Configuration error, log w/o stack trace
|
|
167
|
+
logger.error(f"Configuration error: {e}")
|
|
168
|
+
sys.exit(1)
|
|
169
|
+
except RuntimeError as e:
|
|
170
|
+
# Runtime error, log w/o stack trace
|
|
171
|
+
logger.error(f"Runtime error: {e}")
|
|
172
|
+
sys.exit(1)
|
|
173
|
+
except Exception as e:
|
|
174
|
+
# Unexpected internal error, include full stack trace
|
|
175
|
+
logger.error(f"Internal error: {e}", exc_info=True)
|
|
176
|
+
sys.exit(1)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
main()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Configuration models for the MCP proxy."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class OIDCConfig:
|
|
8
|
+
"""
|
|
9
|
+
OIDC authentication configuration.
|
|
10
|
+
|
|
11
|
+
This dataclass encapsulates OIDC/OAuth 2.0 parameters used for authenticating
|
|
12
|
+
with external authorization servers. All fields are optional to support
|
|
13
|
+
configuration via environment variables as fallback.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
issuer_url: OIDC issuer URL (e.g., https://keycloak.example.com/realms/myrealm)
|
|
17
|
+
client_id: OAuth client identifier
|
|
18
|
+
client_secret: OAuth client secret (optional for public OIDC clients that don't require any such)
|
|
19
|
+
scopes: Space-separated OAuth scopes (e.g., "openid profile email")
|
|
20
|
+
redirect_url: Localhost callback URL for OAuth redirect (e.g., http://localhost:8080/auth/callback)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
issuer_url: str
|
|
24
|
+
client_id: str
|
|
25
|
+
client_secret: str | None = None
|
|
26
|
+
scopes: str | None = None
|
|
27
|
+
redirect_url: str | None = None
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OIDC Auth client provider for external OpenID Connect (OIDC) providers.
|
|
3
|
+
|
|
4
|
+
This module provides an OAuth client for external OIDC providers (Keycloak, Auth0,
|
|
5
|
+
Okta, etc.) that handles the complete OAuth 2.0 authorization code flow with PKCE
|
|
6
|
+
using static client credentials. Key features include:
|
|
7
|
+
|
|
8
|
+
- Automatic provider configuration discovery via /.well-known/openid-configuration
|
|
9
|
+
- Browser-based user authentication with automatic OAuth callback handling
|
|
10
|
+
- Secure token exchange using PKCE (Proof Key for Code Exchange)
|
|
11
|
+
- Local token caching to eliminate repeated browser authentication
|
|
12
|
+
- Automatic access token refresh using refresh tokens
|
|
13
|
+
- Support for custom scopes and redirect URLs
|
|
14
|
+
|
|
15
|
+
Unlike dynamic client registration, this uses pre-configured client credentials
|
|
16
|
+
(client_id/client_secret) that must be set up in the OIDC provider beforehand.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import secrets
|
|
23
|
+
import time
|
|
24
|
+
import webbrowser
|
|
25
|
+
from asyncio import Future
|
|
26
|
+
from collections.abc import AsyncGenerator
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
from urllib.parse import urlencode, urlparse
|
|
31
|
+
|
|
32
|
+
import anyio
|
|
33
|
+
import httpx
|
|
34
|
+
from fastmcp.client.auth.oauth import FileTokenStorage
|
|
35
|
+
from fastmcp.client.oauth_callback import create_oauth_callback_server
|
|
36
|
+
from fastmcp.server.auth.oidc_proxy import OIDCConfiguration
|
|
37
|
+
from fastmcp.utilities.logging import get_logger
|
|
38
|
+
from mcp.client.auth import PKCEParameters
|
|
39
|
+
from mcp.shared.auth import OAuthToken
|
|
40
|
+
from pydantic import AnyHttpUrl
|
|
41
|
+
from uvicorn.server import Server
|
|
42
|
+
|
|
43
|
+
__all__ = ["ExternalOIDCAuth"]
|
|
44
|
+
|
|
45
|
+
logger = get_logger(__name__)
|
|
46
|
+
|
|
47
|
+
HTTPX_REQUEST_TIMEOUT_SECONDS = 5
|
|
48
|
+
BROWSER_LOGIN_TIMEOUT_SECONDS = 300
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class OIDCContext:
|
|
53
|
+
"""OIDC OAuth flow context - similar to OAuthContext but for external OIDC providers."""
|
|
54
|
+
|
|
55
|
+
issuer_url: str
|
|
56
|
+
client_id: str
|
|
57
|
+
client_secret: str | None
|
|
58
|
+
scopes: list[str]
|
|
59
|
+
redirect_uri: str
|
|
60
|
+
storage: FileTokenStorage
|
|
61
|
+
|
|
62
|
+
# Discovered metadata
|
|
63
|
+
oidc_config: OIDCConfiguration
|
|
64
|
+
|
|
65
|
+
# Token management
|
|
66
|
+
current_tokens: OAuthToken | None = None
|
|
67
|
+
token_expiry_time: float | None = None
|
|
68
|
+
|
|
69
|
+
# State
|
|
70
|
+
lock: anyio.Lock = field(default_factory=anyio.Lock)
|
|
71
|
+
|
|
72
|
+
def get_redirect_port(self) -> int:
|
|
73
|
+
"""Extract the port number from the redirect URI."""
|
|
74
|
+
parsed = urlparse(self.redirect_uri)
|
|
75
|
+
return parsed.port or 80
|
|
76
|
+
|
|
77
|
+
def get_authorization_url(self, state: str, pkce: PKCEParameters) -> str:
|
|
78
|
+
"""Build the authorization URL with PKCE parameters."""
|
|
79
|
+
auth_params = {
|
|
80
|
+
"response_type": "code",
|
|
81
|
+
"client_id": self.client_id,
|
|
82
|
+
"redirect_uri": self.redirect_uri,
|
|
83
|
+
"scope": " ".join(self.scopes),
|
|
84
|
+
"state": state,
|
|
85
|
+
"code_challenge": pkce.code_challenge,
|
|
86
|
+
"code_challenge_method": "S256",
|
|
87
|
+
}
|
|
88
|
+
return f"{self.oidc_config.authorization_endpoint}?{urlencode(auth_params)}"
|
|
89
|
+
|
|
90
|
+
def get_token_exchange_data(
|
|
91
|
+
self, auth_code: str, pkce: PKCEParameters
|
|
92
|
+
) -> dict[str, str]:
|
|
93
|
+
"""Build token exchange request data."""
|
|
94
|
+
token_data = {
|
|
95
|
+
"grant_type": "authorization_code",
|
|
96
|
+
"code": auth_code,
|
|
97
|
+
"redirect_uri": self.redirect_uri,
|
|
98
|
+
"client_id": self.client_id,
|
|
99
|
+
"code_verifier": pkce.code_verifier,
|
|
100
|
+
}
|
|
101
|
+
if self.client_secret:
|
|
102
|
+
token_data["client_secret"] = self.client_secret
|
|
103
|
+
return token_data
|
|
104
|
+
|
|
105
|
+
def get_token_refresh_data(self) -> dict[str, str]:
|
|
106
|
+
"""Build token refresh request data."""
|
|
107
|
+
token_data = {
|
|
108
|
+
"grant_type": "refresh_token",
|
|
109
|
+
"refresh_token": self.current_tokens.refresh_token,
|
|
110
|
+
"client_id": self.client_id,
|
|
111
|
+
}
|
|
112
|
+
if self.client_secret:
|
|
113
|
+
token_data["client_secret"] = self.client_secret
|
|
114
|
+
return token_data
|
|
115
|
+
|
|
116
|
+
def update_token_expiry(self, token: OAuthToken) -> None:
|
|
117
|
+
"""Update token expiry time."""
|
|
118
|
+
if token.expires_in:
|
|
119
|
+
self.token_expiry_time = time.time() + token.expires_in
|
|
120
|
+
else:
|
|
121
|
+
self.token_expiry_time = None
|
|
122
|
+
|
|
123
|
+
def is_token_valid(self) -> bool:
|
|
124
|
+
"""Check if current token is valid and not expired."""
|
|
125
|
+
return bool(
|
|
126
|
+
self.current_tokens
|
|
127
|
+
and self.current_tokens.access_token
|
|
128
|
+
and (
|
|
129
|
+
not self.token_expiry_time or time.time() < self.token_expiry_time - 60
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def can_refresh_token(self) -> bool:
|
|
134
|
+
"""Check if token can be refreshed."""
|
|
135
|
+
return bool(self.current_tokens and self.current_tokens.refresh_token)
|
|
136
|
+
|
|
137
|
+
def clear_tokens(self) -> None:
|
|
138
|
+
"""Clear current tokens."""
|
|
139
|
+
self.current_tokens = None
|
|
140
|
+
self.token_expiry_time = None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ExternalOIDCAuth(httpx.Auth):
|
|
144
|
+
"""
|
|
145
|
+
OAuth client provider that authenticates against external OIDC providers.
|
|
146
|
+
|
|
147
|
+
This client fetches OAuth configuration from an external OIDC provider's
|
|
148
|
+
/.well-known/openid-configuration endpoint and uses static client credentials
|
|
149
|
+
(client_id and client_secret) instead of dynamic client registration.
|
|
150
|
+
|
|
151
|
+
Key differences from standard OAuth client:
|
|
152
|
+
- Fetches config from issuer's /.well-known/openid-configuration (not MCP server)
|
|
153
|
+
- Uses static client_id/client_secret (no dynamic registration)
|
|
154
|
+
- Works with any OIDC-compliant provider (Keycloak, Auth0, Okta, etc.)
|
|
155
|
+
|
|
156
|
+
Example:
|
|
157
|
+
```python
|
|
158
|
+
from fastmcp.client import Client
|
|
159
|
+
from fastmcp.client.auth import OIDCAuth
|
|
160
|
+
|
|
161
|
+
auth = OIDCAuth(
|
|
162
|
+
issuer_url="https://your-keycloak.example.com/realms/myrealm",
|
|
163
|
+
client_id="your-client-id",
|
|
164
|
+
client_secret="your-client-secret",
|
|
165
|
+
scopes=["openid", "profile", "email"],
|
|
166
|
+
redirect_url="http://localhost:8080/auth/callback"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
async with Client("http://localhost:8000/mcp", auth=auth) as client:
|
|
170
|
+
# Use authenticated client
|
|
171
|
+
result = await client.call_tool("my_tool", {"arg": "value"})
|
|
172
|
+
```
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def __init__(
|
|
176
|
+
self,
|
|
177
|
+
issuer_url: str,
|
|
178
|
+
client_id: str,
|
|
179
|
+
client_secret: str | None = None,
|
|
180
|
+
scopes: str | list[str] | None = None,
|
|
181
|
+
token_storage_cache_dir: Path | None = None,
|
|
182
|
+
redirect_url: str | None = None,
|
|
183
|
+
):
|
|
184
|
+
"""
|
|
185
|
+
Initialize OIDC Auth client provider.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
issuer_url: OIDC issuer URL (e.g., "https://keycloak.example.com/realms/myrealm")
|
|
189
|
+
client_id: Static OAuth client ID
|
|
190
|
+
client_secret: Static OAuth client secret (optional for public OIDC clients that don't require any such)
|
|
191
|
+
scopes: OAuth scopes to request (default: ["openid"]). Can be a
|
|
192
|
+
space-separated string or a list of strings.
|
|
193
|
+
token_storage_cache_dir: Directory for token storage (cache)
|
|
194
|
+
redirect_url: Localhost URL for OAuth redirect (default: http://localhost:8080/auth/callback)
|
|
195
|
+
"""
|
|
196
|
+
# Validate required parameters
|
|
197
|
+
if not issuer_url:
|
|
198
|
+
raise ValueError("Missing required issuer URL")
|
|
199
|
+
if not client_id:
|
|
200
|
+
raise ValueError("Missing required client id")
|
|
201
|
+
|
|
202
|
+
# Parse and validate scopes
|
|
203
|
+
if isinstance(scopes, list):
|
|
204
|
+
scopes_list = scopes
|
|
205
|
+
elif scopes is not None:
|
|
206
|
+
scopes_list = scopes.split()
|
|
207
|
+
else:
|
|
208
|
+
scopes_list = ["openid"]
|
|
209
|
+
|
|
210
|
+
# Ensure openid scope is always included
|
|
211
|
+
if "openid" not in scopes_list:
|
|
212
|
+
scopes_list.insert(0, "openid")
|
|
213
|
+
|
|
214
|
+
# Setup redirect port and redirect URI
|
|
215
|
+
redirect_uri = redirect_url or "http://localhost:8080/auth/callback"
|
|
216
|
+
|
|
217
|
+
# Initialize token storage - reuse FileTokenStorage
|
|
218
|
+
storage = FileTokenStorage(
|
|
219
|
+
server_url=issuer_url, cache_dir=token_storage_cache_dir
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# Fetch OIDC configuration
|
|
223
|
+
config_url = f"{issuer_url.rstrip('/')}/.well-known/openid-configuration"
|
|
224
|
+
oidc_config = OIDCConfiguration.get_oidc_configuration(
|
|
225
|
+
AnyHttpUrl(config_url),
|
|
226
|
+
strict=True,
|
|
227
|
+
timeout_seconds=HTTPX_REQUEST_TIMEOUT_SECONDS,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Validate required endpoints
|
|
231
|
+
if not oidc_config.authorization_endpoint:
|
|
232
|
+
raise ValueError("OIDC configuration missing authorization_endpoint")
|
|
233
|
+
if not oidc_config.token_endpoint:
|
|
234
|
+
raise ValueError("OIDC configuration missing token_endpoint")
|
|
235
|
+
|
|
236
|
+
# Create context with all configuration and state
|
|
237
|
+
self.context = OIDCContext(
|
|
238
|
+
issuer_url=issuer_url,
|
|
239
|
+
client_id=client_id,
|
|
240
|
+
client_secret=client_secret,
|
|
241
|
+
scopes=scopes_list,
|
|
242
|
+
redirect_uri=redirect_uri,
|
|
243
|
+
oidc_config=oidc_config,
|
|
244
|
+
storage=storage,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
self._initialized = False
|
|
248
|
+
|
|
249
|
+
async def _initialize(self) -> None:
|
|
250
|
+
"""Load stored tokens if available."""
|
|
251
|
+
if self._initialized:
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
self.context.current_tokens = await self.context.storage.get_tokens()
|
|
255
|
+
if self.context.current_tokens:
|
|
256
|
+
self.context.update_token_expiry(self.context.current_tokens)
|
|
257
|
+
self._initialized = True
|
|
258
|
+
logger.debug("OIDC Auth client initialized")
|
|
259
|
+
|
|
260
|
+
async def _run_callback_server(self) -> tuple[str, str]:
|
|
261
|
+
"""Handle OAuth callback and return (auth_code, state)."""
|
|
262
|
+
# Create a future to capture the OAuth response
|
|
263
|
+
response_future: Future[Any] = asyncio.get_running_loop().create_future()
|
|
264
|
+
|
|
265
|
+
# Create server with the future
|
|
266
|
+
server: Server = create_oauth_callback_server(
|
|
267
|
+
port=self.context.get_redirect_port(),
|
|
268
|
+
server_url=self.context.issuer_url,
|
|
269
|
+
response_future=response_future,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# Run server until response is received with timeout logic
|
|
273
|
+
async with anyio.create_task_group() as tg:
|
|
274
|
+
tg.start_soon(server.serve)
|
|
275
|
+
logger.info(
|
|
276
|
+
f"🎧 OIDC Auth callback server started on {self.context.redirect_uri}"
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
with anyio.fail_after(BROWSER_LOGIN_TIMEOUT_SECONDS):
|
|
281
|
+
auth_code, state = await response_future
|
|
282
|
+
return auth_code, state
|
|
283
|
+
except TimeoutError:
|
|
284
|
+
raise TimeoutError(
|
|
285
|
+
f"OIDC Auth callback timed out after {BROWSER_LOGIN_TIMEOUT_SECONDS} seconds"
|
|
286
|
+
)
|
|
287
|
+
finally:
|
|
288
|
+
server.should_exit = True
|
|
289
|
+
await asyncio.sleep(0.1) # Allow server to shut down gracefully
|
|
290
|
+
tg.cancel_scope.cancel()
|
|
291
|
+
|
|
292
|
+
raise RuntimeError("OIDC Auth callback handler could not be started")
|
|
293
|
+
|
|
294
|
+
async def _perform_auth_flow(self) -> OAuthToken:
|
|
295
|
+
"""Perform the OAuth authorization code flow with PKCE."""
|
|
296
|
+
async with self.context.lock:
|
|
297
|
+
# Generate PKCE parameters and state
|
|
298
|
+
pkce = PKCEParameters.generate()
|
|
299
|
+
state = secrets.token_urlsafe(32)
|
|
300
|
+
|
|
301
|
+
# Build authorization URL using context method
|
|
302
|
+
authorization_url = self.context.get_authorization_url(state, pkce)
|
|
303
|
+
|
|
304
|
+
# Open browser for authorization
|
|
305
|
+
logger.info(f"Opening browser for OIDC authorization: {authorization_url}")
|
|
306
|
+
webbrowser.open(authorization_url)
|
|
307
|
+
|
|
308
|
+
# Wait for callback
|
|
309
|
+
auth_code, returned_state = await self._run_callback_server()
|
|
310
|
+
|
|
311
|
+
# Validate state
|
|
312
|
+
if returned_state is None or not secrets.compare_digest(
|
|
313
|
+
returned_state, state
|
|
314
|
+
):
|
|
315
|
+
raise RuntimeError(
|
|
316
|
+
f"OAuth state mismatch: {returned_state} != {state} - possible CSRF attack"
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# Validate auth code
|
|
320
|
+
if not auth_code:
|
|
321
|
+
raise RuntimeError("No authorization code received")
|
|
322
|
+
|
|
323
|
+
# Build token data using context method
|
|
324
|
+
token_data = self.context.get_token_exchange_data(auth_code, pkce)
|
|
325
|
+
|
|
326
|
+
# Exchange authorization code for tokens
|
|
327
|
+
async with httpx.AsyncClient() as client:
|
|
328
|
+
response = await client.post(
|
|
329
|
+
str(self.context.oidc_config.token_endpoint),
|
|
330
|
+
data=token_data,
|
|
331
|
+
timeout=float(HTTPX_REQUEST_TIMEOUT_SECONDS),
|
|
332
|
+
)
|
|
333
|
+
response.raise_for_status()
|
|
334
|
+
token_response = response.json()
|
|
335
|
+
|
|
336
|
+
# Parse and store tokens
|
|
337
|
+
tokens = OAuthToken.model_validate(token_response)
|
|
338
|
+
await self.context.storage.set_tokens(tokens)
|
|
339
|
+
self.context.current_tokens = tokens
|
|
340
|
+
self.context.update_token_expiry(tokens)
|
|
341
|
+
|
|
342
|
+
logger.info("OIDC Auth flow completed successfully")
|
|
343
|
+
return tokens
|
|
344
|
+
|
|
345
|
+
async def _refresh_tokens(self) -> OAuthToken:
|
|
346
|
+
"""Refresh access token using refresh token."""
|
|
347
|
+
async with self.context.lock:
|
|
348
|
+
if not self.context.can_refresh_token():
|
|
349
|
+
raise RuntimeError("No refresh token available")
|
|
350
|
+
|
|
351
|
+
token_data = self.context.get_token_refresh_data()
|
|
352
|
+
|
|
353
|
+
async with httpx.AsyncClient() as client:
|
|
354
|
+
response = await client.post(
|
|
355
|
+
str(self.context.oidc_config.token_endpoint),
|
|
356
|
+
data=token_data,
|
|
357
|
+
timeout=float(HTTPX_REQUEST_TIMEOUT_SECONDS),
|
|
358
|
+
)
|
|
359
|
+
response.raise_for_status()
|
|
360
|
+
token_response = response.json()
|
|
361
|
+
|
|
362
|
+
# Parse and store new tokens
|
|
363
|
+
tokens = OAuthToken.model_validate(token_response)
|
|
364
|
+
await self.context.storage.set_tokens(tokens)
|
|
365
|
+
self.context.current_tokens = tokens
|
|
366
|
+
self.context.update_token_expiry(tokens)
|
|
367
|
+
|
|
368
|
+
logger.debug("OIDC Auth tokens refreshed")
|
|
369
|
+
return tokens
|
|
370
|
+
|
|
371
|
+
async def _get_token(self) -> str:
|
|
372
|
+
"""
|
|
373
|
+
Get a valid access token, renewing it if necessary.
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
A valid access token, either from cache or after renewal.
|
|
377
|
+
"""
|
|
378
|
+
await self._initialize()
|
|
379
|
+
|
|
380
|
+
# If token is valid, return it
|
|
381
|
+
if self.context.is_token_valid():
|
|
382
|
+
return self.context.current_tokens.access_token
|
|
383
|
+
|
|
384
|
+
# Token expired or missing - refresh or re-auth
|
|
385
|
+
return await self._renew_token()
|
|
386
|
+
|
|
387
|
+
async def _renew_token(self) -> str:
|
|
388
|
+
"""Handle authentication errors by refreshing or re-authenticating."""
|
|
389
|
+
if self.context.can_refresh_token():
|
|
390
|
+
try:
|
|
391
|
+
await self._refresh_tokens()
|
|
392
|
+
logger.debug("Token refreshed successfully")
|
|
393
|
+
except Exception as e:
|
|
394
|
+
logger.warning(f"Token refresh failed: {e}, performing full auth flow")
|
|
395
|
+
await self._perform_auth_flow()
|
|
396
|
+
else:
|
|
397
|
+
logger.debug("No refresh token available, performing full auth flow")
|
|
398
|
+
await self._perform_auth_flow()
|
|
399
|
+
|
|
400
|
+
return self.context.current_tokens.access_token
|
|
401
|
+
|
|
402
|
+
async def async_auth_flow(
|
|
403
|
+
self, request: httpx.Request
|
|
404
|
+
) -> AsyncGenerator[httpx.Request, httpx.Response]:
|
|
405
|
+
"""
|
|
406
|
+
HTTPX auth flow implementation.
|
|
407
|
+
|
|
408
|
+
This method is compatible with httpx.Auth interface and automatically
|
|
409
|
+
adds the Bearer token to requests.
|
|
410
|
+
"""
|
|
411
|
+
# Get current access token or a new one if it has expired
|
|
412
|
+
access_token = await self._get_token()
|
|
413
|
+
|
|
414
|
+
# Add authorization header
|
|
415
|
+
request.headers["Authorization"] = f"Bearer {access_token}"
|
|
416
|
+
|
|
417
|
+
# Yield request and handle response
|
|
418
|
+
response = yield request
|
|
419
|
+
|
|
420
|
+
# If we get 401, handle auth error and retry
|
|
421
|
+
if response.status_code == 401:
|
|
422
|
+
logger.debug("Received 401, attempting token refresh")
|
|
423
|
+
try:
|
|
424
|
+
# Token invalid or missing - refresh or re-auth
|
|
425
|
+
access_token = await self._renew_token()
|
|
426
|
+
|
|
427
|
+
# Update request with new token
|
|
428
|
+
request.headers["Authorization"] = f"Bearer {access_token}"
|
|
429
|
+
|
|
430
|
+
# Retry request
|
|
431
|
+
response = yield request
|
|
432
|
+
except Exception as e:
|
|
433
|
+
logger.error(f"Token refresh and retry failed: {e}")
|
|
434
|
+
# Return original 401 response
|
|
435
|
+
pass
|