gh_oidc.py (3126B)
1 """ 2 Verifies GitHub Actions OIDC ID tokens so the backend can trust *who* is 3 pushing without any shared secret living in contributor repos. 4 5 Why this instead of a PAT/App token per contributor: 6 - The token is minted per-workflow-run by GitHub itself and is only valid 7 for ~5 minutes. 8 - Its `repository_owner` / `repository` / `ref` claims are set by GitHub's 9 own infrastructure from the run's real context - a workflow author cannot 10 edit their own workflow YAML to make GitHub sign a token claiming to be a 11 different repo or a different owner. 12 - We never see (or need) a long-lived credential from the contributor. 13 14 Docs: https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect 15 """ 16 17 import os 18 import time 19 import httpx 20 import jwt 21 from jwt import PyJWKClient 22 from fastapi import HTTPException 23 24 GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com" 25 JWKS_URL = f"{GITHUB_OIDC_ISSUER}/.well-known/jwks" 26 # The `audience` your workflows request in core.getIDToken(...). Self-hosters: set OIDC_AUDIENCE 27 # to your own CDN hostname. The default keeps the original deployment working unchanged. 28 EXPECTED_AUDIENCE = os.getenv("OIDC_AUDIENCE", "cdn.amit.is-a.dev") 29 30 _jwks_client = None 31 _jwks_client_ts = 0 32 _JWKS_CLIENT_TTL = 3600 33 34 35 def _get_jwks_client() -> PyJWKClient: 36 global _jwks_client, _jwks_client_ts 37 now = time.time() 38 if _jwks_client is None or now - _jwks_client_ts > _JWKS_CLIENT_TTL: 39 _jwks_client = PyJWKClient(JWKS_URL) 40 _jwks_client_ts = now 41 return _jwks_client 42 43 44 def verify_actions_token(token: str) -> dict: 45 """Verify a GitHub Actions OIDC token and return its trusted claims. 46 47 Raises HTTPException(401) if the token is missing, expired, wrongly 48 scoped, or fails signature verification. 49 """ 50 if not token: 51 raise HTTPException(status_code=401, detail="Missing GitHub Actions OIDC token.") 52 53 try: 54 signing_key = _get_jwks_client().get_signing_key_from_jwt(token) 55 claims = jwt.decode( 56 token, 57 signing_key.key, 58 algorithms=["RS256"], 59 audience=EXPECTED_AUDIENCE, 60 issuer=GITHUB_OIDC_ISSUER, 61 options={"require": ["exp", "iat", "repository", "repository_owner"]}, 62 ) 63 except jwt.PyJWTError as e: 64 raise HTTPException(status_code=401, detail=f"Invalid GitHub Actions token: {e}") 65 66 repository = claims.get("repository", "") 67 owner = claims.get("repository_owner", "") 68 if "/" not in repository: 69 raise HTTPException(status_code=401, detail="Malformed token claims.") 70 71 repo_owner_from_full, repo_name = repository.split("/", 1) 72 if repo_owner_from_full != owner or not owner or not repo_name: 73 raise HTTPException(status_code=401, detail="Token owner/repository mismatch.") 74 75 return { 76 "owner": owner, 77 "owner_id": str(claims.get("repository_owner_id", "")), 78 "repo": repo_name, 79 "actor": claims.get("actor", ""), 80 "ref": claims.get("ref", ""), 81 "workflow_ref": claims.get("job_workflow_ref", ""), 82 }