Skip to content
Merged
38 changes: 36 additions & 2 deletions cli/src/commands/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
generatePkce,
LINEAR_OAUTH_SECRET_PREFIX,
linearOauthSecretName,
resolveWebhookSecretAction,
StoredLinearOauthToken,
} from '../linear-oauth';
import { awaitOauthCallback, CALLBACK_URL } from '../oauth-callback-server';
Expand Down Expand Up @@ -601,6 +602,25 @@ export function makeLinearCommand(): Command {
process.stdout.write(' → Storing OAuth token...');
const sm = new SecretsManagerClient({ region });
const now = new Date().toISOString();
// Preserve any EXISTING per-workspace webhook signing secret before the
// OAuth overwrite below. Re-running `setup` on an already-installed
// workspace must NOT clobber its working signing secret with the
// stack-wide fallback (which belongs to whichever workspace installed
// first) — that silently breaks signature verification (401 "Invalid
// signature") for every workspace after the first in a multi-workspace
// deployment. Rotation stays the job of `update-webhook-secret`.
let existingWebhookSecret: string | undefined;
try {
const prior = await sm.send(new GetSecretValueCommand({ SecretId: linearOauthSecretName(slug) }));
if (prior.SecretString) {
const priorBundle = JSON.parse(prior.SecretString) as Partial<StoredLinearOauthToken>;
if (priorBundle.webhook_signing_secret?.startsWith('lin_wh_')) {
existingWebhookSecret = priorBundle.webhook_signing_secret;
}
}
} catch {
Comment thread
isadeks marked this conversation as resolved.
Outdated
// No prior bundle (first install) — nothing to preserve.
}
const stored: StoredLinearOauthToken = {
access_token: tokenResponse.access_token,
refresh_token: tokenResponse.refresh_token ?? '',
Expand Down Expand Up @@ -687,8 +707,22 @@ export function makeLinearCommand(): Command {
const stackWideAlreadyConfigured = await isWebhookSecretConfigured(sm, webhookSecretArn!);
let webhookSigningSecret: string | undefined;

if (stackWideAlreadyConfigured) {
console.log(' ✓ Webhook signing secret already configured stack-wide (mirroring to per-workspace)');
const secretAction = resolveWebhookSecretAction(existingWebhookSecret, stackWideAlreadyConfigured);
Comment thread
isadeks marked this conversation as resolved.
if (secretAction.kind === 'preserve') {
// This workspace already had its own signing secret — keep it. Do NOT
// overwrite from the stack-wide fallback (a DIFFERENT workspace's
// secret once >1 is installed). Re-run case; rotation is
// `update-webhook-secret`'s job, not setup's.
console.log(' ✓ Preserving this workspace\'s existing webhook signing secret (re-run — not overwriting)');
Comment thread
isadeks marked this conversation as resolved.
webhookSigningSecret = secretAction.secret;
} else if (secretAction.kind === 'mirror-stackwide') {
// No per-workspace secret yet, but the stack-wide one is set. Safe to
// mirror ONLY when this is the first/only workspace — for a genuinely
// new ADDITIONAL workspace the stack-wide secret is the wrong one, so
// warn that the operator should verify (or run `update-webhook-secret`).
console.log(' ✓ No per-workspace secret yet; mirroring the stack-wide signing secret');
console.log(' (if this is an ADDITIONAL workspace, its Linear webhook secret differs —');
console.log(` run \`bgagent linear update-webhook-secret ${slug}\` with this workspace's secret.)`);
try {
const value = await sm.send(new GetSecretValueCommand({ SecretId: webhookSecretArn! }));
if (value.SecretString && value.SecretString.startsWith('lin_wh_')) {
Expand Down
42 changes: 42 additions & 0 deletions cli/src/linear-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,3 +293,45 @@ function isLinearTokenResponse(value: unknown): value is LinearTokenResponse {
export function computeExpiresAt(expiresInSeconds: number, now: Date = new Date()): string {
return new Date(now.getTime() + expiresInSeconds * 1000).toISOString();
}

/** What `bgagent linear setup` should do about the webhook signing secret. */
export type WebhookSecretAction =
/** This workspace already has its own signing secret — keep it (re-run). */
| { readonly kind: 'preserve'; readonly secret: string }
/** No per-workspace secret; mirror the stack-wide one (safe for the first
* workspace, ambiguous for an additional one — caller should warn). */
| { readonly kind: 'mirror-stackwide' }
/** No secret anywhere — prompt the operator for it (first install). */
| { readonly kind: 'prompt' };

/**
* Decide which webhook signing secret `setup` should use, WITHOUT clobbering a
* working per-workspace secret.
*
* The bug this fixes: re-running `setup` on an already-installed workspace used
* to lift the *stack-wide* signing secret into the per-workspace bundle. That's
* correct only for the FIRST workspace — the stack-wide secret belongs to
* whichever workspace installed first, so for any additional workspace it
* overwrites the correct per-workspace secret with the wrong one, silently
* breaking signature verification (webhook 401 "Invalid signature").
*
* Rule: an existing valid per-workspace secret always wins (rotation is
* `update-webhook-secret`'s job); else mirror the stack-wide secret if present;
* else prompt. Pure — the caller supplies what it read from Secrets Manager.
*
* @param existingPerWorkspaceSecret the `webhook_signing_secret` on this
* workspace's OAuth bundle BEFORE the setup rewrite (undefined if none).
* @param stackWideConfigured whether the stack-wide fallback secret is set.
*/
export function resolveWebhookSecretAction(
existingPerWorkspaceSecret: string | undefined,
stackWideConfigured: boolean,
): WebhookSecretAction {
if (existingPerWorkspaceSecret?.startsWith('lin_wh_')) {
return { kind: 'preserve', secret: existingPerWorkspaceSecret };
}
if (stackWideConfigured) {
return { kind: 'mirror-stackwide' };
}
return { kind: 'prompt' };
}
33 changes: 33 additions & 0 deletions cli/test/linear-oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
LINEAR_TOKEN_ENDPOINT,
linearOauthSecretName,
refreshAccessToken,
resolveWebhookSecretAction,
} from '../src/linear-oauth';

describe('linearOauthSecretName', () => {
Expand Down Expand Up @@ -281,3 +282,35 @@ describe('refreshAccessToken', () => {
})).rejects.toThrow(CliError);
});
});

describe('resolveWebhookSecretAction', () => {
it('PRESERVES an existing per-workspace secret over the stack-wide one (multi-workspace re-run — the bug)', () => {
// The regression: re-running `setup` on an already-installed workspace must
// NOT overwrite its working signing secret with the stack-wide fallback
// (which belongs to a different workspace once >1 is installed) — that
// silently breaks signature verification (webhook 401 "Invalid signature").
const action = resolveWebhookSecretAction('lin_wh_thisWorkspace', true);
expect(action).toEqual({ kind: 'preserve', secret: 'lin_wh_thisWorkspace' });
});

it('preserves the existing secret even when no stack-wide secret is set', () => {
expect(resolveWebhookSecretAction('lin_wh_existing', false)).toEqual({
kind: 'preserve',
secret: 'lin_wh_existing',
});
});

it('mirrors the stack-wide secret when there is no per-workspace one yet (first workspace)', () => {
expect(resolveWebhookSecretAction(undefined, true)).toEqual({ kind: 'mirror-stackwide' });
});

it('prompts when neither a per-workspace nor a stack-wide secret exists (first install)', () => {
expect(resolveWebhookSecretAction(undefined, false)).toEqual({ kind: 'prompt' });
});

it('ignores a malformed existing secret (not lin_wh_) and falls through', () => {
// A corrupt/empty value must not be "preserved" as if valid.
expect(resolveWebhookSecretAction('garbage', true)).toEqual({ kind: 'mirror-stackwide' });
expect(resolveWebhookSecretAction('', false)).toEqual({ kind: 'prompt' });
});
});
Loading