diff --git a/pom.xml b/pom.xml
index 92a7a25e071..6fb34ccb198 100644
--- a/pom.xml
+++ b/pom.xml
@@ -145,6 +145,7 @@
proto-google-cloud-spanner-executor-v1
google-cloud-spanner-executor
google-cloud-spanner-bom
+ proto-google-cloud-spanner-host
diff --git a/proto-google-cloud-spanner-host/pom.xml b/proto-google-cloud-spanner-host/pom.xml
new file mode 100644
index 00000000000..dfd1fd9a248
--- /dev/null
+++ b/proto-google-cloud-spanner-host/pom.xml
@@ -0,0 +1,77 @@
+
+
+ 4.0.0
+
+ com.google.cloud
+ google-cloud-spanner-parent
+ 6.88.0
+
+
+ proto-google-cloud-spanner-host
+
+
+ 17
+ 17
+ UTF-8
+
+
+
+ io.grpc
+ grpc-protobuf
+
+
+ io.grpc
+ grpc-stub
+
+
+ com.google.protobuf
+ protobuf-java
+
+
+ org.bouncycastle
+ bcprov-jdk15on
+ 1.70
+
+
+ io.grpc
+ grpc-netty-shaded
+
+
+
+ junit
+ junit
+ test
+
+
+
+
+
+ org.xolstice.maven.plugins
+ protobuf-maven-plugin
+ 0.6.1
+
+ com.google.protobuf:protoc:LATEST:exe:${os.detected.classifier}
+ grpc-java
+ io.grpc:protoc-gen-grpc-java:LATEST:exe:${os.detected.classifier}
+
+
+
+
+ compile
+ compile-custom
+
+
+
+
+
+
+
+ kr.motd.maven
+ os-maven-plugin
+ 1.7.0
+
+
+
+
\ No newline at end of file
diff --git a/proto-google-cloud-spanner-host/src/main/java/com/google/cloud/GrpcClient.java b/proto-google-cloud-spanner-host/src/main/java/com/google/cloud/GrpcClient.java
new file mode 100644
index 00000000000..44c11d02bd9
--- /dev/null
+++ b/proto-google-cloud-spanner-host/src/main/java/com/google/cloud/GrpcClient.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2017 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud;
+
+import io.grpc.ChannelCredentials;
+import io.grpc.Grpc;
+import io.grpc.ManagedChannel;
+import io.grpc.TlsChannelCredentials;
+import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
+import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
+import java.io.File;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+import javax.net.ssl.SSLPeerUnverifiedException;
+import javax.net.ssl.SSLSocket;
+import javax.net.ssl.SSLSocketFactory;
+import spanner.experimental.LoginServiceGrpc;
+
+public class GrpcClient {
+
+ private final ManagedChannel channel;
+ private final LoginServiceGrpc.LoginServiceStub loginService;
+ private X509Certificate serverCertificate; // Store the server certificate
+ private final int DEFAULT_PORT = 15000;
+
+ public GrpcClient(String endpoint) throws IOException {
+ this(endpoint, null, null);
+ }
+
+ public GrpcClient(String endpoint, String clientCertificate, String clientCertificateKey) {
+ try {
+ URI uri = new URI(endpoint);
+ String host = uri.getHost();
+ int port = (uri.getPort() == -1) ? DEFAULT_PORT : uri.getPort();
+
+ if (host == null) {
+ throw new IllegalArgumentException("Invalid endpoint: " + endpoint);
+ }
+
+ // Retrieve the server certificate during handshake
+ this.serverCertificate = fetchServerCertificate(host, port);
+
+ if (clientCertificate != null && clientCertificateKey != null) {
+ this.channel =
+ NettyChannelBuilder.forAddress(host, port)
+ .sslContext(
+ GrpcSslContexts.forClient()
+ .keyManager(
+ new File(clientCertificate),
+ new File(clientCertificateKey)) // Client auth
+ .build())
+ .build();
+ } else {
+ // Normal TLS
+ ChannelCredentials credentials = TlsChannelCredentials.newBuilder().build();
+ this.channel = Grpc.newChannelBuilderForAddress(host, port, credentials).build();
+ }
+ this.loginService = LoginServiceGrpc.newStub(channel);
+ } catch (URISyntaxException | IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /** Establish a TLS connection to fetch the server certificate. */
+ private X509Certificate fetchServerCertificate(String host, int port) throws IOException {
+ try {
+ SSLSocketFactory sslSocketFactory = (SSLSocketFactory) SSLSocketFactory.getDefault();
+ try (SSLSocket socket = (SSLSocket) sslSocketFactory.createSocket(host, port)) {
+ socket.startHandshake();
+ Certificate[] serverCerts = socket.getSession().getPeerCertificates();
+ if (serverCerts.length > 0 && serverCerts[0] instanceof X509Certificate) {
+ return (X509Certificate) serverCerts[0]; // Store the first certificate
+ }
+ }
+ } catch (SSLPeerUnverifiedException e) {
+ throw new IOException("Failed to verify server certificate: " + e.getMessage(), e);
+ }
+ throw new IOException("No server certificate found");
+ }
+
+ public LoginServiceGrpc.LoginServiceStub getLoginService() {
+ return loginService;
+ }
+
+ public X509Certificate getServerCertificate() {
+ return serverCertificate;
+ }
+
+ public void close() {
+ if (channel != null) {
+ channel.shutdown();
+ }
+ }
+}
diff --git a/proto-google-cloud-spanner-host/src/main/java/com/google/cloud/LoginClient.java b/proto-google-cloud-spanner-host/src/main/java/com/google/cloud/LoginClient.java
new file mode 100644
index 00000000000..a7daefe357e
--- /dev/null
+++ b/proto-google-cloud-spanner-host/src/main/java/com/google/cloud/LoginClient.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright 2017 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud;
+
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.ReentrantLock;
+import spanner.experimental.AccessToken;
+
+public class LoginClient {
+
+ private final String username;
+ private final String password;
+ private final String endpoint;
+ private volatile AccessToken accessToken;
+ private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+ private final ReentrantLock refreshLock = new ReentrantLock();
+ private ScheduledFuture> scheduledTask; // Holds the scheduled task
+ private String clientCertificate = null;
+ private String clientCertificateKey = null;
+
+ private static final long TOKEN_REFRESH_THRESHOLD_SECONDS = 300; // Refresh 5 minutes before expiry
+
+ public LoginClient(String username, String password, String endpoint) throws Exception {
+ this(username, password, endpoint, null, null);
+ }
+
+ public LoginClient(
+ String username,
+ String password,
+ String endpoint,
+ String clientCertificate,
+ String clientCertificateKey) {
+ this.username = username;
+ this.password = password;
+ this.endpoint = endpoint;
+ if (clientCertificate != null && clientCertificateKey != null) {
+ this.clientCertificate = clientCertificate;
+ this.clientCertificateKey = clientCertificateKey;
+ }
+ login();
+ scheduleNextTokenRefresh();
+ }
+
+ private void login() {
+ try {
+ Scram scram =
+ new Scram(
+ this.username,
+ this.password,
+ new GrpcClient(this.endpoint, this.clientCertificate, this.clientCertificateKey));
+ this.accessToken = scram.login();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public AccessToken getAccessToken() {
+ return accessToken;
+ }
+
+ private void scheduleNextTokenRefresh() {
+ if (accessToken == null) return;
+ long delay =
+ (accessToken.getExpirationTime().getSeconds() - System.currentTimeMillis() / 1000)
+ - TOKEN_REFRESH_THRESHOLD_SECONDS;
+
+ if (delay <= 0) {
+ refreshToken();
+ return;
+ }
+ if (scheduledTask != null) {
+ scheduledTask.cancel(false);
+ }
+ // Schedule a new token refresh exactly when needed
+ scheduledTask = scheduler.schedule(this::refreshToken, delay, TimeUnit.SECONDS);
+ System.out.println("Next token refresh scheduled in " + delay + " seconds.");
+ }
+
+ private void refreshToken() {
+ if (!refreshLock.tryLock()) return; // Prevent multiple simultaneous refreshes
+
+ try {
+ System.out.println("Refreshing access token...");
+ login();
+ System.out.println("New token acquired.\n" + getAccessToken());
+ scheduleNextTokenRefresh();
+ } catch (Exception e) {
+ System.err.println("Token refresh failed: " + e.getMessage());
+ } finally {
+ refreshLock.unlock();
+ }
+ }
+
+ public void shutdown() {
+ System.out.println("Shutting down LoginClient...");
+ if (scheduledTask != null) {
+ scheduledTask.cancel(false); // Cancel any pending token refresh task
+ }
+ scheduler.shutdown();
+ }
+}
diff --git a/proto-google-cloud-spanner-host/src/main/java/com/google/cloud/Scram.java b/proto-google-cloud-spanner-host/src/main/java/com/google/cloud/Scram.java
new file mode 100644
index 00000000000..72a82ceee5f
--- /dev/null
+++ b/proto-google-cloud-spanner-host/src/main/java/com/google/cloud/Scram.java
@@ -0,0 +1,304 @@
+/*
+ * Copyright 2017 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.protobuf.ByteString;
+import io.grpc.stub.StreamObserver;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.security.cert.X509Certificate;
+import java.util.Arrays;
+import java.util.concurrent.CountDownLatch;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import org.bouncycastle.crypto.generators.Argon2BytesGenerator;
+import org.bouncycastle.crypto.params.Argon2Parameters;
+import spanner.experimental.AccessToken;
+import spanner.experimental.FinalScramRequest;
+import spanner.experimental.FinalScramResponse;
+import spanner.experimental.InitialScramRequest;
+import spanner.experimental.InitialScramResponse;
+import spanner.experimental.LoginRequest;
+import spanner.experimental.LoginResponse;
+import spanner.experimental.LoginServiceGrpc;
+
+public class Scram {
+
+ private final String username;
+ private final String password;
+ private final byte[] clientNonce;
+ private final X509Certificate certificate;
+ private byte[] saltedPassword;
+ private byte[] authMessage;
+ private byte[] serverNonce;
+ private byte[] salt;
+ private int iterationCount;
+ private GrpcClient grpcClient;
+
+ private static final int ARGON_MEMORY_LIMIT = 64 * 1024;
+ private static final int ARGON_THREADS = 4;
+ private static final int ARGON_KEY_LENGTH = 32;
+ private static final String CLIENT_KEY_MESSAGE = "Client Key";
+ private static final String SERVER_KEY_MESSAGE = "Server Key";
+ private static final String HMAC_SHA256 = "HmacSHA256";
+ private static final String SHA256 = "SHA-256";
+ private static final int NONCE_LENGTH = 16;
+
+ private final LoginServiceGrpc.LoginServiceStub loginService;
+
+ public Scram(String username, String password, GrpcClient grpcClient){
+ this.username = username;
+ this.password = password;
+ this.certificate = grpcClient.getServerCertificate();
+ this.clientNonce = nonce();
+ this.loginService = grpcClient.getLoginService();
+ this.grpcClient = grpcClient;
+ }
+
+ public AccessToken login() throws Exception {
+ InitialScramRequest initialScramRequest =
+ InitialScramRequest.newBuilder().setClientNonce(ByteString.copyFrom(clientNonce)).build();
+
+ final LoginRequest initialLoginRequest =
+ LoginRequest.newBuilder()
+ .setUsername(username)
+ .setInitialScramRequest(initialScramRequest)
+ .build();
+
+ AccessToken[] accessToken = new AccessToken[1];
+ final StreamObserver[] requestObserverContainer = new StreamObserver[1];
+ final CountDownLatch latch = new CountDownLatch(1);
+
+ requestObserverContainer[0] =
+ loginService.login(
+ new StreamObserver() {
+ @Override
+ public void onNext(LoginResponse loginResponse) {
+ try {
+ if (loginResponse.hasInitialScramResponse()) {
+ addServerFirstMessage(loginResponse.getInitialScramResponse());
+ byte[] clientProof =
+ clientProof(
+ initialLoginRequest.getInitialScramRequest(),
+ loginResponse.getInitialScramResponse());
+
+ LoginRequest finalLoginRequest =
+ LoginRequest.newBuilder()
+ .setUsername(username)
+ .setFinalScramRequest(
+ FinalScramRequest.newBuilder()
+ .setCredential(ByteString.copyFrom(clientProof))
+ .build())
+ .build();
+ requestObserverContainer[0].onNext(finalLoginRequest);
+
+ } else {
+ verifyLoginResponse(loginResponse.getFinalScramResponse());
+ accessToken[0] = loginResponse.getAccessToken();
+ latch.countDown(); // Signal completion
+ }
+ } catch (Exception e) {
+ System.err.println("Exception in onNext: " + e.getMessage());
+ requestObserverContainer[0].onError(e);
+ }
+ }
+
+ @Override
+ public void onError(Throwable t) {
+ System.err.println("Error during login: " + t.getMessage());
+ latch.countDown();
+ }
+
+ @Override
+ public void onCompleted() {
+ requestObserverContainer[0].onCompleted();
+ }
+ });
+
+ requestObserverContainer[0].onNext(initialLoginRequest);
+
+ try {
+ latch.await();
+ } catch (InterruptedException e) {
+ throw new Exception("Login process interrupted", e);
+ } finally {
+ grpcClient.close();
+ }
+
+ return accessToken[0];
+ }
+
+ private void addServerFirstMessage(InitialScramResponse initialScramResponse) throws Exception {
+ serverNonce = initialScramResponse.getServerNonce().toByteArray();
+ salt = initialScramResponse.getSalt().toByteArray();
+ iterationCount = initialScramResponse.getIterationCount();
+ if (salt.length == 0) {
+ throw new Exception("No salt found in the response");
+ }
+ if (iterationCount == 0) {
+ throw new Exception("No iteration count found in response");
+ }
+ }
+
+ private void verifyLoginResponse(FinalScramResponse finalScramResponse) throws Exception {
+ byte[] serverKey = serverKey(saltedPassword);
+ byte[] serverSignature = serverSignature(serverKey, authMessage);
+ byte[] candidateServerSignature = finalScramResponse.getServerSignature().toByteArray();
+ if (!Arrays.equals(serverSignature, candidateServerSignature)) {
+ throw new Exception("Server signature does not match");
+ }
+ }
+
+ private byte[] clientProof(
+ InitialScramRequest initialScramRequest, InitialScramResponse initialScramResponse)
+ throws Exception {
+ saltedPassword =
+ saltPassword(
+ password,
+ initialScramResponse.getSalt().toByteArray(),
+ initialScramResponse.getIterationCount());
+ byte[] clientKey = clientKey(saltedPassword);
+ byte[] storedKey = storedKey(clientKey);
+ authMessage =
+ authMessage(
+ username, initialScramRequest, initialScramResponse, certificate.getSignature());
+ byte[] clientSignature = clientSignature(storedKey, authMessage);
+ return xorBytes(clientKey, clientSignature);
+ }
+
+ @VisibleForTesting
+ public static byte[] saltPassword(String password, byte[] salt, int iterationCount)
+ throws Exception {
+ if (salt.length == 0) {
+ throw new Exception("No salt found");
+ }
+ if (iterationCount == 0) {
+ throw new Exception("No iteration count found");
+ }
+ Argon2Parameters parameters =
+ new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
+ .withSalt(salt)
+ .withIterations(iterationCount)
+ .withMemoryAsKB(ARGON_MEMORY_LIMIT)
+ .withParallelism(ARGON_THREADS)
+ .build();
+ Argon2BytesGenerator argon2BytesGenerator = new Argon2BytesGenerator();
+ argon2BytesGenerator.init(parameters);
+ byte[] saltedPassword = new byte[ARGON_KEY_LENGTH];
+ argon2BytesGenerator.generateBytes(
+ password.getBytes(StandardCharsets.UTF_8), saltedPassword, 0, saltedPassword.length);
+
+ return saltedPassword;
+ }
+
+ @VisibleForTesting
+ public static byte[] nonce() {
+ byte[] nonce = new byte[NONCE_LENGTH];
+ SecureRandom random = new SecureRandom();
+ random.nextBytes(nonce);
+ return nonce;
+ }
+
+ private static byte[] xorBytes(byte[] a, byte[] b) {
+ int minLength = Math.min(a.length, b.length);
+ byte[] result = new byte[minLength];
+ for (int i = 0; i < minLength; i++) {
+ result[i] = (byte) (a[i] ^ b[i]);
+ }
+ return result;
+ }
+
+ private static byte[] clientSignature(byte[] storedKey, byte[] authMessage) throws Exception {
+ try {
+ Mac hmac = Mac.getInstance(HMAC_SHA256);
+ hmac.init(new SecretKeySpec(storedKey, HMAC_SHA256));
+ hmac.update(authMessage);
+ return hmac.doFinal();
+ } catch (java.security.GeneralSecurityException e) {
+ throw new Exception("Failed to generate client signature due to: " + e.getMessage(), e);
+ }
+ }
+
+ private static byte[] clientKey(byte[] saltedPassword) throws Exception {
+ return hmacSha256(saltedPassword, CLIENT_KEY_MESSAGE.getBytes());
+ }
+
+ private static byte[] serverKey(byte[] saltedPassword) throws Exception {
+ return hmacSha256(saltedPassword, SERVER_KEY_MESSAGE.getBytes());
+ }
+
+ private static byte[] hmacSha256(byte[] key, byte[] message) throws Exception {
+ try {
+ Mac hmac = Mac.getInstance(HMAC_SHA256);
+ hmac.init(new SecretKeySpec(key, HMAC_SHA256));
+ hmac.update(message);
+ return hmac.doFinal();
+ } catch (java.security.GeneralSecurityException e) {
+ throw new Exception("Failed to create hmac-sha256 due to: " + e.getMessage(), e);
+ }
+ }
+
+ private static byte[] serverSignature(byte[] serverKey, byte[] authMessage) throws Exception {
+ return hmacSha256(serverKey, authMessage);
+ }
+
+ private static byte[] storedKey(byte[] clientKey) throws Exception {
+ try {
+ MessageDigest digest = MessageDigest.getInstance(SHA256);
+ return digest.digest(clientKey);
+ } catch (NoSuchAlgorithmException e) {
+ throw new Exception("Failed to create stored key due to: " + e.getMessage(), e);
+ }
+ }
+
+ private static byte[] authMessage(
+ String username,
+ InitialScramRequest initialScramRequest,
+ InitialScramResponse initialScramResponse,
+ byte[] certificateSignature) {
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ try {
+ outputStream.write(username.getBytes(StandardCharsets.UTF_8));
+ outputStream.write(',');
+ outputStream.write(initialScramRequest.getClientNonce().toByteArray());
+ outputStream.write(',');
+ outputStream.write(initialScramRequest.getClientNonce().toByteArray());
+ outputStream.write(',');
+ outputStream.write(initialScramResponse.getServerNonce().toByteArray());
+ outputStream.write(',');
+ outputStream.write(initialScramResponse.getSalt().toByteArray());
+ outputStream.write(',');
+ outputStream.write(
+ String.valueOf(initialScramResponse.getIterationCount())
+ .getBytes(StandardCharsets.UTF_8));
+ outputStream.write(',');
+ outputStream.write(certificateSignature);
+ outputStream.write(',');
+ outputStream.write(initialScramRequest.getClientNonce().toByteArray());
+ outputStream.write(',');
+ outputStream.write(initialScramResponse.getServerNonce().toByteArray());
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to construct authMessage", e);
+ }
+ return outputStream.toByteArray();
+ }
+}
diff --git a/proto-google-cloud-spanner-host/src/main/proto/google/spanner/host/login.proto b/proto-google-cloud-spanner-host/src/main/proto/google/spanner/host/login.proto
new file mode 100644
index 00000000000..68af651f2ad
--- /dev/null
+++ b/proto-google-cloud-spanner-host/src/main/proto/google/spanner/host/login.proto
@@ -0,0 +1,81 @@
+syntax = "proto3";
+
+package spanner.experimental;
+
+import "google/protobuf/timestamp.proto";
+
+option java_multiple_files = true;
+
+// AccessToken is returned by the LoginService after a successful login.
+message AccessToken {
+ // The username of the logged in user.
+ string username = 1;
+ // The creation time of the access token.
+ google.protobuf.Timestamp creation_time = 2;
+ // The expiration time of the access token, this will be checked by the
+ // server.
+ google.protobuf.Timestamp expiration_time = 3;
+ // The signature of the access token, this will be verified by the server.
+ bytes signature = 4;
+}
+
+// InitialScramRequest is used to start the SCRAM handshake, it will contain the
+// nonce provided by the client.
+message InitialScramRequest {
+ // Nonce provided by the client.
+ bytes client_nonce = 1;
+}
+
+// FinalScramRequest is used to complete the SCRAM handshake, it will contain
+// the credential provided by the client.
+message FinalScramRequest {
+ // The credential provided by the client.
+ bytes credential = 1;
+}
+
+// InitialScramResponse is returned by the server so that the client can
+// generate the credential.
+message InitialScramResponse {
+ // The salt to use when hashing the password.
+ bytes salt = 1;
+ // The number of iterations to use when hashing the password.
+ uint32 iteration_count = 2;
+ // Nonce provided by the server.
+ bytes server_nonce = 3;
+}
+
+// FinalScramResponse is returned by the server after it has verified the
+// client credential.
+message FinalScramResponse {
+ // The server_signature from the server.
+ bytes server_signature = 1;
+}
+
+// LoginRequest is used to authenticate the user, and if successful, return an
+// access token.
+message LoginRequest {
+ // The username of the user to log in.
+ string username = 1;
+
+ oneof request {
+ InitialScramRequest initial_scram_request = 2;
+ FinalScramRequest final_scram_request = 3;
+ }
+}
+
+// LoginResponse contains the information the client needs to call the
+// Spanner API.
+message LoginResponse {
+ // The access token for the logged in user. This should be included in
+ // requests to the Spanner API.
+ AccessToken access_token = 1;
+ oneof response {
+ InitialScramResponse initial_scram_response = 2;
+ FinalScramResponse final_scram_response = 3;
+ }
+}
+
+service LoginService {
+ // Performs the login for rfc5802 authentication.
+ rpc Login(stream LoginRequest) returns (stream LoginResponse);
+}
\ No newline at end of file
diff --git a/proto-google-cloud-spanner-host/src/test/java/ScramTest.java b/proto-google-cloud-spanner-host/src/test/java/ScramTest.java
new file mode 100644
index 00000000000..8c2b6518ea2
--- /dev/null
+++ b/proto-google-cloud-spanner-host/src/test/java/ScramTest.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2017 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertThrows;
+
+import com.google.cloud.Scram;
+import com.google.protobuf.ByteString;
+import org.junit.Test;
+
+public class ScramTest {
+
+ @Test
+ public void testNonceGeneration() {
+ byte[] nonce1 = Scram.nonce();
+ byte[] nonce2 = Scram.nonce();
+ assertNotNull(nonce1);
+ assertNotNull(nonce2);
+ assertEquals(16, nonce1.length);
+ assertEquals(16, nonce2.length);
+ assertNotEquals(ByteString.copyFrom(nonce1), ByteString.copyFrom(nonce2));
+ }
+
+ @Test
+ public void testSaltPassword() throws Exception {
+ byte[] salt = "randomSalt".getBytes();
+ int iterations = 3;
+ byte[] hashedPassword = Scram.saltPassword("testPass", salt, iterations);
+ assertNotNull(hashedPassword);
+ assertEquals(32, hashedPassword.length);
+
+ byte[] emptySalt = new byte[0];
+ Exception saltException =
+ assertThrows(
+ Exception.class,
+ () -> {
+ Scram.saltPassword("testPass", emptySalt, iterations);
+ });
+ assertEquals("No salt found", saltException.getMessage());
+
+ Exception iterationException =
+ assertThrows(
+ Exception.class,
+ () -> {
+ Scram.saltPassword("testPass", salt, 0);
+ });
+ assertEquals("No iteration count found", iterationException.getMessage());
+ }
+}