Skip to content

af-credentials

Backend-side client for the AF MCP platform's broker-issued credentials (issue

112), covering both x509/VOMS proxies and krb5 tickets. Import package:

af_credentials. No dependency on af_mcp_broker, FastAPI, or Kubernetes — this is meant to be embedded in other MCP backends that need to trust the broker (ami-mcp's broker mode today, later rucio-mcp), so it stays deliberately thin: pyjwt[crypto] and httpx2 at runtime, mcp>=2.0.0,<3 opt-in via the [mcp] extra.

Installation

pip install af-credentials

With the optional mcp SDK adapter (af_credentials.mcp):

pip install af-credentials[mcp]

Or with pixi:

pixi add af-credentials

BrokerTokenVerifier (af_credentials.verifier)

Verifies an AF Broker Identity Token — the RS256 identity assertion af_mcp_broker.credentials.broker_issued.BrokerTokenIssuer mints for AF-native backends (see the platform's docs/auth.md, "AF Broker Identity Token"). The claim set is exactly iss/sub/aud/exp/iat/jti, plus uid/gid/unixname only when the issuing broker's target config requested POSIX identity — never a capability or group claim.

from af_credentials.verifier import BrokerTokenVerifier

verifier = BrokerTokenVerifier(
    jwks_url="https://mcp.af.uchicago.edu/.well-known/jwks.json",
    issuer="https://mcp.af.uchicago.edu",
    audience="ami-mcp",
)

claims = await verifier.verify(token)
if claims is None:
    ...  # not authenticated: bad signature, wrong iss/aud, expired, ...
else:
    claims.sub, claims.jti, claims.exp  # always present
    claims.uid, claims.gid, claims.unixname  # None unless this token carries POSIX identity

JWKS keys are cached in-process for cache_ttl seconds (default 300), keyed by kid. A token whose kid isn't in the current cache triggers exactly one refetch, to pick up a key rotated in since the last fetch (see the platform's key-rotation procedure) — if the refetched JWKS still doesn't carry that kid, verification fails without fetching again.

verify() returns None for every way a token can be invalid (bad signature, wrong issuer/audience, expired, malformed, unknown key), so callers can treat "not authenticated" uniformly. It does not catch transport failures — a JWKS fetch that can't connect, times out, or gets a non-2xx response raises the underlying httpx2 exception, so a caller can tell "the broker is unreachable" apart from "this token is bad" and respond accordingly (e.g. a 503 vs. a 401).

mcp_token_verifier() (af_credentials.mcp, requires the [mcp] extra)

Adapts a BrokerTokenVerifier to the mcp SDK's TokenVerifier protocol, for wiring an AF Broker Identity Token straight into a FastMCP/mcp server's auth configuration:

from af_credentials.mcp import mcp_token_verifier

token_verifier = mcp_token_verifier(
    verifier
)  # implements mcp.server.auth.provider.TokenVerifier

verify_token(token) returns AccessToken(token=token, client_id=claims.sub, scopes=[], expires_at=claims.exp) or None. scopes is always empty — the token itself carries no authorization claims, so a server wanting authorization must resolve it from client_id (the token's sub) itself, not from this adapter's output.

ProxyClient (af_credentials.proxy)

Redeems a brokered x509/VOMS proxy, krb5 ticket, or ServiceX access token. ProxyClient takes a kind: Literal["x509", "krb5", "servicex"] = "x509" constructor parameter that selects which credential the client redeems; the redeem endpoint is live in the broker (see "The redeem contract" below).

from af_credentials.proxy import ProxyClient, ProxyNotAvailableError, ProxyRedeemError

client = ProxyClient("https://mcp.af.uchicago.edu")  # kind="x509" by default

try:
    with await client.proxy_file(bearer_token) as handle:
        # handle.path   -> Path to a private 0600 PEM file (proxy cert + key)
        # handle.dn     -> VOMS proxy subject DN
        # handle.expires_at -> datetime
        # handle.nickname -> CERN/VOMS nickname, or None if extraction failed
        # handle.voms_attributes -> list[str] of VOMS FQANs
        run_subprocess(env={"X509_USER_PROXY": str(handle.path)})
    # file is deleted here, on __exit__
except ProxyNotAvailableError:
    ...  # no proxy available for this caller right now (no linked .globus,
    # or the broker's own cached proxy is too close to expiry)
except ProxyRedeemError as exc:
    ...  # the broker rejected/failed the call; exc.status_code, exc.detail

Use pem_bytes(bearer_token) instead of proxy_file() when the caller wants the PEM material in-memory rather than as a file.

For krb5 tickets, construct the client with kind="krb5" and use ticket_file()/ccache_bytes() instead — proxy_file()/pem_bytes() raise ValueError on a kind="krb5" client, and ticket_file()/ccache_bytes() raise the same kind of guard error on a kind="x509" client:

from af_credentials.proxy import ProxyClient, ProxyNotAvailableError, ProxyRedeemError

client = ProxyClient("https://mcp.af.uchicago.edu", kind="krb5")

try:
    with await client.ticket_file(bearer_token) as handle:
        # handle.path        -> Path to a private 0600 ccache file
        # handle.principal   -> krb5 principal, e.g. "gstark@CERN.CH"
        # handle.realm       -> krb5 realm, e.g. "CERN.CH"
        # handle.expires_at  -> datetime
        # handle.renew_until -> datetime, or None if not renewable
        run_subprocess(env={"KRB5CCNAME": str(handle.path)})
    # file is deleted here, on __exit__
except ProxyNotAvailableError:
    ...  # no ticket available for this caller right now (no linked krb5-token
    # identity, or the broker's own cached ticket is too close to expiry)
except ProxyRedeemError as exc:
    ...  # the broker rejected/failed the call; exc.status_code, exc.detail

Use ccache_bytes(bearer_token) instead of ticket_file() when the caller wants the ccache material in-memory rather than as a file.

For a ServiceX access token, construct the client with kind="servicex" and use access_token() — there is no file to materialize (it's a bearer token, not key material), so this returns a ServiceXAccessToken directly rather than a context-managed handle:

from af_credentials.proxy import ProxyClient, ProxyNotAvailableError, ProxyRedeemError

client = ProxyClient("https://mcp.af.uchicago.edu", kind="servicex")

try:
    token = await client.access_token(bearer_token)
    # token.access_token -> the short-lived ServiceX access token (str)
    # token.expires_at   -> datetime
except ProxyNotAvailableError:
    ...  # no ServiceX refresh token linked for this caller right now, or
    # the broker's own redeemed token is too close to expiry
except ProxyRedeemError as exc:
    ...  # the broker rejected/failed the call; exc.status_code, exc.detail

The redeem contract

POST {broker_url}/v1/credentials/{kind}/redeem
Authorization: Bearer <token>
Content-Type: application/json

{}

where {kind} is x509, krb5, or servicex, matching the ProxyClient's own kind.

A 200 response for kind="x509":

{
  "pem": "<PEM-encoded proxy certificate + key>",
  "dn": "<VOMS proxy subject DN>",
  "voms_attributes": ["<VOMS FQAN>", "..."],
  "expires_at": "<ISO-8601 timestamp>",
  "remaining_seconds": 3600,
  "nickname": "<CERN/VOMS nickname attribute, or null if extraction failed>"
}

and for kind="krb5":

{
  "ccache_b64": "<base64-encoded ccache file contents>",
  "principal": "<krb5 principal, e.g. gstark@CERN.CH>",
  "realm": "<krb5 realm, e.g. CERN.CH>",
  "expires_at": "<ISO-8601 timestamp>",
  "remaining_seconds": 3600,
  "renew_until": "<ISO-8601 timestamp, or null if not renewable>"
}

and for kind="servicex":

{
  "access_token": "<short-lived ServiceX access token>",
  "expires_at": "<ISO-8601 timestamp>",
  "remaining_seconds": 3600
}

The following applies identically to all three kinds, via the same _redeem() call:

  • 404ProxyNotAvailableError(detail) — the response's detail field (or raw body if not JSON) is the exception's .detail.
  • Any other non-200 → ProxyRedeemError(status_code, detail).
  • A 200 response whose remaining_seconds is below the client's min_remaining (default 60s) is also treated as ProxyNotAvailableError — the broker caches the credential itself, so a caller who retried "the credential I just got" would just get the same near-expired proxy or ticket back.

ProxyClient never caches handles across calls — every proxy_file()/ pem_bytes()/ticket_file()/ccache_bytes()/access_token() call redeems fresh (the broker is expected to be the one doing the caching). Materialized files live under a private, 0700 directory created lazily on first use and reused for the lifetime of the ProxyClient instance; each file inside it is written 0600 (access_token() writes no file at all).


See Contributing for development setup.