Skip to content

Commit af4474d

Browse files
committed
fix(authentication): Fix PAT authentication
1 parent fea5404 commit af4474d

File tree

3 files changed

+91
-8
lines changed

3 files changed

+91
-8
lines changed

packages/gg_api_core/src/gg_api_core/client.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -135,11 +135,13 @@ def _init_personal_access_token(self, personal_access_token: str | None = None):
135135
"HTTP/SSE mode requires per-request authentication via Authorization headers. "
136136
"For local OAuth authentication, use stdio transport (unset MCP_PORT)."
137137
)
138+
else:
139+
# HTTP mode and no personal access token provided
140+
# Token will be extracted from Authorization header per-request via get_client()
141+
logger.info("HTTP/SSE mode: token will be provided via Authorization header per-request")
142+
self._oauth_token = None
138143
else:
139-
if personal_access_token:
140-
logger.info("Using provided PAT")
141-
self._oauth_token = personal_access_token
142-
elif personal_access_token := os.environ.get("GITGUARDIAN_PERSONAL_ACCESS_TOKEN"):
144+
if personal_access_token := os.environ.get("GITGUARDIAN_PERSONAL_ACCESS_TOKEN"):
143145
logger.info("Using PAT from environment variable")
144146
self._oauth_token = personal_access_token
145147
else:
@@ -260,7 +262,7 @@ async def _ensure_api_token(self):
260262
and in test environments.
261263
"""
262264

263-
if self._oauth_token is not None:
265+
if getattr(self, "_oauth_token", None) is not None:
264266
return
265267

266268
if not is_oauth_enabled():
@@ -269,7 +271,7 @@ async def _ensure_api_token(self):
269271
# Use a global lock to prevent parallel OAuth flows across all client instances
270272
async with _oauth_lock:
271273
# Double-check pattern: another thread might have completed OAuth while we waited for the lock
272-
if self._oauth_token is not None:
274+
if getattr(self, "_oauth_token", None) is not None:
273275
logger.debug("OAuth token already available after waiting for lock")
274276
return
275277

packages/gg_api_core/src/gg_api_core/utils.py

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import logging
2+
import os
23
import re
34
from urllib.parse import urljoin as urllib_urljoin
45

6+
from fastmcp.server.dependencies import get_http_headers
7+
from mcp.server.fastmcp.exceptions import ValidationError
8+
59
from .client import GitGuardianClient
610

711
# Setup logger
@@ -27,25 +31,100 @@ def get_client(personal_access_token: str | None = None) -> GitGuardianClient:
2731
with that token (not cached). This is useful for per-request authentication
2832
via HTTP Authorization headers.
2933
34+
In HTTP/SSE mode (when MCP_PORT is set), this function automatically extracts
35+
the token from the Authorization header of the current request.
36+
3037
Args:
3138
personal_access_token: Optional Personal Access Token to use for authentication.
3239
If provided, a new client instance is created with this token.
3340
3441
Returns:
3542
GitGuardianClient: The cached client instance or a new instance with the provided PAT
3643
"""
37-
# If a PAT is provided, create a new client instance (don't use singleton)
44+
# Check if we're in HTTP/SSE mode (MCP_PORT is set)
45+
mcp_port = os.environ.get("MCP_PORT")
46+
47+
logger.debug(
48+
f"get_client() called: mcp_port={mcp_port}, personal_access_token={'provided' if personal_access_token else 'None'}"
49+
)
50+
51+
if mcp_port and not personal_access_token:
52+
# In HTTP mode, get token from Authorization header or raise
53+
logger.debug("HTTP mode detected, extracting token from request headers")
54+
try:
55+
personal_access_token = get_personal_access_token_from_request()
56+
logger.info("Successfully extracted token from HTTP request headers")
57+
except ValidationError as e:
58+
logger.error(f"Failed to extract token from HTTP headers: {e}")
59+
raise
60+
61+
# If a PAT is provided (or extracted from headers), create a new client instance (don't use singleton)
3862
if personal_access_token:
3963
logger.debug("Creating new GitGuardian client with provided Personal Access Token")
4064
return get_gitguardian_client(personal_access_token=personal_access_token)
4165

4266
# Otherwise, use the singleton pattern
67+
logger.debug("Using singleton client (no PAT provided)")
4368
global _client_singleton
4469
if _client_singleton is None:
70+
logger.info("Creating singleton client instance")
4571
_client_singleton = get_gitguardian_client()
4672
return _client_singleton
4773

4874

75+
def get_personal_access_token_from_request():
76+
"""Extract personal access token from HTTP request headers.
77+
78+
Raises:
79+
ValidationError: If headers are missing or invalid
80+
"""
81+
try:
82+
headers = get_http_headers()
83+
logger.debug(f"Retrieved HTTP headers: {list(headers.keys()) if headers else 'None'}")
84+
except Exception as e:
85+
logger.error(f"Failed to get HTTP headers: {e}")
86+
raise ValidationError(f"Failed to retrieve HTTP headers: {e}")
87+
88+
if not headers:
89+
logger.error("No HTTP headers available in current context")
90+
raise ValidationError("No HTTP headers available - Authorization header required in HTTP mode")
91+
92+
auth_header = headers.get("authorization") or headers.get("Authorization")
93+
if not auth_header:
94+
logger.error(f"Missing Authorization header. Available headers: {list(headers.keys())}")
95+
raise ValidationError("Missing Authorization header - required in HTTP mode")
96+
97+
token = _extract_token_from_auth_header(auth_header)
98+
if not token:
99+
logger.error("Failed to extract token from Authorization header")
100+
raise ValidationError("Invalid Authorization header format")
101+
102+
logger.debug("Successfully extracted token from Authorization header")
103+
return token
104+
105+
106+
def _extract_token_from_auth_header(auth_header: str) -> str | None:
107+
"""Extract token from Authorization header.
108+
109+
Supports formats:
110+
- Bearer <token>
111+
- Token <token>
112+
- <token> (raw)
113+
"""
114+
auth_header = auth_header.strip()
115+
116+
if auth_header.lower().startswith("bearer "):
117+
return auth_header[7:].strip()
118+
119+
if auth_header.lower().startswith("token "):
120+
return auth_header[6:].strip()
121+
122+
if auth_header:
123+
return auth_header
124+
125+
return None
126+
127+
49128
def parse_repo_url(remote_url: str) -> str | None:
50129
"""Parse repository name from git remote URL.
51130

tests/test_oauth_config_validation.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ def test_raises_error_when_both_mcp_port_and_oauth_enabled(self):
2222
def test_allows_mcp_port_without_oauth(self):
2323
"""Test that MCP_PORT can be set without ENABLE_LOCAL_OAUTH."""
2424
with patch.dict(os.environ, {"MCP_PORT": "8080", "ENABLE_LOCAL_OAUTH": "false"}, clear=False):
25-
# Should not raise
25+
# Should not raise - HTTP mode allows client creation (token provided per-request)
2626
client = GitGuardianClient()
2727
assert client is not None
28+
assert client._oauth_token is None # Token will be provided per-request
2829

2930
def test_allows_oauth_without_mcp_port(self):
3031
"""Test that ENABLE_LOCAL_OAUTH can be set without MCP_PORT."""
@@ -66,6 +67,7 @@ def test_empty_string_is_not_true(self):
6667
# Should not raise - empty string is treated as false
6768
client = GitGuardianClient()
6869
assert client is not None
70+
assert client._oauth_token is None # Token will be provided per-request
6971

7072
def test_unset_defaults_to_true_and_conflicts_with_mcp_port(self):
7173
"""Test that unset ENABLE_LOCAL_OAUTH defaults to true, which conflicts with MCP_PORT."""

0 commit comments

Comments
 (0)