# 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 ```python 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`: ```python 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. ```python 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'`: ```python 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. ## Back-channel logout Set `backchannel_logout_path` to accept OIDC Back-Channel Logout tokens from the provider: ```python OIDCConfig( issuer=os.environ['OIDC_ISSUER'], client_id=os.environ['OIDC_CLIENT_ID'], client_secret=os.environ['OIDC_CLIENT_SECRET'], redirect_uri='https://example.com/auth/callback', backchannel_logout_path='/auth/backchannel-logout', ) ``` Register the corresponding absolute URL (for example `https://example.com/auth/backchannel-logout`) as the client's back-channel logout URI with the provider. This registration happens at the provider; it is not part of OIDC discovery. With `OIDCProviders`, enabling the option derives `{base_path}/{provider}/backchannel-logout` for that provider. The endpoint accepts form-encoded `POST` requests containing `logout_token`. It validates the JWT signature against the provider JWKS, the `logout+jwt` type, issuer, audience, `iat`, `jti`, and the back-channel logout event, and rejects tokens containing `nonce`. A token with `sid` logs out sessions for that provider session; without `sid`, `sub` logs out all sessions for that subject. Under `multi-identity`, only the named provider's identity is removed from a shared session. Valid requests return `200` with `Cache-Control: no-store`, including when the session is already gone. Invalid requests return `400`. Back-channel logout requires a session store with index support. All built-in stores (`InMemorySessionStore`, `RedisSessionStore`, and `MemcacheSessionStore`) support it. `OIDCMixin` raises immediately if the configured store does not. Sessions created before upgrading are indexed on their next request. Logout tokens must be asymmetrically signed; unsigned tokens, HMAC signatures, and encrypted logout tokens (JWE) are not supported. `jti` is required, but no replay cache is maintained: repeating a valid logout request is idempotent. `backchannel_logout_leeway` controls the allowed clock skew (60 seconds by default). For a templated Microsoft Entra issuer, the logout token must carry `tid`, just as for ID token validation. ## 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. ```python 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`) - Back-channel logout (`sid` and `sub` session invalidation) - 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//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: ```python OIDCConfig( issuer='https://login.microsoftonline.com//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.