Skip to content
Permalink
Browse files
feat: support encoded credentials in connection URL (#1223)
This enables a user to specify a base64 encoded JSON string that contains the credentials
that should be used for the connection. This removes the requirement to write the JSON
string to a file before it can be used for a connection.

Fixes googleapis/java-spanner-jdbc#486
  • Loading branch information
olavloite committed May 31, 2021
1 parent 4856f82 commit 43d5d7e8d7fc1b0304a6fcf940846fe269fd661a
@@ -174,6 +174,8 @@ public String[] getValidValues() {
public static final String RETRY_ABORTS_INTERNALLY_PROPERTY_NAME = "retryAbortsInternally";
/** Name of the 'credentials' connection property. */
public static final String CREDENTIALS_PROPERTY_NAME = "credentials";
/** Name of the 'encodedCredentials' connection property. */
public static final String ENCODED_CREDENTIALS_PROPERTY_NAME = "encodedCredentials";
/**
* OAuth token to use for authentication. Cannot be used in combination with a credentials file.
*/
@@ -210,7 +212,10 @@ public String[] getValidValues() {
DEFAULT_RETRY_ABORTS_INTERNALLY),
ConnectionProperty.createStringProperty(
CREDENTIALS_PROPERTY_NAME,
"The location of the credentials file to use for this connection. If this property is not set, the connection will use the default Google Cloud credentials for the runtime environment."),
"The location of the credentials file to use for this connection. If neither this property or encoded credentials are set, the connection will use the default Google Cloud credentials for the runtime environment."),
ConnectionProperty.createStringProperty(
ENCODED_CREDENTIALS_PROPERTY_NAME,
"Base64-encoded credentials to use for this connection. If neither this property or a credentials location are set, the connection will use the default Google Cloud credentials for the runtime environment."),
ConnectionProperty.createStringProperty(
OAUTH_TOKEN_PROPERTY_NAME,
"A valid pre-existing OAuth token to use for authentication for this connection. Setting this property will take precedence over any value set for a credentials file."),
@@ -344,6 +349,9 @@ private boolean isValidUri(String uri) {
* ConnectionOptions.Builder#setCredentialsUrl(String)} method. If you do not specify any
* credentials at all, the default credentials of the environment as returned by {@link
* GoogleCredentials#getApplicationDefault()} will be used.
* <li>encodedCredentials (String): A Base64 encoded string containing the Google credentials
* to use. You should only set either this property or the `credentials` (file location)
* property, but not both at the same time.
* <li>autocommit (boolean): Sets the initial autocommit mode for the connection. Default is
* true.
* <li>readonly (boolean): Sets the initial readonly mode for the connection. Default is
@@ -458,6 +466,7 @@ public static Builder newBuilder() {
private final String uri;
private final String warnings;
private final String credentialsUrl;
private final String encodedCredentials;
private final String oauthToken;
private final Credentials fixedCredentials;

@@ -491,12 +500,22 @@ private ConnectionOptions(Builder builder) {
this.uri = builder.uri;
this.credentialsUrl =
builder.credentialsUrl != null ? builder.credentialsUrl : parseCredentials(builder.uri);
this.encodedCredentials = parseEncodedCredentials(builder.uri);
// Check that not both a credentials location and encoded credentials have been specified in the
// connection URI.
Preconditions.checkArgument(
this.credentialsUrl == null || this.encodedCredentials == null,
"Cannot specify both a credentials URL and encoded credentials. Only set one of the properties.");

this.oauthToken =
builder.oauthToken != null ? builder.oauthToken : parseOAuthToken(builder.uri);
this.fixedCredentials = builder.credentials;
// Check that not both credentials and an OAuth token have been specified.
Preconditions.checkArgument(
(builder.credentials == null && this.credentialsUrl == null) || this.oauthToken == null,
(builder.credentials == null
&& this.credentialsUrl == null
&& this.encodedCredentials == null)
|| this.oauthToken == null,
"Cannot specify both credentials and an OAuth token.");

this.userAgent = parseUserAgent(this.uri);
@@ -515,13 +534,16 @@ private ConnectionOptions(Builder builder) {
// credentials from the environment, but default to NoCredentials.
if (builder.credentials == null
&& this.credentialsUrl == null
&& this.encodedCredentials == null
&& this.oauthToken == null
&& this.usePlainText) {
this.credentials = NoCredentials.getInstance();
} else if (this.oauthToken != null) {
this.credentials = new GoogleCredentials(new AccessToken(oauthToken, null));
} else if (this.fixedCredentials != null) {
this.credentials = fixedCredentials;
} else if (this.encodedCredentials != null) {
this.credentials = getCredentialsService().decodeCredentials(this.encodedCredentials);
} else {
this.credentials = getCredentialsService().createCredentials(this.credentialsUrl);
}
@@ -632,6 +654,11 @@ static String parseCredentials(String uri) {
return value != null ? value : DEFAULT_CREDENTIALS;
}

@VisibleForTesting
static String parseEncodedCredentials(String uri) {
return parseUriProperty(uri, ENCODED_CREDENTIALS_PROPERTY_NAME);
}

@VisibleForTesting
static String parseOAuthToken(String uri) {
String value = parseUriProperty(uri, OAUTH_TOKEN_PROPERTY_NAME);
@@ -22,6 +22,8 @@
import com.google.cloud.spanner.SpannerExceptionFactory;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.io.BaseEncoding;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
@@ -70,6 +72,27 @@ GoogleCredentials createCredentials(String credentialsUrl) {
}
}

GoogleCredentials decodeCredentials(String encodedCredentials) {
byte[] decodedBytes;
try {
decodedBytes = BaseEncoding.base64Url().decode(encodedCredentials);
} catch (IllegalArgumentException e) {
throw SpannerExceptionFactory.newSpannerException(
ErrorCode.INVALID_ARGUMENT,
"The encoded credentials could not be decoded as a base64 string. "
+ "Please ensure that the provided string is a valid base64 string.",
e);
}
try {
return GoogleCredentials.fromStream(new ByteArrayInputStream(decodedBytes));
} catch (IllegalArgumentException | IOException e) {
throw SpannerExceptionFactory.newSpannerException(
ErrorCode.INVALID_ARGUMENT,
"The encoded credentials do not contain a valid Google Cloud credentials JSON string.",
e);
}
}

@VisibleForTesting
GoogleCredentials internalGetApplicationDefault() throws IOException {
return GoogleCredentials.getApplicationDefault();
@@ -18,6 +18,7 @@

import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;

@@ -27,6 +28,10 @@
import com.google.cloud.spanner.ErrorCode;
import com.google.cloud.spanner.SpannerException;
import com.google.cloud.spanner.SpannerOptions;
import com.google.common.io.BaseEncoding;
import com.google.common.io.Files;
import java.io.File;
import java.io.UnsupportedEncodingException;
import java.util.Arrays;
import java.util.Collections;
import org.junit.Test;
@@ -509,4 +514,63 @@ public void testInvalidCredentials() {
.contains("Invalid credentials path specified: /some/non/existing/path");
}
}

@Test
public void testNonBase64EncodedCredentials() {
String uri =
"cloudspanner:/projects/test-project/instances/test-instance/databases/test-database?encodedCredentials=not-a-base64-string/";
SpannerException e =
assertThrows(
SpannerException.class, () -> ConnectionOptions.newBuilder().setUri(uri).build());
assertEquals(ErrorCode.INVALID_ARGUMENT, e.getErrorCode());
assertThat(e.getMessage())
.contains("The encoded credentials could not be decoded as a base64 string.");
}

@Test
public void testInvalidEncodedCredentials() throws UnsupportedEncodingException {
String uri =
String.format(
"cloudspanner:/projects/test-project/instances/test-instance/databases/test-database?encodedCredentials=%s",
BaseEncoding.base64Url().encode("not-a-credentials-JSON-string".getBytes("UTF-8")));
SpannerException e =
assertThrows(
SpannerException.class, () -> ConnectionOptions.newBuilder().setUri(uri).build());
assertEquals(ErrorCode.INVALID_ARGUMENT, e.getErrorCode());
assertThat(e.getMessage())
.contains(
"The encoded credentials do not contain a valid Google Cloud credentials JSON string.");
}

@Test
public void testValidEncodedCredentials() throws Exception {
String encoded =
BaseEncoding.base64Url().encode(Files.asByteSource(new File(FILE_TEST_PATH)).read());
String uri =
String.format(
"cloudspanner:/projects/test-project/instances/test-instance/databases/test-database?encodedCredentials=%s",
encoded);

ConnectionOptions options = ConnectionOptions.newBuilder().setUri(uri).build();
assertEquals(
new CredentialsService().createCredentials(FILE_TEST_PATH), options.getCredentials());
}

@Test
public void testSetCredentialsAndEncodedCredentials() throws Exception {
String encoded =
BaseEncoding.base64Url().encode(Files.asByteSource(new File(FILE_TEST_PATH)).read());
String uri =
String.format(
"cloudspanner:/projects/test-project/instances/test-instance/databases/test-database?credentials=%s;encodedCredentials=%s",
FILE_TEST_PATH, encoded);

IllegalArgumentException e =
assertThrows(
IllegalArgumentException.class,
() -> ConnectionOptions.newBuilder().setUri(uri).build());
assertThat(e.getMessage())
.contains(
"Cannot specify both a credentials URL and encoded credentials. Only set one of the properties.");
}
}

0 comments on commit 43d5d7e

Please sign in to comment.