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
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ Credentials are resolved via a chain (first match wins):
1. Explicit `api_key` option
2. Explicit `access_token` option (string or callable)
3. `SECLAI_API_KEY` environment variable
4. SSO profile from `~/.seclai/config` with cached tokens in `~/.seclai/sso/cache/`
4. SSO cached tokens from `~/.seclai/sso/cache/` (always available as fallback)

```python
# API key
Expand Down Expand Up @@ -125,13 +125,24 @@ client = Seclai(profile="my-profile")
client = Seclai()
```

To set up SSO authentication, install the [Seclai CLI](https://pypi.org/project/seclai/) and run:
#### SSO authentication

SSO is the default fallback when no explicit credentials are provided. The SDK
includes built-in production SSO defaults, so no configuration is needed:

```bash
seclai configure sso # set up an SSO profile
seclai auth login # authenticate via browser
npx @seclai/cli auth login # authenticate via browser — works immediately
```

To customize SSO settings (e.g. for a staging environment), use `seclai configure sso`
or set environment variables:

| Variable | Description | Default |
|---|---|---|
| `SECLAI_SSO_DOMAIN` | Cognito domain | `auth.seclai.com` |
| `SECLAI_SSO_CLIENT_ID` | Cognito app client ID | `4bgf8v9qmc5puivbaqon9n5lmr` |
| `SECLAI_SSO_REGION` | AWS region | `us-west-2` |

## API documentation

Online API documentation (latest):
Expand Down
244 changes: 159 additions & 85 deletions openapi/seclai.openapi.json

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions seclai/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
"""

from .auth import (
DEFAULT_SSO_CLIENT_ID,
DEFAULT_SSO_DOMAIN,
DEFAULT_SSO_REGION,
SsoCacheEntry,
SsoProfile,
)
Expand All @@ -41,6 +44,9 @@
__all__ = [
"AgentRunStreamRequest",
"AsyncSeclai",
"DEFAULT_SSO_CLIENT_ID",
"DEFAULT_SSO_DOMAIN",
"DEFAULT_SSO_REGION",
"JSONValue",
"Seclai",
"SeclaiAPIStatusError",
Expand Down
132 changes: 72 additions & 60 deletions seclai/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import configparser
import hashlib
import json
import logging
import os
import tempfile
import threading
Expand All @@ -30,7 +31,16 @@
_CONFIG_FILE = "config"
_EXPIRY_BUFFER_SECONDS = 30

_SSO_EXPIRED_MSG = "SSO token expired. Run `seclai auth login` to re-authenticate."
_SSO_EXPIRED_MSG = (
"SSO token is missing or has expired. Run `seclai auth login` to authenticate."
)

#: Default SSO domain (production Cognito). Override with ``SECLAI_SSO_DOMAIN`` or config file.
DEFAULT_SSO_DOMAIN = "auth.seclai.com"
#: Default SSO client ID (production public client). Override with ``SECLAI_SSO_CLIENT_ID`` or config file.
DEFAULT_SSO_CLIENT_ID = "4bgf8v9qmc5puivbaqon9n5lmr"
#: Default SSO region. Override with ``SECLAI_SSO_REGION`` or config file.
DEFAULT_SSO_REGION = "us-west-2"


# ── Types ─────────────────────────────────────────────────────────────────────
Expand All @@ -41,16 +51,16 @@ class SsoProfile:
"""Resolved SSO profile settings from the config file.

Attributes:
sso_account_id: AWS Cognito account ID.
sso_account_id: Account ID (resolved after login via ``/me``).
sso_region: AWS region for the Cognito user pool.
sso_client_id: Cognito app client ID.
sso_domain: Cognito domain (e.g. ``"auth.example.com"``).
"""

sso_account_id: str
sso_region: str
sso_client_id: str
sso_domain: str
sso_account_id: str | None = None


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -164,42 +174,56 @@ def parse_ini_config(config_path: Path) -> configparser.ConfigParser:
return cp


def load_sso_profile(config_dir: Path, profile_name: str) -> SsoProfile | None:
def load_sso_profile(config_dir: Path, profile_name: str) -> SsoProfile:
"""Load and resolve an SSO profile from the config file.

Non-default profiles inherit unset values from ``[default]``.
All profiles fall back to built-in defaults and environment variable
overrides (``SECLAI_SSO_DOMAIN``, ``SECLAI_SSO_CLIENT_ID``,
``SECLAI_SSO_REGION``). Always returns a valid profile.
"""
config_path = config_dir / _CONFIG_FILE
if not config_path.exists():
return None

cp = parse_ini_config(config_path)

default_section: dict[str, str] = {}
if cp.has_section("default"):
default_section = dict(cp.items("default"))

if profile_name == "default":
section = default_section
else:
section_name = f"profile {profile_name}"
if not cp.has_section(section_name):
return None
section = {**default_section, **dict(cp.items(section_name))}
merged: dict[str, str] = {}

sso_account_id = section.get("sso_account_id")
sso_region = section.get("sso_region")
sso_client_id = section.get("sso_client_id")
sso_domain = section.get("sso_domain")

if not all([sso_account_id, sso_region, sso_client_id, sso_domain]):
return None
if config_path.exists():
cp = parse_ini_config(config_path)

default_section: dict[str, str] = {}
if cp.has_section("default"):
default_section = dict(cp.items("default"))

if profile_name == "default":
merged = default_section
else:
section_name = f"profile {profile_name}"
if cp.has_section(section_name):
merged = {**default_section, **dict(cp.items(section_name))}
Comment thread
burgaard marked this conversation as resolved.
else:
logging.getLogger(__name__).warning(
"SSO profile '%s' not found in config; using defaults",
profile_name,
)

# Environment variables override config file values
sso_domain = (
os.getenv("SECLAI_SSO_DOMAIN") or merged.get("sso_domain") or DEFAULT_SSO_DOMAIN
)
sso_client_id = (
os.getenv("SECLAI_SSO_CLIENT_ID")
or merged.get("sso_client_id")
or DEFAULT_SSO_CLIENT_ID
)
sso_region = (
os.getenv("SECLAI_SSO_REGION") or merged.get("sso_region") or DEFAULT_SSO_REGION
)
sso_account_id = merged.get("sso_account_id") or None

return SsoProfile(
sso_account_id=sso_account_id, # type: ignore[arg-type]
sso_region=sso_region, # type: ignore[arg-type]
sso_client_id=sso_client_id, # type: ignore[arg-type]
sso_domain=sso_domain, # type: ignore[arg-type]
sso_region=sso_region,
sso_client_id=sso_client_id,
sso_domain=sso_domain,
sso_account_id=sso_account_id,
)


Expand Down Expand Up @@ -399,27 +423,25 @@ def resolve_credential_chain(
2. Explicit ``access_token`` (static string)
3. Explicit ``access_token_provider`` (callback)
4. ``SECLAI_API_KEY`` environment variable
5. SSO profile from config file + cached tokens
6. Error

Raises:
RuntimeError: If no credentials are found.
5. SSO profile from config file + cached tokens (always available via built-in defaults)
"""
# 1. Explicit API key
if api_key:
stripped_api_key = api_key.strip() if api_key else ""
if stripped_api_key:
return AuthState(
mode="api_key",
api_key=api_key.strip(),
api_key=stripped_api_key,
api_key_header=api_key_header,
account_id=account_id,
auto_refresh=False,
)

# 2. Static access token
if access_token:
stripped_access_token = access_token.strip() if access_token else ""
if stripped_access_token:
return AuthState(
mode="bearer_static",
access_token=access_token,
access_token=stripped_access_token,
api_key_header=api_key_header,
account_id=account_id,
auto_refresh=False,
Expand All @@ -446,27 +468,17 @@ def resolve_credential_chain(
auto_refresh=False,
)

# 5. SSO profile
try:
resolved_dir = resolve_config_dir(config_dir)
profile_name = profile or os.getenv("SECLAI_PROFILE") or "default"
sso = load_sso_profile(resolved_dir, profile_name)
if sso:
return AuthState(
mode="sso",
api_key_header=api_key_header,
account_id=account_id or sso.sso_account_id,
sso_profile=sso,
config_dir=str(resolved_dir),
auto_refresh=auto_refresh,
)
except (OSError, configparser.Error):
pass

# 6. Nothing found
raise RuntimeError(
"Missing credentials. Pass api_key=..., access_token=..., "
"set SECLAI_API_KEY, or run `seclai auth login`."
# 5. SSO profile (always available via built-in defaults)
resolved_dir = resolve_config_dir(config_dir)
profile_name = profile or os.getenv("SECLAI_PROFILE") or "default"
sso = load_sso_profile(resolved_dir, profile_name)
return AuthState(
mode="sso",
Comment thread
burgaard marked this conversation as resolved.
api_key_header=api_key_header,
account_id=account_id or sso.sso_account_id,
sso_profile=sso,
config_dir=str(resolved_dir),
auto_refresh=auto_refresh,
)


Expand Down
9 changes: 6 additions & 3 deletions seclai/seclai.py
Original file line number Diff line number Diff line change
Expand Up @@ -1367,7 +1367,7 @@ def upload_file_to_source(
# Note: openapi-python-client currently struggles with Seclai's spec for this endpoint
# due to duplicate schema names, so we send the multipart request directly and parse
# into our SDK model types.
http = self._generated_client().get_httpx_client()
http = self._sync_generated_client().get_httpx_client()
raw = http.request(
"POST",
endpoint_path,
Expand Down Expand Up @@ -4538,7 +4538,7 @@ async def upload_file_to_source(

endpoint_path = f"/sources/{source_connection_id}/upload"

http = self._generated_client().get_async_httpx_client()
http = (await self._async_generated_client()).get_async_httpx_client()
raw = await http.request(
"POST",
endpoint_path,
Expand Down Expand Up @@ -5760,10 +5760,13 @@ async def download_source_export(
Returns:
A streaming ``httpx.Response``. Must be closed by the caller.
"""
headers = await _merge_request_headers_async(
options=self._options, request_headers=None
)
request = self._client.build_request(
"GET",
f"/sources/{source_id}/exports/{export_id}/download",
headers=_merge_request_headers(options=self._options, request_headers=None),
headers=headers,
Comment thread
burgaard marked this conversation as resolved.
)
response = await self._client.send(request, stream=True)
_raise_for_status(response)
Expand Down
11 changes: 7 additions & 4 deletions tests/test_auth_and_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@ def test_api_key_param_takes_precedence(monkeypatch: pytest.MonkeyPatch) -> None
assert client.api_key == "param-key"


def test_missing_api_key_raises(monkeypatch: pytest.MonkeyPatch) -> None:
def test_missing_api_key_falls_back_to_sso(
monkeypatch: pytest.MonkeyPatch, tmp_path: pytest.TempPathFactory
) -> None:
monkeypatch.delenv("SECLAI_API_KEY", raising=False)
monkeypatch.setenv("SECLAI_CONFIG_DIR", "/nonexistent-seclai-dir")
with pytest.raises(SeclaiConfigurationError):
_ = Seclai()
monkeypatch.setenv("SECLAI_CONFIG_DIR", str(tmp_path))
client = Seclai()
# With built-in SSO defaults, client succeeds and falls back to SSO mode
assert client._options.auth_state.mode == "sso"


def test_both_api_key_and_access_token_raises(monkeypatch: pytest.MonkeyPatch) -> None:
Expand Down
21 changes: 21 additions & 0 deletions tests/test_new_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -1599,6 +1599,27 @@ async def handler(req: httpx.Request) -> httpx.Response:
assert events[0][0] == "init"
assert events[1][0] == "done"

@pytest.mark.asyncio
async def test_async_download_source_export(self) -> None:
seen: dict[str, Any] = {}

async def handler(req: httpx.Request) -> httpx.Response:
seen["method"] = req.method
seen["path"] = req.url.path
seen["has_api_key"] = "x-api-key" in req.headers
return httpx.Response(status_code=200, content=b"csv-data")

client = _async_client(handler)
resp = await client.download_source_export("s1", "e1")
assert seen == {
"method": "GET",
"path": "/sources/s1/exports/e1/download",
"has_api_key": True,
}
await resp.aread()
assert resp.content == b"csv-data"
await resp.aclose()


# ---------------------------------------------------------------------------
# Top-level AI Assistant
Expand Down
Loading