Skip to content

Commit 3ac8d9d

Browse files
committed
fix(cli): correct default API base URL for OAuth authentication
The default API URL was incorrectly set to `https://postgres.ai/api/general/` which returns 404 for the `/rpc/oauth_init` endpoint. The correct production API endpoint is `https://console.postgres.ai/api/general/`. This caused "OAuth session not found" errors when using browser-based authentication because: 1. The CLI would call oauth_init on a non-existent endpoint 2. The browser would open console.postgres.ai/cli/auth 3. The UI couldn't find the OAuth session that was never created Changes: - Fix default API URL in cli/lib/util.ts - Update documentation in cli/README.md with correct URLs - Clarify staging vs production environment examples - Add comprehensive unit tests for URL resolution and auth components - Add CI smoke test to verify OAuth endpoint reachability
1 parent f509de2 commit 3ac8d9d

File tree

4 files changed

+284
-10
lines changed

4 files changed

+284
-10
lines changed

.gitlab-ci.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,18 @@ cli:node:smoke:
193193
- node ./cli/dist/bin/postgres-ai.js mon targets list | head -n 1 || true
194194
- node ./cli/dist/bin/postgres-ai.js mon targets add 'postgresql://user:pass@host:5432/db' ci-test || true
195195
- node ./cli/dist/bin/postgres-ai.js mon targets remove ci-test || true
196+
# Verify production OAuth endpoint is reachable (smoke test for auth flow)
197+
- |
198+
echo "Testing OAuth endpoint reachability..."
199+
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
200+
-H "Content-Type: application/json" \
201+
-d '{"client_type":"cli","state":"ci-test","code_challenge":"test","code_challenge_method":"S256","redirect_uri":"http://localhost:0/callback"}' \
202+
"https://console.postgres.ai/api/general/rpc/oauth_init" || echo "000")
203+
echo "OAuth init endpoint returned HTTP $HTTP_CODE"
204+
if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ]; then
205+
echo "WARNING: OAuth endpoint returned unexpected status (expected 200/201, got $HTTP_CODE)"
206+
echo "This may indicate the OAuth endpoint is misconfigured or unreachable"
207+
fi
196208
rules:
197209
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
198210

cli/README.md

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ Cursor configuration example (Settings → MCP):
207207
"command": "postgresai",
208208
"args": ["mcp", "start"],
209209
"env": {
210-
"PGAI_API_BASE_URL": "https://postgres.ai/api/general/"
210+
"PGAI_API_BASE_URL": "https://console.postgres.ai/api/general/"
211211
}
212212
}
213213
}
@@ -281,7 +281,7 @@ Base URL resolution order:
281281
1. Command line option (`--api-base-url`)
282282
2. Environment variable (`PGAI_API_BASE_URL`)
283283
3. User config file `baseUrl` (`~/.config/postgresai/config.json`)
284-
4. Default: `https://postgres.ai/api/general/`
284+
4. Default: `https://console.postgres.ai/api/general/`
285285
- UI base URL (`uiBaseUrl`):
286286
1. Command line option (`--ui-base-url`)
287287
2. Environment variable (`PGAI_UI_BASE_URL`)
@@ -293,7 +293,7 @@ Normalization:
293293
### Environment variables
294294

295295
- `PGAI_API_KEY` - API key for PostgresAI services
296-
- `PGAI_API_BASE_URL` - API endpoint for backend RPC (default: `https://postgres.ai/api/general/`)
296+
- `PGAI_API_BASE_URL` - API endpoint for backend RPC (default: `https://console.postgres.ai/api/general/`)
297297
- `PGAI_UI_BASE_URL` - UI endpoint for browser routes (default: `https://console.postgres.ai`)
298298

299299
### CLI options
@@ -303,17 +303,24 @@ Normalization:
303303

304304
### Examples
305305

306-
Linux/macOS (bash/zsh):
306+
For production (uses default URLs):
307307

308308
```bash
309+
# Production auth - uses console.postgres.ai by default
310+
postgresai auth --debug
311+
```
312+
313+
For staging/development environments:
314+
315+
```bash
316+
# Linux/macOS (bash/zsh)
309317
export PGAI_API_BASE_URL=https://v2.postgres.ai/api/general/
310318
export PGAI_UI_BASE_URL=https://console-dev.postgres.ai
311319
postgresai auth --debug
312320
```
313321

314-
Windows PowerShell:
315-
316322
```powershell
323+
# Windows PowerShell
317324
$env:PGAI_API_BASE_URL = "https://v2.postgres.ai/api/general/"
318325
$env:PGAI_UI_BASE_URL = "https://console-dev.postgres.ai"
319326
postgresai auth --debug
@@ -327,9 +334,6 @@ postgresai auth --debug \
327334
--ui-base-url https://console-dev.postgres.ai
328335
```
329336

330-
Notes:
331-
- If `PGAI_UI_BASE_URL` is not set, the default is `https://console.postgres.ai`.
332-
333337
## Requirements
334338

335339
- Node.js 18 or higher

cli/lib/util.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export function resolveBaseUrls(
107107
cfg?: ConfigLike,
108108
defaults: { apiBaseUrl?: string; uiBaseUrl?: string } = {}
109109
): ResolvedBaseUrls {
110-
const defApi = defaults.apiBaseUrl || "https://postgres.ai/api/general/";
110+
const defApi = defaults.apiBaseUrl || "https://console.postgres.ai/api/general/";
111111
const defUi = defaults.uiBaseUrl || "https://console.postgres.ai";
112112

113113
const apiCandidate = (opts?.apiBaseUrl || process.env.PGAI_API_BASE_URL || cfg?.baseUrl || defApi) as string;

cli/test/auth.test.ts

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import { describe, test, expect } from "bun:test";
2+
import { resolve } from "path";
3+
4+
import * as util from "../lib/util";
5+
import * as pkce from "../lib/pkce";
6+
import * as authServer from "../lib/auth-server";
7+
8+
function runCli(args: string[], env: Record<string, string> = {}) {
9+
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
10+
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
11+
const result = Bun.spawnSync([bunBin, cliPath, ...args], {
12+
env: { ...process.env, ...env },
13+
});
14+
return {
15+
status: result.exitCode,
16+
stdout: new TextDecoder().decode(result.stdout),
17+
stderr: new TextDecoder().decode(result.stderr),
18+
};
19+
}
20+
21+
describe("URL resolution", () => {
22+
test("resolveBaseUrls returns correct production defaults", () => {
23+
const result = util.resolveBaseUrls();
24+
expect(result.apiBaseUrl).toBe("https://console.postgres.ai/api/general");
25+
expect(result.uiBaseUrl).toBe("https://console.postgres.ai");
26+
});
27+
28+
test("resolveBaseUrls strips trailing slashes", () => {
29+
const result = util.resolveBaseUrls({
30+
apiBaseUrl: "https://example.com/api/",
31+
uiBaseUrl: "https://example.com/",
32+
});
33+
expect(result.apiBaseUrl).toBe("https://example.com/api");
34+
expect(result.uiBaseUrl).toBe("https://example.com");
35+
});
36+
37+
test("resolveBaseUrls respects environment variables", () => {
38+
const originalApiUrl = process.env.PGAI_API_BASE_URL;
39+
const originalUiUrl = process.env.PGAI_UI_BASE_URL;
40+
41+
try {
42+
process.env.PGAI_API_BASE_URL = "https://custom-api.example.com/api/";
43+
process.env.PGAI_UI_BASE_URL = "https://custom-ui.example.com/";
44+
45+
const result = util.resolveBaseUrls();
46+
expect(result.apiBaseUrl).toBe("https://custom-api.example.com/api");
47+
expect(result.uiBaseUrl).toBe("https://custom-ui.example.com");
48+
} finally {
49+
if (originalApiUrl === undefined) {
50+
delete process.env.PGAI_API_BASE_URL;
51+
} else {
52+
process.env.PGAI_API_BASE_URL = originalApiUrl;
53+
}
54+
if (originalUiUrl === undefined) {
55+
delete process.env.PGAI_UI_BASE_URL;
56+
} else {
57+
process.env.PGAI_UI_BASE_URL = originalUiUrl;
58+
}
59+
}
60+
});
61+
62+
test("resolveBaseUrls prefers CLI options over env vars", () => {
63+
const originalApiUrl = process.env.PGAI_API_BASE_URL;
64+
65+
try {
66+
process.env.PGAI_API_BASE_URL = "https://env.example.com/api/";
67+
68+
const result = util.resolveBaseUrls({
69+
apiBaseUrl: "https://cli-option.example.com/api/",
70+
});
71+
expect(result.apiBaseUrl).toBe("https://cli-option.example.com/api");
72+
} finally {
73+
if (originalApiUrl === undefined) {
74+
delete process.env.PGAI_API_BASE_URL;
75+
} else {
76+
process.env.PGAI_API_BASE_URL = originalApiUrl;
77+
}
78+
}
79+
});
80+
81+
test("resolveBaseUrls uses config baseUrl for API", () => {
82+
const result = util.resolveBaseUrls({}, { baseUrl: "https://config.example.com/api/" });
83+
expect(result.apiBaseUrl).toBe("https://config.example.com/api");
84+
// UI should still use default since config doesn't have uiBaseUrl
85+
expect(result.uiBaseUrl).toBe("https://console.postgres.ai");
86+
});
87+
88+
test("normalizeBaseUrl throws on invalid URL", () => {
89+
expect(() => util.normalizeBaseUrl("not-a-url")).toThrow(/Invalid base URL/);
90+
});
91+
92+
test("normalizeBaseUrl accepts valid URLs", () => {
93+
expect(util.normalizeBaseUrl("https://example.com")).toBe("https://example.com");
94+
expect(util.normalizeBaseUrl("https://example.com/")).toBe("https://example.com");
95+
expect(util.normalizeBaseUrl("https://example.com/api/")).toBe("https://example.com/api");
96+
});
97+
});
98+
99+
describe("PKCE module", () => {
100+
test("generateCodeVerifier returns correct length string", () => {
101+
const verifier = pkce.generateCodeVerifier();
102+
expect(typeof verifier).toBe("string");
103+
expect(verifier.length).toBeGreaterThanOrEqual(43);
104+
expect(verifier.length).toBeLessThanOrEqual(128);
105+
});
106+
107+
test("generateCodeChallenge returns base64url encoded SHA256", () => {
108+
const verifier = pkce.generateCodeVerifier();
109+
const challenge = pkce.generateCodeChallenge(verifier);
110+
expect(typeof challenge).toBe("string");
111+
expect(challenge.length).toBeGreaterThan(0);
112+
// Base64url encoding should not contain + or / characters
113+
expect(challenge).not.toMatch(/[+/]/);
114+
});
115+
116+
test("generateState returns random string", () => {
117+
const state1 = pkce.generateState();
118+
const state2 = pkce.generateState();
119+
expect(typeof state1).toBe("string");
120+
expect(state1.length).toBeGreaterThan(0);
121+
expect(state1).not.toBe(state2); // Should be random
122+
});
123+
124+
test("generatePKCEParams returns all required parameters", () => {
125+
const params = pkce.generatePKCEParams();
126+
expect(params.codeVerifier).toBeTruthy();
127+
expect(params.codeChallenge).toBeTruthy();
128+
expect(params.codeChallengeMethod).toBe("S256");
129+
expect(params.state).toBeTruthy();
130+
});
131+
});
132+
133+
describe("Auth callback server", () => {
134+
test("createCallbackServer returns correct interface", () => {
135+
const server = authServer.createCallbackServer(0, "test-state", 1000);
136+
expect(server.server).toBeTruthy();
137+
expect(server.server.stop).toBeInstanceOf(Function);
138+
expect(server.promise).toBeInstanceOf(Promise);
139+
expect(server.ready).toBeInstanceOf(Promise);
140+
expect(server.getPort).toBeInstanceOf(Function);
141+
142+
// Clean up
143+
server.server.stop();
144+
});
145+
146+
test("createCallbackServer binds to a port", async () => {
147+
const server = authServer.createCallbackServer(0, "test-state", 5000);
148+
const port = await server.ready;
149+
expect(typeof port).toBe("number");
150+
expect(port).toBeGreaterThan(0);
151+
152+
// Clean up
153+
server.server.stop();
154+
});
155+
156+
test("createCallbackServer responds to callback requests", async () => {
157+
const testState = "test-state-" + Math.random().toString(36).substring(7);
158+
const server = authServer.createCallbackServer(0, testState, 5000);
159+
const port = await server.ready;
160+
161+
// Simulate OAuth callback
162+
const testCode = "test-auth-code";
163+
const callbackUrl = `http://127.0.0.1:${port}/callback?code=${testCode}&state=${testState}`;
164+
165+
const fetchPromise = fetch(callbackUrl);
166+
const result = await server.promise;
167+
168+
expect(result.code).toBe(testCode);
169+
expect(result.state).toBe(testState);
170+
171+
// Check response
172+
const response = await fetchPromise;
173+
expect(response.status).toBe(200);
174+
const text = await response.text();
175+
expect(text).toMatch(/Authentication successful/);
176+
});
177+
178+
test("createCallbackServer rejects on state mismatch", async () => {
179+
const server = authServer.createCallbackServer(0, "expected-state", 5000);
180+
const port = await server.ready;
181+
182+
const callbackUrl = `http://127.0.0.1:${port}/callback?code=test-code&state=wrong-state`;
183+
184+
const fetchPromise = fetch(callbackUrl);
185+
186+
await expect(server.promise).rejects.toThrow(/State mismatch/);
187+
188+
const response = await fetchPromise;
189+
expect(response.status).toBe(400);
190+
});
191+
192+
test("createCallbackServer handles OAuth errors", async () => {
193+
const server = authServer.createCallbackServer(0, "test-state", 5000);
194+
const port = await server.ready;
195+
196+
const callbackUrl = `http://127.0.0.1:${port}/callback?error=access_denied&error_description=User%20denied%20access`;
197+
198+
const fetchPromise = fetch(callbackUrl);
199+
200+
await expect(server.promise).rejects.toThrow(/OAuth error: access_denied/);
201+
202+
const response = await fetchPromise;
203+
expect(response.status).toBe(400);
204+
});
205+
206+
test("createCallbackServer times out", async () => {
207+
const server = authServer.createCallbackServer(0, "test-state", 100); // 100ms timeout
208+
await server.ready;
209+
210+
await expect(server.promise).rejects.toThrow(/timeout/i);
211+
});
212+
});
213+
214+
describe("CLI auth commands", () => {
215+
test("cli: auth login --help shows all options", () => {
216+
const r = runCli(["auth", "login", "--help"]);
217+
expect(r.status).toBe(0);
218+
expect(r.stdout).toMatch(/--set-key/);
219+
expect(r.stdout).toMatch(/--debug/);
220+
});
221+
222+
test("cli: auth show-key --help works", () => {
223+
const r = runCli(["auth", "show-key", "--help"]);
224+
expect(r.status).toBe(0);
225+
expect(r.stdout).toMatch(/show.*key/i);
226+
});
227+
228+
test("cli: auth remove-key --help works", () => {
229+
const r = runCli(["auth", "remove-key", "--help"]);
230+
expect(r.status).toBe(0);
231+
expect(r.stdout).toMatch(/remove.*key/i);
232+
});
233+
});
234+
235+
describe("maskSecret utility", () => {
236+
test("masks short secrets completely", () => {
237+
expect(util.maskSecret("abc")).toBe("****");
238+
expect(util.maskSecret("12345678")).toBe("****");
239+
});
240+
241+
test("masks medium secrets with visible ends", () => {
242+
const masked = util.maskSecret("1234567890123456");
243+
// maskSecret shows first 4 chars, middle masked, last 4 chars for 16-char strings
244+
expect(masked).toMatch(/^1234\*+3456$/);
245+
});
246+
247+
test("masks long secrets appropriately", () => {
248+
const secret = "abcdefghij1234567890klmnopqrstuvwxyz";
249+
const masked = util.maskSecret(secret);
250+
expect(masked.startsWith("abcdefghij12")).toBe(true);
251+
expect(masked.endsWith("wxyz")).toBe(true);
252+
expect(masked).toMatch(/\*+/);
253+
});
254+
255+
test("handles empty string", () => {
256+
expect(util.maskSecret("")).toBe("");
257+
});
258+
});

0 commit comments

Comments
 (0)