✨(oidc) add support for generic OIDC IdPs and OIDC clients as users #633
✨(oidc) add support for generic OIDC IdPs and OIDC clients as users #633piptouque wants to merge 4 commits into
Conversation
The 'aud' claim may be a list: https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3 The 'exp' and 'iat' claims are `NumericDate` and may be floats: https://www.rfc-editor.org/rfc/rfc7519#section-2
The server should ignore any OAuth 2 scope that it does not know, instead of returning an error.
The ID token and access token are different and have different purpose. The ID token is always a JWT and contains user claims, but the access token may not be. With this change, we get the user claims using the access token, using the /userinfo OIDC enpoint, allowing us to support providers that return opaque access tokens.
Client applications, as authenticated with the 'client_credentials' flow, do not have a dedicated user. As such, we can't get ID tokens for them, always opaque access tokens. Instead, we authenticate them using their client_id. But first, we need to check whether the access token we got was from a real oauth2 user or a client app. To do that, we query the /introspection endpoint of our IdP. This requires that Ralph be registered s another client app to our IdP, and to have configured its client ID and secret.
MYilFun00
left a comment
There was a problem hiding this comment.
Thanks for this OIDC refactor — the move to introspection + /userinfo, Client Credentials support, and the scopes fix are solid improvements. However I found several blocking issues during local review:
Blocking
1. src/ralph/api/auth/oidc.py — get_token_info return type hint is wrong
Current implementation:
def get_token_info(...) -> UserInfo:
...
return TokenInfo.model_validate(token_info)The function returns TokenInfo, not UserInfo.
Suggested fix: change the annotation to -> TokenInfo.
2. tests/fixtures/auth.py + get_user_info_data — JWT /userinfo handling
Current mock:
return (200, {"Content-Type": "application/jwt"}, json.dumps(encoded_user_info))Current production (get_user_info_data):
content_type = response.headers["Content-Type"]
return (response.json(), content_type.lower() == "application/jwt")A real /userinfo endpoint with Content-Type: application/jwt returns the raw JWT in the response body. The current code calls response.json() unconditionally — this fails on a real IdP (JSONDecodeError). The mock passes by accident because json.dumps(jwt) makes response.json() return a Python string.
Both fixes are required together (tested locally):
Suggested fix (mock):
return (200, {"Content-Type": "application/jwt"}, encoded_user_info)Suggested fix (production — get_user_info_data):
content_type = response.headers["Content-Type"]
media_type = content_type.split(";", 1)[0].strip().lower()
is_jwt = media_type == "application/jwt"
body = response.text if is_jwt else response.json()
return (body, is_jwt)Local test results (current vs fix):
| Content-Type | Body | Current code | Fix (mock + prod) |
|---|---|---|---|
application/json |
JSON dict | ✅ dict | ✅ dict |
application/jwt |
raw JWT | ❌ JSONDecodeError |
✅ JWT string → jwt.decode OK |
application/jwt; charset=utf-8 |
raw JWT | ❌ JSONDecodeError |
✅ JWT string |
application/jwt+json |
raw JWT | ❌ JSONDecodeError |
❌ (non-standard type, expected) |
response.text on json.dumps(jwt) returns "eyJ..." (with JSON quotes) → jwt.decode fails. Both changes must land together.
3. src/ralph/api/auth/oidc.py L202 — f-string syntax breaks Python < 3.12
The multiline f-string in the Authorization header raises SyntaxError on Python 3.10/3.11. Project requires >=3.9, CI tests 3.9→3.12. Module import fails locally.
Suggested fix:
basic = encode_client_secret_basic_token(client_id=client_id, client_secret=client_secret)
headers={"Authorization": f"Basic {basic}"}Non-blocking (nice to have / discuss before prod)
4. @lru_cache() keyed by access token — tested behaviour
Verified locally: @lru_cache() defaults to maxsize=128 (not unbounded). After 130 distinct tokens, currsize=128 (LRU eviction). Same token twice → cache hit (useful for repeated requests on one connection).
Remaining risks:
- A revoked token can stay "valid" in cache until evicted (no TTL).
- Up to 128 token introspection/userinfo results kept in memory.
Suggested fix (minimal): remove @lru_cache() from token-specific functions; keep it only on stable data:
@lru_cache(maxsize=1) # discover_provider — OK (issuer config)
def discover_provider(...): ...
@lru_cache(maxsize=1) # get_public_keys — OK (JWKS rotates rarely)
def get_public_keys(...): ...
# NO cache on get_token_info / get_user_info_data — tokens are short-lived
def get_token_info(...): ...
def get_user_info_data(...): ...Suggested fix (if caching is desired): explicit bounded cache with TTL ≤ token lifetime:
@lru_cache(maxsize=128) # document the choice
def get_token_info(...): ...
# + add cache_clear() in tests (already done for basic auth in fixtures)Or a TTL cache (e.g. 60s) so revoked tokens are not served stale for long.
5. Double network call /introspect + /userinfo — flow analysis
Current flow (get_oidc_user):
- User token (
token_info.subset):POST /introspect→GET /userinfo→ comparesub - Client credentials (
subabsent):POST /introspectonly ✅
So client-app auth is already optimized (1 call). User auth always costs 2 IdP round-trips vs 1 JWT decode on old main.
Suggested optimizations (pick one):
- A (simplest): if introspection already returns
scope+target+sub, skip/userinfowhen all Ralph claims are present (config flagRUNSERVER_AUTH_OIDC_SKIP_USERINFO_IF_COMPLETE=true). - B (safe default): keep 2 calls but cache both per token with short TTL (see #4).
- C (document only): accept 2 calls as OIDC-correct, document IdP latency impact in
oidc.md.
The sub cross-check between introspection and userinfo is valuable — do not remove without replacement.
6. Content-Type: application/jwt detection — tested and verified
Prefer parsing the media type over startswith (avoids false positive on application/jwt+json):
| Content-Type | == "application/jwt" |
startswith("application/jwt") |
split(";")[0] parse |
|---|---|---|---|
application/jwt |
✅ | ✅ | ✅ |
application/jwt; charset=utf-8 |
❌ fails | ✅ (works) | ✅ (works) |
application/jwt+json |
❌ | ✅ correctly rejects |
The recommended parse + response.text fix is verified locally against simulated IdP responses. See blocking issue #2 above for the complete production code — it is the same fix, and must be paired with the mock correction.
Do not use startswith("application/jwt") alone — it would treat application/jwt+json as JWT.
7. conf.py formatting
Current:
BASE_SETTINGS_CONFIG = SettingsConfigDict(
case_sensitive=True, env_nested_delimiter="__", env_prefix="RALPH_", extra="ignore"
, secrets_dir=os.environ.get("RALPH_SECRETS_DIR")
)Suggested fix:
BASE_SETTINGS_CONFIG = SettingsConfigDict(
case_sensitive=True,
env_nested_delimiter="__",
env_prefix="RALPH_",
extra="ignore",
secrets_dir=os.environ.get("RALPH_SECRETS_DIR"),
)8. Long parametrize line in test_oidc.py
Line 23 is a single 109-char entry (readable but dense). Split for lint/readability:
@pytest.mark.parametrize(
"runserver_auth_backends,userinfo_response_type",
[
([AuthBackend.BASIC, AuthBackend.OIDC], "jwt"),
([AuthBackend.OIDC], "plain"),
([AuthBackend.OIDC], "jwt"),
],
)Once the blocking issues above are addressed, please rebase on current main (includes CI fixes + #630/#632) before re-requesting review.
MYilFun00
left a comment
There was a problem hiding this comment.
Request changes
Thanks for this OIDC refactor — the move to token introspection combined with /userinfo, the addition of Client Credentials support, and the scope handling improvements are all valuable changes.
However, during my local review, I identified several blocking issues, along with a few points worth discussing before production deployment.
🔴 Blocking
1. src/ralph/api/auth/oidc.py — Incorrect return type annotation for get_token_info
Current implementation:
def get_token_info(...) -> UserInfo:
...
return TokenInfo.model_validate(token_info)
The function returns a TokenInfo instance, not a UserInfo instance.
Suggested fix:
Update the return type annotation to:
def get_token_info(...) -> TokenInfo:
2. tests/fixtures/auth.py — JWT /userinfo mock does not reflect the real endpoint behavior
Current implementation:
return (
200,
{"Content-Type": "application/jwt"},
json.dumps(encoded_user_info),
)
A real /userinfo endpoint returning Content-Type: application/jwt sends the raw JWT in the response body, not a JSON-encoded string.
The current mock causes the tests to pass accidentally because response.json() parses "eyJ..." into a Python string.
Suggested fix (test fixture):
Return encoded_user_info directly, without json.dumps().
Suggested fix (production code – get_user_info_data):
When the response content type is application/jwt, use response.text instead of response.json().
3. src/ralph/api/auth/oidc.py (L202) — Multiline f-string is incompatible with Python < 3.12
The multiline f-string used to build the Authorization header raises a SyntaxError on Python 3.9–3.11.
Since the project supports Python >= 3.9 and the CI pipeline runs against Python 3.9 through 3.12, the module cannot be imported on supported versions.
Suggested fix:
basic = encode_client_secret_basic_token( client_id=client_id, client_secret=client_secret, )
headers = {"Authorization": f"Basic {basic}"}
🟡 Non-blocking / Discussion points
4. @lru_cache() on access tokens — verified behavior
Verified locally:
@lru_cache()defaults tomaxsize=128(it is not unbounded).After 130 distinct access tokens,
currsize=128and LRU eviction occurs.Reusing the same token correctly results in a cache hit.
While the implementation works, a few concerns remain:
A revoked token may continue to be considered valid until it is evicted (no TTL).
Up to 128 introspection/userinfo results may remain cached in memory.
Suggested minimal fix:
Keep caching only for stable provider metadata:
@lru_cache(maxsize=1)
def discover_provider(...):
...@lru_cache(maxsize=1)
def get_public_keys(...):
...No cache for token-specific data
def get_token_info(...):
...
def get_user_info_data(...):
...
If caching is intentional:
Use an explicit bounded cache and document the choice:
@lru_cache(maxsize=128)
def get_token_info(...):
...
or use a short TTL cache (e.g. 60 seconds) to reduce the risk of serving stale results for revoked tokens.
5. Double network call (/introspect + /userinfo) — flow analysis
Current get_oidc_user() flow:
User access token (token_info.sub present)
POST /introspect
GET /userinfo
Compare sub
Client Credentials (sub absent)
POST /introspect only
The Client Credentials flow is already optimized (single network call).
However, user authentication always requires two IdP round trips, whereas the previous implementation only decoded the JWT locally.
The sub consistency check between introspection and /userinfo is valuable and should not be removed without an equivalent safeguard.
Possible improvements:
Option A (simplest): If introspection already provides all required Ralph claims (
scope,target,sub, etc.), skip/userinfo(possibly behind a configuration flag such asRUNSERVER_AUTH_OIDC_SKIP_USERINFO_IF_COMPLETE=true).Option B: Keep both requests, but cache them per access token with a short TTL (see point #4).
Option C: Keep the current behavior and document the additional IdP latency in
oidc.md.
6. Content-Type: application/jwt detection
Verified locally:
| Content-Type | == "application/jwt" | startswith("application/jwt") | Media type parsing |
|---|---|---|---|
| application/jwt | ✅ | ✅ | ✅ |
| application/jwt; charset=utf-8 | ❌ | ✅ | ✅ |
| application/jwt+json | ❌ | ❌ | ✅ |
Using startswith() may incorrectly match unexpected media types.
Suggested fix:
Parse the media type before comparing:
media_type = content_type.split(";", 1)[0].strip().lower()is_jwt = media_type == "application/jwt"
if is_jwt:
body = response.text
else:
body = response.json()
return (body, is_jwt)
Verified locally:
A raw JWT body cannot be parsed with
json.loads().The current mock using
json.dumps(jwt)unintentionally hides this issue.
7. conf.py formatting
Current:
BASE_SETTINGS_CONFIG = SettingsConfigDict(
case_sensitive=True, env_nested_delimiter="", env_prefix="RALPH_", extra="ignore"
, secrets_dir=os.environ.get("RALPH_SECRETS_DIR")
)
Suggested fix:
BASE_SETTINGS_CONFIG = SettingsConfigDict(
case_sensitive=True,
env_nested_delimiter="",
env_prefix="RALPH_",
extra="ignore",
secrets_dir=os.environ.get("RALPH_SECRETS_DIR"),
)
8. test_oidc.py — Long parametrize entry
One of the parametrized entries exceeds the project's preferred line length.
For readability, consider formatting it as:
@pytest.mark.parametrize(
"runserver_auth_backends,userinfo_response_type",
[
([AuthBackend.BASIC, AuthBackend.OIDC], "jwt"),
([AuthBackend.OIDC], "plain"),
([AuthBackend.OIDC], "jwt"),
],
)
Once the blocking issues above have been addressed, please rebase this branch onto the current main (which already includes the CI fixes as well as PRs #630 and #632) before requesting another review.
Purpose
This PR contains multiple changes regarding OIDC
Fixes
NumericDateand may be floatsChanges
Support for generic OIDC IdPs
More specifically, support for IdPs that return a token that is not a JWT.
The ID token and access token are different and have different purpose.
The ID token is always a JWT and contains user claims, but the access token may not be.
With this change, we get the user claims using the access token,
using the
/userinfoOIDC endpoint, allowing us to support providers that return opaque access tokens.Support OIDC clients as 'users'
Client applications, as authenticated with the 'client_credentials' flow, do not have a dedicated user.
As such, we can't get ID tokens for them, always opaque access tokens.
Instead, we authenticate them using their
client_id.But first, we need to check whether the access token we got was from a real Oauth2 user or a OIDC client application.
To do that, we query the
/introspectionendpoint of our IdP.This requires that Ralph be registered as another client app to our IdP, and to have configured its client ID and secret.
Proposal
IDTokennot following specs onaud(may be a list),expandiat(may be floats)/introspectionendpoint when receiving an OIDC access token to determine if it comes from a ODIC client application or a user/userinfoendpoint when receiving an OIDC access token if that token represents a userCHANGELOG.md