5"""Provider OAuth credentials, stored separately from the flat secrets map.
9Keep provider authentication records private and resolve the runtime credential.
13Strict reads, atomic writes, isolated deletion and shared credential resolution.
19 L2 - corrupt store notification without contents
25No credential reaches print, logging, diagnostics or exception messages.
26Reads are strict; runtime resolution degrades on a malformed sidecar.
27Expiry refuses use and never triggers an automatic refresh.
31- oc_diagnostics (structured diagnostics)
32- option_c_agent_contracts.provider_plugin (OAuth value types and the provider plugin interface)
33- oc_chat.core.provider_secrets (gitignore and atomic mode helpers)
35from __future__
import annotations
41from collections.abc
import Mapping
42from dataclasses
import asdict
43from pathlib
import Path
45from oc_diagnostics
import _dbg
46from option_c_agent_contracts.provider_plugin
import OAuthCredentials, ProviderPlugin
48 SecretsFileError, _chmod_secret, _write_atomically, assert_gitignored,
51_dbg(
'OC_CHAT.MAIN',
'module',
'loaded', 4)
53DEFAULT_OAUTH_PATH = Path(
'.option_c/provider_oauth.json')
54_PROVIDER_NAME = re.compile(
r'[a-z][a-z0-9_]*\Z')
57def _target(path: str | Path |
None) -> Path:
58 return DEFAULT_OAUTH_PATH
if path
is None else Path(path)
62 if not isinstance(record, dict):
64 if not {
'access',
'obtained_at'} <= record.keys():
66 if record.keys() - {
'access',
'refresh',
'expires_at',
'obtained_at'}:
68 if not isinstance(record[
'access'], str)
or not record[
'access'].strip():
70 refresh = record.get(
'refresh')
71 if refresh
is not None and (
not isinstance(refresh, str)
or not refresh.strip()):
73 for name
in (
'obtained_at',
'expires_at'):
74 value = record.get(name)
75 if value
is None and name ==
'expires_at':
77 if isinstance(value, bool)
or not isinstance(value, (int, float)):
80 if not math.isfinite(value)
or value < 0:
88 """Read strictly, returning an empty store only when the file is absent."""
91 text = source.read_text(encoding=
'utf-8')
92 except FileNotFoundError:
94 except (OSError, UnicodeError):
95 raise SecretsFileError(
'Could not read the provider OAuth sidecar.')
from None
97 payload = json.loads(text)
98 except (ValueError, RecursionError):
99 raise SecretsFileError(
'Could not parse the provider OAuth sidecar.')
from None
100 if not isinstance(payload, dict)
or any(
101 not _PROVIDER_NAME.fullmatch(name)
or not _valid_record(record)
102 for name, record
in payload.items()
105 return {name: OAuthCredentials(**record)
for name, record
in payload.items()}
110 credentials: OAuthCredentials |
None,
112 path: str | Path |
None =
None,
114 """Replace one provider record, or remove only that record on logout."""
115 if not _PROVIDER_NAME.fullmatch(provider_name):
116 raise ValueError(
'Invalid OAuth provider name.')
117 if credentials
is not None and not _valid_record(asdict(credentials)):
118 raise ValueError(
'Invalid OAuth credential record.')
119 target = assert_gitignored(
_target(path))
120 payload = {name: asdict(creds)
for name, creds
in load_credentials(target).items()}
121 if credentials
is None:
122 payload.pop(provider_name,
None)
124 payload[provider_name] = asdict(credentials)
125 _write_atomically(payload, target)
126 _chmod_secret(target)
131 provider: ProviderPlugin,
132 api_key: str |
None =
None,
134 environ: Mapping[str, str] |
None =
None,
136 """Resolve explicit argument, environment, then a usable stored credential."""
138 func =
"resolve_api_key"
139 if api_key
is not None:
141 env = os.environ
if environ
is None else environ
142 value = env.get(provider.api_key_env_var,
'')
if provider.api_key_env_var
else ''
145 capability = getattr(provider,
'oauth',
None)
146 if capability
is None:
150 except SecretsFileError:
151 _dbg(
'OC_CHAT.MAIN', func,
'Provider OAuth sidecar could not be loaded.', 2)
153 if credentials
is None:
155 message = f
'Credential unavailable for {provider.name}; run /login {provider.name}.'
156 if credentials.expires_at
is not None and credentials.expires_at <= time.time():
157 raise ValueError(message)
159 value = capability.oauth_api_key(credentials)
161 raise ValueError(message)
from None
162 if not isinstance(value, str)
or not value.strip():
163 raise ValueError(message)
168 """Presence-only gate; invalid or expired credentials never pass."""
Path write_credentials(str provider_name, OAuthCredentials|None credentials, *, str|Path|None path=None)
bool has_usable_credentials(ProviderPlugin provider)
str resolve_api_key(ProviderPlugin provider, str|None api_key=None, *, Mapping[str, str]|None environ=None)
Path _target(str|Path|None path)
dict[str, OAuthCredentials] load_credentials(str|Path|None path=None)
bool _valid_record(object record)