Skip to content

OpenID Connect Provider

NeoZen includes a full OpenID Connect 1.0 Provider built on top of the OAuth 2.1 server. It provides discovery, JWKS, ID token signing, UserInfo, consent management, RP-initiated logout, and dynamic client registration.

Installation

uv add "neozen[oidc]"

This installs pyjwt[crypto] for JWT signing.

Quick Start

from neozen.core.oidc import (
    InMemoryKeyManager,
    IDTokenGenerator,
    DefaultClaimsProvider,
)
from neozen.core.oidc.discovery import build_metadata_from_base_url
from neozen.web.oidc import (
    DiscoveryEndpoint,
    JWKSEndpoint,
    UserInfoEndpoint,
)

# Set up key management (rotates RSA signing keys)
key_manager = InMemoryKeyManager()  # use a persistent backend in prod

# ID token generator
id_token_gen = IDTokenGenerator(
    key_manager=key_manager,
    issuer="https://auth.example.com",
)

# Claims provider (reads attributes from your subject model)
claims_provider = DefaultClaimsProvider(my_subject_lookup)

# Discovery metadata
metadata = build_metadata_from_base_url(
    issuer="https://auth.example.com",
    base_url="https://auth.example.com",
)

# HTTP endpoints
discovery = DiscoveryEndpoint(metadata)
jwks = JWKSEndpoint(key_manager)
userinfo = UserInfoEndpoint(
    token_service=oauth_token_service,
    claims_provider=claims_provider,
)

Each adapter can mount discovery and JWKS for you: create_oidc_router (Litestar), create_oidc_routes (Starlette) and create_oidc_blueprint (Flask), from neozen.ext.<framework>.oauth. With a config file, NeoZenFactory builds the ID token generator and the discovery metadata from [oauth_server] (see the configuration reference), so the issuer and signing keys are the same everywhere.

Endpoints

Endpoint Path Returns
Discovery /.well-known/openid-configuration Provider metadata
JWKS /.well-known/jwks.json Public signing keys
UserInfo /userinfo Subject claims (Bearer auth)
End Session /oauth/end_session Logout flow
Registration /oauth/register Dynamic client registration

When a user hasn't yet granted a client access, the authorization endpoint answers with requires_consent and a consent_data object, and your app renders the consent page. Its form posts back to the authorization endpoint with every field of consent_data as a hidden input, plus the user's answer:

<form method="post" action="/oauth/authorize">
  {% for name in ["client_id", "redirect_uri", "scope", "state",
                  "code_challenge", "code_challenge_method", "nonce",
                  "consent_token"] %}
    <input type="hidden" name="{{ name }}" value="{{ consent_data[name] or '' }}">
  {% endfor %}
  <button name="approved" value="true">Allow</button>
  <button name="approved" value="false">Deny</button>
</form>

consent_token is what makes the form safe against cross-site request forgery: it is signed, expires after ten minutes, and is bound to the signed-in user and to the exact request. A submission without a valid one is refused, whatever framework serves the page. Your other forms (login, logout, settings) need your framework's CSRF protection: see the Flask and Litestar guides; with Starlette, use a middleware such as starlette-csrf. Empty values are fine: missing PKCE fields count as absent.

Key Management

The KeyManager protocol manages RSA signing keys for ID tokens:

manager = InMemoryKeyManager()

# Get current signing key
key = await manager.get_active_signing_key()

# Rotate (old key kept for verification of in-flight tokens)
new_key = await manager.rotate_key()

# Get all public keys (for JWKS)
jwks = await manager.get_jwks()

For production, implement KeyManager against a persistent store (database, file, or KMS).

ID Tokens

ID tokens are returned automatically from the OAuth token endpoint when the request includes the openid scope:

from neozen.web.oauth import TokenEndpoint

endpoint = TokenEndpoint(
    oauth_token_service,
    oauth_client_service,  # authenticates the client on every request
    id_token_generator=id_token_gen,
    claims_provider=claims_provider,
)
# Now POST /token with scope=openid email returns id_token in the response

Scopes and Claims

Standard OIDC scope-to-claims mapping is built in:

Scope Claims
openid sub
profile name, family_name, given_name, preferred_username, picture, locale, ...
email email, email_verified
phone phone_number, phone_number_verified
address address
from neozen.core.oidc.consent import ConsentService

service = ConsentService(repository=consent_repo)

# Check before authorization
if not await service.has_consent(user_id, client_id, "openid email"):
    # Show consent screen
    ...

# Grant
await service.grant_consent(user_id, client_id, "openid email")

# List trusted apps
apps = await service.list_trusted_apps(user_id)

# Revoke
await service.revoke_consent(user_id, client_id)

RP-Initiated Logout

from neozen.web.oidc import EndSessionEndpoint

endpoint = EndSessionEndpoint(client_service)
result = await endpoint(query_params)

if result.post_logout_redirect_uri:
    # Destroy session, then redirect
    ...

Dynamic Client Registration

from neozen.web.oidc import RegistrationEndpoint

endpoint = RegistrationEndpoint(client_service)
response = await endpoint({
    "redirect_uris": ["https://app.example.com/callback"],
    "client_name": "My App",
})
# Returns client_id, client_secret, etc.

CORS

from neozen.web.oidc.cors import collect_allowed_origins

origins = await collect_allowed_origins(client_service)
# Use as CORS middleware allowed_origins