Metadata-Version: 2.4
Name: transaierp-desktop-auth
Version: 0.1.2
Summary: OIDC Authorization Code + PKCE for desktop applications
Requires-Python: >=3.10
Description-Content-Type: text/markdown

# transaierp-desktop-auth

Python 桌面应用的浏览器登录 SDK，使用 Authorization Code + PKCE S256。
负责打开系统浏览器、接收本机回调、交换和刷新访问令牌。

## 安装

```powershell
python -m pip install transaierp-desktop-auth
```

需要 Python 3.10+，运行时仅依赖标准库。
安装包名为 `transaierp-desktop-auth`，导入名为 `transai_desktop_auth`。
这是桌面端 public client，不需要、也不应嵌入 `client_secret`。

## 管理员需要先提供的配置

| 配置 | 含义 |
| --- | --- |
| `issuer` | 真实 OIDC issuer；SDK 读取其 `/.well-known/openid-configuration` |
| `client_id` | 管理员创建并批准的 Native / 桌面应用 ID |
| `resource` | API 资源标识，必须与身份平台注册的资源一致 |
| `scopes` | 字符串列表，必须包含 `openid`；按需加入平台批准的权限 |
| 回调规则 | 允许 `http://127.0.0.1:<随机端口>/oauth/callback` |

`resource` 是资源标识，未必是实际发 HTTP 请求的地址，不要自行猜测。
要获得 refresh token，通常需要管理员允许离线访问并申请 `offline_access`；
是否返回仍由身份平台决定。没有 refresh token 时，过期后必须重新登录。
生产环境请使用可信 HTTPS issuer，并确保发现文档返回可信 HTTPS 端点。
此实现不会主动强制 HTTPS。

## 最小登录示例

由管理员提供真实配置后，设置下面示例使用的环境变量。
`AUTH_SCOPES` 是以空格分隔的权限，例如 `openid offline_access`。
这些变量是示例的配置方式，不是 SDK 自动读取的环境变量。

```python
import os
from urllib.error import URLError

from transai_desktop_auth import AuthClient

client = AuthClient(
    issuer=os.environ["AUTH_ISSUER"],
    client_id=os.environ["AUTH_CLIENT_ID"],
    resource=os.environ["AUTH_RESOURCE"],
    scopes=os.environ.get("AUTH_SCOPES", "openid").split(),
    timeout=180,
)

try:
    tokens = client.login()
except TimeoutError:
    raise SystemExit("Login timed out. Start a new login.")
except (ValueError, RuntimeError, URLError, OSError) as exc:
    raise SystemExit(f"Login failed: {type(exc).__name__}") from exc

print("Login succeeded; access token expires at:", tokens.expires_at)
# 不打印 tokens、access_token、refresh_token 或 id_token。
```

运行后浏览器会打开身份平台登录页面。成功后 token 保存在该 `client` 的存储中。
不要为每次业务请求重新创建客户端，否则默认的内存 token 会丢失。
登录调用会阻塞，GUI 应在工作线程中执行，不能阻塞主界面线程。

### 携带 token 请求业务 API

登录成功后，在同一个程序中使用同一个 `client`。将 `BUSINESS_API_URL`
设为对应资源下真实、可信的 HTTPS 接口；不要将 token 发往用户任意提供的 URL。

```python
import os
from urllib.parse import urlsplit
from urllib.request import HTTPRedirectHandler, Request, build_opener

class NoRedirect(HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None

url = os.environ["BUSINESS_API_URL"]
if urlsplit(url).scheme != "https":
    raise ValueError("Business API must use HTTPS")

request = Request(
    url,
    headers={"Authorization": "Bearer " + client.access_token()},
)
with build_opener(NoRedirect()).open(request, timeout=30) as response:
    body = response.read()
```

业务请求由应用负责，SDK 不提供业务 API 客户端。
访问令牌快到期时，`access_token()` 会尝试刷新；刷新失败应提示重新登录。
示例拒绝 HTTP 重定向，防止 Authorization 被发送到其他地址。

## 接口参考

### `AuthClient(issuer, client_id, resource, scopes, store=None, timeout=180)`

| 参数 | 类型 / 默认值 | 作用 |
| --- | --- | --- |
| `issuer` | `str`，必填 | OIDC issuer，末尾斜杠会移除 |
| `client_id` | `str`，必填 | 桌面应用 ID |
| `resource` | `str`，必填 | 请求访问令牌时指定的 API 资源标识 |
| `scopes` | `list[str]`，必填 | 必须包含 `openid`；不要传入单个空格分隔字符串 |
| `store` | 存储对象 / `None` | 默认 `MemoryTokenStore`；自定义对象需实现 `get/set/clear` |
| `timeout` | 正数秒 / `180` | 等待本机登录回调的超时，不是整个网络流程的总超时 |

缺少必要配置或 scopes 中没有 `openid` 时抛出 `ValueError`。
发现文档和 token 请求各使用 30 秒网络超时，当前没有单独配置入口。
同一客户端不应并发登录或刷新；SDK 没有同步锁和自动重试。

### `AuthClient.login() -> TokenSet`

发现 OIDC 端点、打开系统浏览器，在 `127.0.0.1` 随机端口等待回调，
校验 `state`，再用授权码和 PKCE verifier 交换 token。
成功后写入 `store` 并返回 `TokenSet`。每次调用都会发起新的浏览器登录流程。

失败可能抛出 `TimeoutError`、`ValueError`、`RuntimeError`，
以及 `urllib` / socket 的网络异常。用户关闭浏览器通常只能等到超时。
必须允许本机监听端口，并允许浏览器访问该回调地址。

### `AuthClient.refresh() -> TokenSet`

用存储中的 refresh token 换取新 token，成功后更新存储并返回 `TokenSet`。
服务端未返回新 refresh token 时保留旧值。
没有 token 或没有 refresh token 时抛出 `RuntimeError`。
服务端拒绝、网络失败等异常向调用方传播，SDK 不自动重试。

### `AuthClient.access_token() -> str`

返回当前 access token。剩余有效时间不超过 60 秒时先调用 `refresh()`。
没有登录记录时抛出 `RuntimeError`；需要刷新但没有 refresh token 时也会失败。
本方法不会主动打开浏览器重新登录。

### `TokenSet`

可变 dataclass；构造它不会验证 token。通常由 `login()` / `refresh()` 返回。

| 属性 / 构造参数 | 类型 / 默认值 | 含义 |
| --- | --- | --- |
| `access_token` | `str`，必填 | 访问所配置 API resource 的凭据 |
| `token_type` | `str` / `"Bearer"` | 服务端返回的 token 类型 |
| `expires_at` | `int` / `0` | 本地计算的到期 Unix 时间戳，单位为秒 |
| `refresh_token` | `str` 或 `None` / `None` | 刷新凭据，不保证每次登录都会返回 |
| `id_token` | `str` 或 `None` / `None` | 原始 ID Token，当前 SDK 不验证它 |
| `scope` | `str` 或 `None` / `None` | 服务端返回的权限字符串 |

`expires_at` 按当前时间加服务端 `expires_in` 计算；
省略 `expires_in` 时，当前实现默认 3600 秒。

### `MemoryTokenStore()`

| 方法 | 返回值 | 作用 |
| --- | --- | --- |
| `get()` | `TokenSet` 或 `None` | 读取当前 token，返回原对象，不是副本 |
| `set(token)` | `None` | 保存传入的 `TokenSet` |
| `clear()` | `None` | 清除本机内存 token |

默认不会写入磁盘，退出后 token 丢失。需要跨启动保存时，
请实现相同接口并使用操作系统凭据库等安全存储，不要明文写入配置文件。

### 本地退出

```python
client.store.clear()
```

这只清空客户端存储，不会撤销服务端 token，也不会退出浏览器中的身份平台会话。
当前没有 `logout()` 或 token revocation 接口。

## 错误处理和当前边界

| 情况 | 当前异常 / 建议 |
| --- | --- |
| 配置缺失、issuer 不一致、state 不匹配、回调缺少 code | `ValueError`；检查配置或重新登录 |
| 回调等待超时 | `TimeoutError`；检查浏览器和防火墙 |
| 授权端返回 error、没有 token 或 refresh token | `RuntimeError`；按场景重新登录或检查授权 |
| HTTP 非成功状态 | `urllib.error.HTTPError`，可读取 `.code`；不要记录 token 响应正文 |
| DNS、连接、TLS 等错误 | `urllib.error.URLError` 或 `OSError` |
| 非法 JSON | `json.JSONDecodeError`（`ValueError` 子类） |

不符合预期结构的响应也可能产生其他原生 Python 异常；
当前没有统一的 `AuthError` 或稳定错误码。

本 Python 实现使用 PKCE 和 state，但 **不发送 nonce，也不验证 ID Token 的签名、
issuer、audience 或 nonce**。不要直接解析 `id_token` 做身份认证或权限判断。
业务 API 必须在服务端验证 access token 和权限。需要完整的客户端 OIDC 身份验证时，
当前实现仍需补充，不能只靠此文档视为已经具备。

## 安装后离线查看文档

```powershell
python -m transai_desktop_auth
python -c "from importlib.metadata import metadata; print(metadata('transaierp-desktop-auth')['Description'])"
python -m pydoc transai_desktop_auth
```

README 正文包含在安装元数据中，也可以在 IDE 中查看方法的文档字符串，
或在 Python 中执行 `help(AuthClient)`、`help(TokenSet)`。

## 开发检查

在源码仓库的 `desktop-auth-sdk/python` 目录执行：

```powershell
python -m pip install -e .
python -m unittest discover -s tests -v
```
