Skip to content

Commit aeb4263

Browse files
manojbajaj95claude
andcommitted
feat: add browser SSO via Chrome cookie reading (browser-cookie3)
Enables authenticated access to sites like X/Twitter and LinkedIn that use browser session cookies rather than OAuth or API keys. Core logic lives entirely in auth/ — BrowserFlow.run_login() reads Chrome's on-disk SQLite cookie database via browser-cookie3 (macOS Keychain / Linux GNOME Keyring / Windows DPAPI), opens the site in the user's default browser if no valid session exists, then polls until the required auth cookies appear. No separate Chrome profile, no Playwright. - auth/browser_cookies.py: read_chrome_cookies(), cookies_are_valid(), normalize_jsessionid() with lazy browser-cookie3 import - auth/flows/browser.py: BrowserFlow (begin/resume/refresh) + static run_login() for CLI use - auth/models/: AuthType.BROWSER, FlowType.BROWSER, BrowserConfig, ExtractRule, ConnectionRecord.credentials field - server/: register flow, header rendering, export branch, BrowserAction schema, _session_response wiring (~35 lines total) - cli/main.py: 8-line elif block calling BrowserFlow.run_login() - Bundled providers: x-browser, linkedin-browser - 30 new tests in tests/auth/ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Entire-Checkpoint: 3753e27e5941
1 parent fa23037 commit aeb4263

17 files changed

Lines changed: 803 additions & 4 deletions

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ dependencies = [
4040
"argon2-cffi>=25.1.0",
4141
"base58>=2.1.1",
4242
"posthog>=3.0",
43+
"browser-cookie3>=0.19",
4344
]
4445

4546
[project.optional-dependencies]
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Read cookies from Chrome's on-disk SQLite database via browser-cookie3."""
2+
3+
from __future__ import annotations
4+
5+
import time
6+
7+
8+
def read_chrome_cookies(domains: list[str]) -> dict[str, str]:
9+
"""Return a name→value dict of non-expired Chrome cookies matching *domains*.
10+
11+
``import browser_cookie3`` is lazy so the server process never triggers it.
12+
Raises ``ImportError`` if browser-cookie3 is not installed.
13+
"""
14+
import browser_cookie3 # noqa: PLC0415 — intentionally lazy
15+
16+
jar = browser_cookie3.chrome(domain_name=None)
17+
now = int(time.time())
18+
result: dict[str, str] = {}
19+
for cookie in jar:
20+
domain = cookie.domain or ""
21+
normalized = domain.lstrip(".")
22+
if not any(normalized == d.lstrip(".") or normalized.endswith("." + d.lstrip(".")) for d in domains):
23+
continue
24+
if cookie.expires and cookie.expires < now:
25+
continue
26+
result[cookie.name] = cookie.value
27+
return result
28+
29+
30+
def cookies_are_valid(cookies: dict[str, str], auth_cookies: list[str]) -> bool:
31+
"""Return True when every required auth cookie is present and non-empty."""
32+
return all(cookies.get(name, "").strip() for name in auth_cookies)
33+
34+
35+
def normalize_jsessionid(cookies: dict[str, str]) -> dict[str, str]:
36+
"""Strip surrounding quotes from JSESSIONID values.
37+
38+
browser-cookie3 occasionally returns ``'"ajax:12345..."'`` with literal
39+
double-quotes from the SQLite row; LinkedIn's API rejects the quoted form.
40+
"""
41+
result = dict(cookies)
42+
if "JSESSIONID" in result:
43+
result["JSESSIONID"] = result["JSESSIONID"].strip('"')
44+
return result
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"schema_version": 1,
3+
"name": "linkedin-browser",
4+
"display_name": "LinkedIn — Browser Session",
5+
"auth_type": "browser",
6+
"flow": "browser",
7+
"api_url": "regex:^(www\\.linkedin\\.com|linkedin\\.com)",
8+
"browser": {
9+
"entry_url": "https://www.linkedin.com/login",
10+
"domains": [".linkedin.com", "linkedin.com"],
11+
"auth_cookies": ["li_at"],
12+
"ttl_hours": 24,
13+
"extra_headers": {},
14+
"extract": [
15+
{ "cookie": "JSESSIONID", "header": "csrf-token" }
16+
]
17+
},
18+
"export": {
19+
"env": {
20+
"li_at": "LINKEDIN_LI_AT",
21+
"JSESSIONID": "LINKEDIN_JSESSIONID"
22+
}
23+
}
24+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"schema_version": 1,
3+
"name": "x-browser",
4+
"display_name": "X (Twitter) — Browser Session",
5+
"auth_type": "browser",
6+
"flow": "browser",
7+
"api_url": "regex:^(api\\.twitter\\.com|api\\.x\\.com|x\\.com|twitter\\.com)",
8+
"browser": {
9+
"entry_url": "https://x.com/login",
10+
"domains": [".x.com", "x.com", ".twitter.com", "twitter.com"],
11+
"auth_cookies": ["auth_token"],
12+
"ttl_hours": 24,
13+
"extra_headers": {
14+
"x-twitter-active-user": "yes",
15+
"x-twitter-client-language": "en"
16+
},
17+
"extract": [
18+
{ "cookie": "ct0", "header": "x-csrf-token" }
19+
]
20+
},
21+
"export": {
22+
"env": {
23+
"auth_token": "X_AUTH_TOKEN",
24+
"ct0": "X_CT0"
25+
}
26+
}
27+
}
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
"""auth.flows — OAuth and API key authentication flow handlers."""
1+
"""auth.flows — OAuth, API key, and browser authentication flow handlers."""
22

33
from authsome.auth.flows.api_key import ApiKeyFlow
44
from authsome.auth.flows.base import AuthFlow
5+
from authsome.auth.flows.browser import BrowserFlow
56
from authsome.auth.flows.dcr_pkce import DcrPkceFlow
67
from authsome.auth.flows.device_code import DeviceCodeFlow
78
from authsome.auth.flows.pkce import PkceFlow
89

9-
__all__ = ["ApiKeyFlow", "AuthFlow", "DcrPkceFlow", "DeviceCodeFlow", "PkceFlow"]
10+
__all__ = ["ApiKeyFlow", "AuthFlow", "BrowserFlow", "DcrPkceFlow", "DeviceCodeFlow", "PkceFlow"]

src/authsome/auth/flows/browser.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""Browser session cookie authentication flow.
2+
3+
begin() — daemon-side: stash config in session payload.
4+
resume() — daemon-side: build ConnectionRecord from CLI-supplied cookies.
5+
run_login() — CLI-side: read Chrome cookie DB, open browser if needed, poll until valid.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import asyncio
11+
import webbrowser
12+
from datetime import timedelta
13+
from typing import TYPE_CHECKING, Any
14+
15+
from loguru import logger
16+
17+
from authsome.auth.browser_cookies import cookies_are_valid, normalize_jsessionid, read_chrome_cookies
18+
from authsome.auth.flows.base import AuthFlow, FlowResult
19+
from authsome.auth.models.connection import AccountInfo, ConnectionRecord
20+
from authsome.auth.models.enums import AuthType, ConnectionStatus
21+
from authsome.auth.models.provider import ProviderDefinition
22+
from authsome.errors import AuthenticationFailedError, RefreshFailedError
23+
from authsome.utils import utc_now
24+
25+
if TYPE_CHECKING:
26+
from authsome.auth.sessions import AuthSession
27+
28+
_POLL_INTERVAL = 4.0
29+
_DEFAULT_TIMEOUT = 300.0
30+
31+
32+
class BrowserFlow(AuthFlow):
33+
"""Cookie-based browser SSO — reads Chrome's on-disk cookie database."""
34+
35+
async def begin(
36+
self,
37+
provider: ProviderDefinition,
38+
identity: str | None,
39+
connection_name: str,
40+
runtime_session: AuthSession,
41+
scopes: list[str] | None = None,
42+
client_id: str | None = None,
43+
client_secret: str | None = None,
44+
base_url: str | None = None,
45+
) -> None:
46+
if provider.browser is None:
47+
raise AuthenticationFailedError("Provider missing 'browser' configuration", provider=provider.name)
48+
cfg = provider.browser
49+
runtime_session.state = "waiting_for_user"
50+
runtime_session.payload["browser_login"] = True
51+
runtime_session.payload["entry_url"] = cfg.entry_url
52+
runtime_session.payload["domains"] = cfg.domains
53+
runtime_session.payload["auth_cookies"] = cfg.auth_cookies
54+
55+
async def resume(
56+
self,
57+
provider: ProviderDefinition,
58+
identity: str | None,
59+
connection_name: str,
60+
runtime_session: AuthSession,
61+
callback_data: dict[str, Any],
62+
client_id: str | None = None,
63+
client_secret: str | None = None,
64+
) -> FlowResult | None:
65+
if provider.browser is None:
66+
raise AuthenticationFailedError("Provider missing 'browser' configuration", provider=provider.name)
67+
credentials = callback_data.get("credentials")
68+
if not credentials or not isinstance(credentials, dict):
69+
return None
70+
71+
now = utc_now()
72+
return FlowResult(
73+
connection=ConnectionRecord(
74+
schema_version=2,
75+
provider=provider.name,
76+
identity=identity,
77+
connection_name=connection_name,
78+
auth_type=AuthType.BROWSER,
79+
status=ConnectionStatus.CONNECTED,
80+
credentials=credentials,
81+
expires_at=now + timedelta(hours=provider.browser.ttl_hours),
82+
obtained_at=now,
83+
account=AccountInfo(),
84+
)
85+
)
86+
87+
def refresh(
88+
self,
89+
provider: ProviderDefinition,
90+
record: ConnectionRecord,
91+
client_id: str | None = None,
92+
client_secret: str | None = None,
93+
) -> ConnectionRecord:
94+
raise RefreshFailedError(
95+
f"Browser cookies cannot be refreshed automatically — run: authsome login {record.provider}",
96+
provider=record.provider,
97+
)
98+
99+
@staticmethod
100+
async def run_login(
101+
action: dict[str, Any],
102+
provider_name: str,
103+
*,
104+
poll_interval: float = _POLL_INTERVAL,
105+
timeout: float = _DEFAULT_TIMEOUT,
106+
) -> dict[str, str]:
107+
"""CLI-side login: read Chrome cookies, open browser if needed, poll until valid.
108+
109+
Args:
110+
action: The ``BrowserAction`` payload from ``next_action``.
111+
provider_name: Provider name, used to select normalization (e.g. LinkedIn).
112+
poll_interval: Seconds between cookie DB reads.
113+
timeout: Total seconds before ``TimeoutError`` is raised.
114+
115+
Returns:
116+
Cookie name→value dict ready to POST to ``/auth/sessions/{id}/resume``.
117+
"""
118+
entry_url: str = action["entry_url"]
119+
domains: list[str] = action.get("domains", [])
120+
auth_cookies: list[str] = action.get("auth_cookies", [])
121+
122+
def _read() -> dict[str, str] | None:
123+
try:
124+
cookies = read_chrome_cookies(domains)
125+
if provider_name == "linkedin-browser":
126+
cookies = normalize_jsessionid(cookies)
127+
if cookies_are_valid(cookies, auth_cookies):
128+
return cookies
129+
except Exception as exc:
130+
logger.debug("Cookie read failed: {}", exc)
131+
return None
132+
133+
# Fast path: already logged in
134+
if result := _read():
135+
logger.debug("authsome: existing cookies valid for {} — no browser open needed", provider_name)
136+
return result
137+
138+
# Open browser for user to log in
139+
try:
140+
webbrowser.open(entry_url)
141+
except Exception as exc:
142+
logger.warning("Could not open browser: {}", exc)
143+
144+
deadline = asyncio.get_event_loop().time() + timeout
145+
while True:
146+
await asyncio.sleep(poll_interval)
147+
if result := _read():
148+
logger.info("authsome: browser cookies captured for {}", provider_name)
149+
return result
150+
if asyncio.get_event_loop().time() >= deadline:
151+
raise TimeoutError(
152+
f"Timed out waiting for browser login to {entry_url!r} after {int(timeout)}s. "
153+
"Please complete login in the browser window."
154+
)

src/authsome/auth/models/connection.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ class ConnectionRecord(BaseModel):
6161
# API key field
6262
api_key: Annotated[str | None, Sensitive()] = None
6363

64+
# Browser session cookies (keyed by cookie name)
65+
credentials: Annotated[dict[str, str] | None, Sensitive()] = None
66+
6467
# Account info
6568
account: AccountInfo | None = Field(default_factory=AccountInfo)
6669

src/authsome/auth/models/enums.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class AuthType(StrEnum):
88

99
OAUTH2 = "oauth2"
1010
API_KEY = "api_key"
11+
BROWSER = "browser"
1112

1213

1314
class FlowType(StrEnum):
@@ -17,6 +18,7 @@ class FlowType(StrEnum):
1718
DEVICE_CODE = "device_code"
1819
DCR_PKCE = "dcr_pkce"
1920
API_KEY = "api_key"
21+
BROWSER = "browser"
2022

2123

2224
class ConnectionStatus(StrEnum):

src/authsome/auth/models/provider.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,28 @@ class ExportConfig(BaseModel):
5858
model_config = {"extra": "allow"}
5959

6060

61+
class ExtractRule(BaseModel):
62+
"""Map one cookie name to one HTTP request header."""
63+
64+
cookie: str
65+
header: str
66+
prefix: str = ""
67+
68+
69+
class BrowserConfig(BaseModel):
70+
"""Browser session cookie provider configuration."""
71+
72+
entry_url: str
73+
domains: list[str]
74+
auth_cookies: list[str]
75+
validate_url: str | None = None
76+
extra_headers: dict[str, str] = Field(default_factory=dict)
77+
ttl_hours: int = 24
78+
extract: list[ExtractRule] = Field(default_factory=list)
79+
80+
model_config = {"extra": "allow"}
81+
82+
6183
class ProviderDefinition(BaseModel):
6284
"""
6385
Complete provider definition.
@@ -74,6 +96,7 @@ class ProviderDefinition(BaseModel):
7496
oauth: OAuthConfig | None = None
7597
registration: ClientRegistrationConfig | None = None
7698
api_key: ApiKeyConfig | None = None
99+
browser: BrowserConfig | None = None
77100
export: ExportConfig | None = None
78101
docs_url: str | None = None
79102
api_url: str | None = None

src/authsome/cli/main.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,15 @@ async def login(
186186
except Exception:
187187
pass
188188

189+
elif action_type == "browser":
190+
from authsome.auth.flows.browser import BrowserFlow
191+
192+
credentials = await BrowserFlow.run_login(next_action, provider)
193+
session_info = await actx.runtime_client.resume_login_session(
194+
session_info["id"], credentials=credentials
195+
)
196+
login_result = _build_login_json_payload(session_info, provider, connection)
197+
189198
logger.info(
190199
"client_event event=login provider={} connection={} flow={} status={}",
191200
provider,

0 commit comments

Comments
 (0)