dazzer-connect 0.1.0 → 0.2.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 CHANGED
@@ -1,44 +1,53 @@
1
1
  {
2
2
  "name": "dazzer-connect",
3
3
  "private": false,
4
- "version": "0.1.0",
5
- "description": "Sign in to Dazzer from your terminal and configure MCP clients",
4
+ "version": "0.2.0",
5
+ "description": "Sign in to Dazzer \u2014 the memory your AI tools share \u2014 from a terminal, for clients that cannot sign in themselves",
6
6
  "license": "MIT",
7
7
  "type": "module",
8
8
  "bin": {
9
9
  "dazzer": "dist/index.js"
10
10
  },
11
- "files": ["dist/**/*.js", "LICENSE"],
11
+ "files": [
12
+ "dist/**/*.js",
13
+ "LICENSE"
14
+ ],
12
15
  "engines": {
13
- "node": ">=20"
16
+ "node": ">=22.12.0"
14
17
  },
15
- "repository": {
16
- "type": "git",
17
- "url": "git+https://github.com/dazzer-io/dazzer.git",
18
- "directory": "packages/oauth-cli"
19
- },
20
- "homepage": "https://github.com/dazzer-io/dazzer/tree/main/packages/oauth-cli",
21
- "bugs": {
22
- "url": "https://github.com/dazzer-io/dazzer/issues"
23
- },
24
- "keywords": ["dazzer", "oauth", "cli", "mcp", "auth"],
18
+ "homepage": "https://dazzer.io",
19
+ "keywords": [
20
+ "dazzer",
21
+ "mcp",
22
+ "memory",
23
+ "ai",
24
+ "cli",
25
+ "oauth",
26
+ "device-flow"
27
+ ],
25
28
  "publishConfig": {
26
29
  "access": "public"
27
30
  },
28
31
  "scripts": {
29
- "build": "tsc --build",
32
+ "build": "node scripts/build.mjs",
33
+ "typecheck": "tsc --build",
30
34
  "dev": "tsx src/index.ts"
31
35
  },
32
36
  "dependencies": {
33
- "@napi-rs/keyring": "^1.2.0",
34
- "commander": "^14.0.0",
35
- "env-paths": "^3.0.0",
36
- "zod": "^4.3.6"
37
+ "@napi-rs/keyring": "^2.0.0",
38
+ "commander": "^15.0.0",
39
+ "env-paths": "^4.0.0",
40
+ "zod": "^4.6.1"
37
41
  },
38
42
  "devDependencies": {
39
- "@types/node": "^22.10.2",
40
- "tsx": "^4.19.2",
43
+ "@dazzer/auth-ui": "workspace:*",
44
+ "@types/node": "^26.5.1",
45
+ "esbuild": "^0.28.2",
46
+ "tsx": "^4.23.13",
41
47
  "typescript": "^5.7.2",
42
- "vitest": "^4.1.5"
48
+ "vitest": "^4.1.11"
49
+ },
50
+ "bugs": {
51
+ "email": "support@dazzer.io"
43
52
  }
44
53
  }
@@ -1,72 +0,0 @@
1
- /**
2
- * @purpose Runs the browser loopback sign-in flow (gcloud-style) by default and
3
- * falls back to the device-code flow for headless environments, then stores
4
- * tokens and prints copy-paste-ready MCP client config for the user.
5
- */
6
- import { saveAuthMetadata } from "../lib/config-store.js";
7
- import { pollForTokens, requestDeviceCode } from "../lib/device-flow.js";
8
- import { discoverEndpoints } from "../lib/discovery.js";
9
- import { browserLikelyAvailable, runLoopbackFlow } from "../lib/loopback-flow.js";
10
- import { fail, printBrowserOpening, printDeviceInstructions, printLoginSuccess, printMcpClientConfig, } from "../lib/output.js";
11
- import { persistLoginTokens } from "../lib/refresh.js";
12
- import { createTokenStore } from "../lib/token-store.js";
13
- /** Executes auth login: browser loopback (or device fallback), keychain storage, config output. */
14
- export async function runLogin(options) {
15
- const tokenStore = createTokenStore();
16
- let keychainOk = true;
17
- let discovery;
18
- try {
19
- discovery = await discoverEndpoints(options.issuer);
20
- }
21
- catch (err) {
22
- fail(err instanceof Error ? err.message : "Could not reach the authorization server.");
23
- }
24
- // Loopback (gcloud-style) is the default; device-code is the headless fallback.
25
- const useLoopback = !options.noBrowser && Boolean(discovery.authorizationEndpoint) && browserLikelyAvailable();
26
- let tokens;
27
- try {
28
- if (useLoopback) {
29
- tokens = await runLoopbackFlow({
30
- discovery,
31
- clientId: options.clientId,
32
- resource: options.resource,
33
- onPrompt: printBrowserOpening,
34
- });
35
- }
36
- else {
37
- const deviceAuth = await requestDeviceCode({
38
- discovery,
39
- clientId: options.clientId,
40
- });
41
- printDeviceInstructions(deviceAuth);
42
- tokens = await pollForTokens({
43
- discovery,
44
- clientId: options.clientId,
45
- deviceCode: deviceAuth.device_code,
46
- deviceAuth,
47
- resource: options.resource,
48
- });
49
- }
50
- }
51
- catch (err) {
52
- fail(err instanceof Error ? err.message : "Sign-in failed.");
53
- }
54
- if (!tokens.refresh_token) {
55
- fail("The authorization server did not return a refresh token.");
56
- }
57
- try {
58
- await persistLoginTokens(tokenStore, tokens);
59
- }
60
- catch {
61
- keychainOk = false;
62
- }
63
- const expiresAt = typeof tokens.expires_in === "number" ? Date.now() + tokens.expires_in * 1000 : null;
64
- await saveAuthMetadata({
65
- issuer: discovery.issuer,
66
- clientId: options.clientId,
67
- expiresAt,
68
- });
69
- printLoginSuccess(keychainOk);
70
- // The MCP client connects to the resource server (the Brain), not the issuer.
71
- printMcpClientConfig(tokens.access_token, options.resource ?? discovery.issuer);
72
- }
@@ -1,21 +0,0 @@
1
- /**
2
- * @purpose Clears stored tokens and auth metadata so the user is fully signed out.
3
- */
4
- import { clearAuthMetadata, loadAuthMetadata } from "../lib/config-store.js";
5
- import { printLogoutSuccess } from "../lib/output.js";
6
- import { createTokenStore } from "../lib/token-store.js";
7
- /** Executes auth logout: keychain clear + config file removal. */
8
- export async function runLogout() {
9
- const tokenStore = createTokenStore();
10
- await tokenStore.clear();
11
- await clearAuthMetadata();
12
- printLogoutSuccess();
13
- }
14
- /** Returns whether the user appears signed in (metadata + tokens present). */
15
- export async function isSignedIn() {
16
- const meta = await loadAuthMetadata();
17
- if (!meta)
18
- return false;
19
- const tokens = await createTokenStore().load();
20
- return tokens !== null;
21
- }
@@ -1,38 +0,0 @@
1
- /**
2
- * @purpose Shows whether the user is signed in and attempts a transparent refresh
3
- * so status reflects a currently valid access token when possible.
4
- */
5
- import { loadAuthMetadata } from "../lib/config-store.js";
6
- import { discoverEndpoints } from "../lib/discovery.js";
7
- import { printAuthStatus } from "../lib/output.js";
8
- import { getValidAccessToken } from "../lib/refresh.js";
9
- import { createTokenStore } from "../lib/token-store.js";
10
- /** Executes auth status: load metadata, refresh if possible, print result. */
11
- export async function runStatus(options) {
12
- const tokenStore = createTokenStore();
13
- const meta = await loadAuthMetadata();
14
- const tokens = await tokenStore.load();
15
- if (!meta || !tokens) {
16
- printAuthStatus({ signedIn: false });
17
- return;
18
- }
19
- const issuer = options.issuer ?? meta.issuer;
20
- const clientId = options.clientId ?? meta.clientId;
21
- let expiresAt = meta.expiresAt;
22
- try {
23
- const discovery = await discoverEndpoints(issuer);
24
- const refreshed = await getValidAccessToken({
25
- discovery,
26
- clientId,
27
- tokenStore,
28
- resource: options.resource,
29
- });
30
- if (refreshed?.expiresIn) {
31
- expiresAt = Date.now() + refreshed.expiresIn * 1000;
32
- }
33
- }
34
- catch {
35
- // show last known status
36
- }
37
- printAuthStatus({ signedIn: true, issuer: meta.issuer, expiresAt });
38
- }
package/dist/env.js DELETED
@@ -1,25 +0,0 @@
1
- /**
2
- * @purpose Validates CLI environment variables once at startup so every command
3
- * receives a typed, fail-fast config snapshot instead of ad-hoc parsing.
4
- */
5
- import { z } from "zod/v4";
6
- const cliEnvSchema = z.object({
7
- /** Authorization server issuer URL. Defaults to production; override for local dev. */
8
- DAZZER_AUTH_ISSUER: z.url().default("https://auth.dazzer.io"),
9
- /** OAuth public client id. */
10
- DAZZER_AUTH_CLIENT_ID: z.string().min(1).default("dazzer-cli-local"),
11
- /** Resource indicator for JWT access tokens. Defaults to the production MCP server. */
12
- DAZZER_AUTH_RESOURCE: z.url().default("https://graph.dazzer.io/mcp"),
13
- });
14
- let cachedEnv = null;
15
- /** Validates process.env once and returns the typed CLI env snapshot. */
16
- export function validateEnv() {
17
- if (!cachedEnv) {
18
- cachedEnv = cliEnvSchema.parse(process.env);
19
- }
20
- return cachedEnv;
21
- }
22
- /** Test helper: clears lazy env cache between cases. */
23
- export function resetEnvCacheForTests() {
24
- cachedEnv = null;
25
- }
@@ -1,44 +0,0 @@
1
- /**
2
- * @purpose Persists non-secret auth metadata on disk so status and refresh know
3
- * which issuer and client issued the current tokens without storing secrets.
4
- */
5
- import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
6
- import { join } from "node:path";
7
- import envPaths from "env-paths";
8
- const paths = envPaths("dazzer", { suffix: "" });
9
- const CONFIG_FILE = join(paths.config, "auth.json");
10
- async function ensureConfigDir() {
11
- await mkdir(paths.config, { recursive: true });
12
- }
13
- /** Saves non-secret auth metadata to the CLI config directory. */
14
- export async function saveAuthMetadata(meta) {
15
- await ensureConfigDir();
16
- await writeFile(CONFIG_FILE, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
17
- }
18
- /** Loads auth metadata, or null if the user has never signed in. */
19
- export async function loadAuthMetadata() {
20
- try {
21
- const raw = await readFile(CONFIG_FILE, "utf8");
22
- const parsed = JSON.parse(raw);
23
- if (typeof parsed.issuer !== "string" || typeof parsed.clientId !== "string") {
24
- return null;
25
- }
26
- return parsed;
27
- }
28
- catch {
29
- return null;
30
- }
31
- }
32
- /** Removes auth metadata from disk. */
33
- export async function clearAuthMetadata() {
34
- try {
35
- await unlink(CONFIG_FILE);
36
- }
37
- catch {
38
- // ignore missing file
39
- }
40
- }
41
- /** Returns the CLI config directory path (for documentation / diagnostics). */
42
- export function getConfigDir() {
43
- return paths.config;
44
- }
@@ -1,110 +0,0 @@
1
- const DEFAULT_SCOPE = "openid offline_access";
2
- function parseDeviceResponse(body) {
3
- const deviceCode = body.device_code;
4
- const userCode = body.user_code;
5
- const verificationUri = body.verification_uri;
6
- const expiresIn = body.expires_in;
7
- if (typeof deviceCode !== "string" ||
8
- typeof userCode !== "string" ||
9
- typeof verificationUri !== "string" ||
10
- typeof expiresIn !== "number") {
11
- throw new Error("The authorization server returned an unexpected device response.");
12
- }
13
- return {
14
- device_code: deviceCode,
15
- user_code: userCode,
16
- verification_uri: verificationUri,
17
- verification_uri_complete: typeof body.verification_uri_complete === "string"
18
- ? body.verification_uri_complete
19
- : undefined,
20
- expires_in: expiresIn,
21
- interval: typeof body.interval === "number" ? body.interval : undefined,
22
- };
23
- }
24
- /** Requests a device code from the authorization server. */
25
- export async function requestDeviceCode(options) {
26
- const fetchFn = options.fetchFn ?? fetch;
27
- const endpoint = options.discovery.deviceAuthorizationEndpoint;
28
- if (!endpoint) {
29
- throw new Error("This authorization server does not support device sign-in.");
30
- }
31
- const params = new URLSearchParams({
32
- client_id: options.clientId,
33
- scope: options.scope ?? DEFAULT_SCOPE,
34
- });
35
- if (options.resource) {
36
- params.set("resource", options.resource);
37
- }
38
- const res = await fetchFn(endpoint, {
39
- method: "POST",
40
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
41
- body: params.toString(),
42
- });
43
- if (!res.ok) {
44
- throw new Error(`Device sign-in request failed (${res.status}).`);
45
- }
46
- return parseDeviceResponse((await res.json()));
47
- }
48
- /** Polls the token endpoint until the user completes authorization or time runs out. */
49
- export async function pollForTokens(options) {
50
- const fetchFn = options.fetchFn ?? fetch;
51
- const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
52
- const baseIntervalMs = (options.deviceAuth.interval ?? 5) * 1000;
53
- let intervalMs = options.pollIntervalMs ?? baseIntervalMs;
54
- const deadline = Date.now() + (options.maxWaitMs ?? options.deviceAuth.expires_in * 1000);
55
- while (Date.now() < deadline) {
56
- await sleep(intervalMs);
57
- const params = new URLSearchParams({
58
- grant_type: "urn:ietf:params:oauth:grant-type:device_code",
59
- device_code: options.deviceCode,
60
- client_id: options.clientId,
61
- });
62
- if (options.resource) {
63
- params.set("resource", options.resource);
64
- }
65
- const res = await fetchFn(options.discovery.tokenEndpoint, {
66
- method: "POST",
67
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
68
- body: params.toString(),
69
- });
70
- const body = (await res.json());
71
- if (res.ok) {
72
- const accessToken = body.access_token;
73
- if (typeof accessToken !== "string") {
74
- throw new Error("The authorization server returned an unexpected token response.");
75
- }
76
- return {
77
- access_token: accessToken,
78
- refresh_token: typeof body.refresh_token === "string" ? body.refresh_token : undefined,
79
- expires_in: typeof body.expires_in === "number" ? body.expires_in : undefined,
80
- token_type: typeof body.token_type === "string" ? body.token_type : undefined,
81
- };
82
- }
83
- const error = typeof body.error === "string" ? body.error : "unknown_error";
84
- if (error === "authorization_pending") {
85
- continue;
86
- }
87
- if (error === "slow_down") {
88
- intervalMs += 5000;
89
- continue;
90
- }
91
- if (error === "access_denied") {
92
- throw new Error("Sign-in was denied.");
93
- }
94
- if (error === "expired_token") {
95
- throw new Error("The device code expired. Run auth login again.");
96
- }
97
- throw new Error(`Token request failed: ${error}`);
98
- }
99
- throw new Error("Timed out waiting for sign-in. Run auth login again.");
100
- }
101
- /** Runs the full device-code flow: request code then poll for tokens. */
102
- export async function runDeviceFlow(options) {
103
- const deviceAuth = await requestDeviceCode(options);
104
- const tokens = await pollForTokens({
105
- ...options,
106
- deviceCode: deviceAuth.device_code,
107
- deviceAuth,
108
- });
109
- return { deviceAuth, tokens };
110
- }
@@ -1,27 +0,0 @@
1
- /** Fetches and validates the authorization server metadata document. */
2
- export async function discoverEndpoints(issuer) {
3
- const normalized = issuer.replace(/\/$/, "");
4
- const url = `${normalized}/.well-known/oauth-authorization-server`;
5
- const res = await fetch(url);
6
- if (!res.ok) {
7
- throw new Error(`Could not reach the authorization server (${res.status}). Check --issuer.`);
8
- }
9
- const body = (await res.json());
10
- const authorizationEndpoint = body.authorization_endpoint;
11
- const deviceAuthorizationEndpoint = body.device_authorization_endpoint;
12
- const tokenEndpoint = body.token_endpoint;
13
- if (typeof tokenEndpoint !== "string" || tokenEndpoint === "") {
14
- throw new Error("This authorization server is missing a token endpoint.");
15
- }
16
- const hasAuthorization = typeof authorizationEndpoint === "string" && authorizationEndpoint !== "";
17
- const hasDevice = typeof deviceAuthorizationEndpoint === "string" && deviceAuthorizationEndpoint !== "";
18
- if (!hasAuthorization && !hasDevice) {
19
- throw new Error("This authorization server does not support browser or device sign-in.");
20
- }
21
- return {
22
- issuer: typeof body.issuer === "string" ? body.issuer : normalized,
23
- authorizationEndpoint: hasAuthorization ? authorizationEndpoint : undefined,
24
- deviceAuthorizationEndpoint: hasDevice ? deviceAuthorizationEndpoint : undefined,
25
- tokenEndpoint,
26
- };
27
- }
@@ -1,212 +0,0 @@
1
- /**
2
- * @purpose Runs the RFC 8252 loopback authorization-code + PKCE flow so a real
3
- * user signs in through their browser with one click and the CLI captures the
4
- * resulting tokens — no device code, no manual code entry, no polling.
5
- */
6
- import { spawn } from "node:child_process";
7
- import { createHash, randomBytes } from "node:crypto";
8
- import { createServer } from "node:http";
9
- const DEFAULT_SCOPE = "openid offline_access brain";
10
- const CALLBACK_PATH = "/callback";
11
- const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
12
- function base64url(buf) {
13
- return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
14
- }
15
- function createPkce() {
16
- const verifier = base64url(randomBytes(32));
17
- const challenge = base64url(createHash("sha256").update(verifier).digest());
18
- return { verifier, challenge };
19
- }
20
- /** Opens the default browser cross-platform; best-effort and never throws. */
21
- function launchBrowser(url) {
22
- const platform = process.platform;
23
- const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
24
- const args = platform === "win32" ? ["/c", "start", "", url] : [url];
25
- try {
26
- const child = spawn(command, args, { stdio: "ignore", detached: true });
27
- child.on("error", () => { });
28
- child.unref();
29
- }
30
- catch {
31
- // best-effort; the caller surfaces the URL via onPrompt
32
- }
33
- }
34
- function buildAuthorizeUrl(endpoint, params) {
35
- const url = new URL(endpoint);
36
- url.searchParams.set("response_type", "code");
37
- url.searchParams.set("client_id", params.clientId);
38
- url.searchParams.set("redirect_uri", params.redirectUri);
39
- url.searchParams.set("scope", params.scope);
40
- url.searchParams.set("state", params.state);
41
- url.searchParams.set("code_challenge", params.challenge);
42
- url.searchParams.set("code_challenge_method", "S256");
43
- if (params.resource) {
44
- url.searchParams.set("resource", params.resource);
45
- }
46
- return url.toString();
47
- }
48
- const BRAND_STYLE = `
49
- :root{--teal:#0FB6AE;--teal-deep:#0B8C86;--ink:#15211F;--muted:#5B6B68;--line:#E4ECEA;--bg:#F5F8F7;}
50
- *{box-sizing:border-box;margin:0;padding:0;}html,body{height:100%;}
51
- body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:var(--ink);background:var(--bg);
52
- -webkit-font-smoothing:antialiased;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:32px 20px;text-align:center;}
53
- .card{max-width:392px;background:#fff;border:1px solid var(--line);border-radius:14px;padding:40px 36px;
54
- box-shadow:0 1px 2px rgba(16,40,38,.04),0 12px 32px rgba(16,40,38,.07);}
55
- .icon{width:46px;height:46px;margin:0 auto 18px;display:block;}
56
- h1{font-family:'Space Grotesk',sans-serif;font-weight:600;font-size:20px;letter-spacing:-.015em;margin-bottom:8px;color:var(--ink);}
57
- p{font-size:14px;line-height:1.55;color:var(--muted);}
58
- .foot{margin-top:22px;padding-top:16px;border-top:1px solid var(--line);font-size:12px;color:#7C8B88;}
59
- `;
60
- const SUCCESS_MARK = `<svg class="icon" viewBox="0 0 46 46" aria-hidden="true"><circle cx="23" cy="23" r="23" fill="#E3F5F2"/><path d="M14 23.5l6 6 12-13" fill="none" stroke="#0B8C86" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
61
- const ERROR_MARK = `<svg class="icon" viewBox="0 0 46 46" aria-hidden="true"><circle cx="23" cy="23" r="23" fill="#FBEAEA"/><path d="M23 13v13M23 31.5v.5" fill="none" stroke="#B23B3B" stroke-width="2.6" stroke-linecap="round"/></svg>`;
62
- function brandPage(title, heading, body, mark) {
63
- return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
64
- <meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>
65
- <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
66
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&family=Space+Grotesk:wght@600&display=swap" rel="stylesheet">
67
- <style>${BRAND_STYLE}</style></head>
68
- <body><main class="card">${mark}<h1>${heading}</h1><p>${body}</p>
69
- <div class="foot">Dazzer secure sign-in</div></main></body></html>`;
70
- }
71
- function successPage() {
72
- return brandPage("Signed in to Dazzer", "You're signed in", "You can close this tab and return to your terminal.", SUCCESS_MARK);
73
- }
74
- /** The branded success page served on the loopback callback — exported for tests/preview. */
75
- export function renderLoopbackSuccessPage() {
76
- return successPage();
77
- }
78
- /** Escapes a user-provided value before it is embedded in server-rendered HTML. */
79
- function escapeHtml(value) {
80
- return value
81
- .replaceAll("&", "&amp;")
82
- .replaceAll("<", "&lt;")
83
- .replaceAll(">", "&gt;")
84
- .replaceAll('"', "&quot;");
85
- }
86
- function errorPage(reason) {
87
- // `reason` can originate from the OAuth `error` query param (user-controlled),
88
- // so escape it before it reaches the HTML sink (reflected-XSS guard).
89
- return brandPage("Sign-in failed", "Something went wrong", `We couldn't complete sign-in (${escapeHtml(reason)}). Close this tab and run the command again.`, ERROR_MARK);
90
- }
91
- function sendHtml(res, html) {
92
- res.statusCode = 200;
93
- res.setHeader("Content-Type", "text/html; charset=utf-8");
94
- res.end(html);
95
- }
96
- /**
97
- * Runs the full loopback flow: bind an ephemeral 127.0.0.1 port, open the
98
- * browser to the authorization endpoint, capture the redirect, and exchange the
99
- * code (with the PKCE verifier) for tokens.
100
- */
101
- export async function runLoopbackFlow(options) {
102
- const fetchFn = options.fetchFn ?? fetch;
103
- const { authorizationEndpoint } = options.discovery;
104
- if (!authorizationEndpoint) {
105
- throw new Error("This authorization server does not support browser sign-in.");
106
- }
107
- const { verifier, challenge } = createPkce();
108
- const state = base64url(randomBytes(16));
109
- const scope = options.scope ?? DEFAULT_SCOPE;
110
- const { code, redirectUri } = await new Promise((resolve, reject) => {
111
- let timer;
112
- const server = createServer((req, res) => {
113
- const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1");
114
- if (requestUrl.pathname !== CALLBACK_PATH) {
115
- res.statusCode = 404;
116
- res.end("Not found");
117
- return;
118
- }
119
- const addr = server.address();
120
- const redirect = `http://127.0.0.1:${addr.port}${CALLBACK_PATH}`;
121
- const returnedState = requestUrl.searchParams.get("state");
122
- const errorParam = requestUrl.searchParams.get("error");
123
- const returnedCode = requestUrl.searchParams.get("code");
124
- const finish = (err) => {
125
- if (timer)
126
- clearTimeout(timer);
127
- server.close();
128
- if (err)
129
- reject(err);
130
- };
131
- if (errorParam) {
132
- sendHtml(res, errorPage(errorParam));
133
- finish(new Error(`Sign-in failed: ${errorParam}.`));
134
- return;
135
- }
136
- if (returnedState !== state) {
137
- sendHtml(res, errorPage("state_mismatch"));
138
- finish(new Error("Sign-in failed: state mismatch."));
139
- return;
140
- }
141
- if (!returnedCode) {
142
- sendHtml(res, errorPage("missing_code"));
143
- finish(new Error("Sign-in failed: no authorization code returned."));
144
- return;
145
- }
146
- sendHtml(res, successPage());
147
- finish();
148
- resolve({ code: returnedCode, redirectUri: redirect });
149
- });
150
- server.on("error", (err) => {
151
- if (timer)
152
- clearTimeout(timer);
153
- reject(err);
154
- });
155
- server.listen(0, "127.0.0.1", () => {
156
- const addr = server.address();
157
- const redirect = `http://127.0.0.1:${addr.port}${CALLBACK_PATH}`;
158
- const authorizeUrl = buildAuthorizeUrl(authorizationEndpoint, {
159
- clientId: options.clientId,
160
- redirectUri: redirect,
161
- scope,
162
- state,
163
- challenge,
164
- resource: options.resource,
165
- });
166
- timer = setTimeout(() => {
167
- server.close();
168
- reject(new Error("Timed out waiting for sign-in. Run the command again."));
169
- }, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
170
- (options.openBrowser ?? launchBrowser)(authorizeUrl);
171
- options.onPrompt?.(authorizeUrl);
172
- });
173
- });
174
- const params = new URLSearchParams({
175
- grant_type: "authorization_code",
176
- code,
177
- redirect_uri: redirectUri,
178
- client_id: options.clientId,
179
- code_verifier: verifier,
180
- });
181
- if (options.resource) {
182
- params.set("resource", options.resource);
183
- }
184
- const res = await fetchFn(options.discovery.tokenEndpoint, {
185
- method: "POST",
186
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
187
- body: params.toString(),
188
- });
189
- const body = (await res.json());
190
- if (!res.ok) {
191
- const error = typeof body.error === "string" ? body.error : "token_exchange_failed";
192
- throw new Error(`Token request failed: ${error}`);
193
- }
194
- const accessToken = body.access_token;
195
- if (typeof accessToken !== "string") {
196
- throw new Error("The authorization server returned an unexpected token response.");
197
- }
198
- return {
199
- access_token: accessToken,
200
- refresh_token: typeof body.refresh_token === "string" ? body.refresh_token : undefined,
201
- expires_in: typeof body.expires_in === "number" ? body.expires_in : undefined,
202
- token_type: typeof body.token_type === "string" ? body.token_type : undefined,
203
- };
204
- }
205
- /** True when the environment can plausibly open a browser (loopback viable). */
206
- export function browserLikelyAvailable() {
207
- if (process.platform === "darwin" || process.platform === "win32") {
208
- return true;
209
- }
210
- // Linux/other: a display server is needed to open a browser.
211
- return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
212
- }