import uuid from datetime import UTC, datetime, timedelta import bcrypt import jwt from app.core.config import settings # Pre-computed bcrypt hash of a value that cannot match any real password. # Used as a constant-time decoy when the account does not exist, so the # login path always spends the same bcrypt time regardless of whether the # email is registered (mitigates user-enumeration timing side-channel). _DUMMY_PASSWORD_HASH = ( b"$2b$12$CpytPnfTqJZj.Ox8KnM0z.ymBgUKMgxLEXZgDLlaNY9l3tTPgnJPS" ) # Bcrypt work factor. 12 rounds ≈ ~250 ms on a modern server CPU, which is # the OWASP-recommended floor for password storage as of 2024. Bumping # this number rehashes ALL stored passwords on next successful login — # don't change without a migration plan. _BCRYPT_ROUNDS = 12 def hash_password(plain: str) -> str: return bcrypt.hashpw(plain.encode(), bcrypt.gensalt(rounds=_BCRYPT_ROUNDS)).decode() def verify_password(plain: str, hashed: str) -> bool: return bcrypt.checkpw(plain.encode(), hashed.encode()) def verify_password_constant_time(plain: str, hashed: str | None) -> bool: """Verify password while always spending bcrypt time. If ``hashed`` is None or empty, a dummy bcrypt check is performed so the call cost does not reveal whether the account exists. Always returns False in that case. """ if not hashed: bcrypt.checkpw(plain.encode(), _DUMMY_PASSWORD_HASH) return False return bcrypt.checkpw(plain.encode(), hashed.encode()) # Status-aware TTL cap (migration 090). # # Tokens minted for users whose ``employment_status`` is anything other # than ``active`` are capped at ``LEAVER_PENDING_ACCESS_TTL_MINUTES`` so # a user who entered ``leaver_pending`` mid-session is forced to refresh # (and fail) within minutes — they cannot keep mutating with a fresh- # at-login-time token for the full normal TTL. The refresh path also # checks employment_status, so once they hit that wall they stay out. LEAVER_PENDING_ACCESS_TTL_MINUTES = 5 def create_access_token( user_id: str, roles: list[str], *, employment_status: str = "active", ) -> str: """Mint an access token. ``employment_status`` decides the TTL cap: leaver_pending / pre_hire users get a 5-minute window, all others get the configured ``JWT_ACCESS_TOKEN_EXPIRE_MINUTES``. The minted token's payload still carries the user's roles unchanged — runtime authorization (``require_active_employee``) is the layer that strips mutate access. The TTL cap is the second layer: even a token issued seconds before HR confirmed the offboarding will expire and force a refresh, at which point the auth path will reject the user as ``is_effectively_departed``. """ now = datetime.now(UTC) if employment_status == "active": ttl_minutes = settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES else: # leaver_pending, pre_hire, left — short TTL. ``left`` users # never reach this path (the auth layer rejects them before # token mint), but we cap it here as defence in depth. ttl_minutes = min( LEAVER_PENDING_ACCESS_TTL_MINUTES, settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES, ) payload = { "sub": user_id, "roles": roles, "type": "access", "iss": "helix", "iat": now, "exp": now + timedelta(minutes=ttl_minutes), "jti": str(uuid.uuid4()), "es": employment_status, } return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) def create_refresh_token(user_id: str) -> str: now = datetime.now(UTC) payload = { "sub": user_id, "type": "refresh", "iss": "helix", "iat": now, "exp": now + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS), "jti": str(uuid.uuid4()), } return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) # Hard-coded algorithm allowlist for token verification. Even though # ``settings.JWT_ALGORITHM`` defaults to HS256, never trust the config # at decode time — a misconfiguration that allowed "none" or an # asymmetric alg with the symmetric secret as the public key would let # an attacker forge tokens. Encoding still uses the configured value # (so an operator can rotate to a stronger alg by also bumping this # allowlist), decoding stays pinned. _JWT_ALGORITHMS_ALLOWED = ["HS256"] def decode_token(token: str) -> dict: payload = jwt.decode( token, settings.JWT_SECRET_KEY, algorithms=_JWT_ALGORITHMS_ALLOWED, options={"require": ["exp", "iat", "sub", "type", "iss"]}, issuer="helix", ) return payload # ── MCP OAuth 2.1 access / refresh tokens ────────────────────────── # # Issued by Helix's co-hosted OAuth Authorization Server to MCP # connectors (Claude, Cursor, …) after the PKCE authorization-code # flow. Distinct ``type`` values (``mcp_access`` / ``mcp_refresh``) so # they can never be replayed as a web session access/refresh token and # vice-versa. The access token is *audience-bound* (RFC 8707): ``aud`` # is the canonical MCP resource URI, and the MCP middleware rejects any # token whose ``aud`` isn't itself — this is the OAuth boundary that # stops a token minted for another resource from being replayed here. # # Like a PAT, the token carries NO roles/scope authority of its own: # the MCP middleware resolves ``sub`` → Person → fresh roles from the # DB on every request, so a role change / offboarding takes effect # immediately without waiting for the token to expire. MCP_OAUTH_SCOPE = "mcp" def create_mcp_access_token( user_id: str, *, audience: str, client_id: str, scope: str = MCP_OAUTH_SCOPE, ttl_minutes: int | None = None, ) -> tuple[str, int]: """Mint an MCP OAuth access token. Returns ``(token, expires_in_s)``.""" now = datetime.now(UTC) minutes = ( ttl_minutes if ttl_minutes is not None else settings.MCP_OAUTH_ACCESS_TTL_MINUTES ) payload = { "sub": user_id, "type": "mcp_access", "aud": audience, "azp": client_id, # authorized party (the OAuth client) "scope": scope, "iss": "helix", "iat": now, "exp": now + timedelta(minutes=minutes), "jti": str(uuid.uuid4()), } token = jwt.encode( payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM, ) return token, minutes * 60 def create_mcp_refresh_token( user_id: str, *, client_id: str, ttl_days: int | None = None, ) -> tuple[str, str]: """Mint an MCP OAuth refresh token. Returns ``(token, jti)`` — the caller stores ``jti`` in Redis so the refresh is single-use (rotation): presenting a refresh token consumes its jti and a new one is issued.""" now = datetime.now(UTC) days = ttl_days if ttl_days is not None else settings.MCP_OAUTH_REFRESH_TTL_DAYS jti = str(uuid.uuid4()) payload = { "sub": user_id, "type": "mcp_refresh", "azp": client_id, "iss": "helix", "iat": now, "exp": now + timedelta(days=days), "jti": jti, } token = jwt.encode( payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM, ) return token, jti def decode_mcp_access_token(token: str, *, audience: str | list[str]) -> dict: """Verify an MCP OAuth access token, validating the audience claim. Raises ``jwt`` errors on bad/expired signature or audience mismatch; raises ``ValueError`` on a wrong token ``type``. Returns the payload. """ payload = jwt.decode( token, settings.JWT_SECRET_KEY, algorithms=_JWT_ALGORITHMS_ALLOWED, audience=audience, options={"require": ["exp", "iat", "sub", "type", "iss", "aud"]}, issuer="helix", ) if payload.get("type") != "mcp_access": raise ValueError("not an mcp_access token") return payload def decode_mcp_refresh_token(token: str) -> dict: """Verify an MCP OAuth refresh token. Raises ``jwt`` errors on a bad/expired signature; ``ValueError`` on a wrong ``type``.""" payload = jwt.decode( token, settings.JWT_SECRET_KEY, algorithms=_JWT_ALGORITHMS_ALLOWED, options={"require": ["exp", "iat", "sub", "type", "iss"]}, issuer="helix", ) if payload.get("type") != "mcp_refresh": raise ValueError("not an mcp_refresh token") return payload # ── One-off action tokens (signed deep-links in notifications) ────── # # A capability token that grants exactly ONE narrow action (named by # ``purpose``) on exactly ONE resource, to exactly ONE subject, until # ``exp``. Used for no-login "click the button in the email/Feishu card # to finish the task" flows — the signature IS the authorization, so the # recipient never has to log into Helix. Distinct ``type="action"`` from # access/refresh tokens so an action token can never be replayed as a # session credential (and vice-versa), and the route MUST pass the # ``expected_purpose`` so a token minted for one action can't be # redirected to another. def create_action_token( *, purpose: str, subject_id: str, resource_id: str, ttl_days: int = 14, ttl_minutes: int | None = None, ) -> str: """Mint a one-off action token. ``ttl_minutes`` wins when given. It exists because a token whose lifetime outlives the thing it authorises is a standing credential: the assistant's confirmation token was minted for a day against a 60-minute offer, so a forwarded card still opened the confirmation page — and read back the execution result — 23 hours after the offer was dead. """ now = datetime.now(UTC) payload = { "sub": subject_id, "type": "action", "purpose": purpose, "res": resource_id, "iss": "helix", "iat": now, "exp": now + ( timedelta(minutes=ttl_minutes) if ttl_minutes is not None else timedelta(days=ttl_days) ), "jti": str(uuid.uuid4()), } return jwt.encode( payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM, ) def decode_action_token(token: str, *, expected_purpose: str) -> dict: """Verify a one-off action token. Raises ``jwt`` errors on a bad / expired signature; raises ``ValueError`` on type/purpose mismatch. Returns the payload (``sub`` = subject id, ``res`` = resource id). """ payload = jwt.decode( token, settings.JWT_SECRET_KEY, algorithms=_JWT_ALGORITHMS_ALLOWED, options={"require": ["exp", "iat", "sub", "type", "iss"]}, issuer="helix", ) if payload.get("type") != "action": raise ValueError("not an action token") if payload.get("purpose") != expected_purpose: raise ValueError("action token purpose mismatch") return payload # ── Single-use gate for action tokens ────────────────────────────── # # The signature alone makes an action token valid for its whole TTL, so # without this a magic-link button re-fires the action on every click # (double-tap, webview re-open, "back" then re-click). ``consume_*`` # atomically claims the token's ``jti`` in Redis (SET NX, TTL = the # token's own remaining life) so exactly the FIRST click performs the # action; every later click sees it already consumed and the caller can # render an idempotent "already done" response instead of repeating the # side effect. Same mechanism as the MCP refresh-jti rotation store. _ACTION_JTI_PREFIX = "action:jti:" def _action_jti_ttl(payload: dict) -> int: """Seconds until the token's own ``exp`` (min 1). The Redis claim never needs to outlive the token — once ``exp`` passes the token is rejected at decode anyway.""" exp = payload.get("exp") if exp is None: return 1 now = int(datetime.now(UTC).timestamp()) return max(1, int(exp) - now) async def consume_action_jti(payload: dict) -> bool: """Atomically claim this action token's single use. Returns ``True`` if the caller is the first to consume the token (proceed with the action), ``False`` if it was already consumed (replay / double-click — the caller should return an idempotent "already done" response without repeating the side effect). Fails OPEN: a token with no ``jti`` (legacy links minted before this gate) or a Redis outage returns ``True``. The outage fail-open is NOT justified by the guarded actions being idempotent — they are not (the cloud-grant renew re-pins ``expires_at`` to now + 90d on every execution). It is acceptable because a Redis outage also breaks the ``distributed_lock`` the guarded renew path must acquire before doing any work, so the action fails anyway and nothing is replayed. Callers guarding an action that does NOT itself depend on Redis elsewhere must NOT rely on this gate alone for replay protection. """ jti = payload.get("jti") if not jti: return True from app.core.redis_client import get_redis try: redis = get_redis() claimed = await redis.set( f"{_ACTION_JTI_PREFIX}{jti}", "1", nx=True, ex=_action_jti_ttl(payload), ) except Exception: # noqa: BLE001 — Redis down → don't block the action return True return bool(claimed) async def release_action_jti(payload: dict) -> None: """Undo a ``consume_action_jti`` claim so the link can be retried. Call this when the action FAILED after the claim was taken, so a transient error (scope temporarily non-writable, dispatch failure) doesn't permanently burn the user's one-click link.""" jti = payload.get("jti") if not jti: return from app.core.redis_client import get_redis try: redis = get_redis() await redis.delete(f"{_ACTION_JTI_PREFIX}{jti}") except Exception: # noqa: BLE001 — best-effort; token expires on its own pass