feat!: drop the backend-side elevation windows; password and passkey sessions share capabilities, passkeys guard what nodes verify; the trust page lists added and removed passkeys [CI]
This commit is contained in:
1 parent
f704c66dd9
commit
4106f831dc
34 files changed
+191
-1364
No files matched your search
@@ -275,7 +275,7 @@ public class BackendFixture : IAsyncLifetime
|
||||
|
||||
/// <summary>
|
||||
/// 管理员的全能力 API key:和线上一样的路径——密码登录的会话没有任何能力,只能注册 passkey;
|
||||
/// 注册之后用 passkey 登录才有能力,再触碰一次开凭证窗口,才能创建带信任类能力的 key。
|
||||
/// 注册之后用 passkey 登录(要签操作员集合等节点会验证的事时才需要 passkey)。
|
||||
/// </summary>
|
||||
private async Task<string> CreateAdminTokenAsync()
|
||||
{
|
||||
@@ -306,15 +306,6 @@ public class BackendFixture : IAsyncLifetime
|
||||
strong.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await signedIn.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
using (HttpResponseMessage options = await strong.PostAsync("/api/admin/auth/elevation/options", Json(new JsonObject { ["class"] = "credential" })))
|
||||
{
|
||||
options.EnsureSuccessStatusCode();
|
||||
JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>());
|
||||
assertion["class"] = "credential";
|
||||
using HttpResponseMessage elevated = await strong.PostAsync("/api/admin/auth/elevation", Json(assertion));
|
||||
elevated.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
using HttpResponseMessage created = await strong.PostAsync("/api/admin/api-tokens", Json(new JsonObject { ["name"] = "tests", ["capabilities"] = AllCapabilities() }));
|
||||
created.EnsureSuccessStatusCode();
|
||||
return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>提权窗口:信任类操作(加节点、吊销、改凭证)要在登录之外再触碰一次 passkey;API key 自带能力不受影响。设计见 docs/access.md 第 4 节。</summary>
|
||||
[Collection("probe-e2e")]
|
||||
public sealed class ElevationTests
|
||||
{
|
||||
private const string Password = "correct horse battery";
|
||||
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
public ElevationTests(BackendFixture backend)
|
||||
{
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
private string Origin => _backend.BaseAddress.GetLeftPart(UriPartial.Authority);
|
||||
|
||||
private string RpId => _backend.BaseAddress.Host;
|
||||
|
||||
/// <summary>新建一个用户并用密码登录(会话令牌走 Bearer)。每个测试用自己的用户,互不影响。</summary>
|
||||
private async Task<HttpClient> NewSessionAsync()
|
||||
{
|
||||
string name = _backend.UniqueName("elev");
|
||||
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password }));
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
HttpClient client = _backend.NewClient();
|
||||
using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password, ["return_session"] = true }));
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
|
||||
return client;
|
||||
}
|
||||
|
||||
private async Task<(HttpClient, SoftwarePasskey)> StrongAsync()
|
||||
{
|
||||
(HttpClient client, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("elev");
|
||||
return (client, passkey);
|
||||
}
|
||||
|
||||
private async Task<SoftwarePasskey> RegisterAsync(HttpClient session, string name = "key")
|
||||
{
|
||||
var passkey = new SoftwarePasskey(RpId, Origin);
|
||||
using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null);
|
||||
Assert.True(options.IsSuccessStatusCode, await options.Content.ReadAsStringAsync());
|
||||
string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>();
|
||||
using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", BackendFixture.Json(passkey.Register(name, challenge)));
|
||||
Assert.True(registered.StatusCode == HttpStatusCode.Created, await registered.Content.ReadAsStringAsync());
|
||||
return passkey;
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> ElevateAsync(HttpClient session, SoftwarePasskey passkey, string cls, string? optionsFor = null)
|
||||
{
|
||||
using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = optionsFor ?? cls }));
|
||||
Assert.True(options.IsSuccessStatusCode, await options.Content.ReadAsStringAsync());
|
||||
JsonNode body = JsonNode.Parse(await options.Content.ReadAsStringAsync())!;
|
||||
Assert.Contains(passkey.Id, body["allow_credentials"]!.AsArray().Select(x => x!.GetValue<string>())); // 浏览器只会让用户选自己的 passkey
|
||||
JsonObject assertion = passkey.Assert(body["challenge"]!.GetValue<string>());
|
||||
assertion["class"] = cls;
|
||||
return await session.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(assertion));
|
||||
}
|
||||
|
||||
private static async Task<string?> ProblemCodeAsync(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())?["code"]?.GetValue<string>();
|
||||
|
||||
/// <summary>需要「吊销」窗口的操作(删除节点):有窗口时返回 204(删除是幂等的),没有窗口返回 403 elevation_required。</summary>
|
||||
private static Task<HttpResponseMessage> Gated(HttpClient client) => client.DeleteAsync("/api/admin/agents/agt_nobody");
|
||||
|
||||
[Fact]
|
||||
public async Task EnrollingNeedsAWindow_ThatOnlyAPasskeyTouchOpens()
|
||||
{
|
||||
(HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
|
||||
using HttpClient sessionScope = session;
|
||||
using SoftwarePasskey passkeyScope = passkey;
|
||||
|
||||
using HttpResponseMessage denied = await Gated(session);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode);
|
||||
Assert.Equal("elevation_required", await ProblemCodeAsync(denied));
|
||||
Assert.Contains("revoke", await denied.Content.ReadAsStringAsync(), StringComparison.Ordinal);
|
||||
|
||||
using HttpResponseMessage elevated = await ElevateAsync(session, passkey, "revoke");
|
||||
Assert.Equal(HttpStatusCode.OK, elevated.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await Gated(session)).StatusCode); // 有窗口:到了业务层(删除是幂等的)
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await Gated(session)).StatusCode); // 窗口内不用再触碰
|
||||
|
||||
// 窗口只管它那一种
|
||||
using HttpResponseMessage credential = await session.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _backend.UniqueName("x"), ["password"] = Password }));
|
||||
Assert.Equal("elevation_required", await ProblemCodeAsync(credential));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApiKeys_CarryTheirCapabilities_AndSkipTheWindow()
|
||||
{
|
||||
using HttpResponseMessage allowed = await Gated(_backend.Admin);
|
||||
Assert.Equal(HttpStatusCode.NoContent, allowed.StatusCode); // API key 自带能力,不经过提权窗口
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheWindowCanBeListed_AndEndedEarly()
|
||||
{
|
||||
(HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
|
||||
using HttpClient sessionScope = session;
|
||||
using SoftwarePasskey passkeyScope = passkey;
|
||||
Assert.Empty(JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray());
|
||||
|
||||
(await ElevateAsync(session, passkey, "revoke")).Dispose();
|
||||
JsonArray open = JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray();
|
||||
Assert.Equal("revoke", Assert.Single(open)!["class"]!.GetValue<string>());
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await session.DeleteAsync("/api/admin/auth/elevation")).StatusCode);
|
||||
Assert.Empty(JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray());
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await Gated(session)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ACredentialWindow_IsSingleUse()
|
||||
{
|
||||
(HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
|
||||
using HttpClient sessionScope = session;
|
||||
using SoftwarePasskey passkeyScope = passkey;
|
||||
|
||||
(await ElevateAsync(session, passkey, "credential")).Dispose();
|
||||
using HttpResponseMessage first = await session.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _backend.UniqueName("made"), ["password"] = Password }));
|
||||
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
|
||||
using HttpResponseMessage second = await session.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _backend.UniqueName("made"), ["password"] = Password }));
|
||||
Assert.Equal("elevation_required", await ProblemCodeAsync(second));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnlyYourOwnPasskey_WithTheRightChallengeAndKind_OpensAWindow()
|
||||
{
|
||||
(HttpClient mine, SoftwarePasskey myPasskey) = await StrongAsync();
|
||||
(HttpClient theirs, SoftwarePasskey theirPasskey) = await StrongAsync();
|
||||
|
||||
// 别人的 passkey 签了我的挑战:不行(也不在 allow_credentials 里)
|
||||
using HttpResponseMessage options = await mine.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "enroll" }));
|
||||
string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>();
|
||||
JsonObject foreign = theirPasskey.Assert(challenge);
|
||||
foreign["class"] = "enroll";
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await mine.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(foreign))).StatusCode);
|
||||
|
||||
// 挑战是给 revoke 要的,提交成 enroll:不行
|
||||
using HttpResponseMessage wrongKind = await ElevateAsync(mine, myPasskey, "enroll", optionsFor: "revoke");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, wrongKind.StatusCode);
|
||||
|
||||
// 挑战只能用一次
|
||||
using HttpResponseMessage options2 = await mine.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "enroll" }));
|
||||
string challenge2 = JsonNode.Parse(await options2.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>();
|
||||
JsonObject once = myPasskey.Assert(challenge2);
|
||||
once["class"] = "enroll";
|
||||
Assert.Equal(HttpStatusCode.OK, (await mine.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(once))).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await mine.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(once))).StatusCode);
|
||||
|
||||
// 另一个用户的会话没有因此提权
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await Gated(theirs)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WithoutAPasskey_ThereIsNothingToTouch_AndTheFirstPasskeyNeedsNoWindow()
|
||||
{
|
||||
using HttpClient session = await NewSessionAsync();
|
||||
using HttpResponseMessage none = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "enroll" }));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, none.StatusCode);
|
||||
|
||||
using SoftwarePasskey first = await RegisterAsync(session, "first"); // 第一个:不要求提权
|
||||
using HttpResponseMessage second = await session.PostAsync("/api/admin/auth/passkeys/options", null);
|
||||
Assert.Equal("elevation_required", await ProblemCodeAsync(second)); // 第二个起要先用已有的触碰
|
||||
(await ElevateAsync(session, first, "credential")).Dispose();
|
||||
using SoftwarePasskey added = await RegisterAsync(session, "second");
|
||||
|
||||
// 删除也要窗口
|
||||
(await ElevateAsync(session, first, "credential")).Dispose();
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await session.DeleteAsync("/api/admin/auth/passkeys/" + added.Id)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await session.DeleteAsync("/api/admin/auth/passkeys/" + first.Id)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AKeyThatCarriesTrust_NeedsAPasskeySignatureToCreate()
|
||||
{
|
||||
(HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
|
||||
using HttpClient sessionScope = session;
|
||||
using SoftwarePasskey passkeyScope = passkey;
|
||||
var trusted = new JsonObject { ["name"] = _backend.UniqueName("ci"), ["capabilities"] = new JsonArray("fleet.revoke") };
|
||||
var harmless = new JsonObject { ["name"] = _backend.UniqueName("ro"), ["capabilities"] = new JsonArray("fleet.read") };
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/api-tokens", BackendFixture.Json(harmless))).StatusCode);
|
||||
using HttpResponseMessage denied = await session.PostAsync("/api/admin/api-tokens", BackendFixture.Json(trusted));
|
||||
Assert.Equal("elevation_required", await ProblemCodeAsync(denied));
|
||||
(await ElevateAsync(session, passkey, "credential")).Dispose();
|
||||
Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/api-tokens", BackendFixture.Json(trusted))).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListingUsers_NeedsOnlyTheReadCapability()
|
||||
{
|
||||
(HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
|
||||
using HttpClient sessionScope = session;
|
||||
using SoftwarePasskey passkeyScope = passkey;
|
||||
Assert.Equal(HttpStatusCode.OK, (await session.GetAsync("/api/admin/users")).StatusCode); // 看列表不用提权
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,7 @@ public sealed class PasskeyTests
|
||||
|
||||
private string _user = "";
|
||||
|
||||
/// <summary>每个测试用自己新建的用户:管理员已经有 fixture 的 passkey,再加就要提权,而这里测的是第一个 passkey 的流程。</summary>
|
||||
/// <summary>每个测试用自己新建的用户:管理员已经有 fixture 的 passkey,而这里测的是第一个 passkey 的流程。</summary>
|
||||
private async Task<HttpClient> FreshSessionAsync()
|
||||
{
|
||||
_user = _backend.UniqueName("pk");
|
||||
@@ -121,16 +121,6 @@ public sealed class PasskeyTests
|
||||
return await PasswordSessionAsync(_user, "correct horse battery");
|
||||
}
|
||||
|
||||
/// <summary>用已有的 passkey 触碰一次,开凭证窗口(删除 passkey 需要)。</summary>
|
||||
private static async Task ElevateAsync(HttpClient session, SoftwarePasskey passkey)
|
||||
{
|
||||
using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "credential" }));
|
||||
JsonObject assertion = passkey.Assert((await JsonOf(options))["challenge"]!.GetValue<string>());
|
||||
assertion["class"] = "credential";
|
||||
using HttpResponseMessage elevated = await session.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(assertion));
|
||||
Assert.Equal(HttpStatusCode.OK, elevated.StatusCode);
|
||||
}
|
||||
|
||||
private static async Task<JsonNode> JsonOf(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
|
||||
|
||||
private async Task<string> RegistrationChallengeAsync(HttpClient session)
|
||||
@@ -251,12 +241,8 @@ public sealed class PasskeyTests
|
||||
// 正常注册,同一把凭证不能注册两次,也不能用登录用的挑战注册
|
||||
string challenge = await RegistrationChallengeAsync(session);
|
||||
Assert.Equal(HttpStatusCode.Created, await TryAsync(passkey.Register("ok", challenge)));
|
||||
// 已经有 passkey 了,再注册要先触碰一次(凭证窗口,用一次就没有)
|
||||
await ElevateAsync(session, passkey);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("again", challenge))); // 挑战只能用一次
|
||||
await ElevateAsync(session, passkey);
|
||||
Assert.Equal(HttpStatusCode.Conflict, await TryAsync(passkey.Register("again", await RegistrationChallengeAsync(session))));
|
||||
await ElevateAsync(session, passkey);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(new SoftwarePasskey(RpId, Origin).Register("x", await LoginChallengeAsync())));
|
||||
}
|
||||
|
||||
@@ -270,11 +256,9 @@ public sealed class PasskeyTests
|
||||
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = other, ["password"] = "correct horse battery" }));
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
using HttpClient intruder = await PasswordSessionAsync(other, "correct horse battery");
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await intruder.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode); // 没有窗口,连试都不行
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await intruder.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode); // 别人的 passkey:看不见,也删不了
|
||||
Assert.DoesNotContain((await JsonOf(await intruder.GetAsync("/api/admin/auth/passkeys")))["items"]!.AsArray(), p => p!["id"]!.GetValue<string>() == passkey.Id);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await admin.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode); // 删除要先触碰
|
||||
await ElevateAsync(admin, passkey);
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync())));
|
||||
}
|
||||
|
||||
@@ -43,11 +43,10 @@ public sealed class TrustTests
|
||||
return existing;
|
||||
}
|
||||
|
||||
// 操作员:有 passkey、用 passkey 登录的会话(密码会话什么能力都没有)
|
||||
// 操作员:有 passkey、用 passkey 登录的会话
|
||||
(HttpClient session, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("operator");
|
||||
|
||||
// 创世要凭证窗口(立根的人要先证明自己),再用 passkey 签集合本身
|
||||
await ElevateAsync(session, passkey);
|
||||
// 创世:用 passkey 对集合本身签名(节点据此验链)
|
||||
JsonNode state = JsonNode.Parse(await session.GetStringAsync("/api/admin/trust"))!;
|
||||
Assert.False(state["initialized"]!.GetValue<bool>());
|
||||
string statement = state["proposal"]!["statement"]!.GetValue<string>();
|
||||
@@ -83,15 +82,6 @@ public sealed class TrustTests
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task ElevateAsync(HttpClient session, SoftwarePasskey passkey)
|
||||
{
|
||||
using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "credential" }));
|
||||
JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>());
|
||||
assertion["class"] = "credential";
|
||||
using HttpResponseMessage elevated = await session.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(assertion));
|
||||
Assert.Equal(HttpStatusCode.OK, elevated.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>创建一个加入码(普通 API key 就行,不需要 passkey),返回响应里的码和三条安装命令。</summary>
|
||||
private async Task<(string Code, JsonNode Commands, HttpStatusCode Status)> CreateCodeAsync(string role = "probe", string[]? controllers = null)
|
||||
{
|
||||
@@ -235,9 +225,9 @@ public sealed class TrustTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APasswordSession_CanOnlyRegisterAPasskey_AndCreatingACodeNeedsNoPasskeyTouch()
|
||||
public async Task APasswordSession_ManagesAsUsual_AndCreatingACodeNeedsNoPasskey()
|
||||
{
|
||||
// 密码登录的会话没有任何能力:连看节点列表都不行,只能注册 passkey
|
||||
// 密码登录保留:日常管理(看列表、创建加入码)不需要 passkey;passkey 只用在节点自己会验证的事上(操作员集合的签名)
|
||||
string name = _backend.UniqueName("pwonly");
|
||||
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "correct horse battery" }));
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
@@ -245,14 +235,14 @@ public sealed class TrustTests
|
||||
using HttpResponseMessage login = await password.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "correct horse battery", ["return_session"] = true }));
|
||||
password.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
|
||||
JsonNode me = JsonNode.Parse(await password.GetStringAsync("/api/admin/auth/me"))!;
|
||||
Assert.Empty(me["capabilities"]!.AsArray());
|
||||
Assert.NotEmpty(me["capabilities"]!.AsArray());
|
||||
Assert.Equal("password", me["auth_method"]!.GetValue<string>());
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await password.GetAsync("/api/admin/agents")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await password.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent" }))).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await password.GetAsync("/api/admin/agents")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Created, (await password.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent" }))).StatusCode);
|
||||
using HttpResponseMessage options = await password.PostAsync("/api/admin/auth/passkeys/options", null);
|
||||
Assert.Equal(HttpStatusCode.OK, options.StatusCode);
|
||||
|
||||
// passkey 登录的会话创建码:不需要提权窗口、不需要再触碰(创建码不是信任动作,信任在属主固定里)
|
||||
// passkey 登录的会话创建码:同样不需要再触碰(创建码不是信任动作,信任在属主固定里)
|
||||
(HttpClient strong, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("creator");
|
||||
using HttpClient strongScope = strong;
|
||||
using SoftwarePasskey passkeyScope = passkey;
|
||||
@@ -260,6 +250,24 @@ public sealed class TrustTests
|
||||
Assert.Equal(HttpStatusCode.Created, code.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheProposal_ListsEveryNewPasskey_SoTheSignerSeesWhatTheyAreAuthorizing()
|
||||
{
|
||||
// 往账号里塞一把 passkey 再等人签名是这个流程唯一的风险:提案要明确列出新增的凭证
|
||||
Operator op = await OperatorAsync();
|
||||
(HttpClient other, SoftwarePasskey added, _) = await _backend.CreateStrongSessionAsync("added");
|
||||
using HttpClient otherScope = other;
|
||||
using SoftwarePasskey addedScope = added;
|
||||
|
||||
JsonNode proposal = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!["proposal"]!;
|
||||
JsonArray list = proposal["added"]!.AsArray();
|
||||
Assert.Contains(list, a => a!["id"]!.GetValue<string>() == added.Id);
|
||||
Assert.All(list, a => Assert.False(string.IsNullOrWhiteSpace(a!["name"]!.GetValue<string>())));
|
||||
Assert.DoesNotContain(list, a => a!["id"]!.GetValue<string>() == op.Passkey.Id);
|
||||
Assert.Empty(proposal["removed"]!.AsArray());
|
||||
Assert.False(proposal["server_key_changed"]!.GetValue<bool>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheServerKey_IsSignedIntoTheOperatorSet_AndOnlyThatServerPassesTheNodesCheck()
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user