feat(auth): add OAuth 2.1 for remote MCP deployments#233
feat(auth): add OAuth 2.1 for remote MCP deployments#2335queezer wants to merge 662 commits intostickerdaniel:mainfrom
Conversation
…ependencies chore(deps): update ci dependencies
…ependencies chore(deps): update ci dependencies
Point dependency at stickerdaniel/linkedin_scraper fork (fix/rate-limit-false-positive) to fix detect_rate_limit() false-firing on React RSC payloads. Also update docs with detailed release workflow notes and bump opencode agent models to gpt-5.3-codex. See also: joeyism/linkedin_scraper#278
…el#139) Point dependency at stickerdaniel/linkedin_scraper fork (fix/rate-limit-false-positive) to fix detect_rate_limit() false-firing on React RSC payloads. Also update docs with detailed release workflow notes and bump opencode agent models to gpt-5.3-codex. See also: joeyism/linkedin_scraper#278
Replace Playwright with Patchright (anti-detection fork) and use launch_persistent_context(user_data_dir=...) for full Chromium profile persistence. This fixes cross-platform session issues where sessions created on macOS failed in Docker (Linux, headless). BREAKING CHANGE: Old session.json files and LINKEDIN_COOKIE env var are no longer supported. Users must re-run --get-session to create a new persistent browser profile at ~/.linkedin-mcp/profile/.
…aniel#143) feat!: Switch to patchright with persistent browser context Replace Playwright with Patchright (anti-detection fork) and use launch_persistent_context(user_data_dir=...) for full Chromium profile persistence. This fixes cross-platform session issues where sessions created on macOS failed in Docker (Linux, headless). BREAKING CHANGE: Old session.json files and LINKEDIN_COOKIE env var are no longer supported. Users must re-run --get-session to create a new persistent browser profile at ~/.linkedin-mcp/profile/. polish the implementation
…im_agents.md_to_behavioral_guidance_clean_readme_docker_section docs: Trim AGENTS.md to behavioral guidance, clean README Docker section
This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [anthropics/claude-code-action](https://redirect.github.com/anthropics/claude-code-action) ([changelog](https://redirect.github.com/anthropics/claude-code-action/compare/26ec041249acb0a944c0a47b6c0c13f05dbc5b44..df37d2f0760a4b5683a6e617c9325bc1a36443f6)) | action | digest | `26ec041` → `df37d2f` | | [astral-sh/setup-uv](https://redirect.github.com/astral-sh/setup-uv) ([changelog](https://redirect.github.com/astral-sh/setup-uv/compare/5a095e7a2014a4212f075830d4f7277575a9d098..37802adc94f370d6bfd71619e3f0bf239e1f3b78)) | action | digest | `5a095e7` → `37802ad` | | ghcr.io/astral-sh/uv | final | digest | `10902f5` → `3472e43` | | [oven-sh/setup-bun](https://redirect.github.com/oven-sh/setup-bun) ([changelog](https://redirect.github.com/oven-sh/setup-bun/compare/ecf28ddc73e819eb6fa29df6b34ef8921c743461..0c5077e51419868618aeaa5fe8019c62421857d6)) | action | digest | `ecf28dd` → `0c5077e` | | python | stage | digest | `5404df0` → `55e465c` | | [softprops/action-gh-release](https://redirect.github.com/softprops/action-gh-release) ([changelog](https://redirect.github.com/softprops/action-gh-release/compare/a06a81a03ee405af7f2048a818ed3f03bbf83c7b..153bb8e04406b158c6c84fc1615b65b24149a1fe)) | action | digest | `a06a81a` → `153bb8e` | --- ### Configuration 📅 **Schedule**: Branch creation - "before 6am on Monday" (UTC), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://redirect.github.com/renovatebot/renovate/discussions) if that's undesired. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/stickerdaniel/linkedin-mcp-server). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My42Ni40IiwidXBkYXRlZEluVmVyIjoiNDMuNjYuNCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->
Add opt-in OAuth 2.1 authentication behind `--auth oauth` for remote server deployments. Subclasses FastMCP's InMemoryOAuthProvider to add a password-based login page in the /authorize flow, enabling claude.ai custom connector integration via Dynamic Client Registration. - PasswordOAuthProvider with HTML login page and brute-force lockout - OAuthConfig dataclass with validation (skipped for --login/--status/--logout) - AUTH, OAUTH_BASE_URL, OAUTH_PASSWORD env vars and CLI args - Integration tests verifying 401→discovery and .well-known endpoints - Documentation in README, docker-hub.md, and manifest.json Closes stickerdaniel#231 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
🔒 OAuth 2.1 Penetration Test ReportTarget: Summary
🔴 Findings (FAIL)F1: DCR accepts dangerous
|
| Test | Result |
|---|---|
| Unauthenticated MCP → 401 | ✅ |
| WWW-Authenticate header present | ✅ |
| PKCE enforced (no challenge → rejected) | ✅ |
| PKCE plain method rejected (S256 only) | ✅ |
Implicit flow (response_type=token) rejected |
✅ |
XSS via request_id param — properly escaped |
✅ |
| Brute-force lockout after 5 attempts | ✅ |
| Locked request_id invalidated (HTTP 400) | ✅ |
| Fake auth code rejected at token endpoint | ✅ |
client_credentials grant rejected |
✅ |
password (ROPC) grant rejected |
✅ |
| OIDC endpoint not exposed | ✅ |
| OAuth metadata publicly accessible | ✅ |
Recommendations
- Should fix (in this PR): W1+W2 — add security headers to login page HTML responses
- Should file upstream: F1+F2 — DCR redirect_uri scheme validation + rate limiting in MCP Python SDK
- No action needed: W3-W4 (infrastructure-level), F3 (RFC-compliant)
Tested with automated OAuth2 penetration testing against the live Cloud Run deployment. Authorization: project owner. Tool: custom Python test harness following OWASP OAuth Testing Guide.
Adds X-Frame-Options: DENY, Content-Security-Policy with frame-ancestors 'none', and X-Content-Type-Options: nosniff to all login page responses. Prevents clickjacking attacks on the password form. Found by OAuth 2.1 penetration test (W1, W2 in PR stickerdaniel#233 report). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude.ai sends requests to the root path by default, which returns 404. Make it unmistakable that the full MCP endpoint URL is required. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Greptile SummaryThis PR adds opt-in OAuth 2.1 authentication ( Key observations:
Confidence Score: 4/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant Client as claude.ai / MCP Client
participant Server as LinkedIn MCP Server
participant Login as /login page
Client->>Server: POST /mcp (no token)
Server-->>Client: 401 WWW-Authenticate: Bearer
Client->>Server: GET /.well-known/oauth-authorization-server
Server-->>Client: 200 { authorization_endpoint, token_endpoint, registration_endpoint }
Client->>Server: POST /register (DCR)
Server-->>Client: 200 { client_id, ... }
Client->>Server: GET /authorize?client_id=...&code_challenge=...&state=...
Note over Server: authorize() stores pending_auth_request<br/>(request_id, params, created_at, TTL=10min)
Server-->>Client: 302 → /login?request_id=<token>
Client->>Login: GET /login?request_id=<token>
Note over Login: Check TTL & global lockout
Login-->>Client: 200 Password form
Client->>Login: POST /login { request_id, password }
Note over Login: Check TTL, global lockout (60s window),<br/>compare_digest(), per-request counter (max 5)
alt Correct password
Login-->>Client: 302 → redirect_uri?code=...&state=...
Client->>Server: POST /token { code, code_verifier }
Server-->>Client: 200 { access_token }
Client->>Server: POST /mcp (Bearer token)
Server-->>Client: 200 MCP response
else Wrong password (< 5 attempts)
Login-->>Client: 200 Form with error + remaining count
else Wrong password (5th attempt)
Login-->>Client: 403 Request invalidated
else 20 global failures in 5 min
Login-->>Client: 429 Global lockout (60s)
end
Prompt To Fix All With AIThis is a comment left during a code review.
Path: linkedin_mcp_server/auth.py
Line: 113-126
Comment:
**`_render_login` skips global lockout check**
`_render_login` (GET) validates the TTL but does not check `self._global_lockout_until`. During an active global lockout the login form is still rendered and the user can fill it in — only to receive a 429 when they submit (POST). Adding the same early-return here gives the user an immediately actionable message instead of a two-step confusion:
```suggestion
async def _render_login(self, request: Request) -> Response:
request_id = request.query_params.get("request_id", "")
pending = self._pending_auth_requests.get(request_id) if request_id else None
if not pending:
return _html_response("Invalid or expired login request.", status_code=400)
if time.time() - pending["created_at"] > _PENDING_REQUEST_TTL_SECONDS:
del self._pending_auth_requests[request_id]
return _html_response(
"Login request expired. Please restart the authorization flow.",
status_code=400,
)
if time.time() < self._global_lockout_until:
return _html_response(
"Too many failed login attempts. Please try again later.",
status_code=429,
)
return _html_response(self._login_html(request_id))
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: linkedin_mcp_server/auth.py
Line: 153-186
Comment:
**No logging of security events**
The rate-limiting and lockout paths are completely silent. Operators deploying to Cloud Run / Fly.io have no visibility into brute-force attempts without log entries. At minimum, the global lockout trigger and per-request exhaustion should emit a `WARNING`:
```python
# When global lockout is triggered (line ~167):
import logging
logger = logging.getLogger(__name__)
logger.warning(
"Global OAuth lockout triggered: %d failures in %.0fs window",
len(self._global_failed_attempts),
_GLOBAL_RATE_LIMIT_WINDOW_SECONDS,
)
# When per-request attempts exhausted (line ~157):
logger.warning(
"OAuth request %s exhausted per-request attempts (client_id=%s)",
request_id,
pending.get("client_id"),
)
```
Without these, a sustained dictionary attack would be entirely invisible in the server logs.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: linkedin_mcp_server/auth.py
Line: 154-178
Comment:
**Redundant per-request lockout check**
The per-request lockout is checked and the entry deleted at lines 156–157, but the same condition is re-checked at line 174. Because `pending` is still a live local reference after `del self._pending_auth_requests[request_id]`, the logic is correct — but the second check (`pending.get("failed_attempts", 0) >= _MAX_FAILED_ATTEMPTS`) is unreachable when the global-lockout path at lines 166–172 triggers first, and is otherwise always true if we reach that point. Consider consolidating to a single return after the deletion:
```python
if not secrets.compare_digest(password, self._password):
pending["failed_attempts"] = pending.get("failed_attempts", 0) + 1
per_req_exhausted = pending["failed_attempts"] >= _MAX_FAILED_ATTEMPTS
if per_req_exhausted:
del self._pending_auth_requests[request_id]
self._global_failed_attempts = [
t for t in self._global_failed_attempts
if now - t < _GLOBAL_RATE_LIMIT_WINDOW_SECONDS
]
self._global_failed_attempts.append(now)
if len(self._global_failed_attempts) >= _GLOBAL_MAX_FAILED_ATTEMPTS:
self._global_lockout_until = now + _GLOBAL_LOCKOUT_SECONDS
return _html_response(
"Too many failed login attempts. Please try again later, "
"then restart the authorization flow from your client.",
status_code=429,
)
if per_req_exhausted:
return _html_response(
"Too many failed attempts. Please restart the authorization flow.",
status_code=403,
)
remaining = _MAX_FAILED_ATTEMPTS - pending["failed_attempts"]
return _html_response(
self._login_html(request_id, error=f"Invalid password. {remaining} attempt(s) remaining."),
status_code=200,
)
```
This removes the ambiguity around reading `pending` after it has been removed from the dict and makes the control flow unambiguous.
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: "fix(auth): enforce T..." |
…nt, assert removal - Add global rate limiter (20 failures / 5 min window → 60s lockout) to prevent brute-force bypass via fresh request_ids - Enforce TTL at form submission time, not only during cleanup - Replace assert with explicit ValueError for oauth_config guards - Document single-instance requirement for in-memory OAuth state Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| parser.add_argument( | ||
| "--oauth-password", | ||
| type=str, | ||
| default=None, | ||
| metavar="PASSWORD", | ||
| help="Password for the OAuth login page", | ||
| ) |
There was a problem hiding this comment.
--oauth-password exposes secret in process listing
Passing the OAuth password as a CLI argument makes it visible in /proc/<pid>/cmdline, ps aux output, and shell history (~/.bash_history, ~/.zsh_history). On shared or multi-tenant hosts (including many cloud VMs), any process on the system can read /proc/<pid>/cmdline without elevated privileges.
The environment variable path (OAUTH_PASSWORD) is the safer approach and is already the primary method documented in the README. Consider removing the --oauth-password CLI flag entirely and directing users to the env var instead. If the flag must be kept, at minimum the help text should warn about this risk:
| parser.add_argument( | |
| "--oauth-password", | |
| type=str, | |
| default=None, | |
| metavar="PASSWORD", | |
| help="Password for the OAuth login page", | |
| ) | |
| parser.add_argument( | |
| "--oauth-password", | |
| type=str, | |
| default=None, | |
| metavar="PASSWORD", | |
| help="Password for the OAuth login page (WARNING: visible in process list; prefer OAUTH_PASSWORD env var)", | |
| ) |
Prompt To Fix With AI
This is a comment left during a code review.
Path: linkedin_mcp_server/config/loaders.py
Line: 298-304
Comment:
**`--oauth-password` exposes secret in process listing**
Passing the OAuth password as a CLI argument makes it visible in `/proc/<pid>/cmdline`, `ps aux` output, and shell history (`~/.bash_history`, `~/.zsh_history`). On shared or multi-tenant hosts (including many cloud VMs), any process on the system can read `/proc/<pid>/cmdline` without elevated privileges.
The environment variable path (`OAUTH_PASSWORD`) is the safer approach and is already the primary method documented in the README. Consider removing the `--oauth-password` CLI flag entirely and directing users to the env var instead. If the flag must be kept, at minimum the help text should warn about this risk:
```suggestion
parser.add_argument(
"--oauth-password",
type=str,
default=None,
metavar="PASSWORD",
help="Password for the OAuth login page (WARNING: visible in process list; prefer OAUTH_PASSWORD env var)",
)
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Fixed in a4b5d2e. Added warning to the help text: (visible in process list; prefer OAUTH_PASSWORD env var). Kept the flag for convenience in dev/testing but the env var is the documented primary path.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Apply greptile suggestion Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
- Add restart guidance to 429 global lockout message (prevents dead-end when per-request + global lockout collide simultaneously) - Add restart guidance to "Client not found" error message - Validate base_url has no path component in _validate_oauth (prevents silent 404 when base_url contains e.g. /api) - Add tests for HTTPS validation, path-component rejection, and trailing-slash acceptance Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
All three issues from review round 2 addressed in 9ef05c6:
All with test coverage. CI passing. |
…ering Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Known limitation: OAuth protects the MCP endpoint, but LinkedIn session cookies (used for scraping) are still lost on container restart. Deployments on ephemeral runtimes like Cloud Run need a separate storage backend to persist auth state across cold starts. Follow-up: feat: GCS-backed auth state persistence for Cloud Run |
fd80f60 to
7661f43
Compare
Summary
--auth oauthfor remote server deployments (e.g. Cloud Run, Fly.io)InMemoryOAuthProviderwith a password-based login page in the/authorizeflowChanges
linkedin_mcp_server/auth.pyPasswordOAuthProviderwith login page, brute-force lockout (5 attempts)linkedin_mcp_server/config/schema.pyOAuthConfigdataclass + validation (skipped for--login/--status/--logout)linkedin_mcp_server/config/loaders.pyAUTH,OAUTH_BASE_URL,OAUTH_PASSWORDenv vars + CLI argslinkedin_mcp_server/server.pyauth=PasswordOAuthProvider(...)toFastMCP()when enabledlinkedin_mcp_server/cli_main.pyoauth_configthrough tocreate_mcp_server()tests/test_auth.pytests/test_config.pyREADME.mddocs/docker-hub.mdmanifest.jsonuser_configSecurity
secrets.compare_digest)html.escape()on all template inputsTest plan
uv run pytest --cov -v)--auth oauth, verify 401 on unauthenticated/mcp, verify.well-known/oauth-authorization-serverreturns metadataPrompt
Closes #231
🤖 Generated with Claude Code (Claude Opus 4.6)