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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ dependencies = [
"argon2-cffi>=25.1.0",
"base58>=2.1.1",
"posthog>=3.0",
"browser-cookie3>=0.19",
]

[project.optional-dependencies]
Expand Down
59 changes: 59 additions & 0 deletions src/authsome/auth/browser_cookies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Read cookies from Chrome's on-disk SQLite database via browser-cookie3."""

from __future__ import annotations

import time

# Reserved credential key: Unix expiry timestamp for ttl_from_cookie (stripped on resume).
COOKIE_EXPIRES_AT_KEY = "__cookie_expires_at__"


def read_chrome_cookies(
domains: list[str],
*,
ttl_from_cookie: str | None = None,
) -> dict[str, str]:
"""Return a name→value dict of non-expired Chrome cookies matching *domains*.

When *ttl_from_cookie* is set and that cookie has a server expiry, the result
also includes ``COOKIE_EXPIRES_AT_KEY`` with its Unix timestamp as a string.

``import browser_cookie3`` is lazy so the server process never triggers it.
Raises ``ImportError`` if browser-cookie3 is not installed.
"""
import browser_cookie3 # noqa: PLC0415 — intentionally lazy

jar = browser_cookie3.chrome()
now = int(time.time())
result: dict[str, str] = {}
ttl_expires_at: int | None = None
for cookie in jar:
domain = cookie.domain or ""
normalized = domain.lstrip(".")
if not any(normalized == d.lstrip(".") or normalized.endswith("." + d.lstrip(".")) for d in domains):
continue
if cookie.expires and cookie.expires < now:
continue
result[cookie.name] = cookie.value
if ttl_from_cookie and cookie.name == ttl_from_cookie and cookie.expires:
ttl_expires_at = int(cookie.expires)
if ttl_expires_at is not None:
result[COOKIE_EXPIRES_AT_KEY] = str(ttl_expires_at)
return result


def cookies_are_valid(cookies: dict[str, str], auth_cookies: list[str]) -> bool:
"""Return True when every required auth cookie is present and non-empty."""
return all(cookies.get(name, "").strip() for name in auth_cookies)


def normalize_jsessionid(cookies: dict[str, str]) -> dict[str, str]:
"""Strip surrounding quotes from JSESSIONID values.

browser-cookie3 occasionally returns ``'"ajax:12345..."'`` with literal
double-quotes from the SQLite row; LinkedIn's API rejects the quoted form.
"""
result = dict(cookies)
if "JSESSIONID" in result:
result["JSESSIONID"] = result["JSESSIONID"].strip('"')
return result
25 changes: 25 additions & 0 deletions src/authsome/auth/bundled_providers/linkedin-browser.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"schema_version": 1,
"name": "linkedin-browser",
"display_name": "LinkedIn — Browser Session",
"auth_type": "browser",
"flow": "browser",
"api_url": "regex:^(www\\.linkedin\\.com|linkedin\\.com)",
"browser": {
"entry_url": "https://www.linkedin.com/login",
"domains": [".linkedin.com", "linkedin.com"],
"auth_cookies": ["li_at"],
"ttl_from_cookie": "li_at",
"ttl_hours": 24,
"extra_headers": {},
"extract": [
{ "cookie": "JSESSIONID", "header": "csrf-token" }
]
},
"export": {
"env": {
"li_at": "LINKEDIN_LI_AT",
"JSESSIONID": "LINKEDIN_JSESSIONID"
}
}
}
28 changes: 28 additions & 0 deletions src/authsome/auth/bundled_providers/x-browser.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"schema_version": 1,
"name": "x-browser",
"display_name": "X (Twitter) — Browser Session",
"auth_type": "browser",
"flow": "browser",
"api_url": "regex:^(api\\.twitter\\.com|api\\.x\\.com|x\\.com|twitter\\.com)",
"browser": {
"entry_url": "https://x.com/login",
"domains": [".x.com", "x.com", ".twitter.com", "twitter.com"],
"auth_cookies": ["auth_token"],
"ttl_from_cookie": "auth_token",
"ttl_hours": 24,
"extra_headers": {
"x-twitter-active-user": "yes",
"x-twitter-client-language": "en"
},
"extract": [
{ "cookie": "ct0", "header": "x-csrf-token" }
]
},
"export": {
"env": {
"auth_token": "X_AUTH_TOKEN",
"ct0": "X_CT0"
}
}
}
5 changes: 3 additions & 2 deletions src/authsome/auth/flows/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"""auth.flows — OAuth and API key authentication flow handlers."""
"""auth.flows — OAuth, API key, and browser authentication flow handlers."""

from authsome.auth.flows.api_key import ApiKeyFlow
from authsome.auth.flows.base import AuthFlow
from authsome.auth.flows.browser import BrowserFlow
from authsome.auth.flows.dcr_pkce import DcrPkceFlow
from authsome.auth.flows.device_code import DeviceCodeFlow
from authsome.auth.flows.pkce import PkceFlow

__all__ = ["ApiKeyFlow", "AuthFlow", "DcrPkceFlow", "DeviceCodeFlow", "PkceFlow"]
__all__ = ["ApiKeyFlow", "AuthFlow", "BrowserFlow", "DcrPkceFlow", "DeviceCodeFlow", "PkceFlow"]
184 changes: 184 additions & 0 deletions src/authsome/auth/flows/browser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"""Browser session cookie authentication flow.

begin() — daemon-side: stash config in session payload.
resume() — daemon-side: build ConnectionRecord from CLI-supplied cookies.
run_login() — CLI-side: read Chrome cookie DB, open browser if needed, poll until valid.
"""

from __future__ import annotations

import asyncio
import webbrowser
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any

from loguru import logger

from authsome.auth.browser_cookies import (
COOKIE_EXPIRES_AT_KEY,
cookies_are_valid,
normalize_jsessionid,
read_chrome_cookies,
)
from authsome.auth.flows.base import AuthFlow, FlowResult
from authsome.auth.models.connection import AccountInfo, ConnectionRecord
from authsome.auth.models.enums import AuthType, ConnectionStatus
from authsome.auth.models.provider import ProviderDefinition
from authsome.errors import AuthenticationFailedError, RefreshFailedError
from authsome.utils import utc_now

if TYPE_CHECKING:
from authsome.auth.sessions import AuthSession

_POLL_INTERVAL = 4.0
_DEFAULT_TIMEOUT = 300.0


class BrowserFlow(AuthFlow):
"""Cookie-based browser SSO — reads Chrome's on-disk cookie database."""

async def begin(
self,
provider: ProviderDefinition,
identity: str | None,
connection_name: str,
runtime_session: AuthSession,
scopes: list[str] | None = None,
client_id: str | None = None,
client_secret: str | None = None,
base_url: str | None = None,
) -> None:
if provider.browser is None:
raise AuthenticationFailedError("Provider missing 'browser' configuration", provider=provider.name)
cfg = provider.browser
runtime_session.state = "waiting_for_user"
runtime_session.payload["browser_login"] = True
runtime_session.payload["entry_url"] = cfg.entry_url
runtime_session.payload["domains"] = cfg.domains
runtime_session.payload["auth_cookies"] = cfg.auth_cookies
runtime_session.payload["ttl_from_cookie"] = cfg.ttl_from_cookie
runtime_session.payload["ttl_hours"] = cfg.ttl_hours

async def resume(
self,
provider: ProviderDefinition,
identity: str | None,
connection_name: str,
runtime_session: AuthSession,
callback_data: dict[str, Any],
client_id: str | None = None,
client_secret: str | None = None,
) -> FlowResult | None:
if provider.browser is None:
raise AuthenticationFailedError("Provider missing 'browser' configuration", provider=provider.name)
credentials = callback_data.get("credentials")
if not credentials or not isinstance(credentials, dict):
return None

now = utc_now()
stored_credentials = dict(credentials)
expires_at = _resolve_browser_expires_at(
stored_credentials,
ttl_hours=provider.browser.ttl_hours,
now=now,
)
return FlowResult(
connection=ConnectionRecord(
schema_version=2,
provider=provider.name,
identity=identity,
connection_name=connection_name,
auth_type=AuthType.BROWSER,
status=ConnectionStatus.CONNECTED,
credentials=stored_credentials,
expires_at=expires_at,
obtained_at=now,
account=AccountInfo(),
)
)

def refresh(
self,
provider: ProviderDefinition,
record: ConnectionRecord,
client_id: str | None = None,
client_secret: str | None = None,
) -> ConnectionRecord:
raise RefreshFailedError(
f"Browser cookies cannot be refreshed automatically — run: authsome login {record.provider}",
provider=record.provider,
)

@staticmethod
async def run_login(
action: dict[str, Any],
provider_name: str,
*,
poll_interval: float = _POLL_INTERVAL,
timeout: float = _DEFAULT_TIMEOUT,
) -> dict[str, str]:
"""CLI-side login: read Chrome cookies, open browser if needed, poll until valid.

Args:
action: The ``BrowserAction`` payload from ``next_action``.
provider_name: Provider name, used to select normalization (e.g. LinkedIn).
poll_interval: Seconds between cookie DB reads.
timeout: Total seconds before ``TimeoutError`` is raised.

Returns:
Cookie name→value dict ready to POST to ``/auth/sessions/{id}/resume``.
"""
entry_url: str = action["entry_url"]
domains: list[str] = action.get("domains", [])
auth_cookies: list[str] = action.get("auth_cookies", [])
ttl_from_cookie: str | None = action.get("ttl_from_cookie")

def _read() -> dict[str, str] | None:
try:
cookies = read_chrome_cookies(domains, ttl_from_cookie=ttl_from_cookie)
if provider_name == "linkedin-browser":
cookies = normalize_jsessionid(cookies)
if cookies_are_valid(cookies, auth_cookies):
return cookies
except Exception as exc:
logger.debug("Cookie read failed: {}", exc)
return None

# Fast path: already logged in
if result := _read():
logger.debug("authsome: existing cookies valid for {} — no browser open needed", provider_name)
return result

# Open browser for user to log in
try:
webbrowser.open(entry_url)
except Exception as exc:
logger.warning("Could not open browser: {}", exc)

deadline = asyncio.get_event_loop().time() + timeout
while True:
await asyncio.sleep(poll_interval)
if result := _read():
logger.info("authsome: browser cookies captured for {}", provider_name)
return result
if asyncio.get_event_loop().time() >= deadline:
raise TimeoutError(
f"Timed out waiting for browser login to {entry_url!r} after {int(timeout)}s. "
"Please complete login in the browser window."
)


def _resolve_browser_expires_at(
credentials: dict[str, str],
*,
ttl_hours: int,
now: datetime,
) -> datetime:
"""Use real cookie expiry when present, otherwise fall back to ttl_hours."""
raw_expiry = credentials.pop(COOKIE_EXPIRES_AT_KEY, None)
if raw_expiry:
try:
return datetime.fromtimestamp(int(raw_expiry), tz=UTC)
except (ValueError, OSError):
pass
return now + timedelta(hours=ttl_hours)
3 changes: 3 additions & 0 deletions src/authsome/auth/models/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ class ConnectionRecord(BaseModel):
# API key field
api_key: Annotated[str | None, Sensitive()] = None

# Browser session cookies (keyed by cookie name)
credentials: Annotated[dict[str, str] | None, Sensitive()] = None

# Account info
account: AccountInfo | None = Field(default_factory=AccountInfo)

Expand Down
2 changes: 2 additions & 0 deletions src/authsome/auth/models/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class AuthType(StrEnum):

OAUTH2 = "oauth2"
API_KEY = "api_key"
BROWSER = "browser"


class FlowType(StrEnum):
Expand All @@ -17,6 +18,7 @@ class FlowType(StrEnum):
DEVICE_CODE = "device_code"
DCR_PKCE = "dcr_pkce"
API_KEY = "api_key"
BROWSER = "browser"


class ConnectionStatus(StrEnum):
Expand Down
24 changes: 24 additions & 0 deletions src/authsome/auth/models/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,29 @@ class ExportConfig(BaseModel):
model_config = {"extra": "allow"}


class ExtractRule(BaseModel):
"""Map one cookie name to one HTTP request header."""

cookie: str
header: str
prefix: str = ""


class BrowserConfig(BaseModel):
"""Browser session cookie provider configuration."""

entry_url: str
domains: list[str]
auth_cookies: list[str]
validate_url: str | None = None
extra_headers: dict[str, str] = Field(default_factory=dict)
ttl_hours: int = 24
ttl_from_cookie: str | None = None # cookie name whose expires timestamp overrides ttl_hours
extract: list[ExtractRule] = Field(default_factory=list)

model_config = {"extra": "allow"}


class ProviderDefinition(BaseModel):
"""
Complete provider definition.
Expand All @@ -74,6 +97,7 @@ class ProviderDefinition(BaseModel):
oauth: OAuthConfig | None = None
registration: ClientRegistrationConfig | None = None
api_key: ApiKeyConfig | None = None
browser: BrowserConfig | None = None
export: ExportConfig | None = None
docs_url: str | None = None
api_url: str | None = None
Expand Down
Loading
Loading