Skip to content

Commit d2c819e

Browse files
authored
Merge pull request #2 from 5queezer/feature/231-oauth-auth
feat(auth): add OAuth 2.1 for remote MCP deployments
2 parents 9c1ec5e + 27ef2cb commit d2c819e

13 files changed

Lines changed: 938 additions & 9 deletions

File tree

README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,63 @@ Runtime server logs are emitted by FastMCP/Uvicorn.
298298

299299
</details>
300300

301+
<details>
302+
<summary><b>🔐 Remote Deployment with OAuth</b></summary>
303+
304+
When deploying the server remotely (e.g. on Cloud Run, Fly.io, Railway),
305+
enable OAuth 2.1 to protect the MCP endpoint.
306+
307+
**Quick Start:**
308+
309+
```bash
310+
docker run --rm -i \
311+
-v ${HOME}/.linkedin-mcp:/home/pwuser/.linkedin-mcp \
312+
-e TRANSPORT=streamable-http \
313+
-e HOST=0.0.0.0 \
314+
-e AUTH=oauth \
315+
-e OAUTH_BASE_URL=https://your-server.example.com \
316+
-e OAUTH_PASSWORD=your-secret-password \
317+
-p 8000:8000 \
318+
stickerdaniel/linkedin-mcp-server
319+
```
320+
321+
**Adding as a Claude.ai Custom Connector:**
322+
323+
1. Deploy the server with OAuth enabled
324+
2. In claude.ai, go to **Settings → Connectors → Add custom connector**
325+
3. Enter the **full MCP endpoint URL** including `/mcp`:
326+
`https://your-server.example.com/mcp`
327+
> **Important:** Use the `/mcp` path, not the base URL — claude.ai will return "no tools" if you omit it.
328+
4. Claude.ai will discover the OAuth endpoints automatically
329+
5. You'll be redirected to the login page — enter your `OAUTH_PASSWORD`
330+
6. The connection is now authenticated
331+
332+
**Retrieving the OAuth password (if stored in GCP Secret Manager):**
333+
334+
```bash
335+
gcloud secrets versions access latest --secret=linkedin-mcp-oauth-password --project=YOUR_PROJECT
336+
```
337+
338+
**Environment Variables:**
339+
340+
| Variable | Description |
341+
|----------|-------------|
342+
| `AUTH` | Set to `oauth` to enable OAuth 2.1 authentication |
343+
| `OAUTH_BASE_URL` | Public URL of your server (e.g. `https://my-mcp.example.com`) |
344+
| `OAUTH_PASSWORD` | Password for the OAuth login page |
345+
346+
**CLI Flags:**
347+
348+
| Flag | Description |
349+
|------|-------------|
350+
| `--auth oauth` | Enable OAuth 2.1 authentication |
351+
| `--oauth-base-url URL` | Public URL of your server |
352+
| `--oauth-password PASSWORD` | Password for the login page |
353+
354+
> **Note:** OAuth state is stored in-memory. Deploy with a single instance (`--max-instances 1` on Cloud Run) — multi-instance setups will break the login flow because `/authorize` and `/login` may land on different instances.
355+
356+
</details>
357+
301358
<br/>
302359
<br/>
303360

docs/docker-hub.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ This opens a browser window where you log in manually (5 minute timeout for 2FA,
6565
| `SLOW_MO` | `0` | Delay between browser actions in ms (debugging) |
6666
| `VIEWPORT` | `1280x720` | Browser viewport size as WIDTHxHEIGHT |
6767
| `CHROME_PATH` | - | Path to Chrome/Chromium executable (rarely needed in Docker) |
68+
| `AUTH` | - | Set to `oauth` to enable OAuth 2.1 authentication for remote deployments |
69+
| `OAUTH_BASE_URL` | - | Public URL of the server (required when `AUTH=oauth`) |
70+
| `OAUTH_PASSWORD` | - | Password for the OAuth login page (required when `AUTH=oauth`) |
6871
| `LINKEDIN_EXPERIMENTAL_PERSIST_DERIVED_SESSION` | `false` | Experimental: reuse checkpointed derived Linux runtime profiles across Docker restarts instead of fresh-bridging each startup |
6972
| `LINKEDIN_TRACE_MODE` | `on_error` | Trace/log retention mode: `on_error` keeps ephemeral artifacts only when a failure occurs, `always` keeps every run, `off` disables trace persistence |
7073

linkedin_mcp_server/auth.py

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
"""
2+
OAuth 2.1 provider with password-based login for remote MCP deployments.
3+
4+
Subclasses FastMCP's InMemoryOAuthProvider to add a login page in the
5+
authorization flow. All other OAuth infrastructure (DCR, PKCE, token
6+
management, .well-known endpoints) is handled by the parent class.
7+
"""
8+
9+
import html
10+
import secrets
11+
import time
12+
13+
from mcp.server.auth.provider import AuthorizationParams
14+
from mcp.shared.auth import OAuthClientInformationFull
15+
from starlette.requests import Request
16+
from starlette.responses import RedirectResponse, Response
17+
from starlette.routing import Route
18+
19+
from fastmcp.server.auth.providers.in_memory import (
20+
AuthorizationCode,
21+
InMemoryOAuthProvider,
22+
construct_redirect_uri,
23+
)
24+
25+
# Pending auth requests expire after 10 minutes
26+
_PENDING_REQUEST_TTL_SECONDS = 600
27+
28+
# Global rate limiting: max failed attempts across all request_ids in a time window
29+
_GLOBAL_MAX_FAILED_ATTEMPTS = 20
30+
_GLOBAL_RATE_LIMIT_WINDOW_SECONDS = 300 # 5 minutes
31+
_GLOBAL_LOCKOUT_SECONDS = 60
32+
33+
_LOGIN_SECURITY_HEADERS = {
34+
"X-Frame-Options": "DENY",
35+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'",
36+
"X-Content-Type-Options": "nosniff",
37+
}
38+
39+
40+
def _html_response(content: str, status_code: int = 200) -> Response:
41+
"""HTMLResponse with security headers to prevent clickjacking and XSS."""
42+
from starlette.responses import HTMLResponse
43+
44+
return HTMLResponse(
45+
content, status_code=status_code, headers=_LOGIN_SECURITY_HEADERS
46+
)
47+
48+
49+
# Max failed password attempts before the request is invalidated
50+
_MAX_FAILED_ATTEMPTS = 5
51+
52+
53+
class PasswordOAuthProvider(InMemoryOAuthProvider):
54+
"""OAuth provider that requires a password before issuing authorization codes.
55+
56+
When a client (e.g. claude.ai) hits /authorize, the user is redirected to
57+
a login page. After entering the correct password, the authorization code
58+
is issued and the user is redirected back to the client's callback URL.
59+
"""
60+
61+
def __init__(
62+
self,
63+
*,
64+
base_url: str,
65+
password: str,
66+
**kwargs,
67+
):
68+
from mcp.server.auth.settings import ClientRegistrationOptions
69+
70+
super().__init__(
71+
base_url=base_url,
72+
client_registration_options=ClientRegistrationOptions(enabled=True),
73+
**kwargs,
74+
)
75+
self._password = password
76+
self._pending_auth_requests: dict[str, dict] = {}
77+
self._global_failed_attempts: list[float] = [] # timestamps of failures
78+
self._global_lockout_until: float = 0.0
79+
80+
async def authorize(
81+
self, client: OAuthClientInformationFull, params: AuthorizationParams
82+
) -> str:
83+
"""Redirect to login page instead of auto-approving."""
84+
self._cleanup_expired_requests()
85+
86+
request_id = secrets.token_urlsafe(32)
87+
self._pending_auth_requests[request_id] = {
88+
"client_id": client.client_id,
89+
"params": params,
90+
"created_at": time.time(),
91+
}
92+
93+
base = str(self.base_url).rstrip("/")
94+
return f"{base}/login?request_id={request_id}"
95+
96+
def get_login_routes(self) -> list[Route]:
97+
"""Return Starlette routes for the login page."""
98+
return [
99+
Route("/login", endpoint=self._handle_login, methods=["GET", "POST"]),
100+
]
101+
102+
def get_routes(self, mcp_path: str | None = None) -> list[Route]:
103+
"""Extend parent routes with login page."""
104+
routes = super().get_routes(mcp_path)
105+
routes.extend(self.get_login_routes())
106+
return routes
107+
108+
async def _handle_login(self, request: Request) -> Response:
109+
if request.method == "GET":
110+
return await self._render_login(request)
111+
return await self._process_login(request)
112+
113+
async def _render_login(self, request: Request) -> Response:
114+
request_id = request.query_params.get("request_id", "")
115+
pending = self._pending_auth_requests.get(request_id) if request_id else None
116+
if not pending:
117+
return _html_response("Invalid or expired login request.", status_code=400)
118+
119+
if time.time() - pending["created_at"] > _PENDING_REQUEST_TTL_SECONDS:
120+
del self._pending_auth_requests[request_id]
121+
return _html_response(
122+
"Login request expired. Please restart the authorization flow.",
123+
status_code=400,
124+
)
125+
126+
return _html_response(self._login_html(request_id))
127+
128+
async def _process_login(self, request: Request) -> Response:
129+
form = await request.form()
130+
request_id = str(form.get("request_id", ""))
131+
password = str(form.get("password", ""))
132+
133+
pending = self._pending_auth_requests.get(request_id)
134+
if not pending:
135+
return _html_response("Invalid or expired login request.", status_code=400)
136+
137+
# Enforce TTL at submission time (not only during cleanup)
138+
if time.time() - pending["created_at"] > _PENDING_REQUEST_TTL_SECONDS:
139+
del self._pending_auth_requests[request_id]
140+
return _html_response(
141+
"Login request expired. Please restart the authorization flow.",
142+
status_code=400,
143+
)
144+
145+
# Global rate limit: reject if locked out
146+
now = time.time()
147+
if now < self._global_lockout_until:
148+
return _html_response(
149+
"Too many failed login attempts. Please try again later.",
150+
status_code=429,
151+
)
152+
153+
if not secrets.compare_digest(password, self._password):
154+
# Track per-request failures
155+
pending["failed_attempts"] = pending.get("failed_attempts", 0) + 1
156+
if pending["failed_attempts"] >= _MAX_FAILED_ATTEMPTS:
157+
del self._pending_auth_requests[request_id]
158+
159+
# Track global failures and trigger lockout if threshold exceeded
160+
self._global_failed_attempts = [
161+
t
162+
for t in self._global_failed_attempts
163+
if now - t < _GLOBAL_RATE_LIMIT_WINDOW_SECONDS
164+
]
165+
self._global_failed_attempts.append(now)
166+
if len(self._global_failed_attempts) >= _GLOBAL_MAX_FAILED_ATTEMPTS:
167+
self._global_lockout_until = now + _GLOBAL_LOCKOUT_SECONDS
168+
return _html_response(
169+
"Too many failed login attempts. Please try again later, "
170+
"then restart the authorization flow from your client.",
171+
status_code=429,
172+
)
173+
174+
if pending.get("failed_attempts", 0) >= _MAX_FAILED_ATTEMPTS:
175+
return _html_response(
176+
"Too many failed attempts. Please restart the authorization flow.",
177+
status_code=403,
178+
)
179+
remaining = _MAX_FAILED_ATTEMPTS - pending["failed_attempts"]
180+
return _html_response(
181+
self._login_html(
182+
request_id,
183+
error=f"Invalid password. {remaining} attempt(s) remaining.",
184+
),
185+
status_code=200,
186+
)
187+
188+
# Password correct — create the authorization code and redirect
189+
del self._pending_auth_requests[request_id]
190+
191+
client = await self.get_client(pending["client_id"])
192+
if not client:
193+
return _html_response(
194+
"Client registration not found. "
195+
"Please restart the authorization flow from your client.",
196+
status_code=400,
197+
)
198+
199+
params: AuthorizationParams = pending["params"]
200+
scopes_list = params.scopes if params.scopes is not None else []
201+
202+
auth_code_value = f"auth_code_{secrets.token_hex(16)}"
203+
expires_at = time.time() + 300 # 5 min
204+
205+
auth_code = AuthorizationCode(
206+
code=auth_code_value,
207+
client_id=pending["client_id"],
208+
redirect_uri=params.redirect_uri,
209+
redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
210+
scopes=scopes_list,
211+
expires_at=expires_at,
212+
code_challenge=params.code_challenge,
213+
)
214+
self.auth_codes[auth_code_value] = auth_code
215+
216+
redirect_url = construct_redirect_uri(
217+
str(params.redirect_uri), code=auth_code_value, state=params.state
218+
)
219+
return RedirectResponse(redirect_url, status_code=302)
220+
221+
def _cleanup_expired_requests(self) -> None:
222+
now = time.time()
223+
expired = [
224+
rid
225+
for rid, data in self._pending_auth_requests.items()
226+
if now - data["created_at"] > _PENDING_REQUEST_TTL_SECONDS
227+
]
228+
for rid in expired:
229+
del self._pending_auth_requests[rid]
230+
231+
@staticmethod
232+
def _login_html(request_id: str, error: str = "") -> str:
233+
error_html = (
234+
f'<p style="color:#dc2626">{html.escape(error)}</p>' if error else ""
235+
)
236+
return f"""<!DOCTYPE html>
237+
<html lang="en">
238+
<head>
239+
<meta charset="utf-8">
240+
<meta name="viewport" content="width=device-width, initial-scale=1">
241+
<title>LinkedIn MCP Server — Login</title>
242+
<style>
243+
body {{ font-family: system-ui, sans-serif; display: flex; justify-content: center;
244+
align-items: center; min-height: 100vh; margin: 0; background: #f5f5f5; }}
245+
.card {{ background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,.1);
246+
max-width: 400px; width: 100%; }}
247+
h1 {{ font-size: 1.25rem; margin: 0 0 1.5rem; }}
248+
input[type=password] {{ width: 100%; padding: .5rem; margin: .5rem 0 1rem; box-sizing: border-box;
249+
border: 1px solid #ccc; border-radius: 4px; font-size: 1rem; }}
250+
button {{ width: 100%; padding: .6rem; background: #0a66c2; color: white; border: none;
251+
border-radius: 4px; font-size: 1rem; cursor: pointer; }}
252+
button:hover {{ background: #004182; }}
253+
</style>
254+
</head>
255+
<body>
256+
<div class="card">
257+
<h1>LinkedIn MCP Server</h1>
258+
<p>Enter the server password to authorize this connection.</p>
259+
{error_html}
260+
<form method="POST" action="/login">
261+
<input type="hidden" name="request_id" value="{html.escape(request_id)}">
262+
<label for="password">Password</label>
263+
<input type="password" id="password" name="password" required autofocus>
264+
<button type="submit">Authorize</button>
265+
</form>
266+
</div>
267+
</body>
268+
</html>"""

linkedin_mcp_server/cli_main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,7 @@ def main() -> None:
380380
transport = choose_transport_interactive()
381381

382382
# Create and run the MCP server
383-
mcp = create_mcp_server()
383+
mcp = create_mcp_server(oauth_config=config.server.oauth)
384384

385385
if transport == "streamable-http":
386386
mcp.run(

linkedin_mcp_server/config/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import logging
99

1010
from .loaders import load_config
11-
from .schema import AppConfig, BrowserConfig, ServerConfig
11+
from .schema import AppConfig, BrowserConfig, OAuthConfig, ServerConfig
1212

1313
logger = logging.getLogger(__name__)
1414

@@ -35,6 +35,7 @@ def reset_config() -> None:
3535
__all__ = [
3636
"AppConfig",
3737
"BrowserConfig",
38+
"OAuthConfig",
3839
"ServerConfig",
3940
"get_config",
4041
"reset_config",

0 commit comments

Comments
 (0)