@mathismeadows/roamer-device-auth 1.2.0 → 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 +63 -36
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,
|
|
@@ -30,6 +32,7 @@ import {
|
|
|
30
32
|
refreshAuthorization,
|
|
31
33
|
} from "@modelcontextprotocol/sdk/client/auth.js";
|
|
32
34
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
35
|
+
import { realpathSync } from "node:fs";
|
|
33
36
|
import { homedir } from "node:os";
|
|
34
37
|
import { join } from "node:path";
|
|
35
38
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
@@ -110,41 +113,50 @@ function clearCachedTokens() {
|
|
|
110
113
|
return writeFile(CACHE_FILE, "{}", { mode: 0o600 }).catch(() => {});
|
|
111
114
|
}
|
|
112
115
|
|
|
113
|
-
// AUTH-14: loopback-flow
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
|
|
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) {
|
|
117
124
|
try {
|
|
118
125
|
const tokens = JSON.parse(await readFile(LOOPBACK_TOKENS_FILE, "utf8"));
|
|
126
|
+
if (tokens?.issuerUrl !== issuerUrl) return null;
|
|
119
127
|
return tokens?.access_token ? tokens : null;
|
|
120
128
|
} catch {
|
|
121
129
|
return null;
|
|
122
130
|
}
|
|
123
131
|
}
|
|
124
132
|
|
|
125
|
-
async function writeLoopbackTokens(tokens) {
|
|
133
|
+
async function writeLoopbackTokens(tokens, issuerUrl) {
|
|
126
134
|
await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
127
|
-
|
|
135
|
+
const stamped = { ...tokens, issuerUrl };
|
|
136
|
+
await writeFile(LOOPBACK_TOKENS_FILE, JSON.stringify(stamped, null, 2), { mode: 0o600 });
|
|
128
137
|
}
|
|
129
138
|
|
|
130
139
|
function clearLoopbackTokens() {
|
|
131
140
|
return writeFile(LOOPBACK_TOKENS_FILE, "{}", { mode: 0o600 }).catch(() => {});
|
|
132
141
|
}
|
|
133
142
|
|
|
134
|
-
// DCR'd client registration is reused across runs —
|
|
135
|
-
// no reason to be hit on every single sign-in, only the first one (or
|
|
136
|
-
|
|
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) {
|
|
137
147
|
try {
|
|
138
148
|
const info = JSON.parse(await readFile(LOOPBACK_CLIENT_FILE, "utf8"));
|
|
149
|
+
if (info?.issuerUrl !== issuerUrl) return null;
|
|
139
150
|
return info?.client_id ? info : null;
|
|
140
151
|
} catch {
|
|
141
152
|
return null;
|
|
142
153
|
}
|
|
143
154
|
}
|
|
144
155
|
|
|
145
|
-
async function writeLoopbackClientInfo(info) {
|
|
156
|
+
async function writeLoopbackClientInfo(info, issuerUrl) {
|
|
146
157
|
await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
147
|
-
|
|
158
|
+
const stamped = { ...info, issuerUrl };
|
|
159
|
+
await writeFile(LOOPBACK_CLIENT_FILE, JSON.stringify(stamped, null, 2), { mode: 0o600 });
|
|
148
160
|
}
|
|
149
161
|
|
|
150
162
|
// AUTH-14: which auth mechanism a given machine uses. Safari-default clients (macOS only —
|
|
@@ -175,7 +187,7 @@ async function detectDefaultBrowser() {
|
|
|
175
187
|
|
|
176
188
|
// Claude Code (or any MCP host) may kill this process and respawn a fresh one if it doesn't
|
|
177
189
|
// see a stdio handshake within its own connect timeout — and the interactive device flow
|
|
178
|
-
// (dialog -> browser ->
|
|
190
|
+
// (dialog -> browser -> Entra's consent screen -> poll) routinely takes longer than a
|
|
179
191
|
// human can click through inside a short timeout. Without persisting the in-progress flow,
|
|
180
192
|
// every respawn called /oauth/device/start again, which mints a BRAND NEW device_code/
|
|
181
193
|
// user_code — the user would see a different code every single retry, forever, with no
|
|
@@ -340,8 +352,8 @@ async function doGetValidTokens(forceRefresh) {
|
|
|
340
352
|
}
|
|
341
353
|
}
|
|
342
354
|
|
|
343
|
-
// AUTH-14: fixed, well-known port for the loopback callback —
|
|
344
|
-
// 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
|
|
345
357
|
// readLoopbackClientInfo), so the redirect_uri baked into that registration has to stay
|
|
346
358
|
// stable across runs. A fixed port sidesteps re-registering on every single interactive
|
|
347
359
|
// sign-in; the tradeoff (a port-in-use conflict is possible, if rare) is the same one this
|
|
@@ -412,21 +424,26 @@ function getValidTokensLoopback(forceRefresh = false) {
|
|
|
412
424
|
}
|
|
413
425
|
|
|
414
426
|
async function doGetValidTokensLoopback(forceRefresh) {
|
|
415
|
-
|
|
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);
|
|
416
434
|
|
|
417
435
|
if (!forceRefresh && tokens?.access_token && !expiresSoon(tokens)) {
|
|
418
436
|
return tokens;
|
|
419
437
|
}
|
|
420
438
|
|
|
421
|
-
|
|
422
|
-
let clientInformation = await readLoopbackClientInfo();
|
|
439
|
+
let clientInformation = await readLoopbackClientInfo(issuerUrl);
|
|
423
440
|
|
|
424
441
|
if (!forceRefresh && tokens?.refresh_token && clientInformation) {
|
|
425
442
|
try {
|
|
426
443
|
log("Refreshing cached token...");
|
|
427
444
|
const fresh = await refreshLoopbackTokens(tokens.refresh_token, serverInfo, clientInformation);
|
|
428
445
|
tokens = { ...fresh, obtained_at: Date.now() };
|
|
429
|
-
await writeLoopbackTokens(tokens);
|
|
446
|
+
await writeLoopbackTokens(tokens, issuerUrl);
|
|
430
447
|
return tokens;
|
|
431
448
|
} catch (err) {
|
|
432
449
|
log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
|
|
@@ -436,7 +453,7 @@ async function doGetValidTokensLoopback(forceRefresh) {
|
|
|
436
453
|
if (forceRefresh) await clearLoopbackTokens();
|
|
437
454
|
|
|
438
455
|
if (!clientInformation) {
|
|
439
|
-
log("Registering as a new OAuth client
|
|
456
|
+
log("Registering as a new OAuth client...");
|
|
440
457
|
clientInformation = await registerClient(serverInfo.authorizationServerUrl, {
|
|
441
458
|
metadata: serverInfo.authorizationServerMetadata,
|
|
442
459
|
clientMetadata: {
|
|
@@ -447,21 +464,20 @@ async function doGetValidTokensLoopback(forceRefresh) {
|
|
|
447
464
|
token_endpoint_auth_method: "none",
|
|
448
465
|
},
|
|
449
466
|
});
|
|
450
|
-
await writeLoopbackClientInfo(clientInformation);
|
|
467
|
+
await writeLoopbackClientInfo(clientInformation, issuerUrl);
|
|
451
468
|
}
|
|
452
469
|
|
|
453
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).
|
|
454
477
|
const { authorizationUrl, codeVerifier } = await startAuthorization(serverInfo.authorizationServerUrl, {
|
|
455
478
|
metadata: serverInfo.authorizationServerMetadata,
|
|
456
479
|
clientInformation,
|
|
457
480
|
redirectUrl: LOOPBACK_REDIRECT_URI,
|
|
458
|
-
// RFC 8707: Cloudflare's authorization endpoint rejects the request without this
|
|
459
|
-
// (invalid_target / "No resource parameter found") — the exact same requirement
|
|
460
|
-
// DeviceFlowService.cs already hit and fixed server-side for the device-code path
|
|
461
|
-
// (confirmed live 2026-08-21) and just reconfirmed live here 2026-08-26. Only the
|
|
462
|
-
// authorization step needs it; DeviceFlowService.cs's token/refresh calls don't
|
|
463
|
-
// reference it at all, so exchangeAuthorization/refreshAuthorization below don't either.
|
|
464
|
-
resource: new URL(ROAMER_MCP_URL),
|
|
465
481
|
});
|
|
466
482
|
|
|
467
483
|
const server = createServer();
|
|
@@ -501,7 +517,7 @@ async function doGetValidTokensLoopback(forceRefresh) {
|
|
|
501
517
|
redirectUri: LOOPBACK_REDIRECT_URI,
|
|
502
518
|
});
|
|
503
519
|
tokens = { ...fresh, obtained_at: Date.now() };
|
|
504
|
-
await writeLoopbackTokens(tokens);
|
|
520
|
+
await writeLoopbackTokens(tokens, issuerUrl);
|
|
505
521
|
log("Sign-in complete.");
|
|
506
522
|
return tokens;
|
|
507
523
|
}
|
|
@@ -650,7 +666,10 @@ async function main() {
|
|
|
650
666
|
|
|
651
667
|
// Only auto-run when invoked directly (npx/CLI) — importing this module from a test file
|
|
652
668
|
// must not trigger a live device-auth flow and stdio takeover as a side effect.
|
|
653
|
-
|
|
669
|
+
// AUTH-29: npx always launches this via the node_modules/.bin symlink, so argv[1] must be
|
|
670
|
+
// realpath-resolved before comparing — import.meta.url is already realpath-resolved by Node.
|
|
671
|
+
const isMainModule =
|
|
672
|
+
process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
|
|
654
673
|
if (isMainModule) {
|
|
655
674
|
main().catch((err) => {
|
|
656
675
|
log(`Fatal error: ${err.stack ?? err.message}`);
|
|
@@ -658,4 +677,12 @@ if (isMainModule) {
|
|
|
658
677
|
});
|
|
659
678
|
}
|
|
660
679
|
|
|
661
|
-
export {
|
|
680
|
+
export {
|
|
681
|
+
respondWithSignInError,
|
|
682
|
+
forwardLine,
|
|
683
|
+
detectDefaultBrowser,
|
|
684
|
+
readLoopbackTokens,
|
|
685
|
+
writeLoopbackTokens,
|
|
686
|
+
readLoopbackClientInfo,
|
|
687
|
+
writeLoopbackClientInfo,
|
|
688
|
+
};
|