From 3e940de26a70437ad45853ce0ab43c3246f5f2ac Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 29 May 2026 00:47:18 +0800 Subject: [PATCH 1/4] user seed --- Services/Program.cs | 23 +++++++- Services/SetupPages.cs | 85 +++++++++++++++++++++++++--- Services/SetupService.cs | 118 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 214 insertions(+), 12 deletions(-) diff --git a/Services/Program.cs b/Services/Program.cs index ded4d87..c1d0efd 100644 --- a/Services/Program.cs +++ b/Services/Program.cs @@ -930,9 +930,9 @@ await context.Response.WriteAsync(System.Text.Json.JsonSerializer.Serialize(new return Results.Content(SetupPages.IndexHtml, "text/html"); }); -app.MapGet("/api/setup/status", (HttpContext context) => +app.MapGet("/api/setup/status", async (HttpContext context) => { - var status = setupService.GetStatus(); + var status = await setupService.GetStatus(context.RequestAborted); return Results.Json(status); }); @@ -1005,6 +1005,25 @@ await context.Response.WriteAsync(System.Text.Json.JsonSerializer.Serialize(new await setupService.ConfigureManual(appId, clientSecret, tenantId, multiTenant); return Results.Json(new { success = true, message = "App Service auth configured. The app will restart to apply changes." }); }); + +app.MapPost("/api/setup/seed-user", async (HttpContext context) => +{ + try + { + using var reader = new StreamReader(context.Request.Body); + var body = await reader.ReadToEndAsync(); + using var doc = System.Text.Json.JsonDocument.Parse(body); + var root = doc.RootElement; + + var upn = root.GetProperty("upn").GetString()!; + await setupService.SeedFirstUser(upn, context.RequestAborted); + return Results.Json(new { success = true, message = $"Superadmin user {upn} added successfully." }); + } + catch (Exception ex) + { + return Results.Json(new { success = false, message = ex.Message }, statusCode: 400); + } +}); } // end Setup.Enabled // --- Job Status API (C# direct — no PS overhead) --- diff --git a/Services/SetupPages.cs b/Services/SetupPages.cs index e8cbf26..34f725b 100644 --- a/Services/SetupPages.cs +++ b/Services/SetupPages.cs @@ -91,6 +91,7 @@ public static class SetupPages font-size: 0.85rem; color: #94a3b8; margin-top: 0.5rem; } .device-code-box a { color: #60a5fa; text-decoration: underline; } + .disabled-section { opacity: 0.4; pointer-events: none; } @@ -100,9 +101,21 @@ public static class SetupPages - -
-

Automated Setup

+ +
+

Step 1: First User

+

Add the first superadmin user before configuring authentication. This user will have full access to the application.

+ + + +
+
+ +
+ + +
+

Step 2a: Automated Setup

Sign in with a Global Administrator account to automatically create the EasyAuth app registration and configure this App Service.

@@ -131,11 +144,11 @@ public static class SetupPages
-
+
- -
-

Manual Setup

+ +
+

Step 2b: Manual Setup

If you already have an app registration, enter the details below.

@@ -205,15 +218,69 @@ function setTenantMode(section, isMulti) { if (setupState.isEasyAuthConfigured) { showBanner('Authentication is already configured. Redirecting...'); setTimeout(() => window.location.href = '/', 2000); + return; } if (!setupState.isRunningInAppService || !setupState.hasManagedIdentity) { showBanner('Warning: No managed identity detected. ARM self-configuration may fail. Use manual setup instead.'); } + + // Handle user table status + const us = setupState.usersStatus; + if (!us || !us.connected) { + // Connection error — disable user section + document.getElementById('btn-seed').disabled = true; + showStatus('seed-status', 'Cannot connect to storage: ' + (us?.error || 'Unknown error'), 'error'); + } else if (us.hasUsers) { + // Users already exist — skip to auth setup + document.getElementById('user-section').classList.add('disabled-section'); + showStatus('seed-status', 'Users already exist in the table. Proceed to authentication setup below.', 'success'); + enableAuthSections(); + } else { + // No users — enable the seed form, keep auth disabled + document.getElementById('btn-seed').disabled = false; + } } catch (e) { console.error('Failed to load status', e); } })(); + function enableAuthSections() { + document.getElementById('auto-section').classList.remove('disabled-section'); + document.getElementById('manual-section').classList.remove('disabled-section'); + } + + async function seedFirstUser() { + const upn = document.getElementById('seed-upn').value.trim(); + if (!upn) { + showStatus('seed-status', 'Please enter a valid email address.', 'error'); + return; + } + + const btn = document.getElementById('btn-seed'); + btn.disabled = true; + btn.textContent = 'Adding user...'; + showStatus('seed-status', 'Adding superadmin user...', 'info'); + + try { + const res = await fetch('/api/setup/seed-user', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ upn }) + }); + + const data = await res.json(); + if (!res.ok || !data.success) throw new Error(data.message || 'Failed to add user'); + + showStatus('seed-status', data.message, 'success'); + document.getElementById('user-section').classList.add('disabled-section'); + enableAuthSections(); + } catch (e) { + showStatus('seed-status', 'Error: ' + e.message, 'error'); + btn.disabled = false; + btn.textContent = 'Add Superadmin'; + } + } + function showBanner(msg) { const el = document.getElementById('status-banner'); el.textContent = msg; @@ -378,9 +445,11 @@ async function submitManual() { } function showRestartScreen() { // Hide setup sections, show restart polling UI + document.getElementById('user-section').classList.add('hidden'); document.getElementById('auto-section').classList.add('hidden'); document.getElementById('manual-section').classList.add('hidden'); - document.querySelector('.divider').classList.add('hidden'); + document.getElementById('divider-user').classList.add('hidden'); + document.getElementById('divider-auth').classList.add('hidden'); document.querySelector('.subtitle').textContent = ''; document.getElementById('page-title').textContent = 'Restarting...'; diff --git a/Services/SetupService.cs b/Services/SetupService.cs index 220b576..3d3b1c5 100644 --- a/Services/SetupService.cs +++ b/Services/SetupService.cs @@ -2,6 +2,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using Azure.Data.Tables; namespace Craft.Services; @@ -584,16 +585,120 @@ public async Task ConfigureManual( await ConfigureAppServiceAuth(appId, clientSecret, tenantId, multiTenant, ct); } + // ── First User Seeding ── + + /// + /// Resolves the storage connection string for the allowedUsers table. + /// Same logic as AuthService — uses Auth.UserStorageConnection if set, + /// falls back to AzureWebJobsStorage, then dev storage. + /// + private string StorageConnectionString => + (!string.IsNullOrEmpty(_settings.Auth.UserStorageConnection) + ? _settings.Auth.UserStorageConnection + : Environment.GetEnvironmentVariable("AzureWebJobsStorage")) + ?? "UseDevelopmentStorage=true"; + + /// + /// Resolves the user table name with the same sanitization as AuthService. + /// + private string ResolveUserTableName() + { + var raw = _settings.Auth.UserTableName; + var sanitized = new string(raw.Where(char.IsLetterOrDigit).ToArray()); + if (sanitized.Length > 63) sanitized = sanitized[..63]; + if (sanitized.Length < 3) sanitized = "allowedUsers"; + return sanitized; + } + + /// + /// Checks the allowedUsers table status: whether it's reachable and whether + /// it already contains any users. + /// + public async Task CheckAllowedUsersStatus(CancellationToken ct = default) + { + try + { + var tableName = ResolveUserTableName(); + var client = new TableClient(StorageConnectionString, tableName); + await client.CreateIfNotExistsAsync(cancellationToken: ct); + + var count = 0; + await foreach (var entity in client.QueryAsync(cancellationToken: ct)) + { + if (!entity.RowKey.StartsWith("_")) + { + count++; + if (count > 0) break; // We only need to know if any exist + } + } + + return new AllowedUsersStatus + { + Connected = true, + HasUsers = count > 0 + }; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[Setup] Failed to check allowedUsers table"); + return new AllowedUsersStatus + { + Connected = false, + HasUsers = false, + Error = ex.Message + }; + } + } + + /// + /// Seeds the first superadmin user into the allowedUsers table. + /// Only works when the table is empty — refuses if users already exist. + /// Uses the same entity schema as CIPP-API's Invoke-ExecCIPPUsers. + /// + public async Task SeedFirstUser(string upn, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(upn)) + throw new ArgumentException("UPN (email) is required."); + + upn = upn.Trim().ToLower(); + + var tableName = ResolveUserTableName(); + var client = new TableClient(StorageConnectionString, tableName); + await client.CreateIfNotExistsAsync(cancellationToken: ct); + + // Guard: refuse if the table already has users + await foreach (var entity in client.QueryAsync(cancellationToken: ct)) + { + if (!entity.RowKey.StartsWith("_")) + throw new InvalidOperationException("The allowed users table already contains users. First-user seeding is only available on an empty table."); + } + + var roles = new[] { "superadmin" }; + var rolesJson = JsonSerializer.Serialize(roles); + + var userEntity = new TableEntity("User", upn) + { + ["Roles"] = rolesJson, + ["ManualRoles"] = rolesJson, + ["AutoRoles"] = "[]", + ["Source"] = "Manual" + }; + + await client.UpsertEntityAsync(userEntity, TableUpdateMode.Replace, ct); + _logger.LogInformation("[Setup] Seeded first superadmin user: {Upn}", upn); + } + // ── Status ── /// /// Returns setup status information. /// - public SetupStatus GetStatus() + public async Task GetStatus(CancellationToken ct = default) { var isConfigured = IsEasyAuthConfigured(); var siteName = Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME"); var hasManagedIdentity = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("IDENTITY_ENDPOINT")); + var usersStatus = await CheckAllowedUsersStatus(ct); return new SetupStatus { @@ -602,7 +707,8 @@ public SetupStatus GetStatus() HasManagedIdentity = hasManagedIdentity, AppName = _settings.Name, AuthAppDisplayName = ResolveAuthAppDisplayName(), - BootstrapClientId = _settings.Setup.BootstrapClientId + BootstrapClientId = _settings.Setup.BootstrapClientId, + UsersStatus = usersStatus }; } @@ -836,6 +942,14 @@ public class SetupStatus public string AppName { get; set; } = ""; public string AuthAppDisplayName { get; set; } = ""; public string BootstrapClientId { get; set; } = ""; + public AllowedUsersStatus UsersStatus { get; set; } = new(); + } + + public class AllowedUsersStatus + { + public bool Connected { get; set; } + public bool HasUsers { get; set; } + public string? Error { get; set; } } public class DeviceCodeResponse From 0bc032f00d5c20bcde880f54e72c385f88274946 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 29 May 2026 08:11:01 +0800 Subject: [PATCH 2/4] auth tweaks --- Services/AppLifecycleBridge.cs | 2 +- Services/CraftSettings.cs | 9 --------- Services/Program.cs | 17 +++++------------ 3 files changed, 6 insertions(+), 22 deletions(-) diff --git a/Services/AppLifecycleBridge.cs b/Services/AppLifecycleBridge.cs index 6b5ec4e..c77e7eb 100644 --- a/Services/AppLifecycleBridge.cs +++ b/Services/AppLifecycleBridge.cs @@ -57,7 +57,7 @@ public static void RequestSetupMode(string reason = "Setup mode requested by app /// /// Returns true if the child app has explicitly requested setup mode. - /// Used by the setup middleware when AutoActivate is false. + /// Used by the setup middleware to determine whether to activate the setup wizard. /// public static bool IsSetupModeRequested() => s_setupModeRequested; } diff --git a/Services/CraftSettings.cs b/Services/CraftSettings.cs index 3251a0d..aef49d5 100644 --- a/Services/CraftSettings.cs +++ b/Services/CraftSettings.cs @@ -517,15 +517,6 @@ public class SetupSettings /// public bool Enabled { get; set; } = false; - /// - /// When true, the setup wizard activates automatically if EasyAuth is not configured. - /// When false, the child app must explicitly call - /// [Craft.Services.AppLifecycleBridge]::RequestSetupMode() to activate setup mode. - /// This lets the child app decide when setup is appropriate (e.g. after checking - /// for existing credentials that can be migrated automatically). - /// - public bool AutoActivate { get; set; } = true; - /// /// Public client ID used for the PKCE login popup during automated setup. /// Defaults to Microsoft's Azure PowerShell first-party app which supports diff --git a/Services/Program.cs b/Services/Program.cs index c1d0efd..da1bd4c 100644 --- a/Services/Program.cs +++ b/Services/Program.cs @@ -278,15 +278,12 @@ void RunInitialization() app.UseResponseCompression(); // Setup mode middleware: when Setup.Enabled, register setup route guards. -// If AutoActivate is true, setup mode activates automatically when EasyAuth is not configured. -// If AutoActivate is false, the child app must call AppLifecycleBridge.RequestSetupMode() -// to explicitly enable the setup wizard (e.g. after checking for existing credentials). +// The child app must call AppLifecycleBridge.RequestSetupMode() to activate the +// setup wizard (e.g. after determining it cannot auto-configure from existing credentials). var setupService = app.Services.GetRequiredService(); if (CraftSettings.Setup.Enabled) { - var autoActivate = CraftSettings.Setup.AutoActivate; - app.Use(async (context, next) => { if (SetupService.IsEasyAuthConfigured()) @@ -314,10 +311,8 @@ void RunInitialization() return; } - // EasyAuth NOT configured — check whether setup mode should be active. - // AutoActivate: always active. Otherwise: only after child app calls RequestSetupMode(). - var setupActive = autoActivate || AppLifecycleBridge.IsSetupModeRequested(); - if (!setupActive) + // EasyAuth NOT configured — setup mode only active after child app calls RequestSetupMode(). + if (!AppLifecycleBridge.IsSetupModeRequested()) { // Setup not yet requested by child app — let requests through normally // (the startup loading middleware will handle the "pool not ready" case) @@ -368,10 +363,8 @@ void RunInitialization() context.Response.Redirect("/setup"); }); - logger.LogInformation("[Setup] Setup mode enabled (AutoActivate={AutoActivate}) — {Status}", - autoActivate, + logger.LogInformation("[Setup] Setup mode enabled — {Status}", SetupService.IsEasyAuthConfigured() ? "EasyAuth already configured, setup endpoints disabled" - : autoActivate ? "awaiting configuration at /setup" : "waiting for child app to call RequestSetupMode()"); } From d5899b947e1fad1e918bdb139835f4ccbd586e80 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 29 May 2026 08:59:15 +0800 Subject: [PATCH 3/4] setup page tweaks --- Services/AppLifecycleBridge.cs | 24 ++++++++++++++++ Services/CraftSettings.cs | 14 ++++++++++ Services/Program.cs | 8 ++++++ Services/SetupPages.cs | 17 ++++++++++++ Services/SetupService.cs | 51 ++++++++++++++++++++++++++++++++-- appsettings.json | 6 +++- 6 files changed, 116 insertions(+), 4 deletions(-) diff --git a/Services/AppLifecycleBridge.cs b/Services/AppLifecycleBridge.cs index c77e7eb..6935472 100644 --- a/Services/AppLifecycleBridge.cs +++ b/Services/AppLifecycleBridge.cs @@ -43,6 +43,8 @@ public static bool IsEasyAuthConfigured() // --- Setup mode gating --- private static volatile bool s_setupModeRequested; + private static volatile bool s_setupCompleted; + private static string? s_setupCompletedReason; /// /// Explicitly enables the Craft setup wizard. Call this from the child app @@ -60,4 +62,26 @@ public static void RequestSetupMode(string reason = "Setup mode requested by app /// Used by the setup middleware to determine whether to activate the setup wizard. /// public static bool IsSetupModeRequested() => s_setupModeRequested; + + /// + /// Marks setup as completed — credentials have been applied and the app is + /// pending restart. Prevents duplicate credential submissions and lets all + /// setup page instances detect completion via status polling. + /// + public static void MarkSetupCompleted(string reason = "Setup credentials applied") + { + s_setupCompleted = true; + s_setupCompletedReason = reason; + s_logger?.LogInformation("[Lifecycle] Setup marked as completed: {Reason}", reason); + } + + /// + /// Returns true if setup credentials have already been applied this session. + /// + public static bool IsSetupCompleted() => s_setupCompleted; + + /// + /// Returns the reason setup was completed, or null if not yet completed. + /// + public static string? GetSetupCompletedReason() => s_setupCompletedReason; } diff --git a/Services/CraftSettings.cs b/Services/CraftSettings.cs index aef49d5..53e3231 100644 --- a/Services/CraftSettings.cs +++ b/Services/CraftSettings.cs @@ -573,6 +573,20 @@ public class SetupSettings /// The tenant from the setup flow is always included automatically. /// public List AllowedTenants { get; set; } = []; + + /// + /// When set, the EasyAuth client secret is stored in Azure Key Vault instead of + /// directly in the app setting. The app setting AUTH_SECRET is then written as a + /// Key Vault reference (@Microsoft.KeyVault(SecretUri=...)). + /// + /// Value is the Key Vault name (e.g. "my-vault" → https://my-vault.vault.azure.net). + /// If set to the literal string "auto", the site name (WEBSITE_SITE_NAME) is used + /// as the vault name. + /// + /// The managed identity must have Secret Set permission on the vault. + /// When empty (default), the secret is stored directly in the app setting. + /// + public string KeyVaultName { get; set; } = ""; } /// diff --git a/Services/Program.cs b/Services/Program.cs index da1bd4c..b72a3c2 100644 --- a/Services/Program.cs +++ b/Services/Program.cs @@ -969,6 +969,9 @@ await context.Response.WriteAsync(System.Text.Json.JsonSerializer.Serialize(new app.MapPost("/api/setup/configure", async (HttpContext context) => { + if (AppLifecycleBridge.IsSetupCompleted()) + return Results.Json(new { success = false, message = "Setup already completed. The app is pending restart." }, statusCode: 409); + using var reader = new StreamReader(context.Request.Body); var body = await reader.ReadToEndAsync(); using var doc = System.Text.Json.JsonDocument.Parse(body); @@ -980,11 +983,15 @@ await context.Response.WriteAsync(System.Text.Json.JsonSerializer.Serialize(new var multiTenant = root.TryGetProperty("multiTenant", out var mt) && mt.GetBoolean(); await setupService.ConfigureAppServiceAuth(appId, clientSecret, tenantId, multiTenant); + AppLifecycleBridge.MarkSetupCompleted("EasyAuth configured via automated setup"); return Results.Json(new { success = true, message = "App Service auth configured. The app will restart to apply changes." }); }); app.MapPost("/api/setup/manual", async (HttpContext context) => { + if (AppLifecycleBridge.IsSetupCompleted()) + return Results.Json(new { success = false, message = "Setup already completed. The app is pending restart." }, statusCode: 409); + using var reader = new StreamReader(context.Request.Body); var body = await reader.ReadToEndAsync(); using var doc = System.Text.Json.JsonDocument.Parse(body); @@ -996,6 +1003,7 @@ await context.Response.WriteAsync(System.Text.Json.JsonSerializer.Serialize(new var multiTenant = root.TryGetProperty("multiTenant", out var mt2) && mt2.GetBoolean(); await setupService.ConfigureManual(appId, clientSecret, tenantId, multiTenant); + AppLifecycleBridge.MarkSetupCompleted("EasyAuth configured via manual setup"); return Results.Json(new { success = true, message = "App Service auth configured. The app will restart to apply changes." }); }); diff --git a/Services/SetupPages.cs b/Services/SetupPages.cs index 34f725b..dccca98 100644 --- a/Services/SetupPages.cs +++ b/Services/SetupPages.cs @@ -244,6 +244,21 @@ function setTenantMode(section, isMulti) { } })(); + // Background poll — detect setup completion from any session + let statusPollTimer = setInterval(async () => { + try { + const res = await fetch('/api/setup/status', { cache: 'no-store' }); + if (!res.ok) return; + const status = await res.json(); + if (status.isSetupCompleted || status.isEasyAuthConfigured) { + clearInterval(statusPollTimer); + showRestartScreen(); + } + } catch (e) { + // Setup endpoint may be unavailable during restart — ignore + } + }, 5000); + function enableAuthSections() { document.getElementById('auto-section').classList.remove('disabled-section'); document.getElementById('manual-section').classList.remove('disabled-section'); @@ -444,6 +459,8 @@ async function submitManual() { } } function showRestartScreen() { + // Stop background status polling — restart screen has its own polling + clearInterval(statusPollTimer); // Hide setup sections, show restart polling UI document.getElementById('user-section').classList.add('hidden'); document.getElementById('auto-section').classList.add('hidden'); diff --git a/Services/SetupService.cs b/Services/SetupService.cs index 3d3b1c5..d10f9e0 100644 --- a/Services/SetupService.cs +++ b/Services/SetupService.cs @@ -479,8 +479,30 @@ public async Task ConfigureAppServiceAuth( } } - // 2. Add the client secret setting (referenced by authsettingsV2 clientSecretSettingName) - mergedSettings["AUTH_SECRET"] = clientSecret; + // 2. Store the client secret — either directly or via Key Vault reference + var kvName = _settings.Setup.KeyVaultName; + if (!string.IsNullOrEmpty(kvName)) + { + if (kvName.Equals("auto", StringComparison.OrdinalIgnoreCase)) + kvName = siteName; + + // Store secret in Key Vault via REST API + var vaultToken = await GetManagedIdentityToken("https://vault.azure.net", ct) + ?? throw new InvalidOperationException("Cannot get Key Vault token — ensure the managed identity has Secret Set permission on the vault"); + + var kvSecretUrl = $"https://{kvName}.vault.azure.net/secrets/AUTH-SECRET?api-version=7.4"; + var kvBody = new { value = clientSecret }; + await KeyVaultRequest(HttpMethod.Put, kvSecretUrl, vaultToken, kvBody, ct); + + // Set app setting as a KV reference + mergedSettings["AUTH_SECRET"] = $"@Microsoft.KeyVault(VaultName={kvName};SecretName=AUTH-SECRET)"; + _logger.LogInformation("[Setup] Client secret stored in Key Vault '{VaultName}', app setting set as KV reference", kvName); + } + else + { + // Store secret directly in app setting (default) + mergedSettings["AUTH_SECRET"] = clientSecret; + } // Determine effective allowed tenants (always include the setup tenant) bool useCommonIssuer = multiTenant; @@ -496,7 +518,7 @@ await ArmRequest(HttpMethod.Put, $"{baseUri}/config/appsettings?api-version=2024-11-01", managementToken, settingsBody, ct); - _logger.LogInformation("[Setup] App settings updated (AUTH_SECRET set, WEBSITE_AUTH_AAD_ALLOWED_TENANTS removed)"); + _logger.LogInformation("[Setup] App settings updated (WEBSITE_AUTH_AAD_ALLOWED_TENANTS removed)"); // 4. Configure authsettingsV2 var globalValidation = new Dictionary @@ -703,6 +725,8 @@ public async Task GetStatus(CancellationToken ct = default) return new SetupStatus { IsEasyAuthConfigured = isConfigured, + IsSetupCompleted = AppLifecycleBridge.IsSetupCompleted(), + SetupCompletedReason = AppLifecycleBridge.GetSetupCompletedReason(), IsRunningInAppService = !string.IsNullOrEmpty(siteName), HasManagedIdentity = hasManagedIdentity, AppName = _settings.Name, @@ -917,6 +941,25 @@ private async Task ArmRequest( return doc.RootElement.Clone(); } + private async Task KeyVaultRequest( + HttpMethod method, string url, string accessToken, object body, CancellationToken ct) + { + using var request = new HttpRequestMessage(method, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + var json = JsonSerializer.Serialize(body); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await s_httpClient.SendAsync(request, ct); + if (!response.IsSuccessStatusCode) + { + var responseBody = await response.Content.ReadAsStringAsync(ct); + _logger.LogError("[Setup] Key Vault {Method} {Url} failed: {Status} {Body}", + method, url, response.StatusCode, responseBody); + throw new HttpRequestException($"Key Vault {method} {url} failed: {response.StatusCode}"); + } + } + // ── Result Models ── public class TokenExchangeResult @@ -937,6 +980,8 @@ public class AppRegistrationResult public class SetupStatus { public bool IsEasyAuthConfigured { get; set; } + public bool IsSetupCompleted { get; set; } + public string? SetupCompletedReason { get; set; } public bool IsRunningInAppService { get; set; } public bool HasManagedIdentity { get; set; } public string AppName { get; set; } = ""; diff --git a/appsettings.json b/appsettings.json index 249c363..a62c97f 100644 --- a/appsettings.json +++ b/appsettings.json @@ -114,7 +114,11 @@ // "AllowedAudiences": [], // // Tenant IDs allowed to auth. Empty = setup tenant only (single-tenant issuer). // // Multiple entries → issuer becomes "common" + WEBSITE_AUTH_AAD_ALLOWED_TENANTS is set. - // "AllowedTenants": [] + // "AllowedTenants": [], + // // Store EasyAuth client secret in Key Vault instead of app settings. + // // "auto" = use site name as vault name. Or set explicit vault name. + // // Empty (default) = store secret directly in AUTH_SECRET app setting. + // "KeyVaultName": "" // }, "Scheduler": { From a4cb5771a1521378c523a3a3c19cdae03b0b85e7 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 29 May 2026 09:04:33 +0800 Subject: [PATCH 4/4] workflow --- .github/workflows/dev-container.yml | 5 +++++ .github/workflows/release-container.yml | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/dev-container.yml b/.github/workflows/dev-container.yml index 76dce37..895c991 100644 --- a/.github/workflows/dev-container.yml +++ b/.github/workflows/dev-container.yml @@ -32,12 +32,17 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build and push uses: docker/build-push-action@v6 with: context: . file: build/Dockerfile push: true + cache-from: type=gha + cache-to: type=gha,mode=max build-args: | APP_VERSION=dev COMMIT_SHA=${{ github.sha }} diff --git a/.github/workflows/release-container.yml b/.github/workflows/release-container.yml index c2d1b7e..ef62776 100644 --- a/.github/workflows/release-container.yml +++ b/.github/workflows/release-container.yml @@ -36,12 +36,17 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build and push uses: docker/build-push-action@v6 with: context: . file: build/Dockerfile.release push: true + cache-from: type=gha + cache-to: type=gha,mode=max build-args: | APP_VERSION=${{ steps.version.outputs.app_version }} COMMIT_SHA=${{ github.sha }}