Skip to content
Closed
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
2 changes: 2 additions & 0 deletions api/ee/tests/manual/auth/03-identity-tracking.http
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@
# Example protected endpoint (if you have one):
# GET {{apiUrl}}/me
# Cookie: sAccessToken=...; sRefreshToken=...
# Localhost/IP with explicit WEB port:
# Cookie: sAccessToken_<port>=...; sRefreshToken_<port>=...

# The endpoint handler can access session like:
# session = await verify_session(request)
Expand Down
3 changes: 3 additions & 0 deletions api/ee/tests/manual/auth/04-policy-enforcement.http
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
###
# Scenario 1: User with email:otp tries to access SSO-only organization
###
# Local multi-env note:
# - For localhost/IP with explicit WEB port, cookie names are suffixed by port
# (e.g. sAccessToken_8000 / sRefreshToken_8000).

### Step 1: Login with Email OTP
# Complete OTP login flow first to get session with identities=["email:otp"]
Expand Down
1 change: 1 addition & 0 deletions api/ee/tests/manual/auth/QUICK-START.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ After login, check session cookie contains identities:
```bash
# Get session from browser dev tools
# Cookie: sAccessToken=...
# Localhost/IP with explicit WEB port: sAccessToken_<port>=...

# Make authenticated request
curl http://localhost:8000/api/me \
Expand Down
5 changes: 5 additions & 0 deletions api/oss/src/core/auth/supertokens/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
override_passwordless_apis,
override_session_functions,
)
from oss.src.core.auth.supertokens.cookie_names import (
apply_supertokens_cookie_name_overrides,
)

log = get_module_logger(__name__)

Expand Down Expand Up @@ -275,6 +278,8 @@ def add_provider(

def init_supertokens():
"""Initialize SuperTokens with only enabled recipes."""
apply_supertokens_cookie_name_overrides()

# Validate auth configuration
try:
env.auth.validate_config()
Expand Down
107 changes: 107 additions & 0 deletions api/oss/src/core/auth/supertokens/cookie_names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
from __future__ import annotations

import ipaddress
from functools import lru_cache
from typing import Mapping, NamedTuple
from urllib.parse import urlparse

from supertokens_python.recipe.session import constants as st_session_constants
from supertokens_python.recipe.session import cookie_and_header as st_cookie_and_header

from oss.src.utils.env import env
from oss.src.utils.logging import get_module_logger

log = get_module_logger(__name__)

DEFAULT_ACCESS_TOKEN_COOKIE_NAME = "sAccessToken"
DEFAULT_REFRESH_TOKEN_COOKIE_NAME = "sRefreshToken"


class SupertokensCookieNames(NamedTuple):
access_token: str
refresh_token: str


def _get_local_port_from_url(url: str | None) -> int | None:
if not url:
return None

parsed = urlparse(url)
if not _is_localhost_or_ip(parsed.hostname):
return None

try:
return parsed.port
except ValueError:
return None


def _is_localhost_or_ip(hostname: str | None) -> bool:
if not hostname:
return False

normalized = hostname.strip().lower()
if normalized == "localhost":
return True

Comment thread
junaway marked this conversation as resolved.
# Handle IPv6 literals that may be enclosed in brackets, e.g. "[::1]"
if normalized.startswith("[") and normalized.endswith("]"):
normalized = normalized[1:-1]
try:
ipaddress.ip_address(normalized)
return True
except ValueError:
return False


@lru_cache(maxsize=1)
def get_local_cookie_port_suffix() -> str:
port = _get_local_port_from_url(env.agenta.web_url)
if port is None:
return ""
return f"_{port}"


@lru_cache(maxsize=1)
def get_supertokens_cookie_names() -> SupertokensCookieNames:
suffix = get_local_cookie_port_suffix()
return SupertokensCookieNames(
access_token=f"{DEFAULT_ACCESS_TOKEN_COOKIE_NAME}{suffix}",
refresh_token=f"{DEFAULT_REFRESH_TOKEN_COOKIE_NAME}{suffix}",
)


def get_supertokens_access_token_cookie_name() -> str:
return get_supertokens_cookie_names().access_token


def get_supertokens_access_token_from_cookies(
cookies: Mapping[str, str],
) -> str | None:
expected = get_supertokens_access_token_cookie_name()
token = cookies.get(expected)
if token:
return token

# Backward compatibility for setups without suffixing.
if get_local_cookie_port_suffix() == "":
return cookies.get(DEFAULT_ACCESS_TOKEN_COOKIE_NAME)
Comment thread
junaway marked this conversation as resolved.
Comment thread
junaway marked this conversation as resolved.

return None


Comment thread
junaway marked this conversation as resolved.
def apply_supertokens_cookie_name_overrides() -> None:
cookie_names = get_supertokens_cookie_names()
suffix = get_local_cookie_port_suffix()

st_session_constants.ACCESS_TOKEN_COOKIE_KEY = cookie_names.access_token
st_session_constants.REFRESH_TOKEN_COOKIE_KEY = cookie_names.refresh_token
st_cookie_and_header.ACCESS_TOKEN_COOKIE_KEY = cookie_names.access_token
st_cookie_and_header.REFRESH_TOKEN_COOKIE_KEY = cookie_names.refresh_token

if suffix:
log.info(
"Using SuperTokens cookie suffix '%s' for local host '%s'",
suffix,
urlparse(env.agenta.web_url).hostname,
)
Comment thread
junaway marked this conversation as resolved.
5 changes: 4 additions & 1 deletion api/oss/src/services/analytics_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
from fastapi import Request
from oss.src.utils.caching import get_cache, set_cache
from oss.src.utils.common import is_oss
from oss.src.core.auth.supertokens.cookie_names import (
get_supertokens_access_token_from_cookies,
)
from oss.src.utils.env import env
from oss.src.utils.logging import get_module_logger

Expand Down Expand Up @@ -165,7 +168,7 @@ async def analytics_middleware(request: Request, call_next: Callable):
auth_method = "ApiKey"
elif auth_header.startswith(_SECRET_TOKEN_PREFIX):
auth_method = "Secret"
elif request.cookies.get("sAccessToken"):
elif get_supertokens_access_token_from_cookies(request.cookies):
auth_method = "Session"
else: # We use API key without any prefix too.
auth_method = "ApiKey"
Expand Down
7 changes: 6 additions & 1 deletion api/oss/src/services/auth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
from oss.src.utils.common import is_ee
from oss.src.services import db_manager
from oss.src.services import api_key_service
from oss.src.core.auth.supertokens.cookie_names import (
get_supertokens_access_token_from_cookies,
)
from oss.src.services.exceptions import (
UnauthorizedException,
InternalServerErrorException,
Expand Down Expand Up @@ -180,7 +183,9 @@ async def _check_authentication_token(request: Request):
or request.headers.get("authorization")
or None
)
supertokens_access_token = request.cookies.get("sAccessToken")
supertokens_access_token = get_supertokens_access_token_from_cookies(
request.cookies
)

query_project_id = request.query_params.get("project_id")
if query_project_id in [_ZERO_UUID, _NULL_UUID]:
Expand Down
4 changes: 4 additions & 0 deletions api/test-auth.http
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ GET {{apiBaseUrl}}/authorisationurl?thirdPartyId=github&redirectURIOnProviderDas
# 5. SESSION MANAGEMENT (ALWAYS AVAILABLE)
################################################################################
# These endpoints are available regardless of which auth method was used
# Local multi-env note:
# - If AGENTA_WEB_URL is localhost/IP with an explicit port (e.g. :8000),
# cookie names are suffixed by port (e.g. sAccessToken_8000, sRefreshToken_8000).

### 5a. Verify Session
GET {{apiBaseUrl}}/session/verify
Expand Down Expand Up @@ -386,4 +389,5 @@ GET {{baseUrl}}/auth/dashboard
# Session cookies:
# - sAccessToken: Short-lived access token
# - sRefreshToken: Long-lived refresh token
# - Localhost/IP with explicit WEB port: sAccessToken_<port>, sRefreshToken_<port>
# - Both are httpOnly, secure, sameSite=lax
34 changes: 32 additions & 2 deletions sdk/agenta/sdk/middlewares/routing/auth.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from typing import Callable, Optional, Dict
from os import getenv
from json import dumps
from urllib.parse import urlparse
import ipaddress

import httpx

Expand Down Expand Up @@ -37,6 +39,33 @@
_cache = TTLLRUCache()


def _is_localhost_or_ip(hostname: Optional[str]) -> bool:
if not hostname:
return False

if hostname.lower() == "localhost":
return True

try:
ipaddress.ip_address(hostname)
return True
except ValueError:
return False
Comment thread
junaway marked this conversation as resolved.


def _get_access_token_cookie_name(host: str) -> str:
cookie_host = getenv("AGENTA_WEB_URL") or host
parsed = urlparse(cookie_host if "://" in cookie_host else f"//{cookie_host}")
try:
port = parsed.port
except ValueError:
return "sAccessToken"

if _is_localhost_or_ip(parsed.hostname) and port:
return f"sAccessToken_{port}"
return "sAccessToken"


class DenyResponse(JSONResponse):
def __init__(
self,
Expand Down Expand Up @@ -95,8 +124,9 @@ async def get_credentials(
headers = {"Authorization": authorization} if authorization else None

# COOKIES
access_token = request.cookies.get("sAccessToken", None)
cookies = {"sAccessToken": access_token} if access_token else None
access_cookie_name = _get_access_token_cookie_name(host)
access_token = request.cookies.get(access_cookie_name, None)
Comment thread
junaway marked this conversation as resolved.
cookies = {access_cookie_name: access_token} if access_token else None
Comment thread
junaway marked this conversation as resolved.

# PARAMS
params = {}
Expand Down
2 changes: 2 additions & 0 deletions web/oss/src/config/frontendConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import PasswordlessReact from "supertokens-auth-react/recipe/passwordless"
import SessionReact from "supertokens-auth-react/recipe/session"
import ThirdPartyReact from "supertokens-auth-react/recipe/thirdparty"

import {createLocalSupertokensCookieHandler} from "../lib/helpers/auth/supertokensCookieHandler"
import {getEffectiveAuthConfig} from "../lib/helpers/dynamicEnv"

import {appInfo} from "./appInfo"
Expand Down Expand Up @@ -99,5 +100,6 @@ export const frontendConfig = (): SuperTokensConfig => {
},
}
},
cookieHandler: (oI: any) => createLocalSupertokensCookieHandler(oI),
}
}
10 changes: 8 additions & 2 deletions web/oss/src/lib/helpers/analytics/AgPosthogProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {useCallback, useEffect, useRef, useState} from "react"
import {useAtom} from "jotai"
import {useRouter} from "next/router"

import {getLocalCookiePortSuffix} from "../cookies"
import {getEnv} from "../dynamicEnv"
import {generateOrRetrieveDistinctId, isDemo} from "../utils"

Expand All @@ -12,7 +13,7 @@ import {CustomPosthogProviderType} from "./types"

const MAX_POSTHOG_INIT_ATTEMPTS = 3

const CustomPosthogProvider: CustomPosthogProviderType = ({children}) => {
const CustomPosthogProvider: CustomPosthogProviderType = ({children, config}) => {
const router = useRouter()
const [loadingPosthog, setLoadingPosthog] = useState(false)
const [posthogClient, setPosthogClient] = useAtom(posthogAtom)
Expand All @@ -35,6 +36,7 @@ const CustomPosthogProvider: CustomPosthogProviderType = ({children}) => {

if (!getEnv("NEXT_PUBLIC_POSTHOG_API_KEY")) return

const localCookiePortSuffix = getLocalCookiePortSuffix()
posthog.init(getEnv("NEXT_PUBLIC_POSTHOG_API_KEY"), {
api_host: "https://alef.agenta.ai",
ui_host: "https://us.posthog.com",
Expand All @@ -53,7 +55,11 @@ const CustomPosthogProvider: CustomPosthogProviderType = ({children}) => {
}
},
capture_pageview: false,
...(localCookiePortSuffix
? {persistence_name: `agenta_local${localCookiePortSuffix}`}
: {}),
...((isDemo() ? CLOUD_CONFIG : OSS_CONFIG) as Partial<PostHogConfig>),
...(config || {}),
})
} catch (error) {
failedAttemptsRef.current += 1
Expand All @@ -63,7 +69,7 @@ const CustomPosthogProvider: CustomPosthogProviderType = ({children}) => {
} finally {
setLoadingPosthog(false)
}
}, [loadingPosthog, posthogClient, setPosthogClient])
}, [config, loadingPosthog, posthogClient, setPosthogClient])

useEffect(() => {
// Initialize PostHog everywhere except auth routes (but DO initialize on post-signup for survey)
Expand Down
Loading