first
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
"""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),
|
||||
)
|
||||
Reference in New Issue
Block a user