Skip to content

Commit 476a717

Browse files
deinspanjerDaniel E.
andauthored
fix: authenticate PostgreSQL IAM connections on demand (#421)
* fix: authenticate PostgreSQL IAM connections on demand Resolve IAM passwords when opening a connection so failed login attempts remain retryable. - Keep pools available after idle disconnects and share concurrent authentication. - Preserve AWS provider selection and reread refreshed credential files. - Cover recovery with real pg and isolated AWS SDK checks. * fix: correct PostgreSQL SSL configuration type - Use the pool SSL type instead of the nonexistent pg.ConnectionOptions export. --------- Co-authored-by: Daniel E. <daniel.e@teachingstrategies.com>
1 parent af6abe2 commit 476a717

8 files changed

Lines changed: 315 additions & 18 deletions

File tree

‎docs/config/toml.mdx‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,34 @@ Sources define database connections. Each source represents a database that DBHu
255255
another AWS identity. When `aws_profile` is omitted, the AWS SDK default
256256
credential chain is used.
257257

258+
PostgreSQL generates a token when the driver opens a physical connection.
259+
Existing pooled connections stay open; DBHub does not refresh PostgreSQL IAM
260+
credentials on a timer. If a server closes an idle session, the next request
261+
opens a replacement using a fresh token. Sources can have different server
262+
idle timeouts without configuring a matching refresh interval in DBHub.
263+
264+
Credential renewal follows your AWS configuration. A `credential_process`
265+
helper can renew credentials silently or perform its own browser login.
266+
With AWS SSO, complete `aws sso login --profile <profile>` when the SDK
267+
reports that login is required, then retry the request. A failed attempt
268+
remains retryable; DBHub does not automatically replay SQL or repeatedly
269+
retry authentication in the background. Concurrent PostgreSQL connections
270+
for one source share an in-progress token request.
271+
272+
Use `lazy = true` to defer the initial connection and authentication until
273+
the first request. Otherwise, the initial connection is checked at startup.
274+
`connection_timeout` includes time spent waiting for the password callback;
275+
a credential helper's browser login may outlast it. Completing login and
276+
retrying is supported, but a socket timeout does not cancel the helper.
277+
278+
Replaced shared-credential-file values are read on the next authentication
279+
attempt. Changes to environment variables in another shell cannot update a
280+
running DBHub process; use a refreshable credential provider for temporary
281+
credentials. Direct assume-role MFA prompts require a credential helper;
282+
DBHub does not supply an interactive MFA callback.
283+
284+
MySQL and MariaDB continue to use periodic IAM pool refresh.
285+
258286
```toml
259287
[[sources]]
260288
id = "production"

‎src/connectors/__tests__/manager.test.ts‎

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import { ConnectorManager } from "../manager.js";
3-
import { ConnectorRegistry } from "../interface.js";
3+
import { ConnectorRegistry, type Connector, type ConnectorConfig } from "../interface.js";
44
import { SSHTunnel } from "../../utils/ssh-tunnel.js";
55
import type { SourceConfig } from "../../types/config.js";
66
import { homedir } from "os";
@@ -278,7 +278,7 @@ describe("ConnectorManager IAM DSN rewrite", () => {
278278
dsn: "mysql://dbuser%40example.com:ignored@mydb.abc123.eu-west-1.rds.amazonaws.com:3306/mydb?connectTimeout=5000&sslmode=disable",
279279
};
280280

281-
const dsn = await (manager as any).buildConnectionDSN(source);
281+
const dsn = await (manager as any).buildConnectionDSN(source, {});
282282

283283
expect(mocks.generateRdsAuthToken).toHaveBeenCalledWith({
284284
hostname: "mydb.abc123.eu-west-1.rds.amazonaws.com",
@@ -481,3 +481,113 @@ describe("ConnectorManager IAM refresh recovery", () => {
481481
);
482482
});
483483
});
484+
485+
describe("PostgreSQL IAM authentication on demand", () => {
486+
const source: SourceConfig = {
487+
id: "production", type: "postgres", host: "db.example.com", port: 5432,
488+
database: "db", user: "db_user", aws_iam_auth: true,
489+
aws_region: "us-east-1", aws_profile: "production", lazy: true,
490+
};
491+
let manager: ConnectorManager;
492+
let config: ConnectorConfig;
493+
let dsn: string;
494+
const disconnect = vi.fn();
495+
const connect = vi.fn();
496+
497+
beforeEach(() => {
498+
vi.useFakeTimers();
499+
vi.clearAllMocks();
500+
mocks.generateRdsAuthToken.mockReset().mockResolvedValue("token");
501+
connect.mockReset().mockImplementation(async (value: string, _init: string, options: ConnectorConfig) => {
502+
dsn = value;
503+
config = options;
504+
await options.password?.();
505+
});
506+
manager = new ConnectorManager();
507+
vi.spyOn(console, "error").mockImplementation(() => {});
508+
vi.spyOn(ConnectorRegistry, "getConnectorForDSN").mockReturnValue({
509+
clone: () => ({
510+
id: "postgres", disconnect, connect,
511+
}),
512+
} as unknown as Connector);
513+
});
514+
515+
afterEach(async () => {
516+
await manager.disconnect();
517+
vi.useRealTimers();
518+
vi.restoreAllMocks();
519+
});
520+
521+
it("authenticates new connections, never refreshes an idle source on a timer", async () => {
522+
await manager.connectWithSources([source]);
523+
expect(mocks.generateRdsAuthToken).not.toHaveBeenCalled();
524+
await manager.ensureConnected(source.id);
525+
expect(config.password).toBeTypeOf("function");
526+
expect(dsn).not.toContain("token");
527+
expect(dsn).toContain("sslmode=require");
528+
expect(mocks.generateRdsAuthToken).toHaveBeenCalledTimes(1);
529+
530+
await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000);
531+
expect(disconnect).not.toHaveBeenCalled();
532+
expect(mocks.generateRdsAuthToken).toHaveBeenCalledTimes(1);
533+
534+
// A pool opening several sockets shares only the in-flight token request.
535+
const password = config.password!;
536+
await Promise.all(Array.from({ length: 8 }, () => password()));
537+
expect(mocks.generateRdsAuthToken).toHaveBeenCalledTimes(2);
538+
mocks.generateRdsAuthToken.mockRejectedValueOnce(new Error("SSO session expired"));
539+
await expect(password()).rejects.toThrow("SSO session expired");
540+
await expect(password()).resolves.toBe("token");
541+
expect(manager.getConnector(source.id)).toBeDefined();
542+
expect(mocks.generateRdsAuthToken).toHaveBeenCalledTimes(4);
543+
});
544+
545+
it("retries failed initial authentication only when another request arrives", async () => {
546+
mocks.generateRdsAuthToken.mockRejectedValueOnce(new Error("SSO session expired"));
547+
await manager.connectWithSources([source]);
548+
await expect(manager.ensureConnected(source.id)).rejects.toThrow("SSO session expired");
549+
await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000);
550+
expect(mocks.generateRdsAuthToken).toHaveBeenCalledTimes(1);
551+
await Promise.all(Array.from({ length: 8 }, () => manager.ensureConnected(source.id)));
552+
expect(mocks.generateRdsAuthToken).toHaveBeenCalledTimes(2);
553+
expect(manager.getSourceIds()).toEqual([source.id]);
554+
expect(manager.getConnector(source.id)).toBeDefined();
555+
});
556+
557+
it("signs the original endpoint through SSH and cleans up a failed authentication", async () => {
558+
mocks.looksLikeSSHAlias.mockReturnValue(false);
559+
vi.spyOn(SSHTunnel.prototype, "establish").mockResolvedValue({
560+
localPort: 15432, localHost: "127.0.0.1",
561+
} as any);
562+
const close = vi.spyOn(SSHTunnel.prototype, "close").mockResolvedValue();
563+
mocks.generateRdsAuthToken.mockRejectedValueOnce(new Error("SSO session expired"));
564+
await manager.connectWithSources([{ ...source, sslmode: "verify-full",
565+
ssh_host: "bastion.example.com", ssh_user: "user", ssh_key: "/fake/key" }]);
566+
await expect(manager.ensureConnected(source.id)).rejects.toThrow("SSO session expired");
567+
expect(close).toHaveBeenCalledTimes(1);
568+
await manager.ensureConnected(source.id);
569+
expect(dsn).toContain("127.0.0.1:15432");
570+
expect(dsn).toContain("sslmode=verify-full");
571+
expect(mocks.generateRdsAuthToken).toHaveBeenLastCalledWith({
572+
hostname: "db.example.com", port: 5432, username: "db_user",
573+
region: "us-east-1", profile: "production",
574+
});
575+
});
576+
577+
it("shares a still-running credential helper after an initial socket timeout", async () => {
578+
let finishLogin!: (token: string) => void;
579+
mocks.generateRdsAuthToken.mockReturnValueOnce(new Promise<string>(resolve => { finishLogin = resolve; }));
580+
connect.mockImplementationOnce(async (_dsn, _init, options: ConnectorConfig) => {
581+
void options.password!().catch(() => {});
582+
throw new Error("Connection timeout");
583+
});
584+
await manager.connectWithSources([source]);
585+
await expect(manager.ensureConnected(source.id)).rejects.toThrow("Connection timeout");
586+
const retry = manager.ensureConnected(source.id);
587+
await vi.waitFor(() => expect(connect).toHaveBeenCalledTimes(2));
588+
expect(mocks.generateRdsAuthToken).toHaveBeenCalledTimes(1);
589+
finishLogin("token");
590+
await retry;
591+
expect(manager.getConnector(source.id)).toBeDefined();
592+
});
593+
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { createServer, type Socket } from "node:net";
2+
import { once } from "node:events";
3+
import { afterEach, expect, it, vi } from "vitest";
4+
import { PostgresConnector } from "../postgres/index.js";
5+
6+
// A loopback PostgreSQL protocol peer: real pg sockets and password handling,
7+
// no AWS access, real database, or SQL execution. Only auth/empty query responses.
8+
function message(type: string, body: Buffer) {
9+
const length = Buffer.alloc(4);
10+
length.writeInt32BE(body.length + 4);
11+
return Buffer.concat([Buffer.from(type), length, body]);
12+
}
13+
const ready = message("Z", Buffer.from("I"));
14+
const sockets = new Set<Socket>();
15+
const peers = new Map<string, Socket>();
16+
const connectors: PostgresConnector[] = [];
17+
const server = createServer(socket => {
18+
sockets.add(socket);
19+
socket.on("close", () => sockets.delete(socket));
20+
let startup = true;
21+
let buffer: Buffer = Buffer.alloc(0);
22+
socket.on("data", data => {
23+
buffer = Buffer.concat([buffer, data]);
24+
while (buffer.length >= (startup ? 4 : 5)) {
25+
const length = buffer.readInt32BE(startup ? 0 : 1) + (startup ? 0 : 1);
26+
if (buffer.length < length) return;
27+
const packet = buffer.subarray(0, length);
28+
buffer = buffer.subarray(length);
29+
if (startup) {
30+
const params = packet.subarray(8).toString().split("\0");
31+
peers.set(params[params.indexOf("database") + 1], socket);
32+
startup = false;
33+
socket.write(message("R", Buffer.from([0, 0, 0, 3]))); // cleartext password request
34+
} else if (packet[0] === 112) { // password
35+
socket.write(Buffer.concat([message("R", Buffer.alloc(4)), ready]));
36+
} else if (packet[0] === 81) { // simple query
37+
socket.write(Buffer.concat([message("C", Buffer.from("SELECT 0\0")), ready]));
38+
} else if (packet[0] === 88) { // terminate
39+
socket.end();
40+
}
41+
}
42+
});
43+
});
44+
45+
afterEach(async () => {
46+
await Promise.all(connectors.splice(0).map(connector => connector.disconnect()));
47+
for (const socket of sockets) socket.destroy();
48+
peers.clear();
49+
if (server.listening) await new Promise<void>(resolve => server.close(() => resolve()));
50+
vi.restoreAllMocks();
51+
});
52+
53+
it("recovers on demand after idle session expiry and failed auth, independently per source", async () => {
54+
vi.spyOn(console, "error").mockImplementation(() => {});
55+
server.listen(0, "127.0.0.1");
56+
await once(server, "listening");
57+
const port = (server.address() as { port: number }).port;
58+
const passwords = [vi.fn().mockResolvedValue("short-token"), vi.fn().mockResolvedValue("long-token")];
59+
for (const [i, name] of ["short", "long"].entries()) {
60+
const connector = new PostgresConnector();
61+
connectors.push(connector);
62+
await connector.connect(`postgres://user@127.0.0.1:${port}/${name}?sslmode=disable`, undefined, {
63+
password: passwords[i], connectionTimeoutSeconds: i + 1,
64+
});
65+
}
66+
expect(passwords.map(password => password.mock.calls.length)).toEqual([1, 1]);
67+
68+
// Server-side idle expiry is independent of IAM expiry. pg must remove this
69+
// idle client without an unhandled pool error or disturbing the other source.
70+
peers.get("short")!.end(message("E", Buffer.from("SFATAL\0C57P05\0Mterminating connection due to idle-session timeout\0\0")));
71+
await vi.waitFor(() => expect(console.error).toHaveBeenCalledWith(
72+
expect.any(String), expect.stringContaining("idle-session timeout")));
73+
expect(passwords.map(password => password.mock.calls.length)).toEqual([1, 1]);
74+
75+
passwords[0].mockRejectedValueOnce(new Error("SSO session expired"));
76+
await expect(connectors[0].getSchemas()).rejects.toThrow("SSO session expired");
77+
await expect(connectors[0].getSchemas()).resolves.toEqual([]);
78+
await expect(connectors[1].getSchemas()).resolves.toEqual([]);
79+
expect(passwords.map(password => password.mock.calls.length)).toEqual([3, 1]);
80+
});

‎src/connectors/interface.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ export interface ExecuteOptions {
138138
* Different databases may use different subset of these options
139139
*/
140140
export interface ConnectorConfig {
141+
/** Password resolved for each new physical connection (PostgreSQL). */
142+
password?: () => Promise<string>;
141143
/** Connection timeout in seconds (PostgreSQL, MySQL, MariaDB, SQL Server) */
142144
connectionTimeoutSeconds?: number;
143145
/** Query timeout in seconds (PostgreSQL, MySQL, MariaDB, SQL Server) */

‎src/connectors/manager.ts‎

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ export class ConnectorManager {
3030
// Lazy connection support
3131
private lazySources: Map<string, SourceConfig> = new Map(); // Sources pending lazy connection
3232
private pendingConnections: Map<string, Promise<void>> = new Map(); // Prevent race conditions
33+
// A socket timeout does not cancel a credential helper. Share its in-flight
34+
// attempt across retries of the same config, but never across changed profiles.
35+
private pendingIamTokens = new WeakMap<SourceConfig, Promise<string>>();
3336

3437
constructor() {
3538
if (!managerInstance) {
@@ -142,8 +145,9 @@ export class ConnectorManager {
142145
*/
143146
private async connectSource(source: SourceConfig): Promise<void> {
144147
const sourceId = source.id;
148+
const config: ConnectorConfig = {};
145149
// Build DSN from source config
146-
const dsn = await this.buildConnectionDSN(source);
150+
const dsn = await this.buildConnectionDSN(source, config);
147151
console.error(` - ${sourceId}: ${redactDSN(dsn)}`);
148152

149153
// Setup SSH tunnel if needed
@@ -256,7 +260,6 @@ export class ConnectorManager {
256260
(connector as any).sourceId = sourceId;
257261

258262
// Build config for database-specific options
259-
const config: ConnectorConfig = {};
260263
if (source.connection_timeout !== undefined) {
261264
config.connectionTimeoutSeconds = source.connection_timeout;
262265
}
@@ -318,7 +321,7 @@ export class ConnectorManager {
318321
// Store source config (for API exposure)
319322
this.sourceConfigs.set(sourceId, source);
320323

321-
// Keep AWS IAM auth sources fresh by rotating pool credentials before token expiry.
324+
// MySQL/MariaDB still rotate pools; PostgreSQL authenticates on demand.
322325
this.scheduleIamRefresh(source);
323326
}
324327

@@ -497,7 +500,7 @@ export class ConnectorManager {
497500
clearTimeout(existingTimer);
498501
this.iamRefreshTimers.delete(sourceId);
499502
}
500-
if (!source.aws_iam_auth) {
503+
if (!source.aws_iam_auth || source.type === "postgres") {
501504
return;
502505
}
503506

@@ -566,7 +569,7 @@ export class ConnectorManager {
566569
* Build a connection DSN, optionally replacing password with
567570
* an AWS RDS IAM auth token when aws_iam_auth is enabled.
568571
*/
569-
private async buildConnectionDSN(source: SourceConfig): Promise<string> {
572+
private async buildConnectionDSN(source: SourceConfig, config: ConnectorConfig = {}): Promise<string> {
570573
const dsn = buildDSNFromSource(source);
571574

572575
if (!source.aws_iam_auth) {
@@ -597,13 +600,22 @@ export class ConnectorManager {
597600
);
598601
}
599602

600-
const token = await generateRdsAuthToken({
601-
hostname,
602-
port,
603-
username,
604-
region: source.aws_region,
605-
profile: source.aws_profile,
606-
});
603+
// Share concurrent authentication attempts, never retain a failed promise or
604+
// cache a token's lifetime ourselves. The AWS provider owns credential refresh.
605+
const password = () => {
606+
let pending = this.pendingIamTokens.get(source);
607+
if (!pending) {
608+
pending = generateRdsAuthToken({
609+
hostname, port, username, region: source.aws_region!, profile: source.aws_profile,
610+
}).finally(() => { this.pendingIamTokens.delete(source); });
611+
this.pendingIamTokens.set(source, pending);
612+
}
613+
return pending;
614+
};
615+
if (source.type === "postgres") {
616+
config.password = password;
617+
}
618+
const token = source.type === "postgres" ? "" : await password();
607619

608620
const queryParams = new Map(parsed.searchParams);
609621
const currentSslMode = queryParams.get("sslmode");

‎src/connectors/postgres/index.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ class PostgresDSNParser implements DSNParser {
105105
port: url.port ? parseInt(url.port) : 5432,
106106
database: url.pathname ? url.pathname.substring(1) : '', // Remove leading '/' if exists
107107
user: url.username,
108-
password: url.password,
108+
password: config?.password ?? url.password,
109109
};
110110

111111
let sslmode: string | undefined;
@@ -168,7 +168,7 @@ class PostgresDSNParser implements DSNParser {
168168
} else if (sslmode === "require") {
169169
poolConfig.ssl = { rejectUnauthorized: false };
170170
} else if (sslmode === "verify-ca" || sslmode === "verify-full") {
171-
const sslConfig: pg.ConnectionOptions["ssl"] & object = { rejectUnauthorized: true };
171+
const sslConfig: pg.PoolConfig["ssl"] & object = { rejectUnauthorized: true };
172172
// verify-ca checks the certificate chain but does not verify the server hostname,
173173
// matching libpq behavior. verify-full (the default with rejectUnauthorized: true)
174174
// verifies both the certificate chain and the hostname.

0 commit comments

Comments
 (0)