Re-consent, verify and flip collapse into the existing consent flow: the
consent URL funnels legacy-homed tenants to the primary app registration,
and consent-status/access-check verify admin consent via an app-only token
probe under explicit primary credentials, then auto-flip HomedAppClientId.
- AdminConfiguration.SelfServiceAppHomingEnabled: kill switch (default off,
cache-bypassing fail-closed read); gates funnel + auto-flip + tenant-admin
manual flip. GA path deliberately survives the kill switch.
- POST config/{tenantId}/app-homing (TenantAdminOrGA): tenant admins flip to
primary only, probe-gated; GA gets force + revert. Pure decision helper
EvaluateManualFlip shared by function and tests.
- GraphTokenService.GetAccessTokenForAppAsync: probe seam with explicit app
credentials, cache-safe via app-scoped token keys.
- EntraAppRolesEnabled does not block a flip; it emits a Warning ops event
(AppHomingFlippedWithEntraRoles) reminding to re-assign enterprise-app roles.
- Web: homing badge + "App Registration Homing" panel with confirm dialog in
GA tenant management (typed homedAppClientId hardens the PUT round-trip),
funnel/success banners in tenant settings with switchAuthApp re-login,
admin-config kill-switch round-trip, ops event types dual-registered.
- Browser telemetry (App Insights): pre-init event buffer so module-init auth
events survive; strategic events across the whole flow (app_homing_flipped,
consent_flow_*, consent_verify_failed, auth_app_fallback_*, auth_app_switched,
auth_app_homing_learned, app_homing_manual_flip*).
Backend must deploy before web (additive JSON fields); everything is inert
while EntraId:LegacyClientId is unset. Enable the kill switch in the admin
portal after the dual app-reg config swap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
True parallel operation, no forced re-consent: existing tenants keep signing
in via the legacy app (1a400946), new tenants land on the C4A8 app (886ab5e2).
Model "default legacy, funnel primary, learn in the background":
- Web: am_auth_app flag -> NEXT_PUBLIC_ENTRA_DEFAULT_APP -> primary; get-started
CTA hands over with ?authapp=primary; auth/me homedApp updates the flag so an
operator tenant flip takes effect on each user's NEXT login; AADSTS90094/65001
one-shot cross-app retry, AADSTS65004 -> failed screen with previous-app link;
am_login_attempt pins redirect returns to the app that started them.
- Backend: TenantConfiguration.HomedAppClientId (null = legacy, GA-only flip via
PUT config) + LastAuthClientId/Since observability; EntraAppRegistry resolves
the acting app per tenant (no-op until the legacy config pair is set); Graph
client-credentials, admin-consent URLs and the surfaced client id follow the
homed app (app-scoped cache keys); JwtBearer [Authorize] pipeline now shares
the middleware audience builders (was single-audience - would 401 legacy
tokens after the swap); middleware trusts EntraId:LegacyClientId implicitly.
- well-known association file now lists both app ids (publisher domain);
deploy-web passes NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID / _DEFAULT_APP;
mcp-server.bicep default client id -> C4A8 app.
Backend xUnit 3730/3730, vitest 625/625 (13 new), tsc/ESLint clean, next build ok.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ingest hot path + Graph auth latency. IngestTelemetry is the #1 backend
endpoint (~35.6k/day); its p99 tail was dominated not by parsing but by an
uncached Graph token: GraphTokenService minted a fresh client-credentials
token (500-1900ms POST) on every device-validation cache miss and threw it
away. Cache it per tenant.
GraphTokenService:
- Per-tenant token cache keyed on the validated tenantId; TTL from the
token's own expires_in minus a 5min skew margin, clamped to [60s, 55min]
(expires_in was previously ignored). Only successful tokens are cached.
- Per-tenant SemaphoreSlim stampede gate (bounded wait) so an enrollment
wave does not fan a burst of identical token POSTs at AAD.
- InvalidateTenant() drops the token + consent-status entries so the
consent/Graph-permission fresh-read contract still holds.
- Per-tenant invalidation generation closes the write-after-invalidate
race: a POST in flight when InvalidateTenant runs tags its entry with the
pre-invalidation generation, which readers reject.
GraphFeatureDetector:
- InvalidateTenant() now also invalidates the underlying token cache, and
applies the same generation guard to its own parsed-roles cache.
- Cross-layer order is load-bearing: invalidate the token layer FIRST, then
bump the detector generation, so a concurrent acquire cannot pair a new
detector generation with a stale token read.
IngestTelemetryFunction:
- Deserialize the request body directly from the buffered MemoryStream via
JsonTextReader instead of materializing a full UTF-16 string + ToArray()
copy (peak ~4N -> ~1N). Cap still applies before parsing; same parse
semantics. Pure allocation hygiene on the hottest endpoint.
Tests: token caching, cross-tenant isolation, failure-not-cached, stampede
coalescing (barrier-gated), in-flight write-after-invalidate, cross-layer
invalidation, and cross-layer ordering -- each verified red without its fix.
Parse hygiene: PayloadJson stays a raw string, malformed -> 400.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>