Skip to content

Commit e259da1

Browse files
bgagentclaude
andcommitted
fix(cli): switch OAuth callback to plain HTTP localhost
Per RFC 8252 §7.3, OAuth providers (including Linear) treat http://localhost as a special case that doesn't need TLS — the connection never leaves the host. The previous self-signed-cert HTTPS approach forced testers through a "connection not private" warning that scared them off mid-setup. Drops the openssl shell-out + temp-cert plumbing (~60 lines) along with the user-facing warning copy in `bgagent linear setup`. Updates the callback constants to http://localhost:8080/oauth/callback and the test suite to plain http.GET. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6bc41e7 commit e259da1

3 files changed

Lines changed: 27 additions & 100 deletions

File tree

cli/src/commands/linear.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -381,8 +381,7 @@ export function makeLinearCommand(): Command {
381381
const opened = await openBrowser(authorizationUrl);
382382
if (opened) {
383383
console.log(' → Opened your browser to the Linear consent screen.');
384-
console.log(' Your browser will warn about a self-signed cert on the localhost callback —');
385-
console.log(' click through (Advanced → Proceed). The cert is local-only.');
384+
console.log(' The browser will redirect to a localhost page after you Authorize — that\'s expected.');
386385
} else {
387386
console.log(' → Could not open browser automatically. Open this URL manually:');
388387
console.log(` ${authorizationUrl}`);

cli/src/oauth-callback-server.ts

Lines changed: 14 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -17,23 +17,27 @@
1717
* SOFTWARE.
1818
*/
1919

20-
import { execFileSync } from 'child_process';
21-
import * as fs from 'fs';
22-
import * as https from 'https';
23-
import * as os from 'os';
24-
import * as path from 'path';
20+
import * as http from 'http';
2521
import { URL } from 'url';
2622
import { CliError } from './errors';
2723

2824
/**
2925
* Localhost OAuth callback URL used during `bgagent linear setup`.
30-
* Must match the URL allowlisted on the CLI workload identity in CDK
31-
* (cdk/src/constructs/cli-workload-identity.ts).
26+
*
27+
* HTTP (not HTTPS) is intentional. Per RFC 8252 §7.3 (OAuth 2.0 for
28+
* Native Apps) and Linear's docs, providers MUST treat http://localhost
29+
* URLs as a special case and not require TLS — the connection never
30+
* leaves the host. Using HTTP here removes the self-signed-cert browser
31+
* warning that scared early testers during the Phase 2.0b smoke.
32+
*
33+
* The redirect_uri value sent to Linear MUST byte-match what's configured
34+
* in Linear's app — keep this constant in sync with the LINEAR_SETUP_GUIDE
35+
* playbook entry.
3236
*/
3337
export const CALLBACK_HOST = 'localhost';
34-
export const CALLBACK_PORT = 8443;
38+
export const CALLBACK_PORT = 8080;
3539
export const CALLBACK_PATH = '/oauth/callback';
36-
export const CALLBACK_URL = `https://${CALLBACK_HOST}:${CALLBACK_PORT}${CALLBACK_PATH}`;
40+
export const CALLBACK_URL = `http://${CALLBACK_HOST}:${CALLBACK_PORT}${CALLBACK_PATH}`;
3741

3842
const SUCCESS_HTML = `<!doctype html>
3943
<html><head><meta charset="utf-8"><title>bgagent setup</title>
@@ -45,60 +49,6 @@ const FAILURE_HTML = `<!doctype html>
4549
<style>body{font-family:system-ui,sans-serif;max-width:480px;margin:8em auto;text-align:center;color:#222}h1{color:#c00}p{color:#666}</style></head>
4650
<body><h1>✗ Authorization not captured</h1><p>The callback URL did not include a session_id. Re-run <code>bgagent linear setup</code> and try again.</p></body></html>`;
4751

48-
/**
49-
* Generate a self-signed cert + key pair for localhost using openssl.
50-
*
51-
* The cert is created in a temp dir and removed on close; the user's
52-
* browser will warn ("connection not private") on the redirect because
53-
* it's self-signed. This is acceptable: the cert is only used between
54-
* the user's browser and `localhost`, never traverses the network.
55-
*
56-
* Why openssl shell-out instead of node-forge or selfsigned: avoids a
57-
* runtime dependency for a one-off setup-time operation. openssl ships
58-
* with macOS and most Linux distros; if it's missing, fail loudly with
59-
* a remediation hint rather than silently falling back.
60-
*/
61-
export function generateSelfSignedCert(): { certPath: string; keyPath: string; cleanup: () => void } {
62-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bgagent-oauth-'));
63-
const keyPath = path.join(tmpDir, 'key.pem');
64-
const certPath = path.join(tmpDir, 'cert.pem');
65-
66-
try {
67-
// -batch suppresses the interactive subject prompt; -subj sets a minimal
68-
// subject. Localhost cert with 1-day validity (we only need it for the
69-
// setup session — if you don't finish in 24h, regenerate).
70-
execFileSync('openssl', [
71-
'req',
72-
'-x509',
73-
'-newkey', 'rsa:2048',
74-
'-keyout', keyPath,
75-
'-out', certPath,
76-
'-days', '1',
77-
'-nodes',
78-
'-subj', '/CN=localhost',
79-
'-batch',
80-
], { stdio: 'pipe' });
81-
} catch (err) {
82-
fs.rmSync(tmpDir, { recursive: true, force: true });
83-
throw new CliError(
84-
`Failed to generate localhost cert via openssl: ${err instanceof Error ? err.message : String(err)}. `
85-
+ `Confirm \`openssl\` is installed and on PATH (ships with macOS and most Linux distros).`,
86-
);
87-
}
88-
89-
return {
90-
certPath,
91-
keyPath,
92-
cleanup: () => {
93-
try {
94-
fs.rmSync(tmpDir, { recursive: true, force: true });
95-
} catch {
96-
// Best effort — leftover certs in /tmp are harmless.
97-
}
98-
},
99-
};
100-
}
101-
10252
export interface CallbackResult {
10353
/**
10454
* Value of the `session_id` query param if present (AgentCore-style
@@ -157,7 +107,6 @@ export async function awaitOauthCallback(
157107
options: CallbackServerOptions = {},
158108
): Promise<CallbackResult> {
159109
const timeoutMs = options.timeoutMs ?? 700_000;
160-
const cert = generateSelfSignedCert();
161110

162111
return new Promise<CallbackResult>((resolve, reject) => {
163112
let settled = false;
@@ -167,7 +116,6 @@ export async function awaitOauthCallback(
167116
try {
168117
fn();
169118
} finally {
170-
cert.cleanup();
171119
clearTimeout(timer);
172120
// .close() shuts down the listener; in-flight responses still complete.
173121
try {
@@ -178,11 +126,7 @@ export async function awaitOauthCallback(
178126
}
179127
};
180128

181-
const server = https.createServer(
182-
{
183-
key: fs.readFileSync(cert.keyPath),
184-
cert: fs.readFileSync(cert.certPath),
185-
},
129+
const server = http.createServer(
186130
(req, res) => {
187131
// Defensive: if we somehow get a request after settling, just close it.
188132
if (settled || !req.url) {

cli/test/oauth-callback-server.test.ts

Lines changed: 12 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -17,28 +17,24 @@
1717
* SOFTWARE.
1818
*/
1919

20-
import * as fs from 'fs';
21-
import * as https from 'https';
20+
import * as http from 'http';
2221
import {
2322
awaitOauthCallback,
2423
CALLBACK_PORT,
2524
CALLBACK_URL,
26-
generateSelfSignedCert,
2725
} from '../src/oauth-callback-server';
2826

2927
/**
30-
* Make a self-signed-cert-tolerant HTTPS GET request to localhost.
31-
* Returns the response status + body. Closes the connection cleanly so
32-
* the server can finish settling without hanging the test.
28+
* Make a plain HTTP GET request to localhost. Returns the response
29+
* status + body. Closes the connection cleanly so the server can
30+
* finish settling without hanging the test.
3331
*/
3432
function localGet(urlSuffix: string): Promise<{ status: number; body: string }> {
3533
return new Promise((resolve, reject) => {
36-
const req = https.get({
34+
const req = http.get({
3735
host: 'localhost',
3836
port: CALLBACK_PORT,
3937
path: urlSuffix,
40-
// We're testing our own self-signed cert, so accept it.
41-
rejectUnauthorized: false,
4238
}, (res) => {
4339
let body = '';
4440
res.on('data', (chunk) => { body += chunk; });
@@ -48,25 +44,11 @@ function localGet(urlSuffix: string): Promise<{ status: number; body: string }>
4844
});
4945
}
5046

51-
describe('generateSelfSignedCert', () => {
52-
test('produces readable cert + key files and a working cleanup', () => {
53-
const cert = generateSelfSignedCert();
54-
expect(fs.existsSync(cert.certPath)).toBe(true);
55-
expect(fs.existsSync(cert.keyPath)).toBe(true);
56-
// Cert PEM has the standard header — quick sanity check.
57-
expect(fs.readFileSync(cert.certPath, 'utf-8')).toContain('-----BEGIN CERTIFICATE-----');
58-
expect(fs.readFileSync(cert.keyPath, 'utf-8')).toContain('-----BEGIN PRIVATE KEY-----');
59-
cert.cleanup();
60-
expect(fs.existsSync(cert.certPath)).toBe(false);
61-
expect(fs.existsSync(cert.keyPath)).toBe(false);
62-
});
63-
});
64-
6547
describe('awaitOauthCallback', () => {
66-
// The real OAuth flow waits on Linear/AWS — to avoid binding port 8443 in
67-
// CI (which may be in use), these tests run sequentially via Jest's default
68-
// test isolation per file. The 8443 port must be free for these tests; if
69-
// a developer has another bgagent setup running locally, expect EADDRINUSE.
48+
// The real OAuth flow waits on Linear — to avoid binding the callback port
49+
// (8080) in CI when it might be in use, these tests run sequentially via
50+
// Jest's default test isolation per file. If a developer has another
51+
// bgagent setup running locally, expect EADDRINUSE.
7052

7153
test('captures session_id from the first valid request and resolves', async () => {
7254
// Fire the server + the request in parallel; the server resolves once it
@@ -158,6 +140,8 @@ describe('awaitOauthCallback', () => {
158140
// Regression-lock: the URL is also baked into the CDK construct's
159141
// allowlist (cdk/src/constructs/cli-workload-identity.ts default).
160142
// Drift here = silent OAuth failure at runtime ("redirect_uri not allowlisted").
161-
expect(CALLBACK_URL).toBe('https://localhost:8443/oauth/callback');
143+
// RFC 8252 §7.3: http://localhost is the right shape for native-app OAuth
144+
// callbacks (no TLS required, no cert warnings). Port 8080 is conventional.
145+
expect(CALLBACK_URL).toBe('http://localhost:8080/oauth/callback');
162146
});
163147
});

0 commit comments

Comments
 (0)