CI / Build Pip package (push) Successful in 8m49s
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.
228 lines
8.6 KiB
Markdown
228 lines
8.6 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.
|
|
|
|
## 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`)
|
|
- 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.
|