diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index 3edf94c..ef494e7 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -68,6 +68,46 @@
"cwd": "${workspaceFolder}"
},
"problemMatcher": []
+ },
+ {
+ "label": "WorkTracker: Docker Playwright Tests",
+ "type": "shell",
+ "command": "docker",
+ "args": [
+ "compose",
+ "-f",
+ "docker-compose.yml",
+ "-f",
+ "docker-compose.tests.yml",
+ "up",
+ "--build",
+ "--abort-on-container-exit",
+ "--exit-code-from",
+ "playwright",
+ "playwright"
+ ],
+ "options": {
+ "cwd": "${workspaceFolder}"
+ },
+ "problemMatcher": []
+ },
+ {
+ "label": "WorkTracker: Docker Playwright Down",
+ "type": "shell",
+ "command": "docker",
+ "args": [
+ "compose",
+ "-f",
+ "docker-compose.yml",
+ "-f",
+ "docker-compose.tests.yml",
+ "down",
+ "--volumes"
+ ],
+ "options": {
+ "cwd": "${workspaceFolder}"
+ },
+ "problemMatcher": []
}
]
}
\ No newline at end of file
diff --git a/Components/Layout/NavMenu.razor b/Components/Layout/NavMenu.razor
index 3ff3c9a..fe28638 100644
--- a/Components/Layout/NavMenu.razor
+++ b/Components/Layout/NavMenu.razor
@@ -1,4 +1,8 @@
-
+@using Microsoft.Extensions.Options
+@using WorkTracker.Configuration
+@inject IOptions
AppAuthOptions
+
+
@@ -51,13 +55,16 @@
@context.User.Identity?.Name
-
-
-
+ @if (AppAuthOptions.Value.Enabled)
+ {
+
+
+
+ }
diff --git a/Components/Pages/ChangePassword.razor b/Components/Pages/ChangePassword.razor
index 1a96b8a..d6938aa 100644
--- a/Components/Pages/ChangePassword.razor
+++ b/Components/Pages/ChangePassword.razor
@@ -3,9 +3,20 @@
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components
+@using Microsoft.Extensions.Options
+@using WorkTracker.Configuration
+
+@inject IOptions
AppAuthOptions
+@inject NavigationManager Navigation
Change Password
+@if (!AppAuthOptions.Value.Enabled)
+{
+ Redirecting...
+}
+else
+{
Change password
@if (!string.IsNullOrWhiteSpace(Error))
@@ -26,8 +37,17 @@
+}
@code {
[SupplyParameterFromQuery]
public string? Error { get; set; }
+
+ protected override void OnInitialized()
+ {
+ if (!AppAuthOptions.Value.Enabled)
+ {
+ Navigation.NavigateTo("/", forceLoad: true);
+ }
+ }
}
diff --git a/Components/Pages/Login.razor b/Components/Pages/Login.razor
index cf4641d..d6c0749 100644
--- a/Components/Pages/Login.razor
+++ b/Components/Pages/Login.razor
@@ -3,9 +3,20 @@
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components
+@using Microsoft.Extensions.Options
+@using WorkTracker.Configuration
+
+@inject IOptions AppAuthOptions
+@inject NavigationManager Navigation
Login
+@if (!AppAuthOptions.Value.Enabled)
+{
+ Redirecting...
+}
+else
+{
Login
@if (!string.IsNullOrWhiteSpace(Error))
@@ -28,6 +39,7 @@
+}
@code {
[SupplyParameterFromQuery]
@@ -39,6 +51,14 @@
[SupplyParameterFromQuery]
public string? Username { get; set; }
+ protected override void OnInitialized()
+ {
+ if (!AppAuthOptions.Value.Enabled)
+ {
+ Navigation.NavigateTo(SafeReturnUrl, forceLoad: true);
+ }
+ }
+
private string SafeReturnUrl =>
string.IsNullOrWhiteSpace(ReturnUrl) || !Uri.IsWellFormedUriString(ReturnUrl, UriKind.Relative)
? "/"
diff --git a/Configuration/AppAuthOptions.cs b/Configuration/AppAuthOptions.cs
new file mode 100644
index 0000000..8868f8d
--- /dev/null
+++ b/Configuration/AppAuthOptions.cs
@@ -0,0 +1,12 @@
+namespace WorkTracker.Configuration;
+
+public sealed class AppAuthOptions
+{
+ public const string SectionName = "AppAuth";
+
+ public bool Enabled { get; init; } = false;
+
+ public string DefaultUsername { get; init; } = "Admin";
+
+ public string DefaultUserId { get; init; } = "ADMIN";
+}
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 41354d4..637be0f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -36,6 +36,6 @@ EXPOSE 8080
VOLUME ["/data/couchbase"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
- CMD wget -qO- http://127.0.0.1:8080/ >/dev/null 2>&1 || exit 1
+ CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1 || exit 1
ENTRYPOINT ["dotnet", "WorkTracker.dll"]
diff --git a/Dockerfile.playwright b/Dockerfile.playwright
new file mode 100644
index 0000000..4f3a27a
--- /dev/null
+++ b/Dockerfile.playwright
@@ -0,0 +1,10 @@
+FROM mcr.microsoft.com/playwright:v1.59.1-noble
+
+WORKDIR /workspace
+
+COPY package.json playwright.config.ts ./
+COPY tests ./tests
+
+RUN npm install --no-fund --no-audit
+
+CMD ["npx", "playwright", "test"]
\ No newline at end of file
diff --git a/Program.cs b/Program.cs
index 758e924..68c4cec 100644
--- a/Program.cs
+++ b/Program.cs
@@ -1,6 +1,7 @@
using System.Globalization;
using System.Security.Claims;
using NLog.Web;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Components.Authorization;
@@ -25,18 +26,32 @@ builder.Host.UseNLog();
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
+builder.Services.Configure(builder.Configuration.GetSection(AppAuthOptions.SectionName));
+var appAuthOptions = builder.Configuration.GetSection(AppAuthOptions.SectionName).Get() ?? new AppAuthOptions();
+var authenticationEnabled = appAuthOptions.Enabled;
+
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorization();
-builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
- .AddCookie(options =>
- {
- options.LoginPath = "/login";
- options.LogoutPath = "/logout";
- options.AccessDeniedPath = "/login";
- options.SlidingExpiration = true;
- options.ExpireTimeSpan = TimeSpan.FromDays(14);
- });
+if (authenticationEnabled)
+{
+ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
+ .AddCookie(options =>
+ {
+ options.LoginPath = "/login";
+ options.LogoutPath = "/logout";
+ options.AccessDeniedPath = "/login";
+ options.SlidingExpiration = true;
+ options.ExpireTimeSpan = TimeSpan.FromDays(14);
+ });
+}
+else
+{
+ builder.Services.AddAuthentication(DefaultAdminAuthenticationHandler.SchemeName)
+ .AddScheme(
+ DefaultAdminAuthenticationHandler.SchemeName,
+ static _ => { });
+}
builder.Services.AddLocalization();
@@ -86,14 +101,29 @@ app.UseAuthorization();
app.UseAntiforgery();
+static string GetSafeReturnUrl(string? returnUrl)
+{
+ return string.IsNullOrWhiteSpace(returnUrl) || !Uri.IsWellFormedUriString(returnUrl, UriKind.Relative)
+ ? "/"
+ : returnUrl;
+}
+
app.MapPost("/api/login", async (HttpContext context) =>
{
+ var form = await context.Request.ReadFormAsync();
+ var returnUrl = form["returnUrl"].ToString();
+ var safeReturnUrl = GetSafeReturnUrl(returnUrl);
+
+ if (!authenticationEnabled)
+ {
+ context.Response.Redirect(safeReturnUrl);
+ return;
+ }
+
var authService = context.RequestServices.GetRequiredService();
var logger = context.RequestServices.GetRequiredService().CreateLogger("Auth.Login");
- var form = await context.Request.ReadFormAsync();
var username = form["username"].ToString();
var password = form["password"].ToString();
- var returnUrl = form["returnUrl"].ToString();
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
@@ -125,16 +155,18 @@ app.MapPost("/api/login", async (HttpContext context) =>
return;
}
- var safeReturnUrl = string.IsNullOrWhiteSpace(returnUrl) || !Uri.IsWellFormedUriString(returnUrl, UriKind.Relative)
- ? "/"
- : returnUrl;
-
context.Response.Redirect(safeReturnUrl);
return;
}).DisableAntiforgery();
app.MapPost("/api/logout", async (HttpContext context) =>
{
+ if (!authenticationEnabled)
+ {
+ context.Response.Redirect("/");
+ return;
+ }
+
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
context.Response.Redirect("/login");
return;
@@ -142,6 +174,12 @@ app.MapPost("/api/logout", async (HttpContext context) =>
app.MapPost("/api/change-password", async (HttpContext context) =>
{
+ if (!authenticationEnabled)
+ {
+ context.Response.Redirect("/");
+ return;
+ }
+
var authService = context.RequestServices.GetRequiredService();
if (!context.User?.Identity?.IsAuthenticated ?? true)
{
@@ -159,7 +197,7 @@ app.MapPost("/api/change-password", async (HttpContext context) =>
return;
}
- var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
+ var userId = context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrWhiteSpace(userId))
{
await context.ChallengeAsync();
@@ -177,6 +215,24 @@ app.MapPost("/api/change-password", async (HttpContext context) =>
return;
}).DisableAntiforgery();
+app.MapGet("/healthz", [AllowAnonymous] (HttpContext context, IOptions authOptions, CouchbaseLiteDatabaseProvider databaseProvider) =>
+{
+ return Results.Json(new
+ {
+ status = "Healthy",
+ authenticationEnabled = authOptions.Value.Enabled,
+ currentUser = context.User.Identity?.Name,
+ environment = app.Environment.EnvironmentName,
+ storage = new
+ {
+ appSettingsReady = databaseProvider.AppSettings is not null,
+ usersReady = databaseProvider.Users is not null,
+ workDaysReady = databaseProvider.WorkDays is not null
+ },
+ timestampUtc = DateTimeOffset.UtcNow
+ });
+});
+
// Development-only endpoint to reset the seeded Admin password (protected by secret in URL)
if (app.Environment.IsDevelopment())
{
diff --git a/README.Docker.md b/README.Docker.md
index 1663df6..67a7a0d 100644
--- a/README.Docker.md
+++ b/README.Docker.md
@@ -8,6 +8,16 @@ Quick run (Docker Engine required):
2. App will be available on host port 8002 -> container 8080 (http://localhost:8002).
+Authentication mode:
+
+- Authentication is disabled by default and every request runs as the configured default admin user. This is intended for deployments fronted by Cloudflare Zero Trust.
+- Re-enable the built-in login flow by setting `AppAuth__Enabled=true` or changing `AppAuth:Enabled` to `true` in appsettings.json.
+- The default admin identity used while auth is disabled is configured under `AppAuth:DefaultUsername` and `AppAuth:DefaultUserId`.
+
+Health endpoint:
+
+- `GET /healthz` returns JSON health information, including whether built-in authentication is enabled and whether the Couchbase Lite collections initialized correctly.
+
Docker persistence:
- The app now uses an embedded Couchbase Lite database stored under `/data/couchbase` inside the container.
@@ -41,6 +51,11 @@ Manual shutdown:
- `docker compose down`
+Docker Playwright smoke tests:
+
+- Run `docker compose -f docker-compose.yml -f docker-compose.tests.yml up --build --abort-on-container-exit --exit-code-from playwright playwright`
+- The suite starts the app in Docker, waits for `/healthz`, and verifies that protected pages load without a login screen.
+
Notes:
- The base compose file remains production-oriented; the override file is the optional containerized development layer.
diff --git a/Services/Auth/DefaultAdminAuthenticationHandler.cs b/Services/Auth/DefaultAdminAuthenticationHandler.cs
new file mode 100644
index 0000000..34bbc63
--- /dev/null
+++ b/Services/Auth/DefaultAdminAuthenticationHandler.cs
@@ -0,0 +1,48 @@
+using System.Security.Claims;
+using System.Text.Encodings.Web;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.Extensions.Options;
+using WorkTracker.Configuration;
+
+namespace WorkTracker.Services.Auth;
+
+public sealed class DefaultAdminAuthenticationHandler : AuthenticationHandler
+{
+ public const string SchemeName = "DefaultAdmin";
+
+ private readonly IOptions appAuthOptions;
+
+ public DefaultAdminAuthenticationHandler(
+ IOptionsMonitor options,
+ ILoggerFactory logger,
+ UrlEncoder encoder,
+ IOptions appAuthOptions)
+ : base(options, logger, encoder)
+ {
+ this.appAuthOptions = appAuthOptions;
+ }
+
+ protected override Task HandleAuthenticateAsync()
+ {
+ var configuredOptions = appAuthOptions.Value;
+ var username = string.IsNullOrWhiteSpace(configuredOptions.DefaultUsername)
+ ? "Admin"
+ : configuredOptions.DefaultUsername.Trim();
+ var userId = string.IsNullOrWhiteSpace(configuredOptions.DefaultUserId)
+ ? username.ToUpperInvariant()
+ : configuredOptions.DefaultUserId.Trim();
+
+ var claims = new List
+ {
+ new(ClaimTypes.NameIdentifier, userId),
+ new(ClaimTypes.Name, username),
+ new(ClaimTypes.Role, "Admin")
+ };
+
+ var identity = new ClaimsIdentity(claims, SchemeName);
+ var principal = new ClaimsPrincipal(identity);
+ var ticket = new AuthenticationTicket(principal, SchemeName);
+
+ return Task.FromResult(AuthenticateResult.Success(ticket));
+ }
+}
\ No newline at end of file
diff --git a/appsettings.json b/appsettings.json
index 579b485..d6fd259 100644
--- a/appsettings.json
+++ b/appsettings.json
@@ -8,6 +8,11 @@
"Username": "Admin",
"Password": "Disagio"
},
+ "AppAuth": {
+ "Enabled": false,
+ "DefaultUsername": "Admin",
+ "DefaultUserId": "ADMIN"
+ },
"Logging": {
"LogLevel": {
"Default": "Information",
diff --git a/docker-compose.tests.yml b/docker-compose.tests.yml
new file mode 100644
index 0000000..9c4c27a
--- /dev/null
+++ b/docker-compose.tests.yml
@@ -0,0 +1,10 @@
+services:
+ playwright:
+ build:
+ context: .
+ dockerfile: Dockerfile.playwright
+ depends_on:
+ worktracker:
+ condition: service_healthy
+ environment:
+ PLAYWRIGHT_BASE_URL: http://worktracker:8080
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index 715abe3..08d2020 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -14,7 +14,7 @@ services:
- ${WORKTRACKER_DATA_PATH:-./.docker-data/couchbase}:/data/couchbase
restart: unless-stopped
healthcheck:
- test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/ >/dev/null 2>&1 || exit 1"]
+ test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1 || exit 1"]
interval: 30s
timeout: 5s
retries: 3
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..64ec29f
--- /dev/null
+++ b/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "worktracker-playwright",
+ "private": true,
+ "scripts": {
+ "test:e2e": "playwright test"
+ },
+ "devDependencies": {
+ "@playwright/test": "1.59.1"
+ }
+}
\ No newline at end of file
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..df4430c
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from '@playwright/test';
+
+const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:8002';
+
+export default defineConfig({
+ testDir: './tests/playwright',
+ timeout: 30_000,
+ fullyParallel: true,
+ retries: 0,
+ use: {
+ baseURL,
+ trace: 'retain-on-failure'
+ },
+ reporter: [['list']]
+});
\ No newline at end of file
diff --git a/tests/playwright/auth-bypass.spec.ts b/tests/playwright/auth-bypass.spec.ts
new file mode 100644
index 0000000..5250760
--- /dev/null
+++ b/tests/playwright/auth-bypass.spec.ts
@@ -0,0 +1,33 @@
+import { expect, test } from '@playwright/test';
+
+test('home loads without a login screen', async ({ page }) => {
+ await page.goto('/');
+
+ await expect(page).toHaveURL(/\/$/);
+ await expect(page.getByRole('heading', { name: 'WorkTracker' })).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Login' })).toHaveCount(0);
+});
+
+test('protected pages are directly available without redirecting to login', async ({ page }) => {
+ const pages = [
+ { path: '/grid', heading: 'Grid View' },
+ { path: '/calendar', heading: 'Calendar' },
+ { path: '/summary', heading: 'Monthly Summary' },
+ { path: '/settings', heading: 'Settings' },
+ { path: '/workday', heading: 'Work Day Entry' },
+ { path: '/auth', heading: 'You are authenticated' }
+ ];
+
+ for (const target of pages) {
+ await page.goto(target.path);
+ await expect(page).not.toHaveURL(/\/login/i);
+ await expect(page.getByRole('heading', { name: target.heading })).toBeVisible();
+ }
+});
+
+test('login route redirects away when built-in authentication is disabled', async ({ page }) => {
+ await page.goto('/login');
+
+ await expect(page).toHaveURL(/\/$/);
+ await expect(page.getByRole('heading', { name: 'WorkTracker' })).toBeVisible();
+});
\ No newline at end of file
diff --git a/tests/playwright/health.spec.ts b/tests/playwright/health.spec.ts
new file mode 100644
index 0000000..8b7f579
--- /dev/null
+++ b/tests/playwright/health.spec.ts
@@ -0,0 +1,21 @@
+import { expect, test } from '@playwright/test';
+
+test('health endpoint reports healthy zero-trust mode', async ({ request }) => {
+ const response = await request.get('/healthz');
+
+ expect(response.ok()).toBeTruthy();
+
+ const payload = await response.json();
+
+ expect(payload.status).toBe('Healthy');
+ expect(payload.authenticationEnabled).toBe(false);
+ expect(payload.storage.appSettingsReady).toBe(true);
+ expect(payload.storage.usersReady).toBe(true);
+ expect(payload.storage.workDaysReady).toBe(true);
+});
+
+test('authenticated identity is the default admin user', async ({ page }) => {
+ await page.goto('/auth');
+
+ await expect(page.getByText('Hello Admin!')).toBeVisible();
+});
\ No newline at end of file