Skip to content

Commit beac3a5

Browse files
refactor(agentex): cut the identity-link config surface from 8 vars to 1
The identity-link work accumulated eight environment variables. One was a real hazard, one was a trap, and six were knobs nobody has ever turned. Removing them also removes two failure modes. The hazard: IDENTITY_LINK_SESSION_COOKIE_NAME The session cookie name was configurable in two places -- here and AGENTEX_DELEGATION_SESSION_COOKIE_NAMES in delegation_headers. acting_headers() emits a Cookie header that build_delegation_headers then filters down to its allowlist, so if the two ever disagreed the credential would be stripped in transit and every linked turn would silently lose its acting identity, while the link sat in the database looking stored, valid and healthy. It is now derived: session_cookie_name() reads the delegation allowlist. One source of truth, so divergence is unrepresentable rather than merely documented. Empty allowlist (cookie delegation disabled) returns None and acting_headers() refuses, since emitting a credential that will certainly be stripped is worse than admitting we cannot act. The trap: IDENTITY_LINK_REQUIRE_EMAIL_MATCH The email check needs the users:read.email Slack scope, which isn't granted. The flag existed to keep it off until the scope lands -- but flag and scope then had to be flipped together: the flag alone refused every link (unreadable email treated as mismatch), and the scope alone protected nothing. It now enables itself. _email_mismatch() enforces whenever Slack answers with an email and stands down when it won't, so granting the scope switches the protection on with no config change and no ordering hazard. That inverts the unverifiable case from refuse to allow, which is weaker, and deliberately so: with no flag to distinguish "scope missing" from "Slack had a bad minute", failing closed would make linking fail at random. The gap is not attacker-reachable -- nobody outside our infrastructure influences whether our own Slack lookup succeeds -- and the previous shipped state (flag off) verified nothing at all, so this is strictly stronger than what it replaces. The knobs -> module constants IDENTITY_LINK_NONCE_TTL, _MAX_DMS, _CACHE_TTL, _NEGATIVE_CACHE_TTL, _FALLBACK_TTL_DAYS and SLACK_LINK_OFFER_COOLDOWN_S are now constants at their former defaults. Each was a config surface and a branch carrying a value that has never been set to anything else, and therefore never tested at anything else. They crept in by pattern-matching the surrounding file, which isn't a reason. What remains: AGENTEX_CREDENTIAL_ENCRYPTION_KEY and SLACK_GATEWAY_PUBLIC_BASE_URL, both deployment-specific with no sensible default, plus the pre-existing SLACK_GATEWAY_REQUIRE_LINKED_USER, which is a genuine product choice. No behavior change at current settings: every constant equals the default it replaced, and the email check's effective behavior in production (no scope, flag off -> no verification) is unchanged until the scope is granted. Testing: 10 new unit tests. The cookie-name ones assert the end-to-end property that whatever name is configured, what acting_headers emits is what build_delegation_headers forwards -- and that a disabled allowlist refuses rather than emitting something that gets stripped. The email ones pin both directions of the asymmetry: verified-different refuses (and leaves the nonce intact for the legitimate owner), while missing scope, lookup failure and a principal without an email all allow. Full unit suite 691 passed; the 14 Redis integration tests still pass against a real Redis after the TTL constants moved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6101ea4 commit beac3a5

6 files changed

Lines changed: 247 additions & 127 deletions

File tree

agentex/src/api/routes/integrations.py

Lines changed: 83 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@
4141
from __future__ import annotations
4242

4343
import html
44-
import os
4544
from datetime import UTC, datetime, timedelta
4645

4746
from fastapi import APIRouter, Form, Request
@@ -55,8 +54,8 @@
5554
from src.domain.entities.identity_links import IdentityLinkMethod, IdentityProvider
5655
from src.domain.repositories.identity_link_repository import IdentityLinkRepository
5756
from src.domain.services.identity_link_service import (
58-
SESSION_COOKIE_NAME,
5957
IdentityLinkService,
58+
session_cookie_name,
6059
)
6160
from src.domain.services.link_nonce_service import LinkNonceService
6261
from src.utils import session_jwt
@@ -70,27 +69,10 @@
7069
# Used only when the session token doesn't declare its own expiry. Never "no
7170
# expiry": storing a credential with an unbounded lifetime is how you end up
7271
# holding one indefinitely, so an unknown expiry becomes a short known one.
73-
_FALLBACK_TTL_DAYS = int(os.getenv("IDENTITY_LINK_FALLBACK_TTL_DAYS", "30"))
74-
75-
# Require the Slack account's email to match the signed-in SGP account's.
76-
#
77-
# This is the only real defence against a *forwarded* link. The nonce stops an
78-
# attacker forging someone else's Slack identity, but nothing stops them sending
79-
# their OWN link to a victim: if the victim clicks it while signed in, the
80-
# attacker's Slack identity binds to the victim's SGP account, and thereafter the
81-
# attacker's Slack messages run as the victim, using the victim's integrations. The
82-
# confirmation page names both identities, which catches a mis-click but reduces to
83-
# user vigilance against a deliberate attempt.
84-
#
85-
# OFF by default because it needs the ``users:read.email`` Slack scope, which is not
86-
# granted until the app is reinstalled. Enabling it without the scope would refuse
87-
# every link (the check treats an unreadable email as a mismatch, deliberately), so
88-
# the flag and the scope have to be turned on together.
89-
_REQUIRE_EMAIL_MATCH = os.getenv("IDENTITY_LINK_REQUIRE_EMAIL_MATCH", "").lower() in (
90-
"1",
91-
"true",
92-
"yes",
93-
)
72+
_FALLBACK_TTL_DAYS = 30
73+
74+
# Email matching has no flag: it enforces itself whenever Slack will tell us the
75+
# email, and stands down when it won't. See ``_email_mismatch``.
9476

9577

9678
def _page(title: str, body: str, *, status: int = 200) -> HTMLResponse:
@@ -155,15 +137,71 @@ def _session_credential(request: Request) -> str | None:
155137
chokes on, silently dropping every morsel after the first bad one — which can
156138
include the session cookie itself. The same reasoning (and the same approach)
157139
applies in ``delegation_headers``.
140+
141+
The name comes from the delegation allowlist, so what we store here is by
142+
construction what the delegation layer will forward later. None when cookie
143+
delegation is disabled: there would be no way to act through the credential, so
144+
there is no point storing one.
158145
"""
146+
wanted = session_cookie_name()
147+
if wanted is None:
148+
return None
159149
raw = request.headers.get("cookie") or ""
160150
for part in raw.split(";"):
161151
name, sep, value = part.strip().partition("=")
162-
if sep and name.strip() == SESSION_COOKIE_NAME:
152+
if sep and name.strip() == wanted:
163153
return value.strip() or None
164154
return None
165155

166156

157+
async def _email_mismatch(external_user_id: str, sgp_email: str | None) -> bool:
158+
"""True when Slack and SGP demonstrably identify different people.
159+
160+
This is the only real defence against a *forwarded* link. The nonce stops an
161+
attacker forging someone else's Slack identity; it does not stop them sending
162+
their OWN link to a victim, who — clicking it while signed in — would bind the
163+
attacker's Slack identity to their SGP account, after which the attacker's Slack
164+
messages run as them with their integrations.
165+
166+
**Self-enabling, with no flag.** The check needs the ``users:read.email`` Slack
167+
scope, which may not be granted. Rather than gate that on configuration — where
168+
the flag and the scope must be flipped together, and flipping one alone either
169+
breaks every link or silently protects nothing — it simply enforces whenever
170+
Slack answers with an email and stands down when it won't. Granting the scope
171+
turns the protection on by itself.
172+
173+
So the asymmetry is deliberate: **verified different -> refuse; unverifiable ->
174+
allow and warn.** Failing closed on an unreadable email would be stronger, but
175+
with no flag to distinguish "scope missing" from "Slack had a bad minute" it
176+
would make linking fail randomly. The gap it leaves is not attacker-reachable:
177+
nobody outside our infrastructure influences whether our own Slack lookup
178+
succeeds.
179+
"""
180+
from src.domain.use_cases.slack_gateway_use_case import slack_user_profile
181+
182+
profile = await slack_user_profile(external_user_id)
183+
slack_email = profile.get("email")
184+
if not slack_email:
185+
logger.warning(
186+
"identity link: email not verified (Slack would not tell us)",
187+
extra={
188+
"external_user_id": external_user_id,
189+
"slack_error": profile.get("error"),
190+
"hint": "grant users:read.email to enable this check",
191+
},
192+
)
193+
return False
194+
if not sgp_email:
195+
# Slack gave us an email but the SGP session didn't. Nothing to compare, so
196+
# the same rule applies: can't verify, don't block.
197+
logger.warning(
198+
"identity link: email not verified (no email on the SGP principal)",
199+
extra={"external_user_id": external_user_id},
200+
)
201+
return False
202+
return slack_email.strip().lower() != sgp_email.strip().lower()
203+
204+
167205
@router.get("/slack/link", summary="Confirm linking a Slack identity to SGP")
168206
async def slack_link_page(request: Request, nonce: str = "") -> HTMLResponse:
169207
"""Render the confirmation screen. Does NOT consume the nonce, so a refresh or
@@ -267,47 +305,34 @@ async def slack_link_confirm(request: Request, nonce: str = Form("")) -> HTMLRes
267305
status=409,
268306
)
269307

270-
if _REQUIRE_EMAIL_MATCH:
271-
# Local import: the gateway module owns the Slack token and HTTP calls, and
272-
# importing it at module load would pull the use case into the route's import
273-
# graph for a feature that is off by default.
274-
from src.domain.use_cases.slack_gateway_use_case import slack_user_profile
275-
276-
slack_email = (await slack_user_profile(link_request.external_user_id)).get(
277-
"email"
308+
if await _email_mismatch(link_request.external_user_id, email):
309+
logger.warning(
310+
"identity link refused: Slack/SGP email mismatch",
311+
extra={
312+
"sgp_user_id": sgp_user_id,
313+
"external_user_id": link_request.external_user_id,
314+
},
315+
)
316+
return _page(
317+
"Accounts don't match",
318+
"<h1>Those accounts don't match</h1>"
319+
"<p>The Slack account this link was made for and the SGP account "
320+
"you're signed in as belong to different people.</p>"
321+
"<p class=muted>If someone sent you this link, don't use it — it "
322+
"would let their Slack messages run as you. Mention the agent in "
323+
"Slack yourself to get your own link.</p>",
324+
status=403,
278325
)
279-
# An unreadable email is treated as a mismatch, not as "skip the check".
280-
# Failing open here would silently disable the only defence against a
281-
# forwarded link the moment the Slack scope lapsed.
282-
if not slack_email or not email or slack_email.lower() != email.lower():
283-
logger.warning(
284-
"identity link refused: Slack/SGP email mismatch",
285-
extra={
286-
"sgp_user_id": sgp_user_id,
287-
"external_user_id": link_request.external_user_id,
288-
"slack_email_known": bool(slack_email),
289-
},
290-
)
291-
return _page(
292-
"Accounts don't match",
293-
"<h1>Those accounts don't match</h1>"
294-
"<p>The Slack account this link was made for and the SGP account "
295-
"you're signed in as belong to different people.</p>"
296-
"<p class=muted>If someone sent you this link, don't use it — it "
297-
"would let their Slack messages run as you. Mention the agent in "
298-
"Slack yourself to get your own link.</p>",
299-
status=403,
300-
)
301326

302327
secret = _session_credential(request)
303328
if not secret:
304329
# The middleware authenticated this caller somehow, but not by a session
305-
# cookie — an api-key or bearer caller, or a cookie under a different name.
306-
# There is nothing here we can act through later, so refuse rather than
307-
# store an empty credential.
330+
# cookie — an api-key or bearer caller, a cookie under a different name, or
331+
# cookie delegation switched off entirely. There is nothing here we can act
332+
# through later, so refuse rather than store an empty credential.
308333
logger.warning(
309334
"identity link refused: no session cookie on an authenticated request",
310-
extra={"sgp_user_id": sgp_user_id, "cookie": SESSION_COOKIE_NAME},
335+
extra={"sgp_user_id": sgp_user_id, "cookie": session_cookie_name()},
311336
)
312337
return _page(
313338
"Couldn't read your session",

agentex/src/domain/services/identity_link_service.py

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,12 @@
2323
from __future__ import annotations
2424

2525
import json
26-
import os
2726
from datetime import UTC, datetime
2827
from typing import Annotated, Any
2928

3029
from fastapi import Depends
3130

31+
from src.domain.delegation_headers import session_cookie_names_to_forward
3232
from src.domain.entities.identity_links import IdentityLinkEntity, IdentityProvider
3333
from src.domain.repositories.identity_link_repository import DIdentityLinkRepository
3434
from src.utils.credential_encryption import CredentialEncryptionError
@@ -38,21 +38,33 @@
3838

3939
# Positive entries are stable — a link changes only on an explicit link/unlink, and
4040
# both paths invalidate. Negatives expire fast so linking feels immediate.
41-
_CACHE_TTL_S = int(os.getenv("IDENTITY_LINK_CACHE_TTL", "300"))
42-
_NEGATIVE_CACHE_TTL_S = int(os.getenv("IDENTITY_LINK_NEGATIVE_CACHE_TTL", "30"))
41+
_CACHE_TTL_S = 300
42+
_NEGATIVE_CACHE_TTL_S = 30
4343

4444
# Distinguishes "cached: known to be unlinked" from "not in cache".
4545
_UNLINKED = "-"
4646

4747
HEADER_COOKIE = "cookie"
4848
HEADER_SELECTED_ACCOUNT_ID = "x-selected-account-id"
4949

50-
# The stored credential is the linking user's own session cookie, so it goes back
51-
# out as a cookie. ``build_delegation_headers`` filters a Cookie header down to its
52-
# allowlisted names and re-emits it as ``x-acting-user-cookie``, so this name has to
53-
# match that allowlist (``AGENTEX_DELEGATION_SESSION_COOKIE_NAMES``, default
54-
# ``_identityJwt``) or the credential is silently dropped on the way to the agent.
55-
SESSION_COOKIE_NAME = os.getenv("IDENTITY_LINK_SESSION_COOKIE_NAME", "_identityJwt")
50+
51+
def session_cookie_name() -> str | None:
52+
"""The cookie name to store and emit, or None if cookie delegation is off.
53+
54+
**Derived from the delegation allowlist, never separately configurable.** The
55+
stored credential leaves as a Cookie header that ``build_delegation_headers``
56+
filters down to its allowlisted names before re-emitting as
57+
``x-acting-user-cookie``. If this name and that allowlist could disagree, the
58+
credential would be dropped in transit and every linked turn would silently lose
59+
its acting identity — with a stored, valid, apparently-healthy link. One source
60+
of truth removes that failure mode entirely.
61+
62+
None when the allowlist is empty (cookie delegation explicitly disabled): there
63+
is then no way to act through a stored session, so callers must refuse rather
64+
than store or emit something that cannot work.
65+
"""
66+
names = session_cookie_names_to_forward()
67+
return names[0] if names else None
5668

5769

5870
def _cache_key(
@@ -172,8 +184,18 @@ async def acting_headers(self, identity: ResolvedIdentity) -> dict[str, str] | N
172184
extra={"sgp_user_id": identity.sgp_user_id},
173185
)
174186
return None
187+
cookie_name = session_cookie_name()
188+
if cookie_name is None:
189+
# Cookie delegation is disabled, so build_delegation_headers would strip
190+
# whatever we emit. Refuse rather than hand back headers that get
191+
# silently dropped between here and the agent.
192+
logger.warning(
193+
"identity_link_cookie_delegation_disabled",
194+
extra={"sgp_user_id": identity.sgp_user_id},
195+
)
196+
return None
175197
return {
176-
HEADER_COOKIE: f"{SESSION_COOKIE_NAME}={credential}",
198+
HEADER_COOKIE: f"{cookie_name}={credential}",
177199
HEADER_SELECTED_ACCOUNT_ID: identity.sgp_account_id,
178200
}
179201

agentex/src/domain/services/link_nonce_service.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@
4040
from __future__ import annotations
4141

4242
import json
43-
import os
4443
import secrets
4544
from dataclasses import asdict, dataclass, field, replace
4645
from typing import Annotated, Any
@@ -53,7 +52,7 @@
5352

5453
# Long enough for a human to switch windows and sign in, short enough that an
5554
# abandoned link stops being interesting.
56-
_TTL_S = int(os.getenv("IDENTITY_LINK_NONCE_TTL", "600"))
55+
_TTL_S = 600
5756

5857
# 32 bytes of urlsafe randomness. Guessing is not a threat model at this size, but
5958
# the token is still consumed on first use rather than relying on entropy alone.
@@ -63,7 +62,7 @@
6362
# reuse the live nonce rather than minting another, so this caps DM noise without
6463
# multiplying live tokens. Past the cap the caller should fall back to an ephemeral
6564
# in-channel notice rather than going silent.
66-
_MAX_SENDS = int(os.getenv("IDENTITY_LINK_MAX_DMS", "2"))
65+
_MAX_SENDS = 2
6766

6867
_KEY_PREFIX = "link_nonce:"
6968
# identity -> its one live token, so a second mention finds the first nonce instead

agentex/src/domain/use_cases/slack_gateway_use_case.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@
141141
# caps DMs at 2 per live nonce, but a nonce only lives ~10 minutes, so without this a
142142
# persistent mentioner re-arms that budget every 10 minutes. Worst case becomes ~2
143143
# DMs an hour instead of ~12.
144-
_LINK_OFFER_COOLDOWN_S = int(os.getenv("SLACK_LINK_OFFER_COOLDOWN_S", "3600"))
144+
_LINK_OFFER_COOLDOWN_S = 3600
145145

146146
# Slack's HTTP Events API is at-least-once — it retries a delivery (up to ~3x, with an
147147
# X-Slack-Retry-Num header) if we don't 200 within ~3s. Dedup on the envelope's
@@ -373,20 +373,20 @@ def _agent_text(messages) -> str | None:
373373

374374

375375
async def slack_user_profile(user_id: str) -> dict[str, str | None]:
376-
"""``{"display_name": …, "email": …}`` for a Slack user, best-effort.
376+
"""``{"display_name": …, "email": …, "error": …}`` for a Slack user.
377377
378-
Both fields can be None and callers must cope: ``display_name`` is only used to
379-
make the confirmation page legible, and ``email`` requires the
380-
``users:read.email`` scope, which is *not* granted by default. A missing email is
381-
therefore "unknown", never "does not match" — see the email-match check in the
382-
link route, which refuses rather than assuming when it can't read one.
378+
All three can be None. ``display_name`` only makes the confirmation page legible.
379+
``email`` requires the ``users:read.email`` scope, which may not be granted —
380+
``error`` is how a caller tells "Slack won't tell us" apart from "Slack told us
381+
and there's no email", which the link route needs to decide whether it can
382+
perform its identity check at all.
383383
384384
Never raises: an identity-link attempt shouldn't fail because a Slack lookup
385385
hiccupped.
386386
"""
387387
token = os.getenv("SLACK_BOT_TOKEN", "")
388388
if not token or not user_id:
389-
return {"display_name": None, "email": None}
389+
return {"display_name": None, "email": None, "error": "no_token"}
390390
try:
391391
async with httpx.AsyncClient(timeout=10) as client:
392392
resp = await client.get(
@@ -397,18 +397,19 @@ async def slack_user_profile(user_id: str) -> dict[str, str | None]:
397397
body = resp.json()
398398
except Exception: # noqa: BLE001 - best-effort lookup
399399
logger.warning("[slack] users.info failed for %s", user_id, exc_info=True)
400-
return {"display_name": None, "email": None}
400+
return {"display_name": None, "email": None, "error": "request_failed"}
401401
if not body.get("ok"):
402-
# missing_scope here means users:read.email isn't granted; that's expected
403-
# until the app is reinstalled, so it's info rather than a warning.
402+
# missing_scope means users:read.email isn't granted expected until the app
403+
# is reinstalled, so info rather than warning.
404404
logger.info("[slack] users.info -> %s", body.get("error"))
405-
return {"display_name": None, "email": None}
405+
return {"display_name": None, "email": None, "error": body.get("error")}
406406
user = body.get("user") or {}
407407
profile = user.get("profile") or {}
408408
handle = user.get("name")
409409
return {
410410
"display_name": f"@{handle}" if handle else profile.get("real_name"),
411411
"email": profile.get("email"),
412+
"error": None,
412413
}
413414

414415

0 commit comments

Comments
 (0)