File size: 7,098 Bytes
76d6ddf | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 | # Copyright 2026 The HuggingFace Team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""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
# RFC 8693 token-exchange grant + id-token subject type (see trusted-publishers docs).
_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)
|