From c93941139dfdbae3030e6cdb9c9c50a0a2285d95 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 21 Jul 2026 11:25:39 -0400 Subject: [PATCH] docs(oauth): document StatelessOAuthStateStore anti-replay caveat Add a class docstring and consume() docstring to StatelessOAuthStateStore explaining that, unlike the stateful stores, it provides no one-time-use / anti-replay guarantee: a valid, unexpired token passes consume() every time. This is inherent to a self-contained stateless token, which can only be invalidated by expiry. Includes integrator guidance: keep expiration_seconds short (the lifetime is the replay window; 300s/5min recommended, matching the OAuth sample), make the callback idempotent, and a note that CSRF protection is unaffected since it comes from the OAuthStateUtils state cookie rather than one-time-use. Documentation only; no behavior change. Addresses #1919. Co-Authored-By: Claude --- .../oauth/state_store/stateless/__init__.py | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/slack_sdk/oauth/state_store/stateless/__init__.py b/slack_sdk/oauth/state_store/stateless/__init__.py index e123b730c..3e4de7b49 100644 --- a/slack_sdk/oauth/state_store/stateless/__init__.py +++ b/slack_sdk/oauth/state_store/stateless/__init__.py @@ -11,6 +11,47 @@ class StatelessOAuthStateStore(OAuthStateStore, AsyncOAuthStateStore): + """A stateless OAuth ``state`` store backed by a signed, self-contained token. + + ``issue()`` creates an HMAC-SHA256 signed token in the JWT style. The token + carries only an expiration claim. ``consume()`` re-verifies the signature and + the expiry. Nothing is stored server-side. No storage backend is required. + + .. warning:: + This store provides **no one-time-use / anti-replay guarantee**. It returns + ``True`` for the same token every time until that token expires. This is + inherent to a stateless token. The token can only be invalidated by expiry. + It cannot be revoked early unless you add server-side state, and that would + defeat the point of being stateless. + + Consequences for integrators: + + - **Keep the token lifetime short.** A token stays valid until it expires. + Use a short ``expiration_seconds``. A value of ``300`` (5 minutes) is a + good choice and matches the OAuth sample. This limits how long a captured + ``state`` can be replayed. + - **Make the callback idempotent.** A captured ``state`` can be replayed + within its lifetime. Your callback may run more than once for a single + authorization. The ``oauth.v2.access`` exchange and the installation + persistence must tolerate this. Slack authorization codes are single-use. + A sequential replay fails the token exchange. The real exposure is a + concurrent duplicate callback. + - **CSRF protection is unchanged.** CSRF defense comes from the + ``Secure; HttpOnly`` state cookie. ``OAuthStateUtils`` validates that cookie + before ``consume()`` runs in the callback. Compared to the stateful stores, + this store only gives up anti-replay and defense-in-depth. It does not give + up CSRF protection. + + Args: + expiration_seconds: Lifetime of an issued ``state`` token in seconds. After + this window ``consume()`` returns ``False``. Keep this value short. A + value of ``300`` (5 minutes) is a good default. Avoid long lifetimes. + signing_secret: Secret used to sign and verify tokens with HMAC. Keep it + secret. Keep it the same across every instance that must validate a + shared token. + logger: Logger for validation warnings. + """ + def __init__( self, *, @@ -55,6 +96,12 @@ def issue(self, *args, **kwargs) -> str: return f"{header}.{payload}.{signature}" def consume(self, state: str) -> bool: + """Validate a ``state`` token's signature and ``expiration`` claim. + + Returns ``True`` for any correctly signed, unexpired token, **every time**. + This store keeps no record of consumed tokens. It does not enforce + one-time-use. See the class docstring for the anti-replay caveat. + """ try: parts = state.split(".") if len(parts) != 3: