346 lines
11 KiB
JavaScript
346 lines
11 KiB
JavaScript
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();
|
||
|
|
}
|