Skip to content

Commit b19703e

Browse files
author
Georg Traar
committed
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.
1 parent b617619 commit b19703e

13 files changed

Lines changed: 428 additions & 17 deletions

File tree

docs/pages/apis/client.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Every field of the `config` object is entirely optional. A `Client` instance wil
1212
type Config = {
1313
user?: string, // default process.env.PGUSER || process.env.USER
1414
password?: string or function, //default process.env.PGPASSWORD
15+
oauthBearerToken?: string or function, // bearer token or callback returning a bearer token for OAUTHBEARER authentication
1516
host?: string, // default process.env.PGHOST
1617
port?: number, // default process.env.PGPORT
1718
database?: string, // default process.env.PGDATABASE || user

docs/pages/features/connecting.mdx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,26 @@ const pool = new Pool({
114114
})
115115
```
116116

117+
PostgreSQL servers configured for OAuth can authenticate with a bearer token using the SASL `OAUTHBEARER` mechanism.
118+
Pass the token directly, or provide a synchronous or asynchronous callback that resolves to a token string.
119+
When `oauthBearerToken` is set, `OAUTHBEARER` takes precedence over `SCRAM-SHA-256-PLUS` even when TLS channel binding is available.
120+
`pg` does not perform the OAuth authorization flow; your application is responsible for fetching and caching tokens from your OAuth provider.
121+
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.
122+
123+
```js
124+
import pg from 'pg'
125+
const { Pool } = pg
126+
127+
const pool = new Pool({
128+
user: 'api-user',
129+
host: 'database.server.com',
130+
database: 'my-db',
131+
oauthBearerToken: async () => {
132+
return getAccessToken()
133+
},
134+
})
135+
```
136+
117137
### Unix Domain Sockets
118138

119139
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.

packages/pg-pool/index.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,16 @@ class Pool extends EventEmitter {
7878
value: options.password,
7979
})
8080
}
81+
if (options != null && 'oauthBearerToken' in options) {
82+
// "hiding" the OAuth bearer token so it doesn't show up in stack traces
83+
// or if the pool is console.logged
84+
Object.defineProperty(this.options, 'oauthBearerToken', {
85+
configurable: true,
86+
enumerable: false,
87+
writable: true,
88+
value: options.oauthBearerToken,
89+
})
90+
}
8191
if (options != null && options.ssl && options.ssl.key) {
8292
// "hiding" the ssl->key so it doesn't show up in stack traces
8393
// or if the client is console.logged

packages/pg-protocol/src/outbound-serializer.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ describe('serializer', () => {
4545
assert.deepEqual(actual, new BufferList().addString('data').join(true, 'p'))
4646
})
4747

48+
it('builds SASLResponseMessage message', function () {
49+
const actual = serialize.sendSASLResponseMessage('data')
50+
assert.deepEqual(actual, new BufferList().addString('data').join(true, 'p'))
51+
})
52+
4853
it('builds query message', function () {
4954
const txt = 'select * from boom'
5055
const actual = serialize.query(txt)

packages/pg-protocol/src/serializer.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,12 @@ const sendSASLInitialResponseMessage = function (mechanism: string, initialRespo
5353
return writer.flush(code.startup)
5454
}
5555

56-
const sendSCRAMClientFinalMessage = function (additionalData: string): Buffer {
56+
const sendSASLResponseMessage = function (additionalData: string): Buffer {
5757
return writer.addString(additionalData).flush(code.startup)
5858
}
5959

60+
const sendSCRAMClientFinalMessage = sendSASLResponseMessage
61+
6062
const query = (text: string): Buffer => {
6163
return writer.addCString(text).flush(code.query)
6264
}
@@ -261,6 +263,7 @@ const serialize = {
261263
password,
262264
requestSsl,
263265
sendSASLInitialResponseMessage,
266+
sendSASLResponseMessage,
264267
sendSCRAMClientFinalMessage,
265268
query,
266269
parse,

packages/pg/lib/client.js

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ class Client extends EventEmitter {
6565
writable: true,
6666
value: this.connectionParameters.password,
6767
})
68+
Object.defineProperty(this, 'oauthBearerToken', {
69+
configurable: true,
70+
enumerable: false,
71+
writable: true,
72+
value: this.connectionParameters.oauthBearerToken,
73+
})
6874

6975
this.replication = this.connectionParameters.replication
7076

@@ -305,6 +311,33 @@ class Client extends EventEmitter {
305311
}
306312
}
307313

314+
_getOAuthBearerToken(cb) {
315+
const con = this.connection
316+
if (typeof this.oauthBearerToken === 'function') {
317+
let tokenResult
318+
try {
319+
tokenResult = this.oauthBearerToken(this.connectionParameters)
320+
} catch (err) {
321+
process.nextTick(() => con.emit('error', err))
322+
return
323+
}
324+
this._Promise.resolve(tokenResult).then(
325+
(token) => {
326+
if (typeof token !== 'string') {
327+
con.emit('error', new TypeError('OAuth bearer token must be a string'))
328+
return
329+
}
330+
cb(token)
331+
},
332+
(err) => {
333+
con.emit('error', err)
334+
}
335+
)
336+
} else {
337+
cb(this.oauthBearerToken)
338+
}
339+
}
340+
308341
_handleAuthCleartextPassword(msg) {
309342
this._getPassword(() => {
310343
this.connection.password(this.password)
@@ -323,18 +356,35 @@ class Client extends EventEmitter {
323356
}
324357

325358
_handleAuthSASL(msg) {
326-
this._getPassword(() => {
359+
const hasOAuth = msg.mechanisms.includes('OAUTHBEARER')
360+
const hasScram = msg.mechanisms.includes('SCRAM-SHA-256') || msg.mechanisms.includes('SCRAM-SHA-256-PLUS')
361+
362+
const beginSASLSession = (oauthBearerToken) => {
327363
try {
328-
this.saslSession = sasl.startSession(
329-
msg.mechanisms,
330-
this.enableChannelBinding && this.connection.stream,
331-
this.scramMaxIterations
332-
)
364+
this.saslSession = sasl.startSession(msg.mechanisms, this.enableChannelBinding && this.connection.stream, {
365+
oauthBearerToken,
366+
scramMaxIterations: this.scramMaxIterations,
367+
})
333368
this.connection.sendSASLInitialResponseMessage(this.saslSession.mechanism, this.saslSession.response)
334369
} catch (err) {
335370
this.connection.emit('error', err)
336371
}
337-
})
372+
}
373+
374+
if (hasOAuth && this.oauthBearerToken != null) {
375+
return this._getOAuthBearerToken((oauthBearerToken) => {
376+
beginSASLSession(oauthBearerToken)
377+
})
378+
}
379+
380+
if (hasScram) {
381+
return this._getPassword(() => {
382+
beginSASLSession()
383+
})
384+
}
385+
386+
// Let sasl.startSession throw the unsupported-mechanism error.
387+
beginSASLSession()
338388
}
339389

340390
async _handleAuthSASLContinue(msg) {
@@ -345,7 +395,14 @@ class Client extends EventEmitter {
345395
msg.data,
346396
this.enableChannelBinding && this.connection.stream
347397
)
348-
this.connection.sendSCRAMClientFinalMessage(this.saslSession.response)
398+
this.connection.sendSASLResponseMessage(this.saslSession.response)
399+
if (this.saslSession.oauthError) {
400+
this.connection.emit(
401+
'error',
402+
new Error('SASL: OAUTHBEARER authentication failed: ' + this.saslSession.oauthError)
403+
)
404+
return
405+
}
349406
} catch (err) {
350407
this.connection.emit('error', err)
351408
}

packages/pg/lib/connection-parameters.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,16 @@ class ConnectionParameters {
7979
value: val('password', config),
8080
})
8181

82+
// oauthBearerToken is intentionally not read from environment variables.
83+
// OAuth bearer tokens are short-lived credentials obtained programmatically;
84+
// they should not be stored in env vars the way a static password would be.
85+
Object.defineProperty(this, 'oauthBearerToken', {
86+
configurable: true,
87+
enumerable: false,
88+
writable: true,
89+
value: config.oauthBearerToken,
90+
})
91+
8292
this.binary = val('binary', config)
8393
this.options = val('options', config)
8494

packages/pg/lib/connection.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,12 @@ class Connection extends EventEmitter {
157157
this._send(serialize.sendSASLInitialResponseMessage(mechanism, initialResponse))
158158
}
159159

160+
sendSASLResponseMessage(additionalData) {
161+
this._send(serialize.sendSASLResponseMessage(additionalData))
162+
}
163+
160164
sendSCRAMClientFinalMessage(additionalData) {
161-
this._send(serialize.sendSCRAMClientFinalMessage(additionalData))
165+
this.sendSASLResponseMessage(additionalData)
162166
}
163167

164168
_send(buffer) {

packages/pg/lib/crypto/sasl.js

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,48 @@ function saslprep(password) {
3232

3333
const DEFAULT_MAX_SCRAM_ITERATIONS = 100000
3434

35-
function startSession(mechanisms, stream, scramMaxIterations = DEFAULT_MAX_SCRAM_ITERATIONS) {
36-
const candidates = ['SCRAM-SHA-256']
37-
if (stream) candidates.unshift('SCRAM-SHA-256-PLUS') // higher-priority, so placed first
35+
function startSession(mechanisms, stream, options) {
36+
const candidates = []
37+
const isOptionsObject = options !== null && typeof options === 'object'
38+
const oauthBearerToken = isOptionsObject ? options.oauthBearerToken : undefined
39+
const scramMaxIterations =
40+
typeof options === 'number'
41+
? options
42+
: isOptionsObject && 'scramMaxIterations' in options
43+
? options.scramMaxIterations
44+
: DEFAULT_MAX_SCRAM_ITERATIONS
45+
46+
if (oauthBearerToken !== undefined && oauthBearerToken !== null) {
47+
// OAUTHBEARER is preferred when a token is explicitly provided, even over SCRAM-SHA-256-PLUS.
48+
candidates.push('OAUTHBEARER')
49+
}
50+
51+
if (stream) candidates.push('SCRAM-SHA-256-PLUS')
52+
candidates.push('SCRAM-SHA-256')
3853

3954
const mechanism = candidates.find((candidate) => mechanisms.includes(candidate))
4055

4156
if (!mechanism) {
57+
if (mechanisms.includes('OAUTHBEARER')) {
58+
throw new Error('SASL: OAUTHBEARER requires an oauthBearerToken')
59+
}
4260
throw new Error('SASL: Only mechanism(s) ' + candidates.join(' and ') + ' are supported')
4361
}
4462

63+
if (mechanism === 'OAUTHBEARER') {
64+
if (typeof oauthBearerToken !== 'string') {
65+
throw new Error('SASL: OAUTHBEARER token must be a string')
66+
}
67+
if (oauthBearerToken === '') {
68+
throw new Error('SASL: OAUTHBEARER token must be a non-empty string')
69+
}
70+
return {
71+
mechanism,
72+
response: 'n,,\x01auth=Bearer ' + oauthBearerToken + '\x01\x01',
73+
message: 'SASLInitialResponse',
74+
}
75+
}
76+
4577
if (mechanism === 'SCRAM-SHA-256-PLUS' && typeof stream.getPeerCertificate !== 'function') {
4678
// this should never happen if we are really talking to a Postgres server
4779
throw new Error('SASL: Mechanism SCRAM-SHA-256-PLUS requires a certificate')
@@ -63,6 +95,21 @@ async function continueSession(session, password, serverData, stream) {
6395
if (session.message !== 'SASLInitialResponse') {
6496
throw new Error('SASL: Last message was not SASLInitialResponse')
6597
}
98+
if (session.mechanism === 'OAUTHBEARER') {
99+
if (typeof serverData !== 'string') {
100+
throw new Error('SASL: OAUTHBEARER serverData must be a string')
101+
}
102+
// PostgreSQL sends a JSON challenge when OAUTHBEARER authentication fails.
103+
// The client must still send the RFC 7628 dummy response ("\x01") before the
104+
// server can finish the failed authentication exchange, so record the payload
105+
// for the caller to surface after it sends session.response.
106+
if (serverData.length > 0) {
107+
session.oauthError = serverData
108+
}
109+
session.message = 'SASLResponse'
110+
session.response = '\x01'
111+
return
112+
}
66113
if (typeof password !== 'string') {
67114
throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: client password must be a string')
68115
}
@@ -127,6 +174,12 @@ async function continueSession(session, password, serverData, stream) {
127174
}
128175

129176
function finalizeSession(session, serverData) {
177+
if (session.mechanism === 'OAUTHBEARER') {
178+
// OAUTHBEARER auth ends after continueSession (client sends \x01, server replies with
179+
// AuthenticationOk). AuthenticationSASLFinal is never sent for this mechanism, so
180+
// reaching here means a misbehaving server or a protocol bug.
181+
throw new Error('SASL: OAUTHBEARER does not support server final messages')
182+
}
130183
if (session.message !== 'SASLResponse') {
131184
throw new Error('SASL: Last message was not SASLResponse')
132185
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
'use strict'
2+
const helper = require('./test-helper')
3+
const assert = require('assert')
4+
const util = require('util')
5+
6+
const suite = new helper.Suite()
7+
const test = suite.test.bind(suite)
8+
9+
const oauthBearerToken = 'FAIL THIS OAUTH BEARER TOKEN TEST'
10+
11+
test('credential redaction', function () {
12+
test('OAuth bearer token should not exist in toString() output', () => {
13+
const pool = new helper.pg.Pool({ oauthBearerToken })
14+
const client = new helper.pg.Client({ oauthBearerToken })
15+
assert(pool.toString().indexOf(oauthBearerToken) === -1)
16+
assert(client.toString().indexOf(oauthBearerToken) === -1)
17+
})
18+
19+
test('OAuth bearer token should not exist in util.inspect output', () => {
20+
const pool = new helper.pg.Pool({ oauthBearerToken })
21+
const client = new helper.pg.Client({ oauthBearerToken })
22+
const depth = 20
23+
assert(util.inspect(pool, { depth }).indexOf(oauthBearerToken) === -1)
24+
assert(util.inspect(client, { depth }).indexOf(oauthBearerToken) === -1)
25+
})
26+
27+
test('OAuth bearer token should not exist in json.stringify output', () => {
28+
const pool = new helper.pg.Pool({ oauthBearerToken })
29+
const client = new helper.pg.Client({ oauthBearerToken })
30+
assert(JSON.stringify(pool).indexOf(oauthBearerToken) === -1)
31+
assert(JSON.stringify(client).indexOf(oauthBearerToken) === -1)
32+
})
33+
})

0 commit comments

Comments
 (0)