Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.fluxamerica.io/llms.txt

Use this file to discover all available pages before exploring further.

Every WebTransport session is authenticated by a bearer JWT minted by WorkOS, the platform’s identity provider. The SDK presents the token once, on the first frame of the control stream; the platform validates it and binds the session to the principal and tenant the token names. Tokens are short-lived — the SDK refreshes them out-of-band and opens a new session when an old one’s token nears expiry.

The model in one paragraph

WorkOS is the identity provider. The platform never sees a password — only a signed access token. On every session the platform validates the token’s signature against WorkOS’s JWKS, checks the standard OIDC claims (iss, aud, exp, sub, org_id), and resolves org_id to a platform tenant. Tokens are short-lived (minutes, not hours); the WorkOS device flow returns both an access token and a refresh token, and the SDK uses the refresh token to mint replacements without re-prompting the user.

Discovery: /.well-known/auth-config

The SDK does not hard-code the WorkOS endpoints, the OAuth client_id, or the audience. It fetches them from the platform at startup:
GET https://<platform-base>/.well-known/auth-config
This is the one HTTP endpoint in the platform’s public surface. ADR-0026 retired the rest of the REST API in favour of WebTransport; this endpoint stays on HTTP precisely because the SDK must be able to read it before it has a token — and therefore before it can dial the WebTransport listener. It is unauthenticated by design (ADR-0009) and serves the same JSON to every caller. The response body is a stable JSON document:
{
  "auth_provider": "workos",
  "device_authorization_endpoint": "https://api.workos.com/.../device/authorize",
  "token_endpoint":                "https://api.workos.com/.../oauth/token",
  "client_id":                     "client_...",
  "scopes":                        ["openid", "profile", "email", "offline_access"],
  "audience":                      "client_...",
  "platform_base_url":             "https://platform.example.com"
}
Field meanings:
  • auth_provider — IdP family identifier. Always "workos" in v1; the field exists so a future ADR can add others.
  • device_authorization_endpoint — RFC 8628 device-authorization URL the SDK POSTs to in order to start a login.
  • token_endpoint — token URL the SDK polls during device flow and later hits to redeem a refresh token.
  • client_id — OAuth client identifier the SDK presents to the IdP. Not a secret.
  • scopes — OIDC scopes the SDK must request. offline_access is what makes the IdP issue a refresh token.
  • audience — the access token’s intended audience. The platform rejects tokens whose aud claim does not contain this value.
  • platform_base_url — public base URL the SDK dials for the WebTransport listener once it has a token.
The SDK should cache the response (24 hours is a reasonable default) and re-fetch on cache miss or before a login.

Obtaining a token

The SDK runs the WorkOS device-authorization flow (RFC 8628):
  1. Start the flow. POST to device_authorization_endpoint with the client_id and scopes from discovery. The response contains a device_code, a user_code, a verification_uri_complete (the URI with the user code pre-filled), an expires_in, and an interval.
  2. Surface the URI to the user. What the SDK does here is out of scope — open a browser, print to a terminal, render a QR code. The platform does not care.
  3. Poll the token endpoint. Every interval seconds, POST to token_endpoint with grant_type=urn:ietf:params:oauth:grant-type:device_code, the device_code, and the client_id. The endpoint replies authorization_pending until the user completes verification; on success it returns an access token, a refresh token, an expires_in, and a token_type of Bearer.
  4. Store the tokens. The access token is what the SDK sends on the handshake (next section). The refresh token persists across access-token expiries and should be stored durably.
Subsequent logins skip device flow: POST to token_endpoint with grant_type=refresh_token and the refresh token; the response includes a fresh access token and usually a rotated refresh token, which the SDK persists in place of the old one.

The handshake

Once the SDK has an access token, the session itself is a WebTransport connection to platform_base_url. The full framing is in the Protocol reference § 4; the auth-specific contract is:
  1. The SDK opens the QUIC + WebTransport connection. TLS 1.3 with standard PKI; the platform’s certificate is browser-trusted.
  2. The SDK opens exactly one bidirectional control stream.
  3. The SDK sends a ClientHello frame as the first Postcard-framed payload on that stream. The frame carries the access token in its auth field (as a WorkOS-signed JWT — no Bearer prefix, the wire format is a raw token string), along with the SDK’s preferred protocol versions, its capability set, and identifying metadata (product name, version).
  4. The platform validates the token (next section), picks a mutually-supported protocol version, intersects capabilities, and replies with ServerHello — containing the negotiated protocol version, the negotiated capabilities, an opaque session_id for log correlation, server metadata, and auth_expires_at (the exp claim from the bound token, exposed so the SDK does not have to crack the JWT itself).
If validation fails, the platform returns OpError::Auth(...) on the control stream and closes the session. There is no in-session retry — the SDK must reconnect with a different (typically fresh) token. The handshake has a 5-second wall-clock budget. A session that does not complete the handshake in that window is torn down; the SDK may receive OpError::Auth(HandshakeTimeout) on a best-effort basis (or no frame at all, if the tear-down beats the write).

Claims the platform validates

The platform reads the standard OIDC claims plus WorkOS’s org_id. The SDK does not need to validate signatures locally — the platform does that — but it should know what shape it is sending, so it can pre-validate cheaply (for example, refusing to send an obviously expired token):
ClaimWhat it meansReject reason
issIssuer URL the token was minted by. Must match the platform’s configured WorkOS issuer.Auth(TokenInvalid) on mismatch.
audAudience. Must contain the audience value the discovery endpoint advertised.Auth(TokenInvalid) on mismatch.
expExpiry, Unix seconds.Auth(TokenExpired) when exp is in the past.
subSubject — the WorkOS user id.Auth(TokenInvalid) if missing.
org_idWorkOS organization id. Resolves 1:1 to a platform tenant.Auth(TokenInvalid) if missing; the first session from an unknown org_id triggers tenant provisioning, which can itself fail with an internal error.
The token must be signed with RS256 and carry a kid header that names a key in the WorkOS JWKS. The platform fetches and caches the JWKS server-side; the SDK never reads it directly. Beyond the table above, tokens may carry a permissions claim (an array of permission name strings). It is optional; tokens without it are treated as carrying an empty permission set. Privileged ops (currently SubscribeAuditTail, requiring audit_tail.read) are rejected with Auth(PrincipalLacksPermission { required }) when the bound principal’s permission set does not include the required name.

Token expiry mid-session

Tokens expire while sessions are open. The canonical rule:
  • Ops already in flight when exp passes are completed on a best-effort basis. The platform does not abort them mid-stream.
  • New op streams opened after exp are rejected with OpError::Auth(TokenExpired). The platform checks expiry at op-stream creation, not per frame.
  • The platform does not rotate the bound token in-session. There is no TokenRefresh control frame in v1.
The SDK’s pattern, therefore, is:
  1. Read auth_expires_at from the ServerHello. (Equivalent to the exp claim on the bound token; surfaced for convenience.)
  2. Before auth_expires_at, mint a replacement token via the refresh-token flow above.
  3. When the existing session refuses a new op stream with Auth(TokenExpired) — or proactively, ahead of the deadline — open a new WebTransport session with the fresh token. Long-running subscriptions can survive the cutover by resuming on the new session with the resume token they were last given (see the Protocol reference § 6.2 for resume semantics).
For sessions that do not carry long-lived subscriptions, the simplest SDK behaviour is “reconnect on TokenExpired.”

Error responses

Auth-related errors all flow as the OpError::Auth(AuthErrorKind) variant. The SDK should handle each one explicitly:
  • TokenInvalid — signature failed, iss or aud did not match, sub or org_id was missing or malformed, or the token was otherwise unparseable. The SDK should treat this as a hard failure: do not retry with the same token. If it occurred at handshake time, prompt the user to re-authenticate.
  • TokenExpiredexp is in the past. The SDK should refresh the access token and open a new session. This is the common, expected case during normal operation.
  • PrincipalLacksPermission { required } — authentication succeeded but the principal does not hold the named permission claim. The required field is the permission name (e.g. audit_tail.read). Surface this to the user; do not retry, the answer will not change without IdP-side configuration.
  • HandshakeTimeout — the handshake did not complete within the platform’s 5-second budget. Usually a network problem, not an auth problem; retry with backoff.
  • ProtocolVersionUnsupported — none of the protocol versions the SDK offered in ClientHello.supported_protocol_versions is supported by the platform. The SDK is too old (or, briefly, too new) for the deployed platform; the only resolution is an SDK update.
See the Capabilities page for the related PreconditionFailed(CapabilityNotNegotiated) error, which is not an auth error but is also commonly encountered at handshake time.

Compatibility commitments

The auth surface is a stability contract:
  • The discovery JSON shape is wire-stable. New fields land at the end; existing fields never change name, type, or meaning. SDKs should ignore unknown fields so server roll-outs do not break older clients.
  • JWT claim names are wire-stable. The five claims in the table above are required; additions are additive.
  • The handshake frames (ClientHello, ServerHello) are protocol-versioned. Their layout is covered by the negotiated protocol_version (ADR-0023); a major-version bump is the only mechanism that can change them.
  • The 5-second handshake budget and the “expiry rejects new op streams” rule are part of the wire contract per the Protocol reference § 2.7. They will not silently change between minor versions.