Standalone OAuth client¶
Install pyfly[oauth2-client] for pyfly.oauth2. This optional module imports no web
routes, application context, JWT verifier or provider administration client. It
provides token acquisition primitives for native clients and other applications;
there is no password grant.
from pyfly.oauth2 import OAuth2Client, OAuth2Endpoints, generate_pkce
endpoints = OAuth2Endpoints(
token_endpoint="https://identity.example.com/oauth/token",
device_authorization_endpoint="https://identity.example.com/oauth/device",
)
async def acquire():
async with OAuth2Client("my-public-client", endpoints) as client:
grant = await client.authorize_device(scopes=("openid", "api"))
# Display grant.verification_uri and grant.user_code to the user.
return await client.poll_device_token(grant)
async def exchange_verified_callback(code: str, redirect_uri: str, verifier: str):
async with OAuth2Client("my-public-client", endpoints) as client:
return await client.exchange_code(
code, redirect_uri=redirect_uri, code_verifier=verifier,
)
proof = generate_pkce()
# Send proof.challenge and code_challenge_method=S256 in the authorization URL.
# Retain proof.verifier only in the pending transaction until code exchange.
pkce_challenge(verifier) also accepts an existing RFC 7636 verifier. PKCE pairs,
returned tokens and device grants omit secret fields from repr. Token responses
are never logged. OAuth2ClientError.code is an allowlisted protocol/error code;
provider descriptions, bodies and transport exception strings are not exposed.
Treat PKCE pairs, device grants and token objects as opaque in-memory values. Do
not log them or serialize them with dataclasses.asdict() or another serializer:
repr redaction does not remove secrets from their fields.
For a provider that supports or requires PKCE on device authorization, opt in per request:
async def acquire_with_device_pkce():
async with OAuth2Client("my-public-client", endpoints) as client:
grant = await client.authorize_device(scopes=("openid", "api"), use_pkce=True)
# Display only grant.verification_uri and grant.user_code.
return await client.poll_device_token(grant)
authorize_device(*, scopes=(), use_pkce=False) keeps its existing wire format by
default; not every device provider supports this extension. With use_pkce=True,
the client generates a fresh S256 pair internally and sends code_challenge and
code_challenge_method=S256 to the device endpoint. It never sends the verifier
there. The returned grant privately retains its verifier; every subsequent poll
for that grant automatically sends the same code_verifier to the token endpoint.
Concurrent grants have independent verifiers and can be polled in any order.
use_pkce must be a boolean; truthy strings and integers are rejected before HTTP.
There is no plain mode or fallback to an unprotected request after rejection.
Keep the grant with its originating client until completion, cancellation or expiry;
do not extract, persist or separately pass its private verifier. The S256 pair does
not change the existing polling intervals, ownership check, deadline or cleanup.
Before exchange_code, the caller must validate a one-time callback state,
redirect URI/path and issuer (including mix-up/replay protection). The library
creates no callback listener or browser process. OAuth2Tokens.id_token is an
opaque value, not verified identity; never substitute it for access_token.
Credential storage, refresh serialization/rotation and local roles remain the
application's responsibility. Device operations use form authentication for an
explicitly supplied confidential-client secret; public clients send no secret.
Code exchange and refresh¶
exchange_code(code, *, redirect_uri, code_verifier, authentication="client_secret_post", resource=None)
keeps the existing form authentication and wire format when the new options are
omitted. For providers requiring HTTP Basic, explicitly choose
authentication="client_secret_basic". Basic requires a nonempty confidential
client secret and form-encodes each credential before base64 encoding. The default
post method also supports public clients without a secret. Neither method falls
back to another authentication method after rejection.
Use refresh_token(refresh_token, *, scopes=(), authentication="client_secret_post", resource=None)
for one explicit refresh request:
async def refresh_saved_grant(saved_refresh_token: str):
async with OAuth2Client("my-public-client", endpoints) as client:
return await client.refresh_token(
saved_refresh_token,
scopes=("api.read",),
resource="https://api.example.com",
)
Code, redirect URI and client ID must be nonempty UTF-8 strings of at most
4096 bytes without C0/C1 controls. Nonempty client secrets have the same limits;
omitting the secret or passing an empty secret uses a public client with post
authentication. Refresh tokens have
the same rules with a 65,536-byte limit. Invalid values fail before HTTP with
redacted errors. Refresh scopes use the bounded scope syntax below; an empty
tuple omits the parameter. Returned scopes are checked for syntax, but the caller
must validate them against the original grant and its current access policy.
The library does not infer approved scopes from requested scopes. Missing
scope or refresh_token fields remain None; it does not silently reuse a
previous refresh token or invent a replacement.
resource is an optional RFC 8707
identifier sent only to the configured token endpoint. It is never fetched or
opened. Use the exact identifier registered with the trusted provider; the
library does not discover or approve resources. The value must be an absolute
ASCII URI, at most 2048 UTF-8 bytes, with valid percent escapes and no userinfo,
fragment or controls. HTTPS resources require a host. URNs, api:// identifiers
and other absolute schemes are accepted as identifiers. HTTP resources require
allow_loopback_http=True and a literal 127.0.0.1 or ::1 host. This resource
profile is stricter than the development token-endpoint profile: localhost
and truthy non-boolean opt-ins are not accepted for HTTP resources.
Code exchange and refresh each dispatch once using the constructor timeout and response bound. Cancellation, timeout, malformed responses and transport errors never trigger a replay. The application must serialize refreshes, encrypt stored tokens and persist rotation decisions. If a refresh request may have reached the provider but its result is unknown, do not automatically replay the old token; reconcile the grant or ask the user to authorize again. These operations create no callback state, token vault, browser flow, identity verification or local roles.
When calling the native AuthorizationServerEndpoints routes, code exchange
uses the registered redirect URI and retained S256 verifier. Public clients
can exchange a code, but the native server requires a confidential client
secret for refresh. Its audience is configured on the server; the client's
resource parameter does not add resource selection to that server. See the
server HTTP contract.
For machine access, use the public client-credentials operation:
import os
async def acquire_machine_token(remaining_seconds: float):
async with OAuth2Client(
os.environ["OAUTH_CLIENT_ID"],
OAuth2Endpoints("https://identity.example.com/oauth/token"),
client_secret=os.environ["OAUTH_CLIENT_SECRET"],
timeout=10.0,
) as client:
return await client.client_credentials(
scopes=("api.read",),
timeout=remaining_seconds,
)
The exact signature is
client_credentials(*, scopes: tuple[str, ...] = (), authentication: Literal["client_secret_basic", "client_secret_post"] = "client_secret_basic", timeout: float | None = None) -> OAuth2Tokens.
It defaults to HTTP Basic, form-encoding each credential before base64 encoding
as required by RFC 6749 section 2.3.1.
Choose authentication="client_secret_post" explicitly when the trusted provider
requires credentials in the form body. A request uses only the chosen method;
there is no authentication fallback. Both credentials must be nonempty UTF-8
strings of at most 4096 characters with no C0/C1 control characters. Invalid
credentials, scopes, authentication methods or timeouts fail before HTTP with
redacted ValueError messages.
Scopes must be a tuple of at most 64 nonempty tokens, each at most 256 ASCII
characters, with at most 4096 characters including the separating spaces.
They follow RFC 6749 scope syntax:
printable ASCII excluding spaces, double quotes and backslashes within a token.
An empty tuple omits the scope parameter. The operation adds only grant_type,
optional scope and the selected client authentication; arbitrary extra form
fields are not accepted.
Machine acquisition accepts only valid Bearer token responses, including the
RFC 6750 token syntax, and
applies the same scope limits to a returned scope. The presence of either
refresh_token or id_token, even with a null value, fails with
OAuth2ClientError("invalid_response"). This is a strict machine-token contract;
authorization-code and device token response behavior is unchanged. Machine
acquisition performs no refresh grant, persistence, automatic renewal or retry.
Scope and access policy remain the application's responsibility.
The per-call timeout must be finite and positive; booleans are not accepted.
It bounds the request and response read by the smaller of this value and the
constructor timeout. Omit it to use the constructor limit, or pass the caller's
remaining operation budget. Cancellation propagates, and resource cleanup is
awaited before returning, so cleanup may exceed that budget. As with every
operation on this client, a supplied transport must cooperate with cancellation
and finish its close operations.
Endpoints are explicit trusted configuration, never taken from a token. HTTPS is
required; allow_loopback_http=True permits HTTP only for literal loopback or
localhost development endpoints. No discovery, redirect following, environment
proxies or automatic retries occur. Device verification URLs are returned to the
caller for display, never automatically opened or fetched. A provider can use a
different verification origin; the application controls which providers it trusts.
The client owns its HTTPX client and any supplied transport. Always use async
context management or aclose(). A custom transport can configure TLS, proxies,
network policy and test responses; it is trusted infrastructure, including its
chunk allocations and any retry policy. The stock path has zero retries.
Responses use identity encoding and reject compression and redirects before body
consumption. max_response_bytes (default 65,536) bounds bytes accumulated by
PyFly, checked before extending the buffer. It does not bound allocations inside
a supplied transport. timeout (default 10 seconds) bounds each request through response reading.
Timeout/cancellation waits for transport cleanup to finish; custom transports
must provide a terminating aclose() implementation.
Device polling follows RFC 8628:
wait before every poll (default 5 seconds), add 5 seconds cumulatively after each
slow_down, double the delay after a timeout, and stop on denial, expiry or other
errors. A monotonic deadline also bounds in-flight requests and rejects late
successes. Cancellation propagates through sleep and requests; it does not revoke
the pending grant at the provider. A grant is bound to the client instance that
created it. Injected clock/sleep functions support deterministic tests.