@mnemom/mnemom 0.14.5 → 0.15.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/dist/lib/auth.js CHANGED
@@ -3,13 +3,19 @@
3
3
  *
4
4
  * Stores auth tokens in ~/.mnemom/auth.json (UC-9: no more config.json).
5
5
  * License JWTs are stored alongside auth tokens.
6
+ *
7
+ * As of MNE-806, `mnemom login` authenticates against the Mnemom OAuth
8
+ * Authorization Server (see lib/oauth.ts) and persists SCOPED OAuth tokens
9
+ * (access + optional refresh, scope, token_type, and the registered client_id
10
+ * needed to refresh) rather than a full Supabase session. The stored shape
11
+ * stays backward-compatible: `email`/`userId` are optional (opaque OAuth access
12
+ * tokens don't carry identity) and pre-existing Supabase sessions on disk
13
+ * continue to resolve until they expire.
6
14
  */
7
15
  import * as fs from "node:fs";
8
16
  import * as path from "node:path";
9
- import * as http from "node:http";
10
- import * as crypto from "node:crypto";
11
- import { exec } from "node:child_process";
12
- import { getApiUrl, getWebsiteUrl, MNEMOM_DIR } from "./config.js";
17
+ import { MNEMOM_DIR } from "./config.js";
18
+ import { loginWithLoopback, loginWithDevice, refreshTokens as oauthRefreshTokens, } from "./oauth.js";
13
19
  // ============================================================================
14
20
  // Auth Store (persisted to ~/.mnemom/auth.json)
15
21
  // ============================================================================
@@ -27,12 +33,22 @@ function loadAuthStore() {
27
33
  }
28
34
  function saveAuthStore(store) {
29
35
  if (!fs.existsSync(MNEMOM_DIR)) {
30
- fs.mkdirSync(MNEMOM_DIR, { recursive: true });
36
+ // 0700: the directory holds bearer credentials — owner-only.
37
+ fs.mkdirSync(MNEMOM_DIR, { recursive: true, mode: 0o700 });
31
38
  }
32
39
  const resolvedPath = path.resolve(AUTH_FILE);
33
40
  const sanitized = JSON.parse(JSON.stringify(store));
34
41
  const tmpFile = `${resolvedPath}.${process.pid}.tmp`;
35
- fs.writeFileSync(tmpFile, JSON.stringify(sanitized, null, 2));
42
+ // 0600 from creation — the temp file already contains the secret, so the
43
+ // restrictive mode must be set at write time, not after rename.
44
+ fs.writeFileSync(tmpFile, JSON.stringify(sanitized, null, 2), { mode: 0o600 });
45
+ // Belt-and-suspenders: if the file pre-existed with looser perms, tighten it.
46
+ try {
47
+ fs.chmodSync(tmpFile, 0o600);
48
+ }
49
+ catch {
50
+ /* best effort on platforms without POSIX perms */
51
+ }
36
52
  fs.renameSync(tmpFile, resolvedPath);
37
53
  }
38
54
  // ============================================================================
@@ -71,40 +87,16 @@ export function clearLicenseJwt() {
71
87
  export function getLicenseJwt() {
72
88
  return loadAuthStore()?.licenseJwt ?? null;
73
89
  }
74
- /** Sanitize file-sourced data before use in outbound HTTP requests. */
75
- function sanitizeForHttp(data) {
76
- return String(data).trim();
77
- }
78
- /**
79
- * Decode a JWT's `exp` claim (unix seconds), or null if the token can't be
80
- * parsed. We use the JWT's own exp as the source of truth for expiry rather
81
- * than `expires_in` returned by the auth endpoint — the two can disagree
82
- * (Supabase has been observed reporting expires_in values longer than the
83
- * JWT's actual exp), and a divergence makes `whoami` cheerfully report a
84
- * "valid" token while every authenticated API call gets 401.
85
- */
86
- function jwtExpSeconds(accessToken) {
87
- const parts = accessToken.split(".");
88
- if (parts.length !== 3)
89
- return null;
90
- try {
91
- const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
92
- if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) {
93
- return null;
94
- }
95
- return payload.exp;
96
- }
97
- catch {
98
- return null;
99
- }
100
- }
101
- /**
102
- * Compute the effective expiresAt for a freshly issued access token.
103
- * Prefers the JWT's own `exp` claim; falls back to `now + expires_in` if the
104
- * token can't be parsed (e.g. an opaque token).
105
- */
106
- export function computeExpiresAt(accessToken, expiresInSeconds) {
107
- return jwtExpSeconds(accessToken) ?? Math.floor(Date.now() / 1000) + expiresInSeconds;
90
+ /** Convert OAuth tokens (lib/oauth.ts) into the persisted auth-store shape. */
91
+ function fromOAuthTokens(tokens, clientId) {
92
+ return {
93
+ accessToken: tokens.accessToken,
94
+ refreshToken: tokens.refreshToken,
95
+ expiresAt: tokens.expiresAt,
96
+ scope: tokens.scope,
97
+ tokenType: tokens.tokenType,
98
+ clientId,
99
+ };
108
100
  }
109
101
  /**
110
102
  * Get a valid access token, or null if not authenticated.
@@ -126,7 +118,7 @@ export async function getAccessToken() {
126
118
  return auth.accessToken;
127
119
  }
128
120
  // Auto-refresh
129
- const refreshed = await refreshAccessToken(auth.refreshToken);
121
+ const refreshed = await refreshStoredTokens(auth);
130
122
  if (refreshed)
131
123
  return refreshed.accessToken;
132
124
  return null;
@@ -147,7 +139,7 @@ export async function forceRefreshAccessToken() {
147
139
  const auth = getAuthInfo();
148
140
  if (!auth?.refreshToken)
149
141
  return null;
150
- const refreshed = await refreshAccessToken(auth.refreshToken);
142
+ const refreshed = await refreshStoredTokens(auth);
151
143
  return refreshed?.accessToken ?? null;
152
144
  }
153
145
  /**
@@ -203,171 +195,49 @@ export async function isLoggedIn() {
203
195
  return cred.type !== "none";
204
196
  }
205
197
  // ============================================================================
206
- // Browser login flow
198
+ // Interactive login (OAuth 2.1 against the Mnemom Authorization Server)
207
199
  // ============================================================================
200
+ /**
201
+ * Interactive browser login: OAuth 2.1 authorization-code + PKCE with a
202
+ * loopback redirect (the `wrangler login` pattern). Persists the resulting
203
+ * SCOPED tokens (plus the client_id needed to refresh) to ~/.mnemom/auth.json.
204
+ */
208
205
  export async function loginWithBrowser() {
209
- const state = crypto.randomBytes(16).toString("hex");
210
- const { port, tokenPromise, close } = await startCallbackServer(state);
211
- const callbackUrl = `http://127.0.0.1:${port}/callback`;
212
- const loginUrl = `${getWebsiteUrl()}/login?cli_callback=${encodeURIComponent(callbackUrl)}&state=${encodeURIComponent(state)}`;
213
- console.log("Opening browser to authenticate...");
214
- console.log(`If the browser doesn't open, visit:\n ${loginUrl}\n`);
215
- openBrowser(loginUrl);
216
- console.log("Waiting for authentication...");
217
- try {
218
- const tokens = await tokenPromise;
219
- saveAuthTokens(tokens);
220
- return tokens;
221
- }
222
- finally {
223
- close();
224
- }
225
- }
226
- async function startCallbackServer(expectedState) {
227
- let resolveTokens;
228
- let rejectTokens;
229
- const tokenPromise = new Promise((resolve, reject) => {
230
- resolveTokens = resolve;
231
- rejectTokens = reject;
232
- });
233
- const server = http.createServer((req, res) => {
234
- if (req.method === "OPTIONS") {
235
- res.writeHead(200, {
236
- "Access-Control-Allow-Origin": "*",
237
- "Access-Control-Allow-Methods": "POST, OPTIONS",
238
- "Access-Control-Allow-Headers": "Content-Type",
239
- });
240
- res.end();
241
- return;
242
- }
243
- if (req.method !== "POST" || req.url !== "/callback") {
244
- res.writeHead(404, { "Content-Type": "text/plain" });
245
- res.end("Not found");
246
- return;
247
- }
248
- let body = "";
249
- req.on("data", (chunk) => {
250
- body += chunk.toString();
251
- if (body.length > 1_000_000) {
252
- req.destroy();
253
- rejectTokens(new Error("Callback body too large"));
254
- }
255
- });
256
- req.on("end", () => {
257
- try {
258
- const data = JSON.parse(body);
259
- if (data.state !== expectedState) {
260
- res.writeHead(403, {
261
- "Content-Type": "text/html",
262
- "Access-Control-Allow-Origin": "*",
263
- });
264
- res.end("<html><body><h2>Authentication failed</h2><p>State mismatch.</p></body></html>");
265
- rejectTokens(new Error("State mismatch — possible CSRF attack"));
266
- return;
267
- }
268
- const tokens = {
269
- accessToken: data.access_token,
270
- refreshToken: data.refresh_token,
271
- expiresAt: computeExpiresAt(data.access_token, data.expires_in),
272
- userId: data.user_id,
273
- email: data.user_email,
274
- };
275
- res.writeHead(200, {
276
- "Content-Type": "text/html",
277
- "Access-Control-Allow-Origin": "*",
278
- });
279
- res.end(`<html><body style="font-family:system-ui;text-align:center;padding:60px">
280
- <h2>Authenticated!</h2>
281
- <p>You can close this tab and return to the terminal.</p>
282
- </body></html>`);
283
- resolveTokens(tokens);
284
- }
285
- catch {
286
- res.writeHead(400, {
287
- "Content-Type": "text/html",
288
- "Access-Control-Allow-Origin": "*",
289
- });
290
- res.end("<html><body><h2>Authentication failed</h2><p>Invalid callback data.</p></body></html>");
291
- rejectTokens(new Error("Invalid callback data"));
292
- }
293
- });
294
- });
295
- const port = await new Promise((resolve) => {
296
- server.listen(0, "127.0.0.1", () => {
297
- resolve(server.address().port);
298
- });
299
- });
300
- const timeout = setTimeout(() => {
301
- rejectTokens(new Error("Login timed out. Please try again."));
302
- server.close();
303
- }, 5 * 60 * 1000);
304
- return {
305
- port,
306
- tokenPromise,
307
- close: () => {
308
- clearTimeout(timeout);
309
- server.close();
310
- },
311
- };
206
+ const { tokens, clientId } = await loginWithLoopback();
207
+ const stored = fromOAuthTokens(tokens, clientId);
208
+ saveAuthTokens(stored);
209
+ return stored;
312
210
  }
313
- function openBrowser(url) {
314
- const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
315
- exec(`${cmd} ${JSON.stringify(url)}`);
316
- }
317
- // ============================================================================
318
- // Password login
319
- // ============================================================================
320
- export async function loginWithPassword(email, password) {
321
- const url = `${getApiUrl()}/v1/auth/login`;
322
- const res = await fetch(url, {
323
- method: "POST",
324
- headers: { "Content-Type": "application/json" },
325
- body: JSON.stringify({ email, password }),
326
- });
327
- if (!res.ok) {
328
- const body = (await res.json().catch(() => ({})));
329
- throw new Error(body.error || body.message || "Login failed");
330
- }
331
- const data = (await res.json());
332
- const tokens = {
333
- accessToken: data.access_token,
334
- refreshToken: data.refresh_token,
335
- expiresAt: computeExpiresAt(data.access_token, data.expires_in),
336
- userId: data.user.id,
337
- email: data.user.email,
338
- };
339
- saveAuthTokens(tokens);
340
- return tokens;
211
+ /**
212
+ * Headless login: RFC 8628 device authorization grant. Shows a user_code +
213
+ * verification_uri for the user to approve in any browser (possibly on another
214
+ * device), polls until approval, and persists the scoped tokens.
215
+ */
216
+ export async function loginWithDeviceFlow() {
217
+ const { tokens, clientId } = await loginWithDevice();
218
+ const stored = fromOAuthTokens(tokens, clientId);
219
+ saveAuthTokens(stored);
220
+ return stored;
341
221
  }
342
222
  // ============================================================================
343
- // Token refresh
223
+ // Token refresh (OAuth refresh_token grant)
344
224
  // ============================================================================
345
- async function refreshAccessToken(refreshToken) {
346
- if (!refreshToken || typeof refreshToken !== "string") {
225
+ /**
226
+ * Refresh the stored tokens via the OAuth refresh_token grant. Requires both a
227
+ * refresh token and the client_id they were issued to; returns null (rather
228
+ * than throwing) when refresh isn't possible so callers degrade to re-login.
229
+ */
230
+ async function refreshStoredTokens(auth) {
231
+ if (!auth.refreshToken || !auth.clientId)
347
232
  return null;
348
- }
349
- const url = new URL(`${getApiUrl()}/v1/auth/refresh`).href;
350
- try {
351
- const res = await fetch(url, {
352
- method: "POST",
353
- headers: { "Content-Type": "application/json" },
354
- body: sanitizeForHttp(JSON.stringify({ refresh_token: String(refreshToken) })),
355
- });
356
- if (!res.ok)
357
- return null;
358
- const data = (await res.json());
359
- const existing = getAuthInfo();
360
- const tokens = {
361
- accessToken: data.access_token,
362
- refreshToken: data.refresh_token,
363
- expiresAt: computeExpiresAt(data.access_token, data.expires_in),
364
- userId: existing?.userId ?? "",
365
- email: existing?.email ?? "",
366
- };
367
- saveAuthTokens(tokens);
368
- return tokens;
369
- }
370
- catch {
233
+ const refreshed = await oauthRefreshTokens(auth.refreshToken, auth.clientId);
234
+ if (!refreshed)
371
235
  return null;
372
- }
236
+ const stored = fromOAuthTokens(refreshed, auth.clientId);
237
+ // Preserve any legacy identity fields so whoami/status keep working across a
238
+ // refresh of a token set that still carries them.
239
+ stored.userId = auth.userId;
240
+ stored.email = auth.email;
241
+ saveAuthTokens(stored);
242
+ return stored;
373
243
  }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * OAuth 2.1 client for the Mnemom Authorization Server.
3
+ *
4
+ * `mnemom login` authenticates against the Mnemom OAuth AS (the same AS that
5
+ * fronts the MCP control plane) instead of doing a raw Supabase session login.
6
+ * This yields SCOPED access tokens (`mcp:read` / `mcp:write`) — not the full
7
+ * Supabase god-token — so a leaked CLI credential can only do what the CLI was
8
+ * granted, and grants are independently revocable server-side (MNE-805/806).
9
+ *
10
+ * Two interactive flows, both standards-based:
11
+ * - Authorization Code + PKCE with a loopback redirect (the `wrangler login`
12
+ * pattern): open the browser to /authorize, catch the redirect on a
13
+ * localhost listener, exchange code+verifier at /token.
14
+ * - RFC 8628 Device Authorization Grant (`--no-browser` / headless): POST
15
+ * /device_authorization, print the user_code + verification_uri, poll
16
+ * /token honoring `authorization_pending` and `slow_down`.
17
+ *
18
+ * Everything is driven off RFC 8414 discovery
19
+ * (/.well-known/oauth-authorization-server) — endpoint paths are NOT hardcoded.
20
+ * The public client_id is obtained via RFC 7591 Dynamic Client Registration and
21
+ * cached, so there is no client secret to embed (the AS advertises
22
+ * token_endpoint_auth_method "none" — public clients only).
23
+ */
24
+ export interface AuthServerMetadata {
25
+ issuer: string;
26
+ authorization_endpoint: string;
27
+ token_endpoint: string;
28
+ device_authorization_endpoint?: string;
29
+ registration_endpoint?: string;
30
+ revocation_endpoint?: string;
31
+ scopes_supported?: string[];
32
+ grant_types_supported?: string[];
33
+ code_challenge_methods_supported?: string[];
34
+ token_endpoint_auth_methods_supported?: string[];
35
+ }
36
+ /**
37
+ * Fetch (and process-cache) the AS metadata document. We resolve it relative to
38
+ * the active API base URL so staging/local point at their own AS.
39
+ */
40
+ export declare function discover(): Promise<AuthServerMetadata>;
41
+ /** Reset the process-level discovery cache (used by tests). */
42
+ export declare function _resetDiscoveryCache(): void;
43
+ /**
44
+ * Register a public client for the CLI and return its client_id. The AS issues
45
+ * public clients (token_endpoint_auth_method "none"), so there is no secret to
46
+ * persist. The caller is responsible for caching the returned id.
47
+ */
48
+ export declare function registerClient(redirectUris: string[]): Promise<string>;
49
+ export interface Pkce {
50
+ verifier: string;
51
+ challenge: string;
52
+ method: "S256";
53
+ }
54
+ /**
55
+ * Generate a PKCE verifier/challenge pair. The verifier is a high-entropy
56
+ * URL-safe string (RFC 7636 §4.1: 43–128 chars from the unreserved set); the
57
+ * challenge is BASE64URL(SHA256(verifier)) for the S256 method (the only method
58
+ * the AS advertises, and the only one allowed under OAuth 2.1).
59
+ */
60
+ export declare function generatePkce(): Pkce;
61
+ export interface OAuthTokenResponse {
62
+ access_token: string;
63
+ token_type?: string;
64
+ expires_in?: number;
65
+ refresh_token?: string;
66
+ scope?: string;
67
+ }
68
+ export interface OAuthTokens {
69
+ accessToken: string;
70
+ tokenType: string;
71
+ refreshToken?: string;
72
+ scope?: string;
73
+ /** Unix seconds. */
74
+ expiresAt: number;
75
+ }
76
+ export interface LoopbackResult {
77
+ tokens: OAuthTokens;
78
+ clientId: string;
79
+ }
80
+ /**
81
+ * Run the interactive authorization-code + PKCE login. Returns the issued
82
+ * scoped tokens plus the client_id that was used (so the caller can persist it
83
+ * for refresh). `openUrl` is injectable so tests can drive the flow without
84
+ * spawning a real browser.
85
+ */
86
+ export declare function loginWithLoopback(openUrl?: (url: string) => void): Promise<LoopbackResult>;
87
+ export interface DeviceResult {
88
+ tokens: OAuthTokens;
89
+ clientId: string;
90
+ }
91
+ /**
92
+ * Run the RFC 8628 device authorization grant. Registers a client (the device
93
+ * flow needs no redirect, but the AS's DCR validation requires a valid
94
+ * redirect_uri — HTTPS or a loopback — so we register a loopback placeholder it
95
+ * will never redirect to), requests a device code, prints the user_code +
96
+ * verification_uri for the user to approve in any browser, then polls the token
97
+ * endpoint until approval — honoring `authorization_pending` and `slow_down`
98
+ * per the spec.
99
+ *
100
+ * `display` and `sleep` are injectable so tests can drive the poll loop
101
+ * deterministically without real timers or stdout.
102
+ */
103
+ export declare function loginWithDevice(opts?: {
104
+ display?: (line: string) => void;
105
+ sleep?: (ms: number) => Promise<void>;
106
+ }): Promise<DeviceResult>;
107
+ /**
108
+ * Exchange a refresh token for a fresh access token. Returns null if refresh is
109
+ * not possible (no refresh token, or the AS rejects it — e.g. revoked/expired),
110
+ * so callers can fall back to prompting for re-login rather than crashing.
111
+ */
112
+ export declare function refreshTokens(refreshToken: string, clientId: string): Promise<OAuthTokens | null>;
113
+ /**
114
+ * Open `url` in the user's default browser WITHOUT a shell. The URL is always
115
+ * passed as a separate argv element (never concatenated into a command string),
116
+ * so shell metacharacters — `$(...)`, backticks, `\` — in the URL can never be
117
+ * interpreted as a command. Fire-and-forget: any failure (no opener installed,
118
+ * headless host) is swallowed because callers already print a manual-URL
119
+ * fallback, so a missing browser must not crash login.
120
+ */
121
+ export declare function openBrowser(url: string): void;