"""Per-request credential extraction from X- headers (FastAPI dependencies). The service is a STATELESS proxy: it stores no secrets. Every credential is supplied per request as an X- header and used only to talk to the Meta Graph API (see AGENTS.md "Secrets v parametrech"). Declaring the headers as FastAPI ``Header`` parameters makes them appear per-operation in Swagger, including the "Try it out" form. Headers: * ``X-Meta-Access-Token`` - required. A Business Manager **System User** token is the recommended kind: it does not die when an employee leaves and (when generated without an expiry) does not need refreshing. A user OAuth token works identically here - refreshing it stays on the caller's side. As an equivalent alternative the token may arrive in the standard ``Authorization: Bearer `` header; ``X-Meta-Access-Token`` wins if both are present. * ``X-Meta-App-Secret`` - optional but strongly recommended. When present the proxy computes ``appsecret_proof`` (HMAC-SHA256 of the access token, keyed with the app secret) and sends it upstream. Meta requires this for server-side calls whenever the app has "Require app secret proof for server API calls" enabled; without it those calls fail with OAuth error 100. * ``X-Meta-Api-Version`` - optional per-request Graph version override (e.g. ``v25.0``), so bumping the Graph version needs no deploy here. """ from __future__ import annotations import hashlib import hmac import re from dataclasses import dataclass from fastapi import Header from . import config from .errors import MissingCredentialsError # Graph versions look like "v25.0". Validated so a typo fails fast here with a # clear message instead of producing a 404 from a nonsense upstream URL. _VERSION_RE = re.compile(r"^v\d+\.\d+$") @dataclass class MetaCredentials: access_token: str app_secret: str | None api_version: str def appsecret_proof(self) -> str | None: """HMAC-SHA256 of the access token keyed with the app secret. Returns None when no app secret was supplied. The proof is derived per request and never cached or logged. """ if not self.app_secret: return None return hmac.new( self.app_secret.encode("utf-8"), self.access_token.encode("utf-8"), hashlib.sha256, ).hexdigest() def _bearer_from_authorization(authorization: str | None) -> str | None: """Extract the token from a standard ``Authorization: Bearer `` header. Only the ``Bearer`` scheme is accepted; any other scheme (e.g. ``Basic``) is ignored so the caller falls through and gets a clear "no credentials" error rather than a token that cannot work. """ if not authorization: return None parts = authorization.strip().split(None, 1) if len(parts) == 2 and parts[0].lower() == "bearer": return parts[1].strip() or None return None def _resolve_api_version(raw: str | None) -> str: """Pick the Graph version for this request: header override, else default.""" version = (raw or "").strip() if not version: return config.META_API_VERSION # Accept "25.0" as well as "v25.0" - the leading v is easy to forget. if not version.startswith("v"): version = f"v{version}" if not _VERSION_RE.match(version): raise MissingCredentialsError( f"X-Meta-Api-Version '{version}' is not a valid Graph API version " "(expected e.g. 'v25.0')." ) if config.META_ALLOWED_API_VERSIONS and version not in config.META_ALLOWED_API_VERSIONS: raise MissingCredentialsError( f"X-Meta-Api-Version '{version}' is not allowed. Allowed versions: " + ", ".join(config.META_ALLOWED_API_VERSIONS) ) return version def get_meta_credentials( x_meta_access_token: str | None = Header( default=None, alias="X-Meta-Access-Token", description="Meta access token. A Business Manager System User token is " "recommended (long-lived, survives staff changes). Takes precedence over " "the Authorization header.", ), authorization: str | None = Header( default=None, alias="Authorization", description="Standard bearer token, sent as 'Authorization: Bearer " "'. Equivalent alternative to X-Meta-Access-Token, which wins if " "both are present.", ), x_meta_app_secret: str | None = Header( default=None, alias="X-Meta-App-Secret", description="Optional Meta app secret. When supplied the proxy computes " "and sends appsecret_proof, which Meta requires if the app has 'Require " "app secret proof for server API calls' enabled.", ), x_meta_api_version: str | None = Header( default=None, alias="X-Meta-Api-Version", description=f"Optional Graph API version override, e.g. 'v25.0'. " f"Defaults to {config.META_API_VERSION}.", ), ) -> MetaCredentials: token = (x_meta_access_token or "").strip() or None if token is None: token = _bearer_from_authorization(authorization) if not token: raise MissingCredentialsError( "Provide X-Meta-Access-Token or an 'Authorization: Bearer ' header." ) return MetaCredentials( access_token=token, app_secret=(x_meta_app_secret or "").strip() or None, api_version=_resolve_api_version(x_meta_api_version), )