Skip to content

Commit b368b84

Browse files
authored
feat: allow using existing OAuth token for JDBC connection (#37)
* feat: allow using existing OAuth token for JDBC connection Allow the user to specify an existing OAuth token to use for a JDBC connection, instead of requiring the user to specify a credentials file or using the default credentials of the environment. Fixes #29 * tests: fix test cases * fix: remove unused method * fix: use default credentials when no file found * fix: fix faulty merge
1 parent 6cdfb75 commit b368b84

File tree

5 files changed

+161
-16
lines changed

5 files changed

+161
-16
lines changed

src/main/java/com/google/cloud/spanner/jdbc/ConnectionOptions.java

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package com.google.cloud.spanner.jdbc;
1818

1919
import com.google.auth.Credentials;
20+
import com.google.auth.oauth2.AccessToken;
2021
import com.google.auth.oauth2.GoogleCredentials;
2122
import com.google.auth.oauth2.ServiceAccountCredentials;
2223
import com.google.cloud.NoCredentials;
@@ -140,6 +141,7 @@ public String[] getValidValues() {
140141
static final boolean DEFAULT_READONLY = false;
141142
static final boolean DEFAULT_RETRY_ABORTS_INTERNALLY = true;
142143
private static final String DEFAULT_CREDENTIALS = null;
144+
private static final String DEFAULT_OAUTH_TOKEN = null;
143145
private static final String DEFAULT_NUM_CHANNELS = null;
144146
private static final String DEFAULT_USER_AGENT = null;
145147

@@ -156,6 +158,10 @@ public String[] getValidValues() {
156158
public static final String RETRY_ABORTS_INTERNALLY_PROPERTY_NAME = "retryAbortsInternally";
157159
/** Name of the 'credentials' connection property. */
158160
public static final String CREDENTIALS_PROPERTY_NAME = "credentials";
161+
/**
162+
* OAuth token to use for authentication. Cannot be used in combination with a credentials file.
163+
*/
164+
public static final String OAUTH_TOKEN_PROPERTY_NAME = "oauthToken";
159165
/** Name of the 'numChannels' connection property. */
160166
public static final String NUM_CHANNELS_PROPERTY_NAME = "numChannels";
161167
/** Custom user agent string is only for other Google libraries. */
@@ -173,6 +179,7 @@ public String[] getValidValues() {
173179
ConnectionProperty.createBooleanProperty(
174180
RETRY_ABORTS_INTERNALLY_PROPERTY_NAME, "", DEFAULT_RETRY_ABORTS_INTERNALLY),
175181
ConnectionProperty.createStringProperty(CREDENTIALS_PROPERTY_NAME, ""),
182+
ConnectionProperty.createStringProperty(OAUTH_TOKEN_PROPERTY_NAME, ""),
176183
ConnectionProperty.createStringProperty(NUM_CHANNELS_PROPERTY_NAME, ""),
177184
ConnectionProperty.createBooleanProperty(
178185
USE_PLAIN_TEXT_PROPERTY_NAME, "", DEFAULT_USE_PLAIN_TEXT),
@@ -223,6 +230,7 @@ public static void closeSpanner() {
223230
public static class Builder {
224231
private String uri;
225232
private String credentialsUrl;
233+
private String oauthToken;
226234
private Credentials credentials;
227235
private List<StatementExecutionInterceptor> statementExecutionInterceptors =
228236
Collections.emptyList();
@@ -308,6 +316,22 @@ public Builder setCredentialsUrl(String credentialsUrl) {
308316
return this;
309317
}
310318

319+
/**
320+
* Sets the OAuth token to use with this connection. The token must be a valid token with access
321+
* to the resources (project/instance/database) that the connection will be accessing. This
322+
* authentication method cannot be used in combination with a credentials file. If both an OAuth
323+
* token and a credentials file is specified, the {@link #build()} method will throw an
324+
* exception.
325+
*
326+
* @param oauthToken A valid OAuth token for the Google Cloud project that is used by this
327+
* connection.
328+
* @return this builder
329+
*/
330+
public Builder setOAuthToken(String oauthToken) {
331+
this.oauthToken = oauthToken;
332+
return this;
333+
}
334+
311335
@VisibleForTesting
312336
Builder setStatementExecutionInterceptors(List<StatementExecutionInterceptor> interceptors) {
313337
this.statementExecutionInterceptors = interceptors;
@@ -339,6 +363,7 @@ public static Builder newBuilder() {
339363

340364
private final String uri;
341365
private final String credentialsUrl;
366+
private final String oauthToken;
342367

343368
private final boolean usePlainText;
344369
private final String host;
@@ -363,6 +388,13 @@ private ConnectionOptions(Builder builder) {
363388
this.uri = builder.uri;
364389
this.credentialsUrl =
365390
builder.credentialsUrl != null ? builder.credentialsUrl : parseCredentials(builder.uri);
391+
this.oauthToken =
392+
builder.oauthToken != null ? builder.oauthToken : parseOAuthToken(builder.uri);
393+
// Check that not both credentials and an OAuth token have been specified.
394+
Preconditions.checkArgument(
395+
(builder.credentials == null && this.credentialsUrl == null) || this.oauthToken == null,
396+
"Cannot specify both credentials and an OAuth token.");
397+
366398
this.usePlainText = parseUsePlainText(this.uri);
367399
this.userAgent = parseUserAgent(this.uri);
368400

@@ -376,8 +408,13 @@ private ConnectionOptions(Builder builder) {
376408
// Using credentials on a plain text connection is not allowed, so if the user has not specified
377409
// any credentials and is using a plain text connection, we should not try to get the
378410
// credentials from the environment, but default to NoCredentials.
379-
if (builder.credentials == null && this.credentialsUrl == null && this.usePlainText) {
411+
if (builder.credentials == null
412+
&& this.credentialsUrl == null
413+
&& this.oauthToken == null
414+
&& this.usePlainText) {
380415
this.credentials = NoCredentials.getInstance();
416+
} else if (this.oauthToken != null) {
417+
this.credentials = new GoogleCredentials(new AccessToken(oauthToken, null));
381418
} else {
382419
this.credentials =
383420
builder.credentials == null
@@ -446,6 +483,12 @@ static String parseCredentials(String uri) {
446483
return value != null ? value : DEFAULT_CREDENTIALS;
447484
}
448485

486+
@VisibleForTesting
487+
static String parseOAuthToken(String uri) {
488+
String value = parseUriProperty(uri, OAUTH_TOKEN_PROPERTY_NAME);
489+
return value != null ? value : DEFAULT_OAUTH_TOKEN;
490+
}
491+
449492
@VisibleForTesting
450493
static String parseNumChannels(String uri) {
451494
String value = parseUriProperty(uri, NUM_CHANNELS_PROPERTY_NAME);

src/main/java/com/google/cloud/spanner/jdbc/JdbcDriver.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@
7474
* <li>credentials (String): URL for the credentials file to use for the connection. If you do not
7575
* specify any credentials at all, the default credentials of the environment as returned by
7676
* {@link GoogleCredentials#getApplicationDefault()} will be used.
77+
* <li>oauthtoken (String): A valid OAuth2 token to use for the JDBC connection. The token must
78+
* have been obtained with one or both of the scopes
79+
* 'https://www.googleapis.com/auth/spanner.admin' and/or
80+
* 'https://www.googleapis.com/auth/spanner.data'. If you specify both a credentials file and
81+
* an OAuth token, the JDBC driver will throw an exception when you try to obtain a
82+
* connection.
7783
* <li>autocommit (boolean): Sets the initial autocommit mode for the connection. Default is true.
7884
* <li>readonly (boolean): Sets the initial readonly mode for the connection. Default is false.
7985
* <li>retryAbortsInternally (boolean): Sets the initial retryAbortsInternally mode for the

src/test/java/com/google/cloud/spanner/jdbc/ConnectionOptionsTest.java

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import static com.google.common.truth.Truth.assertThat;
2020
import static org.junit.Assert.fail;
2121

22+
import com.google.auth.oauth2.GoogleCredentials;
2223
import com.google.auth.oauth2.ServiceAccountCredentials;
2324
import com.google.cloud.spanner.SpannerOptions;
2425
import java.util.Arrays;
@@ -323,4 +324,66 @@ public void testParsePropertiesSpecifiedMultipleTimes() {
323324
"cloudspanner:/projects/test-project-123/instances/test-instance/databases/test-database"
324325
+ ";autocommit=false;readonly=false;autocommit=true");
325326
}
327+
328+
@Test
329+
public void testParseOAuthToken() {
330+
assertThat(
331+
ConnectionOptions.parseUriProperty(
332+
"cloudspanner:/projects/test-project-123/instances/test-instance/databases/test-database"
333+
+ "?oauthtoken=RsT5OjbzRn430zqMLgV3Ia",
334+
"OAuthToken"))
335+
.isEqualTo("RsT5OjbzRn430zqMLgV3Ia");
336+
// Try to use both credentials and an OAuth token. That should fail.
337+
ConnectionOptions.Builder builder =
338+
ConnectionOptions.newBuilder()
339+
.setUri(
340+
"cloudspanner:/projects/test-project-123/instances/test-instance/databases/test-database"
341+
+ "?OAuthToken=RsT5OjbzRn430zqMLgV3Ia;credentials=/path/to/credentials.json");
342+
try {
343+
builder.build();
344+
fail("missing expected exception");
345+
} catch (IllegalArgumentException e) {
346+
assertThat(e.getMessage()).contains("Cannot specify both credentials and an OAuth token");
347+
}
348+
349+
// Now try to use only an OAuth token.
350+
builder =
351+
ConnectionOptions.newBuilder()
352+
.setUri(
353+
"cloudspanner:/projects/test-project-123/instances/test-instance/databases/test-database"
354+
+ "?OAuthToken=RsT5OjbzRn430zqMLgV3Ia");
355+
ConnectionOptions options = builder.build();
356+
assertThat(options.getCredentials()).isInstanceOf(GoogleCredentials.class);
357+
GoogleCredentials credentials = (GoogleCredentials) options.getCredentials();
358+
assertThat(credentials.getAccessToken().getTokenValue()).isEqualTo("RsT5OjbzRn430zqMLgV3Ia");
359+
}
360+
361+
@Test
362+
public void testSetOAuthToken() {
363+
ConnectionOptions options =
364+
ConnectionOptions.newBuilder()
365+
.setUri(
366+
"cloudspanner:/projects/test-project-123/instances/test-instance/databases/test-database")
367+
.setOAuthToken("RsT5OjbzRn430zqMLgV3Ia")
368+
.build();
369+
assertThat(options.getCredentials()).isInstanceOf(GoogleCredentials.class);
370+
GoogleCredentials credentials = (GoogleCredentials) options.getCredentials();
371+
assertThat(credentials.getAccessToken()).isNotNull();
372+
assertThat(credentials.getAccessToken().getTokenValue()).isEqualTo("RsT5OjbzRn430zqMLgV3Ia");
373+
}
374+
375+
@Test
376+
public void testSetOAuthTokenAndCredentials() {
377+
try {
378+
ConnectionOptions.newBuilder()
379+
.setUri(
380+
"cloudspanner:/projects/test-project-123/instances/test-instance/databases/test-database")
381+
.setOAuthToken("RsT5OjbzRn430zqMLgV3Ia")
382+
.setCredentialsUrl(FILE_TEST_PATH)
383+
.build();
384+
fail("missing expected exception");
385+
} catch (IllegalArgumentException e) {
386+
assertThat(e.getMessage()).contains("Cannot specify both credentials and an OAuth token");
387+
}
388+
}
326389
}

src/test/java/com/google/cloud/spanner/jdbc/JdbcDriverTest.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package com.google.cloud.spanner.jdbc;
1818

1919
import static com.google.common.truth.Truth.assertThat;
20+
import static org.junit.Assert.fail;
2021

2122
import com.google.cloud.spanner.MockSpannerServiceImpl;
2223
import io.grpc.Server;
@@ -87,4 +88,17 @@ public void testInvalidConnect() throws SQLException {
8788
assertThat(connection.isClosed()).isFalse();
8889
}
8990
}
91+
92+
@Test
93+
public void testConnectWithCredentialsAndOAuthToken() throws SQLException {
94+
try (Connection connection =
95+
DriverManager.getConnection(
96+
String.format(
97+
"jdbc:cloudspanner://localhost:%d/projects/test-project/instances/static-test-instance/databases/test-database;usePlainText=true;credentials=%s;OAuthToken=%s",
98+
server.getPort(), TEST_KEY_PATH, "some-token"))) {
99+
fail("missing expected exception");
100+
} catch (SQLException e) {
101+
assertThat(e.getMessage()).contains("Cannot specify both credentials and an OAuth token");
102+
}
103+
}
90104
}

src/test/java/com/google/cloud/spanner/jdbc/it/ITJdbcConnectTest.java

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,16 @@
1616

1717
package com.google.cloud.spanner.jdbc.it;
1818

19-
import static org.hamcrest.CoreMatchers.equalTo;
20-
import static org.hamcrest.CoreMatchers.is;
21-
import static org.hamcrest.MatcherAssert.assertThat;
19+
import static com.google.common.truth.Truth.assertThat;
2220

21+
import com.google.auth.oauth2.AccessToken;
22+
import com.google.auth.oauth2.GoogleCredentials;
2323
import com.google.cloud.spanner.IntegrationTest;
24+
import com.google.cloud.spanner.SpannerOptions;
2425
import com.google.cloud.spanner.jdbc.CloudSpannerJdbcConnection;
2526
import com.google.cloud.spanner.jdbc.ITAbstractJdbcTest;
2627
import com.google.cloud.spanner.jdbc.JdbcDataSource;
28+
import java.io.FileInputStream;
2729
import java.sql.Connection;
2830
import java.sql.DriverManager;
2931
import java.sql.ResultSet;
@@ -57,30 +59,30 @@ private String createBaseUrl() {
5759
}
5860

5961
private void testDefaultConnection(Connection connection) throws SQLException {
60-
assertThat(connection.isWrapperFor(CloudSpannerJdbcConnection.class), is(true));
62+
assertThat(connection.isWrapperFor(CloudSpannerJdbcConnection.class)).isTrue();
6163
CloudSpannerJdbcConnection cs = connection.unwrap(CloudSpannerJdbcConnection.class);
62-
assertThat(cs.getAutoCommit(), is(true));
63-
assertThat(cs.isReadOnly(), is(false));
64+
assertThat(cs.getAutoCommit()).isTrue();
65+
assertThat(cs.isReadOnly()).isFalse();
6466
try (ResultSet rs = connection.createStatement().executeQuery("SELECT 1")) {
65-
assertThat(rs.next(), is(true));
66-
assertThat(rs.getInt(1), is(equalTo(1)));
67+
assertThat(rs.next()).isTrue();
68+
assertThat(rs.getInt(1)).isEqualTo(1);
6769
}
6870
cs.setAutoCommit(false);
69-
assertThat(cs.isRetryAbortsInternally(), is(true));
71+
assertThat(cs.isRetryAbortsInternally()).isTrue();
7072
}
7173

7274
private void testNonDefaultConnection(Connection connection) throws SQLException {
73-
assertThat(connection.isWrapperFor(CloudSpannerJdbcConnection.class), is(true));
75+
assertThat(connection.isWrapperFor(CloudSpannerJdbcConnection.class)).isTrue();
7476
CloudSpannerJdbcConnection cs = connection.unwrap(CloudSpannerJdbcConnection.class);
75-
assertThat(cs.getAutoCommit(), is(false));
76-
assertThat(cs.isReadOnly(), is(true));
77+
assertThat(cs.getAutoCommit()).isFalse();
78+
assertThat(cs.isReadOnly()).isTrue();
7779
try (ResultSet rs = connection.createStatement().executeQuery("SELECT 1")) {
78-
assertThat(rs.next(), is(true));
79-
assertThat(rs.getInt(1), is(equalTo(1)));
80+
assertThat(rs.next()).isTrue();
81+
assertThat(rs.getInt(1)).isEqualTo(1);
8082
}
8183
connection.commit();
8284
cs.setReadOnly(false);
83-
assertThat(cs.isRetryAbortsInternally(), is(false));
85+
assertThat(cs.isRetryAbortsInternally()).isFalse();
8486
}
8587

8688
@Test
@@ -194,4 +196,21 @@ public void testConnectWithDataSourceWithConflictingValues() throws SQLException
194196
testNonDefaultConnection(connection);
195197
}
196198
}
199+
200+
@Test
201+
public void testConnectWithOAuthToken() throws Exception {
202+
GoogleCredentials credentials;
203+
if (hasValidKeyFile()) {
204+
credentials = GoogleCredentials.fromStream(new FileInputStream(getKeyFile()));
205+
} else {
206+
credentials = GoogleCredentials.getApplicationDefault();
207+
}
208+
credentials = credentials.createScoped(SpannerOptions.getDefaultInstance().getScopes());
209+
AccessToken token = credentials.refreshAccessToken();
210+
String urlWithOAuth = createBaseUrl() + "?OAuthToken=" + token.getTokenValue();
211+
try (Connection connectionWithOAuth = DriverManager.getConnection(urlWithOAuth)) {
212+
// Try to do a query using the connection created with an OAuth token.
213+
testDefaultConnection(connectionWithOAuth);
214+
}
215+
}
197216
}

0 commit comments

Comments
 (0)