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.
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 E1–E7: 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; E1–E7 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)
- 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.
- New app registration, single tenant. Platform Web, redirect URI
- 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).
- New app registrations
- 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-aandspike-mcp-bscopes → 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.
- On the spike client app → API permissions → add delegated permission to
- Unconsented control (for V5): a third resource app
spike-mcp-cwith 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:
- One per-user credential -> N audience tokens: VERIFIED on a second IdP. The substrate is portable; only the grant leg differs per IdP.
- Exchanged tokens are cache-shaped (short TTL, no RT) — per-server
mcp_user_tokensrows as short-lived mint cache is the right model. - RT rotation happens here too — newest-RT write-back on every redemption is a correctness requirement of the capture layer, not an Entra quirk.
- 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. - 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