Skip to content

Commit 7b24e1c

Browse files
authored
feat: add OAuth multi-cloud support (sooperset#471)
Enable dynamic multi-tenant authentication via HTTP headers. Users can provide their own OAuth tokens and Cloud IDs to connect to different Atlassian instances through a single MCP server. - Add minimal OAuth config mode with `ATLASSIAN_OAUTH_ENABLE=true` - Support per-request Cloud ID via `X-Atlassian-Cloud-Id` header - Support per-request OAuth tokens via Authorization header - Maintain backward compatibility with env-based OAuth - Add tests for multi-tenant isolation and header extraction Reported-by: Ivaschenko Pavlo (@pavel1tel)
1 parent 5976e5c commit 7b24e1c

File tree

10 files changed

+380
-73
lines changed

10 files changed

+380
-73
lines changed

Dockerfile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,10 @@ COPY --from=uv --chown=app:app /app/.venv /app/.venv
4646
# Place executables in the environment at the front of the path
4747
ENV PATH="/app/.venv/bin:$PATH"
4848

49+
# For minimal OAuth setup without environment variables, use:
50+
# docker run -e ATLASSIAN_OAUTH_ENABLE=true -p 8000:8000 your-image
51+
# Then provide authentication via headers:
52+
# Authorization: Bearer <your_oauth_token>
53+
# X-Atlassian-Cloud-Id: <your_cloud_id>
54+
4955
ENTRYPOINT ["mcp-atlassian"]

src/mcp_atlassian/confluence/config.py

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def from_env(cls) -> "ConfluenceConfig":
6666
ValueError: If any required environment variable is missing
6767
"""
6868
url = os.getenv("CONFLUENCE_URL")
69-
if not url:
69+
if not url and not os.getenv("ATLASSIAN_OAUTH_ENABLE"):
7070
error_msg = "Missing required CONFLUENCE_URL environment variable"
7171
raise ValueError(error_msg)
7272

@@ -82,14 +82,14 @@ def from_env(cls) -> "ConfluenceConfig":
8282
# Use the shared utility function directly
8383
is_cloud = is_atlassian_cloud_url(url)
8484

85-
if oauth_config and oauth_config.cloud_id:
86-
# OAuth takes precedence if fully configured
85+
if oauth_config:
86+
# OAuth is available - could be full config or minimal config for user-provided tokens
8787
auth_type = "oauth"
8888
elif is_cloud:
8989
if username and api_token:
9090
auth_type = "basic"
9191
else:
92-
error_msg = "Cloud authentication requires CONFLUENCE_USERNAME and CONFLUENCE_API_TOKEN, or OAuth configuration"
92+
error_msg = "Cloud authentication requires CONFLUENCE_USERNAME and CONFLUENCE_API_TOKEN, or OAuth configuration (set ATLASSIAN_OAUTH_ENABLE=true for user-provided tokens)"
9393
raise ValueError(error_msg)
9494
else: # Server/Data Center
9595
if personal_token:
@@ -136,24 +136,37 @@ def is_auth_configured(self) -> bool:
136136
"""
137137
logger = logging.getLogger("mcp-atlassian.confluence.config")
138138
if self.auth_type == "oauth":
139-
return bool(
140-
self.oauth_config
141-
and (
142-
(
143-
isinstance(self.oauth_config, OAuthConfig)
144-
and self.oauth_config.client_id
139+
# Handle different OAuth configuration types
140+
if self.oauth_config:
141+
# Full OAuth configuration (traditional mode)
142+
if isinstance(self.oauth_config, OAuthConfig):
143+
if (
144+
self.oauth_config.client_id
145145
and self.oauth_config.client_secret
146146
and self.oauth_config.redirect_uri
147147
and self.oauth_config.scope
148148
and self.oauth_config.cloud_id
149-
)
150-
or (
151-
isinstance(self.oauth_config, BYOAccessTokenOAuthConfig)
152-
and self.oauth_config.cloud_id
153-
and self.oauth_config.access_token
154-
)
155-
)
156-
)
149+
):
150+
return True
151+
# Minimal OAuth configuration (user-provided tokens mode)
152+
# This is valid if we have oauth_config but missing client credentials
153+
# In this case, we expect authentication to come from user-provided headers
154+
elif (
155+
not self.oauth_config.client_id
156+
and not self.oauth_config.client_secret
157+
):
158+
logger.debug(
159+
"Minimal OAuth config detected - expecting user-provided tokens via headers"
160+
)
161+
return True
162+
# Bring Your Own Access Token mode
163+
elif isinstance(self.oauth_config, BYOAccessTokenOAuthConfig):
164+
if self.oauth_config.cloud_id and self.oauth_config.access_token:
165+
return True
166+
167+
# Partial configuration is invalid
168+
logger.warning("Incomplete OAuth configuration detected")
169+
return False
157170
elif self.auth_type == "token":
158171
return bool(self.personal_token)
159172
elif self.auth_type == "basic":

src/mcp_atlassian/jira/config.py

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def from_env(cls) -> "JiraConfig":
6666
ValueError: If required environment variables are missing or invalid
6767
"""
6868
url = os.getenv("JIRA_URL")
69-
if not url:
69+
if not url and not os.getenv("ATLASSIAN_OAUTH_ENABLE"):
7070
error_msg = "Missing required JIRA_URL environment variable"
7171
raise ValueError(error_msg)
7272

@@ -82,14 +82,14 @@ def from_env(cls) -> "JiraConfig":
8282
# Use the shared utility function directly
8383
is_cloud = is_atlassian_cloud_url(url)
8484

85-
if oauth_config and oauth_config.cloud_id:
86-
# OAuth takes precedence if fully configured
85+
if oauth_config:
86+
# OAuth is available - could be full config or minimal config for user-provided tokens
8787
auth_type = "oauth"
8888
elif is_cloud:
8989
if username and api_token:
9090
auth_type = "basic"
9191
else:
92-
error_msg = "Cloud authentication requires JIRA_USERNAME and JIRA_API_TOKEN, or OAuth configuration"
92+
error_msg = "Cloud authentication requires JIRA_USERNAME and JIRA_API_TOKEN, or OAuth configuration (set ATLASSIAN_OAUTH_ENABLE=true for user-provided tokens)"
9393
raise ValueError(error_msg)
9494
else: # Server/Data Center
9595
if personal_token:
@@ -136,24 +136,37 @@ def is_auth_configured(self) -> bool:
136136
"""
137137
logger = logging.getLogger("mcp-atlassian.jira.config")
138138
if self.auth_type == "oauth":
139-
return bool(
140-
self.oauth_config
141-
and (
142-
(
143-
isinstance(self.oauth_config, OAuthConfig)
144-
and self.oauth_config.client_id
139+
# Handle different OAuth configuration types
140+
if self.oauth_config:
141+
# Full OAuth configuration (traditional mode)
142+
if isinstance(self.oauth_config, OAuthConfig):
143+
if (
144+
self.oauth_config.client_id
145145
and self.oauth_config.client_secret
146146
and self.oauth_config.redirect_uri
147147
and self.oauth_config.scope
148148
and self.oauth_config.cloud_id
149-
)
150-
or (
151-
isinstance(self.oauth_config, BYOAccessTokenOAuthConfig)
152-
and self.oauth_config.cloud_id
153-
and self.oauth_config.access_token
154-
)
155-
)
156-
)
149+
):
150+
return True
151+
# Minimal OAuth configuration (user-provided tokens mode)
152+
# This is valid if we have oauth_config but missing client credentials
153+
# In this case, we expect authentication to come from user-provided headers
154+
elif (
155+
not self.oauth_config.client_id
156+
and not self.oauth_config.client_secret
157+
):
158+
logger.debug(
159+
"Minimal OAuth config detected - expecting user-provided tokens via headers"
160+
)
161+
return True
162+
# Bring Your Own Access Token mode
163+
elif isinstance(self.oauth_config, BYOAccessTokenOAuthConfig):
164+
if self.oauth_config.cloud_id and self.oauth_config.access_token:
165+
return True
166+
167+
# Partial configuration is invalid
168+
logger.warning("Incomplete OAuth configuration detected")
169+
return False
157170
elif self.auth_type == "pat":
158171
return bool(self.personal_token)
159172
elif self.auth_type == "basic":

src/mcp_atlassian/servers/dependencies.py

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,15 @@ def _create_user_config_for_fetcher(
3131
base_config: JiraConfig | ConfluenceConfig,
3232
auth_type: str,
3333
credentials: dict[str, Any],
34+
cloud_id: str | None = None,
3435
) -> JiraConfig | ConfluenceConfig:
3536
"""Create a user-specific configuration for Jira or Confluence fetchers.
3637
3738
Args:
3839
base_config: The base JiraConfig or ConfluenceConfig to clone and modify.
3940
auth_type: The authentication type ('oauth' or 'pat').
4041
credentials: Dictionary of credentials (token, email, etc).
42+
cloud_id: Optional cloud ID to override the base config cloud ID.
4143
4244
Returns:
4345
JiraConfig or ConfluenceConfig with user-specific credentials.
@@ -54,7 +56,7 @@ def _create_user_config_for_fetcher(
5456
username_for_config: str | None = credentials.get("user_email_context")
5557

5658
logger.debug(
57-
f"Creating user config for fetcher. Auth type: {auth_type}, Credentials keys: {credentials.keys()}"
59+
f"Creating user config for fetcher. Auth type: {auth_type}, Credentials keys: {credentials.keys()}, Cloud ID: {cloud_id}"
5860
)
5961

6062
common_args: dict[str, Any] = {
@@ -77,22 +79,35 @@ def _create_user_config_for_fetcher(
7779
not base_config
7880
or not hasattr(base_config, "oauth_config")
7981
or not getattr(base_config, "oauth_config", None)
80-
or not getattr(getattr(base_config, "oauth_config", None), "cloud_id", None)
8182
):
8283
raise ValueError(
83-
f"Global OAuth config (with cloud_id) for {type(base_config).__name__} is missing, "
84-
"but user auth_type is 'oauth'. Cannot determine cloud_id."
84+
f"Global OAuth config for {type(base_config).__name__} is missing, "
85+
"but user auth_type is 'oauth'."
8586
)
8687
global_oauth_cfg = base_config.oauth_config
88+
89+
# Use provided cloud_id or fall back to global config cloud_id
90+
effective_cloud_id = cloud_id if cloud_id else global_oauth_cfg.cloud_id
91+
if not effective_cloud_id:
92+
raise ValueError(
93+
"Cloud ID is required for OAuth authentication. "
94+
"Provide it via X-Atlassian-Cloud-Id header or configure it globally."
95+
)
96+
97+
# For minimal OAuth config (user-provided tokens), use empty strings for client credentials
8798
oauth_config_for_user = OAuthConfig(
88-
client_id=global_oauth_cfg.client_id if global_oauth_cfg else "",
89-
client_secret=global_oauth_cfg.client_secret if global_oauth_cfg else "",
90-
redirect_uri=global_oauth_cfg.redirect_uri if global_oauth_cfg else "",
91-
scope=global_oauth_cfg.scope if global_oauth_cfg else "",
99+
client_id=global_oauth_cfg.client_id if global_oauth_cfg.client_id else "",
100+
client_secret=global_oauth_cfg.client_secret
101+
if global_oauth_cfg.client_secret
102+
else "",
103+
redirect_uri=global_oauth_cfg.redirect_uri
104+
if global_oauth_cfg.redirect_uri
105+
else "",
106+
scope=global_oauth_cfg.scope if global_oauth_cfg.scope else "",
92107
access_token=user_access_token,
93108
refresh_token=None,
94109
expires_at=None,
95-
cloud_id=global_oauth_cfg.cloud_id if global_oauth_cfg else "",
110+
cloud_id=effective_cloud_id,
96111
)
97112
common_args.update(
98113
{
@@ -106,6 +121,14 @@ def _create_user_config_for_fetcher(
106121
user_pat = credentials.get("personal_access_token")
107122
if not user_pat:
108123
raise ValueError("PAT missing in credentials for user auth_type 'pat'")
124+
125+
# Log warning if cloud_id is provided with PAT auth (not typically needed)
126+
if cloud_id:
127+
logger.warning(
128+
f"Cloud ID '{cloud_id}' provided with PAT authentication. "
129+
"PAT authentication typically uses the base URL directly and doesn't require cloud_id override."
130+
)
131+
109132
common_args.update(
110133
{
111134
"personal_token": user_pat,
@@ -166,6 +189,8 @@ async def get_jira_fetcher(ctx: Context) -> JiraFetcher:
166189
user_email = getattr(
167190
request.state, "user_atlassian_email", None
168191
) # May be None for PAT
192+
user_cloud_id = getattr(request.state, "user_atlassian_cloud_id", None)
193+
169194
if not user_token:
170195
raise ValueError("User Atlassian token found in state but is empty.")
171196
credentials = {"user_email_context": user_email}
@@ -183,13 +208,16 @@ async def get_jira_fetcher(ctx: Context) -> JiraFetcher:
183208
raise ValueError(
184209
"Jira global configuration (URL, SSL) is not available from lifespan context."
185210
)
211+
212+
cloud_id_info = f" with cloudId {user_cloud_id}" if user_cloud_id else ""
186213
logger.info(
187-
f"Creating user-specific JiraFetcher (type: {user_auth_type}) for user {user_email or 'unknown'} (token ...{str(user_token)[-8:]})"
214+
f"Creating user-specific JiraFetcher (type: {user_auth_type}) for user {user_email or 'unknown'} (token ...{str(user_token)[-8:]}){cloud_id_info}"
188215
)
189216
user_specific_config = _create_user_config_for_fetcher(
190217
base_config=app_lifespan_ctx.full_jira_config,
191218
auth_type=user_auth_type,
192219
credentials=credentials,
220+
cloud_id=user_cloud_id,
193221
)
194222
try:
195223
user_jira_fetcher = JiraFetcher(config=user_specific_config)
@@ -268,6 +296,8 @@ async def get_confluence_fetcher(ctx: Context) -> ConfluenceFetcher:
268296
):
269297
user_token = getattr(request.state, "user_atlassian_token", None)
270298
user_email = getattr(request.state, "user_atlassian_email", None)
299+
user_cloud_id = getattr(request.state, "user_atlassian_cloud_id", None)
300+
271301
if not user_token:
272302
raise ValueError("User Atlassian token found in state but is empty.")
273303
credentials = {"user_email_context": user_email}
@@ -285,13 +315,16 @@ async def get_confluence_fetcher(ctx: Context) -> ConfluenceFetcher:
285315
raise ValueError(
286316
"Confluence global configuration (URL, SSL) is not available from lifespan context."
287317
)
318+
319+
cloud_id_info = f" with cloudId {user_cloud_id}" if user_cloud_id else ""
288320
logger.info(
289-
f"Creating user-specific ConfluenceFetcher (type: {user_auth_type}) for user {user_email or 'unknown'} (token ...{str(user_token)[-8:]})"
321+
f"Creating user-specific ConfluenceFetcher (type: {user_auth_type}) for user {user_email or 'unknown'} (token ...{str(user_token)[-8:]}){cloud_id_info}"
290322
)
291323
user_specific_config = _create_user_config_for_fetcher(
292324
base_config=app_lifespan_ctx.full_confluence_config,
293325
auth_type=user_auth_type,
294326
credentials=credentials,
327+
cloud_id=user_cloud_id,
295328
)
296329
try:
297330
user_confluence_fetcher = ConfluenceFetcher(config=user_specific_config)

src/mcp_atlassian/servers/main.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,14 +239,29 @@ async def dispatch(
239239
)
240240
if request_path == mcp_path and request.method == "POST":
241241
auth_header = request.headers.get("Authorization")
242+
cloud_id_header = request.headers.get("X-Atlassian-Cloud-Id")
243+
242244
token_for_log = mask_sensitive(
243245
auth_header.split(" ", 1)[1].strip()
244246
if auth_header and " " in auth_header
245247
else auth_header
246248
)
247249
logger.debug(
248-
f"UserTokenMiddleware: Path='{request.url.path}', AuthHeader='{mask_sensitive(auth_header)}', ParsedToken(masked)='{token_for_log}'"
250+
f"UserTokenMiddleware: Path='{request.url.path}', AuthHeader='{mask_sensitive(auth_header)}', ParsedToken(masked)='{token_for_log}', CloudId='{cloud_id_header}'"
249251
)
252+
253+
# Extract and save cloudId if provided
254+
if cloud_id_header and cloud_id_header.strip():
255+
request.state.user_atlassian_cloud_id = cloud_id_header.strip()
256+
logger.debug(
257+
f"UserTokenMiddleware: Extracted cloudId from header: {cloud_id_header.strip()}"
258+
)
259+
else:
260+
request.state.user_atlassian_cloud_id = None
261+
logger.debug(
262+
"UserTokenMiddleware: No cloudId header provided, will use global config"
263+
)
264+
250265
# Check for mcp-session-id header for debugging
251266
mcp_session_id = request.headers.get("mcp-session-id")
252267
if mcp_session_id:

src/mcp_atlassian/utils/environment.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,11 @@ def get_available_services() -> dict[str, bool | None]:
5959
logger.info(
6060
"Using Confluence Server/Data Center authentication (PAT or Basic Auth)"
6161
)
62-
# If confluence_url is not set, confluence_is_setup remains False
62+
elif os.getenv("ATLASSIAN_OAUTH_ENABLE", "").lower() in ("true", "1", "yes"):
63+
confluence_is_setup = True
64+
logger.info(
65+
"Using Confluence minimal OAuth configuration - expecting user-provided tokens via headers"
66+
)
6367

6468
jira_url = os.getenv("JIRA_URL")
6569
jira_is_setup = False
@@ -108,7 +112,11 @@ def get_available_services() -> dict[str, bool | None]:
108112
logger.info(
109113
"Using Jira Server/Data Center authentication (PAT or Basic Auth)"
110114
)
111-
# If jira_url is not set, jira_is_setup remains False
115+
elif os.getenv("ATLASSIAN_OAUTH_ENABLE", "").lower() in ("true", "1", "yes"):
116+
jira_is_setup = True
117+
logger.info(
118+
"Using Jira minimal OAuth configuration - expecting user-provided tokens via headers"
119+
)
112120

113121
if not confluence_is_setup:
114122
logger.info(

0 commit comments

Comments
 (0)