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,66 @@
using System.Text;
using System.Text.Json;
using Modgud.Api.Tests.Infrastructure;

namespace Modgud.Api.Tests.Authorization;

/// <summary>
/// Regression for the per-client RP-ID origin mismatch: when the per-client
/// <c>WebAuthnRpId</c> is a registrable SUFFIX of the app origin (RP-ID
/// <c>a.localhost</c>, page on <c>sub.a.localhost</c>), enrollment used to fail with
/// 400 "Passkey enrollment failed." because the accepted origin was derived as
/// <c>https://{rpId}</c>. The fix accepts the signed origin when it is the RP-ID host
/// or a subdomain of it.
/// </summary>
public partial class CocoarPasskeyGrantFlowTests
{
[Fact]
public async Task NativeEnroll_SignedOriginIsSubdomainOfClientRpId_Succeeds()
{
await EnableNativeGrantsAsync();
await SeedPasskeyClientAsync("app-a", rpId: "a.localhost");

// Bootstrap a login → an app-a access token (existing seeded credential).
using var bootstrap = new SoftwareWebAuthnAuthenticator(DefaultUser!.Id.ToByteArray());
await SeedCredentialAsync(bootstrap.CredentialId, bootstrap.CosePublicKey(), bootstrap.UserHandle, rpId: "a.localhost");
var (bootCeremony, bootChallenge, bootRpId) = await BeginAsync(clientId: "app-a");
var bootAssertion = bootstrap.CreateAssertionJson(bootChallenge, bootRpId, $"https://{bootRpId}");
var tokenResp = await PostPasskeyAsync("app-a", bootCeremony, bootAssertion);
var tokenBody = await tokenResp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(tokenResp.IsSuccessStatusCode, $"bootstrap login failed ({(int)tokenResp.StatusCode}): {tokenBody}");
var accessToken = JsonDocument.Parse(tokenBody).RootElement.GetProperty("access_token").GetString()!;

var bearer = Factory.CreateClient();
bearer.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);

// Enroll begin → options pinned to the client's RP-ID a.localhost.
var beginResp = await bearer.PostAsync("/connect/passkey/enroll/begin", content: null, TestContext.Current.CancellationToken);
var beginBody = await beginResp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(beginResp.IsSuccessStatusCode, $"enroll/begin failed ({(int)beginResp.StatusCode}): {beginBody}");
using var beginJson = JsonDocument.Parse(beginBody);
var enrollCeremonyId = beginJson.RootElement.GetProperty("ceremonyId").GetString()!;
var opts = beginJson.RootElement.GetProperty("options");
var enrollChallenge = opts.GetProperty("challenge").GetString()!;
var enrollRpId = opts.GetProperty("rp").GetProperty("id").GetString()!;
Assert.Equal("a.localhost", enrollRpId);

// The credential is created on a SUBDOMAIN of the RP-ID — the case that broke.
using var enrolling = new SoftwareWebAuthnAuthenticator(Encoding.UTF8.GetBytes(DefaultUser!.Id.ToString()));
var origin = $"https://sub.{enrollRpId}"; // https://sub.a.localhost — a registrable suffix of a.localhost
var attestation = enrolling.CreateAttestationJson(enrollChallenge, enrollRpId, origin);
var enrollReqBody = $"{{\"ceremonyId\":\"{enrollCeremonyId}\",\"attestation\":{attestation}}}";

var enrollResp = await bearer.PostAsync("/connect/passkey/enroll",
new StringContent(enrollReqBody, Encoding.UTF8, "application/json"),
TestContext.Current.CancellationToken);

var enrollRespBody = await enrollResp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(enrollResp.IsSuccessStatusCode,
$"enroll from subdomain origin failed ({(int)enrollResp.StatusCode}): {enrollRespBody}");

// Stored under the client's RP-ID (a.localhost), not the subdomain.
var stored = await LoadCredentialAsync(enrolling.CredentialId);
Assert.NotNull(stored);
Assert.Equal("a.localhost", stored!.RpId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -166,12 +166,23 @@ public static WebApplication MapNativePasskeyEnrollEndpoints(this WebApplication
session.Delete(ceremony);
await session.SaveChangesAsync(ct);

// Parse the attestation up-front so the origin the authenticator actually
// signed can scope the relying party (below) — a per-client RP ID that is
// a registrable suffix of the app origin (RP-ID amzettel.at for a page on
// app.amzettel.at) is then accepted instead of failing the origin check.
var attestation = JsonSerializer.Deserialize<AuthenticatorAttestationRawResponse>(
attEl.GetRawText(), new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (attestation is null)
return Results.BadRequest(new { Message = "Invalid attestation response." });

IFido2 fido2;
try
{
// Pinned RP ID from begin — never re-resolved (admin edits mid-ceremony
// cannot drift the attestation's RP ID).
fido2 = await fido2Factory.CreateAsync(ct, rpIdOverride: ceremony.RpId);
// cannot drift the attestation's RP ID). The accepted origin is the
// signed one, filtered to this RP-ID's own subdomains in BuildConfiguration.
fido2 = await fido2Factory.CreateAsync(ct, rpIdOverride: ceremony.RpId,
additionalOrigins: PresentedOrigins(attestation.Response?.ClientDataJson));
}
catch (RelyingPartyUnavailableException ex)
{
Expand All @@ -188,11 +199,6 @@ public static WebApplication MapNativePasskeyEnrollEndpoints(this WebApplication
return Results.BadRequest(new { Message = "Enrollment session expired. Please try again." });
}

var attestation = JsonSerializer.Deserialize<AuthenticatorAttestationRawResponse>(
attEl.GetRawText(), new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (attestation is null)
return Results.BadRequest(new { Message = "Invalid attestation response." });

RegisteredPublicKeyCredential credential;
try
{
Expand All @@ -210,10 +216,16 @@ public static WebApplication MapNativePasskeyEnrollEndpoints(this WebApplication
},
}, ct);
}
catch
catch (Exception ex)
{
// Fail closed on any attestation failure (bad/forged response,
// duplicate credential) — never a 500.
// duplicate credential, origin/RP-ID mismatch) — never a 500. Log the
// reason into the per-realm error feed so an origin mismatch is
// diagnosable instead of surfacing as a reasonless 400.
context.RequestServices.GetRequiredService<ILoggerFactory>()
.CreateLogger(LoggerCategory)
.LogWarning(ex, "Native passkey enrollment verification failed (UserId={UserId}, RpId={RpId}).",
user!.Id, ceremony.RpId);
return Results.BadRequest(new { Message = "Passkey enrollment failed." });
}

Expand Down Expand Up @@ -248,6 +260,12 @@ public static WebApplication MapNativePasskeyEnrollEndpoints(this WebApplication
return application;
}

/// <summary>The single signed origin (if any) read from the attestation's
/// clientDataJSON, as a one-element array for <c>CreateAsync(additionalOrigins:)</c>.
/// <c>null</c> when absent/malformed — then only the RP-ID host is accepted.</summary>
private static string[]? PresentedOrigins(byte[]? clientDataJson)
=> RealmFido2.TryGetClientDataOrigin(clientDataJson) is { } origin ? [origin] : null;

private static IResult RpUnavailable(HttpContext context, RelyingPartyUnavailableException ex)
{
context.RequestServices.GetRequiredService<ILoggerFactory>()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Security.Claims;
using System.Text.Json;
using Modgud.Authentication.Applications;
using Modgud.Authentication.Domain;
using Modgud.Authentication.Identity;
Expand Down Expand Up @@ -820,10 +821,24 @@ private static async Task<IResult> ExchangeNativePasskeyAsync(
var primaryDomain = await rpIdResolver.GetPrimaryDomainAsync(ct);
var activeRpId = string.IsNullOrWhiteSpace(ceremony.RpId) ? primaryDomain : ceremony.RpId;

// Accept the origin the authenticator actually signed (scoped in
// BuildConfiguration to this RP-ID's own subdomains) so a per-client RP-ID
// that is a registrable suffix of the app origin still verifies. Malformed
// input yields no extra origin; the shared verifier then fails closed.
string[]? presentedOrigins = null;
try
{
var assertion = JsonSerializer.Deserialize<AuthenticatorAssertionRawResponse>(
assertionJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (RealmFido2.TryGetClientDataOrigin(assertion?.Response?.ClientDataJson) is { } origin)
presentedOrigins = [origin];
}
catch (JsonException) { /* leave null — verifier fails closed below */ }

IFido2 fido2;
try
{
fido2 = await fido2Factory.CreateAsync(ct, rpIdOverride: activeRpId);
fido2 = await fido2Factory.CreateAsync(ct, rpIdOverride: activeRpId, additionalOrigins: presentedOrigins);
}
catch (RelyingPartyUnavailableException)
{
Expand Down
76 changes: 73 additions & 3 deletions src/dotnet/Modgud.Authentication/Identity/RealmFido2.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text.Json;
using Fido2NetLib;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
Expand Down Expand Up @@ -36,8 +37,23 @@ public static class RealmFido2
/// SPA dev-server origin (<c>http://{PrimaryDomain}:4300</c>) since that is
/// where the WebAuthn ceremony runs in dev. <c>localhost</c> /
/// <c>*.localhost</c> are browser secure-contexts, so dev passkeys work.
///
/// <para><paramref name="additionalOrigins"/> — origins the authenticator
/// actually signed (read from the ceremony's clientDataJSON). Each is accepted
/// ONLY if it is the RP-ID host or a subdomain of it (see
/// <see cref="IsOriginUnderRpId"/>). This is what makes a per-client RP-ID that
/// is a registrable SUFFIX of the app origin work: RP-ID <c>amzettel.at</c> for a
/// page on <c>app.amzettel.at</c> is spec-valid (the browser only ever presents
/// an origin whose effective domain has the RP-ID as a suffix), but deriving the
/// accepted origin as <c>https://{rpId}</c> wrongly rejected it. The RP-ID hash
/// and signature checks remain the primary boundary; this only widens the origin
/// allow-list to the set the WebAuthn spec already scopes to this RP-ID.</para>
/// </summary>
public static Fido2Configuration BuildConfiguration(Realm realm, IWebHostEnvironment env, string? rpIdOverride = null)
public static Fido2Configuration BuildConfiguration(
Realm realm,
IWebHostEnvironment env,
string? rpIdOverride = null,
IEnumerable<string>? additionalOrigins = null)
{
ArgumentNullException.ThrowIfNull(realm);
ArgumentNullException.ThrowIfNull(env);
Expand Down Expand Up @@ -74,6 +90,17 @@ public static Fido2Configuration BuildConfiguration(Realm realm, IWebHostEnviron
origins.Add($"https://{host}");
}

// Widen the accepted origins to the actual signed origin(s), but only those
// genuinely under this RP-ID — never a foreign host.
if (additionalOrigins is not null)
{
foreach (var origin in additionalOrigins)
{
if (IsOriginUnderRpId(origin, host, env.IsDevelopment()))
origins.Add(origin);
}
}

return new Fido2Configuration
{
// RP ID — the effective domain a passkey is bound to.
Expand All @@ -82,6 +109,44 @@ public static Fido2Configuration BuildConfiguration(Realm realm, IWebHostEnviron
Origins = origins,
};
}

/// <summary>
/// True when <paramref name="origin"/> is an absolute <c>https</c> URL (also
/// <c>http</c> when <paramref name="allowInsecure"/>, i.e. dev) whose host equals
/// <paramref name="rpId"/> or is a subdomain of it — exactly the set of origins
/// WebAuthn already scopes to this RP-ID. The dotted-suffix test (<c>host</c> ends
/// with <c>"." + rpId</c>) deliberately rejects look-alikes like
/// <c>amzettel.at.evil.com</c> and <c>evilamzettel.at</c>.
/// </summary>
public static bool IsOriginUnderRpId(string? origin, string? rpId, bool allowInsecure)
{
if (string.IsNullOrWhiteSpace(origin) || string.IsNullOrWhiteSpace(rpId)) return false;
if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri)) return false;
if (uri.Scheme != Uri.UriSchemeHttps && !(allowInsecure && uri.Scheme == Uri.UriSchemeHttp))
return false;
return string.Equals(uri.Host, rpId, StringComparison.OrdinalIgnoreCase)
|| uri.Host.EndsWith("." + rpId, StringComparison.OrdinalIgnoreCase);
}

/// <summary>
/// Extracts the WebAuthn <c>origin</c> from a clientDataJSON byte payload (the
/// value Fido2NetLib already base64url-decoded onto the raw response). Returns
/// <c>null</c> on any malformed input — the caller then simply passes no extra
/// origin and the verify fails closed as before.
/// </summary>
public static string? TryGetClientDataOrigin(byte[]? clientDataJson)
{
if (clientDataJson is null || clientDataJson.Length == 0) return null;
try
{
using var doc = JsonDocument.Parse(clientDataJson);
return doc.RootElement.TryGetProperty("origin", out var o) ? o.GetString() : null;
}
catch (JsonException)
{
return null;
}
}
}

/// <summary>
Expand All @@ -97,7 +162,10 @@ public sealed class RealmScopedFido2Factory(
IWebHostEnvironment env,
IMetadataService? metadataService = null)
{
public async Task<IFido2> CreateAsync(CancellationToken ct = default, string? rpIdOverride = null)
public async Task<IFido2> CreateAsync(
CancellationToken ct = default,
string? rpIdOverride = null,
IEnumerable<string>? additionalOrigins = null)
{
var http = httpContextAccessor.HttpContext
?? throw new InvalidOperationException(
Expand All @@ -110,7 +178,9 @@ public async Task<IFido2> CreateAsync(CancellationToken ct = default, string? rp
// ADR-0009 seam: rpIdOverride is null for every Phase-2 caller (RP-ID stays
// realm.PrimaryDomain). Threaded through so the Phase-3 per-client RP-ID
// only has to supply the value here — no new RP-ID code path.
var config = RealmFido2.BuildConfiguration(realm, env, rpIdOverride);
// additionalOrigins carries the actual signed origin at verify time so a
// per-client RP-ID that is a suffix of the app origin is accepted.
var config = RealmFido2.BuildConfiguration(realm, env, rpIdOverride, additionalOrigins);
// metadataService is optional — the previous global setup used the
// library's NullMetadataService (no attestation-metadata validation),
// and passing null here gives the identical behaviour.
Expand Down
Loading
Loading