Skip to content

Commit 55930b4

Browse files
authored
feat(spanner): auth login support for Spanner Omni endpoints (googleapis#13470)
**⚠️ Note to Reviewers:** > **Of the ~14k lines changed in this PR, approximately ~12.5k lines are auto-generated protobuf and gRPC classes.** > Specifically, the files `Login.java`, `Authentication.java`, and `LoginServiceGrpc.java` account for the vast majority of the diff. > > The actual hand-written logic is small and contained within the core auth implementation and connection classes. This PR introduces native authentication support for Spanner Omni endpoints using the OPAQUE password-authenticated key exchange protocol. **Key Changes:** * **Omni Login Protocol:** Added generated protobufs (`Login.java`, `Authentication.java`, `LoginServiceGrpc.java`) and a gRPC `LoginClient` to handle the authentication handshake with Omni endpoints. * **LoginClient & OPAQUE Protocol**: Implements the `LoginClient` utilizing `OpaqueUtil` to perform the secure two-step OPAQUE authentication flow over gRPC. * **SpannerOmniCredentials**: A new credentials provider that manages Omni authentication tokens, incorporating automatic background token refresh mechanisms. * **Security Considerations**: Securely handles raw passwords using char arrays, ensuring sensitive credentials are zeroed out of memory buffers immediately after processing. * **Client Integration:** Updated `ConnectionOptions` and `SpannerOptions.Builder` with a new `login(username, password)` method to wire up Omni credentials. The channel initialization logic was moved to `prepareBuilder()` to ensure the builder pattern remains order-independent. To run Integration Tests with auth login run below command with default username/password ``` mvn clean -pl java-spanner/google-cloud-spanner -B verify \ -DskipUnitTests=true \ -DskipITs=false \ -Dspanner.omni.host=https://localhost:15000 \ -Dspanner.testenv.instance=projects/default/instances/default \ -Denforcer.skip=true \ -Dspanner.username=admin \ -Dspanner.password=admin ``` **Design Document:** [Spanner Omni Auth Login](https://docs.google.com/document/d/1hFxZmS1WYIW58qIVN2QO5kkfa8sXvxiLtgE395gEW60/edit?resourcekey=0-NLqNwe9NOCoJ1wnE671gTA&tab=t.0) ***
1 parent 6cf0567 commit 55930b4

16 files changed

Lines changed: 14031 additions & 4 deletions

File tree

java-spanner/google-cloud-spanner/pom.xml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,15 @@
532532
<version>2.94.0-SNAPSHOT</version><!-- {x-version-update:proto-google-cloud-trace-v1:current} -->
533533
<scope>test</scope>
534534
</dependency>
535+
<dependency>
536+
<groupId>org.bouncycastle</groupId>
537+
<artifactId>bcprov-jdk18on</artifactId>
538+
</dependency>
539+
<dependency>
540+
<groupId>com.google.crypto.tink</groupId>
541+
<artifactId>tink</artifactId>
542+
<version>1.13.0</version>
543+
</dependency>
535544
</dependencies>
536545
<profiles>
537546
<profile>

java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
import com.google.cloud.spanner.admin.database.v1.stub.DatabaseAdminStubSettings;
5454
import com.google.cloud.spanner.admin.instance.v1.InstanceAdminSettings;
5555
import com.google.cloud.spanner.admin.instance.v1.stub.InstanceAdminStubSettings;
56+
import com.google.cloud.spanner.omni.SpannerOmniCredentials;
5657
import com.google.cloud.spanner.spi.SpannerRpcFactory;
5758
import com.google.cloud.spanner.spi.v1.ChannelEndpointCacheFactory;
5859
import com.google.cloud.spanner.spi.v1.GapicSpannerRpc;
@@ -66,6 +67,7 @@
6667
import com.google.common.collect.ImmutableMap;
6768
import com.google.common.collect.ImmutableSet;
6869
import com.google.common.util.concurrent.ThreadFactoryBuilder;
70+
import com.google.crypto.tink.util.SecretBytes;
6971
import com.google.spanner.v1.DirectedReadOptions;
7072
import com.google.spanner.v1.ExecuteSqlRequest;
7173
import com.google.spanner.v1.ExecuteSqlRequest.QueryOptions;
@@ -1239,8 +1241,22 @@ private static Builder prepareBuilder(Builder builder) {
12391241
builder.sessionPoolOptions =
12401242
builder.sessionPoolOptions.toBuilder().setExperimentalHost().build();
12411243
}
1242-
if (builder.credentials == null) {
1243-
builder.setCredentials(environment.getDefaultSpannerOmniCredentials());
1244+
if (builder.username != null && builder.secretBytes != null) {
1245+
builder.setCredentials(
1246+
new SpannerOmniCredentials(builder.username, builder.secretBytes, builder.host));
1247+
} else if (builder.credentials == null) {
1248+
GoogleCredentials defaultCreds = environment.getDefaultSpannerOmniCredentials();
1249+
if (defaultCreds != null) {
1250+
builder.setCredentials(defaultCreds);
1251+
}
1252+
}
1253+
if (builder.credentials instanceof SpannerOmniCredentials) {
1254+
((SpannerOmniCredentials) builder.credentials)
1255+
.initChannel(builder.usePlainText, builder.mTLSContext);
1256+
}
1257+
} else {
1258+
if (builder.username != null || builder.secretBytes != null) {
1259+
throw new IllegalStateException("login() can only be used with InstanceType.OMNI.");
12441260
}
12451261
}
12461262
return builder;
@@ -1296,6 +1312,8 @@ private static Builder prepareBuilder(Builder builder) {
12961312
DEFAULT_ADMIN_REQUESTS_LIMIT_EXCEEDED_RETRY_SETTINGS;
12971313
private boolean autoThrottleAdministrativeRequests = false;
12981314
private boolean trackTransactionStarter = false;
1315+
private String username;
1316+
private SecretBytes secretBytes;
12991317
private Map<DatabaseId, QueryOptions> defaultQueryOptions = new HashMap<>();
13001318
private boolean enableGrpcGcpOtelMetrics =
13011319
SpannerOptions.environment.isEnableGrpcGcpOtelMetrics();
@@ -1910,6 +1928,28 @@ public Builder setType(InstanceType instanceType) {
19101928
return this;
19111929
}
19121930

1931+
/**
1932+
* Authenticates to Spanner Omni using the provided username and password, and configures the
1933+
* resulting token for use in subsequent Spanner API calls.
1934+
*
1935+
* <p>Note: The provided {@code password} array will be cleared (zeroed out) by this method for
1936+
* security purposes.
1937+
*
1938+
* @param username The username for login.
1939+
* @param password The password for login.
1940+
* @return this builder
1941+
*/
1942+
public Builder login(String username, char[] password) {
1943+
Preconditions.checkArgument(
1944+
username != null && !username.isEmpty(), "username cannot be null or empty");
1945+
Preconditions.checkArgument(
1946+
password != null && password.length > 0, "password cannot be null or empty");
1947+
1948+
this.username = username;
1949+
this.secretBytes = SpannerOmniCredentials.convertToSecretBytes(password);
1950+
return this;
1951+
}
1952+
19131953
/** Enables gRPC-GCP extension with the default settings. This option is enabled by default. */
19141954
public Builder enableGrpcGcpExtension() {
19151955
return this.enableGrpcGcpExtension(null);

java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionOptions.java

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,14 @@
8989
import com.google.cloud.spanner.SpannerOptions;
9090
import com.google.cloud.spanner.connection.ClientSideStatementValueConverters.GrpcInterceptorProviderConverter;
9191
import com.google.cloud.spanner.connection.StatementExecutor.StatementExecutorType;
92+
import com.google.cloud.spanner.omni.SpannerOmniCredentials;
9293
import com.google.common.annotations.VisibleForTesting;
9394
import com.google.common.base.MoreObjects;
9495
import com.google.common.base.Preconditions;
9596
import com.google.common.base.Strings;
9697
import com.google.common.base.Suppliers;
9798
import com.google.common.collect.ImmutableMap;
99+
import com.google.crypto.tink.util.SecretBytes;
98100
import io.grpc.Deadline;
99101
import io.grpc.Deadline.Ticker;
100102
import io.opentelemetry.api.OpenTelemetry;
@@ -154,6 +156,8 @@ public class ConnectionOptions {
154156
static final boolean DEFAULT_USE_PLAIN_TEXT = false;
155157
static final boolean DEFAULT_IS_EXPERIMENTAL_HOST = false;
156158
static final SpannerOptions.InstanceType DEFAULT_TYPE = SpannerOptions.InstanceType.CLOUD;
159+
static final String DEFAULT_USERNAME = "";
160+
static final String DEFAULT_PASSWORD = "";
157161
static final boolean DEFAULT_AUTOCOMMIT = true;
158162
static final boolean DEFAULT_READONLY = false;
159163
static final boolean DEFAULT_RETRY_ABORTS_INTERNALLY = true;
@@ -224,6 +228,12 @@ public class ConnectionOptions {
224228
/** The type of Spanner instance to connect to (cloud, omni, or emulator). */
225229
public static final String TYPE_PROPERTY_NAME = "type";
226230

231+
/** Username for OPAQUE login */
232+
public static final String USERNAME_PROPERTY_NAME = "username";
233+
234+
/** Password for OPAQUE login */
235+
public static final String PASSWORD_PROPERTY_NAME = "password";
236+
227237
/** Client certificate path to establish mTLS */
228238
static final String CLIENT_CERTIFICATE_PROPERTY_NAME = "clientCertificate";
229239

@@ -775,6 +785,8 @@ private ConnectionOptions(Builder builder) {
775785
System.getenv());
776786
GoogleCredentials defaultSpannerOmniCredentials =
777787
SpannerOptions.getDefaultSpannerOmniCredentialsFromSysEnv();
788+
String username = getInitialConnectionPropertyValue(ConnectionProperties.USERNAME);
789+
String password = getInitialConnectionPropertyValue(ConnectionProperties.PASSWORD);
778790
// Using credentials on a plain text connection is not allowed, so if the user has not specified
779791
// any credentials and is using a plain text connection, we should not try to get the
780792
// credentials from the environment, but default to NoCredentials.
@@ -783,14 +795,29 @@ && getInitialConnectionPropertyValue(CREDENTIALS_URL) == null
783795
&& getInitialConnectionPropertyValue(ENCODED_CREDENTIALS) == null
784796
&& getInitialConnectionPropertyValue(CREDENTIALS_PROVIDER) == null
785797
&& getInitialConnectionPropertyValue(OAUTH_TOKEN) == null
798+
&& Strings.isNullOrEmpty(getInitialConnectionPropertyValue(ConnectionProperties.USERNAME))
786799
&& usePlainText) {
787800
this.credentials = NoCredentials.getInstance();
788801
} else if (getInitialConnectionPropertyValue(OAUTH_TOKEN) != null) {
789802
this.credentials =
790803
new GoogleCredentials(
791804
new AccessToken(getInitialConnectionPropertyValue(OAUTH_TOKEN), null));
792-
} else if ((isSpannerOmniPattern || isSpannerOmni()) && defaultSpannerOmniCredentials != null) {
793-
this.credentials = defaultSpannerOmniCredentials;
805+
} else if (isSpannerOmniPattern || isSpannerOmni()) {
806+
if (!Strings.isNullOrEmpty(username) && !Strings.isNullOrEmpty(password)) {
807+
SecretBytes secretBytes =
808+
SpannerOmniCredentials.convertToSecretBytes(password.toCharArray());
809+
this.credentials = new SpannerOmniCredentials(username, secretBytes, this.host);
810+
// Clear the password from the initial connection state to allow it to be GC'd.
811+
this.initialConnectionState.setValue(
812+
ConnectionProperties.PASSWORD,
813+
DEFAULT_PASSWORD,
814+
ConnectionProperty.Context.STARTUP,
815+
/* inTransaction= */ false);
816+
} else if (defaultSpannerOmniCredentials != null) {
817+
this.credentials = defaultSpannerOmniCredentials;
818+
} else {
819+
this.credentials = NoCredentials.getInstance();
820+
}
794821
} else if (getInitialConnectionPropertyValue(CREDENTIALS_PROVIDER) != null) {
795822
try {
796823
this.credentials = getInitialConnectionPropertyValue(CREDENTIALS_PROVIDER).getCredentials();

java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionProperties.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,20 @@ public class ConnectionProperties {
279279
},
280280
InstanceTypeConverter.INSTANCE,
281281
Context.STARTUP);
282+
static final ConnectionProperty<String> USERNAME =
283+
create(
284+
ConnectionOptions.USERNAME_PROPERTY_NAME,
285+
"The username to use for OPAQUE login.",
286+
ConnectionOptions.DEFAULT_USERNAME,
287+
StringValueConverter.INSTANCE,
288+
Context.STARTUP);
289+
static final ConnectionProperty<String> PASSWORD =
290+
create(
291+
ConnectionOptions.PASSWORD_PROPERTY_NAME,
292+
"The password to use for OPAQUE login.",
293+
ConnectionOptions.DEFAULT_PASSWORD,
294+
StringValueConverter.INSTANCE,
295+
Context.STARTUP);
282296
static final ConnectionProperty<String> CLIENT_CERTIFICATE =
283297
create(
284298
CLIENT_CERTIFICATE_PROPERTY_NAME,

0 commit comments

Comments
 (0)