-
Notifications
You must be signed in to change notification settings - Fork 74
feat(mcp): always-on HTTP auth via typed build_mcp_auth with Airbyte Cloud defaults #1084
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Aaron ("AJ") Steers (aaronsteers)
wants to merge
25
commits into
main
Choose a base branch
from
devin/1784611568-cloud-mcp-airbyte-branded-auth
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 3 commits
Commits
Show all changes
25 commits
Select commit
Hold shift + click to select a range
feb9cec
feat(mcp): always-on HTTP auth with baked Airbyte Cloud defaults
devin-ai-integration[bot] 48efff2
fix(mcp): resolve JWT signing-key source so a static public key isn't…
devin-ai-integration[bot] 1d28bfa
feat(mcp): opt-in HTTP Basic client-credentials transport auth
devin-ai-integration[bot] c42c32b
Merge origin/main into devin/1784611568-cloud-mcp-airbyte-branded-auth
devin-ai-integration[bot] f5019b3
build(mcp): declare httpx and uvicorn as direct deps
devin-ai-integration[bot] f5e7af1
fix(mcp): check legacy OIDC env presence via membership, not value read
devin-ai-integration[bot] 2cf089f
fix(mcp): fail closed on token-exchange errors; per-credential locking
devin-ai-integration[bot] b3bb53c
fix(mcp): resolve CodeQL clear-text-logging alert; address Copilot re…
devin-ai-integration[bot] 866a368
docs(mcp): note httpx/uvicorn are directly-imported transitive deps
devin-ai-integration[bot] 938817d
refactor(mcp): bound credential cache, clarify Basic terminology, war…
devin-ai-integration[bot] 3b498a1
test(mcp): prefix unused test args with underscore (ARG)
devin-ai-integration[bot] c83a373
fix(mcp): treat blank auth env vars as unset so baked defaults apply
devin-ai-integration[bot] e6c19dc
fix(mcp): treat blank client-credentials token URL override as unset
devin-ai-integration[bot] d957db3
fix(mcp): harden expires_in coercion and align http_main server-url h…
devin-ai-integration[bot] eb022b1
docs(mcp): correct http_main auth-failure wording (blank falls back t…
devin-ai-integration[bot] c98f870
fix(mcp): read token cache clock inside per-credential lock
devin-ai-integration[bot] c6a062c
test(mcp): drop stale AIRBYTE_MCP_ENV_FILE before importing server
devin-ai-integration[bot] a2b530a
refactor(mcp): consume shared client-credentials middleware from fast…
devin-ai-integration[bot] f61d232
build(mcp): drop now-unused direct httpx dependency
devin-ai-integration[bot] f6ab1ba
test(mcp): restore AIRBYTE_MCP_ENV_FILE after server import
devin-ai-integration[bot] d2d47a8
refactor(mcp): drop legacy auth env-var migration warning
devin-ai-integration[bot] 26fc1d2
refactor(mcp): migrate cloud-mcp auth to typed build_mcp_auth on 0.14.0
devin-ai-integration[bot] 0c92ea8
docs(mcp): align auth docstring with typed build_mcp_auth (no env par…
devin-ai-integration[bot] 2a3472a
test(mcp): restore AIRBYTE_MCP_ENV_FILE via try/finally on import
devin-ai-integration[bot] f14290d
docs(mcp): fix stray apostrophe in fastmcp-extensions reference
devin-ai-integration[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,232 @@ | ||
| # Copyright (c) 2025 Airbyte, Inc., all rights reserved. | ||
| """Opt-in HTTP Basic client-credentials transport auth for the MCP server. | ||
|
|
||
| The headless bearer path verifies an already-minted, short-lived (~15 min) JWT. | ||
| That works for MCP clients that run the OAuth flow and refresh tokens | ||
| automatically, but not for a truly headless agent that can only set a *static* | ||
| `Authorization` header value and cannot re-mint on a timer. | ||
|
|
||
| This module bridges that gap, behind an opt-in flag. When enabled, the server | ||
| accepts the long-lived `client_id` / `client_secret` presented via standard HTTP | ||
| Basic auth (`Authorization: Basic base64(client_id:client_secret)`, the | ||
| `client_secret_basic` token-endpoint auth method), exchanges them for a | ||
| short-lived access token at the Airbyte token endpoint, and rewrites the request | ||
|
aaronsteers marked this conversation as resolved.
Outdated
|
||
| to `Authorization: Bearer <token>` so the existing `JWTVerifier` validates it | ||
| unchanged. The agent thus presents a durable credential once; the server owns | ||
| the short-lived-token churn. | ||
|
|
||
| The exchange runs as the outermost ASGI layer (ahead of FastMCP's auth | ||
| middleware) so the rewritten bearer header is what the verifier sees. Minted | ||
| tokens are cached per credential until shortly before expiry to avoid minting on | ||
| every request. A `Bearer` request, or any request when the flag is unset, passes | ||
| through untouched. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import base64 | ||
| import binascii | ||
| import hashlib | ||
| import logging | ||
| import os | ||
| import time | ||
| from typing import TYPE_CHECKING | ||
|
|
||
| import httpx | ||
|
|
||
|
|
||
| if TYPE_CHECKING: | ||
| from starlette.types import ASGIApp, Receive, Scope, Send | ||
|
|
||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| # Opt-in flag. Off by default: accepting long-lived credentials at the transport | ||
| # is a deliberate escalation, so a deployment must explicitly turn it on. | ||
| ALLOW_CLIENT_CREDENTIALS_ENV = "AIRBYTE_MCP_AUTH_ALLOW_CLIENT_CREDENTIALS" | ||
|
|
||
| # Airbyte token endpoint that mints an application access token from a | ||
| # `client_id` / `client_secret`. Defaults to Airbyte Cloud; overridable for | ||
| # self-hosted deployments pointing at their own Airbyte instance. | ||
| AIRBYTE_CLOUD_TOKEN_URL = "https://api.airbyte.com/v1/applications/token" | ||
| TOKEN_URL_ENV = "AIRBYTE_MCP_AUTH_CLIENT_CREDENTIALS_TOKEN_URL" | ||
|
|
||
| # Re-mint this many seconds before the cached token actually expires, so a token | ||
| # never lapses mid-request due to clock skew or in-flight latency. | ||
| _EXPIRY_SAFETY_MARGIN_SECONDS = 60 | ||
|
|
||
| _TRUTHY = frozenset({"1", "true", "t", "yes", "y", "on"}) | ||
|
|
||
|
|
||
| def client_credentials_enabled(env: os._Environ[str] | None = None) -> bool: | ||
|
aaronsteers marked this conversation as resolved.
Outdated
|
||
| """Return whether the opt-in HTTP Basic client-credentials grant is enabled.""" | ||
| source = env if env is not None else os.environ | ||
| return source.get(ALLOW_CLIENT_CREDENTIALS_ENV, "").strip().lower() in _TRUTHY | ||
|
|
||
|
|
||
| def _token_url() -> str: | ||
| """Return the token endpoint, defaulting to Airbyte Cloud.""" | ||
| return os.getenv(TOKEN_URL_ENV, AIRBYTE_CLOUD_TOKEN_URL) | ||
|
aaronsteers marked this conversation as resolved.
Outdated
|
||
|
|
||
|
|
||
| class ClientCredentialsExchangeMiddleware: | ||
| """ASGI middleware that exchanges HTTP Basic client credentials for a bearer. | ||
|
|
||
| Runs as the outermost layer so the rewritten `Authorization: Bearer` header | ||
| reaches FastMCP's auth verifier. Only `Basic` requests are touched; `Bearer` | ||
| and unauthenticated requests pass through unchanged (the latter are then | ||
| rejected by the verifier, preserving fail-closed behavior). | ||
| """ | ||
|
aaronsteers marked this conversation as resolved.
Outdated
|
||
|
|
||
| def __init__( | ||
| self, | ||
| app: ASGIApp, | ||
| *, | ||
| token_url: str, | ||
| expiry_margin_seconds: int = _EXPIRY_SAFETY_MARGIN_SECONDS, | ||
| ) -> None: | ||
| self._app = app | ||
| self._token_url = token_url | ||
| self._expiry_margin_seconds = expiry_margin_seconds | ||
| # Maps a per-credential cache key to `(access_token, expiry_epoch)`. | ||
|
aaronsteers marked this conversation as resolved.
Outdated
|
||
| self._token_cache: dict[str, tuple[str, float]] = {} | ||
| self._lock = asyncio.Lock() | ||
|
|
||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: | ||
| """Rewrite a Basic-auth HTTP request to Bearer, then delegate downstream.""" | ||
| if scope["type"] != "http": | ||
| await self._app(scope, receive, send) | ||
| return | ||
|
|
||
| credentials = _parse_basic_credentials(scope) | ||
| if credentials is None: | ||
| await self._app(scope, receive, send) | ||
| return | ||
|
|
||
| token = await self._token_for(credentials) | ||
| if token is None: | ||
| # Exchange failed (e.g. bad credentials): pass the request through | ||
| # unmodified so the verifier rejects it with a 401, rather than | ||
| # masking the failure here. | ||
| await self._app(scope, receive, send) | ||
| return | ||
|
|
||
| await self._app(_with_bearer(scope, token), receive, send) | ||
|
|
||
| async def _token_for(self, credentials: tuple[str, str]) -> str | None: | ||
| """Return a valid access token for the credentials, minting if needed.""" | ||
| client_id, client_secret = credentials | ||
| cache_key = _cache_key(client_id, client_secret) | ||
| now = time.monotonic() | ||
|
|
||
| async with self._lock: | ||
| cached = self._token_cache.get(cache_key) | ||
| if cached is not None and cached[1] > now: | ||
| return cached[0] | ||
|
|
||
| minted = await self._mint_token(client_id, client_secret) | ||
|
aaronsteers marked this conversation as resolved.
Outdated
|
||
| if minted is None: | ||
| return None | ||
|
|
||
| token, expires_in = minted | ||
| expiry = now + max(expires_in - self._expiry_margin_seconds, 0) | ||
| self._token_cache[cache_key] = (token, expiry) | ||
| return token | ||
|
|
||
| async def _mint_token(self, client_id: str, client_secret: str) -> tuple[str, float] | None: | ||
| """Exchange client credentials for `(access_token, expires_in)` or `None`. | ||
|
|
||
| Returns `None` when the token endpoint rejects the credentials or omits | ||
| an access token; never logs the credentials or the minted token. | ||
| """ | ||
| async with httpx.AsyncClient(timeout=10.0) as client: | ||
| response = await client.post( | ||
| self._token_url, | ||
| json={"client_id": client_id, "client_secret": client_secret}, | ||
| headers={"Content-Type": "application/json"}, | ||
| ) | ||
|
|
||
| if response.status_code != httpx.codes.OK: | ||
| logger.warning( | ||
| "Client-credentials token exchange failed (HTTP %d); passing " | ||
| "request through for the verifier to reject.", | ||
| response.status_code, | ||
| ) | ||
| return None | ||
|
|
||
| payload = response.json() | ||
| access_token = payload.get("access_token") | ||
| if not access_token: | ||
| logger.warning( | ||
| "Client-credentials token exchange returned no access token; " | ||
| "passing request through for the verifier to reject." | ||
| ) | ||
| return None | ||
|
|
||
| # `expires_in` is advisory; fall back to a conservative default so a | ||
| # missing field can't produce a non-expiring cache entry. | ||
| expires_in = payload.get("expires_in") | ||
| return access_token, float(expires_in) if expires_in else 0.0 | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
aaronsteers marked this conversation as resolved.
Outdated
aaronsteers marked this conversation as resolved.
Outdated
|
||
|
|
||
|
|
||
| def _parse_basic_credentials(scope: Scope) -> tuple[str, str] | None: | ||
| """Return `(client_id, client_secret)` from a Basic `Authorization` header. | ||
|
|
||
| Returns `None` when the header is absent, uses a non-Basic scheme, or is | ||
| malformed, so the request passes through for the verifier to handle. | ||
| """ | ||
| for name, value in scope.get("headers", []): | ||
| if name == b"authorization": | ||
| return _decode_basic(value) | ||
| return None | ||
|
|
||
|
|
||
| def _decode_basic(header_value: bytes) -> tuple[str, str] | None: | ||
| """Decode a `Basic <base64(client_id:client_secret)>` header value.""" | ||
| scheme, _, encoded = header_value.partition(b" ") | ||
| if scheme.lower() != b"basic" or not encoded: | ||
| return None | ||
| try: | ||
| decoded = base64.b64decode(encoded, validate=True).decode("utf-8") | ||
| except (binascii.Error, UnicodeDecodeError): | ||
| return None | ||
| client_id, sep, client_secret = decoded.partition(":") | ||
| if not sep: | ||
| return None | ||
| return client_id, client_secret | ||
|
|
||
|
|
||
| def _cache_key(client_id: str, client_secret: str) -> str: | ||
| """Return a stable, non-reversible cache key for a credential pair. | ||
|
|
||
| Hashes the secret so plaintext credentials never sit in the cache dict. | ||
| """ | ||
| return hashlib.sha256(f"{client_id}:{client_secret}".encode()).hexdigest() | ||
|
|
||
|
|
||
| def _with_bearer(scope: Scope, token: str) -> Scope: | ||
| """Return a shallow copy of `scope` with `Authorization` set to `Bearer`.""" | ||
| headers = [(name, value) for name, value in scope["headers"] if name != b"authorization"] | ||
| headers.append((b"authorization", b"Bearer " + token.encode("ascii"))) | ||
| new_scope = dict(scope) | ||
| new_scope["headers"] = headers | ||
| return new_scope | ||
|
|
||
|
|
||
| def wrap_if_enabled(app: ASGIApp) -> ASGIApp: | ||
| """Wrap `app` with the client-credentials exchange when the flag is set. | ||
|
|
||
| Returns `app` unchanged when the opt-in flag is unset, so the standard | ||
| bearer/OIDC transport auth is the only path. When enabled, returns `app` | ||
| wrapped as the outermost ASGI layer so the Basic-to-Bearer rewrite happens | ||
| before FastMCP's auth verifier runs. | ||
| """ | ||
| if not client_credentials_enabled(): | ||
| return app | ||
| logger.info( | ||
| "HTTP Basic client-credentials transport auth is enabled (%s); the " | ||
| "server will exchange presented client credentials for bearer tokens.", | ||
| ALLOW_CLIENT_CREDENTIALS_ENV, | ||
| ) | ||
| return ClientCredentialsExchangeMiddleware(app, token_url=_token_url()) | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.