Skip to content

Commit c3ee260

Browse files
authored
Merge pull request #6 from CyberDrain/dev
Dev
2 parents dec79b0 + a4cb577 commit c3ee260

8 files changed

Lines changed: 346 additions & 38 deletions

File tree

.github/workflows/dev-container.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,17 @@ jobs:
3232
username: ${{ github.actor }}
3333
password: ${{ secrets.GITHUB_TOKEN }}
3434

35+
- name: Set up Docker Buildx
36+
uses: docker/setup-buildx-action@v3
37+
3538
- name: Build and push
3639
uses: docker/build-push-action@v6
3740
with:
3841
context: .
3942
file: build/Dockerfile
4043
push: true
44+
cache-from: type=gha
45+
cache-to: type=gha,mode=max
4146
build-args: |
4247
APP_VERSION=dev
4348
COMMIT_SHA=${{ github.sha }}

.github/workflows/release-container.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,17 @@ jobs:
3636
username: ${{ github.actor }}
3737
password: ${{ secrets.GITHUB_TOKEN }}
3838

39+
- name: Set up Docker Buildx
40+
uses: docker/setup-buildx-action@v3
41+
3942
- name: Build and push
4043
uses: docker/build-push-action@v6
4144
with:
4245
context: .
4346
file: build/Dockerfile.release
4447
push: true
48+
cache-from: type=gha
49+
cache-to: type=gha,mode=max
4550
build-args: |
4651
APP_VERSION=${{ steps.version.outputs.app_version }}
4752
COMMIT_SHA=${{ github.sha }}

Services/AppLifecycleBridge.cs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ public static bool IsEasyAuthConfigured()
4343

4444
// --- Setup mode gating ---
4545
private static volatile bool s_setupModeRequested;
46+
private static volatile bool s_setupCompleted;
47+
private static string? s_setupCompletedReason;
4648

4749
/// <summary>
4850
/// Explicitly enables the Craft setup wizard. Call this from the child app
@@ -57,7 +59,29 @@ public static void RequestSetupMode(string reason = "Setup mode requested by app
5759

5860
/// <summary>
5961
/// Returns true if the child app has explicitly requested setup mode.
60-
/// Used by the setup middleware when AutoActivate is false.
62+
/// Used by the setup middleware to determine whether to activate the setup wizard.
6163
/// </summary>
6264
public static bool IsSetupModeRequested() => s_setupModeRequested;
65+
66+
/// <summary>
67+
/// Marks setup as completed — credentials have been applied and the app is
68+
/// pending restart. Prevents duplicate credential submissions and lets all
69+
/// setup page instances detect completion via status polling.
70+
/// </summary>
71+
public static void MarkSetupCompleted(string reason = "Setup credentials applied")
72+
{
73+
s_setupCompleted = true;
74+
s_setupCompletedReason = reason;
75+
s_logger?.LogInformation("[Lifecycle] Setup marked as completed: {Reason}", reason);
76+
}
77+
78+
/// <summary>
79+
/// Returns true if setup credentials have already been applied this session.
80+
/// </summary>
81+
public static bool IsSetupCompleted() => s_setupCompleted;
82+
83+
/// <summary>
84+
/// Returns the reason setup was completed, or null if not yet completed.
85+
/// </summary>
86+
public static string? GetSetupCompletedReason() => s_setupCompletedReason;
6387
}

Services/CraftSettings.cs

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -517,15 +517,6 @@ public class SetupSettings
517517
/// </summary>
518518
public bool Enabled { get; set; } = false;
519519

520-
/// <summary>
521-
/// When true, the setup wizard activates automatically if EasyAuth is not configured.
522-
/// When false, the child app must explicitly call
523-
/// [Craft.Services.AppLifecycleBridge]::RequestSetupMode() to activate setup mode.
524-
/// This lets the child app decide when setup is appropriate (e.g. after checking
525-
/// for existing credentials that can be migrated automatically).
526-
/// </summary>
527-
public bool AutoActivate { get; set; } = true;
528-
529520
/// <summary>
530521
/// Public client ID used for the PKCE login popup during automated setup.
531522
/// Defaults to Microsoft's Azure PowerShell first-party app which supports
@@ -582,6 +573,20 @@ public class SetupSettings
582573
/// The tenant from the setup flow is always included automatically.
583574
/// </summary>
584575
public List<string> AllowedTenants { get; set; } = [];
576+
577+
/// <summary>
578+
/// When set, the EasyAuth client secret is stored in Azure Key Vault instead of
579+
/// directly in the app setting. The app setting AUTH_SECRET is then written as a
580+
/// Key Vault reference (@Microsoft.KeyVault(SecretUri=...)).
581+
///
582+
/// Value is the Key Vault name (e.g. "my-vault" → https://my-vault.vault.azure.net).
583+
/// If set to the literal string "auto", the site name (WEBSITE_SITE_NAME) is used
584+
/// as the vault name.
585+
///
586+
/// The managed identity must have Secret Set permission on the vault.
587+
/// When empty (default), the secret is stored directly in the app setting.
588+
/// </summary>
589+
public string KeyVaultName { get; set; } = "";
585590
}
586591

587592
/// <summary>

Services/Program.cs

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -278,15 +278,12 @@ void RunInitialization()
278278
app.UseResponseCompression();
279279

280280
// Setup mode middleware: when Setup.Enabled, register setup route guards.
281-
// If AutoActivate is true, setup mode activates automatically when EasyAuth is not configured.
282-
// If AutoActivate is false, the child app must call AppLifecycleBridge.RequestSetupMode()
283-
// to explicitly enable the setup wizard (e.g. after checking for existing credentials).
281+
// The child app must call AppLifecycleBridge.RequestSetupMode() to activate the
282+
// setup wizard (e.g. after determining it cannot auto-configure from existing credentials).
284283
var setupService = app.Services.GetRequiredService<SetupService>();
285284

286285
if (CraftSettings.Setup.Enabled)
287286
{
288-
var autoActivate = CraftSettings.Setup.AutoActivate;
289-
290287
app.Use(async (context, next) =>
291288
{
292289
if (SetupService.IsEasyAuthConfigured())
@@ -314,10 +311,8 @@ void RunInitialization()
314311
return;
315312
}
316313

317-
// EasyAuth NOT configured — check whether setup mode should be active.
318-
// AutoActivate: always active. Otherwise: only after child app calls RequestSetupMode().
319-
var setupActive = autoActivate || AppLifecycleBridge.IsSetupModeRequested();
320-
if (!setupActive)
314+
// EasyAuth NOT configured — setup mode only active after child app calls RequestSetupMode().
315+
if (!AppLifecycleBridge.IsSetupModeRequested())
321316
{
322317
// Setup not yet requested by child app — let requests through normally
323318
// (the startup loading middleware will handle the "pool not ready" case)
@@ -368,10 +363,8 @@ void RunInitialization()
368363
context.Response.Redirect("/setup");
369364
});
370365

371-
logger.LogInformation("[Setup] Setup mode enabled (AutoActivate={AutoActivate}) — {Status}",
372-
autoActivate,
366+
logger.LogInformation("[Setup] Setup mode enabled — {Status}",
373367
SetupService.IsEasyAuthConfigured() ? "EasyAuth already configured, setup endpoints disabled"
374-
: autoActivate ? "awaiting configuration at /setup"
375368
: "waiting for child app to call RequestSetupMode()");
376369
}
377370

@@ -930,9 +923,9 @@ await context.Response.WriteAsync(System.Text.Json.JsonSerializer.Serialize(new
930923
return Results.Content(SetupPages.IndexHtml, "text/html");
931924
});
932925

933-
app.MapGet("/api/setup/status", (HttpContext context) =>
926+
app.MapGet("/api/setup/status", async (HttpContext context) =>
934927
{
935-
var status = setupService.GetStatus();
928+
var status = await setupService.GetStatus(context.RequestAborted);
936929
return Results.Json(status);
937930
});
938931

@@ -976,6 +969,9 @@ await context.Response.WriteAsync(System.Text.Json.JsonSerializer.Serialize(new
976969

977970
app.MapPost("/api/setup/configure", async (HttpContext context) =>
978971
{
972+
if (AppLifecycleBridge.IsSetupCompleted())
973+
return Results.Json(new { success = false, message = "Setup already completed. The app is pending restart." }, statusCode: 409);
974+
979975
using var reader = new StreamReader(context.Request.Body);
980976
var body = await reader.ReadToEndAsync();
981977
using var doc = System.Text.Json.JsonDocument.Parse(body);
@@ -987,11 +983,15 @@ await context.Response.WriteAsync(System.Text.Json.JsonSerializer.Serialize(new
987983
var multiTenant = root.TryGetProperty("multiTenant", out var mt) && mt.GetBoolean();
988984

989985
await setupService.ConfigureAppServiceAuth(appId, clientSecret, tenantId, multiTenant);
986+
AppLifecycleBridge.MarkSetupCompleted("EasyAuth configured via automated setup");
990987
return Results.Json(new { success = true, message = "App Service auth configured. The app will restart to apply changes." });
991988
});
992989

993990
app.MapPost("/api/setup/manual", async (HttpContext context) =>
994991
{
992+
if (AppLifecycleBridge.IsSetupCompleted())
993+
return Results.Json(new { success = false, message = "Setup already completed. The app is pending restart." }, statusCode: 409);
994+
995995
using var reader = new StreamReader(context.Request.Body);
996996
var body = await reader.ReadToEndAsync();
997997
using var doc = System.Text.Json.JsonDocument.Parse(body);
@@ -1003,8 +1003,28 @@ await context.Response.WriteAsync(System.Text.Json.JsonSerializer.Serialize(new
10031003
var multiTenant = root.TryGetProperty("multiTenant", out var mt2) && mt2.GetBoolean();
10041004

10051005
await setupService.ConfigureManual(appId, clientSecret, tenantId, multiTenant);
1006+
AppLifecycleBridge.MarkSetupCompleted("EasyAuth configured via manual setup");
10061007
return Results.Json(new { success = true, message = "App Service auth configured. The app will restart to apply changes." });
10071008
});
1009+
1010+
app.MapPost("/api/setup/seed-user", async (HttpContext context) =>
1011+
{
1012+
try
1013+
{
1014+
using var reader = new StreamReader(context.Request.Body);
1015+
var body = await reader.ReadToEndAsync();
1016+
using var doc = System.Text.Json.JsonDocument.Parse(body);
1017+
var root = doc.RootElement;
1018+
1019+
var upn = root.GetProperty("upn").GetString()!;
1020+
await setupService.SeedFirstUser(upn, context.RequestAborted);
1021+
return Results.Json(new { success = true, message = $"Superadmin user {upn} added successfully." });
1022+
}
1023+
catch (Exception ex)
1024+
{
1025+
return Results.Json(new { success = false, message = ex.Message }, statusCode: 400);
1026+
}
1027+
});
10081028
} // end Setup.Enabled
10091029

10101030
// --- Job Status API (C# direct — no PS overhead) ---

Services/SetupPages.cs

Lines changed: 94 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ public static class SetupPages
9191
font-size: 0.85rem; color: #94a3b8; margin-top: 0.5rem;
9292
}
9393
.device-code-box a { color: #60a5fa; text-decoration: underline; }
94+
.disabled-section { opacity: 0.4; pointer-events: none; }
9495
</style>
9596
</head>
9697
<body>
@@ -100,9 +101,21 @@ public static class SetupPages
100101
101102
<div id="status-banner" class="info-banner hidden"></div>
102103
103-
<!-- Option 1: Automated Setup (Device Code Flow) -->
104-
<div class="card" id="auto-section">
105-
<h2>Automated Setup</h2>
104+
<!-- Step 1: First User -->
105+
<div class="card" id="user-section">
106+
<h2>Step 1: First User</h2>
107+
<p>Add the first superadmin user before configuring authentication. This user will have full access to the application.</p>
108+
<label for="seed-upn">User Principal Name (email)</label>
109+
<input type="text" id="seed-upn" placeholder="admin@contoso.com">
110+
<button class="btn btn-primary" id="btn-seed" onclick="seedFirstUser()" disabled>Add Superadmin</button>
111+
<div id="seed-status" class="status"></div>
112+
</div>
113+
114+
<div class="divider" id="divider-user"></div>
115+
116+
<!-- Step 2: Automated Setup (Device Code Flow) -->
117+
<div class="card disabled-section" id="auto-section">
118+
<h2>Step 2a: Automated Setup</h2>
106119
<p>Sign in with a Global Administrator account to automatically create the EasyAuth app registration and configure this App Service.</p>
107120
108121
<label style="margin-bottom: 0.25rem;">Tenant Access</label>
@@ -131,11 +144,11 @@ public static class SetupPages
131144
<div id="auto-status" class="status"></div>
132145
</div>
133146
134-
<div class="divider"></div>
147+
<div class="divider" id="divider-auth"></div>
135148
136-
<!-- Option 2: Manual Setup -->
137-
<div class="card" id="manual-section">
138-
<h2>Manual Setup</h2>
149+
<!-- Step 2b: Manual Setup -->
150+
<div class="card disabled-section" id="manual-section">
151+
<h2>Step 2b: Manual Setup</h2>
139152
<p>If you already have an app registration, enter the details below.</p>
140153
<label style="margin-bottom: 0.25rem;">Tenant Access</label>
141154
<div class="toggle-group" id="manual-tenant-toggle">
@@ -205,15 +218,84 @@ function setTenantMode(section, isMulti) {
205218
if (setupState.isEasyAuthConfigured) {
206219
showBanner('Authentication is already configured. Redirecting...');
207220
setTimeout(() => window.location.href = '/', 2000);
221+
return;
208222
}
209223
if (!setupState.isRunningInAppService || !setupState.hasManagedIdentity) {
210224
showBanner('Warning: No managed identity detected. ARM self-configuration may fail. Use manual setup instead.');
211225
}
226+
227+
// Handle user table status
228+
const us = setupState.usersStatus;
229+
if (!us || !us.connected) {
230+
// Connection error — disable user section
231+
document.getElementById('btn-seed').disabled = true;
232+
showStatus('seed-status', 'Cannot connect to storage: ' + (us?.error || 'Unknown error'), 'error');
233+
} else if (us.hasUsers) {
234+
// Users already exist — skip to auth setup
235+
document.getElementById('user-section').classList.add('disabled-section');
236+
showStatus('seed-status', 'Users already exist in the table. Proceed to authentication setup below.', 'success');
237+
enableAuthSections();
238+
} else {
239+
// No users — enable the seed form, keep auth disabled
240+
document.getElementById('btn-seed').disabled = false;
241+
}
212242
} catch (e) {
213243
console.error('Failed to load status', e);
214244
}
215245
})();
216246
247+
// Background poll — detect setup completion from any session
248+
let statusPollTimer = setInterval(async () => {
249+
try {
250+
const res = await fetch('/api/setup/status', { cache: 'no-store' });
251+
if (!res.ok) return;
252+
const status = await res.json();
253+
if (status.isSetupCompleted || status.isEasyAuthConfigured) {
254+
clearInterval(statusPollTimer);
255+
showRestartScreen();
256+
}
257+
} catch (e) {
258+
// Setup endpoint may be unavailable during restart — ignore
259+
}
260+
}, 5000);
261+
262+
function enableAuthSections() {
263+
document.getElementById('auto-section').classList.remove('disabled-section');
264+
document.getElementById('manual-section').classList.remove('disabled-section');
265+
}
266+
267+
async function seedFirstUser() {
268+
const upn = document.getElementById('seed-upn').value.trim();
269+
if (!upn) {
270+
showStatus('seed-status', 'Please enter a valid email address.', 'error');
271+
return;
272+
}
273+
274+
const btn = document.getElementById('btn-seed');
275+
btn.disabled = true;
276+
btn.textContent = 'Adding user...';
277+
showStatus('seed-status', 'Adding superadmin user...', 'info');
278+
279+
try {
280+
const res = await fetch('/api/setup/seed-user', {
281+
method: 'POST',
282+
headers: { 'Content-Type': 'application/json' },
283+
body: JSON.stringify({ upn })
284+
});
285+
286+
const data = await res.json();
287+
if (!res.ok || !data.success) throw new Error(data.message || 'Failed to add user');
288+
289+
showStatus('seed-status', data.message, 'success');
290+
document.getElementById('user-section').classList.add('disabled-section');
291+
enableAuthSections();
292+
} catch (e) {
293+
showStatus('seed-status', 'Error: ' + e.message, 'error');
294+
btn.disabled = false;
295+
btn.textContent = 'Add Superadmin';
296+
}
297+
}
298+
217299
function showBanner(msg) {
218300
const el = document.getElementById('status-banner');
219301
el.textContent = msg;
@@ -377,10 +459,14 @@ async function submitManual() {
377459
}
378460
}
379461
function showRestartScreen() {
462+
// Stop background status polling — restart screen has its own polling
463+
clearInterval(statusPollTimer);
380464
// Hide setup sections, show restart polling UI
465+
document.getElementById('user-section').classList.add('hidden');
381466
document.getElementById('auto-section').classList.add('hidden');
382467
document.getElementById('manual-section').classList.add('hidden');
383-
document.querySelector('.divider').classList.add('hidden');
468+
document.getElementById('divider-user').classList.add('hidden');
469+
document.getElementById('divider-auth').classList.add('hidden');
384470
document.querySelector('.subtitle').textContent = '';
385471
document.getElementById('page-title').textContent = 'Restarting...';
386472

0 commit comments

Comments
 (0)