diff --git a/skills/outline-cli/SKILL.md b/skills/outline-cli/SKILL.md index fe716ae..499b739 100644 --- a/skills/outline-cli/SKILL.md +++ b/skills/outline-cli/SKILL.md @@ -73,9 +73,14 @@ ol col delete --confirm ### Authentication ```bash -ol auth login # Configure API token and base URL -ol auth status # Show current auth state -ol auth logout # Clear saved credentials +ol auth login # OAuth login (opens browser); prompts for base URL + client ID if needed +ol auth login --base-url # Specify Outline base URL for this login (saved for future use) +ol auth login --client-id # Specify OAuth client ID for this login (saved for future use) +ol auth login --callback-port # Override local OAuth callback port +ol auth login --read-only # Request read-only scopes (where supported by the Outline instance) +ol auth login --json | --ndjson # Machine-readable success envelope +ol auth status # Show current auth state +ol auth logout # Clear saved credentials ``` ### Update & Changelog diff --git a/src/__tests__/auth-command.test.ts b/src/__tests__/auth-command.test.ts new file mode 100644 index 0000000..e76476d --- /dev/null +++ b/src/__tests__/auth-command.test.ts @@ -0,0 +1,71 @@ +import { Command } from 'commander' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../lib/auth.js', () => ({ + getApiToken: async () => 'test-token', + getBaseUrl: async () => 'https://test.outline.com', + getOAuthClientId: async () => undefined, + getTokenSource: async () => 'config' as const, + clearConfig: vi.fn(), +})) + +vi.mock('../lib/api.js', () => ({ apiRequest: vi.fn() })) + +// Stub cli-core's `attachLoginCommand` so we can inspect the surface contract +// (chained flags, env-driven port, success hook) without running the flow. +vi.mock('@doist/cli-core/auth', async () => ({ + ...(await vi.importActual('@doist/cli-core/auth')), + attachLoginCommand: vi.fn(), +})) + +async function captureAttachOptions() { + const { attachLoginCommand } = await import('@doist/cli-core/auth') + const login = new Command('login') + vi.mocked(attachLoginCommand).mockReturnValue(login) + const { registerAuthCommand } = await import('../commands/auth.js') + const program = new Command() + program.exitOverride() + registerAuthCommand(program) + return { options: vi.mocked(attachLoginCommand).mock.calls[0][1], login } +} + +afterEach(() => { + vi.clearAllMocks() + delete process.env.OUTLINE_OAUTH_CALLBACK_PORT +}) + +describe('registerAuthCommand', () => { + it('wires --base-url / --client-id, env-driven port, and prints success only in human output mode', async () => { + process.env.OUTLINE_OAUTH_CALLBACK_PORT = '7000' + const logs: string[] = [] + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logs.push(args.join(' ')) + }) + + const { options, login } = await captureAttachOptions() + + expect(options.preferredPort).toBe(7000) + const flags = login.options.map((o) => o.flags) + expect(flags).toContain('--base-url ') + expect(flags).toContain('--client-id ') + + const account = { + id: 'u', + label: 'Ada', + baseUrl: 'https://x', + oauthClientId: 'c', + teamName: 'Analytics', + } + await options.onSuccess({ view: { json: false, ndjson: false }, flags: {}, account }) + await options.onSuccess({ view: { json: true, ndjson: false }, flags: {}, account }) + + expect(logs.length).toBe(1) + expect(logs[0]).toContain('Authenticated to Analytics as Ada') + }) + + it('falls back to the default callback port when the env var is unparseable', async () => { + process.env.OUTLINE_OAUTH_CALLBACK_PORT = 'not-a-number' + const { options } = await captureAttachOptions() + expect(options.preferredPort).toBe(54969) + }) +}) diff --git a/src/__tests__/auth-pages.test.ts b/src/__tests__/auth-pages.test.ts new file mode 100644 index 0000000..b517d32 --- /dev/null +++ b/src/__tests__/auth-pages.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { renderError, renderSuccess } from '../lib/auth-pages.js' + +describe('auth pages', () => { + it('renderSuccess returns the branded post-login page', () => { + const html = renderSuccess() + expect(html).toContain('Login complete - Outline CLI') + expect(html).toContain('Outline CLI is now authenticated.') + expect(html).toContain('You can close this tab now.') + }) + + it('renderError surfaces the failure message and escapes hostile HTML', () => { + const html = renderError('') + expect(html).toContain('Authentication failed - Outline CLI') + expect(html).toContain('Outline CLI could not finish OAuth login.') + expect(html).not.toContain('') + expect(html).toContain('<script>alert(1)</script>') + }) +}) diff --git a/src/__tests__/auth-provider.test.ts b/src/__tests__/auth-provider.test.ts new file mode 100644 index 0000000..8c96615 --- /dev/null +++ b/src/__tests__/auth-provider.test.ts @@ -0,0 +1,179 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const TEST_XDG = join(tmpdir(), `outline-cli-test-${process.pid}-auth-provider`) +const TEST_CONFIG_DIR = join(TEST_XDG, 'outline-cli') +const TEST_CONFIG_PATH = join(TEST_CONFIG_DIR, 'config.json') + +vi.mock('../transport/fetch-with-retry.js', () => ({ fetchWithRetry: vi.fn() })) +vi.mock('../lib/api.js', () => ({ apiRequest: vi.fn() })) + +beforeEach(() => { + process.env.XDG_CONFIG_HOME = TEST_XDG + mkdirSync(TEST_CONFIG_DIR, { recursive: true }) + delete process.env.OUTLINE_API_TOKEN + delete process.env.OUTLINE_URL + delete process.env.OUTLINE_OAUTH_CLIENT_ID + vi.resetModules() + vi.clearAllMocks() +}) + +afterEach(() => { + if (existsSync(TEST_XDG)) rmSync(TEST_XDG, { recursive: true }) + delete process.env.XDG_CONFIG_HOME +}) + +describe('OutlineAuthProvider', () => { + it('authorize builds an outline /oauth/authorize URL with PKCE params from flags', async () => { + const { createOutlineAuthProvider } = await import('../lib/auth-provider.js') + const result = await createOutlineAuthProvider().authorize({ + redirectUri: 'http://localhost:54969/callback', + state: 'state-123', + scopes: [], + readOnly: false, + flags: { baseUrl: 'https://wiki.example.com/', clientId: 'cid-xyz' }, + handshake: {}, + }) + + const url = new URL(result.authorizeUrl) + expect(url.origin + url.pathname).toBe('https://wiki.example.com/oauth/authorize') + expect(Object.fromEntries(url.searchParams)).toMatchObject({ + client_id: 'cid-xyz', + response_type: 'code', + redirect_uri: 'http://localhost:54969/callback', + state: 'state-123', + code_challenge_method: 'S256', + }) + expect(url.searchParams.get('code_challenge')).toMatch(/^[A-Za-z0-9_-]+$/) + + const handshake = result.handshake as Record + expect(handshake).toMatchObject({ + baseUrl: 'https://wiki.example.com', + clientId: 'cid-xyz', + }) + expect(handshake.codeVerifier?.length).toBeGreaterThan(40) + }) + + it('exchangeCode posts via fetchWithRetry and surfaces provider errors', async () => { + const { fetchWithRetry } = await import('../transport/fetch-with-retry.js') + vi.mocked(fetchWithRetry).mockResolvedValueOnce({ + ok: true, + json: async () => ({ access_token: 'tok-abc' }), + } as Response) + + const { createOutlineAuthProvider } = await import('../lib/auth-provider.js') + const provider = createOutlineAuthProvider() + const handshake = { + baseUrl: 'https://wiki.example.com', + clientId: 'cid-xyz', + codeVerifier: 'verifier-1', + } + + const result = await provider.exchangeCode({ + code: 'auth-code', + state: 's', + redirectUri: 'http://localhost:54969/callback', + handshake, + }) + expect(result.accessToken).toBe('tok-abc') + + const args = vi.mocked(fetchWithRetry).mock.calls[0][0] + expect(args.url).toBe('https://wiki.example.com/oauth/token') + const body = new URLSearchParams(args.options.body as string) + expect(Object.fromEntries(body)).toEqual({ + grant_type: 'authorization_code', + client_id: 'cid-xyz', + redirect_uri: 'http://localhost:54969/callback', + code_verifier: 'verifier-1', + code: 'auth-code', + }) + + vi.mocked(fetchWithRetry).mockResolvedValueOnce({ + ok: false, + statusText: 'Bad Request', + json: async () => ({ error_description: 'Authorization code expired' }), + } as Response) + await expect( + provider.exchangeCode({ + code: 'c', + state: 's', + redirectUri: 'http://localhost:54969/callback', + handshake, + }), + ).rejects.toThrow('OAuth token exchange failed: Authorization code expired') + }) + + it('validateToken calls auth.info with the unsaved token and builds an OutlineAccount', async () => { + const { apiRequest } = await import('../lib/api.js') + vi.mocked(apiRequest).mockResolvedValue({ + data: { + user: { id: 'user-uuid', name: 'Ada Lovelace', email: 'ada@example.com' }, + team: { name: 'Analytics', subdomain: 'analytics' }, + }, + }) + + const { createOutlineAuthProvider } = await import('../lib/auth-provider.js') + const account = await createOutlineAuthProvider().validateToken({ + token: 'tok-abc', + handshake: { baseUrl: 'https://wiki.example.com', clientId: 'cid-xyz' }, + }) + + expect(account).toEqual({ + id: 'user-uuid', + label: 'Ada Lovelace', + baseUrl: 'https://wiki.example.com', + oauthClientId: 'cid-xyz', + teamName: 'Analytics', + }) + expect(apiRequest).toHaveBeenCalledWith( + 'auth.info', + {}, + { token: 'tok-abc', baseUrl: 'https://wiki.example.com' }, + ) + }) +}) + +describe('OutlineTokenStore', () => { + const sampleAccount = { + id: 'user-uuid', + label: 'Ada', + baseUrl: 'https://wiki.example.com', + oauthClientId: 'cid-xyz', + teamName: 'Analytics', + } + + it('round-trips token + account through the config file', async () => { + const { createOutlineTokenStore } = await import('../lib/auth-provider.js') + const store = createOutlineTokenStore() + await store.set(sampleAccount, 'tok-persisted') + const got = await store.active() + expect(got).toEqual({ token: 'tok-persisted', account: sampleAccount }) + }) + + it('active returns null when the saved token predates the persisted-identity fields', async () => { + writeFileSync(TEST_CONFIG_PATH, JSON.stringify({ api_token: 'legacy-tok' })) + const { createOutlineTokenStore } = await import('../lib/auth-provider.js') + await expect(createOutlineTokenStore().active()).resolves.toBeNull() + }) + + it('clear strips every auth field but preserves unrelated config keys', async () => { + writeFileSync( + TEST_CONFIG_PATH, + JSON.stringify({ + api_token: 'tok', + base_url: 'https://x', + oauth_client_id: 'c', + auth_user_id: 'u', + auth_user_name: 'l', + auth_team_name: 't', + update_channel: 'pre-release', + }), + ) + const { createOutlineTokenStore } = await import('../lib/auth-provider.js') + await createOutlineTokenStore().clear() + const after = JSON.parse(readFileSync(TEST_CONFIG_PATH, 'utf8')) + expect(after).toEqual({ update_channel: 'pre-release' }) + }) +}) diff --git a/src/__tests__/auth.test.ts b/src/__tests__/auth.test.ts index 6f30250..a87beb0 100644 --- a/src/__tests__/auth.test.ts +++ b/src/__tests__/auth.test.ts @@ -58,12 +58,16 @@ describe('auth', () => { await expect(getBaseUrl()).resolves.toBe('https://app.getoutline.com') }) - it('saveConfig and clearConfig work', async () => { - const { saveConfig, clearConfig, getApiToken, getOAuthClientId } = - await import('../lib/auth.js') - await saveConfig('test-token', 'https://wiki.test.com', 'client-id') - await expect(getApiToken()).resolves.toBe('test-token') - await expect(getOAuthClientId()).resolves.toBe('client-id') + it('clearConfig removes the saved token', async () => { + writeFileSync( + TEST_CONFIG_PATH, + JSON.stringify({ + api_token: 'test-token', + base_url: 'https://wiki.test.com', + oauth_client_id: 'client-id', + }), + ) + const { clearConfig, getApiToken } = await import('../lib/auth.js') await clearConfig() await expect(getApiToken()).rejects.toThrow() }) diff --git a/src/__tests__/commands.test.ts b/src/__tests__/commands.test.ts index 21cd7d3..a51d8ff 100644 --- a/src/__tests__/commands.test.ts +++ b/src/__tests__/commands.test.ts @@ -6,7 +6,6 @@ vi.mock('../lib/auth.js', () => ({ getBaseUrl: async () => 'https://test.outline.com', getOAuthClientId: async () => undefined, getTokenSource: async () => 'config' as const, - saveConfig: vi.fn(), clearConfig: vi.fn(), })) diff --git a/src/__tests__/empty-output.test.ts b/src/__tests__/empty-output.test.ts index feb01bc..614fd55 100644 --- a/src/__tests__/empty-output.test.ts +++ b/src/__tests__/empty-output.test.ts @@ -7,7 +7,6 @@ vi.mock('../lib/auth.js', () => ({ getBaseUrl: async () => 'https://test.outline.com', getOAuthClientId: async () => undefined, getTokenSource: async () => 'config' as const, - saveConfig: vi.fn(), clearConfig: vi.fn(), })) diff --git a/src/__tests__/oauth-server.test.ts b/src/__tests__/oauth-server.test.ts deleted file mode 100644 index bbd7282..0000000 --- a/src/__tests__/oauth-server.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { startOAuthCallbackServer } from '../lib/oauth-server.js' - -describe('oauth callback server', () => { - it('returns success page and resolves authorization code', async () => { - const callbackServer = await startOAuthCallbackServer({ - state: 'expected-state', - timeoutMs: 10_000, - port: 0, - }) - - const response = await fetch( - `${callbackServer.redirectUri}?code=test-code&state=expected-state`, - ) - const html = await response.text() - - expect(response.status).toBe(200) - expect(html).toContain('Login complete') - await expect(callbackServer.waitForCode).resolves.toBe('test-code') - }) - - it('returns error page and rejects on state mismatch', async () => { - const callbackServer = await startOAuthCallbackServer({ - state: 'expected-state', - timeoutMs: 10_000, - port: 0, - }) - const rejection = callbackServer.waitForCode.then( - () => new Error('Expected OAuth state mismatch.'), - (error) => error as Error, - ) - - const response = await fetch( - `${callbackServer.redirectUri}?code=test-code&state=wrong-state`, - ) - const html = await response.text() - - expect(response.status).toBe(400) - expect(html).toContain('Authentication failed') - const error = await rejection - expect(error.message).toBe('OAuth state mismatch.') - }) - - it('returns error page and rejects when OAuth provider sends an error', async () => { - const callbackServer = await startOAuthCallbackServer({ - state: 'expected-state', - timeoutMs: 10_000, - port: 0, - }) - const rejection = callbackServer.waitForCode.then( - () => new Error('Expected OAuth provider error.'), - (error) => error as Error, - ) - - const response = await fetch( - `${callbackServer.redirectUri}?error=access_denied&error_description=User%20denied`, - ) - const html = await response.text() - - expect(response.status).toBe(400) - expect(html).toContain('Authentication failed') - const error = await rejection - expect(error.message).toBe('OAuth authorization denied: User denied') - }) -}) diff --git a/src/__tests__/oauth.test.ts b/src/__tests__/oauth.test.ts deleted file mode 100644 index 73b8f09..0000000 --- a/src/__tests__/oauth.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' - -vi.mock('../transport/fetch-with-retry.js', () => ({ - fetchWithRetry: vi.fn(), -})) - -describe('exchangeCodeForToken', () => { - afterEach(() => { - vi.clearAllMocks() - }) - - it('uses fetchWithRetry for token exchange', async () => { - const mockResponse = { - ok: true, - json: async () => ({ access_token: 'test-access-token' }), - } - const { fetchWithRetry } = await import('../transport/fetch-with-retry.js') - ;(fetchWithRetry as ReturnType).mockResolvedValue(mockResponse) - - const { exchangeCodeForToken } = await import('../lib/oauth.js') - const token = await exchangeCodeForToken({ - baseUrl: 'https://test.outline.com', - clientId: 'client-id', - redirectUri: 'http://localhost:3000/callback', - codeVerifier: 'code-verifier', - code: 'auth-code', - }) - - expect(token).toBe('test-access-token') - expect(fetchWithRetry).toHaveBeenCalledWith({ - url: 'https://test.outline.com/oauth/token', - options: { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - grant_type: 'authorization_code', - client_id: 'client-id', - redirect_uri: 'http://localhost:3000/callback', - code_verifier: 'code-verifier', - code: 'auth-code', - }).toString(), - }, - }) - }) - - it('throws the provider error message on failed exchange', async () => { - const mockResponse = { - ok: false, - statusText: 'Bad Request', - json: async () => ({ - error: 'invalid_grant', - error_description: 'Authorization code expired', - }), - } - const { fetchWithRetry } = await import('../transport/fetch-with-retry.js') - ;(fetchWithRetry as ReturnType).mockResolvedValue(mockResponse) - - const { exchangeCodeForToken } = await import('../lib/oauth.js') - await expect( - exchangeCodeForToken({ - baseUrl: 'https://test.outline.com', - clientId: 'client-id', - redirectUri: 'http://localhost:3000/callback', - codeVerifier: 'code-verifier', - code: 'auth-code', - }), - ).rejects.toThrow('OAuth token exchange failed: Authorization code expired') - }) -}) diff --git a/src/commands/auth.ts b/src/commands/auth.ts index b1a4cac..2a66206 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,43 +1,25 @@ -import { createInterface } from 'node:readline/promises' +import { attachLoginCommand } from '@doist/cli-core/auth' import chalk from 'chalk' import type { Command } from 'commander' -import open from 'open' import { apiRequest } from '../lib/api.js' -import { - clearConfig, - getBaseUrl, - getOAuthClientId, - getTokenSource, - saveConfig, -} from '../lib/auth.js' -import { DEFAULT_OAUTH_CALLBACK_PORT, startOAuthCallbackServer } from '../lib/oauth-server.js' -import { buildAuthorizationUrl, exchangeCodeForToken } from '../lib/oauth.js' +import { renderError, renderSuccess } from '../lib/auth-pages.js' +import { createOutlineAuthProvider, createOutlineTokenStore } from '../lib/auth-provider.js' +import { clearConfig, getBaseUrl, getTokenSource } from '../lib/auth.js' import { formatError } from '../lib/output.js' -import { generateCodeChallenge, generateCodeVerifier, generateState } from '../lib/pkce.js' -interface TeamInfo { - name: string - subdomain: string -} +const DEFAULT_OAUTH_CALLBACK_PORT = 54969 -interface AuthInfoResponse { +type AuthInfoResponse = { user: { name: string; email: string } - team: TeamInfo + team: { name: string; subdomain: string } } -async function prompt(question: string): Promise { - const rl = createInterface({ input: process.stdin, output: process.stdout }) - try { - return await rl.question(question) - } finally { - rl.close() - } -} - -function parseCallbackPort(rawPort: string): number | null { - const parsed = Number(rawPort) +function resolvePreferredCallbackPort(): number { + const raw = process.env.OUTLINE_OAUTH_CALLBACK_PORT?.trim() + if (!raw) return DEFAULT_OAUTH_CALLBACK_PORT + const parsed = Number(raw) if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65_535) { - return null + return DEFAULT_OAUTH_CALLBACK_PORT } return parsed } @@ -45,9 +27,22 @@ function parseCallbackPort(rawPort: string): number | null { export function registerAuthCommand(program: Command): void { const auth = program.command('auth').description('Manage authentication') - auth.command('login') - .description('Authenticate with an Outline instance') - .option('--token ', 'Authenticate using a personal API token') + const provider = createOutlineAuthProvider() + const store = createOutlineTokenStore() + + attachLoginCommand(auth, { + provider, + store, + preferredPort: resolvePreferredCallbackPort(), + resolveScopes: () => [], + renderSuccess, + renderError, + onSuccess({ view, account }) { + if (view.json || view.ndjson) return + console.log(chalk.green(`Authenticated to ${account.teamName} as ${account.label}`)) + }, + }) + .description('Authenticate with an Outline instance via OAuth') .option( '--base-url ', 'Outline base URL to use for this login (saved for future logins)', @@ -56,169 +51,6 @@ export function registerAuthCommand(program: Command): void { '--client-id ', 'OAuth client ID to use for this login (saved for future logins)', ) - .option( - '--callback-port ', - `Local OAuth callback port (default: ${DEFAULT_OAUTH_CALLBACK_PORT})`, - ) - .action( - async (options: { - token?: string - clientId?: string - baseUrl?: string - callbackPort?: string - }) => { - const configuredBaseUrl = await getBaseUrl() - const optionBaseUrl = options.baseUrl?.trim() - const envBaseUrl = process.env.OUTLINE_URL?.trim() - let url = optionBaseUrl || envBaseUrl - if (!url) { - const baseUrlInput = await prompt(`Base URL (default: ${configuredBaseUrl}): `) - url = baseUrlInput.trim() || configuredBaseUrl - } - url = url.replace(/\/$/, '') - const optionClientId = options.clientId?.trim() - const optionCallbackPort = options.callbackPort?.trim() - const envCallbackPort = process.env.OUTLINE_OAUTH_CALLBACK_PORT?.trim() - const rawCallbackPort = optionCallbackPort || envCallbackPort - let callbackPort = DEFAULT_OAUTH_CALLBACK_PORT - - if (rawCallbackPort) { - const parsedPort = parseCallbackPort(rawCallbackPort) - if (!parsedPort) { - console.error( - formatError( - 'OAUTH_CALLBACK_PORT_INVALID', - `Invalid callback port: ${rawCallbackPort}`, - [ - 'Use an integer between 1 and 65535', - 'Set via --callback-port or OUTLINE_OAUTH_CALLBACK_PORT', - ], - ), - ) - process.exit(1) - } - callbackPort = parsedPort - } - - if (options.token) { - await saveConfig(options.token.trim(), url, optionClientId) - try { - const { data } = await apiRequest('auth.info') - console.log( - chalk.green(`Authenticated to ${data.team.name} as ${data.user.name}`), - ) - } catch (err) { - console.log( - chalk.yellow('Token saved, but could not verify:'), - (err as Error).message, - ) - } - return - } - - const existingClientId = await getOAuthClientId() - let clientId = optionClientId || existingClientId - - if (!clientId) { - const clientIdInput = await prompt('OAuth Client ID: ') - clientId = clientIdInput.trim() - } - - if (!clientId) { - console.error( - formatError('OAUTH_CLIENT_ID_REQUIRED', 'OAuth client ID is required.', [ - 'Create a public OAuth app in Outline settings', - 'Use --client-id for this login', - 'Set OUTLINE_OAUTH_CLIENT_ID or enter it here', - ]), - ) - process.exit(1) - } - - const codeVerifier = generateCodeVerifier() - const codeChallenge = generateCodeChallenge(codeVerifier) - const state = generateState() - - let callbackServer: Awaited> - try { - callbackServer = await startOAuthCallbackServer({ - state, - port: callbackPort, - }) - } catch (err) { - const error = err as NodeJS.ErrnoException - const hints = [ - `Ensure http://localhost:${callbackPort}/callback is reachable from your browser`, - "Re-run with 'ol auth login --token ' for manual auth", - ] - if (error.code === 'EADDRINUSE') { - hints.unshift( - `Port ${callbackPort} is already in use. Close the other process using it.`, - ) - } - - console.error( - formatError( - 'OAUTH_CALLBACK_SERVER_FAILED', - `Could not start local OAuth callback server: ${error.message}`, - hints, - ), - ) - process.exit(1) - } - const authorizationUrl = buildAuthorizationUrl({ - baseUrl: url, - clientId, - redirectUri: callbackServer.redirectUri, - codeChallenge, - state, - }) - - try { - await open(authorizationUrl) - } catch (err) { - console.log( - chalk.yellow('Could not open browser automatically.'), - chalk.dim(authorizationUrl), - ) - console.log(chalk.dim((err as Error).message)) - } - - console.log(chalk.dim('Waiting for OAuth authorization...')) - - try { - const code = await callbackServer.waitForCode - const token = await exchangeCodeForToken({ - baseUrl: url, - clientId, - redirectUri: callbackServer.redirectUri, - codeVerifier, - code, - }) - - await saveConfig(token, url, clientId) - - const { data } = await apiRequest('auth.info') - console.log( - chalk.green(`Authenticated to ${data.team.name} as ${data.user.name}`), - ) - } catch (err) { - callbackServer.close() - console.error( - formatError( - 'OAUTH_LOGIN_FAILED', - `OAuth login failed: ${(err as Error).message}`, - [ - 'Confirm the OAuth app redirect URI matches the CLI callback', - 'Verify --base-url or OUTLINE_URL points to your Outline instance', - "Re-run with 'ol auth login --token' for manual auth", - ], - ), - ) - process.exit(1) - } - }, - ) auth.command('status') .description('Show current authentication state') diff --git a/src/lib/api.ts b/src/lib/api.ts index b89cec1..833f2a4 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -47,19 +47,31 @@ export interface PaginatedResult { pagination?: Pagination } +export type ApiRequestOverrides = { + token?: string + baseUrl?: string +} + /** * Core API request function without spinner wrapping. */ -async function rawApiRequest(path: string, body: object = {}): Promise> { - const [baseUrl, token] = await Promise.all([getBaseUrl(), getApiToken()]) +async function rawApiRequest( + path: string, + body: object = {}, + overrides: ApiRequestOverrides = {}, +): Promise> { + const [resolvedBaseUrl, resolvedToken] = await Promise.all([ + overrides.baseUrl ? Promise.resolve(overrides.baseUrl.replace(/\/$/, '')) : getBaseUrl(), + overrides.token ? Promise.resolve(overrides.token) : getApiToken(), + ]) const res = await fetchWithRetry({ - url: `${baseUrl}/api/${path}`, + url: `${resolvedBaseUrl}/api/${path}`, options: { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${resolvedToken}`, }, body: JSON.stringify(body), }, @@ -82,11 +94,15 @@ async function rawApiRequest(path: string, body: object = {}): Promise(path: string, body: object = {}): Promise> { +export async function apiRequest( + path: string, + body: object = {}, + overrides: ApiRequestOverrides = {}, +): Promise> { const spinnerConfig = API_SPINNER_CONFIG[path] ?? { text: 'Loading...', color: 'blue' as const, } - return withSpinner(spinnerConfig, () => rawApiRequest(path, body)) + return withSpinner(spinnerConfig, () => rawApiRequest(path, body, overrides)) } diff --git a/src/lib/auth-pages.ts b/src/lib/auth-pages.ts new file mode 100644 index 0000000..584ed49 --- /dev/null +++ b/src/lib/auth-pages.ts @@ -0,0 +1,111 @@ +function escapeHtml(text: string): string { + return text + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function renderPage(title: string, subtitle: string, body: string): string { + return ` + + + + + ${escapeHtml(title)} - Outline CLI + + + +
+

${escapeHtml(title)}

+

${escapeHtml(subtitle)}

+ ${body} +

Return to your terminal and continue with ol commands.

+
+ +` +} + +export function renderSuccess(): string { + return renderPage( + 'Login complete', + 'Outline CLI is now authenticated.', + '
You can close this tab now.
', + ) +} + +export function renderError(message: string): string { + return renderPage( + 'Authentication failed', + 'Outline CLI could not finish OAuth login.', + `
${escapeHtml(message)}
`, + ) +} diff --git a/src/lib/auth-provider.ts b/src/lib/auth-provider.ts new file mode 100644 index 0000000..25b9272 --- /dev/null +++ b/src/lib/auth-provider.ts @@ -0,0 +1,201 @@ +import { createInterface } from 'node:readline/promises' +import { + type AuthAccount, + type AuthProvider, + deriveChallenge, + generateVerifier, + type TokenStore, +} from '@doist/cli-core/auth' +import { fetchWithRetry } from '../transport/fetch-with-retry.js' +import { apiRequest } from './api.js' +import { clearConfig, getBaseUrl, getOAuthClientId } from './auth.js' +import { getConfig, updateConfig } from './config.js' + +const DEFAULT_BASE_URL = 'https://app.getoutline.com' + +type AuthInfoResponse = { + user: { id: string; name: string; email: string } + team: { name: string; subdomain: string } +} + +export type OutlineAccount = AuthAccount & { + id: string + label: string + baseUrl: string + oauthClientId: string + teamName?: string +} + +type OutlineHandshake = Record & { + baseUrl: string + clientId: string + codeVerifier?: string +} + +function asHandshake(value: Record): OutlineHandshake { + return value as OutlineHandshake +} + +function stringFlag(flags: Record, key: string): string | undefined { + const value = flags[key] + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +async function prompt(question: string): Promise { + // Output to stderr so `--json` / `--ndjson` envelopes on stdout stay clean. + const rl = createInterface({ input: process.stdin, output: process.stderr }) + try { + return (await rl.question(question)).trim() + } finally { + rl.close() + } +} + +async function resolveBaseUrl(flags: Record): Promise { + const fromFlag = stringFlag(flags, 'baseUrl') + if (fromFlag) return fromFlag.replace(/\/$/, '') + const fromEnv = process.env.OUTLINE_URL?.trim() + if (fromEnv) return fromEnv.replace(/\/$/, '') + const configured = await getBaseUrl() + const answered = await prompt(`Base URL (default: ${configured}): `) + return (answered || configured).replace(/\/$/, '') +} + +async function resolveClientId(flags: Record): Promise { + const fromFlag = stringFlag(flags, 'clientId') + if (fromFlag) return fromFlag + const existing = await getOAuthClientId() + if (existing) return existing + const answered = await prompt('OAuth Client ID: ') + if (!answered) { + throw new Error( + 'OAuth client ID is required. Create a public OAuth app in Outline settings, then pass --client-id or set OUTLINE_OAUTH_CLIENT_ID.', + ) + } + return answered +} + +export function createOutlineAuthProvider(): AuthProvider { + return { + async authorize({ redirectUri, state, flags }) { + const baseUrl = await resolveBaseUrl(flags) + const clientId = await resolveClientId(flags) + const codeVerifier = generateVerifier() + const codeChallenge = deriveChallenge(codeVerifier) + + const url = new URL(`${baseUrl}/oauth/authorize`) + url.searchParams.set('client_id', clientId) + url.searchParams.set('response_type', 'code') + url.searchParams.set('code_challenge', codeChallenge) + url.searchParams.set('code_challenge_method', 'S256') + url.searchParams.set('redirect_uri', redirectUri) + url.searchParams.set('state', state) + + const handshake: OutlineHandshake = { baseUrl, clientId, codeVerifier } + return { authorizeUrl: url.toString(), handshake } + }, + + async exchangeCode({ code, redirectUri, handshake }) { + const hs = asHandshake(handshake) + if (!hs.codeVerifier) { + throw new Error('Missing PKCE code verifier from authorize step.') + } + + const params = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: hs.clientId, + redirect_uri: redirectUri, + code_verifier: hs.codeVerifier, + code, + }) + + const res = await fetchWithRetry({ + url: `${hs.baseUrl}/oauth/token`, + options: { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }, + }) + + const json = (await res.json().catch(() => ({}))) as { + access_token?: string + error?: string + error_description?: string + message?: string + } + + if (!res.ok) { + const message = + json.error_description || json.message || json.error || res.statusText + throw new Error(`OAuth token exchange failed: ${message}`) + } + + if (!json.access_token) { + throw new Error('OAuth token exchange did not return an access token.') + } + + return { accessToken: json.access_token } + }, + + async validateToken({ token, handshake }) { + const hs = asHandshake(handshake) + const { data } = await apiRequest( + 'auth.info', + {}, + { + token, + baseUrl: hs.baseUrl, + }, + ) + return { + id: data.user.id, + label: data.user.name, + baseUrl: hs.baseUrl, + oauthClientId: hs.clientId, + teamName: data.team.name, + } + }, + } +} + +export function createOutlineTokenStore(): TokenStore { + return { + async active() { + const config = await getConfig() + if (!config.api_token) return null + const baseUrl = config.base_url ?? DEFAULT_BASE_URL + const oauthClientId = config.oauth_client_id ?? '' + const id = config.auth_user_id + const label = config.auth_user_name + if (!id || !label) { + // Stored token predates this adapter (env var, pre-upgrade + // config). No persisted identity to round-trip. + return null + } + return { + token: config.api_token, + account: { + id, + label, + baseUrl, + oauthClientId, + teamName: config.auth_team_name, + }, + } + }, + async set(account, token) { + await updateConfig({ + api_token: token, + base_url: account.baseUrl, + oauth_client_id: account.oauthClientId, + auth_user_id: account.id, + auth_user_name: account.label, + auth_team_name: account.teamName, + }) + }, + async clear() { + await clearConfig() + }, + } +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 8c6654c..21fc7a7 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,4 +1,4 @@ -import { type Config, getConfig, setConfig, updateConfig } from './config.js' +import { type Config, getConfig, setConfig } from './config.js' const DEFAULT_BASE_URL = 'https://app.getoutline.com' @@ -37,17 +37,6 @@ export async function getTokenSource(): Promise<'env' | 'config' | null> { return null } -export async function saveConfig( - token: string, - baseUrl?: string, - oauthClientId?: string, -): Promise { - const updates: Partial = { api_token: token } - if (baseUrl) updates.base_url = baseUrl.replace(/\/$/, '') - if (oauthClientId) updates.oauth_client_id = oauthClientId - await updateConfig(updates) -} - /** * Clear the auth-related keys without deleting the file. The config is now * shared with non-auth settings (notably `update_channel`); a blanket unlink @@ -55,9 +44,20 @@ export async function saveConfig( */ export async function clearConfig(): Promise { const existing = await getConfig() - const { api_token, base_url, oauth_client_id, ...rest } = existing + const { + api_token, + base_url, + oauth_client_id, + auth_user_id, + auth_user_name, + auth_team_name, + ...rest + } = existing void api_token void base_url void oauth_client_id + void auth_user_id + void auth_user_name + void auth_team_name await setConfig(rest as Config) } diff --git a/src/lib/config.ts b/src/lib/config.ts index cc6f6f8..baf7dda 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -12,6 +12,9 @@ export type Config = CoreConfig & { api_token?: string base_url?: string oauth_client_id?: string + auth_user_id?: string + auth_user_name?: string + auth_team_name?: string } /** diff --git a/src/lib/oauth-server.ts b/src/lib/oauth-server.ts deleted file mode 100644 index 52e8425..0000000 --- a/src/lib/oauth-server.ts +++ /dev/null @@ -1,250 +0,0 @@ -import http from 'node:http' -import type { AddressInfo } from 'node:net' - -interface OAuthServerOptions { - state: string - timeoutMs?: number - port?: number -} - -export interface OAuthCallbackServer { - port: number - redirectUri: string - waitForCode: Promise - close: () => void -} - -export const DEFAULT_OAUTH_CALLBACK_PORT = 54969 - -function escapeHtml(text: string): string { - return text - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", ''') -} - -function renderPage(title: string, subtitle: string, body: string): string { - return ` - - - - - ${escapeHtml(title)} - Outline CLI - - - -
-

${escapeHtml(title)}

-

${escapeHtml(subtitle)}

- ${body} -

Return to your terminal and continue with ol commands.

-
- -` -} - -function renderSuccessPage(): string { - return renderPage( - 'Login complete', - 'Outline CLI is now authenticated.', - '
You can close this tab now.
', - ) -} - -function renderErrorPage(message: string): string { - return renderPage( - 'Authentication failed', - 'Outline CLI could not finish OAuth login.', - `
${escapeHtml(message)}
`, - ) -} - -export async function startOAuthCallbackServer( - options: OAuthServerOptions, -): Promise { - const { state, timeoutMs = 3 * 60 * 1000, port = DEFAULT_OAUTH_CALLBACK_PORT } = options - let origin = 'http://localhost' - let resolved = false - let resolveCode: (code: string) => void - let rejectCode: (error: Error) => void - - const waitForCode = new Promise((resolve, reject) => { - resolveCode = resolve - rejectCode = reject - }) - - const server = http.createServer((req, res) => { - if (resolved) { - res.writeHead(409, { 'Content-Type': 'text/html; charset=utf-8' }) - res.end(renderErrorPage('Authorization was already received.')) - return - } - - if (req.method !== 'GET' || !req.url) { - res.writeHead(405, { 'Content-Type': 'text/html; charset=utf-8' }) - res.end(renderErrorPage('Invalid callback request method.')) - return - } - - const url = new URL(req.url, origin) - if (url.pathname !== '/callback') { - res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' }) - res.end(renderErrorPage('Unknown callback path.')) - return - } - - // Check for OAuth error response first - const error = url.searchParams.get('error') - if (error) { - const errorDescription = url.searchParams.get('error_description') || error - res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }) - res.end(renderErrorPage(`Authorization failed: ${errorDescription}`)) - rejectCode(new Error(`OAuth authorization denied: ${errorDescription}`)) - resolved = true - return - } - - const code = url.searchParams.get('code') - const returnedState = url.searchParams.get('state') - - if (!code || !returnedState) { - res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }) - res.end(renderErrorPage('Missing OAuth code or state parameter.')) - rejectCode(new Error('Missing OAuth authorization code.')) - resolved = true - return - } - - if (returnedState !== state) { - res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }) - res.end(renderErrorPage('Invalid OAuth state parameter.')) - rejectCode(new Error('OAuth state mismatch.')) - resolved = true - return - } - - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) - res.end(renderSuccessPage()) - resolved = true - resolveCode(code) - }) - - server.on('error', (err) => { - if (resolved) return - resolved = true - rejectCode(err as Error) - }) - - await new Promise((resolve, reject) => { - const onListening = () => { - server.off('error', onError) - resolve() - } - const onError = (err: Error) => { - server.off('listening', onListening) - reject(err) - } - - server.once('listening', onListening) - server.once('error', onError) - server.listen(port, '127.0.0.1') - }) - - const { port: listeningPort } = server.address() as AddressInfo - origin = `http://localhost:${listeningPort}` - const redirectUri = `${origin}/callback` - - const timeout = setTimeout(() => { - if (resolved) return - resolved = true - rejectCode(new Error('Timed out waiting for OAuth callback.')) - server.close() - }, timeoutMs) - - const close = () => { - clearTimeout(timeout) - server.close() - } - - waitForCode.then( - () => { - clearTimeout(timeout) - server.close() - }, - () => { - clearTimeout(timeout) - server.close() - }, - ) - - return { port: listeningPort, redirectUri, waitForCode, close } -} diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts deleted file mode 100644 index 817c1b4..0000000 --- a/src/lib/oauth.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { fetchWithRetry } from '../transport/fetch-with-retry.js' - -interface AuthorizationUrlOptions { - baseUrl: string - clientId: string - redirectUri: string - codeChallenge: string - state: string -} - -interface TokenExchangeOptions { - baseUrl: string - clientId: string - redirectUri: string - codeVerifier: string - code: string -} - -interface TokenResponse { - access_token?: string - error?: string - error_description?: string - message?: string -} - -export function buildAuthorizationUrl(options: AuthorizationUrlOptions): string { - const { baseUrl, clientId, redirectUri, codeChallenge, state } = options - const url = new URL(`${baseUrl}/oauth/authorize`) - url.searchParams.set('client_id', clientId) - url.searchParams.set('response_type', 'code') - url.searchParams.set('code_challenge', codeChallenge) - url.searchParams.set('code_challenge_method', 'S256') - url.searchParams.set('redirect_uri', redirectUri) - url.searchParams.set('state', state) - return url.toString() -} - -export async function exchangeCodeForToken(options: TokenExchangeOptions): Promise { - const { baseUrl, clientId, redirectUri, codeVerifier, code } = options - const params = new URLSearchParams({ - grant_type: 'authorization_code', - client_id: clientId, - redirect_uri: redirectUri, - code_verifier: codeVerifier, - code, - }) - - const res = await fetchWithRetry({ - url: `${baseUrl}/oauth/token`, - options: { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: params.toString(), - }, - }) - - const json = (await res.json()) as TokenResponse - if (!res.ok) { - const message = json.error_description || json.message || json.error || res.statusText - throw new Error(`OAuth token exchange failed: ${message}`) - } - - if (!json.access_token) { - throw new Error('OAuth token exchange did not return an access token.') - } - - return json.access_token -} diff --git a/src/lib/pkce.ts b/src/lib/pkce.ts deleted file mode 100644 index 4a9aa37..0000000 --- a/src/lib/pkce.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createHash, randomBytes } from 'node:crypto' - -function base64UrlEncode(buffer: Buffer): string { - return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') -} - -export function generateCodeVerifier(): string { - // 64 bytes => 86 chars, within 43-128 requirement. - return base64UrlEncode(randomBytes(64)) -} - -export function generateCodeChallenge(codeVerifier: string): string { - const hash = createHash('sha256').update(codeVerifier).digest() - return base64UrlEncode(hash) -} - -export function generateState(): string { - return base64UrlEncode(randomBytes(32)) -} diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index 772c967..38895ff 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -72,9 +72,14 @@ ol col delete --confirm ### Authentication \`\`\`bash -ol auth login # Configure API token and base URL -ol auth status # Show current auth state -ol auth logout # Clear saved credentials +ol auth login # OAuth login (opens browser); prompts for base URL + client ID if needed +ol auth login --base-url # Specify Outline base URL for this login (saved for future use) +ol auth login --client-id # Specify OAuth client ID for this login (saved for future use) +ol auth login --callback-port # Override local OAuth callback port +ol auth login --read-only # Request read-only scopes (where supported by the Outline instance) +ol auth login --json | --ndjson # Machine-readable success envelope +ol auth status # Show current auth state +ol auth logout # Clear saved credentials \`\`\` ### Update & Changelog