feat(backend-defaults): improve AWS RDS IAM auth error handling and token expiry

Signed-off-by: Roland Fuszenecker <roland.fuszenecker@seon.io>
This commit is contained in:
Roland Fuszenecker
2026-04-02 15:29:38 +02:00
parent c69e03ce3d
commit 21e7ec5d74
3 changed files with 66 additions and 33 deletions
+2 -2
View File
@@ -644,9 +644,9 @@ export interface Config {
*/
host: string;
/**
* The port number the database is listening on. Defaults to 5432.
* The port number the database is listening on.
*/
port?: number;
port: number;
/**
* The database user to authenticate as. This user must have the `rds_iam` role granted.
*/
@@ -649,6 +649,7 @@ describe('postgres', () => {
connection: {
type: 'rds',
host: 'mydb.cluster.eu-west-1.rds.amazonaws.com',
port: 5432,
user: 'postgres',
region: 'eu-west-1',
},
@@ -662,28 +663,47 @@ describe('postgres', () => {
expect(conn2.password).toBe('token-2');
});
it('defaults port to 5432 for rds connections', async () => {
it('returns an expirationChecker that reflects the token TTL', async () => {
const { Signer } = jest.requireMock('@aws-sdk/rds-signer') as jest.Mocked<
typeof import('@aws-sdk/rds-signer')
>;
Signer.prototype.getAuthToken.mockResolvedValue('mock-iam-token');
await buildPgDatabaseConfig(
const configResult = await buildPgDatabaseConfig(
new ConfigReader({
client: 'pg',
connection: {
type: 'rds',
host: 'mydb.cluster.eu-west-1.rds.amazonaws.com',
port: 5432,
user: 'postgres',
region: 'eu-west-1',
},
}),
);
expect(Signer).toHaveBeenCalledWith(
expect.objectContaining({ port: 5432 }),
);
const conn = await (configResult.connection as () => Promise<any>)();
expect(conn.expirationChecker).toBeInstanceOf(Function);
// Token was just issued, so it should not yet be considered expired.
expect(conn.expirationChecker()).toBe(false);
});
it('throws when port is missing for rds connection', async () => {
await expect(
buildPgDatabaseConfig(
new ConfigReader({
client: 'pg',
connection: {
type: 'rds',
host: 'mydb.cluster.eu-west-1.rds.amazonaws.com',
user: 'postgres',
region: 'eu-west-1',
},
}),
),
).rejects.toThrow(/connection\.port/);
});
it('falls back to AWS_REGION env var when region is not set in config', async () => {
@@ -703,6 +723,7 @@ describe('postgres', () => {
connection: {
type: 'rds',
host: 'mydb.cluster.us-east-1.rds.amazonaws.com',
port: 5432,
user: 'postgres',
},
}),
@@ -727,14 +748,13 @@ describe('postgres', () => {
client: 'pg',
connection: {
type: 'rds',
port: 5432,
user: 'postgres',
region: 'eu-west-1',
},
}),
),
).rejects.toThrow(
/Missing host in connection config for AWS RDS IAM auth/,
);
).rejects.toThrow(/connection\.host/);
});
it('throws when user is missing for rds connection', async () => {
@@ -745,13 +765,12 @@ describe('postgres', () => {
connection: {
type: 'rds',
host: 'mydb.cluster.eu-west-1.rds.amazonaws.com',
port: 5432,
region: 'eu-west-1',
},
}),
),
).rejects.toThrow(
/Missing user in connection config for AWS RDS IAM auth/,
);
).rejects.toThrow(/connection\.user/);
});
it('throws when region is missing and no env var is set for rds connection', async () => {
@@ -768,6 +787,7 @@ describe('postgres', () => {
connection: {
type: 'rds',
host: 'mydb.cluster.eu-west-1.rds.amazonaws.com',
port: 5432,
user: 'postgres',
},
}),
@@ -277,24 +277,21 @@ export async function buildRdsPgConfig(config: Config): Promise<Knex.Config> {
const { Signer } =
require('@aws-sdk/rds-signer') as typeof import('@aws-sdk/rds-signer');
const rawConfig = config.get() as Record<string, unknown>;
const normalized = normalizeConnection(rawConfig.connection as any);
const sanitizedConnection = omit(normalized, [
'type',
'region',
]) as Partial<Knex.StaticConnectionConfig>;
const hostname = (normalized as any).host as string;
if (!hostname) {
throw new Error('Missing host in connection config for AWS RDS IAM auth');
}
const port = ((normalized as any).port as number | undefined) ?? 5432;
const username = (normalized as any).user as string;
if (!username) {
throw new Error('Missing user in connection config for AWS RDS IAM auth');
let hostname: string;
let port: number;
let username: string;
try {
hostname = config.getString('connection.host');
port = config.getNumber('connection.port');
username = config.getString('connection.user');
} catch (err) {
throw new ForwardedError(
'AWS RDS IAM auth: missing required database connection config — make sure connection.host, connection.port, and connection.user are set and any environment variables they reference are set',
err,
);
}
const region =
((normalized as any).region as string | undefined) ??
config.getOptionalString('connection.region') ??
process.env.AWS_REGION ??
process.env.AWS_DEFAULT_REGION;
if (!region) {
@@ -303,17 +300,33 @@ export async function buildRdsPgConfig(config: Config): Promise<Knex.Config> {
);
}
const rawConfig = config.get() as Record<string, unknown>;
const sanitizedConnection = omit(
config.get('connection') as Record<string, unknown>,
['type', 'region'],
) as Partial<Knex.StaticConnectionConfig>;
const signer = new Signer({ hostname, port, username, region });
// RDS IAM auth tokens are valid for 15 minutes. Renew 1 minute early so
// that pooled connections are refreshed before the token actually expires.
const tokenTtlMs = 15 * 60 * 1000;
const renewalOffsetMs = 60 * 1000;
async function getConnectionConfig() {
try {
const password = await signer.getAuthToken();
return { ...sanitizedConnection, password };
const tokenExpiration = Date.now() + tokenTtlMs - renewalOffsetMs;
return {
...sanitizedConnection,
password,
expirationChecker: () => tokenExpiration <= Date.now(),
};
} catch (err) {
console.error(
`AWS RDS IAM auth token acquisition failed for ${username}@${hostname}:${port}: ${err}`,
throw new ForwardedError(
`AWS RDS IAM auth token acquisition failed for ${username}@${hostname}:${port}`,
err,
);
throw err;
}
}