Files
kaya/packages/kaya-oidc
woggioni e73c4b2c18
CI / Build Pip package (push) Successful in 8m49s
Validate ID tokens against discovered issuer and refreshed tokens
OIDCClient.validate_id_token compared the iss claim against
OIDCConfig.issuer (the discovery URL) instead of the issuer advertised by
the provider metadata. This broke Microsoft Entra ID, whose v2 metadata
advertises the templated issuer
https://login.microsoftonline.com/{tenantid}/v2.0 while tokens carry the
concrete tenant GUID, and it also affected tenant-domain issuers.

- Validate iss against the discovered metadata issuer and substitute
  {tenantid} with the token's tid claim.
- Catch jwt.PyJWTError in the OIDC callback so invalid tokens return 400
  instead of 500 (InvalidIssuerError is not a ValueError).
- Re-enable aud/exp/iat/nbf checks for unsigned (alg: none) ID tokens.
- Validate ID tokens returned by the refresh grant before storing them,
  including nonce continuity when present and sub continuity with the
  session user.
- Document Entra ID support and update tests.
2026-10-01 23:22:00 +08:00
..

kaya-oidc

OpenID Connect authentication for the Kaya web framework.

Built on top of kaya-session and implements the OIDC Authorization Code Flow with PKCE.

Usage

import os
from kaya.core import HttpContext, KayaApp
from kaya.session import SessionMixin, InMemorySessionStore
from kaya.oidc import OIDCConfig, OIDCMixin

session = SessionMixin(InMemorySessionStore())
oidc = OIDCMixin(
    OIDCConfig(
        issuer=os.environ['OIDC_ISSUER'],
        client_id=os.environ['OIDC_CLIENT_ID'],
        client_secret=os.environ.get('OIDC_CLIENT_SECRET'),
        redirect_uri='http://localhost:8000/auth/callback',
        fetch_userinfo=True,
    ),
    session=session,
)
app = KayaApp(mixins=[session, oidc])

@app.GET('/')
async def home(ctx: HttpContext):
    await ctx.send_str(200, 'public home')

@app.GET('/profile')
@oidc.require_auth
async def profile(ctx: HttpContext):
    user = oidc.get_user(ctx)
    await ctx.send_str(200, f'Hello {user.email or user.sub}')

OIDCMixin depends on SessionMixin; passing only oidc to KayaApp(mixins=...) also works because the app applies mixin dependencies automatically.

Multiple providers

Use OIDCProviders to run several OIDC providers in one application. Each config's provider becomes a route segment under base_path (/auth/{provider}/login, /auth/{provider}/callback and /auth/{provider}/logout), and all providers share a single SessionMixin:

from kaya.oidc import OIDCConfig, OIDCProviders

session = SessionMixin(InMemorySessionStore())
oidc = OIDCProviders(
    [
        OIDCConfig(
            issuer='https://accounts.google.com',
            client_id=os.environ['GOOGLE_CLIENT_ID'],
            client_secret=os.environ['GOOGLE_CLIENT_SECRET'],
            redirect_uri='http://localhost:8000/auth/google/callback',
            provider='google',
        ),
        OIDCConfig(
            issuer='https://github.com/login/oauth',  # or any OIDC issuer
            client_id=os.environ['GITHUB_CLIENT_ID'],
            client_secret=os.environ['GITHUB_CLIENT_SECRET'],
            redirect_uri='http://localhost:8000/auth/github/callback',
            provider='github',
        ),
    ],
    session=session,
    base_path='/auth',  # optional
)
app = KayaApp(mixins=[session, oidc])

@app.GET('/google-profile')
@oidc.require_auth('google')
async def google_profile(ctx: HttpContext):
    user = oidc.get_user(ctx, 'google')
    await ctx.send_str(200, f"Hello {user.email or user.sub}")

@app.GET('/who')
async def who(ctx: HttpContext):
    users = oidc.get_users(ctx)                     # {provider: OIDCUser}
    providers = oidc.logged_in_providers(ctx)       # ['google', ...]
    await ctx.send_str(200, ', '.join(users))

Each provider's session state is namespaced as oidc:{provider}:.... Make sure each config's redirect_uri points at that provider's derived callback path.

Identity strategy

strategy controls how many identities may coexist in the shared session:

  • 'single-identity' (default): completing a login evicts every other provider's identity and tokens, so the session holds exactly one user — last login wins, like Spring Security. After a GitHub login, get_user(ctx, 'google') returns None and require_auth('google') redirects to the Google login.
  • 'multi-identity': each provider stays logged in independently. Use it for linked accounts, aggregating several provider APIs, or step-up flows. Logging out of one provider keeps the shared session alive while another provider is still logged in.
oidc = OIDCProviders([google_config, github_config], session=session,
                     strategy='multi-identity')

Ephemeral flow keys (state, nonce, code_verifier) are never evicted, so logins already in flight can still complete; under 'single-identity' the last callback to finish wins. Under either strategy, get_users(ctx) only reports providers actually present in the session.

The composable alternative is to create one OIDCMixin per provider, pointing each at distinct login_path/callback_path/logout_path values and sharing the same SessionMixin. The same strategy keyword is accepted and defaults to 'single-identity':

google = OIDCMixin(google_config, session=session)
github = OIDCMixin(github_config, session=session, strategy='multi-identity')
app = KayaApp(mixins=[session, google, github])

Upgrading: session keys are now prefixed with oidc:{provider}: (the default provider is default). Existing logged-in users are signed out once and simply re-authenticate.

Pushed authorization requests (PAR)

OIDCConfig.par controls RFC 9126 Pushed Authorization Requests:

  • 'never' (default): the authorization request travels as query parameters, as before.
  • 'auto': push the request whenever the provider advertises a pushed_authorization_request_endpoint; otherwise fall back to the plain redirect.
  • 'always': always push; login raises a RuntimeError if the provider does not support PAR.
OIDCConfig(..., par='auto')

A provider that advertises require_pushed_authorization_requests: true must be configured with par='auto' or par='always'. With par='never' the login fails fast with a clear error instead of issuing a redirect the provider would reject.

The same parameters as the plain authorization request (PKCE challenge, state, nonce, scopes, ...) are POSTed directly to the provider, and the browser is redirected with only client_id and the single-use request_uri. Request parameters therefore no longer travel through the user agent. PKCE, state, and nonce are still used and protect against request URI swapping.

Client authentication follows the token endpoint: HTTP Basic when client_secret is set, or none for public clients. PAR with other client authentication methods (private_key_jwt, mTLS) is not supported.

Features

  • Generic OIDC discovery
  • Authorization Code Flow with PKCE (S256)
  • ID token signature validation with JWKS
  • Issuer validation against discovery metadata, including support for Microsoft Entra ID {tenantid} issuer templates
  • Refreshed ID token validation on token refresh
  • state and nonce protection
  • Session fixation defense via regenerate_id() after login
  • Optional userinfo endpoint fetch
  • Refresh token support
  • RP-initiated logout (when provider advertises end_session_endpoint)
  • Pushed Authorization Requests (RFC 9126) via OIDCConfig.par
  • Multiple providers in one app via OIDCProviders (or several OIDCMixin instances)
  • single-identity (default) or multi-identity session strategy
  • Composable with any other KayaMixin (RSGI, MCP, etc.)

Microsoft Entra ID

Entra ID v2 ID tokens carry an iss claim of the form https://login.microsoftonline.com/<tenant-id>/v2.0, while the discovery metadata for the multi-tenant endpoints advertises the templated issuer https://login.microsoftonline.com/{tenantid}/v2.0. kaya-oidc validates the iss claim against the discovered metadata and substitutes the token's tid claim into {tenantid}, so both tenant-specific and multi-tenant issuers work:

OIDCConfig(
    issuer='https://login.microsoftonline.com/<tenant-id-or-domain>/v2.0',
    client_id=os.environ['OIDC_CLIENT_ID'],
    client_secret=os.environ['OIDC_CLIENT_SECRET'],
    redirect_uri='https://example.com/auth/callback',
)

Use a tenant-specific issuer (the tenant GUID or a verified domain name) to restrict logins to that tenant. The common and organizations aliases are multi-tenant: any tenant in that cloud can obtain an ID token that passes validation. If you build a multi-tenant application, check the tid claim against the tenants you trust.

Security notes

  • The iss claim is validated against the issuer value of the provider's discovery document, not against OIDCConfig.issuer (they can differ, for example for Entra ID multi-tenant endpoints).
  • Refreshed ID tokens are validated before being stored; the nonce is also checked when the refreshed token carries one.
  • The none signing algorithm is rejected by default.
  • Only algorithms listed in OIDCConfig.allowed_id_token_algorithms are accepted.
  • Always use HTTPS for redirect_uri in production.

Supported flows

Only the Authorization Code Flow with PKCE is supported. Implicit and Hybrid flows are intentionally not implemented.

Supported algorithms

ID token signature verification supports: RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512, and EdDSA.

HMAC algorithms (HS*) are disabled by default and can be enabled by adding them to allowed_id_token_algorithms if your provider uses them.