Files
2026-06-22 05:26:20 +02:00

278 lines
10 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 upstream 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.
All three Google services share the same OAuth mechanism (token wins over
service account); they differ only in the OAuth *scope* and the header prefix:
* Google Analytics -> ``X-GA-*`` (scope analytics.readonly)
* Search Console -> ``X-GSC-*`` (scope webmasters.readonly)
* Google Ads -> ``X-GAds-*`` (scope adwords) + developer token
Each provides ``*-Access-Token`` (ready Bearer token, takes precedence) and
``*-Credentials`` (base64 service-account JSON; the proxy mints a token). As an
alternative, the ready access token may also be supplied via the standard
``Authorization: Bearer <token>`` header (the service-specific ``*-Access-Token``
wins if both are present); this keeps the original header path intact while
offering the conventional bearer-token path.
Sklik uses ``X-Sklik-Token`` (the proxy calls client.loginByToken).
"""
from __future__ import annotations
import base64
import binascii
import json
from dataclasses import dataclass
from fastapi import Header
from . import config
from .errors import MissingCredentialsError
# --- Google (shared) ----------------------------------------------------------
@dataclass
class GoogleCredentials:
access_token: str | None
service_account_info: dict | None
quota_project: str | None
scope: str
@dataclass
class GoogleAdsCredentials:
google: GoogleCredentials
developer_token: str
login_customer_id: str | None
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 to the other credential sources 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 _authorization_description(token_header: str) -> str:
return (
"Standard OAuth2 bearer token, sent as 'Authorization: Bearer <token>'. "
f"Alternative to {token_header} (which wins if both are present) and to "
"the service-account credentials header. Must carry the service's scope."
)
def _build_google_credentials(
access_token: str | None,
raw_credentials: str | None,
quota_project: str | None,
scope: str,
*,
token_header: str,
creds_header: str,
authorization: str | None = None,
) -> GoogleCredentials:
"""Parse a Google access token / base64 service-account JSON from headers.
The ready access token may arrive either in the service-specific
``*-Access-Token`` header (takes precedence, kept for backward
compatibility) or in a standard ``Authorization: Bearer <token>`` header.
"""
token = (access_token or "").strip() or None
if token is None:
token = _bearer_from_authorization(authorization)
service_account_info: dict | None = None
raw = (raw_credentials or "").strip()
if raw:
try:
decoded = base64.b64decode(raw, validate=True)
except (binascii.Error, ValueError) as exc:
raise MissingCredentialsError(
f"{creds_header} is not valid base64."
) from exc
try:
service_account_info = json.loads(decoded)
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise MissingCredentialsError(
f"{creds_header} does not decode to valid JSON."
) from exc
if not isinstance(service_account_info, dict):
raise MissingCredentialsError(
f"{creds_header} JSON must be a service-account object."
)
if not token and service_account_info is None:
raise MissingCredentialsError(
f"Provide either {token_header}, an 'Authorization: Bearer <token>' "
f"header, or {creds_header}."
)
return GoogleCredentials(
access_token=token,
service_account_info=service_account_info,
quota_project=(quota_project or "").strip() or None,
scope=scope,
)
# --- Google Analytics ---------------------------------------------------------
def get_ga_credentials(
x_ga_access_token: str | None = Header(
default=None,
alias="X-GA-Access-Token",
description="Ready OAuth2 access token used directly as a Bearer token. "
"Takes precedence over Authorization and X-GA-Credentials.",
),
authorization: str | None = Header(
default=None,
alias="Authorization",
description=_authorization_description("X-GA-Access-Token"),
),
x_ga_credentials: str | None = Header(
default=None,
alias="X-GA-Credentials",
description="Base64-encoded Google service-account JSON key. The proxy "
"mints a short-lived access token from it (scope analytics.readonly). "
"Used only if X-GA-Access-Token is absent.",
),
x_ga_quota_project: str | None = Header(
default=None,
alias="X-GA-Quota-Project",
description="Optional Google Cloud project id for quota/billing "
"(sets the x-goog-user-project header upstream).",
),
) -> GoogleCredentials:
return _build_google_credentials(
x_ga_access_token,
x_ga_credentials,
x_ga_quota_project,
config.GA_SCOPE,
token_header="X-GA-Access-Token",
creds_header="X-GA-Credentials",
authorization=authorization,
)
# --- Google Search Console ----------------------------------------------------
def get_gsc_credentials(
x_gsc_access_token: str | None = Header(
default=None,
alias="X-GSC-Access-Token",
description="Ready OAuth2 access token used directly as a Bearer token. "
"Takes precedence over Authorization and X-GSC-Credentials.",
),
authorization: str | None = Header(
default=None,
alias="Authorization",
description=_authorization_description("X-GSC-Access-Token"),
),
x_gsc_credentials: str | None = Header(
default=None,
alias="X-GSC-Credentials",
description="Base64-encoded Google service-account JSON key. The proxy "
"mints a short-lived access token from it (scope webmasters.readonly). "
"Used only if X-GSC-Access-Token is absent.",
),
x_gsc_quota_project: str | None = Header(
default=None,
alias="X-GSC-Quota-Project",
description="Optional Google Cloud project id for quota/billing.",
),
) -> GoogleCredentials:
return _build_google_credentials(
x_gsc_access_token,
x_gsc_credentials,
x_gsc_quota_project,
config.GSC_SCOPE,
token_header="X-GSC-Access-Token",
creds_header="X-GSC-Credentials",
authorization=authorization,
)
# --- Google Ads ---------------------------------------------------------------
def get_google_ads_credentials(
x_gads_developer_token: str | None = Header(
default=None,
alias="X-GAds-Developer-Token",
description="Google Ads API developer token (from a Google Ads manager "
"account). Required for every Google Ads call.",
),
x_gads_access_token: str | None = Header(
default=None,
alias="X-GAds-Access-Token",
description="Ready OAuth2 access token used directly as a Bearer token. "
"Takes precedence over Authorization and X-GAds-Credentials.",
),
authorization: str | None = Header(
default=None,
alias="Authorization",
description=_authorization_description("X-GAds-Access-Token"),
),
x_gads_credentials: str | None = Header(
default=None,
alias="X-GAds-Credentials",
description="Base64-encoded Google service-account JSON key (scope "
"adwords). Requires domain-wide delegation; usually an access token is "
"easier. Used only if X-GAds-Access-Token is absent.",
),
x_gads_login_customer_id: str | None = Header(
default=None,
alias="X-GAds-Login-Customer-Id",
description="Optional manager (MCC) customer id used as login-customer-id "
"header. Digits only, no dashes.",
),
x_gads_quota_project: str | None = Header(
default=None,
alias="X-GAds-Quota-Project",
description="Optional Google Cloud project id for quota/billing.",
),
) -> GoogleAdsCredentials:
developer_token = (x_gads_developer_token or "").strip()
if not developer_token:
raise MissingCredentialsError("X-GAds-Developer-Token header is required.")
google = _build_google_credentials(
x_gads_access_token,
x_gads_credentials,
x_gads_quota_project,
config.GOOGLE_ADS_SCOPE,
token_header="X-GAds-Access-Token",
creds_header="X-GAds-Credentials",
authorization=authorization,
)
login_customer_id = (x_gads_login_customer_id or "").strip().replace("-", "") or None
return GoogleAdsCredentials(
google=google,
developer_token=developer_token,
login_customer_id=login_customer_id,
)
# --- Sklik --------------------------------------------------------------------
def get_sklik_token(
x_sklik_token: str | None = Header(
default=None,
alias="X-Sklik-Token",
description="Sklik API token from Sklik account settings. The proxy uses "
"it to obtain a session via client.loginByToken.",
),
) -> str:
token = (x_sklik_token or "").strip()
if not token:
raise MissingCredentialsError("X-Sklik-Token header is required.")
return token