Skip to content

Commit 9371dc6

Browse files
sharpninjaclaude
andcommitted
Swap admin auth to standard Blazor OIDC code + PKCE flow
Phase D-1 step 2c. Replaces the HTTP Basic admin auth scheme with the standard Blazor OIDC workflow: cookie session, OpenIdConnect challenge, interactive login page served by the same Duende IdentityServer instance that authenticates worker machines. Coordinator packages: - Adds Microsoft.AspNetCore.Authentication.OpenIdConnect 10.0.4 (version pinned up from 10.0.0 to satisfy Duende 7.4.7's transitive floor and avoid NU1605 downgrade). IdentityServerResources: - Adds IdentityResources (OpenId, Profile) for the interactive UI client's ID token claims. - Adds BuildAdminUiClient builder that emits a Duende Client with authorization_code + PKCE, openid+profile scopes, and redirect URIs pointing at {baseUrl}/signin-oidc and /signout-callback-oidc. - Exposes AdminUiClientId constant so Program.cs can reference it. CoordinatorOptions: - New BaseUrl setting (default https://localhost:5001). Doubles as the OpenID Connect authority URL so the coordinator's OIDC client discovers its own IS's /.well-known/openid-configuration. In production this should match the public ngrok reserved domain. Program.cs (significant rewrite): - Seeds Duende TestUserStore with a single admin user built from CoordinatorOptions.Admin.{Username,Password}. AddTestUsers wires the default resource-owner password validator so Duende's login endpoint can authenticate the operator. - Configures IdentityServer UserInteraction.LoginUrl = /Account/Login so /connect/authorize redirects unauthenticated users into the Blazor login page instead of the built-in Duende quickstart UI. - Stacks four authentication schemes: Cookies — admin session cookie (default scheme) oidc — OpenIdConnect challenge (default challenge scheme) Bearer — JWT validation for worker endpoints idsrv — Duende's own default cookie (for the IS login flow) - AdminPolicy now requires the Cookies scheme + admin role claim. WorkerPolicy unchanged (Bearer + bitnet-worker scope). - Adds /Account/Login/submit minimal API endpoint that validates posted credentials against TestUserStore, signs in on IdentityServerConstants.DefaultCookieAuthenticationScheme ("idsrv"), and redirects to the caller's returnUrl (the IS /connect/authorize continuation URL). DisableAntiforgery() for now; CSRF hardening lands when the login page gets a Blazor @editform. Components/Pages/LoginPage.razor (new): - Static-SSR Blazor page at /Account/Login with a plain HTML form POSTing to /Account/Login/submit. - SupplyParameterFromQuery populates returnUrl (from Duende's /connect/authorize redirect) and error (from the submit endpoint's bounce-back on bad credentials). - Uses MainLayout so the page picks up the shared dark-theme header / styles. Components/Pages/ApiKeysPage.razor: - Drops the AuthenticationSchemes = "AdminBasic" override on @Attribute [Authorize]; the page now defers to AdminPolicy which in turn uses the default cookie scheme that OIDC populates. AdminBasicAuthenticationHandler.cs removed. Programmatic admin access still possible via the JSON endpoints after obtaining an admin cookie through the OIDC flow, or by adding a client-credentials grant for scripted callers in a follow-up step. Fast-lane regression: 223/223 tests pass in 1m24s on net10 slice. No existing tests exercise the new OIDC handshake end-to-end — that integration suite lands after /work and /heartbeat are implemented so one run covers the full worker lifecycle. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3e2b7a6 commit 9371dc6

7 files changed

Lines changed: 263 additions & 196 deletions

File tree

src/BitNetSharp.Distributed.Coordinator/Auth/AdminBasicAuthenticationHandler.cs

Lines changed: 0 additions & 140 deletions
This file was deleted.

src/BitNetSharp.Distributed.Coordinator/BitNetSharp.Distributed.Coordinator.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
2828
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
2929
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
30+
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.4" />
3031
<PackageReference Include="Duende.IdentityServer" Version="7.4.7" />
3132
</ItemGroup>
3233

src/BitNetSharp.Distributed.Coordinator/Components/Pages/ApiKeysPage.razor

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
@page "/admin/api-keys"
2-
@attribute [Authorize(Policy = "AdminPolicy", AuthenticationSchemes = "AdminBasic")]
2+
@attribute [Authorize(Policy = "AdminPolicy")]
33
@inject WorkerClientRegistry Registry
44
@inject SqliteClientRevocationStore Revocations
55

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
@page "/Account/Login"
2+
@layout MainLayout
3+
4+
@* Static-SSR login page rendered inside Duende IdentityServer's
5+
interactive login flow. Duende redirects unauthenticated users here
6+
with a ?returnUrl=... query parameter pointing at the IS
7+
/connect/authorize continuation URL. The form posts to the minimal
8+
API endpoint /Account/Login/submit in Program.cs, which validates
9+
the credentials against the TestUserStore and calls SignInAsync on
10+
the "idsrv" cookie scheme before redirecting back. *@
11+
12+
<h2>Sign in</h2>
13+
14+
<p>
15+
This coordinator is protected. Sign in with the admin credentials
16+
configured at startup via
17+
<code>Coordinator__Admin__Username</code> and
18+
<code>Coordinator__Admin__Password</code>.
19+
</p>
20+
21+
@if (!string.IsNullOrWhiteSpace(Error))
22+
{
23+
<p class="warn">@Error</p>
24+
}
25+
26+
<form method="post" action="/Account/Login/submit">
27+
<input type="hidden" name="returnUrl" value="@ReturnUrl" />
28+
<div style="max-width:340px; display:flex; flex-direction:column; gap:12px; margin-top:16px;">
29+
<label>
30+
<div style="font-size:12px; color:#bbb; margin-bottom:2px;">Username</div>
31+
<input name="username" autocomplete="username" required
32+
style="width:100%; padding:8px; background:#1c1c1c; color:#eee; border:1px solid #333; border-radius:4px;" />
33+
</label>
34+
<label>
35+
<div style="font-size:12px; color:#bbb; margin-bottom:2px;">Password</div>
36+
<input type="password" name="password" autocomplete="current-password" required
37+
style="width:100%; padding:8px; background:#1c1c1c; color:#eee; border:1px solid #333; border-radius:4px;" />
38+
</label>
39+
<button type="submit" class="btn" style="align-self:flex-start; margin-top:8px;">Sign in</button>
40+
</div>
41+
</form>
42+
43+
@code {
44+
/// <summary>
45+
/// Populated from the <c>?returnUrl</c> query parameter when
46+
/// Duende redirects here from <c>/connect/authorize</c>. After a
47+
/// successful login the submit endpoint redirects the browser
48+
/// back to this URL so the OIDC handshake can continue.
49+
/// </summary>
50+
[SupplyParameterFromQuery(Name = "returnUrl")]
51+
public string? ReturnUrl { get; set; }
52+
53+
/// <summary>
54+
/// One-shot error banner set by the submit endpoint via
55+
/// <c>?error=...</c> on a failed sign-in attempt.
56+
/// </summary>
57+
[SupplyParameterFromQuery(Name = "error")]
58+
public string? Error { get; set; }
59+
}

src/BitNetSharp.Distributed.Coordinator/Configuration/CoordinatorOptions.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,16 @@ public sealed class CoordinatorOptions
6767
/// </summary>
6868
public int AccessTokenLifetimeSeconds { get; set; } = 3600;
6969

70+
/// <summary>
71+
/// Public base URL at which this coordinator is reachable. The
72+
/// identity server and the admin OIDC client both use this value
73+
/// as the OpenID Connect issuer / authority so self-referential
74+
/// discovery works. In production this is the ngrok reserved
75+
/// domain; in local development it is whatever HTTPS URL Kestrel
76+
/// binds to.
77+
/// </summary>
78+
public string BaseUrl { get; set; } = "https://localhost:5001";
79+
7080
/// <summary>
7181
/// List of OAuth 2.0 client-credentials clients that are allowed
7282
/// to authenticate as workers. Populated from environment at

src/BitNetSharp.Distributed.Coordinator/Identity/IdentityServerResources.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Collections.Generic;
2+
using Duende.IdentityServer;
23
using Duende.IdentityServer.Models;
34

45
namespace BitNetSharp.Distributed.Coordinator.Identity;
@@ -46,4 +47,51 @@ public static class IdentityServerResources
4647
Scopes = { WorkerScopeName }
4748
}
4849
};
50+
51+
/// <summary>
52+
/// Interactive identity resources exposed to the admin Blazor UI
53+
/// OIDC client. Only the minimum — openid + profile — so the
54+
/// admin cookie just needs the sub and name claims.
55+
/// </summary>
56+
public static IEnumerable<IdentityResource> IdentityResources => new IdentityResource[]
57+
{
58+
new IdentityResources.OpenId(),
59+
new IdentityResources.Profile()
60+
};
61+
62+
/// <summary>
63+
/// OAuth client id for the coordinator's own Blazor admin UI.
64+
/// The admin panel authenticates against the local IS using this
65+
/// client via OIDC authorization-code + PKCE.
66+
/// </summary>
67+
public const string AdminUiClientId = "bitnet-coordinator-admin-ui";
68+
69+
/// <summary>
70+
/// Builds the Duende <see cref="Client"/> the OpenIdConnect
71+
/// middleware on the admin Blazor UI authenticates against. The
72+
/// redirect URI must match the SignInScheme's callback path the
73+
/// OIDC middleware advertises (the standard <c>/signin-oidc</c>).
74+
/// </summary>
75+
public static Client BuildAdminUiClient(string coordinatorBaseUrl)
76+
{
77+
var baseUrl = coordinatorBaseUrl.TrimEnd('/');
78+
return new Client
79+
{
80+
ClientId = AdminUiClientId,
81+
ClientName = "BitNet Coordinator Admin UI",
82+
AllowedGrantTypes = GrantTypes.Code,
83+
RequireClientSecret = false,
84+
RequirePkce = true,
85+
AllowedScopes =
86+
{
87+
IdentityServerConstants.StandardScopes.OpenId,
88+
IdentityServerConstants.StandardScopes.Profile
89+
},
90+
RedirectUris = { $"{baseUrl}/signin-oidc" },
91+
PostLogoutRedirectUris = { $"{baseUrl}/signout-callback-oidc" },
92+
FrontChannelLogoutUri = $"{baseUrl}/signout-oidc",
93+
AllowOfflineAccess = false,
94+
AllowAccessTokensViaBrowser = false
95+
};
96+
}
4997
}

0 commit comments

Comments
 (0)