httpx-oauth2-flows
Typed, asynchronous OAuth 2 token flows and authentication for HTTPX, using OpenID Connect discovery.
The package can acquire tokens directly or act as an asynchronous httpx.Auth implementation that
acquires, caches, refreshes, and attaches access tokens to outgoing requests.
[!IMPORTANT] This package is asynchronous only. It acquires OAuth 2 access tokens but is not a complete OpenID Connect relying-party implementation: ID tokens are not exposed or validated.
Supported Features
| Feature | Main API | Notes |
|---|---|---|
| Authorization Code with PKCE | AuthorizationCodeFlow |
S256 PKCE, system browser, local loopback callback |
| Client Credentials | ClientCredentialsFlow |
Client Secret Basic/Post/Auto, Private Key JWT, or mutual TLS |
| JWT Bearer | JwtBearerFlow |
Signed RFC 7523 assertion |
| HTTPX authentication | Auth |
Acquires and attaches tokens to asynchronous requests |
| Refresh tokens | Auth |
Automatic for cached Authorization Code tokens |
| Token persistence | Auth |
Uses the system keyring when available |
Implicit, Resource Owner Password Credentials, Device Authorization, SAML Bearer, Token Exchange, and CIBA are not implemented.
Requirements
- Python 3.12 or newer
httpx.AsyncClient- An authorization server exposing OpenID Connect discovery at
{auth_server}/.well-known/openid-configuration
Authorization Code also requires:
- A system browser
- Permission to bind an ephemeral port on
127.0.0.1 - A provider that accepts a dynamic loopback redirect URI
- An OpenID UserInfo endpoint
For the default callback path, register a redirect URI in this form with the provider:
http://127.0.0.1:<dynamic-port>/authorize-callback
Native application providers commonly allow any port for a registered loopback redirect. Providers that require one exact port are not compatible with the current Authorization Code implementation.
Installation
Install from PyPI with pip:
python -m pip install httpx-oauth2-flows
Or with uv:
uv add httpx-oauth2-flows
Input Normalization
Public Config and Flow constructors accept convenient input values and normalize them immediately.
The model attributes remain the canonical Url and Scope types, so application and library code
never needs to handle input unions after construction.
UrlLike accepts:
strUrlorhttpx.URLpydantic.HttpUrlorpydantic.AnyHttpUrl
Every URL is validated as HTTP or HTTPS and stored in canonical form as Url. Relative URLs, other
schemes, bytes, paths, and arbitrary stringifiable objects are rejected.
ScopeLike accepts:
Scope- An OAuth scope expression such as
'openid profile' list[str],tuple[str, ...],set[str], orfrozenset[str]
A string is split on ASCII spaces. Collection elements are individual scope tokens. Tokens are
validated using the RFC 6749 scope-token grammar, duplicates are removed, and transmission order is
sorted. Empty values, ambiguous whitespace, mappings, bytes, generators, and arbitrary iterables
are rejected. Use None to omit the scope parameter.
from httpx_oauth2_flows import (
ClientCredentialsClientSecretPostAuth,
ClientCredentialsConfig,
Scope,
Url,
)
config = ClientCredentialsConfig(
auth_server='https://identity.example.com',
client_id='reporting-service',
auth=ClientCredentialsClientSecretPostAuth(
client_secret='read-from-a-secret-manager'
),
scope='reports.write reports.read',
)
assert isinstance(config.auth_server, Url)
assert isinstance(config.scope, Scope)
UrlLike and ScopeLike are closed type aliases, not structural protocols. This prevents accidental
conversion of objects that merely implement __str__ or __iter__.
Quick Start
Use Auth with an AuthorizationCodeFlow to authenticate requests from a desktop or command-line
application:
import asyncio
import httpx
from httpx_oauth2_flows import Auth, AuthorizationCodeFlow
async def main() -> None:
oauth = Auth(
AuthorizationCodeFlow(
auth_server='https://identity.example.com',
client_id='desktop-client',
scope='openid profile email reports.read',
),
service_name='reports-cli:identity.example.com:desktop-client',
)
async with httpx.AsyncClient(
base_url='https://api.example.com',
auth=oauth,
timeout=10,
) as client:
response = await client.get('/reports/current')
response.raise_for_status()
print(response.json())
asyncio.run(main())
On the first request, the flow:
- Starts a temporary callback server on
127.0.0.1. - Discovers the provider's authorization and token endpoints.
- Opens the authorization request in the system browser.
- Validates the callback state and exchanges the code using S256 PKCE.
- Fetches UserInfo and displays a localized completion page.
- Stores the token in memory and, when available, the system keyring.
- Adds the token to the API request's
Authorizationheader.
Use a unique service_name for each application, authorization server, and client ID. The keyring
lookup identity does not otherwise distinguish clients or scopes on the same authorization server.
Auth cannot be used with synchronous httpx.Client.
Provider Discovery
Every flow resolves its endpoints from:
https://identity.example.com/.well-known/openid-configuration
The discovery document must contain issuer. Additional requirements depend on the flow:
| Flow | Required metadata |
|---|---|
| Authorization Code | authorization_endpoint, token_endpoint, userinfo_endpoint |
| Client Credentials | token_endpoint |
| JWT Bearer | token_endpoint |
If grant_types_supported is present, the selected grant must be listed. If
code_challenge_methods_supported is present, Authorization Code requires S256. Client
Credentials uses token_endpoint_auth_methods_supported to validate or automatically select client
authentication.
OAuth Authorization Server Metadata at /.well-known/oauth-authorization-server and manually
configured endpoints are not currently supported.
Authorization Code
Authorization Code is intended for public/native clients. It does not send a client secret or other client authentication during code exchange.
The default configuration is:
| Option | Default | Description |
|---|---|---|
scope |
None |
Requested OAuth scopes |
callback_path |
/authorize-callback |
Local callback path |
callback_max_wait |
5 minutes | Maximum callback operation time |
callback_response_html |
Bundled page | HTML displayed after completion |
callback_response_localized_texts |
Bundled translations | Text selected from UserInfo locale |
callback_response_default_locale |
en |
Locale used when UserInfo has no locale |
The package does not add the openid scope automatically. Request every scope required by the
provider, including openid when its UserInfo endpoint requires it.
Direct Execution
Use authorization_code_flow when the application needs the token response instead of automatic
HTTPX authentication:
import asyncio
import httpx
from httpx_oauth2_flows import AuthorizationCodeConfig, authorization_code_flow
async def main() -> None:
config = AuthorizationCodeConfig(
auth_server='https://identity.example.com',
client_id='desktop-client',
scope='openid profile reports.read',
)
async with httpx.AsyncClient(timeout=10) as client:
token = await authorization_code_flow(config, client)
print(token.access_token)
asyncio.run(main())
Callback Customization
The built-in completion page provides Bulgarian, Czech, German, English, Spanish, French, Hungarian, Indonesian, Italian, Japanese, Korean, Macedonian, Dutch, Polish, Portuguese, Romanian, Russian, Swedish, Turkish, and Chinese text.
Override or add locales with CallbackResponseTexts:
from httpx_oauth2_flows import AuthorizationCodeFlow, CallbackResponseTexts
flow = AuthorizationCodeFlow(
auth_server='https://identity.example.com',
client_id='desktop-client',
callback_response_localized_texts={
'en': CallbackResponseTexts(
title='Sign-in complete',
body='Welcome, $username. You may return to the application.',
body_male=None,
body_female=None,
button_text='Close tab',
footer_text='This window can be closed safely.',
)
},
callback_response_default_locale='en',
)
Custom body text may use $username. A custom HTML template may use $title, $body,
$button_text, $footer_text, and $lang.
Only use trusted templates and trusted text. Template substitutions and UserInfo values are inserted as HTML and are not escaped by the current implementation.
Client Credentials
Client Credentials supports these token endpoint authentication methods:
| Configuration | Discovery method | Behavior |
|---|---|---|
ClientCredentialsClientSecretBasicAuth |
client_secret_basic |
Sends form-encoded credentials with HTTP Basic |
ClientCredentialsClientSecretPostAuth |
client_secret_post |
Sends the client ID and secret in the request body |
ClientCredentialsClientSecretAutoAuth |
Basic or Post | Prefers Basic, then falls back to Post |
ClientCredentialsPrivateKeyJwtAuth |
private_key_jwt |
Sends a signed client assertion |
ClientCredentialsTlsClientAuth |
tls_client_auth |
Uses an X.509 client certificate for the token request |
If token_endpoint_auth_methods_supported is absent, the package assumes its supported methods are
available.
Client Secret
Read client secrets from a secret manager or environment variable rather than source code:
import asyncio
import os
import httpx
from httpx_oauth2_flows import (
ClientCredentialsClientSecretPostAuth,
ClientCredentialsConfig,
client_credentials_flow,
)
async def main() -> None:
config = ClientCredentialsConfig(
auth_server='https://identity.example.com',
client_id='reporting-service',
auth=ClientCredentialsClientSecretPostAuth(
client_secret=os.environ['OAUTH_CLIENT_SECRET']
),
scope='reports.read',
)
async with httpx.AsyncClient(timeout=10) as client:
token = await client_credentials_flow(config, client)
response = await client.get(
'https://api.example.com/reports/current',
headers={'Authorization': f'{token.token_type} {token.access_token}'},
)
response.raise_for_status()
print(response.json())
asyncio.run(main())
To let discovery choose between Basic and Post, replace the authentication configuration with:
from httpx_oauth2_flows import ClientCredentialsClientSecretAutoAuth
auth = ClientCredentialsClientSecretAutoAuth(client_secret='read-from-a-secret-manager')
ClientCredentialsClientSecretBasicAuth accepts header_name for providers that require the Basic
value in a nonstandard header.
Private Key JWT
Private Key JWT signs a client assertion with iss and sub set to the client ID and aud set to
the discovered token endpoint:
import os
from pathlib import Path
from httpx_oauth2_flows import (
ClientCredentialsConfig,
ClientCredentialsPrivateKeyJwtAuth,
SignerFilePrivateKey,
)
config = ClientCredentialsConfig(
auth_server='https://identity.example.com',
client_id='reporting-service',
scope='reports.read',
auth=ClientCredentialsPrivateKeyJwtAuth(
private_key=SignerFilePrivateKey(
path=Path('/run/secrets/oauth-signing-key.pem'),
password=os.environ['OAUTH_KEY_PASSWORD'].encode(),
),
key_id='reporting-service-2026',
),
)
Mutual TLS
Configure an X.509 certificate and, when separate, its private key:
from pathlib import Path
from httpx_oauth2_flows import ClientCredentialsConfig, ClientCredentialsTlsClientAuth
config = ClientCredentialsConfig(
auth_server='https://identity.example.com',
client_id='reporting-service',
auth=ClientCredentialsTlsClientAuth(
certfile=Path('/run/secrets/client.crt'),
keyfile=Path('/run/secrets/client.key'),
password='read-from-a-secret-manager',
),
)
The certificate is used for the token request, not discovery. The token request uses a dedicated HTTPX client and therefore does not inherit the supplied client's transport, proxy, CA, headers, cookies, or timeout configuration.
JWT Bearer
The JWT Bearer grant exchanges a signed assertion for an access token:
import asyncio
import os
from pathlib import Path
import httpx
from httpx_oauth2_flows import (
JwtBearerConfig,
SignerFilePrivateKey,
jwt_bearer_execute,
)
async def main() -> None:
config = JwtBearerConfig(
auth_server='https://identity.example.com',
client_id='automation-client',
user_id='service-account@example.com',
scope='reports.read',
private_key=SignerFilePrivateKey(
path=Path('/run/secrets/assertion-key.pem'),
password=os.environ['ASSERTION_KEY_PASSWORD'].encode(),
),
key_id='automation-client-2026',
)
async with httpx.AsyncClient(timeout=10) as client:
token = await jwt_bearer_execute(config, client)
print(token.access_token)
asyncio.run(main())
The assertion contains iss, sub, aud, jti, iat, and exp. user_id controls sub and
defaults to the client ID. The audience is the discovered issuer, which the provider must accept.
Assertions are valid for five minutes by default; change this with jwt_duration.
Signing Keys
SignerInlinePrivateKey accepts an unencrypted PEM string. SignerFilePrivateKey loads a PEM file
using a required byte-string password.
Default algorithms are selected from the key type:
| Key | Default algorithm |
|---|---|
| RSA | RS256 |
| EC P-256 | ES256 |
| EC P-384 | ES384 |
| EC P-521 | ES512 |
| Ed25519 or Ed448 | EdDSA |
Override the default with algorithm only when the key and provider support that algorithm. Set
key_id to add a kid header to the assertion.
Token Responses
Direct flow functions and Flow.execute() return SuccessfulTokenResponse:
| Field | Type | Description |
|---|---|---|
access_token |
str |
Token issued by the authorization server |
token_type |
str |
Scheme used in the Authorization header |
expires_in |
datetime.timedelta | None |
Access-token lifetime |
refresh_token |
str | None |
Refresh token, when issued |
scope |
str | None |
Raw space-delimited scope returned by the provider |
Unknown response fields, including id_token, are ignored.
HTTPX Auth, Caching, and Refresh
Auth accepts AuthorizationCodeFlow, ClientCredentialsFlow, or JwtBearerFlow and performs the
following work before an API request:
- Reuses a sufficiently long-lived in-memory token when possible.
- For Authorization Code, attempts to load a persisted token from the system keyring.
- Attempts an unauthenticated refresh-token grant for eligible Authorization Code tokens.
- Executes the configured flow if no valid token remains.
- Sets
Authorization: {token_type} {access_token}on the request.
Tokens are considered valid only when their lifetime exceeds the combined HTTPX connect, read, write, and pool timeout for the request.
If expires_in is missing, Auth treats the access token as a JWT and reads its exp claim without
verifying its signature. An opaque token without expires_in, or a JWT without exp, cannot be
cached by Auth.
Keyring behavior:
- Tokens are persisted when a keyring backend is available.
- Persisted tokens are reloaded only for Authorization Code.
- The default service name is
httpx_oauth2_flows. - The keyring username is derived from the canonical authorization-server URL. Legacy root URLs without a trailing slash are loaded and migrated automatically.
clear_stored_token()removes persisted state but does not clear the current in-memory token.- A
401 Unauthorizedor403 Forbiddenresponse removes persisted state but is not retried.
Concurrent requests are not synchronized around token acquisition and may start duplicate flows.
Error Handling
OAuth-specific errors share the public OAuth2FlowError base class:
import httpx
from httpx_oauth2_flows import OAuth2FlowError, TokenError, TokenSchemaError
try:
response = await client.get('/reports/current')
response.raise_for_status()
except TokenError as error:
print(error.error, error.error_description, error.error_uri)
except TokenSchemaError as error:
print('The token endpoint returned an invalid response:', error)
except OAuth2FlowError as error:
print('The OAuth flow failed:', error)
except httpx.HTTPError as error:
print('The HTTP request failed:', error)
Public OAuth exceptions are:
| Exception | Meaning |
|---|---|
OAuth2FlowError |
Base class for package-specific flow errors |
SupportedAuthMethodError |
The provider does not advertise the requested client authentication |
TokenError |
The token endpoint returned an OAuth error response |
TokenSchemaError |
The token response did not match the expected schema |
AuthorizeCallbackWebServerError |
The local callback server failed or timed out |
AuthorizeCallbackParamsError |
The callback did not contain one code and one state value |
AuthorizeCallbackStateError |
The returned state did not match the generated state |
Transport errors remain httpx.HTTPError subclasses. Invalid provider documents and configuration
may also raise pydantic.ValidationError, RuntimeError, ValueError, or TypeError.
Exceptions for malformed remote responses can include response headers and bodies. Avoid logging them where logs may be accessible to untrusted users.
Security Considerations
- Use trusted HTTPS authorization servers and API endpoints. HTTPS is not enforced by the package.
- Never hardcode client secrets, private keys, or key passwords in source code.
- Treat access tokens, refresh tokens, authorization codes, and exception details as secrets.
- Use a unique
Auth.service_namefor each application, provider, and client ID. - Review the security properties of the operating system's configured keyring backend.
- Keep the system clock synchronized when creating JWT assertions.
- Validate access tokens at the resource server; this package does not validate them.
- Do not use the returned UserInfo or discarded ID token as proof of identity.
- Only use trusted callback HTML and localized text because substitutions are not HTML-escaped.
Known Limitations
- Only asynchronous HTTPX authentication is implemented.
- All flows require OpenID Connect discovery; endpoint overrides are unavailable.
- Authorization Code supports public clients with S256 PKCE only.
- Authorization Code always fetches UserInfo after token exchange.
- The callback port is dynamic and cannot be configured.
- Provider-specific authorization parameters such as
audience,resource,prompt, andlogin_hintcannot be added. - Refresh-token requests do not support client secrets, Private Key JWT, or mutual TLS.
- A refreshed response that omits
refresh_tokendoes not retain the previous refresh token. - Mutual TLS token requests do not reuse the supplied HTTPX client configuration.
- ID tokens are discarded and not validated.
- Access tokens are not inspected or validated, except for the unverified
expfallback used byAuthwhenexpires_inis missing. - Requests are not automatically retried after a
401or403response.
Public API
The supported package-level imports are grouped below.
| Category | Names |
|---|---|
| HTTPX integration | Auth, Flow |
| Authorization Code | AuthorizationCodeConfig, AuthorizationCodeFlow, authorization_code_flow |
| Callback page | CallbackResponseTexts, CallbackResponseLocalizedTexts |
| Client Credentials | ClientCredentialsConfig, ClientCredentialsFlow, client_credentials_flow |
| Client authentication | ClientCredentialsAuth, ClientCredentialsClientSecretAutoAuth, ClientCredentialsClientSecretBasicAuth, ClientCredentialsClientSecretPostAuth, ClientCredentialsPrivateKeyJwtAuth, ClientCredentialsTlsClientAuth |
| JWT Bearer | JwtBearerConfig, JwtBearerFlow, jwt_bearer_execute |
| Signing | SignerConfig, SignerPrivateKey, SignerInlinePrivateKey, SignerFilePrivateKey |
| Values | Scope, ScopeLike, Url, UrlLike, SuccessfulTokenResponse |
| Errors | OAuth2FlowError, SupportedAuthMethodError, TokenError, TokenSchemaError, AuthorizeCallbackWebServerError, AuthorizeCallbackParamsError, AuthorizeCallbackStateError |
| Metadata | __version__ |
Flow, ClientCredentialsAuth, SignerPrivateKey, CallbackResponseLocalizedTexts, ScopeLike,
and UrlLike are type aliases, not constructors.
Use Url and Scope when explicit canonical values are useful. Scope accepts OAuth scope
expressions and the same closed collection types, removes duplicates, and sends scopes in sorted
order:
from httpx_oauth2_flows import Scope
scope = Scope('openid profile', {'reports.read', 'reports.write'})
Configuration classes are frozen Pydantic models. The corresponding Flow classes add an
execute(http_client) method and can be passed to Auth. The standalone functions accept the
matching Config class.
Development
Clone the repository and install its locked dependencies:
git clone https://gitlab.com/daude_f/httpx-oauth2-flows.git
cd httpx-oauth2-flows
uv sync
Run the verification suite:
uv run pytest
uv run ruff check
uv run ruff format --check
uv run pyright
uv build
License
Licensed under the Apache License 2.0.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file httpx_oauth2_flows-0.9.1.tar.gz.
File metadata
- Download URL: httpx_oauth2_flows-0.9.1.tar.gz
- Upload date:
- Size: 38.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1bc913ce53b6617674c781233c9fc366337d3b4385366fb1f2b22bcb01545b6d
|
|
| MD5 |
ae87bb6c48f097bc03b043824ce55b41
|
|
| BLAKE2b-256 |
27e0580466ee4e26a1b4bd8fcad392d8cc7903c4e72a7885941095c555ff224f
|
File details
Details for the file httpx_oauth2_flows-0.9.1-py3-none-any.whl.
File metadata
- Download URL: httpx_oauth2_flows-0.9.1-py3-none-any.whl
- Upload date:
- Size: 40.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1fcb92c4e6b3792a3e08fc407bd7adcb2aabc3bf4220ddcadb75a39ba1398361
|
|
| MD5 |
32b262857f3e4c80cd47f542ad0d568d
|
|
| BLAKE2b-256 |
5726112f617ea53ff0ad2fcfe1f6a0d0602d70a1b2098a22cb6bcd2757b7e4bb
|