167 lines
6.8 KiB
JavaScript
167 lines
6.8 KiB
JavaScript
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;
|
||
|
|
}
|