From b19703e82b627d0a0b6d8cc29ec796747c608a46 Mon Sep 17 00:00:00 2001 From: Georg Traar Date: Fri, 19 Jun 2026 22:38:58 +0200 Subject: [PATCH] Add OAUTHBEARER SASL support Add an oauthBearerToken client option for PostgreSQL OAUTHBEARER authentication, including token callback handling and SASL response serialization. Keep bearer tokens non-enumerable in client, connection parameters, and pool options, and document the new pure-JS client support. Add focused unit coverage for OAuth SASL mechanism selection, callback error paths, credential redaction, and SCRAM compatibility. --- docs/pages/apis/client.mdx | 1 + docs/pages/features/connecting.mdx | 20 ++ packages/pg-pool/index.js | 10 + .../src/outbound-serializer.test.ts | 5 + packages/pg-protocol/src/serializer.ts | 5 +- packages/pg/lib/client.js | 73 ++++++- packages/pg/lib/connection-parameters.js | 10 + packages/pg/lib/connection.js | 6 +- packages/pg/lib/crypto/sasl.js | 59 +++++- .../unit/client/credential-redaction-tests.js | 33 +++ .../pg/test/unit/client/oauth-sasl-tests.js | 193 ++++++++++++++++++ .../connection-pool/configuration-tests.js | 10 + packages/pg/test/unit/test-helper.js | 20 +- 13 files changed, 428 insertions(+), 17 deletions(-) create mode 100644 packages/pg/test/unit/client/credential-redaction-tests.js create mode 100644 packages/pg/test/unit/client/oauth-sasl-tests.js diff --git a/docs/pages/apis/client.mdx b/docs/pages/apis/client.mdx index ecfd67fca..911a7f85b 100644 --- a/docs/pages/apis/client.mdx +++ b/docs/pages/apis/client.mdx @@ -12,6 +12,7 @@ Every field of the `config` object is entirely optional. A `Client` instance wil type Config = { user?: string, // default process.env.PGUSER || process.env.USER password?: string or function, //default process.env.PGPASSWORD + oauthBearerToken?: string or function, // bearer token or callback returning a bearer token for OAUTHBEARER authentication host?: string, // default process.env.PGHOST port?: number, // default process.env.PGPORT database?: string, // default process.env.PGDATABASE || user diff --git a/docs/pages/features/connecting.mdx b/docs/pages/features/connecting.mdx index 97b5c779f..583b4cd89 100644 --- a/docs/pages/features/connecting.mdx +++ b/docs/pages/features/connecting.mdx @@ -114,6 +114,26 @@ const pool = new Pool({ }) ``` +PostgreSQL servers configured for OAuth can authenticate with a bearer token using the SASL `OAUTHBEARER` mechanism. +Pass the token directly, or provide a synchronous or asynchronous callback that resolves to a token string. +When `oauthBearerToken` is set, `OAUTHBEARER` takes precedence over `SCRAM-SHA-256-PLUS` even when TLS channel binding is available. +`pg` does not perform the OAuth authorization flow; your application is responsible for fetching and caching tokens from your OAuth provider. +Using a callback is recommended for short-lived tokens, as it is invoked for each new connection with the connection parameters and must return a non-empty token string. + +```js +import pg from 'pg' +const { Pool } = pg + +const pool = new Pool({ + user: 'api-user', + host: 'database.server.com', + database: 'my-db', + oauthBearerToken: async () => { + return getAccessToken() + }, +}) +``` + ### Unix Domain Sockets Connections to unix sockets can also be made. This can be useful on distros like Ubuntu, where authentication is managed via the socket connection instead of a password. diff --git a/packages/pg-pool/index.js b/packages/pg-pool/index.js index ab514fa88..cf16b2fbb 100644 --- a/packages/pg-pool/index.js +++ b/packages/pg-pool/index.js @@ -78,6 +78,16 @@ class Pool extends EventEmitter { value: options.password, }) } + if (options != null && 'oauthBearerToken' in options) { + // "hiding" the OAuth bearer token so it doesn't show up in stack traces + // or if the pool is console.logged + Object.defineProperty(this.options, 'oauthBearerToken', { + configurable: true, + enumerable: false, + writable: true, + value: options.oauthBearerToken, + }) + } if (options != null && options.ssl && options.ssl.key) { // "hiding" the ssl->key so it doesn't show up in stack traces // or if the client is console.logged diff --git a/packages/pg-protocol/src/outbound-serializer.test.ts b/packages/pg-protocol/src/outbound-serializer.test.ts index aac0c57ba..969cab5c5 100644 --- a/packages/pg-protocol/src/outbound-serializer.test.ts +++ b/packages/pg-protocol/src/outbound-serializer.test.ts @@ -45,6 +45,11 @@ describe('serializer', () => { assert.deepEqual(actual, new BufferList().addString('data').join(true, 'p')) }) + it('builds SASLResponseMessage message', function () { + const actual = serialize.sendSASLResponseMessage('data') + assert.deepEqual(actual, new BufferList().addString('data').join(true, 'p')) + }) + it('builds query message', function () { const txt = 'select * from boom' const actual = serialize.query(txt) diff --git a/packages/pg-protocol/src/serializer.ts b/packages/pg-protocol/src/serializer.ts index 547c053f8..65fefcaa8 100644 --- a/packages/pg-protocol/src/serializer.ts +++ b/packages/pg-protocol/src/serializer.ts @@ -53,10 +53,12 @@ const sendSASLInitialResponseMessage = function (mechanism: string, initialRespo return writer.flush(code.startup) } -const sendSCRAMClientFinalMessage = function (additionalData: string): Buffer { +const sendSASLResponseMessage = function (additionalData: string): Buffer { return writer.addString(additionalData).flush(code.startup) } +const sendSCRAMClientFinalMessage = sendSASLResponseMessage + const query = (text: string): Buffer => { return writer.addCString(text).flush(code.query) } @@ -261,6 +263,7 @@ const serialize = { password, requestSsl, sendSASLInitialResponseMessage, + sendSASLResponseMessage, sendSCRAMClientFinalMessage, query, parse, diff --git a/packages/pg/lib/client.js b/packages/pg/lib/client.js index 18280f3c6..b73fdb39e 100644 --- a/packages/pg/lib/client.js +++ b/packages/pg/lib/client.js @@ -65,6 +65,12 @@ class Client extends EventEmitter { writable: true, value: this.connectionParameters.password, }) + Object.defineProperty(this, 'oauthBearerToken', { + configurable: true, + enumerable: false, + writable: true, + value: this.connectionParameters.oauthBearerToken, + }) this.replication = this.connectionParameters.replication @@ -305,6 +311,33 @@ class Client extends EventEmitter { } } + _getOAuthBearerToken(cb) { + const con = this.connection + if (typeof this.oauthBearerToken === 'function') { + let tokenResult + try { + tokenResult = this.oauthBearerToken(this.connectionParameters) + } catch (err) { + process.nextTick(() => con.emit('error', err)) + return + } + this._Promise.resolve(tokenResult).then( + (token) => { + if (typeof token !== 'string') { + con.emit('error', new TypeError('OAuth bearer token must be a string')) + return + } + cb(token) + }, + (err) => { + con.emit('error', err) + } + ) + } else { + cb(this.oauthBearerToken) + } + } + _handleAuthCleartextPassword(msg) { this._getPassword(() => { this.connection.password(this.password) @@ -323,18 +356,35 @@ class Client extends EventEmitter { } _handleAuthSASL(msg) { - this._getPassword(() => { + const hasOAuth = msg.mechanisms.includes('OAUTHBEARER') + const hasScram = msg.mechanisms.includes('SCRAM-SHA-256') || msg.mechanisms.includes('SCRAM-SHA-256-PLUS') + + const beginSASLSession = (oauthBearerToken) => { try { - this.saslSession = sasl.startSession( - msg.mechanisms, - this.enableChannelBinding && this.connection.stream, - this.scramMaxIterations - ) + this.saslSession = sasl.startSession(msg.mechanisms, this.enableChannelBinding && this.connection.stream, { + oauthBearerToken, + scramMaxIterations: this.scramMaxIterations, + }) this.connection.sendSASLInitialResponseMessage(this.saslSession.mechanism, this.saslSession.response) } catch (err) { this.connection.emit('error', err) } - }) + } + + if (hasOAuth && this.oauthBearerToken != null) { + return this._getOAuthBearerToken((oauthBearerToken) => { + beginSASLSession(oauthBearerToken) + }) + } + + if (hasScram) { + return this._getPassword(() => { + beginSASLSession() + }) + } + + // Let sasl.startSession throw the unsupported-mechanism error. + beginSASLSession() } async _handleAuthSASLContinue(msg) { @@ -345,7 +395,14 @@ class Client extends EventEmitter { msg.data, this.enableChannelBinding && this.connection.stream ) - this.connection.sendSCRAMClientFinalMessage(this.saslSession.response) + this.connection.sendSASLResponseMessage(this.saslSession.response) + if (this.saslSession.oauthError) { + this.connection.emit( + 'error', + new Error('SASL: OAUTHBEARER authentication failed: ' + this.saslSession.oauthError) + ) + return + } } catch (err) { this.connection.emit('error', err) } diff --git a/packages/pg/lib/connection-parameters.js b/packages/pg/lib/connection-parameters.js index 37987fd68..0d1627403 100644 --- a/packages/pg/lib/connection-parameters.js +++ b/packages/pg/lib/connection-parameters.js @@ -79,6 +79,16 @@ class ConnectionParameters { value: val('password', config), }) + // oauthBearerToken is intentionally not read from environment variables. + // OAuth bearer tokens are short-lived credentials obtained programmatically; + // they should not be stored in env vars the way a static password would be. + Object.defineProperty(this, 'oauthBearerToken', { + configurable: true, + enumerable: false, + writable: true, + value: config.oauthBearerToken, + }) + this.binary = val('binary', config) this.options = val('options', config) diff --git a/packages/pg/lib/connection.js b/packages/pg/lib/connection.js index 63cc13a53..fda72c648 100644 --- a/packages/pg/lib/connection.js +++ b/packages/pg/lib/connection.js @@ -157,8 +157,12 @@ class Connection extends EventEmitter { this._send(serialize.sendSASLInitialResponseMessage(mechanism, initialResponse)) } + sendSASLResponseMessage(additionalData) { + this._send(serialize.sendSASLResponseMessage(additionalData)) + } + sendSCRAMClientFinalMessage(additionalData) { - this._send(serialize.sendSCRAMClientFinalMessage(additionalData)) + this.sendSASLResponseMessage(additionalData) } _send(buffer) { diff --git a/packages/pg/lib/crypto/sasl.js b/packages/pg/lib/crypto/sasl.js index ea63b2413..ad4054a5d 100644 --- a/packages/pg/lib/crypto/sasl.js +++ b/packages/pg/lib/crypto/sasl.js @@ -32,16 +32,48 @@ function saslprep(password) { const DEFAULT_MAX_SCRAM_ITERATIONS = 100000 -function startSession(mechanisms, stream, scramMaxIterations = DEFAULT_MAX_SCRAM_ITERATIONS) { - const candidates = ['SCRAM-SHA-256'] - if (stream) candidates.unshift('SCRAM-SHA-256-PLUS') // higher-priority, so placed first +function startSession(mechanisms, stream, options) { + const candidates = [] + const isOptionsObject = options !== null && typeof options === 'object' + const oauthBearerToken = isOptionsObject ? options.oauthBearerToken : undefined + const scramMaxIterations = + typeof options === 'number' + ? options + : isOptionsObject && 'scramMaxIterations' in options + ? options.scramMaxIterations + : DEFAULT_MAX_SCRAM_ITERATIONS + + if (oauthBearerToken !== undefined && oauthBearerToken !== null) { + // OAUTHBEARER is preferred when a token is explicitly provided, even over SCRAM-SHA-256-PLUS. + candidates.push('OAUTHBEARER') + } + + if (stream) candidates.push('SCRAM-SHA-256-PLUS') + candidates.push('SCRAM-SHA-256') const mechanism = candidates.find((candidate) => mechanisms.includes(candidate)) if (!mechanism) { + if (mechanisms.includes('OAUTHBEARER')) { + throw new Error('SASL: OAUTHBEARER requires an oauthBearerToken') + } throw new Error('SASL: Only mechanism(s) ' + candidates.join(' and ') + ' are supported') } + if (mechanism === 'OAUTHBEARER') { + if (typeof oauthBearerToken !== 'string') { + throw new Error('SASL: OAUTHBEARER token must be a string') + } + if (oauthBearerToken === '') { + throw new Error('SASL: OAUTHBEARER token must be a non-empty string') + } + return { + mechanism, + response: 'n,,\x01auth=Bearer ' + oauthBearerToken + '\x01\x01', + message: 'SASLInitialResponse', + } + } + if (mechanism === 'SCRAM-SHA-256-PLUS' && typeof stream.getPeerCertificate !== 'function') { // this should never happen if we are really talking to a Postgres server throw new Error('SASL: Mechanism SCRAM-SHA-256-PLUS requires a certificate') @@ -63,6 +95,21 @@ async function continueSession(session, password, serverData, stream) { if (session.message !== 'SASLInitialResponse') { throw new Error('SASL: Last message was not SASLInitialResponse') } + if (session.mechanism === 'OAUTHBEARER') { + if (typeof serverData !== 'string') { + throw new Error('SASL: OAUTHBEARER serverData must be a string') + } + // PostgreSQL sends a JSON challenge when OAUTHBEARER authentication fails. + // The client must still send the RFC 7628 dummy response ("\x01") before the + // server can finish the failed authentication exchange, so record the payload + // for the caller to surface after it sends session.response. + if (serverData.length > 0) { + session.oauthError = serverData + } + session.message = 'SASLResponse' + session.response = '\x01' + return + } if (typeof password !== 'string') { throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: client password must be a string') } @@ -127,6 +174,12 @@ async function continueSession(session, password, serverData, stream) { } function finalizeSession(session, serverData) { + if (session.mechanism === 'OAUTHBEARER') { + // OAUTHBEARER auth ends after continueSession (client sends \x01, server replies with + // AuthenticationOk). AuthenticationSASLFinal is never sent for this mechanism, so + // reaching here means a misbehaving server or a protocol bug. + throw new Error('SASL: OAUTHBEARER does not support server final messages') + } if (session.message !== 'SASLResponse') { throw new Error('SASL: Last message was not SASLResponse') } diff --git a/packages/pg/test/unit/client/credential-redaction-tests.js b/packages/pg/test/unit/client/credential-redaction-tests.js new file mode 100644 index 000000000..759767f92 --- /dev/null +++ b/packages/pg/test/unit/client/credential-redaction-tests.js @@ -0,0 +1,33 @@ +'use strict' +const helper = require('./test-helper') +const assert = require('assert') +const util = require('util') + +const suite = new helper.Suite() +const test = suite.test.bind(suite) + +const oauthBearerToken = 'FAIL THIS OAUTH BEARER TOKEN TEST' + +test('credential redaction', function () { + test('OAuth bearer token should not exist in toString() output', () => { + const pool = new helper.pg.Pool({ oauthBearerToken }) + const client = new helper.pg.Client({ oauthBearerToken }) + assert(pool.toString().indexOf(oauthBearerToken) === -1) + assert(client.toString().indexOf(oauthBearerToken) === -1) + }) + + test('OAuth bearer token should not exist in util.inspect output', () => { + const pool = new helper.pg.Pool({ oauthBearerToken }) + const client = new helper.pg.Client({ oauthBearerToken }) + const depth = 20 + assert(util.inspect(pool, { depth }).indexOf(oauthBearerToken) === -1) + assert(util.inspect(client, { depth }).indexOf(oauthBearerToken) === -1) + }) + + test('OAuth bearer token should not exist in json.stringify output', () => { + const pool = new helper.pg.Pool({ oauthBearerToken }) + const client = new helper.pg.Client({ oauthBearerToken }) + assert(JSON.stringify(pool).indexOf(oauthBearerToken) === -1) + assert(JSON.stringify(client).indexOf(oauthBearerToken) === -1) + }) +}) diff --git a/packages/pg/test/unit/client/oauth-sasl-tests.js b/packages/pg/test/unit/client/oauth-sasl-tests.js new file mode 100644 index 000000000..7dadd1620 --- /dev/null +++ b/packages/pg/test/unit/client/oauth-sasl-tests.js @@ -0,0 +1,193 @@ +'use strict' +const helper = require('./test-helper') +const BufferList = require('../../buffer-list') +const assert = require('assert') +const suite = new helper.Suite() +const test = suite.test.bind(suite) + +const sasl = require('../../../lib/crypto/sasl') + +function oauthInitialResponse(token) { + return 'n,,\x01auth=Bearer ' + token + '\x01\x01' +} + +function expectedInitialResponse(token) { + const response = oauthInitialResponse(token) + return new BufferList() + .addCString('OAUTHBEARER') + .addInt32(Buffer.byteLength(response)) + .addString(response) + .join(true, 'p') +} + +function expectedResponse(response) { + return new BufferList().addString(response).join(true, 'p') +} + +function markConnected(client) { + client.connection.emit('readyForQuery', { status: 'I' }) +} + +test('sasl/oauth', function () { + test('selects OAUTHBEARER when token is configured', function () { + const session = sasl.startSession(['SCRAM-SHA-256', 'OAUTHBEARER'], null, { oauthBearerToken: 'token' }) + + assert.equal(session.mechanism, 'OAUTHBEARER') + assert.equal(session.message, 'SASLInitialResponse') + assert.equal(session.response, oauthInitialResponse('token')) + }) + + test('selects OAUTHBEARER before SCRAM-SHA-256-PLUS when token is configured', function () { + const session = sasl.startSession( + ['SCRAM-SHA-256', 'SCRAM-SHA-256-PLUS', 'OAUTHBEARER'], + { + getPeerCertificate() {}, + }, + { oauthBearerToken: 'token' } + ) + + assert.equal(session.mechanism, 'OAUTHBEARER') + }) + + test('does not select OAUTHBEARER without token', function () { + const session = sasl.startSession(['SCRAM-SHA-256', 'OAUTHBEARER']) + + assert.equal(session.mechanism, 'SCRAM-SHA-256') + }) + + test('fails when server only offers OAUTHBEARER and no token is configured', function () { + assert.throws( + function () { + sasl.startSession(['OAUTHBEARER']) + }, + { + message: 'SASL: OAUTHBEARER requires an oauthBearerToken', + } + ) + }) + + test('fails when OAUTHBEARER token is not a string', function () { + assert.throws( + function () { + sasl.startSession(['OAUTHBEARER'], null, { oauthBearerToken: 1 }) + }, + { + message: 'SASL: OAUTHBEARER token must be a string', + } + ) + }) + + test('fails when OAUTHBEARER token is empty', function () { + assert.throws( + function () { + sasl.startSession(['OAUTHBEARER'], null, { oauthBearerToken: '' }) + }, + { + message: 'SASL: OAUTHBEARER token must be a non-empty string', + } + ) + }) + + test('responds to empty OAUTHBEARER challenge with an empty response', async function () { + const session = sasl.startSession(['OAUTHBEARER'], null, { oauthBearerToken: 'token' }) + + await sasl.continueSession(session, null, '') + + assert.equal(session.message, 'SASLResponse') + assert.equal(session.response, '\x01') + assert.equal(session.oauthError, undefined) + }) + + test('stores server error from non-empty OAUTHBEARER challenge', async function () { + const session = sasl.startSession(['OAUTHBEARER'], null, { oauthBearerToken: 'token' }) + const errorPayload = '{"status":"invalid_token","scope":"openid"}' + + await sasl.continueSession(session, null, errorPayload) + + assert.equal(session.oauthError, errorPayload) + assert.equal(session.response, '\x01') + }) + + test('stores empty JSON as an OAUTHBEARER error challenge', async function () { + const session = sasl.startSession(['OAUTHBEARER'], null, { oauthBearerToken: 'token' }) + + await sasl.continueSession(session, null, '{}') + + assert.equal(session.oauthError, '{}') + assert.equal(session.response, '\x01') + }) +}) + +test('client oauth authentication', function () { + test('sends OAUTHBEARER initial response with token string', function () { + const client = helper.createClient() + client.oauthBearerToken = 'token' + client.connection.emit('authenticationSASL', { mechanisms: ['OAUTHBEARER'] }) + + assert.lengthIs(client.connection.stream.packets, 1) + assert.equalBuffers(client.connection.stream.packets[0], expectedInitialResponse('token')) + }) + + test('sends OAUTHBEARER initial response with token callback', async function () { + const client = helper.createClient({ + user: 'foo', + database: 'bar', + host: 'baz', + oauthBearerToken: async (params) => { + assert.equal(params.user, 'foo') + assert.equal(params.database, 'bar') + assert.equal(params.host, 'baz') + return 'callback-token' + }, + }) + + client.connection.emit('authenticationSASL', { mechanisms: ['OAUTHBEARER'] }) + await new Promise((resolve) => setImmediate(resolve)) + + assert.equal(typeof client.oauthBearerToken, 'function', 'callback should not be overwritten') + assert.equal(typeof client.connectionParameters.oauthBearerToken, 'function', 'callback should not be overwritten') + assert.lengthIs(client.connection.stream.packets, 1) + assert.equalBuffers(client.connection.stream.packets[0], expectedInitialResponse('callback-token')) + }) + + test('emits error when token callback rejects', async function () { + const client = helper.createClient({ + oauthBearerToken: async () => { + throw new Error('token fetch failed') + }, + }) + markConnected(client) + const errorPromise = new Promise((resolve) => client.once('error', resolve)) + + client.connection.emit('authenticationSASL', { mechanisms: ['OAUTHBEARER'] }) + const err = await errorPromise + + assert.equal(err.message, 'token fetch failed') + }) + + test('emits error when token callback returns a non-string', async function () { + const client = helper.createClient({ oauthBearerToken: async () => 42 }) + markConnected(client) + const errorPromise = new Promise((resolve) => client.once('error', resolve)) + + client.connection.emit('authenticationSASL', { mechanisms: ['OAUTHBEARER'] }) + const err = await errorPromise + + assert.ok(err instanceof TypeError) + }) + + test('emits error when server returns a non-empty OAUTHBEARER challenge', async function () { + const client = helper.createClient() + client.oauthBearerToken = 'token' + markConnected(client) + const errorPromise = new Promise((resolve) => client.once('error', resolve)) + + client.connection.emit('authenticationSASL', { mechanisms: ['OAUTHBEARER'] }) + client.connection.emit('authenticationSASLContinue', { data: '{"status":"invalid_token"}' }) + const err = await errorPromise + + assert.ok(err.message.includes('invalid_token')) + assert.lengthIs(client.connection.stream.packets, 2) + assert.equalBuffers(client.connection.stream.packets[1], expectedResponse('\x01')) + }) +}) diff --git a/packages/pg/test/unit/connection-pool/configuration-tests.js b/packages/pg/test/unit/connection-pool/configuration-tests.js index cfd8f0eec..dfa60a416 100644 --- a/packages/pg/test/unit/connection-pool/configuration-tests.js +++ b/packages/pg/test/unit/connection-pool/configuration-tests.js @@ -13,3 +13,13 @@ suite.test('pool with copied settings includes password', () => { assert.equal(copy.options.password, 'original') }) + +suite.test('pool with copied settings includes OAuth bearer token', () => { + const original = new helper.pg.Pool({ + oauthBearerToken: 'original', + }) + + const copy = new helper.pg.Pool(original.options) + + assert.equal(copy.options.oauthBearerToken, 'original') +}) diff --git a/packages/pg/test/unit/test-helper.js b/packages/pg/test/unit/test-helper.js index 618866920..0b5d38060 100644 --- a/packages/pg/test/unit/test-helper.js +++ b/packages/pg/test/unit/test-helper.js @@ -1,4 +1,5 @@ 'use strict' +const assert = require('assert') const EventEmitter = require('events').EventEmitter const helper = require('../test-helper') @@ -35,11 +36,22 @@ p.setKeepAlive = function () {} p.closed = false p.writable = true -const createClient = function () { +assert.equalBuffers = function (actual, expected) { + assert(Buffer.isBuffer(actual), 'actual must be a Buffer') + assert(Buffer.isBuffer(expected), 'expected must be a Buffer') + assert.equal(actual.compare(expected), 0) +} + +const createClient = function (config) { const stream = new MemoryStream() - const client = new Client({ - connection: new Connection({ stream: stream }), - }) + const client = new Client( + Object.assign( + { + connection: new Connection({ stream: stream }), + }, + config + ) + ) client.connect() return client }