oc_chat
Loading...
Searching...
No Matches
oauth.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# providers/openai_compat/oauth.py
4
5"""OpenRouter paste-only PKCE authentication.
6
7Purpose
8-------
9Exchange a manually pasted authorization code for a provider key.
10
11Responsibilities
12----------------
13Generate PKCE, display the URL through callbacks, and enforce transport policy.
14
15Diagnostics
16-----------
17Domain: OC_CHAT.CLIENT
18Levels:
19 L2 - lifecycle
20 L3 - semantic details without credentials
21 L4 - module lifecycle
22
23Contracts
24---------
25No listener, browser launch, refresh, proxy inheritance or redirect following.
26The operator approved this fixed-endpoint sink outside ClientInterface for P6.
27Credentials and transport exception contents never reach output channels.
28
29Dependencies
30------------
31- oc_diagnostics (diagnostics)
32- httpx (existing provider transport dependency)
33- option_c_agent_contracts.provider_plugin (OAuth protocols and credentials)
34"""
35from __future__ import annotations
36
37import base64
38import hashlib
39import secrets
40import time
41from types import MappingProxyType
42from typing import Final, Mapping
43from urllib.parse import parse_qs, urlencode, urlsplit
44
45import httpx
46from oc_diagnostics import _dbg
47from option_c_agent_contracts.provider_plugin import OAuthCredentials, OAuthLoginCallbacks
48
49_dbg('OC_CHAT.CLIENT', 'module', 'loaded', 4)
50
51TOKEN_ENDPOINTS: Final[Mapping[str, str]] = MappingProxyType({
52 'openrouter': 'https://openrouter.ai/api/v1/auth/keys',
53})
54CALLBACK_URL: Final = 'http://localhost:3000/'
55AUTHORIZE_URL: Final = 'https://openrouter.ai/auth'
56
57
58def _pasted_code(value: str) -> str:
59 """Accept a bare code or the exact callback URL copied from the browser."""
60 text = value.strip()
61 if '://' in text:
62 parts = urlsplit(text)
63 callback = urlsplit(CALLBACK_URL)
64 if (parts.scheme, parts.netloc, parts.path) != (callback.scheme, callback.netloc, callback.path):
65 raise ValueError('Unexpected OAuth callback URL.')
66 codes = parse_qs(parts.query).get('code', [])
67 if len(codes) != 1:
68 raise ValueError('Missing OAuth code.')
69 text = codes[0]
70 if not text or len(text) > 4096 or any(char.isspace() for char in text):
71 raise ValueError('Invalid OAuth code.')
72 return text
73
74
75def _exchange(code: str, verifier: str) -> str:
76 """Use the fixed HTTPS endpoint with no proxy inheritance or redirects."""
77 endpoint = TOKEN_ENDPOINTS['openrouter']
78 parts = urlsplit(endpoint)
79 if parts.scheme != 'https' or parts.hostname != 'openrouter.ai' or parts.username or parts.password:
80 raise ValueError('Invalid OAuth token endpoint.')
81 try:
82 with httpx.Client(trust_env=False, follow_redirects=False, timeout=30.0) as client:
83 response = client.post(endpoint, json={
84 'code': code,
85 'code_verifier': verifier,
86 'code_challenge_method': 'S256',
87 })
88 if not 200 <= response.status_code < 300:
89 raise ValueError('OAuth exchange refused.')
90 payload = response.json()
91 except Exception:
92 raise ValueError('OAuth exchange failed; run /login openrouter again.') from None
93 value = payload.get('key') if isinstance(payload, dict) else None
94 if not isinstance(value, str) or not value.strip():
95 raise ValueError('OAuth exchange returned no usable key.')
96 return value
97
98
100 """Optional capability for the OpenRouter catalogue row only."""
101
102 def oauth_login(self, callbacks: OAuthLoginCallbacks) -> OAuthCredentials:
103 """Display the URL and exchange the pasted code without opening a UI."""
104 verifier = secrets.token_urlsafe(32)
105 challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode('ascii')).digest()).rstrip(b'=').decode('ascii')
106 url = AUTHORIZE_URL + '?' + urlencode({
107 'callback_url': CALLBACK_URL,
108 'code_challenge': challenge,
109 'code_challenge_method': 'S256',
110 })
111 callbacks.on_auth(url)
112 pasted = callbacks.on_prompt('Paste the code or final redirect URL from the browser: ')
113 code = _pasted_code(pasted)
114 return OAuthCredentials(access=_exchange(code, verifier), obtained_at=time.time())
115
116 def oauth_refresh(self, creds: OAuthCredentials) -> OAuthCredentials:
117 """OpenRouter keys do not require a refresh flow in this first lane."""
118 raise NotImplementedError('Refresh is unavailable; run /login openrouter.')
119
120 def oauth_api_key(self, creds: OAuthCredentials) -> str:
121 """Return the bearer string expected by the existing provider client."""
122 return creds.access
str oauth_api_key(self, OAuthCredentials creds)
Definition oauth.py:120
OAuthCredentials oauth_login(self, OAuthLoginCallbacks callbacks)
Definition oauth.py:102
OAuthCredentials oauth_refresh(self, OAuthCredentials creds)
Definition oauth.py:116
str _exchange(str code, str verifier)
Definition oauth.py:75
str _pasted_code(str value)
Definition oauth.py:58