Skip to main content

af-credentials v0.3.1

Actions Status Documentation Status

PyPI version PyPI platforms

GitHub Discussion

Coverage

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

Requirements

  • Python 3.10+
  • An AF MCP broker publishing a JWKS endpoint (for BrokerTokenVerifier)

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
        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:

  • 404 → ProxyNotAvailableError(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).

Development

git clone https://github.com/maniaclab/af-credentials
cd af-credentials
pixi install
pixi run pre-commit-install
pixi run test          # run tests
pixi run lint           # pre-commit + pylint
pixi run build          # build sdist + wheel
pixi run docs-serve     # build and serve docs locally

Metadata

Release files for af-credentials 0.3.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for af-credentials 0.3.1
File Size Uploaded
af_credentials-0.3.1.tar.gz 82.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for af-credentials 0.3.1
File Interpreter ABI Platform
af_credentials-0.3.1-py3-none-any.whl Python 3 none any Details

Total release size: 101.2 kB

Release files / af_credentials-0.3.1.tar.gz

Download URL af_credentials-0.3.1.tar.gz
Size 82.4 kB
Tags Source
SHA-256 checksum
How to use checksums
a606d8a4a1219359e64a2528ba8d9c3e3a798c682b020e71e3b9d900c46f6d13
BLAKE2b-256 checksum
How to use checksums
376bf0ad419f8581edd6836418b9a131843b6006d9359839f5a40f3c4a551807
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.

Transparency log

Release files / af_credentials-0.3.1-py3-none-any.whl

Download URL af_credentials-0.3.1-py3-none-any.whl
Size 18.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7d6b2c72b78c9b03c7db90bd07a728880673f1f6bca792483a8ce58df8b4550b
BLAKE2b-256 checksum
How to use checksums
18b9390f1fc1d95ac3bc9cb2ac77dc7668916497e50edb53268aeb992e6367cb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.

Transparency log

Release history Release notifications | RSS feed

0.3.2

2 release files

This release

0.3.1 This release

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page