diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d660a9f..f5e818a 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -14,6 +14,10 @@ npm install npm run dev -- --token YOUR_LINEAR_API_TOKEN ``` +### Interactive OAuth login + +The `auth` subcommands (`mcp-linear auth login|status|logout`) live in `src/auth/`. `login` runs a PKCE authorization-code flow against `https://linear.app/oauth/authorize` with a loopback callback server (default port 8734) and stores tokens in `$XDG_CONFIG_HOME/mcp-linear/credentials.json`. Set `MCP_LINEAR_CONFIG_DIR` to point the credential store somewhere else — the tests use this to write only inside a temp directory. The server falls back to the store when no explicit `--token` flag or `LINEAR_API_TOKEN`/`LINEAR_API_KEY` variable is provided and refreshes expired access tokens automatically, persisting Linear's rotated refresh tokens atomically (temp file plus rename). Never log token or secret values; errors must stay sanitized (HTTP status only). + ### Validation Use the following checks before merging or publishing: diff --git a/README.md b/README.md index bda69b8..8a9ecad 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,9 @@ Once connected, you can use prompts like: ## Installation -### Getting your Linear API token +### Authentication + +#### Personal API key (default) 1. Log in to your Linear account at [linear.app](https://linear.app) 2. Click on your organization avatar (top-left corner) @@ -63,6 +65,29 @@ Once connected, you can use prompts like: 6. Give your key a name (e.g., `MCP Linear Integration`) 7. Copy the generated API token and store it securely — you won't be able to see it again +Pass the key with `--token`, or set `LINEAR_API_TOKEN` (or `LINEAR_API_KEY`) in the environment. + +#### Interactive OAuth login (no pasted keys) + +Instead of pasting a token, you can sign in once and let the server manage its own credential: + +```bash +mcp-linear auth login --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET +``` + +The client id and secret can also come from `LINEAR_OAUTH_CLIENT_ID` / `LINEAR_OAUTH_CLIENT_SECRET`. They belong to an OAuth application you create at **Settings → API → Applications** with the redirect URI `http://localhost:8734/callback` (override the port with `--redirect-port`, which must match a registered redirect URI). Request different scopes with `--scopes read,write,issues:create`. + +`auth login` starts a loopback server on 127.0.0.1, opens your browser to Linear's consent page (PKCE S256 plus a random `state`), exchanges the authorization code, and saves the credentials to `~/.config/mcp-linear/credentials.json` (`$XDG_CONFIG_HOME` and `MCP_LINEAR_CONFIG_DIR` are honored). The file and its directory are created with owner-only permissions (0600/0700), and token values are never printed or logged. + +When the server starts without an explicit `--token` flag or `LINEAR_API_TOKEN`/`LINEAR_API_KEY` variable, it uses the stored credentials automatically. Explicit CLI and environment credentials always take precedence over the store. Linear access tokens expire and refresh tokens rotate; the server refreshes the access token automatically (at startup and mid-session) and atomically persists each rotated refresh token. + +```bash +mcp-linear auth status # shows scopes, expiry, and a masked token suffix +mcp-linear auth logout # best-effort revocation, then deletes the stored file +``` + +`auth logout` still removes the local credentials even when the revocation request fails (for example, offline). + ### Installing via [add-mcp](https://github.com/neondatabase/add-mcp) (Recommended) `add-mcp` installs the server into Claude Code, Cursor, Codex, VS Code, Claude Desktop, and many other MCP-aware agents with a single command: @@ -132,6 +157,13 @@ export LINEAR_API_TOKEN=YOUR_LINEAR_API_TOKEN mcp-linear ``` +Or sign in interactively once and run without any token at all (see [Interactive OAuth login](#interactive-oauth-login-no-pasted-keys)): + +```bash +mcp-linear auth login --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET +mcp-linear +``` + ## Validation The default validation path is: diff --git a/src/__tests__/auth-callback-server.test.ts b/src/__tests__/auth-callback-server.test.ts new file mode 100644 index 0000000..d2f47b3 --- /dev/null +++ b/src/__tests__/auth-callback-server.test.ts @@ -0,0 +1,95 @@ +import { startOAuthCallbackServer } from '../auth/callback-server.js'; + +describe('OAuth loopback callback server', () => { + it('resolves with the authorization code when state matches', async () => { + const pending = await startOAuthCallbackServer({ + port: 0, + expectedState: 'expected-state', + timeoutMs: 5_000, + }); + + const response = await fetch( + `http://127.0.0.1:${pending.port}/callback?code=auth-code-1&state=expected-state`, + ); + const html = await response.text(); + + expect(response.status).toBe(200); + expect(html).toContain('close this tab'); + await expect(pending.callback).resolves.toEqual({ code: 'auth-code-1' }); + }); + + it('rejects and returns HTTP 400 on a state mismatch', async () => { + const pending = await startOAuthCallbackServer({ + port: 0, + expectedState: 'expected-state', + timeoutMs: 5_000, + }); + const rejection = expect(pending.callback).rejects.toThrow(/state/i); + + const response = await fetch( + `http://127.0.0.1:${pending.port}/callback?code=auth-code-1&state=attacker-state`, + ); + + expect(response.status).toBe(400); + await rejection; + }); + + it('rejects when the provider redirects back with an error', async () => { + const pending = await startOAuthCallbackServer({ + port: 0, + expectedState: 'expected-state', + timeoutMs: 5_000, + }); + const rejection = expect(pending.callback).rejects.toThrow(/access_denied/); + + const response = await fetch( + `http://127.0.0.1:${pending.port}/callback?error=access_denied&state=expected-state`, + ); + + expect(response.status).toBe(400); + await rejection; + }); + + it('ignores unrelated paths while continuing to wait', async () => { + const pending = await startOAuthCallbackServer({ + port: 0, + expectedState: 'expected-state', + timeoutMs: 5_000, + }); + + const stray = await fetch(`http://127.0.0.1:${pending.port}/favicon.ico`); + expect(stray.status).toBe(404); + + const response = await fetch( + `http://127.0.0.1:${pending.port}/callback?code=auth-code-2&state=expected-state`, + ); + expect(response.status).toBe(200); + await expect(pending.callback).resolves.toEqual({ code: 'auth-code-2' }); + }); + + it('times out with a clean error when no callback arrives', async () => { + const pending = await startOAuthCallbackServer({ + port: 0, + expectedState: 'expected-state', + timeoutMs: 50, + }); + + await expect(pending.callback).rejects.toThrow(/timed out/i); + }); + + it('can be cancelled, releasing the port', async () => { + const pending = await startOAuthCallbackServer({ + port: 0, + expectedState: 'expected-state', + timeoutMs: 5_000, + }); + + const cancelled = pending.cancel(); + await expect(pending.callback).rejects.toThrow(/cancelled/i); + await cancelled; + + await expect( + fetch(`http://127.0.0.1:${pending.port}/callback?code=x&state=expected-state`), + ).rejects.toThrow(); + }); +}); diff --git a/src/__tests__/auth-cli.test.ts b/src/__tests__/auth-cli.test.ts new file mode 100644 index 0000000..f519852 --- /dev/null +++ b/src/__tests__/auth-cli.test.ts @@ -0,0 +1,264 @@ +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; + +import { runAuthCli } from '../auth/cli.js'; +import { + getCredentialsPath, + readStoredCredentials, + writeStoredCredentials, + type StoredCredentials, +} from '../auth/credential-store.js'; + +const storedCredentials: StoredCredentials = { + clientId: 'client-id-1', + clientSecret: 'stored-client-secret', + accessToken: 'stored-access-token-abcd', + refreshToken: 'stored-refresh-token', + expiresAt: Date.now() + 3_600_000, + scopes: ['read', 'write'], +}; + +function jsonResponse(status: number, payload: unknown) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => payload, + } as unknown as Response; +} + +function httpGet(url: string): Promise { + return new Promise((resolve, reject) => { + http + // agent: false avoids a lingering keep-alive socket (Jest open handle). + .get(url, { agent: false }, (res) => { + res.resume(); + res.on('end', () => resolve(res.statusCode ?? 0)); + }) + .on('error', reject); + }); +} + +describe('auth CLI', () => { + const originalEnv = process.env; + let tempDir: string; + let printed: string[]; + let print: (message: string) => void; + let fetchSpy: jest.SpyInstance; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.LINEAR_OAUTH_CLIENT_ID; + delete process.env.LINEAR_OAUTH_CLIENT_SECRET; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-linear-auth-')); + process.env.MCP_LINEAR_CONFIG_DIR = tempDir; + printed = []; + print = (message: string) => printed.push(message); + fetchSpy = jest.spyOn(global, 'fetch'); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + process.env = originalEnv; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function expectNoSecretsPrinted(secrets: string[]) { + const allOutput = [ + ...printed, + ...consoleErrorSpy.mock.calls.map((call) => call.map(String).join(' ')), + ].join('\n'); + for (const secret of secrets) { + expect(allOutput).not.toContain(secret); + } + } + + it('rejects unknown subcommands with usage guidance', async () => { + const exitCode = await runAuthCli(['frobnicate'], { print }); + + expect(exitCode).toBe(1); + expect(printed.join('\n')).toMatch(/login|status|logout/); + }); + + describe('login', () => { + it('requires client credentials and explains how to supply them', async () => { + const exitCode = await runAuthCli(['login'], { print }); + + expect(exitCode).toBe(1); + expect(printed.join('\n')).toContain('--client-id'); + expect(printed.join('\n')).toContain('LINEAR_OAUTH_CLIENT_ID'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('runs the full PKCE flow and stores the resulting tokens', async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse(200, { + access_token: 'fresh-access-token', + refresh_token: 'fresh-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'read,write', + }), + ); + + const openBrowser = jest.fn(async (url: string) => { + const authorizeUrl = new URL(url); + expect(authorizeUrl.origin).toBe('https://linear.app'); + expect(authorizeUrl.pathname).toBe('/oauth/authorize'); + expect(authorizeUrl.searchParams.get('response_type')).toBe('code'); + expect(authorizeUrl.searchParams.get('client_id')).toBe('client-id-1'); + expect(authorizeUrl.searchParams.get('scope')).toBe('read,write'); + expect(authorizeUrl.searchParams.get('code_challenge_method')).toBe('S256'); + expect(authorizeUrl.searchParams.get('code_challenge')).toBeTruthy(); + + const state = authorizeUrl.searchParams.get('state'); + expect(state).toBeTruthy(); + const redirectUri = new URL(authorizeUrl.searchParams.get('redirect_uri')!); + expect(redirectUri.pathname).toBe('/callback'); + + // Simulate the browser redirect back to the loopback server. + void httpGet( + `http://127.0.0.1:${redirectUri.port}/callback?code=auth-code-1&state=${state}`, + ); + return true; + }); + + const exitCode = await runAuthCli( + [ + 'login', + '--client-id', + 'client-id-1', + '--client-secret', + 'login-client-secret', + '--redirect-port', + '0', + ], + { print, openBrowser }, + ); + + expect(exitCode).toBe(0); + expect(openBrowser).toHaveBeenCalledTimes(1); + + const body = fetchSpy.mock.calls[0]?.[1]?.body as URLSearchParams; + expect(body.get('grant_type')).toBe('authorization_code'); + expect(body.get('code')).toBe('auth-code-1'); + expect(body.get('code_verifier')).toBeTruthy(); + + const stored = readStoredCredentials(); + expect(stored).toMatchObject({ + clientId: 'client-id-1', + clientSecret: 'login-client-secret', + accessToken: 'fresh-access-token', + refreshToken: 'fresh-refresh-token', + scopes: ['read', 'write'], + }); + expect(stored?.expiresAt).toBeGreaterThan(Date.now()); + + expectNoSecretsPrinted([ + 'login-client-secret', + 'fresh-access-token', + 'fresh-refresh-token', + 'auth-code-1', + ]); + }); + + it('accepts client credentials from the environment', async () => { + process.env.LINEAR_OAUTH_CLIENT_ID = 'env-client-id'; + process.env.LINEAR_OAUTH_CLIENT_SECRET = 'env-client-secret'; + fetchSpy.mockResolvedValueOnce( + jsonResponse(200, { + access_token: 'fresh-access-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'read', + }), + ); + + const openBrowser = jest.fn(async (url: string) => { + const authorizeUrl = new URL(url); + expect(authorizeUrl.searchParams.get('client_id')).toBe('env-client-id'); + const state = authorizeUrl.searchParams.get('state'); + const redirectUri = new URL(authorizeUrl.searchParams.get('redirect_uri')!); + void httpGet( + `http://127.0.0.1:${redirectUri.port}/callback?code=auth-code-2&state=${state}`, + ); + return true; + }); + + const exitCode = await runAuthCli(['login', '--redirect-port', '0'], { + print, + openBrowser, + }); + + expect(exitCode).toBe(0); + expect(readStoredCredentials()?.clientId).toBe('env-client-id'); + }); + }); + + describe('status', () => { + it('reports when no credentials are stored', async () => { + const exitCode = await runAuthCli(['status'], { print }); + + expect(exitCode).toBe(1); + expect(printed.join('\n')).toMatch(/no stored credentials/i); + }); + + it('prints scopes, expiry, and only the token suffix', async () => { + writeStoredCredentials(storedCredentials); + + const exitCode = await runAuthCli(['status'], { print }); + + expect(exitCode).toBe(0); + const output = printed.join('\n'); + expect(output).toContain('read, write'); + expect(output).toContain(new Date(storedCredentials.expiresAt!).toISOString()); + expect(output).toContain('abcd'); + expectNoSecretsPrinted([ + storedCredentials.accessToken, + storedCredentials.clientSecret, + storedCredentials.refreshToken!, + ]); + }); + }); + + describe('logout', () => { + it('succeeds quietly when no credentials are stored', async () => { + const exitCode = await runAuthCli(['logout'], { print }); + + expect(exitCode).toBe(0); + expect(printed.join('\n')).toMatch(/no stored credentials/i); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('revokes the access token and deletes the credentials file', async () => { + writeStoredCredentials(storedCredentials); + fetchSpy.mockResolvedValueOnce(jsonResponse(200, {})); + + const exitCode = await runAuthCli(['logout'], { print }); + + expect(exitCode).toBe(0); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.linear.app/oauth/revoke', + expect.objectContaining({ method: 'POST' }), + ); + const body = fetchSpy.mock.calls[0]?.[1]?.body as URLSearchParams; + expect(body.get('token')).toBe(storedCredentials.accessToken); + expect(body.get('token_type_hint')).toBe('access_token'); + expect(fs.existsSync(getCredentialsPath())).toBe(false); + }); + + it('still deletes local credentials when revocation fails offline', async () => { + writeStoredCredentials(storedCredentials); + fetchSpy.mockRejectedValueOnce(new TypeError('fetch failed')); + + const exitCode = await runAuthCli(['logout'], { print }); + + expect(exitCode).toBe(0); + expect(fs.existsSync(getCredentialsPath())).toBe(false); + expect(printed.join('\n')).toMatch(/could not be revoked|revocation failed/i); + }); + }); +}); diff --git a/src/__tests__/auth-credential-store.test.ts b/src/__tests__/auth-credential-store.test.ts new file mode 100644 index 0000000..ee85d08 --- /dev/null +++ b/src/__tests__/auth-credential-store.test.ts @@ -0,0 +1,124 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + deleteStoredCredentials, + getCredentialsPath, + readStoredCredentials, + writeStoredCredentials, + type StoredCredentials, +} from '../auth/credential-store.js'; + +const sampleCredentials: StoredCredentials = { + clientId: 'client-id-1', + clientSecret: 'client-secret-1', + accessToken: 'access-token-1', + refreshToken: 'refresh-token-1', + expiresAt: 1893456000000, + scopes: ['read', 'write'], +}; + +describe('credential store', () => { + const originalEnv = process.env; + let tempDir: string; + + beforeEach(() => { + process.env = { ...originalEnv }; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-linear-auth-')); + process.env.MCP_LINEAR_CONFIG_DIR = tempDir; + }); + + afterEach(() => { + process.env = originalEnv; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('resolves the credentials path from MCP_LINEAR_CONFIG_DIR first', () => { + expect(getCredentialsPath()).toBe(path.join(tempDir, 'credentials.json')); + }); + + it('falls back to XDG_CONFIG_HOME/mcp-linear when no explicit override is set', () => { + delete process.env.MCP_LINEAR_CONFIG_DIR; + process.env.XDG_CONFIG_HOME = '/custom/xdg'; + + expect(getCredentialsPath()).toBe(path.join('/custom/xdg', 'mcp-linear', 'credentials.json')); + }); + + it('defaults to ~/.config/mcp-linear', () => { + delete process.env.MCP_LINEAR_CONFIG_DIR; + delete process.env.XDG_CONFIG_HOME; + + expect(getCredentialsPath()).toBe( + path.join(os.homedir(), '.config', 'mcp-linear', 'credentials.json'), + ); + }); + + it('round-trips credentials through write and read', () => { + writeStoredCredentials(sampleCredentials); + + expect(readStoredCredentials()).toEqual(sampleCredentials); + }); + + it('writes the credentials file with owner-only permissions', () => { + writeStoredCredentials(sampleCredentials); + + const fileMode = fs.statSync(getCredentialsPath()).mode & 0o777; + const dirMode = fs.statSync(path.dirname(getCredentialsPath())).mode & 0o777; + + expect(fileMode).toBe(0o600); + // The temp dir itself pre-exists; verify a freshly created config dir instead. + const nestedDir = path.join(tempDir, 'nested-config'); + process.env.MCP_LINEAR_CONFIG_DIR = nestedDir; + writeStoredCredentials(sampleCredentials); + expect(fs.statSync(nestedDir).mode & 0o777).toBe(0o700); + expect(dirMode & 0o077).toBeLessThanOrEqual(0o077); + }); + + it('does not leave temporary files behind after a write', () => { + writeStoredCredentials(sampleCredentials); + + const entries = fs.readdirSync(tempDir); + expect(entries).toEqual(['credentials.json']); + }); + + it('returns undefined when no credentials file exists', () => { + expect(readStoredCredentials()).toBeUndefined(); + }); + + it('returns undefined for a corrupt credentials file', () => { + fs.writeFileSync(getCredentialsPath(), 'not-json{', { mode: 0o600 }); + + expect(readStoredCredentials()).toBeUndefined(); + }); + + it('returns undefined when required fields are missing', () => { + fs.writeFileSync( + getCredentialsPath(), + JSON.stringify({ clientId: 'only-a-client-id' }), + { mode: 0o600 }, + ); + + expect(readStoredCredentials()).toBeUndefined(); + }); + + it('tolerates credentials without a refresh token or expiry', () => { + const minimal: StoredCredentials = { + clientId: 'client-id-1', + clientSecret: 'client-secret-1', + accessToken: 'access-token-1', + scopes: ['read'], + }; + writeStoredCredentials(minimal); + + expect(readStoredCredentials()).toEqual(minimal); + }); + + it('deletes stored credentials and reports whether a file was removed', () => { + writeStoredCredentials(sampleCredentials); + + expect(deleteStoredCredentials()).toBe(true); + expect(fs.existsSync(getCredentialsPath())).toBe(false); + expect(deleteStoredCredentials()).toBe(false); + }); +}); diff --git a/src/__tests__/auth-managed-auth.test.ts b/src/__tests__/auth-managed-auth.test.ts new file mode 100644 index 0000000..5538cb2 --- /dev/null +++ b/src/__tests__/auth-managed-auth.test.ts @@ -0,0 +1,165 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + readStoredCredentials, + writeStoredCredentials, + type StoredCredentials, +} from '../auth/credential-store.js'; +import { createStoredCredentialAuth } from '../auth/managed-auth.js'; + +const NOW = 1_752_300_000_000; + +function seedCredentials(overrides: Partial = {}): StoredCredentials { + const credentials: StoredCredentials = { + clientId: 'client-id-1', + clientSecret: 'client-secret-1', + accessToken: 'stored-access-token', + refreshToken: 'stored-refresh-token', + expiresAt: NOW + 3_600_000, + scopes: ['read', 'write'], + ...overrides, + }; + writeStoredCredentials(credentials); + return credentials; +} + +function jsonResponse(status: number, payload: unknown) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => payload, + } as unknown as Response; +} + +describe('stored credential auth', () => { + const originalEnv = process.env; + let tempDir: string; + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + process.env = { ...originalEnv }; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-linear-auth-')); + process.env.MCP_LINEAR_CONFIG_DIR = tempDir; + fetchSpy = jest.spyOn(global, 'fetch'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + process.env = originalEnv; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('returns undefined when no credentials are stored', () => { + expect(createStoredCredentialAuth({ now: () => NOW })).toBeUndefined(); + }); + + it('returns the stored token without any network call when it is still fresh', async () => { + seedCredentials(); + const auth = createStoredCredentialAuth({ now: () => NOW }); + + await expect(auth!.getConfig()).resolves.toEqual({ + type: 'oauth', + token: 'stored-access-token', + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('refreshes an expired token and persists the rotated refresh token', async () => { + seedCredentials({ expiresAt: NOW - 1_000 }); + fetchSpy.mockResolvedValueOnce( + jsonResponse(200, { + access_token: 'refreshed-access-token', + refresh_token: 'rotated-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'read,write', + }), + ); + + const auth = createStoredCredentialAuth({ now: () => NOW }); + await expect(auth!.getConfig()).resolves.toEqual({ + type: 'oauth', + token: 'refreshed-access-token', + }); + + const persisted = readStoredCredentials(); + expect(persisted?.accessToken).toBe('refreshed-access-token'); + expect(persisted?.refreshToken).toBe('rotated-refresh-token'); + expect(persisted?.expiresAt).toBe(NOW + 3_600_000); + }); + + it('refreshes when the token is within the expiry skew window', async () => { + seedCredentials({ expiresAt: NOW + 30_000 }); + fetchSpy.mockResolvedValueOnce( + jsonResponse(200, { + access_token: 'refreshed-access-token', + refresh_token: 'rotated-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'read,write', + }), + ); + + const auth = createStoredCredentialAuth({ now: () => NOW }); + await expect(auth!.getConfig()).resolves.toEqual({ + type: 'oauth', + token: 'refreshed-access-token', + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('does not refresh again once the rotated token is fresh', async () => { + seedCredentials({ expiresAt: NOW - 1_000 }); + fetchSpy.mockResolvedValueOnce( + jsonResponse(200, { + access_token: 'refreshed-access-token', + refresh_token: 'rotated-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'read,write', + }), + ); + + const auth = createStoredCredentialAuth({ now: () => NOW }); + await auth!.getConfig(); + await auth!.getConfig(); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('fails with a sanitized re-login hint when the refresh is rejected', async () => { + seedCredentials({ expiresAt: NOW - 1_000 }); + fetchSpy.mockResolvedValueOnce(jsonResponse(401, { error: 'invalid_grant' })); + + const auth = createStoredCredentialAuth({ now: () => NOW }); + await expect(auth!.getConfig()).rejects.toThrow(/mcp-linear auth login/); + await expect( + auth!.getConfig().catch((error: Error) => { + expect(error.message).not.toContain('stored-refresh-token'); + expect(error.message).not.toContain('client-secret-1'); + throw error; + }), + ).rejects.toThrow(); + }); + + it('asks for a re-login when the token is expired and no refresh token exists', async () => { + seedCredentials({ expiresAt: NOW - 1_000, refreshToken: undefined }); + + const auth = createStoredCredentialAuth({ now: () => NOW }); + await expect(auth!.getConfig()).rejects.toThrow(/mcp-linear auth login/); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('treats a token without expiry metadata as non-expiring', async () => { + seedCredentials({ expiresAt: undefined, refreshToken: undefined }); + + const auth = createStoredCredentialAuth({ now: () => NOW }); + await expect(auth!.getConfig()).resolves.toEqual({ + type: 'oauth', + token: 'stored-access-token', + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/auth-oauth-http.test.ts b/src/__tests__/auth-oauth-http.test.ts new file mode 100644 index 0000000..9708641 --- /dev/null +++ b/src/__tests__/auth-oauth-http.test.ts @@ -0,0 +1,207 @@ +import { + exchangeAuthorizationCode, + refreshAccessToken, + revokeToken, +} from '../auth/oauth-http.js'; + +function jsonResponse(status: number, payload: unknown) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => payload, + } as unknown as Response; +} + +describe('OAuth token endpoint client', () => { + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + fetchSpy = jest.spyOn(global, 'fetch'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('exchangeAuthorizationCode', () => { + it('posts the authorization code with PKCE verifier and returns normalized tokens', async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse(200, { + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'read,write', + }), + ); + + const result = await exchangeAuthorizationCode({ + code: 'auth-code-1', + redirectUri: 'http://localhost:8734/callback', + clientId: 'client-id-1', + clientSecret: 'client-secret-1', + codeVerifier: 'verifier-1', + }); + + expect(result).toEqual({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + expiresIn: 3600, + scopes: ['read', 'write'], + }); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.linear.app/oauth/token', + expect.objectContaining({ + method: 'POST', + redirect: 'error', + signal: expect.any(AbortSignal), + }), + ); + const body = fetchSpy.mock.calls[0]?.[1]?.body as URLSearchParams; + expect(body.get('grant_type')).toBe('authorization_code'); + expect(body.get('code')).toBe('auth-code-1'); + expect(body.get('redirect_uri')).toBe('http://localhost:8734/callback'); + expect(body.get('client_id')).toBe('client-id-1'); + expect(body.get('client_secret')).toBe('client-secret-1'); + expect(body.get('code_verifier')).toBe('verifier-1'); + }); + + it('does not embed the client secret or code in error messages', async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse(400, { error: 'invalid_grant' })); + + await expect( + exchangeAuthorizationCode({ + code: 'super-secret-code', + redirectUri: 'http://localhost:8734/callback', + clientId: 'client-id-1', + clientSecret: 'super-secret-value', + codeVerifier: 'verifier-1', + }), + ).rejects.toThrow(/HTTP 400/); + + await expect( + exchangeAuthorizationCode({ + code: 'super-secret-code', + redirectUri: 'http://localhost:8734/callback', + clientId: 'client-id-1', + clientSecret: 'super-secret-value', + codeVerifier: 'verifier-1', + }).catch((error: Error) => { + expect(error.message).not.toContain('super-secret-value'); + expect(error.message).not.toContain('super-secret-code'); + throw error; + }), + ).rejects.toThrow(); + }); + + it('rejects responses with invalid JSON', async () => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => { + throw new Error('bad json'); + }, + } as unknown as Response); + + await expect( + exchangeAuthorizationCode({ + code: 'auth-code-1', + redirectUri: 'http://localhost:8734/callback', + clientId: 'client-id-1', + clientSecret: 'client-secret-1', + codeVerifier: 'verifier-1', + }), + ).rejects.toThrow(/invalid JSON/); + }); + + it('rejects responses missing an access token', async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse(200, { token_type: 'Bearer' })); + + await expect( + exchangeAuthorizationCode({ + code: 'auth-code-1', + redirectUri: 'http://localhost:8734/callback', + clientId: 'client-id-1', + clientSecret: 'client-secret-1', + codeVerifier: 'verifier-1', + }), + ).rejects.toThrow(/incomplete/); + }); + }); + + describe('refreshAccessToken', () => { + it('posts the refresh token and returns the rotated credentials', async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse(200, { + access_token: 'rotated-access-token', + refresh_token: 'rotated-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'read write', + }), + ); + + const result = await refreshAccessToken({ + refreshToken: 'old-refresh-token', + clientId: 'client-id-1', + clientSecret: 'client-secret-1', + }); + + expect(result).toEqual({ + accessToken: 'rotated-access-token', + refreshToken: 'rotated-refresh-token', + expiresIn: 3600, + scopes: ['read', 'write'], + }); + + const body = fetchSpy.mock.calls[0]?.[1]?.body as URLSearchParams; + expect(body.get('grant_type')).toBe('refresh_token'); + expect(body.get('refresh_token')).toBe('old-refresh-token'); + expect(body.get('client_id')).toBe('client-id-1'); + expect(body.get('client_secret')).toBe('client-secret-1'); + }); + + it('fails with a sanitized error when the refresh is rejected', async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse(401, { error: 'invalid_grant' })); + + await expect( + refreshAccessToken({ + refreshToken: 'secret-refresh-token', + clientId: 'client-id-1', + clientSecret: 'secret-client-secret', + }).catch((error: Error) => { + expect(error.message).not.toContain('secret-refresh-token'); + expect(error.message).not.toContain('secret-client-secret'); + throw error; + }), + ).rejects.toThrow(/HTTP 401/); + }); + }); + + describe('revokeToken', () => { + it('posts the token with a token_type_hint and reports success', async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse(200, {})); + + await expect( + revokeToken({ token: 'access-token-1', tokenTypeHint: 'access_token' }), + ).resolves.toBe(true); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.linear.app/oauth/revoke', + expect.objectContaining({ method: 'POST', redirect: 'error' }), + ); + const body = fetchSpy.mock.calls[0]?.[1]?.body as URLSearchParams; + expect(body.get('token')).toBe('access-token-1'); + expect(body.get('token_type_hint')).toBe('access_token'); + }); + + it('reports failure without throwing when revocation is rejected or offline', async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse(400, {})); + await expect(revokeToken({ token: 'access-token-1' })).resolves.toBe(false); + + fetchSpy.mockRejectedValueOnce(new TypeError('fetch failed')); + await expect(revokeToken({ token: 'access-token-1' })).resolves.toBe(false); + }); + }); +}); diff --git a/src/__tests__/auth-pkce.test.ts b/src/__tests__/auth-pkce.test.ts new file mode 100644 index 0000000..12333d4 --- /dev/null +++ b/src/__tests__/auth-pkce.test.ts @@ -0,0 +1,38 @@ +import { createHash } from 'node:crypto'; + +import { generatePkcePair, generateState } from '../auth/pkce.js'; + +describe('PKCE generation', () => { + it('generates a verifier using only RFC 7636 unreserved characters at a valid length', () => { + const { verifier } = generatePkcePair(); + + expect(verifier.length).toBeGreaterThanOrEqual(43); + expect(verifier.length).toBeLessThanOrEqual(128); + expect(verifier).toMatch(/^[A-Za-z0-9\-._~]+$/); + }); + + it('generates unique verifiers across calls', () => { + const seen = new Set(); + for (let i = 0; i < 20; i++) { + seen.add(generatePkcePair().verifier); + } + expect(seen.size).toBe(20); + }); + + it('derives the challenge as unpadded base64url of the SHA-256 of the verifier', () => { + const { verifier, challenge } = generatePkcePair(); + const expected = createHash('sha256').update(verifier).digest('base64url'); + + expect(challenge).toBe(expected); + expect(challenge).not.toContain('='); + expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('generates random URL-safe state values', () => { + const first = generateState(); + const second = generateState(); + + expect(first).toMatch(/^[A-Za-z0-9_-]{32,}$/); + expect(first).not.toBe(second); + }); +}); diff --git a/src/__tests__/auth-refreshing-provider.test.ts b/src/__tests__/auth-refreshing-provider.test.ts new file mode 100644 index 0000000..2cd88bd --- /dev/null +++ b/src/__tests__/auth-refreshing-provider.test.ts @@ -0,0 +1,53 @@ +import { createRefreshingProvider } from '../auth/refreshing-provider.js'; +import type { LinearAuthConfig } from '../utils/config.js'; + +describe('refreshing client provider', () => { + it('builds the client once while the token is unchanged', async () => { + const config: LinearAuthConfig = { type: 'oauth', token: 'token-1' }; + const build = jest.fn((c: LinearAuthConfig) => ({ client: c.token })); + + const provider = createRefreshingProvider({ getConfig: async () => config, build }); + + const first = await provider.get(); + const second = await provider.get(); + + expect(build).toHaveBeenCalledTimes(1); + expect(first).toBe(second); + expect(first).toEqual({ client: 'token-1' }); + }); + + it('rebuilds the client when the token rotates', async () => { + let config: LinearAuthConfig = { type: 'oauth', token: 'token-1' }; + const build = jest.fn((c: LinearAuthConfig) => ({ client: c.token })); + + const provider = createRefreshingProvider({ getConfig: async () => config, build }); + + const first = await provider.get(); + config = { type: 'oauth', token: 'token-2' }; + const second = await provider.get(); + + expect(build).toHaveBeenCalledTimes(2); + expect(first).toEqual({ client: 'token-1' }); + expect(second).toEqual({ client: 'token-2' }); + expect(second).not.toBe(first); + }); + + it('propagates refresh failures without caching them', async () => { + let fail = true; + const build = jest.fn((c: LinearAuthConfig) => ({ client: c.token })); + const provider = createRefreshingProvider({ + getConfig: async () => { + if (fail) { + throw new Error('refresh failed'); + } + return { type: 'oauth', token: 'token-1' } as LinearAuthConfig; + }, + build, + }); + + await expect(provider.get()).rejects.toThrow('refresh failed'); + + fail = false; + await expect(provider.get()).resolves.toEqual({ client: 'token-1' }); + }); +}); diff --git a/src/__tests__/auth-resolve.test.ts b/src/__tests__/auth-resolve.test.ts new file mode 100644 index 0000000..82479bc --- /dev/null +++ b/src/__tests__/auth-resolve.test.ts @@ -0,0 +1,91 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { writeStoredCredentials } from '../auth/credential-store.js'; +import { resolveLinearAuth } from '../auth/resolve.js'; + +function seedStore() { + writeStoredCredentials({ + clientId: 'client-id-1', + clientSecret: 'client-secret-1', + accessToken: 'stored-access-token', + refreshToken: 'stored-refresh-token', + expiresAt: Date.now() + 3_600_000, + scopes: ['read', 'write'], + }); +} + +describe('resolveLinearAuth', () => { + const originalArgv = process.argv; + const originalEnv = process.env; + let tempDir: string; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + process.argv = ['node', 'mcp-linear']; + process.env = { ...originalEnv }; + delete process.env.LINEAR_API_TOKEN; + delete process.env.LINEAR_API_KEY; + delete process.env.MCP_LINEAR_DEBUG; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-linear-auth-')); + process.env.MCP_LINEAR_CONFIG_DIR = tempDir; + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + process.argv = originalArgv; + process.env = originalEnv; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('prefers an explicit environment credential over stored credentials', async () => { + seedStore(); + process.env.LINEAR_API_TOKEN = 'explicit-api-token'; + + const resolved = resolveLinearAuth(); + + expect(resolved?.source).toBe('explicit'); + await expect(resolved!.getConfig()).resolves.toEqual({ + type: 'apiKey', + token: 'explicit-api-token', + }); + }); + + it('prefers an explicit CLI credential over stored credentials', async () => { + seedStore(); + process.argv = ['node', 'mcp-linear', '--token', 'explicit-cli-token']; + + const resolved = resolveLinearAuth(); + + expect(resolved?.source).toBe('explicit'); + await expect(resolved!.getConfig()).resolves.toEqual({ + type: 'apiKey', + token: 'explicit-cli-token', + }); + }); + + it('falls back to stored credentials when no explicit credential exists', async () => { + seedStore(); + + const resolved = resolveLinearAuth(); + + expect(resolved?.source).toBe('store'); + await expect(resolved!.getConfig()).resolves.toEqual({ + type: 'oauth', + token: 'stored-access-token', + }); + // The missing-credential diagnostic must not fire when the store succeeds. + expect(consoleErrorSpy).not.toHaveBeenCalledWith( + 'API token not found in command line args or environment variables', + ); + }); + + it('returns undefined with the existing diagnostics when nothing is configured', () => { + expect(resolveLinearAuth()).toBeUndefined(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'API token not found in command line args or environment variables', + ); + }); +}); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 249e4f9..50d37c2 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -1,4 +1,4 @@ -import { getLinearApiToken } from '../utils/config.js'; +import { getExplicitLinearAuthConfig, getLinearApiToken } from '../utils/config.js'; describe('getLinearApiToken', () => { const originalArgv = process.argv; @@ -46,4 +46,27 @@ describe('getLinearApiToken', () => { expect.arrayContaining(['LINEAR_WEBHOOK_SECRET']), ); }); + + describe('getExplicitLinearAuthConfig', () => { + it('prefers an explicit --token CLI flag over environment variables', () => { + process.argv = ['node', 'mcp-linear', '--token', 'cli-token']; + process.env.LINEAR_API_TOKEN = 'env-token'; + + expect(getExplicitLinearAuthConfig()).toEqual({ type: 'apiKey', token: 'cli-token' }); + }); + + it('accepts LINEAR_API_TOKEN and falls back to LINEAR_API_KEY', () => { + process.env.LINEAR_API_TOKEN = 'env-token'; + expect(getExplicitLinearAuthConfig()).toEqual({ type: 'apiKey', token: 'env-token' }); + + delete process.env.LINEAR_API_TOKEN; + process.env.LINEAR_API_KEY = 'env-key'; + expect(getExplicitLinearAuthConfig()).toEqual({ type: 'apiKey', token: 'env-key' }); + }); + + it('returns undefined without logging when no explicit credential is configured, so callers can fall back to stored credentials', () => { + expect(getExplicitLinearAuthConfig()).toBeUndefined(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/auth/browser.ts b/src/auth/browser.ts new file mode 100644 index 0000000..9f20ced --- /dev/null +++ b/src/auth/browser.ts @@ -0,0 +1,36 @@ +import { spawn } from 'node:child_process'; + +/** + * Best-effort attempt to open a URL in the user's default browser using the + * platform launcher. Never throws; callers always print the URL as a + * fallback. + */ +export function openBrowser(url: string): Promise { + let command: string; + let args: string[]; + + if (process.platform === 'darwin') { + command = 'open'; + args = [url]; + } else if (process.platform === 'win32') { + command = 'cmd'; + // The empty string is the window title placeholder `start` expects. + args = ['/c', 'start', '', url]; + } else { + command = 'xdg-open'; + args = [url]; + } + + return new Promise((resolve) => { + try { + const child = spawn(command, args, { stdio: 'ignore', detached: true }); + child.on('error', () => resolve(false)); + child.on('spawn', () => { + child.unref(); + resolve(true); + }); + } catch { + resolve(false); + } + }); +} diff --git a/src/auth/callback-server.ts b/src/auth/callback-server.ts new file mode 100644 index 0000000..a4e56bd --- /dev/null +++ b/src/auth/callback-server.ts @@ -0,0 +1,146 @@ +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; + +export interface CallbackServerOptions { + port: number; + expectedState: string; + timeoutMs: number; +} + +export interface PendingCallback { + /** The actual bound port (useful when options.port is 0). */ + port: number; + /** Resolves with the authorization code, or rejects on mismatch/timeout. */ + callback: Promise<{ code: string }>; + /** Stop waiting; resolves once the port is released. */ + cancel: () => Promise; +} + +const SUCCESS_HTML = ` + + MCP Linear + +

Login complete

+

MCP Linear received the authorization response. You can close this tab.

+ + +`; + +const FAILURE_HTML = ` + + MCP Linear + +

Login failed

+

MCP Linear could not accept this authorization response. Return to the terminal for details.

+ + +`; + +/** + * Start a loopback HTTP server that waits for a single OAuth redirect at + * /callback, validates the state parameter, and hands back the authorization + * code. The server always shuts down after the first callback, a timeout, or + * cancellation. + */ +export function startOAuthCallbackServer(options: CallbackServerOptions): Promise { + return new Promise((resolveStart, rejectStart) => { + let settled = false; + let resolveCallback: (value: { code: string }) => void; + let rejectCallback: (reason: Error) => void; + + const callback = new Promise<{ code: string }>((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, { 'Content-Type': 'text/plain' }); + res.end('Not found'); + return; + } + + const finish = (statusCode: number, html: string, outcome: () => void) => { + res.writeHead(statusCode, { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + }); + res.end(html); + settle(outcome); + }; + + const errorParam = url.searchParams.get('error'); + if (errorParam) { + // The error code comes from Linear's redirect and contains no secrets. + finish(400, FAILURE_HTML, () => + rejectCallback(new Error(`Linear authorization was not granted (${errorParam})`)), + ); + return; + } + + const state = url.searchParams.get('state'); + if (state !== options.expectedState) { + finish(400, FAILURE_HTML, () => + rejectCallback(new Error('OAuth callback state did not match; rejecting the response')), + ); + return; + } + + const code = url.searchParams.get('code'); + if (!code) { + finish(400, FAILURE_HTML, () => + rejectCallback(new Error('OAuth callback did not include an authorization code')), + ); + return; + } + + finish(200, SUCCESS_HTML, () => resolveCallback({ code })); + }); + + const timeout = setTimeout(() => { + settle(() => + rejectCallback( + new Error(`Login timed out after ${Math.round(options.timeoutMs / 1000)}s waiting for the browser callback`), + ), + ); + }, options.timeoutMs); + timeout.unref(); + + let closePromise: Promise | undefined; + + function settle(outcome: () => void): Promise { + if (!settled) { + settled = true; + clearTimeout(timeout); + // Stop accepting connections immediately. The in-flight response (if + // any) has already been flushed by res.end, so only idle keep-alive + // sockets remain to be torn down. + closePromise = new Promise((resolveClose) => { + server.close(() => resolveClose()); + }); + server.closeIdleConnections(); + outcome(); + } + return closePromise ?? Promise.resolve(); + } + + server.on('error', (error) => { + if (!settled) { + settled = true; + clearTimeout(timeout); + rejectStart(error); + } + }); + + server.listen(options.port, '127.0.0.1', () => { + const address = server.address() as AddressInfo; + resolveStart({ + port: address.port, + callback, + cancel: () => settle(() => rejectCallback(new Error('Login was cancelled'))), + }); + }); + }); +} diff --git a/src/auth/cli.ts b/src/auth/cli.ts new file mode 100644 index 0000000..925688f --- /dev/null +++ b/src/auth/cli.ts @@ -0,0 +1,223 @@ +import { openBrowser as defaultOpenBrowser } from './browser.js'; +import { startOAuthCallbackServer } from './callback-server.js'; +import { + deleteStoredCredentials, + getCredentialsPath, + readStoredCredentials, + writeStoredCredentials, +} from './credential-store.js'; +import { exchangeAuthorizationCode, revokeToken } from './oauth-http.js'; +import { generatePkcePair, generateState } from './pkce.js'; + +/** Fixed default loopback port; register http://localhost:8734/callback on the OAuth app. */ +export const DEFAULT_REDIRECT_PORT = 8734; +const CALLBACK_PATH = '/callback'; +const LOGIN_TIMEOUT_MS = 5 * 60 * 1000; +const DEFAULT_SCOPES = ['read', 'write']; + +export interface AuthCliIo { + /** Writes a human-facing line. Defaults to stderr to keep stdout MCP-safe. */ + print?: (message: string) => void; + openBrowser?: (url: string) => Promise; +} + +function parseFlag(args: string[], flag: string): string | undefined { + for (let i = 0; i < args.length; i++) { + if (args[i] === flag && i + 1 < args.length) { + return args[i + 1]; + } + } + return undefined; +} + +function maskToken(token: string): string { + return `****${token.slice(-4)}`; +} + +const USAGE = [ + 'Usage: mcp-linear auth ', + '', + ' login Sign in to Linear via OAuth (opens your browser)', + ' --client-id / --client-secret or LINEAR_OAUTH_CLIENT_ID / LINEAR_OAUTH_CLIENT_SECRET', + ' --scopes read,write (optional) --redirect-port 8734 (optional)', + ' status Show whether stored credentials exist and when they expire', + ' logout Revoke the stored access token (best effort) and delete it locally', +].join('\n'); + +/** + * Entry point for `mcp-linear auth `. Returns a process exit code. + * All human-facing output goes through io.print (stderr by default) and never + * contains secret values. + */ +export async function runAuthCli(args: string[], io: AuthCliIo = {}): Promise { + // eslint-disable-next-line no-console + const print = io.print ?? ((message: string) => console.error(message)); + const [subcommand, ...rest] = args; + + switch (subcommand) { + case 'login': + return runLogin(rest, print, io.openBrowser ?? defaultOpenBrowser); + case 'status': + return runStatus(print); + case 'logout': + return runLogout(print); + default: + print(USAGE); + return 1; + } +} + +async function runLogin( + args: string[], + print: (message: string) => void, + openBrowser: (url: string) => Promise, +): Promise { + const clientId = parseFlag(args, '--client-id') ?? process.env.LINEAR_OAUTH_CLIENT_ID; + const clientSecret = parseFlag(args, '--client-secret') ?? process.env.LINEAR_OAUTH_CLIENT_SECRET; + + if (!clientId || !clientSecret) { + print( + 'Linear OAuth client credentials are required. Pass --client-id and --client-secret, or set LINEAR_OAUTH_CLIENT_ID and LINEAR_OAUTH_CLIENT_SECRET.', + ); + print( + `Create an OAuth application at https://linear.app/settings/api/applications/new with redirect URI http://localhost:${DEFAULT_REDIRECT_PORT}${CALLBACK_PATH}.`, + ); + return 1; + } + + const portFlag = parseFlag(args, '--redirect-port'); + const port = portFlag !== undefined ? Number(portFlag) : DEFAULT_REDIRECT_PORT; + if (!Number.isInteger(port) || port < 0 || port > 65535) { + print('Invalid --redirect-port value; expected a port number.'); + return 1; + } + + const scopesFlag = parseFlag(args, '--scopes'); + const requestedScopes = scopesFlag + ? scopesFlag.split(/[\s,]+/).filter(Boolean) + : DEFAULT_SCOPES; + // Linear always expects the read scope. + const scopes = Array.from(new Set(['read', ...requestedScopes.filter((s) => s !== 'read')])); + + const { verifier, challenge } = generatePkcePair(); + const state = generateState(); + + let pending; + try { + pending = await startOAuthCallbackServer({ + port, + expectedState: state, + timeoutMs: LOGIN_TIMEOUT_MS, + }); + } catch (error) { + const reason = error instanceof Error ? error.message : 'unknown error'; + print(`Could not start the local callback server on port ${port} (${reason}).`); + print('Pass --redirect-port to use a different port (it must match a registered redirect URI).'); + return 1; + } + + const redirectUri = `http://localhost:${pending.port}${CALLBACK_PATH}`; + const authorizationUrl = new URL('https://linear.app/oauth/authorize'); + authorizationUrl.searchParams.set('response_type', 'code'); + authorizationUrl.searchParams.set('client_id', clientId); + authorizationUrl.searchParams.set('redirect_uri', redirectUri); + authorizationUrl.searchParams.set('scope', scopes.join(',')); + authorizationUrl.searchParams.set('state', state); + authorizationUrl.searchParams.set('code_challenge', challenge); + authorizationUrl.searchParams.set('code_challenge_method', 'S256'); + + print('Opening your browser to authorize MCP Linear with Linear...'); + const opened = await openBrowser(authorizationUrl.toString()); + if (!opened) { + print('Could not open a browser automatically.'); + } + print(`If the browser did not open, visit:\n\n ${authorizationUrl.toString()}\n`); + print(`Waiting for the authorization callback on ${redirectUri} ...`); + + let code: string; + try { + ({ code } = await pending.callback); + } catch (error) { + await pending.cancel(); + const reason = error instanceof Error ? error.message : 'unknown error'; + print(`Login failed: ${reason}`); + return 1; + } + + print('Authorization received. Exchanging it for tokens...'); + + try { + const tokens = await exchangeAuthorizationCode({ + code, + redirectUri, + clientId, + clientSecret, + codeVerifier: verifier, + }); + + writeStoredCredentials({ + clientId, + clientSecret, + accessToken: tokens.accessToken, + ...(tokens.refreshToken ? { refreshToken: tokens.refreshToken } : {}), + ...(typeof tokens.expiresIn === 'number' + ? { expiresAt: Date.now() + tokens.expiresIn * 1000 } + : {}), + scopes: tokens.scopes.length > 0 ? tokens.scopes : scopes, + }); + } catch (error) { + const reason = error instanceof Error ? error.message : 'unknown error'; + print(`Login failed: ${reason}`); + return 1; + } + + print(`Login successful. Credentials saved to ${getCredentialsPath()}.`); + print( + 'The MCP server will use them automatically when no --token flag or LINEAR_API_TOKEN/LINEAR_API_KEY variable is set.', + ); + return 0; +} + +function runStatus(print: (message: string) => void): number { + const credentials = readStoredCredentials(); + if (!credentials) { + print('No stored credentials. Run `mcp-linear auth login` to sign in.'); + return 1; + } + + print('Logged in with stored Linear OAuth credentials.'); + print(` Credentials file: ${getCredentialsPath()}`); + print(` Client ID: ${credentials.clientId}`); + print(` Scopes: ${credentials.scopes.join(', ')}`); + print(` Access token: ${maskToken(credentials.accessToken)}`); + if (typeof credentials.expiresAt === 'number') { + const expired = credentials.expiresAt <= Date.now(); + print(` Expires: ${new Date(credentials.expiresAt).toISOString()} (${expired ? 'expired' : 'valid'})`); + } else { + print(' Expires: unknown'); + } + print(` Refresh token: ${credentials.refreshToken ? 'stored' : 'none'}`); + return 0; +} + +async function runLogout(print: (message: string) => void): Promise { + const credentials = readStoredCredentials(); + if (!credentials) { + // A corrupt or partial file may still exist; remove it regardless. + deleteStoredCredentials(); + print('No stored credentials to remove.'); + return 0; + } + + const revoked = await revokeToken({ + token: credentials.accessToken, + tokenTypeHint: 'access_token', + }); + if (!revoked) { + print('The access token could not be revoked (offline or already invalid); removing local credentials anyway.'); + } + + deleteStoredCredentials(); + print(`Logged out. Removed ${getCredentialsPath()}.`); + return 0; +} diff --git a/src/auth/credential-store.ts b/src/auth/credential-store.ts new file mode 100644 index 0000000..ded9bed --- /dev/null +++ b/src/auth/credential-store.ts @@ -0,0 +1,144 @@ +import { randomBytes } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +/** + * Credentials persisted by `mcp-linear auth login`. + * + * `expiresAt` is an absolute epoch-millisecond timestamp derived from the + * token endpoint's `expires_in`. The values in this file are secrets and must + * never be logged. + */ +export interface StoredCredentials { + clientId: string; + clientSecret: string; + accessToken: string; + refreshToken?: string; + expiresAt?: number; + scopes: string[]; +} + +const CREDENTIALS_FILE_NAME = 'credentials.json'; + +/** + * Resolve the configuration directory for stored credentials. + * + * Precedence: MCP_LINEAR_CONFIG_DIR, then $XDG_CONFIG_HOME/mcp-linear, then + * ~/.config/mcp-linear. + */ +export function getConfigDir(): string { + const explicit = process.env.MCP_LINEAR_CONFIG_DIR; + if (explicit) { + return explicit; + } + + const xdgConfigHome = process.env.XDG_CONFIG_HOME; + if (xdgConfigHome) { + return path.join(xdgConfigHome, 'mcp-linear'); + } + + return path.join(os.homedir(), '.config', 'mcp-linear'); +} + +export function getCredentialsPath(): string { + return path.join(getConfigDir(), CREDENTIALS_FILE_NAME); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === 'string'); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +/** + * Read stored credentials. Returns undefined when the file is missing, + * unreadable, corrupt, or missing required fields — callers treat all of + * those cases as "not logged in". + */ +export function readStoredCredentials(): StoredCredentials | undefined { + let raw: string; + try { + raw = fs.readFileSync(getCredentialsPath(), 'utf8'); + } catch { + return undefined; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return undefined; + } + + const record = parsed as Record; + if ( + !isNonEmptyString(record.clientId) || + !isNonEmptyString(record.clientSecret) || + !isNonEmptyString(record.accessToken) || + !isStringArray(record.scopes) + ) { + return undefined; + } + + const credentials: StoredCredentials = { + clientId: record.clientId, + clientSecret: record.clientSecret, + accessToken: record.accessToken, + scopes: record.scopes, + }; + + if (isNonEmptyString(record.refreshToken)) { + credentials.refreshToken = record.refreshToken; + } + if (typeof record.expiresAt === 'number' && Number.isFinite(record.expiresAt)) { + credentials.expiresAt = record.expiresAt; + } + + return credentials; +} + +/** + * Persist credentials atomically: write a 0600 temp file in the (0700) + * config directory, then rename it over the destination. The rename keeps a + * concurrent reader from ever observing a partially written file — important + * because Linear rotates refresh tokens and a torn write would lose the only + * valid one. + */ +export function writeStoredCredentials(credentials: StoredCredentials): void { + const configDir = getConfigDir(); + fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); + + const destination = getCredentialsPath(); + const tempPath = path.join(configDir, `.${CREDENTIALS_FILE_NAME}.${randomBytes(6).toString('hex')}.tmp`); + + fs.writeFileSync(tempPath, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 }); + try { + fs.renameSync(tempPath, destination); + } catch (error) { + try { + fs.unlinkSync(tempPath); + } catch { + // Best effort cleanup; surface the original failure. + } + throw error; + } +} + +/** + * Delete stored credentials. Returns true when a file was removed. + */ +export function deleteStoredCredentials(): boolean { + try { + fs.unlinkSync(getCredentialsPath()); + return true; + } catch { + return false; + } +} diff --git a/src/auth/managed-auth.ts b/src/auth/managed-auth.ts new file mode 100644 index 0000000..296a0d7 --- /dev/null +++ b/src/auth/managed-auth.ts @@ -0,0 +1,98 @@ +import type { LinearAuthConfig } from '../utils/config.js'; +import { + readStoredCredentials, + writeStoredCredentials, + type StoredCredentials, +} from './credential-store.js'; +import { refreshAccessToken } from './oauth-http.js'; + +/** Refresh tokens this close to expiry (or past it) before use. */ +export const EXPIRY_SKEW_MS = 60_000; + +export interface StoredCredentialAuth { + /** + * Return a Linear auth config backed by the credential store, refreshing + * (and persisting the rotated refresh token) when the access token is + * expired or within the skew window. + */ + getConfig(): Promise; +} + +export interface StoredCredentialAuthOptions { + now?: () => number; +} + +function needsRefresh(credentials: StoredCredentials, nowMs: number): boolean { + if (typeof credentials.expiresAt !== 'number') { + return false; + } + return credentials.expiresAt - EXPIRY_SKEW_MS <= nowMs; +} + +/** + * Create an auth provider over credentials saved by `mcp-linear auth login`. + * Returns undefined when no usable credentials are stored. + */ +export function createStoredCredentialAuth( + options: StoredCredentialAuthOptions = {}, +): StoredCredentialAuth | undefined { + const initial = readStoredCredentials(); + if (!initial) { + return undefined; + } + + const now = options.now ?? Date.now; + let credentials = initial; + // Serialize refreshes so concurrent requests never race a token rotation. + let pendingRefresh: Promise | undefined; + + async function refresh(): Promise { + if (!credentials.refreshToken) { + throw new Error( + 'Stored Linear access token has expired and no refresh token is available. Run `mcp-linear auth login` again.', + ); + } + + let tokens; + try { + tokens = await refreshAccessToken({ + refreshToken: credentials.refreshToken, + clientId: credentials.clientId, + clientSecret: credentials.clientSecret, + }); + } catch (error) { + const reason = error instanceof Error ? error.message : 'unknown error'; + throw new Error( + `Failed to refresh the stored Linear access token (${reason}). Run \`mcp-linear auth login\` again.`, + ); + } + + const updated: StoredCredentials = { + clientId: credentials.clientId, + clientSecret: credentials.clientSecret, + accessToken: tokens.accessToken, + // Linear rotates refresh tokens; fall back to the previous one only if + // the response omitted a replacement. + refreshToken: tokens.refreshToken ?? credentials.refreshToken, + scopes: tokens.scopes.length > 0 ? tokens.scopes : credentials.scopes, + }; + if (typeof tokens.expiresIn === 'number') { + updated.expiresAt = now() + tokens.expiresIn * 1000; + } + + writeStoredCredentials(updated); + credentials = updated; + } + + return { + async getConfig(): Promise { + if (needsRefresh(credentials, now())) { + pendingRefresh ??= refresh().finally(() => { + pendingRefresh = undefined; + }); + await pendingRefresh; + } + return { type: 'oauth', token: credentials.accessToken }; + }, + }; +} diff --git a/src/auth/oauth-http.ts b/src/auth/oauth-http.ts new file mode 100644 index 0000000..7ba661f --- /dev/null +++ b/src/auth/oauth-http.ts @@ -0,0 +1,149 @@ +const TOKEN_ENDPOINT = 'https://api.linear.app/oauth/token'; +const REVOKE_ENDPOINT = 'https://api.linear.app/oauth/revoke'; +const REQUEST_TIMEOUT_MS = 15_000; + +export interface OAuthTokens { + accessToken: string; + refreshToken?: string; + expiresIn?: number; + scopes: string[]; +} + +export interface AuthorizationCodeExchangeArgs { + code: string; + redirectUri: string; + clientId: string; + clientSecret: string; + codeVerifier: string; +} + +export interface RefreshTokenArgs { + refreshToken: string; + clientId: string; + clientSecret: string; +} + +export interface RevokeTokenArgs { + token: string; + tokenTypeHint?: 'access_token' | 'refresh_token'; +} + +async function postTokenRequest(body: URLSearchParams): Promise { + const response = await fetch(TOKEN_ENDPOINT, { + method: 'POST', + redirect: 'error', + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }); + + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new Error(`Linear OAuth token request returned invalid JSON (HTTP ${response.status})`); + } + + if (!response.ok) { + // Never echo request parameters; they contain the client secret and code. + throw new Error(`Linear OAuth token request failed (HTTP ${response.status})`); + } + if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) { + throw new Error('Linear OAuth token response was incomplete'); + } + + const record = payload as Record; + const accessToken = record.access_token; + if (typeof accessToken !== 'string' || accessToken.trim().length === 0) { + throw new Error('Linear OAuth token response was incomplete'); + } + + const tokens: OAuthTokens = { + accessToken, + scopes: parseScopes(record.scope), + }; + + if (typeof record.refresh_token === 'string' && record.refresh_token.trim().length > 0) { + tokens.refreshToken = record.refresh_token; + } + if ( + typeof record.expires_in === 'number' && + Number.isFinite(record.expires_in) && + record.expires_in > 0 + ) { + tokens.expiresIn = record.expires_in; + } + + return tokens; +} + +function parseScopes(scope: unknown): string[] { + if (Array.isArray(scope)) { + return scope.filter((entry): entry is string => typeof entry === 'string'); + } + if (typeof scope === 'string') { + return scope.split(/[\s,]+/).filter(Boolean); + } + return []; +} + +/** + * Exchange an authorization code (with its PKCE verifier) for tokens. + */ +export function exchangeAuthorizationCode(args: AuthorizationCodeExchangeArgs): Promise { + return postTokenRequest( + new URLSearchParams({ + grant_type: 'authorization_code', + code: args.code, + redirect_uri: args.redirectUri, + client_id: args.clientId, + client_secret: args.clientSecret, + code_verifier: args.codeVerifier, + }), + ); +} + +/** + * Refresh an access token. Linear rotates refresh tokens, so the returned + * refresh token replaces the one that was sent and must be persisted. + */ +export function refreshAccessToken(args: RefreshTokenArgs): Promise { + return postTokenRequest( + new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: args.refreshToken, + client_id: args.clientId, + client_secret: args.clientSecret, + }), + ); +} + +/** + * Best-effort token revocation. Never throws — logout must succeed locally + * even when the network or the revoke endpoint is unavailable. + */ +export async function revokeToken(args: RevokeTokenArgs): Promise { + const body = new URLSearchParams({ token: args.token }); + if (args.tokenTypeHint) { + body.set('token_type_hint', args.tokenTypeHint); + } + + try { + const response = await fetch(REVOKE_ENDPOINT, { + method: 'POST', + redirect: 'error', + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }); + return response.ok; + } catch { + return false; + } +} diff --git a/src/auth/pkce.ts b/src/auth/pkce.ts new file mode 100644 index 0000000..64ffde1 --- /dev/null +++ b/src/auth/pkce.ts @@ -0,0 +1,34 @@ +import { createHash, randomBytes } from 'node:crypto'; + +/** + * RFC 7636 unreserved characters allowed in a PKCE code verifier. + */ +const VERIFIER_CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; +const VERIFIER_LENGTH = 64; + +export interface PkcePair { + verifier: string; + challenge: string; +} + +/** + * Generate a cryptographically random PKCE code verifier and its S256 challenge. + */ +export function generatePkcePair(): PkcePair { + const bytes = randomBytes(VERIFIER_LENGTH); + let verifier = ''; + for (let i = 0; i < VERIFIER_LENGTH; i++) { + verifier += VERIFIER_CHARSET[bytes[i] % VERIFIER_CHARSET.length]; + } + + const challenge = createHash('sha256').update(verifier).digest('base64url'); + + return { verifier, challenge }; +} + +/** + * Generate a cryptographically random, URL-safe OAuth state value. + */ +export function generateState(): string { + return randomBytes(24).toString('base64url'); +} diff --git a/src/auth/refreshing-provider.ts b/src/auth/refreshing-provider.ts new file mode 100644 index 0000000..66ca376 --- /dev/null +++ b/src/auth/refreshing-provider.ts @@ -0,0 +1,36 @@ +import type { LinearAuthConfig } from '../utils/config.js'; + +export interface RefreshingProviderOptions { + /** Resolve the current auth config (may refresh stored tokens). */ + getConfig: () => Promise; + /** Build the client/service bundle for a config. */ + build: (config: LinearAuthConfig) => T; +} + +export interface RefreshingProvider { + get(): Promise; +} + +/** + * Cache a built client for as long as the underlying token is unchanged, and + * transparently rebuild it when a stored-credential refresh rotates the + * token mid-session. Explicit credentials never change, so the first build + * is reused for the lifetime of the process. + */ +export function createRefreshingProvider( + options: RefreshingProviderOptions, +): RefreshingProvider { + let cachedToken: string | undefined; + let cached: T | undefined; + + return { + async get(): Promise { + const config = await options.getConfig(); + if (cached === undefined || config.token !== cachedToken) { + cached = options.build(config); + cachedToken = config.token; + } + return cached; + }, + }; +} diff --git a/src/auth/resolve.ts b/src/auth/resolve.ts new file mode 100644 index 0000000..7eb2847 --- /dev/null +++ b/src/auth/resolve.ts @@ -0,0 +1,41 @@ +import { + getExplicitLinearAuthConfig, + getLinearApiToken, + type LinearAuthConfig, +} from '../utils/config.js'; +import { createStoredCredentialAuth } from './managed-auth.js'; + +export interface ResolvedLinearAuth { + source: 'explicit' | 'store'; + /** Current auth config; store-backed auth refreshes transparently. */ + getConfig(): Promise; +} + +/** + * Resolve the server's Linear credential. + * + * Precedence: explicit CLI flags, then explicit environment variables (the + * existing rules in getExplicitLinearAuthConfig), then credentials stored by + * `mcp-linear auth login`. + */ +export function resolveLinearAuth(): ResolvedLinearAuth | undefined { + const explicit = getExplicitLinearAuthConfig(); + if (explicit) { + return { + source: 'explicit', + getConfig: async () => explicit, + }; + } + + const stored = createStoredCredentialAuth(); + if (stored) { + return { + source: 'store', + getConfig: () => stored.getConfig(), + }; + } + + // Preserve the existing missing-credential diagnostics (no values logged). + getLinearApiToken(); + return undefined; +} diff --git a/src/index.ts b/src/index.ts index b987251..c2ed1a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,10 @@ import { LinearService } from './services/linear-service.js'; import { allToolDefinitions } from './tools/definitions/index.js'; import { registerToolHandlers } from './tools/handlers/index.js'; import { getLinearRateLimitSnapshot, installLinearRateLimitHandling } from './utils/linear-rate-limit.js'; -import { getLinearApiToken, logInfo, logError, isDebugLoggingEnabled } from './utils/config.js'; +import { logInfo, logError, isDebugLoggingEnabled, type LinearAuthConfig } from './utils/config.js'; +import { runAuthCli } from './auth/cli.js'; +import { createRefreshingProvider } from './auth/refreshing-provider.js'; +import { resolveLinearAuth } from './auth/resolve.js'; import pkg from '../package.json' with { type: 'json' }; // Import package.json to access version /** @@ -22,22 +25,41 @@ async function runServer() { // Log package version logInfo(`MCP Linear version: ${pkg.version}`); - // Get Linear API token - const linearApiToken = getLinearApiToken(); + // Resolve the configured Linear authentication mode. Explicit CLI/env + // credentials win; otherwise credentials stored by `mcp-linear auth login` + // are used (and refreshed automatically when they expire). + const resolvedAuth = resolveLinearAuth(); - if (!linearApiToken) { + if (!resolvedAuth) { throw new Error( - 'Linear API token not found. Please provide it via --token command line argument or LINEAR_API_TOKEN environment variable.', + 'Linear credentials not found. Provide an API token via --token, LINEAR_API_TOKEN, or LINEAR_API_KEY, or sign in with `mcp-linear auth login`.', ); } logInfo(`Starting MCP Linear...`); - // Initialize Linear client and service - const linearClient = new LinearClient({ apiKey: linearApiToken }); - installLinearRateLimitHandling(linearClient); - const linearService = new LinearService(linearClient); - const getRateLimitStatus = () => getLinearRateLimitSnapshot(linearClient); + // Build the Linear client/service pair, rebuilding transparently when a + // stored-credential refresh rotates the access token mid-session. + const buildServices = (auth: LinearAuthConfig) => { + const linearClient = new LinearClient( + auth.type === 'oauth' ? { accessToken: auth.token } : { apiKey: auth.token }, + ); + installLinearRateLimitHandling(linearClient); + return { linearClient, linearService: new LinearService(linearClient) }; + }; + const serviceProvider = createRefreshingProvider({ + getConfig: () => resolvedAuth.getConfig(), + build: buildServices, + }); + + // Resolve at startup so an expired stored token is refreshed (or fails + // with a clear re-login hint) before the server accepts requests. + let current = await serviceProvider.get(); + const ensureFreshServices = async () => { + current = await serviceProvider.get(); + return current; + }; + const getRateLimitStatus = () => getLinearRateLimitSnapshot(current.linearClient); const getServerStatus = createServerStatusProvider({ version: pkg.version, toolCount: allToolDefinitions.length, @@ -56,14 +78,17 @@ async function runServer() { }; }, listResources: async () => getLinearResourceDefinitions(), - readResource: async (uri: string) => - readLinearResource(uri, { + readResource: async (uri: string) => { + const { linearService } = await ensureFreshServices(); + return readLinearResource(uri, { linearService, getRateLimitSnapshot: getRateLimitStatus, - }), + }); + }, listPrompts: async () => getLinearPromptDefinitions(), getPrompt: async (name: string, args?: Record) => getLinearPrompt(name, args), handleRequest: async (req: { name: string; args: unknown }) => { + const { linearService } = await ensureFreshServices(); const handlers = registerToolHandlers(linearService, { getRateLimitStatus, getServerStatus, @@ -93,9 +118,22 @@ async function runServer() { } } -// Start the server -installRuntimeDiagnostics(); -runServer().catch((error) => { - logError('Fatal error in MCP Linear', error); - process.exit(1); -}); +const cliArgs = process.argv.slice(2); + +if (cliArgs[0] === 'auth') { + // `mcp-linear auth ` runs the auth CLI and exits + // without starting the MCP server. + runAuthCli(cliArgs.slice(1)) + .then((exitCode) => process.exit(exitCode)) + .catch((error) => { + logError('Auth command failed', error); + process.exit(1); + }); +} else { + // Start the server + installRuntimeDiagnostics(); + runServer().catch((error) => { + logError('Fatal error in MCP Linear', error); + process.exit(1); + }); +} diff --git a/src/utils/config.ts b/src/utils/config.ts index 45ddb91..8187d2e 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -46,6 +46,31 @@ export function getLinearApiToken(): string | undefined { return tokenFromArgs || tokenFromEnv; } +export type LinearAuthConfig = + | { type: 'oauth'; token: string } + | { type: 'apiKey'; token: string }; + +/** + * Resolve only explicitly supplied credentials (the --token CLI flag or the + * LINEAR_API_TOKEN / LINEAR_API_KEY environment variables), without emitting + * missing-credential diagnostics. Callers that support additional credential + * sources (such as the OAuth credentials stored by `mcp-linear auth login`) + * use this to keep the explicit-over-implicit precedence intact. + */ +export function getExplicitLinearAuthConfig(): LinearAuthConfig | undefined { + const apiKeyFromArgs = getCommandLineArg('--token'); + if (apiKeyFromArgs) { + return { type: 'apiKey', token: apiKeyFromArgs }; + } + + const apiKeyFromEnv = process.env.LINEAR_API_TOKEN || process.env.LINEAR_API_KEY; + if (apiKeyFromEnv) { + return { type: 'apiKey', token: apiKeyFromEnv }; + } + + return undefined; +} + export function isDebugLoggingEnabled(): boolean { return process.env.MCP_LINEAR_DEBUG === '1' || process.env.MCP_LINEAR_DEBUG === 'true'; }