Skip to content

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.