Skip to content

fix(b2b_learner_records): verify the bearer JWT instead of trusting X-Userinfo - #69

Open
blarghmatey wants to merge 6 commits into
mainfrom
learner-records-jwt-verify
Open

blarghmatey wants to merge 6 commits into
mainfrom
learner-records-jwt-verify

Conversation

@blarghmatey

Copy link
Copy Markdown
Member

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.py decodes 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), and aws-eks-nodeagent runs with --enable-network-policy=false on both data clusters (checked 2026-09-18; infrastructure/aws/eks/__main__.py:414 sets it), so the clickhouse and jupyter-data NetworkPolicies 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. tenants/b2b_learner_records/token.py reads the bearer from Authorization (falling back to X-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 and exp/nbf/iat. require_organization_grant then takes scope, learner_records_organizations and azp from the verified payload. This tenant does not read X-Userinfo at 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-client carries the realm's signature, this issuer and this audience, so without typ it 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 forged kids 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_url and jwks_url from a single issuer, and the tenant refuses to start if issuer is 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 templates issuer and audience out of secret-operations/sso/ol-analytics-api, the same Vault entry the route's openid-connect plugin reads for its discovery URL and client id, so the gateway and the app can't end up checking different realms.

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, not a follow-up to this one.

How can this be tested?

  • uv run pytest: 312 passed. tests/test_learner_records_token.py covers a valid token accepted; a forged X-Userinfo alone refused; a forged X-Userinfo failing to widen a valid narrow token; wrong audience, wrong issuer, expired, not-yet-valid, missing exp, missing typ, an ID token, missing kid, 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; the X-Access-Token path accepted and verified; and the caching, rotation-refetch, cooldown, TTL, stale-fallback, unreachable-JWKS and unusable-key-set behaviour.
  • tests/conftest.py mints real RS256 tokens from a test realm, so tests/test_learner_records.py now 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, and uv run mypy src: clean.
  • Not verified against a real Keycloak token. The APISIX header behaviour was read out of the openid-connect plugin source at 3.18.0, not observed on QA: rewrite clears a client-supplied X-Userinfo/X-Access-Token, and the plugin only ever writes Authorization when access_token_in_authorization_header is set, which this route does not set. Worth confirming on QA with a real client-credentials token once #5939 is applied, including that Keycloak emits typ: "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-Token as well as Authorization is 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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Medium severity

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.

Comment thread docs/openapi/b2b-learner-records-v1.yaml
Comment thread src/ol_analytics_api/tenants/b2b_learner_records/token.py
blarghmatey and others added 6 commits September 30, 2026 14:34
…-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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants