Files

346 lines
11 KiB
JavaScript
Raw Permalink Normal View History

import crypto from 'crypto';
import {
getLinearClientId,
getLinearClientSecret,
getLinearBrokerUrl,
getLinearRedirectUri,
getLinearScopes,
} from './auth.js';
import { isPlainObject, isString, readFiniteNumber, readTrimmedString } from './parse.js';
export const LINEAR_AUTHORIZE_URL = 'https://linear.app/oauth/authorize';
export const LINEAR_TOKEN_URL = 'https://api.linear.app/oauth/token';
export const LINEAR_REVOKE_URL = 'https://api.linear.app/oauth/revoke';
export const PENDING_AUTHORIZATION_TTL_MS = 10 * 60_000;
const pendingByState = new Map();
const brokerPollsByState = new Map();
export class LinearOAuthError extends Error {
constructor(message, code = 'LINEAR_OAUTH_FAILED') {
super(message);
this.name = 'LinearOAuthError';
this.code = code;
}
}
export function createPkcePair() {
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
return { verifier, challenge };
}
function pruneExpiredPending(now = Date.now()) {
for (const [state, entry] of pendingByState.entries()) {
if (!entry || entry.expiresAt <= now) {
pendingByState.delete(state);
}
}
}
function normalizeScope(scope) {
if (isString(scope)) {
return scope.trim();
}
if (Array.isArray(scope)) {
return scope.filter((item) => isString(item) && item.trim()).join(',');
}
return '';
}
function readExpiresAt(expiresIn, now = Date.now()) {
const seconds = readFiniteNumber(expiresIn);
if (seconds == null || seconds <= 0) {
return now + 24 * 60 * 60 * 1000;
}
return now + Math.floor(seconds) * 1000;
}
function parseTokenPayload(payload) {
if (!isPlainObject(payload)) {
throw new LinearOAuthError('Linear token response was empty');
}
if (readTrimmedString(payload.error)) {
throw new LinearOAuthError(
readTrimmedString(payload.error_description) || readTrimmedString(payload.error),
readTrimmedString(payload.error).toUpperCase(),
);
}
const accessToken = readTrimmedString(payload.access_token);
if (!accessToken) {
throw new LinearOAuthError('Linear token response was missing access_token');
}
return {
accessToken,
refreshToken: readTrimmedString(payload.refresh_token) || null,
tokenType: readTrimmedString(payload.token_type) || 'bearer',
expiresAt: readExpiresAt(payload.expires_in),
scope: normalizeScope(payload.scope),
};
}
async function postForm(url, body) {
const response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(body).toString(),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const description = isPlainObject(payload)
? (readTrimmedString(payload.error_description) || readTrimmedString(payload.error))
: '';
const error = new LinearOAuthError(
description || `Linear token request failed (${response.status})`,
readTrimmedString(payload?.error).toUpperCase() || 'LINEAR_OAUTH_FAILED',
);
error.status = response.status;
throw error;
}
return parseTokenPayload(payload);
}
async function readJsonResponse(response, fallbackMessage) {
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = isPlainObject(payload) && readTrimmedString(payload.error)
? readTrimmedString(payload.error)
: `${fallbackMessage} (${response.status})`;
const error = new LinearOAuthError(message, 'LINEAR_BROKER_FAILED');
error.status = response.status;
throw error;
}
if (!isPlainObject(payload)) {
throw new LinearOAuthError(`${fallbackMessage}: invalid response`, 'LINEAR_BROKER_FAILED');
}
return payload;
}
function brokerCallbackUrl(brokerUrl) {
return `${brokerUrl.replace(/\/+$/, '')}/callback`;
}
async function registerBrokerTransaction({ brokerUrl, state, claimSecret }) {
const response = await fetch(`${brokerUrl}/start`, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ state, claimSecret }),
});
const payload = await readJsonResponse(response, 'Could not start Linear authorization broker');
const redirectUri = readTrimmedString(payload.redirectUri);
if (!redirectUri || redirectUri !== brokerCallbackUrl(brokerUrl)) {
throw new LinearOAuthError('Linear authorization broker returned an unexpected callback URL', 'LINEAR_BROKER_FAILED');
}
return redirectUri;
}
export async function startAuthorization({ origin } = {}) {
const clientId = getLinearClientId();
if (!clientId) {
throw new LinearOAuthError(
'Linear OAuth client not configured. Set OPENCHAMBER_LINEAR_CLIENT_ID.',
'LINEAR_CLIENT_ID_MISSING',
);
}
pruneExpiredPending();
const { verifier, challenge } = createPkcePair();
const state = crypto.randomBytes(32).toString('base64url');
const brokerUrl = getLinearBrokerUrl();
const configuredRedirectUri = getLinearRedirectUri();
const usesBroker = configuredRedirectUri === brokerCallbackUrl(brokerUrl);
const claimSecret = usesBroker ? crypto.randomBytes(32).toString('base64url') : null;
const redirectUri = usesBroker
? await registerBrokerTransaction({ brokerUrl, state, claimSecret })
: configuredRedirectUri;
const scope = getLinearScopes();
pendingByState.set(state, {
codeVerifier: verifier,
redirectUri,
origin: origin === 'desktop' ? 'desktop' : 'web',
broker: usesBroker ? { url: brokerUrl, claimSecret } : null,
expiresAt: Date.now() + PENDING_AUTHORIZATION_TTL_MS,
});
const url = new URL(LINEAR_AUTHORIZE_URL);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', clientId);
url.searchParams.set('redirect_uri', redirectUri);
url.searchParams.set('scope', scope);
url.searchParams.set('state', state);
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('actor', 'user');
url.searchParams.set('prompt', 'consent');
return {
authorizationUrl: url.toString(),
expiresIn: Math.floor(PENDING_AUTHORIZATION_TTL_MS / 1000),
scope,
};
}
async function pollBrokerState(state, pending) {
const response = await fetch(`${pending.broker.url}/poll`, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ state, claimSecret: pending.broker.claimSecret }),
});
if (response.status === 202) {
return null;
}
const payload = await readJsonResponse(response, 'Could not read Linear authorization result');
const status = readTrimmedString(payload.status);
if (status === 'complete') {
const result = await consumeAuthorizationCallback({ code: payload.code, state });
return {
...result,
brokerReceipt: { state, ...pending.broker },
};
}
if (status === 'failed') {
return consumeAuthorizationCallback({
state,
error: payload.error,
errorDescription: payload.errorDescription,
});
}
throw new LinearOAuthError('Linear authorization broker returned an unexpected result', 'LINEAR_BROKER_FAILED');
}
export async function completeAuthorizationBroker(receipt) {
if (!receipt?.url || !receipt?.state || !receipt?.claimSecret) return false;
const response = await fetch(`${receipt.url}/complete`, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ state: receipt.state, claimSecret: receipt.claimSecret }),
});
if (!response.ok) {
throw new LinearOAuthError(`Could not acknowledge Linear authorization result (${response.status})`, 'LINEAR_BROKER_FAILED');
}
return true;
}
export async function pollAuthorizationBroker() {
pruneExpiredPending();
for (const [state, pending] of pendingByState.entries()) {
if (!pending?.broker) continue;
let poll = brokerPollsByState.get(state);
if (!poll) {
poll = pollBrokerState(state, pending).finally(() => brokerPollsByState.delete(state));
brokerPollsByState.set(state, poll);
}
const result = await poll;
if (result) return result;
}
return null;
}
function failAuthorization(message, code, origin) {
const error = new LinearOAuthError(message, code);
if (origin) {
error.origin = origin;
}
return error;
}
export async function consumeAuthorizationCallback({ code, state, error, errorDescription }) {
pruneExpiredPending();
const pending = readTrimmedString(state) ? pendingByState.get(state) : null;
if (readTrimmedString(error)) {
if (readTrimmedString(state)) pendingByState.delete(state);
throw failAuthorization(
readTrimmedString(errorDescription) || readTrimmedString(error),
readTrimmedString(error).toUpperCase(),
pending?.origin,
);
}
if (!readTrimmedString(code)) {
if (readTrimmedString(state)) pendingByState.delete(state);
throw failAuthorization(
'Linear did not return an authorization code.',
'MISSING_CODE',
pending?.origin,
);
}
if (!pending?.codeVerifier) {
throw failAuthorization(
'This authorization session has expired or is unknown to the running app. Return to OpenChamber and click Connect again.',
'UNKNOWN_STATE',
);
}
const body = {
grant_type: 'authorization_code',
code: code.trim(),
redirect_uri: pending.redirectUri,
client_id: getLinearClientId(),
code_verifier: pending.codeVerifier,
};
const clientSecret = getLinearClientSecret();
if (clientSecret) {
body.client_secret = clientSecret;
}
try {
const tokens = await postForm(LINEAR_TOKEN_URL, body);
pendingByState.delete(state);
return {
...tokens,
origin: pending.origin,
};
} catch (caught) {
if (caught instanceof Error) {
caught.origin = pending.origin;
}
throw caught;
}
}
export async function refreshAccessToken(refreshToken) {
const token = readTrimmedString(refreshToken);
if (!token) {
throw new LinearOAuthError('refresh_token is required', 'MISSING_REFRESH_TOKEN');
}
const body = {
grant_type: 'refresh_token',
refresh_token: token,
client_id: getLinearClientId(),
};
const clientSecret = getLinearClientSecret();
if (clientSecret) {
body.client_secret = clientSecret;
}
return postForm(LINEAR_TOKEN_URL, body);
}
export async function revokeToken(token, tokenTypeHint) {
const value = readTrimmedString(token);
if (!value) {
return false;
}
const body = { token: value };
if (tokenTypeHint === 'access_token' || tokenTypeHint === 'refresh_token') {
body.token_type_hint = tokenTypeHint;
}
try {
const response = await fetch(LINEAR_REVOKE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(body).toString(),
});
return response.status === 200;
} catch {
return false;
}
}
export function clearPendingAuthorizationsForTests() {
pendingByState.clear();
brokerPollsByState.clear();
}