From 0ecf1aaac5584070c4e8c086def7ddffe1ff9c9f Mon Sep 17 00:00:00 2001 From: hermes-bot Date: Mon, 19 Oct 2026 09:44:58 +0000 Subject: [PATCH 1/2] feat(auth): humans sign in with OIDC; no header names a user The ProxyHeader scheme took the username from X-Authentik-Username for any peer inside Auth:ReverseProxy:TrustedProxies. The deployment set that to the whole pod CIDR (a hand copy of the Talos podSubnets), so every pod the network policies let reach :8080 (Hermes, Gitea, Traefik, all of ai and monitoring) could claim any human's name on the Facts page and on saved answers. Authentik's proxy provider sends no signed identity header, so the header cannot be verified at all. Humans now sign in with OpenID Connect against Authentik: a public client with PKCE, the username is preferred_username from the ID token, whose signature is checked against the provider's JWKS. Nothing about the network path or the request headers names a user any more. - Auth:Oidc:Authority, ClientId and PublicOrigin are required; the host refuses to start without them. PublicOrigin builds the redirect URI, because TLS ends at Traefik and the request scheme is http. - UI pages, static assets and the Blazor hub need a session (fallback policy); an anonymous browser is redirected to the provider. - The read API (/api/v1 reads, /ask, /questions, /rank, /status) needs a session or an API key (policy "read") and answers 401, never a redirect. Before, it was anonymous and relied on the gate, BasicAuth or network policy in front. - Writes still need an API key (policy "write"). - /health, /ready and the HMAC-signed /webhook stay anonymous. - ProxyHeaderAuthenticationHandler and Auth:ReverseProxy are removed. Tests: an in-process OpenID provider issues RS256 ID tokens. They cover the PKCE challenge and redirect URI, the full code flow ending in a session named after the token's user, rejection of a token signed by a foreign key, the Authentik header signing in nobody, and 401s for credential-less API calls. --- README.md | 5 + src/Wiki.Host/Api/ApiV1.cs | 2 +- src/Wiki.Host/Api/AskEndpoints.cs | 2 +- src/Wiki.Host/Api/IndexEndpoints.cs | 4 +- src/Wiki.Host/Api/QuestionEndpoints.cs | 17 +-- src/Wiki.Host/Api/ReadEndpoints.cs | 14 +-- .../Auth/ApiKeyAuthenticationHandler.cs | 8 ++ .../Auth/ProxyHeaderAuthenticationHandler.cs | 71 ----------- src/Wiki.Host/Auth/WikiAuthentication.cs | 96 +++++++++++++++ src/Wiki.Host/Components/Pages/Facts.razor | 2 +- src/Wiki.Host/Program.cs | 11 +- src/Wiki.Host/Wiki.Host.csproj | 1 + src/Wiki.Host/appsettings.json | 2 +- test/Wiki.Host.Tests/EndpointTests.cs | 73 ++++++------ test/Wiki.Host.Tests/FakeIdp.cs | 96 +++++++++++++++ test/Wiki.Host.Tests/OidcTests.cs | 112 ++++++++++++++++++ 16 files changed, 374 insertions(+), 142 deletions(-) delete mode 100644 src/Wiki.Host/Auth/ProxyHeaderAuthenticationHandler.cs create mode 100644 src/Wiki.Host/Auth/WikiAuthentication.cs create mode 100644 test/Wiki.Host.Tests/FakeIdp.cs create mode 100644 test/Wiki.Host.Tests/OidcTests.cs diff --git a/README.md b/README.md index ae9a51d..e0c6379 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,11 @@ variables (`Wiki__NoteStore__Gitea__Token`, `Wiki__Webhook__Secret`, `Auth__ApiK `Wiki__Judges__Models____ApiKey`, `Wiki__AnswerModels__Models____ApiKey`). Each answer-model entry also provides the fact drafter of the same name (`/facts`). +Humans sign in with OpenID Connect (public client, PKCE): `Auth__Oidc__Authority`, +`Auth__Oidc__ClientId` and `Auth__Oidc__PublicOrigin` (the external origin, for the redirect URI) +are required. Machines send `X-Api-Key`. Only `/health`, `/ready` and the HMAC-signed `/webhook` +answer without either. + ## Run ``` diff --git a/src/Wiki.Host/Api/ApiV1.cs b/src/Wiki.Host/Api/ApiV1.cs index 04ddc07..9d902b3 100644 --- a/src/Wiki.Host/Api/ApiV1.cs +++ b/src/Wiki.Host/Api/ApiV1.cs @@ -16,7 +16,7 @@ public static class ApiV1 v1.MapQuestionEndpoints(); v1.MapGet("/status", async (StatusQuery status, CancellationToken ct) => - Results.Ok(await status.GetAsync(includeAdapterChecks: true, ct))).AllowAnonymous(); + Results.Ok(await status.GetAsync(includeAdapterChecks: true, ct))).RequireAuthorization(Auth.Policies.Read); v1.MapPost("/sync", async (SyncCoordinator sync, bool? force, CancellationToken ct) => { diff --git a/src/Wiki.Host/Api/AskEndpoints.cs b/src/Wiki.Host/Api/AskEndpoints.cs index f27d757..c94bba1 100644 --- a/src/Wiki.Host/Api/AskEndpoints.cs +++ b/src/Wiki.Host/Api/AskEndpoints.cs @@ -79,7 +79,7 @@ public static class AskEndpoints { return Results.Json(new { error = e.Message }, statusCode: StatusCodes.Status502BadGateway); } - }).AllowAnonymous(); + }).RequireAuthorization(Auth.Policies.Read); } public static AskQuestion Parse(string id, AskQuestionDto dto) diff --git a/src/Wiki.Host/Api/IndexEndpoints.cs b/src/Wiki.Host/Api/IndexEndpoints.cs index fe821b4..80f92ed 100644 --- a/src/Wiki.Host/Api/IndexEndpoints.cs +++ b/src/Wiki.Host/Api/IndexEndpoints.cs @@ -20,8 +20,8 @@ public static class IndexEndpoints { public static void MapIndexEndpoints(this WebApplication app) { - app.MapPost("/rank", RankAsync).AllowAnonymous(); - app.MapPost("/api/v1/rank", RankAsync).AllowAnonymous(); + app.MapPost("/rank", RankAsync).RequireAuthorization(Auth.Policies.Read); + app.MapPost("/api/v1/rank", RankAsync).RequireAuthorization(Auth.Policies.Read); app.MapPost("/webhook", async (HttpRequest request, IConfiguration configuration, WikiOptions options, SyncBackgroundService sync, CancellationToken ct) => { diff --git a/src/Wiki.Host/Api/QuestionEndpoints.cs b/src/Wiki.Host/Api/QuestionEndpoints.cs index dcd789d..db95daf 100644 --- a/src/Wiki.Host/Api/QuestionEndpoints.cs +++ b/src/Wiki.Host/Api/QuestionEndpoints.cs @@ -1,9 +1,7 @@ using System.Text; using System.Text.Json.Serialization; -using Microsoft.AspNetCore.Authentication; using Wiki.Application.Questions; using Wiki.Core.Ports; -using Wiki.Host.Auth; namespace Wiki.Host.Api; @@ -25,7 +23,7 @@ public static class QuestionEndpoints { v1.MapPost("/questions", async ( QuestionRequestDto body, QuestionUseCase questions, SaveAnswerUseCase save, IServiceProvider services, - IEmbeddingModel activeModel, IJudge activeJudge, IAnswerModel activeAnswer, HttpContext http, CancellationToken ct) => + IEmbeddingModel activeModel, IJudge activeJudge, IAnswerModel activeAnswer, CancellationToken ct) => { if (string.IsNullOrWhiteSpace(body.Question)) return Results.BadRequest(new { error = "question required" }); @@ -35,8 +33,6 @@ public static class QuestionEndpoints if (err is not null) return Results.BadRequest(new { error = err }); var answerModel = Pick(services, activeAnswer, body.AnswerModel, out err) ?? activeAnswer; if (err is not null) return Results.BadRequest(new { error = err }); - if (body.Save == true && !await IsAuthenticatedAsync(http)) - return Results.Json(new { error = "saving needs a signed-in user or an API key" }, statusCode: StatusCodes.Status401Unauthorized); var text = new StringBuilder(); QuestionEvent.Evidence? evidence = null; @@ -90,18 +86,9 @@ public static class QuestionEndpoints askUsage = ask?.Usage, saved, }); - }).AllowAnonymous(); + }).RequireAuthorization(Auth.Policies.Read); } - /// Humans arrive authenticated by the default (proxy header) scheme; machines present an API key. - private static async Task IsAuthenticatedAsync(HttpContext http) - { - if (http.User.Identity?.IsAuthenticated == true) return true; - var result = await http.AuthenticateAsync(ApiKeyAuthenticationHandler.SchemeName); - if (!result.Succeeded) return false; - http.User = result.Principal; - return true; - } private static T? Pick(IServiceProvider services, T active, string? name, out string? error) where T : class { diff --git a/src/Wiki.Host/Api/ReadEndpoints.cs b/src/Wiki.Host/Api/ReadEndpoints.cs index a367240..fd960e5 100644 --- a/src/Wiki.Host/Api/ReadEndpoints.cs +++ b/src/Wiki.Host/Api/ReadEndpoints.cs @@ -23,7 +23,7 @@ public static class ReadEndpoints return Results.Ok(await queries.SearchAsync(m, q, take, ct)); } return Results.Ok(await queries.ListAsync(tag, take, ct)); - }).AllowAnonymous(); + }).RequireAuthorization(Auth.Policies.Read); v1.MapGet("/notes/{slug}", async (NoteQueries queries, string slug, CancellationToken ct) => { @@ -40,28 +40,28 @@ public static class ReadEndpoints sources = view.Sources.Select(s => new { s.Text, kind = s.Kind?.ToString(), s.Slug }), unresolved = view.UnresolvedLinks, }); - }).AllowAnonymous(); + }).RequireAuthorization(Auth.Policies.Read); v1.MapGet("/tags", async (NoteQueries queries, CancellationToken ct) => Results.Ok((await queries.TagsAsync(ct)).OrderByDescending(kv => kv.Value).ThenBy(kv => kv.Key).Select(kv => new { tag = kv.Key, count = kv.Value }))) - .AllowAnonymous(); + .RequireAuthorization(Auth.Policies.Read); v1.MapGet("/raw", async (NoteQueries queries, CancellationToken ct) => - Results.Ok(await queries.RawQueueAsync(ct))).AllowAnonymous(); + Results.Ok(await queries.RawQueueAsync(ct))).RequireAuthorization(Auth.Policies.Read); v1.MapGet("/raw/{slug}", async (NoteQueries queries, string slug, CancellationToken ct) => { var doc = await queries.GetDocumentAsync(DocumentKind.Raw, slug, ct) ?? await queries.GetDocumentAsync(DocumentKind.RawProcessed, slug, ct); return doc is null ? Results.NotFound(new { error = "no such raw source" }) : Results.Ok(new { doc.Slug, doc.Title, doc.Kind, doc.Path, doc.Body }); - }).AllowAnonymous(); + }).RequireAuthorization(Auth.Policies.Read); v1.MapGet("/reports", async (NoteQueries queries, string? type, int? limit, CancellationToken ct) => - Results.Ok(await queries.ReportsAsync(type, limit ?? 50, ct))).AllowAnonymous(); + Results.Ok(await queries.ReportsAsync(type, limit ?? 50, ct))).RequireAuthorization(Auth.Policies.Read); v1.MapGet("/reports/{slug}", async (NoteQueries queries, string slug, CancellationToken ct) => { var doc = await queries.GetDocumentAsync(DocumentKind.Output, slug, ct); return doc is null ? Results.NotFound(new { error = "no such report" }) : Results.Ok(new { doc.Slug, doc.Title, doc.Type, doc.Path, doc.Sources, doc.Body }); - }).AllowAnonymous(); + }).RequireAuthorization(Auth.Policies.Read); } } diff --git a/src/Wiki.Host/Auth/ApiKeyAuthenticationHandler.cs b/src/Wiki.Host/Auth/ApiKeyAuthenticationHandler.cs index c44d491..ae770af 100644 --- a/src/Wiki.Host/Auth/ApiKeyAuthenticationHandler.cs +++ b/src/Wiki.Host/Auth/ApiKeyAuthenticationHandler.cs @@ -50,5 +50,13 @@ public sealed class ApiKeyAuthenticationHandler( public static class Policies { + /// A signed-in human (OIDC cookie) or any API key. + public const string Read = "read"; + /// An API key only, so a write carries a named machine identity. public const string Write = "write"; } + +public static class Claims +{ + public const string Kind = "wiki:kind"; +} diff --git a/src/Wiki.Host/Auth/ProxyHeaderAuthenticationHandler.cs b/src/Wiki.Host/Auth/ProxyHeaderAuthenticationHandler.cs deleted file mode 100644 index e1a503b..0000000 --- a/src/Wiki.Host/Auth/ProxyHeaderAuthenticationHandler.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System.Net; -using System.Security.Claims; -using System.Text.Encodings.Web; -using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Options; - -namespace Wiki.Host.Auth; - -/// -/// Humans arrive through the Authentik proxy outpost, which sets X-Authentik-Username after -/// validating the session. The header is trusted only from the peers listed in -/// Auth:ReverseProxy:TrustedProxies; with none configured the scheme authenticates nobody. -/// -public sealed class ProxyHeaderAuthenticationHandler( - IOptionsMonitor options, - ILoggerFactory logger, - UrlEncoder encoder, - IConfiguration configuration) - : AuthenticationHandler(options, logger, encoder) -{ - public const string SchemeName = "ProxyHeader"; - public const string DefaultHeaderName = "X-Authentik-Username"; - - protected override Task HandleAuthenticateAsync() - { - var headerName = configuration["Auth:ReverseProxy:HeaderName"]; - if (string.IsNullOrWhiteSpace(headerName)) headerName = DefaultHeaderName; - - var user = Request.Headers[headerName].FirstOrDefault(); - if (string.IsNullOrWhiteSpace(user)) - return Task.FromResult(AuthenticateResult.NoResult()); - - var peer = Context.Connection.RemoteIpAddress; - if (peer is null || !IsTrustedProxy(peer)) - { - Logger.LogWarning("ignoring {Header} from untrusted peer {Peer}", headerName, peer); - return Task.FromResult(AuthenticateResult.NoResult()); - } - - var identity = new ClaimsIdentity([new Claim(ClaimTypes.Name, user), new Claim(Claims.Kind, "human")], Scheme.Name); - return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name))); - } - - protected override Task HandleChallengeAsync(AuthenticationProperties properties) - { - Response.StatusCode = StatusCodes.Status401Unauthorized; - return Task.CompletedTask; - } - - private bool IsTrustedProxy(IPAddress peer) - { - if (peer.IsIPv4MappedToIPv6) peer = peer.MapToIPv4(); - var trusted = configuration.GetSection("Auth:ReverseProxy:TrustedProxies").Get() ?? []; - foreach (var entry in trusted) - { - if (IPAddress.TryParse(entry, out var single)) - { - if (single.Equals(peer)) return true; - continue; - } - if (IPNetwork.TryParse(entry, out var network) && network.Contains(peer)) - return true; - } - return false; - } -} - -public static class Claims -{ - public const string Kind = "wiki:kind"; -} diff --git a/src/Wiki.Host/Auth/WikiAuthentication.cs b/src/Wiki.Host/Auth/WikiAuthentication.cs new file mode 100644 index 0000000..0d97cde --- /dev/null +++ b/src/Wiki.Host/Auth/WikiAuthentication.cs @@ -0,0 +1,96 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Authorization; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; + +namespace Wiki.Host.Auth; + +/// +/// Humans sign in with OpenID Connect against Authentik (public client, PKCE): the username comes +/// from the ID token, whose signature the handler checks against the provider's JWKS, so no +/// network path or request header can assert an identity. Machines present an API key. +/// Every endpoint needs one of the two unless it opts out (/health, /ready, the HMAC webhook). +/// +public static class WikiAuthentication +{ + public const string UsernameClaim = "preferred_username"; + + public static IServiceCollection AddWikiAuthentication(this IServiceCollection services, IConfiguration configuration) + { + var authority = configuration["Auth:Oidc:Authority"]; + var clientId = configuration["Auth:Oidc:ClientId"]; + var publicOrigin = configuration["Auth:Oidc:PublicOrigin"]?.TrimEnd('/'); + if (string.IsNullOrWhiteSpace(authority) || string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(publicOrigin)) + throw new InvalidOperationException("Auth:Oidc:Authority, Auth:Oidc:ClientId and Auth:Oidc:PublicOrigin are required; humans have no other way to sign in."); + + services.AddAuthentication(options => + { + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; + }) + .AddCookie(options => + { + options.Cookie.Name = "wiki.auth"; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.Cookie.SameSite = SameSiteMode.Lax; + options.SlidingExpiration = true; + options.ExpireTimeSpan = TimeSpan.FromHours(12); + // Only the OIDC scheme sends anyone to a login; the cookie scheme answers API callers with a status. + options.Events.OnRedirectToLogin = ctx => { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; }; + options.Events.OnRedirectToAccessDenied = ctx => { ctx.Response.StatusCode = StatusCodes.Status403Forbidden; return Task.CompletedTask; }; + }) + .AddOpenIdConnect(options => + { + options.Authority = authority; + options.ClientId = clientId; + options.ResponseType = OpenIdConnectResponseType.Code; + options.UsePkce = true; + options.Scope.Clear(); + options.Scope.Add("openid"); + options.Scope.Add("profile"); + options.MapInboundClaims = false; + options.GetClaimsFromUserInfoEndpoint = true; + options.ClaimActions.MapUniqueJsonKey(UsernameClaim, UsernameClaim); + options.TokenValidationParameters.NameClaimType = UsernameClaim; + options.SaveTokens = false; + options.Events.OnRedirectToIdentityProvider = ctx => + { + // TLS ends at Traefik, so the request scheme is http; the registered redirect URI is the public one. + ctx.ProtocolMessage.RedirectUri = publicOrigin + ctx.Options.CallbackPath; + return Task.CompletedTask; + }; + options.Events.OnTokenValidated = ctx => + { + if (ctx.Principal?.Identity is ClaimsIdentity identity) + { + if (string.IsNullOrWhiteSpace(identity.Name)) + { + ctx.Fail($"ID token carries no {UsernameClaim}"); + return Task.CompletedTask; + } + identity.AddClaim(new Claim(Claims.Kind, "human")); + } + return Task.CompletedTask; + }; + }) + .AddScheme(ApiKeyAuthenticationHandler.SchemeName, null); + + services.AddAuthorization(options => + { + // UI pages, static assets and the Blazor hub: a signed-in human. No explicit scheme, so the + // challenge goes to the default (OIDC) and an anonymous browser is sent to Authentik. + options.FallbackPolicy = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build(); + options.AddPolicy(Policies.Read, p => p + .AddAuthenticationSchemes(CookieAuthenticationDefaults.AuthenticationScheme, ApiKeyAuthenticationHandler.SchemeName) + .RequireAuthenticatedUser()); + options.AddPolicy(Policies.Write, p => p + .AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName) + .RequireAuthenticatedUser()); + }); + return services; + } +} diff --git a/src/Wiki.Host/Components/Pages/Facts.razor b/src/Wiki.Host/Components/Pages/Facts.razor index c889476..e04a911 100644 --- a/src/Wiki.Host/Components/Pages/Facts.razor +++ b/src/Wiki.Host/Components/Pages/Facts.razor @@ -17,7 +17,7 @@ @if (_user is null) { - Not signed in. Facts are attributed to the person stating them, so this page needs the Authentik sign-in at wiki.renner.uno. + Not signed in. Facts are attributed to the person stating them, so this page needs the Authentik sign-in. } else { diff --git a/src/Wiki.Host/Program.cs b/src/Wiki.Host/Program.cs index d3dc833..c277de0 100644 --- a/src/Wiki.Host/Program.cs +++ b/src/Wiki.Host/Program.cs @@ -1,4 +1,3 @@ -using Microsoft.AspNetCore.Authentication; using MudBlazor.Services; using Wiki.Adapters.FileIndex; using Wiki.Adapters.Gitea; @@ -129,14 +128,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); -// ---- auth: humans arrive through the Authentik outpost, machines with an API key --------- -builder.Services.AddAuthentication(ProxyHeaderAuthenticationHandler.SchemeName) - .AddScheme(ProxyHeaderAuthenticationHandler.SchemeName, null) - .AddScheme(ApiKeyAuthenticationHandler.SchemeName, null); -builder.Services.AddAuthorization(options => -{ - options.AddPolicy(Policies.Write, p => p.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName).RequireAuthenticatedUser()); -}); +// ---- auth: humans sign in with OIDC against Authentik, machines with an API key ---------- +builder.Services.AddWikiAuthentication(builder.Configuration); // ---- UI ---------------------------------------------------------------------------------- builder.Services.AddRazorComponents().AddInteractiveServerComponents(); diff --git a/src/Wiki.Host/Wiki.Host.csproj b/src/Wiki.Host/Wiki.Host.csproj index 76c283d..5392846 100644 --- a/src/Wiki.Host/Wiki.Host.csproj +++ b/src/Wiki.Host/Wiki.Host.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Wiki.Host/appsettings.json b/src/Wiki.Host/appsettings.json index f8105dc..cc82f96 100644 --- a/src/Wiki.Host/appsettings.json +++ b/src/Wiki.Host/appsettings.json @@ -34,7 +34,7 @@ "Sync": { "IntervalSeconds": 1800, "RetrySeconds": 60 } }, "Auth": { - "ReverseProxy": { "HeaderName": "X-Authentik-Username", "TrustedProxies": [] }, + "Oidc": { "Authority": "", "ClientId": "", "PublicOrigin": "" }, "ApiKeys": {} } } diff --git a/test/Wiki.Host.Tests/EndpointTests.cs b/test/Wiki.Host.Tests/EndpointTests.cs index 2e1a158..5b5b6c2 100644 --- a/test/Wiki.Host.Tests/EndpointTests.cs +++ b/test/Wiki.Host.Tests/EndpointTests.cs @@ -20,16 +20,20 @@ public sealed class WikiHostFactory : WebApplicationFactory public const string WebhookSecret = "hook-secret"; public const string ApiKey = "machine-key"; public FakeStore Store { get; } = new(); + public FakeIdp Idp { get; } = new(); protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("Testing"); builder.UseSetting("Wiki:Webhook:Secret", WebhookSecret); builder.UseSetting("Auth:ApiKeys:tester", ApiKey); - builder.UseSetting("Auth:ReverseProxy:TrustedProxies:0", "127.0.0.1"); + builder.UseSetting("Auth:Oidc:Authority", FakeIdp.Issuer); + builder.UseSetting("Auth:Oidc:ClientId", FakeIdp.ClientId); + builder.UseSetting("Auth:Oidc:PublicOrigin", FakeIdp.PublicOrigin); builder.UseSetting("Wiki:Index:File:Directory", Path.Combine(Path.GetTempPath(), "wiki-host-tests", Guid.NewGuid().ToString("N"))); builder.ConfigureServices(services => { + Idp.Register(services); services.RemoveAll(); services.AddSingleton(Store); // The embedding model registered under the active name is replaced by a deterministic fake. @@ -61,6 +65,17 @@ public class EndpointTests(WikiHostFactory factory) : IClassFixture factory.CreateClient(); + /// A machine caller: the read API needs a credential since humans sign in with OIDC. + private HttpClient Keyed + { + get + { + var client = factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Api-Key", WikiHostFactory.ApiKey); + return client; + } + } + [Fact] public async Task HealthIsAlwaysOk() { @@ -96,9 +111,10 @@ public class EndpointTests(WikiHostFactory factory) : IClassFixture(); Assert.NotNull(body); @@ -149,29 +165,29 @@ public class EndpointTests(WikiHostFactory factory) : IClassFixture>("/api/v1/notes?tag=ceph"); + var notes = await Keyed.GetFromJsonAsync>("/api/v1/notes?tag=ceph"); Assert.Equal("ceph-osd", Assert.Single(notes!).Slug); - var note = await Client.GetFromJsonAsync("/api/v1/notes/ceph-osd"); + var note = await Keyed.GetFromJsonAsync("/api/v1/notes/ceph-osd"); Assert.NotNull(note); Assert.Contains("See [[udm-dns]]", note.Body); Assert.Equal(2, note.Links.Count); - Assert.Equal(HttpStatusCode.NotFound, (await Client.GetAsync("/api/v1/notes/nope")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await Keyed.GetAsync("/api/v1/notes/nope")).StatusCode); - var back = await Client.GetFromJsonAsync("/api/v1/notes/udm-dns"); + var back = await Keyed.GetFromJsonAsync("/api/v1/notes/udm-dns"); Assert.Equal(2, back!.Backlinks.Count); - var tags = await Client.GetFromJsonAsync>("/api/v1/tags"); + var tags = await Keyed.GetFromJsonAsync>("/api/v1/tags"); Assert.Contains(tags!, t => t.Tag == "ceph" && t.Count == 1); - var raw = await Client.GetFromJsonAsync>("/api/v1/raw"); + var raw = await Keyed.GetFromJsonAsync>("/api/v1/raw"); Assert.Contains(raw!, r => r.Slug == "2026-09-23-pending"); - var reports = await Client.GetFromJsonAsync>("/api/v1/reports?type=audit"); + var reports = await Keyed.GetFromJsonAsync>("/api/v1/reports?type=audit"); Assert.Equal("Wiki audit", Assert.Single(reports!).Title); - Assert.Equal(HttpStatusCode.OK, (await Client.GetAsync("/api/v1/reports/2026-09-21-wiki-audit")).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await Keyed.GetAsync("/api/v1/reports/2026-09-21-wiki-audit")).StatusCode); } [Fact] @@ -180,12 +196,12 @@ public class EndpointTests(WikiHostFactory factory) : IClassFixture() }); + var bad = await Keyed.PostAsJsonAsync("/api/v1/ask", new { queries = Array.Empty() }); Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode); - var r = await Client.PostAsJsonAsync("/api/v1/ask", new + var r = await Keyed.PostAsJsonAsync("/api/v1/ask", new { queries = new[] { "udm dns" }, k = 3, @@ -252,13 +268,13 @@ public class EndpointTests(WikiHostFactory factory) : IClassFixture(); Assert.NotNull(body); @@ -269,7 +285,7 @@ public class EndpointTests(WikiHostFactory factory) : IClassFixture(); + var saved = await (await Keyed.SendAsync(save)).Content.ReadFromJsonAsync(); Assert.NotNull(saved?.Saved); Assert.StartsWith("output/", saved.Saved.Path); Assert.Contains("type: output", factory.Store.Files[saved.Saved.Path]); @@ -315,22 +331,11 @@ public class EndpointTests(WikiHostFactory factory) : IClassFixture Rows); - [Fact] - public async Task FactsPageRendersAndNamesTheSignInWhenAnonymous() - { - // The test server has no peer address, so the proxy identity header is never trusted here. - var page = await Client.GetAsync("/facts"); - Assert.Equal(HttpStatusCode.OK, page.StatusCode); - var html = await page.Content.ReadAsStringAsync(); - Assert.Contains("Facts", html); - Assert.Contains("Not signed in", html); - Assert.Contains("Raw sources awaiting distillation", html); - } - [Fact] public async Task StatusListsAdaptersAndIndexes() { - var status = await Client.GetFromJsonAsync("/api/v1/status"); + var client = Keyed; + var status = await client.GetFromJsonAsync("/api/v1/status"); Assert.NotNull(status); Assert.Contains(status.Indexes, i => i.ModelId == "bge-m3" && i.Active); Assert.NotEmpty(status.Adapters); diff --git a/test/Wiki.Host.Tests/FakeIdp.cs b/test/Wiki.Host.Tests/FakeIdp.cs new file mode 100644 index 0000000..235b5f7 --- /dev/null +++ b/test/Wiki.Host.Tests/FakeIdp.cs @@ -0,0 +1,96 @@ +using System.Net; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; + +namespace Wiki.Host.Tests; + +/// +/// An in-process OpenID provider: static discovery, a token endpoint that issues RS256 ID tokens and a +/// userinfo endpoint, reached through the OIDC handler's backchannel. Records what the wiki sent. +/// +public sealed class FakeIdp : HttpMessageHandler +{ + public const string Issuer = "https://idp.test/application/o/wiki/"; + public const string ClientId = "wiki"; + public const string PublicOrigin = "https://wiki.test"; + private const string AccessToken = "access-token"; + + private readonly RsaSecurityKey _key = new(RSA.Create(2048)) { KeyId = "idp-key" }; + + /// The user the next token response is issued for. + public string Username { get; set; } = "alice"; + /// Signs the ID token with a key the wiki does not trust when set. + public bool SignWithForeignKey { get; set; } + /// The nonce the wiki put into the authorize request; the ID token must echo it. + public string? Nonce { get; set; } + public List> TokenRequests { get; } = []; + + public OpenIdConnectConfiguration Configuration { get; } + + public FakeIdp() + { + Configuration = new OpenIdConnectConfiguration + { + Issuer = Issuer, + AuthorizationEndpoint = "https://idp.test/application/o/authorize/", + TokenEndpoint = "https://idp.test/application/o/token/", + UserInfoEndpoint = "https://idp.test/application/o/userinfo/", + }; + Configuration.SigningKeys.Add(_key); + Configuration.CodeChallengeMethodsSupported.Add("S256"); + } + + public void Register(IServiceCollection services) => + services.PostConfigure(OpenIdConnectDefaults.AuthenticationScheme, o => + { + o.Configuration = Configuration; + o.ConfigurationManager = new StaticConfigurationManager(Configuration); + o.Backchannel = new HttpClient(this); + }); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + var url = request.RequestUri!.ToString(); + if (url == Configuration.TokenEndpoint) + { + var form = await request.Content!.ReadAsStringAsync(ct); + TokenRequests.Add(form.Split('&').Select(kv => kv.Split('=', 2)) + .ToDictionary(kv => Uri.UnescapeDataString(kv[0]), kv => Uri.UnescapeDataString(kv[1].Replace('+', ' ')))); + return Json(new { access_token = AccessToken, token_type = "Bearer", expires_in = 300, id_token = IdToken() }); + } + if (url == Configuration.UserInfoEndpoint) + return Json(new { sub = Sub, preferred_username = Username }); + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + + private string Sub => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(Username))); + + private string IdToken() + { + var key = SignWithForeignKey ? new RsaSecurityKey(RSA.Create(2048)) { KeyId = "idp-key" } : _key; + var atHash = Base64UrlEncoder.Encode(SHA256.HashData(Encoding.ASCII.GetBytes(AccessToken))[..16]); + var now = DateTime.UtcNow; + return new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor + { + Issuer = Issuer, + Audience = ClientId, + IssuedAt = now, + NotBefore = now, + Expires = now.AddMinutes(5), + Subject = new ClaimsIdentity([new Claim("sub", Sub), new Claim("preferred_username", Username)]), + Claims = new Dictionary { ["nonce"] = Nonce ?? string.Empty, ["at_hash"] = atHash }, + SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256), + }); + } + + private static HttpResponseMessage Json(object body) => + new(HttpStatusCode.OK) { Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json") }; +} diff --git a/test/Wiki.Host.Tests/OidcTests.cs b/test/Wiki.Host.Tests/OidcTests.cs new file mode 100644 index 0000000..f37c314 --- /dev/null +++ b/test/Wiki.Host.Tests/OidcTests.cs @@ -0,0 +1,112 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.WebUtilities; + +namespace Wiki.Host.Tests; + +/// +/// Human sign-in: OIDC against the provider, nothing else. A browser without a session is sent to the +/// provider, API callers get a status, and no request header names a user. +/// +public class OidcTests(WikiHostFactory factory) : IClassFixture +{ + private HttpClient Browser() => factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = true, + BaseAddress = new Uri("https://localhost"), + }); + + [Fact] + public async Task AnonymousBrowserIsSentToTheProviderWithPkce() + { + var page = await Browser().GetAsync("/facts"); + Assert.Equal(HttpStatusCode.Redirect, page.StatusCode); + var location = page.Headers.Location!; + Assert.StartsWith(factory.Idp.Configuration.AuthorizationEndpoint, location.ToString()); + var query = QueryHelpers.ParseQuery(location.Query); + Assert.Equal(FakeIdp.ClientId, query["client_id"]); + Assert.Equal("code", query["response_type"]); + Assert.Equal("S256", query["code_challenge_method"]); + Assert.False(string.IsNullOrEmpty(query["code_challenge"])); + Assert.Equal($"{FakeIdp.PublicOrigin}/signin-oidc", query["redirect_uri"]); + } + + [Fact] + public async Task TheAuthentikHeaderSignsNobodyIn() + { + var browser = Browser(); + var page = new HttpRequestMessage(HttpMethod.Get, "/facts"); + page.Headers.Add("X-Authentik-Username", "k.renner"); + Assert.Equal(HttpStatusCode.Redirect, (await browser.SendAsync(page)).StatusCode); + + var api = new HttpRequestMessage(HttpMethod.Get, "/api/v1/notes"); + api.Headers.Add("X-Authentik-Username", "k.renner"); + Assert.Equal(HttpStatusCode.Unauthorized, (await browser.SendAsync(api)).StatusCode); + } + + [Fact] + public async Task ApiWithoutCredentialsAnswers401NotARedirect() + { + var browser = Browser(); + Assert.Equal(HttpStatusCode.Unauthorized, (await browser.GetAsync("/api/v1/notes")).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await browser.GetAsync("/api/v1/status")).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await browser.PostAsJsonAsync("/rank", new { query = "x" })).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await browser.PostAsJsonAsync("/api/v1/questions", new { question = "x" })).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await browser.GetAsync("/health")).StatusCode); + } + + [Fact] + public async Task SignInTakesTheUsernameFromTheVerifiedIdToken() + { + var browser = Browser(); + var (code, redirect) = await SignInAsync(browser, "alice"); + Assert.Equal(HttpStatusCode.Redirect, code); + Assert.Equal("/facts", redirect); + + var token = factory.Idp.TokenRequests[^1]; + Assert.Equal($"{FakeIdp.PublicOrigin}/signin-oidc", token["redirect_uri"]); + Assert.True(token.ContainsKey("code_verifier")); + Assert.False(token.ContainsKey("client_secret")); + + var page = await browser.GetAsync("/facts"); + Assert.Equal(HttpStatusCode.OK, page.StatusCode); + var html = await page.Content.ReadAsStringAsync(); + Assert.Contains("captured_by: alice", html); + + // A signed-in human may read the API with the session cookie. + Assert.Equal(HttpStatusCode.OK, (await browser.GetAsync("/api/v1/tags")).StatusCode); + } + + [Fact] + public async Task AnIdTokenSignedByAnotherKeyIsRejected() + { + factory.Idp.SignWithForeignKey = true; + try + { + var browser = Browser(); + await SignInAsync(browser, "mallory"); + Assert.Equal(HttpStatusCode.Redirect, (await browser.GetAsync("/facts")).StatusCode); + } + finally + { + factory.Idp.SignWithForeignKey = false; + } + } + + /// Follows the code flow by hand: challenge, then the provider's form_post back to the callback. + private async Task<(HttpStatusCode, string?)> SignInAsync(HttpClient browser, string username) + { + var challenge = await browser.GetAsync("/facts"); + var query = QueryHelpers.ParseQuery(challenge.Headers.Location!.Query); + factory.Idp.Username = username; + factory.Idp.Nonce = query["nonce"]; + var callback = await browser.PostAsync("/signin-oidc", new FormUrlEncodedContent(new Dictionary + { + ["code"] = "the-code", + ["state"] = query["state"].ToString(), + })); + return (callback.StatusCode, callback.Headers.Location?.OriginalString); + } +} -- 2.54.0 From 4820bc9f6dcbcbd0a51d5ecd1816bbd7703ad327 Mon Sep 17 00:00:00 2001 From: hermes-bot Date: Mon, 19 Oct 2026 09:45:56 +0000 Subject: [PATCH 2/2] fix(auth): redirect URI from the request host, no PublicOrigin setting Auth:Oidc:PublicOrigin was a hand copy of the Ingress host. Traefik routes on the Host header and Authentik matches redirect URIs strictly, so the URI is https:///signin-oidc; https because TLS ends at Traefik and browsers only reach the wiki over https. --- README.md | 5 ++--- src/Wiki.Host/Auth/WikiAuthentication.cs | 9 ++++----- src/Wiki.Host/appsettings.json | 2 +- test/Wiki.Host.Tests/EndpointTests.cs | 1 - test/Wiki.Host.Tests/FakeIdp.cs | 1 - test/Wiki.Host.Tests/OidcTests.cs | 18 ++++++++++++++++-- 6 files changed, 23 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index e0c6379..4bca33b 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,8 @@ variables (`Wiki__NoteStore__Gitea__Token`, `Wiki__Webhook__Secret`, `Auth__ApiK `Wiki__Judges__Models____ApiKey`, `Wiki__AnswerModels__Models____ApiKey`). Each answer-model entry also provides the fact drafter of the same name (`/facts`). -Humans sign in with OpenID Connect (public client, PKCE): `Auth__Oidc__Authority`, -`Auth__Oidc__ClientId` and `Auth__Oidc__PublicOrigin` (the external origin, for the redirect URI) -are required. Machines send `X-Api-Key`. Only `/health`, `/ready` and the HMAC-signed `/webhook` +Humans sign in with OpenID Connect (public client, PKCE): `Auth__Oidc__Authority` and +`Auth__Oidc__ClientId` are required; the redirect URI is `https:///signin-oidc`. Machines send `X-Api-Key`. Only `/health`, `/ready` and the HMAC-signed `/webhook` answer without either. ## Run diff --git a/src/Wiki.Host/Auth/WikiAuthentication.cs b/src/Wiki.Host/Auth/WikiAuthentication.cs index 0d97cde..fa7d943 100644 --- a/src/Wiki.Host/Auth/WikiAuthentication.cs +++ b/src/Wiki.Host/Auth/WikiAuthentication.cs @@ -21,9 +21,8 @@ public static class WikiAuthentication { var authority = configuration["Auth:Oidc:Authority"]; var clientId = configuration["Auth:Oidc:ClientId"]; - var publicOrigin = configuration["Auth:Oidc:PublicOrigin"]?.TrimEnd('/'); - if (string.IsNullOrWhiteSpace(authority) || string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(publicOrigin)) - throw new InvalidOperationException("Auth:Oidc:Authority, Auth:Oidc:ClientId and Auth:Oidc:PublicOrigin are required; humans have no other way to sign in."); + if (string.IsNullOrWhiteSpace(authority) || string.IsNullOrWhiteSpace(clientId)) + throw new InvalidOperationException("Auth:Oidc:Authority and Auth:Oidc:ClientId are required; humans have no other way to sign in."); services.AddAuthentication(options => { @@ -57,8 +56,8 @@ public static class WikiAuthentication options.SaveTokens = false; options.Events.OnRedirectToIdentityProvider = ctx => { - // TLS ends at Traefik, so the request scheme is http; the registered redirect URI is the public one. - ctx.ProtocolMessage.RedirectUri = publicOrigin + ctx.Options.CallbackPath; + // TLS ends at Traefik, so the request scheme is http; browsers only ever reach the wiki over https. + ctx.ProtocolMessage.RedirectUri = $"https://{ctx.Request.Host}{ctx.Options.CallbackPath}"; return Task.CompletedTask; }; options.Events.OnTokenValidated = ctx => diff --git a/src/Wiki.Host/appsettings.json b/src/Wiki.Host/appsettings.json index cc82f96..8bb5015 100644 --- a/src/Wiki.Host/appsettings.json +++ b/src/Wiki.Host/appsettings.json @@ -34,7 +34,7 @@ "Sync": { "IntervalSeconds": 1800, "RetrySeconds": 60 } }, "Auth": { - "Oidc": { "Authority": "", "ClientId": "", "PublicOrigin": "" }, + "Oidc": { "Authority": "", "ClientId": "" }, "ApiKeys": {} } } diff --git a/test/Wiki.Host.Tests/EndpointTests.cs b/test/Wiki.Host.Tests/EndpointTests.cs index 5b5b6c2..fba1357 100644 --- a/test/Wiki.Host.Tests/EndpointTests.cs +++ b/test/Wiki.Host.Tests/EndpointTests.cs @@ -29,7 +29,6 @@ public sealed class WikiHostFactory : WebApplicationFactory builder.UseSetting("Auth:ApiKeys:tester", ApiKey); builder.UseSetting("Auth:Oidc:Authority", FakeIdp.Issuer); builder.UseSetting("Auth:Oidc:ClientId", FakeIdp.ClientId); - builder.UseSetting("Auth:Oidc:PublicOrigin", FakeIdp.PublicOrigin); builder.UseSetting("Wiki:Index:File:Directory", Path.Combine(Path.GetTempPath(), "wiki-host-tests", Guid.NewGuid().ToString("N"))); builder.ConfigureServices(services => { diff --git a/test/Wiki.Host.Tests/FakeIdp.cs b/test/Wiki.Host.Tests/FakeIdp.cs index 235b5f7..f3b2aa9 100644 --- a/test/Wiki.Host.Tests/FakeIdp.cs +++ b/test/Wiki.Host.Tests/FakeIdp.cs @@ -20,7 +20,6 @@ public sealed class FakeIdp : HttpMessageHandler { public const string Issuer = "https://idp.test/application/o/wiki/"; public const string ClientId = "wiki"; - public const string PublicOrigin = "https://wiki.test"; private const string AccessToken = "access-token"; private readonly RsaSecurityKey _key = new(RSA.Create(2048)) { KeyId = "idp-key" }; diff --git a/test/Wiki.Host.Tests/OidcTests.cs b/test/Wiki.Host.Tests/OidcTests.cs index f37c314..ebde30a 100644 --- a/test/Wiki.Host.Tests/OidcTests.cs +++ b/test/Wiki.Host.Tests/OidcTests.cs @@ -30,7 +30,21 @@ public class OidcTests(WikiHostFactory factory) : IClassFixture Assert.Equal("code", query["response_type"]); Assert.Equal("S256", query["code_challenge_method"]); Assert.False(string.IsNullOrEmpty(query["code_challenge"])); - Assert.Equal($"{FakeIdp.PublicOrigin}/signin-oidc", query["redirect_uri"]); + Assert.Equal("https://localhost/signin-oidc", query["redirect_uri"]); + } + + [Fact] + public async Task TheRedirectUriIsHttpsOnTheRequestHostEvenBehindTlsTermination() + { + // Traefik ends TLS and forwards plain http with the original Host. + var behindProxy = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + BaseAddress = new Uri("http://wiki.test"), + }); + var page = await behindProxy.GetAsync("/facts"); + var query = QueryHelpers.ParseQuery(page.Headers.Location!.Query); + Assert.Equal("https://wiki.test/signin-oidc", query["redirect_uri"]); } [Fact] @@ -66,7 +80,7 @@ public class OidcTests(WikiHostFactory factory) : IClassFixture Assert.Equal("/facts", redirect); var token = factory.Idp.TokenRequests[^1]; - Assert.Equal($"{FakeIdp.PublicOrigin}/signin-oidc", token["redirect_uri"]); + Assert.Equal("https://localhost/signin-oidc", token["redirect_uri"]); Assert.True(token.ContainsKey("code_verifier")); Assert.False(token.ContainsKey("client_secret")); -- 2.54.0