Files
Patrick Buckley 8605c9783d feat(models): rfc8693_obo auth mode, per-alias exchange scopes, identity-keyed mint cache
Adds the dedicated `rfc8693_obo` model auth mode (#955): model
definitions gain an `obo_scopes` column (migration 069), the mint
threads the scopes to the token-exchange leg (RFC 8693), and every
dynamic mode pins its grant leg — a mode is a dialect commitment, not a
hint the deployment profile resolves. Exchange-capable IdPs refuse an
audience whose scope was not requested; this closes the structurally
unmintable model-OBO path on token-exchange deployments.

The model mint-cache is identity-keyed on the owning definition's
alias (`__model_obo__:<alias>` per user, `__model_app__:<alias>` under
the shared app principal), matching the MCP discipline where rows key
on the unique server name. The bearer's shape lives in the row's
audience/scopes columns and the freshness gate compares it on every
read, so a re-aimed alias refuses its old row and overwrites the same
key in place. Admin lifecycle (rename, re-aim, scope change, delete)
purges a definition's own rows through one shared helper — sound
because one definition owns each key; a sibling's rows are untouchable
by construction. Cooldown and backoff additionally key on the dispatch
shape, so an operator's config repair is an instant clean slate. Cause
records, cooldowns, locks and memoization are per-alias end to end,
and the session heartbeat reads refusal causes under the same keys.

Console: default-deny write gating for dynamic rows (value-diff over
the full column ladder, admin.mcp escalation, a never-blockable
pure-disable carve-out), a two-tier validator (audience allow-list on
every write; deployment-posture checks when the pair is chosen), one
shared scopes parser whose omit-unchanged arm keeps over-cap DB-direct
residue rows disarmable without ungating real changes, and served
constraints (dynamic/scopes/app-identity mode lists, mode-to-profile
pairing) so the shelf tracks the registry by data. The admin shelf
gains the mode option, a scopes input with residue affordances,
pairing-aware option greying, and a derived auth badge.

Registry load refuses control characters in alias, audience, and
scopes — including the C0 separator block that str.split() would
silently collapse — and the C0/DEL class has one exported spelling
shared by every surface. Profile-mismatch visibility warns at reload
and boot with the mode-correct cause, gated on OIDC being enabled.

Breaking: a stored `entra_obo` alias on a deployment whose
`[oidc] obo_grant_profile` is `rfc8693` (or the inverse pairing) no
longer mints via the profile-driven overload — the mint refuses before
any IdP traffic with cause `grant_profile_mismatch`, and the
`model.auth_fail_closed` policy governs static fallback. Such rows
never minted usefully on scope-gating IdPs; the shelf now surfaces the
pairing and the per-turn heartbeat names the refusal cause.

Live-verified end to end: scoped token exchange mints, the warm cache
serves with zero IdP calls, and the mode/profile mismatch refuses with
zero IdP traffic (scripts/obo-e2e/keycloak_e2e.sh); the
refresh-redemption profile's E1-E7 hold via scripts/obo-e2e/entra_e2e.py.

Closes #955.
2026-08-04 05:19:03 -07:00
..

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