Skip to content

Commit 1340081

Browse files
committed
fix(spanner): synchronize token refresh with lock and optimize xor_bytes
1 parent 9477e40 commit 1340081

4 files changed

Lines changed: 84 additions & 56 deletions

File tree

packages/google-cloud-spanner/google/cloud/spanner_v1/omni/credentials.py

Lines changed: 69 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import base64
2323
import datetime
2424
import logging
25+
import threading
2526
from collections import namedtuple
2627
from typing import Any, Callable, MutableMapping, Optional, Sequence
2728

@@ -261,6 +262,7 @@ def __init__(
261262

262263
self.token: Optional[str] = None
263264
self.expiry: Optional[datetime.datetime] = None
265+
self._lock = threading.Lock()
264266

265267
def init_channel(
266268
self,
@@ -302,66 +304,81 @@ def create_async_auth_interceptor(
302304
"""Creates async gRPC interceptors that attach the Bearer token."""
303305
return self.create_async_auth_interceptors()
304306

305-
def refresh(self, request: Any = None) -> None:
307+
def _perform_refresh_token(self, request: Any = None) -> None:
306308
"""Refreshes the access token by performing the OPAQUE login flow with Spanner Omni.
307309
308310
Args:
309311
request (Any, optional): Unused; part of google.auth.credentials.Credentials interface.
310312
"""
311-
login_channel = None
312-
try:
313-
if self.use_plain_text:
314-
login_channel = grpc.insecure_channel(self.target)
315-
elif self.ssl_credentials is not None:
316-
login_channel = grpc.secure_channel(self.target, self.ssl_credentials)
317-
elif self.ca_certificate:
318-
with open(self.ca_certificate, "rb") as f:
319-
ca_cert = f.read()
320-
if self.client_certificate and self.client_key:
321-
with open(self.client_certificate, "rb") as f:
322-
client_cert = f.read()
323-
with open(self.client_key, "rb") as f:
324-
private_key = f.read()
325-
ssl_creds = grpc.ssl_channel_credentials(
326-
root_certificates=ca_cert,
327-
private_key=private_key,
328-
certificate_chain=client_cert,
313+
with self._lock:
314+
if self.valid:
315+
return
316+
login_channel = None
317+
try:
318+
if self.use_plain_text:
319+
login_channel = grpc.insecure_channel(self.target)
320+
elif self.ssl_credentials is not None:
321+
login_channel = grpc.secure_channel(
322+
self.target, self.ssl_credentials
329323
)
330-
elif self.client_certificate or self.client_key:
331-
raise ValueError(
332-
"Both client_certificate and client_key must be provided for mTLS"
324+
elif self.ca_certificate:
325+
with open(self.ca_certificate, "rb") as f:
326+
ca_cert = f.read()
327+
if self.client_certificate and self.client_key:
328+
with open(self.client_certificate, "rb") as f:
329+
client_cert = f.read()
330+
with open(self.client_key, "rb") as f:
331+
private_key = f.read()
332+
ssl_creds = grpc.ssl_channel_credentials(
333+
root_certificates=ca_cert,
334+
private_key=private_key,
335+
certificate_chain=client_cert,
336+
)
337+
elif self.client_certificate or self.client_key:
338+
raise ValueError(
339+
"Both client_certificate and client_key must be provided for mTLS"
340+
)
341+
else:
342+
ssl_creds = grpc.ssl_channel_credentials(
343+
root_certificates=ca_cert
344+
)
345+
login_channel = grpc.secure_channel(self.target, ssl_creds)
346+
else:
347+
login_channel = grpc.secure_channel(
348+
self.target, grpc.ssl_channel_credentials()
333349
)
350+
351+
client = LoginClient(login_channel)
352+
proto_token = client.login(self.username, self._password)
353+
354+
token_bytes = proto_token.SerializeToString()
355+
self.token = base64.b64encode(token_bytes).decode("ascii")
356+
357+
if proto_token.HasField("expiration_time"):
358+
seconds = proto_token.expiration_time.seconds
359+
nanos = proto_token.expiration_time.nanos
360+
self.expiry = datetime.datetime.fromtimestamp(
361+
seconds + nanos / 1e9, tz=datetime.timezone.utc
362+
).replace(tzinfo=None)
334363
else:
335-
ssl_creds = grpc.ssl_channel_credentials(root_certificates=ca_cert)
336-
login_channel = grpc.secure_channel(self.target, ssl_creds)
337-
else:
338-
login_channel = grpc.secure_channel(
339-
self.target, grpc.ssl_channel_credentials()
340-
)
341-
342-
client = LoginClient(login_channel)
343-
proto_token = client.login(self.username, self._password)
344-
345-
token_bytes = proto_token.SerializeToString()
346-
self.token = base64.b64encode(token_bytes).decode("ascii")
347-
348-
if proto_token.HasField("expiration_time"):
349-
seconds = proto_token.expiration_time.seconds
350-
nanos = proto_token.expiration_time.nanos
351-
self.expiry = datetime.datetime.fromtimestamp(
352-
seconds + nanos / 1e9, tz=datetime.timezone.utc
353-
).replace(tzinfo=None)
354-
else:
355-
self.expiry = datetime.datetime.now(datetime.timezone.utc).replace(
356-
tzinfo=None
357-
) + datetime.timedelta(hours=1)
358-
except Exception as e:
359-
raise google.auth.exceptions.RefreshError(
360-
f"Failed to login to Spanner Omni: {e}"
361-
) from e
362-
finally:
363-
if login_channel is not None:
364-
login_channel.close()
364+
self.expiry = datetime.datetime.now(datetime.timezone.utc).replace(
365+
tzinfo=None
366+
) + datetime.timedelta(hours=1)
367+
except Exception as e:
368+
raise google.auth.exceptions.RefreshError(
369+
f"Failed to login to Spanner Omni: {e}"
370+
) from e
371+
finally:
372+
if login_channel is not None:
373+
login_channel.close()
374+
375+
def refresh(self, request: Any = None) -> None:
376+
"""Refreshes the access token.
377+
378+
Args:
379+
request (Any, optional): Unused; part of google.auth.credentials.Credentials interface.
380+
"""
381+
self._perform_refresh_token(request)
365382

366383
def apply(
367384
self, headers: MutableMapping[str, str], token: Optional[str] = None

packages/google-cloud-spanner/google/cloud/spanner_v1/omni/opaque.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,7 @@ def xor_bytes(a: bytes, b: bytes) -> bytes:
215215
"""Computes bitwise XOR of two equal-length byte sequences."""
216216
if len(a) != len(b):
217217
raise ValueError(f"Byte sequences must have equal length: {len(a)} != {len(b)}")
218-
if len(a) == 0:
219-
raise ValueError("Byte sequences must not be empty")
220-
return bytes(x ^ y for x, y in zip(a, b))
218+
return (int.from_bytes(a, "big") ^ int.from_bytes(b, "big")).to_bytes(len(a), "big")
221219

222220

223221
def concat(*arrays: bytes) -> bytes:

packages/google-cloud-spanner/tests/unit/omni/test_credentials.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,19 @@ def test_refresh_failure_wraps_in_refresh_error(self):
120120
with self.assertRaises(google.auth.exceptions.RefreshError):
121121
creds.refresh()
122122

123+
def test_refresh_skips_when_valid_inside_lock(self):
124+
creds = SpannerOmniCredentials("user", "pass", "localhost:9010")
125+
creds.token = "existing_valid_token"
126+
creds.expiry = datetime.datetime.now(datetime.timezone.utc).replace(
127+
tzinfo=None
128+
) + datetime.timedelta(hours=1)
129+
130+
with mock.patch(
131+
"google.cloud.spanner_v1.omni.credentials.LoginClient"
132+
) as mock_login_client_cls:
133+
creds.refresh()
134+
mock_login_client_cls.assert_not_called()
135+
123136
def test_apply_and_before_request(self):
124137
creds = SpannerOmniCredentials("user", "pass", "localhost:9010")
125138
headers = {}

packages/google-cloud-spanner/tests/unit/omni/test_opaque.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def test_xor_bytes(self):
6060
),
6161
(b"abc", b"defghi", True),
6262
(b"abcdefghi", b"jklmnop", True),
63-
(b"", b"", True),
63+
(b"", b"", False),
6464
]
6565
for a, b, want_err in tests:
6666
if want_err:

0 commit comments

Comments
 (0)