feat(auth): prepare the new app registration as the default sign-in app

- Backend: the app-homing consent probe now requires the primary app's Graph
  roles to be a superset of the legacy app's, so a mere sign-in consent
  (User.Read, no application role) can never auto-flip a tenant onto an app
  without the device-validation permission.
- Web: dashboard banner for tenants still on the previous app (per-tab
  dismiss, docs link); docs links on the settings funnel banner, the flipped
  banner and the failed-sign-in screen; immediate re-homing after a redirect
  sign-in on the other app (one hop per tab, never after a cross-app
  fallback); the www -> portal handover passes the learned app as ?authapp=.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-02 16:18:45 +02:00
parent 3eb1f39c4b
commit 4e1a3c6312
17 changed files with 639 additions and 63 deletions
@@ -10,13 +10,16 @@ using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Unit tests for <see cref="AppHomingService"/> — funnel eligibility, the consent-driven
/// auto-flip, and the flip persist side-effect chain (save + cache invalidation + audit +
/// ops events). The consent probe and the kill-switch flag are mocked at their virtual seams.
/// Unit tests for <see cref="AppHomingService"/> — funnel eligibility, the consent probe's
/// role-superset rule, the consent-driven auto-flip, and the flip persist side-effect chain
/// (save + cache invalidation + audit + ops events). Token minting and the kill-switch flag
/// are mocked at their virtual seams.
/// </summary>
public class AppHomingServiceTests
{
@@ -24,6 +27,8 @@ public class AppHomingServiceTests
private const string PrimaryId = "aaaaaaaa-0000-0000-0000-000000000001";
private const string LegacyId = "bbbbbbbb-0000-0000-0000-000000000002";
private const string Actor = "admin@contoso.com";
private const string ValidationRole = "DeviceManagementServiceConfig.Read.All";
private const string ScriptsRole = "DeviceManagementScripts.Read.All";
private readonly Mock<AdminConfigurationService> _adminConfigMock;
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
@@ -93,14 +98,16 @@ public class AppHomingServiceTests
opsService,
new TelemetryClient(new TelemetryConfiguration { DisableTelemetry = true }));
// Defaults: flag on, legacy-homed config exists, probe succeeds. FlipAsync reads via
// the cache-bypassing GetConfigurationFreshAsync (read-modify-write on the whole entity).
// Defaults: flag on, legacy-homed config exists, both apps mint a role-less token (the
// superset rule passes trivially). FlipAsync reads via the cache-bypassing
// GetConfigurationFreshAsync (read-modify-write on the whole entity).
_adminConfigMock.Setup(x => x.IsSelfServiceAppHomingEnabledAsync()).ReturnsAsync(true);
var config = LegacyHomedConfig();
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
_tenantConfigMock.Setup(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
.Returns(Task.CompletedTask);
SetupProbe(GraphTokenResult.Success("tok"));
SetupProbe(GraphTokenResult.Success(Jwt()));
SetupLegacyToken(GraphTokenResult.Success(Jwt()));
}
private static TenantConfiguration LegacyHomedConfig()
@@ -110,12 +117,35 @@ public class AppHomingServiceTests
return config;
}
/// <summary>Unsigned app-only token carrying the given Graph application roles (the probe never validates signatures).</summary>
private static string Jwt(params string[] roles)
{
var jwt = new JwtSecurityToken(
issuer: "https://sts.windows.net/test",
audience: "https://graph.microsoft.com",
claims: roles.Select(r => new Claim("roles", r)),
notBefore: null,
expires: DateTime.UtcNow.AddHours(1));
return new JwtSecurityTokenHandler().WriteToken(jwt);
}
/// <summary>What the PRIMARY app's app-only token mint returns.</summary>
private void SetupProbe(GraphTokenResult result) =>
_graphTokenMock
.Setup(x => x.GetAccessTokenForAppAsync(TenantId, It.Is<EntraAppCredentials>(c => !c.IsLegacy),
It.IsAny<CancellationToken>()))
.ReturnsAsync(result);
/// <summary>What the LEGACY app's app-only token mint returns (the superset baseline).</summary>
private void SetupLegacyToken(GraphTokenResult result) =>
_graphTokenMock
.Setup(x => x.GetAccessTokenForAppAsync(TenantId, It.Is<EntraAppCredentials>(c => c.IsLegacy),
It.IsAny<CancellationToken>()))
.ReturnsAsync(result);
private void VerifyNeverSaved() =>
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
// ── IsFunnelEligibleAsync ───────────────────────────────────────────────
[Fact]
@@ -156,8 +186,104 @@ public class AppHomingServiceTests
Assert.True(await _sut.IsFunnelEligibleAsync(config));
}
// ── ProbePrimaryConsentAsync (role-superset rule) ───────────────────────
[Fact]
public async Task Probe_refuses_when_primary_lacks_a_role_the_legacy_app_holds()
{
// The trap of the primary-default sign-in: a delegated User.Read consent created the
// primary SP (token acquirable, no roles) while the legacy app carries the validation
// role — flipping would break device validation until re-consent.
SetupProbe(GraphTokenResult.Success(Jwt()));
SetupLegacyToken(GraphTokenResult.Success(Jwt(ValidationRole)));
var probe = await _sut.ProbePrimaryConsentAsync(TenantId);
Assert.False(probe.Succeeded);
Assert.False(probe.IsTransient);
Assert.Equal(new[] { ValidationRole }, probe.MissingRoles);
}
[Fact]
public async Task Probe_succeeds_when_primary_roles_cover_the_legacy_roles()
{
SetupProbe(GraphTokenResult.Success(Jwt(ValidationRole, ScriptsRole)));
SetupLegacyToken(GraphTokenResult.Success(Jwt(ValidationRole)));
var probe = await _sut.ProbePrimaryConsentAsync(TenantId);
Assert.True(probe.Succeeded);
Assert.Null(probe.MissingRoles);
}
[Fact]
public async Task Probe_compares_roles_case_insensitively()
{
SetupProbe(GraphTokenResult.Success(Jwt(ValidationRole.ToUpperInvariant())));
SetupLegacyToken(GraphTokenResult.Success(Jwt(ValidationRole)));
Assert.True((await _sut.ProbePrimaryConsentAsync(TenantId)).Succeeded);
}
[Fact]
public async Task Probe_succeeds_for_role_less_tenants()
{
// Neither app holds a Graph application role (validation never enabled): nothing to
// lose, the flip stays free so such tenants keep migrating on their own.
var probe = await _sut.ProbePrimaryConsentAsync(TenantId);
Assert.True(probe.Succeeded);
}
[Fact]
public async Task Probe_is_transient_when_the_legacy_token_is_transient()
{
SetupProbe(GraphTokenResult.Success(Jwt(ValidationRole)));
SetupLegacyToken(GraphTokenResult.TransientFailure());
var probe = await _sut.ProbePrimaryConsentAsync(TenantId);
Assert.False(probe.Succeeded);
Assert.True(probe.IsTransient);
}
[Fact]
public async Task Probe_succeeds_when_the_legacy_token_is_permanently_unacquirable()
{
// Legacy SP gone (admin deleted the previous app early): nothing left to lose.
SetupProbe(GraphTokenResult.Success(Jwt()));
SetupLegacyToken(GraphTokenResult.PermanentFailure());
Assert.True((await _sut.ProbePrimaryConsentAsync(TenantId)).Succeeded);
}
[Fact]
public async Task Probe_never_mints_a_legacy_token_when_the_primary_token_fails()
{
SetupProbe(GraphTokenResult.PermanentFailure());
var probe = await _sut.ProbePrimaryConsentAsync(TenantId);
Assert.False(probe.Succeeded);
_graphTokenMock.Verify(x => x.GetAccessTokenForAppAsync(TenantId, It.Is<EntraAppCredentials>(c => c.IsLegacy),
It.IsAny<CancellationToken>()), Times.Never);
}
// ── TryAutoFlipToPrimaryAsync ───────────────────────────────────────────
[Fact]
public async Task AutoFlip_does_not_flip_when_primary_lacks_a_legacy_role()
{
SetupProbe(GraphTokenResult.Success(Jwt()));
SetupLegacyToken(GraphTokenResult.Success(Jwt(ValidationRole)));
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
Assert.Equal(AppHomingAutoFlipOutcome.ProbeFailed, outcome);
VerifyNeverSaved();
Assert.Empty(_savedOpsEvents);
}
[Fact]
public async Task AutoFlip_flips_on_probe_success_with_full_side_effect_chain()
{
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Security;
@@ -21,11 +22,16 @@ namespace AutopilotMonitor.Functions.Services
}
/// <summary>
/// Result of the primary-app consent probe: an app-only token acquisition under the PRIMARY
/// app's credentials. <c>Succeeded</c> proves an admin consented the primary app in that
/// tenant; <c>IsTransient</c> means "unknown" (network/AAD blip), never "not consented".
/// Result of the primary-app consent probe. <c>Succeeded</c> proves the PRIMARY app is
/// consented in that tenant AND carries every Graph application role the legacy app holds
/// there (see <see cref="AppHomingService.ProbePrimaryConsentAsync"/>); <c>MissingRoles</c>
/// lists the legacy roles the primary app lacks when that superset check failed.
/// <c>IsTransient</c> means "unknown" (network/AAD blip), never "not consented".
/// </summary>
public sealed record AppHomingProbeResult(bool Succeeded, bool IsTransient);
public sealed record AppHomingProbeResult(
bool Succeeded,
bool IsTransient,
IReadOnlyCollection<string>? MissingRoles = null);
public enum AppHomingDecisionKind { Allow, AllowNoOp, RequireProbe, Deny }
@@ -94,19 +100,65 @@ namespace AutopilotMonitor.Functions.Services
}
/// <summary>
/// Proves (or refutes) admin consent for the PRIMARY app in this tenant by minting an
/// app-only token under the primary credentials, regardless of the tenant's homing.
/// Token acquirability — not the roles claim — is the flip criterion: the flip only moves
/// which app acts for the tenant; feature gates keep their own role-checked probes.
/// Proves (or refutes) that the PRIMARY app may take over this tenant, regardless of the
/// tenant's homing: an app-only token under the primary credentials must be acquirable
/// (the service principal exists ⇒ someone consented the app) AND its <c>roles</c> claim
/// must be a superset of the legacy app's — the flip must never cost the tenant a Graph
/// capability it has today. The superset rule (rather than "has the validation role")
/// keeps role-less tenants flipping freely while blocking the trap where a mere sign-in
/// consent (delegated <c>User.Read</c>, no application role) created the primary SP: with
/// the primary app as the default sign-in app that is a routine event, and flipping on it
/// would silently break device validation until an admin re-consents.
/// A transient failure on either token means "unknown" — never a refusal.
/// </summary>
public virtual async Task<AppHomingProbeResult> ProbePrimaryConsentAsync(string tenantId, CancellationToken ct = default)
{
var result = await _graphTokenService.GetAccessTokenForAppAsync(tenantId, _appRegistry.Primary, ct);
return new AppHomingProbeResult(
Succeeded: !string.IsNullOrWhiteSpace(result.AccessToken),
IsTransient: result.IsTransient);
var primary = await _graphTokenService.GetAccessTokenForAppAsync(tenantId, _appRegistry.Primary, ct);
if (string.IsNullOrWhiteSpace(primary.AccessToken))
{
return new AppHomingProbeResult(Succeeded: false, IsTransient: primary.IsTransient);
}
var legacyApp = _appRegistry.Legacy;
if (legacyApp == null)
{
// Parallel window closed: nothing to compare against.
return new AppHomingProbeResult(Succeeded: true, IsTransient: false);
}
var legacy = await _graphTokenService.GetAccessTokenForAppAsync(tenantId, legacyApp, ct);
if (string.IsNullOrWhiteSpace(legacy.AccessToken) && legacy.IsTransient)
{
return new AppHomingProbeResult(Succeeded: false, IsTransient: true);
}
// A permanently unacquirable legacy token (SP gone) means there is nothing to lose.
var primaryRoles = ParseRoles(primary.AccessToken);
var legacyRoles = ParseRoles(legacy.AccessToken);
var missing = legacyRoles
.Where(role => !primaryRoles.Contains(role))
.OrderBy(role => role, StringComparer.OrdinalIgnoreCase)
.ToList();
if (missing.Count == 0)
{
return new AppHomingProbeResult(Succeeded: true, IsTransient: false);
}
_logger.LogWarning(
"Tenant {TenantId}: primary app is consented but lacks {MissingCount} legacy Graph role(s) — homing flip refused until re-consent: {MissingRoles}",
tenantId, missing.Count, string.Join(", ", missing));
return new AppHomingProbeResult(Succeeded: false, IsTransient: false, MissingRoles: missing);
}
private static readonly IReadOnlySet<string> EmptyRoles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
/// <summary>Roles claim of an app-only token; empty for a missing or unparsable token.</summary>
private static IReadOnlySet<string> ParseRoles(string? accessToken) =>
!string.IsNullOrWhiteSpace(accessToken)
&& GraphFeatureDetector.TryParseToken(accessToken, out var roles, out _)
? roles
: EmptyRoles;
/// <summary>
/// Consent-driven auto-flip: if the tenant is funnel-eligible and the primary app proves
/// consented, flip the homing to primary. Idempotent — primary-homed tenants (and any
@@ -0,0 +1,94 @@
"use client";
import { useEffect, useSyncExternalStore } from "react";
import Link from "next/link";
import { DOCS_URL } from "@/utils/config";
import { DOCS_PATHS } from "@/lib/docsPaths";
import { trackEvent } from "@/lib/appInsights";
// Session-scoped dismissal (same shape as ActivelyDevelopedBanner): hidden across reloads in
// this tab, back in every new tab — the switch is a one-time minute of admin work, so the
// nudge stays persistent but never blocks anything. Module-level store because a same-tab
// sessionStorage write fires no "storage" event.
const DismissKey = "appHomingBannerDismissed";
const listeners = new Set<() => void>();
const getSnapshot = (): boolean => sessionStorage.getItem(DismissKey) === "1";
const getServerSnapshot = (): boolean => false;
function subscribe(onStoreChange: () => void): () => void {
listeners.add(onStoreChange);
return () => listeners.delete(onStoreChange);
}
function dismiss(): void {
sessionStorage.setItem(DismissKey, "1");
for (const listener of [...listeners]) listener();
}
/**
* Dashboard nudge of the dual app-reg migration, shown to tenant admins while the tenant still
* runs on the previous app registration and the self-service switch is open
* (feature-flags `appHomingFunnelActive`). Keyed on the TENANT's state, not on which app this
* browser signed in with: the fix is a tenant-wide admin consent. The consent flow itself
* lives in Settings → Autopilot Validation (its redirect returns there) — this banner only
* links to it and to the docs.
*/
export function AppHomingBanner() {
const dismissed = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
useEffect(() => {
if (!dismissed) trackEvent("app_homing_banner_shown");
}, [dismissed]);
if (dismissed) return null;
return (
<div className="mb-6 bg-blue-50 border border-blue-300 rounded-lg px-4 py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 dark:bg-blue-950/30 dark:border-blue-700/50">
<div className="flex items-start gap-3">
<svg className="w-4 h-4 text-blue-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<p className="text-sm text-blue-800 dark:text-blue-300">
<span className="font-semibold">Please switch to the new Autopilot Monitor app registration.</span>{" "}
Your organization still signs in through the previous app. A one-time admin consent
(about a minute) switches it over — until then, colleagues signing in from a new
browser or device see an extra consent prompt.{" "}
<a
href={`${DOCS_URL}${DOCS_PATHS.appRegistrationMigration}`}
target="_blank"
rel="noopener noreferrer"
className="underline font-medium hover:text-blue-600 dark:hover:text-blue-200"
onClick={() => trackEvent("app_homing_banner_clicked", { link: "docs" })}
>
Read why and how
</a>
.
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Link
href="/settings/tenant/autopilot"
className="inline-flex items-center gap-2 bg-blue-600 text-white font-medium text-sm px-3 py-1.5 rounded-lg hover:bg-blue-700 transition-colors"
onClick={() => trackEvent("app_homing_banner_clicked", { link: "settings" })}
>
Switch in Settings
</Link>
<button
type="button"
onClick={() => {
trackEvent("app_homing_banner_dismissed");
dismiss();
}}
aria-label="Hide for this browser tab"
title="Hide for this browser tab"
className="p-1 rounded text-blue-300 hover:text-blue-600 hover:bg-blue-100 transition-colors dark:text-blue-700 dark:hover:text-blue-300 dark:hover:bg-blue-900/40"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
);
}
@@ -0,0 +1,20 @@
import { describe, it, expect } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { AppHomingBanner } from "../AppHomingBanner";
import { DOCS_URL } from "@/utils/config";
describe("AppHomingBanner", () => {
it("renders the nudge with the settings CTA, the docs link and a per-tab dismiss control", () => {
const html = renderToStaticMarkup(createElement(AppHomingBanner));
expect(html).toContain("Please switch to the new Autopilot Monitor app registration.");
expect(html).toContain('href="/settings/tenant/autopilot"');
expect(html).toContain(`href="${DOCS_URL}/troubleshooting-and-support/app-registration-migration"`);
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noopener noreferrer"');
expect(html).toContain('aria-label="Hide for this browser tab"');
// Stays in the blue family of the settings funnel banner (no new colour family).
expect(html).toContain("bg-blue-50");
expect(html).not.toMatch(/bg-(amber|red|green)-/);
});
});
@@ -0,0 +1,22 @@
import { describe, it, expect } from "vitest";
import { summarizeTenantSecurityConfig } from "../useTenantSecurityConfig";
describe("summarizeTenantSecurityConfig", () => {
it("shows the app-homing banner only on an explicit true", () => {
expect(summarizeTenantSecurityConfig({ validateAutopilotDevice: true, appHomingFunnelActive: true }).appHomingFunnelActive).toBe(true);
expect(summarizeTenantSecurityConfig({ validateAutopilotDevice: true, appHomingFunnelActive: false }).appHomingFunnelActive).toBe(false);
// Older backend without the field: never nag.
expect(summarizeTenantSecurityConfig({ validateAutopilotDevice: true }).appHomingFunnelActive).toBe(false);
});
it("keys the validation banner on the flag and the contact nag on Pro editions only", () => {
const community = summarizeTenantSecurityConfig({ validateAutopilotDevice: false, edition: "community", contactEmailSet: false });
expect(community.serialValidationEnabled).toBe(false);
expect(community.proContactMissing).toBe(false);
const pro = summarizeTenantSecurityConfig({ validateAutopilotDevice: true, edition: "pro", contactEmailSet: false, companyNameSet: true });
expect(pro.serialValidationEnabled).toBe(true);
expect(pro.proContactMissing).toBe(true);
expect(pro.proContactMissingParts).toEqual(["contact address"]);
});
});
@@ -15,11 +15,13 @@ type AddNotification = (
href?: string,
) => void;
interface TenantConfigurationSummary {
/** The subset of the feature-flags response the dashboard banners read. */
export interface TenantConfigurationSummary {
validateAutopilotDevice: boolean;
edition?: string;
contactEmailSet?: boolean;
companyNameSet?: boolean;
appHomingFunnelActive?: boolean;
}
interface User {
@@ -39,14 +41,39 @@ export interface TenantSecuritySummary {
proContactMissing: boolean;
/** Display labels of the missing parts ("contact address", "company name"); empty when complete. */
proContactMissingParts: string[];
/**
* Dual app-reg window: the tenant still runs on the previous app registration and the
* self-service switch is open — drives the blue "switch to the new app" banner. Requires an
* EXPLICIT true from the backend; loading/error/older-backend states show nothing.
*/
appHomingFunnelActive: boolean;
}
const EMPTY_SUMMARY: TenantSecuritySummary = {
serialValidationEnabled: null,
proContactMissing: false,
proContactMissingParts: [],
appHomingFunnelActive: false,
};
/** Pure projection of a feature-flags body onto the banner summary (unit-tested). */
export function summarizeTenantSecurityConfig(data: TenantConfigurationSummary): TenantSecuritySummary {
const isPro = data.edition === "pro" || data.edition === "enterprise";
const missingParts = isPro ? missingContactProfileParts(data) : [];
return {
serialValidationEnabled: !!data.validateAutopilotDevice,
proContactMissing: missingParts.length > 0,
proContactMissingParts: missingParts,
appHomingFunnelActive: data.appHomingFunnelActive === true,
};
}
/**
* Fetches the tenant's feature-flags summary to drive the dashboard banners:
* the red "Autopilot Device Validation is disabled" banner and the amber
* Pro-without-contact-address banner.
* the red "Autopilot Device Validation is disabled" banner, the amber
* Pro-without-contact-address banner, and the blue app-registration migration banner.
*
* Skips the fetch for regular users (they never see the dashboard).
* Skips the fetch for users without a tenant role (they never see the dashboard).
*/
export function useTenantSecurityConfig(
tenantId: string | null | undefined,
@@ -54,11 +81,7 @@ export function useTenantSecurityConfig(
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>,
addNotification: AddNotification,
): TenantSecuritySummary {
const [summary, setSummary] = useState<TenantSecuritySummary>({
serialValidationEnabled: null,
proContactMissing: false,
proContactMissingParts: [],
});
const [summary, setSummary] = useState<TenantSecuritySummary>(EMPTY_SUMMARY);
useEffect(() => {
// Wait until both tenant and user are resolved. Without the !user guard
@@ -75,23 +98,17 @@ export function useTenantSecurityConfig(
const response = await dedupedAuthFetch(api.config.featureFlags(tenantId), getAccessToken);
if (!response.ok) {
setSummary({ serialValidationEnabled: null, proContactMissing: false, proContactMissingParts: [] });
setSummary(EMPTY_SUMMARY);
return;
}
const data: TenantConfigurationSummary = await response.json();
const isPro = data.edition === "pro" || data.edition === "enterprise";
const missingParts = isPro ? missingContactProfileParts(data) : [];
setSummary({
serialValidationEnabled: !!data.validateAutopilotDevice,
proContactMissing: missingParts.length > 0,
proContactMissingParts: missingParts,
});
setSummary(summarizeTenantSecurityConfig(data));
} catch (error) {
if (error instanceof TokenExpiredError) {
addNotification('error', 'Session Expired', error.message, 'session-expired-error');
}
setSummary({ serialValidationEnabled: null, proContactMissing: false, proContactMissingParts: [] });
setSummary(EMPTY_SUMMARY);
}
};
@@ -13,6 +13,7 @@ import { useNotifications } from "../../contexts/NotificationContext";
import { StatsCard } from "./components/StatsCards";
import { WelcomeMessage } from "./components/WelcomeMessage";
import { ActivelyDevelopedBanner } from "./components/ActivelyDevelopedBanner";
import { AppHomingBanner } from "./components/AppHomingBanner";
import { SessionTable } from "./components/SessionTable";
import { TenantFilterBar } from "./components/TenantFilterBar";
import { DeleteConfirmModal, BlockConfirmModal } from "./components/ConfirmationModals";
@@ -242,7 +243,7 @@ function HomeContent() {
}
}, [user, router]);
const { serialValidationEnabled, proContactMissing, proContactMissingParts } = useTenantSecurityConfig(tenantId, user, getAccessToken, addNotification);
const { serialValidationEnabled, proContactMissing, proContactMissingParts, appHomingFunnelActive } = useTenantSecurityConfig(tenantId, user, getAccessToken, addNotification);
const rawTenantList = useTenantList(crossTenant, getAccessToken);
// Delegated: bound the tenant filter's autocomplete to the managed subset (defense in depth on top of the
// backend-bounded config/all), plus the caller's own HOME tenant when they hold a member role there —
@@ -348,6 +349,13 @@ function HomeContent() {
{/* Feedback & bug report banner (session-dismissable, telemetry-instrumented) */}
<ActivelyDevelopedBanner />
{/* Dual app-reg migration nudge (blue, session-dismissable): the TENANT still runs on
the previous app registration. Admins only — they own the consent; delegated admins
are excluded (the link would target their own tenant, not the viewed one). */}
{appHomingFunnelActive && !isDelegated && (user?.isTenantAdmin || user?.isGlobalAdmin) && (
<AppHomingBanner />
)}
{serialValidationEnabled === false && (
<div className="mb-6 bg-red-600 border-2 border-red-700 rounded-xl p-5 shadow-lg">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
@@ -3,6 +3,7 @@
import { useAuth } from "@/contexts/AuthContext";
import { useGlobalAdminUi } from "@/hooks/useGlobalAdminUi";
import { legacyConfigured, switchAuthApp } from "@/lib/authApp";
import { DOCS_PATHS } from "@/lib/docsPaths";
import { DOCS_URL } from "@/utils/config";
import { useTenantConfig } from "../../TenantConfigContext";
import { TenantNotifications } from "../../TenantNotifications";
@@ -56,12 +57,22 @@ export function SectionAutopilotValidation() {
Your tenant now runs on the <strong>new</strong> Autopilot Monitor app registration.
Current sessions keep working; every next sign-in uses the new app automatically.
</p>
<button
onClick={() => switchAuthApp("primary")}
className="mt-2 px-3 py-1.5 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
>
Sign in with the new app now
</button>
<div className="mt-2 flex flex-wrap items-center gap-3">
<button
onClick={() => switchAuthApp("primary")}
className="px-3 py-1.5 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
>
Sign in with the new app now
</button>
<a
href={`${DOCS_URL}${DOCS_PATHS.appRegistrationMigrationAfter}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium underline underline-offset-2 hover:text-green-900 dark:hover:text-green-100"
>
What to check after the switch
</a>
</div>
</div>
) : appHomingFunnelActive ? (
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border-2 border-blue-300 rounded-lg p-5 shadow-sm dark:from-blue-950/40 dark:to-indigo-950/40 dark:border-blue-700/60">
@@ -87,12 +98,12 @@ export function SectionAutopilotValidation() {
(<code className="text-xs bg-blue-100 dark:bg-blue-900/60 px-1 rounded">DeviceManagementServiceConfig.Read.All</code>).
</p>
<a
href={`${DOCS_URL}/getting-started/portal-setup`}
href={`${DOCS_URL}${DOCS_PATHS.appRegistrationMigration}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 mt-2.5 text-sm font-medium text-blue-700 hover:text-blue-900 underline dark:text-blue-300 dark:hover:text-blue-100"
>
See the documentation for details on the required permissions
Read what changes and what to check before you migrate
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
@@ -7,6 +7,8 @@ import { savePostLoginReturnUrl } from "../lib/postLoginReturn";
import { isOnPublicHost } from "../lib/hostRouting";
import { consumeLoginDeclined, legacyConfigured, switchAuthApp } from "../lib/authApp";
import { activeAuthApp } from "../lib/msalConfig";
import { DOCS_PATHS } from "../lib/docsPaths";
import { DOCS_URL } from "../utils/config";
import { useAdminMode } from "../hooks/useAdminMode";
interface ProtectedRouteProps {
@@ -134,6 +136,18 @@ export function ProtectedRoute({ children, requireGlobalAdmin = false, requireGl
</button>
</p>
)}
{legacyConfigured() && (
<p className="mt-2 text-xs text-gray-400">
<a
href={`${DOCS_URL}${DOCS_PATHS.appRegistrationMigration}`}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-gray-600"
>
Why are there two Autopilot Monitor apps?
</a>
</p>
)}
</div>
</div>
);
@@ -5,9 +5,12 @@ import { useRouter } from "next/navigation";
import { useEffect } from "react";
import type { Route } from "next";
import { trustedRoute } from "../../lib/routes";
import { consumePostLoginReturnUrl } from "../../lib/postLoginReturn";
import { consumePostLoginReturnUrl, savePostLoginReturnUrl } from "../../lib/postLoginReturn";
import { hasOwnTenantOrPlatformRole, hasTenantReadScope } from "../../lib/tenantScope";
import { PORTAL_HOST, shouldCrossOriginToPortal } from "../../lib/hostRouting";
import { portalHandoverUrl, shouldCrossOriginToPortal } from "../../lib/hostRouting";
import { consumePendingRehome, getSelectedAuthApp, legacyConfigured, switchAuthApp, tryBeginRehome } from "../../lib/authApp";
import { activeAuthApp } from "../../lib/msalConfig";
import { trackEvent } from "../../lib/appInsights";
/**
* Invisible client component that handles auth redirect logic.
@@ -37,11 +40,22 @@ export function AuthGate() {
} else {
target = "/progress";
}
// Dual app-reg window: the sign-in that just completed ran on the other app than the
// tenant is homed on (fresh browser, wrong default). Same-origin: re-home NOW — switch
// the browser to the homed app and land on the target, where ProtectedRoute completes
// the sign-in silently via the Entra session and returns here with the deep link intact.
// One hop per tab; the pending request is consumed either way.
const rehome = consumePendingRehome();
// On the public host, hand over to the portal origin in ONE full-page
// navigation instead of router.push + HostRoutingGuard bounce. Auth state
// is per-origin — the portal side runs its own (silent) MSAL sign-in.
// is per-origin — the portal side runs its own (silent) MSAL sign-in, on
// the app this browser just learned (passed along as ?authapp=).
if (shouldCrossOriginToPortal()) {
window.location.href = `https://${PORTAL_HOST}${target}`;
window.location.href = portalHandoverUrl(target, legacyConfigured() ? getSelectedAuthApp() : null);
} else if (rehome && tryBeginRehome()) {
savePostLoginReturnUrl(target);
trackEvent("auth_app_rehomed", { from: activeAuthApp, to: rehome });
switchAuthApp(rehome, target);
} else {
router.replace(target);
}
@@ -13,8 +13,10 @@ import {
loginFallbackActive,
markLoginDeclined,
otherApp,
requestRehome,
setLoginAttemptApp,
setSelectedAuthApp,
shouldRehome,
tryBeginLoginFallback,
} from '@/lib/authApp';
import { api } from '@/lib/api';
@@ -36,6 +38,11 @@ let msalReady = false;
// otherwise a cold backend would keep the UI on a white screen.
let prefetchedAuthMePromise: Promise<AuthMeResponse | null> | null = null;
// Post-login re-homing inputs (see lib/authApp.ts shouldRehome): set by the redirect handler
// below for the page load that completed a redirect sign-in, consumed by the first auth/me.
let redirectLoginCompleted = false;
let redirectLoginViaFallback = false;
/**
* Safety net of the dual app-reg model, for the rare browser that lands on an app its tenant
* never consented to (no stored selection, wrong default): Entra reports "needs admin
@@ -87,7 +94,9 @@ const msalInitPromise = msalInstance
// (the "learn" half of the model — future boots go straight to the right one).
// A still-set fallback marker means THIS login only worked via the one-shot cross-app
// retry — the strategic signal that one of the two apps is not consented in the tenant.
if (loginFallbackActive()) {
redirectLoginCompleted = true;
redirectLoginViaFallback = loginFallbackActive();
if (redirectLoginViaFallback) {
trackEvent('auth_app_fallback_succeeded', { app: activeAuthApp });
}
setSelectedAuthApp(activeAuthApp);
@@ -201,9 +210,14 @@ interface AuthContextType {
/**
* Background learning of the dual app-reg model: auth/me returns which app registration the
* tenant is homed on; storing it makes the NEXT login on this browser use that app directly.
* No reload — the current session keeps its valid token (both audiences are accepted).
* No reload here — the current session keeps its valid token (both audiences are accepted).
* This is also how an operator flip propagates: flip → next login of every user runs via the
* new app, at which point the tenant's admin consent already exists.
*
* Exception: the page load that just completed a redirect sign-in on the OTHER app (fresh
* browser, wrong default). That browser is re-homed immediately — AuthGate, the post-login
* hop, switches it to the homed app and the sign-in completes silently — so the first session
* never runs on an app the tenant did not set up (Entra app roles, Conditional Access scope).
*/
function learnHomedAppFromAuthMe(data: AuthMeResponse): void {
const homedApp = data.homedApp;
@@ -214,6 +228,17 @@ function learnHomedAppFromAuthMe(data: AuthMeResponse): void {
trackEvent('auth_app_homing_learned', { homedApp });
}
setSelectedAuthApp(homedApp);
if (shouldRehome({
homedApp,
activeApp: activeAuthApp,
redirectLoginCompleted,
viaFallback: redirectLoginViaFallback,
})) {
requestRehome(homedApp);
}
// The redirect-login page load is spent either way: a later refreshUserInfo must not
// re-request a re-home.
redirectLoginCompleted = false;
}
}
@@ -2,14 +2,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
classifyEntraAuthError,
consumeLoginDeclined,
consumePendingRehome,
getBootAuthApp,
getSelectedAuthApp,
legacyConfigured,
markLoginDeclined,
otherApp,
requestRehome,
setLoginAttemptApp,
setSelectedAuthApp,
shouldRehome,
switchAuthApp,
tryBeginLoginFallback,
tryBeginRehome,
} from "../authApp";
const LEGACY_ID = "1a400946-62c1-4ab4-aa37-f730ac89704d";
@@ -33,7 +38,7 @@ function stubWindow(search = ""): void {
vi.stubGlobal("window", {
localStorage: makeStorage(),
sessionStorage: makeStorage(),
location: { search, reload: vi.fn() },
location: { search, reload: vi.fn(), assign: vi.fn() },
});
}
@@ -151,3 +156,69 @@ describe("safety-net primitives", () => {
expect(otherApp("primary")).toBe("legacy");
});
});
describe("post-login re-homing", () => {
const base = { homedApp: "legacy" as const, activeApp: "primary" as const, redirectLoginCompleted: true, viaFallback: false };
it("re-homes only when a redirect sign-in just completed on the other app", () => {
stubWindow();
vi.stubEnv("NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID", LEGACY_ID);
expect(shouldRehome(base)).toBe(true);
expect(shouldRehome({ ...base, homedApp: "primary" })).toBe(false);
// A later auth/me refresh in the same page life is not a sign-in.
expect(shouldRehome({ ...base, redirectLoginCompleted: false })).toBe(false);
});
it("never re-homes after a cross-app fallback — the homed app is proven unusable there", () => {
stubWindow();
vi.stubEnv("NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID", LEGACY_ID);
expect(shouldRehome({ ...base, viaFallback: true })).toBe(false);
});
it("is inert outside the parallel window", () => {
stubWindow();
expect(legacyConfigured()).toBe(false);
expect(shouldRehome(base)).toBe(false);
});
it("requestRehome / consumePendingRehome is a one-shot handoff", () => {
expect(consumePendingRehome()).toBeNull();
requestRehome("legacy");
expect(consumePendingRehome()).toBe("legacy");
expect(consumePendingRehome()).toBeNull();
});
it("tryBeginRehome is one-shot per tab", () => {
stubWindow();
expect(tryBeginRehome()).toBe(true);
expect(tryBeginRehome()).toBe(false);
});
it("switchAuthApp with a target navigates there instead of reloading, on the new app with MSAL state purged", () => {
stubWindow();
vi.stubEnv("NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID", LEGACY_ID);
setSelectedAuthApp("primary");
setLoginAttemptApp("primary");
window.sessionStorage.setItem("msal.account.keys", "x");
window.sessionStorage.setItem("abc.login.request", "y");
window.sessionStorage.setItem("apm:postLoginReturnUrl", "/sessions?id=1");
switchAuthApp("legacy", "/sessions?id=1");
expect(window.localStorage.getItem("am_auth_app")).toBe("legacy");
expect(window.sessionStorage.getItem("am_login_attempt")).toBeNull();
expect(window.sessionStorage.getItem("msal.account.keys")).toBeNull();
expect(window.sessionStorage.getItem("abc.login.request")).toBeNull();
// The deep link survives the purge — only MSAL keys are doomed.
expect(window.sessionStorage.getItem("apm:postLoginReturnUrl")).toBe("/sessions?id=1");
expect(window.location.assign).toHaveBeenCalledWith("/sessions?id=1");
expect(window.location.reload).not.toHaveBeenCalled();
});
it("switchAuthApp without a target reloads in place (manual switch)", () => {
stubWindow();
switchAuthApp("primary");
expect(window.location.reload).toHaveBeenCalled();
expect(window.location.assign).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,16 @@
import { describe, it, expect } from "vitest";
import { PORTAL_HOST, portalHandoverUrl } from "../hostRouting";
describe("portalHandoverUrl", () => {
it("passes the learned app registration along as ?authapp=", () => {
expect(portalHandoverUrl("/dashboard", "legacy")).toBe(`https://${PORTAL_HOST}/dashboard?authapp=legacy`);
});
it("keeps an existing query string on the target", () => {
expect(portalHandoverUrl("/sessions?id=abc", "primary")).toBe(`https://${PORTAL_HOST}/sessions?id=abc&authapp=primary`);
});
it("adds nothing outside the parallel window", () => {
expect(portalHandoverUrl("/progress", null)).toBe(`https://${PORTAL_HOST}/progress`);
});
});
@@ -11,7 +11,7 @@ export function appHomingErrorMessage(reason: string | undefined, fallback: stri
case "self-service-disabled":
return "Self-service app switching is currently disabled by the operator.";
case "probe-failed":
return "Admin consent for the new app registration was not found in this tenant. Grant consent first, or force the switch as Global Admin.";
return "The new app registration is not fully consented in this tenant (missing, or lacking a Graph permission the previous app holds). Grant admin consent first, or force the switch as Global Admin.";
case "probe-transient":
return "Could not verify consent for the new app registration right now (transient error). Please try again.";
case "revert-is-ga-only":
+81 -10
View File
@@ -1,15 +1,22 @@
/**
* App-registration selection for the dual app-reg parallel window (legacy gktatooine app ∥
* new C4A8 app). Model: "default legacy, funnel primary, learn in the background" —
* new C4A8 app). Model: "default app for fresh browsers, learn the tenant's app, re-home once" —
*
* - A browser that has signed in before carries `am_auth_app` in localStorage and always
* boots MSAL with that app. auth/me returns the tenant's `homedApp` after every login and
* AuthContext re-writes the flag, so an operator flipping a tenant to the new app makes
* every user's NEXT login use it automatically (admin consent exists by then → seamless).
* - No flag → NEXT_PUBLIC_ENTRA_DEFAULT_APP decides (start: "legacy" = today's behaviour for
* every existing customer; flipped to "primary" once the re-consent campaign is done).
* - No flag → NEXT_PUBLIC_ENTRA_DEFAULT_APP decides which app a fresh browser signs in with
* ("primary" once the new app is the default; "legacy" during the initial window).
* - A redirect login that lands on the OTHER app than the tenant is homed on (fresh browser,
* wrong default) is re-homed right away: AuthGate switches the browser to the homed app
* and completes the sign-in silently via the Entra session (`requestRehome` →
* `consumePendingRehome`, one-shot per tab, never after a cross-app fallback). Without
* this the wrong app would carry the whole first session — e.g. without the tenant's
* Entra app-role assignments, which live on the homed app's enterprise application.
* - The signup funnel (get-started CTA) hands over to portal with ?authapp=primary so brand-new
* tenants consent the NEW app as part of the expected signup flow.
* tenants consent the NEW app as part of the expected signup flow; the www → portal handover
* after a sign-in passes the learned app the same way (localStorage is per-origin).
* - ?authapp=legacy|primary doubles as a silent support lever (persists the flag).
*
* Everything here is deliberately msal-free (no import cycles) and SSR-safe.
@@ -23,6 +30,7 @@ const SELECTED_KEY = "am_auth_app"; // localStorage: app of the last s
const ATTEMPT_KEY = "am_login_attempt"; // sessionStorage: app that started the in-flight redirect
const FALLBACK_KEY = "am_login_fallback"; // sessionStorage: the one-shot 90094 auto-fallback ran
const DECLINED_KEY = "am_login_declined"; // sessionStorage: user actively declined consent (65004)
const REHOME_KEY = "am_rehome"; // sessionStorage: the one-shot post-login re-home ran
export function legacyClientId(): string | undefined {
return process.env.NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID || undefined;
@@ -192,16 +200,75 @@ export function classifyEntraAuthError(errorText: string): "admin-approval-requi
return "other";
}
// ── Post-login re-homing ────────────────────────────────────────────────────
/** Inputs of the re-home decision, captured by AuthContext's redirect handling. */
export interface RehomeDecisionInput {
/** The app the tenant is homed on, as reported by auth/me. */
homedApp: AuthApp;
/** The app this page load signed in with. */
activeApp: AuthApp;
/** True only on the page load that completed a redirect sign-in (not on later refreshes). */
redirectLoginCompleted: boolean;
/**
* True when that sign-in only worked through the one-shot cross-app fallback — the OTHER
* app is then proven unusable in this tenant, and re-homing to it would ping-pong.
*/
viaFallback: boolean;
}
/**
* Whether the browser should switch to the tenant's homed app right after a redirect sign-in
* that ran on the other app. Pure; the one-shot tab guard lives in {@link tryBeginRehome}.
*/
export function shouldRehome(input: RehomeDecisionInput): boolean {
if (!legacyConfigured()) return false;
if (!input.redirectLoginCompleted || input.viaFallback) return false;
return input.homedApp !== input.activeApp;
}
let pendingRehome: AuthApp | null = null;
/** Records that the next post-login hop (AuthGate) should re-home the browser to `app`. */
export function requestRehome(app: AuthApp): void {
pendingRehome = app;
}
/** Reads AND clears the pending re-home request (one-shot, consumed by AuthGate). */
export function consumePendingRehome(): AuthApp | null {
const app = pendingRehome;
pendingRehome = null;
return app;
}
/**
* One-shot guard for the post-login re-home: true exactly once per browser tab. A tenant whose
* homed app cannot complete a sign-in for some other reason (a Conditional Access policy
* scoped to one app, say) thus costs at most one extra hop, never a loop.
*/
export function tryBeginRehome(): boolean {
try {
if (window.sessionStorage.getItem(REHOME_KEY)) return false;
window.sessionStorage.setItem(REHOME_KEY, "1");
return true;
} catch {
return false;
}
}
/**
* Switch this browser to the given app and reboot the bundle so the module-level MSAL
* instance is reconstructed for it. Purges MSAL state to avoid interaction_in_progress
* leftovers from an interrupted flow on the other app.
* leftovers from an interrupted flow on the other app. With `navigateTo` the reboot lands on
* that in-app path instead of reloading the current one (the post-login re-home: the landing
* page would otherwise show the public site to a now signed-out browser).
*/
export function switchAuthApp(app: AuthApp): void {
export function switchAuthApp(app: AuthApp, navigateTo?: string): void {
// Strategic trace point of the dual app-reg migration: an explicit app switch (support
// lever, post-flip "sign in with the new app" button). Best-effort — the reload below may
// outrun the batch, but AI's pagehide beacon usually carries it out.
trackEvent("auth_app_switched", { to: app });
// lever, post-flip "sign in with the new app" button) or the post-login re-home.
// Best-effort — the navigation below may outrun the batch, but AI's pagehide beacon
// usually carries it out.
trackEvent("auth_app_switched", { to: app, reason: navigateTo ? "rehome" : "manual" });
setSelectedAuthApp(app);
clearLoginAttemptApp();
clearLoginFallback();
@@ -215,5 +282,9 @@ export function switchAuthApp(app: AuthApp): void {
} catch {
/* ignore */
}
window.location.reload();
if (navigateTo) {
window.location.assign(navigateTo);
} else {
window.location.reload();
}
}
@@ -30,6 +30,9 @@ export const DOCS_PATHS = {
// Rules
analyzeRules: "/rules/analyze-rules",
gatherRules: "/rules/gather-rules",
// Dual app-registration window (sign-in banners, failed-sign-in screen)
appRegistrationMigration: "/troubleshooting-and-support/app-registration-migration",
appRegistrationMigrationAfter: "/troubleshooting-and-support/app-registration-migration#after-the-migration",
} as const;
/** Top-level URL segments GitBook publishes for docs.autopilotmonitor.com (SUMMARY.md groups + root pages). */
@@ -94,6 +94,18 @@ export function getPortalLoginUrl(path: string = DEFAULT_PORTAL_LANDING): string
return `https://${PORTAL_HOST}${normalized}`;
}
/**
* The www → portal handover URL after a sign-in on www. Auth state (and the dual app-reg
* selection in localStorage) is per-origin, so the app registration this browser learned
* travels along as `?authapp=` — the portal then boots the tenant's app directly instead of
* its default and a re-home hop. `target` may carry its own query string.
*/
export function portalHandoverUrl(target: string, authApp: "primary" | "legacy" | null): string {
const url = new URL(target, `https://${PORTAL_HOST}`);
if (authApp) url.searchParams.set("authapp", authApp);
return url.toString();
}
export type HostBounce = "to-portal" | "to-www" | null;
/**