Skip to content

Commit 78ab8b3

Browse files
IvanMurzakclaude
andauthored
feat(auth): unified-machine-auth f1 — F1 login via machine store, F6 machine-wide sign-out, O8 legacy cloudToken migration (#343)
* feat(auth): adopt the shared machine-auth stack — F1 two-lock-hold login, F6 machine-wide sign-out, O8 legacy cloudToken migration (unified-machine-auth f1) The editor Authorize button stops writing Config.CloudToken to the plaintext user://godot-mcp-config.json sink and instead runs the F1 flow through GodotAccountAuth + the shared machine credential store: - Device flow now requests scope mcp:agent (03 F1.2); the agent family is committed under the first lock hold, derived to the plugin family via RFC 8693 between holds, committed + v1-mirrored under the second hold (MachineCredentialLoginCommit — guarded helpers only, never bare Adopt()). - GodotTokenRefresher reduced to a thin adapter over the shared HttpTokenRefresher (stored clientId, no scope/resource, machine lock, 15 s contract timeout); the local refresh wire path in GodotDeviceAuthService is deleted so exactly one refresh shape exists. - Sign out is the F6 machine-wide sequence (revoke every family with its stored clientId, lock-protocol store delete) behind a confirm dialog, and clears the legacy sink so migrate-on-touch cannot resurrect it. - O8/F11.2: a pre-existing user:// cloudToken migrates into the machine store (as a legacy family, under the lock) when the store is empty; read-fallback stays; the sink write-path removal is the f4 follow-up. - Expiry self-heals via the shared provider (proactive + reactive refresh under the machine lock); panel renders signed-in from the machine store OR the legacy sink (O8 window). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): review round 1 — bounded second-phase retry + abandoned-mint revocation (B1), unreadable-store/busy-lock migration guards pinned (B2) B1: SignInAsync no longer strands a minted plugin family. AgentOnly gets ONE bounded exchange retry with backoff (03 F1 'P retries with backoff'; the agent family stays committed, no second device flow); PluginCommitBusy/StoreUnreadable get ONE bounded CommitPluginFamilyAsync retry with the CARRIED ExchangeResult (never a re-exchange, expected subject = the mint's own sub); a mint still uncommitted after the retry is best-effort revoked (b3 twin rule 4) before the coordinator stops retrying. Panel copy no longer promises 'again ... to finish'. B2: the O8 migration guards are now pinned by tests that fail when a guard is removed: an unreadable store (garbage bytes -> DPAPI/JSON Unreadable on both codecs) is never overwritten (byte-identical assert), and a held machine lock yields Busy with nothing written, then Migrated once the lock frees (via the package's internal short-budget lock ctor, reached by reflection with a loud failure if the upstream shape changes). A2 (advisory): retired providers are parked until the coordinator disposes, so an in-flight token resolution can no longer race a provider Dispose. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent e291580 commit 78ab8b3

15 files changed

Lines changed: 1818 additions & 334 deletions

Godot-MCP.Tests/ConnectionPanelViewTests.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,47 @@ public void ConnectionUrlLabel_FormatsPinnedUrl_EmptyWhenAbsent()
318318
Assert.Equal(string.Empty, ConnectionPanelView.ConnectionUrlLabel(" "));
319319
}
320320

321+
// --- Cloud signed-in predicate + sign-out / sign-in-outcome copy (unified-machine-auth f1) ---
322+
323+
[Theory]
324+
[InlineData(true, null, true)] // machine-store account signed in (F1 golden path)
325+
[InlineData(true, "legacy-tok", true)] // both
326+
[InlineData(false, "legacy-tok", true)] // O8 read-fallback window: the legacy sink still counts
327+
[InlineData(false, null, false)]
328+
[InlineData(false, "", false)]
329+
public void IsCloudSignedIn_MachineStoreOrLegacySink(bool accountSignedIn, string? legacyToken, bool expected)
330+
{
331+
Assert.Equal(expected, ConnectionPanelView.IsCloudSignedIn(accountSignedIn, legacyToken));
332+
}
333+
334+
[Fact]
335+
public void SignOutConfirmText_NamesTheMachineWideScope()
336+
{
337+
// F6.1: the confirmation must say it signs out ALL tools on this machine — a local-sounding
338+
// confirm in front of a machine-wide delete would be a consent bug.
339+
Assert.Contains("ALL", ConnectionPanelView.SignOutConfirmText);
340+
Assert.Contains("machine", ConnectionPanelView.SignOutConfirmText);
341+
}
342+
343+
[Fact]
344+
public void SignInOutcomeMessage_CoversEveryStatus_AndNeverEchoesUnexpectedDetail()
345+
{
346+
static GodotAccountSignInResult Make(GodotAccountSignInStatus status, string? detail = null)
347+
=> (GodotAccountSignInResult)Activator.CreateInstance(
348+
typeof(GodotAccountSignInResult),
349+
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic,
350+
null, new object?[] { status, detail }, null)!;
351+
352+
Assert.Contains("Signed in", ConnectionPanelView.SignInOutcomeMessage(Make(GodotAccountSignInStatus.SignedIn)));
353+
Assert.Contains("Partially", ConnectionPanelView.SignInOutcomeMessage(Make(GodotAccountSignInStatus.PartiallyAuthorized)));
354+
// NotAuthorized: the device-flow status line already rendered the terminal state — no double message.
355+
Assert.Equal(string.Empty, ConnectionPanelView.SignInOutcomeMessage(Make(GodotAccountSignInStatus.NotAuthorized)));
356+
Assert.Contains("lock", ConnectionPanelView.SignInOutcomeMessage(Make(GodotAccountSignInStatus.Busy)));
357+
Assert.Contains("different account", ConnectionPanelView.SignInOutcomeMessage(Make(GodotAccountSignInStatus.SubjectConflict)));
358+
Assert.Contains("failed", ConnectionPanelView.SignInOutcomeMessage(Make(GodotAccountSignInStatus.Failed)));
359+
Assert.Contains("exchange refused", ConnectionPanelView.SignInOutcomeMessage(Make(GodotAccountSignInStatus.Failed, "exchange refused")));
360+
}
361+
321362
[Fact]
322363
public void CustomHost_PersistsAndReloads()
323364
{

Godot-MCP.Tests/Godot-MCP.Tests.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,9 @@
144144
<Compile Include="..\addons\godot_mcp\Editor\Connection\GodotDeviceAuthResult.cs" Link="Source\GodotDeviceAuthResult.cs" />
145145
<Compile Include="..\addons\godot_mcp\Editor\Connection\GodotTokenRefresher.cs" Link="Source\GodotTokenRefresher.cs" />
146146
<Compile Include="..\addons\godot_mcp\Editor\Connection\GodotAccountAuth.cs" Link="Source\GodotAccountAuth.cs" />
147+
<!-- unified-machine-auth f1: the pure orchestration behind the dock's Authorize button — the seam
148+
whose no-new-cloudToken invariant (O8) is pinned by GodotCloudAccountControllerTests. -->
149+
<Compile Include="..\addons\godot_mcp\Editor\Connection\GodotCloudAccountController.cs" Link="Source\GodotCloudAccountController.cs" />
147150
<!-- Editor-runtime NuGet-dependency resolver. Pure-BCL (System.Runtime.Loader), no #if TOOLS,
148151
no Godot native types — so it is unit-testable in this plain-xUnit host. -->
149152
<Compile Include="..\addons\godot_mcp\Runtime\Connection\GodotMcpAssemblyResolver.cs" Link="Source\GodotMcpAssemblyResolver.cs" />

Godot-MCP.Tests/GodotAccountAuthTests.cs

Lines changed: 611 additions & 111 deletions
Large diffs are not rendered by default.
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
/*
2+
┌──────────────────────────────────────────────────────────────────┐
3+
│ Author: Ivan Murzak (https://github.com/IvanMurzak) │
4+
│ Repository: GitHub (https://github.com/IvanMurzak/Godot-MCP) │
5+
│ Copyright (c) 2026 Ivan Murzak │
6+
│ Licensed under the Apache License, Version 2.0. │
7+
│ See the LICENSE file in the project root for more information. │
8+
└──────────────────────────────────────────────────────────────────┘
9+
*/
10+
#nullable enable
11+
using System;
12+
using System.Collections.Generic;
13+
using System.IO;
14+
using System.Net;
15+
using System.Net.Http;
16+
using System.Threading;
17+
using System.Threading.Tasks;
18+
using com.IvanMurzak.Godot.MCP.Connection;
19+
using com.IvanMurzak.McpPlugin.AgentConfig;
20+
using Xunit;
21+
22+
namespace com.IvanMurzak.Godot.MCP.Tests
23+
{
24+
/// <summary>
25+
/// Covers <see cref="GodotCloudAccountController"/> — the pure orchestration behind the dock's Cloud
26+
/// "Authorize" button (unified-machine-auth task f1), and the home of the O8 sink-write invariant:
27+
///
28+
/// <para><b>A successful authorize persists the credential ONLY into the machine store.</b> The
29+
/// persisted-config layer (<see cref="GodotMcpConfig.CloudToken"/> — serialized to the plaintext
30+
/// <c>user://godot-mcp-config.json</c> sink) gains NO new cloudToken. This is the G-SEC-1 plant-1
31+
/// test: re-enabling the historical <c>config.CloudToken = token</c> write anywhere on the authorize
32+
/// path turns <see cref="SignIn_Success_WritesTheMachineStore_AndNeverTheCloudTokenSink"/> RED.
33+
/// The positive half is proven in the same test by a POSITIVE artifact — the machine store actually
34+
/// holding the committed families — so the no-write assert can never pass vacuously (the path
35+
/// demonstrably CAN persist a credential; it persists it to the store).</para>
36+
/// </summary>
37+
public class GodotCloudAccountControllerTests
38+
{
39+
const string AsBaseUrl = "https://ai-game.dev";
40+
41+
[Fact]
42+
public async Task SignIn_Success_WritesTheMachineStore_AndNeverTheCloudTokenSink()
43+
{
44+
using var tmp = new TempDir();
45+
var handler = new ScriptedHandler(
46+
deviceAuthorize: DeviceAuthorizeJson("USER-1", "dev-1"),
47+
deviceToken: TokenJson("acc-agent", "ref-agent", scope: "mcp:agent"),
48+
exchange: ExchangeJson("acc-plugin", "ref-plugin", scope: "mcp:plugin", sub: "usr_1"));
49+
using var account = MakeAccount(tmp, handler);
50+
var config = new GodotMcpConfig();
51+
Assert.Null(config.CloudToken); // precondition: a fresh config carries no cloud token
52+
53+
var outcome = await GodotCloudAccountController.SignInAsync(
54+
account, MakeFlow(handler), AsBaseUrl, config);
55+
56+
// POSITIVE artifact: the sign-in DID persist a credential — into the machine store.
57+
Assert.Equal(GodotAccountSignInStatus.SignedIn, outcome.Status);
58+
var persisted = new MachineCredentialStore(tmp.Path).Read();
59+
Assert.Equal("acc-plugin", persisted?.Families?.Plugin?.AccessToken);
60+
Assert.Equal("acc-agent", persisted?.Families?.Agent?.AccessToken);
61+
Assert.True(account.IsSignedIn);
62+
63+
// THE PIN (O8 / G-SEC-1 plant 1): the legacy user:// sink layer gained no new cloudToken.
64+
Assert.Null(config.CloudToken);
65+
// And the custom-token field was not abused as a side channel either.
66+
Assert.Null(config.CustomToken);
67+
}
68+
69+
[Fact]
70+
public async Task SignIn_NotAuthorized_TouchesNeitherStoreNorConfig()
71+
{
72+
using var tmp = new TempDir();
73+
var handler = new ScriptedHandler(
74+
deviceAuthorize: DeviceAuthorizeJson("USER-1", "dev-1"),
75+
deviceToken: "{ \"error\": \"access_denied\" }",
76+
deviceTokenStatus: HttpStatusCode.BadRequest);
77+
using var account = MakeAccount(tmp, handler);
78+
var config = new GodotMcpConfig();
79+
80+
var outcome = await GodotCloudAccountController.SignInAsync(
81+
account, MakeFlow(handler), AsBaseUrl, config);
82+
83+
Assert.Equal(GodotAccountSignInStatus.NotAuthorized, outcome.Status);
84+
Assert.False(new MachineCredentialStore(tmp.Path).Exists);
85+
Assert.Null(config.CloudToken);
86+
}
87+
88+
/// <summary>
89+
/// A PRE-EXISTING legacy sink token (the O8 read-fallback window) is left exactly as it was — the
90+
/// controller neither clears nor overwrites it on a successful machine-store sign-in (delete-source
91+
/// semantics belong to the f4 follow-up).
92+
/// </summary>
93+
[Fact]
94+
public async Task SignIn_Success_LeavesAPreExistingLegacySinkTokenUntouched()
95+
{
96+
using var tmp = new TempDir();
97+
var handler = new ScriptedHandler(
98+
deviceAuthorize: DeviceAuthorizeJson("USER-1", "dev-1"),
99+
deviceToken: TokenJson("acc-agent", "ref-agent", scope: "mcp:agent"),
100+
exchange: ExchangeJson("acc-plugin", "ref-plugin", scope: "mcp:plugin", sub: "usr_1"));
101+
using var account = MakeAccount(tmp, handler);
102+
var config = new GodotMcpConfig { CloudToken = "pre-existing-sink-token" };
103+
104+
var outcome = await GodotCloudAccountController.SignInAsync(
105+
account, MakeFlow(handler), AsBaseUrl, config);
106+
107+
Assert.True(outcome.Succeeded);
108+
Assert.Equal("pre-existing-sink-token", config.CloudToken);
109+
}
110+
111+
// --- helpers (mirrors GodotAccountAuthTests' fixtures) ---
112+
113+
static GodotAccountAuth MakeAccount(TempDir tmp, HttpMessageHandler handler)
114+
=> new(
115+
asBaseUrlProvider: () => AsBaseUrl,
116+
store: new MachineCredentialStore(tmp.Path),
117+
httpClient: new HttpClient(handler));
118+
119+
static GodotDeviceAuthFlow MakeFlow(HttpMessageHandler handler)
120+
=> new(
121+
new GodotDeviceAuthService(new HttpClient(handler)),
122+
delay: (_, _) => Task.CompletedTask,
123+
utcNow: () => DateTime.UtcNow);
124+
125+
static string DeviceAuthorizeJson(string userCode, string deviceCode) => $$"""
126+
{
127+
"device_code": "{{deviceCode}}",
128+
"user_code": "{{userCode}}",
129+
"verification_uri": "https://ai-game.dev/verify",
130+
"verification_uri_complete": "https://ai-game.dev/verify?code={{userCode}}",
131+
"expires_in": 600,
132+
"interval": 5
133+
}
134+
""";
135+
136+
static string TokenJson(string access, string refresh, string scope) => $$"""
137+
{
138+
"access_token": "{{access}}",
139+
"refresh_token": "{{refresh}}",
140+
"token_type": "Bearer",
141+
"expires_in": 3600,
142+
"scope": "{{scope}}"
143+
}
144+
""";
145+
146+
static string ExchangeJson(string access, string refresh, string scope, string sub) => $$"""
147+
{
148+
"access_token": "{{access}}",
149+
"refresh_token": "{{refresh}}",
150+
"token_type": "Bearer",
151+
"expires_in": 3600,
152+
"scope": "{{scope}}",
153+
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
154+
"sub": "{{sub}}"
155+
}
156+
""";
157+
158+
sealed class TempDir : IDisposable
159+
{
160+
public string Path { get; } = System.IO.Path.Combine(
161+
System.IO.Path.GetTempPath(), "godot-mcp-ctrl-" + Guid.NewGuid().ToString("N"));
162+
163+
public void Dispose()
164+
{
165+
try { if (Directory.Exists(Path)) Directory.Delete(Path, recursive: true); }
166+
catch { /* best-effort test cleanup */ }
167+
}
168+
}
169+
170+
/// <summary>Routes device_authorization / device-code token / token-exchange to scripted JSON.</summary>
171+
sealed class ScriptedHandler : HttpMessageHandler
172+
{
173+
readonly string _deviceAuthorize;
174+
readonly string _deviceToken;
175+
readonly HttpStatusCode _deviceTokenStatus;
176+
readonly string? _exchange;
177+
178+
public ScriptedHandler(string deviceAuthorize, string deviceToken,
179+
string? exchange = null, HttpStatusCode deviceTokenStatus = HttpStatusCode.OK)
180+
{
181+
_deviceAuthorize = deviceAuthorize;
182+
_deviceToken = deviceToken;
183+
_exchange = exchange;
184+
_deviceTokenStatus = deviceTokenStatus;
185+
}
186+
187+
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
188+
{
189+
var path = request.RequestUri!.AbsolutePath;
190+
var body = request.Content != null
191+
? await request.Content.ReadAsStringAsync(cancellationToken)
192+
: string.Empty;
193+
var decoded = WebUtility.UrlDecode(body);
194+
195+
if (path.EndsWith("/oauth/device_authorization", StringComparison.Ordinal))
196+
return Json(HttpStatusCode.OK, _deviceAuthorize);
197+
if (path.EndsWith("/oauth/revoke", StringComparison.Ordinal))
198+
return new HttpResponseMessage(HttpStatusCode.OK);
199+
if (path.EndsWith("/oauth/token", StringComparison.Ordinal))
200+
{
201+
if (decoded.Contains("grant-type:token-exchange"))
202+
return Json(HttpStatusCode.OK, _exchange ?? throw new InvalidOperationException("unscripted token exchange"));
203+
return Json(_deviceTokenStatus, _deviceToken);
204+
}
205+
throw new InvalidOperationException($"unexpected request: {path}"); // path only — never form fields
206+
207+
static HttpResponseMessage Json(HttpStatusCode code, string json)
208+
=> new(code) { Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") };
209+
}
210+
}
211+
}
212+
}

Godot-MCP.Tests/GodotDeviceAuthFlowTests.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,14 @@ public async Task AuthorizeAsync_SendsRfc8628FormFields()
9999

100100
await flow.AuthorizeAsync(AsBaseUrl);
101101

102-
// Authorize request: POST /oauth/device_authorization with client_id + scope=mcp:plugin.
102+
// Authorize request: POST /oauth/device_authorization with client_id + scope=mcp:agent
103+
// (unified-machine-auth 03 F1.2 — the flow mints AGENT tokens; the plugin family is derived
104+
// via RFC 8693 by the login commit, never requested here).
103105
var authorize = handler.Requests[0];
104106
Assert.EndsWith("/oauth/device_authorization", authorize.Path);
105107
Assert.Contains($"client_id={GodotDeviceAuthFlow.DefaultClientId}", authorize.Body);
106-
Assert.Contains("scope=mcp%3Aplugin", authorize.Body); // "mcp:plugin" url-encoded
108+
Assert.Contains("scope=mcp%3Aagent", authorize.Body); // "mcp:agent" url-encoded
109+
Assert.DoesNotContain("scope=mcp%3Aplugin", authorize.Body);
107110

108111
// Token request: POST /oauth/token with the device-code grant + same client_id + device_code.
109112
var token = handler.Requests[1];

0 commit comments

Comments
 (0)