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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using Marten;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Modgud.Api.Tests.Infrastructure;
using Modgud.Application.DTOs.OAuth;
using Modgud.Application.Services;
using Modgud.Authentication.Setup;
using Modgud.Domain.OAuth.Applications;
using Modgud.Domain.OAuth.Common;

namespace Modgud.Api.Tests.Authorization;

/// <summary>
/// Verifies the one-time PAR-permission boot backfill
/// (<see cref="PushedAuthorizationPermissionBackfill"/>): existing OAuth clients
/// that predate the <c>ept:pushed_authorization</c> baseline get it added on the
/// next boot, without a manual re-save and without touching anything else — in
/// particular the separate <c>ft:par</c> requirement.
/// </summary>
[Collection(IntegrationTestCollection.Name)]
public class PushedAuthorizationPermissionBackfillTests : IntegrationTestBase
{
public PushedAuthorizationPermissionBackfillTests(SharedPostgresFixture fixture) : base(fixture) { }

private const string Par = OAuthPermissions.Endpoints.PushedAuthorization;
private const string ParRequirement = OAuthPermissions.Requirements.PushedAuthorizationRequests;

[Fact]
public async Task Backfill_adds_par_permission_preserves_the_rest_and_is_idempotent()
{
var ct = TestContext.Current.CancellationToken;
var clientId = $"legacy-{Guid.NewGuid():N}";

// A client that also *requires* PAR (ft:par), so we can prove the
// requirement is left untouched by the permission backfill.
await CreateConfidentialClientAsync(clientId, "S3cret!Value", "https://app.example/cb",
["openid"], requirePar: true);
var streamId = await StreamIdForAsync(clientId, ct);

// Simulate a pre-migration client: strip the PAR endpoint permission.
var (permsBefore, reqsBefore) = await StripParPermissionAsync(streamId, ct);
Assert.DoesNotContain(Par, permsBefore);
Assert.Contains(ParRequirement, reqsBefore); // ft:par is on and must stay on

var versionBefore = await StreamVersionAsync(streamId, ct);

// Run the boot migration.
await RunBackfillAsync(ct);

var agg = await LoadAggregateAsync(streamId, ct);
// Exactly the previous permissions plus PAR — nothing else added or dropped.
Assert.Equal(
permsBefore.Append(Par).OrderBy(p => p, StringComparer.Ordinal),
agg.Permissions.OrderBy(p => p, StringComparer.Ordinal));
// The ft:par requirement (and any others) are byte-for-byte unchanged.
Assert.Equal(reqsBefore, agg.Requirements);

// One append happened.
var versionAfter = await StreamVersionAsync(streamId, ct);
Assert.Equal(versionBefore + 1, versionAfter);

// Idempotent: a second boot appends nothing.
await RunBackfillAsync(ct);
Assert.Equal(versionAfter, await StreamVersionAsync(streamId, ct));
}

[Fact]
public async Task A_client_that_already_has_the_permission_is_left_untouched()
{
var ct = TestContext.Current.CancellationToken;
var clientId = $"fresh-{Guid.NewGuid():N}";

// A freshly-created client already carries the PAR permission.
await CreateConfidentialClientAsync(clientId, "S3cret!Value", "https://app.example/cb", ["openid"]);
var streamId = await StreamIdForAsync(clientId, ct);
Assert.Contains(Par, (await LoadAggregateAsync(streamId, ct)).Permissions);

var versionBefore = await StreamVersionAsync(streamId, ct);
await RunBackfillAsync(ct);
Assert.Equal(versionBefore, await StreamVersionAsync(streamId, ct)); // no new event
}

[Fact]
public async Task Backfilled_client_can_reach_the_par_endpoint()
{
var ct = TestContext.Current.CancellationToken;
var clientId = $"legacy-par-{Guid.NewGuid():N}";
const string secret = "S3cret!Value";

await CreateConfidentialClientAsync(clientId, secret, "https://app.example/cb", ["openid"]);
var streamId = await StreamIdForAsync(clientId, ct);
await StripParPermissionAsync(streamId, ct);

// Before the backfill the advertised endpoint refuses the client (ID2183).
Assert.NotEqual(HttpStatusCode.Created, await PostParAsync(clientId, secret));

await RunBackfillAsync(ct);

// After the backfill the same client is accepted.
Assert.Equal(HttpStatusCode.Created, await PostParAsync(clientId, secret));
}

// ── helpers ──

private async Task RunBackfillAsync(CancellationToken ct)
{
var migration = Factory.Services.GetServices<IHostedService>()
.OfType<PushedAuthorizationPermissionBackfill>().Single();
await migration.StartAsync(ct);
}

private async Task<Guid> StreamIdForAsync(string clientId, CancellationToken ct)
{
using var session = GetTenantedSession();
var state = await session.Query<OAuthApplicationState>()
.FirstOrDefaultAsync(x => x.ClientId == clientId, ct);
Assert.NotNull(state);
return state!.Id;
}

private async Task<OAuthApplicationAggregate> LoadAggregateAsync(Guid streamId, CancellationToken ct)
{
using var session = GetTenantedSession();
var agg = await session.Events.AggregateStreamAsync<OAuthApplicationAggregate>(streamId, token: ct);
Assert.NotNull(agg);
return agg!;
}

private async Task<long> StreamVersionAsync(Guid streamId, CancellationToken ct)
{
using var session = GetTenantedSession();
var events = await session.Events.FetchStreamAsync(streamId, token: ct);
return events.Count == 0 ? 0 : events[^1].Version;
}

private async Task<(List<string> perms, List<string> reqs)> StripParPermissionAsync(Guid streamId, CancellationToken ct)
{
using var session = GetTenantedDocumentSession();
var agg = await session.Events.AggregateStreamAsync<OAuthApplicationAggregate>(streamId, token: ct);
Assert.NotNull(agg);
var stripped = agg!.Permissions.Where(p => p != Par).ToList();
session.Events.Append(streamId, agg.SetPermissions(stripped));
await session.SaveChangesAsync(ct);
return (stripped, agg.Requirements.ToList());
}

private async Task<HttpStatusCode> PostParAsync(string clientId, string clientSecret)
{
var verifier = GeneratePkceVerifier();
var form = new Dictionary<string, string>
{
["response_type"] = "code",
["client_id"] = clientId,
["redirect_uri"] = "https://app.example/cb",
["scope"] = "openid",
["code_challenge"] = GeneratePkceS256Challenge(verifier),
["code_challenge_method"] = "S256",
};
var client = Factory.CreateClient();
using var req = new HttpRequestMessage(HttpMethod.Post, "/connect/par")
{
Content = new FormUrlEncodedContent(form),
};
req.Headers.Authorization = new AuthenticationHeaderValue(
"Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{clientId}:{clientSecret}")));
var resp = await client.SendAsync(req, TestContext.Current.CancellationToken);
return resp.StatusCode;
}

private async Task CreateConfidentialClientAsync(
string clientId, string clientSecret, string redirectUri, List<string> scopes, bool requirePar = false)
{
using var scope = Factory.Services.CreateScope();
var oauthAdmin = scope.ServiceProvider.GetRequiredService<OAuthAdminService>();
var dto = new CreateOAuthClientDto
{
ClientId = clientId,
ClientSecret = clientSecret,
ClientType = OAuthClientTypes.Confidential,
ConsentType = OAuthConsentTypes.Implicit,
DisplayName = clientId,
RedirectUris = [redirectUri],
PostLogoutRedirectUris = [],
Scopes = scopes,
AllowedGrantTypes = ["authorization_code", "refresh_token"],
RequireConsent = false,
AccessTokenType = AccessTokenType.Jwt,
RequirePushedAuthorizationRequests = requirePar,
};
var result = await oauthAdmin.CreateClientAsync(dto, TestContext.Current.CancellationToken);
if (result.IsError)
throw new InvalidOperationException(
$"CreateClientAsync failed: {string.Join(", ", result.Errors.Select(e => $"{e.Code}: {e.Description}"))}");
}

private static string GeneratePkceVerifier()
{
Span<byte> bytes = stackalloc byte[32];
RandomNumberGenerator.Fill(bytes);
return Base64UrlEncode(bytes);
}

private static string GeneratePkceS256Challenge(string verifier)
=> Base64UrlEncode(SHA256.HashData(Encoding.ASCII.GetBytes(verifier)));

private static string Base64UrlEncode(ReadOnlySpan<byte> bytes)
=> Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
5 changes: 5 additions & 0 deletions src/dotnet/Modgud.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,11 @@
// renames a realm's legacy "Administratoren" admin group to "Administrators".
// Same cold-start-walks-every-realm pattern as OidcSchemeBootstrap below.
builder.Services.AddHostedService<Modgud.Authentication.Setup.LegacyAdminGroupRenameBootstrap>();
// RFC 9126 (2026-07) — one-time, idempotent boot migration that backfills the
// ept:pushed_authorization endpoint permission onto pre-existing OAuth clients
// so they can use the advertised /connect/par endpoint (fixes unauthorized_client
// / ID2183 without a manual re-save). Same cold-start-walks-every-realm pattern.
builder.Services.AddHostedService<Modgud.Authentication.Setup.PushedAuthorizationPermissionBackfill>();
builder.Services.AddSingleton<UserUpdateScriptRunner>();
builder.Services.AddSingleton<Modgud.Authentication.Api.ExternalAuth.OidcSchemeRealmRegistry>();
builder.Services.AddSingleton<DynamicOidcSchemeManager>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using Marten;
using Modgud.Domain.OAuth.Applications;
using Modgud.Domain.OAuth.Common;
using Modgud.Infrastructure.Persistence.Tenancy;
using Modgud.Infrastructure.Realms;

namespace Modgud.Authentication.Setup;

/// <summary>
/// One-time, idempotent boot migration (2026-07): backfills the RFC-9126 PAR
/// endpoint permission <c>ept:pushed_authorization</c> onto every existing
/// OAuth client that predates it.
///
/// <para>Since Modgud advertises <c>/connect/par</c> in discovery, current
/// .NET OIDC clients call it by default (<c>PushedAuthorizationBehavior.UseIfAvailable</c>).
/// <c>OAuthAdminMapping.BuildClientPermissions()</c> grants the permission as a
/// baseline, but only to clients that are created or whose permissions are
/// recalculated (a grant/scope change, or an unrelated re-save). Clients
/// persisted before that change kept their old permission list and failed the
/// PAR challenge with <c>unauthorized_client</c> / OpenIddict <c>ID2183</c>
/// until an admin re-saved them. This closes that upgrade gap.</para>
///
/// <para>Same cold-start-walks-every-realm shape as
/// <see cref="LegacyAdminGroupRenameBootstrap"/>: re-enter <see cref="TenantContext"/>
/// per realm so the tenant-scoped <see cref="IDocumentSession"/> reads/writes the
/// correct database, and append directly to the event stream (no Wolverine bus,
/// so tenant DBs never need the durable-inbox tables). Naturally idempotent — a
/// repeat boot finds every client already carrying the permission and appends
/// nothing.</para>
///
/// <para>Only <see cref="OAuthApplicationAggregate.Permissions"/> is touched, and
/// only by <em>adding</em> the one missing entry. The PAR <em>requirement</em>
/// (<c>ft:par</c> / <c>RequirePushedAuthorizationRequests</c>) lives in the
/// separate <see cref="OAuthApplicationAggregate.Requirements"/> list and is left
/// exactly as-is — this grants "may use PAR", never "must use PAR".</para>
/// </summary>
public class PushedAuthorizationPermissionBackfill(
IServiceScopeFactory scopeFactory,
IRealmCache realmCache,
ILogger<PushedAuthorizationPermissionBackfill> logger) : IHostedService
{
private const string ParPermission = OAuthPermissions.Endpoints.PushedAuthorization;

public async Task StartAsync(CancellationToken cancellationToken)
{
var realms = await realmCache.GetAllActiveAsync();
var totalBackfilled = 0;

foreach (var realm in realms)
{
try
{
using var _ = TenantContext.Enter(realm.Slug);
using var scope = scopeFactory.CreateScope();
var session = scope.ServiceProvider.GetRequiredService<IDocumentSession>();

// The projection carries the authoritative permission set, so we
// only aggregate + append for streams that actually lack it — a
// repeat boot returns zero candidates and does no stream work.
var candidates = await session.Query<OAuthApplicationState>()
.Where(x => !x.IsDeleted)
.ToListAsync(cancellationToken);

var backfilled = 0;
foreach (var state in candidates)
{
if (state.Permissions.Contains(ParPermission)) continue;

var aggregate = await session.Events
.AggregateStreamAsync<OAuthApplicationAggregate>(state.Id, token: cancellationToken);
// Re-check on the authoritative aggregate; skip deleted streams.
if (aggregate is null || aggregate.IsDeleted) continue;
if (aggregate.Permissions.Contains(ParPermission)) continue;

// Add-only: every existing permission (grants, scopes, response
// types, other endpoints) is carried through unchanged.
var permissions = new List<string>(aggregate.Permissions) { ParPermission };
session.Events.Append(state.Id, aggregate.SetPermissions(permissions));
backfilled++;
}

if (backfilled > 0)
{
await session.SaveChangesAsync(cancellationToken);
totalBackfilled += backfilled;
logger.LogInformation(
"Backfilled '{Perm}' on {Count} OAuth client(s) in realm {Realm}",
ParPermission, backfilled, realm.Slug);
}
}
catch (Exception ex)
{
logger.LogError(ex,
"PAR permission backfill failed for realm {Realm} — it self-heals on the next boot",
realm.Slug);
}
}

if (totalBackfilled > 0)
{
logger.LogInformation(
"PushedAuthorizationPermissionBackfill added '{Perm}' to {Count} client(s) across {Realms} realm(s)",
ParPermission, totalBackfilled, realms.Count);
}
}

public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
Loading