Skip to content

Commit c4c0f9c

Browse files
authored
Re-authentication (#1050)
**⚠️ This API is released as preview.** This changes introduce two ways of changing the connection credentials in a driver instance, each of them solving a different use case. ### Token Expiration / Change Credentials for the whole driver instance This use case is related to the issue #993 in the repository. For solving this, the driver is now able to receive a `AuthTokenManager` in the driver creation. This interface enables the user code provide new auth tokens to the driver and be notified by token expiration failures. For simplifying the usage, the driver also provides a default implementation of `AuthTokenManager` which can be created with `neo4j.temporalAuthDataManager` and receives a function for renewing the auth token as parameters. Example: ```typescript import neo4j, { AuthToken } from 'neo4j-driver' /** * Method called whenever the driver needs to refresh the token. * * The refresh will happen if the driver is notified by the server * about a token expiration or if the `Date.now() > tokenData.expiry` * * Important, the driver will block all the connections creation until * this function resolves the new auth token. */ async function fetchAuthTokenFromMyProvider () { const bearer: string = await myProvider.getBearerToken() const token: AuthToken = neo4j.auth.bearer(bearer) const expiration: Date = myProvider.getExpiryDate() return { token, // if expiration is not provided, // the driver will only fetch a new token when a failure happens expiration } } const driver = neo4j.driver( 'neo4j://localhost:7687', neo4j.expirationBasedAuthTokenManager({ getAuthData: fetchAuthTokenFromMyProvider }) ) ``` ### User Switching In this scenario, different credentials can be configured in a session providing a way for change the user context for the session. For using this feature, it needed to check if your server supports session auth by calling `driver.supportsSessionAuth()`. Example: ```typescript import neo4j from 'neo4j-driver' const driver = neo4j.driver( 'neo4j://localhost:7687', neo4j.auth.basic('neo4j', 'password') ) const sessionWithUserB = driver.session({ database: 'neo4j', auth: neo4j.auth.basic('userB', 'userBpassword') }) try { // run some queries as userB const result = await sessionWithUserB.executeRead(tx => tx.run('RETURN 1')) } finally { // close the session as usual await sessionWithUserB.close() } ``` **⚠️ This API is released as preview.**
1 parent 93c0a16 commit c4c0f9c

File tree

74 files changed

+5496
-490
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

74 files changed

+5496
-490
lines changed

packages/bolt-connection/src/bolt/bolt-protocol-v1.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ export default class BoltProtocol {
205205
onError: onError
206206
})
207207

208+
// TODO: Verify the Neo4j version in the message
208209
const error = newError(
209210
'Driver is connected to a database that does not support logoff. ' +
210211
'Please upgrade to Neo4j 5.5.0 or later in order to use this functionality.'
@@ -233,6 +234,7 @@ export default class BoltProtocol {
233234
onError: (error) => this._onLoginError(error, onError)
234235
})
235236

237+
// TODO: Verify the Neo4j version in the message
236238
const error = newError(
237239
'Driver is connected to a database that does not support logon. ' +
238240
'Please upgrade to Neo4j 5.5.0 or later in order to use this functionality.'
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* Copyright (c) "Neo4j"
3+
* Neo4j Sweden AB [http://neo4j.com]
4+
*
5+
* This file is part of Neo4j.
6+
*
7+
* Licensed under the Apache License, Version 2.0 (the "License");
8+
* you may not use this file except in compliance with the License.
9+
* You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing, software
14+
* distributed under the License is distributed on an "AS IS" BASIS,
15+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
* See the License for the specific language governing permissions and
17+
* limitations under the License.
18+
*/
19+
20+
import { expirationBasedAuthTokenManager } from 'neo4j-driver-core'
21+
import { object } from '../lang'
22+
23+
/**
24+
* Class which provides Authorization for {@link Connection}
25+
*/
26+
export default class AuthenticationProvider {
27+
constructor ({ authTokenManager, userAgent }) {
28+
this._authTokenManager = authTokenManager || expirationBasedAuthTokenManager({
29+
tokenProvider: () => {}
30+
})
31+
this._userAgent = userAgent
32+
}
33+
34+
async authenticate ({ connection, auth, skipReAuth, waitReAuth, forceReAuth }) {
35+
if (auth != null) {
36+
const shouldReAuth = connection.supportsReAuth === true && (
37+
(!object.equals(connection.authToken, auth) && skipReAuth !== true) ||
38+
forceReAuth === true
39+
)
40+
if (connection.authToken == null || shouldReAuth) {
41+
return await connection.connect(this._userAgent, auth, waitReAuth || false)
42+
}
43+
return connection
44+
}
45+
46+
const authToken = await this._authTokenManager.getToken()
47+
48+
if (!object.equals(authToken, connection.authToken)) {
49+
return await connection.connect(this._userAgent, authToken)
50+
}
51+
52+
return connection
53+
}
54+
55+
handleError ({ connection, code }) {
56+
if (
57+
connection &&
58+
[
59+
'Neo.ClientError.Security.Unauthorized',
60+
'Neo.ClientError.Security.TokenExpired'
61+
].includes(code)
62+
) {
63+
this._authTokenManager.onTokenExpired(connection.authToken)
64+
}
65+
}
66+
}

packages/bolt-connection/src/connection-provider/connection-provider-direct.js

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,19 @@ import {
2626
import { internal, error } from 'neo4j-driver-core'
2727

2828
const {
29-
constants: { BOLT_PROTOCOL_V3, BOLT_PROTOCOL_V4_0, BOLT_PROTOCOL_V4_4 }
29+
constants: {
30+
BOLT_PROTOCOL_V3,
31+
BOLT_PROTOCOL_V4_0,
32+
BOLT_PROTOCOL_V4_4,
33+
BOLT_PROTOCOL_V5_1
34+
}
3035
} = internal
3136

3237
const { SERVICE_UNAVAILABLE } = error
3338

3439
export default class DirectConnectionProvider extends PooledConnectionProvider {
35-
constructor ({ id, config, log, address, userAgent, authToken }) {
36-
super({ id, config, log, userAgent, authToken })
40+
constructor ({ id, config, log, address, userAgent, authTokenManager, newPool }) {
41+
super({ id, config, log, userAgent, authTokenManager, newPool })
3742

3843
this._address = address
3944
}
@@ -42,27 +47,33 @@ export default class DirectConnectionProvider extends PooledConnectionProvider {
4247
* See {@link ConnectionProvider} for more information about this method and
4348
* its arguments.
4449
*/
45-
acquireConnection ({ accessMode, database, bookmarks } = {}) {
50+
async acquireConnection ({ accessMode, database, bookmarks, auth, forceReAuth } = {}) {
4651
const databaseSpecificErrorHandler = ConnectionErrorHandler.create({
4752
errorCode: SERVICE_UNAVAILABLE,
48-
handleAuthorizationExpired: (error, address) =>
49-
this._handleAuthorizationExpired(error, address, database)
53+
handleAuthorizationExpired: (error, address, conn) =>
54+
this._handleAuthorizationExpired(error, address, conn, database)
5055
})
5156

52-
return this._connectionPool
53-
.acquire(this._address)
54-
.then(
55-
connection =>
56-
new DelegateConnection(connection, databaseSpecificErrorHandler)
57-
)
57+
const connection = await this._connectionPool.acquire({ auth, forceReAuth }, this._address)
58+
59+
if (auth) {
60+
await this._verifyStickyConnection({
61+
auth,
62+
connection,
63+
address: this._address
64+
})
65+
return connection
66+
}
67+
68+
return new DelegateConnection(connection, databaseSpecificErrorHandler)
5869
}
5970

60-
_handleAuthorizationExpired (error, address, database) {
71+
_handleAuthorizationExpired (error, address, connection, database) {
6172
this._log.warn(
6273
`Direct driver ${this._id} will close connection to ${address} for database '${database}' because of an error ${error.code} '${error.message}'`
6374
)
64-
this._connectionPool.purge(address).catch(() => {})
65-
return error
75+
76+
return super._handleAuthorizationExpired(error, address, connection)
6677
}
6778

6879
async _hasProtocolVersion (versionPredicate) {
@@ -111,6 +122,19 @@ export default class DirectConnectionProvider extends PooledConnectionProvider {
111122
)
112123
}
113124

125+
async supportsSessionAuth () {
126+
return await this._hasProtocolVersion(
127+
version => version >= BOLT_PROTOCOL_V5_1
128+
)
129+
}
130+
131+
async verifyAuthentication ({ auth }) {
132+
return this._verifyAuthentication({
133+
auth,
134+
getAddress: () => this._address
135+
})
136+
}
137+
114138
async verifyConnectivityAndGetServerInfo () {
115139
return await this._verifyConnectivityAndGetServerVersion({ address: this._address })
116140
}

packages/bolt-connection/src/connection-provider/connection-provider-pooled.js

Lines changed: 104 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,30 @@
1919

2020
import { createChannelConnection, ConnectionErrorHandler } from '../connection'
2121
import Pool, { PoolConfig } from '../pool'
22-
import { error, ConnectionProvider, ServerInfo } from 'neo4j-driver-core'
22+
import { error, ConnectionProvider, ServerInfo, newError, isStaticAuthTokenManger } from 'neo4j-driver-core'
23+
import AuthenticationProvider from './authentication-provider'
24+
import { object } from '../lang'
2325

2426
const { SERVICE_UNAVAILABLE } = error
27+
const AUTHENTICATION_ERRORS = [
28+
'Neo.ClientError.Security.CredentialsExpired',
29+
'Neo.ClientError.Security.Forbidden',
30+
'Neo.ClientError.Security.TokenExpired',
31+
'Neo.ClientError.Security.Unauthorized'
32+
]
33+
2534
export default class PooledConnectionProvider extends ConnectionProvider {
2635
constructor (
27-
{ id, config, log, userAgent, authToken },
36+
{ id, config, log, userAgent, authTokenManager, newPool = (...args) => new Pool(...args) },
2837
createChannelConnectionHook = null
2938
) {
3039
super()
3140

3241
this._id = id
3342
this._config = config
3443
this._log = log
35-
this._userAgent = userAgent
36-
this._authToken = authToken
44+
this._authTokenManager = authTokenManager
45+
this._authenticationProvider = new AuthenticationProvider({ authTokenManager, userAgent })
3746
this._createChannelConnection =
3847
createChannelConnectionHook ||
3948
(address => {
@@ -44,10 +53,11 @@ export default class PooledConnectionProvider extends ConnectionProvider {
4453
this._log
4554
)
4655
})
47-
this._connectionPool = new Pool({
56+
this._connectionPool = newPool({
4857
create: this._createConnection.bind(this),
4958
destroy: this._destroyConnection.bind(this),
50-
validate: this._validateConnection.bind(this),
59+
validateOnAcquire: this._validateConnectionOnAcquire.bind(this),
60+
validateOnRelease: this._validateConnectionOnRelease.bind(this),
5161
installIdleObserver: PooledConnectionProvider._installIdleObserverOnConnection.bind(
5262
this
5363
),
@@ -57,6 +67,7 @@ export default class PooledConnectionProvider extends ConnectionProvider {
5767
config: PoolConfig.fromDriverConfig(config),
5868
log: this._log
5969
})
70+
this._userAgent = userAgent
6071
this._openConnections = {}
6172
}
6273

@@ -69,14 +80,13 @@ export default class PooledConnectionProvider extends ConnectionProvider {
6980
* @return {Promise<Connection>} promise resolved with a new connection or rejected when failed to connect.
7081
* @access private
7182
*/
72-
_createConnection (address, release) {
83+
_createConnection ({ auth }, address, release) {
7384
return this._createChannelConnection(address).then(connection => {
7485
connection._release = () => {
7586
return release(address, connection)
7687
}
7788
this._openConnections[connection.id] = connection
78-
return connection
79-
.connect(this._userAgent, this._authToken)
89+
return this._authenticationProvider.authenticate({ connection, auth })
8090
.catch(error => {
8191
// let's destroy this connection
8292
this._destroyConnection(connection)
@@ -86,6 +96,26 @@ export default class PooledConnectionProvider extends ConnectionProvider {
8696
})
8797
}
8898

99+
async _validateConnectionOnAcquire ({ auth, skipReAuth }, conn) {
100+
if (!this._validateConnection(conn)) {
101+
return false
102+
}
103+
104+
try {
105+
await this._authenticationProvider.authenticate({ connection: conn, auth, skipReAuth })
106+
return true
107+
} catch (error) {
108+
this._log.debug(
109+
`The connection ${conn.id} is not valid because of an error ${error.code} '${error.message}'`
110+
)
111+
return false
112+
}
113+
}
114+
115+
_validateConnectionOnRelease (conn) {
116+
return conn._sticky !== true && this._validateConnection(conn)
117+
}
118+
89119
/**
90120
* Check that a connection is usable
91121
* @return {boolean} true if the connection is open
@@ -98,7 +128,11 @@ export default class PooledConnectionProvider extends ConnectionProvider {
98128

99129
const maxConnectionLifetime = this._config.maxConnectionLifetime
100130
const lifetime = Date.now() - conn.creationTimestamp
101-
return lifetime <= maxConnectionLifetime
131+
if (lifetime > maxConnectionLifetime) {
132+
return false
133+
}
134+
135+
return true
102136
}
103137

104138
/**
@@ -118,7 +152,7 @@ export default class PooledConnectionProvider extends ConnectionProvider {
118152
* @return {Promise<ServerInfo>} the server info
119153
*/
120154
async _verifyConnectivityAndGetServerVersion ({ address }) {
121-
const connection = await this._connectionPool.acquire(address)
155+
const connection = await this._connectionPool.acquire({}, address)
122156
const serverInfo = new ServerInfo(connection.server, connection.protocol().version)
123157
try {
124158
if (!connection.protocol().isLastMessageLogon()) {
@@ -130,6 +164,47 @@ export default class PooledConnectionProvider extends ConnectionProvider {
130164
return serverInfo
131165
}
132166

167+
async _verifyAuthentication ({ getAddress, auth }) {
168+
const connectionsToRelease = []
169+
try {
170+
const address = await getAddress()
171+
const connection = await this._connectionPool.acquire({ auth, skipReAuth: true }, address)
172+
connectionsToRelease.push(connection)
173+
174+
const lastMessageIsNotLogin = !connection.protocol().isLastMessageLogon()
175+
176+
if (!connection.supportsReAuth) {
177+
throw newError('Driver is connected to a database that does not support user switch.')
178+
}
179+
if (lastMessageIsNotLogin && connection.supportsReAuth) {
180+
await this._authenticationProvider.authenticate({ connection, auth, waitReAuth: true, forceReAuth: true })
181+
} else if (lastMessageIsNotLogin && !connection.supportsReAuth) {
182+
const stickyConnection = await this._connectionPool.acquire({ auth }, address, { requireNew: true })
183+
stickyConnection._sticky = true
184+
connectionsToRelease.push(stickyConnection)
185+
}
186+
return true
187+
} catch (error) {
188+
if (AUTHENTICATION_ERRORS.includes(error.code)) {
189+
return false
190+
}
191+
throw error
192+
} finally {
193+
await Promise.all(connectionsToRelease.map(conn => conn._release()))
194+
}
195+
}
196+
197+
async _verifyStickyConnection ({ auth, connection, address }) {
198+
const connectionWithSameCredentials = object.equals(auth, connection.authToken)
199+
const shouldCreateStickyConnection = !connectionWithSameCredentials
200+
connection._sticky = connectionWithSameCredentials && !connection.supportsReAuth
201+
202+
if (shouldCreateStickyConnection || connection._sticky) {
203+
await connection._release()
204+
throw newError('Driver is connected to a database that does not support user switch.')
205+
}
206+
}
207+
133208
async close () {
134209
// purge all idle connections in the connection pool
135210
await this._connectionPool.close()
@@ -146,4 +221,22 @@ export default class PooledConnectionProvider extends ConnectionProvider {
146221
static _removeIdleObserverOnConnection (conn) {
147222
conn._updateCurrentObserver()
148223
}
224+
225+
_handleAuthorizationExpired (error, address, connection) {
226+
this._authenticationProvider.handleError({ connection, code: error.code })
227+
228+
if (error.code === 'Neo.ClientError.Security.AuthorizationExpired') {
229+
this._connectionPool.apply(address, (conn) => { conn.authToken = null })
230+
}
231+
232+
if (connection) {
233+
connection.close().catch(() => undefined)
234+
}
235+
236+
if (error.code === 'Neo.ClientError.Security.TokenExpired' && !isStaticAuthTokenManger(this._authTokenManager)) {
237+
error.retriable = true
238+
}
239+
240+
return error
241+
}
149242
}

0 commit comments

Comments
 (0)