Bifrost/tests/Bifrost.Web.Tests/PasskeyScriptTests.cs
Heimdall dad45faeac Passkeys: Fido2NetLib, credential store, enrolment behind a fresh sign-in, assertion and app-asserted step-up for unlink
Heimdall-backed members enrol WebAuthn passkeys on the Linked accounts page (ticket from a fresh ordinary Heimdall sign-in, 48 h cooling-off, cap 5,
FixedWindowBudget on ceremonies) and unlink with a strong passkey: the assertion is verified at Bifrost, then inside the completion that holds the bearer
Bifrost calls Heimdall's POST /v1/apps/bifrost/step-up and the unlink with the returned token. The Google step-up closes once a strong passkey exists.
Migration AddPasskeyCredentials is additive. Tests use a software authenticator and a fake Heimdall step-up route modeled on the contract.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 20:48:37 +00:00

99 lines
4.7 KiB
C#

using System.Diagnostics;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Bifrost.Web.Tests;
/// <summary>
/// Runs the page's first-party passkey script (src/Bifrost.Web/wwwroot/js/passkeys.js) under Node against a simulated browser
/// (Fixtures/passkey-script-harness.cjs) and pins what it posts: the antiforgery header, base64url strings only, and no navigation
/// text that did not come from a closed code. Node is required: a missing Node fails these tests.
/// </summary>
public sealed partial class PasskeyScriptTests
{
private sealed record Fetch(string Url, string Method, Dictionary<string, string> Headers, string Body, string Credentials);
private sealed record Call(string Name, Fetch[] Fetches, JsonElement CredentialCall, string[] Navigations, Dictionary<string, string>? Submitted, string Status);
[GeneratedRegex("^[A-Za-z0-9_-]*$")]
private static partial Regex Base64Url();
private static string ScriptPath()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "Bifrost.slnx")))
{
dir = dir.Parent;
}
return Path.Combine(dir!.FullName, "src", "Bifrost.Web", "wwwroot", "js", "passkeys.js");
}
private static async Task<Dictionary<string, Call>> RunAsync()
{
var start = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true };
start.ArgumentList.Add(Path.Combine(AppContext.BaseDirectory, "Fixtures", "passkey-script-harness.cjs"));
start.ArgumentList.Add(ScriptPath());
using var process = Process.Start(start)!;
var output = process.StandardOutput.ReadToEndAsync();
var errors = process.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
await process.WaitForExitAsync(timeout.Token);
Assert.True(process.ExitCode == 0, await errors);
return JsonSerializer.Deserialize<Call[]>(await output, new JsonSerializerOptions(JsonSerializerDefaults.Web))!.ToDictionary(x => x.Name);
}
[Fact]
public async Task Enrolment_posts_the_antiforgery_header_to_this_page_and_only_base64url_fields_after_the_ceremony()
{
var run = (await RunAsync())["enrol"];
var fetch = Assert.Single(run.Fetches);
Assert.Equal("/Membership/LinkedAccounts?handler=PasskeyEnrolOptions", fetch.Url);
Assert.Equal("POST", fetch.Method);
Assert.Equal("same-origin", fetch.Credentials);
Assert.Equal("TOKEN", fetch.Headers["RequestVerificationToken"]);
Assert.Equal("", fetch.Body);
Assert.True(run.CredentialCall.GetProperty("challengeIsBuffer").GetBoolean());
Assert.True(run.CredentialCall.GetProperty("listIdsAreBuffers").GetBoolean());
Assert.Equal([4, 5, 6], run.CredentialCall.GetProperty("userIdBytes").EnumerateArray().Select(x => x.GetInt32()));
Assert.Equal("CRED-ID", run.Submitted!["id"]);
Assert.Equal("-__-", run.Submitted["rawId"]); // 251,255,254 in base64url without padding
Assert.Matches(Base64Url(), run.Submitted["attestationObject"]);
Assert.Matches(Base64Url(), run.Submitted["clientDataJson"]);
Assert.Equal("internal,hybrid", run.Submitted["transports"]);
Assert.Empty(run.Navigations);
}
[Fact]
public async Task Unlinking_asks_for_a_challenge_for_the_chosen_identity_only_and_posts_the_assertion_as_base64url()
{
var run = (await RunAsync())["unlink"];
var fetch = Assert.Single(run.Fetches);
Assert.Equal("/Membership/LinkedAccounts?handler=PasskeyChallenge", fetch.Url);
Assert.Equal("identityId=IDENTITY", fetch.Body);
Assert.Equal("TOKEN", fetch.Headers["RequestVerificationToken"]);
Assert.True(run.CredentialCall.GetProperty("listIdsAreBuffers").GetBoolean());
foreach (var name in new[] { "rawId", "authenticatorData", "clientDataJson", "signature", "userHandle" })
{
Assert.Matches(Base64Url(), run.Submitted![name]);
Assert.NotEmpty(run.Submitted[name]);
}
Assert.Equal("IDENTITY", run.Submitted!["identityId"]);
Assert.Empty(run.Navigations);
}
[Theory]
[InlineData("refused", "?result=enrol_needs_sign_in")]
[InlineData("hostile-error", "?result=passkey_failed")]
[InlineData("cancelled", "?result=passkey_failed")]
public async Task A_refusal_or_a_cancelled_ceremony_submits_nothing_and_navigates_only_to_a_closed_result_code(string scenario, string expected)
{
var run = (await RunAsync())[scenario];
Assert.Null(run.Submitted);
Assert.Equal(["/Membership/LinkedAccounts" + expected], run.Navigations);
}
}