Files
meta/app/credentials.py
JiriUhlir a7cb53e0e6 first
2026-07-20 07:36:55 +02:00

144 lines
5.4 KiB
Python

"""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 <token>`` 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 <token>`` 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 "
"<token>'. 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 <token>' 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),
)