diff --git a/.changeset/sequelize-keeps-its-ssl-options.md b/.changeset/sequelize-keeps-its-ssl-options.md new file mode 100644 index 0000000..677ad1c --- /dev/null +++ b/.changeset/sequelize-keeps-its-ssl-options.md @@ -0,0 +1,12 @@ +--- +'seamless-auth-api': patch +--- + +Keep the TLS options when `DATABASE_URL` carries `sslmode`. `resolveSslOptions` turns `DB_SSL`, +or an `sslmode` on the connection string, into Sequelize's `dialectOptions.ssl`, but Sequelize +then read the same `sslmode` for itself and let pg-connection-string's reading of it replace +those options. A URL carrying the parameter had its certificate verified whatever `DB_SSL` or +`DB_SSL_REJECT_UNAUTHORIZED` said, which against Amazon RDS is a boot failure, and `DB_SSL=false` +could not turn TLS off for it. Sequelize is now constructed with `sslmode` taken out of the URL, +once `resolveSslOptions` has read it. Nothing changes on the discrete `DB_*` path, whose URL +never carries the parameter. diff --git a/src/config/database.cjs b/src/config/database.cjs index 6b91317..105fa47 100644 --- a/src/config/database.cjs +++ b/src/config/database.cjs @@ -103,9 +103,29 @@ function resolveSslOptions(url) { return ca ? { ca, rejectUnauthorized } : { rejectUnauthorized }; } +// The connection string to construct Sequelize with. Sequelize reads the query of a +// connection string for itself and lets pg-connection-string's reading of `sslmode` +// replace the `ssl` it was given in `dialectOptions`, so with `sslmode` on the URL the +// options above would be thrown away and the certificate verified whatever `DB_SSL` or +// `DB_SSL_REJECT_UNAUTHORIZED` said. `resolveSslOptions` has read `sslmode` by then, so +// it comes out of the URL here. Migrations are not affected: config.cjs hands +// sequelize-cli discrete fields, never the string. +function withoutSslMode(url) { + const query = url.indexOf('?'); + if (query === -1) return url; + + const params = new URLSearchParams(url.slice(query + 1)); + if (!params.has('sslmode')) return url; + + params.delete('sslmode'); + const rest = params.toString(); + return rest ? `${url.slice(0, query)}?${rest}` : url.slice(0, query); +} + module.exports = { buildDatabaseUrl, parseDatabaseUrl, resolveDatabaseUrl, resolveSslOptions, + withoutSslMode, }; diff --git a/src/config/database.d.cts b/src/config/database.d.cts index f62bf2f..3f67f83 100644 --- a/src/config/database.d.cts +++ b/src/config/database.d.cts @@ -21,3 +21,4 @@ export declare function buildDatabaseUrl(): string; export declare function parseDatabaseUrl(url: string): DatabaseConnection | null; export declare function resolveDatabaseUrl(): string | null; export declare function resolveSslOptions(url: string | null): DatabaseSslOptions | null; +export declare function withoutSslMode(url: string): string; diff --git a/src/models/index.ts b/src/models/index.ts index 3cd735f..c6003fe 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -9,7 +9,7 @@ import path from 'path'; import { Sequelize } from 'sequelize'; import { fileURLToPath } from 'url'; -import { buildDatabaseUrl, resolveSslOptions } from '../config/database.cjs'; +import { buildDatabaseUrl, resolveSslOptions, withoutSslMode } from '../config/database.cjs'; import getLogger from '../utils/logger.js'; const logger = getLogger('sequelize'); @@ -42,7 +42,7 @@ export function getSequelize(): Sequelize { logger.info(`Using Postgres database (TLS ${ssl ? 'enabled' : 'disabled'})`); - sequelizeInstance = new Sequelize(DATABASE_URL, { + sequelizeInstance = new Sequelize(withoutSslMode(DATABASE_URL), { logging: enableDbLogging ? (msg) => logger.debug(msg) : false, ...(ssl ? { dialectOptions: { ssl } } : {}), }); diff --git a/tests/unit/config/database.spec.ts b/tests/unit/config/database.spec.ts index 79b5bbb..cfc3f32 100644 --- a/tests/unit/config/database.spec.ts +++ b/tests/unit/config/database.spec.ts @@ -6,6 +6,7 @@ import { parseDatabaseUrl, resolveDatabaseUrl, resolveSslOptions, + withoutSslMode, } from '../../../src/config/database.cjs'; const DB_VARS = [ @@ -193,3 +194,25 @@ describe('sequelize-cli config', () => { }); }); }); + +describe('withoutSslMode', () => { + it('takes sslmode out and leaves the credentials and other parameters as they were', () => { + expect( + withoutSslMode( + 'postgres://auth:p%40ss@db.internal:5432/auth_db?sslmode=require&application_name=auth', + ), + ).toBe('postgres://auth:p%40ss@db.internal:5432/auth_db?application_name=auth'); + }); + + it('leaves no trailing query behind when sslmode was the only parameter', () => { + expect(withoutSslMode('postgres://auth@db.internal:5432/auth_db?sslmode=require')).toBe( + 'postgres://auth@db.internal:5432/auth_db', + ); + }); + + it('returns a string without sslmode untouched', () => { + expect(withoutSslMode('postgres://auth@db.internal:5432/auth_db')).toBe( + 'postgres://auth@db.internal:5432/auth_db', + ); + }); +}); diff --git a/tests/unit/models/sequelize.spec.ts b/tests/unit/models/sequelize.spec.ts index d0f5ccd..858fd7c 100644 --- a/tests/unit/models/sequelize.spec.ts +++ b/tests/unit/models/sequelize.spec.ts @@ -85,15 +85,17 @@ describe('getSequelize database URL building', () => { ); }); - it('uses DB_URI when DATABASE_URL is unset', async () => { + it('uses DB_URI when DATABASE_URL is unset, with its sslmode read and then taken out', async () => { process.env.DB_URI = 'postgres://user:pass@example.com:5432/auth_db?sslmode=verify-full'; const { getSequelize } = await import('../../../src/models/index.js'); getSequelize(); + // Sequelize would read the sslmode itself and overwrite dialectOptions.ssl with + // pg-connection-string's version, so the URL it gets no longer carries it. expect(sequelizeConstructor).toHaveBeenCalledWith( - process.env.DB_URI, + 'postgres://user:pass@example.com:5432/auth_db', expect.objectContaining({ dialectOptions: { ssl: { rejectUnauthorized: true } }, }),