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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this

## [Unreleased]

## [0.9.4] - 2026-10-21

### Fixed

- OAuth (MCP and A2A): an expired sign-in no longer blocks the request for 5 minutes and ends in `TimeoutError`. When the IdP refuses the stored refresh token (`invalid_grant`/`invalid_token`, e.g. Keycloak "Token is not active" once an offline session reaches its maximum lifespan), the dead token is deleted, the user is sent a fresh sign-in link straight away, and the call returns a `sign_in_expired` result telling the user to sign in and ask again. The sign-in completes in the background, so the next request uses the new token. Background pollers get the same `ReauthRequired` handling as before, without a prompt.

- MCP over HTTP (`slack_agents.tools.mcp_http`): a closed or dead session is now reopened instead of failing every later call. Previously the session was opened once at startup and never replaced, so after the connection dropped each tool call failed at once with `anyio.ClosedResourceError` until the agent restarted. A call whose request could not be written to the closed transport (`ClosedResourceError`/`BrokenResourceError`, so it never reached the server) is retried once on a new session. When the connection closes after the request was sent, or the server no longer knows the session (`Session terminated`, e.g. after a server restart), the call fails without replay and the next call reconnects. The connection now runs in its own task, so the transport is always entered and exited by the same task.

## [0.9.3] - 2026-07-08

### Fixed
Expand Down
8 changes: 8 additions & 0 deletions docs/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ If they don't click within the configured `auth_timeout` (default 5 minutes),
the agent surfaces a "timed out — please try again" error and the tool call
ends. They can re-ask whenever they're ready and a fresh prompt appears.

Tokens are refreshed silently. If the identity provider refuses the refresh
token (`invalid_grant`/`invalid_token`, for example once a Keycloak offline
session reaches its maximum lifespan), the agent deletes the dead token, sends
a fresh "Authenticate" prompt straight away, and ends that tool call telling
the user their sign-in expired and to sign in and ask again. The request does
not wait for them; the sign-in completes in the background and their next
request uses the new token.

If the upstream tool later requires additional permissions (e.g. they had read
access but are now trying to write), the same flow re-runs requesting the
broader scope. Most identity providers auto-collapse the consent screen if the
Expand Down
8 changes: 8 additions & 0 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1458,6 +1458,14 @@ If they don't click within the configured `auth_timeout` (default 5 minutes),
the agent surfaces a "timed out — please try again" error and the tool call
ends. They can re-ask whenever they're ready and a fresh prompt appears.

Tokens are refreshed silently. If the identity provider refuses the refresh
token (`invalid_grant`/`invalid_token`, for example once a Keycloak offline
session reaches its maximum lifespan), the agent deletes the dead token, sends
a fresh "Authenticate" prompt straight away, and ends that tool call telling
the user their sign-in expired and to sign in and ask again. The request does
not wait for them; the sign-in completes in the background and their next
request uses the new token.

If the upstream tool later requires additional permissions (e.g. they had read
access but are now trying to write), the same flow re-runs requesting the
broader scope. Most identity providers auto-collapse the consent screen if the
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "python-slack-agents"
version = "0.9.3"
version = "0.9.4"
description = "A Python framework for deploying AI agents as Slack bots"
authors = [{name = "Eric Pichon", email = "epichon@comparenetworks.com"}]
readme = "README.md"
Expand Down
11 changes: 11 additions & 0 deletions src/slack_agents/a2a/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,17 @@ async def call_tool(
)
except Exception as e:
if self._oauth_mode:
from slack_agents.oauth.errors import ( # noqa: PLC0415
find_sign_in_expired,
sign_in_expired_tool_result,
)

expired = find_sign_in_expired(e)
if expired is not None:
# The cached client's provider holds the dead token; rebuild
# from storage once the user has signed in again.
self._user_clients.pop(user_conversation_context["user_id"], None)
return sign_in_expired_tool_result(expired, tool=name)
handled = await self._handle_oauth_error(e, name, user_conversation_context)
if handled is not None:
return handled
Expand Down
39 changes: 39 additions & 0 deletions src/slack_agents/oauth/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from slack_agents.tools.base import (
ERROR_PERMISSION_DENIED,
ERROR_SYSTEM_ERROR,
RECOVERY_ABORT,
RECOVERY_CONTACT_ADMIN,
RECOVERY_CONTACT_SUPPORT,
RECOVERY_RETRY,
Expand Down Expand Up @@ -85,6 +86,17 @@ class ReauthRequired(Exception):
Slack auth prompt when a non-interactive OAuth flow hits a full re-auth."""


class SignInExpired(ReauthRequired):
"""The IdP refused the stored refresh token (e.g. the offline session reached
its maximum lifespan). The dead token has been deleted; in an interactive
context a fresh sign-in link has already been posted to the user."""

def __init__(self, server_id: str, *, prompted: bool) -> None:
self.server_id = server_id
self.prompted = prompted
super().__init__(f"sign-in to {server_id} expired (refresh token refused)")


class UserAuthorizationDenied(Exception):
"""Raised when the upstream IdP (or the resource server) rejects access
because the user's account lacks permission for the operation. Distinct
Expand Down Expand Up @@ -248,3 +260,30 @@ def message_from_tool_error(tr: ToolResult) -> str:
except (json.JSONDecodeError, TypeError):
return tr["content"]
return payload.get("message") or tr["content"]


def find_sign_in_expired(exc: BaseException) -> SignInExpired | None:
for x in flatten_exceptions(exc):
if isinstance(x, SignInExpired):
return x
return None


def sign_in_expired_tool_result(expired: SignInExpired, *, tool: str | None = None) -> ToolResult:
"""Tell the user their sign-in expired and to sign in and ask again.

The LLM must not retry in this turn: the user has to sign in first.
"""
link = "I've just sent you a sign-in link (only you can see it). " if expired.prompted else ""
return make_tool_error(
error=ERROR_PERMISSION_DENIED,
code="sign_in_expired",
server=expired.server_id,
tool=tool,
recovery=RECOVERY_ABORT,
message=(
f"Your sign-in to {expired.server_id} has expired. {link}"
"Please sign in, then ask again. Tell the user exactly this and stop; "
"do not retry this tool in this turn."
),
)
90 changes: 88 additions & 2 deletions src/slack_agents/oauth/flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

from slack_agents import OAuthCallbackResult, PendingFlowsRegistry
from slack_agents.oauth.discovery import DiscoveryResult, DiscoveryStrategy
from slack_agents.oauth.errors import ReauthRequired, UserAuthorizationDenied
from slack_agents.oauth.errors import ReauthRequired, SignInExpired, UserAuthorizationDenied
from slack_agents.oauth.prompts import AuthPromptDeliveryError, send_auth_prompt
from slack_agents.oauth.scopes import (
OIDC_BASELINE_SCOPES,
Expand All @@ -33,6 +33,45 @@

logger = logging.getLogger(__name__)

# Token-endpoint errors meaning the refresh token itself is dead (expired, revoked,
# or its session gone): retrying can never succeed, only a new sign-in can.
_DEAD_REFRESH_ERRORS = frozenset({"invalid_grant", "invalid_token"})


class _PerUserProvider(OAuthClientProvider):
"""OAuthClientProvider that fails fast when the IdP refuses the refresh token.

The SDK's default clears the token in memory and falls through to a full
authorization inside the same request, blocking it until the user signs in
or the callback wait times out. Instead the dead token is deleted at once,
`on_refresh_refused` starts a fresh sign-in, and `SignInExpired` ends the call.
"""

def __init__(self, *args, on_refresh_refused, server_id: str, interactive: bool, **kwargs):
super().__init__(*args, **kwargs)
self._on_refresh_refused = on_refresh_refused
self._server_id = server_id
self._interactive = interactive

async def _handle_refresh_response(self, response: httpx.Response) -> bool:
if response.status_code in (400, 401):
await response.aread()
try:
error = response.json().get("error")
except ValueError:
error = None
if error in _DEAD_REFRESH_ERRORS:
logger.warning(
"oauth: %s refused the refresh token (HTTP %s %s) — sign-in expired",
self._server_id,
response.status_code,
error,
)
self.context.clear_tokens()
await self._on_refresh_refused()
raise SignInExpired(self._server_id, prompted=self._interactive)
return await super()._handle_refresh_response(response)


@dataclass
class PerUserHandle:
Expand Down Expand Up @@ -138,6 +177,7 @@ def __init__(
self._auth_timeout = auth_timeout
self._discovery = discovery
self._cached: DiscoveryResult | None = None
self._sign_ins: set[asyncio.Task] = set()

def make_handle(self, user_id, channel_id, thread_id, *, interactive=True) -> PerUserHandle:
return PerUserHandle(
Expand Down Expand Up @@ -200,7 +240,17 @@ async def build_provider(
response_types=["code"],
scope=seed_scope,
)
oauth = OAuthClientProvider(

async def on_refresh_refused() -> None:
scopes = await self.cached_scope_set(user_id)
await self._storage.delete_oauth_token(user_id, self._server_id)
if interactive:
self.start_sign_in(user_id, channel_id, thread_id, scopes)

oauth = _PerUserProvider(
on_refresh_refused=on_refresh_refused,
server_id=self._server_id,
interactive=interactive,
server_url=self._server_url,
client_metadata=client_metadata,
storage=token_storage,
Expand All @@ -218,6 +268,42 @@ async def build_provider(
logger.warning("oauth: pre-initialize for %s failed", self._server_id, exc_info=True)
return oauth

def start_sign_in(
self, user_id: str, channel_id: str, thread_id: str | None, scopes: set[str]
) -> None:
"""Post the user a sign-in link now and finish the sign-in in the background.

The turn that hit the expired sign-in returns immediately; when the user
completes sign-in the new token is stored and their next request uses it.
"""
task = asyncio.create_task(self._sign_in(user_id, channel_id, thread_id, scopes))
self._sign_ins.add(task)
task.add_done_callback(self._sign_ins.discard)

async def _sign_in(
self, user_id: str, channel_id: str, thread_id: str | None, scopes: set[str]
) -> None:
try:
oauth = await self.build_provider(user_id, channel_id, thread_id)
# Ask for at least what the expired token had, so the new token keeps
# offline_access and the resource scopes.
requested = set((oauth.context.client_metadata.scope or "").split())
oauth.context.client_metadata.scope = " ".join(
sorted(OIDC_BASELINE_SCOPES | requested | scopes)
)
token_request = await oauth._perform_authorization()
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as http:
response = await http.send(token_request)
await oauth._handle_token_response(response)
logger.info("oauth: %s user %s signed in again", self._server_id, user_id)
except Exception:
logger.warning(
"oauth: %s background sign-in for %s did not complete",
self._server_id,
user_id,
exc_info=True,
)

def _apply_cached_metadata(self, oauth: OAuthClientProvider) -> None:
if self._cached is None:
return
Expand Down
Loading
Loading