| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| """Keyless CI/CD authentication via OIDC token exchange ("Trusted Publishers"). |
| |
| A CI job proves its identity to the Hub with a short-lived OIDC id token minted by its CI |
| provider (e.g. GitHub Actions), then exchanges it at ``POST {ENDPOINT}/oauth/token`` (RFC 8693) |
| for a short-lived Hugging Face token — no long-lived ``HF_TOKEN`` secret to store. |
| |
| This module is self-contained: it only handles minting the provider id token and the exchange. |
| It deliberately does not register a public API or a CLI verb; the integration point is the token |
| resolution in ``utils/_auth.py`` (see ``_get_token_from_oidc``). |
| |
| Docs: https://huggingface.co/docs/hub/trusted-publishers |
| """ |
|
|
| import os |
| from enum import Enum |
|
|
| from . import constants |
| from .errors import OIDCError |
| from .utils import get_session, hf_raise_for_status |
|
|
|
|
| |
| _TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" |
| _ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token" |
|
|
|
|
| class Provider(str, Enum): |
| """CI providers that can mint an OIDC id token natively. GitHub Actions only for now.""" |
|
|
| GITHUB = "github" |
|
|
|
|
| def detect_provider() -> Provider | None: |
| """Detect the CI provider able to mint an OIDC id token, or `None` if not in a supported CI.""" |
| if os.environ.get("GITHUB_ACTIONS") == "true": |
| return Provider.GITHUB |
| return None |
|
|
|
|
| def _get_github_oidc_token(audience: str) -> str: |
| """Mint an OIDC id token from the GitHub Actions runtime. |
| |
| Relies on the `ACTIONS_ID_TOKEN_REQUEST_URL` / `ACTIONS_ID_TOKEN_REQUEST_TOKEN` env vars, |
| which GitHub only injects when the job declares `permissions: id-token: write`. |
| """ |
| request_url = os.environ.get("ACTIONS_ID_TOKEN_REQUEST_URL") |
| request_token = os.environ.get("ACTIONS_ID_TOKEN_REQUEST_TOKEN") |
| if not request_url or not request_token: |
| raise OIDCError( |
| "Cannot request an OIDC id token from GitHub Actions. Make sure the workflow job sets " |
| "`permissions: id-token: write`. See " |
| "https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect" |
| ) |
| response = get_session().get( |
| request_url, |
| params={"audience": audience}, |
| headers={"Authorization": f"Bearer {request_token}"}, |
| ) |
| hf_raise_for_status(response) |
| return response.json()["value"] |
|
|
|
|
| def get_oidc_token(*, provider: Provider | str | None = None, audience: str | None = None) -> str: |
| """Mint a raw OIDC id token (JWT) from the current CI provider. |
| |
| Args: |
| provider (`str`, *optional*): |
| CI provider to use. Auto-detected from the environment when omitted. |
| audience (`str`, *optional*): |
| The `aud` claim to request. Defaults to `constants.ENDPOINT` so it matches the endpoint |
| that validates it (respects `HF_ENDPOINT`/staging). |
| |
| Returns: |
| `str`: The raw id token (JWT) to pass to [`exchange_oidc_token`]. |
| """ |
| audience = audience or constants.ENDPOINT |
| provider = provider or detect_provider() |
| supported = ", ".join(p.value for p in Provider) |
| if provider is None: |
| raise OIDCError(f"No supported CI OIDC provider detected. Trusted Publishers currently supports: {supported}.") |
| if provider == Provider.GITHUB: |
| return _get_github_oidc_token(audience) |
| raise NotImplementedError(f"OIDC provider '{provider}' is not supported yet. Supported: {supported}.") |
|
|
|
|
| def exchange_oidc_token(*, subject_token: str, resource: str, endpoint: str | None = None) -> dict: |
| """Exchange a CI OIDC id token for a short-lived Hugging Face token (RFC 8693). |
| |
| Args: |
| subject_token (`str`): |
| The raw OIDC id token (JWT) from the CI provider. Its `aud` claim must be the Hub URL. |
| resource (`str`): |
| What to scope the token to: a Hub repo (`namespace/name`, `datasets/namespace/name`, |
| `spaces/namespace/name`, `kernels/namespace/name`) for a write token, or a bare Hub |
| username for a read-only `gated-repos` token. |
| endpoint (`str`, *optional*): |
| Hub endpoint. Defaults to `constants.ENDPOINT` (respects `HF_ENDPOINT`/staging). |
| |
| Returns: |
| `dict`: The token-exchange response, e.g. |
| `{"access_token": "hf_jwt_…", "token_type": "bearer", "expires_in": 3600, ...}`. |
| """ |
| response = get_session().post( |
| f"{endpoint or constants.ENDPOINT}/oauth/token", |
| json={ |
| "grant_type": _TOKEN_EXCHANGE_GRANT_TYPE, |
| "subject_token_type": _ID_TOKEN_TYPE, |
| "subject_token": subject_token, |
| "resource": resource, |
| }, |
| ) |
| hf_raise_for_status(response) |
| return response.json() |
|
|
|
|
| def oidc_login( |
| *, |
| resource: str, |
| subject_token: str | None = None, |
| provider: Provider | str | None = None, |
| audience: str | None = None, |
| endpoint: str | None = None, |
| ) -> dict: |
| """Mint a CI OIDC id token and exchange it for a Hugging Face token. |
| |
| Convenience wrapper around [`get_oidc_token`] + [`exchange_oidc_token`]. Returns the raw |
| exchange response (it does not persist anything — the caller decides what to do with the token). |
| |
| Args: |
| resource (`str`): |
| Repo or username to scope the token to. See [`exchange_oidc_token`]. |
| subject_token (`str`, *optional*): |
| A pre-minted OIDC id token to exchange directly. Use this for CI providers not yet |
| supported natively (e.g. GitLab): mint the id token in your job and pass it here. When |
| omitted, the token is minted from the detected `provider`. |
| provider (`str`, *optional*): |
| CI provider. Auto-detected when omitted. Ignored when `subject_token` is provided. |
| audience (`str`, *optional*): |
| The `aud` claim to request. Defaults to the resolved `endpoint`, so it matches the |
| endpoint that validates it. |
| endpoint (`str`, *optional*): |
| Hub endpoint. Defaults to `constants.ENDPOINT`. |
| |
| Returns: |
| `dict`: The token-exchange response (`access_token`, `token_type`, `expires_in`, ...). |
| """ |
| endpoint = endpoint or constants.ENDPOINT |
| if subject_token is None: |
| subject_token = get_oidc_token(provider=provider, audience=audience or endpoint) |
| return exchange_oidc_token(subject_token=subject_token, resource=resource, endpoint=endpoint) |
|
|