Implementing OAuth2 for PMS API Access
Static API keys are the wrong credential for an automated rate parity pipeline: they never expire, they carry every scope the integration was ever granted, and a single leaked key lets a forgotten cron job overwrite live prices for weeks before anyone notices. This page is the build guide for replacing them with the OAuth2 client-credentials flow — the machine-to-machine grant that mints a short-lived, scope-checked bearer token for every PMS API call your parity engine makes. It is the initial grant that sits under Security & Authentication Boundaries, the gate every outbound mutation crosses before it is paced and dispatched to an OTA.
Prerequisites & environment
The token manager below runs in-process alongside the parity dispatcher; it owns the OAuth2 exchange so the rate-push logic never touches a raw credential. Pin these versions so the async and caching behaviour is reproducible:
- Python 3.11+ — for
asyncio.TaskGroupand modern typing on the async client. httpx0.27+ — async HTTP client with granular connect/read/write timeouts for the token endpoint.pydantic2.6+ — validates the token-endpoint response at the boundary (v2 API:field_validator,model_dump).tenacity8.x — available for transport-level backoff, though the auth-retry path below is deliberately hand-written (see Step 3).structlog24.x — key=value telemetry for audit-grade credential lifecycle logging.
On the access side you need an OAuth2 client registered in the PMS developer portal, its client_id / client_secret loaded from a secrets manager (HashiCorp Vault, AWS Secrets Manager, or Kubernetes secrets — never version control), the provider’s token endpoint URL, and an explicit list of granted scopes. This flow issues only an access_token; if your provider also returns a refresh_token, rotating it across a portfolio is covered separately in automating channel manager token renewal.
Step-by-step implementation
The implementation is four parts: a scoped grant registration, a TTL-buffered token manager, an auth-aware retry wrapper, and an idempotent dispatch call.
Step 1 — Register the grant and pin least-privilege scopes
Use the Client Credentials grant for headless parity pushes — it has no user session, so it survives batch execution across hundreds of properties. Reserve Authorization Code with PKCE for the dashboard where a human overrides a rate. When you register, request the narrowest scopes the workflow actually needs and reject anything wider at config-load time.
from pydantic import BaseModel, field_validator
# Wildcard grants (admin:all, *) are refused outright: an over-permissioned
# token that leaks can rewrite plans on a sister property, and it fails the
# least-privilege check every hospitality PCI/GDPR audit looks for.
FORBIDDEN_SCOPES = {"*", "admin:all", "admin:*"}
class GrantConfig(BaseModel):
client_id: str
client_secret: str
token_url: str
scopes: list[str] # e.g. ["rates:read", "inventory:write", "parities:audit"]
@field_validator("scopes")
@classmethod
def reject_wildcards(cls, v: list[str]) -> list[str]:
offending = set(v) & FORBIDDEN_SCOPES
if offending or not v:
raise ValueError(f"refusing wildcard/empty scope grant: {offending or 'empty'}")
return v
@property
def scope_str(self) -> str:
return " ".join(self.scopes)
Validating scopes in the model rather than at the call site is the non-obvious choice: a wildcard grant that slips through config is a silent liability that only surfaces during an incident review, so the pipeline refuses to start with one rather than deferring the failure to production.
Step 2 — Build a token manager with a refresh buffer
The token acquisition endpoint takes a POST with grant_type=client_credentials and returns access_token, expires_in, and token_type. Cache the token in-process (or in Redis for a multi-worker fleet) with a TTL set to expires_in − 60 seconds. That 60-second buffer is what prevents a 401 mid-batch when network latency or OTA rate limiting pushes a request past the token’s hard expiry.
import time
import hashlib
import httpx
import structlog
log = structlog.get_logger()
REFRESH_BUFFER_SECONDS = 60
class PMSOAuth2Manager:
def __init__(self, cfg: GrantConfig):
self.cfg = cfg
self._access_token: str | None = None
self._cache_expiry: float = 0.0
self._client = httpx.AsyncClient(timeout=httpx.Timeout(connect=5.0, read=10.0, write=10.0, pool=10.0))
async def _fetch_token(self) -> str:
payload = {
"grant_type": "client_credentials",
"client_id": self.cfg.client_id,
"client_secret": self.cfg.client_secret,
"scope": self.cfg.scope_str,
}
try:
resp = await self._client.post(self.cfg.token_url, data=payload)
resp.raise_for_status()
data = resp.json()
# Never trust the token to outlive its stated window: subtract a buffer,
# and floor at 30s so a pathologically short expires_in can't thrash.
ttl = max(data["expires_in"] - REFRESH_BUFFER_SECONDS, 30)
self._access_token = data["access_token"]
self._cache_expiry = time.time() + ttl
log.info("oauth_token_acquired", scope=self.cfg.scope_str, ttl=ttl)
return self._access_token
except httpx.HTTPStatusError as exc:
# Log a hash of the request body, never the body — it holds the secret.
body_hash = hashlib.sha256(str(exc.request.content).encode()).hexdigest()[:12]
log.error("oauth_token_fetch_failed", status_code=exc.response.status_code,
body_hash=body_hash, detail=exc.response.text)
raise RuntimeError(f"OAuth2 acquisition failed: {exc.response.status_code}") from exc
def invalidate(self) -> None:
self._access_token = None
self._cache_expiry = 0.0
async def get_valid_token(self) -> str:
if self._access_token and time.time() < self._cache_expiry:
return self._access_token
return await self._fetch_token()
Hashing the request body before logging a failure — rather than emitting detail alongside the payload — is the detail that keeps a leaked-credential incident out of your log aggregator: the hash lets you correlate repeated failures without ever writing client_secret to disk.
Step 3 — Wrap parity calls in an auth-aware retry
Any long-running batch can outlive a token even with the buffer, so wrap each call in a retry that treats a 401 specially: invalidate the cache, mint a fresh token, and re-attempt once with capped exponential backoff. This is deliberately hand-written rather than delegated to tenacity because the retry must force a credential refresh between attempts — a plain backoff would replay the same expired token. The retryable-versus-terminal split here mirrors categorizing 4xx vs 5xx sync errors.
import asyncio
from functools import wraps
def retry_on_auth_failure(max_retries: int = 3, backoff_factor: float = 1.5):
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
token_manager: PMSOAuth2Manager = kwargs["token_manager"]
for attempt in range(max_retries):
token = await token_manager.get_valid_token()
kwargs["headers"]["Authorization"] = f"Bearer {token}"
try:
return await func(*args, **kwargs)
except httpx.HTTPStatusError as exc:
# Only 401 is worth a token refresh; a 403 is a scope problem
# that no amount of retrying fixes, so let it propagate.
if exc.response.status_code == 401 and attempt < max_retries - 1:
log.warning("auth_retry_triggered", attempt=attempt + 1,
endpoint=str(exc.request.url))
token_manager.invalidate()
# await asyncio.sleep, never time.sleep — the latter blocks
# the event loop and stalls every other in-flight push.
await asyncio.sleep(backoff_factor ** attempt)
continue
raise
return wrapper
return decorator
Distinguishing 401 (retry with a fresh token) from 403 (a scope the grant never had — let it fail loudly) is the design choice that matters most here: retrying a 403 just burns your rate-limit budget on a request that can never succeed and hides the real fix, which is widening the scope in Step 1.
Step 4 — Dispatch an idempotent parity batch
PMS rate endpoints often process updates asynchronously, so a duplicate submission without an idempotency key can trigger a double rate push — and an overbooking or parity violation downstream. Attach an idempotency key derived from the batch content so a safe retry is a no-op on the server. When the PMS answers 202 Accepted with a job ID, hand off to an async poller rather than blocking.
async def push_parity_batch(
client: httpx.AsyncClient,
token_manager: PMSOAuth2Manager,
rate_updates: list[dict], # each: {"property_id", "room_type_code", "rate_plan_code", "amount"}
idempotency_key: str,
) -> dict:
headers = {"Content-Type": "application/json", "Idempotency-Key": idempotency_key}
@retry_on_auth_failure(max_retries=3)
async def _execute(**kwargs) -> dict:
resp = await client.post(
"https://api.pms-provider.com/v2/rates/batch",
json={"rates": rate_updates},
headers=kwargs["headers"],
)
resp.raise_for_status()
return resp.json()
result = await _execute(token_manager=token_manager, headers=headers)
if result.get("status") == "accepted":
log.info("parity_job_submitted", job_id=result.get("job_id"),
record_count=len(rate_updates), idempotency_key=idempotency_key)
return result
Passing token_manager and headers through kwargs is what lets the decorator rewrite the Authorization header on each attempt — the inner function reads the header the decorator just refreshed, so a retry after a 401 carries the new token rather than the stale one it failed with.
Gotchas & production notes
- The refresh buffer must exceed your worst-case batch latency, not a round number. Sixty seconds is a starting point; if a full-portfolio push to
booking_comtakes ninety seconds under load, a token minted at the start can still expire before the last property. Measure your P99 batch duration and setREFRESH_BUFFER_SECONDSabove it, or refresh per-request against the cache rather than per-batch. - Scope names are provider-specific — do not assume
inventory:writemaps cleanly across PMS vendors. Align the strings you request in Step 1 with the vendor’s exact scope catalogue and the shape of your rate plan taxonomy; a token scopedrates:writethat the provider expects asrate_plans:mutatefails with a403that looks identical to a genuine permission gap. - Never let the token or secret reach a log line, a trace attribute, or an exception message. The manager logs
ttl,scope, and abody_hashonly. Hospitality stacks run under PCI-adjacent governance, and a bearer token surfaced in astructlogevent or an APM trace is a reportable incident. - A client-credentials token authorizes the request; it does not authenticate the callback. OAuth2 secures your outbound push, but the OTA’s inbound “rate accepted” webhook needs HMAC verification, covered in channel manager webhook integration — do not treat a valid token as proof the round-trip is trustworthy.
Verification snippet
Prove the scope guard and the TTL-buffer arithmetic before wiring the manager to a live endpoint. This asserts a wildcard grant is refused, and that the cached token is treated as expired the moment it crosses the buffered boundary.
import pytest
def test_wildcard_scope_is_rejected() -> None:
with pytest.raises(ValueError):
GrantConfig(client_id="cid", client_secret="s",
token_url="https://auth.example/token", scopes=["*"])
def test_ttl_applies_the_refresh_buffer() -> None:
cfg = GrantConfig(client_id="cid", client_secret="s",
token_url="https://auth.example/token",
scopes=["rates:read", "inventory:write"])
mgr = PMSOAuth2Manager(cfg)
mgr._access_token = "cached"
# expires_in was 3600; buffered TTL must retire the token 60s early.
mgr._cache_expiry = time.time() + (3600 - REFRESH_BUFFER_SECONDS)
assert time.time() < mgr._cache_expiry # still valid now
mgr._cache_expiry = time.time() - 1 # simulate crossing the boundary
assert time.time() >= mgr._cache_expiry # forces a re-fetch
test_wildcard_scope_is_rejected()
test_ttl_applies_the_refresh_buffer()
Asserting on the buffered boundary rather than the raw expires_in is the check that matters: an off-by-one that drops the 60-second buffer is invisible in a smoke test but shows up in production as intermittent 401s on the last property of every large batch. In a fuller suite, also mock a 401 to confirm retry_on_auth_failure calls invalidate() and re-fetches exactly once.
FAQ
Client Credentials or Authorization Code with PKCE — which grant should a rate-push pipeline use?
Client Credentials. A parity dispatcher is a headless service with no human in the loop, so it needs a machine-to-machine grant that survives an unattended batch across hundreds of properties. Authorization Code with PKCE is the right flow only for the interactive dashboard where a revenue manager signs in to override a rate — it mints a token bound to that person’s session, which is exactly what you do not want a cron job depending on.
Where should the access token live in a multi-worker fleet?
Move the cache out of process into Redis (or another shared store) keyed by client_id plus scope_str, with the TTL set to the same expires_in − 60 buffer. In-process caching means every worker independently hammers the token endpoint on cold start, and on a provider that counts token requests against your rate budget that stampede can itself trigger a 429. A shared cache lets one exchange serve the whole fleet; guard the refresh with a short lock so concurrent misses do not all re-mint at once.
My provider returned a refresh_token with a client-credentials grant — should I use it?
Usually no. The client-credentials flow is designed to re-exchange client_id / client_secret for a fresh access_token whenever the cached one expires, so a refresh_token is redundant and just widens what a leak exposes. Prefer re-running the grant. If your provider rate-limits the token endpoint hard enough that re-exchange is impractical, treat the refresh_token as a rotating secret and manage it with the pre-flight-and-lock design in OAuth2 token refresh strategies rather than the retry loop here.
Why do I get a 403 after switching PMS vendors when the code is unchanged?
Scope strings are vendor-specific and are not portable. A grant that one provider accepts as rates:write another may expect as rate_plans:mutate, and requesting the wrong name yields a 403 that is indistinguishable from a genuine permission gap. Realign the strings in Step 1 with the new vendor’s scope catalogue; because the retry wrapper deliberately never retries a 403, the failure surfaces loudly at the first push instead of quietly burning your rate budget.
Related
- Security & Authentication Boundaries — the parent gate: trust model, scope isolation, and the audit trail this grant feeds
- OAuth2 Token Refresh Strategies — the deterministic pre-flight-and-lock refresh design for tokens that carry a
refresh_token - Automating Channel Manager Token Renewal — rotating credentials across a whole portfolio before any expire
- Handling OTA API Rate Limits — why the refresh buffer and the token endpoint both live inside your rate budget
- Channel Manager Webhook Integration — HMAC-verifying the inbound callbacks this token’s pushes trigger
← Back to Security & Authentication Boundaries