Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/dev-container.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/release-container.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
26 changes: 25 additions & 1 deletion Services/AppLifecycleBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

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

/// <summary>
/// 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.
/// </summary>
public static bool IsSetupModeRequested() => s_setupModeRequested;

/// <summary>
/// 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.
/// </summary>
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);
}

/// <summary>
/// Returns true if setup credentials have already been applied this session.
/// </summary>
public static bool IsSetupCompleted() => s_setupCompleted;

/// <summary>
/// Returns the reason setup was completed, or null if not yet completed.
/// </summary>
public static string? GetSetupCompletedReason() => s_setupCompletedReason;
}
23 changes: 14 additions & 9 deletions Services/CraftSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -517,15 +517,6 @@ public class SetupSettings
/// </summary>
public bool Enabled { get; set; } = false;

/// <summary>
/// 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).
/// </summary>
public bool AutoActivate { get; set; } = true;

/// <summary>
/// Public client ID used for the PKCE login popup during automated setup.
/// Defaults to Microsoft's Azure PowerShell first-party app which supports
Expand Down Expand Up @@ -582,6 +573,20 @@ public class SetupSettings
/// The tenant from the setup flow is always included automatically.
/// </summary>
public List<string> AllowedTenants { get; set; } = [];

/// <summary>
/// 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.
/// </summary>
public string KeyVaultName { get; set; } = "";
}

/// <summary>
Expand Down
48 changes: 34 additions & 14 deletions Services/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SetupService>();

if (CraftSettings.Setup.Enabled)
{
var autoActivate = CraftSettings.Setup.AutoActivate;

app.Use(async (context, next) =>
{
if (SetupService.IsEasyAuthConfigured())
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()");
}

Expand Down Expand Up @@ -930,9 +923,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);
});

Expand Down Expand Up @@ -976,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);
Expand All @@ -987,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);
Expand All @@ -1003,8 +1003,28 @@ 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." });
});

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) ---
Expand Down
102 changes: 94 additions & 8 deletions Services/SetupPages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
</style>
</head>
<body>
Expand All @@ -100,9 +101,21 @@ public static class SetupPages

<div id="status-banner" class="info-banner hidden"></div>

<!-- Option 1: Automated Setup (Device Code Flow) -->
<div class="card" id="auto-section">
<h2>Automated Setup</h2>
<!-- Step 1: First User -->
<div class="card" id="user-section">
<h2>Step 1: First User</h2>
<p>Add the first superadmin user before configuring authentication. This user will have full access to the application.</p>
<label for="seed-upn">User Principal Name (email)</label>
<input type="text" id="seed-upn" placeholder="admin@contoso.com">
<button class="btn btn-primary" id="btn-seed" onclick="seedFirstUser()" disabled>Add Superadmin</button>
<div id="seed-status" class="status"></div>
</div>

<div class="divider" id="divider-user"></div>

<!-- Step 2: Automated Setup (Device Code Flow) -->
<div class="card disabled-section" id="auto-section">
<h2>Step 2a: Automated Setup</h2>
<p>Sign in with a Global Administrator account to automatically create the EasyAuth app registration and configure this App Service.</p>

<label style="margin-bottom: 0.25rem;">Tenant Access</label>
Expand Down Expand Up @@ -131,11 +144,11 @@ public static class SetupPages
<div id="auto-status" class="status"></div>
</div>

<div class="divider"></div>
<div class="divider" id="divider-auth"></div>

<!-- Option 2: Manual Setup -->
<div class="card" id="manual-section">
<h2>Manual Setup</h2>
<!-- Step 2b: Manual Setup -->
<div class="card disabled-section" id="manual-section">
<h2>Step 2b: Manual Setup</h2>
<p>If you already have an app registration, enter the details below.</p>
<label style="margin-bottom: 0.25rem;">Tenant Access</label>
<div class="toggle-group" id="manual-tenant-toggle">
Expand Down Expand Up @@ -205,15 +218,84 @@ 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);
}
})();

// 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');
}

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;
Expand Down Expand Up @@ -377,10 +459,14 @@ 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');
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...';

Expand Down
Loading
Loading