mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
+20
@@ -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)-/);
|
||||
});
|
||||
});
|
||||
+22
@@ -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">
|
||||
|
||||
+19
-8
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user