fix(b2b_learner_records): verify the bearer JWT instead of trusting X-Userinfo - #69
Open
blarghmatey wants to merge 6 commits into
Open
blarghmatey wants to merge 6 commits into
blarghmatey wants to merge 6 commits into
Conversation
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Failed JWKS refreshes can incorrectly suppress recovery after key rotation, and the documented 401 body does not match runtime behavior.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 2
Open (2)
What changed in this PR
Adds application-level JWT verification for learner-record endpoints, removing their reliance on trusted X-Userinfo headers.
Changes:
- Verifies RS256 tokens against cached realm JWKS.
- Authorizes using verified scope and organization claims.
- Adds configuration, documentation, and comprehensive authentication tests.
| File | Description |
|---|---|
src/ol_analytics_api/tenants/b2b_learner_records/token.py |
Implements JWT verification and JWKS caching. |
src/ol_analytics_api/tenants/b2b_learner_records/config.py |
Adds issuer, audience, and JWKS settings. |
src/ol_analytics_api/tenants/b2b_learner_records/auth.py |
Authorizes from verified token claims. |
tests/conftest.py |
Provides signing keys and JWT fixtures. |
tests/test_learner_records.py |
Migrates endpoint tests to signed tokens. |
tests/test_learner_records_token.py |
Tests verification, caching, and failure handling. |
pyproject.toml |
Adds JWT and cryptography dependencies. |
uv.lock |
Locks new cryptographic dependencies. |
README.md |
Documents the revised trust boundary. |
docs/openapi/b2b-learner-records-v1.yaml |
Updates authentication API documentation. |
docs/b2b-learner-records-provider-authorization.md |
Records the authorization decision. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
blarghmatey
force-pushed
the
learner-records-jwt-verify
branch
from
September 22, 2026 18:25
763f8ea to
b267cd0
Compare
blarghmatey
added this pull request to stack #74
September 25, 2026 20:22
blarghmatey
force-pushed
the
learner-records-jwt-verify
branch
from
September 30, 2026 14:06
b267cd0 to
1d1ad8b
Compare
…-Userinfo The tenant authorized from X-Userinfo, which APISIX rebuilds from a token it has already validated. That only says anything about traffic that went through APISIX, and nothing forces a caller to: the pod security group admits the whole pod subnet, and aws-eks-nodeagent runs with --enable-network-policy=false on both data clusters, so the NetworkPolicies that exist are no-ops. A compromised in-cluster workload could post a forged X-Userinfo carrying learner-records:read and any organization UUID and read identifiable learner records. This verifies the token in the app instead. token.py checks RS256 against the olapps realm JWKS, with issuer, audience and lifetime, and require_organization_grant reads scope, learner_records_organizations and azp from the verified payload. X-Userinfo is not read by this tenant at all. Anything that fails verification gets one 401 that does not say which check failed. The key set is cached for a TTL and refetched once on an unseen kid, so a realm key rotation costs a fetch rather than a TTL of refusals; a cooldown keeps forged kids from turning the endpoint into an amplifier pointed at Keycloak. An unreachable JWKS refuses rather than falling back. Config derives the token, JWKS and issuer URLs from one issuer setting, so moving a deployment to another realm is one environment variable. b2b_dashboard still reads X-Userinfo. Its exposure is aggregate and k-anonymized and it has a browser session flow, so that is a separate call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qoz9pfQxUU8VLsRxr1tTnm
The security scheme said the credential is 'validated at the gateway' and the 401 example named a detail string the tenant no longer returns. Both describe the behaviour before the tenant started verifying the bearer JWT itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qoz9pfQxUU8VLsRxr1tTnm
…lass Review findings on the verification added in the previous commit. A JWKS body that parses as JSON but holds no usable key (an empty set, an error document served as 200, a bare list) raised out of the dependency as a 500 rather than the 401 it exists to return, and since the fetch never populated the cache it recurred on every request. _fetch now turns every way the endpoint can go wrong into one JWKSUnavailableError. A fetch failure was neither held off nor backed by the key set already in hand, so once the TTL lapsed with Keycloak unreachable every request took the lock and paid the timeout in turn while a still-valid key set sat unused. Failures now hold off further attempts briefly, and a stale key set is served through an outage: realm keys turn over on the order of months, so refusing every request because a refresh failed is an outage this service inflicts on itself. An ID token minted for ol-analytics-api-client carries the realm's signature, this issuer and this audience, so it cleared verification and was stopped only by carrying no organization claim. The token class is now required and checked. Also: a forced load no longer refetches what another request just fetched; the unknown-kid cooldown starts only once a freshly fetched key set really lacked the kid, so a failed refetch doesn't lock every other kid out for a minute; and the tenant refuses to start when the issuer is left at the production realm in another deployed environment, since every failure mode of that setting is a silent 401. The concurrency test pinned the cache rather than the lock -- a mocked transport never suspends, so the gathered requests serialised themselves and it passed with the lock removed. It now fetches through a callback that awaits. Added cases for the token class, for HS256 signed with the public key out of the JWKS, for the multi-audience shape Keycloak actually emits, and for the unusable-key-set bodies above. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qoz9pfQxUU8VLsRxr1tTnm
…fetch A forced refetch that fails hands back the stale key set rather than raising, so the unknown kid still isn't found and the cooldown started on evidence nobody gathered. The comment there already claimed this didn't happen. The case it breaks is a Keycloak blip during a key rotation: the refetch fails, the cooldown starts, and tokens carrying the new kid keep getting 401s for a minute after Keycloak comes back, with no refetch attempted. Now the cooldown starts only when _fetched_at advanced, which is what says a freshly fetched key set really lacked the kid. A failed fetch is already held off by _retry_after. The regression test fails against the previous code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qoz9pfQxUU8VLsRxr1tTnm
…ys required
The Error schema in docs/openapi/b2b-learner-records-v1.yaml declares every
error response as {code, detail}, with code required and drawn from a fixed
enum, and tells clients to branch on it because the wording of detail may
change. Nothing produced it. FastAPI's HTTPException renders {"detail": ...},
so a client generated from the spec would reject every error this tenant
returned, on all five documented statuses.
errors.py adds ApiError, carrying the code, plus the handler that renders it.
The code can't be derived from the status, which is why it rides on the
exception: 403 is missing_scope when the token's scopes fall short and
no_organization_access when the organization isn't granted, and those stay
distinct to a client while remaining indistinguishable to an attacker, since
both keep the same detail.
400 (invalid_parameter), 401 (unauthorized), both 403s and 503 (unavailable)
now carry their code. Errors the contract doesn't document, a 404 for an
unrouted path or a 405, keep FastAPI's own body rather than being dressed in
a code that claims a contract covering them.
Scoped to this tenant. b2b_dashboard has no published contract to honour, so
adding a field to its error bodies would be a change with no reader. 429 is
enforced at APISIX, not here, so nothing in this service produces that body.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qoz9pfQxUU8VLsRxr1tTnm
… main Two things #70 changed under this branch. _partner_header became _partner_token here while #70 added a test that calls it. Both sides touched different parts of the file, so the rebase merged them cleanly and the result referenced a helper that no longer exists. against the ErrorCode enum rather than the contract the enum exists to mirror. It now reads the enum out of docs/openapi/b2b-learner-records-v1.yaml, so a code added on one side and not the other fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qoz9pfQxUU8VLsRxr1tTnm
blarghmatey
force-pushed
the
learner-records-jwt-verify
branch
from
September 30, 2026 18:34
1d1ad8b to
544fdd5
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

What are the relevant tickets?
N/A. Copilot raised this on mitodl/ol-infrastructure#5939. Implements the decision now recorded in
docs/b2b-learner-records-provider-authorization.md.Description (What does it do?)
The learner-records tenant authorized entirely from
X-Userinfo.core/auth/userinfo.pydecodes whatever header arrives, and APISIX rebuilds that header only for traffic that goes through APISIX. Nothing forces a caller to: the pod security group admits the whole pod subnet (applications/ol_analytics_api/__main__.py:412,get_default_psg_ingress_args), andaws-eks-nodeagentruns with--enable-network-policy=falseon both data clusters (checked 2026-09-18;infrastructure/aws/eks/__main__.py:414sets it), so the clickhouse and jupyter-data NetworkPolicies are no-ops. A compromised in-cluster workload could post a forgedX-Userinfocarryinglearner-records:readand any organization UUID and read identifiable learner records.This verifies the token in the app.
tenants/b2b_learner_records/token.pyreads the bearer fromAuthorization(falling back toX-Access-Token, which the gateway sets and its own plugin accepts as a bearer), checks RS256 against the realm JWKS, and checks issuer, audience, token class andexp/nbf/iat.require_organization_grantthen takesscope,learner_records_organizationsandazpfrom the verified payload. This tenant does not readX-Userinfoat all. Every failure gets the same 401, which doesn't say which check failed.The token class matters more than it looks: an ID token minted for
ol-analytics-api-clientcarries the realm's signature, this issuer and this audience, so withouttypit clears verification and is stopped only by carrying no organization claim. That is one claim deep.The key set is cached for a TTL and refetched once on an unseen
kid, so a realm key rotation costs one fetch rather than a TTL of 401s; a cooldown keeps forgedkids from turning the endpoint into an amplifier pointed at Keycloak, and it starts only once a freshly fetched set really lacked the kid. A fetch failure holds off further attempts briefly and falls back on the key set already in hand. Realm keys turn over on the order of months, so refusing every request because a refresh failed would be an outage this service inflicted on itself. With no key set at all it refuses. Anything the endpoint can answer with that isn't a usable key set is a 401, not a 500.Config derives
token_urlandjwks_urlfrom a singleissuer, and the tenant refuses to start ifissueris left at the production realm in any other deployed environment, because every failure mode of that setting is a silent 401 that reads like a bad credential. mitodl/ol-infrastructure#5939 templatesissuerandaudienceout ofsecret-operations/sso/ol-analytics-api, the same Vault entry the route'sopenid-connectplugin reads for its discovery URL and client id, so the gateway and the app can't end up checking different realms.b2b_dashboardstill readsX-Userinfo. Its exposure is aggregate and k-anonymized and it has a browser session flow, so that is a separate call, not a follow-up to this one.How can this be tested?
uv run pytest: 312 passed.tests/test_learner_records_token.pycovers a valid token accepted; a forgedX-Userinfoalone refused; a forgedX-Userinfofailing to widen a valid narrow token; wrong audience, wrong issuer, expired, not-yet-valid, missingexp, missingtyp, an ID token, missingkid,alg=none, HS256 signed with the public key out of the JWKS, and a token signed by an impostor key all refused; the multi-audience shape Keycloak actually emits accepted; theX-Access-Tokenpath accepted and verified; and the caching, rotation-refetch, cooldown, TTL, stale-fallback, unreachable-JWKS and unusable-key-set behaviour.tests/conftest.pymints real RS256 tokens from a test realm, sotests/test_learner_records.pynow drives the tenant with signed tokens rather than a base64 claims blob. All 34 of its existing cases pass unchanged otherwise.uv run ruff check,ruff format --check, anduv run mypy src: clean.openid-connectplugin source at 3.18.0, not observed on QA:rewriteclears a client-suppliedX-Userinfo/X-Access-Token, and the plugin only ever writesAuthorizationwhenaccess_token_in_authorization_headeris set, which this route does not set. Worth confirming on QA with a real client-credentials token once #5939 is applied, including that Keycloak emitstyp: "Bearer"on these tokens, which this now requires.Additional Context
A security review pass ran against this branch before it opened. It found no way to authorize without a validly-signed, unexpired token for the right issuer and audience. The second commit is its other findings: the 500-instead-of-401 path, the fetch-failure pile-up, the missing token-class check, and a concurrency test that pinned the cache rather than the lock (it passed with the lock removed, because a mocked transport never suspends).
Reading the token from
X-Access-Tokenas well asAuthorizationis broader than strictly needed. It costs nothing (both go through the same signature check) and it keeps this working if the route is ever configured the other way, but it is worth an explicit look.Checklist:
🤖 Generated with Claude Code
https://claude.ai/code/session_01Qoz9pfQxUU8VLsRxr1tTnm