Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/pages/apis/client.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions docs/pages/features/connecting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions packages/pg-pool/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions packages/pg-protocol/src/outbound-serializer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion packages/pg-protocol/src/serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -261,6 +263,7 @@ const serialize = {
password,
requestSsl,
sendSASLInitialResponseMessage,
sendSASLResponseMessage,
sendSCRAMClientFinalMessage,
query,
parse,
Expand Down
73 changes: 65 additions & 8 deletions packages/pg/lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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) {
Expand All @@ -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)
}
Expand Down
10 changes: 10 additions & 0 deletions packages/pg/lib/connection-parameters.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
6 changes: 5 additions & 1 deletion packages/pg/lib/connection.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
59 changes: 56 additions & 3 deletions packages/pg/lib/crypto/sasl.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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')
}
Expand Down Expand Up @@ -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')
}
Expand Down
33 changes: 33 additions & 0 deletions packages/pg/test/unit/client/credential-redaction-tests.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading