diff --git a/README.md b/README.md index ae9a51d..4bca33b 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,10 @@ 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` 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/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..fa7d943 --- /dev/null +++ b/src/Wiki.Host/Auth/WikiAuthentication.cs @@ -0,0 +1,95 @@ +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"]; + 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 => + { + 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; 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 => + { + 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..8bb5015 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": "" }, "ApiKeys": {} } } diff --git a/test/Wiki.Host.Tests/EndpointTests.cs b/test/Wiki.Host.Tests/EndpointTests.cs index 2e1a158..fba1357 100644 --- a/test/Wiki.Host.Tests/EndpointTests.cs +++ b/test/Wiki.Host.Tests/EndpointTests.cs @@ -20,16 +20,19 @@ 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("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 +64,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 +110,10 @@ public class EndpointTests(WikiHostFactory factory) : IClassFixture(); Assert.NotNull(body); @@ -149,29 +164,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 +195,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 +267,13 @@ public class EndpointTests(WikiHostFactory factory) : IClassFixture(); Assert.NotNull(body); @@ -269,7 +284,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 +330,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..f3b2aa9 --- /dev/null +++ b/test/Wiki.Host.Tests/FakeIdp.cs @@ -0,0 +1,95 @@ +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"; + 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..ebde30a --- /dev/null +++ b/test/Wiki.Host.Tests/OidcTests.cs @@ -0,0 +1,126 @@ +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("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] + 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("https://localhost/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); + } +}