✨(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 15 commits into
Conversation
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.
51a49e9 to
33bb61a
Compare
|
Thanks for the review. |
|
Thanks @piptouque. I re-tested the branch locally at The three blocking points are resolved:
And the caching rework is exactly what I was hoping for: I verified point 2 and point 6 end to end against simulated IdP responses: The three valid cases behave correctly. The two remaining cases do not — see 🔴 1.
|
6027e9a to
75c2834
Compare
|
Thanks for the review, I think that's all for the fixes. |
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.
75c2834 to
2faa723
Compare
MYilFun00
left a comment
There was a problem hiding this comment.
Thanks @piptouque. I re-tested the current head after a local rebase onto main (53cc58c3). Lint is green and the 22 CI checks match that — but CI does not cover the upgrade path of an existing OIDC deployment, nor the Content-Type error branch.
The mechanical points from last round are done (print, ruff/black, CHANGELOG ≤ 80, cache_clear, rebase). The runtime bug from last round is not fixed, and the new flow introduces a regression on current main.
Please do not update-branch / merge until the points below are addressed. Suggested fixes below are the ones I validated locally: existing JWT deployments keep working, /userinfo + client-credentials still work, AUTH_CACHE_TTL stays 3600 for Basic auth.
🔴 1. HTTPException(error=...) — still raises TypeError (same as last review)
src/ralph/api/auth/oidc.py still passes error="invalid_request". FastAPI's signature is (status_code, detail=None, headers=None).
TypeError: HTTPException.__init__() got an unexpected keyword argument 'error'
An IdP returning text/html or application/jwt+json still gets a 500 + stacktrace instead of the intended 400. Still no test on that branch, which is why CI stays green.
Suggested fix:
if not is_jwt and not is_json:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"Invalid media type in header: {media_type}, "
"expected application/jwt or application/json"
),
headers={"WWW-Authenticate": "Bearer"},
)
body = response.text if is_jwt else response.json()
return (body, is_jwt)
except HTTPException:
raise
except requests.exceptions.RequestException as exc:
...Drop error=, split detail (clears E501), and re-raise HTTPException so it is not swallowed.
Test (the only branch of this function currently unexercised):
@pytest.mark.anyio
@responses.activate
async def test_api_auth_oidc_userinfo_invalid_content_type():
get_user_info_data.cache_clear()
responses.add(
responses.GET,
"https://idp.example/userinfo",
body="not json",
status=200,
headers={"Content-Type": "text/html"},
)
with pytest.raises(HTTPException) as exc_info:
get_user_info_data("https://idp.example/userinfo", "token")
assert exc_info.value.status_code == 400
assert "text/html" in exc_info.value.detail🔴 2. Existing OIDC deployments 500 after upgrade
On current main, get_oidc_user decodes the JWT locally via JWKS. This PR always calls /introspection with:
RUNSERVER_AUTH_OIDC_CLIENT_ID = None # default
RUNSERVER_AUTH_OIDC_CLIENT_SECRET = None
AttributeError: 'NoneType' object has no attribute 'encode'
Every authenticated request dies. Tests always monkeypatch CLIENT_ID/CLIENT_SECRET, so they never see this.
introspection_endpoint is also optional in OIDC Discovery. A generic IdP without it raises KeyError → 500. That contradicts the PR title.
Suggested behaviour (opt-in, no break):
def _can_introspect(provider_config: dict) -> bool:
"""True when Ralph has client credentials and the IdP exposes introspect."""
return bool(
settings.RUNSERVER_AUTH_OIDC_CLIENT_ID
and settings.RUNSERVER_AUTH_OIDC_CLIENT_SECRET
and provider_config.get("introspection_endpoint")
)
def get_oidc_user(...):
...
access_token = auth_header.split(" ")[-1]
provider_config = discover_provider(settings.RUNSERVER_AUTH_OIDC_ISSUER_URI)
if not _can_introspect(provider_config):
# Existing deployments: decode the JWT locally, no client credentials.
return _user_from_jwt(access_token, provider_config)
token_info = get_token_info(
provider_config["introspection_endpoint"],
token=access_token,
client_id=settings.RUNSERVER_AUTH_OIDC_CLIENT_ID,
client_secret=settings.RUNSERVER_AUTH_OIDC_CLIENT_SECRET,
)
...- Client id and secret and
introspection_endpointset → new flow (introspect, then/userinfofor users /client_idfor apps). - Otherwise → keep today's local JWT validation.
That is how existing Keycloak-style deployments survive, and how opaque-token / client-credentials support stays available once operators register Ralph as a client.
Test for the upgrade path (no client id/secret, JWT bearer token, IdP without introspection_endpoint):
async def test_api_auth_oidc_legacy_jwt_without_client_credentials(client, monkeypatch):
configure_env_for_mock_oidc_auth(monkeypatch, with_oidc_client=False)
oidc_token = mock_oidc_jwt_user(scopes=["all", "profile/read"])
response = await client.get("/whoami", headers={"Authorization": f"Bearer {oidc_token}"})
assert response.status_code == 200
assert response.json()["agent"]["openid"] == "https://iss.example.com/123|oidc"🔴 3. {iss}/{sub} → {iss}/user/{sub} breaks stored authority
On main the agent is f"{iss}/{sub}". This PR writes f"{iss}/user/{sub}".
That value is copied onto every statement as authority, and mine=true / LRS_RESTRICT_BY_AUTHORITY filter on it. After upgrade, previously stored statements no longer match the same user.
Suggested fix — keep {iss}/{sub} for end-users; /application/ only for the new client-credentials path:
if token_info.sub:
user_info = get_user_info(provider_config, access_token=access_token)
if user_info.sub != token_info.sub:
...
raise _unauthorized()
return AuthenticatedUser(
agent={"openid": f"{token_info.iss}/{user_info.sub}"},
scopes=get_user_scopes(user_info.scope),
target=user_info.target,
)
return AuthenticatedUser(
agent={"openid": f"{token_info.iss}/application/{token_info.client_id}"},
scopes=get_user_scopes(token_info.scope),
target=token_info.target,
)Also update the whoami assertion (and tests/api/test_statements_get.py) from {iss}/user/{sub} back to {iss}/{sub}.
🔴 4. Helm: RALPH_AUTH_OIDC_CACHE_TTL is gated on basic auth
Current:
{{- if .Values.lrs.auth.basic.enabled }}
RALPH_AUTH_CACHE_MAX_SIZE: {{ .Values.lrs.auth.basic.cacheMaxSize | quote }}
RALPH_AUTH_CACHE_TTL: {{ .Values.lrs.auth.basic.cacheTTL | quote }}
RALPH_AUTH_OIDC_CACHE_TTL: {{ .Values.lrs.auth.oidc.cacheTTL | quote }}
{{- end }}
{{- if .Values.lrs.auth.oidc.enabled }}
RALPH_RUNSERVER_AUTH_OIDC_AUDIENCE: ...
RALPH_RUNSERVER_AUTH_OIDC_ISSUER_URI: ...
{{- end }}Rendered: default (basic on, oidc off) sets an unused OIDC TTL; oidc on, basic off (this PR's target) omits the variable.
Suggested fix in src/helm/ralph/templates/cm_lrs.yaml:
{{- if .Values.lrs.auth.basic.enabled }}
RALPH_AUTH_CACHE_MAX_SIZE: {{ .Values.lrs.auth.basic.cacheMaxSize | quote }}
RALPH_AUTH_CACHE_TTL: {{ .Values.lrs.auth.basic.cacheTTL | quote }}
{{- end }}
{{- if .Values.lrs.auth.oidc.enabled }}
RALPH_AUTH_OIDC_CACHE_TTL: {{ .Values.lrs.auth.oidc.cacheTTL | quote }}
RALPH_RUNSERVER_AUTH_OIDC_AUDIENCE: {{ .Values.lrs.authOIDCAudience | quote }}
RALPH_RUNSERVER_AUTH_OIDC_ISSUER_URI: {{ .Values.lrs.authOIDCIssuerURI | quote }}
{{- end }}🟠 5. AUTH_CACHE_TTL 3600 → 36000 (not 60, not 3600)
You did split AUTH_OIDC_CACHE_TTL = 60 — thank you, that was the request.
But Basic auth now defaults to 10 hours (36000), while values.yaml still has cacheTTL: 3600. Revoking a user in auth.json can take 10 h to take effect.
Suggested fix in src/ralph/conf.py — restore the Basic default, keep the OIDC split:
AUTH_CACHE_MAX_SIZE: int = 100
AUTH_CACHE_TTL: int = 3600
AUTH_OIDC_CACHE_TTL: int = 60Drop the CHANGELOG line « changed default TTL of cache to 60 seconds » (wrong for Basic, trailing space).
🟡 6. The headline feature has no test
tests/api/auth/test_oidc.py still has the same 7 functions. The agent={"openid": ".../application/{client_id}"} path (token with no sub) is never exercised.
Suggested test:
@pytest.mark.anyio
@responses.activate
async def test_api_auth_oidc_client_credentials_application_agent(client, monkeypatch):
"""A token without `sub` is treated as a client application."""
configure_env_for_mock_oidc_auth(monkeypatch)
oidc_token = mock_oidc_user(sub=None, scopes=["all", "profile/read"])
response = await client.get(
"/whoami",
headers={"Authorization": f"Bearer {oidc_token}"},
)
assert response.status_code == 200
assert response.json()["agent"] == {
"openid": "https://iss.example.com/application/my-other-client-id",
"objectType": "Agent",
}(_mock_oidc_token_info must omit sub when it is None.)
CHANGELOG: move the two Fix … lines into the existing ### Fixed section (they currently sit under a second ### Changed):
### Fixed
- Fix type of OIDC ID tokens (`aud` may be a list, `exp`/`iat` may be floats)
- Ignore OIDC scopes unrelated to Ralph instead of returning an error
### Changed
- OIDC: query `/userinfo` for access tokens that are not JWTs
- OIDC: introspect tokens to support client-credentials apps as usersHappy to re-review once those are in.
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.
Using a Time-To-Live (TTL) to invalidate the responses of IdP.
Now using one minute for credentials and responses from/to IdP.
openfun#633 (review) Added some tests for `/userinfo` endpoint.
2faa723 to
411d8aa
Compare
Add `cacheTTL` value for `lrs.auth.oidc`.
openfun#633 (review) Added some tests for `/userinfo` endpoint.
Now these function expect a complete `auth_header`, starting with `Basic` or `Bearer` depending on the token type.
That test was missing `@responses.activate`, which meant that the reported error was not the right one. It also made it leek out in tests that were run afterwards.
4a26ee6 to
7e537a6
Compare
985fda8 to
c5945b8
Compare
Specifically refactor the auth guards in OIDC fixtures in order to reuse them.
These should cover the basic handling to our IdP's `/userinfo` and `/introspection` endpoints.
c5945b8 to
c060cc9
Compare
If `CLIENT_ID`, `CLIENT_SECRET` are not set, or that the IdP did not advertise an `/introspection` endpoint, we assume that the access token we were given is a JWT ID Token and can be decoded to get `UserInfo`.
We now check the `/whoami` endpoint with: - an OIDC user authenticated via `/userinfo` IdP endpoint - an OIDC user authenticated directly by ID token - an OIDC client
Using `ruff` and `black`.
c060cc9 to
07fecef
Compare
commit 07fecef Author: piptouque <pierre.thiel@touque.fr> Date: Wed Sep 9 12:22:53 2026 +0200 🚨(tests,oidc) run linter and formatter Using `ruff` and `black`. commit c77498f Author: piptouque <pierre.thiel@touque.fr> Date: Tue Sep 8 18:31:41 2026 +0200 ✅(tests,oidc) update the OIDC `/whoami` validity test We now check the `/whoami` endpoint with: - an OIDC user authenticated via `/userinfo` IdP endpoint - an OIDC user authenticated directly by ID token - an OIDC client commit 0644d33 Author: piptouque <pierre.thiel@touque.fr> Date: Tue Sep 8 18:24:53 2026 +0200 🐛(oidc) add back support for ID tokens as access token If `CLIENT_ID`, `CLIENT_SECRET` are not set, or that the IdP did not advertise an `/introspection` endpoint, we assume that the access token we were given is a JWT ID Token and can be decoded to get `UserInfo`. commit 5ea3419 Author: piptouque <pierre.thiel@touque.fr> Date: Tue Sep 8 16:18:59 2026 +0200 ✅(tests,oidc) add user_info and intropection tests These should cover the basic handling to our IdP's `/userinfo` and `/introspection` endpoints. commit e249ff5 Author: piptouque <pierre.thiel@touque.fr> Date: Tue Sep 8 16:18:31 2026 +0200 ♻️(tests,oidc) refactor fixtures Specifically refactor the auth guards in OIDC fixtures in order to reuse them. commit 715f6a0 Author: piptouque <pierre.thiel@touque.fr> Date: Sun Sep 6 13:07:37 2026 +0200 🐛(tests) fix missing responses in oidc test That test was missing `@responses.activate`, which meant that the reported error was not the right one. It also made it leek out in tests that were run afterwards. commit 6e4e6c7 Author: piptouque <pierre.thiel@touque.fr> Date: Sat Sep 5 18:31:29 2026 +0200 ♻️(oidc) refactor OIDC auth args Now these function expect a complete `auth_header`, starting with `Basic` or `Bearer` depending on the token type. commit 9ef1d9d Author: piptouque <pierre.thiel@touque.fr> Date: Tue Sep 8 13:28:14 2026 +0200 🐛(oidc) fix issues with PR from review openfun#633 (review) Added some tests for `/userinfo` endpoint. commit e75790a Author: piptouque <pierre.thiel@touque.fr> Date: Fri Sep 4 16:33:26 2026 +0200 🔧(auth) update helm chart Add `cacheTTL` value for `lrs.auth.oidc`. commit aa2e7cb Author: piptouque <pierre.thiel@touque.fr> Date: Fri Sep 4 16:31:28 2026 +0200 🔧(auth) change auth caching values Now using one minute for credentials and responses from/to IdP. commit 0e084c0 Author: piptouque <pierre.thiel@touque.fr> Date: Tue Aug 4 15:13:00 2026 +0200 ✨(oidc) improve caching of IdP responses Using a Time-To-Live (TTL) to invalidate the responses of IdP. commit 8ff7aa1 Author: piptouque <pierre.thiel@touque.fr> Date: Tue Feb 17 17:27:53 2026 +0100 ✨(oidc) add support for client apps 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 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. commit 325ffcc Author: piptouque <pierre.thiel@touque.fr> Date: Tue Feb 17 15:04:13 2026 +0100 ✨(oidc) get user claims from /userinfo endpoint 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. commit 7a66dae Author: piptouque <pierre.thiel@touque.fr> Date: Wed Feb 18 19:23:55 2026 +0100 🐛(oidc) fix ignore unrelated scopes The server should ignore any OAuth 2 scope that it does not know, instead of returning an error. commit ad42220 Author: piptouque <pierre.thiel@touque.fr> Date: Wed Feb 18 18:47:43 2026 +0100 🐛(oidc) fix types of id token 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
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