Skip to content

Commit 365cbca

Browse files
committed
Claude and Codex logins that work from a server; auth diagnostics
- Claude: correct the public client ID (44d9), and make the browser method use Claude's hosted code page so the browser may run on another machine; the localhost flow stays available as 'loopback'. Pasted returns are checked against the registered destination and state before any exchange. - Auth HTTP identifies itself as lm15/<version>: Claude's token endpoint refuses Python urllib's default User-Agent with a bare 403. - Failed token exchanges report status, response format and a recognized OAuth error code only; provider text is never copied. - Method descriptions for Claude, Codex, OpenRouter, Copilot and xAI record what was observed live on 2026-09-22/23 and what remains open (provider permission, billing). - docs/cookbooks/21-managed-login-live.md: the live notebook, with authorization codes and state values redacted. Signed-off-by: Maxime Rivest <mrive052@gmail.com>
1 parent 9100305 commit 365cbca

13 files changed

Lines changed: 1702 additions & 107 deletions

‎docs/cookbooks/21-managed-login-live.md‎

Lines changed: 1020 additions & 0 deletions
Large diffs are not rendered by default.

‎docs/managed-login.md‎

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,23 +137,84 @@ store.
137137

138138
| Provider | Method | Status today |
139139
|---|---|---|
140-
| xAI | device code | **supported** (protocol validated live 2026-09-01; managed-path receipt owed) |
140+
| xAI | device code | **supported**: managed device login, inference, streaming, `connect()`, logout blocking, fresh-process persistence and one early renewal observed live (2026-09-22/23) |
141141
| Claude / Codex | your existing CLI login (`external:*`) | **supported** (the proven path since 2026-08-31) |
142-
| Claude, ChatGPT (browser, device), OpenRouter, Meta, Kimi Code, GitHub Copilot | LM15-owned login | **unverified**: implemented, no live receipt; `login(..., allow_unverified=True)` to try |
142+
| Claude | LM15-owned hosted browser login | Login, inference, fresh-process persistence and one early renewal observed **2026-09-23**. Still **unverified** for provider permission and billing; explicit opt-in remains required. |
143+
| ChatGPT / Codex | LM15-owned browser login | Login, catalog, inference, fresh-process persistence and one early renewal observed **2026-09-23**. Provider permission and billing remain **unverified**. |
144+
| ChatGPT / Codex | LM15-owned device login | Separate memory-only login and inference observed **2026-09-23**. Device persistence/renewal were not separately tested; permission and billing remain **unverified**. |
145+
| OpenRouter | Browser approval → API key | Key issuance, $1 limit inspection, model discovery, inference and fresh-process reuse observed **2026-09-23**. No refresh-token flow; broader support review remains pending. |
146+
| GitHub Copilot | Device login → Copilot token | github.com login, catalog, inference, fresh-process persistence and one early renewal observed **2026-09-23**. Uses the public Copilot Chat app registration; permission and billing remain **unverified**. |
147+
| Claude (loopback), Meta, Kimi Code | Other LM15-owned login methods | **unverified**: implemented, no live LM15 receipt; `login(..., allow_unverified=True)` to try |
143148
| Radius | — | **unavailable**: its model protocol is not in lm15-python |
144149

145150
An unverified method is never offered by the default picker. Promotion to
146151
*supported* needs a redacted login → inference → renewal receipt per provider,
147152
recorded in lm15-contract. Login success alone does not prove a subscription
148153
entitles you to anything; that is the provider's decision and your account's.
149154

155+
### Claude login with a browser on another machine
156+
157+
```python
158+
current = auth.status("claude-code").connection
159+
connection = auth.login(
160+
"claude-code", "browser", ui=TerminalUI(), allow_unverified=True,
161+
replace=current.id if current else None,
162+
)
163+
```
164+
165+
`browser` opens Claude's hosted code page. Copy its complete `code#state` back
166+
into the waiting prompt; a full hosted return URL is also accepted and checked
167+
against the exact registered destination. No localhost listener is opened, so
168+
Python may be on a server while the browser is on your laptop. A new attempt
169+
needs a new code; it never replays a failed exchange automatically.
170+
171+
The older localhost flow remains available as method `loopback` and still lacks
172+
live LM15 verification. The hosted method has now completed real LM15 login and
173+
inference. Separate fresh Python processes confirmed loading the saved credential,
174+
one successful renewal, and inference after reloading the renewed credential.
175+
Renewal was triggered early with a process-local freshness override; saved expiry
176+
and the wall clock were not falsified. This does not prove an unattended full
177+
expiry cycle, provider permission or included billing. Both methods still require
178+
`allow_unverified=True`, and the existing CLI credential route remains available.
179+
180+
### OpenRouter: no manual key handling, but still a key
181+
182+
```python
183+
auth.login("openrouter", "browser", ui=TerminalUI(), allow_unverified=True)
184+
```
185+
186+
OpenRouter's approval page creates a user-controlled API key for the application.
187+
LM15 exchanges the authorization code and saves the key, so you do not need to
188+
create or paste one yourself. Requests spend OpenRouter credits; this is not
189+
subscription access. The approval page can set a spending limit and expiration.
190+
The observed live test used a $1 total limit, no automatic reset and no expiration.
191+
192+
The saved key worked from a fresh Python process without another authorization
193+
or refresh request. Revoking the key or exhausting its limit still stops access.
194+
Immediate usage reporting may lag, so a zero usage value does not prove calls
195+
were free. Broader conformance review is still pending; explicit opt-in remains.
196+
150197
## Errors
151198

152199
`AuthOperationError` (`code="auth_operation"`) is root-level and never
153200
auto-retried. Match on `.reason`; read `.commit_state` (`committed`,
154201
`not_committed`, `unknown`) before deciding whether the store changed;
155202
`.recovery` says what a person can do. Provider text is never copied into it.
156203

204+
For a failed Claude token exchange, `.status` retains the HTTP status and
205+
`.provider_code` contains only a recognized OAuth error code (otherwise `None`).
206+
The message identifies JSON, HTML, empty or invalid responses. A security
207+
challenge is reported only when the response explicitly marks one; HTML or
208+
HTTP 403 alone does not establish the cause. Unknown provider messages are
209+
intentionally withheld rather than risking credentials in logs. These clues
210+
are not proof of registration permission or subscription entitlement, and do
211+
not authorize retrying a one-use code.
212+
213+
Auth HTTP requests identify this SDK as `lm15/<version>` instead of inheriting
214+
Python urllib's default User-Agent. Provider-specific identifiers explicitly
215+
required by a flow are preserved; LM15 does not retry failed token exchanges
216+
with guessed headers or impersonate another HTTP library.
217+
157218
## Async
158219

159220
`AsyncAuth` wraps the same manager for `async` code: logins are long and

‎lm15/auth.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
from typing import Any
5757

5858
from ._authlock import CredentialLockTimeout, hold_file_lock, write_private_json_atomic
59+
from ._version import __version__
5960
from .errors import AuthError, NotConfiguredError, UnsupportedFeatureError
6061

6162
__all__ = [
@@ -101,7 +102,7 @@ def _user_path(*parts: str) -> Path:
101102

102103

103104
CLAUDE_CODE_CREDENTIALS_PATH = _user_path(".claude", ".credentials.json")
104-
CLAUDE_CODE_CLIENT_ID = "9d1c250a-e61b-44d5-88ed-5944d1962f5e"
105+
CLAUDE_CODE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
105106
CLAUDE_CODE_TOKEN_URL = "https://platform.claude.com/v1/oauth/token"
106107
CLAUDE_CODE_LOGIN_HINT = "Log in again: run `claude` and use /login (Claude subscription auth)"
107108

@@ -203,7 +204,8 @@ def _post_json(url: str, payload: dict[str, Any]) -> dict[str, Any]:
203204
request = urllib.request.Request(
204205
url,
205206
data=body,
206-
headers={"Content-Type": "application/json", "Accept": "application/json"},
207+
headers={"Content-Type": "application/json", "Accept": "application/json",
208+
"User-Agent": f"lm15/{__version__}"},
207209
method="POST",
208210
)
209211
with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310 - provider token endpoint
@@ -216,7 +218,8 @@ def _post_form(url: str, payload: dict[str, Any]) -> dict[str, Any]:
216218
request = urllib.request.Request(
217219
url,
218220
data=body,
219-
headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"},
221+
headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json",
222+
"User-Agent": f"lm15/{__version__}"},
220223
method="POST",
221224
)
222225
with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310 - provider token endpoint

‎lm15/login/engine.py‎

Lines changed: 136 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
from typing import Any, Callable
4343
from urllib.parse import parse_qsl, urlsplit
4444

45+
from .._version import __version__
4546
from ..errors import AuthError, AuthOperationError, ServerError, TransportError
4647
from .types import AuthUI, ManualCodePrompt, Notice, Prompt
4748

@@ -90,6 +91,13 @@ class LoginDenied(Exception):
9091
a code exchange, a device code the provider says expired). The message
9192
is ours; provider text is never copied into it."""
9293

94+
def __init__(self, message: str, *, status: int | None = None,
95+
provider_code: str | None = None, stage: str = "authorization") -> None:
96+
super().__init__(message)
97+
self.status = status
98+
self.provider_code = provider_code
99+
self.stage = stage
100+
93101

94102
# ─── The attempt context ───────────────────────────────────────────────
95103

@@ -161,11 +169,48 @@ def budget(self) -> float:
161169
# ─── Bounded HTTP (AUTH-18/20/21) ──────────────────────────────────────
162170

163171

172+
# Only these fixed protocol words can leave a private auth response. Never
173+
# reflect arbitrary error descriptions, URLs, header values or response bodies.
174+
_OAUTH_ERROR_CODES = frozenset({
175+
"invalid_request", "invalid_client", "invalid_grant", "unauthorized_client",
176+
"unsupported_grant_type", "invalid_scope", "access_denied", "server_error",
177+
"temporarily_unavailable", "authorization_pending", "slow_down", "expired_token",
178+
})
179+
180+
164181
@dataclass(frozen=True, slots=True)
165182
class HttpReply:
166183
status: int
167184
body: dict[str, Any] = field(repr=False) # may hold tokens: never rendered
168185
ok: bool = False
186+
response_format: str = "unknown"
187+
oauth_error: str | None = None
188+
security_challenge: bool = False
189+
190+
def failure_summary(self) -> str:
191+
details = [f"HTTP {self.status}", f"response={self.response_format}"]
192+
if self.oauth_error:
193+
details.append(f"OAuth error={self.oauth_error}")
194+
else:
195+
details.append("no recognized OAuth error code; cause not established")
196+
if self.security_challenge:
197+
details.append("response explicitly marked as a security challenge")
198+
elif self.response_format == "html":
199+
details.append("HTML alone does not establish a security block")
200+
return "; ".join(details)
201+
202+
203+
def _auth_json_members(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
204+
result: dict[str, Any] = {}
205+
for key, value in pairs:
206+
if key in result:
207+
raise ValueError("duplicate member in auth response")
208+
result[key] = value
209+
return result
210+
211+
212+
def _reject_auth_number(value: str) -> None:
213+
raise ValueError("non-finite number in auth response")
169214

170215

171216
def _tls_only(url: str) -> None:
@@ -189,22 +234,31 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D401
189234

190235
def _send(ctx: LoginContext, request: urllib.request.Request) -> HttpReply:
191236
_tls_only(request.full_url)
237+
# Identify this SDK, not urllib (some auth endpoints reject that default).
238+
# Keep an explicit provider-required identification, e.g. Copilot's headers.
239+
if not request.has_header("User-agent"):
240+
request.add_header("User-Agent", f"lm15/{__version__}")
192241
timeout = ctx.budget()
193242
opener = ctx.opener
243+
response_headers: Any = {}
194244
try:
195245
if opener is not None:
196246
response = opener(request, timeout)
197247
else:
198248
response = _OPENER.open(request, timeout=timeout) # noqa: S310 - TLS enforced above
199249
with response:
200250
status = getattr(response, "status", None) or response.getcode()
251+
response_headers = getattr(response, "headers", None) or {}
201252
raw = response.read(AUTH_RESPONSE_LIMIT + 1)
202253
except urllib.error.HTTPError as exc:
203254
status = exc.code
255+
response_headers = exc.headers or {}
204256
try:
205257
raw = exc.read(AUTH_RESPONSE_LIMIT + 1)
206258
except Exception:
207259
raw = b""
260+
finally:
261+
exc.close()
208262
except (urllib.error.URLError, http.client.HTTPException, OSError, TimeoutError) as exc:
209263
# The URL is safe; the exception text can contain anything the
210264
# network stack saw, so only its class is named. ``exchange_uncertain``
@@ -224,19 +278,39 @@ def _send(ctx: LoginContext, request: urllib.request.Request) -> HttpReply:
224278
provider=ctx.provider or None,
225279
)
226280
body: dict[str, Any] = {}
281+
# Map headers to fixed categories; never return their raw values.
282+
content_type = response_headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
283+
response_format = "empty"
284+
oauth_error = None
227285
if raw:
286+
response_format = "text_or_binary"
287+
if content_type in ("text/html", "application/xhtml+xml"):
288+
response_format = "html"
289+
elif content_type == "application/json" or content_type.endswith("+json"):
290+
response_format = "invalid_json"
228291
try:
229-
parsed = json.loads(raw.decode("utf-8"))
230-
except (ValueError, UnicodeDecodeError):
292+
parsed = json.loads(raw.decode("utf-8"), object_pairs_hook=_auth_json_members,
293+
parse_constant=_reject_auth_number)
294+
except (ValueError, UnicodeDecodeError, RecursionError):
231295
parsed = None
296+
else:
297+
response_format = "json"
232298
if isinstance(parsed, dict):
233299
body = parsed
300+
candidate = parsed.get("error")
301+
if isinstance(candidate, dict):
302+
candidate = candidate.get("code", candidate.get("type"))
303+
if isinstance(candidate, str) and candidate in _OAUTH_ERROR_CODES:
304+
oauth_error = candidate
305+
security_challenge = response_headers.get("cf-mitigated", "").strip().lower() == "challenge"
234306
if status >= 500:
235307
raise ServerError(
236308
f"{ctx.provider or 'auth'}: the authentication server answered HTTP {status}",
237309
provider=ctx.provider or None, status=status,
238310
)
239-
return HttpReply(status=status, body=body, ok=200 <= status < 300)
311+
return HttpReply(status=status, body=body, ok=200 <= status < 300,
312+
response_format=response_format, oauth_error=oauth_error,
313+
security_challenge=security_challenge)
240314

241315

242316
def _uncertain(exc: BaseException) -> bool:
@@ -499,55 +573,79 @@ def __exit__(self, *_exc: Any) -> None:
499573

500574

501575
def parse_manual_return(text: str, *, expected_state: str | None, allow_bare_code: bool,
502-
registered_path: str | None) -> CallbackReturn:
503-
"""Read a pasted redirect URL, ``code=...`` query, ``code#state`` or bare
504-
code. A URL is checked against the attempt's registered return path and
505-
state; a bare code carries no state, so it is accepted only when the
506-
method profile says so (``allow_bare_code``)."""
576+
registered_path: str | None, registered_uri: str | None = None) -> CallbackReturn:
577+
"""Validate a manual return, including state on provider error callbacks.
578+
579+
When registered_uri is supplied, a full URL must match its scheme, host,
580+
effective port and path. A bare code is allowed only for profiles that
581+
explicitly permit it; it is not permission for a URL to omit its state.
582+
No pasted value is included in an exception.
583+
"""
507584
value = (text or "").strip()
508585
if not value:
509586
raise AuthOperationError("nothing was pasted", reason="invalid_login_state", stage="interaction",
510587
recovery="provide_input")
511588
if len(value) > CALLBACK_TARGET_LIMIT:
512589
raise AuthOperationError("pasted return is too long", reason="invalid_login_state", stage="interaction",
513590
recovery="provide_input")
591+
def invalid(message: str) -> AuthOperationError:
592+
return AuthOperationError(message, reason="invalid_login_state", stage="interaction",
593+
recovery="provide_input")
594+
514595
code: str | None = None
515596
state: str | None = None
597+
params: list[tuple[str, str]] | None = None
598+
bare = False
516599
if "://" in value:
517-
split = urlsplit(value)
518-
if registered_path is not None and split.path != registered_path:
519-
raise AuthOperationError(
520-
"the pasted URL is not this sign-in's return URL", reason="invalid_login_state",
521-
stage="interaction", recovery="provide_input",
522-
)
523-
params = parse_qsl(split.query, keep_blank_values=True)
524-
names = [k for k, _ in params]
525-
if len(names) != len(set(names)):
526-
raise AuthOperationError("the pasted URL repeats a parameter", reason="invalid_login_state",
527-
stage="interaction", recovery="provide_input")
528-
query = dict(params)
529-
if "error" in query:
530-
raise LoginDenied("the pasted return carries a provider error")
531-
code, state = query.get("code"), query.get("state")
532-
elif "code=" in value:
533-
query = dict(parse_qsl(value, keep_blank_values=True))
534-
code, state = query.get("code"), query.get("state")
600+
try:
601+
split = urlsplit(value)
602+
if split.username is not None or split.password is not None or split.fragment:
603+
raise ValueError("unexpected URL components")
604+
if registered_path is not None and split.path != registered_path:
605+
raise ValueError("wrong callback path")
606+
if registered_uri is not None:
607+
expected = urlsplit(registered_uri)
608+
609+
def address(url):
610+
port = url.port if url.port is not None else {"https": 443, "http": 80}.get(url.scheme)
611+
return url.scheme, url.hostname, port, url.path
612+
613+
if address(split) != address(expected):
614+
raise ValueError("wrong callback destination")
615+
params = parse_qsl(split.query, keep_blank_values=True)
616+
except ValueError:
617+
raise invalid("the pasted URL is not this sign-in's registered return URL") from None
618+
elif value.startswith(("code=", "state=", "error=")):
619+
params = parse_qsl(value, keep_blank_values=True)
535620
elif "#" in value:
536621
code, _, state = value.partition("#")
537622
else:
538-
code = value
623+
code, bare = value, True
624+
625+
denied = False
626+
if params is not None:
627+
names = [key for key, _ in params]
628+
if len(names) != len(set(names)):
629+
raise invalid("the pasted return repeats a parameter")
630+
query = dict(params)
631+
if "code" in query and "error" in query:
632+
raise invalid("the pasted return contains both a code and an error")
633+
denied = "error" in query
634+
code, state = query.get("code"), query.get("state")
635+
636+
if bare and not allow_bare_code:
637+
raise invalid("paste the complete code#state or return URL, not the code alone")
638+
if expected_state is not None:
639+
if state is None:
640+
if not (bare and allow_bare_code):
641+
raise invalid("this provider's return must carry its state value")
642+
elif not secrets.compare_digest(state.encode("utf-8"), expected_state.encode("utf-8")):
643+
raise invalid("the pasted return does not belong to this sign-in attempt")
644+
# A wrong-state error must never terminate the legitimate attempt as denied.
645+
if denied:
646+
raise LoginDenied("the validated pasted return carries a provider error")
539647
if not code:
540-
raise AuthOperationError("no authorization code in the pasted text", reason="invalid_login_state",
541-
stage="interaction", recovery="provide_input")
542-
if state is None:
543-
if expected_state is not None and not allow_bare_code:
544-
raise AuthOperationError(
545-
"paste the full redirect URL: this provider's return must carry its state value",
546-
reason="invalid_login_state", stage="interaction", recovery="provide_input",
547-
)
548-
elif expected_state is not None and not secrets.compare_digest(state, expected_state):
549-
raise AuthOperationError("the pasted return does not belong to this sign-in attempt",
550-
reason="invalid_login_state", stage="interaction", recovery="provide_input")
648+
raise invalid("no authorization code in the pasted text")
551649
return CallbackReturn(code=code, state=state)
552650

553651

0 commit comments

Comments
 (0)