Skip to content

OAuth router helpers

The litestar_auth.oauth package uses lazy imports (PEP 562) so optional dependencies such as httpx-oauth are not loaded until OAuth helpers are used. If your IDE does not resolve symbols on litestar_auth.oauth, import from litestar_auth.oauth.router directly—the module that implements the helpers re-exported from the package.

These helpers cover the supported manual OAuth client contract used by create_provider_oauth_controller(), create_oauth_controller(), and OAuthClientAdapter. For direct provider-login assembly, ProviderOAuthControllerConfig(...) carries the same settings accepted by create_provider_oauth_controller(...) as keyword arguments.

  • Typed contract: litestar_auth.oauth._client.OAuthClientProtocol with the narrower OAuthDirectIdentityClientProtocol, OAuthProfileClientProtocol, and optional OAuthEmailVerificationAsyncClientProtocol capability protocols. Wrap sync-only verification clients with make_async_email_verification_client().
  • Provisioning: pass oauth_client, oauth_client_factory, or oauth_client_class plus oauth_client_kwargs.
  • Flow-cookie secret: every manual controller factory requires oauth_flow_cookie_secret for the encrypted, authenticated OAuth state + PKCE verifier cookie. The secret is HKDF-derived into Fernet key material for the short-lived v2 flow-cookie envelope.
  • Authorization: the client must provide get_authorization_url(...) -> str and accept PKCE S256 challenge keyword arguments.
  • Token exchange: the client must provide get_access_token(...) with an access_token payload and accept the PKCE code_verifier keyword argument.
  • Identity: provide get_id_email(...) or get_profile(...) with account id and email fields.
  • Optional verification: provide async get_email_verified(...) or an email_verified field on the profile payload.
  • Invalid import paths, missing methods, or malformed payloads fail closed with ConfigurationError.

See OAuth2 login and account linking for the full behavioral contract.

Token encryption policy

OAuth token persistence is configured with OAuthConfig.oauth_token_encryption_keyring for plugin-managed routes or with an explicit OAuthTokenEncryption(...) policy for direct SQLAlchemyUserDatabase(...) usage. Encrypted stored values use the fernet:v1:<key_id>:<ciphertext> envelope. The one-key oauth_token_encryption_key shortcut and OAuthTokenEncryption(key=...) path write with the default key id; use a FernetKeyringConfig(active_key_id=..., keys=...) or OAuthTokenEncryption(active_key_id=..., keys=...) when you need rotation.

OAuthTokenEncryption.requires_reencrypt(value) and OAuthTokenEncryption.reencrypt(value) operate on one stored token value at a time. Migration jobs should apply them to both access_token and refresh_token columns, commit rewritten values, scan again, and remove retired key ids only after no rows require rotation. Legacy unversioned Fernet values need explicit old-key migration input because the stored value does not identify its decrypting key. These public helpers fail closed unless the policy has a configured key/keyring or explicitly sets unsafe_testing=True; keyless plaintext mode is only a test-owned override.

litestar_auth.oauth_encryption

Explicit OAuth token encryption helpers for SQLAlchemy-backed persistence.

OAuth token storage is bound to a concrete session via bind_oauth_token_encryption(...) or by passing oauth_token_encryption=... to SQLAlchemyUserDatabase. Mapped OAuthAccount instances keep plaintext tokens in memory while mapper events encrypt them before writes and decrypt them after loads/refreshes.

OAuthTokenEncryption(key=None, unsafe_testing=False, active_key_id=_DEFAULT_OAUTH_FERNET_KEY_ID, keys=None) dataclass

Explicit OAuth token encryption policy for one session-bound persistence path.

__post_init__()

Initialize the cached Fernet keyring for this policy's key.

Raises:

Type Description
ConfigurationError

If both one-key and keyring inputs are configured.

Source code in litestar_auth/oauth_encryption.py
def __post_init__(self) -> None:
    """Initialize the cached Fernet keyring for this policy's key.

    Raises:
        ConfigurationError: If both one-key and keyring inputs are configured.
    """
    if self.key is not None and self.keys is not None:
        msg = "OAuth token encryption accepts either key or keys, not both."
        raise ConfigurationError(msg)
    if self.keys is not None:
        object.__setattr__(self, "keys", MappingProxyType(dict(self.keys)))
    object.__setattr__(self, "_keyring", self._build_keyring())

decrypt(value)

Return the value decrypted with this policy, or plaintext in explicit unsafe tests.

Source code in litestar_auth/oauth_encryption.py
def decrypt(self, value: str | None) -> str | None:
    """Return the value decrypted with this policy, or plaintext in explicit unsafe tests."""
    self.require_configured(context="decrypting OAuth tokens")
    if value is None:
        return None
    return self._keyring.decrypt(value)

encrypt(value)

Return the value encrypted with this policy, or plaintext in explicit unsafe tests.

Source code in litestar_auth/oauth_encryption.py
def encrypt(self, value: str | None) -> str | None:
    """Return the value encrypted with this policy, or plaintext in explicit unsafe tests."""
    self.require_configured(context="encrypting OAuth tokens")
    if value is None:
        return None
    return self._keyring.encrypt(value)

reencrypt(value)

Return a stored OAuth token rewritten with the active key.

Source code in litestar_auth/oauth_encryption.py
def reencrypt(self, value: str | None) -> str | None:
    """Return a stored OAuth token rewritten with the active key."""
    self.require_configured(context="re-encrypting OAuth tokens")
    if value is None:
        return None
    plaintext = self.decrypt(value)
    return self.encrypt(plaintext)

require_configured(*, context='OAuth token persistence')

Fail closed when encryption is required but no key is configured.

Raises:

Type Description
ConfigurationError

When unsafe_testing=False and no encryption key is configured.

Source code in litestar_auth/oauth_encryption.py
def require_configured(self, *, context: str = "OAuth token persistence") -> None:
    """Fail closed when encryption is required but no key is configured.

    Raises:
        ConfigurationError: When ``unsafe_testing=False`` and no encryption key is configured.
    """
    if self.unsafe_testing or self.key is not None or self.keys is not None:
        return
    msg = (
        f"oauth_token_encryption_key is required when {context}. "
        'Generate one with `python -c "from cryptography.fernet import Fernet; '
        'print(Fernet.generate_key().decode())"`.'
    )
    raise ConfigurationError(msg)

requires_reencrypt(value)

Return whether a stored OAuth token should be rewritten with the active key.

Source code in litestar_auth/oauth_encryption.py
def requires_reencrypt(self, value: str | None) -> bool:
    """Return whether a stored OAuth token should be rewritten with the active key."""
    self.require_configured(context="checking OAuth token rotation")
    if value is None:
        return False
    return self._keyring.needs_rotation(value)

bind_oauth_token_encryption(session, oauth_token_encryption)

Bind an explicit OAuth token encryption policy to a SQLAlchemy session path.

Raises:

Type Description
TypeError

When oauth_token_encryption is not a current-module OAuthTokenEncryption instance.

Source code in litestar_auth/oauth_encryption.py
def bind_oauth_token_encryption(session: object, oauth_token_encryption: OAuthTokenEncryption) -> None:
    """Bind an explicit OAuth token encryption policy to a SQLAlchemy session path.

    Raises:
        TypeError: When ``oauth_token_encryption`` is not a current-module
            ``OAuthTokenEncryption`` instance.
    """
    if not isinstance(oauth_token_encryption, OAuthTokenEncryption):
        msg = "oauth_token_encryption must be an OAuthTokenEncryption instance from the current module."
        raise TypeError(msg)
    for target in _iter_session_targets(session):
        info = getattr(target, "info", None)
        if isinstance(info, dict):
            info[_OAUTH_TOKEN_ENCRYPTION_INFO_KEY] = oauth_token_encryption

get_bound_oauth_token_encryption(session)

Return the OAuth token encryption policy bound to the given session path.

Source code in litestar_auth/oauth_encryption.py
def get_bound_oauth_token_encryption(session: object) -> OAuthTokenEncryption | None:
    """Return the OAuth token encryption policy bound to the given session path."""
    for target in _iter_session_targets(session):
        info = getattr(target, "info", None)
        if isinstance(info, dict):
            policy = info.get(_OAUTH_TOKEN_ENCRYPTION_INFO_KEY)
            if isinstance(policy, OAuthTokenEncryption):
                return policy
    return None

register_oauth_model_encryption_events(model_base)

Register mapper events that keep OAuth token attributes plaintext in memory.

Source code in litestar_auth/oauth_encryption.py
def register_oauth_model_encryption_events(model_base: type[Any]) -> None:
    """Register mapper events that keep OAuth token attributes plaintext in memory."""
    listeners: tuple[tuple[str, object], ...] = (
        ("load", _decrypt_loaded_oauth_tokens),
        ("refresh", _decrypt_refreshed_oauth_tokens),
        ("before_insert", _encrypt_oauth_tokens_before_insert),
        ("before_update", _encrypt_oauth_tokens_before_update),
        ("after_insert", _restore_oauth_tokens_after_write),
        ("after_update", _restore_oauth_tokens_after_write),
    )
    for identifier, listener in listeners:
        if not event.contains(model_base, identifier, listener):
            event.listen(model_base, identifier, listener, propagate=True)

    transaction_listeners: tuple[tuple[str, object], ...] = (
        ("after_rollback", _restore_oauth_token_snapshots_after_rollback),
        ("after_soft_rollback", _restore_oauth_token_snapshots_after_rollback),
    )
    for identifier, listener in transaction_listeners:
        if not event.contains(Session, identifier, listener):
            event.listen(Session, identifier, listener)

require_oauth_token_encryption(oauth_token_encryption, *, context='OAuth token persistence')

Return the explicit policy or fail when persistence would rely on ambient state.

Raises:

Type Description
ConfigurationError

When no explicit policy was supplied, or when a policy without a configured key is used while unsafe_testing=False.

Source code in litestar_auth/oauth_encryption.py
def require_oauth_token_encryption(
    oauth_token_encryption: OAuthTokenEncryption | None,
    *,
    context: str = "OAuth token persistence",
) -> OAuthTokenEncryption:
    """Return the explicit policy or fail when persistence would rely on ambient state.

    Raises:
        ConfigurationError: When no explicit policy was supplied, or when a policy without a
            configured key is used while ``unsafe_testing=False``.
    """
    if oauth_token_encryption is None:
        msg = (
            f"{context} requires an explicit oauth_token_encryption policy. "
            "Pass oauth_token_encryption=OAuthTokenEncryption(...) to SQLAlchemyUserDatabase() "
            "or call bind_oauth_token_encryption(session, OAuthTokenEncryption(...))."
        )
        raise ConfigurationError(msg)
    if not isinstance(oauth_token_encryption, OAuthTokenEncryption):
        msg = (
            f"{context} requires an OAuthTokenEncryption instance from the current module. "
            "Create a fresh OAuthTokenEncryption(...) policy before binding or passing it to "
            "SQLAlchemyUserDatabase()."
        )
        raise ConfigurationError(msg)
    oauth_token_encryption.require_configured(context=context)
    return oauth_token_encryption

Public OAuth helpers are implemented in litestar_auth.oauth.router (the litestar_auth.oauth package re-exports them lazily). The old litestar_auth.contrib.oauth package is no longer a compatibility import path.

litestar_auth.oauth.router

Helpers for constructing provider-specific OAuth controllers.

ProviderOAuthControllerConfig(provider_name, backend, user_manager, redirect_base_url, oauth_flow_cookie_secret, oauth_client=None, oauth_client_factory=None, oauth_client_class=None, oauth_client_kwargs=None, auth_path='/auth', path=None, cookie_secure=True, oauth_scopes=None, associate_by_email=False, trust_provider_email_verified=False, oauth_redirect_dns_strict=True) dataclass

Configuration for :func:create_provider_oauth_controller.

ProviderOAuthControllerOptions

Bases: TypedDict

Keyword options accepted by :func:create_provider_oauth_controller.

create_provider_oauth_controller(*, config=None, **options)

create_provider_oauth_controller(*, config: ProviderOAuthControllerConfig[UP, ID]) -> type[Controller]
create_provider_oauth_controller(**options: Unpack[ProviderOAuthControllerOptions[UP, ID]]) -> type[Controller]

Build a provider-specific OAuth controller from a client or lazy factory.

The authorize endpoint uses only server-configured oauth_scopes. Runtime scope-query overrides are rejected. redirect_base_url must use a non-loopback https:// origin; the manual controller API does not expose a debug or testing override for insecure callback origins. The generated flow encrypts transient state + PKCE verifier material with oauth_flow_cookie_secret and enforces RFC 7636 PKCE S256, so manual clients must accept code_challenge / code_challenge_method on authorization and code_verifier on token exchange.

Returns:

Type Description
type[Controller]

Generated controller class mounted under the provider-specific path.

Raises:

Type Description
ValueError

If config and keyword options are combined.

Source code in litestar_auth/oauth/router.py
def create_provider_oauth_controller[UP: UserProtocol[Any], ID](
    *,
    config: ProviderOAuthControllerConfig[UP, ID] | None = None,
    **options: Unpack[ProviderOAuthControllerOptions[UP, ID]],
) -> type[Controller]:
    """Build a provider-specific OAuth controller from a client or lazy factory.

    The authorize endpoint uses only server-configured ``oauth_scopes``. Runtime
    scope-query overrides are rejected. ``redirect_base_url`` must use a
    non-loopback ``https://`` origin; the manual controller API does not expose
    a debug or testing override for insecure callback origins. The generated
    flow encrypts transient state + PKCE verifier material with
    ``oauth_flow_cookie_secret`` and enforces RFC 7636 PKCE S256, so manual
    clients must accept ``code_challenge`` / ``code_challenge_method`` on
    authorization and ``code_verifier`` on token exchange.

    Returns:
        Generated controller class mounted under the provider-specific path.

    Raises:
        ValueError: If ``config`` and keyword options are combined.
    """
    if config is not None and options:
        msg = "Pass either ProviderOAuthControllerConfig or keyword options, not both."
        raise ValueError(msg)
    settings = ProviderOAuthControllerConfig(**options) if config is None else config

    _validate_manual_oauth_redirect_base_url(
        settings.redirect_base_url,
        strict=settings.oauth_redirect_dns_strict,
    )
    oauth_client_adapter = _build_oauth_client_adapter(
        oauth_client=settings.oauth_client,
        oauth_client_factory=settings.oauth_client_factory,
        oauth_client_class=settings.oauth_client_class,
        oauth_client_kwargs=settings.oauth_client_kwargs,
        oauth_client_class_loader=load_httpx_oauth_client,
    )
    resolved_path = settings.path if settings.path is not None else _build_oauth_login_path(settings.auth_path)

    return _create_login_oauth_controller(
        _OAuthLoginControllerSettings(
            provider_name=settings.provider_name,
            backend=settings.backend,
            user_manager=settings.user_manager,
            oauth_client_adapter=oauth_client_adapter,
            redirect_base_url=settings.redirect_base_url,
            oauth_flow_cookie_secret=settings.oauth_flow_cookie_secret,
            path=resolved_path,
            cookie_secure=settings.cookie_secure,
            oauth_scopes=settings.oauth_scopes,
            associate_by_email=settings.associate_by_email,
            trust_provider_email_verified=settings.trust_provider_email_verified,
            oauth_redirect_dns_strict=settings.oauth_redirect_dns_strict,
            validate_redirect_base_url=False,
        ),
    )

load_httpx_oauth_client(oauth_client_class, /, **client_kwargs)

Import and instantiate an httpx-oauth client lazily.

Returns:

Type Description
OAuthClientProtocol

Instantiated OAuth client.

Raises:

Type Description
ImportError

If the optional httpx-oauth dependency is not installed.

ModuleNotFoundError

If a non-httpx-oauth import required by the client cannot be resolved.

ConfigurationError

If the client path is invalid or the class cannot be imported.

Source code in litestar_auth/oauth/router.py
def load_httpx_oauth_client(oauth_client_class: str, /, **client_kwargs: object) -> OAuthClientProtocol:
    """Import and instantiate an ``httpx-oauth`` client lazily.

    Returns:
        Instantiated OAuth client.

    Raises:
        ImportError: If the optional `httpx-oauth` dependency is not installed.
        ModuleNotFoundError: If a non-`httpx-oauth` import required by the client cannot be resolved.
        ConfigurationError: If the client path is invalid or the class cannot be imported.
    """
    module_name, _, class_name = oauth_client_class.rpartition(".")
    if not module_name or not class_name:
        msg = "oauth_client_class must be a fully qualified module path."
        raise ConfigurationError(msg)

    try:
        module = import_module(module_name)
    except ModuleNotFoundError as exc:
        if exc.name is not None and exc.name.startswith("httpx_oauth"):
            msg = "Install litestar-auth[oauth] to use OAuth controllers."
            raise ImportError(msg) from exc
        raise

    oauth_client_type: OAuthClientConstructor | None = getattr(module, class_name, None)
    if oauth_client_type is None:
        msg = f"OAuth client class {oauth_client_class!r} could not be imported."
        raise ConfigurationError(msg)

    return oauth_client_type(**client_kwargs)