109 lines
4.0 KiB
Python
109 lines
4.0 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.
|
|
|
|
Google Analytics (chosen model: token has precedence over service account):
|
|
* ``X-GA-Access-Token`` - a ready OAuth2 access token; used directly as Bearer.
|
|
* ``X-GA-Credentials`` - base64-encoded service-account JSON key; the proxy
|
|
mints a short-lived access token from it.
|
|
* ``X-GA-Quota-Project`` - optional billing/quota project id.
|
|
At least one of token / credentials must be present.
|
|
|
|
Sklik:
|
|
* ``X-Sklik-Token`` - the Sklik API token from account settings. The proxy
|
|
calls ``client.loginByToken`` to obtain a session.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import binascii
|
|
import json
|
|
from dataclasses import dataclass
|
|
|
|
from fastapi import Header
|
|
|
|
from .errors import MissingCredentialsError
|
|
|
|
|
|
# --- Google Analytics ---------------------------------------------------------
|
|
@dataclass
|
|
class GaCredentials:
|
|
access_token: str | None
|
|
service_account_info: dict | None
|
|
quota_project: str | None
|
|
|
|
|
|
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 X-GA-Credentials.",
|
|
),
|
|
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 used for quota/billing "
|
|
"(sets the x-goog-user-project header upstream).",
|
|
),
|
|
) -> GaCredentials:
|
|
"""Resolve GA credentials from headers. Token wins over service account."""
|
|
access_token = (x_ga_access_token or "").strip() or None
|
|
|
|
service_account_info: dict | None = None
|
|
raw = (x_ga_credentials or "").strip()
|
|
if raw:
|
|
try:
|
|
decoded = base64.b64decode(raw, validate=True)
|
|
except (binascii.Error, ValueError) as exc:
|
|
raise MissingCredentialsError(
|
|
"X-GA-Credentials is not valid base64."
|
|
) from exc
|
|
try:
|
|
service_account_info = json.loads(decoded)
|
|
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
raise MissingCredentialsError(
|
|
"X-GA-Credentials does not decode to valid JSON."
|
|
) from exc
|
|
if not isinstance(service_account_info, dict):
|
|
raise MissingCredentialsError(
|
|
"X-GA-Credentials JSON must be a service-account object."
|
|
)
|
|
|
|
if not access_token and service_account_info is None:
|
|
raise MissingCredentialsError(
|
|
"Provide either X-GA-Access-Token or X-GA-Credentials."
|
|
)
|
|
|
|
return GaCredentials(
|
|
access_token=access_token,
|
|
service_account_info=service_account_info,
|
|
quota_project=(x_ga_quota_project or "").strip() or None,
|
|
)
|
|
|
|
|
|
# --- 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
|