Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions airbyte/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,14 @@
OIDC_CLIENT_SECRET_ENV = "AIRBYTE_MCP_OIDC_CLIENT_SECRET"
OIDC_CONFIG_URL_ENV = "AIRBYTE_MCP_OIDC_CONFIG_URL"

# Space-separated upstream authorize scopes requested for the interactive OIDC
# flow, also advertised to clients via DCR/`.well-known` and enforced on the
# verified upstream token. `openid` is required for OIDC: without it the IdP may
# issue an identity-only token that downstream APIs reject. Defaults to the
# standard OIDC set; a deployment can override for a realm needing other scopes.
OIDC_SCOPES_ENV = "AIRBYTE_MCP_OIDC_SCOPES"
DEFAULT_OIDC_SCOPES = "openid email profile"
Comment thread
aaronsteers marked this conversation as resolved.
Outdated

# Headless JWT verifier. A signing-key source (`JWKS_URI_ENV` or
# `JWT_PUBLIC_KEY_ENV`) activates it; issuer/audience/algorithm refine it.
JWKS_URI_ENV = "AIRBYTE_MCP_AUTH_JWKS_URI"
Expand Down Expand Up @@ -185,6 +193,23 @@ def _env_or_default(name: str, default: str) -> str:
return value or default


def _resolve_oidc_scopes() -> list[str]:
"""Return the interactive OIDC scopes, always including `openid` first.

Reads the space-separated `AIRBYTE_MCP_OIDC_SCOPES` override (defaulting to
`DEFAULT_OIDC_SCOPES`) and guarantees `openid` is present: without it the IdP
may issue an identity-only token that downstream APIs reject, so an override
that omits it would silently recreate that failure. Custom scopes are
preserved in order, deduplicated, with `openid` guaranteed at the front.
"""
configured = _env_or_default(OIDC_SCOPES_ENV, DEFAULT_OIDC_SCOPES).split()
scopes = ["openid"]
for scope in configured:
if scope not in scopes:
scopes.append(scope)
return scopes


def _resolve_client_storage(*, encryption_source_material: str) -> AsyncKeyValue | None:
"""Resolve the durable `OIDCProxy` OAuth-state store, if one is configured.

Expand Down Expand Up @@ -278,6 +303,7 @@ def _create_auth() -> AuthProvider | None:
client_id=oidc_client_id,
client_secret=oidc_client_secret,
base_url=base_url,
required_scopes=_resolve_oidc_scopes(),
client_storage=_resolve_client_storage(encryption_source_material=oidc_client_secret),
)

Expand Down
36 changes: 36 additions & 0 deletions tests/unit_tests/test_mcp_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
server.OIDC_CLIENT_ID_ENV,
server.OIDC_CLIENT_SECRET_ENV,
server.OIDC_CONFIG_URL_ENV,
server.OIDC_SCOPES_ENV,
server.OIDC_CLIENT_STORAGE_FACTORY_ENV,
server.JWKS_URI_ENV,
server.JWT_PUBLIC_KEY_ENV,
Expand Down Expand Up @@ -185,10 +186,45 @@ def test_create_auth_activates_oidc_when_credentials_present(
assert oidc.client_id == "cid"
assert oidc.client_secret == "csecret"
assert oidc.config_url == "https://idp.example/.well-known"
# `openid` must be requested upstream, else the IdP may issue an
# identity-only token that downstream APIs reject.
assert oidc.required_scopes == ["openid", "email", "profile"]
Comment thread
aaronsteers marked this conversation as resolved.
# No storage factory configured -> in-memory default.
assert oidc.client_storage is None


def test_create_auth_honors_oidc_scopes_override(monkeypatch: MonkeyPatch) -> None:
"""A realm needing different scopes can override the default via env."""
_clear_all_auth_env(monkeypatch)
monkeypatch.setenv(server.OIDC_CLIENT_ID_ENV, "cid")
monkeypatch.setenv(server.OIDC_CLIENT_SECRET_ENV, "csecret")
monkeypatch.setenv(server.OIDC_CONFIG_URL_ENV, "https://idp.example/.well-known")
monkeypatch.setenv(server.OIDC_SCOPES_ENV, "openid custom-api")
captured = _capture_build_mcp_auth(monkeypatch)
server._create_auth()

oidc = captured["oidc"]
assert isinstance(oidc, OIDCAuthConfig)
assert oidc.required_scopes == ["openid", "custom-api"]


def test_create_auth_forces_openid_when_override_omits_it(
monkeypatch: MonkeyPatch,
) -> None:
"""An override missing `openid` still gets it, so the token stays API-usable."""
_clear_all_auth_env(monkeypatch)
monkeypatch.setenv(server.OIDC_CLIENT_ID_ENV, "cid")
monkeypatch.setenv(server.OIDC_CLIENT_SECRET_ENV, "csecret")
monkeypatch.setenv(server.OIDC_CONFIG_URL_ENV, "https://idp.example/.well-known")
monkeypatch.setenv(server.OIDC_SCOPES_ENV, "custom-api")
captured = _capture_build_mcp_auth(monkeypatch)
server._create_auth()

oidc = captured["oidc"]
assert isinstance(oidc, OIDCAuthConfig)
assert oidc.required_scopes == ["openid", "custom-api"]


def test_create_auth_oidc_without_config_url_raises(monkeypatch: MonkeyPatch) -> None:
"""OIDC credentials without a discovery URL fail clearly, naming the env var."""
_clear_all_auth_env(monkeypatch)
Expand Down
Loading