Skip to main content

OAuth Token Lifecycle

The SDK manages OAuth2 client_credentials tokens automatically — fetching, caching, proactive refresh before expiry, and retry on 401/403 responses. This page documents the token lifecycle states and how the SDK handles each transition.

How it works​

The Management, Model Security, Red Team, and SCM AI Gateway management APIs authenticate with OAuth2 client credentials: you present a client ID + secret, the auth server hands back a short-lived bearer token (Strata Cloud Manager issues ~900s tokens), and each service request carries that token. Gateway runtime inference uses a separate gateway key; public model pricing uses no credentials. Tokens expire, so the OAuth services need fetching, caching, and refresh.

That "something" is OAuthClient, and for normal use you never touch it — ManagementClient (and the other OAuth clients) embed one and handle the whole cycle for you. This page exists for the cases where you do want visibility or control: health checks, custom auth flows, or just understanding what happens under load. Note that the embedded instance is private — ManagementClient does not expose it or accept tokenBufferMs / onTokenRefresh — so a standalone OAuthClient observes its own token cache, not the managed client's.

The model is a small state machine with one knob — the buffer window:

  • A token is Valid right after it's fetched and stays cached (zero network calls) until it nears expiry.
  • The buffer (default 30s) marks a token Expiring Soon before it actually expires, so the next call refreshes proactively rather than failing.
  • If a token somehow expires anyway — or gets revoked server-side — a 401/403 triggers a one-time refresh-and-retry.

Two guarantees worth knowing: concurrent calls that need a refresh are deduplicated into a single token fetch (no thundering herd), and getTokenInfo() lets you inspect state without ever exposing the raw token.

Bounded refresh and timeout recovery​

Each token refresh has one fixed 30-second deadline covering both the HTTP response headers and the success/error JSON body. This is independent of the pre-expiry tokenBufferMs setting. The token manager bounds its own wait even if a replaced or instrumented fetch ignores its AbortSignal. All callers sharing that refresh receive an OAUTH_ERROR with Token request timed out and network-failure metadata. The pending-refresh slot is released so a later call can try again; the SDK does not automatically replay the timed-out token request.

A late response from the expired attempt cannot cache a token or invoke onTokenRefresh. Unused bodies are cancelled without awaiting a potentially stuck cancellation, and timers are disposed after success or failure. Cancelling a service request does not cancel a shared token refresh for other callers; the token manager still enforces its own deadline.

Sixteen failing-first regression tests cover stalled fetches, success/error bodies, concurrent callers, late responses, cleanup failures, recovery and timer disposal. The packed ESM/CommonJS candidate also passes real 30-second synthetic fault-injection checks on Node 18/20/22/24. These are offline resilience tests, not live service outages. The live authentication example separately verifies successful concurrent refresh deduplication and explicit cache clearing without printing a token.

Token States​

StateisExpiredisExpiringSoonisValidgetToken() behavior
No TokentruetruefalseFetches new token from endpoint
ValidfalsefalsetrueReturns cached token (no network)
Expiring SoonfalsetruefalseFetches new token proactively
ExpiredtruetruefalseFetches new token

The buffer window determines when a token transitions from Valid to Expiring Soon. With the default 30s buffer and Strata Cloud Manager's 900s token TTL, a token is treated as expiring soon from the 870s mark, and the next getToken() (or API call) after that point fetches a fresh one — well before any API call would fail. There is no background timer; refresh is lazy and happens on demand.

Inspecting Token State​

import { OAuthClient } from '@cdot65/prisma-airs-sdk';

const oauth = new OAuthClient({
clientId: 'your-client-id',
clientSecret: 'your-client-secret',
tsgId: '1234567890',
tokenBufferMs: 60_000, // refresh 60s before expiry (default: 30s)
});

// Snapshot of current state (never exposes the actual token)
const info = oauth.getTokenInfo();
// {
// hasToken: true,
// isValid: true,
// isExpired: false,
// isExpiringSoon: false,
// expiresInMs: 840000,
// expiresAt: 1741448400000
// }

// Individual checks
oauth.isTokenExpired(); // past absolute expiry time?
oauth.isTokenExpiringSoon(); // within configured buffer?
oauth.isTokenExpiringSoon(120_000); // within custom 2-minute buffer?

Monitoring Token Refreshes​

The onTokenRefresh callback fires after every successful token fetch, including the initial one:

const oauth = new OAuthClient({
clientId: 'your-client-id',
clientSecret: 'your-client-secret',
tsgId: '1234567890',
onTokenRefresh: (info) => {
console.log(`Token refreshed, valid for ${info.expiresInMs / 1000}s`);
// Send to monitoring, update health checks, etc.
},
});

The callback receives a TokenInfo object. If the callback throws, the error is swallowed — it never blocks token delivery.

Auto-Retry on 401/403​

When a management API request receives a 401 Unauthorized or 403 Forbidden:

An explicit SCM policy denial (403 with x-opa-decision: false) is returned immediately; refreshing the same identity cannot repair that permission denial. Other eligible 401/403 responses follow the bounded one-time refresh path below.

  1. The SDK calls clearToken() to invalidate the cached token
  2. getToken() fetches a fresh token from the OAuth endpoint
  3. The original request is retried with the new token
  4. This happens once per request — if the retry also fails, the error propagates

This handles the case where a token expires between the buffer check and the API call, or when the server-side token is revoked.

Validation Output​

Captured 2026-09-07T02:00:36.854Z by executing docs-site/examples/oauth-lifecycle-validation.ts against local mock servers. Tokens and credentials in this script are synthetic. This proves local timing and retry behavior, not external OAuth availability; the live OAuth result is listed on the examples page.

Full captured mock validation output
═══════════════════════════════════════════════════════════════
OAuth Token Lifecycle Validation
Token TTL: 5s | Buffer: 3s
═══════════════════════════════════════════════════════════════

[T+ 0.0s] SETUP Mock token server on port 43251
[T+ 0.0s] SETUP Mock API server on port 39239

── Phase 1: Pre-fetch state ──────────────────────────────

[T+ 0.0s] INFO Before any token fetch:
[T+ 0.0s] INFO hasToken = false
[T+ 0.0s] INFO isValid = false
[T+ 0.0s] INFO isExpired = true
[T+ 0.0s] INFO isExpiringSoon = true
[T+ 0.0s] INFO expiresInMs = 0
[T+ 0.0s] INFO expiresAt = N/A
[T+ 0.0s] PASS ✓ No token before first fetch
[T+ 0.0s] PASS ✓ isTokenExpired() true before fetch
[T+ 0.0s] PASS ✓ isTokenExpiringSoon() true before fetch
[T+ 0.0s] PASS ✓ expiresInMs is 0 before fetch

── Phase 2: Initial token fetch ─────────────────────────

[T+ 0.0s] SERVER Issued token #1: mock-token-1 to test-client (TTL=5s)
[T+ 0.0s] CALLBACK onTokenRefresh fired — expiresInMs=5000
[T+ 0.0s] PHASE 2 Got token: mock-token-1
[T+ 0.0s] INFO After first fetch:
[T+ 0.0s] INFO hasToken = true
[T+ 0.0s] INFO isValid = true
[T+ 0.0s] INFO isExpired = false
[T+ 0.0s] INFO isExpiringSoon = false
[T+ 0.0s] INFO expiresInMs = 5000
[T+ 0.0s] INFO expiresAt = 2026-09-07T01:59:03.319Z
[T+ 0.0s] PASS ✓ First token is mock-token-1
[T+ 0.0s] PASS ✓ hasToken is true
[T+ 0.0s] PASS ✓ isValid is true
[T+ 0.0s] PASS ✓ isExpired is false
[T+ 0.0s] PASS ✓ isExpiringSoon is false
[T+ 0.0s] PASS ✓ expiresInMs > 0 (got 5000)
[T+ 0.0s] PASS ✓ Only 1 server request so far

── Phase 3: Token caching (no re-fetch within TTL) ──────

[T+ 0.0s] PHASE 3 Subsequent getToken() returned: mock-token-1, mock-token-1
[T+ 0.0s] PASS ✓ Cached token returned (call 2)
[T+ 0.0s] PASS ✓ Cached token returned (call 3)
[T+ 0.0s] PASS ✓ Still only 1 server request (token cached)

── Phase 4: Wait 2.2s for buffer window ─────────

[T+ 0.0s] PHASE 4 Sleeping 2.2s to reach buffer window...
[T+ 2.2s] INFO After entering buffer window:
[T+ 2.2s] INFO hasToken = true
[T+ 2.2s] INFO isValid = false
[T+ 2.2s] INFO isExpired = false
[T+ 2.2s] INFO isExpiringSoon = true
[T+ 2.2s] INFO expiresInMs = 2798
[T+ 2.2s] INFO expiresAt = 2026-09-07T01:59:03.319Z
[T+ 2.2s] PASS ✓ isTokenExpiringSoon() true in buffer window
[T+ 2.2s] PASS ✓ TokenInfo.isExpiringSoon is true
[T+ 2.2s] PASS ✓ isValid is false (within buffer)
[T+ 2.2s] SERVER Issued token #2: mock-token-2 to test-client (TTL=5s)
[T+ 2.2s] CALLBACK onTokenRefresh fired — expiresInMs=5000
[T+ 2.2s] PHASE 4 getToken() after buffer window: mock-token-2
[T+ 2.2s] PASS ✓ Auto-refreshed to mock-token-2
[T+ 2.2s] PASS ✓ Second server request for refresh
[T+ 2.2s] INFO After automatic refresh:
[T+ 2.2s] INFO hasToken = true
[T+ 2.2s] INFO isValid = true
[T+ 2.2s] INFO isExpired = false
[T+ 2.2s] INFO isExpiringSoon = false
[T+ 2.2s] INFO expiresInMs = 4999
[T+ 2.2s] INFO expiresAt = 2026-09-07T01:59:05.525Z
[T+ 2.2s] PASS ✓ Refreshed token is valid
[T+ 2.2s] PASS ✓ Refreshed token not expired

── Phase 5: Wait 6s for full expiry ──────────────────

[T+ 2.2s] PHASE 5 Sleeping 6s for full token expiry...
[T+ 8.2s] INFO After full expiry:
[T+ 8.2s] INFO hasToken = true
[T+ 8.2s] INFO isValid = false
[T+ 8.2s] INFO isExpired = true
[T+ 8.2s] INFO isExpiringSoon = true
[T+ 8.2s] INFO expiresInMs = 0
[T+ 8.2s] INFO expiresAt = 2026-09-07T01:59:05.525Z
[T+ 8.2s] PASS ✓ isTokenExpired() true after expiry
[T+ 8.2s] PASS ✓ TokenInfo.isExpired is true
[T+ 8.2s] PASS ✓ expiresInMs is 0 after expiry
[T+ 8.2s] SERVER Issued token #3: mock-token-3 to test-client (TTL=5s)
[T+ 8.2s] CALLBACK onTokenRefresh fired — expiresInMs=5000
[T+ 8.2s] PHASE 5 getToken() after expiry: mock-token-3
[T+ 8.2s] PASS ✓ Auto-refreshed to mock-token-3 after expiry
[T+ 8.2s] PASS ✓ Third server request

── Phase 6: 401 auto-retry with token refresh ───────────

[T+ 8.2s] SERVER Issued token #4: mock-token-4 to mgmt-client (TTL=900s)
[T+ 8.2s] API GET /v1/mgmt/profiles/tsg/1234567890 auth=[REDACTED]
[T+ 8.2s] API Responding 401 to simulate expired token
[T+ 8.2s] SERVER Issued token #5: mock-token-5 to mgmt-client (TTL=900s)
[T+ 8.3s] API GET /v1/mgmt/profiles/tsg/1234567890 auth=[REDACTED]
[T+ 8.3s] PHASE 6 profiles.list() resolved after a 401: 0 profiles
[T+ 8.3s] PASS ✓ 401 auto-retry succeeded with fresh token
[T+ 8.3s] PASS ✓ Initial fetch + one refresh after 401 (fetches 3 → 5)

── Phase 7: 403 auto-retry with token refresh ───────────

[T+ 8.3s] API GET /v1/mgmt/profiles/tsg/1234567890 auth=[REDACTED]
[T+ 8.3s] API Responding 403 to simulate expired token
[T+ 8.3s] SERVER Issued token #6: mock-token-6 to mgmt-client (TTL=900s)
[T+ 8.3s] API GET /v1/mgmt/profiles/tsg/1234567890 auth=[REDACTED]
[T+ 8.3s] PHASE 7 profiles.list() resolved after a 403: 0 profiles
[T+ 8.3s] PASS ✓ 403 auto-retry succeeded with fresh token
[T+ 8.3s] PASS ✓ Cached token reused, then exactly one refresh after 403 (fetches 5 → 6)

── Phase 8: clearToken() → forced re-fetch ──────────────

[T+ 8.3s] INFO After clearToken():
[T+ 8.3s] INFO hasToken = false
[T+ 8.3s] INFO isValid = false
[T+ 8.3s] INFO isExpired = true
[T+ 8.3s] INFO isExpiringSoon = true
[T+ 8.3s] INFO expiresInMs = 0
[T+ 8.3s] INFO expiresAt = N/A
[T+ 8.3s] PASS ✓ No token after clearToken()
[T+ 8.3s] PASS ✓ isTokenExpired() true after clear
[T+ 8.3s] SERVER Issued token #7: mock-token-7 to test-client (TTL=5s)
[T+ 8.3s] CALLBACK onTokenRefresh fired — expiresInMs=5000
[T+ 8.3s] PHASE 8 getToken() after clearToken(): mock-token-7
[T+ 8.3s] PASS ✓ New server request after clearToken()
[T+ 8.3s] PASS ✓ Fresh token is valid

── Phase 9: Custom buffer override ──────────────────────

[T+ 8.3s] PASS ✓ isTokenExpiringSoon(6000ms) true with large custom buffer
[T+ 8.3s] PASS ✓ isTokenExpiringSoon(100ms) false with tiny buffer

── Phase 10: onTokenRefresh callback audit ──────────────

[T+ 8.3s] PHASE 10 Total onTokenRefresh callbacks (standalone client): 4
[T+ 8.3s] PHASE 10 Token fetches — standalone: 4, managed: 3
[T+ 8.3s] PASS ✓ Callback count (4) matches standalone fetch count (4)
[T+ 8.3s] PASS ✓ Callback #1: hasToken=true
[T+ 8.3s] PASS ✓ Callback #1: expiresInMs=5000
[T+ 8.3s] PASS ✓ Callback #2: hasToken=true
[T+ 8.3s] PASS ✓ Callback #2: expiresInMs=5000
[T+ 8.3s] PASS ✓ Callback #3: hasToken=true
[T+ 8.3s] PASS ✓ Callback #3: expiresInMs=5000
[T+ 8.3s] PASS ✓ Callback #4: hasToken=true
[T+ 8.3s] PASS ✓ Callback #4: expiresInMs=5000

═══════════════════════════════════════════════════════════════
Validation Complete
Total token fetches: 7 (standalone 4, managed 3)
Total callbacks: 4
Duration: 8.3s
Result: ALL PASSED
═══════════════════════════════════════════════════════════════

What the validation proves​

PhaseWhat it testsReal timing?
1Pre-fetch state — all fields report "no token"N/A
2Initial fetch — token acquired, state transitions to ValidYes
3Caching — repeated getToken() returns cached token, no networkYes
4Buffer window — after 2.2s of a 5s token with 3s buffer, isExpiringSoon flips and getToken() proactively refreshesYes (2.2s real wait)
5Full expiry — after 6s, isExpired flips and getToken() fetches fresh tokenYes (6s real wait)
6401 auto-retry — ManagementClient.profiles.list() gets a 401, the SDK clears the token, fetches a new one, and retries (with numRetries: 0, proving the auth retry is free)Yes (mock API)
7403 auto-retry — same path with a 403; the cached token is reused first, then exactly one refreshYes (mock API)
8clearToken() — invalidates cache, next getToken() forces fresh fetchYes
9Custom buffer — isTokenExpiringSoon(ms) respects overrideYes
10Callback audit — onTokenRefresh fires exactly once per fetchYes

Get the most out of it​

Let the SDK do the work

For 99% of use cases you don't need OAuthClient at all — just construct a ManagementClient and make calls. Fetch, cache, refresh, and 401/403 retry all happen automatically. Reach for a standalone OAuthClient only when you need a bearer token for your own HTTP calls, want to observe refreshes against your own token source, or are testing a custom auth flow — it does not hook into the instance a ManagementClient owns.

Size the buffer to your token TTL

The default 30s buffer suits SCM's ~900s tokens. Choose a buffer comfortably below the token TTL: if you point the SDK at an auth server that issues tokens shorter than about a minute, reduce tokenBufferMs (the validation script above uses 3s for 5s tokens). If you set the buffer larger than the token TTL, the token is "expiring soon" the instant it's issued — and you'll refetch on every call.

The 401/403 retry happens once

On 401/403 the SDK clears the token, fetches a fresh one, and retries the request a single time. If the retry also fails, the error propagates — a persistent 403 means a real permissions or credentials problem, not an expiry hiccup. Don't wrap calls in your own refresh loop; you'd just duplicate this.

Inspect state cheaply. getTokenInfo(), isTokenExpired(), and isTokenExpiringSoon() are pure reads — they never trigger a network fetch. Poll them freely for health endpoints or dashboards without burning token requests.

Use onTokenRefresh for observability, not control flow. It's the right hook to emit a metric or log line on each refresh. Keep it fast and side-effect-only: if the callback throws, the error is swallowed so it can never block token delivery — meaning you also can't rely on it to gate anything.

Concurrency is handled. Fire many requests at once after a cold start (or right at expiry) and they collapse into one token fetch, not N. You don't need to serialize or pre-warm.

clearToken() is your reset. Rotated the client secret, or want to force a clean fetch in a test? Call clearToken() — the next getToken() (or API call) fetches fresh.

Full reference​

OAuthClient, TokenInfo, and the OAuth-backed clients — with full signatures and examples — are in the Full API reference.