dazzer-connect 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dazzer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # dazzer-cli
2
+
3
+ Sign in to Dazzer from your terminal and get a copy-paste MCP client configuration.
4
+
5
+ Requires Node.js 20 or later.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ # One-off sign-in (downloads and runs the CLI)
11
+ npx dazzer-cli auth login
12
+
13
+ # Or install globally — the command is `dazzer`
14
+ npm i -g dazzer-cli
15
+ dazzer auth login
16
+ ```
17
+
18
+ The CLI opens your browser to sign in (or falls back to a device code when headless). On success it prints MCP client configuration to your terminal.
19
+
20
+ ## Configure Claude Desktop
21
+
22
+ After `dazzer auth login`, copy the JSON block from your terminal into your Claude Desktop MCP settings file:
23
+
24
+ - **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
25
+ - **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
26
+
27
+ The block looks like this (your access token is filled in automatically):
28
+
29
+ ```json
30
+ {
31
+ "mcpServers": {
32
+ "dazzer": {
33
+ "url": "https://graph.dazzer.io/mcp",
34
+ "headers": {
35
+ "Authorization": "Bearer <your-access-token>"
36
+ }
37
+ }
38
+ }
39
+ }
40
+ ```
41
+
42
+ Merge it with any existing `mcpServers` entries, save the file, then restart Claude Desktop.
43
+
44
+ If keychain storage failed during login, the printed config is your fallback — paste it before closing the terminal.
45
+
46
+ ## Other commands
47
+
48
+ ```bash
49
+ dazzer auth status # show sign-in status
50
+ dazzer auth logout # sign out and remove stored tokens
51
+ dazzer --help
52
+ ```
53
+
54
+ ## Configuration
55
+
56
+ Production defaults apply when you omit flags and env vars:
57
+
58
+ | Source | Variable | Default |
59
+ |--------|----------|---------|
60
+ | Flag | `--issuer` | — |
61
+ | Env | `DAZZER_AUTH_ISSUER` | `https://auth.dazzer.io` |
62
+ | Env | `DAZZER_AUTH_CLIENT_ID` | `dazzer-cli-local` |
63
+ | Env | `DAZZER_AUTH_RESOURCE` | `https://graph.dazzer.io/mcp` |
64
+
65
+ For local development against a running authorization server:
66
+
67
+ ```bash
68
+ dazzer auth login --issuer http://localhost:3003
69
+ ```
70
+
71
+ ## Token storage
72
+
73
+ - **Secrets** (access + refresh tokens): OS keychain via `@napi-rs/keyring` (macOS Keychain, Windows Credential Manager, Linux Secret Service). Prebuilt binaries install with the package — no compiler required.
74
+ - **Metadata** (issuer, client id, expiry): config directory from `env-paths("dazzer")` — typically `~/Library/Application Support/dazzer/` on macOS.
75
+
76
+ If keychain storage fails, login still completes and prints copy-paste-ready MCP client config to stdout.
77
+
78
+ ## Develop from source
79
+
80
+ ```bash
81
+ pnpm --filter dazzer-cli build
82
+ node packages/oauth-cli/dist/index.js auth login --issuer http://localhost:3003
83
+ ```
@@ -0,0 +1,72 @@
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
+ }
@@ -0,0 +1,21 @@
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
+ }
@@ -0,0 +1,38 @@
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 ADDED
@@ -0,0 +1,25 @@
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
+ }
package/dist/index.js ADDED
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @purpose Entry point for the dazzer CLI — wires Commander subcommands for
4
+ * auth login, logout, and status against a configurable authorization server.
5
+ */
6
+ import { createRequire } from "node:module";
7
+ import { Command } from "commander";
8
+ import { runLogin } from "./commands/login.js";
9
+ import { runLogout } from "./commands/logout.js";
10
+ import { runStatus } from "./commands/status.js";
11
+ import { validateEnv } from "./env.js";
12
+ const require = createRequire(import.meta.url);
13
+ const { version } = require("../package.json");
14
+ const program = new Command();
15
+ program.name("dazzer").description("Sign in to Dazzer from your terminal").version(version);
16
+ const auth = program.command("auth").description("Manage sign-in");
17
+ auth
18
+ .command("login")
19
+ .description("Sign in through your browser; falls back to a device code when headless")
20
+ .option("--issuer <url>", "Authorization server URL")
21
+ .option("--client-id <id>", "OAuth client id")
22
+ .option("--resource <url>", "Resource indicator for access tokens")
23
+ .option("--no-browser", "Use the device-code flow instead of opening a browser")
24
+ .action(async (opts) => {
25
+ const env = validateEnv();
26
+ await runLogin({
27
+ issuer: opts.issuer ?? env.DAZZER_AUTH_ISSUER,
28
+ clientId: opts.clientId ?? env.DAZZER_AUTH_CLIENT_ID,
29
+ resource: opts.resource ?? env.DAZZER_AUTH_RESOURCE,
30
+ noBrowser: opts.browser === false,
31
+ });
32
+ });
33
+ auth
34
+ .command("logout")
35
+ .description("Sign out and remove stored tokens")
36
+ .action(async () => {
37
+ await runLogout();
38
+ });
39
+ auth
40
+ .command("status")
41
+ .description("Show sign-in status")
42
+ .option("--issuer <url>", "Authorization server URL")
43
+ .option("--client-id <id>", "OAuth client id")
44
+ .option("--resource <url>", "Resource indicator for access tokens")
45
+ .action(async (opts) => {
46
+ await runStatus({
47
+ issuer: opts.issuer,
48
+ clientId: opts.clientId,
49
+ resource: opts.resource,
50
+ });
51
+ });
52
+ program.parseAsync(process.argv).catch((err) => {
53
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
54
+ process.exit(1);
55
+ });
@@ -0,0 +1,44 @@
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
+ }
@@ -0,0 +1,110 @@
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
+ }
@@ -0,0 +1,27 @@
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
+ }
@@ -0,0 +1,212 @@
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
+ }
@@ -0,0 +1,79 @@
1
+ function writeOut(message) {
2
+ process.stdout.write(`${message}\n`);
3
+ }
4
+ function writeErr(message) {
5
+ process.stderr.write(`${message}\n`);
6
+ }
7
+ /** Prints device-flow instructions for the user to complete in a browser. */
8
+ export function printDeviceInstructions(deviceAuth) {
9
+ writeOut("");
10
+ if (deviceAuth.verification_uri_complete) {
11
+ writeOut("To finish signing in, open this link in your browser:");
12
+ writeOut(` ${deviceAuth.verification_uri_complete}`);
13
+ }
14
+ else {
15
+ writeOut("To finish signing in, open this page in your browser:");
16
+ writeOut(` ${deviceAuth.verification_uri}`);
17
+ writeOut("");
18
+ writeOut("Then enter this code:");
19
+ writeOut(` ${deviceAuth.user_code}`);
20
+ }
21
+ writeOut("");
22
+ writeOut("Waiting for you to approve in the browser...");
23
+ }
24
+ /** Tells the user their browser is opening for the loopback sign-in, with a manual fallback. */
25
+ export function printBrowserOpening(url) {
26
+ writeOut("");
27
+ writeOut("Opening your browser to sign in…");
28
+ writeOut("If it doesn't open automatically, paste this into your browser:");
29
+ writeOut(` ${url}`);
30
+ writeOut("");
31
+ }
32
+ /** Prints success message after login completes. */
33
+ export function printLoginSuccess(keychainOk) {
34
+ writeOut("");
35
+ writeOut("Signed in successfully.");
36
+ if (!keychainOk) {
37
+ writeErr("Warning: could not save tokens to the OS keychain. Use the config below.");
38
+ }
39
+ }
40
+ /** Prints copy-paste-ready MCP client config with Bearer authorization. */
41
+ export function printMcpClientConfig(accessToken, mcpServerUrl) {
42
+ writeOut("");
43
+ writeOut("Copy this into your MCP client configuration:");
44
+ writeOut("");
45
+ writeOut("{");
46
+ writeOut(' "mcpServers": {');
47
+ writeOut(' "dazzer": {');
48
+ writeOut(` "url": "${mcpServerUrl.replace(/\/$/, "")}",`);
49
+ writeOut(' "headers": {');
50
+ writeOut(` "Authorization": "Bearer ${accessToken}"`);
51
+ writeOut(" }");
52
+ writeOut(" }");
53
+ writeOut(" }");
54
+ writeOut("}");
55
+ writeOut("");
56
+ }
57
+ /** Prints auth status for the status command. */
58
+ export function printAuthStatus(params) {
59
+ if (!params.signedIn) {
60
+ writeOut("Not signed in. Run: dazzer auth login");
61
+ return;
62
+ }
63
+ writeOut("Signed in.");
64
+ if (params.issuer) {
65
+ writeOut(` Issuer: ${params.issuer}`);
66
+ }
67
+ if (params.expiresAt) {
68
+ writeOut(` Token expires: ${new Date(params.expiresAt).toISOString()}`);
69
+ }
70
+ }
71
+ /** Prints logout confirmation. */
72
+ export function printLogoutSuccess() {
73
+ writeOut("Signed out.");
74
+ }
75
+ /** Prints a user-facing error and exits. */
76
+ export function fail(message) {
77
+ writeErr(`Error: ${message}`);
78
+ process.exit(1);
79
+ }
@@ -0,0 +1,60 @@
1
+ /** Exchanges a refresh token for a new access token (and rotated refresh token). */
2
+ export async function refreshAccessToken(options) {
3
+ const fetchFn = options.fetchFn ?? fetch;
4
+ const params = new URLSearchParams({
5
+ grant_type: "refresh_token",
6
+ client_id: options.clientId,
7
+ refresh_token: options.refreshToken,
8
+ });
9
+ if (options.resource) {
10
+ params.set("resource", options.resource);
11
+ }
12
+ const res = await fetchFn(options.discovery.tokenEndpoint, {
13
+ method: "POST",
14
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
15
+ body: params.toString(),
16
+ });
17
+ const body = (await res.json());
18
+ if (!res.ok) {
19
+ const error = typeof body.error === "string" ? body.error : "refresh_failed";
20
+ throw new Error(`Could not refresh token: ${error}`);
21
+ }
22
+ const accessToken = body.access_token;
23
+ const refreshToken = body.refresh_token;
24
+ if (typeof accessToken !== "string") {
25
+ throw new Error("The authorization server returned an unexpected refresh response.");
26
+ }
27
+ const newRefresh = typeof refreshToken === "string" ? refreshToken : options.refreshToken;
28
+ const expiresIn = typeof body.expires_in === "number" ? body.expires_in : null;
29
+ await options.tokenStore.save({ accessToken, refreshToken: newRefresh });
30
+ return { accessToken, refreshToken: newRefresh, expiresIn };
31
+ }
32
+ /** Returns a valid access token, refreshing when possible. */
33
+ export async function getValidAccessToken(options) {
34
+ const stored = await options.tokenStore.load();
35
+ if (!stored) {
36
+ return null;
37
+ }
38
+ try {
39
+ return await refreshAccessToken({ ...options, refreshToken: stored.refreshToken });
40
+ }
41
+ catch {
42
+ return {
43
+ accessToken: stored.accessToken,
44
+ refreshToken: stored.refreshToken,
45
+ expiresIn: null,
46
+ };
47
+ }
48
+ }
49
+ /** Saves freshly issued tokens after login. */
50
+ export async function persistLoginTokens(tokenStore, tokens) {
51
+ if (!tokens.refresh_token) {
52
+ throw new Error("The authorization server did not return a refresh token.");
53
+ }
54
+ const stored = {
55
+ accessToken: tokens.access_token,
56
+ refreshToken: tokens.refresh_token,
57
+ };
58
+ await tokenStore.save(stored);
59
+ return stored;
60
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @purpose Defines the token storage contract and provides keychain-backed and
3
+ * in-memory implementations so secrets stay out of disk while tests stay hermetic.
4
+ */
5
+ import { Entry } from "@napi-rs/keyring";
6
+ const SERVICE = "dazzer-oauth-cli";
7
+ const ACCESS_ACCOUNT = "access-token";
8
+ const REFRESH_ACCOUNT = "refresh-token";
9
+ function accessEntry() {
10
+ return new Entry(SERVICE, ACCESS_ACCOUNT);
11
+ }
12
+ function refreshEntry() {
13
+ return new Entry(SERVICE, REFRESH_ACCOUNT);
14
+ }
15
+ /** Persists tokens in the OS keychain via @napi-rs/keyring. */
16
+ export class KeychainTokenStore {
17
+ async save(tokens) {
18
+ accessEntry().setPassword(tokens.accessToken);
19
+ refreshEntry().setPassword(tokens.refreshToken);
20
+ }
21
+ async load() {
22
+ const accessToken = accessEntry().getPassword();
23
+ const refreshToken = refreshEntry().getPassword();
24
+ if (!accessToken || !refreshToken) {
25
+ return null;
26
+ }
27
+ return { accessToken, refreshToken };
28
+ }
29
+ async clear() {
30
+ try {
31
+ accessEntry().deletePassword();
32
+ }
33
+ catch {
34
+ // ignore missing entry
35
+ }
36
+ try {
37
+ refreshEntry().deletePassword();
38
+ }
39
+ catch {
40
+ // ignore missing entry
41
+ }
42
+ }
43
+ }
44
+ /** In-memory store for unit tests. */
45
+ export class MemoryTokenStore {
46
+ tokens = null;
47
+ async save(tokens) {
48
+ this.tokens = { ...tokens };
49
+ }
50
+ async load() {
51
+ return this.tokens ? { ...this.tokens } : null;
52
+ }
53
+ async clear() {
54
+ this.tokens = null;
55
+ }
56
+ }
57
+ /** Default production token store. */
58
+ export function createTokenStore() {
59
+ return new KeychainTokenStore();
60
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "dazzer-connect",
3
+ "private": false,
4
+ "version": "0.1.0",
5
+ "description": "Sign in to Dazzer from your terminal and configure MCP clients",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "bin": {
9
+ "dazzer": "dist/index.js"
10
+ },
11
+ "files": ["dist/**/*.js", "LICENSE"],
12
+ "engines": {
13
+ "node": ">=20"
14
+ },
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"],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "scripts": {
29
+ "build": "tsc --build",
30
+ "dev": "tsx src/index.ts"
31
+ },
32
+ "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
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^22.10.2",
40
+ "tsx": "^4.19.2",
41
+ "typescript": "^5.7.2",
42
+ "vitest": "^4.1.5"
43
+ }
44
+ }