Skip to content

Commit 02240ce

Browse files
committed
feat: Implement TenantAuthHealthService for runtime token-renewal monitoring
- Added TenantAuthHealthService to manage authentication state for tenants, including failure reporting and recovery. - Integrated the service into the application startup process. - Updated SettingsWindow to display re-authentication prompts and actions for tenants with expired tokens. - Enhanced SettingsViewModel to handle tenant authentication state changes and provide a "Fix sign-in" command. - Introduced unit tests for TenantAuthHealthService to ensure correct behavior of failure reporting and recovery mechanisms. - Updated UI components to reflect authentication state and provide user feedback on sign-in status.
1 parent 731404f commit 02240ce

24 files changed

Lines changed: 1375 additions & 33 deletions

src/AzureTray.Plugin.Contracts/AzureTray.Plugin.Contracts.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
the host's <Version> in Directory.Build.props. The host can ship
1616
many releases without changing this; we only bump when the
1717
contracts surface itself changes. Bump when adding members. -->
18-
<Version>1.0.0</Version>
18+
<Version>1.2.0</Version>
1919

2020
<IsPackable>true</IsPackable>
2121
<PackageId>AzureTray.Plugin.Contracts</PackageId>

src/AzureTray.Plugin.Contracts/ITrayPlugin.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@ public interface ITrayPlugin
2828

2929
/// <summary>
3030
/// The <see cref="PluginApiVersion.Current"/> value the plugin was compiled
31-
/// against. The host rejects any plugin whose declared value does not match
32-
/// its own — keeps ABI mismatches loud and visible.
31+
/// against. The host loads the plugin when this falls within its supported
32+
/// range [<see cref="PluginApiVersion.MinSupported"/>,
33+
/// <see cref="PluginApiVersion.Current"/>] and rejects it (with a logged
34+
/// message naming the range) otherwise — keeps ABI mismatches loud and visible.
3335
/// </summary>
3436
int ApiVersion { get; }
3537

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,50 @@
1-
namespace AzureTray.Plugin.Contracts;
1+
namespace AzureTray.Plugin.Contracts;
22

33
/// <summary>
4-
/// Contract version gate. Plugins declare <see cref="ITrayPlugin.ApiVersion"/>
5-
/// equal to <see cref="Current"/> and the host rejects any plugin whose
6-
/// declared value does not match its own. The value is bumped only on breaking
7-
/// changes — minor host releases keep loading existing plugins unchanged.
4+
/// Contract version gate. A plugin declares <see cref="ITrayPlugin.ApiVersion"/>
5+
/// and the host loads it when that value falls within the supported range
6+
/// [<see cref="MinSupported"/>, <see cref="Current"/>]. Use
7+
/// <see cref="IsSupported(int)"/> to test a value against the range.
88
/// </summary>
9+
/// <remarks>
10+
/// <para>Evolution policy:</para>
11+
/// <list type="bullet">
12+
/// <item><description>
13+
/// Additive, binary-compatible surface changes (new default-interface members,
14+
/// new optional capability interfaces, new init-only DTO properties) bump
15+
/// <see cref="Current"/> and leave <see cref="MinSupported"/> alone, so plugins
16+
/// built against any version still in the window keep loading.
17+
/// </description></item>
18+
/// <item><description>
19+
/// A genuinely breaking change raises <see cref="MinSupported"/> to lock out the
20+
/// now-incompatible older plugins — the host logs the rejection with the range.
21+
/// </description></item>
22+
/// </list>
23+
/// <para>
24+
/// The contracts assembly keeps a fixed <c>AssemblyVersion</c>, so an old
25+
/// plugin always binds to the host's current copy at runtime; this range is the
26+
/// only thing that decides whether that copy will load it.
27+
/// </para>
28+
/// </remarks>
929
public static class PluginApiVersion
1030
{
11-
/// <summary>The current contract version. Declare this in <see cref="ITrayPlugin.ApiVersion"/>.</summary>
31+
/// <summary>
32+
/// The newest contract version. New plugins should declare this in
33+
/// <see cref="ITrayPlugin.ApiVersion"/>.
34+
/// </summary>
1235
public const int Current = 2;
36+
37+
/// <summary>
38+
/// The oldest contract version the host still loads. Raise this only when
39+
/// dropping support for an old contract shape (a breaking change).
40+
/// </summary>
41+
public const int MinSupported = 1;
42+
43+
/// <summary>
44+
/// True when a plugin declaring <paramref name="apiVersion"/> is loadable by
45+
/// this host — i.e. it falls within
46+
/// [<see cref="MinSupported"/>, <see cref="Current"/>].
47+
/// </summary>
48+
public static bool IsSupported(int apiVersion)
49+
=> apiVersion >= MinSupported && apiVersion <= Current;
1350
}

src/AzureTray.Plugin.Contracts/PluginMenuItem.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ namespace AzureTray.Plugin.Contracts;
3131
/// <see cref="Invoke"/> but does not dismiss the menu. Use for refresh-style
3232
/// actions where the user expects to see the result land in the visible menu.
3333
/// </para>
34+
/// <para>
35+
/// When <see cref="IsFavorite"/> is non-null the host renders a star at the
36+
/// right edge of the row (☆ for <c>false</c>, ★ for <c>true</c>). Clicking the
37+
/// star fires <see cref="OnToggleFavorite"/> and flips the glyph in place
38+
/// without dismissing the menu or triggering the row's primary
39+
/// <see cref="Invoke"/>/<see cref="Children"/> action. Leave it <c>null</c> on
40+
/// rows that aren't favoritable so no star is shown.
41+
/// </para>
3442
/// </remarks>
3543
public sealed record PluginMenuItem(
3644
string Text,
@@ -42,7 +50,9 @@ public sealed record PluginMenuItem(
4250
bool KeepMenuOpen = false,
4351
string? Icon = null,
4452
Func<string, IReadOnlyList<PluginMenuItem>>? SearchProvider = null,
45-
string? SearchPlaceholder = null)
53+
string? SearchPlaceholder = null,
54+
bool? IsFavorite = null,
55+
Action? OnToggleFavorite = null)
4656
{
4757
/// <summary>A pre-built horizontal divider. Use instead of constructing manually.</summary>
4858
public static PluginMenuItem Separator { get; } = new(string.Empty, IsSeparator: true);
@@ -52,4 +62,11 @@ public sealed record PluginMenuItem(
5262
/// Convenience for XAML data binding (chevron visibility).
5363
/// </summary>
5464
public bool HasChildren => Children is { Count: > 0 } || SearchProvider is not null;
65+
66+
/// <summary>
67+
/// <c>true</c> when this row should display a favorite star. Convenience for
68+
/// XAML data binding (star visibility); mirrors <see cref="IsFavorite"/>
69+
/// having a value.
70+
/// </summary>
71+
public bool ShowFavorite => IsFavorite.HasValue;
5572
}

src/AzureTray.Plugin.Contracts/README.md

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,65 @@ The SDK package for building [AzureTray](https://github.com/Proxylayer/AzureTray
2626
the contracts assembly into a collectible AssemblyLoadContext;
2727
shipping a transitive dependency on this package would cause
2828
duplicate type loads and break the ITrayPlugin cast. -->
29-
<PackageReference Include="AzureTray.Plugin.Contracts" Version="0.2.0" PrivateAssets="all" />
29+
<PackageReference Include="AzureTray.Plugin.Contracts" Version="1.2.0" PrivateAssets="all" />
3030
</ItemGroup>
3131
</Project>
3232
```
3333

34+
## Packaging & deployment
35+
36+
### 1. Add the required project property
37+
38+
Add `<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>` to your `<PropertyGroup>`. This copies every transitive NuGet dependency into the build output so the host can resolve them at runtime without a global package cache.
39+
40+
```xml
41+
<PropertyGroup>
42+
<TargetFramework>net8.0</TargetFramework>
43+
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
44+
...
45+
</PropertyGroup>
46+
```
47+
48+
### 2. Publish (recommended) or build
49+
50+
```powershell
51+
# Produces a self-contained output folder with a .deps.json — the preferred path.
52+
dotnet publish YourPlugin.csproj -c Release -o ./out
53+
54+
# A plain build also works; the host falls back to sibling-DLL resolution
55+
# when no .deps.json is present.
56+
dotnet build YourPlugin.csproj -c Release
57+
```
58+
59+
Do **not** ship `AzureTray.Plugin.Contracts.dll` in the output — the `PrivateAssets="all"` reference in your `.csproj` already excludes it. The host loads its own copy of that assembly; a second copy causes a type-identity mismatch and the `ITrayPlugin` cast silently fails.
60+
61+
### 3. Install into the plugins folder
62+
63+
The host scans `%LOCALAPPDATA%\AzureTray.Data\plugins\` at startup using two layouts:
64+
65+
| Layout | When to use | Steps |
66+
|---|---|---|
67+
| **Subfolder (recommended)** | Any plugin with transitive deps | Create `plugins\<YourPackageId>\`, copy your publish output there. The main DLL **must** be named `<folder-name>.dll` (e.g. `plugins\Acme.Plugin.Foo\Acme.Plugin.Foo.dll`). |
68+
| **Flat (legacy)** | Single-DLL plugins with no private deps | Drop `YourPlugin.dll` directly in `plugins\`. |
69+
70+
For the subfolder layout the loader first tries `plugins\<folder>\<folder>.dll`; if that name doesn't exist it scans for any DLL in the folder that contains an `ITrayPlugin` implementation. Framework assemblies whose name starts with `System.`, `Microsoft.`, `Azure.`, or `Newtonsoft.` are skipped during the scan.
71+
72+
### 4. Runtime trust mode
73+
74+
The default trust mode is `AllowUnsigned` (development). For your own testing this means no signing is needed. Deployments configured with `RequireSigned` or `RequireTrustedPublisher` will reject the plugin unless it carries a valid Authenticode signature.
75+
3476
## Version gate
3577

36-
Plugins declare `ITrayPlugin.ApiVersion` and the host rejects any plugin whose value doesn't equal `PluginApiVersion.Current`. The contract version bumps only on breaking changes — minor host releases keep loading existing plugins.
78+
Plugins declare `ITrayPlugin.ApiVersion` (the `PluginApiVersion.Current` value they were built against). The host loads a plugin when that value falls within its **supported range** `[PluginApiVersion.MinSupported, PluginApiVersion.Current]`; anything outside the range is rejected with a logged message naming the range. Use `PluginApiVersion.IsSupported(int)` to test a value.
79+
80+
Because the contracts assembly keeps a **fixed `AssemblyVersion`**, an old plugin always binds to the host's current contracts copy at runtime — the range is the only thing that decides whether that copy will load it.
81+
82+
How the range moves:
83+
84+
- **Additive, binary-compatible changes** (a new default-interface member, a new optional capability interface, a new init-only property on a record) bump `Current` and leave `MinSupported` alone. Plugins built against any version still in the window keep loading — so you can build against an older API and keep running on newer hosts.
85+
- **Breaking changes** raise `MinSupported`, intentionally locking out the now-incompatible older plugins. These should be rare; prefer the additive techniques above.
86+
87+
To run a single plugin binary across a span of hosts, build against the lowest API you need and feature-detect newer host capabilities at runtime via `IPluginContext.HostVersion`.
3788

3889
## More
3990

src/AzureTray.Plugin.LAPS/LapsPlugin.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Collections.Concurrent;
3+
using System.Reflection;
34
using System.Collections.Generic;
45
using System.Linq;
56
using System.Net;
@@ -65,7 +66,18 @@ public void SetValue(string key, object? value)
6566

6667
public string DisplayName => "LAPS Passwords";
6768

68-
public string Version => "0.1.0";
69+
public string Version { get; } = ResolveVersion();
70+
private static string ResolveVersion()
71+
{
72+
var asm = typeof(LapsPlugin).Assembly;
73+
var v = asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
74+
if (!string.IsNullOrEmpty(v))
75+
{
76+
var plus = v.IndexOf('+', StringComparison.Ordinal);
77+
return plus >= 0 ? v[..plus] : v;
78+
}
79+
return asm.GetName().Version?.ToString() ?? "0.0.0";
80+
}
6981

7082
public int ApiVersion => PluginApiVersion.Current;
7183

src/AzureTray.Plugin.PIM/PimPlugin.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Reflection;
34
using System.Linq;
45
using System.Threading;
56
using System.Threading.Tasks;
@@ -62,7 +63,18 @@ private sealed record TenantWatchers(PendingApprovalWatcher Pending, EligibleRol
6263

6364
public string DisplayName => "Azure PIM";
6465

65-
public string Version => "0.1.0";
66+
public string Version { get; } = ResolveVersion();
67+
private static string ResolveVersion()
68+
{
69+
var asm = typeof(PimPlugin).Assembly;
70+
var v = asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
71+
if (!string.IsNullOrEmpty(v))
72+
{
73+
var plus = v.IndexOf('+', StringComparison.Ordinal);
74+
return plus >= 0 ? v[..plus] : v;
75+
}
76+
return asm.GetName().Version?.ToString() ?? "0.0.0";
77+
}
6678

6779
public int ApiVersion => PluginApiVersion.Current;
6880

src/AzureTray/Configuration/AuthOptions.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ public sealed class AuthOptions
1010

1111
public int TokenAcquisitionTimeoutSeconds { get; init; } = 30;
1212

13+
// How often the background token monitor silently re-probes each ready
14+
// tenant to detect a refresh token that expired mid-session. Clamped to a
15+
// 30s floor at runtime. Set lower in development to exercise the re-auth
16+
// popup / Settings button without waiting for a real expiry.
17+
public int TokenMonitorIntervalSeconds { get; init; } = 300;
18+
1319
// Display-name (or prefix) of the app registration the host tries to
1420
// auto-discover when adding a tenant via "Sign in with Windows" or a
1521
// domain lookup. After the broker / OIDC step resolves the tenant ID,

src/AzureTray/Models/Tenant.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,13 @@ public sealed record Tenant(
1818
string DisplayName,
1919
string? ClientId,
2020
string? SignInEmail = null,
21-
bool ProbeDisabled = false);
21+
bool ProbeDisabled = false)
22+
{
23+
// Transient, runtime-only flag: set when the tenant's token failed to
24+
// renew mid-session and interactive sign-in is required (see
25+
// ITenantAuthHealth). Drives the "Fix sign-in" button on the Settings row.
26+
// [JsonIgnore] keeps it out of the persisted store — it always loads false
27+
// and is recomputed from ITenantAuthHealth when the Settings window opens.
28+
[System.Text.Json.Serialization.JsonIgnore]
29+
public bool NeedsReauth { get; init; }
30+
}

src/AzureTray/Notifications/NotificationService.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,23 @@ private static void PositionWindow(NotificationWindow window, int slot)
138138
var workArea = SystemParameters.WorkArea;
139139
window.Width = WindowWidth;
140140
window.Left = workArea.Right - WindowWidth - EdgeMargin;
141+
// Initial estimate based on fixed slot height so the window doesn't
142+
// flash at (0,0) before layout completes.
141143
window.Top = workArea.Bottom - (slot + 1) * (StackSlotHeight + StackSpacing) - EdgeMargin;
144+
145+
// Re-anchor from the slot's bottom edge once the real height is
146+
// known, and again whenever the window resizes (e.g. the Details
147+
// expander is opened). This prevents tall notifications from
148+
// clipping off the top of the work area.
149+
void Reanchor()
150+
{
151+
if (window.ActualHeight == 0) return;
152+
var wa = SystemParameters.WorkArea;
153+
var slotBottom = wa.Bottom - slot * (StackSlotHeight + StackSpacing) - EdgeMargin;
154+
window.Top = Math.Max(wa.Top + EdgeMargin, slotBottom - window.ActualHeight);
155+
}
156+
157+
window.ContentRendered += (_, _) => Reanchor();
158+
window.SizeChanged += (_, _) => Reanchor();
142159
}
143160
}

0 commit comments

Comments
 (0)