Metadata-Version: 2.4
Name: s-authkit-client
Version: 0.0.2
Summary: Слой доступа для Python-клиентов: хранилище сессий (каталог+SQLite+Fernet), хранилище токенов (keyring+file-fallback), OAuth с проактивным обновлением по TTL и живая проба доступа — без браузера в зависимостях.
Author: Dmitry
License: MIT
License-File: LICENSE
Keywords: auth,cookies,keyring,oauth,session,storage,tokens
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.11
Requires-Dist: cryptography>=43
Requires-Dist: keyring>=25.0
Requires-Dist: oschmod>=0.3
Requires-Dist: platformdirs>=4.0
Requires-Dist: s-corekit>=0.0.1
Requires-Dist: tomli-w>=1.0
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.8; extra == 'dev'
Provides-Extra: probe
Requires-Dist: s-netkit>=0.0.1; extra == 'probe'
Description-Content-Type: text/markdown

# s-authkit-client — слой ДОСТУПА

Библиотека, отвечающая на один вопрос: **сервис меня узнаёт?** Всё, что нужно,
чтобы представиться сервису и удержать это представление живым — хранилище
сессий, хранилище пары токенов, OAuth-обновление по сроку и живая проба доступа.

Браузера внутри нет: ни импорта, ни зависимости, ни лениво (см. ниже).

```sh
pip install s-authkit-client          # Python >= 3.11
pip install "s-authkit-client[probe]" # + сетевое колесо для живой пробы
```

## Место в семействе китов

```
    clientkit                     <- сборщик клиентов по декларации
    /        \
authkit-client  browserkit        <- доступ (кто ты) / чеканка (как войти)
    \        /
      netkit                      <- сеть (чем ходить)
         |
      corekit                     <- основание (значения и чистые правила)
```

Обязательная зависимость одна — `s-corekit` (значения `SessionRef`,
`ExecutionContext`, канон диагностики доступа). Соседи сверху и сбоку — отдельные
пакеты, и ни один из них этому киту не нужен.

## Что внутри

| модуль | что делает |
| --- | --- |
| `authkit_client.sessions` | `SessionStore`: каталог — источник правды, SQLite — производный индекс без секретов, `storage_state` под Fernet (DEK в `.dek.enc`, обёрнут KEK из keyring / env / парольной фразы) |
| `authkit_client.secret_store` | `SecretStore`: пара токенов (access/refresh) в keyring с прозрачным file-fallback (Windows CredWrite ломается на длинных JWT — `WinError 1783`) |
| `authkit_client.oauth` | `OAuthSession`: **проактивное** обновление по TTL (живой токен = ноль сетевых вызовов), single-flight, окончательный `OAuthRevoked` вместо вечного ретрая |
| `authkit_client.access_probe` | ЖИВАЯ проба: спросить сервер, а не посмотреть на файл с куками |
| `authkit_client.session_fingerprint` | инвариант «один egress + один профиль»: сессия, отчеканенная через один выход, не уезжает в другой |
| `authkit_client.ports` | протоколы `SessionStorePort` / `SecretStorePort` / `FetchPort` / `MinterPort` и реестр чеканщиков |
| `authkit_client.context_port` | ссылка на носителя `ExecutionContext` (мультитенантность), если его завёл вышележащий слой |
| `authkit_client.diagnosis` | реэкспорт `AccessState` / `AccessVerdict` / `classify_access` из `corekit` |

Импорт самого кита ничего тяжёлого не исполняет: `sqlite3`, `cryptography`,
`keyring`, `platformdirs` подтягиваются по месту использования (PEP 562) —
платит тот, кто действительно открыл хранилище или полез в keyring.

## Хранилище сессий

```python
import asyncio
from corekit import SessionRef
from authkit_client import SessionStore

store = SessionStore(root="~/.myapp")           # состояние ляжет в <root>/sessions/
ref = SessionRef(profile="work", social="example", account_id="me@example.com")

async def main() -> None:
    await store.save_state(ref, {"cookies": [], "origins": []})  # шифруется
    state = await store.load_state(ref)                          # расшифровывается
    print(store.path_for(ref), await store.list(profile="work"))

asyncio.run(main())
```

Каталоги `0700` / файлы `0600` выставляются кросс-платформенно (на Windows —
через ACL). `storage_state` шифруется Fernet-ом: случайный DEK лежит в
`.dek.enc`, обёрнутый KEK из OS keyring, переменной окружения или парольной
фразы (Argon2id). Открытым текстом состояние молча не пишется никогда: если KEK
недоступен, а среда неинтерактивна — отказ с громким предупреждением.

## Пара токенов

```python
from authkit_client import SecretStore

secrets = SecretStore("myapp")               # keyring-namespace = "myapp"
secrets.save_tokens("me@example.com", access="…", refresh="…")
access, refresh = secrets.load_tokens("me@example.com")
```

Порядок чтения: env `MYAPP_ACCESS_TOKEN` / `MYAPP_REFRESH_TOKEN` → keyring →
файловый fallback. Fallback включается сам, когда keyring отказал (например,
Windows Credential Manager на blob'е длиннее ~2.5 КБ).

## OAuth с проактивным обновлением

```python
from authkit_client import OAuthSession, OAuthTokens

session = OAuthSession(
    OAuthTokens(access_token="…", refresh_token="…", expires_at=1893456000.0),
    refresher=refresh_call,   # async (payload) -> Mapping: чем ходить — дело вызывающего
    on_tokens=persist,        # куда сохранять обновлённую пару
)
await session.maybe_refresh()  # внутри срока — сравнение чисел, ноль запросов
```

Своего HTTP-клиента модуль не имеет: обмен кода и обмен refresh-токена делает
колбэк потребителя. Отказ `invalid_grant` считается окончательным — дальнейшие
вызовы падают `OAuthRevoked` мгновенно, вместо того чтобы долбить сервер.

## Живая проба доступа

```python
from authkit_client import probe_access, probe_for

@probe_for("example", endpoint_markers=("unknown method",))
async def _probe(ctx):
    return await client.get("https://example.com/", follow_redirects=True)

verdict = await probe_access("example", ctx)
if verdict.needs_human:
    ...  # сервер действительно отверг куки, а не «сети нет»
```

Вердикт выносится по подписи ответа, а не по наличию файла с куками: «сервер
увёл на страницу входа» (нужен человек) и «до сервера не дошли» (чинить выход) —
разные состояния, и путать их дорого. Сеть приносит сама проба: своего
HTTP-клиента у кита нет, подойдёт httpx, curl_cffi или что угодно утиное.

## Браузера здесь нет

Минт сессии — дело браузерного слоя, и он приходит сюда **через порт**:

```python
from authkit_client.ports import register_minter, minted_state

class NodriverMinter:                       # живёт в браузерном ките
    name = "nodriver"
    async def mint(self, ref, ctx=None): ...

register_minter(NodriverMinter())
state = await minted_state("nodriver", ref)  # слой доступа не знает про браузер
```

Причина простая: клиенту белого API с api-ключом браузер не нужен **никогда**, а
жадный импорт стоил бы ему playwright/nodriver на каждом старте. Инвариант
закреплён `tests/test_no_browser_imports.py` — тест смотрит на `sys.modules`
свежего интерпретатора, а не на строчки исходника.

## Хранилища подменяемы

`SessionStorePort` / `SecretStorePort` — структурные протоколы: подходит любой
объект с нужными методами. Реализация «из памяти» проходит их без единой строки
SQL, и код потребителя при подмене не правится (`tests/test_ports.py`).

## Совместимость

Кит выделен из `s-librarykit`, поэтому у тех, кто ставит их вместе, прежние пути
импорта — `librarykit.sessions`, `librarykit.secret_store`,
`librarykit.access_probe`, `librarykit.oauth`, `librarykit.session_fingerprint` —
продолжают работать: там это `sys.modules`-алиасы на **те же объекты модулей**, а
не копии, включая приватные имена, которые патчат тесты. Сам по себе
`s-authkit-client` самодостаточен и `librarykit` не требует.

Сохранены и прежние имена переменных окружения / keyring-namespace'ов: новые
ключи пишутся в нейтральный namespace, а ранее созданные сессии по-прежнему
расшифровываются через fallback.

## Лицензия

MIT — см. [LICENSE](LICENSE).
