Files

167 lines
6.8 KiB
JavaScript
Raw Permalink Normal View History

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
startAuthorization,
consumeAuthorizationCallback,
pollAuthorizationBroker,
completeAuthorizationBroker,
refreshAccessToken,
clearPendingAuthorizationsForTests,
} from './oauth.js';
const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-oauth-'));
describe('Linear OAuth PKCE', () => {
let dataDir;
let previousDataDir;
let previousPort;
let previousRedirect;
beforeEach(() => {
previousDataDir = process.env.OPENCHAMBER_DATA_DIR;
previousPort = process.env.OPENCHAMBER_PORT;
previousRedirect = process.env.OPENCHAMBER_LINEAR_REDIRECT_URI;
dataDir = makeTempDir();
process.env.OPENCHAMBER_DATA_DIR = dataDir;
process.env.OPENCHAMBER_PORT = '3001';
delete process.env.OPENCHAMBER_LINEAR_CLIENT_ID;
process.env.OPENCHAMBER_LINEAR_REDIRECT_URI = 'http://127.0.0.1:3001/linear/oauth/callback';
clearPendingAuthorizationsForTests();
});
afterEach(() => {
vi.unstubAllGlobals();
clearPendingAuthorizationsForTests();
restoreEnv('OPENCHAMBER_DATA_DIR', previousDataDir);
restoreEnv('OPENCHAMBER_PORT', previousPort);
restoreEnv('OPENCHAMBER_LINEAR_REDIRECT_URI', previousRedirect);
fs.rmSync(dataDir, { recursive: true, force: true });
});
it('creates an S256 authorize URL and stores a pending verifier', async () => {
const started = await startAuthorization({ origin: 'desktop' });
const url = new URL(started.authorizationUrl);
expect(url.origin + url.pathname).toBe('https://linear.app/oauth/authorize');
expect(url.searchParams.get('client_id')).toBe('91bbe26a69a2c8568d3683f1e01e776c');
expect(url.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:3001/linear/oauth/callback');
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
expect(url.searchParams.get('code_challenge')).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(url.searchParams.get('actor')).toBe('user');
expect(url.searchParams.get('prompt')).toBe('consent');
expect(started.scope).toBe('read,write,comments:create');
expect(started.expiresIn).toBe(600);
});
it('refuses a callback whose state was never started', async () => {
const tokenFetch = vi.fn();
vi.stubGlobal('fetch', tokenFetch);
await expect(consumeAuthorizationCallback({
code: 'attacker-code',
state: 'forged',
})).rejects.toMatchObject({ code: 'UNKNOWN_STATE' });
expect(tokenFetch).not.toHaveBeenCalled();
});
it('exchanges a matching code with the original PKCE verifier', async () => {
const started = await startAuthorization({ origin: 'web' });
const state = new URL(started.authorizationUrl).searchParams.get('state');
const tokenFetch = vi.fn(async () => new Response(JSON.stringify({
access_token: 'access-1',
refresh_token: 'refresh-1',
token_type: 'Bearer',
expires_in: 86399,
scope: 'read,write,comments:create',
}), { status: 200 }));
vi.stubGlobal('fetch', tokenFetch);
const result = await consumeAuthorizationCallback({ code: 'auth-code', state });
expect(result.accessToken).toBe('access-1');
expect(result.refreshToken).toBe('refresh-1');
expect(result.origin).toBe('web');
expect(tokenFetch).toHaveBeenCalledTimes(1);
const [url, init] = tokenFetch.mock.calls[0];
expect(String(url)).toBe('https://api.linear.app/oauth/token');
expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
const body = new URLSearchParams(init.body);
expect(body.get('grant_type')).toBe('authorization_code');
expect(body.get('code')).toBe('auth-code');
expect(body.get('code_verifier')).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(body.get('client_secret')).toBeNull();
await expect(consumeAuthorizationCallback({ code: 'auth-code', state })).rejects.toMatchObject({
code: 'UNKNOWN_STATE',
});
});
it('persists a rotated refresh token from Linear', async () => {
const tokenFetch = vi.fn(async () => new Response(JSON.stringify({
access_token: 'access-2',
refresh_token: 'refresh-2',
token_type: 'Bearer',
expires_in: 86399,
}), { status: 200 }));
vi.stubGlobal('fetch', tokenFetch);
const tokens = await refreshAccessToken('refresh-1');
expect(tokens.accessToken).toBe('access-2');
expect(tokens.refreshToken).toBe('refresh-2');
const body = new URLSearchParams(tokenFetch.mock.calls[0][1].body);
expect(body.get('grant_type')).toBe('refresh_token');
expect(body.get('refresh_token')).toBe('refresh-1');
});
it('claims a broker callback and exchanges it locally with PKCE', async () => {
delete process.env.OPENCHAMBER_LINEAR_REDIRECT_URI;
const brokerAndTokenFetch = vi.fn(async (url, init) => {
const target = String(url);
if (target.endsWith('/start')) {
const body = JSON.parse(init.body);
expect(body.state).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(body.claimSecret).toMatch(/^[A-Za-z0-9_-]{43}$/);
return new Response(JSON.stringify({
redirectUri: 'https://api.openchamber.dev/v1/oauth/linear/callback',
expiresIn: 600,
}), { status: 200 });
}
if (target.endsWith('/poll')) {
return new Response(JSON.stringify({ status: 'complete', code: 'broker-code' }), { status: 200 });
}
if (target.endsWith('/complete')) {
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
if (target === 'https://api.linear.app/oauth/token') {
const body = new URLSearchParams(init.body);
expect(body.get('code')).toBe('broker-code');
expect(body.get('redirect_uri')).toBe('https://api.openchamber.dev/v1/oauth/linear/callback');
expect(body.get('code_verifier')).toMatch(/^[A-Za-z0-9_-]{43}$/);
return new Response(JSON.stringify({
access_token: 'broker-access',
refresh_token: 'broker-refresh',
expires_in: 86399,
}), { status: 200 });
}
throw new Error(`unexpected fetch: ${target}`);
});
vi.stubGlobal('fetch', brokerAndTokenFetch);
const started = await startAuthorization({ origin: 'desktop' });
const authorizationUrl = new URL(started.authorizationUrl);
expect(authorizationUrl.searchParams.get('redirect_uri')).toBe('https://api.openchamber.dev/v1/oauth/linear/callback');
const result = await pollAuthorizationBroker();
expect(result).toMatchObject({ accessToken: 'broker-access', origin: 'desktop' });
await expect(completeAuthorizationBroker(result.brokerReceipt)).resolves.toBe(true);
expect(brokerAndTokenFetch).toHaveBeenCalledTimes(4);
});
});
function restoreEnv(name, previous) {
if (previous === undefined) {
delete process.env[name];
return;
}
process.env[name] = previous;
}