diff --git a/src/edutap/wallet_google/api.py b/src/edutap/wallet_google/api.py index 9d6292b..71c0913 100644 --- a/src/edutap/wallet_google/api.py +++ b/src/edutap/wallet_google/api.py @@ -18,9 +18,9 @@ from google.auth import jwt from pydantic import ValidationError +import datetime import json import logging -import time import typing @@ -357,15 +357,33 @@ def _create_payload(models: list[ClassModel | ObjectModel | Reference]) -> JWTPa return payload +def _convert_str_or_datetime_to_str(value: str | datetime.datetime) -> str: + """convert and check the value to be a valid string for the JWT claim timestamps""" + if isinstance(value, datetime.datetime): + return str(int(value.timestamp())) + if value == "": + return value + if not value.isdecimal(): + raise ValueError("string must be a decimal") + if int(value) < 0: + raise ValueError("string must be an int >= 0 number") + if int(value) > 2**32: + raise ValueError("string must be an int < 2**32 number") + return value + + def _create_claims( issuer: str, origins: list[str], models: list[ClassModel | ObjectModel | Reference], + iat: str | datetime.datetime, + exp: str | datetime.datetime, ) -> JWTClaims: """Creates a JWTClaims instance based on the given issuer, origins and models.""" return JWTClaims( iss=issuer, - iat=str(int(time.time())), + iat=_convert_str_or_datetime_to_str(iat), + exp=_convert_str_or_datetime_to_str(exp), origins=origins, payload=_create_payload(models), ) @@ -375,6 +393,8 @@ def save_link( models: list[ClassModel | ObjectModel | Reference], *, origins: list[str] = [], + iat: str | datetime.datetime = "", + exp: str | datetime.datetime = "", ) -> str: """ Creates a link to save a Google Wallet Object to the wallet on the device. @@ -393,14 +413,26 @@ def save_link( The Google Wallet API button will not render when the origins field is not defined. You could potentially get an "Load denied by X-Frame-Options" or "Refused to display" messages in the browser console when the origins field is not defined. + :param: iat: Issued At Time. The time when the JWT was issued. + :param: exp: Expiration Time. The time when the JWT expires. :return: Link with JWT to save the resources to the wallet. """ - claims = _create_claims( session_manager.settings.credentials_info["client_email"], origins, models, + iat=iat, + exp=exp, ) + logger.debug( + claims.model_dump_json( + indent=2, + exclude_unset=False, + exclude_defaults=False, + exclude_none=True, + ) + ) + signer = crypt.RSASigner.from_service_account_file( session_manager.settings.credentials_file ) @@ -413,6 +445,7 @@ def save_link( exclude_none=True, ), ).decode("utf-8") + logger.debug(jwt_string) if (jwt_len := len(jwt_string)) >= 1800: logger.debug( f"JWT-Length: {jwt_len} is larger than recommended 1800 bytes", diff --git a/src/edutap/wallet_google/models/datatypes/jwt.py b/src/edutap/wallet_google/models/datatypes/jwt.py index 134910c..d9659dd 100644 --- a/src/edutap/wallet_google/models/datatypes/jwt.py +++ b/src/edutap/wallet_google/models/datatypes/jwt.py @@ -7,6 +7,7 @@ from ..passes import retail from ..passes import tickets_and_transit from ..passes.bases import Reference +from datetime import datetime class JWTPayload(Model): @@ -38,7 +39,8 @@ class JWTClaims(Model): iss: str aud: str = "google" - typ: str = "savettowallet" - iat: str = "" + typ: str = "savetowallet" + iat: str | datetime = "" + exp: str | datetime = "" payload: JWTPayload origins: list[str] diff --git a/tests/test_api_save_link.py b/tests/test_api_save_link.py index 9b05b80..e0506a3 100644 --- a/tests/test_api_save_link.py +++ b/tests/test_api_save_link.py @@ -1,4 +1,5 @@ -from freezegun import freeze_time +import datetime +import pytest def test_create_payload(): @@ -20,7 +21,6 @@ def test_create_payload(): assert payload.model_dump_json(exclude_none=True) == expected -@freeze_time("2025-01-17 12:54:00") def test_create_claims(): from edutap.wallet_google import api @@ -35,8 +35,9 @@ def test_create_claims(): expected = { "iss": "123456789", "aud": "google", - "typ": "savettowallet", - "iat": "1737118440", + "typ": "savetowallet", + "iat": "1737115974", + "exp": "", "payload": { "offerObjects": [ { @@ -53,7 +54,13 @@ def test_create_claims(): "origins": [], } - claims = api._create_claims("123456789", [], models) + claims = api._create_claims( + "123456789", + [], + models, + iat=datetime.datetime(2025, 1, 17, 12, 12, 54, 0, datetime.timezone.utc), + exp="", + ) dumped = claims.model_dump( mode="json", @@ -64,7 +71,6 @@ def test_create_claims(): assert expected == dumped -@freeze_time("2025-01-17 12:54:00") def test_api_save_link(mock_settings): from edutap.wallet_google.settings import ROOT_DIR @@ -84,9 +90,39 @@ def test_api_save_link(mock_settings): "OfferObject", {"id": "test-2.edutap.eu", "classId": "test-class-1.edutap.eu"}, ), - ] - ) - assert ( - link - == "https://pay.google.com/gp/v/save/eyJ0eXAiOiAiSldUIiwgImFsZyI6ICJSUzI1NiIsICJraWQiOiAiMTIzNDU2Nzg5MGFiY2RlZjEyMzQ1Njc4OTBhYmNkZWYxMjM0NTY3OCJ9.eyJpc3MiOiAiZWR1dGFwLXRlc3QtZXhhbXBsZUBzb2RpdW0tcmF5LTEyMzQ1Ni5pYW0uZ3NlcnZpY2VhY2NvdW50LmNvbSIsICJhdWQiOiAiZ29vZ2xlIiwgInR5cCI6ICJzYXZldHRvd2FsbGV0IiwgImlhdCI6ICIxNzM3MTE4NDQwIiwgInBheWxvYWQiOiB7Im9mZmVyT2JqZWN0cyI6IFt7ImlkIjogInRlc3QtMi5lZHV0YXAuZXUiLCAiY2xhc3NJZCI6ICJ0ZXN0LWNsYXNzLTEuZWR1dGFwLmV1IiwgInN0YXRlIjogIlNUQVRFX1VOU1BFQ0lGSUVEIiwgImhhc0xpbmtlZERldmljZSI6IGZhbHNlLCAiZGlzYWJsZUV4cGlyYXRpb25Ob3RpZmljYXRpb24iOiBmYWxzZSwgIm5vdGlmeVByZWZlcmVuY2UiOiAiTk9USUZJQ0FUSU9OX1NFVFRJTkdTX0ZPUl9VUERBVEVTX1VOU1BFQ0lGSUVEIn1dLCAiZ2VuZXJpY09iamVjdHMiOiBbeyJpZCI6ICJ0ZXN0LTEuZWR1dGFwLmV1In1dfSwgIm9yaWdpbnMiOiBbXX0.X5nE4Zh4kvxGZ4LxnEw8_9i2tCq-JJUmSCRxpf8ckQJVNumsA_uQze0uyuCPTBSVsdHcnMxozFdiCktrwIdvBYDjSLf91acoADMtY1n-HIPbCexlpTpHgyDpyC8giVx27lAvkjaDtHzvEoJ1nAkwh-_-322uKwEWOllR_voV162lNLMIE5QY2eDJ9yfcFa5LXqxSW64UDTZSYX0DsJtGf-Oa1HOgnG77D6RZfMYUq18duNmcBp5wREVrE27vPjnVRC2kEsrWfQNw4gkN_aSp6ZhEAG-exQqUQQXeR7nPqYm-DM7uVOCh8pbk8WqwA7fKcsZIRK8dsWnqnDuNdwP7Gw" + ], + iat=datetime.datetime(2025, 1, 22, 10, 20, 0, 0, datetime.timezone.utc) + ) + expected = "https://pay.google.com/gp/v/save/eyJ0eXAiOiAiSldUIiwgImFsZyI6ICJSUzI1NiIsICJraWQiOiAiMTIzNDU2Nzg5MGFiY2RlZjEyMzQ1Njc4OTBhYmNkZWYxMjM0NTY3OCJ9.eyJpc3MiOiAiZWR1dGFwLXRlc3QtZXhhbXBsZUBzb2RpdW0tcmF5LTEyMzQ1Ni5pYW0uZ3NlcnZpY2VhY2NvdW50LmNvbSIsICJhdWQiOiAiZ29vZ2xlIiwgInR5cCI6ICJzYXZldG93YWxsZXQiLCAiaWF0IjogIjE3Mzc1NDEyMDAiLCAiZXhwIjogIiIsICJwYXlsb2FkIjogeyJvZmZlck9iamVjdHMiOiBbeyJpZCI6ICJ0ZXN0LTIuZWR1dGFwLmV1IiwgImNsYXNzSWQiOiAidGVzdC1jbGFzcy0xLmVkdXRhcC5ldSIsICJzdGF0ZSI6ICJTVEFURV9VTlNQRUNJRklFRCIsICJoYXNMaW5rZWREZXZpY2UiOiBmYWxzZSwgImRpc2FibGVFeHBpcmF0aW9uTm90aWZpY2F0aW9uIjogZmFsc2UsICJub3RpZnlQcmVmZXJlbmNlIjogIk5PVElGSUNBVElPTl9TRVRUSU5HU19GT1JfVVBEQVRFU19VTlNQRUNJRklFRCJ9XSwgImdlbmVyaWNPYmplY3RzIjogW3siaWQiOiAidGVzdC0xLmVkdXRhcC5ldSJ9XX0sICJvcmlnaW5zIjogW119.u8xDMKKdPBB0yjYqR-uM4eAYMEskRZyv_AOBhGkZ0oswvr-nVOs4jogXZo6cOmSvzjE_tRviNf_GHDelOaND-c4AqNwTg13DRG0c-aNWKbROTlrZefG0dusPcAuhTwzG-gsDn_sCstHWy8gkKQOmb_x4RjRB-b_gsv2uhmeKtNPvofxBNLUHbOefYKL12PPII9kI00Dl0pAyh0dgqI3yew0197a2rYl6_lOlYfO4jd784b-3CDCDKpOZnEjqBBedbLSDhKdWV10eo9mz6OsgqydERuUDDzhJopkwz6BIFL_HA_IHeAaiLtoSNbuOqc7zUecgOHlqecaWBZhV_-WPkQ" + assert link == expected + + +def test__convert_str_or_datetime_to_str__timestamp(): + from edutap.wallet_google.api import _convert_str_or_datetime_to_str + + dt = datetime.datetime(2021, 1, 1, 12, 0, 0, 0, datetime.timezone.utc) + expected = "1609502400" + + assert _convert_str_or_datetime_to_str(dt) == expected + + +def test__convert_str_or_datetime_to_str__str_int_lt_zero(): + from edutap.wallet_google.api import _convert_str_or_datetime_to_str + + with pytest.raises(ValueError): + _convert_str_or_datetime_to_str("-1") + + +def test__convert_str_or_datetime_to_str__str_int_tr_4bytes(): + from edutap.wallet_google.api import _convert_str_or_datetime_to_str + + with pytest.raises(ValueError): + _convert_str_or_datetime_to_str(f"{2**32+1}") + + +def test__convert_str_or_datetime_to_str__not_decimal(): + from edutap.wallet_google.api import _convert_str_or_datetime_to_str + + with pytest.raises(ValueError): + _convert_str_or_datetime_to_str("x 100")