diff --git a/package-lock.json b/package-lock.json index 4442250a0..4f2058e2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -330,7 +330,6 @@ "version": "7.24.9", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.9.tgz", "integrity": "sha512-5e3FI4Q3M3Pbr21+5xJwCv6ZT6KmGkI0vw3Tozy5ODAQFTIWe37iT8Cr7Ice2Ntb+M3iSKCEWMB1MBgKrW3whg==", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.7", @@ -733,7 +732,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -1259,7 +1257,6 @@ "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", @@ -1702,7 +1699,6 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2665,7 +2661,6 @@ "integrity": "sha512-yl43JD/86CIj3Mz5mvvLJqAOfIup7ncxfJ0Btnl0/v5TouVUyeEdcpknfgc+yMevS/48oH9WAkkw93m7otLb/A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/checkbox": "^3.0.1", "@inquirer/confirm": "^4.0.1", @@ -3002,7 +2997,6 @@ "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -3313,7 +3307,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -3335,7 +3328,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.30.1.tgz", "integrity": "sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" }, @@ -3372,7 +3364,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.57.2", "@types/shimmer": "^1.2.0", @@ -3903,7 +3894,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.31.0.tgz", "integrity": "sha512-cYJeP+6qN0UnBv1r09hXl0YorB8kXHv61BC0NUlBA8vxrylZ4/C8lnva3gd1E8n33DNYSaiGW+DuGoSt0QQ7Dw==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" } @@ -6957,7 +6947,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.0.tgz", "integrity": "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -7034,7 +7023,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -7045,7 +7033,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -7150,7 +7137,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.16.1.tgz", "integrity": "sha512-SxdPak/5bO0EnGktV05+Hq8oatjAYVY3Zh2bye9pGZy6+jwyR3LG3YKkV4YatlsgqXP28BTeVm9pqwJM96vf2A==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "7.16.1", @@ -8030,7 +8016,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9233,7 +9218,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -11798,7 +11782,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -11937,7 +11920,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", "dev": true, - "peer": true, "dependencies": { "array-includes": "^3.1.7", "array.prototype.findlastindex": "^1.2.3", @@ -13825,7 +13807,6 @@ "version": "10.1.1", "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -16011,7 +15992,6 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "license": "MIT", - "peer": true, "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" @@ -17914,7 +17894,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -17939,7 +17918,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -17951,7 +17929,6 @@ "version": "7.53.0", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.53.0.tgz", "integrity": "sha512-M1n3HhqCww6S2hxLxciEXy2oISPnAzxY7gvwVPrtlczTM/1dDadXgUxDpHMrMTblDOcm/AXtXxHwZ3jpg1mqKQ==", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -18904,7 +18881,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -20414,8 +20390,7 @@ "node_modules/tslib": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", - "peer": true + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" }, "node_modules/type-check": { "version": "0.4.0", @@ -20570,7 +20545,6 @@ "version": "5.5.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -21178,7 +21152,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -22301,7 +22274,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/handlers/grafana-assistant/index.ts b/src/handlers/grafana-assistant/index.ts new file mode 100644 index 000000000..c78995dfa --- /dev/null +++ b/src/handlers/grafana-assistant/index.ts @@ -0,0 +1,82 @@ +import { ipcMain } from 'electron' +import log from 'electron-log/main' + +import { browserWindowFromEvent } from '@/utils/electron' + +import { ConnectStateMachine } from './states' +import { + getConnection, + getFirstStoredGrafanaUrl, + removeConnection, +} from './storage' +import { + ConnectResult, + GrafanaAssistantConnection, + GrafanaAssistantHandler, +} from './types' + +export function initialize() { + let pending: ConnectStateMachine | null = null + + ipcMain.handle( + GrafanaAssistantHandler.Connect, + async (event, grafanaUrl: string): Promise => { + const browserWindow = browserWindowFromEvent(event) + + try { + if (pending !== null) { + pending.abort() + pending = null + } + + pending = new ConnectStateMachine(grafanaUrl) + + pending.on('state-change', (state) => { + browserWindow.webContents.send( + GrafanaAssistantHandler.StateChange, + state + ) + }) + + return await pending.start() + } catch (error) { + log.error('Unexpected error during Grafana Assistant connect.', error) + throw error + } finally { + pending = null + } + } + ) + + ipcMain.handle(GrafanaAssistantHandler.Abort, () => { + pending?.abort() + pending = null + }) + + ipcMain.handle( + GrafanaAssistantHandler.GetConnection, + async ( + _event, + grafanaUrl?: string + ): Promise => { + const url = grafanaUrl ?? (await getFirstStoredGrafanaUrl()) + if (!url) return null + + const conn = await getConnection(url) + if (!conn) return null + + return { + grafanaUrl: url, + apiEndpoint: conn.apiEndpoint, + expiresAt: conn.expiresAt, + } + } + ) + + ipcMain.handle( + GrafanaAssistantHandler.Disconnect, + async (_event, grafanaUrl: string): Promise => { + await removeConnection(grafanaUrl) + } + ) +} diff --git a/src/handlers/grafana-assistant/preload.ts b/src/handlers/grafana-assistant/preload.ts new file mode 100644 index 000000000..d89fdd970 --- /dev/null +++ b/src/handlers/grafana-assistant/preload.ts @@ -0,0 +1,41 @@ +import { ipcRenderer } from 'electron' + +import { createListener } from '../utils' + +import { + ConnectProcessState, + ConnectResult, + GrafanaAssistantConnection, + GrafanaAssistantHandler, +} from './types' + +export function connect(grafanaUrl: string): Promise { + return ipcRenderer.invoke( + GrafanaAssistantHandler.Connect, + grafanaUrl + ) as Promise +} + +export function abort(): Promise { + return ipcRenderer.invoke(GrafanaAssistantHandler.Abort) as Promise +} + +export function getConnection( + grafanaUrl?: string +): Promise { + return ipcRenderer.invoke( + GrafanaAssistantHandler.GetConnection, + grafanaUrl + ) as Promise +} + +export function disconnect(grafanaUrl: string): Promise { + return ipcRenderer.invoke( + GrafanaAssistantHandler.Disconnect, + grafanaUrl + ) as Promise +} + +export function onStateChange(callback: (state: ConnectProcessState) => void) { + return createListener(GrafanaAssistantHandler.StateChange, callback) +} diff --git a/src/handlers/grafana-assistant/states.test.ts b/src/handlers/grafana-assistant/states.test.ts new file mode 100644 index 000000000..1a2affd2e --- /dev/null +++ b/src/handlers/grafana-assistant/states.test.ts @@ -0,0 +1,201 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { shell } from 'electron' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { exchangeCodeForTokens } from '@/services/grafana-assistant/auth' +import { startCallbackServer } from '@/services/grafana-assistant/callback-server' + +import { ConnectStateMachine } from './states' +import { saveConnection } from './storage' +import type { ConnectProcessState } from './types' + +vi.mock('electron', () => ({ + shell: { + openExternal: vi.fn().mockResolvedValue(undefined), + }, +})) + +vi.mock('@/services/grafana-assistant/pkce', () => ({ + generateCodeVerifier: vi.fn(() => 'mock-verifier'), + generateCodeChallenge: vi.fn(() => 'mock-challenge'), + generateState: vi.fn(() => 'mock-csrf-state'), +})) + +vi.mock('@/services/grafana-assistant/callback-server', () => ({ + startCallbackServer: vi.fn(), +})) + +vi.mock('@/services/grafana-assistant/auth', () => ({ + buildAuthUrl: vi.fn( + () => 'https://grafana.example.com/a/grafana-assistant-app/cli/auth?...' + ), + exchangeCodeForTokens: vi.fn(), +})) + +vi.mock('./storage', () => ({ + saveConnection: vi.fn().mockResolvedValue(undefined), +})) + +const mockedOpenExternal = vi.mocked(shell.openExternal) +const mockedStartCallbackServer = vi.mocked(startCallbackServer) +const mockedExchangeCodeForTokens = vi.mocked(exchangeCodeForTokens) +const mockedSaveConnection = vi.mocked(saveConnection) + +const mockCallbackParams = { + code: 'auth-code-123', + state: 'mock-csrf-state', + endpoint: 'https://grafana.example.com', +} + +const mockTokens = { + gatToken: 'gat-token', + garToken: 'gar-token', + apiEndpoint: 'https://api.example.com', + expiresAt: '2026-12-31T00:00:00Z', + refreshExpiresAt: '2027-12-31T00:00:00Z', +} + +beforeEach(() => { + vi.clearAllMocks() + mockedStartCallbackServer.mockResolvedValue({ + port: 54321, + result: Promise.resolve(mockCallbackParams), + }) + mockedExchangeCodeForTokens.mockResolvedValue(mockTokens) +}) + +describe('ConnectStateMachine', () => { + describe('start()', () => { + it('returns a connected result on success', async () => { + const machine = new ConnectStateMachine('https://grafana.example.com') + + const result = await machine.start() + + expect(result).toEqual({ + type: 'connected', + connection: { + grafanaUrl: 'https://grafana.example.com', + apiEndpoint: mockTokens.apiEndpoint, + expiresAt: mockTokens.expiresAt, + }, + }) + }) + + it('opens the auth URL in the browser', async () => { + const machine = new ConnectStateMachine('https://grafana.example.com') + + await machine.start() + + expect(mockedOpenExternal).toHaveBeenCalledWith( + expect.stringContaining('grafana-assistant-app/cli/auth') + ) + }) + + it('saves the connection after a successful exchange', async () => { + const machine = new ConnectStateMachine('https://grafana.example.com') + + await machine.start() + + expect(mockedSaveConnection).toHaveBeenCalledWith( + 'https://grafana.example.com', + mockTokens + ) + }) + + it('emits state-change events in order', async () => { + const machine = new ConnectStateMachine('https://grafana.example.com') + const states: ConnectProcessState[] = [] + machine.on('state-change', (s) => states.push(s)) + + await machine.start() + + expect(states[0]).toEqual({ type: 'authorizing' }) + expect(states[1]).toEqual({ type: 'exchanging' }) + expect(states[2]).toMatchObject({ type: 'completed' }) + }) + + it('emits completed state with connection info', async () => { + const machine = new ConnectStateMachine('https://grafana.example.com') + const states: ConnectProcessState[] = [] + machine.on('state-change', (s) => states.push(s)) + + await machine.start() + + const completed = states.find((s) => s.type === 'completed') + expect(completed).toEqual({ + type: 'completed', + connection: { + grafanaUrl: 'https://grafana.example.com', + apiEndpoint: mockTokens.apiEndpoint, + expiresAt: mockTokens.expiresAt, + }, + }) + }) + + it('returns an error result when token exchange fails', async () => { + mockedExchangeCodeForTokens.mockRejectedValueOnce( + new Error('Exchange failed') + ) + + const machine = new ConnectStateMachine('https://grafana.example.com') + const result = await machine.start() + + expect(result).toEqual({ type: 'error', message: 'Exchange failed' }) + }) + + it('returns an error result when callback server fails', async () => { + mockedStartCallbackServer.mockRejectedValueOnce( + new Error('No ports available') + ) + + const machine = new ConnectStateMachine('https://grafana.example.com') + const result = await machine.start() + + expect(result).toEqual({ + type: 'error', + message: 'No ports available', + }) + }) + + it('returns error with "Unknown error" for non-Error throws', async () => { + mockedStartCallbackServer.mockRejectedValueOnce('a string error') + + const machine = new ConnectStateMachine('https://grafana.example.com') + const result = await machine.start() + + expect(result).toEqual({ type: 'error', message: 'Unknown error' }) + }) + }) + + describe('abort()', () => { + it('returns an aborted result when aborted before starting', async () => { + const machine = new ConnectStateMachine('https://grafana.example.com') + + // Set up callback server to hang until aborted + mockedStartCallbackServer.mockImplementationOnce( + (_state, signal) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + const err = new Error('Aborted') + err.name = 'AbortError' + reject(err) + }) + }) + ) + + const resultPromise = machine.start() + machine.abort() + const result = await resultPromise + + expect(result).toEqual({ type: 'aborted' }) + }) + + it('can be called multiple times without error', () => { + const machine = new ConnectStateMachine('https://grafana.example.com') + expect(() => { + machine.abort() + machine.abort() + }).not.toThrow() + }) + }) +}) diff --git a/src/handlers/grafana-assistant/states.ts b/src/handlers/grafana-assistant/states.ts new file mode 100644 index 000000000..edfad79d8 --- /dev/null +++ b/src/handlers/grafana-assistant/states.ts @@ -0,0 +1,159 @@ +import { shell } from 'electron' +import { EventEmitter } from 'node:events' + +import { + buildAuthUrl, + exchangeCodeForTokens, +} from '@/services/grafana-assistant/auth' +import { startCallbackServer } from '@/services/grafana-assistant/callback-server' +import { + generateCodeChallenge, + generateCodeVerifier, + generateState, +} from '@/services/grafana-assistant/pkce' +import { exhaustive } from '@/utils/typescript' + +import { saveConnection } from './storage' +import { + ConnectProcessState, + ConnectResult, + GrafanaAssistantConnection, +} from './types' + +interface AuthorizingState { + type: 'authorizing' + grafanaUrl: string +} + +interface ExchangingState { + type: 'exchanging' + grafanaUrl: string + endpoint: string + code: string + codeVerifier: string +} + +interface CompletedState { + type: 'completed' + result: ConnectResult +} + +type State = AuthorizingState | ExchangingState | CompletedState + +type StateEventMap = { + 'state-change': [ConnectProcessState] +} + +function wasAborted(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError' +} + +export class ConnectStateMachine extends EventEmitter { + #controller: AbortController + #signal: AbortSignal + #grafanaUrl: string + + constructor(grafanaUrl: string) { + super() + this.#grafanaUrl = grafanaUrl + this.#controller = new AbortController() + this.#signal = this.#controller.signal + } + + async start(): Promise { + try { + return await this.#loop() + } catch (error) { + if (wasAborted(error)) { + return { type: 'aborted' } + } + const message = error instanceof Error ? error.message : 'Unknown error' + return { type: 'error', message } + } + } + + abort() { + this.#controller.abort() + } + + async #loop(): Promise { + let state: State = { type: 'authorizing', grafanaUrl: this.#grafanaUrl } + + while (!this.#signal.aborted) { + state = await this.#execute(state) + + if (state.type === 'completed') { + return state.result + } + } + + return { type: 'aborted' } + } + + #execute(state: State): Promise { + switch (state.type) { + case 'authorizing': + return this.#authorize(state) + case 'exchanging': + return this.#exchange(state) + case 'completed': + return Promise.resolve(state) + default: + return exhaustive(state) + } + } + + async #authorize(state: AuthorizingState): Promise { + this.emit('state-change', { type: 'authorizing' }) + + const codeVerifier = generateCodeVerifier() + const codeChallenge = generateCodeChallenge(codeVerifier) + const csrfState = generateState() + + const { port, result } = await startCallbackServer(csrfState, this.#signal) + + const authUrl = buildAuthUrl( + state.grafanaUrl, + port, + csrfState, + codeChallenge + ) + await shell.openExternal(authUrl) + + const { code, endpoint } = await result + + return { + type: 'exchanging', + grafanaUrl: state.grafanaUrl, + endpoint, + code, + codeVerifier, + } + } + + async #exchange(state: ExchangingState): Promise { + this.emit('state-change', { type: 'exchanging' }) + + const tokens = await exchangeCodeForTokens( + state.endpoint, + state.code, + state.codeVerifier, + this.#signal + ) + + await saveConnection(state.grafanaUrl, tokens) + + const connection: GrafanaAssistantConnection = { + grafanaUrl: state.grafanaUrl, + apiEndpoint: tokens.apiEndpoint, + expiresAt: tokens.expiresAt, + } + + this.emit('state-change', { type: 'completed', connection }) + + return { + type: 'completed', + result: { type: 'connected', connection }, + } + } +} diff --git a/src/handlers/grafana-assistant/storage.test.ts b/src/handlers/grafana-assistant/storage.test.ts new file mode 100644 index 000000000..5928b2331 --- /dev/null +++ b/src/handlers/grafana-assistant/storage.test.ts @@ -0,0 +1,270 @@ +import { readFile, writeFile } from 'fs/promises' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { ConnectionTokens } from './storage' + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => '/mock/user/data'), + }, +})) + +vi.mock('fs/promises') + +vi.mock('@/main/encryption', () => ({ + encryptString: vi.fn((s: string) => `encrypted:${s}`), + decryptString: vi.fn((s: string) => s.replace('encrypted:', '')), + isEncryptionAvailable: vi.fn(() => false), +})) + +const mockedReadFile = vi.mocked(readFile) +const mockedWriteFile = vi.mocked(writeFile) + +// Import after mocks are set up +const { + saveConnection, + getConnection, + removeConnection, + getFirstStoredGrafanaUrl, +} = await import('./storage') + +const { isEncryptionAvailable, encryptString, decryptString } = + await import('@/main/encryption') +const mockedIsEncryptionAvailable = vi.mocked(isEncryptionAvailable) +const mockedEncryptString = vi.mocked(encryptString) +const mockedDecryptString = vi.mocked(decryptString) + +const sampleTokens: ConnectionTokens = { + apiEndpoint: 'https://api.example.com', + gatToken: 'gat-token-value', + garToken: 'gar-token-value', + expiresAt: '2026-12-31T00:00:00Z', + refreshExpiresAt: '2027-12-31T00:00:00Z', +} + +function makeStore( + connections: Record = {}, + version = '1.0' +): string { + return JSON.stringify({ version, connections }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockedWriteFile.mockResolvedValue(undefined) +}) + +describe('saveConnection', () => { + it('writes a new connection to the store without encryption', async () => { + mockedIsEncryptionAvailable.mockReturnValue(false) + mockedReadFile.mockRejectedValue(new Error('not found')) + + await saveConnection('https://grafana.example.com', sampleTokens) + + expect(mockedWriteFile).toHaveBeenCalledOnce() + const written = JSON.parse( + mockedWriteFile.mock.calls[0]![1] as string + ) as object + expect(written).toMatchObject({ + version: '1.0', + connections: { + 'https://grafana.example.com': { + grafanaUrl: 'https://grafana.example.com', + apiEndpoint: sampleTokens.apiEndpoint, + gatToken: sampleTokens.gatToken, + garToken: sampleTokens.garToken, + encrypted: false, + }, + }, + }) + }) + + it('encrypts tokens when encryption is available', async () => { + mockedIsEncryptionAvailable.mockReturnValue(true) + mockedReadFile.mockRejectedValue(new Error('not found')) + + await saveConnection('https://grafana.example.com', sampleTokens) + + expect(mockedEncryptString).toHaveBeenCalledWith(sampleTokens.gatToken) + expect(mockedEncryptString).toHaveBeenCalledWith(sampleTokens.garToken) + + const written = JSON.parse( + mockedWriteFile.mock.calls[0]![1] as string + ) as object + expect(written).toMatchObject({ + connections: { + 'https://grafana.example.com': { + gatToken: `encrypted:${sampleTokens.gatToken}`, + garToken: `encrypted:${sampleTokens.garToken}`, + encrypted: true, + }, + }, + }) + }) + + it('normalizes trailing slashes in the URL key', async () => { + mockedIsEncryptionAvailable.mockReturnValue(false) + mockedReadFile.mockRejectedValue(new Error('not found')) + + await saveConnection('https://grafana.example.com/', sampleTokens) + + const written = JSON.parse(mockedWriteFile.mock.calls[0]![1] as string) as { + connections: Record + } + expect(Object.keys(written.connections)).toContain( + 'https://grafana.example.com' + ) + }) + + it('merges into an existing store', async () => { + const existing = makeStore({ + 'https://other.example.com': { + grafanaUrl: 'https://other.example.com', + apiEndpoint: 'https://other-api.com', + gatToken: 'old-gat', + garToken: 'old-gar', + expiresAt: '2025-01-01T00:00:00Z', + refreshExpiresAt: '2025-06-01T00:00:00Z', + encrypted: false, + }, + }) + mockedIsEncryptionAvailable.mockReturnValue(false) + mockedReadFile.mockResolvedValue(existing) + + await saveConnection('https://grafana.example.com', sampleTokens) + + const written = JSON.parse(mockedWriteFile.mock.calls[0]![1] as string) as { + connections: Record + } + expect(Object.keys(written.connections)).toHaveLength(2) + }) +}) + +describe('getConnection', () => { + it('returns null when the URL is not in the store', async () => { + mockedReadFile.mockRejectedValue(new Error('not found')) + + const result = await getConnection('https://grafana.example.com') + expect(result).toBeNull() + }) + + it('returns tokens for a known URL', async () => { + const store = makeStore({ + 'https://grafana.example.com': { + grafanaUrl: 'https://grafana.example.com', + ...sampleTokens, + encrypted: false, + }, + }) + mockedReadFile.mockResolvedValue(store) + + const result = await getConnection('https://grafana.example.com') + expect(result).toEqual(sampleTokens) + }) + + it('decrypts tokens when encrypted flag is true', async () => { + const store = makeStore({ + 'https://grafana.example.com': { + grafanaUrl: 'https://grafana.example.com', + apiEndpoint: sampleTokens.apiEndpoint, + gatToken: `encrypted:${sampleTokens.gatToken}`, + garToken: `encrypted:${sampleTokens.garToken}`, + expiresAt: sampleTokens.expiresAt, + refreshExpiresAt: sampleTokens.refreshExpiresAt, + encrypted: true, + }, + }) + mockedReadFile.mockResolvedValue(store) + + const result = await getConnection('https://grafana.example.com') + + expect(mockedDecryptString).toHaveBeenCalledTimes(2) + expect(result?.gatToken).toBe(sampleTokens.gatToken) + expect(result?.garToken).toBe(sampleTokens.garToken) + }) + + it('normalizes trailing slash when looking up', async () => { + const store = makeStore({ + 'https://grafana.example.com': { + grafanaUrl: 'https://grafana.example.com', + ...sampleTokens, + encrypted: false, + }, + }) + mockedReadFile.mockResolvedValue(store) + + const result = await getConnection('https://grafana.example.com/') + expect(result).not.toBeNull() + }) +}) + +describe('removeConnection', () => { + it('removes the connection from the store', async () => { + const store = makeStore({ + 'https://grafana.example.com': { + grafanaUrl: 'https://grafana.example.com', + ...sampleTokens, + encrypted: false, + }, + }) + mockedReadFile.mockResolvedValue(store) + + await removeConnection('https://grafana.example.com') + + const written = JSON.parse(mockedWriteFile.mock.calls[0]![1] as string) as { + connections: Record + } + expect(Object.keys(written.connections)).not.toContain( + 'https://grafana.example.com' + ) + }) + + it('normalizes trailing slash when removing', async () => { + const store = makeStore({ + 'https://grafana.example.com': { + grafanaUrl: 'https://grafana.example.com', + ...sampleTokens, + encrypted: false, + }, + }) + mockedReadFile.mockResolvedValue(store) + + await removeConnection('https://grafana.example.com/') + + const written = JSON.parse(mockedWriteFile.mock.calls[0]![1] as string) as { + connections: Record + } + expect(Object.keys(written.connections)).toHaveLength(0) + }) + + it('does not fail when the URL is not in the store', async () => { + mockedReadFile.mockRejectedValue(new Error('not found')) + + await expect( + removeConnection('https://unknown.example.com') + ).resolves.toBeUndefined() + }) +}) + +describe('getFirstStoredGrafanaUrl', () => { + it('returns null when the store is empty', async () => { + mockedReadFile.mockRejectedValue(new Error('not found')) + + const result = await getFirstStoredGrafanaUrl() + expect(result).toBeNull() + }) + + it('returns the first stored URL', async () => { + const store = makeStore({ + 'https://grafana.example.com': { + grafanaUrl: 'https://grafana.example.com', + ...sampleTokens, + encrypted: false, + }, + }) + mockedReadFile.mockResolvedValue(store) + + const result = await getFirstStoredGrafanaUrl() + expect(result).toBe('https://grafana.example.com') + }) +}) diff --git a/src/handlers/grafana-assistant/storage.ts b/src/handlers/grafana-assistant/storage.ts new file mode 100644 index 000000000..b714aac8e --- /dev/null +++ b/src/handlers/grafana-assistant/storage.ts @@ -0,0 +1,112 @@ +import { app } from 'electron' +import { readFile, writeFile } from 'fs/promises' +import path from 'path' +import { z } from 'zod' + +import { + decryptString, + encryptString, + isEncryptionAvailable, +} from '@/main/encryption' + +const fileName = + process.env.NODE_ENV === 'development' + ? 'k6-studio-grafana-assistant-dev.json' + : 'k6-studio-grafana-assistant.json' + +const filePath = path.join(app.getPath('userData'), fileName) + +const StoredConnectionSchema = z.object({ + grafanaUrl: z.string(), + apiEndpoint: z.string(), + gatToken: z.string(), + garToken: z.string(), + expiresAt: z.string(), + refreshExpiresAt: z.string(), + encrypted: z.boolean(), +}) + +const GrafanaAssistantStoreSchema = z.object({ + version: z.literal('1.0'), + connections: z.record(StoredConnectionSchema), +}) + +type GrafanaAssistantStore = z.infer + +export interface ConnectionTokens { + apiEndpoint: string + gatToken: string + garToken: string + expiresAt: string + refreshExpiresAt: string +} + +function defaultStore(): GrafanaAssistantStore { + return { version: '1.0', connections: {} } +} + +function normalizeUrl(url: string): string { + return url.replace(/\/$/, '') +} + +async function readStore(): Promise { + try { + const file = await readFile(filePath, 'utf-8') + return GrafanaAssistantStoreSchema.parse(JSON.parse(file)) + } catch { + return defaultStore() + } +} + +async function writeStore(store: GrafanaAssistantStore): Promise { + await writeFile(filePath, JSON.stringify(store, null, 2)) +} + +export async function saveConnection( + grafanaUrl: string, + tokens: ConnectionTokens +): Promise { + const store = await readStore() + const key = normalizeUrl(grafanaUrl) + const canEncrypt = isEncryptionAvailable() + + store.connections[key] = { + grafanaUrl: key, + apiEndpoint: tokens.apiEndpoint, + gatToken: canEncrypt ? encryptString(tokens.gatToken) : tokens.gatToken, + garToken: canEncrypt ? encryptString(tokens.garToken) : tokens.garToken, + expiresAt: tokens.expiresAt, + refreshExpiresAt: tokens.refreshExpiresAt, + encrypted: canEncrypt, + } + + await writeStore(store) +} + +export async function getConnection( + grafanaUrl: string +): Promise { + const store = await readStore() + const conn = store.connections[normalizeUrl(grafanaUrl)] + if (!conn) return null + + return { + apiEndpoint: conn.apiEndpoint, + gatToken: conn.encrypted ? decryptString(conn.gatToken) : conn.gatToken, + garToken: conn.encrypted ? decryptString(conn.garToken) : conn.garToken, + expiresAt: conn.expiresAt, + refreshExpiresAt: conn.refreshExpiresAt, + } +} + +export async function removeConnection(grafanaUrl: string): Promise { + const store = await readStore() + delete store.connections[normalizeUrl(grafanaUrl)] + await writeStore(store) +} + +export async function getFirstStoredGrafanaUrl(): Promise { + const store = await readStore() + const [first] = Object.keys(store.connections) + return first ?? null +} diff --git a/src/handlers/grafana-assistant/types.ts b/src/handlers/grafana-assistant/types.ts new file mode 100644 index 000000000..7838fd88f --- /dev/null +++ b/src/handlers/grafana-assistant/types.ts @@ -0,0 +1,24 @@ +export enum GrafanaAssistantHandler { + Connect = 'grafana-assistant:connect', + Disconnect = 'grafana-assistant:disconnect', + GetConnection = 'grafana-assistant:get-connection', + StateChange = 'grafana-assistant:state-change', + Abort = 'grafana-assistant:abort', +} + +export interface GrafanaAssistantConnection { + grafanaUrl: string + apiEndpoint: string + expiresAt: string +} + +export type ConnectProcessState = + | { type: 'authorizing' } + | { type: 'exchanging' } + | { type: 'completed'; connection: GrafanaAssistantConnection } + | { type: 'error'; message: string } + +export type ConnectResult = + | { type: 'connected'; connection: GrafanaAssistantConnection } + | { type: 'aborted' } + | { type: 'error'; message: string } diff --git a/src/handlers/index.ts b/src/handlers/index.ts index 3d8cb2f73..874696522 100644 --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -7,6 +7,7 @@ import * as browserTest from './browserTest' import * as cloud from './cloud' import * as dataFiles from './dataFiles' import * as generator from './generator' +import * as grafanaAssistant from './grafana-assistant' import * as har from './har' import * as log from './log' import * as proxy from './proxy' @@ -30,4 +31,5 @@ export function initialize() { log.initialize() app.initialize() ai.initialize() + grafanaAssistant.initialize() } diff --git a/src/preload.ts b/src/preload.ts index b5a9a5fa7..d96675b11 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -9,6 +9,7 @@ import * as browserTest from './handlers/browserTest/preload' import * as cloud from './handlers/cloud/preload' import * as data from './handlers/dataFiles/preload' import * as generator from './handlers/generator/preload' +import * as grafanaAssistant from './handlers/grafana-assistant/preload' import * as har from './handlers/har/preload' import * as log from './handlers/log/preload' import * as proxy from './handlers/proxy/preload' @@ -33,6 +34,7 @@ const studio = { browserRemote, cloud, ai, + grafanaAssistant, } as const contextBridge.exposeInMainWorld('studio', studio) diff --git a/src/services/grafana-assistant/auth.test.ts b/src/services/grafana-assistant/auth.test.ts new file mode 100644 index 000000000..3eca14921 --- /dev/null +++ b/src/services/grafana-assistant/auth.test.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { buildAuthUrl, exchangeCodeForTokens } from './auth' + +global.fetch = vi.fn() + +describe('buildAuthUrl', () => { + it('builds a correct auth URL', () => { + const url = buildAuthUrl( + 'https://grafana.example.com', + 54321, + 'test-state', + 'test-challenge' + ) + + expect(url).toBe( + 'https://grafana.example.com/a/grafana-assistant-app/cli/auth' + + '?callback_port=54321&state=test-state&code_challenge=test-challenge' + + '&code_challenge_method=S256&scopes=assistant%3Achat%2Cassistant%3Aa2a' + ) + }) + + it('strips trailing slash from grafanaUrl', () => { + const url = buildAuthUrl( + 'https://grafana.example.com/', + 54321, + 'state', + 'challenge' + ) + + expect(url).toContain('https://grafana.example.com/a/grafana-assistant-app') + expect(url).not.toContain('https://grafana.example.com//a') + }) + + it('includes all required scopes', () => { + const url = buildAuthUrl('https://example.com', 1234, 'state', 'challenge') + const parsed = new URL(url) + expect(parsed.searchParams.get('scopes')).toBe( + 'assistant:chat,assistant:a2a' + ) + }) + + it('uses S256 as code challenge method', () => { + const url = buildAuthUrl('https://example.com', 1234, 'state', 'challenge') + const parsed = new URL(url) + expect(parsed.searchParams.get('code_challenge_method')).toBe('S256') + }) +}) + +describe('exchangeCodeForTokens', () => { + const mockedFetch = vi.mocked(fetch) + const endpoint = 'https://grafana.example.com' + const code = 'auth-code-123' + const codeVerifier = 'verifier-abc' + + const mockResponseData = { + data: { + token: 'gat-token', + refresh_token: 'gar-token', + api_endpoint: 'https://api.example.com', + expires_at: '2026-12-31T00:00:00Z', + refresh_expires_at: '2027-12-31T00:00:00Z', + }, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('exchanges code for tokens successfully', async () => { + mockedFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponseData), + } as Response) + + const tokens = await exchangeCodeForTokens(endpoint, code, codeVerifier) + + expect(tokens).toEqual({ + gatToken: 'gat-token', + garToken: 'gar-token', + apiEndpoint: 'https://api.example.com', + expiresAt: '2026-12-31T00:00:00Z', + refreshExpiresAt: '2027-12-31T00:00:00Z', + }) + }) + + it('posts to the correct endpoint URL', async () => { + mockedFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponseData), + } as Response) + + await exchangeCodeForTokens(endpoint, code, codeVerifier) + + expect(mockedFetch).toHaveBeenCalledWith( + 'https://grafana.example.com/api/cli/v1/auth/exchange', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code, code_verifier: codeVerifier }), + }) + ) + }) + + it('strips trailing slash from endpoint', async () => { + mockedFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponseData), + } as Response) + + await exchangeCodeForTokens( + 'https://grafana.example.com/', + code, + codeVerifier + ) + + const [url] = mockedFetch.mock.calls[0]! + expect(url as string).toBe( + 'https://grafana.example.com/api/cli/v1/auth/exchange' + ) + }) + + it('throws when the response is not ok', async () => { + mockedFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + text: () => Promise.resolve('Unauthorized'), + } as Response) + + await expect( + exchangeCodeForTokens(endpoint, code, codeVerifier) + ).rejects.toThrow('Token exchange failed (401): Unauthorized') + }) + + it('forwards the AbortSignal to fetch', async () => { + mockedFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponseData), + } as Response) + + const controller = new AbortController() + await exchangeCodeForTokens(endpoint, code, codeVerifier, controller.signal) + + expect(mockedFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ signal: controller.signal }) + ) + }) + + it('propagates AbortError when request is aborted', async () => { + const controller = new AbortController() + const abortError = new DOMException('Aborted', 'AbortError') + mockedFetch.mockRejectedValueOnce(abortError) + + controller.abort() + await expect( + exchangeCodeForTokens(endpoint, code, codeVerifier, controller.signal) + ).rejects.toThrow('Aborted') + }) +}) diff --git a/src/services/grafana-assistant/auth.ts b/src/services/grafana-assistant/auth.ts new file mode 100644 index 000000000..68c2041ad --- /dev/null +++ b/src/services/grafana-assistant/auth.ts @@ -0,0 +1,67 @@ +const SCOPES = ['assistant:chat', 'assistant:a2a'] + +export interface GrafanaAssistantTokens { + gatToken: string + garToken: string + apiEndpoint: string + expiresAt: string + refreshExpiresAt: string +} + +interface ExchangeResponse { + data: { + token: string + refresh_token: string + api_endpoint: string + expires_at: string + refresh_expires_at: string + } +} + +export async function exchangeCodeForTokens( + endpoint: string, + code: string, + codeVerifier: string, + signal?: AbortSignal +): Promise { + const url = `${endpoint.replace(/\/$/, '')}/api/cli/v1/auth/exchange` + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code, code_verifier: codeVerifier }), + signal, + }) + + if (!response.ok) { + const text = await response.text() + throw new Error(`Token exchange failed (${response.status}): ${text}`) + } + + const { data } = (await response.json()) as ExchangeResponse + + return { + gatToken: data.token, + garToken: data.refresh_token, + apiEndpoint: data.api_endpoint, + expiresAt: data.expires_at, + refreshExpiresAt: data.refresh_expires_at, + } +} + +export function buildAuthUrl( + grafanaUrl: string, + callbackPort: number, + state: string, + codeChallenge: string +): string { + const base = grafanaUrl.replace(/\/$/, '') + const params = new URLSearchParams({ + callback_port: String(callbackPort), + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + scopes: SCOPES.join(','), + }) + return `${base}/a/grafana-assistant-app/cli/auth?${params.toString()}` +} diff --git a/src/services/grafana-assistant/callback-server.ts b/src/services/grafana-assistant/callback-server.ts new file mode 100644 index 000000000..e661d9092 --- /dev/null +++ b/src/services/grafana-assistant/callback-server.ts @@ -0,0 +1,83 @@ +import http from 'node:http' + +const PORT_RANGE_START = 54321 +const PORT_RANGE_END = 54399 + +export interface CallbackParams { + code: string + state: string + endpoint: string +} + +function tryListen(server: http.Server, port: number): Promise { + return new Promise((resolve) => { + server.once('error', () => resolve(false)) + server.listen(port, '127.0.0.1', () => resolve(true)) + }) +} + +export async function startCallbackServer( + expectedState: string, + signal: AbortSignal +): Promise<{ port: number; result: Promise }> { + let resolveCallback!: (params: CallbackParams) => void + let rejectCallback!: (error: Error) => void + + const result = new Promise((resolve, reject) => { + resolveCallback = resolve + rejectCallback = reject + }) + + const server = http.createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://127.0.0.1`) + + if (url.pathname !== '/callback') { + res.writeHead(404) + res.end() + return + } + + const code = url.searchParams.get('code') + const state = url.searchParams.get('state') + const endpoint = url.searchParams.get('endpoint') + + if (!code || !state || !endpoint) { + res.writeHead(400) + res.end('Missing code, state, or endpoint') + rejectCallback(new Error('Missing code, state, or endpoint in callback')) + return + } + + if (state !== expectedState) { + res.writeHead(400) + res.end('Invalid state') + rejectCallback(new Error('State mismatch: possible CSRF attack')) + return + } + + res.writeHead(200, { 'Content-Type': 'text/html' }) + res.end( + '

Authentication successful

You can close this tab and return to k6 Studio.

' + ) + + server.close() + resolveCallback({ code, state, endpoint }) + }) + + const handleAbort = () => { + server.close() + rejectCallback(new Error('Authentication aborted')) + } + signal.addEventListener('abort', handleAbort, { once: true }) + + for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port++) { + const ok = await tryListen(server, port) + if (ok) { + return { port, result } + } + } + + throw new Error( + 'Could not find an available port for the OAuth callback server' + ) +} diff --git a/src/services/grafana-assistant/pkce.test.ts b/src/services/grafana-assistant/pkce.test.ts new file mode 100644 index 000000000..7aefa776e --- /dev/null +++ b/src/services/grafana-assistant/pkce.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' + +import { + generateCodeChallenge, + generateCodeVerifier, + generateState, +} from './pkce' + +describe('generateCodeVerifier', () => { + it('returns a base64url string', () => { + const verifier = generateCodeVerifier() + expect(verifier).toMatch(/^[A-Za-z0-9\-_]+$/) + }) + + it('returns 43 characters (32 bytes in base64url)', () => { + const verifier = generateCodeVerifier() + // 32 bytes → 43 base64url chars + expect(verifier.length).toBe(43) + }) + + it('returns a different value each call', () => { + const a = generateCodeVerifier() + const b = generateCodeVerifier() + expect(a).not.toBe(b) + }) +}) + +describe('generateCodeChallenge', () => { + it('returns a base64url string', () => { + const challenge = generateCodeChallenge('test-verifier') + expect(challenge).toMatch(/^[A-Za-z0-9\-_]+$/) + }) + + it('is deterministic for the same verifier', () => { + const verifier = 'fixed-verifier-value' + expect(generateCodeChallenge(verifier)).toBe( + generateCodeChallenge(verifier) + ) + }) + + it('produces different challenges for different verifiers', () => { + const a = generateCodeChallenge('verifier-a') + const b = generateCodeChallenge('verifier-b') + expect(a).not.toBe(b) + }) + + it('produces the correct SHA-256 hash for a known verifier', () => { + // echo -n "abc" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=' + // SHA-256("abc") = ungWv48Bz-pBQUDeXa4iI7ADYaOWF3qctBD_YfIAFa0 + expect(generateCodeChallenge('abc')).toBe( + 'ungWv48Bz-pBQUDeXa4iI7ADYaOWF3qctBD_YfIAFa0' + ) + }) +}) + +describe('generateState', () => { + it('returns a hex string', () => { + const state = generateState() + expect(state).toMatch(/^[0-9a-f]+$/) + }) + + it('returns 64 characters (32 bytes as hex)', () => { + const state = generateState() + expect(state.length).toBe(64) + }) + + it('returns a different value each call', () => { + const a = generateState() + const b = generateState() + expect(a).not.toBe(b) + }) +}) diff --git a/src/services/grafana-assistant/pkce.ts b/src/services/grafana-assistant/pkce.ts new file mode 100644 index 000000000..025e92adb --- /dev/null +++ b/src/services/grafana-assistant/pkce.ts @@ -0,0 +1,13 @@ +import crypto from 'node:crypto' + +export function generateCodeVerifier(): string { + return crypto.randomBytes(32).toString('base64url') +} + +export function generateCodeChallenge(verifier: string): string { + return crypto.createHash('sha256').update(verifier).digest('base64url') +} + +export function generateState(): string { + return crypto.randomBytes(32).toString('hex') +}