Skip to content

Commit 634f9ee

Browse files
authored
Merge pull request #52 from IABTechLab/mkc-UID2-3678-base64url
Support base64-encoded v4 tokens
2 parents f4802b4 + cfd0830 commit 634f9ee

File tree

8 files changed

+69
-27
lines changed

8 files changed

+69
-27
lines changed

.gitignore

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
*.swp
22
.idea/*
33
*__pycache__*
4-
dist/*
5-
*egg-info/*
4+
5+
# Distribution / packaging
6+
build/
7+
dist/
8+
*egg-info/
9+
10+
# Environments
11+
.venv

tests/test_bidstream_client.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,5 +280,19 @@ def test_refresh_keys(self, mock_refresh_bidstream_keys):
280280
client_secret_bytes)
281281

282282

283+
def test_decrypt_v4_token_encoded_as_base64(self):
284+
for scope in IdentityScope:
285+
with self.subTest(scope=scope):
286+
self.refresh(key_bidstream_response_json_default_keys(identity_scope=scope))
287+
288+
while True:
289+
token = generate_uid_token(scope, AdvertisingTokenVersion.ADVERTISING_TOKEN_V4)
290+
token = base64.b64encode(Uid2Base64UrlCoder.decode(token)).decode('utf-8')
291+
if ("=" in token) and ("/" in token) and ("+" in token):
292+
break
293+
294+
self._decrypt_and_assert_success(token, AdvertisingTokenVersion.ADVERTISING_TOKEN_V4, scope)
295+
296+
283297
if __name__ == '__main__':
284298
unittest.main()

tests/test_identity_map_client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77
from uid2_client import IdentityMapClient, IdentityMapInput, normalize_and_hash_email, normalize_and_hash_phone
88

99

10+
@unittest.skipIf(
11+
os.getenv("UID2_BASE_URL") == None
12+
or os.getenv("UID2_API_KEY") == None
13+
or os.getenv("UID2_SECRET_KEY") == None,
14+
reason="Environment variables UID2_BASE_URL, UID2_API_KEY, and UID2_SECRET_KEY must be set",
15+
)
1016
class IdentityMapIntegrationTests(unittest.TestCase):
1117
UID2_BASE_URL = None
1218
UID2_API_KEY = None

tests/test_publisher_client.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
from urllib.request import HTTPError
99

1010

11+
@unittest.skipIf(
12+
os.getenv("EUID_BASE_URL") == None
13+
or os.getenv("EUID_API_KEY") == None
14+
or os.getenv("EUID_SECRET_KEY") == None,
15+
reason="Environment variables EUID_BASE_URL, EUID_API_KEY, and EUID_SECRET_KEY must be set",
16+
)
1117
class PublisherEuidIntegrationTests(unittest.TestCase):
1218

1319
EUID_SECRET_KEY = None
@@ -61,6 +67,12 @@ def test_integration_optout_generate_token(self):
6167
self.assertFalse(token_generate_response.is_success())
6268
self.assertIsNone(token_generate_response.get_identity())
6369

70+
@unittest.skipIf(
71+
os.getenv("UID2_BASE_URL") == None
72+
or os.getenv("UID2_API_KEY") == None
73+
or os.getenv("UID2_SECRET_KEY") == None,
74+
reason="Environment variables UID2_BASE_URL, UID2_API_KEY, and UID2_SECRET_KEY must be set",
75+
)
6476
class PublisherUid2IntegrationTests(unittest.TestCase):
6577

6678
UID2_SECRET_KEY = None

tests/test_sharing_client.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
from unittest.mock import patch
44

55
from test_utils import *
6-
from tests.test_bidstream_client import TestBidStreamClient
7-
from tests.test_encryption import TestEncryptionFunctions
6+
from test_bidstream_client import TestBidStreamClient
7+
from test_encryption import TestEncryptionFunctions
88
from uid2_client import SharingClient, DecryptionStatus, Uid2ClientFactory
99
from uid2_client.encryption_status import EncryptionStatus
1010
from uid2_client.refresh_response import RefreshResponse
@@ -283,7 +283,7 @@ def test_expiry_in_token_matches_expiry_in_response(self): # ExpiryInTokenMatch
283283
key_sharing_response_json([master_key, site_key], identity_scope=IdentityScope.UID2,
284284
default_keyset_id=99999, token_expiry_seconds=2))
285285

286-
encryption_data_response = self._client.encrypt_raw_uid_into_token(example_uid)
286+
encryption_data_response = self._client._encrypt_raw_uid_into_token(example_uid, now=now)
287287
self.assertEqual(encryption_data_response.status, EncryptionStatus.SUCCESS)
288288

289289
result = self._client._decrypt_token_into_raw_uid(encryption_data_response.encrypted_data, now + dt.timedelta(seconds=1))

tests/test_utils.py

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,19 +39,18 @@
3939
phone_uid = "BEOGxroPLdcY7LrSiwjY52+X05V0ryELpJmoWAyXiwbZ"
4040

4141
test_cases_all_scopes_all_versions = [
42-
[IdentityScope.UID2, AdvertisingTokenVersion.ADVERTISING_TOKEN_V2],
43-
[IdentityScope.UID2, AdvertisingTokenVersion.ADVERTISING_TOKEN_V3],
44-
[IdentityScope.UID2, AdvertisingTokenVersion.ADVERTISING_TOKEN_V4],
45-
[IdentityScope.EUID, AdvertisingTokenVersion.ADVERTISING_TOKEN_V2],
46-
[IdentityScope.EUID, AdvertisingTokenVersion.ADVERTISING_TOKEN_V3],
47-
[IdentityScope.EUID, AdvertisingTokenVersion.ADVERTISING_TOKEN_V4]
42+
[scope, version]
43+
for scope in IdentityScope
44+
for version in AdvertisingTokenVersion
4845
]
4946

5047
test_cases_all_scopes_v3_v4_versions = [
51-
[IdentityScope.UID2, AdvertisingTokenVersion.ADVERTISING_TOKEN_V3],
52-
[IdentityScope.UID2, AdvertisingTokenVersion.ADVERTISING_TOKEN_V4],
53-
[IdentityScope.EUID, AdvertisingTokenVersion.ADVERTISING_TOKEN_V3],
54-
[IdentityScope.EUID, AdvertisingTokenVersion.ADVERTISING_TOKEN_V4]
48+
[scope, version]
49+
for scope in IdentityScope
50+
for version in [
51+
AdvertisingTokenVersion.ADVERTISING_TOKEN_V3,
52+
AdvertisingTokenVersion.ADVERTISING_TOKEN_V4,
53+
]
5554
]
5655

5756
YESTERDAY = now + dt.timedelta(days=-1)

uid2_client/encryption.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,25 @@ def _decrypt_token(token, keys, domain_name, client_type, now):
105105
elif token_bytes[1] == AdvertisingTokenVersion.ADVERTISING_TOKEN_V3.value:
106106
return _decrypt_token_v3(base64.b64decode(token), keys, domain_name, client_type, now, AdvertisingTokenVersion.ADVERTISING_TOKEN_V3)
107107
elif token_bytes[1] == AdvertisingTokenVersion.ADVERTISING_TOKEN_V4.value:
108-
# same as V3 but use Base64URL encoding
109-
return _decrypt_token_v3(Uid2Base64UrlCoder.decode(token), keys, domain_name, client_type, now, AdvertisingTokenVersion.ADVERTISING_TOKEN_V4)
108+
# Accept either base64 or base64url encoding.
109+
return _decrypt_token_v3(base64.b64decode(_base64url_to_base64(token)), keys, domain_name, client_type, now, AdvertisingTokenVersion.ADVERTISING_TOKEN_V4)
110110
else:
111111
return DecryptedToken.make_error(DecryptionStatus.VERSION_NOT_SUPPORTED)
112112

113113

114+
def _base64url_to_base64(value):
115+
value = value.replace('-', '+').replace('_', '/')
116+
input_size_mod4 = len(value) % 4
117+
if input_size_mod4 == 0:
118+
return value
119+
elif input_size_mod4 == 2:
120+
return value + '=='
121+
elif input_size_mod4 == 3:
122+
return value + '='
123+
else:
124+
raise EncryptionError('invalid payload')
125+
126+
114127
def _token_has_valid_lifetime(keys, client_type, generated_or_now, expires, now):
115128
# generated_or_now allows "now" for token v2, since v2 does not contain a "token generated" field.
116129
# v2 therefore checks against remaining lifetime rather than total lifetime
@@ -294,7 +307,6 @@ def encrypt(uid2, identity_scope, keys, keyset_id=None, **kwargs):
294307
return EncryptionDataResponse.make_error(EncryptionStatus.ENCRYPTION_FAILURE)
295308

296309

297-
298310
# DEPRECATED, DO NOT CALL
299311
def encrypt_data(data, identity_scope, **kwargs):
300312
"""Encrypt arbitrary binary data.

uid2_client/uid2_base64_url_coder.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,11 @@ def encode(input):
1111
encoded_token = base64.urlsafe_b64encode(input).decode('ascii')
1212
# urlsafe_b64encode doesn't remove the '=' padding per the spec so we should remove it
1313
# as '=' is a reserved char in URL spec
14-
count = 0
15-
for i in range(3):
16-
if encoded_token[len(encoded_token) - 1 - i] == '=':
17-
count = count + 1
18-
# encoded_token[:-0] will empty the whole string!
19-
if count > 0:
20-
return encoded_token[:-count]
21-
return encoded_token
14+
return encoded_token.rstrip('=')
2215

2316
@staticmethod
2417
def decode(token):
25-
input_size_mod4 = len(token) % 4;
18+
input_size_mod4 = len(token) % 4
2619
if input_size_mod4 > 0:
2720
padding_needed = 4 - input_size_mod4
2821
padding = ""

0 commit comments

Comments
 (0)