@mathismeadows/roamer-device-auth 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/roamer-device-auth.mjs +58 -35
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mathismeadows/roamer-device-auth",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "AUTH-14/AUTH-25: stdio<->HTTP MCP proxy for Roamer MCP with two auth mechanisms, chosen per-machine by default-browser detection. Safari-default machines use a server-mediated device authorization flow (AUTH-25) since Safari's HTTPS-Only Mode blocks a loopback redirect; every other machine uses a standard direct loopback redirect against
|
|
6
|
+
"description": "AUTH-14/AUTH-25/AUTH-38: stdio<->HTTP MCP proxy for Roamer MCP with two auth mechanisms, chosen per-machine by default-browser detection. Safari-default machines use a server-mediated device authorization flow (AUTH-25) since Safari's HTTPS-Only Mode blocks a loopback redirect; every other machine uses a standard direct loopback redirect instead. Both authenticate against RoamerMcp's own OAuth authorization server (AUTH-38).",
|
|
7
7
|
"bin": {
|
|
8
8
|
"roamer-device-auth": "./roamer-device-auth.mjs"
|
|
9
9
|
},
|
package/roamer-device-auth.mjs
CHANGED
|
@@ -2,17 +2,19 @@
|
|
|
2
2
|
// Roamer MCP — server-mediated device authorization flow (AUTH-25), used only for
|
|
3
3
|
// Safari-default clients since AUTH-14's 2026-08-25 revival. Non-Safari clients (and any
|
|
4
4
|
// OS where Safari isn't an option, e.g. Windows/Linux) instead use a standard direct
|
|
5
|
-
// loopback-redirect flow against
|
|
5
|
+
// loopback-redirect flow against RoamerMcp's own OAuth authorization server — see
|
|
6
6
|
// getValidTokensLoopback() below — removing the device-code confirmation screen AUTH-25's
|
|
7
7
|
// prior unified design imposed on every client, Safari or not.
|
|
8
8
|
//
|
|
9
9
|
// Why Safari still needs device-code: Safari's HTTPS-Only Mode unconditionally blocks the
|
|
10
10
|
// loopback flow's plain http:// callback (WebKitErrorDomain:305, confirmed live
|
|
11
|
-
// 2026-08-20),
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// server-to-server
|
|
11
|
+
// 2026-08-20), so RoamerMcp's server implements the RFC 8628 shape itself for that case.
|
|
12
|
+
// AUTH-38: as of 2026-08-27 the actual login step this brokers is against Entra directly
|
|
13
|
+
// (RoamerMcp's server acting as an OAuth client of its own AS, then handing this process a
|
|
14
|
+
// RoamerMcp-signed token pair) — previously it brokered against Cloudflare Access Managed
|
|
15
|
+
// OAuth server-to-server instead, back when Cloudflare was the token issuer for every
|
|
16
|
+
// RoamerMcp client, not just this one. detectDefaultBrowser() below decides which path
|
|
17
|
+
// (this one or the loopback one) a given machine takes.
|
|
16
18
|
//
|
|
17
19
|
// AUTH-24: this is the publishable source for the @mathismeadows/roamer-device-auth npm
|
|
18
20
|
// package — roamer-bridge.sh (here and in roamer-mcp-plugin) invokes the published,
|
|
@@ -111,41 +113,50 @@ function clearCachedTokens() {
|
|
|
111
113
|
return writeFile(CACHE_FILE, "{}", { mode: 0o600 }).catch(() => {});
|
|
112
114
|
}
|
|
113
115
|
|
|
114
|
-
// AUTH-14: loopback-flow
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
116
|
+
// AUTH-14/AUTH-38: loopback-flow caches, stamped with the authorization server URL they were
|
|
117
|
+
// obtained against. Not just a version stamp (like CACHE_VERSION above) — a real issuer
|
|
118
|
+
// comparison, so ANY future AS migration self-invalidates these automatically, not just this
|
|
119
|
+
// one. Confirmed live 2026-08-27 this gap is real, not theoretical: AUTH-35 retargeted RoamerMcp's
|
|
120
|
+
// discovery documents from Cloudflare Access Managed OAuth to RoamerMcp's own AS, and a client_id
|
|
121
|
+
// or token cached from before that change would otherwise be silently carried forward and
|
|
122
|
+
// presented to a completely different issuer with no invalidation at all.
|
|
123
|
+
async function readLoopbackTokens(issuerUrl) {
|
|
118
124
|
try {
|
|
119
125
|
const tokens = JSON.parse(await readFile(LOOPBACK_TOKENS_FILE, "utf8"));
|
|
126
|
+
if (tokens?.issuerUrl !== issuerUrl) return null;
|
|
120
127
|
return tokens?.access_token ? tokens : null;
|
|
121
128
|
} catch {
|
|
122
129
|
return null;
|
|
123
130
|
}
|
|
124
131
|
}
|
|
125
132
|
|
|
126
|
-
async function writeLoopbackTokens(tokens) {
|
|
133
|
+
async function writeLoopbackTokens(tokens, issuerUrl) {
|
|
127
134
|
await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
128
|
-
|
|
135
|
+
const stamped = { ...tokens, issuerUrl };
|
|
136
|
+
await writeFile(LOOPBACK_TOKENS_FILE, JSON.stringify(stamped, null, 2), { mode: 0o600 });
|
|
129
137
|
}
|
|
130
138
|
|
|
131
139
|
function clearLoopbackTokens() {
|
|
132
140
|
return writeFile(LOOPBACK_TOKENS_FILE, "{}", { mode: 0o600 }).catch(() => {});
|
|
133
141
|
}
|
|
134
142
|
|
|
135
|
-
// DCR'd client registration is reused across runs —
|
|
136
|
-
// no reason to be hit on every single sign-in, only the first one (or
|
|
137
|
-
|
|
143
|
+
// DCR'd client registration is reused across runs — the authorization server's
|
|
144
|
+
// registration_endpoint has no reason to be hit on every single sign-in, only the first one (or
|
|
145
|
+
// after a reset, or after an issuer change per this function's own issuerUrl check above).
|
|
146
|
+
async function readLoopbackClientInfo(issuerUrl) {
|
|
138
147
|
try {
|
|
139
148
|
const info = JSON.parse(await readFile(LOOPBACK_CLIENT_FILE, "utf8"));
|
|
149
|
+
if (info?.issuerUrl !== issuerUrl) return null;
|
|
140
150
|
return info?.client_id ? info : null;
|
|
141
151
|
} catch {
|
|
142
152
|
return null;
|
|
143
153
|
}
|
|
144
154
|
}
|
|
145
155
|
|
|
146
|
-
async function writeLoopbackClientInfo(info) {
|
|
156
|
+
async function writeLoopbackClientInfo(info, issuerUrl) {
|
|
147
157
|
await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
148
|
-
|
|
158
|
+
const stamped = { ...info, issuerUrl };
|
|
159
|
+
await writeFile(LOOPBACK_CLIENT_FILE, JSON.stringify(stamped, null, 2), { mode: 0o600 });
|
|
149
160
|
}
|
|
150
161
|
|
|
151
162
|
// AUTH-14: which auth mechanism a given machine uses. Safari-default clients (macOS only —
|
|
@@ -176,7 +187,7 @@ async function detectDefaultBrowser() {
|
|
|
176
187
|
|
|
177
188
|
// Claude Code (or any MCP host) may kill this process and respawn a fresh one if it doesn't
|
|
178
189
|
// see a stdio handshake within its own connect timeout — and the interactive device flow
|
|
179
|
-
// (dialog -> browser ->
|
|
190
|
+
// (dialog -> browser -> Entra's consent screen -> poll) routinely takes longer than a
|
|
180
191
|
// human can click through inside a short timeout. Without persisting the in-progress flow,
|
|
181
192
|
// every respawn called /oauth/device/start again, which mints a BRAND NEW device_code/
|
|
182
193
|
// user_code — the user would see a different code every single retry, forever, with no
|
|
@@ -341,8 +352,8 @@ async function doGetValidTokens(forceRefresh) {
|
|
|
341
352
|
}
|
|
342
353
|
}
|
|
343
354
|
|
|
344
|
-
// AUTH-14: fixed, well-known port for the loopback callback —
|
|
345
|
-
// registration is persisted and reused across every future sign-in (see
|
|
355
|
+
// AUTH-14: fixed, well-known port for the loopback callback — the authorization server's DCR'd
|
|
356
|
+
// client registration is persisted and reused across every future sign-in (see
|
|
346
357
|
// readLoopbackClientInfo), so the redirect_uri baked into that registration has to stay
|
|
347
358
|
// stable across runs. A fixed port sidesteps re-registering on every single interactive
|
|
348
359
|
// sign-in; the tradeoff (a port-in-use conflict is possible, if rare) is the same one this
|
|
@@ -413,21 +424,26 @@ function getValidTokensLoopback(forceRefresh = false) {
|
|
|
413
424
|
}
|
|
414
425
|
|
|
415
426
|
async function doGetValidTokensLoopback(forceRefresh) {
|
|
416
|
-
|
|
427
|
+
// AUTH-38: discovery has to run before any cached token/client is trusted, not after — a
|
|
428
|
+
// cached-but-not-yet-expired token from a prior authorization server would otherwise be
|
|
429
|
+
// returned early below without ever learning the issuer changed underneath it.
|
|
430
|
+
const serverInfo = await discoverLoopbackServerInfo();
|
|
431
|
+
const issuerUrl = serverInfo.authorizationServerUrl.toString();
|
|
432
|
+
|
|
433
|
+
let tokens = forceRefresh ? null : await readLoopbackTokens(issuerUrl);
|
|
417
434
|
|
|
418
435
|
if (!forceRefresh && tokens?.access_token && !expiresSoon(tokens)) {
|
|
419
436
|
return tokens;
|
|
420
437
|
}
|
|
421
438
|
|
|
422
|
-
|
|
423
|
-
let clientInformation = await readLoopbackClientInfo();
|
|
439
|
+
let clientInformation = await readLoopbackClientInfo(issuerUrl);
|
|
424
440
|
|
|
425
441
|
if (!forceRefresh && tokens?.refresh_token && clientInformation) {
|
|
426
442
|
try {
|
|
427
443
|
log("Refreshing cached token...");
|
|
428
444
|
const fresh = await refreshLoopbackTokens(tokens.refresh_token, serverInfo, clientInformation);
|
|
429
445
|
tokens = { ...fresh, obtained_at: Date.now() };
|
|
430
|
-
await writeLoopbackTokens(tokens);
|
|
446
|
+
await writeLoopbackTokens(tokens, issuerUrl);
|
|
431
447
|
return tokens;
|
|
432
448
|
} catch (err) {
|
|
433
449
|
log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
|
|
@@ -437,7 +453,7 @@ async function doGetValidTokensLoopback(forceRefresh) {
|
|
|
437
453
|
if (forceRefresh) await clearLoopbackTokens();
|
|
438
454
|
|
|
439
455
|
if (!clientInformation) {
|
|
440
|
-
log("Registering as a new OAuth client
|
|
456
|
+
log("Registering as a new OAuth client...");
|
|
441
457
|
clientInformation = await registerClient(serverInfo.authorizationServerUrl, {
|
|
442
458
|
metadata: serverInfo.authorizationServerMetadata,
|
|
443
459
|
clientMetadata: {
|
|
@@ -448,21 +464,20 @@ async function doGetValidTokensLoopback(forceRefresh) {
|
|
|
448
464
|
token_endpoint_auth_method: "none",
|
|
449
465
|
},
|
|
450
466
|
});
|
|
451
|
-
await writeLoopbackClientInfo(clientInformation);
|
|
467
|
+
await writeLoopbackClientInfo(clientInformation, issuerUrl);
|
|
452
468
|
}
|
|
453
469
|
|
|
454
470
|
log("Starting sign-in...");
|
|
471
|
+
// AUTH-38: no `resource` parameter — that was a Cloudflare Access Managed OAuth-specific
|
|
472
|
+
// requirement (RFC 8707; it rejected the request without one). RoamerMcp's own /oauth/authorize
|
|
473
|
+
// doesn't bind or read a resource parameter at all, so sending one now would be dead weight,
|
|
474
|
+
// not a compatibility need — same reasoning as OAuthAuthorizationServerService's own Entra-
|
|
475
|
+
// facing hop never sending one (see that class's doc comment for the Entra-side reason, a
|
|
476
|
+
// different one: including it there triggers AADSTS9010010).
|
|
455
477
|
const { authorizationUrl, codeVerifier } = await startAuthorization(serverInfo.authorizationServerUrl, {
|
|
456
478
|
metadata: serverInfo.authorizationServerMetadata,
|
|
457
479
|
clientInformation,
|
|
458
480
|
redirectUrl: LOOPBACK_REDIRECT_URI,
|
|
459
|
-
// RFC 8707: Cloudflare's authorization endpoint rejects the request without this
|
|
460
|
-
// (invalid_target / "No resource parameter found") — the exact same requirement
|
|
461
|
-
// DeviceFlowService.cs already hit and fixed server-side for the device-code path
|
|
462
|
-
// (confirmed live 2026-08-21) and just reconfirmed live here 2026-08-26. Only the
|
|
463
|
-
// authorization step needs it; DeviceFlowService.cs's token/refresh calls don't
|
|
464
|
-
// reference it at all, so exchangeAuthorization/refreshAuthorization below don't either.
|
|
465
|
-
resource: new URL(ROAMER_MCP_URL),
|
|
466
481
|
});
|
|
467
482
|
|
|
468
483
|
const server = createServer();
|
|
@@ -502,7 +517,7 @@ async function doGetValidTokensLoopback(forceRefresh) {
|
|
|
502
517
|
redirectUri: LOOPBACK_REDIRECT_URI,
|
|
503
518
|
});
|
|
504
519
|
tokens = { ...fresh, obtained_at: Date.now() };
|
|
505
|
-
await writeLoopbackTokens(tokens);
|
|
520
|
+
await writeLoopbackTokens(tokens, issuerUrl);
|
|
506
521
|
log("Sign-in complete.");
|
|
507
522
|
return tokens;
|
|
508
523
|
}
|
|
@@ -662,4 +677,12 @@ if (isMainModule) {
|
|
|
662
677
|
});
|
|
663
678
|
}
|
|
664
679
|
|
|
665
|
-
export {
|
|
680
|
+
export {
|
|
681
|
+
respondWithSignInError,
|
|
682
|
+
forwardLine,
|
|
683
|
+
detectDefaultBrowser,
|
|
684
|
+
readLoopbackTokens,
|
|
685
|
+
writeLoopbackTokens,
|
|
686
|
+
readLoopbackClientInfo,
|
|
687
|
+
writeLoopbackClientInfo,
|
|
688
|
+
};
|