144 lines
5.4 KiB
Python
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),
|
|
)
|