Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,33 @@ if __name__ == '__main__':
app.run(debug=True)
```

## Usage with cache and django

```python
from rest_framework import permissions
from django.core.cache import cache

from google_wallet_webhook_auth import Validator
from google_wallet_webhook_auth.exceptions import SignatureVerificationError
from google_wallet_webhook_auth.cache import CacheConfig, CacheInterface

class DjangoCacheAdapter(CacheInterface):
def get(self, key):
return cache.get(key)

def set(self, key, value, timeout=None):
cache.set(key, value, timeout=timeout)

class IsGoogleWebhook(permissions.BasePermission):
def has_permission(self, request, __view):
cache_config = CacheConfig(key="google_key", backend=DjangoCacheAdapter())
Comment thread
ripoul marked this conversation as resolved.
try:
Validator("YOUR_ISSUER_ID", cache_config=cache_config).validate(request.data)
except SignatureVerificationError:
return False
return True
```

## Development

- Install deps: `uv sync --all-extras`
Expand Down
49 changes: 49 additions & 0 deletions google_wallet_webhook_auth/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from dataclasses import dataclass
from abc import ABC, abstractmethod
from typing import Any


class CacheInterface(ABC):
"""Abstract interface for a simple key/value cache backend.
Implementations are responsible for providing storage and retrieval
of arbitrary Python objects associated with string keys.
"""

@abstractmethod
def get(self, key: str) -> Any:
"""Retrieve a value from the cache by key.
Args:
key: The string key whose value should be retrieved.
Returns:
The cached value associated with ``key`` if it exists and has
not expired. Implementations should return ``None`` when the
key is not present or the entry has expired.
"""
...

@abstractmethod
def set(self, key: str, value: Any, timeout: int | None = None) -> None:
"""Store a value in the cache under the given key.
Args:
key: The string key under which the value should be stored.
value: The value to cache. This may be any serializable object
supported by the backend implementation.
timeout: The cache lifetime in seconds. If ``None``, the
backend's default timeout should be used, which may mean
that the value does not expire automatically.
"""
...


@dataclass
class CacheConfig:
"""Configuration for caching Google's public keys used for webhook verification.
This dataclass groups together the cache key and the cache backend implementation.
"""

key: str
"""Cache key under which Google's public keys will be stored."""
backend: CacheInterface
Comment thread
ripoul marked this conversation as resolved.
"""Cache backend used to store and retrieve Google's public keys.
This should be an implementation of :class:`CacheInterface`.
"""
13 changes: 12 additions & 1 deletion google_wallet_webhook_auth/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,24 @@

from .exceptions import SignatureVerificationError
from .crypto import construct_signed_data, load_public_key
from .cache import CacheConfig


class Validator:
issuer_id: str
cache_config: CacheConfig | None

def __init__(self, issuer_id: str) -> None:
def __init__(self, issuer_id: str, cache_config: CacheConfig | None = None) -> None:
self.issuer_id = issuer_id
self.cache_config = cache_config

def _get_google_key(self) -> list[dict[str, Any]]:
if self.cache_config and (
cached_keys := self.cache_config.backend.get(self.cache_config.key)
):
if isinstance(cached_keys, list):
Comment thread
ripoul marked this conversation as resolved.
Comment thread
ripoul marked this conversation as resolved.
return cached_keys

try:
google_keys = requests.get(
"https://pay.google.com/gp/m/issuer/keys", timeout=3
Expand All @@ -29,6 +38,8 @@ def _get_google_key(self) -> list[dict[str, Any]]:

keys = google_keys["keys"]
assert isinstance(keys, list)
if self.cache_config:
self.cache_config.backend.set(self.cache_config.key, keys, timeout=86400)
Comment thread
ripoul marked this conversation as resolved.
Comment thread
ripoul marked this conversation as resolved.
return keys

def _verify_intermediate_signing_key(self, data: dict[str, Any]) -> None:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "google_wallet_webhook_auth"
version = "0.1.0"
version = "1.0.0"
authors = [
{ name="Jules LE BRIS", email="jls.lebris@gmail.com" },
]
Expand Down
44 changes: 44 additions & 0 deletions tests/test_validator.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from google_wallet_webhook_auth import Validator
from google_wallet_webhook_auth.exceptions import SignatureVerificationError
from google_wallet_webhook_auth.cache import CacheConfig
import base64
from cryptography.exceptions import InvalidSignature
from unittest.mock import MagicMock, patch
Expand Down Expand Up @@ -131,3 +132,46 @@ def test_verify_signature_keyerror():
)
with pytest.raises(SignatureVerificationError):
validator.validate({"dummy": "value"})


def test_get_google_key_with_cache_hit():
mock_cache = MagicMock()
mock_cache.get.return_value = [{"keyValue": "cached_key"}]
validator = Validator(
"issuer_id", cache_config=CacheConfig(key="google_keys", backend=mock_cache)
)
keys = validator._get_google_key()
assert keys == [{"keyValue": "cached_key"}]
mock_cache.get.assert_called_once_with("google_keys")


@patch("google_wallet_webhook_auth.validator.requests.get")
def test_get_google_key_with_cache_miss(mock_get):
mock_get.return_value.json.return_value = {"keys": [{"keyValue": "dummy"}]}
mock_cache = MagicMock()
mock_cache.get.return_value = None
validator = Validator(
"issuer_id", cache_config=CacheConfig(key="google_keys", backend=mock_cache)
)
keys = validator._get_google_key()
assert keys == [{"keyValue": "dummy"}]
mock_cache.get.assert_called_once_with("google_keys")
mock_cache.set.assert_called_once_with(
"google_keys", [{"keyValue": "dummy"}], timeout=86400
)


@patch("google_wallet_webhook_auth.validator.requests.get")
def test_get_google_key_with_cache_hit_wrong_format(mock_get):
mock_get.return_value.json.return_value = {"keys": [{"keyValue": "dummy"}]}
mock_cache = MagicMock()
mock_cache.get.return_value = "blabla" # Not a list
validator = Validator(
"issuer_id", cache_config=CacheConfig(key="google_keys", backend=mock_cache)
)
keys = validator._get_google_key()
assert keys == [{"keyValue": "dummy"}]
mock_cache.get.assert_called_once_with("google_keys")
mock_cache.set.assert_called_once_with(
"google_keys", [{"keyValue": "dummy"}], timeout=86400
)
Comment thread
ripoul marked this conversation as resolved.
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.