273 lines
11 KiB
Markdown
273 lines
11 KiB
Markdown
# 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/<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:
|
|
|
|
```python
|
|
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.
|