Files
Patrick Buckley 3e88c54751 test(mcp): check in oauth_obo e2e harnesses under scripts/obo-e2e
Manual (non-CI) harnesses that exercise the real oauth_obo mint path
against a live IdP, kept for future validation of the feature:

- entra_e2e.py: real Entra tenant, one interactive sign-in, drives
  get_obo_access_token_classified -> _obo_mint_entra (E1-E7)
- keycloak_e2e.py + .sh: ephemeral Keycloak, fully headless, drives the
  rfc8693 leg (refresh grant -> token exchange)
- entra_spike.py: raw-OAuth wire probe (pre-implementation reference)
- entra_setup.sh: creates the Entra spike app registrations
- .env.example template; real creds stay in a gitignored .env

Both legs pass E1-E7 (mint + aud, cache hit, single-credential->multi-
audience, rotation write-back, force_refresh, unconsented->credential
survives, flush->re-mint). Not wired into CI.

Refs #551.
2026-07-12 19:03:35 -07:00

12 KiB
Raw Permalink Blame History

OBO e2e harnesses — single-credential MCP token minting (auth_type=oauth_obo)

Manual test harnesses for the oauth_obo feature (issue #551). They exercise the real Turnstone mint path (get_obo_access_token_classified_obo_mint_entra / _obo_mint_rfc8693) against a real identity provider — not mocks, not the unit suite. Two grant legs:

  • Entra (entra_e2e.py) — real tenant, one interactive sign-in.
  • Keycloak / RFC 8693 (keycloak_e2e.py + .sh) — ephemeral docker, fully headless.

There is also entra_spike.py (raw-OAuth wire probe, pre-implementation reference) and entra_setup.sh (creates the Entra app registrations + writes a populated .env).

Secrets: these read config from env. Real credentials live in a gitignored .env (copy .env.example); nothing tenant-specific is committed. The only literal secret in the tree is the ephemeral Keycloak container's throwaway spike-secret, which lives and dies with the container.

Not part of CI — run by hand when validating the feature against a live IdP.

entra_e2e.py — end-to-end product exercise (post-implementation)

entra_spike.py verified the raw OAuth WIRE (before code existed). entra_e2e.py verifies the SHIPPED Turnstone code: it does a real Entra login, feeds the credential through the real MCPTokenStore.upsert_oidc_credential (the call the OIDC callback makes on capture), then drives the real get_obo_access_token_classified_obo_mint_entra against the live Entra token endpoint. Checks E1E7: real mint + aud claim, cache-hit (0 Entra calls), single-credential→audiences A&B, rotation write-back, force_refresh re-mint, unconsented-audience classification with the credential surviving, and flush→re-mint. Reuses the same .env and interactive login (SPIKE_CALLBACK_FILE for remote browser).

source scripts/obo-e2e/.env
uv run python scripts/obo-e2e/entra_e2e.py
# one interactive sign-in; E1E7 then run against the real product code. Results below.

Results — RUN 2026-07-12 on the real tenant, ALL VERIFIED (exit 0): capture persisted; E1 mint A (aud=A app-id, cache row refresh_token_ct NULL); E2 cache hit (0 extra Entra calls); E3 mint B from the SAME credential (aud=B app-id); E4 rotation write-back (RT rotated 2040→2091 chars, newest persisted); E5 force_refresh re-mint (1 Entra call); E6 unconsented C → refresh_failed and the credential SURVIVES; E7 flush→re-mint. The real get_obo_access_token_classified_obo_mint_entra path against the live Entra token endpoint.

keycloak_e2e.py + keycloak_e2e.sh — OSS path (RFC 8693), headless

The rfc8693 equivalent of entra_e2e.py: keycloak_e2e.sh spins up ephemeral Keycloak, configures the realm (turnstone client with standard token exchange, mcp-a/b/c clients, aud-mcp-a/b audience scopes, a test user), runs the harness against the real get_obo_access_token_classified_obo_mint_rfc8693 (refresh grant → token exchange), then tears down. No browser (password grant).

./scripts/obo-e2e/keycloak_e2e.sh

Results — RUN 2026-07-12, ALL VERIFIED: capture persisted; E1 mint A (refresh→exchange, aud=mcp-a, cache row refresh_token_ct NULL); E2 cache hit (0 extra KC calls); E3 mint B from the SAME credential (aud=mcp-b); E4 rotation write-back (KC rotated the RT on the refresh leg, newest persisted); E5 force_refresh re-mint (2 KC calls = the two-leg chain); E6 unconsented C → refresh_failed_transient (KC returns invalid_request for a missing audience scope → classified transient; credential SURVIVES either way); E7 flush→re-mint. Gotcha: dev-mode Keycloak boot is slow on a loaded host — the script now waits on kcadm auth (up to ~6 min) rather than a fixed sleep. Port 8091 (8090 = the dev console).

Leg 1 — Entra (entra_spike.py) — NEEDS TENANT ACCESS

Tenant / app-registration setup (one-time, ~15 min)

  1. Spike client app (stands in for Turnstone's OIDC app registration):
    • New app registration, single tenant. Platform Web, redirect URI http://localhost:8765/callback. Create a client secret.
  2. Two resource apps (stand in for MCP servers A and B):
    • New app registrations spike-mcp-a, spike-mcp-b. In each: Expose an API → set Application ID URI (api://<guid>) → add a scope (e.g. mcp.access).
  3. Delegated grants (this is metaclassing's "proper tenant and app reg setup"):
    • On the spike client app → API permissions → add delegated permission to spike-mcp-a and spike-mcp-b scopes → Grant admin consent.
    • Optionally also add the spike client's app id to each resource app's preAuthorizedApplications (Expose an API → Add a client application) to compare against pure admin consent.
  4. Unconsented control (for V5): a third resource app spike-mcp-c with an exposed API but NO permission granted to the spike client.

Run

export ENTRA_TENANT_ID=... ENTRA_CLIENT_ID=... ENTRA_CLIENT_SECRET=...
export SPIKE_AUDIENCE_A=api://<a-guid> SPIKE_AUDIENCE_B=api://<b-guid>
export SPIKE_AUDIENCE_UNCONSENTED=api://<c-guid>   # optional (V5)
export SPIKE_RUN_OBO=1                              # optional (V6)
uv run python scripts/obo-e2e/entra_spike.py

A browser opens for one interactive login (any tenant user). Everything after is non-interactive — that IS the feature.

What each check pins down

Check Design assumption it verifies
V1 offline_access on the login yields a client-bound RT (capture layer)
V2/V3 ONE RT redeems for access tokens of DIFFERENT audiences (scope=<aud>/.default) — the load-bearing Entra behavior
V4 rotation semantics → whether RT write-back on every mint is convenience or correctness-critical
V5 unconsented audience fails AADSTS65001 consent_required → maps to the reconnect-rail fallback, never a silent failure
V6 OBO jwt-bearer middle-tier variant works with the same app registration (comparison data only)

Also record (manual): whether Conditional Access / MFA policies in the tenant produce interaction_required on redemption — that's the fallback path's other trigger.

Results — RUN 2026-07-11 on a real tenant, ALL SIX VERIFIED

Tenant: personal default directory (Global Admin), user is an MSA member. Setup via entra_setup.sh setup; V3 initially failed (see gotcha below), passed after fixing the grant. Second run: V1-V6 all VERIFIED, exit 0.

Check Result
V1 offline_access login -> RT VERIFIED (confidential client + PKCE, RT ~2KB)
V2 RT -> audience A token VERIFIED (aud=<A app guid>, ~70 min TTL, new RT returned)
V3 SAME RT -> audience B token VERIFIED — the load-bearing claim: one RT, many audiences
V4 rotation VERIFIED: RT rotates on every redemption, but the OLD RT stays valid (reuse HTTP 200) -> write-back-newest is required; races are benign on Entra
V5 unconsented audience VERIFIED: invalid_grant + AADSTS65001 (error_codes=[65001]) -> clean mapping to the reconnect-rail fallback
V6 OBO jwt-bearer variant VERIFIED: middle-tier shape also works with the same app registration

Operator gotcha (feeds #682 + product docs): az ad app permission admin-consent run immediately after SP creation SILENTLY skips not-yet-propagated resource SPs — grant A landed, grant B didn't, and the only symptom was AADSTS65001 at redemption. Verify grants after consent (oauth2PermissionGrants filter on the client SP) or write them directly with az ad app permission grant --id <client> --api <resource> --scope <scope>. Product-side implication: a missing tenant grant for a NEW oauth_obo server surfaces as AADSTS65001 -> the same reconnect-rail path as revocation; the admin docs must say "grant first, then add the server".

Leg 2 — Keycloak RFC 8693 (portability check) — runnable locally

Ephemeral quay.io/keycloak/keycloak:26.3 (start-dev, port 8089), realm spike, confidential client turnstone with standard token exchange enabled, resource clients mcp-a/mcp-b, user alice. Pipeline mirrors the product design for a generic-8693 IdP:

stored user RT --(refresh grant)--> user AT --(RFC 8693 exchange, audience=mcp-X)--> audience-scoped AT

i.e. the per-user credential stays ONE refresh token; per-server tokens are minted via standard token exchange instead of Entra's multi-resource RT redemption. Same substrate, different grant leg.

Results — RUN 2026-07-11, VERIFIED (Keycloak 26.3, ephemeral)

alice ONE stored RT
  -> refresh grant                      -> user AT (azp=turnstone); RT ROTATED on refresh
  -> 8693 exchange audience=mcp-a scope=aud-mcp-a -> AT aud=mcp-a user=alice 300s, NO RT
  -> 8693 exchange audience=mcp-b scope=aud-mcp-b -> AT aud=mcp-b (same subject AT)
  negative control audience=mcp-c       -> invalid_client "Audience not found"

Findings that feed the design:

  1. One per-user credential -> N audience tokens: VERIFIED on a second IdP. The substrate is portable; only the grant leg differs per IdP.
  2. Exchanged tokens are cache-shaped (short TTL, no RT) — per-server mcp_user_tokens rows as short-lived mint cache is the right model.
  3. RT rotation happens here too — newest-RT write-back on every redemption is a correctness requirement of the capture layer, not an Entra quirk.
  4. The IdP-side "delegated grant" has a per-IdP shape: Entra = API permissions + admin consent; Keycloak = audience client scopes attached to the requester client (optional scopes activate via scope= at exchange). Operator runbooks are per-IdP (#682 pattern), code is not.
  5. Gotchas hit: KC user needs a complete profile for direct grant ("Account is not fully set up"); optional audience scope must be requested explicitly or the exchange 400s with "Requested audience not available".

Repro (ephemeral, ~2 min):

docker run -d --name kc-obo-spike -p 127.0.0.1:8089:8080 \
  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
  quay.io/keycloak/keycloak:26.3 start-dev
KC="docker exec kc-obo-spike /opt/keycloak/bin/kcadm.sh"
$KC config credentials --server http://localhost:8080 --realm master --user admin --password admin
$KC create realms -s realm=spike -s enabled=true
$KC create clients -r spike -s clientId=turnstone -s enabled=true -s publicClient=false \
  -s secret=spike-secret -s directAccessGrantsEnabled=true \
  -s 'attributes={"standard.token.exchange.enabled":"true"}'
$KC create clients -r spike -s clientId=mcp-a -s enabled=true -s publicClient=false -s secret=x
$KC create clients -r spike -s clientId=mcp-b -s enabled=true -s publicClient=false -s secret=x
$KC create users -r spike -s username=alice -s enabled=true -s email=a@s.test \
  -s emailVerified=true -s firstName=A -s lastName=S
$KC set-password -r spike --username alice --new-password alice-pw
TURNSTONE_UUID=$($KC get clients -r spike -q clientId=turnstone --fields id --format csv --noquotes)
for t in mcp-a mcp-b; do
  SID=$($KC create client-scopes -r spike -s name=aud-$t -s protocol=openid-connect -i)
  $KC create client-scopes/$SID/protocol-mappers/models -r spike -s name=aud-$t \
    -s protocol=openid-connect -s protocolMapper=oidc-audience-mapper \
    -s "config={\"included.client.audience\":\"$t\",\"access.token.claim\":\"true\"}"
  $KC update clients/$TURNSTONE_UUID/optional-client-scopes/$SID -r spike
done
# then: password grant -> refresh grant -> token-exchange with
# grant_type=urn:ietf:params:oauth:grant-type:token-exchange,
# subject_token=<user AT>, subject_token_type=...:access_token,
# audience=mcp-a, scope=aud-mcp-a