From 6e647fbfaed1d9ef1824e7380b0ced50abb66b97 Mon Sep 17 00:00:00 2001 From: Muskan Kumari Date: Thu, 16 Jul 2026 23:37:52 +0530 Subject: [PATCH 1/2] feat(oauth): add StatelessOAuthStateStore using JWT Implements a stateless OAuth state store that embeds expiry into a signed JWT (HS256/HMAC-SHA256) so no persistent storage is required. Resolves #1823. --- slack_sdk/oauth/state_store/__init__.py | 2 + .../oauth/state_store/stateless/__init__.py | 79 +++++++++++++++++++ .../oauth/state_store/test_stateless.py | 65 +++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 slack_sdk/oauth/state_store/stateless/__init__.py create mode 100644 tests/slack_sdk/oauth/state_store/test_stateless.py diff --git a/slack_sdk/oauth/state_store/__init__.py b/slack_sdk/oauth/state_store/__init__.py index 15491fd1d..c3fbd0c4e 100644 --- a/slack_sdk/oauth/state_store/__init__.py +++ b/slack_sdk/oauth/state_store/__init__.py @@ -6,8 +6,10 @@ # from .amazon_s3_state_store import AmazonS3OAuthStateStore from .file import FileOAuthStateStore from .state_store import OAuthStateStore +from .stateless import StatelessOAuthStateStore __all__ = [ "FileOAuthStateStore", "OAuthStateStore", + "StatelessOAuthStateStore", ] diff --git a/slack_sdk/oauth/state_store/stateless/__init__.py b/slack_sdk/oauth/state_store/stateless/__init__.py new file mode 100644 index 000000000..22f907473 --- /dev/null +++ b/slack_sdk/oauth/state_store/stateless/__init__.py @@ -0,0 +1,79 @@ +import base64 +import hashlib +import hmac as _hmac +import json +import logging +import time +from logging import Logger + +from ..async_state_store import AsyncOAuthStateStore +from ..state_store import OAuthStateStore + + +class StatelessOAuthStateStore(OAuthStateStore, AsyncOAuthStateStore): + def __init__( + self, + *, + expiration_seconds: int, + signing_secret: str, + logger: Logger = logging.getLogger(__name__), + ): + self.expiration_seconds = expiration_seconds + self.signing_secret = signing_secret + self._logger = logger + + @property + def logger(self) -> Logger: + if self._logger is None: + self._logger = logging.getLogger(__name__) + return self._logger + + @staticmethod + def _b64url_encode(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8") + + @staticmethod + def _b64url_decode(data: str) -> bytes: + padded = data + "=" * (4 - len(data) % 4) + return base64.urlsafe_b64decode(padded) + + def _sign(self, message: str) -> str: + return self._b64url_encode( + _hmac.new( + self.signing_secret.encode("utf-8"), + message.encode("utf-8"), + hashlib.sha256, + ).digest() + ) + + def issue(self, *args, **kwargs) -> str: + header = self._b64url_encode(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) + payload = self._b64url_encode(json.dumps({"exp": int(time.time()) + self.expiration_seconds}).encode()) + signature = self._sign(f"{header}.{payload}") + return f"{header}.{payload}.{signature}" + + def consume(self, state: str) -> bool: + try: + parts = state.split(".") + if len(parts) != 3: + return False + header, payload, signature = parts + expected_signature = self._sign(f"{header}.{payload}") + if not _hmac.compare_digest(signature, expected_signature): + self.logger.warning("Invalid JWT signature for state parameter") + return False + claims = json.loads(self._b64url_decode(payload)) + exp = claims.get("exp") + if exp is None or time.time() > exp: + self.logger.warning("Expired or missing exp claim in state JWT") + return False + return True + except Exception as e: + self.logger.warning(f"Failed to validate state JWT: {e}") + return False + + async def async_issue(self, *args, **kwargs) -> str: + return self.issue(*args, **kwargs) + + async def async_consume(self, state: str) -> bool: + return self.consume(state) diff --git a/tests/slack_sdk/oauth/state_store/test_stateless.py b/tests/slack_sdk/oauth/state_store/test_stateless.py new file mode 100644 index 000000000..7a36e4b1f --- /dev/null +++ b/tests/slack_sdk/oauth/state_store/test_stateless.py @@ -0,0 +1,65 @@ +import unittest + +from slack_sdk.oauth.state_store import StatelessOAuthStateStore + + +class TestStateless(unittest.TestCase): + def setUp(self): + self.store = StatelessOAuthStateStore( + expiration_seconds=10, + signing_secret="test-signing-secret", + ) + + def tearDown(self): + pass + + def test_instance(self): + self.assertIsNotNone(self.store) + + def test_issue_returns_jwt_format(self): + state = self.store.issue() + self.assertEqual(len(state.split(".")), 3) + + def test_issue_and_consume(self): + state = self.store.issue() + result = self.store.consume(state) + self.assertTrue(result) + + def test_consume_is_repeatable(self): + state = self.store.issue() + self.assertTrue(self.store.consume(state)) + self.assertTrue(self.store.consume(state)) + + def test_invalid_state_format(self): + self.assertFalse(self.store.consume("not-a-jwt")) + + def test_tampered_signature(self): + state = self.store.issue() + parts = state.split(".") + parts[2] = "invalidsignature" + self.assertFalse(self.store.consume(".".join(parts))) + + def test_tampered_payload(self): + state = self.store.issue() + parts = state.split(".") + parts[1] = parts[1][:-2] + "AA" + self.assertFalse(self.store.consume(".".join(parts))) + + def test_wrong_signing_secret(self): + store2 = StatelessOAuthStateStore( + expiration_seconds=10, + signing_secret="different-secret", + ) + state = self.store.issue() + self.assertFalse(store2.consume(state)) + + def test_expired_state(self): + expired_store = StatelessOAuthStateStore( + expiration_seconds=-1, + signing_secret="test-signing-secret", + ) + state = expired_store.issue() + self.assertFalse(self.store.consume(state)) + + def test_kwargs(self): + self.store.issue(foo=123, bar="baz") From fa3abdfdb1a78a2cdf1cf625610b266a84b90ef2 Mon Sep 17 00:00:00 2001 From: Muskan Kumari Date: Sat, 18 Jul 2026 00:30:26 +0530 Subject: [PATCH 2/2] fix(oauth): use plain hmac import and compact JSON serialization in StatelessOAuthStateStore --- slack_sdk/oauth/state_store/stateless/__init__.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/slack_sdk/oauth/state_store/stateless/__init__.py b/slack_sdk/oauth/state_store/stateless/__init__.py index 22f907473..e123b730c 100644 --- a/slack_sdk/oauth/state_store/stateless/__init__.py +++ b/slack_sdk/oauth/state_store/stateless/__init__.py @@ -1,6 +1,6 @@ import base64 import hashlib -import hmac as _hmac +import hmac import json import logging import time @@ -39,7 +39,7 @@ def _b64url_decode(data: str) -> bytes: def _sign(self, message: str) -> str: return self._b64url_encode( - _hmac.new( + hmac.new( self.signing_secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256, @@ -47,8 +47,10 @@ def _sign(self, message: str) -> str: ) def issue(self, *args, **kwargs) -> str: - header = self._b64url_encode(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) - payload = self._b64url_encode(json.dumps({"exp": int(time.time()) + self.expiration_seconds}).encode()) + header = self._b64url_encode(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode()) + payload = self._b64url_encode( + json.dumps({"exp": int(time.time()) + self.expiration_seconds}, separators=(",", ":")).encode() + ) signature = self._sign(f"{header}.{payload}") return f"{header}.{payload}.{signature}" @@ -59,7 +61,7 @@ def consume(self, state: str) -> bool: return False header, payload, signature = parts expected_signature = self._sign(f"{header}.{payload}") - if not _hmac.compare_digest(signature, expected_signature): + if not hmac.compare_digest(signature, expected_signature): self.logger.warning("Invalid JWT signature for state parameter") return False claims = json.loads(self._b64url_decode(payload))