oc_chat
Loading...
Searching...
No Matches
provider_oauth.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/provider_oauth.py
4
5"""Provider OAuth credentials, stored separately from the flat secrets map.
6
7Purpose
8-------
9Keep provider authentication records private and resolve the runtime credential.
10
11Responsibilities
12----------------
13Strict reads, atomic writes, isolated deletion and shared credential resolution.
14
15Diagnostics
16-----------
17Domain: OC_CHAT.MAIN
18Levels:
19 L2 - corrupt store notification without contents
20 L3 - lifecycle counts
21 L4 - module lifecycle
22
23Contracts
24---------
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.
28
29Dependencies
30------------
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)
34"""
35from __future__ import annotations
36
37import json
38import math
39import re
40import time
41from collections.abc import Mapping
42from dataclasses import asdict
43from pathlib import Path
44
45from oc_diagnostics import _dbg
46from option_c_agent_contracts.provider_plugin import OAuthCredentials, ProviderPlugin
48 SecretsFileError, _chmod_secret, _write_atomically, assert_gitignored,
49)
50
51_dbg('OC_CHAT.MAIN', 'module', 'loaded', 4)
52
53DEFAULT_OAUTH_PATH = Path('.option_c/provider_oauth.json')
54_PROVIDER_NAME = re.compile(r'[a-z][a-z0-9_]*\Z')
55
56
57def _target(path: str | Path | None) -> Path:
58 return DEFAULT_OAUTH_PATH if path is None else Path(path)
59
60
61def _valid_record(record: object) -> bool:
62 if not isinstance(record, dict):
63 return False
64 if not {'access', 'obtained_at'} <= record.keys():
65 return False
66 if record.keys() - {'access', 'refresh', 'expires_at', 'obtained_at'}:
67 return False
68 if not isinstance(record['access'], str) or not record['access'].strip():
69 return False
70 refresh = record.get('refresh')
71 if refresh is not None and (not isinstance(refresh, str) or not refresh.strip()):
72 return False
73 for name in ('obtained_at', 'expires_at'):
74 value = record.get(name)
75 if value is None and name == 'expires_at':
76 continue
77 if isinstance(value, bool) or not isinstance(value, (int, float)):
78 return False
79 try:
80 if not math.isfinite(value) or value < 0:
81 return False
82 except OverflowError:
83 return False
84 return True
85
86
87def load_credentials(path: str | Path | None = None) -> dict[str, OAuthCredentials]:
88 """Read strictly, returning an empty store only when the file is absent."""
89 source = _target(path)
90 try:
91 text = source.read_text(encoding='utf-8')
92 except FileNotFoundError:
93 return {}
94 except (OSError, UnicodeError):
95 raise SecretsFileError('Could not read the provider OAuth sidecar.') from None
96 try:
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()
103 ):
104 raise SecretsFileError('Invalid provider OAuth sidecar shape.')
105 return {name: OAuthCredentials(**record) for name, record in payload.items()}
106
107
109 provider_name: str,
110 credentials: OAuthCredentials | None,
111 *,
112 path: str | Path | None = None,
113) -> Path:
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)
123 else:
124 payload[provider_name] = asdict(credentials)
125 _write_atomically(payload, target)
126 _chmod_secret(target)
127 return target
128
129
131 provider: ProviderPlugin,
132 api_key: str | None = None,
133 *,
134 environ: Mapping[str, str] | None = None,
135) -> str:
136 """Resolve explicit argument, environment, then a usable stored credential."""
137 import os
138 func = "resolve_api_key"
139 if api_key is not None:
140 return api_key
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 ''
143 if value.strip():
144 return value
145 capability = getattr(provider, 'oauth', None)
146 if capability is None:
147 return ''
148 try:
149 credentials = load_credentials().get(provider.name)
150 except SecretsFileError:
151 _dbg('OC_CHAT.MAIN', func, 'Provider OAuth sidecar could not be loaded.', 2)
152 return ''
153 if credentials is None:
154 return ''
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)
158 try:
159 value = capability.oauth_api_key(credentials)
160 except Exception:
161 raise ValueError(message) from None
162 if not isinstance(value, str) or not value.strip():
163 raise ValueError(message)
164 return value
165
166
167def has_usable_credentials(provider: ProviderPlugin) -> bool:
168 """Presence-only gate; invalid or expired credentials never pass."""
169 try:
170 return bool(resolve_api_key(provider, environ={}))
171 except ValueError:
172 return False
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)