Skip to content

Commit 9e0548d

Browse files
authored
feat(spanner): auth login support for Spanner Omni endpoints (#18273)
**⚠️ Note to Reviewers:** > The files under `google/cloud/spanner_v1/omni/proto/` (`authentication_pb2*`, `login_pb2*`, `login_pb2_grpc*`) are auto-generated. > > The core hand-written logic is contained in the OPAQUE cryptography module, `LoginClient`, `SpannerOmniCredentials`, and the client/DB-API integrations. This PR introduces native username/password authentication support for Spanner Omni endpoints using the OPAQUE password-authenticated key exchange (PAKE) protocol, maintaining parity with the Go and Java client implementations. Java implementation - googleapis/google-cloud-java#13470 Go implementation - googleapis/google-cloud-go#20085 **Key Changes:** * **Omni Login Protocol:** Added generated protobufs (`authentication_pb2*`, `login_pb2*`, `login_pb2_grpc*`) and a gRPC `LoginClient` to handle the authentication handshake with Spanner Omni endpoints over the `LoginService/Login` bi-directional stream. * **OPAQUE Protocol (`opaque.py`):** Implements the client-side OPAQUE protocol utilizing NIST P-256 elliptic curve arithmetic (`cryptography`), Argon2id stretching with defensive parameter validation, RFC 9380 hash-to-curve / random oracle mapping, and constant-time MAC verification. * **Security & Memory Safety:** Ensures in-place zeroization of intermediate keys, stretched passwords, and shared secrets using mutable byte buffers inside `finally` blocks. * **SpannerOmniCredentials & Interceptors (`credentials.py`):** Implements `SpannerOmniCredentials` subclassing `google.auth.credentials.Credentials` to manage Bearer access token lifecycle, automatic expiry checks, and transparent token refreshes. Provides dedicated interceptors for both synchronous `grpc` and asynchronous `grpc.aio` channels across all RPC patterns (unary-unary, unary-stream, stream-unary, stream-stream). * **Client & DB-API Integration:** - Extended `spanner_v1.Client` and `spanner_v1.AsyncClient` to accept `username`, `password`, and `instance_type="omni"`, automatically wiring up `SpannerOmniCredentials`. - Updated `google.cloud.spanner_dbapi.connect()` to accept `username` and `password` for DB-API connections. - Updated sync and async transport factories to attach Bearer auth interceptors to Omni gRPC channels. * **Testing:** Added comprehensive unit test suites covering the OPAQUE cryptographic engine, `LoginClient` state machine, credentials lifecycle/interceptors, DB-API connect options, and system test configurations for both sync and async client workflows against live Spanner Omni instances. --- ### Running Integration Tests To run integration tests against a live Spanner Omni instance with auth login, set the following environment variables: ```bash export SPANNER_OMNI=localhost:15000 export CA_CERTIFICATE=/path/to/ca.crt export SPANNER_OMNI_USER=admin export SPANNER_OMNI_PASSWORD=admin python -m pytest -v -s --disable-warnings tests/system/ \ -o asyncio_mode=auto \ -o asyncio_default_test_loop_scope=session \ -o asyncio_default_fixture_loop_scope=session ``` ***
1 parent 1857302 commit 9e0548d

35 files changed

Lines changed: 4747 additions & 12 deletions

‎packages/google-cloud-spanner/google/cloud/spanner_dbapi/connection.py‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -820,6 +820,8 @@ def connect(
820820
instance_type=None,
821821
data_boost_enabled=False,
822822
auto_partition_mode=False,
823+
username=None,
824+
password=None,
823825
**kwargs,
824826
):
825827
"""Creates a connection to a Google Cloud Spanner database.
@@ -909,6 +911,10 @@ def connect(
909911
:param client_key: (Optional) The path to the client key file used for mTLS connection.
910912
This is intended only for Spanner Omni endpoints.
911913
This is mandatory if Spanner Omni requires an mTLS connection.
914+
:type username: str
915+
:param username: (Optional) Username for Spanner Omni authentication.
916+
:type password: str
917+
:param password: (Optional) Password for Spanner Omni authentication.
912918
"""
913919
if client is None:
914920
client_info = ClientInfo(
@@ -956,7 +962,29 @@ def connect(
956962
)
957963

958964
project = "default"
959-
credentials = AnonymousCredentials()
965+
has_username = username is not None
966+
has_password = password is not None
967+
if has_username != has_password:
968+
raise ValueError(
969+
"Both username and password must be specified for Omni authentication"
970+
)
971+
from google.cloud.spanner_v1.omni.credentials import (
972+
SpannerOmniCredentials,
973+
)
974+
975+
if has_username and has_password:
976+
credentials = SpannerOmniCredentials(
977+
username=username,
978+
password=password,
979+
target=host_endpoint,
980+
use_plain_text=use_plain_text,
981+
ca_certificate=ca_certificate,
982+
client_certificate=client_certificate,
983+
client_key=client_key,
984+
)
985+
else:
986+
credentials = AnonymousCredentials()
987+
960988
client_options = kwargs.get("client_options")
961989
if client_options is None:
962990
client_options = ClientOptions(api_endpoint=host_endpoint)
@@ -968,7 +996,6 @@ def connect(
968996

969997
client_options = copy.copy(client_options)
970998
client_options.api_endpoint = host_endpoint
971-
972999
client = spanner.Client(
9731000
project=project,
9741001
credentials=credentials,

‎packages/google-cloud-spanner/google/cloud/spanner_v1/_async/_helpers.py‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ def _create_spanner_omni_transport(
7575
client_certificate,
7676
client_key,
7777
interceptors=None,
78+
credentials=None,
7879
):
7980
"""Creates a Spanner Omni transport in async mode.
8081
@@ -87,6 +88,8 @@ def _create_spanner_omni_transport(
8788
client_certificate (str): Path to the client certificate file for mTLS.
8889
client_key (str): Path to the client key file for mTLS.
8990
interceptors (list): Optional list of interceptors to add to the channel.
91+
credentials (google.auth.credentials.Credentials, optional): Credentials
92+
to use for authentication.
9093
9194
Returns:
9295
object: An instance of the transport class created by `transport_factory`.
@@ -98,8 +101,25 @@ def _create_spanner_omni_transport(
98101
from google.auth.credentials import AnonymousCredentials
99102

100103
channel = None
104+
all_interceptors = list(interceptors) if interceptors is not None else []
105+
if credentials is not None:
106+
if hasattr(credentials, "create_async_auth_interceptors"):
107+
all_interceptors.extend(credentials.create_async_auth_interceptors())
108+
elif hasattr(credentials, "create_async_auth_interceptor"):
109+
res = credentials.create_async_auth_interceptor()
110+
if isinstance(res, (list, tuple)):
111+
all_interceptors.extend(res)
112+
else:
113+
all_interceptors.append(res)
114+
elif hasattr(credentials, "create_auth_interceptor"):
115+
res = credentials.create_auth_interceptor(is_async=True)
116+
if isinstance(res, (list, tuple)):
117+
all_interceptors.extend(res)
118+
else:
119+
all_interceptors.append(res)
120+
101121
if use_plain_text:
102-
channel = grpc.aio.insecure_channel(target=host, interceptors=interceptors)
122+
channel = grpc.aio.insecure_channel(target=host, interceptors=all_interceptors)
103123
elif ca_certificate:
104124
with open(ca_certificate, "rb") as f:
105125
ca_cert = f.read()
@@ -119,12 +139,17 @@ def _create_spanner_omni_transport(
119139
)
120140
else:
121141
ssl_creds = grpc.ssl_channel_credentials(root_certificates=ca_cert)
122-
channel = grpc.aio.secure_channel(host, ssl_creds, interceptors=interceptors)
142+
channel = grpc.aio.secure_channel(
143+
host, ssl_creds, interceptors=all_interceptors
144+
)
123145
else:
124146
raise ValueError(
125147
"TLS/mTLS connection requires ca_certificate to be set for Spanner Omni"
126148
)
127-
return transport_factory(channel=channel, credentials=AnonymousCredentials())
149+
actual_credentials = (
150+
credentials if credentials is not None else AnonymousCredentials()
151+
)
152+
return transport_factory(channel=channel, credentials=actual_credentials)
128153

129154

130155
def _create_experimental_host_transport(

‎packages/google-cloud-spanner/google/cloud/spanner_v1/_async/client.py‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,8 @@ def __init__(
302302
client_certificate=None,
303303
client_key=None,
304304
instance_type=None,
305+
username=None,
306+
password=None,
305307
):
306308
self._emulator_host = _get_spanner_emulator_host()
307309
self._use_plain_text = use_plain_text
@@ -353,10 +355,37 @@ def __init__(
353355
self._ca_certificate = ca_certificate
354356
self._client_certificate = client_certificate
355357
self._client_key = client_key
356-
credentials = AnonymousCredentials()
358+
self._host = host_endpoint
359+
has_username = username is not None
360+
has_password = password is not None
361+
if has_username != has_password:
362+
raise ValueError(
363+
"Both username and password must be specified for Omni authentication"
364+
)
365+
from google.cloud.spanner_v1.omni.credentials import (
366+
SpannerOmniCredentials,
367+
)
368+
369+
if has_username and has_password:
370+
credentials = SpannerOmniCredentials(
371+
username=username,
372+
password=password,
373+
target=host_endpoint,
374+
use_plain_text=use_plain_text,
375+
ca_certificate=ca_certificate,
376+
client_certificate=client_certificate,
377+
client_key=client_key,
378+
)
379+
elif not isinstance(credentials, SpannerOmniCredentials):
380+
credentials = AnonymousCredentials()
357381
disable_builtin_metrics = True
358382
elif isinstance(credentials, AnonymousCredentials):
359383
self._emulator_host = self._client_options.api_endpoint
384+
else:
385+
if username is not None or password is not None:
386+
raise ValueError(
387+
"username and password can only be used when instance_type='omni'."
388+
)
360389

361390
# NOTE: This API has no use for the _http argument, but sending it
362391
# will have no impact since the _http() @property only lazily
@@ -509,6 +538,7 @@ def instance_admin_api(self):
509538
self._ca_certificate,
510539
self._client_certificate,
511540
self._client_key,
541+
credentials=self.credentials,
512542
)
513543

514544
else:
@@ -519,6 +549,7 @@ def instance_admin_api(self):
519549
self._ca_certificate,
520550
self._client_certificate,
521551
self._client_key,
552+
credentials=self.credentials,
522553
)
523554

524555
self._instance_admin_api = InstanceAdminClient(
@@ -567,6 +598,7 @@ def database_admin_api(self):
567598
self._ca_certificate,
568599
self._client_certificate,
569600
self._client_key,
601+
credentials=self.credentials,
570602
)
571603

572604
else:
@@ -577,6 +609,7 @@ def database_admin_api(self):
577609
self._ca_certificate,
578610
self._client_certificate,
579611
self._client_key,
612+
credentials=self.credentials,
580613
)
581614

582615
self._database_admin_api = DatabaseAdminClient(

‎packages/google-cloud-spanner/google/cloud/spanner_v1/_async/database.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,7 @@ def spanner_api(self):
513513
client._ca_certificate,
514514
client._client_certificate,
515515
client._client_key,
516+
credentials=client.credentials,
516517
)
517518
else:
518519
transport = _create_spanner_omni_transport_sync(
@@ -522,6 +523,7 @@ def spanner_api(self):
522523
client._ca_certificate,
523524
client._client_certificate,
524525
client._client_key,
526+
credentials=client.credentials,
525527
)
526528
self._spanner_api = SpannerClient(
527529
client_info=client_info,

‎packages/google-cloud-spanner/google/cloud/spanner_v1/_async/testing/database_test.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ def spanner_api(self):
138138
client._client_certificate,
139139
client._client_key,
140140
self._interceptors,
141+
credentials=client.credentials,
141142
)
142143
else:
143144
transport = _create_spanner_omni_transport_sync(
@@ -148,6 +149,7 @@ def spanner_api(self):
148149
client._client_certificate,
149150
client._client_key,
150151
self._interceptors,
152+
credentials=client.credentials,
151153
)
152154
self._spanner_api = SpannerClient(
153155
client_info=client_info,

‎packages/google-cloud-spanner/google/cloud/spanner_v1/_helpers.py‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1044,6 +1044,7 @@ def _create_spanner_omni_transport(
10441044
client_certificate,
10451045
client_key,
10461046
interceptors=None,
1047+
credentials=None,
10471048
):
10481049
"""Creates a Spanner Omni transport.
10491050
@@ -1056,6 +1057,8 @@ def _create_spanner_omni_transport(
10561057
client_certificate (str): Path to the client certificate file for mTLS.
10571058
client_key (str): Path to the client key file for mTLS.
10581059
interceptors (list): Optional list of interceptors to add to the channel.
1060+
credentials (google.auth.credentials.Credentials, optional): Credentials
1061+
to use for authentication (e.g. `SpannerOmniCredentials`).
10591062
10601063
Returns:
10611064
object: An instance of the transport class created by `transport_factory`.
@@ -1067,6 +1070,10 @@ def _create_spanner_omni_transport(
10671070
from google.auth.credentials import AnonymousCredentials
10681071

10691072
channel = None
1073+
all_interceptors = list(interceptors) if interceptors is not None else []
1074+
if credentials is not None and hasattr(credentials, "create_auth_interceptor"):
1075+
all_interceptors.append(credentials.create_auth_interceptor())
1076+
10701077
if use_plain_text:
10711078
channel = grpc.insecure_channel(target=host)
10721079
elif ca_certificate:
@@ -1093,9 +1100,12 @@ def _create_spanner_omni_transport(
10931100
raise ValueError(
10941101
"TLS/mTLS connection requires ca_certificate to be set for Spanner Omni"
10951102
)
1096-
if interceptors is not None:
1097-
channel = grpc.intercept_channel(channel, *interceptors)
1098-
return transport_factory(channel=channel, credentials=AnonymousCredentials())
1103+
if all_interceptors:
1104+
channel = grpc.intercept_channel(channel, *all_interceptors)
1105+
actual_credentials = (
1106+
credentials if credentials is not None else AnonymousCredentials()
1107+
)
1108+
return transport_factory(channel=channel, credentials=actual_credentials)
10991109

11001110

11011111
def _create_experimental_host_transport(

‎packages/google-cloud-spanner/google/cloud/spanner_v1/client.py‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,8 @@ def __init__(
267267
client_certificate=None,
268268
client_key=None,
269269
instance_type=None,
270+
username=None,
271+
password=None,
270272
):
271273
self._emulator_host = _get_spanner_emulator_host()
272274
self._use_plain_text = use_plain_text
@@ -316,10 +318,37 @@ def __init__(
316318
self._ca_certificate = ca_certificate
317319
self._client_certificate = client_certificate
318320
self._client_key = client_key
319-
credentials = AnonymousCredentials()
321+
self._host = host_endpoint
322+
has_username = username is not None
323+
has_password = password is not None
324+
if has_username != has_password:
325+
raise ValueError(
326+
"Both username and password must be specified for Omni authentication"
327+
)
328+
from google.cloud.spanner_v1.omni.credentials import (
329+
SpannerOmniCredentials,
330+
)
331+
332+
if has_username and has_password:
333+
credentials = SpannerOmniCredentials(
334+
username=username,
335+
password=password,
336+
target=host_endpoint,
337+
use_plain_text=use_plain_text,
338+
ca_certificate=ca_certificate,
339+
client_certificate=client_certificate,
340+
client_key=client_key,
341+
)
342+
elif not isinstance(credentials, SpannerOmniCredentials):
343+
credentials = AnonymousCredentials()
320344
disable_builtin_metrics = True
321345
elif isinstance(credentials, AnonymousCredentials):
322346
self._emulator_host = self._client_options.api_endpoint
347+
else:
348+
if username is not None or password is not None:
349+
raise ValueError(
350+
"username and password can only be used when instance_type='omni'."
351+
)
323352
super(Client, self).__init__(
324353
project=project,
325354
credentials=credentials,
@@ -443,6 +472,7 @@ def instance_admin_api(self):
443472
self._ca_certificate,
444473
self._client_certificate,
445474
self._client_key,
475+
credentials=self.credentials,
446476
)
447477
self._instance_admin_api = InstanceAdminClient(
448478
client_info=self._client_info,
@@ -481,6 +511,7 @@ def database_admin_api(self):
481511
self._ca_certificate,
482512
self._client_certificate,
483513
self._client_key,
514+
credentials=self.credentials,
484515
)
485516
self._database_admin_api = DatabaseAdminClient(
486517
client_info=self._client_info,

‎packages/google-cloud-spanner/google/cloud/spanner_v1/database.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,7 @@ def spanner_api(self):
452452
client._ca_certificate,
453453
client._client_certificate,
454454
client._client_key,
455+
credentials=client.credentials,
455456
)
456457
self._spanner_api = SpannerClient(
457458
client_info=client_info,
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2026 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
17+
"""Spanner Omni authentication and connection utilities."""
18+
19+
from google.cloud.spanner_v1.omni.credentials import SpannerOmniCredentials
20+
from google.cloud.spanner_v1.omni.login_client import LoginClient
21+
from google.cloud.spanner_v1.omni.opaque import UserAuthenticator
22+
23+
__all__ = (
24+
"LoginClient",
25+
"SpannerOmniCredentials",
26+
"UserAuthenticator",
27+
)

0 commit comments

Comments
 (0)