Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support encoded credentials in connection URL #1223

Merged
merged 1 commit into from May 31, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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."),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Expand Up @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -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.");
}
}