File size: 10,248 Bytes
f16268d
 
 
 
 
 
 
 
 
 
73ff78e
f16268d
 
73ff78e
f16268d
 
 
 
f15f08b
f16268d
 
 
 
 
 
 
 
 
 
 
 
3c81b0a
 
 
 
 
 
 
 
 
 
 
 
 
 
f16268d
3c81b0a
 
f16268d
 
 
3c81b0a
 
 
 
 
 
f16268d
 
 
 
 
 
 
 
 
3c81b0a
 
 
 
 
 
f16268d
3c81b0a
f16268d
 
 
 
 
 
3c81b0a
 
f16268d
 
 
3c81b0a
 
f16268d
3c81b0a
 
f16268d
3c81b0a
f16268d
 
 
 
3c81b0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f16268d
 
 
3c81b0a
 
 
 
 
 
 
 
 
 
f16268d
 
 
3c81b0a
 
 
f16268d
3c81b0a
f16268d
 
3c81b0a
 
 
 
 
 
 
 
 
 
f16268d
 
3c81b0a
 
 
 
f16268d
 
3c81b0a
 
 
f16268d
 
 
3c81b0a
 
f16268d
 
 
 
 
 
3c81b0a
 
 
 
 
 
f16268d
 
 
 
 
 
 
 
 
73ff78e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f16268d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73ff78e
f16268d
 
73ff78e
f16268d
73ff78e
 
f16268d
 
 
 
73ff78e
 
 
f16268d
 
 
 
73ff78e
 
f16268d
 
 
 
73ff78e
f16268d
 
 
 
 
 
 
 
7218a9e
 
f16268d
 
 
 
3959209
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f16268d
 
73ff78e
 
 
f16268d
 
3c81b0a
f16268d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
"""
OAuth callback server for handling authorization code flows.

This module provides a reusable callback server that can handle OAuth redirects
and display styled responses to users.
"""

from __future__ import annotations

import asyncio
from dataclasses import dataclass

from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import HTMLResponse
from starlette.routing import Route
from uvicorn import Config, Server

from fastmcp.utilities.http import find_available_port
from fastmcp.utilities.logging import get_logger

logger = get_logger(__name__)


def create_callback_html(
    message: str,
    is_success: bool = True,
    title: str = "FastMCP OAuth",
    server_url: str | None = None,
) -> str:
    """Create a styled HTML response for OAuth callbacks."""
    logo_url = "https://gofastmcp.com/assets/brand/blue-logo.png"

    # Build the main status message
    if is_success:
        status_title = "Authentication successful"
        status_icon = "✓"
        icon_bg = "#10b98120"
    else:
        status_title = "Authentication failed"
        status_icon = "✕"
        icon_bg = "#ef444420"

    # Add detail info box for both success and error cases
    detail_info = ""
    if is_success and server_url:
        detail_info = f"""
            <div class="info-box">
                Connected to: <strong>{server_url}</strong>
            </div>
        """
    elif not is_success:
        detail_info = f"""
            <div class="info-box error">
                {message}
            </div>
        """

    return f"""
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>{title}</title>
        <style>
            * {{
                margin: 0;
                padding: 0;
                box-sizing: border-box;
            }}
            
            body {{
                font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
                margin: 0;
                padding: 0;
                min-height: 100vh;
                display: flex;
                align-items: center;
                justify-content: center;
                background: #ffffff;
                color: #0a0a0a;
            }}
            
            .container {{
                background: #ffffff;
                border: 1px solid #e5e5e5;
                padding: 3rem 2rem;
                border-radius: 0.75rem;
                box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
                text-align: center;
                max-width: 28rem;
                margin: 1rem;
                position: relative;
            }}
            
            .logo {{
                width: 60px;
                height: auto;
                margin-bottom: 2rem;
                display: block;
                margin-left: auto;
                margin-right: auto;
            }}
            
            .status-message {{
                display: flex;
                align-items: center;
                justify-content: center;
                gap: 0.75rem;
                margin-bottom: 1.5rem;
            }}
            
            .status-icon {{
                font-size: 1.5rem;
                line-height: 1;
                display: inline-flex;
                align-items: center;
                justify-content: center;
                width: 2rem;
                height: 2rem;
                background: {icon_bg};
                border-radius: 0.5rem;
                flex-shrink: 0;
            }}
            
            .message {{
                font-size: 1.125rem;
                line-height: 1.75;
                color: #0a0a0a;
                font-weight: 600;
                text-align: left;
            }}
            
            .info-box {{
                background: #f5f5f5;
                border: 1px solid #e5e5e5;
                border-radius: 0.5rem;
                padding: 0.875rem;
                margin: 1.25rem 0;
                font-size: 0.875rem;
                color: #525252;
                font-family: 'SF Mono', 'Monaco', 'Consolas', 'Courier New', monospace;
                text-align: left;
            }}
            
            .info-box.error {{
                background: #fef2f2;
                border-color: #fecaca;
                color: #991b1b;
            }}
            
            .info-box strong {{
                color: #0a0a0a;
                font-weight: 600;
            }}
            
            .close-instruction {{
                font-size: 0.875rem;
                color: #737373;
                margin-top: 1.5rem;
            }}
        </style>
    </head>
    <body>
        <div class="container">
            <img src="{logo_url}" alt="FastMCP" class="logo" />
            <div class="status-message">
                <span class="status-icon">{status_icon}</span>
                <div class="message">{status_title}</div>
            </div>
            {detail_info}
            <div class="close-instruction">
                You can safely close this tab now.
            </div>
        </div>
    </body>
    </html>
    """


@dataclass
class CallbackResponse:
    code: str | None = None
    state: str | None = None
    error: str | None = None
    error_description: str | None = None

    @classmethod
    def from_dict(cls, data: dict[str, str]) -> CallbackResponse:
        return cls(**{k: v for k, v in data.items() if k in cls.__annotations__})

    def to_dict(self) -> dict[str, str]:
        return {k: v for k, v in self.__dict__.items() if v is not None}


def create_oauth_callback_server(
    port: int,
    callback_path: str = "/callback",
    server_url: str | None = None,
    response_future: asyncio.Future | None = None,
) -> Server:
    """
    Create an OAuth callback server.

    Args:
        port: The port to run the server on
        callback_path: The path to listen for OAuth redirects on
        server_url: Optional server URL to display in success messages
        response_future: Optional future to resolve when OAuth callback is received

    Returns:
        Configured uvicorn Server instance (not yet running)
    """

    async def callback_handler(request: Request):
        """Handle OAuth callback requests with proper HTML responses."""
        query_params = dict(request.query_params)
        callback_response = CallbackResponse.from_dict(query_params)

        if callback_response.error:
            error_desc = callback_response.error_description or "Unknown error"

            # Resolve future with exception if provided
            if response_future and not response_future.done():
                response_future.set_exception(
                    RuntimeError(
                        f"OAuth error: {callback_response.error} - {error_desc}"
                    )
                )

            return HTMLResponse(
                create_callback_html(
                    f"FastMCP OAuth Error: {callback_response.error}<br>{error_desc}",
                    is_success=False,
                ),
                status_code=400,
            )

        if not callback_response.code:
            # Resolve future with exception if provided
            if response_future and not response_future.done():
                response_future.set_exception(
                    RuntimeError("OAuth callback missing authorization code")
                )

            return HTMLResponse(
                create_callback_html(
                    "FastMCP OAuth Error: No authorization code received",
                    is_success=False,
                ),
                status_code=400,
            )

        # Check for missing state parameter (indicates OAuth flow issue)
        if callback_response.state is None:
            # Resolve future with exception if provided
            if response_future and not response_future.done():
                response_future.set_exception(
                    RuntimeError(
                        "OAuth server did not return state parameter - authentication failed"
                    )
                )

            return HTMLResponse(
                create_callback_html(
                    "FastMCP OAuth Error: Authentication failed<br>The OAuth server did not return the expected state parameter",
                    is_success=False,
                ),
                status_code=400,
            )

        # Success case
        if response_future and not response_future.done():
            response_future.set_result(
                (callback_response.code, callback_response.state)
            )

        return HTMLResponse(
            create_callback_html("", is_success=True, server_url=server_url)
        )

    app = Starlette(routes=[Route(callback_path, callback_handler)])

    return Server(
        Config(
            app=app,
            host="127.0.0.1",
            port=port,
            lifespan="off",
            log_level="warning",
        )
    )


if __name__ == "__main__":
    """Run a test server when executed directly."""
    import webbrowser

    import uvicorn

    port = find_available_port()
    print("🎭 OAuth Callback Test Server")
    print("📍 Test URLs:")
    print(f"  Success: http://localhost:{port}/callback?code=test123&state=xyz")
    print(
        f"  Error:   http://localhost:{port}/callback?error=access_denied&error_description=User%20denied"
    )
    print(f"  Missing: http://localhost:{port}/callback")
    print("🛑 Press Ctrl+C to stop")
    print()

    # Create test server without future (just for testing HTML responses)
    server = create_oauth_callback_server(
        port=port, server_url="https://fastmcp-test-server.example.com"
    )

    # Open browser to success example
    webbrowser.open(f"http://localhost:{port}/callback?code=test123&state=xyz")

    # Run with uvicorn directly
    uvicorn.run(
        server.config.app,
        host="127.0.0.1",
        port=port,
        log_level="warning",
        access_log=False,
    )