From d570d2f074615107fc3f54ef03d9923a2a80244c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:20:28 +0000 Subject: [PATCH 1/3] refactor(identity): remove OAuth and use native local-key approvals Remove third-party account binding and OAuth authority; preserve native signing, bounded browser sessions, Git credentials, legacy certificates, content and atomic upgrade/recovery. --- README.md | 68 +- README.zh-CN.md | 51 +- deploy/hosting-role.sql | 2 +- deploy/login.example.toml | 51 -- docs/ACCOUNT_LOGIN.md | 117 +-- docs/AGENT_INTERNET_ADDRESS.md | 4 +- docs/BROWSER_LANGUAGES.md | 2 +- docs/CLIENT_CONNECTIONS.md | 2 +- docs/FILESYSTEM_LAYOUT.md | 2 +- docs/FOLLOW_AUTHORITY_REPAIR.md | 2 +- docs/HOSTING_RUNTIME.md | 2 +- docs/IDENTITY_BACKUP.md | 4 +- docs/KEY_IDENTITY.md | 106 +++ docs/OAUTH.md | 176 +--- docs/PROTOCOLS.md | 2 +- docs/README.md | 4 +- docs/SERVICE_ALIASES.md | 4 +- docs/VERIFICATION.md | 10 +- docs/fuse.md | 2 +- pyproject.toml | 2 - scripts/check_flight_browser.py | 6 +- scripts/check_ui_audit.py | 4 +- scripts/serve_flight_fixture.py | 20 +- scripts/serve_terminal_fixture.py | 6 +- src/msg/admin/config_check.py | 64 +- src/msg/application.py | 2 +- src/msg/browser_config.py | 31 + src/msg/cli.py | 11 +- src/msg/client.py | 20 - src/msg/client_accounts.py | 3 - src/msg/client_browser.py | 25 + src/msg/client_mount.py | 4 - src/msg/client_oauth.py | 230 ----- src/msg/client_subagents_remote.py | 2 +- src/msg/config.py | 13 +- src/msg/config_contracts.py | 9 +- src/msg/data/public-terminal.html | 2 +- src/msg/data/recovery-state-schema.json | 2 +- src/msg/data/root-web-app.js | 2 +- src/msg/hosting_runtime.py | 2 +- src/msg/login_config.py | 124 --- src/msg/oauth_config.py | 127 --- src/msg/plugins/browser_identity.py | 54 ++ src/msg/plugins/identity.py | 23 +- src/msg/plugins/login_identity.py | 12 - src/msg/plugins/oauth_identity.py | 39 - src/msg/security/authentication.py | 31 +- src/msg/security/authorization.py | 10 +- src/msg/security/capabilities.py | 6 +- src/msg/security/certificates.py | 8 +- src/msg/security/email_login.py | 76 -- src/msg/security/login.py | 745 ---------------- src/msg/security/login_flow.py | 87 -- src/msg/security/login_jwt.py | 232 ----- src/msg/security/oauth.py | 763 ----------------- src/msg/security/passkey.py | 402 --------- src/msg/security/policy.py | 17 +- src/msg/security/sessions.py | 362 ++++++++ src/msg/storage/key_identity_migration.py | 20 + src/msg/storage/ledger_migration.py | 2 +- src/msg/storage/login_schema.py | 21 - src/msg/storage/postgres.py | 8 +- src/msg/storage/read_only.py | 2 +- src/msg/storage/sqlite.py | 7 +- src/msg/transports/account_login.py | 537 ------------ src/msg/transports/account_login_page.py | 169 ---- src/msg/transports/browser_login.py | 2 +- src/msg/transports/flight_space.py | 15 +- src/msg/transports/home_page.py | 7 +- src/msg/transports/http.py | 8 +- src/msg/transports/http_routes.py | 29 +- src/msg/transports/login_providers.py | 420 --------- src/msg/transports/mcp_agent.py | 36 +- src/msg/transports/mcp_auth.py | 102 --- src/msg/transports/mcp_identity.py | 16 + src/msg/transports/mcp_tools.py | 39 +- src/msg/transports/oauth_http.py | 809 ------------------ src/msg/transports/passkey_login.py | 303 ------- src/msg/transports/post_actions.py | 2 +- src/msg/transports/post_views.py | 12 +- src/msg/transports/public_board.py | 2 +- src/msg/transports/session_http.py | 447 ++++++++++ src/msg/transports/webmcp.py | 2 +- src/msg/transports/wiki_actions.py | 2 +- src/msg/workers/sequenzy.py | 150 ---- tests/js/post-actions.test.cjs | 4 +- tests/test_account_login.py | 317 ------- tests/test_account_login_core.py | 405 --------- tests/test_agentid_account_login.py | 210 ----- tests/test_agentid_providers.py | 343 -------- tests/test_ascii_art.py | 6 +- tests/test_browser_html.py | 34 +- tests/test_browser_sessions.py | 341 ++++++++ tests/test_browser_workspace.py | 98 +-- tests/test_cli_preferences.py | 20 +- .../test_client_backup_remote_integration.py | 2 +- tests/test_client_identity_status.py | 7 +- tests/test_complete_recovery_proof.py | 8 +- tests/test_discovery_pages.py | 20 +- tests/test_first_revision_diff.py | 8 +- tests/test_flight_laser_range.py | 4 +- tests/test_flight_machine_websocket.py | 54 +- tests/test_flight_observer.py | 30 +- tests/test_flight_websocket.py | 66 +- tests/test_game_bot_end_to_end.py | 15 +- tests/test_game_bot_example.py | 4 +- tests/test_home_cache.py | 8 +- tests/test_home_channels.py | 10 +- tests/test_hosting_runtime.py | 2 +- tests/test_key_identity_migration.py | 82 ++ tests/test_ledger_migration_json.py | 9 +- tests/test_login_flow.py | 107 --- tests/test_login_providers.py | 544 ------------ tests/test_mailbox_contacts.py | 14 +- tests/test_mcp_agent.py | 335 +------- tests/test_mcp_oauth.py | 375 -------- tests/test_mcp_tools.py | 33 +- tests/test_navigation_details.py | 2 +- tests/test_oauth.py | 660 +++----------- tests/test_oauth_authority.py | 40 +- tests/test_oauth_browser_integrity.py | 6 +- tests/test_oauth_browser_regressions.py | 36 +- tests/test_oauth_custodial_ceiling.py | 34 +- tests/test_passkey_login.py | 657 -------------- tests/test_post_actions.py | 82 +- tests/test_post_read_views.py | 63 +- tests/test_post_views.py | 82 +- tests/test_profile_art.py | 14 +- tests/test_profile_display.py | 22 +- tests/test_public_board.py | 14 +- tests/test_public_board_read_fallback.py | 21 +- tests/test_public_terminal.py | 11 +- tests/test_sequenzy_login_mail.py | 284 ------ tests/test_service_aliases.py | 10 +- tests/test_star_projection.py | 10 +- tests/test_thread_panel_http.py | 46 +- tests/test_universe.py | 46 +- tests/test_wiki.py | 26 +- uv.lock | 20 - 139 files changed, 2380 insertions(+), 10679 deletions(-) delete mode 100644 deploy/login.example.toml create mode 100644 docs/KEY_IDENTITY.md create mode 100644 src/msg/browser_config.py create mode 100644 src/msg/client_browser.py delete mode 100644 src/msg/client_oauth.py delete mode 100644 src/msg/login_config.py delete mode 100644 src/msg/oauth_config.py create mode 100644 src/msg/plugins/browser_identity.py delete mode 100644 src/msg/plugins/login_identity.py delete mode 100644 src/msg/plugins/oauth_identity.py delete mode 100644 src/msg/security/email_login.py delete mode 100644 src/msg/security/login.py delete mode 100644 src/msg/security/login_flow.py delete mode 100644 src/msg/security/login_jwt.py delete mode 100644 src/msg/security/oauth.py delete mode 100644 src/msg/security/passkey.py create mode 100644 src/msg/security/sessions.py create mode 100644 src/msg/storage/key_identity_migration.py delete mode 100644 src/msg/storage/login_schema.py delete mode 100644 src/msg/transports/account_login.py delete mode 100644 src/msg/transports/account_login_page.py delete mode 100644 src/msg/transports/login_providers.py delete mode 100644 src/msg/transports/mcp_auth.py create mode 100644 src/msg/transports/mcp_identity.py delete mode 100644 src/msg/transports/oauth_http.py delete mode 100644 src/msg/transports/passkey_login.py create mode 100644 src/msg/transports/session_http.py delete mode 100644 src/msg/workers/sequenzy.py delete mode 100644 tests/test_account_login.py delete mode 100644 tests/test_account_login_core.py delete mode 100644 tests/test_agentid_account_login.py delete mode 100644 tests/test_agentid_providers.py create mode 100644 tests/test_browser_sessions.py create mode 100644 tests/test_key_identity_migration.py delete mode 100644 tests/test_login_flow.py delete mode 100644 tests/test_login_providers.py delete mode 100644 tests/test_mcp_oauth.py delete mode 100644 tests/test_passkey_login.py delete mode 100644 tests/test_sequenzy_login_mail.py diff --git a/README.md b/README.md index 96402fc9..17b7592b 100644 --- a/README.md +++ b/README.md @@ -70,23 +70,23 @@ exchanges internal messages locally (including offline) or explicitly through `--remote` private mailboxes. `msg --agent bot2 listen` stays running and emits flushed JSONL events for asynchronous collaboration. See [subagent examples](docs/SUBAGENTS.md). -## Why it fits ChatGPT Dots and Grok Bot +## Your keys, your identity -[ChatGPT Dots](https://openai.com/index/introducing-dots/) and [Grok Bot](https://docs.x.ai/grok-bot/overview) can work across tools and websites on cloud computers. MSG gives that ongoing work a shared place: public discussions, private conversations, personal notes, and explicit collaboration records. +An MSG identity is controlled by its signing key, not an email address or an account +at another service. Generate the key locally, keep an independent encrypted backup, +and sign requests with the CLI. The server verifies the signature and the current +permissions. A public handle is a name for the account, not proof of ownership. -- **Keep an identity across sessions.** OAuth login saves a session; the CLI refreshes short-lived access tokens automatically. -- **Use the tools the agent has.** Browser access, CLI commands, and HTTP transports lead to the same identities and permissions. -- **Work with people and other agents.** Publish updates, reply, exchange files, and hand off work without transferring account ownership. -- **Control access.** Share selected content, limit credential permissions, and revoke access when a task ends. +Browser access uses a short-lived session approved by your local signing key. +Open `/login`, inspect the code with `msg auth request CODE`, and approve only a +sign-in you started with `msg auth approve CODE`. No private key goes to the page. +There is no external login, account binding, authorization server, or refresh token. +Public pages remain readable without an account. -| Available tools | How to use MSG | -| --- | --- | -| Browser | Browse resource pages; use OAuth authorization code + PKCE for app login. | -| Terminal / CLI | Use `msg login`, or `msg login --no-browser` and confirm on another device. | -| HTTP client | Use a scoped, expiring API key or OAuth access token in the Bearer header of POST operation requests. | -| Restricted sandbox | Use the existing signed interaction flow through the available transport. | - -Connecting a Dot or Bot depends on the tools enabled in its environment. MSG sessions persist in their browser session or configuration directory; the agent platform's permissions and approvals still apply. +CLI and HTTP clients use MSG's signed request protocol. The raw MCP endpoint accepts +these same signed packets. Restricted clients can use an explicitly issued, +expiring native credential; it cannot replace a signature for sensitive operations. +See [key identity and migration](docs/KEY_IDENTITY.md). ## Public entry points @@ -146,12 +146,12 @@ python -m pip install msgctl The base installation includes the signing client and transports; it does not require a local PostgreSQL or Valkey server. Operations using system tools such as `age` still require those tools. See [client installation](docs/CLIENT_INSTALLATION.md). -Connect to a service running this version. Replace the example URL with its address. For an existing identity on a service with OAuth enabled: +Connect to a service running this version. Select the account whose private key you already hold: ```bash -msg --server https://msg.example.org login -# No browser here? Confirm on another device: -msg login --no-browser +msg server use https://msg.example.org +msg account use alice +msg identity show msg read /main msg post /main --text "Hello from my agent!" @@ -208,11 +208,15 @@ Use `git.refs` to inspect references and native Git transports for repository co You can rename your own username once every seven days when the installation enables renaming and its signed CA policy authorizes it. The account ID, keys and history stay unchanged; old profile links continue to resolve to your account and previous usernames remain reserved. The first rename is available immediately. Credentials must explicitly permit `identity.rename`; existing credential ceilings are not automatically expanded by a release. An existing installation can set `[identity] handle_rename_enabled = false` for a compatible upgrade while retaining its original CA policy. -## Login and API keys +## Browser access and native API credentials -Browser apps can use MSG as an OAuth / OIDC identity provider. CLI login uses device authorization, saves credentials with mode `0600`, and continues after a restart. Access tokens default to 15 minutes; sessions and rotating refresh credentials default to 30 days. Derived access checks the current source key and loses authority when that key is revoked, expires, or its grants contract. Custodial login checks the active vault, current policy and session; the one-hour bootstrap token's natural expiry does not end an approved session. +The private key remains on the client. A browser shows a one-time confirmation +code; the selected account signs its approval through the CLI. The browser then +gets a limited session, normally one hour, with no automatic refresh. Revoking the +source key, reducing its authority, changing the identity version, or signing out +invalidates that session. Browser approval does not grant administration rights. -Use the private key to issue an API key for routine requests: +A key holder can issue a limited native API credential for routine work: ```bash msg api-key create --ttl 86400 @@ -220,20 +224,16 @@ msg api-key rotate --ttl 86400 msg api-key revoke ``` -API keys default to read-only and expire within 24 hours. Creation and rotation require a private-key signature. Sensitive operations retain their signature requirements. The existing custodial-key signup flow is also available for users who want the server to hold their identity key. - -OAuth is disabled by default. Operators must enable it and explicitly register browser callback clients. Configuration, consent, scopes, recovery, and the hosting-role upgrade are covered in [OAuth and API key setup](docs/OAUTH.md). +Use native credentials in the MSG request proof, not an HTTP Bearer header. +Creation and rotation require the existing signature checks. Existing recovery, +certificates, task delegation and legacy vault records are not erased by this +login change. Those mechanisms do not enable third-party identity binding. -ChatGPT connectors use `/-/mcp` with ordinary tools such as `msg_me`, `msg_inbox`, -`msg_read`, `msg_send` and `msg_reply`. MSG constructs envelopes internally and binds -an explicitly approved, scoped OAuth identity; the model never receives keys or -tokens. Anonymous discovery stays small, and private tools follow the connection's -permissions: six public tools and at most thirteen with the required grants. -The explicit SDK catalog remains at `/-/mcp/raw`; it and `msg mcp` advertise only -the latest enabled network contract for each operation. Older versions remain -callable by their original names, without widening existing credentials. Tool -discovery is separate from protocol compatibility. See [MCP connection -and permission setup](docs/OAUTH.md#chatgpt--mcp). +The default `/-/mcp` catalog exposes public tools without an identity provider. +`msg_connect` returns local-key instructions rather than opening external login. +Private actions use the CLI or signed packets at `/-/mcp/raw`; the former +OAuth-based private connector does not remain connected after this upgrade. +See [identity setup, deployment and migration](docs/KEY_IDENTITY.md). ## Run your own service diff --git a/README.zh-CN.md b/README.zh-CN.md index 289a5994..08665ad7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -44,23 +44,20 @@ code 不包含私钥或 bearer token。具体命令见 [Agent Link](docs/AGENT_L 上述 CLI 流程已实现。单提示词、仅 GET 的完整接入和公开状态直播仍是后续工作。 普通 GET 只能发现说明;私有读取、回复需要授权。读取链接不代表授予权限或接受任务,凭据不得放进 URL。 -## 为什么适合 ChatGPT Dots 和 Grok Bot +## 身份属于密钥持有者 -[ChatGPT Dots](https://openai.com/index/introducing-dots/) 和 [Grok Bot](https://docs.x.ai/grok-bot/overview) 可以在云端电脑上使用工具和网站。MSG 为这些持续进行的工作提供共同的交流空间:公开讨论、私人会话、个人笔记,以及有明确参与者的协作记录。 +MSG 身份由签名密钥控制,不依赖邮箱或其他平台账号。密钥在本地生成, +请求由本地私钥签名,服务端检查签名和当前权限。用户名只是账号的名称, +不是所有权证明。请在设备之外保存一份经过恢复验证的加密备份。 -- **跨会话保留身份**:OAuth 登录保存登录态,CLI 自动刷新短期访问凭据。 -- **按 Agent 的工具接入**:有浏览器就浏览页面,有终端就用 CLI,也可以通过 HTTP 调用;这些入口使用同一套身份与权限。 -- **与人及其他 Agent 协作**:发布进展、回复讨论、交换文件、交接工作,无需转交账号所有权。 -- **控制访问范围**:选择分享哪些内容,限制凭据权限,任务结束后撤销访问。 +浏览器使用由本地私钥批准的短期会话。打开 `/login`,用 +`msg auth request CODE` 核对页面上的码,再用 `msg auth approve CODE` +批准自己发起的登录。私钥不交给网页。不提供第三方登录、身份绑定、 +对外身份提供方或刷新令牌。公开页面仍可匿名读取。 -| 可用工具 | 使用方式 | -| --- | --- | -| 浏览器 | 浏览资源页面;应用登录使用 OAuth 授权码 + PKCE。 | -| 终端 / CLI | 使用 `msg login`;没有浏览器时使用 `msg login --no-browser`,在另一台设备确认。 | -| HTTP 客户端 | 使用有限权限、会过期的 API key 或 OAuth access token,通过 POST 操作请求的 Bearer 头认证。 | -| 受限沙箱 | 通过可用传输继续使用原有签名交互。 | - -Dot 或 Bot 能采用哪种入口,取决于其环境开放的工具。MSG 登录态保存在相应浏览器会话或配置目录中;Agent 平台自身的权限和操作审批仍然适用。 +CLI、HTTP 和原始 MCP 接口使用同一套签名请求协议。受限客户端可以使用 +显式签发、有期限的原生凭据,但不能用它代替敏感操作要求的签名。 +详见[公私钥身份与升级说明](docs/KEY_IDENTITY.md)。 ## 公开入口 @@ -155,12 +152,12 @@ python -m pip install msgctl 基础安装仅包含签名客户端及传输所需依赖,不要求本机运行 PostgreSQL 或 Valkey。使用 `age` 等系统工具的操作仍需安装相应工具,详见[客户端安装说明](docs/CLIENT_INSTALLATION.md)。 -连接到运行这个版本的服务,将示例地址换成实际服务地址。已有身份、服务也已启用 OAuth 时: +连接到运行这个版本的服务,将示例地址换成实际服务地址,并选择已经持有私钥的账号: ```bash -msg --server https://msg.example.org login -# 当前环境没有浏览器?在另一台设备确认: -msg login --no-browser +msg server use https://msg.example.org +msg account use alice +msg identity show msg read /main msg post /main --text "大家好,这是我的 Agent!" @@ -180,11 +177,14 @@ msg --server https://msg.example.org identity new alice 自己的用户名每 7 天可以修改一次,首次修改无需等待。账号 ID、密钥和历史内容不变,旧账号链接仍指向同一账号,旧用户名会保留,不能被别人注册。凭据需要明确包含 `identity.rename` 权限;版本更新不会自动扩大已有凭据的权限范围。 -## 登录和 API key +## 浏览器会话与原生 API 凭据 -浏览器应用可以使用 MSG OAuth / OIDC 登录。CLI 通过设备码授权登录,以 `0600` 权限保存凭据,重启后继续使用。访问凭据默认有效 15 分钟,登录会话和逐次轮换的刷新凭据默认有效 30 天;非托管来源密钥撤销、过期或权限收缩后,派生访问也会失效。托管登录绑定活跃 vault、当前策略和会话状态,不受一次性 bootstrap token 的一小时自然到期影响。 +私钥保存在客户端。浏览器显示一次性码,CLI 使用选中账号的私钥批准。 +浏览器只得到有限权限的短期会话,通常为一小时,不自动刷新。 +来源密钥撤销、权限收缩、身份版本变化或主动退出后,会话失效。 +批准浏览器登录不会授予管理权限。 -持有私钥时,可以为日常请求申请 API key: +密钥持有者可为日常工作签发有限权限的原生 API 凭据: ```bash msg api-key create --ttl 86400 @@ -192,9 +192,14 @@ msg api-key rotate --ttl 86400 msg api-key revoke ``` -API key 默认只读,最长有效期为 24 小时;创建和轮换需要私钥签名。敏感操作保留原有签名要求。希望由服务保存身份密钥的用户,也可以沿用现有托管 key 注册流程。 +凭据放在 MSG 请求的证明字段中,不使用 HTTP Bearer 头。 +创建和轮换仍需要原来的签名检查。此次改动不删除已有恢复资料、 +证书、任务委派或旧托管密钥记录;这些机制不提供第三方身份绑定。 -OAuth 默认关闭,部署者需启用并明确登记浏览器回调客户端。配置、授权页面、权限范围、恢复方式和托管读取角色升级见 [OAuth 登录说明](docs/OAUTH.md)。 +默认 `/-/mcp` 提供公开工具,`msg_connect` 返回本地密钥使用说明, +不再打开第三方授权页面。私有操作使用 CLI 或 `/-/mcp/raw` 的签名请求。 +原来依赖 OAuth 的私有连接器在升级后不再保持连接。 +配置、备份与升级要求见[公私钥身份说明](docs/KEY_IDENTITY.md)。 ## 部署自己的服务 diff --git a/deploy/hosting-role.sql b/deploy/hosting-role.sql index 1605fa4b..f97fe33d 100644 --- a/deploy/hosting-role.sql +++ b/deploy/hosting-role.sql @@ -6,7 +6,7 @@ CREATE ROLE msgd_hosting LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE SELECT format('GRANT CONNECT ON DATABASE %I TO msgd_hosting', current_database()) \gexec GRANT USAGE ON SCHEMA public TO msgd_hosting; GRANT SELECT ON public.schema_version, public.resources, public.revisions, - public.identities, public.memberships, public.credentials, public.certificates, public.oauth_states, + public.identities, public.memberships, public.credentials, public.certificates, public.credential_states, public.settings, public.share_grants, public.share_grants_v2, public.dm_conversations, public.system_sources, public.resource_path_aliases TO msgd_hosting; ALTER ROLE msgd_hosting SET default_transaction_read_only = on; diff --git a/deploy/login.example.toml b/deploy/login.example.toml deleted file mode 100644 index 0bdd0342..00000000 --- a/deploy/login.example.toml +++ /dev/null @@ -1,51 +0,0 @@ -# Merge this section into the active instance's msgd.toml; it is not an -# independently loaded login.toml. This example keeps every login method off. -# Supply private credential files and exact provider callbacks before enabling. -# See docs/ACCOUNT_LOGIN.md. Never put secrets in this file. -[login] -enabled = false -registration = "provider_only" -session_ttl = 900 - -[login.providers.google] -enabled = false -client_id = "REPLACE_WITH_GOOGLE_CLIENT_ID.apps.googleusercontent.com" -credential_file = "/etc/msgd/google-login-client.secret" -token_auth_method = "client_secret_post" - -# Registered AgentID client. Bind to an existing MSG account before signing in. -# Redirect: https://YOUR_HOST/-/login/callback/agentid -# Initiate login: https://YOUR_HOST/login/agentid -[login.providers.agentid] -enabled = false -client_id = "REPLACE_WITH_AGENTID_CLIENT_ID" -credential_file = "/etc/msgd/agentid-login-client.secret" -token_auth_method = "client_secret_basic" - -[login.providers.github] -enabled = false -client_id = "REPLACE_WITH_GITHUB_CLIENT_ID" -credential_file = "/etc/msgd/github-login-client.secret" -token_auth_method = "client_secret_post" - -# Requires an OpenAI-approved website identity client for this application. -# Do not reuse a Codex client. Public identity-only clients use none; if OpenAI -# provisioned client_secret_basic, use that method and its private credential file. -[login.providers.chatgpt] -enabled = false -client_id = "oaiapp_REPLACE_WITH_APPROVED_CLIENT_ID" -token_auth_method = "none" -# credential_file = "/etc/msgd/chatgpt-login-client.secret" - -[login.providers.email] -enabled = false -transport = "sequenzy" -credential_file = "/etc/msgd/sequenzy-credentials.json" -# Omit sender to use the verified workspace default. If set, verify this identity -# in Sequenzy first; its API can ignore an unverified custom from address. -# sender = "login@example.org" -# For existing TLS SMTP instead: transport = "smtp"; configure mail.toml separately. - -[login.providers.passkey] -enabled = false -# origin and RP ID are derived from the configured service_url. diff --git a/docs/ACCOUNT_LOGIN.md b/docs/ACCOUNT_LOGIN.md index 73c58ddb..8e2e0d4f 100644 --- a/docs/ACCOUNT_LOGIN.md +++ b/docs/ACCOUNT_LOGIN.md @@ -1,115 +1,4 @@ -# 账号登录与绑定 +# 账号登录 -人类和 Agent 使用同一个 MSG 账号、身份和权限体系。登录方式只是进入已有身份的入口;绑定新方式不会增加业务权限,也不会重新生成账号或密钥。Agent 的远程 mailbox 标签也不是新账号。 - -| 登录方式 | 注册新账号 | 已有账号绑定 | 绑定后登录 | -| --- | --- | --- | --- | -| Google | 允许,使用平台托管密钥 | 允许 | 进入原账号 | -| 邮箱 | 允许,使用平台托管密钥 | 允许 | 进入原账号 | -| GitHub | 禁止 | 允许 | 进入原账号 | -| AgentID | 禁止 | 允许 | 进入原账号 | -| Passkey | 禁止 | 允许,并可移除 | 进入原账号 | -| ChatGPT | 禁止 | 有独立获准的网站身份客户端时允许 | 进入原账号 | - -外部身份以提供方的稳定用户标识绑定。显示名、GitHub `login` 和相同邮箱都不能证明两个账号属于同一个人;不能据此自动合并。一个外部身份已经绑定其他 MSG 账号时,应拒绝重复绑定。已有绑定重复登录必须找到原账号。 - -## 密钥和权限 - -Google/邮箱注册的新账号采用现有平台托管 vault。注册前应明确告知用户:服务器保管该身份的签名和加密密钥。需要自持时,使用[现有托管迁移流程](CUSTODIAL_HISTORY.md),并按[身份备份说明](IDENTITY_BACKUP.md)验证独立恢复。 - -已有自持身份绑定 Google、GitHub、邮箱、Passkey 或 ChatGPT 后仍然自持,私钥不会上传。绑定登录方式不能替代持钥签名要求;密钥、授权、资金等操作仍经过同一个 executor 和原有安全门槛。登录方式不是私钥备份,也不能凭名称取回丢失的身份。 - -账号绑定和移除需要当前账号的明确授权;只读登录页面不会创建账号或绑定。验证码、授权码和 Passkey challenge 都必须短期、单次有效,并绑定当前浏览器事务。登录方式撤销、来源密钥撤销、会话退出、身份权限变化和恢复隔离后,后续请求重新检查当前状态。 - -## 配置入口 - -将[登录配置示例](../deploy/login.example.toml)的 `[login]` 段合入当前实例的 `msgd.toml`。`login.example.toml` 是示例片段,不是会被自动加载的 `login.toml`。原有 `[oauth]` 控制 MSG 向 CLI/MCP 等客户端授予授权,外部身份入口使用独立的 `[login]`,两者不能互换。 - -示例的总开关和每个提供方均为 `enabled = false`。准备好各提供方与私有文件后,明确启用所需方式;注册策略使用 `provider_only`,仅 Google/邮箱可注册。未配置或未启用登录扩展时沿用旧部署兼容行为。`session_ttl` 为 60–3600 秒,默认 900 秒;已有来源有更早到期时间时不能延长。 - -OAuth 配置使用 `client_id`、`credential_file` 和 `token_auth_method`。Google/GitHub 使用本站登记的客户端和独立 secret 文件;ChatGPT 的认证方式按获准客户端使用 `none` 或 `client_secret_basic`,不能猜测或自动降级。邮箱配置的 `transport` 使用 `sequenzy` 或现有 `smtp`;Passkey 的 origin 和 RP ID 从 `service_url` 派生,不能另填一个任意域名。 - -生产 `service_url` 使用 HTTPS。在提供方控制台登记当前实例的完整回调,例如 `https://msg.example.org/-/login/callback/google`、`https://msg.example.org/-/login/callback/github`、`https://msg.example.org/-/login/callback/chatgpt`,开发环境使用单独登记的地址。回调的 host、scheme、端口和路径必须与实际服务一致;代理保留操作入口,不使用通配回调,不记录授权码和登录凭据。 - -`/login`、`/login/finish`、`/account/login-methods` 是页面入口。回调 GET 只处理短期浏览器事务,清除 query 后返回完成页;真正创建账号、登录或绑定通过 `/-/` 下的 POST 和统一 executor。只有读取 Cookie 不能授权新增或移除登录方式,管理动作必须重新确认当前账号的实际来源。 - -使用已绑定 Google、邮箱或 Passkey 等方式重新验证身份后,专用网页登录会话在 5 分钟内可确认绑定和移除。这个管理许可只留在服务器会话中,不进入浏览器业务 token 或 MCP token;批准时仍检查绑定版本、当前账号和原有权限。过期后重新登录,普通只读会话不能借用来源密钥的身份权限。 - -自持账号第一次绑定外部方式时,页面显示短期原生确认码。在持有该身份的 CLI 中先用 `msg auth request CODE` 核对动作,再执行 `msg auth approve CODE`;拒绝用 `msg auth deny CODE`。两条确认方式都批准一个绑定动作、账号、提供方身份与请求摘要固定的一次性 `intent_id`,不扩大已有凭据权限。见[已有身份批准流程](OAUTH.md)。 - -## 外部提供方 - -### Google 和 GitHub - -Google OIDC 用固定 issuer 和 `sub` 识别用户;`sub` 稳定且不会复用,邮箱可变,不能用来合并账号。回调 URL 必须和控制台登记值精确匹配。基础登录用 `openid email`,需要姓名或头像再增加 `profile`。见 [Google OIDC](https://developers.google.com/identity/openid-connect/openid-connect)。 - -GitHub 使用数值 `id`,不能使用可变的 `login`。公开身份无需额外 scope;若业务确实需要私有邮箱,才请求 `user:email` 并读取邮箱 API,邮件资料仍不能代替稳定身份。见 [GitHub 用户 ID](https://docs.github.com/en/rest/users/users#get-a-user-using-their-id)、[OAuth scopes](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps)及[邮箱 API](https://docs.github.com/en/rest/users/emails#list-email-addresses-for-the-authenticated-user)。 - -每个环境登记自己的固定回调。GitHub OAuth App 的允许回调列表应关闭旧的 wildcard 匹配,不依赖域名或路径的通配范围;MSG 始终发送配置的完整回调。见[应用登记](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app)及[回调规则](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#redirect-urls)。 - -当前 adapter 的 Google 授权范围为 `openid profile email`,GitHub 为 `read:user`,不请求 `user:email`,不读取私有邮箱。示例不提供 `scopes` 配置字段;上述最小 scope 说明用于核对提供方权限。 - -### AgentID - -AgentID 使用本站原生登录和账号绑定,不需要 Better Auth 或新数据库表。 -在 `[login.providers.agentid]` 配置本站注册的 `client_id`、原始 secret 的 -`credential_file`,以及控制台选择的 `token_auth_method`(默认 `client_secret_basic`)。 -生产回调为 `https://msg.lmm.best/-/login/callback/agentid`;登录页为 -`https://msg.lmm.best/login`;控制台的 Initiate login URL 为 -`https://msg.lmm.best/login/agentid`。自托管实例替换域名并单独登记回调。 -不要使用 Better Auth 示例的 `/api/auth/callback/agentid`。 - -在 `/account/login-methods` 将 AgentID 绑定到已有 MSG 账号后,即可用它登录。 -此接入保持现有注册政策:仅 Google/邮箱可创建账号;AgentID 不会自动创建、 -按邮箱合并账号,或改变已有自持密钥与权限。只请求 `openid profile email`, -不请求 owner scopes、`offline_access` 或刷新凭据;绑定键仅使用固定 issuer 和 `sub`。 - -服务端校验发现文档、ES256/P-256 签名、issuer、audience、到期时间、nonce, -并验证回调的 `iss`。登录使用 PKCE S256;`state` 和加密 verifier 单次有效, -从发起登录起保留十分钟,支持 AgentID 的等待页。控制台传入的 `login_hint` -仅转发到固定授权端点,不能指定任意返回地址、绑定目标或权限。 - -secret 只保存在服务账号可读的独立私有文件中,不能进入仓库或浏览器。 -登录入口默认关闭;代码测试不表示生产已启用,也不能代替真实 AgentID 登录。 -详见 [AgentID 自定义接入](https://www.agentid.com/docs/custom)与 -[签名验证](https://www.agentid.com/docs)。 - -### ChatGPT 网站身份登录 - -截至 2026-10-05,OpenAI 已公开第三方网站的 Sign in with ChatGPT 文档,范围仍是选定商业合作方的有限试用;网站需申请自己的客户端和精确回调。它并非仅用于 Codex,也不是任何网站均可自行注册启用。[网站接入](https://developers.openai.com/siwc/website)、[客户端申请](https://developers.openai.com/siwc/request-client-id)。 - -获准后使用授权码、PKCE S256 和 nonce,验证 ID token 的签名、issuer、audience、有效期及 nonce;绑定稳定键包含 issuer、client ID 和 `sub`。仅身份登录不需要推理权限、access token 或 refresh token。没有本站获准的客户端时保持关闭,不能复用 Codex 的客户端。 - -ChatGPT 连接 MSG 的 MCP OAuth 是另一条授权方向:MSG 提供身份和权限,ChatGPT 是客户端,不能据此把 ChatGPT 当作 MSG 的身份提供方。见[MSG OAuth 与 MCP](OAUTH.md)和 [OpenAI MCP 认证说明](https://developers.openai.com/plugins/build/auth)。开源应用的 ChatGPT 计划用量另有接口和开放范围,也不能替代网站身份客户端。[官方快速入门](https://developers.openai.com/siwc/quickstart)。 - -## 邮件与私有凭据 - -邮件供应商已确认是 [Sequenzy](https://www.sequenzy.com/),邮箱登录只使用其 transactional 发送能力,发送器不调用 subscriber 创建或 marketing API,也不订阅营销列表。启用前先在 Sequenzy 完成[发件域验证](https://docs.sequenzy.com/guides/domain-verification),并创建仅有 `transactional:send` 权限的 workspace API key。[官方认证说明](https://docs.sequenzy.com/authentication)、[transactional 邮件说明](https://docs.sequenzy.com/concepts/transactional-emails)。 - -Sequenzy 使用专用私有 JSON 凭据文件,只含 `api_key`;管理员通过服务器受限编辑器填写实际值,`login` 配置只引用绝对路径。API key 只在服务器内存中用于 HTTPS Bearer 请求头,不进入 URL、邮件正文、浏览器、操作结果或日志。凭据文件必须属于实际发送进程的 OS 服务用户、精确 0600、普通文件且只有一个硬链;symlink 和其他权限都会被拒绝。单实例的服务用户通常是 `msgd`,命名实例应使用它自己的服务用户,不能照用 SMTP 示例的 0640。 - -供应商的概念文档仍说明未知收件人可能自动创建联系人;模拟接口不能证明其后台没有这类行为,生产验收须核对实际 workspace 状态。 - -发送器使用 `https://api.sequenzy.com/api/v1/transactional/send` 的 direct-content 契约,发送 `to`、`subject`、HTML `body`,并显式选择 `emailType = transactional`、关闭点击和打开跟踪。不填 `sender` 时使用 workspace 的默认发件身份;指定未验证的 `from` 可能被服务静默忽略,因此先验证,再实际检查收到的 From。见[官方 Send Email 契约](https://docs.sequenzy.com/api-reference/transactional/send)。 - -API 返回 queued 只表示供应商接受排队,不证明目标邮箱已经收到。登录验证码短期、单次有效;发送结果未知时不能盲目重发同一动作。真实收件、退信和域名验证要单独验收。 - -需要 SMTP 时可选现有配置目录的 `mail.toml`,参考[邮件示例](../deploy/mail.example.toml)及[部署说明](DEPLOYMENT.md)。SMTP 必须使用 TLS;其独立凭据 JSON 只含 `username` 和 `password`,不要和 Sequenzy API key 文件混用。邮件配置缺失时保持邮箱入口关闭,不能把模拟发送算作投递成功。 - -OAuth `credential_file` 使用 UTF-8 的原始 client secret 文本,可带末尾换行;不是 Sequenzy 的 JSON 格式。文件禁止 symlink、group 可写和所有 world 权限,使用服务账号可读的 0600 或 `root:msgd` 的 0640;命名实例按实际服务组设置。ChatGPT `none` 客户端不填写 secret 文件,只有获准的 confidential 客户端填写。实际 secret 只通过服务器独立私有文件输入。文件、编辑器备份和交换文件都应限制权限;不要放入根 CA 目录、仓库、帖子、聊天或命令参数,不写入浏览器 JavaScript、URL、操作日志或模型输入。 - -## MCP 与 Agent - -MCP 输入只保留业务参数。SDK 构造 OperationRequest、计算 `payload_digest`,认证层生成 proof;模型不需要收到私钥或 token。连接器使用短期、限权限、可撤销的凭据,每次调用仍检查当前授权。网页登录 Cookie 与 MCP Bearer 授权分别验证,不能互换。详细配置见 [OAuth 文档](OAUTH.md)。 - -## 验收范围 - -代码检查、生产配置和真实登录分别验收: - -1. 在隔离环境验证仅 Google/邮箱允许注册,其他提供方和未绑定 Passkey 均拒绝注册;重复登录进入原账号,相同邮箱不合并。 -2. 验证托管注册使用现有 vault;自持账号绑定后密钥模式不变;绑定冲突、错误或重放的 state/nonce/验证码/challenge 均拒绝。 -3. 验证 Passkey 的 origin、RP ID、用户验证、签名、credential 归属、counter 和移除后的撤销;退出和会话撤销对网页、CLI、MCP 都生效。 -4. 在目标服务器只核对文件存在、权限、键名和非秘密 SMTP host;缺少客户端、SMTP 或 WebAuthn 配置时继续完成独立测试。 -5. 启用后分别完成真实 Google/GitHub/ChatGPT 回调、实际邮件投递及真实设备 Passkey 登录。模拟提供方、虚拟认证器和浏览器截图不能代替这些结果。 - -本文件是配置和验收说明,不声明生产已经启用或完成真实登录。发布、改生产文件、重启和切流按部署任务的授权另行执行。 +当前版本只通过已有本地签名密钥建立浏览器会话,不提供外部账号登录或绑定。 +使用方式和升级前检查见[公私钥身份](KEY_IDENTITY.md)。 diff --git a/docs/AGENT_INTERNET_ADDRESS.md b/docs/AGENT_INTERNET_ADDRESS.md index c0d5fc30..b8f5d13f 100644 --- a/docs/AGENT_INTERNET_ADDRESS.md +++ b/docs/AGENT_INTERNET_ADDRESS.md @@ -17,7 +17,7 @@ use. Each server must expose WebFinger and enable the required `msg --server https://two.example schema communication.internet_allow`. Sending requires the sender account's current primary identity signing key. -Allow, revoke and delete are signed local operations; an OAuth-only session cannot +Allow, revoke and delete are signed local operations; a browser-only session cannot perform them. Resolve uses public discovery, while inbox requires the owning account's current authorization. Credentials and certificates must cover the local operations used; updating code does not expand their ceilings or CA authority. @@ -89,7 +89,7 @@ use the URI namespace `https://msg.lmm.best/ns/internet/`: Clients require all discovered links to match the queried origin and documented paths. They reject redirects and oversized responses. Discovery carries no local -cookies, OAuth tokens, private keys, or certificates to the remote origin. +cookies, browser credentials, private keys, or certificates to the remote origin. ## Delivery and trust diff --git a/docs/BROWSER_LANGUAGES.md b/docs/BROWSER_LANGUAGES.md index 4357d558..27b08764 100644 --- a/docs/BROWSER_LANGUAGES.md +++ b/docs/BROWSER_LANGUAGES.md @@ -18,4 +18,4 @@ selection does not change authorization or translate identifiers sent to APIs. Add a locale to `LANGUAGES` and a complete column in `browser_i18n.py`; catalogs are embedded in the existing CSP-hashed script without another network request. -Run the browser language, rendering and OAuth integrity tests after changes. +Run the browser language, rendering and browser-session integrity tests after changes. diff --git a/docs/CLIENT_CONNECTIONS.md b/docs/CLIENT_CONNECTIONS.md index 99a0be27..d3710733 100644 --- a/docs/CLIENT_CONNECTIONS.md +++ b/docs/CLIENT_CONNECTIONS.md @@ -126,7 +126,7 @@ msg --server https://primary.example.org --endpoint https://backup.example.org i The endpoint is explicit and invocation-only; it never changes the saved service authority. Both origins must use the same scheme. Discovery must report the -canonical authority before MSG operations, OAuth secrets or signed previews are +canonical authority before MSG operations, native credential secrets or signed previews are sent to an alias. Redirects are refused. Without `ServiceURL` or `--server`, a DNS alias remains a separate local origin; MSG does not automatically trust a remote claim to move or share existing credentials. diff --git a/docs/FILESYSTEM_LAYOUT.md b/docs/FILESYSTEM_LAYOUT.md index 45b0cce0..ab22033e 100644 --- a/docs/FILESYSTEM_LAYOUT.md +++ b/docs/FILESYSTEM_LAYOUT.md @@ -10,7 +10,7 @@ Host filesystem paths follow the [XDG Base Directory specification](https://spec | --- | --- | --- | | Configuration | `~/.config/msg` | SSH-style `config`, default server in `service.json` (set with `msg server use`), service-selection aliases and per-service configuration; per-account configuration and `services//current-account.json`; never new private keys or tokens | | Durable identity data | `~/.local/share/msg/services//accounts/` | Signing keys, encryption keys including rotated keys, CA keys, exported certificates, Bank approval records, retained custodial recovery history | -| Persistent state | `~/.local/state/msg/services//accounts/` | `client.json` (server binding, tokens and certificate IDs), registration and operation journals, OAuth sessions, upgrade locks | +| Persistent state | `~/.local/state/msg/services//accounts/` | `client.json` (server binding, tokens and certificate IDs), registration and operation journals, retired login state retained only to protect old identity material, upgrade locks | | Disposable cache | `~/.cache/msg/services//accounts/` | Verified operation catalogs | | Temporary work | `$XDG_RUNTIME_DIR/msg`, or Python's temporary directory | Unique private working directories, removed when the operation exits; Python honors `TMPDIR`, `TEMP`, and `TMP` | diff --git a/docs/FOLLOW_AUTHORITY_REPAIR.md b/docs/FOLLOW_AUTHORITY_REPAIR.md index 720e651f..ae156cdc 100644 --- a/docs/FOLLOW_AUTHORITY_REPAIR.md +++ b/docs/FOLLOW_AUTHORITY_REPAIR.md @@ -38,4 +38,4 @@ sudo msgd --config-dir /etc/msgd account repair-follows @lightdot \ 凭据更新、账号授权版本、授权 epoch 和带 Root 签名的审计记录在同一事务提交;任一步失败全部回滚。预览后账号状态或权限变化会拒绝旧摘要。撤销、过期、停用、委托、非原 primary、token、无既有通信 grant 和恢复隔离状态都会拒绝。已经具备两项操作时返回 `changed: false`,不要求解锁 Root,也不产生新审计。 -修复后使用恢复出的原账号、原密钥重新尝试关注;继续失败时检查返回码和实际授权,不重复扩大权限。此工具只补原密钥的两项操作,已有 token/OAuth 授权不会因此自动扩大。 +修复后使用恢复出的原账号、原密钥重新尝试关注;继续失败时检查返回码和实际授权,不重复扩大权限。此工具只补原密钥的两项操作,已有 token/浏览器会话授权不会因此自动扩大。 diff --git a/docs/HOSTING_RUNTIME.md b/docs/HOSTING_RUNTIME.md index 15718edd..e54d716f 100644 --- a/docs/HOSTING_RUNTIME.md +++ b/docs/HOSTING_RUNTIME.md @@ -34,7 +34,7 @@ A PostgreSQL service entry `msgd-hosting` plus a reader-owned 0600 pgpass/peer m supplies the dedicated database credential. Protect those files and the config. The SQL template grants only SELECT on schema_version, resources, revisions, -identities, memberships, credentials, certificates, oauth_states, settings, +identities, memberships, credentials, certificates, credential_states, settings, share_grants, share_grants_v2, dm_conversations, system_sources and resource_path_aliases. It does not grant table ownership, sequence use, CREATE, role membership or token delivery/vault/job/ledger diff --git a/docs/IDENTITY_BACKUP.md b/docs/IDENTITY_BACKUP.md index 28551e00..68e10d88 100644 --- a/docs/IDENTITY_BACKUP.md +++ b/docs/IDENTITY_BACKUP.md @@ -111,9 +111,9 @@ msg --server https://msg.lmm.best --account restored-lightjunction identity show msg --server https://msg.lmm.best --account restored-lightjunction auth status ``` -核对 subject、key ID 和 server 是否仍是原身份。`identity show` 是本地检查;`auth status` 查看 OAuth 会话,软件签名账号没有 OAuth 时 `logged_in: false` 并不代表恢复失败。这两项都不能代替真实签名验证。 +核对 subject、key ID 和 server 是否仍是原身份。`identity show` 和 `auth status` 都是本地身份检查;登录不再依赖第三方会话。这两项都不能代替真实签名验证。 -对标准 XDG 布局的软件签名账号,用恢复出的 `identity.key` 强制签名,避免有效 OAuth 或 API token 掩盖签名密钥问题。下面按默认 data 目录举例;自定义 `XDG_DATA_HOME` 时换成对应路径。 +对标准 XDG 布局的软件签名账号,用恢复出的 `identity.key` 强制签名,避免有效 API token 掩盖签名密钥问题。下面按默认 data 目录举例;自定义 `XDG_DATA_HOME` 时换成对应路径。 ```sh msg --server https://msg.lmm.best --account restored-lightjunction \ diff --git a/docs/KEY_IDENTITY.md b/docs/KEY_IDENTITY.md new file mode 100644 index 00000000..86ab1d44 --- /dev/null +++ b/docs/KEY_IDENTITY.md @@ -0,0 +1,106 @@ +# 公私钥身份 + +身份由密钥持有者控制,不绑定 Google、GitHub、ChatGPT、AgentID、邮箱或 Passkey。 +MSG 不再提供 OAuth / OpenID Connect 服务,也不再接受这些外部身份作为登录证明。 +这采用密钥持有者证明所有权的方式,但不引入区块链、交易手续费或外部钱包依赖。 + +## 创建与使用 + +新身份使用现有本地生成流程: + +```sh +msg --server https://你的服务 identity new alice +msg server use https://你的服务 +msg account use alice +msg identity show +msg read /main +msg post /main --text 'Hello.' +``` + +CLI 保存并使用本地签名私钥。服务端保存签名公钥并核验请求,不需要第三方账号。 +现有账号 ID、用户名、内容、证书和权限不会因为移除登录提供方而重建。 +用户名只是名称,不能代替签名。签名密钥和 age 解密密钥分别保留,不共用一把密钥。 +生成身份后,按[独立加密备份](IDENTITY_BACKUP.md)完成备份与恢复验证。 +私钥全部丢失且没有有效恢复方式时,第三方账号不能帮助找回身份。 + +## 浏览器登录 + +打开目标服务的 `/login`,页面显示一次性码。在持有该账号签名密钥的终端执行: + +```sh +msg --server https://你的服务 --account alice auth request XXXXXXXX +msg --server https://你的服务 --account alice auth approve XXXXXXXX +``` + +只批准自己刚刚发起、目标服务正确的登录。拒绝时使用 `auth deny`。 +页面自动完成登录,不要求输入或上传私钥。仅持有浏览器会话、API 凭据或外部账号 +不能批准另一个浏览器。`msg login` 只显示本地身份状态和浏览器入口,不向外部服务 +申请凭据;`msg auth status` 也检查本地身份,不刷新 OAuth 登录态。 + +确认码十分钟后过期,批准后只可领取一次。浏览器会话默认一小时,无刷新令牌。 +会话权限不得超过来源密钥,身份管理、根管理等敏感操作仍需本地签名。 +退出入口为 `/session/logout`;退出需要同站表单确认,单纯打开页面不会撤销会话。 +每次访问都检查来源密钥、身份版本、期限、会话撤销状态和当前权限。 +公开页面与 `/login?format=raw` 不要求登录。 + +## 部署配置 + +删除旧配置中的整个 `[oauth]`、`[[oauth.clients]]`、`[login]` 及其提供方子节。 +旧字段不被静默接受,残留配置会使启动校验失败。新的可选配置只有: + +```toml +[browser] +enabled = true +session_ttl = 3600 +``` + +浏览器会话需要 HTTPS,只有本机开发地址例外。`session_ttl` 允许 60 至 86400 秒, +浏览器凭据还受来源权限和服务内较短限制约束,不保证能取得所配置的最大期限。 +设置 `enabled = false` 可关闭浏览器会话,原生签名客户端不受影响。 +不再需要提供方 client ID、client secret、邮件登录码配置或回调域名。 + +数据库会建立通用 `credential_states` 表,并在现有迁移事务内清除旧 OAuth 状态和 +第三方绑定。只复制其中原生 API 凭据的来源限制,避免现有原生凭据失去撤销检查。 +独立托管只读角色需要数据库所有者更新授权: + +```sql +GRANT SELECT ON public.credential_states TO msgd_hosting; +``` + +使用自定义角色名时替换 `msgd_hosting`。升级前先创建并验证数据库及身份备份。 + +## 旧账号与升级前检查 + +**不要直接部署到仍有人只能通过第三方登录、且没有掌握原身份密钥的实例。** +应先在旧版本中,按现有的身份恢复或托管转自持流程让这些用户取得可用密钥, +备份后验证真实签名请求,再切换到新版本。新版本不能从公钥恢复未知私钥, +也不能凭邮箱或第三方声明接管旧账号。账号内容不删除,不代表所有旧用户已经 +具备新版本所需的登录条件;部署者必须实际确认。 + +旧 cookie、授权码、刷新令牌、OAuth access token 和第三方绑定全部失效。 +即使恢复迁移前的数据库,旧协议凭据也会被认证层拒绝,不能退回普通 token 认证。 +旧客户端的 `oauth-session.json` 不再用于登录或刷新;仅保留对旧身份资料存在的 +检查,避免注册或迁移覆盖尚未恢复的账号。完整回滚必须使用升级前验证过的备份。 + +已签署证书可能包含旧操作名称。程序只保留五个不可调用的旧操作声明,用于检查 +历史证书原字节。旧浏览器批准权限只对应两项同用途的新操作,不扩大到其他权限。 +原有签名、加密、证书、有限任务委派、恢复和旧托管密钥资料继续保留;这不是删除 +所有原生凭据或销毁历史托管数据的迁移,也不会自动把旧托管身份改成自持密钥。 + +## CLI、API 和 MCP + +CLI 原生签名请求不需要浏览器会话。原生 API 凭据继续使用 MSG 请求证明字段, +不接受 HTTP `Authorization: Bearer ...` 作为替代。敏感操作仍要求签名。 +标准 Git 客户端在专用 Git 路径仍可用 Basic 格式传输本地密钥签发的原生凭据; +这不是第三方登录,仍检查来源密钥、范围、撤销和请求限制,不能用于网页或 MCP 登录。 + +`/-/mcp` 的公开工具继续可用。`msg_connect` 给出本地密钥指引,不再触发宿主 OAuth。 +私有读写使用 CLI 或 `/-/mcp/raw` 的签名请求格式,见[协议](PROTOCOLS.md)。 +依赖 OAuth 的旧第三方连接器必须更换接入方式,不能声称升级后仍已连接。 + +## 验证范围 + +`test_browser_sessions.py` 验证真实签名批准、一次性领取、过期、撤销、权限收缩及并发。 +`test_oauth.py` 现在是移除回归:旧入口关闭、旧凭据拒绝、旧操作不可执行。 +`test_key_identity_migration.py` 验证存储迁移可重复执行,保留原生 API 来源与账号资料。 +这些是源码测试范围;是否通过、对应提交及是否部署,必须以该次测试和发布证据为准。 diff --git a/docs/OAUTH.md b/docs/OAUTH.md index 32df0a66..68b27a51 100644 --- a/docs/OAUTH.md +++ b/docs/OAUTH.md @@ -1,173 +1,5 @@ -# msgctl OAuth 登录与 API key +# OAuth 已移除 -MSG 可以作为 OAuth 2.0 / OpenID Connect 提供方。身份仍由现有 Ed25519 私钥或托管 vault 控制;OAuth 授权不会创建第二套账号、证书或业务权限。外部提供方登录、注册限制和已有账号绑定见[账号登录与绑定](ACCOUNT_LOGIN.md)。 - -## 启用和注册应用 - -在 `msgd.toml` 加入: - -```toml -[oauth] -enabled = true -access_ttl = 900 -session_ttl = 2592000 - -[[oauth.clients]] -client_id = "my-app" -name = "My app" -redirect_uris = ["https://app.example.com/auth/msg/callback"] -scopes = ["openid", "profile", "offline_access", "msg.read"] -``` - -`msg-cli` 是内置设备码客户端,不接受浏览器回调;`msg-chatgpt` 是内置 MCP 客户端,回调见下文。其他客户端需要明确登记;没有动态注册、通配回调、密码授权或 implicit grant。当前客户端均为 public client,`token_endpoint_auth_method=none`;服务器应用也必须发送 PKCE。 - -生产地址必须使用 HTTPS。回调地址精确匹配登记值,不允许 query、fragment、用户名或密码;本机开发仅允许明确登记的 `http://127.0.0.1` / `http://[::1]` 地址。 - -启动时数据库会创建 `oauth_states` 表和到期索引。独立托管读取进程也需要查询凭据来源及会话撤销状态;**现有**读取角色需由数据库所有者在升级后执行: - -```sql -GRANT SELECT ON public.oauth_states TO msgd_hosting; -``` - -新的角色使用更新后的 `deploy/hosting-role.sql`。没有该权限时读取进程拒绝启动,不会自行扩大数据库权限。重启服务和读取进程后配置生效。 - -## 浏览器 - -访问 `/oauth/authorize`,发送 `response_type=code`、登记的 `client_id` 和 `redirect_uri`、`scope`、随机 `state`、`code_challenge`、`code_challenge_method=S256`。登录用途应发送随机 `nonce`。客户端必须验证回调 `state` 和 ID token 的签名、`iss`、`aud`、`exp`、`nonce`;以 `sub` 识别用户,不以可变名称识别用户。 - -第一次登录打开 `/login`(`/oauth/login` 仍兼容),页面显示 `msg auth approve XXXXXXXX`。在持有身份私钥的 CLI 中执行即可确认浏览器登录。`msg auth request XXXXXXXX` 可先查看应用和权限,`msg auth deny XXXXXXXX` 拒绝请求。 - -浏览器请求 `text/html` 时,首页、普通帖子和用户页面显示渲染后的 HTML;CLI 的 Markdown 内容保持不变。支持 WebMCP 的浏览器可以发现 `msg_home` 和 `msg_read` 两个只读工具,读取请求沿用同源 Cookie 和现有 ACL。页面脚本以 CSP SHA-256 固定,不执行帖子中的 HTML 或脚本。普通页面提供 `raw` 链接(`?format=raw`),显示纯文本。`/register` 提供 CLI 注册步骤。语言、强调色和亮暗主题偏好只保存于本机 localStorage,身份凭据仍只使用 HttpOnly Cookie。私聊按联系人显示,频道列表使用正文标题和摘要。 - -登录完成后回到 `/`,首页显示当前身份、收件箱、私聊、发件箱和退出入口。 -浏览器会话只用于普通资源页面的 GET/HEAD 读取,沿用执行器的当前凭据、祖先权限、 -证书门槛和恢复隔离检查;私密帖子不会加入首页的公共列表或统计。 -Cookie 不用于 `/-/` 操作入口,也不能授权发帖、ACK、转账或其他写操作。 -退出页面需要确认并提交带 CSRF 的 POST;退出、来源密钥撤销/到期、权限收缩、 -身份版本变更或关闭 OAuth 后,会话不能继续读取私密内容。 -会话页面禁用共享缓存,Cookie 不会出现在页面或链接中;派生读取凭据不发送给浏览器, -也不写入 localStorage。 - -没有本地私钥的用户可以访问 `/oauth/signup`,沿用现有托管 key 注册流程。服务器保管该身份的签名和加密密钥,登录会话绑定到活跃 vault;原有托管升级流程仍可将身份转为自行保管。托管会话不要求每小时重新使用 bootstrap token。浏览器登录态是 HttpOnly、SameSite=Lax cookie,HTTPS 下使用 Secure 和 `__Host-` 名称。网页没有把 access/refresh token 写入 localStorage。 - -登录后授权页面展示应用、当前身份和请求权限,点击同意才签发一次性授权码。授权码仅存活 60 秒。客户端后端向 `/oauth/token` POST 表单兑换,必须提供同一 `client_id`、`redirect_uri` 和 PKCE `code_verifier`。现阶段跨域网站应由其后端兑换;端点没有开放跨域浏览器 CORS。 - -浏览器会话使用持久 cookie,关闭浏览器不会主动结束会话;实际有效期不超过批准登录的来源凭据到期时间。星图切到后台会清理私有视图,这与退出登录不同,需要时重新打开 My orbit 验证并加载。 - -浏览器会话默认有效 30 天,绑定原身份密钥、身份授权版本、托管状态和恢复状态。会话撤销后,由它授出的 OAuth token 同时失效。首次申请托管身份后,应通过设备码在一个可信 CLI 中保存登录态;丢失所有登录凭据时不能凭名称取回托管身份。 - -## CLI / 有浏览器的 Agent - -```bash -msg login -msg auth status -msg read /main -msg logout -``` - -`msg login` 尝试打开浏览器,也在 stderr 显示验证地址和设备码。设备没有浏览器、处于 SSH / 容器内时: - -```bash -msg login --no-browser -``` - -用手机或另一台电脑打开显示的地址、输入设备码、登录并同意。CLI 在规定间隔内轮询,保存凭据后退出。验证码本身不能登录、不能批准权限;可使用已登录的私钥 CLI 直接 `msg auth approve CODE`。 - -凭据保存为所选账号状态目录中的 `oauth-session.json`,权限为 0600;不写入正常 status 输出。CLI 重启后继续使用,access token 到期前自动刷新。刷新 token 每次轮换,绝对到期时间不延长;再次使用旧刷新 token 会撤销整个授权。多个 CLI 进程通过现有身份状态锁串行刷新。若刷新响应丢失,客户端保存未完成标志并要求重新登录,避免盲目重试已经消耗的刷新 token。私钥不会因此被删除。 - -access token 默认 15 分钟,长期凭据默认 30 天。原来源凭据有更早到期时间时采用较早值。使用 `--account NAME` 选择同一服务的不同本地身份;显式 `--config-dir` 仍支持独立的便携目录。完整路径和迁移规则见[文件系统布局](FILESYSTEM_LAYOUT.md)。 - -## API key / 受限环境 - -持有私钥的身份可以申请日常 API key: - -```bash -msg api-key create --ttl 86400 -msg api-key show -msg api-key rotate --ttl 86400 -msg api-key revoke -``` - -创建和轮换必须验证私钥签名。默认 ceiling 仅包含该身份当前具备的普通读取操作;通过 `--ceiling '[...]'` 传入现有 CapabilityGrant 数组可明确缩小或指定权限,但不能超过签名凭据的权限。API key 的最长有效期为 24 小时。 - -首次创建/轮换命令仅一次输出 key,并将其保存供后续 CLI 调用。`show` 只显示标识和到期时间。轮换在一次事务里创建新 key 并撤销旧 key。来源签名凭据撤销、到期或授权版本改变后,新 key 也会失效。key 不具有签发其他 key 的权力。 - -申请复用 `identity.token_create@3`、既有一次性秘密交付和独立 recovery secret。丢失交付响应时保留 0600 的 `api-key-create.json`,执行 `msg identity recover-token` 恢复;私钥不变,来源绑定也不会丢失。 - -API key / OAuth access token 采用 `credential_id.base64url_secret` 形式,允许放在 POST `/-/p/` 的 `Authorization: Bearer ...` 请求头。业务请求仍使用现有 OperationRequest,subject 必须等于 token 身份,proof 留空;服务端注入 TokenProof 后经过相同 executor、权限、签名要求和恢复门槛。也可直接使用原有 TokenProof。不能同时提供 Bearer 和另一份 proof。 - -只能通过受限 GET / 浏览器交互的 Agent 继续使用原签名路径;不允许把 API key、access token、refresh token 放进 URL。PathGETTransport 保留原有秘密通道拒绝规则。私钥仍用于需要签名的内容确认、密钥/授权/资金等操作,OAuth token 不会替代这些要求。 - -## 端点和权限 - -| 端点 | 用途 | -| --- | --- | -| `/.well-known/oauth-authorization-server` | OAuth 元数据 | -| `/.well-known/openid-configuration` | OIDC 元数据 | -| `/oauth/authorize` | 浏览器授权码 + PKCE | -| `/oauth/device_authorization` | 设备码申请 | -| `/oauth/device` | 设备授权确认 | -| `/oauth/token` | 授权码、设备码兑换 / 刷新 | -| `/oauth/userinfo` | `sub` 和可选 `preferred_username` | -| `/oauth/jwks` | 验证 ID token 的公开密钥 | -| `/oauth/revoke` | 撤销所属客户端的授权 | -| `/oauth/logout` | 浏览器会话退出,需 Origin + CSRF | - -| scope | 含义 | -| --- | --- | -| `openid` | 身份登录,返回签名 ID token,可访问 userinfo | -| `profile` | userinfo 返回当前名称 | -| `offline_access` | 请求逐次轮换的刷新 token | -| `msg.read` | 普通读取操作的凭据上限 | -| `msg.write` | 普通写操作的凭据上限;仍受签名和现有业务权限约束 | - -`scope` 是权限上限,不是额外授权。MSG 作为 OIDC 提供方时没有 email claim、动态客户端注册、跨域 CORS 或完整 OIDC 认证声明。 - -所有随机 token、cookie、授权码只存摘要;权限和来源信息保存在 PostgreSQL。认证记录与业务提交共享已有写锁,重复消费和并发刷新不能产生两个有效后继。登录端点带请求上限和到期清理,拒绝跨站 cookie 写入;HTTP 缓存关闭,浏览器引用来源隐藏。恢复隔离、过期运行 generation、来源密钥撤销或来源记录缺失均拒绝访问。 - -参考:[OAuth 安全建议 RFC 9700](https://www.rfc-editor.org/rfc/rfc9700.html)、[设备码 RFC 8628](https://www.rfc-editor.org/rfc/rfc8628.html)、[OpenID Connect Core](https://openid.net/specs/openid-connect-core-1_0.html)。 - -## 当前权限与只读信息 - -非托管 OAuth 与 API key 的权限上限还须落在来源密钥当前的 operations、scope 和 -constraints 内;保持同一 key ID 或 auth_version 不会保留来源已失去的权限。 -托管 vault 的签名 key 本身没有请求 grants,已批准会话按捕获的上限与当前临时策略 -双重限制,同时检查活跃 vault、撤销、身份版本、会话及恢复状态;bootstrap token 的 -一小时自然到期不缩短已批准会话。策略放宽不会扩大既有会话捕获的上限。 -`GET` 和 `POST /oauth/userinfo` 使用数据库只读事务,不写持久 rate 状态或产生外发 -副作用;仍检查当前来源、family、runtime generation 与 quarantine。 - -## ChatGPT / MCP - -`/-/mcp` 默认提供日常工具:`msg_me`、`msg_connect`、`msg_resolve`、`msg_read`、 -`msg_list`、`msg_search`,连接身份后按授权增加收件箱、通知、发送、回复、发帖和 ACK。 -输入只有用户名、资源引用、正文和分页参数;请求 ID、三分钟有效期、摘要与凭据证明由 -服务器的 MSG SDK 构造。工具返回精简业务结果,正文按预算读取;读取不表示已确认。 - -启用 OAuth 后,内置 public client `msg-chatgpt` 使用 PKCE S256,回调地址精确为 -`https://chatgpt.com/connector_platform_oauth_redirect`。在宿主中添加连接器,MCP URL 为 -`https://你的服务/-/mcp`,OAuth client ID 填 `msg-chatgpt`,无需 client secret。 -服务提供受保护资源元数据、授权服务元数据和授权响应 `iss`;不提供动态客户端注册。 -若宿主使用其他回调,按前面的配置登记单独的客户端和精确回调,不能使用通配地址。 - -`msg_connect` 让宿主启动 OAuth;用户在 MSG 选择已有身份并明确同意权限。模型看不到 -私钥、access token 或 refresh token。宿主保存 OAuth token,后续通过 Bearer header -传给服务器,服务器验证当前来源凭据、会话、资源受众和权限,再构造业务请求。 -MCP 写操作不使用网页 Cookie,也不能混用 Bearer 与另一个 packet proof。 - -三种授权范围使用固定操作白名单:`msg.mcp.read` 读取可见内容和收件箱, -`msg.mcp.message` 发送私信、处理邀请和显式 ACK,`msg.mcp.post` 发帖与讨论回复。 -授予范围不会扩大来源凭据或旧 token 的上限。旧来源缺少新操作版本时,先在可信 CLI -检查并明确更新来源授权,再重新连接;不要把旧 token 自动升级为更大的权限。 -私聊仍要求对方接受邀请,拒绝、封锁、撤销和会话过期立即限制后续调用。 - -宿主应保留初始化返回的 `MCP-Session-Id`。网络超时后,用同一会话和相同 JSON-RPC ID -重试原参数;服务器保留业务请求 ID,统一执行器保证写入幂等。新动作使用新 ID, -复用 ID 却修改参数会失败。会话绑定到 OAuth family,刷新不改变重试身份。 - -高级 SDK 的完整操作目录保留在 `/-/mcp/raw`。已部署 SDK 和 Agent Link 的签名 packet -仍可通过默认入口调用,但不会进入默认工具目录。`msg mcp` 本地 stdio 仍是原有自动 -签名 SDK 入口;该兼容入口不会把本地私钥写进工具参数。 - -连接器升级后,请让宿主重新发现工具并重新授权。服务器验证可以证明 OAuth 和 MCP -协议行为;宿主实际弹窗、回调接收和安全保存还需在该连接器中完成一次真实连接验收。 +此页只保留旧文档链接的去向,不再提供 OAuth 配置或接入步骤。 +使用[公私钥身份](KEY_IDENTITY.md);部署前必须先检查旧用户的密钥与备份。 +旧第三方登录、对外授权服务、回调、刷新令牌和身份绑定不再可用。 diff --git a/docs/PROTOCOLS.md b/docs/PROTOCOLS.md index 317d28aa..1e676ffe 100644 --- a/docs/PROTOCOLS.md +++ b/docs/PROTOCOLS.md @@ -79,7 +79,7 @@ JSON 拒绝重复键、非有限数和未声明字段。`core/codec.py` 使用 U 只读 GraphQL 路由只接受 query,`/-/graphql` 只接受 mutation;两者由 graphql-core 和同一 Registry 生成字段。通用字段是 `call(packet: $packet)`,CLI GraphQL 适配器按 effect 选择路由,结构化查询不绕过授权。 -MCP stdio 的 stdout 只有 JSON-RPC。Streamable HTTP 使用 POST JSON 响应模式;当前无 SSE 监听,GET 返回 405,通知接受后返回 202。协议版本以 `transports/mcp_protocol.py` 为准。默认 `/-/mcp` 使用业务参数和经 OAuth 明确绑定的身份,匿名目录为 6 个工具,具备全部所需授权时最多 13 个;不让模型处理 envelope 或秘密凭据。 +默认 MCP 目录提供公开工具,不再使用 OAuth 身份或 Bearer 认证。私有操作使用 CLI 或 `/-/mcp/raw` 的签名请求;详见[公私钥身份](KEY_IDENTITY.md)。 显式 SDK 入口 `/-/mcp/raw` 使用 `arguments.packet` 提交证明。`msg mcp` 的本地工具由客户端签名,`params._meta["msg/request_id"]` 指定重试 ID,`msg/expected_generations` 指定预期 generation。SDK 与本地 stdio 的工具目录仅列每个 operation 最新启用的 network 版本,版本大于 1 时仍使用原有 `name@version` 工具名。旧版本不再占发现目录,但仍可按原名显式调用,HTTP/API、签名字节、凭据上限与版本语义不变;已拒绝的过时凭据操作不会因此恢复可用。目录筛选后的游标绑定新视图,旧目录游标需要从第一页重新获取。所有 MCP 入口都不暴露 Root 管理工具。 diff --git a/docs/README.md b/docs/README.md index 7bde0b31..820f36ca 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,10 +6,10 @@ | 要做的事 | 入口 | | --- | --- | | 安装、连接与选择账号 | [安装](CLIENT_INSTALLATION.md)、[连接](CLIENT_CONNECTIONS.md)、[目录和迁移](FILESYSTEM_LAYOUT.md) | -| 账号登录和绑定方式 | [Google、邮箱、GitHub、Passkey 与 ChatGPT](ACCOUNT_LOGIN.md)、[MSG OAuth 与 MCP 授权](OAUTH.md) | +| 公私钥身份与登录 | [创建、浏览器确认及升级](KEY_IDENTITY.md) | | Agent 接任务和交结果 | [Agent Link 外援接入](AGENT_LINK.md)、[Subagent](SUBAGENTS.md)、[协作编程](AGENT_PROGRAMMING.md)、[跨服务地址](AGENT_INTERNET_ADDRESS.md) | | 读取、查询与固定版本 | [协议](PROTOCOLS.md)、[字段选择](CLI_FIELD_SELECTION.md)、[查询](SEARCH_QUERY_V5.md)、[SavedQuery](SAVED_QUERIES.md)、[资源地址和事件](RESOURCE_EVENTS.md) | -| 浏览、飞船与发布网页 | [浏览器登录](OAUTH.md)、[星图](post-universe.md)、[飞船](LIVE_FLIGHT.md)、[托管边界](HOSTING_RUNTIME.md) | +| 浏览、飞船与发布网页 | [浏览器登录](KEY_IDENTITY.md)、[星图](post-universe.md)、[飞船](LIVE_FLIGHT.md)、[托管边界](HOSTING_RUNTIME.md) | | AI 驾驶和游戏通知 | [游客机器人、账号授权与 webhook](ai-game.md)、[可运行示例](../examples/game_bot.py) | | 保护原身份与恢复账号 | [加密备份](IDENTITY_BACKUP.md)、[备份格式](IDENTITY_BACKUP_FORMAT.md)、[YubiKey](YUBIKEY.md)、[临时升级](TEMPORARY_UPGRADE.md) | | 检查授权和秘密交付 | [授权来源](AUTHORIZATION_SOURCES.md)、[凭据交付](CREDENTIAL_DELIVERY.md)、[托管历史](CUSTODIAL_HISTORY.md)、[信任路径](TRUST_PATHS.md) | diff --git a/docs/SERVICE_ALIASES.md b/docs/SERVICE_ALIASES.md index 81fae65e..d3c4d1cb 100644 --- a/docs/SERVICE_ALIASES.md +++ b/docs/SERVICE_ALIASES.md @@ -26,7 +26,7 @@ sandbox and opaque-origin behavior, without cookies or server write authority. Clients explicitly keep the original `--server` while selecting `--endpoint`, or use `ServiceURL` in their SSH-style host configuration. All four operation -transports, OAuth endpoints and signed hosting previews use the selected endpoint. +transports, native browser-session endpoints and signed hosting previews use the selected endpoint. The discovery response must still report the original authority. Requests signed for the alias itself fail `wrong_service`; another installation cannot accept the original installation's packets or certificates. Endpoint selection never moves @@ -69,7 +69,7 @@ current authorization or promotion requirements. `tests/test_service_aliases.py` covers parsing, rejection, canonical signed publication/replay, wrong-service rejection, all four transports, identity -reuse, Origin/Host denial, no redirect, OAuth endpoint security and configuration. +reuse, Origin/Host denial, no redirect, native session endpoint security and configuration. `tests/test_hosting_alias_ingress.py` runs against the real read-only PostgreSQL hosting role. `tests/test_backup_daemon.py` checks alias preservation in a real backup/restore with quarantine. The connection tests reject unsafe IdentityFile diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index 059bacce..d1d92469 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -63,11 +63,9 @@ Root 内存 fixture 没有飞行 WebSocket 服务,因此只豁免回环 `/_fli ## 面向 agent 的 MCP `tests/test_mcp_tools.py` 检查有限目录、无凭据参数、精简投影和用户名映射。 -`tests/test_mcp_oauth.py` 使用真实 PostgreSQL 与现有登录流程检查受众绑定、PKCE、 -权限范围、刷新和撤销;`tests/test_mcp_direct.py` 检查新私信授权版本及旧签名版本兼容。 -`tests/test_mcp_agent.py` 从 HTTP 入口检查默认目录、连接提示、身份、私信、回复、 -读取不 ACK、重试和重启幂等。高级操作兼容仍由 HTTP、stdio 和 Agent Link 测试覆盖。 - -发布后再次读取 OAuth 元数据和默认 MCP 目录,确认不存在完整 envelope 字段。 +`tests/test_browser_sessions.py` 使用真实 PostgreSQL、原生签名和浏览器会话检查 +确认、权限收缩与撤销;`tests/test_mcp_agent.py` 检查公开工具及原始签名 MCP。 +`tests/test_oauth.py` 验证旧入口和旧凭据被拒绝,不再测试可用的 OAuth 服务。 +升级后读取默认 MCP 目录,确认没有第三方授权提示;私有操作另验原生签名传输。 协议验收与 ChatGPT 宿主实际完成授权是两个证据范围;不能用服务端 HTTP 测试替代 宿主保存、刷新 token 和真实回调验收。 diff --git a/docs/fuse.md b/docs/fuse.md index 33f9ad5a..07c93a79 100644 --- a/docs/fuse.md +++ b/docs/fuse.md @@ -62,7 +62,7 @@ Git 仓库、网页托管资源不会自动解包为 Git 工作树或发布目 ## 权限、缓存和限制 -挂载复用现有账号、私钥/令牌、OAuth 登录态、证书以及显式身份代理配置。 +挂载复用现有账号、私钥/原生令牌、证书以及显式身份代理配置。 服务端仍决定每次请求能读取什么;本地挂载不会获得额外权限。 `stat`、目录分页和每次内容块读取均经过鉴权。原始文件读取关闭内核内容缓存和属性缓存, 权限被撤销后后续读取失败;已交付给本地程序的字节当然无法收回。 diff --git a/pyproject.toml b/pyproject.toml index cf22c7d3..96b4316c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,8 +35,6 @@ server = [ "markdown-it-py>=4,<5", "pygments>=2.19,<3", "websockets>=15,<17", - "fido2>=2.2.1,<3", - "pyjwt[crypto]>=2.10,<3", ] dev = [ "msgctl[server]", diff --git a/scripts/check_flight_browser.py b/scripts/check_flight_browser.py index 439d49f7..d8f7bb6a 100644 --- a/scripts/check_flight_browser.py +++ b/scripts/check_flight_browser.py @@ -113,7 +113,9 @@ def check_multiplayer_app(browser, base_url: str, accounts_file: str | None = No ARTIFACTS.mkdir(parents=True, exist_ok=True) parsed = urlsplit(base_url) assert parsed.hostname in {'127.0.0.1', 'localhost', '::1'}, 'Loopback fixture required' - assert accounts_file, 'Full app checks require two generated loopback OAuth accounts' + assert accounts_file, ( + 'Full app checks require two generated loopback key-approved browser accounts' + ) path = Path(accounts_file) assert path.stat().st_mode & 0o077 == 0, 'Fixture account file must be private' fixture_accounts = json.loads(path.read_text()) @@ -177,7 +179,7 @@ def wait(page, expression, **kwargs): viewport=viewport, has_touch=index == 1, is_mobile=index == 1 ) contexts.append(context) - # Generated local OAuth cookies only; do not print or trace them. + # Generated local key-approved browser cookies only; do not print or trace them. context.add_cookies(accounts[index]['cookies']) context.add_init_script(probe) page = context.new_page() diff --git a/scripts/check_ui_audit.py b/scripts/check_ui_audit.py index b4372d2e..db9c9949 100644 --- a/scripts/check_ui_audit.py +++ b/scripts/check_ui_audit.py @@ -44,8 +44,8 @@ def pages(service): """注册表值只渲染内存数据;不实例化应用或存储连接。""" from msg.transports.home_page import document_html, home_html - from msg.transports.oauth_http import page as form_page from msg.transports.search_page import search_document + from msg.transports.session_http import page as form_page data = { 'posts': 24, @@ -182,7 +182,7 @@ def static_headers(name): def fixture_server(): from msg.extensions.hosting import hosted_headers from msg.transports.home_page import HOME_BROWSER_HEADERS - from msg.transports.oauth_http import page as form_page + from msg.transports.session_http import page as form_page fixture = root_fixture() rendered = {} diff --git a/scripts/serve_flight_fixture.py b/scripts/serve_flight_fixture.py index b544cec6..01f4d080 100644 --- a/scripts/serve_flight_fixture.py +++ b/scripts/serve_flight_fixture.py @@ -8,7 +8,7 @@ The JSON readiness file contains origin and the ordinary /@root/web/ URL. SIGTERM or the finite lifetime closes the app, stops PG, and removes test data and files. No production settings, keys, database, recipients, or WS mocks. -Optional --browser-accounts-file writes two ordinary OAuth session cookies to +Optional --browser-accounts-file writes two ordinary key-approved session cookies to a new mode-0600 file for local browser contexts; it is never printed. """ @@ -35,15 +35,15 @@ from msg.admin.root import _approve_csr, _provision from msg.application import Application from msg.bootstrap import ROOT_WEB_SAMPLE +from msg.browser_config import BrowserConfig from msg.config import write_example from msg.constants import ROOT_SUBJECT from msg.core.codec import b64 from msg.core.requests import request_for -from msg.oauth_config import OAuthConfig from msg.security.age_keys import generate_age_key from msg.security.crypto import Ed25519Signer, subject_id from msg.transports.http import create_app -from msg.transports.oauth_http import csrf +from msg.transports.session_http import csrf class FixtureServer(uvicorn.Server): @@ -145,27 +145,27 @@ async def browser_accounts(app, router, accounts): async with httpx.AsyncClient( transport=httpx.ASGITransport(app=router), base_url=app.settings.service_url ) as http: - login = await http.get('/oauth/login') + login = await http.get('/login') if login.status_code != 200: - raise RuntimeError('Fixture OAuth login unavailable') + raise RuntimeError('Fixture key-approved browser login unavailable') code = login.text.split('msg auth approve ', 1)[1].split('', 1)[0] await call( app, - 'identity.oauth_approve', + 'identity.session_approve', {'user_code': code, 'decision': 'approve'}, key=account['key'], subject=account['subject_id'], ) response = await http.post( - '/oauth/login/poll', + '/session/poll', json={'csrf': csrf(http.cookies.get('msg_login'))}, headers={'Origin': app.settings.service_url}, ) if response.status_code != 200 or response.json() != {'logged_in': True}: - raise RuntimeError('Fixture OAuth approval failed') + raise RuntimeError('Fixture key-approved browser approval failed') cookie = http.cookies.get('msg_session') if not cookie: - raise RuntimeError('Fixture OAuth session missing') + raise RuntimeError('Fixture key-approved browser session missing') result.append({ 'handle': account['handle'], 'subject_id': account['subject_id'], @@ -228,7 +228,7 @@ def stop(): if settings.server.mail is not None or settings.server.valkey_url: raise RuntimeError('Fixture must have no mail or Valkey') if options.browser_accounts_file: - settings = replace(settings, oauth=OAuthConfig(enabled=True)) + settings = replace(settings, browser=BrowserConfig(enabled=True)) app = Application(settings) try: csr, root = await _provision(app, 'disposable-flight-' + os.urandom(24).hex()) diff --git a/scripts/serve_terminal_fixture.py b/scripts/serve_terminal_fixture.py index 83e6aa92..085ac700 100644 --- a/scripts/serve_terminal_fixture.py +++ b/scripts/serve_terminal_fixture.py @@ -2,7 +2,7 @@ Run with the candidate's src on PYTHONPATH and an ordinary OS user. Supply new --ready-file and --accounts-file paths. SIGTERM or --lifetime removes all test -data and private OAuth files. No production settings or credentials are used. +data and private key-approved browser files. No production settings or credentials are used. """ from __future__ import annotations @@ -27,10 +27,10 @@ from msg.admin.diagnostics import temporary_postgres from msg.admin.root import _approve_csr, _provision from msg.application import Application +from msg.browser_config import BrowserConfig from msg.config import write_example from msg.core.codec import b64 from msg.core.requests import request_for -from msg.oauth_config import OAuthConfig from msg.security.age_keys import generate_age_key from msg.security.crypto import Ed25519Signer, subject_id from msg.transports.http import create_app @@ -152,7 +152,7 @@ def stop(): postgres = Path(parse_qs(urlsplit(dsn).query)['host'][0]).parent settings = replace( write_example(folder / 'etc', folder / 'data', origin, postgres_dsn=dsn), - oauth=OAuthConfig(enabled=True), + browser=BrowserConfig(enabled=True), ) if settings.server.mail is not None or settings.server.valkey_url: raise RuntimeError('Fixture must not use mail or Valkey') diff --git a/src/msg/admin/config_check.py b/src/msg/admin/config_check.py index a8703872..897cba15 100644 --- a/src/msg/admin/config_check.py +++ b/src/msg/admin/config_check.py @@ -23,54 +23,11 @@ def _vectors(): recipient = recipient_from_public(bytes.fromhex('09' + '00' * 31)) custodian = {'id': 'test-custodian', 'name': 'Isolated custodian', 'recipient': recipient} - # 独立固定默认客户端的验收值,避免与加载器一起漂移。 - builtin_chatgpt = { - 'client_id': 'msg-chatgpt', - 'name': 'MSG for ChatGPT', - 'redirect_uris': ['https://chatgpt.com/connector_platform_oauth_redirect'], - 'scopes': [ - 'msg.mcp.message', - 'msg.mcp.post', - 'msg.mcp.read', - 'offline_access', - 'openid', - 'profile', - ], - } # These are independent acceptance expectations, not configuration defaults # used by the server. Each case asserts the loader's effective Settings value. rows = { - 'login.enabled': (False, True, 'true', 'invalid_login_config'), - 'login.registration': ('legacy', 'provider_only', 'any_provider', 'invalid_login_config'), - 'login.session_ttl': (900, 3600, 3601, 'invalid_login_config'), - 'login.providers': ( - {}, - {'passkey': {'enabled': True}}, - {'unknown': {}}, - 'invalid_login_provider', - ), - 'oauth.enabled': (False, True, 'true', 'invalid_oauth_config'), - 'oauth.access_ttl': (900, 3600, 3601, 'invalid_oauth_config'), - 'oauth.session_ttl': (2592000, 7776000, 7776001, 'invalid_oauth_config'), - 'oauth.clients': ( - [builtin_chatgpt], - [ - { - 'client_id': 'selftest', - 'name': 'Config test', - 'redirect_uris': ['https://example.invalid/callback'], - 'scopes': ['msg.read', 'openid'], - }, - ], - [ - { - 'client_id': 'selftest', - 'name': 'Config test', - 'redirect_uris': ['http://example.invalid/callback'], - } - ], - 'invalid_redirect_uri', - ), + 'browser.enabled': (True, False, 'true', 'invalid_browser_config'), + 'browser.session_ttl': (3600, 86400, 86401, 'invalid_browser_config'), 'server.service_url': ( _REQUIRED, 'https://config.invalid', @@ -256,19 +213,6 @@ def _effective(settings, field): if isinstance(value, Path): return str(value) if isinstance(value, tuple): - if field == 'login.providers': - return {item.name: {'enabled': True} for item in value if item.enabled} - if field == 'oauth.clients': - return [ - { - 'client_id': item.client_id, - 'name': item.name, - 'redirect_uris': list(item.redirect_uris), - 'scopes': sorted(item.scopes), - } - for item in value - if item.client_id != 'msg-cli' - ] if field == 'recovery.custodians': return [ {key: getattr(item, key) for key in ('id', 'name', 'recipient')} for item in value @@ -316,8 +260,6 @@ def configuration_selftest(): default_status = 'pass' for accepted, value in ((True, good), (False, bad)): data = deepcopy(baseline) - if field == 'login.enabled': - data['oauth'] = {'enabled': True} mail = None if field.startswith('mail.'): mail = { @@ -349,8 +291,6 @@ def configuration_selftest(): if field == 'identity.credential_delivery_recovery_window' else None if field == 'mail.enabled' - else default + good - if field == 'oauth.clients' else good ) require( diff --git a/src/msg/application.py b/src/msg/application.py index dfd2146c..581539fd 100644 --- a/src/msg/application.py +++ b/src/msg/application.py @@ -162,7 +162,7 @@ async def load(self): ) self.authorizer = AuthorizationService(self.registry, self.certificates) self.authenticator.runtime_generation = self.runtime_generation - self.authenticator.oauth_config = self.settings.oauth + self.authenticator.browser_config = self.settings.browser self.authorizer.runtime_generation = self.runtime_generation self.executor = self.new_executor(self.authenticator) self.executor.recovery_drill_marker = marker diff --git a/src/msg/browser_config.py b/src/msg/browser_config.py new file mode 100644 index 00000000..1b9f1734 --- /dev/null +++ b/src/msg/browser_config.py @@ -0,0 +1,31 @@ +"""Native browser sessions are approved by the account's local signing key.""" + +from dataclasses import dataclass +from urllib.parse import urlsplit + +from msg.core.errors import require + + +@dataclass(frozen=True, slots=True) +class BrowserConfig: + enabled: bool = True + session_ttl: int = 3600 + + +def load_browser(data, service): + require( + isinstance(data, dict) and set(data) <= {'enabled', 'session_ttl'}, 'invalid_browser_config' + ) + enabled = data.get('enabled', True) + ttl = data.get('session_ttl', 3600) + require( + type(enabled) is bool and type(ttl) is int and 60 <= ttl <= 86400, 'invalid_browser_config' + ) + origin = urlsplit(service) + require( + not enabled + or origin.scheme == 'https' + or origin.hostname in {'localhost', '127.0.0.1', '::1', 'testserver'}, + 'secure_channel_required', + ) + return BrowserConfig(enabled, ttl) diff --git a/src/msg/cli.py b/src/msg/cli.py index e324ba18..d2c33089 100644 --- a/src/msg/cli.py +++ b/src/msg/cli.py @@ -118,9 +118,9 @@ def parser(): ).add_subparsers(dest='action', required=True) servers.add_parser('show') servers.add_parser('use').add_argument('url') - login = commands.add_parser('login', help='Login once using an OAuth device code.') - login.add_argument('--no-browser', action='store_true') - login.add_argument('--scope', default='openid profile msg.read msg.write offline_access') + commands.add_parser( + 'login', help='Show local key identity and native browser sign-in instructions.' + ) accounts = commands.add_parser( 'account', help='List, select, import or age-backup local accounts.' ).add_subparsers(dest='action', required=True) @@ -172,7 +172,6 @@ def parser(): account_fetch.add_argument('--from', dest='from_profile', required=True) account_fetch.add_argument('--output', type=Path, required=True) account_fetch.add_argument('--expected-sha256') - commands.add_parser('logout') auth = commands.add_parser('auth').add_subparsers(dest='action', required=True) auth.add_parser('status') for action in ('request', 'approve', 'deny'): @@ -668,8 +667,8 @@ async def run(args): from msg.client_mount import run_mount return await run_mount(client, args) - if command in {'login', 'logout', 'auth'}: - from msg.client_oauth import run_command + if command in {'login', 'auth'}: + from msg.client_browser import run_command result = await run_command(client, args) if args.user and command == 'login': diff --git a/src/msg/client.py b/src/msg/client.py index 4f344570..0d0b14f9 100644 --- a/src/msg/client.py +++ b/src/msg/client.py @@ -434,18 +434,7 @@ def prepare( if token is None and signer is None and self.state.data.get('api_key'): saved = self.state.data['api_key'] token = (saved['credential_id'], unb64(saved['value'])) - if ( - not anonymous - and signer is None - and operation not in {'identity.oauth_request', 'identity.oauth_approve'} - ): - from msg.client_oauth import read_session - session = read_session(self.state) - if session is not None: - credential, _, encoded = session['access_token'].partition('.') - token = (credential, unb64(encoded, limit=32)) - selected_subject = selected_subject or session['subject_id'] if token: selected_signer = None return request_for( @@ -493,15 +482,6 @@ async def send(self, packet): return result async def call(self, operation, arguments=None, **kwargs): - if ( - not kwargs.get('anonymous') - and kwargs.get('signer') is None - and self.signer_override is None - ): - from msg.client_oauth import read_session, refresh - - if read_session(self.state) is not None: - await refresh(self) return await self.send(self.prepare(operation, arguments or {}, **kwargs)) async def _website_generation(self, website): diff --git a/src/msg/client_accounts.py b/src/msg/client_accounts.py index 41f96b17..44921221 100644 --- a/src/msg/client_accounts.py +++ b/src/msg/client_accounts.py @@ -14,11 +14,8 @@ def identity_status(state, *, signer_override=None): """Report configured request credentials without opening sessions or contacting hardware.""" - session = state.file('oauth-session.json') if signer_override is not None: auth = 'signature' - elif session.exists() or session.is_symlink(): - auth = 'oauth' elif state.data.get('token'): auth = 'token' elif state.data.get('api_key'): diff --git a/src/msg/client_browser.py b/src/msg/client_browser.py new file mode 100644 index 00000000..8e23beb7 --- /dev/null +++ b/src/msg/client_browser.py @@ -0,0 +1,25 @@ +"""Inspect and approve native browser access using a locally held signing key.""" + +from msg.client_accounts import identity_status +from msg.core.errors import require + + +async def run_command(client, args): + if args.command == 'login' or args.action == 'status': + status = identity_status(client.state, signer_override=client.signer_override) + return { + **status, + 'browser_login': client.state.server + '/login', + 'instructions': 'Keep your private key on this device. Open /login, inspect its code with msg auth request CODE, then use msg auth approve CODE. New identities use msg register HANDLE.', + } + if args.action == 'request': + return await client.call( + 'identity.session_request', {'user_code': args.user_code}, anonymous=True + ) + signer = client.signer_override or client.state.signer + require(signer is not None and client.state.subject is not None, 'signing_key_required') + return await client.call( + 'identity.session_approve', + {'user_code': args.user_code, 'decision': 'deny' if args.action == 'deny' else 'approve'}, + signer=signer, + ) diff --git a/src/msg/client_mount.py b/src/msg/client_mount.py index 1a51e96e..4bac5259 100644 --- a/src/msg/client_mount.py +++ b/src/msg/client_mount.py @@ -242,11 +242,7 @@ async def read(self, node, size, offset): # A zero-byte read must not keep accepting a revoked open handle. await self.query('file.stat', {'id': node.metadata['id']}) return node.projection[offset : offset + length] if node.projection is not None else b'' - from msg.client_oauth import read_session, refresh - client = self.client - if client.signer_override is None and read_session(client.state) is not None: - await refresh(client) transport = client.transport description = await transport.description() require(description['operations'].get('discovery.raw') == 'read', 'effect_mismatch') diff --git a/src/msg/client_oauth.py b/src/msg/client_oauth.py deleted file mode 100644 index d99c9893..00000000 --- a/src/msg/client_oauth.py +++ /dev/null @@ -1,230 +0,0 @@ -"""Device login and durable origin-bound sessions for browserless agents.""" - -import asyncio -import os -import stat -import sys -import time -import webbrowser -from datetime import timedelta - -from msg.atomic_file import durable_write -from msg.client_upgrade import locked_state -from msg.core.codec import canonical, loads, parse_time, unb64, wire -from msg.core.errors import Failure, require -from msg.security.oauth import DEVICE_GRANT -from msg.transports.client import GraphQLTransport, HTTPTransport, MCPHTTPTransport - - -def read_session(state): - path = state.file('oauth-session.json') - if not path.exists() and not path.is_symlink(): - return None - try: - fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) - except OSError: - raise Failure('unsafe_oauth_session') from None - try: - info = os.fstat(fd) - require( - stat.S_ISREG(info.st_mode) - and info.st_uid == os.geteuid() - and info.st_nlink == 1 - and info.st_mode & 0o077 == 0 - and info.st_size <= 16384, - 'unsafe_oauth_session', - ) - with os.fdopen(fd, 'rb', closefd=False) as stream: - value = loads(stream.read(16385)) - finally: - os.close(fd) - require( - isinstance(value, dict) - and value.get('server') == state.server - and value.get('client_id') == 'msg-cli', - 'oauth_session_server_mismatch', - ) - return value - - -def save_session(client, value): - require( - value.get('token_type') == 'Bearer' - and isinstance(value.get('expires_in'), int) - and value['expires_in'] > 0 - and isinstance(value.get('subject_id'), str), - 'invalid_oauth_response', - ) - require( - client.state.subject is None or client.state.subject == value['subject_id'], - 'oauth_identity_mismatch', - ) - token = value.get('access_token', '') - id, sep, encoded = token.partition('.') - require( - sep and id.startswith('t_oauth_') and len(unb64(encoded, limit=32)) == 32, - 'invalid_oauth_response', - ) - saved = dict( - value, - server=client.state.server, - client_id='msg-cli', - expires_at=wire(client.clock() + timedelta(seconds=value['expires_in'])), - ) - durable_write(client.state.file('oauth-session.json'), canonical(saved), mode=0o600) - client.state.data['subject_id'] = value['subject_id'] - client.state._save() - return saved - - -def secure_transport(client): - require( - type(client.transport) in {HTTPTransport, GraphQLTransport, MCPHTTPTransport}, - 'secure_channel_required', - ) - from urllib.parse import urlsplit - - parsed = urlsplit(client.transport.endpoint) - require( - parsed.scheme == 'https' - or parsed.hostname in {'localhost', '127.0.0.1', '::1', 'testserver'}, - 'secure_channel_required', - ) - - -async def endpoint(client, path, data): - secure_transport(client) - if client.transport.endpoint != client.state.server: - await client.transport.description() - try: - return await client.transport._json('POST', path, body=data) - except Failure as exc: - # OAuth errors are standard string-valued errors, unlike MSG envelopes. - raise exc - - -async def start_login( - client, *, scope='openid profile msg.read msg.write offline_access', browser=False -): - value = await endpoint( - client, '/oauth/device_authorization', {'client_id': 'msg-cli', 'scope': scope} - ) - require( - all( - isinstance(value.get(k), str) - for k in ('device_code', 'user_code', 'verification_uri', 'verification_uri_complete') - ), - 'invalid_oauth_response', - ) - require( - value['verification_uri'] == client.state.server + '/oauth/device' - and value['verification_uri_complete'].startswith(value['verification_uri'] + '?'), - 'oauth_response_origin_mismatch', - ) - if browser: - webbrowser.open(value['verification_uri_complete']) - return value - - -async def finish_login(client, pending): - secure_transport(client) - deadline = time.monotonic() + min(pending.get('expires_in', 600), 600) - interval = max(5, pending.get('interval', 5)) - while time.monotonic() < deadline: - await asyncio.sleep(interval) - try: - value = await endpoint( - client, - '/oauth/token', - { - 'client_id': 'msg-cli', - 'grant_type': DEVICE_GRANT, - 'device_code': pending['device_code'], - }, - ) - except Failure as exc: - if exc.code == 'authorization_pending': - continue - if exc.code == 'slow_down': - interval += 5 - continue - raise - with locked_state(client.state): - save_session(client, value) - return {'logged_in': True, 'subject_id': value['subject_id'], 'scope': value['scope']} - raise Failure('expired_token') - - -async def refresh(client): - secure_transport(client) - with locked_state(client.state): - current = read_session(client.state) - require(current is not None, 'oauth_session_missing') - if parse_time(current['expires_at']) > client.clock() + timedelta(seconds=30): - return current - require(current.get('refresh_token'), 'oauth_login_required') - # A failed/lost response is ambiguous after rotation. Keep the local key, - # require a fresh login, and never replay a consumed refresh token. - if current.get('refresh_pending'): - raise Failure('oauth_login_required') - current['refresh_pending'] = True - durable_write(client.state.file('oauth-session.json'), canonical(current), mode=0o600) - value = await endpoint( - client, - '/oauth/token', - { - 'client_id': 'msg-cli', - 'grant_type': 'refresh_token', - 'refresh_token': current['refresh_token'], - }, - ) - return save_session(client, value) - - -async def logout(client): - secure_transport(client) - with locked_state(client.state): - current = read_session(client.state) - if current is not None: - await endpoint( - client, - '/oauth/revoke', - { - 'client_id': 'msg-cli', - 'token': current.get('refresh_token') or current['access_token'], - }, - ) - from msg.client_tokens import remove_journal - - remove_journal(client.state.file('oauth-session.json')) - return {'logged_out': True} - - -async def run_command(client, args): - if args.command == 'login': - pending = await start_login(client, scope=args.scope, browser=not args.no_browser) - print( - 'Open ' + pending['verification_uri'] + ' and enter ' + pending['user_code'], - file=sys.stderr, - flush=True, - ) - return await finish_login(client, pending) - if args.command == 'logout': - return await logout(client) - if args.action == 'status': - current = read_session(client.state) - return { - 'logged_in': current is not None and not current.get('refresh_pending'), - 'subject_id': current.get('subject_id') if current else None, - 'expires_at': current.get('expires_at') if current else None, - 'scope': current.get('scope') if current else None, - } - if args.action == 'request': - return await client.call( - 'identity.oauth_request', {'user_code': args.user_code}, anonymous=True - ) - return await client.call( - 'identity.oauth_approve', - {'user_code': args.user_code, 'decision': 'deny' if args.action == 'deny' else 'approve'}, - signer=client.state.signer, - ) diff --git a/src/msg/client_subagents_remote.py b/src/msg/client_subagents_remote.py index abd6b135..b9d14b16 100644 --- a/src/msg/client_subagents_remote.py +++ b/src/msg/client_subagents_remote.py @@ -155,7 +155,7 @@ async def _send_preflight(self, root, sender, recipient): results = await read_many([self.client.prepare(op, args) for op, args in calls]) except httpx.TimeoutException, httpx.NetworkError: # Reads have no uncertain mutation. Normal calls retain the - # client's retry and OAuth-refresh behavior on the fallback. + # client's signed retry behavior on the fallback. pass except Failure as exc: if not exc.retryable and exc.code not in {'not_found', 'dependency_unavailable'}: diff --git a/src/msg/config.py b/src/msg/config.py index 6fcc30ca..88aca87a 100644 --- a/src/msg/config.py +++ b/src/msg/config.py @@ -9,11 +9,10 @@ from pathlib import Path from urllib.parse import urlsplit +from msg.browser_config import BrowserConfig, load_browser from msg.config_contracts import configuration_keys, validate_sections from msg.core.errors import Failure, require from msg.core.models import MailConfig, ServerConfig, TransportLimits -from msg.login_config import LoginConfig, load_login -from msg.oauth_config import OAuthConfig, load_oauth from msg.paths import ROOT_PRIVATE_DIR, SERVER_CONFIG_DIR, ServerPaths from msg.security.age_keys import encryption_key_id, public_from_recipient from msg.service_origin import service_origin @@ -108,8 +107,7 @@ class Settings: hosting_base_capacity_bytes: int = 10 * 1024 * 1024 hosting_recovery_marker: Path | None = None hosting_content_group_read: bool = False - oauth: OAuthConfig = OAuthConfig() - login: LoginConfig = LoginConfig() + browser: BrowserConfig = BrowserConfig() websub_hubs: tuple[str, ...] = () @property @@ -495,9 +493,7 @@ def load_settings(config_dir=SERVER_CONFIG_DIR): for hub in hubs: validate_endpoint(hub) hubs = tuple(dict.fromkeys(hubs)) - oauth = load_oauth(data.get('oauth', {}), service) - login = load_login(data.get('login', {}), service) - require(not login.enabled or oauth.enabled, 'login_requires_oauth') + browser = load_browser(data.get('browser', {}), service) return Settings( server=ServerConfig( config_dir=config_dir, @@ -537,8 +533,7 @@ def load_settings(config_dir=SERVER_CONFIG_DIR): hosting_base_capacity_bytes=hosting_base, hosting_recovery_marker=Path(recovery_marker) if recovery_marker is not None else None, hosting_content_group_read=content_group_read, - oauth=oauth, - login=login, + browser=browser, ) diff --git a/src/msg/config_contracts.py b/src/msg/config_contracts.py index 2d5da94e..4011275a 100644 --- a/src/msg/config_contracts.py +++ b/src/msg/config_contracts.py @@ -8,14 +8,7 @@ # section -> field -> (Settings attribute path, related doctor check) _SECTIONS = { - 'login': { - name: ('login.' + name, None) - for name in ('enabled', 'registration', 'session_ttl', 'providers') - }, - 'oauth': { - name: ('oauth.' + name, None) - for name in ('enabled', 'access_ttl', 'session_ttl', 'clients') - }, + 'browser': {name: ('browser.' + name, None) for name in ('enabled', 'session_ttl')}, 'server': { 'service_url': ('service_url', None), 'service_aliases': ('service_aliases', None), diff --git a/src/msg/data/public-terminal.html b/src/msg/data/public-terminal.html index eee9b247..519238e4 100644 --- a/src/msg/data/public-terminal.html +++ b/src/msg/data/public-terminal.html @@ -98,7 +98,7 @@ const path = decodeURIComponent(url.pathname); if (path.startsWith('//') || /[\\\s\u0000-\u001f\u007f?#%]/.test(path)) return null; if (new URL(path, location.origin).pathname !== url.pathname) return null; - if (/^\/(?:-\/|_terminal(?:\/|$)|oauth(?:\/|$)|login(?:\/|$)|logout(?:\/|$))/.test(path)) return null; + if (/^\/(?:-\/|_terminal(?:\/|$)|oauth(?:\/|$)|session(?:\/|$)|login(?:\/|$)|logout(?:\/|$))/.test(path)) return null; return path; } catch { return null; } } diff --git a/src/msg/data/recovery-state-schema.json b/src/msg/data/recovery-state-schema.json index 9214f6c3..a27b47aa 100644 --- a/src/msg/data/recovery-state-schema.json +++ b/src/msg/data/recovery-state-schema.json @@ -1 +1 @@ -{"constraints":[["achievement_ceremonies","achievement_ceremonies_pkey","p"],["achievement_grants","achievement_grants_pkey","p"],["achievement_grants","achievement_grants_subject_achievement_id_spec_version_key","u"],["achievement_pins","achievement_pins_grant_id_fkey","f"],["achievement_pins","achievement_pins_pkey","p"],["achievement_pins","achievement_pins_position_check","c"],["achievement_pins","achievement_pins_subject_fkey","f"],["achievement_pins","achievement_pins_subject_position_key","u"],["agent_drops","agent_drops_kind_check","c"],["agent_drops","agent_drops_pkey","p"],["agent_drops","agent_drops_recipient_fkey","f"],["agent_drops","agent_drops_sender_fkey","f"],["agent_follows","agent_follows_check","c"],["agent_follows","agent_follows_follower_fkey","f"],["agent_follows","agent_follows_pkey","p"],["agent_follows","agent_follows_target_fkey","f"],["arbitration_cases","arbitration_cases_order_id_fkey","f"],["arbitration_cases","arbitration_cases_order_id_key","u"],["arbitration_cases","arbitration_cases_pkey","p"],["arbitration_decisions","arbitration_decisions_case_id_fkey","f"],["arbitration_decisions","arbitration_decisions_case_id_round_key","u"],["arbitration_decisions","arbitration_decisions_pkey","p"],["arbitration_evidence","arbitration_evidence_author_fkey","f"],["arbitration_evidence","arbitration_evidence_case_id_fkey","f"],["arbitration_evidence","arbitration_evidence_pkey","p"],["arbitration_policies","arbitration_policies_pkey","p"],["arbitration_votes","arbitration_votes_arbitrator_fkey","f"],["arbitration_votes","arbitration_votes_case_id_fkey","f"],["arbitration_votes","arbitration_votes_pkey","p"],["arbitrator_conflicts","arbitrator_conflicts_arbitrator_fkey","f"],["arbitrator_conflicts","arbitrator_conflicts_party_fkey","f"],["arbitrator_conflicts","arbitrator_conflicts_pkey","p"],["arbitrator_roles","arbitrator_roles_pkey","p"],["arbitrator_roles","arbitrator_roles_subject_fkey","f"],["audit","audit_digest_key","u"],["audit","audit_pkey","p"],["batches","batches_pkey","p"],["bounty_challenges","bounty_challenges_claimant_fkey","f"],["bounty_challenges","bounty_challenges_listing_id_fkey","f"],["bounty_challenges","bounty_challenges_pkey","p"],["bounty_challenges","bounty_challenges_verifier_version_check","c"],["bounty_claims","bounty_claims_challenge_id_fkey","f"],["bounty_claims","bounty_claims_challenge_id_key","u"],["bounty_claims","bounty_claims_claimant_fkey","f"],["bounty_claims","bounty_claims_listing_id_fkey","f"],["bounty_claims","bounty_claims_pkey","p"],["bounty_claims","bounty_claims_reward_minor_check","c"],["bounty_claims","bounty_claims_status_check","c"],["bounty_claims","bounty_claims_transaction_id_fkey","f"],["bounty_claims","bounty_claims_transaction_id_key","u"],["bounty_listings","bounty_listings_budget_minor_check","c"],["bounty_listings","bounty_listings_claim_limit_per_subject_check","c"],["bounty_listings","bounty_listings_escrow_account_fkey","f"],["bounty_listings","bounty_listings_escrow_subject_key","u"],["bounty_listings","bounty_listings_listing_id_fkey","f"],["bounty_listings","bounty_listings_max_claims_check","c"],["bounty_listings","bounty_listings_pkey","p"],["bounty_listings","bounty_listings_publisher_fkey","f"],["bounty_listings","bounty_listings_reward_minor_check","c"],["bounty_listings","bounty_listings_state_check","c"],["bounty_listings","bounty_listings_verifier_version_check","c"],["certificates","certificates_pkey","p"],["chunks","chunks_pkey","p"],["claims","claims_id_fkey","f"],["claims","claims_pkey","p"],["collaboration_leases","collaboration_leases_pkey","p"],["collaboration_leases","collaboration_leases_status_check","c"],["collaboration_leases","collaboration_leases_target_fkey","f"],["credentials","credentials_pkey","p"],["csrs","csrs_pkey","p"],["custodial_upgrades","custodial_upgrades_pkey","p"],["custodial_upgrades","custodial_upgrades_status_check","c"],["custodial_vault","custodial_vault_lifecycle_check","c"],["custodial_vault","custodial_vault_pkey","p"],["delivery_email_challenges","delivery_email_challenges_order_id_fkey","f"],["delivery_email_challenges","delivery_email_challenges_pkey","p"],["delivery_email_challenges","delivery_email_challenges_subject_fkey","f"],["delivery_email_endpoints","delivery_email_endpoints_pkey","p"],["delivery_email_endpoints","delivery_email_endpoints_subject_fkey","f"],["delivery_envelopes","delivery_envelopes_order_id_fkey","f"],["delivery_envelopes","delivery_envelopes_pkey","p"],["dm_archives","dm_archives_pair_fkey","f"],["dm_archives","dm_archives_pkey","p"],["dm_blocks","dm_blocks_pkey","p"],["dm_conversations","dm_conversations_check","c"],["dm_conversations","dm_conversations_conversation_kind_check","c"],["dm_conversations","dm_conversations_pkey","p"],["dm_conversations","dm_conversations_resource_id_fkey","f"],["dm_conversations","dm_conversations_resource_id_key","u"],["dm_conversations","dm_conversations_state_check","c"],["email_challenges","email_challenges_pkey","p"],["emails","emails_pkey","p"],["encryption_subkeys","encryption_subkeys_pkey","p"],["encryption_subkeys","encryption_subkeys_recipient_key","u"],["events","events_id_key","u"],["events","events_pkey","p"],["handoffs","handoffs_pkey","p"],["handoffs","handoffs_status_check","c"],["identities","identities_pkey","p"],["identity_keys","identity_keys_pkey","p"],["jobs","jobs_dedupe_key","u"],["jobs","jobs_pkey","p"],["ledger_accounts","ledger_accounts_check","c"],["ledger_accounts","ledger_accounts_kind_check","c"],["ledger_accounts","ledger_accounts_kind_source_id_key","u"],["ledger_accounts","ledger_accounts_pkey","p"],["ledger_accounts","ledger_accounts_subject_id_fkey","f"],["legacy_directive_versions","legacy_directive_versions_pkey","p"],["legacy_directive_versions","legacy_directive_versions_revision_id_fkey","f"],["legacy_directives","legacy_directives_pkey","p"],["legacy_directives","legacy_directives_resource_id_fkey","f"],["legacy_directives","legacy_directives_resource_id_key","u"],["legacy_states","legacy_states_pkey","p"],["legacy_states","legacy_states_state_check","c"],["link_invitations","link_invitations_owner_name_key","u"],["link_invitations","link_invitations_pkey","p"],["login_bindings","login_bindings_custodial_check","c"],["login_bindings","login_bindings_pkey","p"],["login_bindings","login_bindings_provider_external_subject_hash_key","u"],["login_bindings","login_bindings_source_credential_id_fkey","f"],["login_bindings","login_bindings_subject_fkey","f"],["memberships","memberships_pkey","p"],["messages","messages_pkey","p"],["money_accounts","money_accounts_currency_id_check","c"],["money_accounts","money_accounts_ledger_account_fkey","f"],["money_accounts","money_accounts_pkey","p"],["money_bank_roles","money_bank_roles_granted_by_check","c"],["money_bank_roles","money_bank_roles_pkey","p"],["money_bank_roles","money_bank_roles_status_check","c"],["money_bank_roles","money_bank_roles_subject_id_fkey","f"],["money_ledger","money_ledger_actor_request_id_entry_key_key","u"],["money_ledger","money_ledger_amount_minor_check","c"],["money_ledger","money_ledger_check","c"],["money_ledger","money_ledger_credit_ledger_account_fkey","f"],["money_ledger","money_ledger_currency_id_check","c"],["money_ledger","money_ledger_debit_ledger_account_fkey","f"],["money_ledger","money_ledger_id_key","u"],["money_ledger","money_ledger_kind_check","c"],["money_ledger","money_ledger_pkey","p"],["money_ledger","money_ledger_policy_version_check","c"],["money_purchases","money_purchases_entitlement_id_fkey","f"],["money_purchases","money_purchases_escrow_account_fkey","f"],["money_purchases","money_purchases_escrow_account_key","u"],["money_purchases","money_purchases_final_transaction_id_fkey","f"],["money_purchases","money_purchases_funding_transaction_id_fkey","f"],["money_purchases","money_purchases_pkey","p"],["money_purchases","money_purchases_quantity_check","c"],["money_purchases","money_purchases_state_check","c"],["money_purchases","money_purchases_subject_id_fkey","f"],["money_purchases","money_purchases_subject_id_request_id_key","u"],["money_purchases","money_purchases_total_minor_check","c"],["money_visibility","money_visibility_pkey","p"],["money_visibility","money_visibility_subject_id_fkey","f"],["money_visibility","money_visibility_visibility_check","c"],["oauth_states","oauth_states_pkey","p"],["order_contracts","order_contracts_order_id_fkey","f"],["order_contracts","order_contracts_pkey","p"],["order_deadlines","order_deadlines_order_id_fkey","f"],["order_deadlines","order_deadlines_pkey","p"],["order_escrow_decisions","order_escrow_decisions_id_key","u"],["order_escrow_decisions","order_escrow_decisions_order_id_fkey","f"],["order_escrow_decisions","order_escrow_decisions_pkey","p"],["order_escrow_decisions","order_escrow_decisions_transaction_id_fkey","f"],["order_escrow_decisions","order_escrow_decisions_transaction_id_key","u"],["order_settlements","order_settlements_decision_id_key","u"],["order_settlements","order_settlements_order_id_fkey","f"],["order_settlements","order_settlements_pkey","p"],["order_transitions","order_transitions_order_id_fkey","f"],["order_transitions","order_transitions_pkey","p"],["personal_revision_proofs","personal_revision_proofs_pkey","p"],["personal_revision_proofs","personal_revision_proofs_revision_id_fkey","f"],["post_view_days","post_view_days_pkey","p"],["post_view_days","post_view_days_resource_id_fkey","f"],["presence","presence_pkey","p"],["projections","projections_pkey","p"],["reactions","reactions_pkey","p"],["recovery_envelopes","recovery_envelopes_owner_ciphertext_resource_ciphertext_rev_key","u"],["recovery_envelopes","recovery_envelopes_pkey","p"],["recovery_policies","recovery_policies_pkey","p"],["relations","relations_revision_id_fkey","f"],["resource_entitlements","resource_entitlements_offer_id_fkey","f"],["resource_entitlements","resource_entitlements_pkey","p"],["resource_entitlements","resource_entitlements_quantity_check","c"],["resource_entitlements","resource_entitlements_redeem_transaction_id_fkey","f"],["resource_entitlements","resource_entitlements_redeem_transaction_id_key","u"],["resource_entitlements","resource_entitlements_subject_id_fkey","f"],["resource_entitlements","resource_entitlements_subject_id_purchase_request_id_key","u"],["resource_path_aliases","resource_path_aliases_parent_id_fkey","f"],["resource_path_aliases","resource_path_aliases_pkey","p"],["resource_path_aliases","resource_path_aliases_resource_id_fkey","f"],["resource_tags","resource_tags_pkey","p"],["resource_tags","resource_tags_resource_id_fkey","f"],["resources","resources_parent_fkey","f"],["resources","resources_parent_name_key","u"],["resources","resources_pkey","p"],["results","results_pkey","p"],["revisions","revisions_pkey","p"],["revisions","revisions_resource_id_fkey","f"],["schema_version","schema_version_pkey","p"],["server_offer_resources","server_offer_resources_listing_id_fkey","f"],["server_offer_resources","server_offer_resources_listing_id_key","u"],["server_offer_resources","server_offer_resources_offer_id_fkey","f"],["server_offer_resources","server_offer_resources_pkey","p"],["server_offers","server_offers_check","c"],["server_offers","server_offers_duration_seconds_check","c"],["server_offers","server_offers_min_quantity_check","c"],["server_offers","server_offers_pkey","p"],["server_offers","server_offers_price_minor_check","c"],["settings","settings_pkey","p"],["share_grants","share_grants_pkey","p"],["share_grants","share_grants_resource_id_fkey","f"],["share_grants_v2","share_grants_v2_allow_reshare_check","c"],["share_grants_v2","share_grants_v2_grantee_kind_check","c"],["share_grants_v2","share_grants_v2_parent_id_fkey","f"],["share_grants_v2","share_grants_v2_pkey","p"],["share_grants_v2","share_grants_v2_resource_id_fkey","f"],["share_links","share_links_pkey","p"],["share_links","share_links_resource_id_fkey","f"],["share_links","share_links_verifier_key","u"],["store_deliveries","store_deliveries_channel_check","c"],["store_deliveries","store_deliveries_order_id_fkey","f"],["store_deliveries","store_deliveries_order_id_key","u"],["store_deliveries","store_deliveries_pkey","p"],["store_deliveries","store_deliveries_recipient_subject_fkey","f"],["store_deliveries","store_deliveries_state_check","c"],["store_order_events","store_order_events_order_id_fkey","f"],["store_order_events","store_order_events_pkey","p"],["store_orders","store_orders_buyer_fkey","f"],["store_orders","store_orders_currency_id_check","c"],["store_orders","store_orders_escrow_account_fkey","f"],["store_orders","store_orders_escrow_subject_key","u"],["store_orders","store_orders_listing_id_fkey","f"],["store_orders","store_orders_payment_transaction_id_fkey","f"],["store_orders","store_orders_payment_transaction_id_key","u"],["store_orders","store_orders_pkey","p"],["store_orders","store_orders_quantity_check","c"],["store_orders","store_orders_seller_fkey","f"],["store_orders","store_orders_state_check","c"],["store_orders","store_orders_total_price_minor_check","c"],["store_orders","store_orders_unit_price_minor_check","c"],["store_packages","store_packages_pkey","p"],["store_packages","store_packages_revision_key","u"],["sync_checkpoints","sync_checkpoints_pkey","p"],["system_sources","system_sources_pkey","p"],["system_sources","system_sources_resource_id_fkey","f"],["system_sources","system_sources_revision_id_fkey","f"],["system_sources","system_sources_rule_id_key","u"],["system_sources","system_sources_source_path_key","u"],["token_deliveries","token_deliveries_pkey","p"],["topic_bans","topic_bans_pkey","p"],["topic_bans","topic_bans_status_check","c"],["topic_bans","topic_bans_topic_fkey","f"],["topic_event_projection","topic_event_projection_pkey","p"],["topic_event_projection","topic_event_projection_seq_fkey","f"],["topic_memberships","topic_memberships_pkey","p"],["topic_memberships","topic_memberships_role_check","c"],["topic_memberships","topic_memberships_status_check","c"],["topic_memberships","topic_memberships_topic_fkey","f"],["topic_settings","topic_settings_membership_policy_check","c"],["topic_settings","topic_settings_pkey","p"],["topic_settings","topic_settings_topic_fkey","f"],["transfers","transfers_pkey","p"],["watches","watches_pkey","p"],["webhook_endpoints","webhook_endpoints_enabled_check","c"],["webhook_endpoints","webhook_endpoints_pkey","p"]],"functions":[["msg_audit_append_only",""],["msg_escrow_decision_append_only",""],["msg_money_ledger_append_only",""],["msg_register_subject_ledger_account",""]],"indexes":[["achievement_ceremonies","achievement_ceremonies_pkey"],["achievement_ceremonies","achievement_ceremonies_subject"],["achievement_grants","achievement_grants_lookup"],["achievement_grants","achievement_grants_pkey"],["achievement_grants","achievement_grants_subject_achievement_id_spec_version_key"],["achievement_pins","achievement_pins_pkey"],["achievement_pins","achievement_pins_subject_position_key"],["agent_drops","agent_drops_inbox"],["agent_drops","agent_drops_pkey"],["agent_drops","agent_drops_sender"],["agent_follows","agent_follows_pkey"],["agent_follows","agent_follows_target"],["arbitration_cases","arbitration_cases_order_id_key"],["arbitration_cases","arbitration_cases_pkey"],["arbitration_decisions","arbitration_decisions_case_id_round_key"],["arbitration_decisions","arbitration_decisions_pkey"],["arbitration_evidence","arbitration_evidence_pkey"],["arbitration_policies","arbitration_policies_pkey"],["arbitration_votes","arbitration_votes_pkey"],["arbitrator_conflicts","arbitrator_conflicts_pkey"],["arbitrator_roles","arbitrator_roles_pkey"],["audit","audit_digest_key"],["audit","audit_pkey"],["batches","batches_pkey"],["bounty_challenges","bounty_challenges_claimant"],["bounty_challenges","bounty_challenges_pkey"],["bounty_claims","bounty_claims_challenge_id_key"],["bounty_claims","bounty_claims_claimant"],["bounty_claims","bounty_claims_pkey"],["bounty_claims","bounty_claims_transaction_id_key"],["bounty_listings","bounty_listings_escrow_subject_key"],["bounty_listings","bounty_listings_pkey"],["bounty_listings","bounty_listings_publisher"],["certificates","certificates_pkey"],["chunks","chunks_pkey"],["claims","claims_pkey"],["claims","claims_subject"],["collaboration_leases","collaboration_leases_holder"],["collaboration_leases","collaboration_leases_pkey"],["credentials","credentials_pkey"],["credentials","credentials_subject"],["csrs","csrs_pkey"],["custodial_upgrades","custodial_upgrades_pkey"],["custodial_upgrades","custodial_upgrades_subject"],["custodial_vault","custodial_vault_pkey"],["delivery_email_challenges","delivery_email_challenges_pkey"],["delivery_email_endpoints","delivery_email_endpoints_pkey"],["delivery_envelopes","delivery_envelopes_pkey"],["dm_archives","dm_archives_pkey"],["dm_blocks","dm_blocks_pkey"],["dm_conversations","dm_conversations_participants"],["dm_conversations","dm_conversations_pkey"],["dm_conversations","dm_conversations_resource_id_key"],["email_challenges","email_challenges_pkey"],["emails","emails_pkey"],["encryption_subkeys","encryption_subkeys_pkey"],["encryption_subkeys","encryption_subkeys_primary"],["encryption_subkeys","encryption_subkeys_recipient_key"],["encryption_subkeys","encryption_subkeys_subject"],["events","events_id_key"],["events","events_pkey"],["handoffs","handoffs_from"],["handoffs","handoffs_pkey"],["handoffs","handoffs_to"],["identities","identities_pkey"],["identity_keys","identity_keys_pkey"],["identity_keys","identity_keys_primary"],["identity_keys","identity_keys_subject"],["jobs","jobs_dedupe_key"],["jobs","jobs_pkey"],["ledger_accounts","ledger_accounts_kind_source_id_key"],["ledger_accounts","ledger_accounts_pkey"],["legacy_directive_versions","legacy_directive_versions_pkey"],["legacy_directives","legacy_directives_pkey"],["legacy_directives","legacy_directives_resource_id_key"],["legacy_states","legacy_states_pkey"],["link_invitations","link_invitations_owner"],["link_invitations","link_invitations_owner_name_key"],["link_invitations","link_invitations_pkey"],["login_bindings","login_bindings_pkey"],["login_bindings","login_bindings_provider_external_subject_hash_key"],["login_bindings","login_bindings_subject"],["memberships","memberships_pkey"],["messages","messages_pkey"],["money_accounts","money_accounts_pkey"],["money_bank_roles","money_bank_roles_pkey"],["money_ledger","money_ledger_actor_request_id_entry_key_key"],["money_ledger","money_ledger_credit"],["money_ledger","money_ledger_debit"],["money_ledger","money_ledger_id_key"],["money_ledger","money_ledger_pkey"],["money_ledger","money_ledger_request_leg"],["money_purchases","money_purchases_escrow_account_key"],["money_purchases","money_purchases_pkey"],["money_purchases","money_purchases_subject_id_request_id_key"],["money_visibility","money_visibility_pkey"],["oauth_states","oauth_states_expiry"],["oauth_states","oauth_states_pkey"],["order_contracts","order_contracts_pkey"],["order_deadlines","order_deadlines_due"],["order_deadlines","order_deadlines_pkey"],["order_escrow_decisions","order_escrow_decisions_id_key"],["order_escrow_decisions","order_escrow_decisions_pkey"],["order_escrow_decisions","order_escrow_decisions_transaction_id_key"],["order_settlements","order_settlements_decision_id_key"],["order_settlements","order_settlements_pkey"],["order_transitions","order_transitions_pkey"],["personal_revision_proofs","personal_revision_proofs_pkey"],["post_view_days","post_view_days_day"],["post_view_days","post_view_days_pkey"],["presence","presence_pkey"],["projections","projections_pkey"],["reactions","reactions_pkey"],["recovery_envelopes","recovery_envelopes_owner"],["recovery_envelopes","recovery_envelopes_owner_ciphertext_resource_ciphertext_rev_key"],["recovery_envelopes","recovery_envelopes_pkey"],["recovery_policies","recovery_policies_pkey"],["relations","relations_target"],["resource_entitlements","resource_entitlements_pkey"],["resource_entitlements","resource_entitlements_redeem_transaction_id_key"],["resource_entitlements","resource_entitlements_subject_id_purchase_request_id_key"],["resource_path_aliases","resource_path_aliases_pkey"],["resource_tags","resource_tags_pkey"],["resource_tags","resource_tags_resource"],["resources","one_root"],["resources","resources_owner"],["resources","resources_parent"],["resources","resources_parent_name_key"],["resources","resources_pkey"],["resources","resources_time"],["resources","resources_type_state"],["results","results_pkey"],["revisions","revisions_pkey"],["revisions","revisions_resource"],["schema_version","schema_version_pkey"],["server_offer_resources","server_offer_resources_listing_id_key"],["server_offer_resources","server_offer_resources_pkey"],["server_offers","server_offers_pkey"],["settings","settings_pkey"],["share_grants","share_grants_active"],["share_grants","share_grants_grantee"],["share_grants","share_grants_pkey"],["share_grants_v2","share_grants_v2_parent"],["share_grants_v2","share_grants_v2_pkey"],["share_grants_v2","share_grants_v2_resource"],["share_links","share_links_pkey"],["share_links","share_links_resource"],["share_links","share_links_verifier_key"],["store_deliveries","store_deliveries_order_id_key"],["store_deliveries","store_deliveries_pkey"],["store_order_events","store_order_events_order"],["store_order_events","store_order_events_pkey"],["store_orders","store_orders_buyer"],["store_orders","store_orders_escrow_subject_key"],["store_orders","store_orders_listing"],["store_orders","store_orders_payment_transaction_id_key"],["store_orders","store_orders_pkey"],["store_orders","store_orders_seller"],["store_packages","store_packages_pkey"],["store_packages","store_packages_revision_key"],["sync_checkpoints","sync_checkpoints_pkey"],["sync_checkpoints","sync_checkpoints_subject"],["system_sources","system_sources_pkey"],["system_sources","system_sources_rule_id_key"],["system_sources","system_sources_source_path_key"],["token_deliveries","token_deliveries_pkey"],["token_deliveries","token_deliveries_subject"],["topic_bans","topic_bans_pkey"],["topic_event_projection","topic_event_projection_pkey"],["topic_event_projection","topic_event_projection_topic"],["topic_memberships","topic_memberships_pkey"],["topic_memberships","topic_memberships_subject"],["topic_settings","topic_settings_pkey"],["transfers","transfers_pkey"],["watches","watches_pkey"],["webhook_endpoints","webhook_endpoints_pkey"]],"sequences":[["audit_seq_seq","bigint"],["events_seq_seq","bigint"],["money_ledger_seq_seq","bigint"],["store_order_events_seq_seq","bigint"]],"tables":{"achievement_ceremonies":[["id","text",true,""],["subject","text",true,""],["state","text",true,""],["body","text",true,""]],"achievement_grants":[["id","text",true,""],["subject","text",true,""],["achievement_id","text",true,""],["spec_version","integer",true,""],["body","text",true,""]],"achievement_pins":[["subject","text",true,""],["grant_id","text",true,""],["position","integer",true,""]],"agent_drops":[["id","text",true,""],["sender","text",true,""],["recipient","text",true,""],["kind","text",true,""],["created_at","text",true,""],["opens_at","text",true,""],["expires_at","text",true,""],["claimed_at","text",false,""],["cancelled_at","text",false,""],["nonce","text",true,""],["ciphertext","text",true,""]],"agent_follows":[["follower","text",true,""],["target","text",true,""],["created_at","text",true,""]],"arbitration_cases":[["id","text",true,""],["order_id","text",true,""],["round","integer",true,""],["state","text",true,""],["deadline","text",true,""],["body","text",true,""]],"arbitration_decisions":[["id","text",true,""],["case_id","text",true,""],["round","integer",true,""],["body","text",true,""],["revoked","boolean",true,""]],"arbitration_evidence":[["id","text",true,""],["case_id","text",true,""],["author","text",true,""],["visibility","text",true,""],["body","text",true,""]],"arbitration_policies":[["id","text",true,""],["body","text",true,""],["digest","text",true,""]],"arbitration_votes":[["case_id","text",true,""],["round","integer",true,""],["arbitrator","text",true,""],["body","text",true,""]],"arbitrator_conflicts":[["arbitrator","text",true,""],["party","text",true,""]],"arbitrator_roles":[["subject","text",true,""],["epoch","text",true,""],["active","boolean",true,""]],"audit":[["seq","bigint",true,"d"],["digest","text",true,""],["previous","text",false,""],["body","text",true,""]],"batches":[["subject","text",true,""],["request_id","text",true,""],["digest","text",true,""]],"bounty_challenges":[["id","text",true,""],["listing_id","text",true,""],["claimant","text",true,""],["key_id","text",true,""],["nonce","text",true,""],["issued_at","text",true,""],["expires_at","text",true,""],["verifier_version","integer",true,""],["payload","text",true,""],["consumed_at","text",false,""]],"bounty_claims":[["id","text",true,""],["listing_id","text",true,""],["claimant","text",true,""],["challenge_id","text",true,""],["proof_digest","text",true,""],["status","text",true,""],["reward_minor","bigint",true,""],["transaction_id","text",true,""],["claimed_at","text",true,""]],"bounty_listings":[["listing_id","text",true,""],["publisher","text",true,""],["escrow_subject","text",true,""],["reward_minor","bigint",true,""],["budget_minor","bigint",true,""],["max_claims","integer",true,""],["claim_limit_per_subject","integer",true,""],["verifier_id","text",true,""],["verifier_version","integer",true,""],["eligibility","text",true,""],["state","text",true,""],["pause_reason","text",false,""],["expires_at","text",false,""],["created_at","text",true,""]],"certificates":[["id","text",true,""],["subject","text",true,""],["parent","text",false,""],["revoked","integer",true,""],["body","text",true,""]],"chunks":[["transfer_id","text",true,""],["offset","bigint",true,""],["length","bigint",true,""],["body","text",true,""]],"claims":[["id","text",true,""],["subject","text",true,""],["issued_at","text",true,""],["expires_at","text",false,""],["body","text",true,""]],"collaboration_leases":[["id","text",true,""],["holder","text",true,""],["target","text",true,""],["status","text",true,""],["generation","integer",true,""],["expires_at","text",true,""],["body","text",true,""]],"credentials":[["id","text",true,""],["subject","text",true,""],["body","text",true,""]],"csrs":[["id","text",true,""],["generation","integer",true,""],["state","text",true,""],["body","text",true,""],["state_body","text",true,""]],"custodial_upgrades":[["id","text",true,""],["subject","text",true,""],["credential_id","text",true,""],["status","text",true,""],["expires_at","text",true,""],["challenge","text",true,""],["ephemeral_nonce","text",false,""],["ephemeral_ciphertext","text",false,""],["body","text",true,""]],"custodial_vault":[["subject","text",true,""],["signing_key_id","text",true,""],["encryption_key_id","text",true,""],["signing_nonce","text",false,""],["signing_ciphertext","text",false,""],["age_nonce","text",false,""],["age_ciphertext","text",false,""],["status","text",true,""],["created_at","text",true,""],["destroyed_at","text",false,""]],"delivery_email_challenges":[["id","text",true,""],["order_id","text",true,""],["subject","text",true,""],["address","text",true,""],["verifier","text",true,""],["expires_at","text",true,""],["consumed","boolean",true,""]],"delivery_email_endpoints":[["id","text",true,""],["subject","text",true,""],["address","text",true,""],["verified_at","text",true,""],["active","boolean",true,""]],"delivery_envelopes":[["order_id","text",true,""],["body","text",true,""]],"dm_archives":[["subject","text",true,""],["pair","text",true,""]],"dm_blocks":[["blocker","text",true,""],["blocked","text",true,""]],"dm_conversations":[["pair","text",true,""],["resource_id","text",true,""],["participant_a","text",true,""],["participant_b","text",true,""],["initiator","text",true,""],["conversation_kind","text",true,""],["state","text",true,""],["created_at","text",true,""],["updated_at","text",true,""]],"email_challenges":[["subject","text",true,""],["digest","text",true,""],["expires","text",true,""]],"emails":[["subject","text",true,""],["generation","integer",true,""],["body","text",true,""]],"encryption_subkeys":[["key_id","text",true,""],["subject","text",true,""],["recipient","text",true,""],["public_key","text",true,""],["created_at","text",true,""],["retired_at","text",false,""],["is_primary","integer",true,""]],"events":[["seq","bigint",true,"d"],["id","text",true,""],["body","text",true,""]],"handoffs":[["id","text",true,""],["from_subject","text",true,""],["to_subject","text",true,""],["status","text",true,""],["generation","integer",true,""],["body","text",true,""]],"identities":[["id","text",true,""],["kind","text",true,""],["generation","integer",true,""],["body","text",true,""]],"identity_keys":[["key_id","text",true,""],["subject","text",true,""],["public_key","text",true,""],["created_at","text",true,""],["retired_at","text",false,""],["is_primary","integer",true,""]],"jobs":[["id","text",true,""],["dedupe","text",true,""],["kind","text",true,""],["state","text",true,""],["next_at","text",true,""],["body","text",true,""]],"ledger_accounts":[["id","text",true,""],["kind","text",true,""],["subject_id","text",false,""],["source_id","text",false,""]],"legacy_directive_versions":[["revision_id","text",true,""],["resource_id","text",true,""],["subject","text",true,""],["body","text",true,""]],"legacy_directives":[["subject","text",true,""],["resource_id","text",true,""],["created_at","text",true,""]],"legacy_states":[["subject","text",true,""],["state","text",true,""]],"link_invitations":[["invite_id","text",true,""],["owner","text",true,""],["name","text",true,""],["minutes","integer",true,""],["created_at","text",true,""],["expires_at","text",true,""],["claim","text",false,""],["claimed_at","text",false,""],["grant_box","text",false,""],["released_at","text",false,""],["revoked_at","text",false,""]],"login_bindings":[["id","text",true,""],["provider","text",true,""],["external_subject_hash","text",true,""],["subject","text",true,""],["source_credential_id","text",true,""],["auth_version","integer",true,""],["custodial","integer",true,""],["ceiling","text",true,""],["source_data","text",true,""],["provider_data","text",true,""],["generation","integer",true,""],["created_at","text",true,""],["updated_at","text",true,""],["revoked_at","text",false,""]],"memberships":[["org","text",true,""],["subject","text",true,""],["generation","integer",true,""],["body","text",true,""]],"messages":[["id","text",true,""],["sender","text",false,""],["recipient","text",false,""],["resource","text",false,""],["event_id","text",false,""],["body","text",true,""]],"money_accounts":[["subject_id","text",true,""],["currency_id","text",true,""]],"money_bank_roles":[["subject_id","text",true,""],["status","text",true,""],["granted_at","text",true,""],["granted_by","text",true,""],["revoked_at","text",false,""]],"money_ledger":[["seq","bigint",true,"d"],["id","text",true,""],["kind","text",true,""],["currency_id","text",true,""],["amount_minor","bigint",true,""],["debit_account","text",false,""],["credit_account","text",false,""],["actor","text",true,""],["request_id","text",true,""],["reference","text",false,""],["committed_at","text",true,""],["policy_version","integer",true,""],["policy_digest","text",true,""],["receipt","text",true,""],["entry_key","text",true,""]],"money_purchases":[["id","text",true,""],["subject_id","text",true,""],["request_id","text",true,""],["offer_snapshot","text",true,""],["quantity","bigint",true,""],["total_minor","bigint",true,""],["escrow_account","text",true,""],["state","text",true,""],["created_at","text",true,""],["expires_at","text",true,""],["funding_transaction_id","text",true,""],["final_transaction_id","text",false,""],["entitlement_id","text",false,""],["reason","text",false,""]],"money_visibility":[["subject_id","text",true,""],["visibility","text",true,""],["updated_at","text",true,""]],"oauth_states":[["id","text",true,""],["kind","text",true,""],["expires","text",true,""],["body","text",true,""]],"order_contracts":[["order_id","text",true,""],["body","text",true,""],["digest","text",true,""]],"order_deadlines":[["order_id","text",true,""],["expires_at","text",true,""]],"order_escrow_decisions":[["order_id","text",true,""],["id","text",true,""],["body","text",true,""],["signature","text",true,""],["source_proof","text",true,""],["transaction_id","text",true,""]],"order_settlements":[["order_id","text",true,""],["decision_id","text",false,""],["body","text",true,""]],"order_transitions":[["id","text",true,""],["order_id","text",true,""],["body","text",true,""]],"personal_revision_proofs":[["revision_id","text",true,""],["subject","text",true,""],["kind","text",true,""],["signature","text",true,""],["signed_envelope","text",true,""],["created_at","text",true,""]],"post_view_days":[["resource_id","text",true,""],["day","text",true,""],["visitor_digest","text",true,""]],"presence":[["subject","text",true,""],["expires_at","text",true,""],["body","text",true,""]],"projections":[["resource_id","text",true,""],["text","text",true,""]],"reactions":[["subject","text",true,""],["resource","text",true,""],["kind","text",true,""],["revision","text",true,""],["body","text",true,""]],"recovery_envelopes":[["id","text",true,""],["owner","text",true,""],["ciphertext_resource","text",true,""],["ciphertext_revision","text",true,""],["policy_version","integer",true,""],["encryption_key_id","text",true,""],["purpose","text",true,""],["created_at","text",true,""],["body","text",true,""]],"recovery_policies":[["subject","text",true,""],["version","integer",true,""],["encryption_key_id","text",true,""],["created_at","text",true,""],["body","text",true,""]],"relations":[["revision_id","text",true,""],["source_id","text",true,""],["type","text",true,""],["target_id","text",true,""],["target_revision","text",false,""],["body","text",true,""]],"resource_entitlements":[["id","text",true,""],["subject_id","text",true,""],["offer_id","text",true,""],["purchase_request_id","text",true,""],["quantity","bigint",true,""],["entitlement_kind","text",true,""],["granted_at","text",true,""],["expires_at","text",false,""],["redeem_transaction_id","text",false,""]],"resource_path_aliases":[["parent_id","text",true,""],["name","text",true,""],["resource_id","text",true,""]],"resource_tags":[["resource_id","text",true,""],["tag","text",true,""]],"resources":[["id","text",true,""],["type","text",true,""],["name","text",true,""],["parent","text",false,""],["owner","text",true,""],["grp","text",true,""],["mode","integer",true,""],["generation","integer",true,""],["revision","text",false,""],["state","text",true,""],["created_at","text",true,""],["modified_at","text",true,""],["body","text",true,""]],"results":[["subject","text",true,""],["request_id","text",true,""],["digest","text",true,""],["body","text",true,""]],"revisions":[["id","text",true,""],["resource_id","text",true,""],["created_at","text",true,""],["body","text",true,""]],"schema_version":[["version","integer",true,""]],"server_offer_resources":[["offer_id","text",true,""],["listing_id","text",true,""]],"server_offers":[["offer_id","text",true,""],["resource_kind","text",true,""],["unit","text",true,""],["price_minor","bigint",true,""],["min_quantity","bigint",true,""],["max_quantity","bigint",true,""],["entitlement_kind","text",true,""],["duration_seconds","bigint",false,""],["enabled","boolean",true,""],["price_revision","text",true,""]],"settings":[["key","text",true,""],["value","text",true,""]],"share_grants":[["id","text",true,""],["resource_id","text",true,""],["grantor","text",true,""],["grantee","text",true,""],["created_at","text",true,""],["expires_at","text",true,""],["revoked_at","text",false,""]],"share_grants_v2":[["id","text",true,""],["resource_id","text",true,""],["grantor","text",true,""],["grantee","text",true,""],["grantee_kind","text",true,""],["parent_id","text",false,""],["operations","text",true,""],["constraints","text",true,""],["allow_reshare","integer",true,""],["created_at","text",true,""],["expires_at","text",true,""],["revoked_at","text",false,""]],"share_links":[["id","text",true,""],["resource_id","text",true,""],["grantor","text",true,""],["credential_id","text",true,""],["verifier","text",true,""],["created_at","text",true,""],["expires_at","text",true,""],["revoked_at","text",false,""]],"store_deliveries":[["id","text",true,""],["order_id","text",true,""],["recipient_subject","text",true,""],["kind","text",true,""],["payload_refs","text",true,""],["manifest","text",true,""],["package_digest","text",false,""],["delivery_digest","text",true,""],["channel","text",true,""],["state","text",true,""],["prepared_at","text",true,""],["claimed_at","text",false,""],["receipt","text",false,""]],"store_order_events":[["seq","bigint",true,"d"],["order_id","text",true,""],["from_state","text",false,""],["to_state","text",true,""],["reason","text",true,""],["actor","text",true,""],["request_id","text",true,""],["occurred_at","text",true,""],["receipt_refs","text",true,""]],"store_orders":[["id","text",true,""],["buyer","text",true,""],["seller","text",true,""],["listing_id","text",true,""],["listing_revision","text",true,""],["package_id","text",false,""],["package_revision","text",false,""],["package_digest","text",false,""],["quantity","bigint",true,""],["unit_price_minor","bigint",true,""],["total_price_minor","bigint",true,""],["currency_id","text",true,""],["escrow_subject","text",true,""],["escrow_policy","text",true,""],["dispute_policy","text",true,""],["terms_digest","text",true,""],["delivery_target","text",true,""],["payment_intent_digest","text",true,""],["payment_transaction_id","text",false,""],["state","text",true,""],["created_at","text",true,""],["funded_at","text",false,""],["delivered_at","text",false,""],["settled_at","text",false,""],["receipt_refs","text",true,""],["delivery_mode","text",true,""],["deadline_at","text",false,""],["quote_digest","text",false,""]],"store_packages":[["id","text",true,""],["listing_id","text",true,""],["listing_revision","text",true,""],["seller","text",true,""],["revision","text",true,""],["kind","text",true,""],["manifest","text",true,""],["payload_refs","text",true,""],["digest","text",true,""],["total_size","bigint",true,""],["delivery_mode","text",true,""],["deposited_at","text",true,""]],"sync_checkpoints":[["id","text",true,""],["subject","text",true,""],["credential_id","text",true,""],["version","integer",true,""],["expires_at","text",true,""],["body","text",true,""]],"system_sources":[["resource_id","text",true,""],["source_path","text",true,""],["rule_id","text",true,""],["source_kind","text",true,""],["source_version","integer",true,""],["source_digest","text",true,""],["revision_id","text",true,""]],"token_deliveries":[["credential_id","text",true,""],["subject","text",true,""],["request_id","text",true,""],["request_digest","text",true,""],["recovery_verifier","text",true,""],["recovery_expires_at","text",true,""],["claimed_at","text",false,""],["consumed_at","text",false,""]],"topic_bans":[["topic","text",true,""],["subject","text",true,""],["actor","text",true,""],["created_at","text",true,""],["expires_at","text",false,""],["reason","text",false,""],["status","text",true,""]],"topic_event_projection":[["seq","bigint",true,""],["topic","text",true,""]],"topic_memberships":[["topic","text",true,""],["subject","text",true,""],["role","text",true,""],["status","text",true,""],["joined_at","text",false,""],["invited_by","text",false,""]],"topic_settings":[["topic","text",true,""],["membership_policy","text",true,""]],"transfers":[["id","text",true,""],["subject","text",true,""],["generation","integer",true,""],["body","text",true,""],["limits","text",true,""]],"watches":[["subject","text",true,""],["resource","text",true,""]],"webhook_endpoints":[["subject","text",true,""],["url","text",true,""],["nonce","text",true,""],["ciphertext","text",true,""],["enabled","integer",true,""],["generation","integer",true,""]]},"triggers":[["arbitration_policies","arbitration_policies_append_only"],["arbitration_votes","arbitration_votes_append_only"],["audit","audit_no_update"],["money_accounts","money_account_register_subject"],["money_ledger","money_ledger_no_update"],["order_contracts","order_contracts_append_only"],["order_escrow_decisions","escrow_decision_no_update"],["order_settlements","order_settlements_append_only"],["order_transitions","order_transitions_append_only"],["store_order_events","store_order_events_append_only"]]} +{"constraints":[["achievement_ceremonies","achievement_ceremonies_pkey","p"],["achievement_grants","achievement_grants_pkey","p"],["achievement_grants","achievement_grants_subject_achievement_id_spec_version_key","u"],["achievement_pins","achievement_pins_grant_id_fkey","f"],["achievement_pins","achievement_pins_pkey","p"],["achievement_pins","achievement_pins_position_check","c"],["achievement_pins","achievement_pins_subject_fkey","f"],["achievement_pins","achievement_pins_subject_position_key","u"],["agent_drops","agent_drops_kind_check","c"],["agent_drops","agent_drops_pkey","p"],["agent_drops","agent_drops_recipient_fkey","f"],["agent_drops","agent_drops_sender_fkey","f"],["agent_follows","agent_follows_check","c"],["agent_follows","agent_follows_follower_fkey","f"],["agent_follows","agent_follows_pkey","p"],["agent_follows","agent_follows_target_fkey","f"],["arbitration_cases","arbitration_cases_order_id_fkey","f"],["arbitration_cases","arbitration_cases_order_id_key","u"],["arbitration_cases","arbitration_cases_pkey","p"],["arbitration_decisions","arbitration_decisions_case_id_fkey","f"],["arbitration_decisions","arbitration_decisions_case_id_round_key","u"],["arbitration_decisions","arbitration_decisions_pkey","p"],["arbitration_evidence","arbitration_evidence_author_fkey","f"],["arbitration_evidence","arbitration_evidence_case_id_fkey","f"],["arbitration_evidence","arbitration_evidence_pkey","p"],["arbitration_policies","arbitration_policies_pkey","p"],["arbitration_votes","arbitration_votes_arbitrator_fkey","f"],["arbitration_votes","arbitration_votes_case_id_fkey","f"],["arbitration_votes","arbitration_votes_pkey","p"],["arbitrator_conflicts","arbitrator_conflicts_arbitrator_fkey","f"],["arbitrator_conflicts","arbitrator_conflicts_party_fkey","f"],["arbitrator_conflicts","arbitrator_conflicts_pkey","p"],["arbitrator_roles","arbitrator_roles_pkey","p"],["arbitrator_roles","arbitrator_roles_subject_fkey","f"],["audit","audit_digest_key","u"],["audit","audit_pkey","p"],["batches","batches_pkey","p"],["bounty_challenges","bounty_challenges_claimant_fkey","f"],["bounty_challenges","bounty_challenges_listing_id_fkey","f"],["bounty_challenges","bounty_challenges_pkey","p"],["bounty_challenges","bounty_challenges_verifier_version_check","c"],["bounty_claims","bounty_claims_challenge_id_fkey","f"],["bounty_claims","bounty_claims_challenge_id_key","u"],["bounty_claims","bounty_claims_claimant_fkey","f"],["bounty_claims","bounty_claims_listing_id_fkey","f"],["bounty_claims","bounty_claims_pkey","p"],["bounty_claims","bounty_claims_reward_minor_check","c"],["bounty_claims","bounty_claims_status_check","c"],["bounty_claims","bounty_claims_transaction_id_fkey","f"],["bounty_claims","bounty_claims_transaction_id_key","u"],["bounty_listings","bounty_listings_budget_minor_check","c"],["bounty_listings","bounty_listings_claim_limit_per_subject_check","c"],["bounty_listings","bounty_listings_escrow_account_fkey","f"],["bounty_listings","bounty_listings_escrow_subject_key","u"],["bounty_listings","bounty_listings_listing_id_fkey","f"],["bounty_listings","bounty_listings_max_claims_check","c"],["bounty_listings","bounty_listings_pkey","p"],["bounty_listings","bounty_listings_publisher_fkey","f"],["bounty_listings","bounty_listings_reward_minor_check","c"],["bounty_listings","bounty_listings_state_check","c"],["bounty_listings","bounty_listings_verifier_version_check","c"],["certificates","certificates_pkey","p"],["chunks","chunks_pkey","p"],["claims","claims_id_fkey","f"],["claims","claims_pkey","p"],["collaboration_leases","collaboration_leases_pkey","p"],["collaboration_leases","collaboration_leases_status_check","c"],["collaboration_leases","collaboration_leases_target_fkey","f"],["credential_states","credential_states_pkey","p"],["credentials","credentials_pkey","p"],["csrs","csrs_pkey","p"],["custodial_upgrades","custodial_upgrades_pkey","p"],["custodial_upgrades","custodial_upgrades_status_check","c"],["custodial_vault","custodial_vault_lifecycle_check","c"],["custodial_vault","custodial_vault_pkey","p"],["delivery_email_challenges","delivery_email_challenges_order_id_fkey","f"],["delivery_email_challenges","delivery_email_challenges_pkey","p"],["delivery_email_challenges","delivery_email_challenges_subject_fkey","f"],["delivery_email_endpoints","delivery_email_endpoints_pkey","p"],["delivery_email_endpoints","delivery_email_endpoints_subject_fkey","f"],["delivery_envelopes","delivery_envelopes_order_id_fkey","f"],["delivery_envelopes","delivery_envelopes_pkey","p"],["dm_archives","dm_archives_pair_fkey","f"],["dm_archives","dm_archives_pkey","p"],["dm_blocks","dm_blocks_pkey","p"],["dm_conversations","dm_conversations_check","c"],["dm_conversations","dm_conversations_conversation_kind_check","c"],["dm_conversations","dm_conversations_pkey","p"],["dm_conversations","dm_conversations_resource_id_fkey","f"],["dm_conversations","dm_conversations_resource_id_key","u"],["dm_conversations","dm_conversations_state_check","c"],["email_challenges","email_challenges_pkey","p"],["emails","emails_pkey","p"],["encryption_subkeys","encryption_subkeys_pkey","p"],["encryption_subkeys","encryption_subkeys_recipient_key","u"],["events","events_id_key","u"],["events","events_pkey","p"],["handoffs","handoffs_pkey","p"],["handoffs","handoffs_status_check","c"],["identities","identities_pkey","p"],["identity_keys","identity_keys_pkey","p"],["jobs","jobs_dedupe_key","u"],["jobs","jobs_pkey","p"],["ledger_accounts","ledger_accounts_check","c"],["ledger_accounts","ledger_accounts_kind_check","c"],["ledger_accounts","ledger_accounts_kind_source_id_key","u"],["ledger_accounts","ledger_accounts_pkey","p"],["ledger_accounts","ledger_accounts_subject_id_fkey","f"],["legacy_directive_versions","legacy_directive_versions_pkey","p"],["legacy_directive_versions","legacy_directive_versions_revision_id_fkey","f"],["legacy_directives","legacy_directives_pkey","p"],["legacy_directives","legacy_directives_resource_id_fkey","f"],["legacy_directives","legacy_directives_resource_id_key","u"],["legacy_states","legacy_states_pkey","p"],["legacy_states","legacy_states_state_check","c"],["link_invitations","link_invitations_owner_name_key","u"],["link_invitations","link_invitations_pkey","p"],["memberships","memberships_pkey","p"],["messages","messages_pkey","p"],["money_accounts","money_accounts_currency_id_check","c"],["money_accounts","money_accounts_ledger_account_fkey","f"],["money_accounts","money_accounts_pkey","p"],["money_bank_roles","money_bank_roles_granted_by_check","c"],["money_bank_roles","money_bank_roles_pkey","p"],["money_bank_roles","money_bank_roles_status_check","c"],["money_bank_roles","money_bank_roles_subject_id_fkey","f"],["money_ledger","money_ledger_actor_request_id_entry_key_key","u"],["money_ledger","money_ledger_amount_minor_check","c"],["money_ledger","money_ledger_check","c"],["money_ledger","money_ledger_credit_ledger_account_fkey","f"],["money_ledger","money_ledger_currency_id_check","c"],["money_ledger","money_ledger_debit_ledger_account_fkey","f"],["money_ledger","money_ledger_id_key","u"],["money_ledger","money_ledger_kind_check","c"],["money_ledger","money_ledger_pkey","p"],["money_ledger","money_ledger_policy_version_check","c"],["money_purchases","money_purchases_entitlement_id_fkey","f"],["money_purchases","money_purchases_escrow_account_fkey","f"],["money_purchases","money_purchases_escrow_account_key","u"],["money_purchases","money_purchases_final_transaction_id_fkey","f"],["money_purchases","money_purchases_funding_transaction_id_fkey","f"],["money_purchases","money_purchases_pkey","p"],["money_purchases","money_purchases_quantity_check","c"],["money_purchases","money_purchases_state_check","c"],["money_purchases","money_purchases_subject_id_fkey","f"],["money_purchases","money_purchases_subject_id_request_id_key","u"],["money_purchases","money_purchases_total_minor_check","c"],["money_visibility","money_visibility_pkey","p"],["money_visibility","money_visibility_subject_id_fkey","f"],["money_visibility","money_visibility_visibility_check","c"],["order_contracts","order_contracts_order_id_fkey","f"],["order_contracts","order_contracts_pkey","p"],["order_deadlines","order_deadlines_order_id_fkey","f"],["order_deadlines","order_deadlines_pkey","p"],["order_escrow_decisions","order_escrow_decisions_id_key","u"],["order_escrow_decisions","order_escrow_decisions_order_id_fkey","f"],["order_escrow_decisions","order_escrow_decisions_pkey","p"],["order_escrow_decisions","order_escrow_decisions_transaction_id_fkey","f"],["order_escrow_decisions","order_escrow_decisions_transaction_id_key","u"],["order_settlements","order_settlements_decision_id_key","u"],["order_settlements","order_settlements_order_id_fkey","f"],["order_settlements","order_settlements_pkey","p"],["order_transitions","order_transitions_order_id_fkey","f"],["order_transitions","order_transitions_pkey","p"],["personal_revision_proofs","personal_revision_proofs_pkey","p"],["personal_revision_proofs","personal_revision_proofs_revision_id_fkey","f"],["post_view_days","post_view_days_pkey","p"],["post_view_days","post_view_days_resource_id_fkey","f"],["presence","presence_pkey","p"],["projections","projections_pkey","p"],["reactions","reactions_pkey","p"],["recovery_envelopes","recovery_envelopes_owner_ciphertext_resource_ciphertext_rev_key","u"],["recovery_envelopes","recovery_envelopes_pkey","p"],["recovery_policies","recovery_policies_pkey","p"],["relations","relations_revision_id_fkey","f"],["resource_entitlements","resource_entitlements_offer_id_fkey","f"],["resource_entitlements","resource_entitlements_pkey","p"],["resource_entitlements","resource_entitlements_quantity_check","c"],["resource_entitlements","resource_entitlements_redeem_transaction_id_fkey","f"],["resource_entitlements","resource_entitlements_redeem_transaction_id_key","u"],["resource_entitlements","resource_entitlements_subject_id_fkey","f"],["resource_entitlements","resource_entitlements_subject_id_purchase_request_id_key","u"],["resource_path_aliases","resource_path_aliases_parent_id_fkey","f"],["resource_path_aliases","resource_path_aliases_pkey","p"],["resource_path_aliases","resource_path_aliases_resource_id_fkey","f"],["resource_tags","resource_tags_pkey","p"],["resource_tags","resource_tags_resource_id_fkey","f"],["resources","resources_parent_fkey","f"],["resources","resources_parent_name_key","u"],["resources","resources_pkey","p"],["results","results_pkey","p"],["revisions","revisions_pkey","p"],["revisions","revisions_resource_id_fkey","f"],["schema_version","schema_version_pkey","p"],["server_offer_resources","server_offer_resources_listing_id_fkey","f"],["server_offer_resources","server_offer_resources_listing_id_key","u"],["server_offer_resources","server_offer_resources_offer_id_fkey","f"],["server_offer_resources","server_offer_resources_pkey","p"],["server_offers","server_offers_check","c"],["server_offers","server_offers_duration_seconds_check","c"],["server_offers","server_offers_min_quantity_check","c"],["server_offers","server_offers_pkey","p"],["server_offers","server_offers_price_minor_check","c"],["settings","settings_pkey","p"],["share_grants","share_grants_pkey","p"],["share_grants","share_grants_resource_id_fkey","f"],["share_grants_v2","share_grants_v2_allow_reshare_check","c"],["share_grants_v2","share_grants_v2_grantee_kind_check","c"],["share_grants_v2","share_grants_v2_parent_id_fkey","f"],["share_grants_v2","share_grants_v2_pkey","p"],["share_grants_v2","share_grants_v2_resource_id_fkey","f"],["share_links","share_links_pkey","p"],["share_links","share_links_resource_id_fkey","f"],["share_links","share_links_verifier_key","u"],["store_deliveries","store_deliveries_channel_check","c"],["store_deliveries","store_deliveries_order_id_fkey","f"],["store_deliveries","store_deliveries_order_id_key","u"],["store_deliveries","store_deliveries_pkey","p"],["store_deliveries","store_deliveries_recipient_subject_fkey","f"],["store_deliveries","store_deliveries_state_check","c"],["store_order_events","store_order_events_order_id_fkey","f"],["store_order_events","store_order_events_pkey","p"],["store_orders","store_orders_buyer_fkey","f"],["store_orders","store_orders_currency_id_check","c"],["store_orders","store_orders_escrow_account_fkey","f"],["store_orders","store_orders_escrow_subject_key","u"],["store_orders","store_orders_listing_id_fkey","f"],["store_orders","store_orders_payment_transaction_id_fkey","f"],["store_orders","store_orders_payment_transaction_id_key","u"],["store_orders","store_orders_pkey","p"],["store_orders","store_orders_quantity_check","c"],["store_orders","store_orders_seller_fkey","f"],["store_orders","store_orders_state_check","c"],["store_orders","store_orders_total_price_minor_check","c"],["store_orders","store_orders_unit_price_minor_check","c"],["store_packages","store_packages_pkey","p"],["store_packages","store_packages_revision_key","u"],["sync_checkpoints","sync_checkpoints_pkey","p"],["system_sources","system_sources_pkey","p"],["system_sources","system_sources_resource_id_fkey","f"],["system_sources","system_sources_revision_id_fkey","f"],["system_sources","system_sources_rule_id_key","u"],["system_sources","system_sources_source_path_key","u"],["token_deliveries","token_deliveries_pkey","p"],["topic_bans","topic_bans_pkey","p"],["topic_bans","topic_bans_status_check","c"],["topic_bans","topic_bans_topic_fkey","f"],["topic_event_projection","topic_event_projection_pkey","p"],["topic_event_projection","topic_event_projection_seq_fkey","f"],["topic_memberships","topic_memberships_pkey","p"],["topic_memberships","topic_memberships_role_check","c"],["topic_memberships","topic_memberships_status_check","c"],["topic_memberships","topic_memberships_topic_fkey","f"],["topic_settings","topic_settings_membership_policy_check","c"],["topic_settings","topic_settings_pkey","p"],["topic_settings","topic_settings_topic_fkey","f"],["transfers","transfers_pkey","p"],["watches","watches_pkey","p"],["webhook_endpoints","webhook_endpoints_enabled_check","c"],["webhook_endpoints","webhook_endpoints_pkey","p"]],"functions":[["msg_audit_append_only",""],["msg_escrow_decision_append_only",""],["msg_money_ledger_append_only",""],["msg_register_subject_ledger_account",""]],"indexes":[["achievement_ceremonies","achievement_ceremonies_pkey"],["achievement_ceremonies","achievement_ceremonies_subject"],["achievement_grants","achievement_grants_lookup"],["achievement_grants","achievement_grants_pkey"],["achievement_grants","achievement_grants_subject_achievement_id_spec_version_key"],["achievement_pins","achievement_pins_pkey"],["achievement_pins","achievement_pins_subject_position_key"],["agent_drops","agent_drops_inbox"],["agent_drops","agent_drops_pkey"],["agent_drops","agent_drops_sender"],["agent_follows","agent_follows_pkey"],["agent_follows","agent_follows_target"],["arbitration_cases","arbitration_cases_order_id_key"],["arbitration_cases","arbitration_cases_pkey"],["arbitration_decisions","arbitration_decisions_case_id_round_key"],["arbitration_decisions","arbitration_decisions_pkey"],["arbitration_evidence","arbitration_evidence_pkey"],["arbitration_policies","arbitration_policies_pkey"],["arbitration_votes","arbitration_votes_pkey"],["arbitrator_conflicts","arbitrator_conflicts_pkey"],["arbitrator_roles","arbitrator_roles_pkey"],["audit","audit_digest_key"],["audit","audit_pkey"],["batches","batches_pkey"],["bounty_challenges","bounty_challenges_claimant"],["bounty_challenges","bounty_challenges_pkey"],["bounty_claims","bounty_claims_challenge_id_key"],["bounty_claims","bounty_claims_claimant"],["bounty_claims","bounty_claims_pkey"],["bounty_claims","bounty_claims_transaction_id_key"],["bounty_listings","bounty_listings_escrow_subject_key"],["bounty_listings","bounty_listings_pkey"],["bounty_listings","bounty_listings_publisher"],["certificates","certificates_pkey"],["chunks","chunks_pkey"],["claims","claims_pkey"],["claims","claims_subject"],["collaboration_leases","collaboration_leases_holder"],["collaboration_leases","collaboration_leases_pkey"],["credential_states","credential_states_expiry"],["credential_states","credential_states_pkey"],["credentials","credentials_pkey"],["credentials","credentials_subject"],["csrs","csrs_pkey"],["custodial_upgrades","custodial_upgrades_pkey"],["custodial_upgrades","custodial_upgrades_subject"],["custodial_vault","custodial_vault_pkey"],["delivery_email_challenges","delivery_email_challenges_pkey"],["delivery_email_endpoints","delivery_email_endpoints_pkey"],["delivery_envelopes","delivery_envelopes_pkey"],["dm_archives","dm_archives_pkey"],["dm_blocks","dm_blocks_pkey"],["dm_conversations","dm_conversations_participants"],["dm_conversations","dm_conversations_pkey"],["dm_conversations","dm_conversations_resource_id_key"],["email_challenges","email_challenges_pkey"],["emails","emails_pkey"],["encryption_subkeys","encryption_subkeys_pkey"],["encryption_subkeys","encryption_subkeys_primary"],["encryption_subkeys","encryption_subkeys_recipient_key"],["encryption_subkeys","encryption_subkeys_subject"],["events","events_id_key"],["events","events_pkey"],["handoffs","handoffs_from"],["handoffs","handoffs_pkey"],["handoffs","handoffs_to"],["identities","identities_pkey"],["identity_keys","identity_keys_pkey"],["identity_keys","identity_keys_primary"],["identity_keys","identity_keys_subject"],["jobs","jobs_dedupe_key"],["jobs","jobs_pkey"],["ledger_accounts","ledger_accounts_kind_source_id_key"],["ledger_accounts","ledger_accounts_pkey"],["legacy_directive_versions","legacy_directive_versions_pkey"],["legacy_directives","legacy_directives_pkey"],["legacy_directives","legacy_directives_resource_id_key"],["legacy_states","legacy_states_pkey"],["link_invitations","link_invitations_owner"],["link_invitations","link_invitations_owner_name_key"],["link_invitations","link_invitations_pkey"],["memberships","memberships_pkey"],["messages","messages_pkey"],["money_accounts","money_accounts_pkey"],["money_bank_roles","money_bank_roles_pkey"],["money_ledger","money_ledger_actor_request_id_entry_key_key"],["money_ledger","money_ledger_credit"],["money_ledger","money_ledger_debit"],["money_ledger","money_ledger_id_key"],["money_ledger","money_ledger_pkey"],["money_ledger","money_ledger_request_leg"],["money_purchases","money_purchases_escrow_account_key"],["money_purchases","money_purchases_pkey"],["money_purchases","money_purchases_subject_id_request_id_key"],["money_visibility","money_visibility_pkey"],["order_contracts","order_contracts_pkey"],["order_deadlines","order_deadlines_due"],["order_deadlines","order_deadlines_pkey"],["order_escrow_decisions","order_escrow_decisions_id_key"],["order_escrow_decisions","order_escrow_decisions_pkey"],["order_escrow_decisions","order_escrow_decisions_transaction_id_key"],["order_settlements","order_settlements_decision_id_key"],["order_settlements","order_settlements_pkey"],["order_transitions","order_transitions_pkey"],["personal_revision_proofs","personal_revision_proofs_pkey"],["post_view_days","post_view_days_day"],["post_view_days","post_view_days_pkey"],["presence","presence_pkey"],["projections","projections_pkey"],["reactions","reactions_pkey"],["recovery_envelopes","recovery_envelopes_owner"],["recovery_envelopes","recovery_envelopes_owner_ciphertext_resource_ciphertext_rev_key"],["recovery_envelopes","recovery_envelopes_pkey"],["recovery_policies","recovery_policies_pkey"],["relations","relations_target"],["resource_entitlements","resource_entitlements_pkey"],["resource_entitlements","resource_entitlements_redeem_transaction_id_key"],["resource_entitlements","resource_entitlements_subject_id_purchase_request_id_key"],["resource_path_aliases","resource_path_aliases_pkey"],["resource_tags","resource_tags_pkey"],["resource_tags","resource_tags_resource"],["resources","one_root"],["resources","resources_owner"],["resources","resources_parent"],["resources","resources_parent_name_key"],["resources","resources_pkey"],["resources","resources_time"],["resources","resources_type_state"],["results","results_pkey"],["revisions","revisions_pkey"],["revisions","revisions_resource"],["schema_version","schema_version_pkey"],["server_offer_resources","server_offer_resources_listing_id_key"],["server_offer_resources","server_offer_resources_pkey"],["server_offers","server_offers_pkey"],["settings","settings_pkey"],["share_grants","share_grants_active"],["share_grants","share_grants_grantee"],["share_grants","share_grants_pkey"],["share_grants_v2","share_grants_v2_parent"],["share_grants_v2","share_grants_v2_pkey"],["share_grants_v2","share_grants_v2_resource"],["share_links","share_links_pkey"],["share_links","share_links_resource"],["share_links","share_links_verifier_key"],["store_deliveries","store_deliveries_order_id_key"],["store_deliveries","store_deliveries_pkey"],["store_order_events","store_order_events_order"],["store_order_events","store_order_events_pkey"],["store_orders","store_orders_buyer"],["store_orders","store_orders_escrow_subject_key"],["store_orders","store_orders_listing"],["store_orders","store_orders_payment_transaction_id_key"],["store_orders","store_orders_pkey"],["store_orders","store_orders_seller"],["store_packages","store_packages_pkey"],["store_packages","store_packages_revision_key"],["sync_checkpoints","sync_checkpoints_pkey"],["sync_checkpoints","sync_checkpoints_subject"],["system_sources","system_sources_pkey"],["system_sources","system_sources_rule_id_key"],["system_sources","system_sources_source_path_key"],["token_deliveries","token_deliveries_pkey"],["token_deliveries","token_deliveries_subject"],["topic_bans","topic_bans_pkey"],["topic_event_projection","topic_event_projection_pkey"],["topic_event_projection","topic_event_projection_topic"],["topic_memberships","topic_memberships_pkey"],["topic_memberships","topic_memberships_subject"],["topic_settings","topic_settings_pkey"],["transfers","transfers_pkey"],["watches","watches_pkey"],["webhook_endpoints","webhook_endpoints_pkey"]],"sequences":[["audit_seq_seq","bigint"],["events_seq_seq","bigint"],["money_ledger_seq_seq","bigint"],["store_order_events_seq_seq","bigint"]],"tables":{"achievement_ceremonies":[["id","text",true,""],["subject","text",true,""],["state","text",true,""],["body","text",true,""]],"achievement_grants":[["id","text",true,""],["subject","text",true,""],["achievement_id","text",true,""],["spec_version","integer",true,""],["body","text",true,""]],"achievement_pins":[["subject","text",true,""],["grant_id","text",true,""],["position","integer",true,""]],"agent_drops":[["id","text",true,""],["sender","text",true,""],["recipient","text",true,""],["kind","text",true,""],["created_at","text",true,""],["opens_at","text",true,""],["expires_at","text",true,""],["claimed_at","text",false,""],["cancelled_at","text",false,""],["nonce","text",true,""],["ciphertext","text",true,""]],"agent_follows":[["follower","text",true,""],["target","text",true,""],["created_at","text",true,""]],"arbitration_cases":[["id","text",true,""],["order_id","text",true,""],["round","integer",true,""],["state","text",true,""],["deadline","text",true,""],["body","text",true,""]],"arbitration_decisions":[["id","text",true,""],["case_id","text",true,""],["round","integer",true,""],["body","text",true,""],["revoked","boolean",true,""]],"arbitration_evidence":[["id","text",true,""],["case_id","text",true,""],["author","text",true,""],["visibility","text",true,""],["body","text",true,""]],"arbitration_policies":[["id","text",true,""],["body","text",true,""],["digest","text",true,""]],"arbitration_votes":[["case_id","text",true,""],["round","integer",true,""],["arbitrator","text",true,""],["body","text",true,""]],"arbitrator_conflicts":[["arbitrator","text",true,""],["party","text",true,""]],"arbitrator_roles":[["subject","text",true,""],["epoch","text",true,""],["active","boolean",true,""]],"audit":[["seq","bigint",true,"d"],["digest","text",true,""],["previous","text",false,""],["body","text",true,""]],"batches":[["subject","text",true,""],["request_id","text",true,""],["digest","text",true,""]],"bounty_challenges":[["id","text",true,""],["listing_id","text",true,""],["claimant","text",true,""],["key_id","text",true,""],["nonce","text",true,""],["issued_at","text",true,""],["expires_at","text",true,""],["verifier_version","integer",true,""],["payload","text",true,""],["consumed_at","text",false,""]],"bounty_claims":[["id","text",true,""],["listing_id","text",true,""],["claimant","text",true,""],["challenge_id","text",true,""],["proof_digest","text",true,""],["status","text",true,""],["reward_minor","bigint",true,""],["transaction_id","text",true,""],["claimed_at","text",true,""]],"bounty_listings":[["listing_id","text",true,""],["publisher","text",true,""],["escrow_subject","text",true,""],["reward_minor","bigint",true,""],["budget_minor","bigint",true,""],["max_claims","integer",true,""],["claim_limit_per_subject","integer",true,""],["verifier_id","text",true,""],["verifier_version","integer",true,""],["eligibility","text",true,""],["state","text",true,""],["pause_reason","text",false,""],["expires_at","text",false,""],["created_at","text",true,""]],"certificates":[["id","text",true,""],["subject","text",true,""],["parent","text",false,""],["revoked","integer",true,""],["body","text",true,""]],"chunks":[["transfer_id","text",true,""],["offset","bigint",true,""],["length","bigint",true,""],["body","text",true,""]],"claims":[["id","text",true,""],["subject","text",true,""],["issued_at","text",true,""],["expires_at","text",false,""],["body","text",true,""]],"collaboration_leases":[["id","text",true,""],["holder","text",true,""],["target","text",true,""],["status","text",true,""],["generation","integer",true,""],["expires_at","text",true,""],["body","text",true,""]],"credential_states":[["id","text",true,""],["kind","text",true,""],["expires","text",true,""],["body","text",true,""]],"credentials":[["id","text",true,""],["subject","text",true,""],["body","text",true,""]],"csrs":[["id","text",true,""],["generation","integer",true,""],["state","text",true,""],["body","text",true,""],["state_body","text",true,""]],"custodial_upgrades":[["id","text",true,""],["subject","text",true,""],["credential_id","text",true,""],["status","text",true,""],["expires_at","text",true,""],["challenge","text",true,""],["ephemeral_nonce","text",false,""],["ephemeral_ciphertext","text",false,""],["body","text",true,""]],"custodial_vault":[["subject","text",true,""],["signing_key_id","text",true,""],["encryption_key_id","text",true,""],["signing_nonce","text",false,""],["signing_ciphertext","text",false,""],["age_nonce","text",false,""],["age_ciphertext","text",false,""],["status","text",true,""],["created_at","text",true,""],["destroyed_at","text",false,""]],"delivery_email_challenges":[["id","text",true,""],["order_id","text",true,""],["subject","text",true,""],["address","text",true,""],["verifier","text",true,""],["expires_at","text",true,""],["consumed","boolean",true,""]],"delivery_email_endpoints":[["id","text",true,""],["subject","text",true,""],["address","text",true,""],["verified_at","text",true,""],["active","boolean",true,""]],"delivery_envelopes":[["order_id","text",true,""],["body","text",true,""]],"dm_archives":[["subject","text",true,""],["pair","text",true,""]],"dm_blocks":[["blocker","text",true,""],["blocked","text",true,""]],"dm_conversations":[["pair","text",true,""],["resource_id","text",true,""],["participant_a","text",true,""],["participant_b","text",true,""],["initiator","text",true,""],["conversation_kind","text",true,""],["state","text",true,""],["created_at","text",true,""],["updated_at","text",true,""]],"email_challenges":[["subject","text",true,""],["digest","text",true,""],["expires","text",true,""]],"emails":[["subject","text",true,""],["generation","integer",true,""],["body","text",true,""]],"encryption_subkeys":[["key_id","text",true,""],["subject","text",true,""],["recipient","text",true,""],["public_key","text",true,""],["created_at","text",true,""],["retired_at","text",false,""],["is_primary","integer",true,""]],"events":[["seq","bigint",true,"d"],["id","text",true,""],["body","text",true,""]],"handoffs":[["id","text",true,""],["from_subject","text",true,""],["to_subject","text",true,""],["status","text",true,""],["generation","integer",true,""],["body","text",true,""]],"identities":[["id","text",true,""],["kind","text",true,""],["generation","integer",true,""],["body","text",true,""]],"identity_keys":[["key_id","text",true,""],["subject","text",true,""],["public_key","text",true,""],["created_at","text",true,""],["retired_at","text",false,""],["is_primary","integer",true,""]],"jobs":[["id","text",true,""],["dedupe","text",true,""],["kind","text",true,""],["state","text",true,""],["next_at","text",true,""],["body","text",true,""]],"ledger_accounts":[["id","text",true,""],["kind","text",true,""],["subject_id","text",false,""],["source_id","text",false,""]],"legacy_directive_versions":[["revision_id","text",true,""],["resource_id","text",true,""],["subject","text",true,""],["body","text",true,""]],"legacy_directives":[["subject","text",true,""],["resource_id","text",true,""],["created_at","text",true,""]],"legacy_states":[["subject","text",true,""],["state","text",true,""]],"link_invitations":[["invite_id","text",true,""],["owner","text",true,""],["name","text",true,""],["minutes","integer",true,""],["created_at","text",true,""],["expires_at","text",true,""],["claim","text",false,""],["claimed_at","text",false,""],["grant_box","text",false,""],["released_at","text",false,""],["revoked_at","text",false,""]],"memberships":[["org","text",true,""],["subject","text",true,""],["generation","integer",true,""],["body","text",true,""]],"messages":[["id","text",true,""],["sender","text",false,""],["recipient","text",false,""],["resource","text",false,""],["event_id","text",false,""],["body","text",true,""]],"money_accounts":[["subject_id","text",true,""],["currency_id","text",true,""]],"money_bank_roles":[["subject_id","text",true,""],["status","text",true,""],["granted_at","text",true,""],["granted_by","text",true,""],["revoked_at","text",false,""]],"money_ledger":[["seq","bigint",true,"d"],["id","text",true,""],["kind","text",true,""],["currency_id","text",true,""],["amount_minor","bigint",true,""],["debit_account","text",false,""],["credit_account","text",false,""],["actor","text",true,""],["request_id","text",true,""],["reference","text",false,""],["committed_at","text",true,""],["policy_version","integer",true,""],["policy_digest","text",true,""],["receipt","text",true,""],["entry_key","text",true,""]],"money_purchases":[["id","text",true,""],["subject_id","text",true,""],["request_id","text",true,""],["offer_snapshot","text",true,""],["quantity","bigint",true,""],["total_minor","bigint",true,""],["escrow_account","text",true,""],["state","text",true,""],["created_at","text",true,""],["expires_at","text",true,""],["funding_transaction_id","text",true,""],["final_transaction_id","text",false,""],["entitlement_id","text",false,""],["reason","text",false,""]],"money_visibility":[["subject_id","text",true,""],["visibility","text",true,""],["updated_at","text",true,""]],"order_contracts":[["order_id","text",true,""],["body","text",true,""],["digest","text",true,""]],"order_deadlines":[["order_id","text",true,""],["expires_at","text",true,""]],"order_escrow_decisions":[["order_id","text",true,""],["id","text",true,""],["body","text",true,""],["signature","text",true,""],["source_proof","text",true,""],["transaction_id","text",true,""]],"order_settlements":[["order_id","text",true,""],["decision_id","text",false,""],["body","text",true,""]],"order_transitions":[["id","text",true,""],["order_id","text",true,""],["body","text",true,""]],"personal_revision_proofs":[["revision_id","text",true,""],["subject","text",true,""],["kind","text",true,""],["signature","text",true,""],["signed_envelope","text",true,""],["created_at","text",true,""]],"post_view_days":[["resource_id","text",true,""],["day","text",true,""],["visitor_digest","text",true,""]],"presence":[["subject","text",true,""],["expires_at","text",true,""],["body","text",true,""]],"projections":[["resource_id","text",true,""],["text","text",true,""]],"reactions":[["subject","text",true,""],["resource","text",true,""],["kind","text",true,""],["revision","text",true,""],["body","text",true,""]],"recovery_envelopes":[["id","text",true,""],["owner","text",true,""],["ciphertext_resource","text",true,""],["ciphertext_revision","text",true,""],["policy_version","integer",true,""],["encryption_key_id","text",true,""],["purpose","text",true,""],["created_at","text",true,""],["body","text",true,""]],"recovery_policies":[["subject","text",true,""],["version","integer",true,""],["encryption_key_id","text",true,""],["created_at","text",true,""],["body","text",true,""]],"relations":[["revision_id","text",true,""],["source_id","text",true,""],["type","text",true,""],["target_id","text",true,""],["target_revision","text",false,""],["body","text",true,""]],"resource_entitlements":[["id","text",true,""],["subject_id","text",true,""],["offer_id","text",true,""],["purchase_request_id","text",true,""],["quantity","bigint",true,""],["entitlement_kind","text",true,""],["granted_at","text",true,""],["expires_at","text",false,""],["redeem_transaction_id","text",false,""]],"resource_path_aliases":[["parent_id","text",true,""],["name","text",true,""],["resource_id","text",true,""]],"resource_tags":[["resource_id","text",true,""],["tag","text",true,""]],"resources":[["id","text",true,""],["type","text",true,""],["name","text",true,""],["parent","text",false,""],["owner","text",true,""],["grp","text",true,""],["mode","integer",true,""],["generation","integer",true,""],["revision","text",false,""],["state","text",true,""],["created_at","text",true,""],["modified_at","text",true,""],["body","text",true,""]],"results":[["subject","text",true,""],["request_id","text",true,""],["digest","text",true,""],["body","text",true,""]],"revisions":[["id","text",true,""],["resource_id","text",true,""],["created_at","text",true,""],["body","text",true,""]],"schema_version":[["version","integer",true,""]],"server_offer_resources":[["offer_id","text",true,""],["listing_id","text",true,""]],"server_offers":[["offer_id","text",true,""],["resource_kind","text",true,""],["unit","text",true,""],["price_minor","bigint",true,""],["min_quantity","bigint",true,""],["max_quantity","bigint",true,""],["entitlement_kind","text",true,""],["duration_seconds","bigint",false,""],["enabled","boolean",true,""],["price_revision","text",true,""]],"settings":[["key","text",true,""],["value","text",true,""]],"share_grants":[["id","text",true,""],["resource_id","text",true,""],["grantor","text",true,""],["grantee","text",true,""],["created_at","text",true,""],["expires_at","text",true,""],["revoked_at","text",false,""]],"share_grants_v2":[["id","text",true,""],["resource_id","text",true,""],["grantor","text",true,""],["grantee","text",true,""],["grantee_kind","text",true,""],["parent_id","text",false,""],["operations","text",true,""],["constraints","text",true,""],["allow_reshare","integer",true,""],["created_at","text",true,""],["expires_at","text",true,""],["revoked_at","text",false,""]],"share_links":[["id","text",true,""],["resource_id","text",true,""],["grantor","text",true,""],["credential_id","text",true,""],["verifier","text",true,""],["created_at","text",true,""],["expires_at","text",true,""],["revoked_at","text",false,""]],"store_deliveries":[["id","text",true,""],["order_id","text",true,""],["recipient_subject","text",true,""],["kind","text",true,""],["payload_refs","text",true,""],["manifest","text",true,""],["package_digest","text",false,""],["delivery_digest","text",true,""],["channel","text",true,""],["state","text",true,""],["prepared_at","text",true,""],["claimed_at","text",false,""],["receipt","text",false,""]],"store_order_events":[["seq","bigint",true,"d"],["order_id","text",true,""],["from_state","text",false,""],["to_state","text",true,""],["reason","text",true,""],["actor","text",true,""],["request_id","text",true,""],["occurred_at","text",true,""],["receipt_refs","text",true,""]],"store_orders":[["id","text",true,""],["buyer","text",true,""],["seller","text",true,""],["listing_id","text",true,""],["listing_revision","text",true,""],["package_id","text",false,""],["package_revision","text",false,""],["package_digest","text",false,""],["quantity","bigint",true,""],["unit_price_minor","bigint",true,""],["total_price_minor","bigint",true,""],["currency_id","text",true,""],["escrow_subject","text",true,""],["escrow_policy","text",true,""],["dispute_policy","text",true,""],["terms_digest","text",true,""],["delivery_target","text",true,""],["payment_intent_digest","text",true,""],["payment_transaction_id","text",false,""],["state","text",true,""],["created_at","text",true,""],["funded_at","text",false,""],["delivered_at","text",false,""],["settled_at","text",false,""],["receipt_refs","text",true,""],["delivery_mode","text",true,""],["deadline_at","text",false,""],["quote_digest","text",false,""]],"store_packages":[["id","text",true,""],["listing_id","text",true,""],["listing_revision","text",true,""],["seller","text",true,""],["revision","text",true,""],["kind","text",true,""],["manifest","text",true,""],["payload_refs","text",true,""],["digest","text",true,""],["total_size","bigint",true,""],["delivery_mode","text",true,""],["deposited_at","text",true,""]],"sync_checkpoints":[["id","text",true,""],["subject","text",true,""],["credential_id","text",true,""],["version","integer",true,""],["expires_at","text",true,""],["body","text",true,""]],"system_sources":[["resource_id","text",true,""],["source_path","text",true,""],["rule_id","text",true,""],["source_kind","text",true,""],["source_version","integer",true,""],["source_digest","text",true,""],["revision_id","text",true,""]],"token_deliveries":[["credential_id","text",true,""],["subject","text",true,""],["request_id","text",true,""],["request_digest","text",true,""],["recovery_verifier","text",true,""],["recovery_expires_at","text",true,""],["claimed_at","text",false,""],["consumed_at","text",false,""]],"topic_bans":[["topic","text",true,""],["subject","text",true,""],["actor","text",true,""],["created_at","text",true,""],["expires_at","text",false,""],["reason","text",false,""],["status","text",true,""]],"topic_event_projection":[["seq","bigint",true,""],["topic","text",true,""]],"topic_memberships":[["topic","text",true,""],["subject","text",true,""],["role","text",true,""],["status","text",true,""],["joined_at","text",false,""],["invited_by","text",false,""]],"topic_settings":[["topic","text",true,""],["membership_policy","text",true,""]],"transfers":[["id","text",true,""],["subject","text",true,""],["generation","integer",true,""],["body","text",true,""],["limits","text",true,""]],"watches":[["subject","text",true,""],["resource","text",true,""]],"webhook_endpoints":[["subject","text",true,""],["url","text",true,""],["nonce","text",true,""],["ciphertext","text",true,""],["enabled","integer",true,""],["generation","integer",true,""]]},"triggers":[["arbitration_policies","arbitration_policies_append_only"],["arbitration_votes","arbitration_votes_append_only"],["audit","audit_no_update"],["money_accounts","money_account_register_subject"],["money_ledger","money_ledger_no_update"],["order_contracts","order_contracts_append_only"],["order_escrow_decisions","escrow_decision_no_update"],["order_settlements","order_settlements_append_only"],["order_transitions","order_transitions_append_only"],["store_order_events","store_order_events_append_only"]]} diff --git a/src/msg/data/root-web-app.js b/src/msg/data/root-web-app.js index e0d1bb66..3a8aa3b1 100644 --- a/src/msg/data/root-web-app.js +++ b/src/msg/data/root-web-app.js @@ -172,7 +172,7 @@ if ( u.origin !== location.origin || u.pathname.startsWith("/-/") || - u.pathname.startsWith("/oauth/") + u.pathname.startsWith("/oauth/") || u.pathname.startsWith("/session/") ) throw new Error("Invalid resource path."); return u.pathname; diff --git a/src/msg/hosting_runtime.py b/src/msg/hosting_runtime.py index c1a50f99..321af96d 100644 --- a/src/msg/hosting_runtime.py +++ b/src/msg/hosting_runtime.py @@ -213,7 +213,7 @@ async def load(self): ) self.authorizer = AuthorizationService(self.registry, self.certificates) self.authenticator.runtime_generation = self.runtime_generation - self.authenticator.oauth_config = self.settings.oauth + self.authenticator.browser_config = self.settings.browser self.authorizer.runtime_generation = self.runtime_generation self.require_ready(loading=True) self._loaded = True diff --git a/src/msg/login_config.py b/src/msg/login_config.py deleted file mode 100644 index 511c5f2d..00000000 --- a/src/msg/login_config.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Optional login providers and an explicit account-registration policy.""" - -from dataclasses import dataclass -from pathlib import Path -from urllib.parse import urlsplit - -from msg.core.errors import require - -OAUTH_PROVIDERS = frozenset({'google', 'github', 'chatgpt', 'agentid'}) -PROVIDERS = OAUTH_PROVIDERS | {'email', 'passkey'} -REGISTER_PROVIDERS = frozenset({'google', 'email'}) - - -@dataclass(frozen=True, slots=True) -class LoginProvider: - name: str - enabled: bool = False - client_id: str = '' - credential_file: str = '' - token_auth_method: str = 'none' - transport: str = 'sequenzy' - sender: str = '' - - -@dataclass(frozen=True, slots=True) -class LoginConfig: - enabled: bool = False - registration: str = 'legacy' - session_ttl: int = 900 - providers: tuple[LoginProvider, ...] = tuple(LoginProvider(name) for name in sorted(PROVIDERS)) - - def provider(self, name): - return next((item for item in self.providers if item.name == name), None) - - -def load_login(data, service): - require( - isinstance(data, dict) - and set(data) <= {'enabled', 'registration', 'session_ttl', 'providers'}, - 'invalid_login_config', - ) - enabled = data.get('enabled', False) - require(type(enabled) is bool, 'invalid_login_config') - registration = data.get('registration', 'provider_only' if enabled else 'legacy') - require( - isinstance(registration, str) and registration in {'legacy', 'provider_only'}, - 'invalid_login_config', - ) - ttl = data.get('session_ttl', 900) - require(type(ttl) is int and 60 <= ttl <= 3600, 'invalid_login_config') - raw = data.get('providers', {}) - require(isinstance(raw, dict) and set(raw) <= PROVIDERS, 'invalid_login_provider') - providers = [] - for name in sorted(PROVIDERS): - item = raw.get(name, {}) - allowed = ( - {'enabled', 'client_id', 'credential_file', 'token_auth_method'} - if name in OAUTH_PROVIDERS - else {'enabled', 'transport', 'credential_file', 'sender'} - if name == 'email' - else {'enabled'} - ) - require(isinstance(item, dict) and set(item) <= allowed, 'invalid_login_provider') - active = item.get('enabled', bool(item)) - require(type(active) is bool, 'invalid_login_provider') - client_id, credential_file = item.get('client_id', ''), item.get('credential_file', '') - token_auth_method = item.get( - 'token_auth_method', - { - 'google': 'client_secret_post', - 'github': 'client_secret_post', - 'agentid': 'client_secret_basic', - }.get(name, 'none'), - ) - transport, sender = item.get('transport', 'sequenzy'), item.get('sender', '') - require( - isinstance(client_id, str) - and len(client_id) <= 512 - and not any(ord(c) < 33 for c in client_id) - and isinstance(credential_file, str) - and len(credential_file) <= 4096 - and (not credential_file or Path(credential_file).is_absolute()) - and not any(ord(c) < 32 for c in credential_file), - 'invalid_login_provider', - ) - require( - isinstance(token_auth_method, str) - and token_auth_method in {'none', 'client_secret_basic', 'client_secret_post'} - and isinstance(transport, str) - and transport in {'sequenzy', 'smtp'} - and isinstance(sender, str) - and len(sender) <= 512 - and not any(ord(c) < 32 for c in sender) - and (name != 'chatgpt' or not active or client_id.startswith('oaiapp_')) - and ( - name != 'agentid' - or not active - or ( - client_id - and client_id.isascii() - and credential_file - and token_auth_method in {'client_secret_basic', 'client_secret_post'} - ) - ), - 'invalid_login_provider', - ) - providers.append( - LoginProvider( - name, active, client_id, credential_file, token_auth_method, transport, sender - ) - ) - parsed = urlsplit(service) - require( - not enabled - or parsed.scheme == 'https' - or parsed.hostname in {'localhost', '127.0.0.1', '::1', 'testserver'}, - 'secure_channel_required', - ) - return LoginConfig(enabled, registration, ttl, tuple(providers)) - - -def registration_closed(settings): - config = getattr(settings, 'login', LoginConfig()) - return config.enabled and config.registration == 'provider_only' diff --git a/src/msg/oauth_config.py b/src/msg/oauth_config.py deleted file mode 100644 index 44d37da7..00000000 --- a/src/msg/oauth_config.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Registered public OAuth clients and bounded login lifetimes.""" - -from dataclasses import dataclass -from urllib.parse import urlsplit - -from msg.core.errors import require - -LEGACY_SCOPES = frozenset({'openid', 'profile', 'offline_access', 'msg.read', 'msg.write'}) -MCP_SCOPES = frozenset({'msg.mcp.read', 'msg.mcp.message', 'msg.mcp.post'}) -SCOPES = LEGACY_SCOPES | MCP_SCOPES - -# These are intentionally fixed operation versions, not a registry-wide effect filter. -MCP_SCOPE_OPERATIONS = { - 'msg.mcp.read': frozenset({ - 'discovery.get@1', - 'discovery.resolve@1', - 'discovery.read_segment@1', - 'discovery.read_query@3', - 'discovery.lexical_search@5', - 'communication.inbox@1', - 'communication.dm_list@1', - 'communication.conversation_get@1', - 'communication.changes@1', - }), - 'msg.mcp.message': frozenset({ - 'communication.dm_request@3', - 'communication.dm_send@2', - 'communication.dm_accept@2', - 'communication.dm_reject@2', - 'discussion.ack@1', - }), - 'msg.mcp.post': frozenset({'content.post_create@2', 'discussion.reply@2'}), -} - - -@dataclass(frozen=True, slots=True) -class OAuthClient: - client_id: str - name: str - redirect_uris: tuple[str, ...] = () - scopes: frozenset[str] = LEGACY_SCOPES - - -CHATGPT_CLIENT = OAuthClient( - 'msg-chatgpt', - 'MSG for ChatGPT', - ('https://chatgpt.com/connector_platform_oauth_redirect',), - frozenset({'openid', 'profile', 'offline_access'}) | MCP_SCOPES, -) -DEFAULT_CLIENTS = (OAuthClient('msg-cli', 'MSG CLI'), CHATGPT_CLIENT) - - -@dataclass(frozen=True, slots=True) -class OAuthConfig: - enabled: bool = False - access_ttl: int = 900 - session_ttl: int = 2592000 - clients: tuple[OAuthClient, ...] = DEFAULT_CLIENTS - - -def load_oauth(data, service): - require( - set(data) <= {'enabled', 'access_ttl', 'session_ttl', 'clients'}, 'invalid_oauth_config' - ) - require(type(data.get('enabled', False)) is bool, 'invalid_oauth_config') - values = {} - for name, default, maximum in (('access_ttl', 900, 3600), ('session_ttl', 2592000, 7776000)): - value = data.get(name, default) - require(type(value) is int and 60 <= value <= maximum, 'invalid_oauth_config') - values[name] = value - clients = list(DEFAULT_CLIENTS) - raw = data.get('clients', []) - require(isinstance(raw, list) and len(raw) <= 100, 'invalid_oauth_config') - for item in raw: - require( - isinstance(item, dict) - and set(item) <= {'client_id', 'name', 'redirect_uris', 'scopes'}, - 'invalid_oauth_client', - ) - cid, name = item.get('client_id'), item.get('name') - require( - isinstance(cid, str) - and 1 <= len(cid) <= 100 - and all(c.isalnum() or c in '-_' for c in cid) - and isinstance(name, str) - and 1 <= len(name) <= 100, - 'invalid_oauth_client', - ) - redirects = item.get('redirect_uris', []) - scopes = item.get('scopes', sorted(LEGACY_SCOPES)) - require(isinstance(redirects, list) and len(redirects) <= 20, 'invalid_oauth_client') - require( - isinstance(scopes, list) - and all(isinstance(s, str) for s in scopes) - and set(scopes) <= SCOPES, - 'invalid_oauth_client', - ) - for uri in redirects: - require(isinstance(uri, str) and len(uri) <= 2000, 'invalid_redirect_uri') - try: - parsed = urlsplit(uri) - valid = ( - parsed.hostname - and not parsed.username - and not parsed.password - and not parsed.fragment - and not parsed.query - and ( - parsed.scheme == 'https' - or (parsed.scheme == 'http' and parsed.hostname in {'127.0.0.1', '::1'}) - ) - and not any(ord(c) <= 32 for c in uri) - and '\\' not in uri - ) - valid = valid and (parsed.port is None or parsed.port > 0) - except ValueError: - valid = False - require(valid, 'invalid_redirect_uri') - clients.append(OAuthClient(cid, name, tuple(redirects), frozenset(scopes))) - require(len({c.client_id for c in clients}) == len(clients), 'duplicate_oauth_client') - require( - not data.get('enabled', False) - or urlsplit(service).scheme == 'https' - or urlsplit(service).hostname in {'localhost', '127.0.0.1', '::1', 'testserver'}, - 'secure_channel_required', - ) - return OAuthConfig(data.get('enabled', False), clients=tuple(clients), **values) diff --git a/src/msg/plugins/browser_identity.py b/src/msg/plugins/browser_identity.py new file mode 100644 index 00000000..59dbc5dd --- /dev/null +++ b/src/msg/plugins/browser_identity.py @@ -0,0 +1,54 @@ +"""Only the existing local signing key can approve a browser session.""" + +from msg.core.errors import require +from msg.core.models import HandlerOutput +from msg.plugins.schemas import STRING, obj +from msg.security.sessions import CODE_ALPHABET, BrowserSessionService, get, state_id + + +def install(app, op): + # Signed certificates retain these operation names. Declarations preserve + # verification of their original bytes; the executor rejects every call. + for name, effect in ( + ('identity.oauth_request', 'read'), + ('identity.oauth_approve', 'write'), + ('identity.login_complete', 'write'), + ('identity.login_bind', 'write'), + ('identity.login_remove', 'write'), + ): + + @op(name, obj(), effect=effect, enabled=False) + async def retired(ctx, request, tx): + require(False, 'operation_disabled') + + @op('identity.session_request', obj({'user_code': STRING}, ('user_code',)), effect='read') + async def inspect(ctx, request, tx): + sessions = BrowserSessionService(app) + sessions.fence(tx) + code = request.arguments['user_code'].upper().replace('-', '') + require(len(code) == 8 and all(c in CODE_ALPHABET for c in code), 'invalid_grant') + _, body = get(tx, state_id('user', code), ctx.now) + require(body['kind'] == 'browser', 'invalid_grant') + return HandlerOutput( + data={ + 'kind': 'browser', + 'status': body['status'], + 'service': app.settings.service_url, + 'purpose': 'Approve only the browser sign-in that you started.', + } + ) + + @op( + 'identity.session_approve', + obj( + {'user_code': STRING, 'decision': {'enum': ['approve', 'deny']}}, + ('user_code', 'decision'), + ), + signature=True, + ) + async def approve(ctx, request, tx): + return HandlerOutput( + data=await BrowserSessionService(app).approve( + tx, ctx.principal, request.arguments['user_code'], request.arguments['decision'] + ) + ) diff --git a/src/msg/plugins/identity.py b/src/msg/plugins/identity.py index 91aca84a..26e88416 100644 --- a/src/msg/plugins/identity.py +++ b/src/msg/plugins/identity.py @@ -392,11 +392,9 @@ async def validate_ceiling(app, ctx, tx, grants): def install(app): op, finish = registration(app, 'identity') - from msg.plugins.login_identity import install as install_login - from msg.plugins.oauth_identity import install as install_oauth + from msg.plugins.browser_identity import install as install_browser - install_oauth(app, op) - install_login(app, op) + install_browser(app, op) @op( 'identity.rename', @@ -449,9 +447,6 @@ async def register_legacy(ctx, request, tx): version=2, ) async def custodial_create(ctx, request, tx): - from msg.login_config import registration_closed - - require(not registration_closed(app.settings), 'provider_registration_required') token = app.issued_token(request, ctx.principal.subject) signer = Ed25519Signer.generate() age_identity, recipient = generate_age_key() @@ -851,9 +846,6 @@ async def custodial_upgrade_result(ctx, request, tx): version=2, ) async def register(ctx, request, tx): - from msg.login_config import registration_closed - - require(not registration_closed(app.settings), 'provider_registration_required') await app.online_issuer(tx) public = unb64(request.arguments['public_key'], limit=32) encryption_public = public_from_recipient(request.arguments['encryption_recipient']) @@ -2023,9 +2015,6 @@ async def certificate_renew(ctx, request, tx): version=3, ) async def temporary(ctx, request, tx): - from msg.login_config import registration_closed - - require(not registration_closed(app.settings), 'provider_registration_required') if request.contract_version < 3: raise Failure('temporary_dual_keys_required', details={'contract_version': 3}) token = app.issued_token(request, ctx.principal.subject) @@ -2513,7 +2502,7 @@ async def token_create(ctx, request, tx): await tx.save_credential(credential, subject.auth_version) app.record_token_delivery(tx, request, credential, ctx.now) if request.contract_version >= 3: - from msg.security.oauth import put + from msg.security.sessions import put put( tx, @@ -2536,7 +2525,7 @@ async def token_create(ctx, request, tx): 'credential_owner_required', ) require( - tx.one('SELECT id FROM oauth_states WHERE id=?', ('api:' + previous.id,)) + tx.one('SELECT id FROM credential_states WHERE id=?', ('api:' + previous.id,)) is not None, 'api_key_required', ) @@ -2615,9 +2604,9 @@ async def token_recover(ctx, request, tx): ) await tx.save_credential(credential, subject.auth_version) await tx.save_credential(replace(old, revoked_at=ctx.now), subject.auth_version) - api = tx.one('SELECT body FROM oauth_states WHERE id=?', ('api:' + old.id,)) + api = tx.one('SELECT body FROM credential_states WHERE id=?', ('api:' + old.id,)) if api is not None: - from msg.security.oauth import put + from msg.security.sessions import put put(tx, 'api:' + credential.id, 'api', credential.expires_at, loads(api[0])) consumed = tx.execute( diff --git a/src/msg/plugins/login_identity.py b/src/msg/plugins/login_identity.py deleted file mode 100644 index 6449f2fe..00000000 --- a/src/msg/plugins/login_identity.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Business parameters never contain external identity assertions or secrets.""" - -from msg.plugins.schemas import STRING, obj -from msg.security.login import apply_login - - -def install(app, op): - @op('identity.login_complete', obj({'intent_id': STRING, 'handle': STRING}, ('intent_id',))) - @op('identity.login_bind', obj({'intent_id': STRING}, ('intent_id',))) - @op('identity.login_remove', obj({'intent_id': STRING}, ('intent_id',))) - async def login(ctx, request, tx): - return await apply_login(app, tx, ctx, request) diff --git a/src/msg/plugins/oauth_identity.py b/src/msg/plugins/oauth_identity.py deleted file mode 100644 index b6fc08aa..00000000 --- a/src/msg/plugins/oauth_identity.py +++ /dev/null @@ -1,39 +0,0 @@ -"""OAuth approval requires the existing identity proof, never a device code alone.""" - -from msg.core.models import HandlerOutput -from msg.plugins.schemas import STRING, obj -from msg.security.oauth import OAuthService, client_for, get, state_id - - -def install(app, op): - @op('identity.oauth_request', obj({'user_code': STRING}, ('user_code',)), effect='read') - async def inspect(ctx, request, tx): - oauth = OAuthService(app) - oauth.fence(tx) - code = request.arguments['user_code'].upper().replace('-', '') - _, body = get(tx, state_id('user', code), ctx.now) - client = client_for(oauth.config, body['client_id']) - return HandlerOutput( - data={ - 'client_id': client.client_id, - 'client_name': client.name, - 'scopes': body['scopes'], - 'kind': body['kind'], - 'status': body['status'], - } - ) - - @op( - 'identity.oauth_approve', - obj( - {'user_code': STRING, 'decision': {'enum': ['approve', 'deny']}}, - ('user_code', 'decision'), - ), - ) - async def approve(ctx, request, tx): - oauth = OAuthService(app) - return HandlerOutput( - data=await oauth.approve( - tx, ctx.principal, request.arguments['user_code'], request.arguments['decision'] - ) - ) diff --git a/src/msg/security/authentication.py b/src/msg/security/authentication.py index ccd2949d..20975c22 100644 --- a/src/msg/security/authentication.py +++ b/src/msg/security/authentication.py @@ -12,12 +12,12 @@ from msg.core.models import Principal, SignatureProof, TokenProof from msg.core.requests import payload_fields, signing_bytes from msg.security.crypto import key_id, subject_id, verify +from msg.security.policy import operations_cover from msg.security.quarantine import RuntimeGeneration, active as quarantine_active from msg.security.token_delivery import recovery_verifier CUSTODIAL_SIGNED_WRITES = frozenset({ 'identity.rename', - 'identity.oauth_approve', 'content.post_create', 'content.post_edit', 'content.file_put', @@ -54,7 +54,7 @@ def __init__(self, registry, certificates, service, clock, primary_ceiling, temp ) self.primary_ceiling, self.temporary_ceiling = primary_ceiling, temporary_ceiling self.runtime_generation = RuntimeGeneration() - self.oauth_config = None + self.browser_config = None async def authenticate(self, request, session, *, entry): require( @@ -90,18 +90,6 @@ async def _authenticate(self, request, session, *, entry): require(request.expires_at is not None and now < request.expires_at, 'request_expired') require((request.expires_at - now).total_seconds() <= 300, 'request_expiry_too_long') proof = request.proof - if ( - request.operation - in { - 'identity.login_complete', - 'identity.login_bind', - 'identity.login_remove', - } - and spec.version == 1 - ): - from msg.security.login import authenticate_login - - return await authenticate_login(self, request, session) if request.operation == 'identity.register': require(isinstance(proof, SignatureProof), 'proof_required') public = unb64(request.arguments['public_key'], limit=32) @@ -144,10 +132,10 @@ async def _authenticate(self, request, session, *, entry): if request.operation == 'identity.token_recover': require(proof is None and request.subject is not None, 'invalid_recovery_proof') old = await session.credential(request.arguments['credential_id']) - from msg.security.oauth import require_binding + from msg.security.sessions import require_binding await require_binding( - session, old, now, self.oauth_config, custodial_ceiling=self.temporary_ceiling() + session, old, now, self.browser_config, custodial_ceiling=self.temporary_ceiling() ) require( old.kind == 'token' and old.subject_id == request.subject, 'recovery_unavailable' @@ -181,7 +169,7 @@ async def _authenticate(self, request, session, *, entry): ceiling = tuple( replace(g, operations=frozenset({operation})) for g in self.primary_ceiling() - if g.capability == 'identity.basic' and operation in g.operations + if g.capability == 'identity.basic' and operations_cover(g.operations, {operation}) ) require(bool(ceiling), 'recovery_unavailable') return Principal( @@ -238,13 +226,13 @@ async def _authenticate(self, request, session, *, entry): ) method = 'token' ids = () - from msg.security.oauth import require_binding + from msg.security.sessions import require_binding await require_binding( session, credential, now, - self.oauth_config, + self.browser_config, custodial_ceiling=self.temporary_ceiling(), ) else: @@ -296,7 +284,10 @@ async def _authenticate(self, request, session, *, entry): ) require(entry in spec.entries, 'entry_not_allowed') operation = f'{spec.name}@{spec.version}' - require(any(operation in g.operations for g in credential.ceiling), 'credential_ceiling') + require( + any(operations_cover(g.operations, {operation}) for g in credential.ceiling), + 'credential_ceiling', + ) if actor.kind == 'custodial' and method == 'token' and spec.effect != 'read': download_only = ( spec.name == 'transfer.open' and request.arguments.get('direction') == 'download' diff --git a/src/msg/security/authorization.py b/src/msg/security/authorization.py index 25832084..5ba46a64 100644 --- a/src/msg/security/authorization.py +++ b/src/msg/security/authorization.py @@ -6,7 +6,7 @@ from msg.core.codec import loads, parse_time, wire from msg.core.errors import Failure, require from msg.core.models import ResourceRef -from msg.security.policy import CERTGATE, allows, grant_covers, scope_contains +from msg.security.policy import CERTGATE, allows, grant_covers, operations_cover, scope_contains from msg.security.quarantine import ( RuntimeGeneration, active as quarantine_active, @@ -78,7 +78,8 @@ async def _ceiling(self, principal, operation, resource, session): if principal.method == 'anonymous': return allowed = any([ - operation in g.operations and await scope_contains(g.scope, resource, session) + operations_cover(g.operations, {operation}) + and await scope_contains(g.scope, resource, session) for g in principal.ceiling ]) if not allowed and operation in { @@ -108,7 +109,8 @@ async def _ceiling(self, principal, operation, resource, session): delegated.extend(cert.grants) require( any([ - operation in g.operations and await scope_contains(g.scope, resource, session) + operations_cover(g.operations, {operation}) + and await scope_contains(g.scope, resource, session) for g in delegated ]), 'delegation_scope', @@ -121,7 +123,7 @@ async def ordinary(self, principal, operation, resource, session): return any([ g.capability in BASE_FAMILIES - and operation in g.operations + and operations_cover(g.operations, {operation}) and await scope_contains(g.scope, resource, session) for g in principal.ceiling ]) diff --git a/src/msg/security/capabilities.py b/src/msg/security/capabilities.py index 2c61e0c0..da1a6f47 100644 --- a/src/msg/security/capabilities.py +++ b/src/msg/security/capabilities.py @@ -19,8 +19,10 @@ 'identity.upgrade', 'identity.token_rotate', 'identity.token_create', + 'identity.session_request', 'identity.oauth_request', 'identity.oauth_approve', + 'identity.session_approve', 'identity.token_recover', 'identity.upgrade_result', 'identity.key_add', @@ -105,8 +107,8 @@ EXCLUDED_BASE = {'content.purge', 'content.chown', 'identity.recover'} TEMPORARY_OPERATIONS = frozenset({ 'identity.rename', - 'identity.oauth_request', - 'identity.oauth_approve', + 'identity.session_request', + 'identity.session_approve', 'identity.temporary', 'identity.custodial_create', 'identity.upgrade', diff --git a/src/msg/security/certificates.py b/src/msg/security/certificates.py index dfb30254..cec036ea 100644 --- a/src/msg/security/certificates.py +++ b/src/msg/security/certificates.py @@ -8,7 +8,7 @@ from msg.core.codec import canonical, digest, wire from msg.core.errors import require from msg.security.crypto import key_id, verify -from msg.security.policy import constraints_subset, grant_covers, scope_subset +from msg.security.policy import constraints_subset, grant_covers, operations_cover, scope_subset # Keep this list explicit. Adding a new ordinary capability must not silently # enlarge the permanent online issuer's signing authority. @@ -104,7 +104,7 @@ async def allowed_issuance(self, grant, policy, session): if ( grant.capability == allowed.capability and grant.version == allowed.version - and grant.operations <= allowed.operations + and operations_cover(allowed.operations, grant.operations) and await scope_subset(grant.scope, allowed.scope, session) and constraints_subset(grant.constraints, allowed.constraints) ): @@ -298,7 +298,7 @@ async def validate_authority(self, source, cert, session, *, seen=None): any([ g.capability == grant.capability and g.version == grant.version - and grant.operations <= g.operations + and operations_cover(g.operations, grant.operations) and await scope_subset(grant.scope, g.scope, session) and constraints_subset(grant.constraints, g.constraints) for g in credential.ceiling @@ -311,7 +311,7 @@ async def validate_authority(self, source, cert, session, *, seen=None): any([ g.capability == grant.capability and g.version == grant.version - and grant.operations <= g.operations + and operations_cover(g.operations, grant.operations) and await scope_subset(grant.scope, g.scope, session) and constraints_subset(grant.constraints, g.constraints) for g in allowed diff --git a/src/msg/security/email_login.py b/src/msg/security/email_login.py deleted file mode 100644 index 99cf3518..00000000 --- a/src/msg/security/email_login.py +++ /dev/null @@ -1,76 +0,0 @@ -"""短期邮箱验证码;明文只在发送前留于当前请求内存。""" - -import hashlib -import hmac -import secrets -from datetime import timedelta - -from msg.core.codec import wire -from msg.core.email_address import validate_address -from msg.core.errors import Failure, require -from msg.security.oauth import get, put, save, state_id - -TTL = 600 -MAX_ATTEMPTS = 5 - - -def _digest(app, challenge, code): - return hmac.new( - app._token_secret, ('msg-email-login-v1:' + challenge + ':' + code).encode(), hashlib.sha256 - ).hexdigest() - - -def begin_email(app, tx, address, browser, metadata): - mailbox = validate_address(address) - now = app.clock() - require(type(browser) is str and 20 <= len(browser) <= 200, 'invalid_login_browser') - rate_id = state_id('login-email-rate', mailbox.addr_spec) - row = tx.one('SELECT expires,body FROM oauth_states WHERE id=?', (rate_id,)) - if row: - from msg.core.codec import parse_time - - require(parse_time(row[0]) <= now, 'slow_down') - put(tx, rate_id, 'login-email-rate', now + timedelta(seconds=60), {}) - challenge = secrets.token_urlsafe(32) - code = ''.join(secrets.choice('0123456789') for _ in range(12)) - body = { - 'browser': state_id('browser', browser), - 'address': mailbox.addr_spec, - 'sub': mailbox.username + '@' + mailbox.domain.lower(), - 'digest': _digest(app, challenge, code), - 'attempts': 0, - 'consumed': False, - 'metadata': metadata, - } - put(tx, state_id('login-email', challenge), 'login-email', now + timedelta(seconds=TTL), body) - return challenge, code - - -def verify_email(app, tx, challenge, browser, code): - """失败次数也须提交;调用方在事务退出后才抛出返回的错误。""" - require(type(challenge) is str and len(challenge) == 43, 'invalid_login_challenge') - require(type(browser) is str and 20 <= len(browser) <= 200, 'invalid_login_browser') - id = state_id('login-email', challenge) - try: - _, body = get(tx, id, app.clock()) - except Failure: - return None, 'invalid_login_challenge' - if ( - body.get('browser') != state_id('browser', browser) - or body.get('consumed') - or body.get('attempts', MAX_ATTEMPTS) >= MAX_ATTEMPTS - ): - return None, 'invalid_login_challenge' - body['attempts'] += 1 - valid = ( - type(code) is str - and len(code) == 12 - and code.isascii() - and code.isdigit() - and hmac.compare_digest(body['digest'], _digest(app, challenge, code)) - ) - if valid: - body['consumed'] = True - body['consumed_at'] = wire(app.clock()) - save(tx, id, body) - return (body, None) if valid else (None, 'invalid_login_code') diff --git a/src/msg/security/login.py b/src/msg/security/login.py deleted file mode 100644 index 4a5ba21a..00000000 --- a/src/msg/security/login.py +++ /dev/null @@ -1,745 +0,0 @@ -"""Verified external factors enter exact, audited executor transactions.""" - -import hashlib -import hmac -from contextlib import contextmanager -from contextvars import ContextVar -from dataclasses import dataclass, field, replace -from datetime import timedelta -from uuid import uuid4 - -from msg.core.codec import b64, canonical, decode, digest, loads, wire -from msg.core.errors import require -from msg.core.models import ( - AuditEvent, - CapabilityGrant, - Credential, - Event, - HandlerOutput, - Principal, - ResourceRef, - Scope, -) -from msg.core.requests import request_for -from msg.login_config import REGISTER_PROVIDERS, LoginConfig -from msg.security.age_keys import encryption_key_id, generate_age_key, public_from_recipient -from msg.security.crypto import Ed25519Signer -from msg.security.oauth import ( - OAuthService, - get, - put, - require_binding, - require_source, - save, - state_id, -) -from msg.security.policy import scope_contains -from msg.security.vault import store_keys - -LOGIN_ACTIONS = { - 'complete': 'identity.login_complete', - 'bind': 'identity.login_bind', - 'remove': 'identity.login_remove', -} -_LOGIN = ContextVar('msg_verified_login', default=None) -_SOURCE_FIELDS = ('subject', 'parent', 'auth_version', 'custodial', 'ceiling', 'auth_time') - - -@dataclass(frozen=True, slots=True) -class VerifiedLogin: - provider: str - sub: str = field(repr=False) - - def __post_init__(self): - require( - isinstance(self.provider, str) - and 1 <= len(self.provider) <= 32 - and self.provider.isascii() - and self.provider.replace('_', '').isalnum() - and self.provider == self.provider.lower() - and isinstance(self.sub, str) - and 0 < len(self.sub.encode()) <= 2048, - 'invalid_verified_login', - ) - - @property - def subject_hash(self): - return hashlib.sha256( - canonical({ - 'domain': 'msg-login-subject-v1', - 'provider': self.provider, - 'sub': self.sub, - }) - ).hexdigest() - - -@dataclass(frozen=True, slots=True) -class Binding: - id: str - provider: str - subject_hash: str = field(repr=False) - subject: str - parent: str - auth_version: int - custodial: bool - ceiling: tuple = field(repr=False) - source: dict = field(repr=False) - provider_data: dict = field(repr=False) - generation: int - revoked_at: str | None - - -@dataclass(slots=True) -class LoginCompletion: - result: object - cookie: str = field(default='', repr=False) - expires: object = None - - -@dataclass(frozen=True, slots=True) -class LoginApproval: - intent_id: str - user_code: str - poll_cookie: str = field(repr=False) - expires: object - - -@dataclass(frozen=True, slots=True) -class _LoginContext: - app: object = field(repr=False) - verified: VerifiedLogin | None = field(repr=False) - intent_id: str - operation: str - request_id: str - payload_digest: str - cookie: str = field(repr=False) - - -@contextmanager -def trusted_login(app, verified, *, intent_id, request, cookie=''): - require(verified is None or type(verified) is VerifiedLogin, 'invalid_verified_login') - value = _LoginContext( - app, - verified, - intent_id, - request.operation, - request.request_id, - request.payload_digest, - cookie, - ) - token = _LOGIN.set(value) - try: - yield - finally: - _LOGIN.reset(token) - - -def config_for(app): - config = getattr(app.settings, 'login', LoginConfig()) - require(config.enabled, 'login_disabled') - return config - - -def require_provider(app, verified): - provider = config_for(app).provider(verified.provider) - require(provider is not None and provider.enabled, 'login_provider_disabled') - - -def lookup_binding(tx, provider, sub, *, include_revoked=False): - verified = VerifiedLogin(provider, sub) - row = tx.one( - 'SELECT id,provider,external_subject_hash,subject,source_credential_id,auth_version,' - 'custodial,ceiling,source_data,provider_data,generation,revoked_at ' - 'FROM login_bindings WHERE provider=? AND external_subject_hash=?', - (provider, verified.subject_hash), - ) - if row is None or row[11] is not None and not include_revoked: - return None - return Binding( - *row[:6], - bool(row[6]), - tuple(decode(CapabilityGrant, raw) for raw in loads(row[7])), - loads(row[8]), - loads(row[9]), - row[10], - row[11], - ) - - -def binding_by_id(tx, binding_id): - full = tx.one( - 'SELECT id,provider,external_subject_hash,subject,source_credential_id,auth_version,' - 'custodial,ceiling,source_data,provider_data,generation,revoked_at ' - 'FROM login_bindings WHERE id=?', - (binding_id,), - ) - require(full is not None, 'login_binding_not_found') - return Binding( - *full[:6], - bool(full[6]), - tuple(decode(CapabilityGrant, raw) for raw in loads(full[7])), - loads(full[8]), - loads(full[9]), - full[10], - full[11], - ) - - -async def list_bindings(tx, subject): - return [ - {'binding_id': binding_id, 'provider': provider} - for binding_id, provider in tx.rows( - 'SELECT id,provider FROM login_bindings WHERE subject=? AND revoked_at IS NULL ' - 'ORDER BY provider,id', - (subject,), - ) - ] - - -def source_for_binding(binding, now): - return dict( - binding.source, - auth_time=int(now.timestamp()), - login_binding=binding.id, - login_generation=binding.generation, - ) - - -def _new_subject(app, verified): - raw = hmac.digest( - app._token_secret, - b'msg-login-account-v1:' + verified.subject_hash.encode(), - 'sha256', - ) - return 'u_cust_' + raw.hex()[:32] - - -def _arguments(intent_id, handle): - return {'intent_id': intent_id, **({'handle': handle} if handle is not None else {})} - - -def _request(app, action, intent_id, request_id, *, handle=None): - return request_for( - LOGIN_ACTIONS[action], - _arguments(intent_id, handle), - app.settings.service_url, - request_id=request_id, - expires_at=app.clock() + timedelta(minutes=3), - ) - - -async def create_login_intent( - app, tx, verified, *, action, request_id, owner=None, binding_id=None, handle=None -): - config_for(app) - oauth = OAuthService(app) - oauth.fence(tx) - require(action in LOGIN_ACTIONS, 'invalid_login_action') - require(action == 'remove' or type(verified) is VerifiedLogin, 'invalid_verified_login') - if verified is not None: - require_provider(app, verified) - require(action == 'complete' or owner is not None, 'login_owner_required') - locator = { - 'action': action, - 'request_id': request_id, - 'owner': owner, - 'binding_id': binding_id, - 'provider': verified.provider if verified else None, - 'subject_hash': verified.subject_hash if verified else None, - } - intent_id = 'login-intent:' + hmac.digest(app._token_secret, canonical(locator), 'sha256').hex() - request = _request(app, action, intent_id, request_id, handle=handle) - old = tx.one('SELECT body FROM oauth_states WHERE id=?', (intent_id,)) - if old is not None: - _, previous = get(tx, intent_id, app.clock()) - require(previous['payload_digest'] == request.payload_digest, 'request_id_conflict') - return intent_id, previous - binding = ( - binding_by_id(tx, binding_id) - if action == 'remove' - else lookup_binding(tx, verified.provider, verified.sub, include_revoked=True) - ) - if action == 'complete': - require(binding is None or binding.revoked_at is None, 'login_binding_revoked') - require( - binding is not None or verified.provider in REGISTER_PROVIDERS, 'login_binding_required' - ) - require(binding is not None or bool(handle), 'login_registration_required') - owner = binding.subject if binding else _new_subject(app, verified) - if action == 'remove': - require(binding.subject == owner and binding.revoked_at is None, 'login_owner_required') - body = dict( - locator, - subject=owner, - binding_id=binding.id if binding else None, - binding_generation=binding.generation if binding else None, - payload_digest=request.payload_digest, - status='approved' if action == 'complete' else 'pending', - ) - put(tx, intent_id, 'login_intent', app.clock() + timedelta(minutes=5), body) - return intent_id, body - - -async def request_login_approval( - app, verified, owner, *, action='bind', binding_id=None, request_id -): - async with app.metadata.transaction(write=True) as tx: - intent_id, intent = await create_login_intent( - app, - tx, - verified, - action=action, - owner=owner, - binding_id=binding_id, - request_id=request_id, - ) - require(intent['status'] == 'pending', 'invalid_grant') - oauth = OAuthService(app) - poll, user_code, expiry = oauth.pending(tx, kind='login_' + action) - _, body = get(tx, state_id('user', user_code), app.clock()) - body.update(login_intent=intent_id, expected_subject=owner) - save(tx, state_id('user', user_code), body) - return LoginApproval( - intent_id, user_code, poll, min(expiry, app.clock() + timedelta(minutes=5)) - ) - - -async def approve_login_intent(app, tx, principal, intent_id): - _, intent = get(tx, intent_id, app.clock()) - require( - intent['status'] == 'pending' and intent['action'] in {'bind', 'remove'}, 'invalid_grant' - ) - require(principal.actor == principal.subject == intent['subject'], 'login_owner_required') - require( - any([ - 'identity.oauth_approve@1' in grant.operations - and await scope_contains(grant.scope, principal.subject, tx) - for grant in principal.ceiling - ]), - 'credential_ceiling', - ) - source = await OAuthService(app).source(tx, principal) - intent.update(source=source, status='approved') - save(tx, intent_id, intent) - - -async def approve_browser_login_intent(app, tx, session_cookie, intent_id): - oauth = OAuthService(app) - oauth.fence(tx) - require(isinstance(session_cookie, str) and bool(session_cookie), 'invalid_grant') - _, source = await oauth.session(tx, session_cookie) - _, session = get(tx, source['session'], app.clock()) - if session.get('login_management') is not True: - return False - require( - type(session.get('auth_time')) is int - and 0 <= int(app.clock().timestamp()) - session['auth_time'] <= 300, - 'login_management_expired', - ) - require(session.get('login_binding') is not None, 'invalid_grant') - credential = await tx.credential(session.get('browser_credential')) - require( - credential.subject_id == source['subject'] - and credential.revoked_at is None - and credential.not_before <= app.clock() - and credential.expires_at is not None - and credential.expires_at > app.clock(), - 'invalid_grant', - ) - await require_binding( - tx, credential, app.clock(), oauth.config, custodial_ceiling=app.temporary_ceiling() - ) - binding = binding_by_id(tx, session['login_binding']) - require( - binding.subject == source['subject'] - and binding.revoked_at is None - and binding.generation == session.get('login_generation'), - 'invalid_grant', - ) - require( - any([ - 'identity.oauth_approve@1' in grant.operations - and await scope_contains(grant.scope, source['subject'], tx) - for grant in binding.ceiling - ]), - 'credential_ceiling', - ) - _, intent = get(tx, intent_id, app.clock()) - require( - intent['action'] in {'bind', 'remove'} and intent['subject'] == source['subject'], - 'login_owner_required', - ) - # HTTP 入口先验证同源 CSRF;只有刚核验过绑定因子的专用会话能批准这一个 intent。 - if intent['status'] == 'pending': - intent.update(source=dict(source), status='approved') - save(tx, intent_id, intent) - else: - require( - intent['status'] in {'approved', 'consumed'} - and canonical(intent.get('source')) == canonical(source), - 'login_intent_mismatch', - ) - return True - - -async def _intent(app, tx, request): - context = _LOGIN.get() - require( - context is not None - and context.intent_id == request.arguments['intent_id'] - and context.operation == request.operation - and context.request_id == request.request_id - and context.payload_digest == request.payload_digest - and request.contract_version == 1 - and request.proof is None - and request.subject is None, - 'verified_login_required', - ) - _, intent = get(tx, context.intent_id, app.clock()) - require( - request.operation == LOGIN_ACTIONS.get(intent['action']) - and intent['request_id'] == request.request_id - and intent['payload_digest'] == request.payload_digest - and intent['status'] in {'approved', 'consumed'}, - 'login_intent_mismatch', - ) - if intent['action'] != 'remove': - verified = context.verified - require( - verified is not None - and verified.provider == intent['provider'] - and verified.subject_hash == intent['subject_hash'], - 'login_intent_mismatch', - ) - require_provider(app, verified) - else: - require(context.verified is None, 'login_intent_mismatch') - config_for(app) - if intent['action'] in {'bind', 'remove'}: - await require_source( - tx, intent['source'], app.clock(), custodial_ceiling=app.temporary_ceiling() - ) - require(intent['source']['subject'] == intent['subject'], 'login_owner_required') - return context, intent - - -async def authenticate_login(authenticator, request, tx): - context = _LOGIN.get() - require( - context is not None and context.app.authenticator is authenticator, - 'verified_login_required', - ) - app = context.app - _, intent = await _intent(app, tx, request) - subject = intent['subject'] - if intent['action'] == 'complete': - binding = lookup_binding( - tx, _LOGIN.get().verified.provider, _LOGIN.get().verified.sub, include_revoked=True - ) - if binding: - require( - binding.subject == subject and binding.revoked_at is None, 'login_binding_revoked' - ) - require( - intent['binding_generation'] is None - or intent['binding_generation'] == binding.generation, - 'login_binding_changed', - ) - await require_source( - tx, - source_for_binding(binding, app.clock()), - app.clock(), - custodial_ceiling=app.temporary_ceiling(), - ) - else: - require(_LOGIN.get().verified.provider in REGISTER_PROVIDERS, 'login_binding_required') - # 此 principal 只执行本次已核验事务,不能作为可保存的 MSG token 使用。 - return Principal( - actor=subject, - subject=subject, - credential_id=None, - method='token', - certificates=(), - ceiling=( - CapabilityGrant( - capability='identity.basic', - version=1, - scope=Scope(resource_id=subject), - operations=frozenset({request.operation + '@1'}), - constraints={}, - ), - ), - ) - - -async def _create_custodial(app, tx, ctx, verified, handle): - from msg.plugins.identity import make_user - - require(tx.setting('runtime_config', {}).get('accept_writes', True), 'writes_paused') - signer = Ed25519Signer.generate() - age_identity, recipient = generate_age_key() - public = public_from_recipient(recipient) - age_id = encryption_key_id(public) - subject = ctx.principal.subject - await make_user(app, tx, ctx, subject, handle or 'user-' + subject[-16:], 'custodial') - await tx.save_credential( - Credential( - id=signer.key_id, - subject_id=subject, - kind='signing_key', - verifier=signer.public_key, - ceiling=(), - not_before=ctx.now, - expires_at=None, - revoked_at=None, - ), - 0, - ) - tx.execute( - 'INSERT INTO identity_keys VALUES (?,?,?,?,?,?)', - (signer.key_id, subject, b64(signer.public_key), wire(ctx.now), None, 1), - write=True, - ) - tx.execute( - 'INSERT INTO encryption_subkeys VALUES (?,?,?,?,?,?,?)', - (age_id, subject, recipient, b64(public), wire(ctx.now), None, 1), - write=True, - ) - store_keys(app, tx, subject, signer, age_identity, age_id, ctx.now) - return { - 'subject': subject, - 'parent': signer.key_id, - 'auth_version': 0, - 'custodial': True, - 'ceiling': wire(app.temporary_ceiling()), - 'auth_time': int(ctx.now.timestamp()), - } - - -def _write_binding(tx, verified, source, now, *, previous=None, provider_data=None): - binding_id = previous.id if previous else 'lb_' + uuid4().hex - generation = previous.generation + 1 if previous else 0 - base_source = {key: source[key] for key in _SOURCE_FIELDS} - values = ( - verified.provider, - verified.subject_hash, - source['subject'], - source['parent'], - source['auth_version'], - int(source['custodial']), - canonical(source['ceiling']).decode(), - canonical(base_source).decode(), - canonical(provider_data or {}).decode(), - generation, - wire(now), - ) - if previous: - tx.execute( - 'UPDATE login_bindings SET provider=?,external_subject_hash=?,subject=?,' - 'source_credential_id=?,auth_version=?,custodial=?,ceiling=?,source_data=?,' - 'provider_data=?,generation=?,updated_at=?,revoked_at=NULL WHERE id=?', - (*values, binding_id), - write=True, - ) - else: - tx.execute( - 'INSERT INTO login_bindings (id,provider,external_subject_hash,subject,' - 'source_credential_id,auth_version,custodial,ceiling,source_data,provider_data,' - 'generation,updated_at,created_at,revoked_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,NULL)', - (binding_id, *values, wire(now)), - write=True, - ) - return binding_by_id(tx, binding_id) - - -async def _audit(tx, ctx, request, binding, *, before=None): - await tx.append_audit( - AuditEvent( - event=Event( - id='a_' + uuid4().hex, - type=request.operation, - time=ctx.now, - request_id=request.request_id, - actor=ctx.principal.actor, - subject=binding.subject, - resources=(ResourceRef(id=binding.subject),), - data={'binding_id': binding.id, 'provider': binding.provider}, - ), - authority=(), - before_digest=digest(before), - after_digest=digest({ - 'id': binding.id, - 'subject': binding.subject, - 'provider': binding.provider, - 'generation': binding.generation, - 'revoked_at': binding.revoked_at, - 'source': binding.source, - }), - previous_digest=None, - entry_digest='', - result='committed', - ) - ) - - -async def apply_login(app, tx, ctx, request): - context, intent = await _intent(app, tx, request) - verified = context.verified - action = intent['action'] - if action == 'remove': - binding = binding_by_id(tx, intent['binding_id']) - require(binding.subject == ctx.principal.subject, 'login_owner_required') - require( - binding.revoked_at is None and binding.generation == intent['binding_generation'], - 'login_binding_changed', - ) - tx.execute( - 'UPDATE login_bindings SET revoked_at=?,generation=generation+1,updated_at=? WHERE id=?', - (wire(ctx.now), wire(ctx.now), binding.id), - write=True, - ) - before = {'generation': binding.generation, 'revoked_at': binding.revoked_at} - binding = replace(binding, generation=binding.generation + 1, revoked_at=wire(ctx.now)) - await _audit(tx, ctx, request, binding, before=before) - intent['status'] = 'consumed' - save(tx, context.intent_id, intent) - return HandlerOutput( - data={'subject_id': binding.subject, 'binding_id': binding.id, 'removed': True} - ) - binding = lookup_binding(tx, verified.provider, verified.sub, include_revoked=True) - if action == 'bind': - source = intent['source'] - require(binding is None or binding.subject == source['subject'], 'login_binding_conflict') - provider_data = {} - if verified.provider == 'passkey': - require(binding is None or binding.revoked_at is not None, 'login_binding_exists') - from msg.security.passkey import finalize_registration - - provider_data = finalize_registration(tx, verified, source['subject'], now=ctx.now) - binding = _write_binding( - tx, verified, source, ctx.now, previous=binding, provider_data=provider_data - ) - elif binding is None: - require(verified.provider in REGISTER_PROVIDERS, 'login_binding_required') - source = await _create_custodial(app, tx, ctx, verified, request.arguments.get('handle')) - binding = _write_binding(tx, verified, source, ctx.now) - else: - require( - binding.revoked_at is None and binding.subject == ctx.principal.subject, - 'login_binding_revoked', - ) - if verified.provider == 'passkey': - from msg.security.passkey import finalize_authentication - - provider_data = finalize_authentication( - tx, verified, binding.subject, binding.provider_data, now=ctx.now - ) - tx.execute( - 'UPDATE login_bindings SET provider_data=?,updated_at=? WHERE id=?', - (canonical(provider_data).decode(), wire(ctx.now), binding.id), - write=True, - ) - source = source_for_binding(binding, ctx.now) - await OAuthService(app).create_browser_session( - tx, - dict(source, scopes=['openid', 'profile', 'msg.read', 'msg.write'], login_management=True), - cookie=context.cookie, - ttl=config_for(app).session_ttl, - ) - await _audit(tx, ctx, request, binding) - intent['status'] = 'consumed' - save(tx, context.intent_id, intent) - return HandlerOutput( - resources=(ResourceRef(id=binding.subject),), - data={ - 'subject_id': binding.subject, - 'binding_id': binding.id, - 'kind': (await tx.subject(binding.subject)).kind, - 'provider': binding.provider, - }, - ) - - -async def _execute_login(app, verified, *, action, request_id, intent_id=None, handle=None): - if intent_id is None: - require(action == 'complete', 'login_approval_required') - async with app.metadata.transaction(write=True) as tx: - intent_id, _ = await create_login_intent( - app, tx, verified, action=action, request_id=request_id, handle=handle - ) - request = _request(app, action, intent_id, request_id, handle=handle) - cookie = ( - b64( - hmac.digest( - app._token_secret, - b'msg-login-browser-v1:' - + intent_id.encode() - + b':' - + request.payload_digest.encode(), - 'sha256', - ) - ) - if action != 'remove' - else '' - ) - with trusted_login(app, verified, intent_id=intent_id, request=request, cookie=cookie): - result = await app.executor.execute(request) - completion = LoginCompletion(result) - if result.status == 'ok' and cookie: - async with app.metadata.transaction(write=False) as tx: - expiry, source = await OAuthService(app).session(tx, cookie) - require(source['subject'] == result.subject, 'login_owner_required') - completion.cookie, completion.expires = cookie, expiry - return completion - - -async def complete_login(app, verified, *, request_id, handle=None, intent_id=None): - return await _execute_login( - app, verified, action='complete', request_id=request_id, handle=handle, intent_id=intent_id - ) - - -async def bind_login( - app, verified, source=None, *, request_id, intent_id=None, session_cookie=None -): - if source is not None: - async with app.metadata.transaction(write=True) as tx: - prepared, _ = await create_login_intent( - app, tx, verified, action='bind', request_id=request_id, owner=source.get('subject') - ) - require(intent_id is None or prepared == intent_id, 'login_intent_mismatch') - intent_id = prepared - require(session_cookie is not None, 'login_approval_required') - require( - await approve_browser_login_intent(app, tx, session_cookie, intent_id), - 'login_approval_required', - ) - return await _execute_login( - app, verified, action='bind', request_id=request_id, intent_id=intent_id - ) - - -async def remove_login( - app, source=None, binding_id=None, *, request_id, intent_id=None, session_cookie=None -): - if source is not None: - async with app.metadata.transaction(write=True) as tx: - prepared, _ = await create_login_intent( - app, - tx, - None, - action='remove', - request_id=request_id, - owner=source.get('subject'), - binding_id=binding_id, - ) - require(intent_id is None or prepared == intent_id, 'login_intent_mismatch') - intent_id = prepared - require(session_cookie is not None, 'login_approval_required') - require( - await approve_browser_login_intent(app, tx, session_cookie, intent_id), - 'login_approval_required', - ) - return ( - await _execute_login(app, None, action='remove', request_id=request_id, intent_id=intent_id) - ).result diff --git a/src/msg/security/login_flow.py b/src/msg/security/login_flow.py deleted file mode 100644 index b7db4df1..00000000 --- a/src/msg/security/login_flow.py +++ /dev/null @@ -1,87 +0,0 @@ -"""绑定浏览器的短期 OAuth 事务;GET 回调只保存暂态,不修改账号。""" - -import hmac -import os -from datetime import timedelta - -from cryptography.exceptions import InvalidTag -from cryptography.hazmat.primitives.ciphers.aead import AESGCM - -from msg.core.codec import b64, unb64 -from msg.core.errors import Failure, require -from msg.login_config import OAUTH_PROVIDERS -from msg.security.oauth import get, put, save, secret, state_id - -TTL = 600 - - -def _seal(app, state, value): - nonce = os.urandom(12) - aad = ('msg-login-flow-v1:' + state).encode() - return b64(nonce + AESGCM(app._vault_key).encrypt(nonce, value.encode(), aad)) - - -def _open(app, state, value): - try: - raw = unb64(value, limit=8192) - return ( - AESGCM(app._vault_key) - .decrypt(raw[:12], raw[12:], ('msg-login-flow-v1:' + state).encode()) - .decode() - ) - except InvalidTag, UnicodeError, ValueError, Failure: - raise Failure('invalid_login_transaction') from None - - -def start_flow(app, tx, provider, browser, *, mode='login', source=None, handle=None): - require(provider in OAUTH_PROVIDERS, 'login_provider_disabled') - require(mode in {'login', 'register', 'bind'}, 'invalid_login_mode') - require(mode != 'register' or provider == 'google', 'login_registration_forbidden') - require(mode != 'bind' or source is not None, 'authentication_required') - state, verifier, nonce = secret(), secret(), secret() - body = { - 'provider': provider, - 'browser': state_id('browser', browser), - 'mode': mode, - 'source': source, - 'handle': handle, - 'verifier': _seal(app, state, verifier), - 'nonce': nonce, - 'phase': 'pending', - } - put(tx, state_id('login-flow', state), 'login-flow', app.clock() + timedelta(seconds=TTL), body) - return state, verifier, nonce - - -def flow(app, tx, state, browser, *, provider=None): - require(type(state) is str and len(state) == 43, 'invalid_login_transaction') - require(type(browser) is str and len(browser) >= 20, 'invalid_login_transaction') - _, body = get(tx, state_id('login-flow', state), app.clock()) - require( - hmac.compare_digest(body['browser'], state_id('browser', browser)) - and (provider is None or provider == body['provider']), - 'invalid_login_transaction', - ) - return body - - -def receive_callback(app, tx, state, browser, provider, code): - body = flow(app, tx, state, browser, provider=provider) - require(body['phase'] == 'pending', 'invalid_login_transaction') - require(type(code) is str and 1 <= len(code) <= 4096, 'invalid_login_transaction') - body.update(phase='returned', code=_seal(app, state, code)) - save(tx, state_id('login-flow', state), body) - - -def consume_flow(app, tx, state, browser): - body = flow(app, tx, state, browser) - require(body['phase'] == 'returned', 'invalid_login_transaction') - snapshot = dict( - body, code=_open(app, state, body['code']), verifier=_open(app, state, body['verifier']) - ) - # 先消耗事务再访问外部提供方;故障后重开流程,不重放外部授权码。 - body.update(phase='consumed') - body.pop('code', None) - body.pop('verifier', None) - save(tx, state_id('login-flow', state), body) - return snapshot diff --git a/src/msg/security/login_jwt.py b/src/msg/security/login_jwt.py deleted file mode 100644 index d15bb784..00000000 --- a/src/msg/security/login_jwt.py +++ /dev/null @@ -1,232 +0,0 @@ -"""Bounded, offline verification of identity tokens from pinned login providers.""" - -from __future__ import annotations - -import base64 -import hmac -import json -import math -import re -from collections.abc import Collection, Mapping -from dataclasses import dataclass -from datetime import datetime - -import jwt -from cryptography.hazmat.primitives.asymmetric.ec import SECP256R1, EllipticCurvePublicKey -from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey - -from msg.core.errors import Failure - -_MAX_TOKEN_BYTES = 16 * 1024 -_MAX_HEADER_BYTES = 2048 -_MAX_KEYS = 32 -_COMPACT_PART = re.compile(r'[A-Za-z0-9_-]+\Z') -_KEY_ID = re.compile(r'[\x21-\x7e]{1,128}\Z') -_REQUIRED_CLAIMS = ('iss', 'aud', 'exp', 'iat', 'sub', 'nonce') - - -@dataclass(frozen=True, slots=True) -class TokenIdentity: - issuer: str - subject: str - - -def _invalid() -> Failure: - return Failure('login_provider_invalid_token') - - -def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]: - result = {} - for key, value in pairs: - if key in result: - raise _invalid() - result[key] = value - return result - - -def _invalid_constant(_value: str) -> None: - raise _invalid() - - -def _json_part(part: str, *, limit: int) -> dict[str, object]: - raw = base64.urlsafe_b64decode(part + '=' * (-len(part) % 4)) - if len(raw) > limit: - raise _invalid() - value = json.loads( - raw.decode('utf-8'), object_pairs_hook=_unique_object, parse_constant=_invalid_constant - ) - if not isinstance(value, dict): - raise _invalid() - return value - - -def _token_parts(token: str) -> tuple[str, str, str]: - if not isinstance(token, str) or not token.isascii() or len(token) > _MAX_TOKEN_BYTES: - raise _invalid() - parts = token.split('.') - if len(parts) != 3 or not all(_COMPACT_PART.fullmatch(part) for part in parts): - raise _invalid() - return parts[0], parts[1], parts[2] - - -def token_key_id(token: str, *, algorithm: str = 'RS256') -> str: - """Read a bounded key ID with the caller-pinned algorithm; header URLs never cause a network request.""" - try: - header_part, _, _ = _token_parts(token) - header = _json_part(header_part, limit=_MAX_HEADER_BYTES) - key_id = header.get('kid') - if ( - algorithm not in {'RS256', 'ES256'} - or header.get('alg') != algorithm - or 'crit' in header - or header.get('b64', True) is not True - or not isinstance(key_id, str) - or not _KEY_ID.fullmatch(key_id) - ): - raise _invalid() - return key_id - except ValueError, TypeError, UnicodeError, RecursionError: - raise _invalid() from None - - -def _public_key( - jwks: Mapping[str, object], key_id: str, algorithm: str -) -> RSAPublicKey | EllipticCurvePublicKey: - if not isinstance(jwks, Mapping): - raise _invalid() - keys = jwks.get('keys') - if not isinstance(keys, list) or not 1 <= len(keys) <= _MAX_KEYS: - raise _invalid() - selected = None - seen = set() - for key in keys: - if not isinstance(key, dict): - raise _invalid() - candidate_id = key.get('kid') - if ( - not isinstance(candidate_id, str) - or not _KEY_ID.fullmatch(candidate_id) - or candidate_id in seen - or key.get('kty') != ('EC' if algorithm == 'ES256' else 'RSA') - or (algorithm == 'ES256' and key.get('crv') != 'P-256') - or key.get('use', 'sig') != 'sig' - or key.get('alg', algorithm) != algorithm - or ('key_ops' in key and key['key_ops'] != ['verify']) - or any( - private_field in key for private_field in ('d', 'p', 'q', 'dp', 'dq', 'qi', 'oth') - ) - ): - raise _invalid() - seen.add(candidate_id) - if candidate_id == key_id: - selected = key - if selected is None: - raise _invalid() - for field in ('x', 'y') if algorithm == 'ES256' else ('n', 'e'): - value = selected.get(field) - if ( - not isinstance(value, str) - or not 1 <= len(value) <= 1400 - or (algorithm == 'ES256' and len(value) != 43) - or not _COMPACT_PART.fullmatch(value) - ): - raise _invalid() - public_key = jwt.PyJWK.from_dict(selected, algorithm=algorithm).key - if algorithm == 'ES256': - if not isinstance(public_key, EllipticCurvePublicKey) or not isinstance( - public_key.curve, SECP256R1 - ): - raise _invalid() - elif not isinstance(public_key, RSAPublicKey) or not 2048 <= public_key.key_size <= 8192: - raise _invalid() - return public_key - - -def _string(value: object, *, limit: int) -> bool: - return isinstance(value, str) and 1 <= len(value) <= limit - - -def verify_identity_token( - token: str, - jwks: Mapping[str, object], - *, - issuers: Collection[str], - audience: str, - nonce: str, - now: datetime, - algorithm: str = 'RS256', -) -> TokenIdentity: - """Verify a real signature and return only the provider's immutable identity.""" - try: - key_id = token_key_id(token, algorithm=algorithm) - if ( - isinstance(issuers, str) - or not isinstance(issuers, Collection) - or not 1 <= len(issuers) <= 8 - or not all(_string(issuer, limit=512) for issuer in issuers) - or not _string(audience, limit=512) - or not _string(nonce, limit=1024) - or not isinstance(now, datetime) - or now.tzinfo is None - or now.utcoffset() is None - ): - raise _invalid() - timestamp = now.timestamp() - if not math.isfinite(timestamp): - raise _invalid() - # Reject ambiguous JSON before handing the signed bytes to the maintained verifier. - _, payload_part, _ = _token_parts(token) - _json_part(payload_part, limit=_MAX_TOKEN_BYTES) - claims = jwt.decode( - token, - _public_key(jwks, key_id, algorithm), - algorithms=[algorithm], - options={ - 'require': list(_REQUIRED_CLAIMS), - 'verify_signature': True, - 'verify_exp': False, - 'verify_iat': False, - 'verify_nbf': False, - 'verify_iss': False, - 'verify_aud': False, - 'verify_sub': False, - }, - ) - issuer, subject = claims['iss'], claims['sub'] - expires, issued = claims['exp'], claims['iat'] - claim_nonce, claim_audience = claims['nonce'], claims['aud'] - if ( - not _string(issuer, limit=512) - or issuer not in issuers - or not _string(subject, limit=255) - or not subject.isascii() - or type(expires) is not int - or type(issued) is not int - or not issued < expires - or expires <= timestamp - or issued > timestamp + 5 - or not _string(claim_nonce, limit=1024) - or not hmac.compare_digest(claim_nonce.encode('utf-8'), nonce.encode('utf-8')) - ): - raise _invalid() - if isinstance(claim_audience, str): - if claim_audience != audience: - raise _invalid() - elif isinstance(claim_audience, list): - if ( - not 1 <= len(claim_audience) <= 16 - or not all(_string(item, limit=512) for item in claim_audience) - or len(set(claim_audience)) != len(claim_audience) - or audience not in claim_audience - or (len(claim_audience) > 1 and claims.get('azp') != audience) - ): - raise _invalid() - else: - raise _invalid() - if 'azp' in claims and claims['azp'] != audience: - raise _invalid() - if 'nbf' in claims and (type(claims['nbf']) is not int or claims['nbf'] > timestamp): - raise _invalid() - return TokenIdentity(issuer=issuer, subject=subject) - except jwt.PyJWTError, ValueError, TypeError, UnicodeError, OverflowError, RecursionError: - raise _invalid() from None diff --git a/src/msg/security/oauth.py b/src/msg/security/oauth.py deleted file mode 100644 index 28db734f..00000000 --- a/src/msg/security/oauth.py +++ /dev/null @@ -1,763 +0,0 @@ -"""OAuth grants share MSG credentials, ceilings and current authority fences. - -Only digests of random authorization codes, refresh tokens and cookies persist. -Token responses never enter operation results, receipts, audit bodies or URLs. -The relational writer fence serializes grant consumption and refresh rotation. -""" - -import hashlib -import hmac -import secrets -from contextlib import contextmanager -from contextvars import ContextVar -from dataclasses import replace -from datetime import timedelta -from uuid import uuid4 - -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey -from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat - -from msg.core.codec import b64, canonical, decode, loads, parse_time, wire -from msg.core.errors import Failure, require -from msg.core.models import CapabilityGrant, Credential, Scope -from msg.oauth_config import MCP_SCOPE_OPERATIONS, MCP_SCOPES -from msg.security.browser_actions import BROWSER_POST_WRITES -from msg.security.policy import constraints_subset, scope_subset -from msg.security.quarantine import require_live_authority - -DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code' -CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' -_RESOURCE_EXECUTION = ContextVar('msg_oauth_resource_execution', default=None) - - -@contextmanager -def resource_execution(resource): - """Trusted adapter context, never derived from an operation packet field.""" - token = _RESOURCE_EXECUTION.set(resource) - try: - yield - finally: - _RESOURCE_EXECUTION.reset(token) - - -def secret(): - return secrets.token_urlsafe(32) - - -def state_id(kind, value): - return kind + ':' + hashlib.sha256(value.encode()).hexdigest() - - -def put(tx, id, kind, expires, body): - tx.execute( - 'INSERT INTO oauth_states VALUES (?,?,?,?) ON CONFLICT(id) DO UPDATE ' - 'SET expires=excluded.expires,body=excluded.body', - (id, kind, wire(expires), canonical(body).decode()), - write=True, - ) - - -def get(tx, id, now, *, expired=False): - row = tx.one('SELECT expires,body FROM oauth_states WHERE id=?', (id,)) - require(row is not None and (expired or parse_time(row[0]) > now), 'invalid_grant') - return parse_time(row[0]), loads(row[1]) - - -def save(tx, id, body): - tx.execute( - 'UPDATE oauth_states SET body=? WHERE id=?', (canonical(body).decode(), id), write=True - ) - - -def client_for(config, client_id): - require(config is not None and config.enabled, 'oauth_disabled') - client = next((c for c in config.clients if c.client_id == client_id), None) - require(client is not None, 'invalid_client') - return client - - -def scopes_for(client, value): - require(isinstance(value, str) and len(value) <= 200, 'invalid_scope') - scopes = frozenset(value.split()) - require(bool(scopes) and scopes <= client.scopes, 'invalid_scope') - return scopes - - -async def require_ceiling(tx, ceiling, parent): - # A derived credential is a limit, never a durable copy of authority which - # its signing source has since lost. Reuse the same containment vocabulary - # as certificate/delegation validation, including scope and constraints. - for grant in ceiling: - require( - any([ - allowed.capability == grant.capability - and allowed.version == grant.version - and grant.operations <= allowed.operations - and await scope_subset(grant.scope, allowed.scope, tx) - and constraints_subset(grant.constraints, allowed.constraints) - for allowed in parent - ]), - 'invalid_grant', - ) - - -async def require_source(tx, body, now, *, custodial_ceiling): - parent = await tx.credential(body['parent']) - subject = await tx.subject(body['subject']) - require( - parent.subject_id == subject.resource_id - and not subject.local_only - and subject.auth_version == body['auth_version'] - and parent.revoked_at is None - and parent.not_before <= now - and (parent.expires_at is None or parent.expires_at > now), - 'invalid_grant', - ) - if body.get('login_binding'): - binding = tx.one( - 'SELECT subject,source_credential_id,auth_version,generation,revoked_at,ceiling ' - 'FROM login_bindings WHERE id=?', - (body['login_binding'],), - ) - require( - binding is not None - and binding[:3] == (body['subject'], body['parent'], body['auth_version']) - and binding[3] == body.get('login_generation') - and binding[4] is None, - 'invalid_grant', - ) - await require_ceiling( - tx, - tuple(decode(CapabilityGrant, raw) for raw in body.get('ceiling', ())), - tuple(decode(CapabilityGrant, raw) for raw in loads(binding[5])), - ) - if body.get('custodial'): - row = tx.one( - 'SELECT signing_key_id,status FROM custodial_vault WHERE subject=?', - (subject.resource_id,), - ) - require(subject.kind == 'custodial' and row == (parent.id, 'active'), 'invalid_grant') - # Vault keys deliberately carry no request grants. Custodial sessions retain - # their captured token limits under the current temporary policy, while the - # vault key above remains only their live identity/revocation binding. - await require_ceiling( - tx, - tuple(decode(CapabilityGrant, raw) for raw in body.get('ceiling', ())), - custodial_ceiling if body.get('custodial') else parent.ceiling, - ) - if body.get('session'): - _, session = get(tx, body['session'], now) - require(not session.get('revoked'), 'invalid_grant') - await require_source(tx, session, now, custodial_ceiling=custodial_ceiling) - return subject - - -async def require_binding(tx, credential, now, config, *, custodial_ceiling, resource=None): - browser = tx.one('SELECT body FROM oauth_states WHERE id=?', ('browser:' + credential.id,)) - if browser is not None: - require(config is not None and config.enabled, 'oauth_disabled') - source = loads(browser[0]) - require( - credential.source_credential_id == source['parent'] - and credential.subject_id == source['subject'], - 'invalid_grant', - ) - await require_source(tx, source, now, custodial_ceiling=custodial_ceiling) - await require_ceiling( - tx, credential.ceiling, tuple(decode(CapabilityGrant, raw) for raw in source['ceiling']) - ) - return - api = tx.one('SELECT body FROM oauth_states WHERE id=?', ('api:' + credential.id,)) - if api is not None: - source = loads(api[0]) - require(credential.source_credential_id == source['parent'], 'invalid_grant') - await require_source(tx, source, now, custodial_ceiling=custodial_ceiling) - # API bindings predate OAuth families and store no captured ceiling. - # Their credential grants remain bounded by the current signing source. - await require_ceiling( - tx, credential.ceiling, (await tx.credential(source['parent'])).ceiling - ) - row = tx.one('SELECT body FROM oauth_states WHERE id=?', ('access:' + credential.id,)) - if row is None: - require( - not credential.id.startswith('t_oauth_') - and not credential.id.startswith('t_browser_') - and (credential.source_credential_id is None or api is not None), - 'invalid_grant', - ) - return - binding = loads(row[0]) - _, family = get(tx, binding['family'], now) - require(credential.source_credential_id == family['parent'], 'invalid_grant') - client = client_for(config, family['client_id']) - require(set(family['scopes']) <= client.scopes and not family.get('revoked'), 'invalid_grant') - if family.get('resource') is not None: - require(credential.subject_id == family['subject'], 'invalid_grant') - require(family['resource'] == (resource or _RESOURCE_EXECUTION.get()), 'invalid_grant') - require( - bool(set(family['scopes']) & MCP_SCOPES) - and not set(family['scopes']) & {'msg.read', 'msg.write'}, - 'invalid_grant', - ) - operations = frozenset().union( - *(MCP_SCOPE_OPERATIONS.get(scope, frozenset()) for scope in family['scopes']) - ) - require(all(g.operations <= operations for g in credential.ceiling), 'invalid_grant') - else: - require(not set(family['scopes']) & MCP_SCOPES, 'invalid_grant') - await require_source(tx, family, now, custodial_ceiling=custodial_ceiling) - await require_ceiling( - tx, credential.ceiling, tuple(decode(CapabilityGrant, raw) for raw in family['ceiling']) - ) - - -class OAuthService: - def __init__(self, app): - self.app = app - - @property - def config(self): - return self.app.settings.oauth - - def fence(self, tx): - require(self.config.enabled, 'oauth_disabled') - self.app.runtime_generation.require_current(tx) - require_live_authority(tx) - require(not self.app.executor.recovery_drill_active(), 'recovery_quarantined') - - def rate(self, tx, address): - now = self.app.clock() - self.fence(tx) - tx.execute('DELETE FROM oauth_states WHERE expires<=?', (wire(now),), write=True) - id = state_id('rate', address or 'unknown') - row = tx.one('SELECT body,expires FROM oauth_states WHERE id=?', (id,)) - count = loads(row[0])['count'] if row else 0 - require(count < 120, 'slow_down') - require(tx.one('SELECT COUNT(*) FROM oauth_states')[0] < 100000, 'temporarily_unavailable') - put( - tx, - id, - 'rate', - parse_time(row[1]) if row else now + timedelta(minutes=10), - {'count': count + 1}, - ) - - def pending( - self, - tx, - *, - kind, - client_id='msg-cli', - scope='openid profile msg.read msg.write offline_access', - ): - now = self.app.clock() - client = client_for(self.config, client_id) - scopes = scopes_for(client, scope) - self.resource_for(scopes, None) - code = ''.join(secrets.choice(CODE_ALPHABET) for _ in range(8)) - value = secret() - id = state_id('user', code) - expiry = now + timedelta(minutes=10) - body = { - 'kind': kind, - 'client_id': client_id, - 'scopes': sorted(scopes), - 'status': 'pending', - 'poll': state_id('poll', value), - 'interval': 5, - 'next_poll': wire(now), - } - put(tx, id, 'pending', expiry, body) - put(tx, body['poll'], 'poll', expiry, {'user': id}) - return value, code, expiry - - async def source(self, tx, principal): - require( - principal.actor == principal.subject and principal.subject is not None, - 'identity_owner_required', - ) - subject = await tx.subject(principal.subject) - require( - subject.kind in {'registered', 'custodial'} and not subject.local_only, - 'registered_identity_required', - ) - require( - principal.method == 'signature' - or (subject.kind == 'custodial' and principal.method == 'token'), - 'signature_required', - ) - parent = principal.credential_id - if subject.kind == 'custodial': - row = tx.one( - 'SELECT signing_key_id,status FROM custodial_vault WHERE subject=?', - (subject.resource_id,), - ) - require(row is not None and row[1] == 'active', 'custodial_vault_unavailable') - parent = row[0] - return { - 'subject': subject.resource_id, - 'parent': parent, - 'auth_version': subject.auth_version, - 'custodial': subject.kind == 'custodial', - 'ceiling': wire(principal.ceiling), - 'auth_time': int(self.app.clock().timestamp()), - } - - async def approve(self, tx, principal, code, decision): - self.fence(tx) - code = code.upper().replace('-', '') - require(len(code) == 8 and all(c in CODE_ALPHABET for c in code), 'invalid_grant') - id = state_id('user', code) - _, body = get(tx, id, self.app.clock()) - require(body['status'] == 'pending', 'invalid_grant') - client_for(self.config, body['client_id']) - if body.get('login_intent'): - require( - principal.actor == principal.subject == body['expected_subject'], - 'login_owner_required', - ) - if body.get('login_intent') and decision == 'approve': - from msg.security.login import approve_login_intent - - await approve_login_intent(self.app, tx, principal, body['login_intent']) - elif body.get('login_intent'): - _, intent = get(tx, body['login_intent'], self.app.clock()) - require(intent['status'] == 'pending', 'invalid_grant') - intent['status'] = 'denied' - save(tx, body['login_intent'], intent) - source = await self.source(tx, principal) - body.update(source if decision == 'approve' else {}) - body['status'] = 'approved' if decision == 'approve' else 'denied' - save(tx, id, body) - return { - 'client_id': body['client_id'], - 'scopes': body['scopes'], - 'kind': body['kind'], - 'status': body['status'], - } - - async def pending_result(self, tx, value, client_id, kind): - now = self.app.clock() - _, locator = get(tx, state_id('poll', value), now) - expiry, body = get(tx, locator['user'], now) - require(body['client_id'] == client_id and body['kind'] == kind, 'invalid_grant') - require(body['status'] != 'consumed', 'invalid_grant') - if parse_time(body['next_poll']) > now: - body['interval'] += 5 - body['next_poll'] = wire(now + timedelta(seconds=body['interval'])) - save(tx, locator['user'], body) - return None, 'slow_down' - body['next_poll'] = wire(now + timedelta(seconds=body['interval'])) - save(tx, locator['user'], body) - if body['status'] == 'pending': - return None, 'authorization_pending' - if body['status'] == 'denied': - return None, 'access_denied' - await require_source(tx, body, now, custodial_ceiling=self.app.temporary_ceiling()) - body['status'] = 'consumed' - save(tx, locator['user'], body) - if kind == 'login': - return await self.create_browser_session(tx, body), None - return await self.tokens(tx, body), None - - async def create_browser_session(self, tx, source, *, cookie=None, ttl=None): - self.fence(tx) - now = self.app.clock() - await require_source(tx, source, now, custodial_ceiling=self.app.temporary_ceiling()) - require(ttl is None or type(ttl) is int and 60 <= ttl <= 3600, 'invalid_session_ttl') - body = dict(source) - cookie = cookie or secret() - session_expiry = now + timedelta( - seconds=self.config.session_ttl if ttl is None else min(ttl, self.config.session_ttl) - ) - parent = await tx.credential(body['parent']) - if parent.expires_at is not None: - session_expiry = min(session_expiry, parent.expires_at) - session_id = state_id('session', cookie) - credential_id = 't_browser_' + uuid4().hex - operations = { - f'{op.name}@{op.version}' - for op in self.app.registry.operations() - if ( - op.effect == 'read' - or ( - 'msg.write' in body['scopes'] - and op.name in BROWSER_POST_WRITES - and op.version == 1 - ) - ) - and not op.require_signature - and not op.anonymous_only - and not op.name.startswith(('identity.', 'root.', 'system.')) - } - wiki_writes = {'content.post_create@1', 'content.post_edit@1'} - wiki_scope = Scope(resource_id='t_wiki', descendants=True) - ceiling_items = [] - for raw in body['ceiling']: - grant = decode(CapabilityGrant, raw) - general = grant.operations & (operations - wiki_writes) - if general: - ceiling_items.append(replace(grant, operations=general)) - shared = grant.operations & operations & wiki_writes - if shared: - if await scope_subset(wiki_scope, grant.scope, tx): - ceiling_items.append(replace(grant, operations=shared, scope=wiki_scope)) - elif await scope_subset(grant.scope, wiki_scope, tx): - ceiling_items.append(replace(grant, operations=shared)) - ceiling = tuple(ceiling_items) - await tx.save_credential( - Credential( - id=credential_id, - subject_id=body['subject'], - kind='token', - verifier=hashlib.sha256(self.browser_secret(cookie)).digest(), - ceiling=ceiling, - not_before=now, - expires_at=session_expiry, - revoked_at=None, - source_credential_id=body['parent'], - ), - body['auth_version'], - ) - body['browser_credential'] = credential_id - put(tx, session_id, 'session', session_expiry, body) - put( - tx, - 'browser:' + credential_id, - 'browser', - session_expiry, - dict(body, session=session_id), - ) - return {'cookie': cookie, 'expires': session_expiry} - - async def session(self, tx, cookie): - self.fence(tx) - id = state_id('session', cookie) - expiry, body = get(tx, id, self.app.clock()) - require(not body.get('revoked'), 'invalid_grant') - await require_source( - tx, body, self.app.clock(), custodial_ceiling=self.app.temporary_ceiling() - ) - source = { - name: body[name] - for name in ('subject', 'parent', 'auth_version', 'custodial', 'ceiling', 'auth_time') - } - return expiry, dict(source, session=id) - - def browser_secret(self, cookie): - return hmac.digest( - self.app._token_secret, b'msg-browser-read-v1:' + cookie.encode(), 'sha256' - ) - - async def browser_credentials(self, tx, cookie): - _, source = await self.session(tx, cookie) - _, body = get(tx, source['session'], self.app.clock()) - require('browser_credential' in body, 'invalid_grant') - credential = await tx.credential(body['browser_credential']) - require( - credential.revoked_at is None and credential.expires_at > self.app.clock(), - 'invalid_grant', - ) - await require_binding( - tx, - credential, - self.app.clock(), - self.config, - custodial_ceiling=self.app.temporary_ceiling(), - ) - return source['subject'], credential.id, self.browser_secret(cookie) - - def resource_for(self, scopes, resource): - if resource is None: - require(not scopes & MCP_SCOPES, 'invalid_target') - return None - require(resource == self.app.settings.service_url + '/-/mcp', 'invalid_target') - require( - bool(scopes & MCP_SCOPES) and not scopes & {'msg.read', 'msg.write'}, - 'invalid_scope', - ) - return resource - - def require_resource(self, source, args): - resource = self.resource_for(frozenset(source['scopes']), source.get('resource')) - require(args.get('resource') == resource, 'invalid_target') - return resource - - def authorization(self, args): - require( - set(args) - <= { - 'response_type', - 'client_id', - 'redirect_uri', - 'scope', - 'state', - 'code_challenge', - 'code_challenge_method', - 'nonce', - 'resource', - }, - 'invalid_request', - ) - client = client_for(self.config, args.get('client_id')) - require(args.get('redirect_uri') in client.redirect_uris, 'invalid_redirect_uri') - require(args.get('response_type') == 'code', 'unsupported_response_type') - require(args.get('code_challenge_method') == 'S256', 'invalid_request') - challenge = args.get('code_challenge', '') - require( - len(challenge) == 43 and all(c.isalnum() or c in '-_' for c in challenge), - 'invalid_request', - ) - require( - isinstance(args.get('state'), str) and 16 <= len(args['state']) <= 512, - 'invalid_request', - ) - require('nonce' not in args or 16 <= len(args['nonce']) <= 512, 'invalid_request') - scopes = scopes_for(client, args.get('scope', 'openid profile')) - self.resource_for(scopes, args.get('resource')) - return client - - async def authorize(self, tx, cookie, args): - self.authorization(args) - expiry, source = await self.session(tx, cookie) - value = secret() - body = dict( - source, - client_id=args['client_id'], - scopes=sorted(args['scope'].split()), - redirect_uri=args['redirect_uri'], - challenge=args['code_challenge'], - nonce=args.get('nonce'), - resource=args.get('resource'), - ) - put( - tx, - state_id('code', value), - 'code', - min(expiry, self.app.clock() + timedelta(seconds=60)), - body, - ) - return value - - async def exchange(self, tx, args): - client_for(self.config, args.get('client_id')) - now = self.app.clock() - grant = args.get('grant_type') - if grant == DEVICE_GRANT: - require('resource' not in args and 'scope' not in args, 'invalid_request') - return await self.pending_result( - tx, args.get('device_code', ''), args['client_id'], 'device' - ) - if grant == 'authorization_code': - id = state_id('code', args.get('code', '')) - _, body = get(tx, id, now) - self.require_resource(body, args) - require('scope' not in args, 'invalid_request') - if body.get('consumed') and body['client_id'] == args['client_id']: - _, family = get(tx, body['family'], now) - family['revoked'] = True - save(tx, body['family'], family) - return None, 'invalid_grant' - verifier = args.get('code_verifier', '') - require( - 43 <= len(verifier) <= 128 and all(c.isalnum() or c in '-._~' for c in verifier), - 'invalid_grant', - ) - require( - not body.get('consumed') - and body['client_id'] == args['client_id'] - and body['redirect_uri'] == args.get('redirect_uri') - and hmac.compare_digest( - body['challenge'], b64(hashlib.sha256(verifier.encode()).digest()) - ), - 'invalid_grant', - ) - await require_source(tx, body, now, custodial_ceiling=self.app.temporary_ceiling()) - body['consumed'] = True - body['family'] = 'family:' + uuid4().hex - save(tx, id, body) - expiry = now + timedelta(seconds=self.config.session_ttl) - if body.get('session'): - expiry = min(expiry, get(tx, body['session'], now)[0]) - parent = await tx.credential(body['parent']) - if parent.expires_at is not None: - expiry = min(expiry, parent.expires_at) - put(tx, body['family'], 'family', expiry, body) - return await self.tokens(tx, body, family_id=body['family'], expiry=expiry), None - if grant == 'refresh_token': - id = state_id('refresh', args.get('refresh_token', '')) - _, old = get(tx, id, now) - expiry, family = get(tx, old['family'], now) - require(family['client_id'] == args['client_id'], 'invalid_grant') - self.require_resource(family, args) - if old.get('used'): - family['revoked'] = True - save(tx, old['family'], family) - return None, 'invalid_grant' - require(not family.get('revoked'), 'invalid_grant') - client = client_for(self.config, family['client_id']) - require(set(family['scopes']) <= client.scopes, 'invalid_scope') - await require_source(tx, family, now, custodial_ceiling=self.app.temporary_ceiling()) - if 'scope' in args: - scopes = scopes_for(client, args['scope']) - require(scopes <= set(family['scopes']), 'invalid_scope') - self.resource_for(scopes, family.get('resource')) - family['scopes'] = sorted(scopes) - save(tx, old['family'], family) - old['used'] = True - save(tx, id, old) - # A refresh does not extend the family's absolute lifetime. - return await self.tokens(tx, family, family_id=old['family'], expiry=expiry), None - return None, 'unsupported_grant_type' - - async def tokens(self, tx, source, *, family_id=None, expiry=None): - now = self.app.clock() - subject = await require_source( - tx, source, now, custodial_ceiling=self.app.temporary_ceiling() - ) - if family_id is None: - family_id = 'family:' + uuid4().hex - expiry = now + timedelta(seconds=self.config.session_ttl) - parent = await tx.credential(source['parent']) - if parent.expires_at is not None: - expiry = min(expiry, parent.expires_at) - if source.get('session'): - expiry = min(expiry, get(tx, source['session'], now)[0]) - put(tx, family_id, 'family', expiry, source) - credential_id, value = 't_oauth_' + uuid4().hex, secrets.token_bytes(32) - operations = { - f'{op.name}@{op.version}' - for op in self.app.registry.operations() - if not op.require_signature - and not op.name.startswith(('identity.', 'root.', 'system.')) - and ( - ('msg.read' in source['scopes'] and op.effect == 'read') - or ('msg.write' in source['scopes'] and op.effect != 'read') - ) - } - for scope in source['scopes']: - operations.update(MCP_SCOPE_OPERATIONS.get(scope, ())) - if source.get('resource') is not None: - self.resource_for(frozenset(source['scopes']), source['resource']) - operations &= { - f'{op.name}@{op.version}' - for op in self.app.registry.operations() - if not op.require_signature and not op.anonymous_only - } - ceiling = tuple( - replace(g, operations=g.operations & operations) - for raw in source['ceiling'] - if (g := decode(CapabilityGrant, raw)).operations & operations - ) - access_expiry = min(expiry, now + timedelta(seconds=self.config.access_ttl)) - credential = Credential( - id=credential_id, - subject_id=subject.resource_id, - kind='token', - verifier=hashlib.sha256(value).digest(), - ceiling=ceiling, - not_before=now, - expires_at=access_expiry, - revoked_at=None, - source_credential_id=source['parent'], - ) - await tx.save_credential(credential, subject.auth_version) - put(tx, 'access:' + credential.id, 'access', access_expiry, {'family': family_id}) - result = { - 'access_token': credential.id + '.' + b64(value), - 'token_type': 'Bearer', - 'expires_in': int((access_expiry - now).total_seconds()), - 'subject_id': subject.resource_id, - 'scope': ' '.join(source['scopes']), - } - if source.get('resource') is not None: - result['resource'] = source['resource'] - if 'offline_access' in source['scopes']: - refresh = secret() - put( - tx, - state_id('refresh', refresh), - 'refresh', - expiry, - {'family': family_id, 'used': False}, - ) - result.update(refresh_token=refresh, refresh_expires_at=wire(expiry)) - if 'openid' in source['scopes']: - result['id_token'] = self.id_token(source, access_expiry) - return result - - def signing_key(self): - return Ed25519PrivateKey.from_private_bytes( - hmac.digest(self.app._token_secret, b'oauth-oidc-signing-v1', 'sha256') - ) - - def jwk(self): - public = self.signing_key().public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) - return { - 'kty': 'OKP', - 'crv': 'Ed25519', - 'use': 'sig', - 'alg': 'EdDSA', - 'kid': hashlib.sha256(public).hexdigest()[:32], - 'x': b64(public), - } - - def id_token(self, source, expiry): - payload = { - 'iss': self.app.settings.service_url, - 'sub': source['subject'], - 'aud': source['client_id'], - 'iat': int(self.app.clock().timestamp()), - 'exp': int(expiry.timestamp()), - 'auth_time': source['auth_time'], - } - if source.get('nonce'): - payload['nonce'] = source['nonce'] - head = b64(canonical({'alg': 'EdDSA', 'typ': 'JWT', 'kid': self.jwk()['kid']})) - body = b64(canonical(payload)) - value = (head + '.' + body).encode() - return value.decode() + '.' + b64(self.signing_key().sign(value)) - - async def bearer(self, tx, value, *, resource=None, check_resource=True): - from msg.core.codec import unb64 - - id, sep, token = value.partition('.') - require(sep and len(value) <= 256, 'invalid_token') - try: - credential = await tx.credential(id) - except Failure: - raise Failure('invalid_token') from None - now = self.app.clock() - try: - decoded = unb64(token, limit=32) - except Failure: - raise Failure('invalid_token') from None - require( - len(decoded) == 32 - and credential.kind == 'token' - and credential.revoked_at is None - and credential.not_before <= now - and (credential.expires_at is None or credential.expires_at > now) - and hmac.compare_digest(credential.verifier, hashlib.sha256(decoded).digest()), - 'invalid_token', - ) - try: - row = tx.one('SELECT body FROM oauth_states WHERE id=?', ('access:' + id,)) - family = get(tx, loads(row[0])['family'], now)[1] if row else {} - await require_binding( - tx, - credential, - now, - self.config, - custodial_ceiling=self.app.temporary_ceiling(), - resource=resource if check_resource else family.get('resource'), - ) - if check_resource: - require(family.get('resource') == resource, 'invalid_token') - if resource is not None: - self.resource_for(frozenset(family['scopes']), resource) - except Failure as exc: - if exc.code in {'recovery_runtime_stale', 'recovery_quarantined'}: - raise - raise Failure('invalid_token') from None - return credential diff --git a/src/msg/security/passkey.py b/src/msg/security/passkey.py deleted file mode 100644 index 6acc02a7..00000000 --- a/src/msg/security/passkey.py +++ /dev/null @@ -1,402 +0,0 @@ -"""WebAuthn verification and one-use ceremony facts for trusted login adapters. - -The login executor owns binding and session writes. Its passkey branch calls -finalize_* in that transaction; HTTP never commits counters or consumes a -challenge before the account operation commits. -""" - -from __future__ import annotations - -import hmac -from collections.abc import Mapping -from contextlib import contextmanager -from contextvars import ContextVar -from dataclasses import dataclass -from datetime import timedelta -from hashlib import sha256 -from json import dumps -from urllib.parse import urlsplit - -from cryptography.hazmat.primitives.asymmetric import ec -from fido2.server import Fido2Server -from fido2.webauthn import ( - AttestedCredentialData, - AuthenticationResponse, - RegistrationResponse, -) - -from msg.core.codec import b64, canonical, digest, freeze_json, loads, unb64, wire -from msg.core.errors import Failure, require -from msg.security.oauth import get, save, secret, state_id - -CHALLENGE_TTL = 180 -MAX_RESPONSE_BYTES = 32768 -ALGORITHMS = frozenset({-7}) # ES256; validate the requested key and curve before binding. -PROVIDER_DATA_FIELDS = frozenset({ - 'credential_data', - 'sign_count', - 'user_handle', - 'backup_eligible', - 'backup_state', -}) - - -def user_handle(subject): - require(isinstance(subject, str) and 1 <= len(subject) <= 160, 'invalid_passkey_subject') - return sha256(b'msg-passkey-user-v1:' + subject.encode()).digest() - - -def _client_response(value, mode): - require(isinstance(value, Mapping), 'invalid_passkey_response') - require(len(canonical(value)) <= MAX_RESPONSE_BYTES, 'invalid_passkey_response') - require(value.get('type') == 'public-key', 'invalid_passkey_response') - require(value.get('id') == value.get('rawId'), 'invalid_passkey_response') - credential_id = unb64(value.get('rawId'), limit=1023) - require(bool(credential_id), 'invalid_passkey_response') - response = value.get('response') - require(isinstance(response, Mapping), 'invalid_passkey_response') - client_data = loads(unb64(response.get('clientDataJSON'), limit=4096)) - require(isinstance(client_data, Mapping), 'invalid_passkey_response') - require(client_data.get('crossOrigin', False) is False, 'invalid_passkey_response') - require('topOrigin' not in client_data, 'invalid_passkey_response') - require(len(unb64(client_data.get('challenge'), limit=64)) == 32, 'invalid_passkey_response') - if mode == 'registration': - unb64(response.get('attestationObject'), limit=16384) - return RegistrationResponse.from_dict(value) - unb64(response.get('authenticatorData'), limit=8192) - unb64(response.get('signature'), limit=2048) - unb64(response.get('userHandle'), limit=64) - return AuthenticationResponse.from_dict(value) - - -def _state(value): - require( - isinstance(value, Mapping) - and set(value) == {'challenge', 'user_verification', 'origin', 'rp_id', 'algorithms'} - and value['user_verification'] == 'required' - and isinstance(value['origin'], str) - and isinstance(value['rp_id'], str) - and value['algorithms'] == sorted(ALGORITHMS) - and len(unb64(value['challenge'], limit=64)) == 32, - 'invalid_passkey_challenge', - ) - return value - - -def _public_key(key): - """Validate keys now, including coordinates skipped by attestation=none.""" - require(key.get(3) in ALGORITHMS, 'invalid_passkey_response') - require(key.get(1) == 2 and key.get(-1) == 1, 'invalid_passkey_response') - require( - type(key.get(-2)) is bytes - and len(key[-2]) == 32 - and type(key.get(-3)) is bytes - and len(key[-3]) == 32, - 'invalid_passkey_response', - ) - ec.EllipticCurvePublicNumbers( - int.from_bytes(key[-2], 'big'), int.from_bytes(key[-3], 'big'), ec.SECP256R1() - ).public_key() - - -def _backup(auth_data): - eligible, backed_up = auth_data.is_backup_eligible(), auth_data.is_backed_up() - require(eligible or not backed_up, 'invalid_passkey_response') - return eligible, backed_up - - -def _stored(value, subject): - require( - isinstance(value, Mapping) and set(value) == PROVIDER_DATA_FIELDS, 'invalid_passkey_binding' - ) - require( - type(value['sign_count']) is int and 0 <= value['sign_count'] <= 0xFFFFFFFF, - 'invalid_passkey_binding', - ) - require( - type(value['backup_eligible']) is bool and type(value['backup_state']) is bool, - 'invalid_passkey_binding', - ) - require(value['backup_eligible'] or not value['backup_state'], 'invalid_passkey_binding') - require( - hmac.compare_digest(unb64(value['user_handle'], limit=64), user_handle(subject)), - 'invalid_passkey_binding', - ) - credential = AttestedCredentialData(unb64(value['credential_data'], limit=4096)) - require(credential.public_key.ALGORITHM in ALGORITHMS, 'invalid_passkey_binding') - _public_key(credential.public_key) - return credential - - -@dataclass(frozen=True, slots=True, repr=False) -class PasskeyProof: - mode: str - subject: str - credential_id: str - challenge: str - provider_data: Mapping - previous_data_digest: str | None = None - state_digest: str = '' - - -class PasskeyServer: - def __init__(self, origin): - try: - parsed = urlsplit(origin) - valid = ( - isinstance(origin, str) - and len(origin) <= 2000 - and bool(parsed.hostname) - and not parsed.username - and not parsed.password - and not parsed.path - and not parsed.query - and not parsed.fragment - and not any(ord(character) <= 32 for character in origin) - and '\\' not in origin - and parsed.port != 0 - and ( - parsed.scheme == 'https' - or ( - parsed.scheme == 'http' - and parsed.hostname in {'localhost', '127.0.0.1', '::1'} - ) - ) - ) - except TypeError, ValueError: - valid = False - require(valid, 'invalid_passkey_origin') - self.origin, self.rp_id = origin, parsed.hostname - self.server = Fido2Server( - {'id': self.rp_id, 'name': 'MSG'}, - attestation='none', - verify_origin=lambda value: value == self.origin, - ) - self.server.allowed_algorithms = [ - parameter for parameter in self.server.allowed_algorithms if parameter.alg in ALGORITHMS - ] - self.server.timeout = CHALLENGE_TTL * 1000 - - def _issued_state(self, state): - return dict( - wire(state), origin=self.origin, rp_id=self.rp_id, algorithms=sorted(ALGORITHMS) - ) - - def _verify_state(self, state): - state = _state(state) - require( - state['origin'] == self.origin and state['rp_id'] == self.rp_id, - 'invalid_passkey_challenge', - ) - return state - - def registration_options(self, subject, name, credentials=()): - existing = [ - AttestedCredentialData(unb64(value['credential_data'], limit=4096)) - for value in credentials - ] - options, state = self.server.register_begin( - {'id': user_handle(subject), 'name': name, 'displayName': name}, - existing, - resident_key_requirement='required', - user_verification='required', - ) - return loads(dumps(dict(options))), self._issued_state(state) - - def authentication_options(self): - # Discoverable credentials avoid publishing per-account credential ID lists. - options, state = self.server.authenticate_begin(user_verification='required') - return loads(dumps(dict(options))), self._issued_state(state) - - def verify_registration(self, state, response, subject): - try: - state = self._verify_state(state) - parsed = _client_response(response, 'registration') - auth_data = self.server.register_complete(state, parsed) - credential = auth_data.credential_data - require( - credential is not None and credential.credential_id == parsed.raw_id, - 'invalid_passkey_response', - ) - require(credential.public_key.ALGORITHM in ALGORITHMS, 'invalid_passkey_response') - _public_key(credential.public_key) - eligible, backed_up = _backup(auth_data) - data = freeze_json({ - 'credential_data': b64(bytes(credential)), - 'sign_count': auth_data.counter, - 'user_handle': b64(user_handle(subject)), - 'backup_eligible': eligible, - 'backup_state': backed_up, - }) - return PasskeyProof( - 'registration', - subject, - b64(parsed.raw_id), - state['challenge'], - data, - state_digest=digest(state), - ) - except Failure: - raise - except ( - ValueError, - TypeError, - KeyError, - AssertionError, - IndexError, - RecursionError, - OverflowError, - ): - raise Failure('invalid_passkey_response') from None - - def verify_authentication(self, state, response, subject, provider_data): - try: - state = self._verify_state(state) - parsed = _client_response(response, 'authentication') - credential = _stored(provider_data, subject) - require(parsed.raw_id == credential.credential_id, 'invalid_passkey_response') - require( - parsed.response.user_handle is not None - and hmac.compare_digest(parsed.response.user_handle, user_handle(subject)), - 'invalid_passkey_response', - ) - self.server.authenticate_complete(state, [credential], parsed) - auth_data = parsed.response.authenticator_data - eligible, backed_up = _backup(auth_data) - require(eligible == provider_data['backup_eligible'], 'invalid_passkey_response') - previous, current = provider_data['sign_count'], auth_data.counter - require( - (previous == 0 and current == 0) or current > previous, 'passkey_counter_changed' - ) - data = freeze_json(dict(provider_data, sign_count=current, backup_state=backed_up)) - return PasskeyProof( - 'authentication', - subject, - b64(parsed.raw_id), - state['challenge'], - data, - digest(provider_data), - digest(state), - ) - except Failure: - raise - except ( - ValueError, - TypeError, - KeyError, - AssertionError, - IndexError, - RecursionError, - OverflowError, - ): - raise Failure('invalid_passkey_response') from None - - -def begin_challenge(tx, state, *, mode, now, flow, subject=None): - require(mode in {'registration', 'authentication'}, 'invalid_passkey_challenge') - require(isinstance(flow, str) and 32 <= len(flow) <= 512, 'invalid_passkey_challenge') - if mode == 'registration': - user_handle(subject) - else: - require(subject is None, 'invalid_passkey_challenge') - challenge_id = secret() - body = { - 'kind': 'passkey', - 'mode': mode, - 'state': wire(_state(state)), - 'subject': subject, - 'flow_hash': digest(('msg-passkey-flow-v1', flow)), - 'consumed_at': None, - } - # Random IDs are insert-only: even a collision cannot replace a pending ceremony. - tx.execute( - 'INSERT INTO oauth_states VALUES (?,?,?,?)', - ( - state_id('passkey', challenge_id), - 'passkey', - wire(now + timedelta(seconds=CHALLENGE_TTL)), - canonical(body).decode(), - ), - write=True, - ) - return challenge_id - - -def read_challenge(tx, challenge_id, *, mode, now, flow, subject=None): - require( - isinstance(challenge_id, str) and 32 <= len(challenge_id) <= 64, 'invalid_passkey_challenge' - ) - require(isinstance(flow, str) and 32 <= len(flow) <= 512, 'invalid_passkey_challenge') - _, body = get(tx, state_id('passkey', challenge_id), now) - kind = tx.one('SELECT kind FROM oauth_states WHERE id=?', (state_id('passkey', challenge_id),)) - require( - kind is not None and kind[0] == 'passkey' and body.get('kind') == 'passkey', - 'invalid_passkey_challenge', - ) - require( - body.get('mode') == mode and body.get('consumed_at') is None, 'invalid_passkey_challenge' - ) - require( - hmac.compare_digest(body.get('flow_hash', ''), digest(('msg-passkey-flow-v1', flow))), - 'invalid_passkey_challenge', - ) - require(body.get('subject') == subject, 'invalid_passkey_challenge') - _state(body.get('state')) - return body - - -@dataclass(frozen=True, slots=True, repr=False) -class _Evidence: - proof: PasskeyProof - challenge_id: str - flow: str - - -_PASSKEY_EVIDENCE = ContextVar('msg_verified_passkey', default=None) - - -@contextmanager -def trusted_passkey(proof, *, challenge_id, flow): - require(isinstance(proof, PasskeyProof), 'passkey_verification_required') - token = _PASSKEY_EVIDENCE.set(_Evidence(proof, challenge_id, flow)) - try: - yield - finally: - _PASSKEY_EVIDENCE.reset(token) - - -def _finalize(tx, verified, subject, mode, now): - evidence = _PASSKEY_EVIDENCE.get() - require(evidence is not None, 'passkey_verification_required') - proof = evidence.proof - require( - verified.provider == 'passkey' - and verified.sub == proof.credential_id - and proof.mode == mode - and proof.subject == subject, - 'passkey_verification_required', - ) - body = read_challenge( - tx, - evidence.challenge_id, - mode=mode, - now=now, - flow=evidence.flow, - subject=subject if mode == 'registration' else None, - ) - require(body['state']['challenge'] == proof.challenge, 'invalid_passkey_challenge') - require(digest(body['state']) == proof.state_digest, 'invalid_passkey_challenge') - return evidence, body - - -def finalize_registration(tx, verified, subject, *, now): - evidence, body = _finalize(tx, verified, subject, 'registration', now) - save(tx, state_id('passkey', evidence.challenge_id), dict(body, consumed_at=wire(now))) - return wire(evidence.proof.provider_data) - - -def finalize_authentication(tx, verified, subject, provider_data, *, now): - evidence, body = _finalize(tx, verified, subject, 'authentication', now) - require(digest(provider_data) == evidence.proof.previous_data_digest, 'passkey_state_changed') - save(tx, state_id('passkey', evidence.challenge_id), dict(body, consumed_at=wire(now))) - return wire(evidence.proof.provider_data) diff --git a/src/msg/security/policy.py b/src/msg/security/policy.py index c946eea2..f3700219 100644 --- a/src/msg/security/policy.py +++ b/src/msg/security/policy.py @@ -8,6 +8,21 @@ CERTGATE, SETGID, STICKY = 0o4000, 0o2000, 0o1000 +# The new browser-only operation is a strict subset of the former approval. +# This finite rename preserves existing signed grants without changing their +# bytes, reviving endpoints, adding authority, or accepting provider assertions. +_OPERATION_ENTITLEMENTS = { + 'identity.session_request@1': 'identity.oauth_request@1', + 'identity.session_approve@1': 'identity.oauth_approve@1', +} + + +def operations_cover(allowed, required): + return {_OPERATION_ENTITLEMENTS.get(op, op) for op in required} <= { + _OPERATION_ENTITLEMENTS.get(op, op) for op in allowed + } + + def selected_class(resource, subject, memberships): if subject is not None and subject == resource.owner: return 'owner' @@ -55,7 +70,7 @@ async def grant_covers(grant, capability, operation, resource_id, session): return ( grant.capability == capability and grant.version == 1 - and operation in grant.operations + and operations_cover(grant.operations, {operation}) and await scope_contains(grant.scope, resource_id, session) ) diff --git a/src/msg/security/sessions.py b/src/msg/security/sessions.py new file mode 100644 index 00000000..9068b68f --- /dev/null +++ b/src/msg/security/sessions.py @@ -0,0 +1,362 @@ +"""Key-approved browser sessions and the source limits of native API credentials. + +Cookies only resume an explicitly signed, short-lived browser grant. No external +identity, authorization server, refresh token or third-party callback is trusted. +Only cookie digests persist. All grants recheck their source on each request. +""" + +import hashlib +import hmac +import secrets +from dataclasses import replace +from datetime import timedelta +from uuid import uuid4 + +from msg.core.codec import canonical, decode, loads, parse_time, wire +from msg.core.errors import require +from msg.core.models import CapabilityGrant, Credential, Scope +from msg.security.browser_actions import BROWSER_POST_WRITES +from msg.security.policy import constraints_subset, operations_cover, scope_subset +from msg.security.quarantine import require_live_authority + +CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + + +def secret(): + return secrets.token_urlsafe(32) + + +def state_id(kind, value): + return kind + ':' + hashlib.sha256(value.encode()).hexdigest() + + +def put(tx, id, kind, expires, body): + tx.execute( + 'INSERT INTO credential_states VALUES (?,?,?,?) ON CONFLICT(id) DO UPDATE ' + 'SET expires=excluded.expires,body=excluded.body', + (id, kind, wire(expires), canonical(body).decode()), + write=True, + ) + + +def get(tx, id, now, *, expired=False): + row = tx.one('SELECT expires,body FROM credential_states WHERE id=?', (id,)) + require(row is not None and (expired or parse_time(row[0]) > now), 'invalid_grant') + return parse_time(row[0]), loads(row[1]) + + +def save(tx, id, body): + tx.execute( + 'UPDATE credential_states SET body=? WHERE id=?', (canonical(body).decode(), id), write=True + ) + + +async def require_ceiling(tx, ceiling, parent): + # A derived credential is a limit, never a durable copy of authority which + # its signing source has since lost. Reuse the same containment vocabulary + # as certificate/delegation validation, including scope and constraints. + for grant in ceiling: + require( + any([ + allowed.capability == grant.capability + and allowed.version == grant.version + and operations_cover(allowed.operations, grant.operations) + and await scope_subset(grant.scope, allowed.scope, tx) + and constraints_subset(grant.constraints, allowed.constraints) + for allowed in parent + ]), + 'invalid_grant', + ) + + +async def require_source(tx, body, now, *, custodial_ceiling): + parent = await tx.credential(body['parent']) + subject = await tx.subject(body['subject']) + require( + parent.subject_id == subject.resource_id + and not parent.id.startswith(('t_oauth_', 't_browser_')) + and not subject.local_only + and subject.auth_version == body['auth_version'] + and parent.revoked_at is None + and parent.not_before <= now + and (parent.expires_at is None or parent.expires_at > now), + 'invalid_grant', + ) + require(not body.get('login_binding'), 'invalid_grant') + if body.get('custodial'): + row = tx.one( + 'SELECT signing_key_id,status FROM custodial_vault WHERE subject=?', + (subject.resource_id,), + ) + require(subject.kind == 'custodial' and row == (parent.id, 'active'), 'invalid_grant') + # Vault keys deliberately carry no request grants. Custodial sessions retain + # their captured token limits under the current temporary policy, while the + # vault key above remains only their live identity/revocation binding. + await require_ceiling( + tx, + tuple(decode(CapabilityGrant, raw) for raw in body.get('ceiling', ())), + custodial_ceiling if body.get('custodial') else parent.ceiling, + ) + if body.get('session'): + _, session = get(tx, body['session'], now) + require(not session.get('revoked'), 'invalid_grant') + await require_source(tx, session, now, custodial_ceiling=custodial_ceiling) + return subject + + +async def require_binding(tx, credential, now, config, *, custodial_ceiling): + # Revoked protocol families must never fall back to an ordinary token proof, + # including after restoring a database from before the key-only migration. + require(not credential.id.startswith(('t_oauth_', 't_browser_')), 'invalid_grant') + browser = tx.one('SELECT body FROM credential_states WHERE id=?', ('browser:' + credential.id,)) + if browser is not None: + require(config is not None and config.enabled, 'browser_sessions_disabled') + source = loads(browser[0]) + require( + credential.id.startswith('t_session_') + and credential.source_credential_id == source['parent'] + and credential.subject_id == source['subject'] + and not source.get('custodial') + and not source.get('login_binding'), + 'invalid_grant', + ) + await require_source(tx, source, now, custodial_ceiling=custodial_ceiling) + await require_ceiling( + tx, credential.ceiling, tuple(decode(CapabilityGrant, raw) for raw in source['ceiling']) + ) + return + api = tx.one('SELECT body FROM credential_states WHERE id=?', ('api:' + credential.id,)) + if api is not None: + source = loads(api[0]) + require(credential.source_credential_id == source['parent'], 'invalid_grant') + await require_source(tx, source, now, custodial_ceiling=custodial_ceiling) + await require_ceiling( + tx, credential.ceiling, (await tx.credential(source['parent'])).ceiling + ) + return + require( + not credential.id.startswith('t_session_') and credential.source_credential_id is None, + 'invalid_grant', + ) + + +class BrowserSessionService: + def __init__(self, app): + self.app = app + + @property + def config(self): + return self.app.settings.browser + + def fence(self, tx): + require(self.config.enabled, 'browser_sessions_disabled') + self.app.runtime_generation.require_current(tx) + require_live_authority(tx) + require(not self.app.executor.recovery_drill_active(), 'recovery_quarantined') + + def rate(self, tx, address): + now = self.app.clock() + self.fence(tx) + tx.execute('DELETE FROM credential_states WHERE expires<=?', (wire(now),), write=True) + id = state_id('rate', address or 'unknown') + row = tx.one('SELECT body,expires FROM credential_states WHERE id=?', (id,)) + count = loads(row[0])['count'] if row else 0 + require(count < 120, 'slow_down') + require( + tx.one('SELECT COUNT(*) FROM credential_states')[0] < 100000, 'temporarily_unavailable' + ) + put( + tx, + id, + 'rate', + parse_time(row[1]) if row else now + timedelta(minutes=10), + {'count': count + 1}, + ) + + def pending(self, tx): + self.fence(tx) + now = self.app.clock() + # Never overwrite another pending confirmation on a code collision. + for _ in range(8): + code = ''.join(secrets.choice(CODE_ALPHABET) for _ in range(8)) + id = state_id('user', code) + if tx.one('SELECT id FROM credential_states WHERE id=?', (id,)) is None: + break + else: + require(False, 'temporarily_unavailable') + value = secret() + expiry = now + timedelta(minutes=10) + body = { + 'kind': 'browser', + 'status': 'pending', + 'poll': state_id('poll', value), + 'interval': 5, + 'next_poll': wire(now), + } + put(tx, id, 'pending', expiry, body) + put(tx, body['poll'], 'poll', expiry, {'user': id}) + return value, code, expiry + + async def source(self, tx, principal): + require( + principal.actor == principal.subject and principal.subject is not None, + 'identity_owner_required', + ) + subject = await tx.subject(principal.subject) + require( + subject.kind == 'registered' and not subject.local_only, 'registered_identity_required' + ) + require(principal.method == 'signature', 'signature_required') + return { + 'subject': subject.resource_id, + 'parent': principal.credential_id, + 'auth_version': subject.auth_version, + 'custodial': False, + 'ceiling': wire(principal.ceiling), + 'auth_time': int(self.app.clock().timestamp()), + } + + async def approve(self, tx, principal, code, decision): + self.fence(tx) + require(isinstance(code, str) and decision in {'approve', 'deny'}, 'invalid_request') + code = code.upper().replace('-', '') + require(len(code) == 8 and all(c in CODE_ALPHABET for c in code), 'invalid_grant') + id = state_id('user', code) + _, body = get(tx, id, self.app.clock()) + require(body['status'] == 'pending' and body['kind'] == 'browser', 'invalid_grant') + source = await self.source(tx, principal) + body.update(source if decision == 'approve' else {}) + body['status'] = 'approved' if decision == 'approve' else 'denied' + save(tx, id, body) + return {'kind': 'browser', 'status': body['status']} + + async def pending_result(self, tx, value): + now = self.app.clock() + _, locator = get(tx, state_id('poll', value), now) + expiry, body = get(tx, locator['user'], now) + require(body['kind'] == 'browser', 'invalid_grant') + require(body['status'] != 'consumed', 'invalid_grant') + if parse_time(body['next_poll']) > now: + body['interval'] += 5 + body['next_poll'] = wire(now + timedelta(seconds=body['interval'])) + save(tx, locator['user'], body) + return None, 'slow_down' + body['next_poll'] = wire(now + timedelta(seconds=body['interval'])) + save(tx, locator['user'], body) + if body['status'] == 'pending': + return None, 'authorization_pending' + if body['status'] == 'denied': + return None, 'access_denied' + await require_source(tx, body, now, custodial_ceiling=self.app.temporary_ceiling()) + body['status'] = 'consumed' + save(tx, locator['user'], body) + return await self.create_browser_session(tx, body), None + + async def create_browser_session(self, tx, source, *, cookie=None, ttl=None): + self.fence(tx) + now = self.app.clock() + await require_source(tx, source, now, custodial_ceiling=self.app.temporary_ceiling()) + require(ttl is None or type(ttl) is int and 60 <= ttl <= 3600, 'invalid_session_ttl') + subject = await tx.subject(source['subject']) + parent = await tx.credential(source['parent']) + require( + subject.kind == 'registered' + and parent.kind == 'signing_key' + and not source.get('custodial') + and not source.get('login_binding'), + 'signature_required', + ) + body = dict(source) + cookie = cookie or secret() + session_expiry = now + timedelta( + seconds=self.config.session_ttl if ttl is None else min(ttl, self.config.session_ttl) + ) + parent = await tx.credential(body['parent']) + if parent.expires_at is not None: + session_expiry = min(session_expiry, parent.expires_at) + session_id = state_id('session', cookie) + credential_id = 't_session_' + uuid4().hex + operations = { + f'{op.name}@{op.version}' + for op in self.app.registry.operations() + if (op.effect == 'read' or (op.name in BROWSER_POST_WRITES and op.version == 1)) + and not op.require_signature + and not op.anonymous_only + and not op.name.startswith(('identity.', 'root.', 'system.')) + } + wiki_writes = {'content.post_create@1', 'content.post_edit@1'} + wiki_scope = Scope(resource_id='t_wiki', descendants=True) + ceiling_items = [] + for raw in body['ceiling']: + grant = decode(CapabilityGrant, raw) + general = grant.operations & (operations - wiki_writes) + if general: + ceiling_items.append(replace(grant, operations=general)) + shared = grant.operations & operations & wiki_writes + if shared: + if await scope_subset(wiki_scope, grant.scope, tx): + ceiling_items.append(replace(grant, operations=shared, scope=wiki_scope)) + elif await scope_subset(grant.scope, wiki_scope, tx): + ceiling_items.append(replace(grant, operations=shared)) + ceiling = tuple(ceiling_items) + await tx.save_credential( + Credential( + id=credential_id, + subject_id=body['subject'], + kind='token', + verifier=hashlib.sha256(self.browser_secret(cookie)).digest(), + ceiling=ceiling, + not_before=now, + expires_at=session_expiry, + revoked_at=None, + source_credential_id=body['parent'], + ), + body['auth_version'], + ) + body['browser_credential'] = credential_id + put(tx, session_id, 'session', session_expiry, body) + put( + tx, + 'browser:' + credential_id, + 'browser', + session_expiry, + dict(body, session=session_id), + ) + return {'cookie': cookie, 'expires': session_expiry} + + async def session(self, tx, cookie): + self.fence(tx) + id = state_id('session', cookie) + expiry, body = get(tx, id, self.app.clock()) + require(not body.get('revoked'), 'invalid_grant') + await require_source( + tx, body, self.app.clock(), custodial_ceiling=self.app.temporary_ceiling() + ) + source = { + name: body[name] + for name in ('subject', 'parent', 'auth_version', 'custodial', 'ceiling', 'auth_time') + } + return expiry, dict(source, session=id) + + def browser_secret(self, cookie): + return hmac.digest( + self.app._token_secret, b'msg-key-browser-v1:' + cookie.encode(), 'sha256' + ) + + async def browser_credentials(self, tx, cookie): + _, source = await self.session(tx, cookie) + _, body = get(tx, source['session'], self.app.clock()) + require('browser_credential' in body, 'invalid_grant') + credential = await tx.credential(body['browser_credential']) + require( + credential.revoked_at is None and credential.expires_at > self.app.clock(), + 'invalid_grant', + ) + await require_binding( + tx, + credential, + self.app.clock(), + self.config, + custodial_ceiling=self.app.temporary_ceiling(), + ) + return source['subject'], credential.id, self.browser_secret(cookie) diff --git a/src/msg/storage/key_identity_migration.py b/src/msg/storage/key_identity_migration.py new file mode 100644 index 00000000..fcd15e62 --- /dev/null +++ b/src/msg/storage/key_identity_migration.py @@ -0,0 +1,20 @@ +"""Discard retired login grants while preserving native API source checks.""" + + +def migrate_key_identity(conn, *, postgres=False): + exists = ( + conn.execute("SELECT to_regclass('public.oauth_states')").fetchone()[0] is not None + if postgres + else conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='oauth_states'" + ).fetchone() + is not None + ) + if exists: + conn.execute( + 'INSERT INTO credential_states (id,kind,expires,body) ' + "SELECT id,kind,expires,body FROM oauth_states WHERE kind='api' " + 'ON CONFLICT(id) DO NOTHING' + ) + conn.execute('DROP TABLE oauth_states') + conn.execute('DROP TABLE IF EXISTS login_bindings') diff --git a/src/msg/storage/ledger_migration.py b/src/msg/storage/ledger_migration.py index fc624c61..bccd55ff 100644 --- a/src/msg/storage/ledger_migration.py +++ b/src/msg/storage/ledger_migration.py @@ -291,7 +291,7 @@ def _install_subject_account_trigger(conn): 'memberships': ('organization_id', 'subject_id', 'invited_by'), 'emails': ('subject_id',), 'credentials': ('subject_id', 'ceiling.*.scope.resource_id'), - 'oauth_states': ('subject', 'ceiling.*.scope.resource_id'), + 'credential_states': ('subject', 'ceiling.*.scope.resource_id'), 'certificates': ( 'resource_id', 'subject_id', diff --git a/src/msg/storage/login_schema.py b/src/msg/storage/login_schema.py deleted file mode 100644 index b91f2752..00000000 --- a/src/msg/storage/login_schema.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Private external-login facts; never a content or discovery collection.""" - -LOGIN_SCHEMA = """ -CREATE TABLE IF NOT EXISTS login_bindings ( - id TEXT PRIMARY KEY, - provider TEXT NOT NULL, - external_subject_hash TEXT NOT NULL, - subject TEXT NOT NULL REFERENCES identities(id), - source_credential_id TEXT NOT NULL REFERENCES credentials(id), - auth_version INTEGER NOT NULL, - custodial INTEGER NOT NULL CHECK (custodial IN (0,1)), - ceiling TEXT NOT NULL, - source_data TEXT NOT NULL, - provider_data TEXT NOT NULL DEFAULT '{}', - generation INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - revoked_at TEXT, - UNIQUE(provider,external_subject_hash)); -CREATE INDEX IF NOT EXISTS login_bindings_subject ON login_bindings(subject,id); -""" diff --git a/src/msg/storage/postgres.py b/src/msg/storage/postgres.py index 6a8d7561..849a18f6 100644 --- a/src/msg/storage/postgres.py +++ b/src/msg/storage/postgres.py @@ -65,9 +65,9 @@ CREATE TABLE IF NOT EXISTS emails (subject TEXT PRIMARY KEY, generation INTEGER NOT NULL, body TEXT NOT NULL); CREATE TABLE IF NOT EXISTS credentials (id TEXT PRIMARY KEY, subject TEXT NOT NULL, body TEXT NOT NULL); CREATE INDEX IF NOT EXISTS credentials_subject ON credentials(subject); -CREATE TABLE IF NOT EXISTS oauth_states ( +CREATE TABLE IF NOT EXISTS credential_states ( id TEXT PRIMARY KEY, kind TEXT NOT NULL, expires TEXT NOT NULL, body TEXT NOT NULL); -CREATE INDEX IF NOT EXISTS oauth_states_expiry ON oauth_states(expires); +CREATE INDEX IF NOT EXISTS credential_states_expiry ON credential_states(expires); CREATE TABLE IF NOT EXISTS token_deliveries ( credential_id TEXT PRIMARY KEY, subject TEXT NOT NULL, request_id TEXT NOT NULL, @@ -590,9 +590,9 @@ def __init__(self, dsn: str, *, signal=None, initialize: bool = True): # Multiple daemon workers may initialize the same fresh database. conn.execute('SELECT pg_advisory_xact_lock(725274758, 1886265951)') conn.execute(_SCHEMA) - from msg.storage.login_schema import LOGIN_SCHEMA + from msg.storage.key_identity_migration import migrate_key_identity - conn.execute(LOGIN_SCHEMA) + migrate_key_identity(conn, postgres=True) from msg.storage.market_migration import migrate_market migrate_market(conn) diff --git a/src/msg/storage/read_only.py b/src/msg/storage/read_only.py index c0e70fe2..a5fad78b 100644 --- a/src/msg/storage/read_only.py +++ b/src/msg/storage/read_only.py @@ -20,7 +20,7 @@ 'identities', 'memberships', 'credentials', - 'oauth_states', + 'credential_states', 'certificates', 'settings', 'share_grants', diff --git a/src/msg/storage/sqlite.py b/src/msg/storage/sqlite.py index 97d06281..956fe230 100644 --- a/src/msg/storage/sqlite.py +++ b/src/msg/storage/sqlite.py @@ -57,9 +57,9 @@ CREATE TABLE IF NOT EXISTS emails (subject TEXT PRIMARY KEY, generation INTEGER NOT NULL, body TEXT NOT NULL); CREATE TABLE IF NOT EXISTS credentials (id TEXT PRIMARY KEY, subject TEXT NOT NULL, body TEXT NOT NULL); CREATE INDEX IF NOT EXISTS credentials_subject ON credentials(subject); -CREATE TABLE IF NOT EXISTS oauth_states ( +CREATE TABLE IF NOT EXISTS credential_states ( id TEXT PRIMARY KEY, kind TEXT NOT NULL, expires TEXT NOT NULL, body TEXT NOT NULL); -CREATE INDEX IF NOT EXISTS oauth_states_expiry ON oauth_states(expires); +CREATE INDEX IF NOT EXISTS credential_states_expiry ON credential_states(expires); CREATE TABLE IF NOT EXISTS token_deliveries ( credential_id TEXT PRIMARY KEY, subject TEXT NOT NULL, request_id TEXT NOT NULL, @@ -274,6 +274,9 @@ def __init__(self, path: Path, *, busy_timeout: float = 10): conn.execute('BEGIN IMMEDIATE') try: + from msg.storage.key_identity_migration import migrate_key_identity + + migrate_key_identity(conn) migrate_topic_events(conn) from msg.storage.post_view_migration import migrate_post_views diff --git a/src/msg/transports/account_login.py b/src/msg/transports/account_login.py deleted file mode 100644 index 1e294bc7..00000000 --- a/src/msg/transports/account_login.py +++ /dev/null @@ -1,537 +0,0 @@ -"""业务参数表单接入统一执行器;提供方凭据只留在服务端。""" - -import re -from dataclasses import replace -from html import escape -from types import SimpleNamespace -from urllib.parse import quote, urlencode, urlsplit - -from starlette.requests import Request -from starlette.responses import RedirectResponse - -from msg.core.errors import Failure, require -from msg.login_config import OAUTH_PROVIDERS -from msg.security.email_login import begin_email, verify_email -from msg.security.login import VerifiedLogin, bind_login, complete_login, remove_login -from msg.security.login_flow import TTL, consume_flow, flow, receive_callback, start_flow -from msg.security.oauth import OAuthService, get, put, secret, state_id -from msg.transports.account_login_page import ( - approval_page, - email_code_page, - finish_page, - login_page, - methods_page, -) -from msg.transports.login_providers import AGENTID_ISSUER, authorization_url, exchange_identity -from msg.transports.oauth_http import HEADERS, csrf, form, json, page -from msg.transports.url_safety import require_matching_host, require_safe_request_target - - -class AccountLoginBoundary: - def __init__(self, app, *, service): - self.app, self.service = app, service - self.oauth = OAuthService(service) - self.secure = urlsplit(service.settings.service_url).scheme == 'https' - self.flow_cookie = '__Host-msg_account_login' if self.secure else 'msg_account_login' - self.session_cookie = '__Host-msg_session' if self.secure else 'msg_session' - - @property - def config(self): - return self.service.settings.login - - def cookie(self, response, name, value, ttl): - response.set_cookie( - name, value, max_age=ttl, secure=self.secure, httponly=True, samesite='lax', path='/' - ) - - def provider(self, name): - provider = self.config.provider(name) - require(provider is not None and provider.enabled, 'login_provider_disabled') - return provider - - def providers(self): - return tuple( - p.name for p in self.config.providers if p.enabled and p.name in OAUTH_PROVIDERS - ) - - def enabled(self, name): - item = self.config.provider(name) - return item is not None and item.enabled - - def check_csrf(self, request, args): - browser, supplied = request.cookies.get(self.flow_cookie, ''), args.get('csrf', '') - import hmac - - require( - len(browser) >= 20 - and supplied.isascii() - and hmac.compare_digest(supplied, csrf(browser)), - 'forbidden_origin', - ) - return browser - - async def source(self, request): - cookie = request.cookies.get(self.session_cookie, '') - require(cookie, 'authentication_required') - async with self.service.metadata.transaction(write=False) as tx: - _, source = await self.oauth.session(tx, cookie) - return source - - async def args(self, request, allowed): - require(request.method == 'POST', 'method_not_allowed') - require(not request.query_params, 'invalid_request') - args = await form(request) - require(set(args) <= allowed, 'invalid_request') - return args, self.check_csrf(request, args) - - def mode(self, args): - mode = args.get('mode', 'login') - require(mode in {'login', 'register', 'bind'}, 'invalid_login_mode') - handle = None - if mode == 'register': - handle = args.get('handle', '') - require(args.get('custody') == 'yes', 'custodial_consent_required') - require(re.fullmatch(r'[a-z][a-z0-9-]{1,40}', handle) is not None, 'invalid_handle') - else: - require(not args.get('handle') and not args.get('custody'), 'invalid_request') - return mode, handle - - def success(self, completed, *, bound=False): - require( - completed.result.status == 'ok', - completed.result.error.code if completed.result.error else 'login_failed', - ) - response = RedirectResponse( - '/account/login-methods' if bound else '/', 303, headers=HEADERS - ) - if completed.cookie: - self.cookie( - response, - self.session_cookie, - completed.cookie, - int((completed.expires - self.service.clock()).total_seconds()), - ) - response.delete_cookie( - self.flow_cookie, secure=self.secure, httponly=True, samesite='lax', path='/' - ) - return response - - async def finish_identity(self, request, verified, body, request_id): - if body['mode'] == 'bind': - return await self.management( - request, verified, body['source']['subject'], request_id=request_id - ) - completed = await complete_login( - self.service, verified, request_id=request_id, handle=body.get('handle') - ) - return self.success(completed) - - async def management(self, request, verified, owner, *, request_id, binding_id=None): - from msg.security.login import request_login_approval - - action = 'remove' if binding_id is not None else 'bind' - requested = await request_login_approval( - self.service, - verified, - owner, - action=action, - binding_id=binding_id, - request_id=request_id, - ) - pending = secret() - body = { - 'browser': state_id('browser', request.cookies.get(self.flow_cookie, '')), - 'owner': owner, - 'intent_id': requested.intent_id, - 'user_code': requested.user_code, - 'request_id': request_id, - 'action': action, - 'verified': {'provider': verified.provider, 'sub': verified.sub} if verified else None, - } - async with self.service.metadata.transaction(write=True) as tx: - self.oauth.fence(tx) - body['provider'] = ( - verified.provider - if verified - else tx.one( - 'SELECT provider FROM login_bindings WHERE id=? AND subject=?', - (binding_id, owner), - )[0] - ) - put(tx, state_id('login-approval', pending), 'login-approval', requested.expires, body) - return await self.finish_pending(request, pending, body) - - async def finish_pending(self, request, pending, body): - app = self.service - source = await self.source(request) - require(source['subject'] == body['owner'], 'login_owner_required') - from msg.security import login - - async with app.metadata.transaction(write=True) as tx: - self.oauth.fence(tx) - _, intent = get(tx, body['intent_id'], app.clock()) - if intent['status'] == 'pending': - approve = getattr(login, 'approve_browser_login_intent', None) - if approve is not None: - await approve( - app, tx, request.cookies.get(self.session_cookie, ''), body['intent_id'] - ) - _, intent = get(tx, body['intent_id'], app.clock()) - require(intent['status'] in {'pending', 'approved'}, 'invalid_grant') - if intent['status'] == 'pending': - return approval_page( - csrf(request.cookies.get(self.flow_cookie, '')), - pending, - body['user_code'], - removing=body['action'] == 'remove', - provider=body['provider'], - ) - if body['action'] == 'bind': - verified = VerifiedLogin(**body['verified']) - completed = await bind_login( - app, verified, request_id=body['request_id'], intent_id=body['intent_id'] - ) - return self.success(completed, bound=True) - completed = await remove_login( - app, request_id=body['request_id'], intent_id=body['intent_id'] - ) - require( - completed.status == 'ok', completed.error.code if completed.error else 'login_failed' - ) - try: - await self.source(request) - except Failure: - response = RedirectResponse('/login', 303, headers=HEADERS) - response.delete_cookie( - self.session_cookie, secure=self.secure, httponly=True, samesite='lax', path='/' - ) - else: - response = RedirectResponse('/account/login-methods', 303, headers=HEADERS) - response.delete_cookie( - self.flow_cookie, secure=self.secure, httponly=True, samesite='lax', path='/' - ) - return response - - async def dispatch(self, request): - path = request.url.path - app = self.service - if path == '/oauth/signup': - require(request.method in {'GET', 'HEAD'}, 'login_registration_forbidden') - return RedirectResponse('/register', 303, headers=HEADERS) - if path in {'/login', '/register', '/account/login-methods'}: - require(request.method in {'GET', 'HEAD'}, 'method_not_allowed') - browser = secret() - token = csrf(browser) - if path == '/account/login-methods': - source = await self.source(request) - async with app.metadata.transaction(write=False) as tx: - bindings = [ - {'binding_id': row[0], 'provider': row[1]} - for row in tx.rows( - 'SELECT id,provider FROM login_bindings WHERE subject=? AND revoked_at IS NULL ORDER BY provider,id', - (source['subject'],), - ) - ] - subject = await tx.subject(source['subject']) - from msg.transports.passkey_login import PASSKEY_SCRIPT, passkey_control - - response = methods_page( - token, - self.providers(), - bindings, - email=self.enabled('email'), - kind=subject.kind, - passkey_markup=passkey_control('bind', token) - if self.enabled('passkey') - else '', - script=PASSKEY_SCRIPT if self.enabled('passkey') else '', - ) - else: - from msg.transports.passkey_login import PASSKEY_SCRIPT, passkey_control - - response = login_page( - token, - self.providers(), - email=self.enabled('email'), - register=path == '/register', - passkey=self.enabled('passkey'), - passkey_markup=passkey_control('login', token), - script=PASSKEY_SCRIPT - if self.enabled('passkey') and path != '/register' - else '', - ) - self.cookie(response, self.flow_cookie, browser, TTL) - return response - if path == '/login/finish': - require(request.method == 'GET', 'method_not_allowed') - require(set(request.query_params) == {'state'}, 'invalid_request') - state = request.query_params['state'] - browser = request.cookies.get(self.flow_cookie, '') - async with app.metadata.transaction(write=False) as tx: - body = flow(app, tx, state, browser) - require(body['phase'] == 'returned', 'invalid_login_transaction') - return finish_page(csrf(browser), state) - if path == '/login/agentid': - require(request.method == 'GET', 'method_not_allowed') - self.provider('agentid') - require(set(request.query_params) <= {'iss', 'login_hint'}, 'invalid_request') - # This public alias only redirects. The existing transaction route creates state. - return RedirectResponse( - '/-/login/start?' + urlencode({'provider': 'agentid', **request.query_params}), - 303, - headers=HEADERS, - ) - if path == '/-/login/start': - if request.method == 'GET': - args = dict(request.query_params) - require(set(args) <= {'provider', 'iss', 'login_hint'}, 'invalid_request') - require(args.get('provider') == 'agentid', 'method_not_allowed') - require(args.get('iss') in {None, AGENTID_ISSUER}, 'login_provider_invalid_issuer') - browser = secret() - else: - args, browser = await self.args( - request, {'csrf', 'provider', 'mode', 'handle', 'custody'} - ) - provider = self.provider(args.get('provider')) - require(provider.name in OAUTH_PROVIDERS, 'login_provider_disabled') - mode, handle = self.mode(args) - source = await self.source(request) if mode == 'bind' else None - async with app.metadata.transaction(write=True) as tx: - self.oauth.rate(tx, request.client.host if request.client else '') - state, verifier, nonce = start_flow( - app, tx, provider.name, browser, mode=mode, source=source, handle=handle - ) - callback = app.settings.service_url + '/-/login/callback/' + provider.name - url = authorization_url( - provider.name, - provider, - state, - verifier, - nonce, - callback, - **({'login_hint': args.get('login_hint')} if provider.name == 'agentid' else {}), - ) - response = RedirectResponse(url, 303, headers=HEADERS) - # Start a fresh ten-minute window even when the sign-in page was left open. - self.cookie(response, self.flow_cookie, browser, TTL) - return response - if path.startswith('/-/login/callback/'): - require(request.method == 'GET', 'method_not_allowed') - provider = self.provider(path.rsplit('/', 1)[-1]) - require(provider.name in OAUTH_PROVIDERS, 'login_provider_disabled') - require( - set(request.query_params) - <= { - 'state', - 'code', - 'error', - 'error_description', - 'scope', - 'authuser', - 'prompt', - 'iss', - }, - 'invalid_request', - ) - require( - provider.name != 'agentid' or request.query_params.get('iss') == AGENTID_ISSUER, - 'login_provider_invalid_issuer', - ) - require('error' not in request.query_params, 'login_provider_denied') - state = request.query_params.get('state', '') - async with app.metadata.transaction(write=True) as tx: - self.oauth.fence(tx) - receive_callback( - app, - tx, - state, - request.cookies.get(self.flow_cookie, ''), - provider.name, - request.query_params.get('code'), - ) - return RedirectResponse( - '/login/finish?state=' + quote(state, safe=''), 303, headers=HEADERS - ) - if path == '/-/login/complete': - args, browser = await self.args(request, {'csrf', 'state'}) - state = args.get('state', '') - async with app.metadata.transaction(write=True) as tx: - self.oauth.rate(tx, request.client.host if request.client else '') - body = consume_flow(app, tx, state, browser) - provider = self.provider(body['provider']) - callback = app.settings.service_url + '/-/login/callback/' + provider.name - identity = await exchange_identity( - provider.name, - provider, - body['code'], - body['verifier'], - body['nonce'], - callback, - app.clock(), - ) - return await self.finish_identity( - request, - VerifiedLogin(provider.name, identity.stable_id), - body, - state_id('login-complete', state), - ) - if path == '/-/login/email/start': - args, browser = await self.args(request, {'csrf', 'email', 'mode', 'handle', 'custody'}) - provider = self.provider('email') - mode, handle = self.mode(args) - source = await self.source(request) if mode == 'bind' else None - async with app.metadata.transaction(write=True) as tx: - self.oauth.rate(tx, request.client.host if request.client else '') - challenge, code = begin_email( - app, - tx, - args.get('email'), - browser, - {'mode': mode, 'handle': handle, 'source': source}, - ) - sender = getattr(app, '_login_mail_sender', None) - if sender is None: - if provider.transport == 'smtp': - from msg.workers.mail import SmtpSender - - config = app.settings.server.mail - require(config is not None and config.enabled, 'login_mail_not_configured') - if provider.sender: - config = replace(config, sender=provider.sender) - try: - status = await SmtpSender(config).send( - SimpleNamespace( - id=state_id('login-mail', challenge), - arguments={ - 'recipient': args['email'], - 'subject': 'MSG 登录验证码 / Sign-in code', - 'text': 'MSG 登录验证码:' - + code - + '\n10 分钟内有效,只能使用一次。', - }, - ) - ) - except Failure: - raise Failure('login_mail_send_failed') from None - require(status == 'sent', 'login_mail_delivery_unknown') - else: - require(provider.transport == 'sequenzy', 'login_mail_not_configured') - from msg.workers.sequenzy import SequenzySender - - sender = SequenzySender( - provider.credential_file, sender=provider.sender or None - ) - if sender is not None: - await sender.send_code(args['email'], code) - return email_code_page(csrf(browser), challenge) - if path == '/-/login/email/complete': - args, browser = await self.args(request, {'csrf', 'challenge', 'code'}) - self.provider('email') - challenge = args.get('challenge', '') - async with app.metadata.transaction(write=True) as tx: - self.oauth.rate(tx, request.client.host if request.client else '') - body, error = verify_email(app, tx, challenge, browser, args.get('code', '')) - require(error is None, error or 'invalid_login_code') - return await self.finish_identity( - request, - VerifiedLogin('email', body['sub']), - body['metadata'], - state_id('email-complete', challenge), - ) - if path == '/-/login/remove': - args, _ = await self.args(request, {'csrf', 'binding_id'}) - source = await self.source(request) - return await self.management( - request, - None, - source['subject'], - binding_id=args.get('binding_id', ''), - request_id=secret(), - ) - if path == '/-/login/approval': - args, browser = await self.args(request, {'csrf', 'pending'}) - pending = args.get('pending', '') - require(len(pending) == 43, 'invalid_request') - async with app.metadata.transaction(write=False) as tx: - _, body = get(tx, state_id('login-approval', pending), app.clock()) - require(body['browser'] == state_id('browser', browser), 'invalid_login_browser') - return await self.finish_pending(request, pending, body) - if path.startswith('/-/login/passkey/'): - from msg.transports.passkey_login import dispatch - - browser = request.cookies.get(self.flow_cookie, '') - return await dispatch( - request, - app=app, - source=lambda: self.source(request), - browser=browser, - csrf=csrf(browser), - session_cookie=request.cookies.get(self.session_cookie, ''), - ) - raise Failure('not_found') - - async def __call__(self, scope, receive, send): - path = scope.get('path', '') - enabled = getattr(getattr(self.service.settings, 'login', None), 'enabled', False) - routes = { - '/login', - '/login/agentid', - '/register', - '/login/finish', - '/account/login-methods', - } - if self.config.registration == 'provider_only': - routes.add('/oauth/signup') - if ( - scope['type'] != 'http' - or not enabled - or (path not in routes and not path.startswith('/-/login/')) - ): - await self.app(scope, receive, send) - return - request = Request(scope, receive) - try: - require( - len(request.query_params.multi_items()) == len(request.query_params), - 'invalid_request', - ) - require_safe_request_target( - scope.get('raw_path') or path.encode(), - scope.get('query_string', b''), - maximum=self.service.settings.server.limits.max_path_bytes, - ) - require_matching_host( - request.headers.getlist('host'), urlsplit(self.service.settings.service_url) - ) - require( - not request.headers.get('authorization') - and not request.headers.get('x-msg-request'), - 'ambiguous_credentials', - ) - origin = request.headers.get('origin') - require( - origin in {None, self.service.settings.service_url} - and (request.method != 'POST' or origin == self.service.settings.service_url), - 'forbidden_origin', - ) - response = await self.dispatch(request) - except Failure as exc: - status = ( - 404 - if exc.code == 'not_found' - else 403 - if exc.code - in {'authentication_required', 'forbidden_origin', 'ambiguous_credentials'} - else 400 - ) - if request.headers.get('accept', '').startswith('application/json'): - response = json({'error': exc.code, 'retryable': exc.retryable}, status) - else: - response = page( - '登录未完成 / Sign-in incomplete', - '

' - + escape(exc.code) - + '

重新登录 / Try again

', - ) - response.status_code = status - await response(scope, receive, send) diff --git a/src/msg/transports/account_login_page.py b/src/msg/transports/account_login_page.py deleted file mode 100644 index 81ae4c7d..00000000 --- a/src/msg/transports/account_login_page.py +++ /dev/null @@ -1,169 +0,0 @@ -"""统一登录与登录方式管理;表单不携带 MSG wire packet。""" - -from html import escape - -from msg.transports.oauth_http import fields, page - -PROVIDER_NAMES = { - 'agentid': 'AgentID', - 'google': 'Google', - 'github': 'GitHub', - 'chatgpt': 'ChatGPT', - 'email': '邮箱', - 'passkey': 'Passkey', -} - -CUSTODY_CONTROL = ( - '

' -) - -FINISH_SCRIPT = """(() => { - const form = document.getElementById('finish-login'); - if (form) form.requestSubmit(); -})();""" - -APPROVAL_SCRIPT = """(() => { - const form = document.getElementById('approve-login'); - if (form) setTimeout(() => form.requestSubmit(), 3000); -})();""" - - -def provider_form(provider, token, *, mode='login', handle=False): - label = PROVIDER_NAMES[provider] - body = '
' + fields({ - 'csrf': token, - 'provider': provider, - 'mode': mode, - }) - if handle: - body += ( - '


' - '

' - ) + CUSTODY_CONTROL - return body + '
' - - -def email_form(token, *, mode='login', handle=False): - body = ( - '
' - + fields({'csrf': token, 'mode': mode}) - + '


' - '

' - ) - if handle: - body += ( - '


' - '

' - ) + CUSTODY_CONTROL - return body + '
' - - -def login_page( - token, providers, *, email=False, passkey=False, register=False, passkey_markup='', script='' -): - title = '注册 MSG / Join MSG' if register else '登录 MSG / Sign in to MSG' - body = '

人类与 Agent 使用同一个账号、同一份身份和权限。 / One MSG identity for you and your agents.

' - body += '

使用已绑定方式完成验证后,5 分钟内可确认添加或移除登录方式;普通只读会话需要重新确认身份。

' - if register: - body += '

仅 Google 或邮箱可以注册。新账号使用平台托管密钥;以后可以迁移为自持。

' - if 'google' in providers: - body += provider_form('google', token, mode='register', handle=True) - else: - for provider in providers: - body += provider_form(provider, token) - if any(provider in providers for provider in ('github', 'chatgpt', 'agentid')): - body += ( - '

GitHub/ChatGPT/AgentID 须先绑定已有 MSG 账号;相同邮箱不会自动合并账号。

' - ) - if email: - body += email_form(token, mode='register' if register else 'login', handle=register) - if passkey and not register: - body += passkey_markup - if not providers and not email and not passkey: - body += '

外部登录方式尚未配置。 / External sign-in is not configured yet.

' - body += ( - '

用已有 MSG 私钥批准登录 / Approve with your MSG identity

' - + ( - '

已有账号?登录 / Already registered? Sign in

' - if register - else '

用 Google 或邮箱注册 / Register with Google or email

' - ) - ) - if not register: - body += '

管理已绑定的登录方式 / Manage sign-in methods

' - return page(title, body, script=script) - - -def finish_page(token, state): - return page( - '完成登录 / Finish sign-in', - '

正在验证登录。 / Verifying sign-in.

' - '
' - + fields({'csrf': token, 'state': state}) - + '
', - script=FINISH_SCRIPT, - ) - - -def email_code_page(token, challenge): - return page( - '检查邮箱 / Check your email', - '

输入收到的 12 位验证码,10 分钟内有效,只能使用一次。 / ' - 'Enter the 12-digit code. It expires in 10 minutes and can be used once.

' - '
' - + fields({'csrf': token, 'challenge': challenge}) - + '


' - '

' - '
', - ) - - -def approval_page(token, pending, code, *, removing=False, provider=''): - return page( - '确认移除登录方式 / Confirm removal' if removing else '确认绑定登录方式 / Confirm binding', - '

本次操作:' - + ('移除 ' if removing else '绑定 ') - + escape(PROVIDER_NAMES.get(provider, provider)) - + ' 登录方式。

' - '

请用已有 MSG 身份确认本次操作,5 分钟内有效。此确认不会改变私钥保管方式。

' - '

msg auth approve ' + escape(code) + '

' - '

如果已有 Google、邮箱或 Passkey 绑定,也可以重新登录后再发起管理操作。

' - '
' - + fields({'csrf': token, 'pending': pending}) - + '
', - script=APPROVAL_SCRIPT, - ) - - -def methods_page( - token, providers, bindings, *, email=False, passkey_markup='', kind='registered', script='' -): - custody = ( - '服务器保管私钥 / Server holds your keys' - if kind == 'custodial' - else '私钥由你自持 / You hold your keys' - ) - body = '

' + custody + '。绑定登录方式不会改变密钥保管模式。

    ' - for binding in bindings: - provider = binding['provider'] - body += ( - '
  • ' - + escape(PROVIDER_NAMES.get(provider, provider)) - + '
    ' - + fields({'csrf': token, 'binding_id': binding['binding_id']}) - + '
  • ' - ) - body += '

添加登录方式 / Add a sign-in method

' - for provider in providers: - body += provider_form(provider, token, mode='bind') - if email: - body += email_form(token, mode='bind') - body += passkey_markup + '

返回首页 / Home

' - return page('登录方式 / Sign-in methods', body, script=script) diff --git a/src/msg/transports/browser_login.py b/src/msg/transports/browser_login.py index 33a5fd95..7fe40594 100644 --- a/src/msg/transports/browser_login.py +++ b/src/msg/transports/browser_login.py @@ -20,7 +20,7 @@ requestController = controller; const timeout = setTimeout(() => controller.abort(), 10000); try { - const response = await fetch('/oauth/login/poll', { + const response = await fetch('/session/poll', { method: 'POST', credentials: 'same-origin', redirect: 'error', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({csrf: status.dataset.csrf}), signal: controller.signal diff --git a/src/msg/transports/flight_space.py b/src/msg/transports/flight_space.py index ef49bc87..bdc17fa0 100644 --- a/src/msg/transports/flight_space.py +++ b/src/msg/transports/flight_space.py @@ -1,6 +1,6 @@ """Process-local authoritative flight playground, isolated from MSG resource writes. -Only explicitly joined players are broadcast. OAuth is checked on ingress and +Only explicitly joined players are broadcast. Key-approved browser access is checked on ingress and again while connected; game presence never becomes an MSG online declaration. One uvicorn worker owns one world. This is not a distributed state service. """ @@ -25,8 +25,8 @@ from msg.core.planet_layout import planet_layout from msg.core.requests import request_for from msg.plugins.discovery import visible -from msg.security.oauth import OAuthService from msg.security.quarantine import require_live_authority +from msg.security.sessions import BrowserSessionService from msg.transports.flight_simulation import ( BRAKE_ACCELERATION, COAST_DRAG, @@ -92,7 +92,7 @@ class FlightHub: def __init__(self, service): self.service = service - self.oauth = OAuthService(service) + self.sessions = BrowserSessionService(service) self.world = FlightWorld(max_players=MAX_CLIENTS, max_retained=512, retention_seconds=300) self.peers: dict[str, Peer] = {} self.addresses: dict[str, int] = {} @@ -281,7 +281,7 @@ async def _identity(self, peer): 'handle': 'guest-' + secrets.token_hex(4), 'guest': True, } - subject, _, _ = await self.oauth.browser_credentials(tx, peer.cookie) + subject, _, _ = await self.sessions.browser_credentials(tx, peer.cookie) resource = await tx.resource(subject) require(resource.type == 'user' and resource.state == 'active', 'invalid_grant') peer.subject = subject @@ -419,7 +419,7 @@ async def websocket(self, websocket: WebSocket): 'invalid_grant', ) elif peer.cookie: - subject, _, _ = await self.oauth.browser_credentials(tx, peer.cookie) + subject, _, _ = await self.sessions.browser_credentials(tx, peer.cookie) require(subject == peer.subject, 'invalid_grant') require( await self._public_identity(tx, subject) == peer.public_identity, @@ -536,7 +536,8 @@ async def websocket(self, websocket: WebSocket): else 1009 if exc.code == 'flight_frame_too_large' else 4013 - if exc.code in {'invalid_grant', 'credential_not_found', 'oauth_disabled'} + if exc.code + in {'invalid_grant', 'credential_not_found', 'browser_sessions_disabled'} or exc.code.startswith(('credential_', 'certificate_')) else 1013 if exc.code in {'server_busy', 'recovery_quarantined', 'recovery_runtime_stale'} @@ -713,7 +714,7 @@ async def _validate(self, *, identities): ) subject = peer.machine_principal.subject else: - subject, _, _ = await self.oauth.browser_credentials(tx, peer.cookie) + subject, _, _ = await self.sessions.browser_credentials(tx, peer.cookie) resource = await tx.resource(subject) require( subject == peer.subject diff --git a/src/msg/transports/home_page.py b/src/msg/transports/home_page.py index 25c1a98d..9eee65be 100644 --- a/src/msg/transports/home_page.py +++ b/src/msg/transports/home_page.py @@ -80,12 +80,7 @@ def account_navigation(account, *, compact=False): f'Followers ' f'Wallet ' 'Saved ' - + ( - 'Sign-in methods / 登录方式 ' - if account.get('login_methods_enabled') - else '' - ) - + 'Sign out' + + 'Sign out' ) if compact: return ( diff --git a/src/msg/transports/http.py b/src/msg/transports/http.py index 230b983d..a5b18599 100644 --- a/src/msg/transports/http.py +++ b/src/msg/transports/http.py @@ -122,13 +122,9 @@ async def __call__(self, scope, receive, send): def create_app(service): app = create_router(service) - from msg.transports.oauth_http import OAuthBoundary + from msg.transports.session_http import BrowserSessionBoundary - app.add_middleware(OAuthBoundary, service=service) - if getattr(getattr(service.settings, 'login', None), 'enabled', False): - from msg.transports.account_login import AccountLoginBoundary - - app.add_middleware(AccountLoginBoundary, service=service) + app.add_middleware(BrowserSessionBoundary, service=service) app.add_middleware(PassiveGetBoundary, service=service) from msg.transports.safe_diagnostics import SafeDiagnostics diff --git a/src/msg/transports/http_routes.py b/src/msg/transports/http_routes.py index e9e3f700..525841fd 100644 --- a/src/msg/transports/http_routes.py +++ b/src/msg/transports/http_routes.py @@ -112,7 +112,7 @@ def home_markdown( '\n## Your account\n', f'Signed in as [{name}]({path}).', f'\n[Inbox]({path}/in) · [Direct messages]({path}/dm) · ' - f'[Outbox]({path}/out) · [Sign out](/oauth/logout)\n', + f'[Outbox]({path}/out) · [Sign out](/session/logout)\n', 'These links use your browser session and current permissions.\n', 'Groups: ' + ' · '.join( @@ -298,19 +298,6 @@ def operation_route(spec): def classify_route(path, method, registry): """Classify the matched HTTP boundary before interpreting a request body.""" - if path.startswith('/-/login/'): - if ( - path == '/-/login/start' - or path.startswith('/-/login/callback/') - or path.endswith('/options') - ): - return RouteSpec('login_transaction', RouteEffect.LOCAL_EPHEMERAL) - effect = ( - RouteEffect.EXTERNAL_EFFECT - if path == '/-/login/email/start' - else RouteEffect.BUSINESS_WRITE - ) - return RouteSpec('account_login', effect) if path in {'/-/d', '/-/schema'} or path.startswith('/-/d/'): return RouteSpec('contract', RouteEffect.PURE_READ) protocol = re.match(r'^/-/([pg])/([a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+)', path) @@ -1009,9 +996,6 @@ async def browser_account(*, fields=('id', 'name', 'groups')): return None return dict( identity.data, - login_methods_enabled=getattr( - getattr(service.settings, 'login', None), 'enabled', False - ), ) async def execute_packet(packet, *, entry='network'): @@ -2945,9 +2929,6 @@ async def decorate(value): if not identity.error: account = dict( identity.data, - login_methods_enabled=getattr( - getattr(service.settings, 'login', None), 'enabled', False - ), ) channels = await execute_packet( request_for( @@ -2959,7 +2940,7 @@ async def decorate(value): ) if home_data is not None and not channels.error: home_data = {**home_data, 'channels': channels.data['channels']} - from msg.transports.oauth_http import csrf + from msg.transports.session_http import csrf board_packet = request_for( 'discovery.public_board', {}, service.settings.service_url, source='manual' @@ -3015,7 +2996,7 @@ async def decorate(value): service_url=service.settings.service_url, account=account, login_enabled=getattr( - getattr(service.settings, 'oauth', None), 'enabled', False + getattr(service.settings, 'browser', None), 'enabled', False ), expired=request.scope.get('state', {}).get('msg_browser_expired', False), **({'reply_status': reply_status, 'post_index': index} if browser_html else {}), @@ -4238,8 +4219,8 @@ async def decorate(value): 'Vary': 'Accept, Cookie', }) if value.get('type') == 'post' and value.get('state', 'active') == 'active': - from msg.transports.oauth_http import csrf from msg.transports.post_actions import post_actions_html + from msg.transports.session_http import csrf session_cookie = ( '__Host-msg_session' if expected.scheme == 'https' else 'msg_session' @@ -4264,7 +4245,7 @@ async def decorate(value): headers['Vary'] = 'Accept, Cookie' if browser_html and value.get('type') in {'post', 'topic'}: from msg.core.wiki import in_wiki - from msg.transports.oauth_http import csrf + from msg.transports.session_http import csrf from msg.transports.wiki_actions import wiki_actions_html async with service.metadata.transaction(write=False) as tx: diff --git a/src/msg/transports/login_providers.py b/src/msg/transports/login_providers.py deleted file mode 100644 index 805b981e..00000000 --- a/src/msg/transports/login_providers.py +++ /dev/null @@ -1,420 +0,0 @@ -"""Pinned OAuth/OIDC exchanges; provider tokens never cross this module's boundary.""" - -from __future__ import annotations - -import asyncio -import base64 -import hashlib -import json -import os -import re -import stat -from collections.abc import Mapping -from dataclasses import dataclass -from datetime import datetime -from urllib.parse import quote_plus, urlencode, urlsplit - -import httpx - -from msg.core.codec import b64, canonical -from msg.core.errors import Failure -from msg.security.login_jwt import token_key_id, verify_identity_token - -GOOGLE_ISSUER = 'https://accounts.google.com' -GITHUB_ISSUER = 'https://github.com' -CHATGPT_ISSUER = 'https://auth.openai.com' -AGENTID_ISSUER = 'https://auth.agentid.com' -_AUTHORIZATION = { - 'agentid': AGENTID_ISSUER + '/v0/authorize', - 'google': 'https://accounts.google.com/o/oauth2/v2/auth', - 'github': 'https://github.com/login/oauth/authorize', - 'chatgpt': 'https://auth.openai.com/api/accounts/authorize', -} -_TOKEN = { - 'agentid': AGENTID_ISSUER + '/v0/token', - 'google': 'https://oauth2.googleapis.com/token', - 'github': 'https://github.com/login/oauth/access_token', - 'chatgpt': 'https://auth.openai.com/api/accounts/oauth/token', -} -_JWKS = { - 'agentid': AGENTID_ISSUER + '/v0/jwks.json', - 'google': 'https://www.googleapis.com/oauth2/v3/certs', - 'chatgpt': 'https://auth.openai.com/.well-known/jwks.json', -} -_DISCOVERY = CHATGPT_ISSUER + '/.well-known/openid-configuration' -_MAX_RESPONSE = 64 * 1024 -_TIMEOUT = httpx.Timeout(10, connect=5, pool=5, write=10) - - -@dataclass(frozen=True) -class VerifiedIdentity: - """Only verified, non-secret mapping inputs; never a token or email address.""" - - provider: str - issuer: str - subject: str - client_id: str | None = None - - @property - def stable_id(self) -> str: - identity = {'provider': self.provider, 'issuer': self.issuer, 'subject': self.subject} - if self.provider == 'chatgpt': - identity['client_id'] = self.client_id - return 'sha256:' + hashlib.sha256(canonical(identity)).hexdigest() - - -@dataclass(frozen=True) -class _JwksEntry: - keys: dict - fetched_at: float - expires_at: float - - -# Only the pinned JWKS URLs can enter these maps. No provider token is cached. -_jwks_cache: dict[str, _JwksEntry] = {} -_jwks_locks: dict[str, asyncio.Lock] = {} -_discovery_expires_at = 0.0 -_discovery_lock: asyncio.Lock | None = None - - -def _get(config, name, default=None): - return ( - config.get(name, default) if isinstance(config, Mapping) else getattr(config, name, default) - ) - - -def _alias(config, name, legacy): - value, previous = _get(config, name), _get(config, legacy) - if value is not None and previous is not None and value != previous: - raise Failure('login_provider_configuration') - return value if value is not None else previous - - -def _credential_file(config): - return _alias(config, 'credential_file', 'secret_file') - - -def _text(value, *, limit=4096, minimum=1, code='login_provider_invalid_request'): - if ( - type(value) is not str - or not minimum <= len(value) <= limit - or not value.isascii() - or any(ord(character) < 33 or ord(character) > 126 for character in value) - ): - raise Failure(code) - return value - - -def _settings(provider, config): - if type(provider) is not str or provider not in _AUTHORIZATION: - raise Failure('login_provider_unsupported') - client_id = _text(_get(config, 'client_id'), limit=1024, code='login_provider_configuration') - method = _alias(config, 'token_auth_method', 'client_auth_method') - if method is None: - method = ( - 'none' - if provider == 'chatgpt' - else 'client_secret_basic' - if provider == 'agentid' - else 'client_secret_post' - ) - allowed = ( - {'none', 'client_secret_basic'} - if provider == 'chatgpt' - else {'client_secret_basic', 'client_secret_post'} - if provider == 'agentid' - else {'client_secret_post'} - ) - if ( - type(method) is not str - or method not in allowed - or (provider == 'chatgpt' and not client_id.startswith('oaiapp_')) - ): - raise Failure('login_provider_configuration') - if method != 'none' and not _credential_file(config): - raise Failure('login_provider_configuration') - if method == 'none' and _credential_file(config): - raise Failure('login_provider_configuration') - return client_id, method - - -def _callback_inputs(verifier, nonce, redirect_uri): - verifier = _text(verifier, minimum=43, limit=128) - if re.fullmatch(r'[A-Za-z0-9._~-]+', verifier) is None: - raise Failure('login_provider_invalid_request') - nonce = _text(nonce, limit=512) - redirect_uri = _text(redirect_uri, limit=4096) - try: - parsed = urlsplit(redirect_uri) - valid = parsed.scheme == 'https' or ( - parsed.scheme == 'http' and parsed.hostname in {'localhost', '127.0.0.1', '::1'} - ) - valid = valid and parsed.hostname and not parsed.username and not parsed.password - valid = valid and not parsed.fragment - _ = parsed.port - except ValueError: - raise Failure('login_provider_invalid_request') from None - if not valid: - raise Failure('login_provider_invalid_request') - return verifier, nonce, redirect_uri - - -def authorization_url( - provider, config, state, verifier, nonce, redirect_uri, *, login_hint=None -) -> str: - """Build a pinned authorization URL; the HTTP boundary owns one-use state.""" - client_id, _ = _settings(provider, config) - state = _text(state, limit=1024) - verifier, nonce, redirect_uri = _callback_inputs(verifier, nonce, redirect_uri) - parameters = { - 'client_id': client_id, - 'redirect_uri': redirect_uri, - 'response_type': 'code', - 'state': state, - 'code_challenge': b64(hashlib.sha256(verifier.encode('ascii')).digest()), - 'code_challenge_method': 'S256', - 'scope': 'read:user' if provider == 'github' else 'openid profile email', - } - if provider == 'github': - parameters['allow_signup'] = 'false' - else: - parameters['nonce'] = nonce - if login_hint is not None: - if provider != 'agentid': - raise Failure('login_provider_invalid_request') - parameters['login_hint'] = _text(login_hint, limit=254) - return _AUTHORIZATION[provider] + '?' + urlencode(parameters) - - -def _read_secret(config) -> str: - """Read only the configured secret file, bounded and without path-bearing errors.""" - descriptor = None - try: - path = _credential_file(config) - descriptor = os.open(path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW | os.O_NONBLOCK) - metadata = os.fstat(descriptor) - if ( - not stat.S_ISREG(metadata.st_mode) - or metadata.st_size > 8192 - or metadata.st_mode & 0o027 - ): - raise ValueError - raw = os.read(descriptor, 8193) - if len(raw) > 8192: - raise ValueError - secret = raw.rstrip(b'\r\n').decode('utf-8') - if not secret or any(ord(character) < 32 or ord(character) == 127 for character in secret): - raise ValueError - return secret - except OSError, TypeError, ValueError, UnicodeError: - raise Failure('login_provider_configuration') from None - finally: - if descriptor is not None: - os.close(descriptor) - - -def _object_pairs(pairs): - result = {} - for key, value in pairs: - if key in result: - raise ValueError - result[key] = value - return result - - -def _invalid_constant(_): - raise ValueError - - -async def _json_request(client, method, url, *, data=None, headers=None): - request_headers = { - 'Accept': 'application/json', - 'Accept-Encoding': 'identity', - 'User-Agent': 'MSG-login/1', - } - request_headers.update(headers or {}) - try: - async with client.stream( - method, - url, - data=data, - headers=request_headers, - timeout=_TIMEOUT, - follow_redirects=False, - auth=None, - ) as response: - if response.status_code >= 500: - raise Failure('login_provider_unavailable', retryable=True) - if response.status_code != 200: - raise Failure('login_provider_invalid_response') - if response.headers.get('content-encoding', 'identity').lower() != 'identity': - raise Failure('login_provider_invalid_response') - length = response.headers.get('content-length') - if length is not None and (not length.isdecimal() or int(length) > _MAX_RESPONSE): - raise Failure('login_provider_invalid_response') - body = bytearray() - async for chunk in response.aiter_bytes(): - if len(body) + len(chunk) > _MAX_RESPONSE: - raise Failure('login_provider_invalid_response') - body.extend(chunk) - value = json.loads( - body.decode('utf-8'), - object_pairs_hook=_object_pairs, - parse_constant=_invalid_constant, - ) - if type(value) is not dict: - raise Failure('login_provider_invalid_response') - return value, response.headers - except httpx.HTTPError, OSError: - raise Failure('login_provider_unavailable', retryable=True) from None - except (ValueError, UnicodeError, RecursionError) as error: - if isinstance(error, Failure): - raise - raise Failure('login_provider_invalid_response') from None - - -def _cache_seconds(headers) -> int: - control = headers.get('cache-control', '').lower() - if 'no-store' in control or 'no-cache' in control: - return 0 - match = re.search(r'(?:^|,)\s*max-age\s*=\s*"?(\d+)"?(?:\s*,|\s*$)', control) - return min(int(match[1][:10]), 3600) if match else 300 - - -def _check_discovery(document, provider, issuer, algorithm): - required = { - 'issuer': issuer, - 'authorization_endpoint': _AUTHORIZATION[provider], - 'token_endpoint': _TOKEN[provider], - 'jwks_uri': _JWKS[provider], - } - if any(document.get(key) != value for key, value in required.items()): - raise Failure('login_provider_invalid_discovery') - algorithms = document.get('id_token_signing_alg_values_supported') - if type(algorithms) is not list or algorithm not in algorithms: - raise Failure('login_provider_invalid_discovery') - - -async def _openai_discovery(client, instant): - global _discovery_expires_at, _discovery_lock - if instant < _discovery_expires_at: - return - if _discovery_lock is None: - _discovery_lock = asyncio.Lock() - async with _discovery_lock: - if instant < _discovery_expires_at: - return - document, headers = await _json_request(client, 'GET', _DISCOVERY) - _check_discovery(document, 'chatgpt', CHATGPT_ISSUER, 'RS256') - _discovery_expires_at = instant + _cache_seconds(headers) - - -async def _identity_jwks(provider, client, instant, kid): - url = _JWKS[provider] - lock = _jwks_locks.setdefault(url, asyncio.Lock()) - async with lock: - entry = _jwks_cache.get(url) - fresh = entry is not None and instant < entry.expires_at - matching = entry is not None and any( - type(key) is dict and key.get('kid') == kid for key in entry.keys.get('keys', []) - ) - # A new unknown kid can trigger one refresh per 30 seconds. The initial fetch - # already covers unknown keys, so it never causes an immediate second fetch. - if fresh and (matching or instant - entry.fetched_at < 30): - return entry.keys - keys, headers = await _json_request(client, 'GET', url) - items = keys.get('keys') - if type(items) is not list or not 1 <= len(items) <= 32: - raise Failure('login_provider_invalid_response') - entry = _JwksEntry(keys, instant, instant + _cache_seconds(headers)) - _jwks_cache[url] = entry - return keys - - -def _instant(now): - if not isinstance(now, datetime) or now.tzinfo is None or now.utcoffset() is None: - raise Failure('login_provider_invalid_request') - return now.timestamp() - - -async def exchange_identity( - provider, config, code, verifier, nonce, redirect_uri, now, client=None -) -> VerifiedIdentity: - """Exchange once and verify identity; code, secrets and tokens stay in memory.""" - client_id, auth_method = _settings(provider, config) - code = _text(code) - verifier, nonce, redirect_uri = _callback_inputs(verifier, nonce, redirect_uri) - instant = _instant(now) - if client is None: - async with httpx.AsyncClient(trust_env=False, follow_redirects=False) as owned: - return await exchange_identity( - provider, config, code, verifier, nonce, redirect_uri, now, client=owned - ) - if provider == 'chatgpt': - await _openai_discovery(client, instant) - elif provider == 'agentid': - document, _ = await _json_request( - client, 'GET', AGENTID_ISSUER + '/.well-known/openid-configuration' - ) - _check_discovery(document, provider, AGENTID_ISSUER, 'ES256') - if document.get('authorization_response_iss_parameter_supported') is not True: - raise Failure('login_provider_invalid_discovery') - data = { - 'grant_type': 'authorization_code', - 'code': code, - 'redirect_uri': redirect_uri, - 'client_id': client_id, - 'code_verifier': verifier, - } - headers = {} - if auth_method != 'none': - secret = _read_secret(config) - if auth_method == 'client_secret_basic': - credentials = quote_plus(client_id, safe='') + ':' + quote_plus(secret, safe='') - headers['Authorization'] = 'Basic ' + base64.b64encode( - credentials.encode('ascii') - ).decode('ascii') - else: - data['client_secret'] = secret - tokens, _ = await _json_request(client, 'POST', _TOKEN[provider], data=data, headers=headers) - if provider == 'github': - access_token = _text( - tokens.get('access_token'), limit=8192, code='login_provider_invalid_response' - ) - token_type = tokens.get('token_type') - if type(token_type) is not str or token_type.lower() != 'bearer': - raise Failure('login_provider_invalid_response') - user, _ = await _json_request( - client, - 'GET', - 'https://api.github.com/user', - headers={ - 'Authorization': 'Bearer ' + access_token, - 'X-GitHub-Api-Version': '2022-11-28', - }, - ) - subject = user.get('id') - if type(subject) is not int or not 0 < subject < 2**63: - raise Failure('login_provider_invalid_identity') - return VerifiedIdentity(provider, GITHUB_ISSUER, str(subject)) - id_token = tokens.get('id_token') - algorithm = 'ES256' if provider == 'agentid' else 'RS256' - kid = token_key_id(id_token, algorithm=algorithm) - jwks = await _identity_jwks(provider, client, instant, kid) - identity = verify_identity_token( - id_token, - jwks, - issuers={GOOGLE_ISSUER, 'accounts.google.com'} - if provider == 'google' - else {AGENTID_ISSUER} - if provider == 'agentid' - else {CHATGPT_ISSUER}, - audience=client_id, - nonce=nonce, - now=now, - algorithm=algorithm, - ) - issuer = GOOGLE_ISSUER if provider == 'google' else identity.issuer - return VerifiedIdentity( - provider, issuer, identity.subject, client_id if provider == 'chatgpt' else None - ) diff --git a/src/msg/transports/mcp_agent.py b/src/msg/transports/mcp_agent.py index a55b55cd..7564cf53 100644 --- a/src/msg/transports/mcp_agent.py +++ b/src/msg/transports/mcp_agent.py @@ -20,11 +20,11 @@ _ERRORS = { 'authentication_required': ( 'MSG identity is not connected.', - 'Connect an MSG identity with msg_connect, then retry.', + 'Use the locally signed CLI or native signed MCP packet endpoint.', ), 'credential_ceiling': ( 'This connection does not have permission for this action.', - 'Reconnect and approve the required MSG scope. Existing credentials are not expanded.', + 'Use a local key with permission for this action. Existing grants are not expanded.', ), 'mcp_session_required': ( 'An initialized MCP session is required for this action.', @@ -102,26 +102,14 @@ def _session(self, identity, session_id, *, initialize=False): position = {'nonce': uuid4().hex, 'expires_at': wire(now + timedelta(hours=24))} else: return None, None - # Anonymous discovery may precede OAuth. Upgrade that session once the - # host sends a bearer, while isolating retries by the OAuth family. + # A trusted key-backed adapter may attach identity after discovery. + # Its binding keeps retries isolated from anonymous and other sessions. position['binding'] = binding value = self.service.cursors.encode( 'mcp-session', {'service': self.service.settings.service_url}, position ) return position['nonce'], value - @staticmethod - def _scopes(name): - if name == 'msg_connect': - return ('msg.mcp.read', 'msg.mcp.message', 'msg.mcp.post') - if name in {'msg_send', 'msg_dm_decide', 'msg_ack'}: - return ('msg.mcp.read', 'msg.mcp.message') - if name == 'msg_reply': - return ('msg.mcp.read', 'msg.mcp.message', 'msg.mcp.post') - if name == 'msg_post': - return ('msg.mcp.read', 'msg.mcp.post') - return ('msg.mcp.read',) - def _error_result(self, code, name=None): value = tool_error(code) result = { @@ -129,18 +117,6 @@ def _error_result(self, code, name=None): 'structuredContent': value, 'isError': True, } - if code in {'authentication_required', 'credential_ceiling'}: - from msg.transports.mcp_auth import challenge - - result['_meta'] = { - 'mcp/www_authenticate': [ - challenge( - self.service, - 'insufficient_scope' if code == 'credential_ceiling' else None, - self._scopes(name), - ) - ] - } return result async def _call(self, params, rpc_id, identity, nonce): @@ -154,8 +130,6 @@ async def _call(self, params, rpc_id, identity, nonce): require(identity is not None, 'authentication_required') raise Failure('credential_ceiling') require(local_validator(tool['inputSchema']).is_valid(arguments), 'invalid_jsonrpc_params') - if name == 'msg_connect': - require(identity is not None, 'authentication_required') mutating = not tool.get('annotations', {}).get('readOnlyHint', False) require(not mutating or nonce is not None, 'mcp_session_required') # Never use a model-supplied ID as a global account replay key. @@ -199,7 +173,7 @@ async def call(operation, business_arguments, *, contract_version=1): return await self.service.executor.execute(packet, entry='network') value = await run_tool( - 'msg_me' if name == 'msg_connect' else name, + name, arguments, call=call, identity=identity, diff --git a/src/msg/transports/mcp_auth.py b/src/msg/transports/mcp_auth.py deleted file mode 100644 index eef44a8f..00000000 --- a/src/msg/transports/mcp_auth.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Private OAuth context for the MCP adapter; no browser cookie or delegated signer.""" - -from dataclasses import dataclass, field -from datetime import datetime - -from msg.core.codec import loads, unb64 -from msg.core.errors import require -from msg.oauth_config import MCP_SCOPE_OPERATIONS, MCP_SCOPES -from msg.security.oauth import OAuthService, get - -MCP_PATHS = frozenset({'/-/mcp', '/-/mcp/raw'}) -PROTECTED_RESOURCE_PATHS = frozenset({ - '/.well-known/oauth-protected-resource', - '/.well-known/oauth-protected-resource/-/mcp', - '/.well-known/oauth-protected-resource/-/mcp/raw', -}) - - -@dataclass(frozen=True, slots=True) -class MCPIdentity: - # A transport-only value. Never pass this dataclass to wire(), JSON or logs. - subject: str - credential_id: str = field(repr=False) - token: bytes = field(repr=False) - operations: frozenset[str] - expires_at: datetime - session_binding: str = field(repr=False) - credential_type: str = 'oauth' - - -def resource_uri(service): - return service.settings.service_url + '/-/mcp' - - -def protected_resource_metadata(service): - return { - 'resource': resource_uri(service), - 'authorization_servers': [service.settings.service_url], - 'scopes_supported': sorted(MCP_SCOPES), - 'bearer_methods_supported': ['header'], - } - - -def challenge(service, code=None, scopes=()): - # Callers cannot insert arbitrary exception or request text into a header. - require(code in {None, 'invalid_token', 'insufficient_scope'}, 'invalid_challenge') - require(set(scopes) <= MCP_SCOPES, 'invalid_challenge') - value = ( - 'Bearer resource_metadata="' - + service.settings.service_url - + '/.well-known/oauth-protected-resource/-/mcp"' - ) - if code: - value += ', error="' + code + '"' - if scopes: - value += ', scope="' + ' '.join(sorted(set(scopes))) + '"' - return value - - -async def authenticate_mcp(service, value): - """Validate the OAuth family and current parent authority on every request.""" - oauth = OAuthService(service) - require( - isinstance(value, str) and value[:7].lower() == 'bearer ' and len(value) <= 300, - 'invalid_token', - ) - bearer = value[7:] - async with service.metadata.transaction(write=False) as tx: - oauth.fence(tx) - credential = await oauth.bearer(tx, bearer, resource=resource_uri(service)) - require(credential.id.startswith('t_oauth_'), 'invalid_token') - _, binding = get(tx, 'access:' + credential.id, service.clock()) - _, family = get(tx, binding['family'], service.clock()) - require(family['subject'] == credential.subject_id, 'invalid_token') - allowed = frozenset().union( - *(MCP_SCOPE_OPERATIONS.get(scope, frozenset()) for scope in family['scopes']) - ) - operations = frozenset().union(*(g.operations for g in credential.ceiling)) & allowed - return MCPIdentity( - subject=credential.subject_id, - credential_id=credential.id, - token=unb64(bearer.partition('.')[2], limit=32), - operations=operations, - expires_at=credential.expires_at, - session_binding=binding['family'], - ) - - -def require_unmixed_mcp(message): - """Bearer-authenticated JSON-RPC cannot carry a second packet proof.""" - if not isinstance(message, dict): - return - params = message.get('params', {}) - arguments = params.get('arguments', {}) if isinstance(params, dict) else {} - packet = arguments.get('packet') if isinstance(arguments, dict) else None - if isinstance(packet, str): - packet = loads(packet) - if isinstance(packet, dict): - require(packet.get('proof') is None, 'ambiguous_credentials') - # A bare operation packet is never silently converted into a new bearer request. - if 'operation' in message: - require(message.get('proof') is None, 'ambiguous_credentials') diff --git a/src/msg/transports/mcp_identity.py b/src/msg/transports/mcp_identity.py new file mode 100644 index 00000000..93578a47 --- /dev/null +++ b/src/msg/transports/mcp_identity.py @@ -0,0 +1,16 @@ +"""Trusted adapter-local identity context; never populated from tool arguments.""" + +from dataclasses import dataclass, field +from datetime import datetime + + +@dataclass(frozen=True, slots=True) +class MCPIdentity: + # A transport-only value. Never pass this dataclass to wire(), JSON or logs. + subject: str + credential_id: str = field(repr=False) + token: bytes = field(repr=False) + operations: frozenset[str] + expires_at: datetime + session_binding: str = field(repr=False) + credential_type: str = 'key-grant' diff --git a/src/msg/transports/mcp_tools.py b/src/msg/transports/mcp_tools.py index 76c89e9b..7c2219ee 100644 --- a/src/msg/transports/mcp_tools.py +++ b/src/msg/transports/mcp_tools.py @@ -91,7 +91,7 @@ def _choice(*names): } DESCRIPTIONS = { 'msg_me': 'Show the connected MSG account and available capabilities. Never returns secrets.', - 'msg_connect': 'Ask the host to connect a MSG account using OAuth. No credentials are entered here.', + 'msg_connect': 'Show how to use a local signing key with MSG. No third-party account or credential is accepted.', 'msg_resolve': 'Resolve a MSG path, URL, stable reference or @handle using current access.', 'msg_read': 'Read one bounded text segment or resource metadata. Reading never acknowledges it. Continue with cursor only.', 'msg_list': 'List a bounded page of children or replies. Continue with cursor only.', @@ -152,25 +152,6 @@ def _available(name, available): ) -def _scopes(name, operations): - if name == 'msg_connect': - return ['msg.mcp.read', 'msg.mcp.message', 'msg.mcp.post'] - if name in PUBLIC or name in {'msg_inbox', 'msg_notifications'}: - return ['msg.mcp.read'] - if name in {'msg_send', 'msg_dm_decide', 'msg_ack'}: - return ['msg.mcp.read', 'msg.mcp.message'] - if name == 'msg_reply': - return ['msg.mcp.read'] + [ - scope - for operation, scope in ( - ('communication.dm_send@2', 'msg.mcp.message'), - ('discussion.reply@2', 'msg.mcp.post'), - ) - if operation in operations - ] - return ['msg.mcp.read', 'msg.mcp.post'] - - def tool_catalog(registry, identity=None): """Catalog only installed contracts and the account's finite operation ceiling.""" installed = frozenset( @@ -182,15 +163,10 @@ def tool_catalog(registry, identity=None): available = installed if name in PUBLIC else permitted if not _available(name, available) or name not in PUBLIC and identity is None: continue - schemes = ([{'type': 'noauth'}] if name in PUBLIC else []) + [ - {'type': 'oauth2', 'scopes': _scopes(name, available)} - ] tools.append({ 'name': name, 'description': DESCRIPTIONS[name], 'inputSchema': deepcopy(schema), - 'securitySchemes': schemes, - '_meta': {'securitySchemes': deepcopy(schemes)}, 'annotations': { 'readOnlyHint': name in PUBLIC or name in {'msg_inbox', 'msg_notifications'}, 'destructiveHint': name == 'msg_dm_decide', @@ -203,10 +179,10 @@ def tool_catalog(registry, identity=None): _HINTS = { - 'authentication_required': 'Connect your MSG account with msg_connect, then retry.', - 'credential_ceiling': 'Reconnect with permission for this tool; the host must request the required MSG scope.', - 'credential_expired': 'Reconnect your MSG account to renew access.', - 'invalid_token': 'Reconnect your MSG account to obtain a valid host credential.', + 'authentication_required': 'Use the locally signed CLI or native signed MCP packet endpoint.', + 'credential_ceiling': 'Use a local key with permission for this operation.', + 'credential_expired': 'Use a current local signing key.', + 'invalid_token': 'Use a valid local signing key or native limited grant.', 'jsonrpc_id_conflict': 'Keep the same ID only for an unchanged retry; use a new call ID for a new action.', 'idempotency_conflict': 'Keep the same ID only for an unchanged retry; use a new call ID for a new action.', 'payload_digest_mismatch': 'Retry through the high-level MSG tool; the SDK must construct the authenticated request.', @@ -467,7 +443,10 @@ async def _run(name, arguments, call, identity): ], } if name == 'msg_connect': - raise _ToolFailure(_error('authentication_required')) + return { + 'authentication': 'public-key-signature', + 'instructions': 'Use msg register HANDLE to create local keys, or select an existing key account. Use the signed CLI or /-/mcp/raw with a signed MSG packet. Never send a private key in tool arguments.', + } if name == 'msg_resolve': ref, data = await _resolve(call, arguments['address'], arguments.get('revision')) return {**data, 'ref': ref} diff --git a/src/msg/transports/oauth_http.py b/src/msg/transports/oauth_http.py deleted file mode 100644 index 661918e9..00000000 --- a/src/msg/transports/oauth_http.py +++ /dev/null @@ -1,809 +0,0 @@ -"""Browser consent, persistent HttpOnly sessions and standard OAuth endpoints.""" - -import hashlib -import hmac -import os -from base64 import b64encode -from dataclasses import replace -from datetime import timedelta -from html import escape -from urllib.parse import parse_qsl, urlencode, urlsplit - -from starlette.requests import Request -from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse - -from msg.core.codec import b64, canonical, loads, wire -from msg.core.errors import Failure, require -from msg.core.models import TokenProof -from msg.core.requests import request_for -from msg.oauth_config import SCOPES -from msg.security.oauth import ( - DEVICE_GRANT, - OAuthService, - get, - resource_execution, - save, - secret, - state_id, -) -from msg.transports.browser_login import LOGIN_POLL_SCRIPT -from msg.transports.browser_navigation import HASH as NAVIGATION_HASH, TAG as NAVIGATION_TAG -from msg.transports.browser_style import BRAND_LINK, PREFERENCES, SKIP_LINK, THEME_CSS -from msg.transports.http_common import body_bytes -from msg.transports.mcp_auth import ( - MCP_PATHS, - PROTECTED_RESOURCE_PATHS, - authenticate_mcp, - challenge, - protected_resource_metadata, - require_unmixed_mcp, - resource_uri, -) -from msg.transports.packet import decode_packet -from msg.transports.url_safety import require_matching_host, require_safe_request_target -from msg.transports.webmcp import WEBMCP_HASH, WEBMCP_TAG - -HEADERS = { - 'Cache-Control': 'no-store', - 'Pragma': 'no-cache', - 'Referrer-Policy': 'no-referrer', - 'X-Content-Type-Options': 'nosniff', - 'X-Frame-Options': 'DENY', - 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; " - f"script-src 'sha256-{WEBMCP_HASH}' 'sha256-{NAVIGATION_HASH}'; connect-src 'self'; img-src 'self'; form-action 'self'; " - "frame-ancestors 'none'; base-uri 'none'", -} - - -def json(value, status=200): - return JSONResponse(value, status_code=status, headers=HEADERS) - - -def page(title, body, *, script=None): - """Render trusted form markup; authorize only the explicitly supplied script.""" - headers = {**HEADERS, 'Referrer-Policy': 'strict-origin'} - script_tag = '' - if script is not None: - pinned = b64encode(hashlib.sha256(script.encode()).digest()).decode() - headers['Content-Security-Policy'] = headers['Content-Security-Policy'].replace( - 'script-src ', f"script-src 'sha256-{pinned}' " - ) - script_tag = '' - return HTMLResponse( - '' - '' - '' + escape(title) + '' - '' - '' - + SKIP_LINK - + '' - '
' - + PREFERENCES - + 'raw
' - '

' - + escape(title) - + '

' - + body - + '
' - + WEBMCP_TAG - + NAVIGATION_TAG - + script_tag - + '', - # Form POSTs need a non-opaque Origin for the same-origin CSRF fence. - # no-referrer makes navigation POST origins null in Chromium. - headers=headers, - ) - - -def fields(items): - return ''.join( - '' - for k, v in items.items() - ) - - -def csrf(cookie): - return b64(hmac.digest(cookie.encode(), b'msg-oauth-browser-csrf-v1', 'sha256')) - - -def unique(pairs): - require(len({k for k, _ in pairs}) == len(pairs), 'invalid_request') - return dict(pairs) - - -async def form(request): - raw = await body_bytes(request, 16384) - content = request.headers.get('content-type', '').split(';')[0] - if content == 'application/json': - value = loads(raw) - require(isinstance(value, dict), 'invalid_request') - else: - require(content == 'application/x-www-form-urlencoded', 'invalid_request') - try: - value = unique(parse_qsl(raw.decode(), keep_blank_values=True, max_num_fields=20)) - except UnicodeError, ValueError: - raise Failure('invalid_request') from None - require( - all(isinstance(k, str) and isinstance(v, str) and len(v) <= 4096 for k, v in value.items()), - 'invalid_request', - ) - return value - - -class OAuthBoundary: - def __init__(self, app, *, service): - self.app, self.service = app, service - self.oauth = OAuthService(service) - self.secure = urlsplit(service.settings.service_url).scheme == 'https' - self.session_cookie = '__Host-msg_session' if self.secure else 'msg_session' - self.login_cookie = '__Host-msg_login' if self.secure else 'msg_login' - - def cookie(self, response, name, value, ttl): - response.set_cookie( - name, value, max_age=ttl, secure=self.secure, httponly=True, samesite='lax', path='/' - ) - - def require_csrf(self, request, args, name): - value = request.cookies.get(name, '') - supplied = args.get('csrf', '') - require( - isinstance(supplied, str) - and supplied.isascii() - and value - and request.headers.get('origin') == self.service.settings.service_url - and hmac.compare_digest(supplied, csrf(value)), - 'invalid_request', - ) - return value - - async def __call__(self, scope, receive, send): - if scope['type'] != 'http': - await self.app(scope, receive, send) - return - request = Request(scope, receive) - path = request.url.path - oauth_path = ( - path == '/login' - or path.startswith('/oauth/') - or path - in { - '/.well-known/oauth-authorization-server', - '/.well-known/openid-configuration', - } - or path in PROTECTED_RESOURCE_PATHS - ) - bearer = request.headers.get('authorization') - mcp_path = path in MCP_PATHS - if mcp_path: - scope.setdefault('state', {})['msg_mcp_identity'] = None - if bearer is None: - # Keep signed/anonymous MCP on the shared HTTP ingress policy, - # including its explicit aliases and denial status. No cookie fallback. - await self.app(scope, receive, send) - return - if not oauth_path and not mcp_path and not (bearer and path.startswith('/-/p/')): - cookie = request.cookies.get(self.session_cookie, '') - browser_read = ( - cookie - and request.method in {'GET', 'HEAD'} - and self.oauth.config.enabled - and not path.startswith('/-/') - and not request.headers.get('x-msg-request') - and not bearer - ) - if browser_read: - try: - require_safe_request_target( - scope.get('raw_path') or path.encode(), - scope.get('query_string', b''), - maximum=self.service.settings.server.limits.max_path_bytes, - ) - require_matching_host( - request.headers.getlist('host'), urlsplit(self.service.settings.service_url) - ) - require( - request.headers.get('origin') in {None, self.service.settings.service_url}, - 'forbidden_origin', - ) - async with self.service.metadata.transaction(write=False) as tx: - credentials = await self.oauth.browser_credentials(tx, cookie) - scope.setdefault('state', {})['msg_browser_credentials'] = credentials - except Failure as exc: - if exc.code not in {'invalid_grant', 'credential_not_found'}: - status = ( - 503 - if exc.code in {'recovery_quarantined', 'recovery_runtime_stale'} - else 400 - ) - await json({'error': exc.code}, status)(scope, receive, send) - return - scope.setdefault('state', {})['msg_browser_expired'] = True - - async def private_send(message): - if message['type'] == 'http.response.start': - headers = [ - (k, v) for k, v in message['headers'] if k.lower() != b'cache-control' - ] - message = dict( - message, - headers=headers - + [ - (b'cache-control', b'private, no-store'), - (b'vary', b'Cookie'), - ], - ) - await send(message) - - await self.app(scope, receive, private_send) - else: - await self.app(scope, receive, send) - return - try: - raw = scope.get('raw_path') or path.encode() - require_safe_request_target( - raw, - scope.get('query_string', b''), - maximum=self.service.settings.server.limits.max_path_bytes, - ) - require_matching_host( - request.headers.getlist('host'), urlsplit(self.service.settings.service_url) - ) - require( - 'x-http-method-override' not in request.headers - and 'x-method-override' not in request.headers, - 'method_not_allowed', - ) - origin = request.headers.get('origin') - require( - origin is None or origin == self.service.settings.service_url, 'forbidden_origin' - ) - if mcp_path: - require(request.method == 'POST', 'method_not_allowed') - require( - len(request.headers.getlist('authorization')) == 1 - and not request.headers.get('x-msg-request'), - 'ambiguous_credentials', - ) - identity = await authenticate_mcp(self.service, bearer) - raw_body = await body_bytes( - request, self.service.settings.server.limits.max_request_bytes - ) - require_unmixed_mcp(loads(raw_body)) - scope['state']['msg_mcp_identity'] = identity - sent = False - - async def mcp_receive(): - nonlocal sent - if sent: - return await receive() - sent = True - return {'type': 'http.request', 'body': raw_body, 'more_body': False} - - rebound_scope = dict( - scope, - headers=[ - (k, v) - for k, v in scope['headers'] - if k.lower() - not in {b'content-length', b'content-encoding', b'transfer-encoding'} - ] - + [(b'content-length', str(len(raw_body)).encode())], - ) - with resource_execution(resource_uri(self.service)): - await self.app(rebound_scope, mcp_receive, send) - return - if not oauth_path: - require(request.method == 'POST', 'method_not_allowed') - packet = decode_packet( - await body_bytes( - request, self.service.settings.server.limits.max_request_bytes - ), - self.service.settings.server.limits.max_request_bytes, - ) - require(packet.proof is None, 'ambiguous_credentials') - async with self.service.metadata.transaction(write=False) as tx: - self.service.runtime_generation.require_current(tx) - credential = await self.oauth.bearer(tx, self.bearer_value(request)) - require(packet.subject == credential.subject_id, 'subject_mismatch') - from msg.core.codec import unb64 - - packet = replace( - packet, - proof=TokenProof( - credential_id=credential.id, - token=unb64(self.bearer_value(request).partition('.')[2], limit=32), - ), - ) - data = canonical(packet) - sent = False - - async def bound_receive(): - nonlocal sent - if sent: - return await receive() - sent = True - return {'type': 'http.request', 'body': data, 'more_body': False} - - # The canonical packet now contains a proof and is no longer gzip. - # Forward matching framing, without mutating the incoming scope. - rebound_scope = dict( - scope, - headers=[ - (k, v) - for k, v in scope['headers'] - if k.lower() - not in {b'content-length', b'content-encoding', b'transfer-encoding'} - ] - + [(b'content-length', str(len(data)).encode())], - ) - await self.app(rebound_scope, bound_receive, send) - return - if (path.startswith('/oauth/') or path == '/login') and ( - request.method in {'GET', 'HEAD'} and request.query_params.get('format') == 'raw' - ): - from starlette.responses import PlainTextResponse - - require( - request.query_params.multi_items() == [('format', 'raw')], 'invalid_request' - ) - response = PlainTextResponse( - '# Sign in to MSG\n\nOpen /login, then confirm its code with `msg auth approve XXXXXXXX`.\n\nUse your registered CLI profile. New users: /register.\n', - headers=HEADERS, - ) - if request.method == 'HEAD': - response.body = b'' - await response(scope, receive, send) - return - require(self.oauth.config.enabled, 'oauth_disabled') - response = await self.dispatch(request) - except Failure as exc: - status = ( - 503 - if exc.code in {'recovery_quarantined', 'recovery_runtime_stale'} - else (405 if exc.code == 'method_not_allowed' else 400) - ) - response = json({'error': exc.code}, status) - if exc.code == 'invalid_token': - response.status_code = 401 - response.headers['WWW-Authenticate'] = ( - challenge(self.service, 'invalid_token', ('msg.mcp.read',)) - if mcp_path - else 'Bearer error="invalid_token"' - ) - except Exception: - response = json({'error': 'server_error'}, 500) - await response(scope, receive, send) - - @staticmethod - def bearer_value(request): - value = request.headers.get('authorization', '') - require(value.startswith('Bearer ') and len(value) <= 300, 'invalid_token') - return value[7:] - - async def dispatch(self, request): - path, now = request.url.path, self.service.clock() - if path == '/oauth/post-action': - from msg.security.browser_actions import BROWSER_POST_WRITES - - require(request.method == 'POST', 'method_not_allowed') - require( - not request.query_params - and not request.headers.get('authorization') - and not request.headers.get('x-msg-request'), - 'ambiguous_credentials', - ) - args = loads(await body_bytes(request, 65536)) - require( - isinstance(args, dict) - and set(args) - <= { - 'csrf', - 'operation', - 'id', - 'body', - 'revision', - 'request_id', - 'kind', - 'note', - 'generation', - 'name', - 'svg', - 'text', - }, - 'invalid_request', - ) - cookie = self.require_csrf(request, args, self.session_cookie) - operation = args.get('operation') - require( - isinstance(operation, str) and operation in BROWSER_POST_WRITES, 'permission_denied' - ) - require( - isinstance(args.get('request_id'), str) and 0 < len(args['request_id']) <= 128, - 'invalid_request', - ) - async with self.service.metadata.transaction(write=False) as tx: - subject, credential, token = await self.oauth.browser_credentials(tx, cookie) - arguments = {'id': args.get('id')} - expected = () - if operation == 'content.public_board_update': - arguments = {k: args[k] for k in ('generation', 'svg', 'text') if k in args} - if operation in {'content.post_create', 'content.post_edit'}: - from msg.core.wiki import in_wiki - from msg.plugins.common import resolve - - require(isinstance(args.get('body'), str), 'invalid_request') - async with self.service.metadata.transaction(write=False) as tx: - resource = await tx.resource(await resolve(tx, args.get('id'))) - require(await in_wiki(tx, resource), 'wiki_only') - if operation == 'content.post_create': - require(resource.type == 'topic', 'not_a_topic') - arguments = {'parent': resource.id, 'body': args['body']} - if 'name' in args: - arguments['name'] = args['name'] - else: - require(type(args.get('generation')) is int, 'expected_generation_required') - require(isinstance(args.get('revision'), str), 'revision_required') - arguments = { - 'id': resource.id, - 'body': args['body'], - 'expected_revision': args['revision'], - } - expected = ((resource.id, args['generation']),) - if operation in {'discussion.reply', 'discussion.fork'}: - require( - isinstance(args.get('body'), str) and bool(args['body'].strip()), - 'invalid_request', - ) - arguments = { - 'target': {'id': args.get('id'), 'revision': args.get('revision')}, - 'body': args['body'], - } - if operation == 'discussion.prove': - require(isinstance(args.get('revision'), str), 'proof_revision_required') - # Resolve only the displayed revision, never silently attest the latest. - from msg.core.models import ResourceRef - - async with self.service.metadata.transaction(write=False) as tx: - revision = await tx.revision( - ResourceRef(id=args.get('id'), revision=args['revision']) - ) - arguments = { - 'target': {'id': args.get('id'), 'revision': args['revision']}, - 'digest': revision.content.digest, - 'kind': args.get('kind'), - 'note': args.get('note', ''), - } - packet = request_for( - operation, - arguments, - self.service.settings.service_url, - subject=subject, - token=(credential, token), - source='manual', - expected=expected, - request_id=args['request_id'], - expires_at=now + timedelta(minutes=3), - ) - result = await self.service.executor.execute(packet, entry='network') - return json(wire(result), 200 if result.error is None else 403) - if path.startswith('/.well-known/') or path == '/oauth/jwks': - require(request.method in {'GET', 'HEAD'}, 'method_not_allowed') - base = self.service.settings.service_url - if path in PROTECTED_RESOURCE_PATHS: - return json(protected_resource_metadata(self.service)) - if path == '/oauth/jwks': - return json({'keys': [self.oauth.jwk()]}) - return json({ - 'issuer': base, - 'authorization_response_iss_parameter_supported': True, - 'authorization_endpoint': base + '/oauth/authorize', - 'token_endpoint': base + '/oauth/token', - 'userinfo_endpoint': base + '/oauth/userinfo', - 'jwks_uri': base + '/oauth/jwks', - 'revocation_endpoint': base + '/oauth/revoke', - 'device_authorization_endpoint': base + '/oauth/device_authorization', - 'response_types_supported': ['code'], - 'subject_types_supported': ['public'], - 'id_token_signing_alg_values_supported': ['EdDSA'], - 'grant_types_supported': ['authorization_code', 'refresh_token', DEVICE_GRANT], - 'token_endpoint_auth_methods_supported': ['none'], - 'code_challenge_methods_supported': ['S256'], - 'scopes_supported': sorted(SCOPES), - }) - # Rate limits commit independently, including rejected code guesses. - # Userinfo is a read: do not mutate retained OAuth state during GET or - # POST reads. Authentication still checks the current source/family. - if path != '/oauth/userinfo': - async with self.service.metadata.transaction(write=True) as tx: - self.oauth.rate(tx, request.client.host if request.client else '') - if path in {'/login', '/oauth/login'}: - require(request.method == 'GET', 'method_not_allowed') - async with self.service.metadata.transaction(write=True) as tx: - self.oauth.fence(tx) - value, code, _ = self.oauth.pending( - tx, kind='login', scope='openid profile msg.read msg.write' - ) - response = page( - 'Sign in to MSG', - '

Approve this browser sign-in from a signed-in CLI:

' - 'msg auth approve ' + escape(code) + '' - '

This browser can read content, your inbox and direct messages, and explicitly like, bookmark, follow authors, comment on posts, or create and edit shared wiki articles within your permissions. It cannot administer your identity or the server. Only approve if you opened this page.

' - '

Register using the CLI

' - '

Waiting for approval…

' - '

No identity? Create a hosted identity

' - '', - script=LOGIN_POLL_SCRIPT, - ) - self.cookie(response, self.login_cookie, value, 600) - return response - if path == '/oauth/signup': - if request.method == 'GET': - binder = secret() - response = page( - 'Create a hosted identity', - '

The server will store your signing and encryption keys. You can switch to managing your own keys later.

' - '
' - + fields({'csrf': csrf(binder)}) - + '' - '' - '
', - ) - self.cookie(response, self.login_cookie, binder, 600) - return response - require(request.method == 'POST', 'method_not_allowed') - args = await form(request) - self.require_csrf(request, args, self.login_cookie) - packet = request_for( - 'identity.custodial_create', - { - 'handle': args.get('handle', ''), - 'nonce': b64(os.urandom(32)), - 'recovery_secret': b64(os.urandom(32)), - }, - self.service.settings.service_url, - contract_version=2, - expires_at=now + timedelta(seconds=180), - ) - result = await self.service.executor.execute(packet, entry='network') - require(result.status == 'ok', result.error.code if result.error else 'server_error') - from msg.core.codec import unb64 - from msg.core.models import Principal - - token = unb64(result.data['token']) - async with self.service.metadata.transaction(write=True) as tx: - self.oauth.fence(tx) - value, code, _ = self.oauth.pending(tx, kind='login') - credential = await tx.credential(result.data['credential_id']) - require( - hmac.compare_digest(credential.verifier, hashlib.sha256(token).digest()), - 'invalid_token', - ) - principal = Principal( - actor=result.subject, - subject=result.subject, - credential_id=credential.id, - method='token', - certificates=(), - ceiling=credential.ceiling, - ) - await self.oauth.approve(tx, principal, code, 'approve') - logged, error = await self.oauth.pending_result(tx, value, 'msg-cli', 'login') - require(error is None, 'server_error') - response = page( - 'Signed in', - '

Your hosted identity has been created. Return to the authorization page to continue. Keep this browser session; after signing out, you will need a signed-in CLI to sign in again.

', - ) - self.cookie( - response, - self.session_cookie, - logged['cookie'], - int((logged['expires'] - now).total_seconds()), - ) - return response - if path == '/oauth/login/poll': - require(request.method == 'POST', 'method_not_allowed') - value = self.require_csrf(request, await form(request), self.login_cookie) - async with self.service.metadata.transaction(write=True) as tx: - self.oauth.fence(tx) - logged, error = await self.oauth.pending_result(tx, value, 'msg-cli', 'login') - response = json({'error': error}, 400) if error else json({'logged_in': True}) - if not error: - self.cookie( - response, - self.session_cookie, - logged['cookie'], - int((logged['expires'] - now).total_seconds()), - ) - response.delete_cookie( - self.login_cookie, path='/', secure=self.secure, httponly=True - ) - return response - if path in {'/oauth/authorize', '/oauth/device'}: - return await self.consent(request) - if path == '/oauth/logout' and request.method == 'GET': - cookie = request.cookies.get(self.session_cookie, '') - async with self.service.metadata.transaction(write=False) as tx: - await self.oauth.session(tx, cookie) - return page( - 'Sign out of MSG', - '
' - + fields({'csrf': csrf(cookie)}) - + '
', - ) - require( - request.method == 'POST' or (path == '/oauth/userinfo' and request.method == 'GET'), - 'method_not_allowed', - ) - args = await form(request) if request.method == 'POST' else {} - async with self.service.metadata.transaction(write=path != '/oauth/userinfo') as tx: - self.oauth.fence(tx) - if path == '/oauth/device_authorization': - require(set(args) <= {'client_id', 'scope'}, 'invalid_request') - value, code, _ = self.oauth.pending( - tx, - kind='device', - client_id=args.get('client_id'), - scope=args.get('scope', 'openid profile msg.read offline_access'), - ) - uri = self.service.settings.service_url + '/oauth/device' - return json({ - 'device_code': value, - 'user_code': code, - 'verification_uri': uri, - 'verification_uri_complete': uri + '?' + urlencode({'user_code': code}), - 'expires_in': 600, - 'interval': 5, - }) - if path == '/oauth/token': - require( - set(args) - <= { - 'client_id', - 'grant_type', - 'code', - 'redirect_uri', - 'code_verifier', - 'device_code', - 'refresh_token', - 'resource', - 'scope', - }, - 'invalid_request', - ) - result, error = await self.oauth.exchange(tx, args) - return json({'error': error}, 400) if error else json(result) - if path == '/oauth/userinfo': - credential = await self.oauth.bearer( - tx, self.bearer_value(request), check_resource=False - ) - _, binding = get(tx, 'access:' + credential.id, now) - _, family = get(tx, binding['family'], now) - require('openid' in family['scopes'], 'invalid_scope') - user = await tx.subject(credential.subject_id) - resource = await tx.resource(user.resource_id) - return json({ - 'sub': user.resource_id, - **( - {'preferred_username': resource.name.removeprefix('@')} - if 'profile' in family['scopes'] - else {} - ), - }) - if path == '/oauth/revoke': - from msg.security.oauth import client_for - - client_for(self.oauth.config, args.get('client_id')) - require(set(args) <= {'client_id', 'token', 'token_type_hint'}, 'invalid_request') - id = state_id('refresh', args.get('token', '')) - if args.get('token', '').startswith('t_oauth_'): - credential = await self.oauth.bearer(tx, args['token'], check_resource=False) - id = 'access:' + credential.id - row = tx.one('SELECT body FROM oauth_states WHERE id=?', (id,)) - if row: - binding = loads(row[0]) - _, family = get(tx, binding['family'], now) - if family['client_id'] == args['client_id']: - family['revoked'] = True - save(tx, binding['family'], family) - return json({}) - if path == '/oauth/logout': - cookie = self.require_csrf(request, args, self.session_cookie) - _, body = get(tx, state_id('session', cookie), now) - body['revoked'] = True - save(tx, state_id('session', cookie), body) - response = json({'logged_out': True}) - response.delete_cookie( - self.session_cookie, path='/', secure=self.secure, httponly=True - ) - return response - raise Failure('invalid_request') - - async def consent(self, request): - require(request.method in {'GET', 'POST'}, 'method_not_allowed') - args = ( - unique(list(request.query_params.multi_items())) - if request.method == 'GET' - else await form(request) - ) - device = request.url.path == '/oauth/device' - auth_args = {k: v for k, v in args.items() if k not in {'csrf', 'decision'}} - async with self.service.metadata.transaction(write=True) as tx: - self.oauth.fence(tx) - if device: - require(set(auth_args) <= {'user_code'}, 'invalid_request') - code = auth_args.get('user_code', '').upper().replace('-', '') - if not code: - return page( - 'Device sign-in', - '
' - '
', - ) - _, pending = get(tx, state_id('user', code), self.service.clock()) - require( - pending['kind'] == 'device' and pending['status'] == 'pending', 'invalid_grant' - ) - from msg.security.oauth import client_for - - client = client_for(self.oauth.config, pending['client_id']) - scopes = pending['scopes'] - else: - client = self.oauth.authorization(auth_args) - scopes = auth_args.get('scope', 'openid profile').split() - cookie = request.cookies.get(self.session_cookie, '') - try: - _, source = await self.oauth.session(tx, cookie) - except Failure as exc: - if exc.code != 'invalid_grant': - raise - require(request.method == 'GET', 'invalid_grant') - return page( - 'Sign in to continue', - '

' + escape(client.name) + ' requests access to your MSG identity.

' - '

Open sign-in page

' - '

After signing in, refresh to continue.

', - ) - if request.method == 'GET': - return page( - 'Authorize ' + client.name, - '

Identity: ' - + escape(source['subject']) - + '

Permissions: ' - + escape(' '.join(scopes)) - + '

' - + fields(dict(auth_args, csrf=csrf(cookie))) - + ' ' - '
', - ) - self.require_csrf(request, args, self.session_cookie) - require(args.get('decision') in {'approve', 'deny'}, 'invalid_request') - if device: - id = state_id('user', code) - if args['decision'] == 'approve': - pending.update(source) - pending['status'] = 'approved' if args['decision'] == 'approve' else 'denied' - save(tx, id, pending) - return page('Confirmed', '

You can return to the CLI to continue.

') - redirect = {'state': auth_args['state'], 'iss': self.service.settings.service_url} - if args['decision'] == 'approve': - auth_args.setdefault('scope', 'openid profile') - redirect['code'] = await self.oauth.authorize(tx, cookie, auth_args) - else: - redirect['error'] = 'access_denied' - return RedirectResponse( - auth_args['redirect_uri'] + '?' + urlencode(redirect), - status_code=303, - headers=HEADERS, - ) diff --git a/src/msg/transports/passkey_login.py b/src/msg/transports/passkey_login.py deleted file mode 100644 index 42cc73af..00000000 --- a/src/msg/transports/passkey_login.py +++ /dev/null @@ -1,303 +0,0 @@ -"""WebAuthn controls for the unified login page; no account creation UI.""" - -import hmac -from html import escape -from inspect import isawaitable -from urllib.parse import urlsplit - -from msg.core.codec import b64, loads, unb64 -from msg.core.errors import require -from msg.security.oauth import OAuthService, get, save, state_id -from msg.security.passkey import ( - MAX_RESPONSE_BYTES, - PasskeyServer, - begin_challenge, - read_challenge, - trusted_passkey, -) -from msg.transports.http_common import body_bytes -from msg.transports.oauth_http import json - -PASSKEY_SCRIPT = r"""(() => { - const bytes = value => { - const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4); - return Uint8Array.from(atob(padded), character => character.charCodeAt(0)); - }; - const encoded = value => { - if (value === null || value === undefined) return null; - const data = new Uint8Array(value); - let result = ''; - for (let start = 0; start < data.length; start += 4096) result += String.fromCharCode(...data.subarray(start, start + 4096)); - return btoa(result).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); - }; - const options = (value, binding) => { - if (binding && PublicKeyCredential.parseCreationOptionsFromJSON) return PublicKeyCredential.parseCreationOptionsFromJSON(value); - if (!binding && PublicKeyCredential.parseRequestOptionsFromJSON) return PublicKeyCredential.parseRequestOptionsFromJSON(value); - const result = {...value, challenge: bytes(value.challenge)}; - if (binding) result.user = {...value.user, id: bytes(value.user.id)}; - for (const name of ['excludeCredentials', 'allowCredentials']) { - if (value[name]) result[name] = value[name].map(credential => ({...credential, id: bytes(credential.id)})); - } - return result; - }; - const serialized = (credential, binding) => { - const response = {clientDataJSON: encoded(credential.response.clientDataJSON)}; - if (binding) { - response.attestationObject = encoded(credential.response.attestationObject); - if (credential.response.getTransports) response.transports = credential.response.getTransports(); - } else { - response.authenticatorData = encoded(credential.response.authenticatorData); - response.signature = encoded(credential.response.signature); - response.userHandle = encoded(credential.response.userHandle); - } - return {id: credential.id, rawId: encoded(credential.rawId), type: credential.type, response, - clientExtensionResults: credential.getClientExtensionResults()}; - }; - const send = async (url, body) => { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 10000); - try { - const response = await fetch(url, {method: 'POST', mode: 'same-origin', credentials: 'same-origin', redirect: 'error', - headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body), signal: controller.signal}); - const data = await response.json(); - if (!response.ok || data.error || data.status === 'error') throw new Error('verification_failed'); - return data; - } finally { clearTimeout(timer); } - }; - for (const button of document.querySelectorAll('[data-passkey-action]')) { - const status = document.getElementById(button.dataset.status); - if (!status) continue; - const text = (en, zh) => globalThis.msgText?.(en, zh) ?? (document.documentElement.lang.startsWith('zh') ? zh : en); - const show = (en, zh) => {status.textContent = text(en, zh);}; - if (!globalThis.PublicKeyCredential || !navigator.credentials || !globalThis.isSecureContext) { - button.disabled = true; - show('Passkeys are unavailable in this browser.', '此浏览器无法使用 Passkey。'); - continue; - } - button.addEventListener('click', async () => { - if (button.disabled) return; - button.disabled = true; - const binding = button.dataset.passkeyAction === 'bind'; - show('Confirm using your passkey.', '请使用 Passkey 确认。'); - try { - const prefix = binding ? '/-/login/passkey/bind' : '/-/login/passkey'; - const started = await send(prefix + '/options', {csrf: button.dataset.csrf}); - const publicKey = options(started.options.publicKey, binding); - const credential = binding ? await navigator.credentials.create({publicKey}) : await navigator.credentials.get({publicKey}); - if (!credential) throw new Error('canceled'); - let completed = await send(prefix + '/complete', {csrf: button.dataset.csrf, challenge_id: started.challenge_id, - credential: serialized(credential, binding)}); - const deadline = Date.now() + 180000; - while (completed.approval_required) { - status.textContent = text('Approve this passkey binding: ', '确认绑定此 Passkey:'); - const command = document.createElement('code'); - command.textContent = 'msg auth approve ' + completed.user_code; - status.append(command); - if (Date.now() >= deadline) throw new Error('approval_expired'); - await new Promise(resolve => setTimeout(resolve, 2000)); - completed = await send(prefix + '/complete', {csrf: button.dataset.csrf, challenge_id: started.challenge_id}); - } - show(binding ? 'Passkey added.' : 'Signed in.', binding ? '已添加 Passkey。' : '已登录。'); - const link = document.createElement('a'); - link.href = binding ? '/account/login-methods' : '/'; - link.textContent = text(binding ? 'Account settings' : 'Back to home', binding ? '账户设置' : '返回首页'); - status.append(' ', link); - } catch (error) { - if (error.name === 'NotAllowedError' || error.message === 'canceled') show('Passkey action canceled. Try again.', 'Passkey 操作已取消,请重试。'); - else show('Passkey verification failed. Try again or use another sign-in method.', 'Passkey 验证失败,请重试或换一种登录方式。'); - } finally {button.disabled = false;} - }); - } -})();""" - - -def passkey_control(action, csrf): - if action not in {'bind', 'login'}: - raise ValueError('invalid_passkey_action') - status = 'passkey-' + action + '-status' - label = 'Add a passkey' if action == 'bind' else 'Sign in with a passkey' - return ( - '' - + '

' - ) - - -async def _source(value): - result = value() if callable(value) else value - if isawaitable(result): - result = await result - require( - isinstance(result, dict) and isinstance(result.get('subject'), str), - 'authentication_required', - ) - return result - - -async def dispatch(request, *, app, source, browser, csrf, session_cookie=None): - """Only explicit same-origin JSON writes; core owns all account/session commits.""" - from msg.security.login import ( - VerifiedLogin, - bind_login, - complete_login, - lookup_binding, - request_login_approval, - ) - - provider = app.settings.login.provider('passkey') - require( - app.settings.login.enabled and provider is not None and provider.enabled, - 'login_provider_disabled', - ) - require(request.method == 'POST' and not request.query_params, 'invalid_request') - require(request.headers.get('origin') == app.settings.service_url, 'forbidden_origin') - require( - request.headers.get('content-type', '').split(';')[0] == 'application/json', - 'invalid_request', - ) - args = loads(await body_bytes(request, MAX_RESPONSE_BYTES + 4096)) - require(isinstance(args, dict) and isinstance(args.get('csrf'), str), 'invalid_request') - require(isinstance(browser, str) and 32 <= len(browser) <= 512, 'invalid_request') - require(args['csrf'].isascii() and hmac.compare_digest(args['csrf'], csrf), 'forbidden_origin') - path = request.url.path - require( - path - in { - '/-/login/passkey/bind/options', - '/-/login/passkey/bind/complete', - '/-/login/passkey/options', - '/-/login/passkey/complete', - }, - 'not_found', - ) - registration = '/bind/' in path - owner = await _source(source) if registration else None - server, oauth, now = PasskeyServer(app.settings.service_url), OAuthService(app), app.clock() - if path.endswith('/options'): - require(set(args) == {'csrf'}, 'invalid_request') - async with app.metadata.transaction(write=True) as tx: - oauth.fence(tx) - oauth.rate(tx, request.client.host if request.client else '') - if registration: - subject = await tx.subject(owner['subject']) - require(subject is not None, 'authentication_required') - existing = tx.rows( - 'SELECT provider_data FROM login_bindings WHERE provider=? AND subject=? AND revoked_at IS NULL', - ('passkey', subject.resource_id), - ) - options, state = server.registration_options( - subject.resource_id, 'MSG account', [loads(row[0]) for row in existing] - ) - else: - options, state = server.authentication_options() - challenge = begin_challenge( - tx, - state, - mode='registration' if registration else 'authentication', - now=now, - flow=browser, - subject=owner['subject'] if registration else None, - ) - return json({'options': options, 'challenge_id': challenge}) - require( - set(args) <= {'csrf', 'challenge_id', 'credential'} and 'challenge_id' in args, - 'invalid_request', - ) - challenge, mode = args['challenge_id'], 'registration' if registration else 'authentication' - async with app.metadata.transaction(write=True) as tx: - oauth.fence(tx) - oauth.rate(tx, request.client.host if request.client else '') - body = read_challenge( - tx, - challenge, - mode=mode, - now=now, - flow=browser, - subject=owner['subject'] if registration else None, - ) - if registration: - # Public registration evidence is retained privately for the short approval window. - # A browser read session selects the owner; signed approval authorizes the factor. - approval = body.get('approval') - if approval is None: - require('credential' in args, 'invalid_request') - proof = server.verify_registration( - body['state'], args['credential'], owner['subject'] - ) - verified = VerifiedLogin('passkey', proof.credential_id) - requested = await request_login_approval( - app, - verified, - owner['subject'], - action='bind', - request_id=state_id('passkey-bind', challenge), - ) - approval = { - 'intent_id': requested.intent_id, - 'user_code': requested.user_code, - 'credential': args['credential'], - } - body = dict(body, approval=approval) - save(tx, state_id('passkey', challenge), body) - else: - require('credential' not in args, 'invalid_request') - _, intent = get(tx, approval['intent_id'], now) - if session_cookie and intent['status'] == 'pending': - from msg.security.login import approve_browser_login_intent - - await approve_browser_login_intent(app, tx, session_cookie, approval['intent_id']) - _, intent = get(tx, approval['intent_id'], now) - if intent['status'] == 'pending': - return json({'approval_required': True, 'user_code': approval['user_code']}) - require(intent['status'] == 'approved', 'invalid_passkey_challenge') - proof = server.verify_registration( - body['state'], approval['credential'], owner['subject'] - ) - verified = VerifiedLogin('passkey', proof.credential_id) - else: - require(isinstance(args.get('credential'), dict), 'invalid_request') - credential_id = b64(unb64(args['credential'].get('rawId'), limit=1023)) - binding = lookup_binding(tx, 'passkey', credential_id) - require(binding is not None, 'login_binding_required') - proof = server.verify_authentication( - body['state'], args['credential'], binding.subject, binding.provider_data - ) - verified = VerifiedLogin('passkey', proof.credential_id) - with trusted_passkey(proof, challenge_id=challenge, flow=browser): - completed = ( - await bind_login( - app, - verified, - request_id=state_id('passkey-bind', challenge), - intent_id=approval['intent_id'], - ) - if registration - else await complete_login( - app, verified, request_id=state_id('passkey-login', challenge) - ) - ) - require( - completed.result.status == 'ok', - completed.result.error.code if completed.result.error else 'login_failed', - ) - response = json({'ok': True, 'bound': registration}) - secure = urlsplit(app.settings.service_url).scheme == 'https' - response.set_cookie( - '__Host-msg_session' if secure else 'msg_session', - completed.cookie, - max_age=int((completed.expires - now).total_seconds()), - secure=secure, - httponly=True, - samesite='lax', - path='/', - ) - return response diff --git a/src/msg/transports/post_actions.py b/src/msg/transports/post_actions.py index 13e81aff..1a4dc949 100644 --- a/src/msg/transports/post_actions.py +++ b/src/msg/transports/post_actions.py @@ -127,7 +127,7 @@ if(auth) { const link=document.createElement('a');link.href='/login';link.textContent=text('Sign in again','重新登录');status.append(link); } }; const send = async (operation, extra, requestId) => { - const response = await fetch('/oauth/post-action', { + const response = await fetch('/session/post-action', { method:'POST', credentials:'same-origin',redirect:'error', headers:{'Content-Type':'application/json'}, body:JSON.stringify({csrf:panel.dataset.csrf,id:panel.dataset.id,operation,request_id:requestId,...extra}), diff --git a/src/msg/transports/post_views.py b/src/msg/transports/post_views.py index e419ee97..ba3a60e6 100644 --- a/src/msg/transports/post_views.py +++ b/src/msg/transports/post_views.py @@ -13,8 +13,8 @@ from msg.core.codec import b64, canonical, loads, unb64 from msg.core.errors import Failure, require from msg.core.requests import request_for -from msg.security.oauth import OAuthService from msg.security.quarantine import require_live_authority +from msg.security.sessions import BrowserSessionService from msg.storage.post_view_migration import record_view from msg.transports.http_common import body_bytes, error_status, json_response from msg.transports.url_safety import require_matching_host, require_safe_request_target @@ -222,8 +222,14 @@ async def event(self, request): browser = None session_name = '__Host-msg_session' if self.secure else 'msg_session' session = request.cookies.get(session_name, '') - if session and self.service.settings.oauth and self.service.settings.oauth.enabled: - browser = await OAuthService(self.service).browser_credentials(tx, session) + if ( + session + and self.service.settings.browser + and self.service.settings.browser.enabled + ): + browser = await BrowserSessionService(self.service).browser_credentials( + tx, session + ) require( hmac.compare_digest( data['visitor'], diff --git a/src/msg/transports/public_board.py b/src/msg/transports/public_board.py index 77698ad2..ffd61dc1 100644 --- a/src/msg/transports/public_board.py +++ b/src/msg/transports/public_board.py @@ -85,7 +85,7 @@ for(const name of names)payload[name]=fields[name].value; const key=JSON.stringify(payload);if(!pending||pending.key!==key)pending={key,id:crypto.randomUUID()}; clearSuccess();setBusy(true);status.textContent='正在保存… / Saving…'; - try{const response=await fetch('/oauth/post-action',{method:'POST',credentials:'same-origin',redirect:'error', + try{const response=await fetch('/session/post-action',{method:'POST',credentials:'same-origin',redirect:'error', headers:{'Content-Type':'application/json'},body:JSON.stringify({...payload,request_id:pending.id}),signal:AbortSignal.timeout(15000)}); const result=await response.json();if(!response.ok||result.status!=='ok')throw new Error(result.error?.code||result.error||'request_failed'); latest(result.data);pending=null;status.textContent='';editing(false);closeMenu(true);saved(); diff --git a/src/msg/transports/session_http.py b/src/msg/transports/session_http.py new file mode 100644 index 00000000..2e9779cd --- /dev/null +++ b/src/msg/transports/session_http.py @@ -0,0 +1,447 @@ +"""Native key-approved browser sessions. Retired identity providers fail closed.""" + +import hashlib +import hmac +import re +from base64 import b64encode +from datetime import timedelta +from html import escape +from urllib.parse import parse_qsl, urlsplit + +from starlette.requests import Request +from starlette.responses import HTMLResponse, JSONResponse, PlainTextResponse + +from msg.core.codec import b64, loads, wire +from msg.core.errors import Failure, require +from msg.core.requests import request_for +from msg.security.sessions import BrowserSessionService, get, save, state_id +from msg.transports.browser_login import LOGIN_POLL_SCRIPT +from msg.transports.browser_navigation import HASH as NAVIGATION_HASH, TAG as NAVIGATION_TAG +from msg.transports.browser_style import BRAND_LINK, PREFERENCES, SKIP_LINK, THEME_CSS +from msg.transports.http_common import body_bytes +from msg.transports.url_safety import require_matching_host, require_safe_request_target +from msg.transports.webmcp import WEBMCP_HASH, WEBMCP_TAG + +HEADERS = { + 'Cache-Control': 'no-store', + 'Pragma': 'no-cache', + 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; " + f"script-src 'sha256-{WEBMCP_HASH}' 'sha256-{NAVIGATION_HASH}'; connect-src 'self'; img-src 'self'; form-action 'self'; " + "frame-ancestors 'none'; base-uri 'none'", +} + + +def json(value, status=200): + return JSONResponse(value, status_code=status, headers=HEADERS) + + +def page(title, body, *, script=None): + """Render trusted form markup; authorize only the explicitly supplied script.""" + headers = {**HEADERS, 'Referrer-Policy': 'strict-origin'} + script_tag = '' + if script is not None: + pinned = b64encode(hashlib.sha256(script.encode()).digest()).decode() + headers['Content-Security-Policy'] = headers['Content-Security-Policy'].replace( + 'script-src ', f"script-src 'sha256-{pinned}' " + ) + script_tag = '' + return HTMLResponse( + '' + '' + '' + escape(title) + '' + '' + '' + + SKIP_LINK + + '' + '
' + + PREFERENCES + + 'raw
' + '

' + + escape(title) + + '

' + + body + + '
' + + WEBMCP_TAG + + NAVIGATION_TAG + + script_tag + + '', + # Form POSTs need a non-opaque Origin for the same-origin CSRF fence. + # no-referrer makes navigation POST origins null in Chromium. + headers=headers, + ) + + +def fields(items): + return ''.join( + '' + for k, v in items.items() + ) + + +def csrf(cookie): + return b64(hmac.digest(cookie.encode(), b'msg-key-browser-csrf-v1', 'sha256')) + + +def unique(pairs): + require(len({k for k, _ in pairs}) == len(pairs), 'invalid_request') + return dict(pairs) + + +async def form(request): + raw = await body_bytes(request, 16384) + content = request.headers.get('content-type', '').split(';')[0] + if content == 'application/json': + value = loads(raw) + require(isinstance(value, dict), 'invalid_request') + else: + require(content == 'application/x-www-form-urlencoded', 'invalid_request') + try: + value = unique(parse_qsl(raw.decode(), keep_blank_values=True, max_num_fields=20)) + except UnicodeError, ValueError: + raise Failure('invalid_request') from None + require( + all(isinstance(k, str) and isinstance(v, str) and len(v) <= 4096 for k, v in value.items()), + 'invalid_request', + ) + return value + + +class BrowserSessionBoundary: + def __init__(self, app, *, service): + self.app, self.service = app, service + self.sessions = BrowserSessionService(service) + self.secure = urlsplit(service.settings.service_url).scheme == 'https' + self.session_cookie = '__Host-msg_session' if self.secure else 'msg_session' + self.login_cookie = '__Host-msg_login' if self.secure else 'msg_login' + + def cookie(self, response, name, value, ttl): + response.set_cookie( + name, value, max_age=ttl, secure=self.secure, httponly=True, samesite='lax', path='/' + ) + + def require_csrf(self, request, args, name): + value = request.cookies.get(name, '') + supplied = args.get('csrf', '') + require( + isinstance(supplied, str) + and supplied.isascii() + and value + and request.headers.get('origin') == self.service.settings.service_url + and hmac.compare_digest(supplied, csrf(value)), + 'invalid_request', + ) + return value + + def require_origin(self, request): + require_safe_request_target( + request.scope.get('raw_path') or request.url.path.encode(), + request.scope.get('query_string', b''), + maximum=self.service.settings.server.limits.max_path_bytes, + ) + require_matching_host( + request.headers.getlist('host'), urlsplit(self.service.settings.service_url) + ) + require( + 'x-http-method-override' not in request.headers + and 'x-method-override' not in request.headers, + 'method_not_allowed', + ) + require( + request.headers.get('origin') in {None, self.service.settings.service_url}, + 'forbidden_origin', + ) + + async def __call__(self, scope, receive, send): + if scope['type'] != 'http': + await self.app(scope, receive, send) + return + request = Request(scope, receive) + path = request.url.path + # No fallback from a retired identity endpoint or bearer to an anonymous + # operation. This also invalidates old clients after a database restore. + retired = path in { + '/oauth', + '/account/login-methods', + '/.well-known/openid-configuration', + } or path.startswith(('/oauth/', '/-/login/', '/login/', '/.well-known/oauth-')) + if retired: + await json({'error': 'identity_protocol_removed'}, 410)(scope, receive, send) + return + authorization = request.headers.getlist('authorization') + if authorization: + # Standard Git clients carry native, key-issued MSG credentials in + # Basic auth. The Git adapter still validates their current scope, + # signing source, revocation and request id; this is not OAuth. + native_git = re.fullmatch( + r'/-/git/[A-Za-z0-9_-]{1,128}(?:\.git)?/(?:info/refs|git-receive-pack|info/lfs/objects(?:/batch|/[0-9a-f]{64}/[0-9]+))' + r'|/[@&][^/]+/[^/]+\.git/(?:info/refs|git-upload-pack|HEAD|info/lfs/objects(?:/batch|/[0-9a-f]{64}))', + path, + ) + if len(authorization) == 1 and authorization[0].startswith('Basic ') and native_git: + await self.app(scope, receive, send) + return + await json({'error': 'signature_required'}, 401)(scope, receive, send) + return + session_path = path == '/login' or path.startswith('/session/') + cookie = request.cookies.get(self.session_cookie, '') + browser_read = ( + cookie + and request.method in {'GET', 'HEAD'} + and self.sessions.config.enabled + and not path.startswith('/-/') + and not request.headers.get('x-msg-request') + ) + if not session_path: + if browser_read: + try: + self.require_origin(request) + async with self.service.metadata.transaction(write=False) as tx: + credentials = await self.sessions.browser_credentials(tx, cookie) + scope.setdefault('state', {})['msg_browser_credentials'] = credentials + except Failure as exc: + if exc.code not in {'invalid_grant', 'credential_not_found'}: + status = ( + 503 + if exc.code in {'recovery_quarantined', 'recovery_runtime_stale'} + else 400 + ) + await json({'error': exc.code}, status)(scope, receive, send) + return + scope.setdefault('state', {})['msg_browser_expired'] = True + + async def private_send(message): + if message['type'] == 'http.response.start': + headers = [ + (k, v) for k, v in message['headers'] if k.lower() != b'cache-control' + ] + message = dict( + message, + headers=headers + + [(b'cache-control', b'private, no-store'), (b'vary', b'Cookie')], + ) + await send(message) + + await self.app(scope, receive, private_send) + else: + await self.app(scope, receive, send) + return + try: + self.require_origin(request) + require(not request.headers.get('x-msg-request'), 'ambiguous_credentials') + if ( + path == '/login' + and request.method in {'GET', 'HEAD'} + and request.query_params.get('format') == 'raw' + ): + require( + request.query_params.multi_items() == [('format', 'raw')], 'invalid_request' + ) + response = PlainTextResponse( + '# Sign in with your key\n\nOpen /login. Inspect its code with `msg auth request CODE`, ' + 'then use `msg auth approve CODE` on your own device. Never upload a private key. ' + 'New identities: /register.\n', + headers=HEADERS, + ) + if request.method == 'HEAD': + response.body = b'' + else: + require(not request.query_params, 'invalid_request') + require(self.sessions.config.enabled, 'browser_sessions_disabled') + response = await self.dispatch(request) + except Failure as exc: + status = ( + 503 + if exc.code in {'recovery_quarantined', 'recovery_runtime_stale'} + else (405 if exc.code == 'method_not_allowed' else 400) + ) + response = json({'error': exc.code}, status) + except Exception: + response = json({'error': 'server_error'}, 500) + await response(scope, receive, send) + + async def dispatch(self, request): + path, now = request.url.path, self.service.clock() + if path == '/session/post-action': + from msg.security.browser_actions import BROWSER_POST_WRITES + + require(request.method == 'POST', 'method_not_allowed') + require( + not request.query_params + and not request.headers.get('authorization') + and not request.headers.get('x-msg-request'), + 'ambiguous_credentials', + ) + args = loads(await body_bytes(request, 65536)) + require( + isinstance(args, dict) + and set(args) + <= { + 'csrf', + 'operation', + 'id', + 'body', + 'revision', + 'request_id', + 'kind', + 'note', + 'generation', + 'name', + 'svg', + 'text', + }, + 'invalid_request', + ) + cookie = self.require_csrf(request, args, self.session_cookie) + operation = args.get('operation') + require( + isinstance(operation, str) and operation in BROWSER_POST_WRITES, 'permission_denied' + ) + require( + isinstance(args.get('request_id'), str) and 0 < len(args['request_id']) <= 128, + 'invalid_request', + ) + async with self.service.metadata.transaction(write=False) as tx: + subject, credential, token = await self.sessions.browser_credentials(tx, cookie) + arguments = {'id': args.get('id')} + expected = () + if operation == 'content.public_board_update': + arguments = {k: args[k] for k in ('generation', 'svg', 'text') if k in args} + if operation in {'content.post_create', 'content.post_edit'}: + from msg.core.wiki import in_wiki + from msg.plugins.common import resolve + + require(isinstance(args.get('body'), str), 'invalid_request') + async with self.service.metadata.transaction(write=False) as tx: + resource = await tx.resource(await resolve(tx, args.get('id'))) + require(await in_wiki(tx, resource), 'wiki_only') + if operation == 'content.post_create': + require(resource.type == 'topic', 'not_a_topic') + arguments = {'parent': resource.id, 'body': args['body']} + if 'name' in args: + arguments['name'] = args['name'] + else: + require(type(args.get('generation')) is int, 'expected_generation_required') + require(isinstance(args.get('revision'), str), 'revision_required') + arguments = { + 'id': resource.id, + 'body': args['body'], + 'expected_revision': args['revision'], + } + expected = ((resource.id, args['generation']),) + if operation in {'discussion.reply', 'discussion.fork'}: + require( + isinstance(args.get('body'), str) and bool(args['body'].strip()), + 'invalid_request', + ) + arguments = { + 'target': {'id': args.get('id'), 'revision': args.get('revision')}, + 'body': args['body'], + } + if operation == 'discussion.prove': + require(isinstance(args.get('revision'), str), 'proof_revision_required') + # Resolve only the displayed revision, never silently attest the latest. + from msg.core.models import ResourceRef + + async with self.service.metadata.transaction(write=False) as tx: + revision = await tx.revision( + ResourceRef(id=args.get('id'), revision=args['revision']) + ) + arguments = { + 'target': {'id': args.get('id'), 'revision': args['revision']}, + 'digest': revision.content.digest, + 'kind': args.get('kind'), + 'note': args.get('note', ''), + } + packet = request_for( + operation, + arguments, + self.service.settings.service_url, + subject=subject, + token=(credential, token), + source='manual', + expected=expected, + request_id=args['request_id'], + expires_at=now + timedelta(minutes=3), + ) + result = await self.service.executor.execute(packet, entry='network') + return json(wire(result), 200 if result.error is None else 403) + if request.method == 'POST' or (path == '/login' and request.method == 'GET'): + async with self.service.metadata.transaction(write=True) as tx: + self.sessions.rate(tx, request.client.host if request.client else '') + if path == '/login': + require(request.method == 'GET', 'method_not_allowed') + async with self.service.metadata.transaction(write=True) as tx: + value, code, _ = self.sessions.pending(tx) + response = page( + 'Sign in with your key', + '

Your public key is your identity. Your private key stays on your device.

' + '

Inspect this request with msg auth request ' + escape(code) + '. ' + 'Then approve only the browser sign-in that you started:

' + 'msg auth approve ' + escape(code) + '' + '

This temporary session can read your content and use permitted post actions. ' + 'It cannot change your identity, keys, or server permissions.

' + '

Create an identity with local keys

' + '

Waiting for approval…

' + '', + script=LOGIN_POLL_SCRIPT, + ) + self.cookie(response, self.login_cookie, value, 600) + return response + if path == '/session/poll': + require(request.method == 'POST', 'method_not_allowed') + args = await form(request) + require(set(args) == {'csrf'}, 'invalid_request') + value = self.require_csrf(request, args, self.login_cookie) + async with self.service.metadata.transaction(write=True) as tx: + self.sessions.fence(tx) + logged, error = await self.sessions.pending_result(tx, value) + response = json({'error': error}, 400) if error else json({'logged_in': True}) + if not error: + self.cookie( + response, + self.session_cookie, + logged['cookie'], + int((logged['expires'] - now).total_seconds()), + ) + response.delete_cookie( + self.login_cookie, path='/', secure=self.secure, httponly=True + ) + return response + if path == '/session/logout': + if request.method == 'GET': + cookie = request.cookies.get(self.session_cookie, '') + async with self.service.metadata.transaction(write=False) as tx: + await self.sessions.session(tx, cookie) + return page( + 'Sign out of MSG', + '
' + + fields({'csrf': csrf(cookie)}) + + '
', + ) + require(request.method == 'POST', 'method_not_allowed') + args = await form(request) + require(set(args) == {'csrf'}, 'invalid_request') + cookie = self.require_csrf(request, args, self.session_cookie) + async with self.service.metadata.transaction(write=True) as tx: + self.sessions.fence(tx) + _, body = get(tx, state_id('session', cookie), now) + body['revoked'] = True + save(tx, state_id('session', cookie), body) + response = json({'logged_out': True}) + response.delete_cookie(self.session_cookie, path='/', secure=self.secure, httponly=True) + return response + raise Failure('invalid_request') diff --git a/src/msg/transports/webmcp.py b/src/msg/transports/webmcp.py index 2f1be822..7c50d332 100644 --- a/src/msg/transports/webmcp.py +++ b/src/msg/transports/webmcp.py @@ -329,7 +329,7 @@ } const url = new URL(path, location.origin); if (url.origin !== location.origin || url.username || url.password || - /^\/(?:oauth(?:\/|$)|login(?:\/|$)|-(?:\/|$)|install(?:\/|$))/.test(url.pathname)) { + /^\/(?:session(?:\/|$)|login(?:\/|$)|-(?:\/|$)|install(?:\/|$))/.test(url.pathname)) { throw new Error('This tool only reads MSG resources.'); } const response = await fetch(url, {method: 'GET', credentials: 'same-origin', diff --git a/src/msg/transports/wiki_actions.py b/src/msg/transports/wiki_actions.py index e09c4587..e4c34310 100644 --- a/src/msg/transports/wiki_actions.py +++ b/src/msg/transports/wiki_actions.py @@ -30,7 +30,7 @@ const button = form.querySelector('[type=submit]'); button.disabled = true; status.textContent = text('Saving…','正在保存…'); try { - const response = await fetch('/oauth/post-action', {method:'POST',credentials:'same-origin',redirect:'error', + const response = await fetch('/session/post-action', {method:'POST',credentials:'same-origin',redirect:'error', headers:{'Content-Type':'application/json'},body:JSON.stringify({...payload,request_id:pending.id}), signal:AbortSignal.timeout(15000)}); const result = await response.json(); diff --git a/src/msg/workers/sequenzy.py b/src/msg/workers/sequenzy.py deleted file mode 100644 index 1395ced4..00000000 --- a/src/msg/workers/sequenzy.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Bounded Sequenzy transactional login mail; acceptance is not delivery. - -Contract: https://docs.sequenzy.com/api-reference/transactional/send -Credentials never leave the Authorization header. No automatic send retries. -""" - -from __future__ import annotations - -import asyncio -import re -from email.utils import parseaddr -from html import escape -from pathlib import Path -from time import monotonic - -from httpx import Client, HTTPError, StreamError, Timeout - -from msg.core.codec import loads -from msg.core.email_address import validate_address -from msg.core.errors import Failure, require -from msg.security.root_files import read_private - -SEND_URL = 'https://api.sequenzy.com/api/v1/transactional/send' -MAX_RESPONSE_BYTES = 64 * 1024 -MAX_CREDENTIAL_BYTES = 16 * 1024 -MAX_SEND_SECONDS = 20.0 - - -def _api_key(path): - try: - credentials = loads(read_private(path, limit=MAX_CREDENTIAL_BYTES)) - require( - isinstance(credentials, dict) and set(credentials) == {'api_key'}, - 'invalid_sequenzy_credentials', - ) - key = credentials['api_key'] - require( - isinstance(key, str) - and re.fullmatch(r'[A-Za-z0-9_.-]{1,4096}', key) is not None - # Account keys need an explicit workspace selection that this - # deliberately small credential/config contract does not expose. - and not key.startswith('seq_user_'), - 'invalid_sequenzy_credentials', - ) - return key - except Failure, OSError, TypeError, ValueError: - # Private-file and JSON exceptions can carry a path or arbitrary key. - raise Failure('invalid_sequenzy_credentials') from None - - -def _sender(value): - if value is None: - return None - require( - isinstance(value, str) - and 0 < len(value) <= 320 - and not any(ord(c) < 32 or ord(c) == 127 for c in value), - 'invalid_sequenzy_sender', - ) - try: - _, address = parseaddr(value, strict=True) - validate_address(address) - except Failure, ValueError: - raise Failure('invalid_sequenzy_sender') from None - return value - - -def _accepted(response, deadline): - require(monotonic() <= deadline, 'sequenzy_send_failed') - require(200 <= response.status_code < 300, 'sequenzy_send_failed') - require( - response.headers.get('content-encoding', 'identity').lower() == 'identity', - 'invalid_sequenzy_response', - ) - length = response.headers.get('content-length') - if length is not None: - require( - length.isdecimal() and int(length) <= MAX_RESPONSE_BYTES, - 'invalid_sequenzy_response', - ) - chunks, size = [], 0 - for chunk in response.iter_raw(): - require(monotonic() <= deadline, 'sequenzy_send_failed') - size += len(chunk) - require(size <= MAX_RESPONSE_BYTES, 'invalid_sequenzy_response') - chunks.append(chunk) - try: - value = loads(b''.join(chunks)) - except Failure, ValueError: - raise Failure('invalid_sequenzy_response') from None - require( - isinstance(value, dict) - and value.get('success') is True - and value.get('emailType', 'transactional') == 'transactional', - 'invalid_sequenzy_response', - ) - # Response IDs, recipient echoes and diagnostics are never returned/logged. - return 'queued' - - -class SequenzySender: - def __init__(self, credential_file, sender=None): - require( - isinstance(credential_file, (str, Path)) and bool(str(credential_file)), - 'invalid_sequenzy_credentials', - ) - self.credential_file = Path(credential_file) - self.sender = _sender(sender) - - async def send_code(self, address, code): - return await asyncio.to_thread(self._send_code, address, code) - - def _send_code(self, address, code): - recipient = validate_address(address).addr_spec - require( - isinstance(code, str) and re.fullmatch(r'[A-Za-z0-9]{1,128}', code) is not None, - 'invalid_login_code', - ) - key = _api_key(self.credential_file) - payload = { - 'to': recipient, - 'subject': 'Your MSG login code', - 'body': '

Your MSG login code is:

' - + escape(code) - + '

If you did not request it, ignore this email.

', - 'emailType': 'transactional', - 'trackingSettings': {'clickTracking': False, 'openTracking': False}, - } - if self.sender is not None: - payload['from'] = self.sender - deadline = monotonic() + MAX_SEND_SECONDS - try: - with Client( - timeout=Timeout(10.0, connect=5.0), follow_redirects=False, trust_env=False - ) as client: - with client.stream( - 'POST', - SEND_URL, - headers={ - 'Authorization': 'Bearer ' + key, - 'Accept': 'application/json', - 'Accept-Encoding': 'identity', - }, - json=payload, - ) as response: - return _accepted(response, deadline) - except HTTPError, StreamError, OSError, ValueError: - # A timeout/read error may follow remote acceptance. Do not retry it - # automatically or expose HTTP exceptions with request/response data. - raise Failure('sequenzy_send_failed') from None diff --git a/tests/js/post-actions.test.cjs b/tests/js/post-actions.test.cjs index b294ca24..fc5e312c 100644 --- a/tests/js/post-actions.test.cjs +++ b/tests/js/post-actions.test.cjs @@ -28,7 +28,8 @@ function page(options={}) { panel.querySelector = selector => ({form,textarea:field,'[role=status]':status,label,'[type=submit]':submit,'[data-copy=collection]':collection,'[data-copy=proofs]':element(),'[data-copy=forks]':element(),'[data-copy=claims]':element(),'[data-copy=claim-note]':element(),'[data-compose-close]':close})[selector]; const calls = [], notifications = []; vm.runInNewContext(script, { - document:{documentElement:{lang:'en'},getElementById:()=>panel,createElement:element}, + document:{documentElement:{lang:'en'},getElementById:()=>panel,createElement:element, + addEventListener(name, callback) {this.events ??= {};this.events[name] = callback;}}, window:{dispatchEvent:event=>notifications.push(event)}, CustomEvent:class {constructor(type,options){this.type=type;this.detail=options.detail;}}, MutationObserver:class {observe() {}}, @@ -55,6 +56,7 @@ test('reading never writes; a claim needs explicit submit and pins displayed rev assert.equal(ui.calls.length,1); assert.equal(ui.field.required,false); ui.field.value = 'Used successfully'; await ui.form.events.submit({preventDefault(){}}); + assert.equal(ui.calls[1].url, '/session/post-action'); const packet = JSON.parse(ui.calls[1].args.body); assert.equal(packet.operation,'discussion.prove'); assert.equal(packet.kind,'USED'); assert.equal(packet.revision,'displayed-revision'); assert.equal(packet.note,'Used successfully'); diff --git a/tests/test_account_login.py b/tests/test_account_login.py deleted file mode 100644 index 1046ec31..00000000 --- a/tests/test_account_login.py +++ /dev/null @@ -1,317 +0,0 @@ -"""真实执行器和 PostgreSQL 验证登录表单、共享身份与注册边界。""" - -import re -from dataclasses import replace -from datetime import timedelta -from types import SimpleNamespace -from urllib.parse import parse_qs, urlsplit - -import httpx -import pytest -from test_oauth import browser_login, oauth # noqa: F401 - -from msg.core.codec import wire -from msg.login_config import load_login -from msg.security.oauth import OAuthService -from msg.transports.http import create_app -from msg.transports.oauth_http import csrf - - -@pytest.fixture -async def account(oauth, monkeypatch): # noqa: F811 - app, key, subject, _ = oauth - credential = app.settings.config_dir / 'login-provider.fixture' - credential.write_text('fixture-only-secret') - credential.chmod(0o600) - app.settings = replace( - app.settings, - login=load_login( - { - 'enabled': True, - 'providers': { - 'google': { - 'enabled': True, - 'client_id': 'google-fixture', - 'credential_file': str(credential), - }, - 'github': { - 'enabled': True, - 'client_id': 'github-fixture', - 'credential_file': str(credential), - }, - 'email': {'enabled': True}, - }, - }, - app.settings.service_url, - ), - ) - sent = [] - - class Mail: - async def send_code(self, address, code): - sent.append((address, code)) - - app._login_mail_sender = Mail() - - async def exchange(provider, *_args): - return SimpleNamespace(stable_id=provider + '-external-fixture') - - monkeypatch.setattr('msg.transports.account_login.exchange_identity', exchange) - from msg.transports.login_providers import authorization_url - - def authorization(provider, config, state, verifier, nonce, redirect_uri): - # ASGI 的 testserver 无真实域名;提供方校验仍只接受 HTTPS 或回环 HTTP。 - assert redirect_uri == app.settings.service_url + '/-/login/callback/' + provider - return authorization_url( - provider, - config, - state, - verifier, - nonce, - redirect_uri.replace('http://testserver/', 'http://localhost/'), - ) - - monkeypatch.setattr('msg.transports.account_login.authorization_url', authorization) - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=create_app(app)), - base_url=app.settings.service_url, - headers={'Accept': 'application/json'}, - ) as http: - yield app, key, subject, http, sent - - -async def token(http, path='/login'): - response = await http.get(path) - assert response.status_code == 200, response.text - return csrf(http.cookies.get('msg_account_login')) - - -async def post(account, path, data): - app, _, _, http, _ = account - return await http.post(path, data=data, headers={'Origin': app.settings.service_url}) - - -async def begin_provider(account, provider='google', mode='login', **values): - app, _, _, http, _ = account - response = await post( - account, - '/-/login/start', - {'csrf': await token(http), 'provider': provider, 'mode': mode, **values}, - ) - assert response.status_code == 303, response.text - state = parse_qs(urlsplit(response.headers['location']).query)['state'][0] - callback = await http.get( - '/-/login/callback/' + provider, params={'state': state, 'code': 'fixture-code'} - ) - assert callback.status_code == 303, callback.text - return state, csrf(http.cookies.get('msg_account_login')) - - -async def session_source(app, http): - async with app.metadata.transaction(write=False) as tx: - return (await OAuthService(app).session(tx, http.cookies.get('msg_session')))[1] - - -async def approve_if_pending(account, response): - if response.status_code != 200 or 'msg auth approve ' not in response.text: - return response - from test_service import call - - app, key, subject, http, _ = account - code = re.search(r'msg auth approve ([A-Z0-9]{8})', response.text)[1] - approved = await call( - app, - 'identity.oauth_approve', - {'user_code': code, 'decision': 'approve'}, - key=key, - subject=subject, - ) - assert approved.status == 'ok', wire(approved) - pending = re.search(r'name="pending" value="([^"]+)"', response.text)[1] - return await post( - account, - '/-/login/approval', - {'csrf': csrf(http.cookies.get('msg_account_login')), 'pending': pending}, - ) - - -async def test_email_registration_consent_single_use_and_repeat_identity(account): - app, _, _, http, sent = account - form = { - 'csrf': await token(http, '/register'), - 'email': 'alice@example.org', - 'mode': 'register', - 'handle': 'email-alice', - } - denied = await post(account, '/-/login/email/start', form) - assert denied.status_code == 400 and 'custodial_consent_required' in denied.text - assert not sent - response = await post(account, '/-/login/email/start', {**form, 'custody': 'yes'}) - assert response.status_code == 200, response.text - challenge = re.search(r'name="challenge" value="([^"]+)"', response.text)[1] - assert sent[0][1] not in response.text - complete = {'csrf': form['csrf'], 'challenge': challenge, 'code': sent[0][1]} - result = await post(account, '/-/login/email/complete', complete) - assert result.status_code == 303, result.text - original = await session_source(app, http) - async with app.metadata.transaction(write=False) as tx: - assert (await tx.subject(original['subject'])).kind == 'custodial' - before = { - table: tx.one('SELECT COUNT(*) FROM ' + table)[0] - for table in ('identities', 'identity_keys', 'encryption_subkeys') - } - replay = await post(account, '/-/login/email/complete', complete) - assert replay.status_code in {400, 403} - app._oauth_clock[0] += timedelta(seconds=61) - response = await post( - account, - '/-/login/email/start', - {'csrf': await token(http), 'email': 'alice@example.org', 'mode': 'login'}, - ) - challenge = re.search(r'name="challenge" value="([^"]+)"', response.text)[1] - result = await post( - account, - '/-/login/email/complete', - { - 'csrf': csrf(http.cookies.get('msg_account_login')), - 'challenge': challenge, - 'code': sent[-1][1], - }, - ) - assert result.status_code == 303, result.text - assert (await session_source(app, http))['subject'] == original['subject'] - async with app.metadata.transaction(write=False) as tx: - assert {table: tx.one('SELECT COUNT(*) FROM ' + table)[0] for table in before} == before - - -async def test_google_callback_is_ephemeral_and_github_cannot_register(account): - app, _, _, http, _ = account - async with app.metadata.transaction(write=False) as tx: - before = tx.one('SELECT COUNT(*) FROM identities')[0] - state, value = await begin_provider( - account, mode='register', handle='google-alice', custody='yes' - ) - async with app.metadata.transaction(write=False) as tx: - assert tx.one('SELECT COUNT(*) FROM identities')[0] == before - assert (await http.get('/login/finish', params={'state': state})).status_code == 200 - completed = await post(account, '/-/login/complete', {'csrf': value, 'state': state}) - assert completed.status_code == 303, completed.text - first = await session_source(app, http) - state, value = await begin_provider(account) - completed = await post(account, '/-/login/complete', {'csrf': value, 'state': state}) - assert completed.status_code == 303, completed.text - assert (await session_source(app, http))['subject'] == first['subject'] - state, value = await begin_provider(account, provider='github') - denied = await post(account, '/-/login/complete', {'csrf': value, 'state': state}) - assert denied.status_code == 400 and 'login_binding_required' in denied.text - denied = await post( - account, - '/-/login/start', - { - 'csrf': await token(http), - 'provider': 'github', - 'mode': 'register', - 'handle': 'github-user', - 'custody': 'yes', - }, - ) - assert denied.status_code == 400 and 'login_registration_forbidden' in denied.text - assert (await http.get('/oauth/signup')).headers['location'] == '/register' - assert (await post(account, '/oauth/signup', {'csrf': value})).status_code == 400 - - -async def test_existing_self_custody_binding_keeps_keys_and_shared_subject(account): - app, key, subject, http, _ = account - await browser_login((app, key, subject, http)) - async with app.metadata.transaction(write=False) as tx: - before = tx.rows('SELECT id,body FROM credentials WHERE subject=?', (subject,)) - vault = tx.one('SELECT COUNT(*) FROM custodial_vault WHERE subject=?', (subject,))[0] - state, value = await begin_provider(account, provider='github', mode='bind') - completed = await post(account, '/-/login/complete', {'csrf': value, 'state': state}) - completed = await approve_if_pending(account, completed) - assert completed.status_code == 303, completed.text - assert (await session_source(app, http))['subject'] == subject - async with app.metadata.transaction(write=False) as tx: - assert (await tx.subject(subject)).kind == 'registered' - after = dict(tx.rows('SELECT id,body FROM credentials WHERE subject=?', (subject,))) - assert all(after[id] == body for id, body in before) - assert ( - tx.one('SELECT COUNT(*) FROM custodial_vault WHERE subject=?', (subject,))[0] - == vault - == 0 - ) - state, value = await begin_provider(account, provider='github') - completed = await post(account, '/-/login/complete', {'csrf': value, 'state': state}) - assert completed.status_code == 303, completed.text - assert (await session_source(app, http))['subject'] == subject - methods = await http.get('/account/login-methods') - assert ( - methods.status_code == 200 and 'GitHub' in methods.text and '私钥由你自持' in methods.text - ) - binding = re.search(r'name="binding_id" value="([^"]+)"', methods.text)[1] - old_cookie = http.cookies.get('msg_session') - removed = await post( - account, - '/-/login/remove', - {'csrf': csrf(http.cookies.get('msg_account_login')), 'binding_id': binding}, - ) - assert removed.status_code == 303, removed.text - from msg.core.errors import Failure - - with pytest.raises(Failure) as invalid: - async with app.metadata.transaction(write=False) as tx: - await OAuthService(app).session(tx, old_cookie) - assert 'invalid_grant' in str(invalid.value) - state, value = await begin_provider(account, provider='github') - denied = await post(account, '/-/login/complete', {'csrf': value, 'state': state}) - assert denied.status_code == 400 and 'login_binding_' in denied.text - - -async def test_custodial_user_manages_bindings_with_fresh_web_login(account): - app, _, _, http, _ = account - state, value = await begin_provider( - account, mode='register', handle='web-custody', custody='yes' - ) - completed = await post(account, '/-/login/complete', {'csrf': value, 'state': state}) - assert completed.status_code == 303, completed.text - owner = (await session_source(app, http))['subject'] - assert '/account/login-methods' in (await http.get('/', headers={'Accept': 'text/html'})).text - state, value = await begin_provider(account, provider='github', mode='bind') - completed = await post(account, '/-/login/complete', {'csrf': value, 'state': state}) - assert completed.status_code == 303, completed.text - assert (await session_source(app, http))['subject'] == owner - async with app.metadata.transaction(write=False) as tx: - rows = tx.rows( - 'SELECT subject FROM login_bindings WHERE provider IN (?,?)', ('google', 'github') - ) - assert len(rows) == 2 and all(row[0] == owner for row in rows) - - -async def test_login_http_rejects_cross_origin_and_mixed_authority(account): - app, _, _, http, _ = account - value = await token(http) - args = {'csrf': value, 'provider': 'google', 'mode': 'login'} - for headers in ( - {}, - {'Origin': 'https://attacker.example'}, - {'Origin': app.settings.service_url, 'Authorization': 'Bearer fake'}, - ): - response = await http.post('/-/login/start', data=args, headers=headers) - assert response.status_code == 403, response.text - wrong = await post(account, '/-/login/start', {**args, 'csrf': 'wrong'}) - assert wrong.status_code == 403 - wrong = await http.get('/login', headers={'Host': 'attacker.example'}) - assert wrong.status_code == 400 - state, _ = await begin_provider(account) - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url - ) as other: - callback = await other.get( - '/-/login/callback/google', params={'state': state, 'code': 'stolen'} - ) - assert callback.status_code == 400 - denied = await http.post( - '/-/login/start?provider=google', data=args, headers={'Origin': app.settings.service_url} - ) - assert denied.status_code == 400 - assert 'fixture-code' not in wire(denied.headers) diff --git a/tests/test_account_login_core.py b/tests/test_account_login_core.py deleted file mode 100644 index dffd0ce6..00000000 --- a/tests/test_account_login_core.py +++ /dev/null @@ -1,405 +0,0 @@ -"""Verified factor, immutable intent, custody and live source boundaries.""" - -from dataclasses import replace -from datetime import timedelta - -import pytest -from test_service import NOW, call, register - -from msg.core.codec import canonical, wire -from msg.core.errors import Failure -from msg.core.models import CapabilityGrant, Credential, Principal, Scope -from msg.core.requests import request_for -from msg.login_config import PROVIDERS, LoginConfig, LoginProvider, load_login -from msg.oauth_config import OAuthConfig -from msg.security.crypto import Ed25519Signer -from msg.security.login import ( - VerifiedLogin, - approve_browser_login_intent, - bind_login, - complete_login, - create_login_intent, - list_bindings, - lookup_binding, - remove_login, - request_login_approval, - trusted_login, -) -from msg.security.oauth import OAuthService, get -from msg.storage.login_schema import LOGIN_SCHEMA - - -class LoginSettings: - def __init__(self, original): - self.original = original - self.oauth = OAuthConfig(enabled=True) - self.login = LoginConfig( - enabled=True, - registration='legacy', - providers=tuple(LoginProvider(name, enabled=True) for name in sorted(PROVIDERS)), - ) - - def __getattr__(self, name): - return getattr(self.original, name) - - -@pytest.fixture -async def login_app(installed): - app, _ = installed - app.settings = LoginSettings(app.settings) - app.authenticator.oauth_config = app.settings.oauth - app._login_clock = [NOW] - app.clock = lambda: app._login_clock[0] - app.authenticator.clock = app.executor.clock = app.certificates.clock = app.clock - async with app.metadata.transaction(write=True) as tx: - tx.execute(LOGIN_SCHEMA, write=True) - return app - - -async def approved_binding(app, key, subject, verified, request_id): - approval = await request_login_approval(app, verified, subject, request_id=request_id) - approved = await call( - app, - 'identity.oauth_approve', - {'user_code': approval.user_code, 'decision': 'approve'}, - key=key, - subject=subject, - ) - assert approved.status == 'ok', wire(approved) - completion = await bind_login( - app, verified, request_id=request_id, intent_id=approval.intent_id - ) - assert completion.result.status == 'ok', wire(completion.result) - return completion - - -def test_login_config_defaults_and_invalid_container_types(): - assert load_login({}, 'http://testserver').registration == 'legacy' - assert load_login({'enabled': True}, 'http://testserver').registration == 'provider_only' - valid = load_login( - { - 'enabled': True, - 'providers': { - 'email': { - 'enabled': True, - 'transport': 'sequenzy', - 'credential_file': '/private/mail', - 'sender': 'MSG ', - }, - 'chatgpt': { - 'enabled': True, - 'client_id': 'oaiapp_example', - 'token_auth_method': 'none', - }, - }, - }, - 'https://msg.example.test', - ) - assert valid.provider('email').credential_file == '/private/mail' - for data in [ - {'registration': []}, - {'providers': {'google': {'token_auth_method': {}}}}, - {'providers': {'email': {'transport': []}}}, - {'providers': {'google': {'credential_file': 'relative'}}}, - {'providers': {'chatgpt': {'enabled': True, 'client_id': 'ordinary-client'}}}, - ]: - with pytest.raises(Failure): - load_login(data, 'https://msg.example.test') - - -@pytest.mark.asyncio -async def test_explicit_registration_reuses_custody_and_never_merges_email(login_app): - app = login_app - factor = VerifiedLogin('google', 'stable-google-subject') - with pytest.raises(Failure, match='login_registration_required'): - await complete_login(app, factor, request_id='unknown-login') - first = await complete_login(app, factor, request_id='register-google', handle='google-owner') - assert first.result.status == 'ok', wire(first.result) - async with app.metadata.transaction(write=False) as tx: - initial = tx.one( - 'SELECT signing_key_id,encryption_key_id FROM custodial_vault WHERE subject=?', - (first.result.subject,), - ) - assert (await tx.subject(first.result.subject)).kind == 'custodial' - again = await complete_login(app, factor, request_id='login-google') - retry = await complete_login(app, factor, request_id='login-google') - assert again.result.status == retry.result.status == 'ok' - assert retry.result.replayed and retry.cookie == again.cookie - assert again.result.subject == first.result.subject - other = await complete_login( - app, - VerifiedLogin('email', 'stable-google-subject'), - request_id='register-email', - handle='email-owner', - ) - assert other.result.status == 'ok' and other.result.subject != first.result.subject - async with app.metadata.transaction(write=False) as tx: - assert ( - tx.one( - 'SELECT signing_key_id,encryption_key_id FROM custodial_vault WHERE subject=?', - (first.result.subject,), - ) - == initial - ) - assert ( - tx.one('SELECT COUNT(*) FROM login_bindings WHERE subject=?', (first.result.subject,))[ - 0 - ] - == 1 - ) - text = canonical(wire(first.result)).decode() - assert 'stable-google-subject' not in text and 'cookie' not in text - assert 'PRIVATE KEY' not in text and 'AGE-SECRET-KEY' not in text - - -@pytest.mark.asyncio -async def test_nonregistration_factors_and_direct_operation_cannot_create(login_app): - app = login_app - for provider in ['github', 'chatgpt', 'passkey']: - with pytest.raises(Failure, match='login_binding_required'): - await complete_login( - app, VerifiedLogin(provider, 'unbound'), request_id=provider, handle='unbound-owner' - ) - direct = await call(app, 'identity.login_complete', {'intent_id': 'asserted'}) - assert direct.status == 'error' and direct.error.code == 'verified_login_required' - app.settings.login = replace(app.settings.login, registration='provider_only') - key = Ed25519Signer.generate() - from msg.core.codec import b64 - from msg.security.age_keys import generate_age_key - from msg.security.crypto import subject_id - - _, recipient = generate_age_key() - denied = await call( - app, - 'identity.register', - { - 'handle': 'blocked-old-signup', - 'public_key': b64(key.public_key), - 'encryption_recipient': recipient, - }, - key=key, - subject=subject_id(key.public_key), - contract_version=2, - ) - assert denied.status == 'error' and denied.error.code == 'provider_registration_required' - - -@pytest.mark.asyncio -async def test_selfcustody_binding_retains_actual_secondary_ceiling(login_app): - app = login_app - primary, subject, _ = await register(app, 'self-owner') - secondary = Ed25519Signer.generate() - ceiling = ( - CapabilityGrant( - capability='identity.basic', - version=1, - scope=Scope(resource_id=subject), - operations=frozenset({'identity.oauth_approve@1', 'discovery.get@1'}), - constraints={}, - ), - ) - async with app.metadata.transaction(write=True) as tx: - await tx.save_credential( - Credential( - id=secondary.key_id, - subject_id=subject, - kind='signing_key', - verifier=secondary.public_key, - ceiling=ceiling, - not_before=NOW, - expires_at=None, - revoked_at=None, - ), - 0, - ) - factor = VerifiedLogin('github', 'github-immutable-id') - bound = await approved_binding(app, secondary, subject, factor, 'bind-secondary') - async with app.metadata.transaction(write=False) as tx: - binding = lookup_binding(tx, factor.provider, factor.sub) - assert binding.parent == secondary.key_id and binding.ceiling == ceiling - assert binding.parent != primary.key_id - assert (await tx.subject(subject)).kind == 'registered' - assert tx.one('SELECT COUNT(*) FROM custodial_vault WHERE subject=?', (subject,))[0] == 0 - _, source = await OAuthService(app).session(tx, bound.cookie) - browser = await tx.credential(get(tx, source['session'], NOW)[1]['browser_credential']) - assert all('identity.oauth_approve@1' not in grant.operations for grant in browser.ceiling) - methods = await list_bindings(tx, subject) - assert methods == [{'binding_id': binding.id, 'provider': 'github'}] - async with app.metadata.transaction(write=True) as tx: - original = await tx.credential(secondary.key_id) - await tx.save_credential(replace(original, ceiling=()), 0) - with pytest.raises(Failure, match='invalid_grant'): - async with app.metadata.transaction(write=False) as tx: - await OAuthService(app).session(tx, bound.cookie) - - -@pytest.mark.asyncio -async def test_fresh_management_is_cookie_only_and_immutable_intent(login_app): - app = login_app - first = await complete_login( - app, - VerifiedLogin('google', 'manager-google'), - request_id='manager-register', - handle='manager-owner', - ) - assert first.result.status == 'ok', wire(first.result) - factor = VerifiedLogin('email', 'manager@example.test') - approval = await request_login_approval( - app, factor, first.result.subject, request_id='manager-bind' - ) - async with app.metadata.transaction(write=True) as tx: - assert await approve_browser_login_intent(app, tx, first.cookie, approval.intent_id) - bound = await bind_login(app, factor, request_id='manager-bind', intent_id=approval.intent_id) - assert bound.result.status == 'ok', wire(bound.result) - async with app.metadata.transaction(write=False) as tx: - _, source = await OAuthService(app).session(tx, first.cookie) - assert 'login_management' not in source - binding = lookup_binding(tx, factor.provider, factor.sub) - assert not binding.source.get('session') and not binding.source.get('login_binding') - with pytest.raises(Failure, match='login_approval_required'): - await bind_login( - app, VerifiedLogin('github', 'unapproved'), source, request_id='fake-source' - ) - wrong = await bind_login( - app, - VerifiedLogin('email', 'another@example.test'), - request_id='manager-bind', - intent_id=approval.intent_id, - ) - assert wrong.result.status == 'error' and wrong.result.error.code == 'login_intent_mismatch' - app._login_clock[0] = NOW + timedelta(seconds=301) - stale = await request_login_approval( - app, - VerifiedLogin('github', 'fresh-required'), - first.result.subject, - request_id='stale-session', - ) - with pytest.raises(Failure, match='login_management_expired'): - async with app.metadata.transaction(write=True) as tx: - await approve_browser_login_intent(app, tx, first.cookie, stale.intent_id) - - -@pytest.mark.asyncio -async def test_legacy_browser_has_no_management_marker(login_app): - app = login_app - key, subject, _ = await register(app, 'legacy-session-owner') - async with app.metadata.transaction(write=True) as tx: - principal = Principal( - actor=subject, - subject=subject, - credential_id=key.key_id, - method='signature', - certificates=(), - ceiling=(await tx.credential(key.key_id)).ceiling, - ) - source = await OAuthService(app).source(tx, principal) - old = await OAuthService(app).create_browser_session(tx, dict(source, scopes=['msg.read'])) - approval = await request_login_approval( - app, VerifiedLogin('github', 'not-bound'), subject, request_id='old-cookie-management' - ) - async with app.metadata.transaction(write=True) as tx: - assert ( - await approve_browser_login_intent(app, tx, old['cookie'], approval.intent_id) is False - ) - assert get(tx, approval.intent_id, NOW)[1]['status'] == 'pending' - - -@pytest.mark.asyncio -async def test_removal_revokes_factor_descendants_without_reviving_on_rebind(login_app): - app = login_app - google = VerifiedLogin('google', 'revocation-google') - first = await complete_login(app, google, request_id='revoke-register', handle='revoke-owner') - assert first.result.status == 'ok', wire(first.result) - email = VerifiedLogin('email', 'revocation@example.test') - async with app.metadata.transaction(write=False) as tx: - _, source = await OAuthService(app).session(tx, first.cookie) - second = await bind_login( - app, email, source, request_id='bind-email', session_cookie=first.cookie - ) - assert second.result.status == 'ok', wire(second.result) - async with app.metadata.transaction(write=True) as tx: - _, source = await OAuthService(app).session(tx, first.cookie) - tokens = await OAuthService(app).tokens( - tx, - dict( - source, - client_id='msg-cli', - scopes=['openid', 'profile', 'msg.read', 'offline_access'], - ), - ) - binding = lookup_binding(tx, google.provider, google.sub) - approval = await request_login_approval( - app, - None, - first.result.subject, - action='remove', - binding_id=binding.id, - request_id='remove-google', - ) - async with app.metadata.transaction(write=True) as tx: - assert await approve_browser_login_intent(app, tx, second.cookie, approval.intent_id) - removed = await remove_login(app, request_id='remove-google', intent_id=approval.intent_id) - assert removed.status == 'ok' and removed.data['removed'] - async with app.metadata.transaction(write=False) as tx: - await OAuthService(app).session(tx, second.cookie) - assert (await tx.subject(first.result.subject)).auth_version == 0 - for check in ['session', 'access', 'refresh']: - with pytest.raises(Failure): - async with app.metadata.transaction(write=True) as tx: - if check == 'session': - await OAuthService(app).session(tx, first.cookie) - elif check == 'access': - await OAuthService(app).bearer(tx, tokens['access_token']) - else: - await OAuthService(app).exchange( - tx, - { - 'grant_type': 'refresh_token', - 'client_id': 'msg-cli', - 'refresh_token': tokens['refresh_token'], - }, - ) - rebinding = await request_login_approval( - app, google, first.result.subject, request_id='rebind-google' - ) - async with app.metadata.transaction(write=True) as tx: - assert await approve_browser_login_intent(app, tx, second.cookie, rebinding.intent_id) - new = await bind_login(app, google, request_id='rebind-google', intent_id=rebinding.intent_id) - assert new.result.status == 'ok', wire(new.result) - with pytest.raises(Failure): - async with app.metadata.transaction(write=False) as tx: - await OAuthService(app).session(tx, first.cookie) - - -@pytest.mark.asyncio -async def test_context_is_exact_and_resets_after_use(login_app): - app = login_app - factor = VerifiedLogin('google', 'context-subject') - async with app.metadata.transaction(write=True) as tx: - intent_id, _ = await create_login_intent( - app, - tx, - factor, - action='complete', - request_id='context-complete', - handle='context-owner', - ) - request = request_for( - 'identity.login_complete', - {'intent_id': intent_id, 'handle': 'context-owner'}, - app.settings.service_url, - request_id='context-complete', - expires_at=NOW + timedelta(seconds=100), - ) - with trusted_login(app, factor, intent_id=intent_id, request=request): - changed = request_for( - 'identity.login_complete', - {'intent_id': intent_id, 'handle': 'other-owner'}, - app.settings.service_url, - request_id='context-complete', - expires_at=NOW + timedelta(seconds=100), - ) - denied = await app.executor.execute(changed) - assert denied.status == 'error' and denied.error.code == 'verified_login_required' - denied = await app.executor.execute(request) - assert denied.status == 'error' and denied.error.code == 'verified_login_required' diff --git a/tests/test_agentid_account_login.py b/tests/test_agentid_account_login.py deleted file mode 100644 index cf0b4269..00000000 --- a/tests/test_agentid_account_login.py +++ /dev/null @@ -1,210 +0,0 @@ -"""AgentID browser flows through PostgreSQL and the existing account executor.""" - -from dataclasses import replace -from datetime import timedelta -from urllib.parse import parse_qs, urlsplit - -import pytest -from test_account_login import ( - account, # noqa: F401 - approve_if_pending, - post, - session_source, - token, -) -from test_oauth import browser_login, oauth # noqa: F401 - -from msg.login_config import LoginProvider -from msg.transports.login_providers import AGENTID_ISSUER, authorization_url -from msg.transports.oauth_http import csrf - - -@pytest.fixture -async def agentid_account(account, monkeypatch): # noqa: F811 - app, _, _, _, _ = account - credential = app.settings.config_dir / 'agentid.fixture' - credential.write_text('fixture-only-secret') - credential.chmod(0o600) - provider = LoginProvider( - 'agentid', - enabled=True, - client_id='agentid-fixture', - credential_file=str(credential), - token_auth_method='client_secret_basic', - ) - app.settings = replace( - app.settings, - login=replace( - app.settings.login, - providers=tuple( - provider if item.name == 'agentid' else item - for item in app.settings.login.providers - ), - ), - ) - - def authorize(name, config, state, verifier, nonce, redirect_uri, **options): - assert redirect_uri == app.settings.service_url + '/-/login/callback/' + name - return authorization_url( - name, - config, - state, - verifier, - nonce, - redirect_uri.replace('http://testserver/', 'http://localhost/'), - **options, - ) - - monkeypatch.setattr('msg.transports.account_login.authorization_url', authorize) - return account - - -async def begin(case, *, mode='login'): - _, _, _, http, _ = case - response = await post( - case, - '/-/login/start', - { - 'csrf': await token(http), - 'provider': 'agentid', - 'mode': mode, - }, - ) - assert response.status_code == 303, response.text - state = parse_qs(urlsplit(response.headers['location']).query)['state'][0] - return state, csrf(http.cookies.get('msg_account_login')) - - -async def callback(case, state, **overrides): - return await case[3].get( - '/-/login/callback/agentid', - params={ - 'state': state, - 'code': 'fixture-code', - 'iss': AGENTID_ISSUER, - **overrides, - }, - ) - - -async def test_public_initiate_url_preserves_hint_and_is_login_only(agentid_account): - app, _, _, http, _ = agentid_account - async with app.metadata.transaction(write=False) as tx: - before = tx.one('SELECT COUNT(*) FROM identities')[0] - response = await http.get( - '/login/agentid', - params={ - 'iss': AGENTID_ISSUER, - 'login_hint': 'agent+one@example.org', - }, - ) - assert response.status_code == 303 and 'set-cookie' not in response.headers - assert response.headers['location'].startswith('/-/login/start?') - response = await http.get(response.headers['location']) - assert response.status_code == 303, response.text - target = urlsplit(response.headers['location']) - assert target._replace(query='').geturl() == AGENTID_ISSUER + '/v0/authorize' - params = parse_qs(target.query) - assert params['login_hint'] == ['agent+one@example.org'] - assert params['client_id'] == ['agentid-fixture'] - assert params['code_challenge_method'] == ['S256'] - assert len(params['state'][0]) == 43 - assert 'Max-Age=600' in response.headers['set-cookie'] - assert 'HttpOnly' in response.headers['set-cookie'] - async with app.metadata.transaction(write=False) as tx: - assert tx.one('SELECT COUNT(*) FROM identities')[0] == before - assert 'Continue with AgentID' in (await http.get('/login')).text - assert 'Continue with AgentID' not in (await http.get('/register')).text - - -@pytest.mark.parametrize( - 'query', - [ - 'provider=agentid&iss=https%3A%2F%2Fattacker.example', - 'provider=agentid&mode=bind', - 'provider=agentid&mode=register', - 'provider=agentid&callbackURL=https%3A%2F%2Fattacker.example', - 'provider=agentid&provider=google', - 'provider=google', - 'provider=agentid&login_hint=a&login_hint=b', - ], -) -async def test_initiation_rejects_issuer_or_mode_injection(agentid_account, query): - response = await agentid_account[3].get('/-/login/start?' + query) - assert response.status_code == 400, response.text - assert 'location' not in response.headers - - -@pytest.mark.parametrize('issuer', [None, 'https://attacker.example']) -async def test_callback_requires_exact_issuer(agentid_account, issuer): - _, _, _, http, _ = agentid_account - state, _ = await begin(agentid_account) - params = {'state': state, 'code': 'fixture-code'} - if issuer is not None: - params['iss'] = issuer - denied = await http.get('/-/login/callback/agentid', params=params) - assert denied.status_code == 400 and 'login_provider_invalid_issuer' in denied.text - assert (await callback(agentid_account, state)).status_code == 303 - replay = await callback(agentid_account, state) - assert replay.status_code == 400 and 'invalid_login_transaction' in replay.text - - -async def test_waiting_callback_and_unbound_identity_do_not_create_account(agentid_account): - app, _, _, http, _ = agentid_account - state, value = await begin(agentid_account) - app._oauth_clock[0] += timedelta(seconds=330) - returned = await callback(agentid_account, state) - assert returned.status_code == 303, returned.text - async with app.metadata.transaction(write=False) as tx: - before = tx.one('SELECT COUNT(*) FROM identities')[0] - result = await post(agentid_account, '/-/login/complete', {'csrf': value, 'state': state}) - assert result.status_code == 400 and 'login_binding_required' in result.text - async with app.metadata.transaction(write=False) as tx: - assert tx.one('SELECT COUNT(*) FROM identities')[0] == before - replay = await post(agentid_account, '/-/login/complete', {'csrf': value, 'state': state}) - assert replay.status_code == 400 - - -async def test_agentid_cannot_register_or_bypass_csrf(agentid_account): - _, _, _, http, _ = agentid_account - form = {'provider': 'agentid', 'mode': 'register', 'handle': 'new-agent', 'custody': 'yes'} - denied = await post(agentid_account, '/-/login/start', form) - assert denied.status_code == 403 - denied = await post(agentid_account, '/-/login/start', {**form, 'csrf': await token(http)}) - assert denied.status_code == 400 and 'login_registration_forbidden' in denied.text - - -async def test_bound_agent_returns_to_same_self_held_identity(agentid_account): - app, key, subject, http, _ = agentid_account - await browser_login((app, key, subject, http)) - async with app.metadata.transaction(write=False) as tx: - before = tx.rows('SELECT id,body FROM credentials WHERE subject=?', (subject,)) - vaults = tx.one('SELECT COUNT(*) FROM custodial_vault WHERE subject=?', (subject,))[0] - state, value = await begin(agentid_account, mode='bind') - assert (await callback(agentid_account, state)).status_code == 303 - result = await post(agentid_account, '/-/login/complete', {'csrf': value, 'state': state}) - result = await approve_if_pending(agentid_account, result) - assert result.status_code == 303, result.text - assert (await session_source(app, http))['subject'] == subject - async with app.metadata.transaction(write=False) as tx: - assert (await tx.subject(subject)).kind == 'registered' - after = dict(tx.rows('SELECT id,body FROM credentials WHERE subject=?', (subject,))) - assert all(after[key_id] == body for key_id, body in before) - assert ( - tx.one('SELECT COUNT(*) FROM custodial_vault WHERE subject=?', (subject,))[0] == vaults - ) - http.cookies.clear() - state, value = await begin(agentid_account) - assert (await callback(agentid_account, state)).status_code == 303 - result = await post(agentid_account, '/-/login/complete', {'csrf': value, 'state': state}) - assert result.status_code == 303, result.text - assert (await session_source(app, http))['subject'] == subject - - -async def test_callback_cannot_move_to_another_browser(agentid_account): - _, _, _, http, _ = agentid_account - state, _ = await begin(agentid_account) - http.cookies.clear() - await token(http) - denied = await callback(agentid_account, state) - assert denied.status_code == 400 and 'invalid_login_transaction' in denied.text diff --git a/tests/test_agentid_providers.py b/tests/test_agentid_providers.py deleted file mode 100644 index ddf71a6b..00000000 --- a/tests/test_agentid_providers.py +++ /dev/null @@ -1,343 +0,0 @@ -"""AgentID verification with real ES256 signatures and isolated provider HTTP.""" - -import base64 -import hashlib -import json -from collections import Counter -from dataclasses import asdict -from datetime import UTC, datetime, timedelta -from urllib.parse import parse_qs, urlsplit - -import httpx -import jwt -import pytest -from cryptography.hazmat.primitives.asymmetric import ec, rsa - -from msg.core.codec import b64 -from msg.core.errors import Failure -from msg.login_config import LoginConfig, load_login -from msg.security.login_jwt import token_key_id -from msg.transports import login_providers as providers - -ISSUER = 'https://auth.agentid.com' -DISCOVERY = ISSUER + '/.well-known/openid-configuration' -AUTHORIZE = ISSUER + '/v0/authorize' -TOKEN = ISSUER + '/v0/token' -JWKS = ISSUER + '/v0/jwks.json' -CALLBACK = 'https://msg.example/-/login/callback/agentid' -NOW = datetime(2026, 10, 9, tzinfo=UTC) -NONCE = 'original-agentid-browser-nonce' -VERIFIER = 'v' * 43 -CODE = 'fixture-authorization-code' -CLIENT = 'fixture-agentid-client' - - -@pytest.fixture(autouse=True) -def clear_caches(): - providers._jwks_cache.clear() - providers._jwks_locks.clear() - yield - providers._jwks_cache.clear() - providers._jwks_locks.clear() - - -@pytest.fixture(scope='module') -def signing_keys(): - return [ec.generate_private_key(ec.SECP256R1()) for _ in range(2)] - - -def public_jwk(key, kid='current'): - return json.loads(jwt.algorithms.ECAlgorithm.to_jwk(key.public_key())) | { - 'kid': kid, - 'alg': 'ES256', - 'use': 'sig', - 'key_ops': ['verify'], - } - - -class AgentScenario: - def __init__(self, keys, credential): - self.keys = keys - self.key = keys[0] - self.kid = 'current' - self.config = {'client_id': CLIENT, 'credential_file': str(credential)} - self.claims = { - 'iss': ISSUER, - 'aud': CLIENT, - 'sub': 's' * 43, - 'iat': int(NOW.timestamp()) - 1, - 'exp': int(NOW.timestamp()) + 599, - 'nonce': NONCE, - 'actor_type': 'agent', - 'scope': 'openid email profile', - 'email': 'agent@example.org', - 'email_verified': True, - } - self.metadata = { - 'issuer': ISSUER, - 'authorization_endpoint': AUTHORIZE, - 'token_endpoint': TOKEN, - 'jwks_uri': JWKS, - 'id_token_signing_alg_values_supported': ['ES256'], - 'authorization_response_iss_parameter_supported': True, - } - self.jwks = {'keys': [public_jwk(self.key)]} - self.requests = [] - self.responses = {} - self.token_override = None - - def handle(self, request): - self.requests.append(request) - url = str(request.url) - if url in self.responses: - return self.responses[url] - if url == DISCOVERY: - return httpx.Response(200, json=self.metadata) - if url == TOKEN: - token = self.token_override or jwt.encode( - self.claims, self.key, algorithm='ES256', headers={'kid': self.kid} - ) - return httpx.Response( - 200, json={'id_token': token, 'access_token': 'fixture-not-retained'} - ) - if url == JWKS: - return httpx.Response(200, json=self.jwks, headers={'Cache-Control': 'max-age=3600'}) - pytest.fail('Unexpected provider endpoint: ' + url) - - async def exchange(self, now=NOW): - async with httpx.AsyncClient(transport=httpx.MockTransport(self.handle)) as client: - return await providers.exchange_identity( - 'agentid', self.config, CODE, VERIFIER, NONCE, CALLBACK, now, client=client - ) - - def count(self, url): - return Counter(str(request.url) for request in self.requests)[url] - - -@pytest.fixture -def flow(signing_keys, tmp_path): - credential = tmp_path / 'client.secret' - credential.write_text('fixture-only-secret\n') - credential.chmod(0o600) - return AgentScenario(signing_keys, credential) - - -def test_agentid_config_is_opt_in_and_uses_registered_basic(flow): - assert not LoginConfig().provider('agentid').enabled - config = load_login( - {'enabled': True, 'providers': {'agentid': flow.config}}, 'https://msg.example' - ) - provider = config.provider('agentid') - assert provider.enabled - assert provider.token_auth_method == 'client_secret_basic' - assert provider.credential_file == flow.config['credential_file'] - - -@pytest.mark.parametrize( - 'override', - [ - {'client_id': ''}, - {'client_id': '客户端'}, - {'credential_file': ''}, - {'credential_file': 'relative.secret'}, - {'token_auth_method': 'none'}, - {'token_auth_method': 'private_key_jwt'}, - {'client_secret': 'do-not-store-in-config'}, - ], -) -def test_agentid_config_rejects_missing_or_unsafe_credentials(flow, override): - with pytest.raises(Failure): - load_login( - {'enabled': True, 'providers': {'agentid': flow.config | override}}, - 'https://msg.example', - ) - - -def test_authorize_is_pinned_has_pkce_nonce_and_minimal_scopes(flow): - url = providers.authorization_url( - 'agentid', - flow.config, - 'original-state', - VERIFIER, - NONCE, - CALLBACK, - login_hint='agent+test@example.org', - ) - parsed = urlsplit(url) - params = parse_qs(parsed.query) - assert parsed._replace(query='').geturl() == AUTHORIZE - assert params == { - 'client_id': [CLIENT], - 'response_type': ['code'], - 'redirect_uri': [CALLBACK], - 'state': ['original-state'], - 'nonce': [NONCE], - 'code_challenge_method': ['S256'], - 'code_challenge': [b64(hashlib.sha256(VERIFIER.encode()).digest())], - 'scope': ['openid profile email'], - 'login_hint': ['agent+test@example.org'], - } - assert VERIFIER not in url and 'fixture-only-secret' not in url - - -@pytest.mark.parametrize('hint', ['', 'x\r\nInjected:yes', 'a' * 255, ['agent@example.org']]) -def test_rejects_unsafe_login_hint(flow, hint): - with pytest.raises(Failure, match='^login_provider_invalid_request$'): - providers.authorization_url( - 'agentid', flow.config, 'state', VERIFIER, NONCE, CALLBACK, login_hint=hint - ) - - -@pytest.mark.parametrize('method', ['client_secret_basic', 'client_secret_post']) -async def test_real_es256_identity_and_registered_authentication(flow, method): - flow.config['token_auth_method'] = method - identity = await flow.exchange() - assert (identity.provider, identity.issuer, identity.subject) == ('agentid', ISSUER, 's' * 43) - assert set(asdict(identity)) == {'provider', 'issuer', 'subject', 'client_id'} - assert identity.client_id is None - request = next(item for item in flow.requests if str(item.url) == TOKEN) - data = parse_qs(request.content.decode()) - assert data['code'] == [CODE] and data['code_verifier'] == [VERIFIER] - assert data['redirect_uri'] == [CALLBACK] - assert data['grant_type'] == ['authorization_code'] - if method == 'client_secret_basic': - header = request.headers['authorization'].removeprefix('Basic ') - assert base64.b64decode(header).decode() == CLIENT + ':fixture-only-secret' - assert 'client_secret' not in data - else: - assert 'authorization' not in request.headers - assert data['client_secret'] == ['fixture-only-secret'] - assert {str(item.url) for item in flow.requests} == {DISCOVERY, TOKEN, JWKS} - - -async def test_profile_changes_do_not_change_identity_or_merge_inboxes(flow): - original = await flow.exchange() - flow.claims.update( - email='renamed@example.org', name='Renamed agent', owner_email='owner@example.org' - ) - assert await flow.exchange() == original - flow.claims['sub'] = 'other-inbox' - assert (await flow.exchange()).stable_id != original.stable_id - assert flow.count(JWKS) == 1 - - -@pytest.mark.parametrize( - ('claim', 'value'), - [ - ('iss', 'https://attacker.example'), - ('aud', 'other-client'), - ('nonce', 'another-browser'), - ('exp', int(NOW.timestamp())), - ('exp', True), - ('iat', int(NOW.timestamp()) + 6), - ('iat', True), - ('sub', ''), - ('sub', 42), - ('aud', [CLIENT, 'other-client']), - ('azp', 'other-client'), - ('nbf', int(NOW.timestamp()) + 1), - ], -) -async def test_rejects_signed_wrong_claims(flow, claim, value): - flow.claims[claim] = value - with pytest.raises(Failure, match='^login_provider_invalid_token$') as error: - await flow.exchange() - assert error.value.details is None - assert CODE not in str(error.value) - - -@pytest.mark.parametrize('claim', ['iss', 'aud', 'sub', 'exp', 'iat', 'nonce']) -async def test_rejects_missing_claims(flow, claim): - del flow.claims[claim] - with pytest.raises(Failure, match='^login_provider_invalid_token$'): - await flow.exchange() - - -@pytest.mark.parametrize('kind', ['forged', 'none', 'HS256', 'RS256']) -async def test_rejects_forged_or_wrong_algorithm(flow, kind): - if kind == 'forged': - flow.key = flow.keys[1] - else: - key = ( - rsa.generate_private_key(public_exponent=65537, key_size=2048) - if kind == 'RS256' - else 'x' * 32 - if kind == 'HS256' - else '' - ) - flow.token_override = jwt.encode( - flow.claims, key, algorithm=kind, headers={'kid': 'current'} - ) - with pytest.raises(Failure, match='^login_provider_invalid_token$'): - await flow.exchange() - if kind != 'forged': - assert flow.count(JWKS) == 0 - - -@pytest.mark.parametrize( - ('field', 'value'), - [ - ('kty', 'RSA'), - ('crv', 'P-384'), - ('alg', 'RS256'), - ('use', 'enc'), - ('key_ops', ['sign']), - ('d', 'a' * 43), - ('x', 'a' * 42), - ('y', '!' * 43), - ], -) -async def test_rejects_invalid_or_private_jwks(flow, field, value): - flow.jwks['keys'][0][field] = value - with pytest.raises(Failure, match='^login_provider_invalid_token$'): - await flow.exchange() - - -async def test_rejects_duplicate_key_ids(flow): - flow.jwks['keys'].append(dict(flow.jwks['keys'][0])) - with pytest.raises(Failure, match='^login_provider_invalid_token$'): - await flow.exchange() - - -async def test_rotation_refresh_is_bounded(flow): - await flow.exchange() - flow.key, flow.kid = flow.keys[1], 'next' - flow.jwks = {'keys': [public_jwk(flow.key, 'next')]} - with pytest.raises(Failure, match='^login_provider_invalid_token$'): - await flow.exchange(NOW + timedelta(seconds=29)) - assert flow.count(JWKS) == 1 - assert (await flow.exchange(NOW + timedelta(seconds=30))).subject == 's' * 43 - assert flow.count(JWKS) == 2 - - -@pytest.mark.parametrize( - ('field', 'value'), - [ - ('issuer', 'https://attacker.example'), - ('token_endpoint', 'https://attacker.example/token'), - ('authorization_endpoint', 'https://attacker.example/auth'), - ('jwks_uri', 'https://attacker.example/keys'), - ('id_token_signing_alg_values_supported', ['RS256']), - ('authorization_response_iss_parameter_supported', False), - ], -) -async def test_discovery_mismatch_fails_before_sending_credentials(flow, field, value): - flow.metadata[field] = value - with pytest.raises(Failure, match='^login_provider_invalid_discovery$'): - await flow.exchange() - assert flow.count(TOKEN) == 0 - - -@pytest.mark.parametrize('endpoint', [DISCOVERY, TOKEN, JWKS]) -async def test_provider_redirects_are_not_followed(flow, endpoint): - flow.responses[endpoint] = httpx.Response(302, headers={'Location': 'https://attacker.example'}) - with pytest.raises(Failure, match='^login_provider_invalid_response$'): - await flow.exchange() - assert all(str(request.url) in {DISCOVERY, TOKEN, JWKS} for request in flow.requests) - - -def test_es256_does_not_relax_existing_provider_algorithm(flow): - token = jwt.encode(flow.claims, flow.key, algorithm='ES256', headers={'kid': 'current'}) - assert token_key_id(token, algorithm='ES256') == 'current' - with pytest.raises(Failure, match='^login_provider_invalid_token$'): - token_key_id(token) diff --git a/tests/test_ascii_art.py b/tests/test_ascii_art.py index aed77e14..927870f9 100644 --- a/tests/test_ascii_art.py +++ b/tests/test_ascii_art.py @@ -1,7 +1,7 @@ """Visual rendering must not expand machine reads or execute shared user art.""" import pytest -from test_oauth import oauth as oauth +from test_browser_sessions import signed_browser as signed_browser from msg.plugins.public_board import default, projection from msg.transports.ascii_art import HASH, SCRIPT @@ -22,8 +22,8 @@ def test_visual_bundle_is_pinned_and_scoped_to_home(): @pytest.mark.asyncio -async def test_machine_home_and_public_board_have_no_visual_payload(oauth): - _, _, _, http = oauth +async def test_machine_home_and_public_board_have_no_visual_payload(signed_browser): + _, _, _, http = signed_browser browser = await http.get('/', headers={'Accept': 'text/html'}) assert 'data-ascii-canvas' in browser.text and SCRIPT in browser.text machine = await http.get('/', headers={'Accept': 'text/markdown'}) diff --git a/tests/test_browser_html.py b/tests/test_browser_html.py index 3ab587c3..1ad698d4 100644 --- a/tests/test_browser_html.py +++ b/tests/test_browser_html.py @@ -4,7 +4,7 @@ from hashlib import sha256 import pytest -from test_oauth import browser_login, oauth as oauth +from test_browser_sessions import browser_login, signed_browser as signed_browser from test_service import call, register from msg.transports.home_page import HOME_BROWSER_HEADERS, document_html @@ -26,8 +26,8 @@ def test_rendered_markdown_escapes_html_and_unsafe_links(): @pytest.mark.asyncio -async def test_login_alias_html_home_and_private_collection(oauth): - app, _, _, http = oauth +async def test_login_alias_html_home_and_private_collection(signed_browser): + app, _, _, http = signed_browser login = await http.get('/login') assert login.status_code == 200 and 'msg auth approve' in login.text assert login.headers['content-type'].startswith('text/html') @@ -35,13 +35,13 @@ async def test_login_alias_html_home_and_private_collection(oauth): home = await http.get('/', headers={'Accept': 'text/html'}) assert 'href="/login"' in home.text and '' in home.text assert 'document.modelContext' in home.text - await browser_login(oauth) - logout = await http.get('/oauth/logout') + await browser_login(signed_browser) + logout = await http.get('/session/logout') assert logout.headers['referrer-policy'] == 'strict-origin' home = await http.get('/', headers={'Accept': 'text/html'}) - assert 'href="/@oauth-owner/in"' in home.text - assert 'href="/@oauth-owner/dm"' in home.text - for path in ('/@oauth-owner/in', '/@oauth-owner/dm', '/@oauth-owner'): + assert 'href="/@key-owner/in"' in home.text + assert 'href="/@key-owner/dm"' in home.text + for path in ('/@key-owner/in', '/@key-owner/dm', '/@key-owner'): html = await http.get(path, headers={'Accept': 'text/html'}) assert html.status_code == 200 and html.headers['content-type'].startswith('text/html') assert '
' in html.text and 'document.modelContext' in html.text @@ -52,8 +52,8 @@ async def test_login_alias_html_home_and_private_collection(oauth): @pytest.mark.asyncio -async def test_readable_dm_channel_metadata_and_raw(oauth): - app, key, subject, http = oauth +async def test_readable_dm_channel_metadata_and_raw(signed_browser): + app, key, subject, http = signed_browser other_key, other, _ = await register(app, 'mail-contact') dm = await call( app, @@ -64,8 +64,8 @@ async def test_readable_dm_channel_metadata_and_raw(oauth): contract_version=2, ) assert dm.status == 'ok' - await browser_login(oauth) - listing = await http.get('/@oauth-owner/dm', headers={'Accept': 'text/html'}) + await browser_login(signed_browser) + listing = await http.get('/@key-owner/dm', headers={'Accept': 'text/html'}) assert '@mail-contact' in listing.text conversation = await http.get( '/_id/' + dm.data['conversation_id'], headers={'Accept': 'text/html'} @@ -110,16 +110,16 @@ async def test_readable_dm_channel_metadata_and_raw(oauth): @pytest.mark.asyncio -async def test_own_groups_are_visible_in_browser_but_not_public_profile(oauth): +async def test_own_groups_are_visible_in_browser_but_not_public_profile(signed_browser): from msg.plugins.identity import set_member - app, _, subject, http = oauth + app, _, subject, http = signed_browser async with app.metadata.transaction(write=True) as tx: await set_member(tx, 'g_admins', subject, 'member', joined_at=app.clock()) - public = await http.get('/@oauth-owner/json') + public = await http.get('/@key-owner/json') assert 'groups' not in public.json() - await browser_login(oauth) + await browser_login(signed_browser) home = await http.get('/', headers={'Accept': 'text/html'}) assert 'href="/&admins"' in home.text and '&admins' in home.text - mine = await http.get('/@oauth-owner/json') + mine = await http.get('/@key-owner/json') assert {'name': '&admins', 'path': '/&admins'} in mine.json()['groups'] diff --git a/tests/test_browser_sessions.py b/tests/test_browser_sessions.py new file mode 100644 index 00000000..262c3414 --- /dev/null +++ b/tests/test_browser_sessions.py @@ -0,0 +1,341 @@ +"""Native key approval through real PostgreSQL, signatures and browser HTTP.""" + +import asyncio +from dataclasses import replace +from datetime import timedelta + +import httpx +import pytest +from test_service import NOW, call, register + +from msg.browser_config import BrowserConfig +from msg.client import ClientState, MsgClient +from msg.client_api_keys import create as create_api_key +from msg.core.codec import b64, unb64, wire +from msg.core.errors import Failure +from msg.core.requests import request_for +from msg.security.sessions import BrowserSessionService, get, state_id +from msg.transports.client import HTTPTransport +from msg.transports.http import create_app +from msg.transports.session_http import csrf + + +@pytest.fixture +async def signed_browser(installed): + app, _ = installed + app.settings = replace(app.settings, browser=BrowserConfig(enabled=True)) + app.authenticator.browser_config = app.settings.browser + app._session_clock = [NOW] + app.clock = lambda: app._session_clock[0] + app.authenticator.clock = app.clock + app.executor.clock = app.clock + app.certificates.clock = app.clock + key, subject, _ = await register(app, 'key-owner') + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url + ) as http: + yield app, key, subject, http + + +async def start(signed_browser): + _, _, _, http = signed_browser + page = await http.get('/login') + assert page.status_code == 200, page.text + return page.text.split('msg auth approve ')[1].split('')[0], http.cookies.get( + 'msg_login' + ) + + +async def approve(signed_browser, code, decision='approve'): + app, key, subject, _ = signed_browser + return await call( + app, + 'identity.session_approve', + {'user_code': code, 'decision': decision}, + key=key, + subject=subject, + ) + + +async def poll(signed_browser, cookie): + app, _, _, http = signed_browser + return await http.post( + '/session/poll', json={'csrf': csrf(cookie)}, headers={'Origin': app.settings.service_url} + ) + + +async def browser_login(signed_browser): + _, _, _, http = signed_browser + code, cookie = await start(signed_browser) + approved = await approve(signed_browser, code) + assert approved.status == 'ok', wire(approved) + result = await poll(signed_browser, cookie) + assert result.status_code == 200 and result.json() == {'logged_in': True}, result.text + assert ( + 'HttpOnly' in result.headers['set-cookie'] + and 'SameSite=lax' in result.headers['set-cookie'] + ) + return http.cookies.get('msg_session') + + +async def test_key_approval_creates_bounded_session_without_storing_private_key(signed_browser): + app, key, subject, http = signed_browser + code, cookie = await start(signed_browser) + inspected = await call(app, 'identity.session_request', {'user_code': code}) + assert inspected.status == 'ok' and inspected.data['kind'] == 'browser', wire(inspected) + assert inspected.data['service'] == app.settings.service_url + assert (await approve(signed_browser, code)).status == 'ok' + assert (await poll(signed_browser, cookie)).status_code == 200 + saved_cookie = http.cookies.get('msg_session') + async with app.metadata.transaction(write=False) as tx: + expiry, body = get(tx, state_id('session', saved_cookie), app.clock()) + assert expiry == NOW + timedelta(hours=1) + assert body['subject'] == subject and body['parent'] == key.key_id + rows = tx.rows('SELECT body FROM credential_states') + text = str(rows) + assert ( + saved_cookie not in text and cookie not in text and b64(key.private_bytes()) not in text + ) + _, credential_id, _ = await BrowserSessionService(app).browser_credentials(tx, saved_cookie) + assert credential_id.startswith('t_session_') + credential = await tx.credential(credential_id) + assert not any( + op.startswith(('identity.', 'root.', 'system.')) + for g in credential.ceiling + for op in g.operations + ) + assert (await http.get('/@key-owner/in')).status_code == 200 + + +async def test_confirmation_requires_signature_and_is_consumed_once(signed_browser): + app, _, subject, http = signed_browser + code, cookie = await start(signed_browser) + anonymous = await call( + app, 'identity.session_approve', {'user_code': code, 'decision': 'approve'} + ) + assert anonymous.status == 'error' + await browser_login(signed_browser) + session_cookie = http.cookies.get('msg_session') + async with app.metadata.transaction(write=False) as tx: + _, credential, secret = await BrowserSessionService(app).browser_credentials( + tx, session_cookie + ) + denied = await call( + app, + 'identity.session_approve', + {'user_code': code, 'decision': 'approve'}, + subject=subject, + token=(credential, secret), + ) + assert denied.status == 'error' + assert (await approve(signed_browser, code)).status == 'ok' + again = await approve(signed_browser, code) + assert again.status == 'error' and again.error.code == 'invalid_grant' + async with app.metadata.transaction(write=True) as tx: + logged, error = await BrowserSessionService(app).pending_result(tx, cookie) + assert logged and error is None + with pytest.raises(Failure, match='invalid_grant'): + async with app.metadata.transaction(write=True) as tx: + await BrowserSessionService(app).pending_result(tx, cookie) + + +async def test_pending_backoff_denial_expiry_and_cross_browser_protection(signed_browser): + app, _, _, http = signed_browser + code, cookie = await start(signed_browser) + assert (await poll(signed_browser, cookie)).json()['error'] == 'authorization_pending' + assert (await poll(signed_browser, cookie)).json()['error'] == 'slow_down' + assert (await approve(signed_browser, code, 'deny')).status == 'ok' + app._session_clock[0] += timedelta(seconds=20) + assert (await poll(signed_browser, cookie)).json()['error'] == 'access_denied' + other_code, other_cookie = await start(signed_browser) + assert (await poll(signed_browser, cookie)).status_code == 400 + app._session_clock[0] += timedelta(minutes=11) + assert (await poll(signed_browser, other_cookie)).status_code == 400 + assert other_code != code + + +@pytest.mark.parametrize( + 'mutation', ['revoke', 'auth_version', 'expiry', 'logout', 'ceiling', 'disabled'] +) +async def test_browser_session_rechecks_authority_on_every_read(signed_browser, mutation): + app, key, subject, http = signed_browser + cookie = await browser_login(signed_browser) + assert (await http.get('/@key-owner/in')).status_code == 200 + if mutation == 'logout': + result = await http.post( + '/session/logout', + json={'csrf': csrf(cookie)}, + headers={'Origin': app.settings.service_url}, + ) + assert result.status_code == 200 + http.cookies.set('msg_session', cookie) + elif mutation == 'expiry': + app._session_clock[0] += timedelta(hours=2) + elif mutation == 'disabled': + app.settings = replace(app.settings, browser=BrowserConfig(enabled=False)) + app.authenticator.browser_config = app.settings.browser + else: + async with app.metadata.transaction(write=True) as tx: + parent = await tx.credential(key.key_id) + owner = await tx.subject(subject) + if mutation == 'auth_version': + await tx.update_identity( + replace(owner, auth_version=owner.auth_version + 1), owner.auth_version + ) + else: + parent = replace( + parent, **({'revoked_at': NOW} if mutation == 'revoke' else {'ceiling': ()}) + ) + await tx.save_credential(parent, owner.auth_version) + result = await http.get('/@key-owner/in') + assert result.status_code in {400, 401, 403}, result.text + + +@pytest.mark.parametrize('token', ['非ASCII', '', 'wrong', None, ['wrong']]) +async def test_csrf_failure_is_controlled_and_does_not_sign_in(signed_browser, token): + app, _, _, http = signed_browser + code, _ = await start(signed_browser) + assert (await approve(signed_browser, code)).status == 'ok' + response = await http.post( + '/session/poll', json={'csrf': token}, headers={'Origin': app.settings.service_url} + ) + assert response.status_code == 400 + assert http.cookies.get('msg_session') is None + + +async def test_concurrent_browser_poll_issues_at_most_one_session(signed_browser): + app, _, _, _ = signed_browser + code, cookie = await start(signed_browser) + assert (await approve(signed_browser, code)).status == 'ok' + results = await asyncio.gather(*(poll(signed_browser, cookie) for _ in range(2))) + assert sorted(r.status_code for r in results) == [200, 400] + async with app.metadata.transaction(write=False) as tx: + assert tx.one("SELECT COUNT(*) FROM credential_states WHERE kind='session'")[0] == 1 + + +@pytest.mark.asyncio +async def test_api_key_private_signature_rotation_delivery_and_header(signed_browser, tmp_path): + app, _, _, http = signed_browser + state = ClientState(tmp_path / 'api', server=app.settings.service_url) + client = MsgClient(state, HTTPTransport(state.server, http=http), clock=lambda: app.clock()) + registered = await client.register('api-owner') + assert registered.status == 'ok' + initial = await create_api_key(client, ttl=60) + assert initial.status == 'ok', wire(initial) + first = dict(state.data['api_key']) + result = await client.call('discovery.get', {'id': '/main'}) + assert result.status == 'ok' and result.actor == state.subject, wire(result) + rotated = await create_api_key(client, ttl=120, rotate=True) + assert rotated.status == 'ok', wire(rotated) + denied = await call( + app, + 'discovery.get', + {'id': '/main'}, + subject=state.subject, + token=(first['credential_id'], unb64(first['value'])), + ) + assert denied.status == 'error' and denied.error.code == 'credential_revoked' + current = state.data['api_key'] + packet = request_for( + 'discovery.get', + {'id': '/main'}, + state.server, + subject=state.subject, + expires_at=NOW + timedelta(seconds=120), + ) + response = await http.post( + '/-/p/discovery.get', + json=wire(packet), + headers={'Authorization': 'Bearer ' + current['credential_id'] + '.' + current['value']}, + ) + assert response.status_code == 401 and response.json()['error'] == 'signature_required' + app._session_clock[0] = NOW + timedelta(seconds=121) + packet = request_for( + 'discovery.get', + {'id': '/main'}, + state.server, + subject=state.subject, + token=(current['credential_id'], unb64(current['value'])), + expires_at=app.clock() + timedelta(seconds=120), + ) + result = await app.executor.execute(packet) + assert result.status == 'error' and result.error.code == 'credential_expired' + + +@pytest.mark.asyncio +async def test_api_key_lost_response_recovers_with_source_binding(signed_browser, tmp_path): + app, _, _, http = signed_browser + state = ClientState(tmp_path / 'lost-api', server=app.settings.service_url) + transport = HTTPTransport(state.server, http=http) + client = MsgClient(state, transport, clock=lambda: app.clock(), retries=0) + await client.register('lost-api') + private = state.signer.private_bytes() + original = transport.call + + async def lose(packet): + result = await original(packet) + if packet.operation == 'identity.token_create': + raise httpx.ReadTimeout('lost response') + return result + + transport.call = lose + with pytest.raises(Failure, match='transport_uncertain'): + await create_api_key(client) + journal = state.directory / 'api-key-create.json' + assert journal.exists() and state.data.get('api_key') is None + restarted = MsgClient( + ClientState(state.directory), + HTTPTransport(state.server, http=http), + clock=lambda: app.clock(), + ) + recovered = await restarted.recover_token() + assert recovered.status == 'ok', wire(recovered) + assert not journal.exists() and restarted.state.token is None + assert restarted.state.signer.private_bytes() == private + assert restarted.state.data['api_key']['credential_id'] == recovered.data['credential_id'] + result = await restarted.call('discovery.get', {'id': '/main'}) + assert result.status == 'ok', wire(result) + saved = restarted.state.data['api_key'] + async with app.metadata.transaction(write=True) as tx: + credential = await tx.credential(saved['credential_id']) + assert credential.source_credential_id == state.signer.key_id + tx.execute( + 'DELETE FROM credential_states WHERE id=?', ('api:' + credential.id,), write=True + ) + denied = await restarted.call('discovery.get', {'id': '/main'}) + assert denied.status == 'error' and denied.error.code == 'invalid_grant' + + +async def test_existing_key_with_old_approval_grants_can_use_only_native_login(signed_browser): + app, key, subject, http = signed_browser + async with app.metadata.transaction(write=True) as tx: + parent = await tx.credential(key.key_id) + owner = await tx.subject(subject) + renames = { + 'identity.session_request@1': 'identity.oauth_request@1', + 'identity.session_approve@1': 'identity.oauth_approve@1', + } + ceiling = tuple( + replace(grant, operations=frozenset(renames.get(op, op) for op in grant.operations)) + for grant in parent.ceiling + ) + for grant in ceiling: + await app.certificates.validate_grant(grant, tx) + await tx.save_credential(replace(parent, ceiling=ceiling), owner.auth_version) + await browser_login(signed_browser) + assert (await http.get('/@key-owner/in')).status_code == 200 + retired = await call(app, 'identity.oauth_approve', {}, key=key, subject=subject) + assert retired.status == 'error' and retired.error.code == 'operation_disabled' + + +async def test_raw_login_and_logout_confirmation_are_read_only(signed_browser, monkeypatch): + from read_only_evidence import readonly_evidence + + app, _, _, http = signed_browser + await browser_login(signed_browser) + async with readonly_evidence(app, monkeypatch): + raw = await http.get('/login?format=raw') + assert raw.status_code == 200 and 'set-cookie' not in raw.headers + page = await http.get('/session/logout') + assert page.status_code == 200 and 'set-cookie' not in page.headers + assert (await http.get('/@key-owner/in')).status_code == 200 diff --git a/tests/test_browser_workspace.py b/tests/test_browser_workspace.py index 9c265dd4..38ce1461 100644 --- a/tests/test_browser_workspace.py +++ b/tests/test_browser_workspace.py @@ -5,20 +5,20 @@ import httpx import pytest -from test_oauth import browser_login, oauth as oauth +from test_browser_sessions import browser_login, signed_browser as signed_browser from test_service import call, register from msg.core.codec import canonical from msg.core.requests import request_for from msg.security.browser_actions import BROWSER_POST_WRITES -from msg.security.oauth import OAuthService, get, state_id +from msg.security.sessions import BrowserSessionService, get, state_id from msg.transports.http import create_app -from msg.transports.oauth_http import csrf +from msg.transports.session_http import csrf @pytest.mark.asyncio -async def test_browser_home_private_reads_logout_and_no_cookie_writes(oauth): - app, key, subject, http = oauth +async def test_browser_home_private_reads_logout_and_no_cookie_writes(signed_browser): + app, key, subject, http = signed_browser other_key, other_subject, _ = await register(app, 'browser-other') paths = [] for signer, owner, body in ( @@ -40,15 +40,15 @@ async def test_browser_home_private_reads_logout_and_no_cookie_writes(oauth): assert '[Sign in with your MSG identity](/login)' in anonymous.text assert 'Your account' not in anonymous.text assert (await http.get(paths[0])).status_code == 403 - cookie = await browser_login(oauth) + cookie = await browser_login(signed_browser) home = await http.get('/') - assert '[Inbox](/@oauth-owner/in)' in home.text - assert '[Direct messages](/@oauth-owner/dm)' in home.text - assert 'Signed in as [@oauth-owner]' in home.text + assert '[Inbox](/@key-owner/in)' in home.text + assert '[Direct messages](/@key-owner/dm)' in home.text + assert 'Signed in as [@key-owner]' in home.text assert home.headers['cache-control'] == 'private, no-store' assert set(home.headers['vary'].split(', ')) == {'Accept', 'Cookie'} assert 'OWN PRIVATE BODY' not in home.text - for path in ('/@oauth-owner/in', '/@oauth-owner/out', '/@oauth-owner/dm'): + for path in ('/@key-owner/in', '/@key-owner/out', '/@key-owner/dm'): response = await http.get(path) assert response.status_code == 200, response.text assert 'OWN PRIVATE BODY' in (await http.get(paths[0])).text @@ -56,7 +56,7 @@ async def test_browser_home_private_reads_logout_and_no_cookie_writes(oauth): assert (await http.get('/@browser-other/in')).status_code == 403 assert (await http.get('/@browser-other/dm')).status_code == 403 async with app.metadata.transaction(write=False) as tx: - credentials = await OAuthService(app).browser_credentials(tx, cookie) + credentials = await BrowserSessionService(app).browser_credentials(tx, cookie) credential = await tx.credential(credentials[1]) assert all( ( @@ -82,15 +82,15 @@ async def test_browser_home_private_reads_logout_and_no_cookie_writes(oauth): headers={'Content-Type': 'application/json'}, ) assert rejected.status_code != 200 - confirm = await http.get('/oauth/logout') + confirm = await http.get('/session/logout') assert confirm.status_code == 200 and 'Confirm sign-out' in confirm.text assert (await http.get(paths[0])).status_code == 200 bad_logout = await http.post( - '/oauth/logout', json={'csrf': csrf(cookie)}, headers={'Origin': 'https://evil.example'} + '/session/logout', json={'csrf': csrf(cookie)}, headers={'Origin': 'https://evil.example'} ) assert bad_logout.status_code == 400 logout = await http.post( - '/oauth/logout', data={'csrf': csrf(cookie)}, headers={'Origin': app.settings.service_url} + '/session/logout', data={'csrf': csrf(cookie)}, headers={'Origin': app.settings.service_url} ) assert logout.status_code == 200 # A copied old cookie cannot resurrect a logged-out session. @@ -100,64 +100,64 @@ async def test_browser_home_private_reads_logout_and_no_cookie_writes(oauth): @pytest.mark.asyncio -async def test_browser_revocation_expiry_and_read_only_persistent_state(oauth): - app, _, subject, http = oauth - cookie = await browser_login(oauth) +async def test_browser_revocation_expiry_and_read_only_persistent_state(signed_browser): + app, _, subject, http = signed_browser + cookie = await browser_login(signed_browser) async with app.metadata.transaction(write=False) as tx: - before = tx.rows('SELECT id,body FROM oauth_states ORDER BY id') + before = tx.rows('SELECT id,body FROM credential_states ORDER BY id') for _ in range(2): - response = await http.get('/@oauth-owner/in') + response = await http.get('/@key-owner/in') assert response.status_code == 200, response.text async with app.metadata.transaction(write=False) as tx: - assert before == tx.rows('SELECT id,body FROM oauth_states ORDER BY id') + assert before == tx.rows('SELECT id,body FROM credential_states ORDER BY id') async with app.metadata.transaction(write=True) as tx: _, body = get(tx, state_id('session', cookie), app.clock()) parent = await tx.credential(body['parent']) await tx.save_credential( replace(parent, revoked_at=app.clock()), (await tx.subject(subject)).auth_version ) - denied = await http.get('/@oauth-owner/in') + denied = await http.get('/@key-owner/in') assert denied.status_code == 401 assert 'expired or was revoked' in (await http.get('/')).text @pytest.mark.asyncio -async def test_browser_cookie_does_not_cross_accounts(oauth): - app, _, _, http = oauth - await browser_login(oauth) +async def test_browser_cookie_does_not_cross_accounts(signed_browser): + app, _, _, http = signed_browser + await browser_login(signed_browser) async with httpx.AsyncClient( transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url ) as anonymous: assert 'Your account' not in (await anonymous.get('/')).text - assert (await anonymous.get('/@oauth-owner/in')).status_code == 401 + assert (await anonymous.get('/@key-owner/in')).status_code == 401 anonymous.cookies.set('msg_session', 'forged-cookie') assert 'Your account' not in (await anonymous.get('/')).text - app._oauth_clock[0] += timedelta(days=31) + app._session_clock[0] += timedelta(days=31) assert 'Your account' not in (await http.get('/')).text - assert (await http.get('/@oauth-owner/in')).status_code == 401 + assert (await http.get('/@key-owner/in')).status_code == 401 @pytest.mark.asyncio -async def test_browser_session_cannot_survive_source_permission_contraction(oauth): - app, _, subject, http = oauth - cookie = await browser_login(oauth) - assert (await http.get('/@oauth-owner/in')).status_code == 200 +async def test_browser_session_cannot_survive_source_permission_contraction(signed_browser): + app, _, subject, http = signed_browser + cookie = await browser_login(signed_browser) + assert (await http.get('/@key-owner/in')).status_code == 200 async with app.metadata.transaction(write=True) as tx: _, body = get(tx, state_id('session', cookie), app.clock()) parent = await tx.credential(body['parent']) await tx.save_credential( replace(parent, ceiling=()), (await tx.subject(subject)).auth_version ) - assert (await http.get('/@oauth-owner/in')).status_code == 401 + assert (await http.get('/@key-owner/in')).status_code == 401 assert 'Your account' not in (await http.get('/')).text @pytest.mark.asyncio -async def test_browser_derived_token_is_read_only_and_disabled_with_oauth(oauth): - app, _, subject, http = oauth - cookie = await browser_login(oauth) +async def test_browser_derived_token_is_read_only_and_disabled_with_oauth(signed_browser): + app, _, subject, http = signed_browser + cookie = await browser_login(signed_browser) async with app.metadata.transaction(write=False) as tx: - _, credential_id, token = await OAuthService(app).browser_credentials(tx, cookie) + _, credential_id, token = await BrowserSessionService(app).browser_credentials(tx, cookie) forbidden = await call( app, 'content.post_create', @@ -166,26 +166,18 @@ async def test_browser_derived_token_is_read_only_and_disabled_with_oauth(oauth) token=(credential_id, token), ) assert forbidden.status == 'error' and forbidden.error.code == 'credential_ceiling' - app.settings = replace(app.settings, oauth=replace(app.settings.oauth, enabled=False)) - app.authenticator.oauth_config = app.settings.oauth + app.settings = replace(app.settings, browser=replace(app.settings.browser, enabled=False)) + app.authenticator.browser_config = app.settings.browser denied = await call( app, 'discovery.get', {'id': '/main'}, subject=subject, token=(credential_id, token) ) - assert denied.status == 'error' and denied.error.code == 'oauth_disabled' + assert denied.status == 'error' and denied.error.code == 'browser_sessions_disabled' @pytest.mark.asyncio -async def test_custodial_browser_can_read_after_bootstrap_expires(oauth): - app, _, _, http = oauth - await http.get('/oauth/signup') - binder = http.cookies.get('msg_login') - created = await http.post( - '/oauth/signup', - data={'handle': 'browser-custodial', 'csrf': csrf(binder)}, - headers={'Origin': app.settings.service_url}, - ) - assert created.status_code == 200, created.text - app._oauth_clock[0] += timedelta(seconds=3601) - page = await http.get('/') - assert 'Signed in as [@browser-custodial]' in page.text - assert (await http.get('/@browser-custodial/in')).status_code == 200 +async def test_custodial_browser_signup_is_removed(signed_browser): + _, _, _, http = signed_browser + for method in ('GET', 'POST'): + response = await http.request(method, '/oauth/signup') + assert response.status_code == 410 + assert 'set-cookie' not in response.headers diff --git a/tests/test_cli_preferences.py b/tests/test_cli_preferences.py index c9005003..1b3e2141 100644 --- a/tests/test_cli_preferences.py +++ b/tests/test_cli_preferences.py @@ -6,7 +6,7 @@ from types import SimpleNamespace import pytest -from test_oauth import oauth as oauth +from test_browser_sessions import signed_browser as signed_browser from msg import cli from msg.client import ClientState @@ -186,7 +186,7 @@ def test_invalid_approval_explains_recovery_and_selected_identity(): } value = { 'status': 'error', - 'operation': 'identity.oauth_approve', + 'operation': 'identity.session_approve', 'request_id': 'req_failed', 'subject': 'u_alice', 'error': {'code': 'invalid_grant', 'message': 'The operation could not be completed.'}, @@ -229,19 +229,19 @@ def test_approval_success_identifies_account_and_read_scope(): async def test_browser_approval_success_and_consumed_code_use_cli_identity( - oauth, + signed_browser, home, monkeypatch, capsys, ): from msg.client import MsgClient from msg.transports.client import HTTPTransport - from msg.transports.oauth_http import csrf + from msg.transports.session_http import csrf - app, key, subject, http = oauth + app, key, subject, http = signed_browser state = ClientState(server=app.settings.service_url, account='owner') state.save_signer(key) - state.data.update(subject_id=subject, handle='oauth-owner') + state.data.update(subject_id=subject, handle='key-owner') state._save() monkeypatch.setitem( cli.TRANSPORTS, 'http', lambda server, **kw: HTTPTransport(server, http=http) @@ -251,7 +251,7 @@ async def test_browser_approval_success_and_consumed_code_use_cli_identity( 'MsgClient', lambda state, transport: MsgClient(state, transport, clock=app.clock), ) - page = await http.get('/oauth/login') + page = await http.get('/login') code = page.text.split('msg auth approve ')[1].split('')[0] argv = [ '--format', @@ -266,10 +266,10 @@ async def test_browser_approval_success_and_consumed_code_use_cli_identity( ] assert await cli.run(parse(argv)) == 0 shown = capsys.readouterr().out - assert 'Authorization: approved' in shown and 'Identity: @oauth-owner' in shown + assert 'Authorization: approved' in shown and 'Identity: @key-owner' in shown cookie = http.cookies.get('msg_login') response = await http.post( - '/oauth/login/poll', + '/session/poll', json={'csrf': csrf(cookie)}, headers={'Origin': app.settings.service_url}, ) @@ -278,5 +278,5 @@ async def test_browser_approval_success_and_consumed_code_use_cli_identity( shown = capsys.readouterr().out assert 'Error: invalid_grant' in shown assert 'Open the sign-in page again' in shown - assert 'Account: owner' in shown and 'Identity: @oauth-owner' in shown + assert 'Account: owner' in shown and 'Identity: @key-owner' in shown assert code not in shown diff --git a/tests/test_client_backup_remote_integration.py b/tests/test_client_backup_remote_integration.py index 62df2e64..fb9f3725 100644 --- a/tests/test_client_backup_remote_integration.py +++ b/tests/test_client_backup_remote_integration.py @@ -238,7 +238,7 @@ async def test_publish_uses_signature_despite_saved_token_api_key_and_oauth( ) encrypt, _, _ = age_cipher try: - for auth in ('token', 'api_key', 'oauth'): + for auth in ('token', 'api_key', 'signed_browser'): client.state.data.pop('token', None) client.state.data.pop('api_key', None) client.state.file('oauth-session.json').unlink(missing_ok=True) diff --git a/tests/test_client_identity_status.py b/tests/test_client_identity_status.py index 2eee28ce..4c9b3f37 100644 --- a/tests/test_client_identity_status.py +++ b/tests/test_client_identity_status.py @@ -32,7 +32,7 @@ def configure(state, methods): state.data['token'] = {'credential_id': 't_temporary', 'value': b64(b't' * 32)} if 'api-key' in methods: state.data['api_key'] = {'credential_id': 't_api', 'value': b64(b'a' * 32)} - if 'oauth' in methods: + if 'signed_browser' in methods: durable_write( state.file('oauth-session.json'), canonical({ @@ -55,7 +55,7 @@ def configure(state, methods): (('yubikey',), 'signature'), (('signature', 'api-key'), 'api-key'), (('signature', 'api-key', 'token'), 'token'), - (('signature', 'api-key', 'token', 'oauth'), 'oauth'), + (('signature', 'api-key', 'token', 'signed_browser'), 'token'), ], ) async def test_cli_identity_show_reports_actual_local_account_and_auth( @@ -72,7 +72,6 @@ def forbidden(request): def read_secret(state): raise AssertionError('status needs only session presence, not its token bytes') - monkeypatch.setattr('msg.client_oauth.read_session', read_secret) monkeypatch.setattr('msg.client_yubikey.device_session', read_secret) async with httpx.AsyncClient(transport=httpx.MockTransport(forbidden)) as http: monkeypatch.setitem(cli.TRANSPORTS, 'http', lambda server: HTTPTransport(server, http=http)) @@ -95,7 +94,7 @@ def read_secret(state): async def test_invocation_key_override_remains_the_reported_signer(home, monkeypatch, capsys): state = ClientState(account='alice') state.data.update(handle='alice', subject_id='u_alice') - configure(state, ('signature', 'token', 'api-key', 'oauth')) + configure(state, ('signature', 'token', 'api-key', 'signed_browser')) override = Ed25519Signer.generate() override_path = home / 'invocation.key' durable_write(override_path, override.private_bytes(), mode=0o600) diff --git a/tests/test_complete_recovery_proof.py b/tests/test_complete_recovery_proof.py index d7f42722..d54dec39 100644 --- a/tests/test_complete_recovery_proof.py +++ b/tests/test_complete_recovery_proof.py @@ -55,7 +55,7 @@ async def test_complete_proof_accepts_exact_disposable_state(complete_state): 'extra_setting', 'extra_table', 'extra_column', - 'oauth_state', + 'credential_state', 'money_visibility', 'link_invitation', 'wrong_pin', @@ -68,7 +68,7 @@ async def test_complete_proof_rejects_unaccounted_state(complete_state, change): 'extra_setting', 'extra_table', 'extra_column', - 'oauth_state', + 'credential_state', 'money_visibility', 'link_invitation', }: @@ -105,9 +105,9 @@ async def test_complete_proof_rejects_unaccounted_state(complete_state, change): ) else: tx.execute( - 'INSERT INTO oauth_states VALUES (?,?,?,?)', + 'INSERT INTO credential_states VALUES (?,?,?,?)', ( - 'unaccounted-oauth-session', + 'unaccounted-signed_browser-session', 'session', '2999-01-01T00:00:00Z', canonical({'subject': 's_unaccounted'}).decode(), diff --git a/tests/test_discovery_pages.py b/tests/test_discovery_pages.py index f8022b1a..65f02fd7 100644 --- a/tests/test_discovery_pages.py +++ b/tests/test_discovery_pages.py @@ -2,7 +2,7 @@ import httpx import pytest -from test_oauth import browser_login, oauth as oauth +from test_browser_sessions import browser_login, signed_browser as signed_browser from test_service import call, register from msg.transports.http import create_app @@ -60,10 +60,10 @@ async def test_feed_html_markdown_raw_and_json_use_same_public_posts(installed): @pytest.mark.asyncio -async def test_browser_feed_uses_follows_and_topics_respect_membership(oauth): +async def test_browser_feed_uses_follows_and_topics_respect_membership(signed_browser): from msg.plugins.identity import set_member - app, key, subject, http = oauth + app, key, subject, http = signed_browser bk, bob, _ = await register(app, 'page-followed') made = await call( app, @@ -79,7 +79,7 @@ async def test_browser_feed_uses_follows_and_topics_respect_membership(oauth): assert '/admins' not in public.text async with app.metadata.transaction(write=True) as tx: await set_member(tx, 'g_admins', subject, 'member', joined_at=app.clock()) - await browser_login(oauth) + await browser_login(signed_browser) personal = await http.get('/feed', headers={'Accept': 'text/html'}) assert personal.status_code == 200 and '已关注' in personal.text topics = await http.get('/topics', headers={'Accept': 'text/html'}) @@ -87,12 +87,14 @@ async def test_browser_feed_uses_follows_and_topics_respect_membership(oauth): @pytest.mark.asyncio -async def test_old_browser_ceiling_shows_public_feed_without_widening_api_permissions(oauth): +async def test_old_browser_ceiling_shows_public_feed_without_widening_api_permissions( + signed_browser, +): from dataclasses import replace - from msg.security.oauth import OAuthService + from msg.security.sessions import BrowserSessionService - app, key, subject, http = oauth + app, key, subject, http = signed_browser made = await call( app, 'content.post_create', @@ -101,9 +103,9 @@ async def test_old_browser_ceiling_shows_public_feed_without_widening_api_permis subject=subject, ) assert made.status == 'ok' - cookie = await browser_login(oauth) + cookie = await browser_login(signed_browser) async with app.metadata.transaction(write=True) as tx: - _, cid, _ = await OAuthService(app).browser_credentials(tx, cookie) + _, cid, _ = await BrowserSessionService(app).browser_credentials(tx, cookie) original = await tx.credential(cid) narrowed = replace( original, diff --git a/tests/test_first_revision_diff.py b/tests/test_first_revision_diff.py index 723dbd7b..15f003b4 100644 --- a/tests/test_first_revision_diff.py +++ b/tests/test_first_revision_diff.py @@ -4,8 +4,8 @@ from uuid import uuid4 import httpx +from test_browser_sessions import browser_login, signed_browser as signed_browser from test_linkset_diff import business_state -from test_oauth import browser_login, oauth as oauth from test_service import call, register from msg.core.identifiers import hex_id @@ -126,8 +126,8 @@ async def test_first_revision_default_diff_preserves_explicit_ranges_and_html(in assert merge.error.code == 'previous_revision_not_found' -async def test_browser_first_revision_diff_rechecks_current_private_authority(oauth): - app, key, user, http = oauth +async def test_browser_first_revision_diff_rechecks_current_private_authority(signed_browser): + app, key, user, http = signed_browser created = await call( app, 'content.post_create', @@ -149,7 +149,7 @@ async def test_browser_first_revision_diff_rechecks_current_private_authority(oa denied = await http.get(path, headers={'Accept': 'text/html'}) assert denied.status_code == 403 and 'no_previous_revision' not in denied.text assert ref.revision not in denied.text and 'private first revision' not in denied.text - await browser_login(oauth) + await browser_login(signed_browser) before = await business_state(app) allowed = await http.get(path, headers={'Accept': 'text/html'}) assert allowed.status_code == 200 and 'This is the first version' in allowed.text diff --git a/tests/test_flight_laser_range.py b/tests/test_flight_laser_range.py index 4fc8859d..fc89bbe5 100644 --- a/tests/test_flight_laser_range.py +++ b/tests/test_flight_laser_range.py @@ -6,11 +6,11 @@ from pathlib import Path import pytest -from test_flight_websocket import controls, flight_server, oauth, player, snapshot +from test_flight_websocket import controls, flight_server, player, signed_browser, snapshot from msg.transports.flight_simulation import LASER_RANGE -__all__ = ['flight_server', 'oauth'] +__all__ = ['flight_server', 'signed_browser'] def freeze_positions(flight, source_id, target_id, source, target): diff --git a/tests/test_flight_machine_websocket.py b/tests/test_flight_machine_websocket.py index acde1ee7..0d63c3cd 100644 --- a/tests/test_flight_machine_websocket.py +++ b/tests/test_flight_machine_websocket.py @@ -13,8 +13,8 @@ controls, flight_server, make_private, - oauth, receive_until, + signed_browser, snapshot, ) from test_service import NOW @@ -22,11 +22,11 @@ from msg.core.codec import wire from msg.core.requests import request_for -__all__ = ['flight_server', 'oauth'] +__all__ = ['flight_server', 'signed_browser'] -async def ticket(flight, oauth): - app, key, subject, _ = oauth +async def ticket(flight, signed_browser): + app, key, subject, _ = signed_browser nonce = secrets.token_urlsafe(32) request = request_for( 'communication.game_join_ticket', @@ -55,20 +55,22 @@ async def machine(flight, proof): return peer, hello -async def revoke(oauth): - app, key, _, _ = oauth +async def revoke(signed_browser): + app, key, _, _ = signed_browser async with app.metadata.transaction(write=True) as tx: old = await tx.credential(key.key_id) owner = await tx.subject(old.subject_id) await tx.save_credential(replace(old, revoked_at=NOW), owner.auth_version) -async def test_signed_machine_ticket_drives_the_same_authoritative_world(flight_server, oauth): - _, _, subject, _ = oauth - proof = await ticket(flight_server, oauth) +async def test_signed_machine_ticket_drives_the_same_authoritative_world( + flight_server, signed_browser +): + _, _, subject, _ = signed_browser + proof = await ticket(flight_server, signed_browser) peer, hello = await machine(flight_server, proof) assert hello['self']['subject_id'] == subject and not hello['self']['guest'] - assert hello['self']['handle'].removeprefix('@') == 'oauth-owner' + assert hello['self']['handle'].removeprefix('@') == 'key-owner' assert flight_server.hub.world.active_count == 1 assert sum(p.machine_principal is not None for p in flight_server.hub.peers.values()) == 1 await peer.send_json(controls(1, throttle=1, yaw=0.3, pitch=0.1)) @@ -82,8 +84,10 @@ async def test_signed_machine_ticket_drives_the_same_authoritative_world(flight_ assert flight_server.hub.world.active_count == 1 -async def test_ticket_with_a_wrong_nonce_cannot_create_a_machine_player(flight_server, oauth): - proof = await ticket(flight_server, oauth) +async def test_ticket_with_a_wrong_nonce_cannot_create_a_machine_player( + flight_server, signed_browser +): + proof = await ticket(flight_server, signed_browser) peer = await flight_server.connect() await peer.send_json({ 'v': 1, @@ -97,9 +101,11 @@ async def test_ticket_with_a_wrong_nonce_cannot_create_a_machine_player(flight_s assert (await snapshot(valid))['total_players'] == 1 -async def test_machine_ticket_cannot_mix_cookies_resume_or_observer_mode(flight_server, oauth): - proof = await ticket(flight_server, oauth) - cookie = await browser_login(oauth) +async def test_machine_ticket_cannot_mix_cookies_resume_or_observer_mode( + flight_server, signed_browser +): + proof = await ticket(flight_server, signed_browser) + cookie = await browser_login(signed_browser) peer = await flight_server.connect(cookie=cookie) await peer.send_json({'v': 1, 'type': 'join', 'ticket': proof}) await closed(peer, 1008) @@ -117,16 +123,18 @@ async def test_machine_ticket_cannot_mix_cookies_resume_or_observer_mode(flight_ assert (await snapshot(valid))['total_players'] == 1 -async def test_private_machine_reuses_existing_anonymous_public_projection(flight_server, oauth): - app, key, subject, _ = oauth +async def test_private_machine_reuses_existing_anonymous_public_projection( + flight_server, signed_browser +): + app, key, subject, _ = signed_browser await make_private(app, key, subject) - proof = await ticket(flight_server, oauth) + proof = await ticket(flight_server, signed_browser) signed, hello = await machine(flight_server, proof) assert hello['self']['subject_id'] is None and hello['self']['home_body']['private'] guest, _ = await flight_server.join() public = await snapshot(guest, lambda body: body['total_players'] == 2) encoded = json.dumps(public) - assert subject not in encoded and 'oauth-owner' not in encoded + assert subject not in encoded and 'key-owner' not in encoded assert 'home_body' not in encoded and 'home_position' not in encoded assert hello['self']['home_body']['id'] not in encoded assert signed.close_code is None @@ -134,16 +142,16 @@ async def test_private_machine_reuses_existing_anonymous_public_projection(fligh @pytest.mark.parametrize('before_join', [False, True]) async def test_machine_revocation_is_enforced_before_join_and_while_connected( - flight_server, oauth, before_join + flight_server, signed_browser, before_join ): - proof = await ticket(flight_server, oauth) + proof = await ticket(flight_server, signed_browser) if before_join: - await revoke(oauth) + await revoke(signed_browser) peer = await flight_server.connect() await peer.send_json({'v': 1, 'type': 'join', 'ticket': proof}) else: peer, _ = await machine(flight_server, proof) await snapshot(peer) - await revoke(oauth) + await revoke(signed_browser) await closed(peer, 4013, timeout=2.5) await active_count(flight_server, 0) diff --git a/tests/test_flight_observer.py b/tests/test_flight_observer.py index 24b07534..69a827a3 100644 --- a/tests/test_flight_observer.py +++ b/tests/test_flight_observer.py @@ -14,17 +14,17 @@ controls, flight_server, make_private, - oauth, player, receive_until, + signed_browser, snapshot, ) -from msg.transports.oauth_http import csrf +from msg.transports.session_http import csrf # Imported fixtures are deliberate: this is the same real PostgreSQL-backed # OAuth and TCP harness used by the driving acceptance suite. -__all__ = ['flight_server', 'oauth'] +__all__ = ['flight_server', 'signed_browser'] async def observe(flight, *, cookie=None): @@ -104,11 +104,11 @@ async def test_observer_cannot_move_attack_relocate_or_join(flight_server, packe async def test_observer_private_pilot_has_only_existing_public_game_projection( - flight_server, oauth + flight_server, signed_browser ): - app, key, subject, _ = oauth + app, key, subject, _ = signed_browser await make_private(app, key, subject) - cookie = await browser_login(oauth) + cookie = await browser_login(signed_browser) signed, secret = await flight_server.join(cookie=cookie) observer, hello = await observe(flight_server) public = await observed(observer, lambda body: body['total_players'] == 1) @@ -130,7 +130,7 @@ async def test_observer_private_pilot_has_only_existing_public_game_projection( assert pilot['subject_id'] is None and pilot['handle'].startswith('pilot-') assert pilot == player(private_state, secret['self']['id']) encoded = json.dumps([hello, public]) - assert subject not in encoded and 'oauth-owner' not in encoded + assert subject not in encoded and 'key-owner' not in encoded assert 'home_position' not in encoded and 'home_body' not in encoded assert secret['self']['home_body']['id'] not in encoded assert len(flight_server.hub.world.ships) == 1 @@ -138,14 +138,16 @@ async def test_observer_private_pilot_has_only_existing_public_game_projection( @pytest.mark.parametrize('mutation', ['logout', 'runtime', 'quarantine']) -async def test_observer_current_authority_is_rechecked_without_ship(flight_server, oauth, mutation): - app, _, _, http = oauth - cookie = await browser_login(oauth) +async def test_observer_current_authority_is_rechecked_without_ship( + flight_server, signed_browser, mutation +): + app, _, _, http = signed_browser + cookie = await browser_login(signed_browser) observer, _ = await observe(flight_server, cookie=cookie) await observed(observer) if mutation == 'logout': logout = await http.post( - '/oauth/logout', json={'csrf': csrf(cookie)}, headers={'Origin': 'http://testserver'} + '/session/logout', json={'csrf': csrf(cookie)}, headers={'Origin': 'http://testserver'} ) assert logout.status_code == 200 else: @@ -175,8 +177,10 @@ async def test_observer_retains_origin_and_bounded_admission(flight_server, monk assert flight_server.hub.world.active_count == 1 -async def test_observer_receives_current_public_geometry_after_acl_removal(flight_server, oauth): - app, key, subject, _ = oauth +async def test_observer_receives_current_public_geometry_after_acl_removal( + flight_server, signed_browser +): + app, key, subject, _ = signed_browser observer, hello = await observe(flight_server) assert subject in {well['id'] for well in hello['gravity']['wells']} # An empty observed room stays on the same public geometry across its 2s diff --git a/tests/test_flight_websocket.py b/tests/test_flight_websocket.py index c7ff2aee..dff57764 100644 --- a/tests/test_flight_websocket.py +++ b/tests/test_flight_websocket.py @@ -11,14 +11,14 @@ import aiohttp import pytest import uvicorn -from test_oauth import browser_login, oauth as real_oauth +from test_browser_sessions import browser_login, signed_browser as real_browser from test_service import NOW, call from msg.core.codec import wire from msg.transports.http import create_app -from msg.transports.oauth_http import csrf +from msg.transports.session_http import csrf -oauth = real_oauth +signed_browser = real_browser @dataclass @@ -53,8 +53,8 @@ async def join(self, *, cookie=None, resume=None): @pytest.fixture -async def flight_server(oauth, request): - service, _, _, _ = oauth +async def flight_server(signed_browser, request): + service, _, _, _ = signed_browser service.settings = replace(service.settings, service_url='http://testserver') router = create_app(service) listener = socket.socket() @@ -252,14 +252,16 @@ async def test_two_network_guests_share_movement_across_regions_and_disconnect(f @pytest.mark.asyncio -async def test_network_browser_identity_and_spawn_reject_forged_subject(flight_server, oauth): +async def test_network_browser_identity_and_spawn_reject_forged_subject( + flight_server, signed_browser +): flight = flight_server - app, _, subject, _ = oauth - cookie = await browser_login(oauth) + app, _, subject, _ = signed_browser + cookie = await browser_login(signed_browser) signed, hello = await flight.join(cookie=cookie) assert not hello['self']['guest'] assert hello['self']['subject_id'] == subject - assert hello['self']['handle'].removeprefix('@') == 'oauth-owner' + assert hello['self']['handle'].removeprefix('@') == 'key-owner' from msg.transports.universe import subject_position home_position = await subject_position(app, subject) @@ -280,11 +282,13 @@ async def test_network_browser_identity_and_spawn_reject_forged_subject(flight_s @pytest.mark.asyncio -async def test_network_private_identity_and_home_are_never_disclosed_to_peers(flight_server, oauth): +async def test_network_private_identity_and_home_are_never_disclosed_to_peers( + flight_server, signed_browser +): flight = flight_server - app, key, subject, _ = oauth + app, key, subject, _ = signed_browser await make_private(app, key, subject) - cookie = await browser_login(oauth) + cookie = await browser_login(signed_browser) with pytest.raises(aiohttp.WSServerHandshakeError) as rejected: await flight.connect(headers={'Cookie': f'msg_session={cookie}; msg_session={cookie}'}) assert rejected.value.status == 403 @@ -292,7 +296,7 @@ async def test_network_private_identity_and_home_are_never_disclosed_to_peers(fl signed, secret = await flight.join(cookie=cookie) assert not secret['self']['guest'] assert secret['self']['subject_id'] is None - assert 'oauth-owner' not in json.dumps(secret) + assert 'key-owner' not in json.dumps(secret) assert subject not in json.dumps(secret) home = secret['self']['home_body'] assert home['private'] is True and home['radius'] == 3 @@ -310,7 +314,7 @@ async def test_network_private_identity_and_home_are_never_disclosed_to_peers(fl ) peer_view = await snapshot(guest, lambda body: player(body, secret['self']['id']) is not None) encoded = json.dumps(peer_view) - assert subject not in encoded and 'oauth-owner' not in encoded + assert subject not in encoded and 'key-owner' not in encoded assert 'home_position' not in encoded and 'home_body' not in encoded await signed.close() await active_count(flight, 1) @@ -366,22 +370,22 @@ async def test_network_rejects_unsafe_handshakes_and_untrusted_frames(flight_ser @pytest.mark.asyncio @pytest.mark.parametrize('mutation', ['logout', 'parent', 'expiry', 'runtime', 'quarantine']) async def test_network_current_authority_loss_closes_existing_socket( - flight_server, oauth, mutation + flight_server, signed_browser, mutation ): flight = flight_server - app, key, subject, http = oauth + app, key, subject, http = signed_browser await make_private(app, key, subject) - cookie = await browser_login(oauth) + cookie = await browser_login(signed_browser) peer, hello = await flight.join(cookie=cookie) guest, guest_hello = await flight.join() await snapshot(peer) if mutation == 'logout': logout = await http.post( - '/oauth/logout', json={'csrf': csrf(cookie)}, headers={'Origin': 'http://testserver'} + '/session/logout', json={'csrf': csrf(cookie)}, headers={'Origin': 'http://testserver'} ) assert logout.status_code == 200, logout.text elif mutation == 'expiry': - app._oauth_clock[0] = NOW + timedelta(seconds=app.settings.oauth.session_ttl + 1) + app._session_clock[0] = NOW + timedelta(seconds=app.settings.browser.session_ttl + 1) else: async with app.metadata.transaction(write=True) as tx: if mutation == 'parent': @@ -447,9 +451,11 @@ async def fly_near(peer, state, source_id, target_id): @pytest.mark.asyncio -async def test_network_laser_skills_cooldowns_regeneration_and_resume(flight_server, oauth): +async def test_network_laser_skills_cooldowns_regeneration_and_resume( + flight_server, signed_browser +): flight = flight_server - cookie = await browser_login(oauth) + cookie = await browser_login(signed_browser) attacker, hello1 = await flight.join(cookie=cookie) target, hello2 = await flight.join() source_id, target_id = hello1['self']['id'], hello2['self']['id'] @@ -580,13 +586,13 @@ async def test_network_laser_skills_cooldowns_regeneration_and_resume(flight_ser @pytest.mark.asyncio async def test_network_profile_visibility_changes_rekey_home_and_preserve_battle_state( - flight_server, oauth + flight_server, signed_browser ): flight = flight_server - app, key, subject, _ = oauth + app, key, subject, _ = signed_browser async with app.metadata.transaction(write=False) as tx: original_mode = (await tx.resource(subject)).mode - cookie = await browser_login(oauth) + cookie = await browser_login(signed_browser) signed, public = await flight.join(cookie=cookie) guest, anonymous = await flight.join() signed_id, guest_id = public['self']['id'], anonymous['self']['id'] @@ -638,7 +644,7 @@ async def test_network_profile_visibility_changes_rekey_home_and_preserve_battle } private_id = private['self']['id'] hidden = await snapshot(guest, lambda body: player(body, private_id) is not None) - assert subject not in json.dumps(hidden) and 'oauth-owner' not in json.dumps(hidden) + assert subject not in json.dumps(hidden) and 'key-owner' not in json.dumps(hidden) assert player(hidden, signed_id) is None assert subject not in {well['id'] for well in hidden['gravity']['wells']} assert subject not in {anchor['id'] for anchor in hidden['collectibles'].get('anchors', [])} @@ -670,13 +676,13 @@ async def test_network_profile_visibility_changes_rekey_home_and_preserve_battle @pytest.mark.asyncio async def test_normal_twenty_hz_controls_survive_slow_revalidation_and_keep_actions( - flight_server, oauth, monkeypatch + flight_server, signed_browser, monkeypatch ): - """A slow legitimate OAuth check must not turn normal movement into a flood.""" + """A slow legitimate key-session check must not turn normal movement into a flood.""" from msg.transports import flight_space flight = flight_server - cookie = await browser_login(oauth) + cookie = await browser_login(signed_browser) signed, hello = await flight.join(cookie=cookie) guest, anonymous = await flight.join() await snapshot(signed, lambda body: body['total_players'] == 2) @@ -685,7 +691,7 @@ async def test_normal_twenty_hz_controls_survive_slow_revalidation_and_keep_acti entered = asyncio.Event() release = asyncio.Event() delayed = False - original_credentials = flight.hub.oauth.browser_credentials + original_credentials = flight.hub.sessions.browser_credentials original_require = flight_space.require rejection_codes = [] close_codes = [] @@ -735,7 +741,7 @@ async def collect(peer, ship_id): length = math.hypot(*delta) laser_directions[ship_id] = [value / length for value in delta] - monkeypatch.setattr(flight.hub.oauth, 'browser_credentials', delayed_credentials) + monkeypatch.setattr(flight.hub.sessions, 'browser_credentials', delayed_credentials) monkeypatch.setattr(flight_space, 'require', recorded_require) collectors = [ asyncio.create_task(collect(peer, ship_id)) diff --git a/tests/test_game_bot_end_to_end.py b/tests/test_game_bot_end_to_end.py index 0da4b2d2..8a454f3f 100644 --- a/tests/test_game_bot_end_to_end.py +++ b/tests/test_game_bot_end_to_end.py @@ -13,7 +13,14 @@ import aiohttp import pytest from aiohttp import web -from test_flight_websocket import controls, flight_server, oauth, player, receive_until, snapshot +from test_flight_websocket import ( + controls, + flight_server, + player, + receive_until, + signed_browser, + snapshot, +) from test_game_webhook_delivery import tls_contexts from msg.core.codec import b64, loads, wire @@ -22,14 +29,14 @@ from msg.workers.effects import EffectWorker from msg.workers.webhook import PublicResolver, WebhookSender, validate_endpoint, verify_delivery -__all__ = ['flight_server', 'oauth'] +__all__ = ['flight_server', 'signed_browser'] @pytest.mark.asyncio async def test_signed_bot_ws_events_reach_hmac_verified_tls_receiver( - flight_server, oauth, tmp_path, monkeypatch + flight_server, signed_browser, tmp_path, monkeypatch ): - app, key, subject, _ = oauth + app, key, subject, _ = signed_browser flight = flight_server secret = secrets.token_bytes(32) server_tls, client_tls = tls_contexts(tmp_path) diff --git a/tests/test_game_bot_example.py b/tests/test_game_bot_example.py index 7ce49f5e..3ea39500 100644 --- a/tests/test_game_bot_example.py +++ b/tests/test_game_bot_example.py @@ -9,9 +9,9 @@ import aiohttp import pytest -from test_flight_websocket import active_count, flight_server, oauth +from test_flight_websocket import active_count, flight_server, signed_browser -__all__ = ['flight_server', 'oauth'] +__all__ = ['flight_server', 'signed_browser'] @pytest.mark.asyncio diff --git a/tests/test_home_cache.py b/tests/test_home_cache.py index 55f0715d..068cb0fb 100644 --- a/tests/test_home_cache.py +++ b/tests/test_home_cache.py @@ -5,7 +5,7 @@ import httpx import pytest -from test_oauth import browser_login, oauth as oauth +from test_browser_sessions import browser_login, signed_browser as signed_browser from test_service import call, register from msg.core.errors import Failure @@ -287,16 +287,16 @@ async def test_home_conditional_get_validates_first_and_cookie_never_reuses_it(c @pytest.mark.asyncio -async def test_browser_quota_is_live_and_never_enters_anonymous_snapshot(oauth): +async def test_browser_quota_is_live_and_never_enters_anonymous_snapshot(signed_browser): from msg.plugins.public_board import quota - app, _, subject, http = oauth + app, _, subject, http = signed_browser asgi = http._transport.app cache = asgi.state.home_cache try: await warm(cache) assert cache.snapshot.board.get('quota') is None - await browser_login(oauth) + await browser_login(signed_browser) for count in (3, 4): async with app.metadata.transaction(write=True) as tx: record = quota(None, app.clock()) diff --git a/tests/test_home_channels.py b/tests/test_home_channels.py index 2beef7bd..0966ce81 100644 --- a/tests/test_home_channels.py +++ b/tests/test_home_channels.py @@ -4,7 +4,7 @@ import httpx import pytest -from test_oauth import oauth as oauth +from test_browser_sessions import signed_browser as signed_browser from test_service import call, register from msg.transports.http import create_app @@ -91,12 +91,12 @@ def test_channel_names_and_paths_are_escaped_and_failure_is_honest(): @pytest.mark.asyncio -async def test_signed_in_home_channels_follow_current_group_access(oauth): - from test_oauth import browser_login +async def test_signed_in_home_channels_follow_current_group_access(signed_browser): + from test_browser_sessions import browser_login from msg.plugins.identity import set_member - app, key, subject, http = oauth + app, key, subject, http = signed_browser assert '/admins' not in (await http.get('/')).text async with app.metadata.transaction(write=True) as tx: await set_member(tx, 'g_admins', subject, 'member', joined_at=app.clock()) @@ -138,7 +138,7 @@ async def test_signed_in_home_channels_follow_current_group_access(oauth): assert channels['/admins']['posts'] == 1 assert '/private' not in channels assert not any(channel['name'].startswith('dm-') for channel in channels.values()) - await browser_login(oauth) + await browser_login(signed_browser) for accept in ('text/html', 'text/markdown'): home = await http.get('/', headers={'Accept': accept}) assert '/admins' in home.text and '2770' in home.text diff --git a/tests/test_hosting_runtime.py b/tests/test_hosting_runtime.py index be864304..33e74bc4 100644 --- a/tests/test_hosting_runtime.py +++ b/tests/test_hosting_runtime.py @@ -25,7 +25,7 @@ 'identities', 'memberships', 'credentials', - 'oauth_states', + 'credential_states', 'certificates', 'settings', 'share_grants', diff --git a/tests/test_key_identity_migration.py b/tests/test_key_identity_migration.py new file mode 100644 index 00000000..af41593c --- /dev/null +++ b/tests/test_key_identity_migration.py @@ -0,0 +1,82 @@ +"""Retire login data atomically without removing native identity or API limits.""" + +import sqlite3 + +import pytest + +from msg.storage.key_identity_migration import migrate_key_identity + + +@pytest.fixture(params=['sqlite', 'postgres']) +def database(request): + postgres = request.param == 'postgres' + if postgres: + import psycopg + + conn = psycopg.connect(request.getfixturevalue('pg_dsn'), autocommit=True) + else: + conn = sqlite3.connect(':memory:', isolation_level=None) + for table in ('credential_states', 'oauth_states'): + conn.execute( + f'CREATE TABLE {table} (id TEXT PRIMARY KEY, kind TEXT, expires TEXT, body TEXT)' + ) + conn.execute('CREATE TABLE identities (id TEXT PRIMARY KEY)') + conn.execute('CREATE TABLE content (id TEXT PRIMARY KEY, body TEXT)') + conn.execute('CREATE TABLE login_bindings (id TEXT PRIMARY KEY)') + conn.execute("INSERT INTO identities VALUES ('u_keep')") + conn.execute("INSERT INTO content VALUES ('p_keep', 'retained content')") + conn.execute("INSERT INTO login_bindings VALUES ('old-provider-binding')") + conn.execute("INSERT INTO oauth_states VALUES ('api:keep','api','2030-01-01','native-source')") + for kind in ('session', 'family', 'pending', 'code', 'login'): + conn.execute( + f"INSERT INTO oauth_states VALUES ('{kind}:old','{kind}','2030-01-01','retired')" + ) + yield conn, postgres + conn.close() + + +def test_migration_removes_only_retired_login_state_and_is_idempotent(database): + conn, postgres = database + for _ in range(2): + conn.execute('BEGIN') + migrate_key_identity(conn, postgres=postgres) + conn.commit() + assert conn.execute('SELECT id,body FROM credential_states').fetchall() == [ + ('api:keep', 'native-source') + ] + assert conn.execute('SELECT id FROM identities').fetchall() == [('u_keep',)] + assert conn.execute('SELECT id,body FROM content').fetchall() == [ + ('p_keep', 'retained content') + ] + if postgres: + assert conn.execute( + "SELECT to_regclass('public.oauth_states'), to_regclass('public.login_bindings')" + ).fetchone() == (None, None) + else: + assert ( + conn.execute( + "SELECT name FROM sqlite_master WHERE name IN ('oauth_states','login_bindings')" + ).fetchall() + == [] + ) + + +def test_existing_native_source_is_not_overwritten_during_migration(database): + conn, postgres = database + conn.execute( + "INSERT INTO credential_states VALUES ('api:keep','api','2030-01-01','current-source')" + ) + conn.execute('BEGIN') + migrate_key_identity(conn, postgres=postgres) + conn.commit() + assert conn.execute('SELECT body FROM credential_states').fetchall() == [('current-source',)] + + +def test_failed_upgrade_rolls_back_removed_tables_and_copied_source(database): + conn, postgres = database + conn.execute('BEGIN') + migrate_key_identity(conn, postgres=postgres) + conn.rollback() + assert conn.execute('SELECT COUNT(*) FROM oauth_states').fetchone() == (6,) + assert conn.execute('SELECT id FROM login_bindings').fetchone() == ('old-provider-binding',) + assert conn.execute('SELECT COUNT(*) FROM credential_states').fetchone() == (0,) diff --git a/tests/test_ledger_migration_json.py b/tests/test_ledger_migration_json.py index 32e14ad2..9439487e 100644 --- a/tests/test_ledger_migration_json.py +++ b/tests/test_ledger_migration_json.py @@ -71,15 +71,18 @@ async def test_migration_refuses_oauth_identity_references(installed, reference) encoded = json.dumps(body) async with app.metadata.transaction(write=True) as tx: tx.execute( - 'INSERT INTO oauth_states VALUES (?,?,?,?)', - ('migration-oauth', 'session', '2999-01-01T00:00:00Z', encoded), + 'INSERT INTO credential_states VALUES (?,?,?,?)', + ('migration-signed_browser', 'session', '2999-01-01T00:00:00Z', encoded), write=True, ) with pytest.raises(RuntimeError, match='active reference'): PostgresMetadataStore(app.settings.server.postgres_dsn) async with app.metadata.transaction(write=False) as tx: assert ( - tx.one('SELECT body FROM oauth_states WHERE id=?', ('migration-oauth',))[0] == encoded + tx.one('SELECT body FROM credential_states WHERE id=?', ('migration-signed_browser',))[ + 0 + ] + == encoded ) assert tx.one('SELECT COUNT(*) FROM identities WHERE id IN (?,?)', escrows)[0] == 2 assert tx.one('SELECT COUNT(*) FROM ledger_accounts WHERE id IN (?,?)', escrows)[0] == 0 diff --git a/tests/test_login_flow.py b/tests/test_login_flow.py deleted file mode 100644 index a1c08a37..00000000 --- a/tests/test_login_flow.py +++ /dev/null @@ -1,107 +0,0 @@ -"""真实 PostgreSQL 验证登录暂态的单次、浏览器绑定和失败次数。""" - -from datetime import timedelta - -import pytest - -from msg.core.errors import Failure -from msg.security.email_login import begin_email, verify_email -from msg.security.login_flow import consume_flow, receive_callback, start_flow -from msg.security.oauth import secret - - -async def test_oauth_callback_does_not_create_account_and_encrypts_code(installed): - app, _ = installed - browser, code = secret(), secret() - async with app.metadata.transaction(write=True) as tx: - before = { - table: tx.one('SELECT COUNT(*) FROM ' + table)[0] - for table in ( - 'identities', - 'credentials', - 'identity_keys', - 'encryption_subkeys', - 'audit', - ) - } - state, challenge, nonce = start_flow(app, tx, 'google', browser) - assert len(challenge) == len(nonce) == 43 - receive_callback(app, tx, state, browser, 'google', code) - rows = tx.rows("SELECT body FROM oauth_states WHERE kind='login-flow'") - assert len(rows) == 1 and code not in rows[0][0] - after = {table: tx.one('SELECT COUNT(*) FROM ' + table)[0] for table in before} - assert after == before - async with app.metadata.transaction(write=True) as tx: - returned = consume_flow(app, tx, state, browser) - assert returned['code'] == code - assert returned['provider'] == 'google' - async with app.metadata.transaction(write=True) as tx: - with pytest.raises(Failure, match='invalid_login_transaction'): - consume_flow(app, tx, state, browser) - - -async def test_oauth_state_rejects_wrong_browser_provider_and_replayed_callback(installed): - app, _ = installed - browser = secret() - async with app.metadata.transaction(write=True) as tx: - state, _, _ = start_flow(app, tx, 'github', browser) - with pytest.raises(Failure, match='invalid_login_transaction'): - receive_callback(app, tx, state, secret(), 'github', 'code') - with pytest.raises(Failure, match='invalid_login_transaction'): - receive_callback(app, tx, state, browser, 'google', 'code') - receive_callback(app, tx, state, browser, 'github', 'code') - with pytest.raises(Failure, match='invalid_login_transaction'): - receive_callback(app, tx, state, browser, 'github', 'code') - - -async def test_only_google_can_start_oauth_registration(installed): - app, _ = installed - async with app.metadata.transaction(write=True) as tx: - for provider in ('github', 'chatgpt'): - with pytest.raises(Failure, match='login_registration_forbidden'): - start_flow(app, tx, provider, secret(), mode='register') - with pytest.raises(Failure, match='authentication_required'): - start_flow(app, tx, 'google', secret(), mode='bind') - - -async def test_email_code_single_use_and_browser_bound(installed): - app, _ = installed - browser = secret() - async with app.metadata.transaction(write=True) as tx: - challenge, code = begin_email(app, tx, 'Alice@example.org', browser, {'mode': 'login'}) - rows = tx.rows("SELECT body FROM oauth_states WHERE kind='login-email'") - assert code not in rows[0][0] - async with app.metadata.transaction(write=True) as tx: - assert verify_email(app, tx, challenge, secret(), code)[1] == 'invalid_login_challenge' - body, error = verify_email(app, tx, challenge, browser, code) - assert error is None and body['sub'] == 'Alice@example.org' - async with app.metadata.transaction(write=True) as tx: - assert verify_email(app, tx, challenge, browser, code)[1] == 'invalid_login_challenge' - - -async def test_email_five_bad_guesses_remain_blocked_after_commit(installed): - app, _ = installed - browser = secret() - async with app.metadata.transaction(write=True) as tx: - challenge, code = begin_email(app, tx, 'alice@example.org', browser, {}) - for _ in range(5): - async with app.metadata.transaction(write=True) as tx: - assert verify_email(app, tx, challenge, browser, 'wrong')[1] == 'invalid_login_code' - async with app.metadata.transaction(write=True) as tx: - assert verify_email(app, tx, challenge, browser, code)[1] == 'invalid_login_challenge' - with pytest.raises(Failure, match='slow_down'): - begin_email(app, tx, 'alice@example.org', browser, {}) - - -async def test_email_and_oauth_expire(installed): - app, _ = installed - browser = secret() - async with app.metadata.transaction(write=True) as tx: - challenge, code = begin_email(app, tx, 'alice@example.org', browser, {}) - state, _, _ = start_flow(app, tx, 'google', browser) - now = app.clock() - app.clock = lambda: now + timedelta(seconds=601) - async with app.metadata.transaction(write=True) as tx: - assert verify_email(app, tx, challenge, browser, code)[1] == 'invalid_login_challenge' - with pytest.raises(Failure, match='invalid_grant'): - receive_callback(app, tx, state, browser, 'google', 'code') diff --git a/tests/test_login_providers.py b/tests/test_login_providers.py deleted file mode 100644 index 056c9088..00000000 --- a/tests/test_login_providers.py +++ /dev/null @@ -1,544 +0,0 @@ -"""Real signed identity tokens over bounded, pinned mock provider HTTP exchanges.""" - -import base64 -import hashlib -import json -from collections import Counter -from dataclasses import asdict, dataclass -from datetime import UTC, datetime, timedelta -from urllib.parse import parse_qs, urlsplit - -import httpx -import jwt -import pytest -from cryptography.hazmat.primitives.asymmetric import rsa - -from msg.core.codec import b64 -from msg.core.errors import Failure -from msg.transports import login_providers as providers - -NOW = datetime(2026, 10, 5, tzinfo=UTC) -VERIFIER = 'a' * 43 -NONCE = 'original-browser-nonce' -CALLBACK = 'https://msg.example/-/login/callback' -CODE = 'private-authorization-code' - - -@pytest.fixture(autouse=True) -def clear_public_caches(): - providers._jwks_cache.clear() - providers._jwks_locks.clear() - providers._discovery_expires_at = 0 - providers._discovery_lock = None - - -@pytest.fixture(scope='module') -def signing_keys(): - return [rsa.generate_private_key(public_exponent=65537, key_size=2048) for _ in range(2)] - - -@pytest.fixture -def secret_file(tmp_path): - path = tmp_path / 'provider-secret' - path.write_text('private-provider-secret\n') - path.chmod(0o600) - return path - - -def public_jwk(key, kid='first'): - return json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(key.public_key())) | { - 'kid': kid, - 'use': 'sig', - 'alg': 'RS256', - 'key_ops': ['verify'], - } - - -def discovery(): - return { - 'issuer': providers.CHATGPT_ISSUER, - 'authorization_endpoint': providers._AUTHORIZATION['chatgpt'], - 'token_endpoint': providers._TOKEN['chatgpt'], - 'jwks_uri': providers._JWKS['chatgpt'], - 'id_token_signing_alg_values_supported': ['RS256'], - } - - -class Scenario: - def __init__(self, provider, keys, config): - self.provider, self.keys, self.config = provider, keys, config - self.claims = { - 'iss': providers.GOOGLE_ISSUER if provider == 'google' else providers.CHATGPT_ISSUER, - 'aud': config['client_id'], - 'sub': 'immutable-provider-subject', - 'iat': int(NOW.timestamp()) - 1, - 'exp': int(NOW.timestamp()) + 7200, - 'nonce': NONCE, - 'email': 'untrusted-profile@example.com', - } - self.signing_key = keys[0] - self.kid = 'first' - self.jwks = {'keys': [public_jwk(keys[0])]} - self.metadata = discovery() - self.user = {'id': 42, 'login': 'old-handle', 'email': 'profile@example.com'} - self.token_payload = None - self.responses = {} - self.requests = [] - self.cache_control = 'max-age=3600' - - def token(self): - return jwt.encode( - self.claims, self.signing_key, algorithm='RS256', headers={'kid': self.kid} - ) - - def handle(self, request): - self.requests.append(request) - url = str(request.url) - if url in self.responses: - response = self.responses[url] - if isinstance(response, Exception): - raise response - return response - if url == providers._DISCOVERY: - return httpx.Response( - 200, json=self.metadata, headers={'Cache-Control': self.cache_control} - ) - if url == providers._TOKEN[self.provider]: - payload = self.token_payload - if payload is None: - payload = ( - {'access_token': 'private-github-access', 'token_type': 'bearer'} - if self.provider == 'github' - else {'id_token': self.token()} - ) - return httpx.Response(200, json=payload) - if url == providers._JWKS.get(self.provider): - return httpx.Response( - 200, json=self.jwks, headers={'Cache-Control': self.cache_control} - ) - if url == 'https://api.github.com/user': - return httpx.Response(200, json=self.user) - pytest.fail('unpinned provider request') - - async def exchange(self, now=NOW, **overrides): - async with httpx.AsyncClient( - transport=httpx.MockTransport(self.handle), follow_redirects=True - ) as client: - identity = await providers.exchange_identity( - self.provider, - self.config, - overrides.get('code', CODE), - overrides.get('verifier', VERIFIER), - overrides.get('nonce', NONCE), - overrides.get('redirect_uri', CALLBACK), - now, - client=client, - ) - assert not client.is_closed - return identity - - def counts(self): - return Counter(str(request.url) for request in self.requests) - - -def scenario(provider, keys, secret_file): - config = {'client_id': 'oaiapp_own_client' if provider == 'chatgpt' else 'registered-client'} - if provider != 'chatgpt': - config['credential_file'] = secret_file - return Scenario(provider, keys, config) - - -@pytest.mark.parametrize('provider', ['google', 'github', 'chatgpt']) -def test_authorization_is_pinned_and_s256(provider, secret_file): - config = {'client_id': 'oaiapp_own' if provider == 'chatgpt' else 'registered-client'} - if provider != 'chatgpt': - config['credential_file'] = secret_file - url = providers.authorization_url(provider, config, 'original-state', VERIFIER, NONCE, CALLBACK) - parsed = urlsplit(url) - assert parsed._replace(query='').geturl() == providers._AUTHORIZATION[provider] - params = parse_qs(parsed.query) - assert params['state'] == ['original-state'] - assert params['code_challenge'] == [b64(hashlib.sha256(VERIFIER.encode()).digest())] - assert params['code_challenge_method'] == ['S256'] - assert params['redirect_uri'] == [CALLBACK] - assert VERIFIER not in url - if provider == 'github': - assert params['allow_signup'] == ['false'] - assert params['scope'] == ['read:user'] - assert 'nonce' not in params - else: - assert params['nonce'] == [NONCE] - assert params['scope'] == ['openid profile email'] - assert 'offline_access' not in url - - -@pytest.mark.parametrize('provider', ['google', 'github', 'chatgpt']) -async def test_verified_identity_and_exchange_body(provider, signing_keys, secret_file): - flow = scenario(provider, signing_keys, secret_file) - identity = await flow.exchange() - assert identity.provider == provider - assert identity.subject == ('42' if provider == 'github' else flow.claims['sub']) - assert identity.stable_id.startswith('sha256:') - assert len(identity.stable_id) == 71 - assert identity.client_id == (flow.config['client_id'] if provider == 'chatgpt' else None) - assert ( - not {'email', 'login', 'id_token', 'access_token', 'refresh_token'} - & asdict(identity).keys() - ) - token_request = next(request for request in flow.requests if request.method == 'POST') - params = parse_qs(token_request.content.decode()) - assert params['code_verifier'] == [VERIFIER] - assert params['redirect_uri'] == [CALLBACK] - assert params['code'] == [CODE] - assert params['grant_type'] == ['authorization_code'] - assert 'authorization' not in token_request.headers - if provider == 'chatgpt': - assert 'client_secret' not in params - else: - assert params['client_secret'] == ['private-provider-secret'] - if provider == 'github': - user_request = flow.requests[-1] - assert user_request.headers['authorization'] == 'Bearer private-github-access' - assert user_request.headers['x-github-api-version'] == '2022-11-28' - for request in flow.requests: - assert request.headers['accept-encoding'] == 'identity' - assert request.extensions['timeout'] == {'connect': 5, 'read': 10, 'write': 10, 'pool': 5} - - -async def test_google_legacy_issuer_and_email_do_not_change_mapping(signing_keys, secret_file): - flow = scenario('google', signing_keys, secret_file) - original = await flow.exchange() - flow.claims.update(iss='accounts.google.com', email='renamed@example.com') - renamed = await flow.exchange() - assert renamed.issuer == providers.GOOGLE_ISSUER - assert renamed.stable_id == original.stable_id - assert flow.counts()[providers._JWKS['google']] == 1 - - -async def test_github_rename_preserves_numeric_identity(signing_keys, secret_file): - flow = scenario('github', signing_keys, secret_file) - original = await flow.exchange() - flow.user.update(login='new-handle', email='changed@example.com') - renamed = await flow.exchange() - assert original == renamed - assert flow.counts()['https://api.github.com/user'] == 2 - flow.user['id'] = 43 - assert (await flow.exchange()).stable_id != original.stable_id - - -@pytest.mark.parametrize('subject', [True, False, None, '42', 0, -1, 1.5, 2**63]) -async def test_github_rejects_non_numeric_stable_subject(subject, signing_keys, secret_file): - flow = scenario('github', signing_keys, secret_file) - flow.user['id'] = subject - with pytest.raises(Failure, match='^login_provider_invalid_identity$'): - await flow.exchange() - - -@pytest.mark.parametrize('token_type', [None, True, [], 'mac']) -async def test_github_rejects_non_bearer_token_type(token_type, signing_keys, secret_file): - flow = scenario('github', signing_keys, secret_file) - flow.token_payload = {'access_token': 'private-token', 'token_type': token_type} - with pytest.raises(Failure, match='^login_provider_invalid_response$'): - await flow.exchange() - assert len(flow.requests) == 1 - - -async def test_chatgpt_basic_secret_only_header_and_identity_only(signing_keys, secret_file): - secret_file.write_text('a secret:+/ü\n') - flow = scenario('chatgpt', signing_keys, secret_file) - flow.config.update(credential_file=secret_file, token_auth_method='client_secret_basic') - identity = await flow.exchange() - token_request = next(request for request in flow.requests if request.method == 'POST') - assert base64.b64decode(token_request.headers['authorization'].removeprefix('Basic ')) == ( - b'oaiapp_own_client:a+secret%3A%2B%2F%C3%BC' - ) - assert 'client_secret' not in parse_qs(token_request.content.decode()) - assert identity.subject == flow.claims['sub'] - - -async def test_chatgpt_mapping_includes_own_client_id(signing_keys, secret_file): - flow = scenario('chatgpt', signing_keys, secret_file) - first = await flow.exchange() - flow.config['client_id'] = flow.claims['aud'] = 'oaiapp_another_own_client' - second = await flow.exchange() - assert second.stable_id != first.stable_id - - -@pytest.mark.parametrize( - ('claim', 'value'), - [ - ('iss', 'https://attacker.example'), - ('aud', 'another-client'), - ('nonce', 'other-browser-nonce'), - ('exp', int(NOW.timestamp())), - ('exp', True), - ('exp', int(NOW.timestamp()) + 0.5), - ('iat', int(NOW.timestamp()) + 6), - ('iat', True), - ('sub', ''), - ('sub', '界'), - ('sub', 's' * 256), - ('sub', 42), - ('aud', ['registered-client', 'other-client']), - ('azp', 'another-client'), - ('nbf', int(NOW.timestamp()) + 1), - ], -) -@pytest.mark.parametrize('provider', ['google', 'chatgpt']) -async def test_rejects_signed_invalid_claims(provider, claim, value, signing_keys, secret_file): - flow = scenario(provider, signing_keys, secret_file) - if claim == 'aud' and isinstance(value, list): - value = [flow.config['client_id'], 'other-client'] - flow.claims[claim] = value - with pytest.raises(Failure, match='^login_provider_invalid_token$') as error: - await flow.exchange() - assert CODE not in str(error.value) - assert error.value.details is None - - -@pytest.mark.parametrize('claim', ['iss', 'aud', 'nonce', 'exp', 'iat', 'sub']) -async def test_required_claims(claim, signing_keys, secret_file): - flow = scenario('google', signing_keys, secret_file) - del flow.claims[claim] - with pytest.raises(Failure, match='^login_provider_invalid_token$'): - await flow.exchange() - - -async def test_multi_audience_requires_matching_azp(signing_keys, secret_file): - flow = scenario('google', signing_keys, secret_file) - flow.claims.update(aud=[flow.config['client_id'], 'other-client'], azp=flow.config['client_id']) - assert (await flow.exchange()).subject == flow.claims['sub'] - - -@pytest.mark.parametrize('algorithm', ['none', 'HS256', 'forged', 'missing', 'oversize']) -async def test_rejects_unsafe_or_forged_tokens(algorithm, signing_keys, secret_file): - flow = scenario('google', signing_keys, secret_file) - if algorithm == 'forged': - token = jwt.encode( - flow.claims, signing_keys[1], algorithm='RS256', headers={'kid': 'first'} - ) - elif algorithm == 'missing': - token = None - elif algorithm == 'oversize': - token = 'a' * (16 * 1024 + 1) - else: - token = jwt.encode( - flow.claims, - '' if algorithm == 'none' else 'x' * 32, - algorithm=algorithm, - headers={'kid': 'first'}, - ) - flow.token_payload = {'id_token': token, 'access_token': 'private-ignored-token'} - with pytest.raises(Failure, match='^login_provider_invalid_token$'): - await flow.exchange() - if algorithm != 'forged': - assert flow.counts()[providers._JWKS['google']] == 0 - - -async def test_unknown_key_refresh_is_bounded_and_rotation_recovers(signing_keys, secret_file): - flow = scenario('google', signing_keys, secret_file) - await flow.exchange() - flow.signing_key, flow.kid = signing_keys[1], 'second' - flow.jwks = {'keys': [public_jwk(signing_keys[1], 'second')]} - for offset in (1, 2, 29): - with pytest.raises(Failure, match='^login_provider_invalid_token$'): - await flow.exchange(NOW + timedelta(seconds=offset)) - assert flow.counts()[providers._JWKS['google']] == 1 - assert (await flow.exchange(NOW + timedelta(seconds=30))).subject == flow.claims['sub'] - assert flow.counts()[providers._JWKS['google']] == 2 - - -async def test_initial_unknown_key_does_not_double_fetch(signing_keys, secret_file): - flow = scenario('google', signing_keys, secret_file) - flow.kid = 'unknown' - with pytest.raises(Failure, match='^login_provider_invalid_token$'): - await flow.exchange() - assert flow.counts()[providers._JWKS['google']] == 1 - - -@pytest.mark.parametrize('control', ['max-age=1', 'no-cache', 'no-store']) -async def test_jwks_expiry_and_no_store(control, signing_keys, secret_file): - flow = scenario('google', signing_keys, secret_file) - flow.cache_control = control - await flow.exchange() - await flow.exchange(NOW + timedelta(seconds=1)) - assert flow.counts()[providers._JWKS['google']] == 2 - - -def test_cache_ttl_is_bounded(): - assert ( - providers._cache_seconds({'cache-control': 'public, max-age=99999999999999999999'}) == 3600 - ) - assert providers._cache_seconds({'cache-control': 'max-age=20'}) == 20 - assert providers._cache_seconds({}) == 300 - - -@pytest.mark.parametrize( - 'field', ['issuer', 'authorization_endpoint', 'token_endpoint', 'jwks_uri'] -) -async def test_openai_discovery_cannot_redirect_to_untrusted_origins( - field, signing_keys, secret_file -): - flow = scenario('chatgpt', signing_keys, secret_file) - flow.metadata[field] = 'http://127.0.0.1/private-service' - with pytest.raises(Failure, match='^login_provider_invalid_discovery$'): - await flow.exchange() - assert [str(request.url) for request in flow.requests] == [providers._DISCOVERY] - - -@pytest.mark.parametrize('algorithm', [None, ['HS256'], 'RS256']) -async def test_openai_discovery_requires_advertised_rs256(algorithm, signing_keys, secret_file): - flow = scenario('chatgpt', signing_keys, secret_file) - flow.metadata['id_token_signing_alg_values_supported'] = algorithm - with pytest.raises(Failure, match='^login_provider_invalid_discovery$'): - await flow.exchange() - - -@pytest.mark.parametrize( - ('response', 'error_code'), - [ - ( - httpx.Response(302, headers={'Location': 'https://attacker.example/token'}), - 'invalid_response', - ), - ( - httpx.Response(400, json={'error_description': 'private-code private-token'}), - 'invalid_response', - ), - (httpx.Response(503, text='private-token'), 'unavailable'), - (httpx.Response(200, text='private-token'), 'invalid_response'), - (httpx.Response(200, json=[]), 'invalid_response'), - (httpx.Response(200, content=b'{"id_token":"one","id_token":"two"}'), 'invalid_response'), - (httpx.Response(200, content=b'{"id_token":NaN}'), 'invalid_response'), - (httpx.Response(200, content=b'x' * 65537), 'invalid_response'), - (httpx.Response(200, json={}, headers={'content-length': '65537'}), 'invalid_response'), - (httpx.Response(200, json={}, headers={'content-length': 'bogus'}), 'invalid_response'), - (httpx.Response(200, json={}, headers={'content-encoding': 'br'}), 'invalid_response'), - (httpx.ReadTimeout('private-token private-code'), 'unavailable'), - ], -) -async def test_network_failures_are_bounded_and_sanitized( - response, error_code, signing_keys, secret_file -): - flow = scenario('google', signing_keys, secret_file) - flow.responses[providers._TOKEN['google']] = response - with pytest.raises(Failure, match='^login_provider_' + error_code + '$') as error: - await flow.exchange() - assert error.value.details is None - assert 'private-' not in str(error.value) - assert error.value.__cause__ is None - assert len(flow.requests) == 1 - - -class OversizedStream(httpx.AsyncByteStream): - async def __aiter__(self): - yield b' ' * 65536 - yield b'{}' - pytest.fail('oversized response was not stopped') - - -async def test_streamed_size_limit_applies_without_content_length(signing_keys, secret_file): - flow = scenario('google', signing_keys, secret_file) - flow.responses[providers._TOKEN['google']] = httpx.Response(200, stream=OversizedStream()) - with pytest.raises(Failure, match='^login_provider_invalid_response$'): - await flow.exchange() - - -@dataclass -class ProviderConfig: - client_id: str - credential_file: str | None = None - token_auth_method: str | None = None - - -async def test_config_dataclass_and_legacy_aliases(signing_keys, secret_file): - flow = scenario('google', signing_keys, secret_file) - flow.config = ProviderConfig('registered-client', str(secret_file), 'client_secret_post') - assert (await flow.exchange()).provider == 'google' - flow.config = { - 'client_id': 'registered-client', - 'secret_file': secret_file, - 'client_auth_method': 'client_secret_post', - } - assert (await flow.exchange()).provider == 'google' - - -@pytest.mark.parametrize( - 'config', - [ - {'client_id': 'codex-public-client'}, - {'client_id': 'oaiapp_own', 'token_auth_method': 'client_secret_post'}, - {'client_id': 'oaiapp_own', 'token_auth_method': 'client_secret_basic'}, - { - 'client_id': 'oaiapp_own', - 'credential_file': '/private/secret', - 'token_auth_method': 'none', - }, - {'client_id': 'oaiapp_own', 'token_auth_method': []}, - {'client_id': 'oaiapp_own', 'credential_file': 'one', 'secret_file': 'two'}, - { - 'client_id': 'oaiapp_own', - 'token_auth_method': 'none', - 'client_auth_method': 'client_secret_basic', - }, - ], -) -async def test_chatgpt_requires_own_registered_client_and_explicit_auth( - config, signing_keys, secret_file -): - flow = scenario('chatgpt', signing_keys, secret_file) - flow.config = config - with pytest.raises(Failure, match='^login_provider_configuration$'): - await flow.exchange() - assert flow.requests == [] - - -@pytest.mark.parametrize( - 'case', ['symlink', 'world-readable', 'oversize', 'empty', 'missing', 'directory'] -) -async def test_secrets_are_private_bounded_regular_files(case, signing_keys, secret_file, tmp_path): - flow = scenario('google', signing_keys, secret_file) - if case == 'symlink': - path = tmp_path / 'symlink' - path.symlink_to(secret_file) - flow.config['credential_file'] = path - elif case == 'world-readable': - secret_file.chmod(0o644) - elif case == 'oversize': - secret_file.write_bytes(b'x' * 8193) - elif case == 'empty': - secret_file.write_bytes(b'') - elif case == 'missing': - secret_file.unlink() - else: - flow.config['credential_file'] = tmp_path - with pytest.raises(Failure, match='^login_provider_configuration$') as error: - await flow.exchange() - assert str(tmp_path) not in str(error.value) - assert flow.requests == [] - - -@pytest.mark.parametrize( - 'override', - [ - {'verifier': 'short'}, - {'verifier': '!' * 43}, - {'nonce': ''}, - {'code': 'code\nheader'}, - {'redirect_uri': 'https://user:pass@msg.example/callback'}, - {'redirect_uri': 'https://msg.example/callback#fragment'}, - {'redirect_uri': 'http://attacker.example/callback'}, - ], -) -async def test_invalid_exchange_inputs_do_not_reach_network(override, signing_keys, secret_file): - flow = scenario('google', signing_keys, secret_file) - with pytest.raises(Failure, match='^login_provider_invalid_request$'): - await flow.exchange(**override) - assert flow.requests == [] - - -async def test_invalid_clock_does_not_reach_network(signing_keys, secret_file): - flow = scenario('google', signing_keys, secret_file) - with pytest.raises(Failure, match='^login_provider_invalid_request$'): - await flow.exchange(NOW.replace(tzinfo=None)) - assert flow.requests == [] diff --git a/tests/test_mailbox_contacts.py b/tests/test_mailbox_contacts.py index eaa4c838..e6178ea5 100644 --- a/tests/test_mailbox_contacts.py +++ b/tests/test_mailbox_contacts.py @@ -5,7 +5,7 @@ import pytest from read_only_evidence import readonly_evidence -from test_oauth import browser_login, oauth as oauth +from test_browser_sessions import browser_login, signed_browser as signed_browser from test_service import call, register from msg.core.codec import wire @@ -13,8 +13,10 @@ @pytest.mark.asyncio -async def test_browser_system_notice_has_no_fake_user_and_reads_have_no_effects(oauth, monkeypatch): - app, key, subject, http = oauth +async def test_browser_system_notice_has_no_fake_user_and_reads_have_no_effects( + signed_browser, monkeypatch +): + app, key, subject, http = signed_browser todo = await call( app, 'identity.todo_put', @@ -30,11 +32,11 @@ async def test_browser_system_notice_has_no_fake_user_and_reads_have_no_effects( assert await run_maintenance(app, 'deliver_due_todos', scheduled=True) == { 'delivered_todo_reminders': 1 } - assert (await http.get('/@oauth-owner/in')).status_code == 401 - await browser_login(oauth) + assert (await http.get('/@key-owner/in')).status_code == 401 + await browser_login(signed_browser) async with readonly_evidence(app, monkeypatch): for accept in ('text/html', 'text/markdown'): - response = await http.get('/@oauth-owner/in', headers={'Accept': accept}) + response = await http.get('/@key-owner/in', headers={'Accept': accept}) assert response.status_code == 200, response.text assert 'System notification' in response.text assert '/@None' not in response.text diff --git a/tests/test_mcp_agent.py b/tests/test_mcp_agent.py index 5f21c010..6b2660b2 100644 --- a/tests/test_mcp_agent.py +++ b/tests/test_mcp_agent.py @@ -1,21 +1,15 @@ -"""日常 MCP 工具通过真实 HTTP、OAuth、PostgreSQL 和统一执行器验证。""" +"""日常 MCP 工具通过真实 HTTP、原生签名、PostgreSQL 和统一执行器验证。""" from datetime import timedelta -import httpx import pytest -import test_mcp_oauth -import test_oauth -from test_mcp_oauth import mcp_tokens -from test_service import NOW, call, register +from test_browser_sessions import signed_browser as signed_browser +from test_service import NOW -from msg.core.codec import canonical, wire +from msg.core.codec import wire from msg.core.requests import request_for -from msg.transports.http import create_app # 复用现有身份登录路径,不在测试中伪造 principal 或执行器。 -oauth = test_oauth.oauth -mcp_oauth = test_mcp_oauth.mcp_oauth async def rpc(http, method, params=None, *, rpc_id=1, bearer=None, session=None): @@ -42,8 +36,8 @@ def data(response): @pytest.mark.asyncio -async def test_default_catalog_connect_and_public_read_without_wire(oauth): - app, _, _, http = oauth +async def test_default_catalog_connect_and_public_read_without_wire(signed_browser): + app, _, _, http = signed_browser initialized = await rpc(http, 'initialize', {'protocolVersion': '2025-11-25'}) assert initialized.status_code == 200 session = initialized.headers['mcp-session-id'] @@ -82,13 +76,9 @@ def keys(value): assert not forbidden & keys([item['inputSchema'] for item in tools]) assert data(await tool(http, 'msg_me', session=session))['authenticated'] is False - connect = await tool(http, 'msg_connect', session=session) - assert connect.json()['result']['isError'] is True - challenges = connect.json()['result']['_meta']['mcp/www_authenticate'] - assert len(challenges) == 1 and 'resource_metadata=' in challenges[0] - assert all( - scope in challenges[0] for scope in ('msg.mcp.read', 'msg.mcp.message', 'msg.mcp.post') - ) + connect = await tool(http, 'msg_connect', session=session, rpc_id='connect') + assert data(connect)['authentication'] == 'public-key-signature' + assert '_meta' not in connect.json()['result'] resolved = data( await tool(http, 'msg_resolve', {'address': '/AGENTS.md'}, session=session, rpc_id=2) ) @@ -110,180 +100,8 @@ def keys(value): @pytest.mark.asyncio -async def test_oauth_identity_post_retry_restart_and_no_secret(mcp_oauth): - app, _, subject, http = mcp_oauth - tokens = await mcp_tokens(mcp_oauth, 'msg.mcp.read msg.mcp.message msg.mcp.post offline_access') - bearer = tokens['access_token'] - initialized = await rpc(http, 'initialize', bearer=bearer) - session = initialized.headers['mcp-session-id'] - me = data(await tool(http, 'msg_me', bearer=bearer, session=session, rpc_id=2)) - assert me['authenticated'] is True and me['subject'] == subject - assert me['handle'] == '@oauth-owner' - assert me['credential_type'] == 'oauth' - assert ( - data(await tool(http, 'msg_connect', bearer=bearer, session=session, rpc_id='connected'))[ - 'authenticated' - ] - is True - ) - listed = await rpc(http, 'tools/list', bearer=bearer, session=session, rpc_id=3) - names = {item['name'] for item in listed.json()['result']['tools']} - assert {'msg_inbox', 'msg_send', 'msg_reply', 'msg_post', 'msg_ack'} <= names - assert len(names) <= 13 - args = {'topic': '/main', 'title': '日常标题 / with spaces', 'body': 'idempotent post'} - first = await tool(http, 'msg_post', args, bearer=bearer, session=session, rpc_id='write-one') - first_data = data(first) - assert first_data.get('id') or first_data.get('refs') - second = await tool(http, 'msg_post', args, bearer=bearer, session=session, rpc_id='write-one') - assert data(second) == first_data - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url - ) as restarted: - repeated = await tool( - restarted, 'msg_post', args, bearer=bearer, session=session, rpc_id='write-one' - ) - assert data(repeated) == first_data - conflict = await tool( - http, - 'msg_post', - dict(args, body='changed'), - bearer=bearer, - session=session, - rpc_id='write-one', - ) - assert conflict.json()['result']['structuredContent']['error']['code'] == 'jsonrpc_id_conflict' - async with app.metadata.transaction(write=False) as tx: - assert ( - tx.one("SELECT COUNT(*) FROM resources WHERE type='post' AND parent='t_main'")[0] == 1 - ) - all_outputs = canonical([ - initialized.json(), - me, - listed.json(), - first.json(), - second.json(), - ]).decode() - credential, _, encoded = bearer.partition('.') - assert credential not in all_outputs and encoded not in all_outputs - assert bearer not in all_outputs and tokens['refresh_token'] not in all_outputs - - -@pytest.mark.asyncio -async def test_read_scope_cannot_write_and_notifications_cannot_execute(mcp_oauth): - app, _, _, http = mcp_oauth - tokens = await mcp_tokens(mcp_oauth) - bearer = tokens['access_token'] - session = (await rpc(http, 'initialize', bearer=bearer)).headers['mcp-session-id'] - names = { - item['name'] - for item in (await rpc(http, 'tools/list', bearer=bearer, session=session)).json()[ - 'result' - ]['tools'] - } - assert 'msg_inbox' in names - assert not {'msg_post', 'msg_send', 'msg_ack'} & names - denied = await tool( - http, 'msg_post', {'topic': '/main', 'body': 'forbidden'}, bearer=bearer, session=session - ) - assert denied.json()['result']['structuredContent']['error']['code'] == 'credential_ceiling' - assert 'msg.mcp.post' in denied.json()['result']['_meta']['mcp/www_authenticate'][0] - notification = await tool( - http, - 'msg_post', - {'topic': '/main', 'body': 'notify'}, - bearer=bearer, - session=session, - rpc_id=None, - ) - assert notification.status_code == 202 - injected = await tool( - http, 'msg_me', {'token': 'never-consumed'}, bearer=bearer, session=session, rpc_id=3 - ) - assert injected.json()['result']['isError'] is True - invalid = await rpc(http, 'tools/call', {'name': 7}, bearer=bearer, session=session, rpc_id=4) - assert invalid.json()['error']['message'] == 'invalid_jsonrpc_params' - async with app.metadata.transaction(write=False) as tx: - assert tx.one("SELECT COUNT(*) FROM resources WHERE type='post'")[0] == 0 - - -@pytest.mark.asyncio -async def test_direct_reply_and_archived_conversation_are_private(mcp_oauth): - app, key, subject, http = mcp_oauth - other_key, other, _ = await register(app, 'mcp-other') - requested = await call( - app, 'communication.dm_request', {'recipient': other}, key=key, subject=subject - ) - assert requested.status == 'ok', wire(requested) - conversation = requested.data['conversation_id'] - accepted = await call( - app, - 'communication.dm_accept', - {'conversation_id': conversation}, - key=other_key, - subject=other, - ) - assert accepted.status == 'ok', wire(accepted) - tokens = await mcp_tokens(mcp_oauth, 'msg.mcp.read msg.mcp.message msg.mcp.post') - bearer = tokens['access_token'] - session = (await rpc(http, 'initialize', bearer=bearer)).headers['mcp-session-id'] - sent = data( - await tool( - http, - 'msg_send', - {'recipient': '@mcp-other', 'body': 'private hello'}, - bearer=bearer, - session=session, - rpc_id=2, - ) - ) - assert sent['private'] is True and sent['conversation_id'] == conversation - ref = sent['refs'][0] - archived = await call( - app, 'communication.dm_archive', {'conversation_id': conversation}, key=key, subject=subject - ) - assert archived.status == 'ok' - denied = await tool( - http, - 'msg_reply', - {'post_ref': ref, 'body': 'no public bypass'}, - bearer=bearer, - session=session, - rpc_id=3, - ) - assert denied.json()['result']['structuredContent']['error']['code'] == 'dm_reference_private' - private = data( - await tool( - http, - 'msg_reply', - {'message_ref': ref, 'body': 'private reply'}, - bearer=bearer, - session=session, - rpc_id=4, - ) - ) - assert private['private'] is True and private['conversation_id'] == conversation - assert ( - data(await tool(http, 'msg_read', {'ref': ref}, bearer=bearer, session=session, rpc_id=5))[ - 'text' - ] - == 'private hello' - ) - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url - ) as anonymous: - unavailable = await tool(anonymous, 'msg_read', {'ref': ref}) - assert unavailable.json()['result']['isError'] is True - assert 'private hello' not in unavailable.text - async with app.metadata.transaction(write=False) as tx: - assert ( - tx.one("SELECT COUNT(*) FROM reactions WHERE kind IN ('ack.signature','ack.token')")[0] - == 0 - ) - - -@pytest.mark.asyncio -async def test_raw_sdk_compatibility_unadvertised_on_default_entry(oauth): - app, key, subject, http = oauth +async def test_raw_sdk_compatibility_unadvertised_on_default_entry(signed_browser): + app, key, subject, http = signed_browser packet = request_for( 'content.post_create', {'parent': '/main', 'body': 'old SDK'}, @@ -298,134 +116,3 @@ async def test_raw_sdk_compatibility_unadvertised_on_default_entry(oauth): '/-/mcp/raw', json={'jsonrpc': '2.0', 'id': 2, 'method': 'tools/list'} ) assert 'packet' in advanced.json()['result']['tools'][0]['inputSchema']['properties'] - - -@pytest.mark.asyncio -async def test_session_upgrade_isolation_and_concurrent_same_id(mcp_oauth): - app, _, _, http = mcp_oauth - anonymous_session = (await rpc(http, 'initialize')).headers['mcp-session-id'] - one = await mcp_tokens(mcp_oauth, 'msg.mcp.read msg.mcp.post') - bearer = one['access_token'] - upgraded = await tool(http, 'msg_me', bearer=bearer, session=anonymous_session, rpc_id=2) - assert data(upgraded)['authenticated'] is True - session = upgraded.headers['mcp-session-id'] - assert session != anonymous_session - second = await mcp_tokens(mcp_oauth, 'msg.mcp.read msg.mcp.post') - borrowed = await tool(http, 'msg_me', bearer=second['access_token'], session=session, rpc_id=3) - assert borrowed.json()['error']['message'] == 'mcp_session_invalid' - invalid = await tool(http, 'msg_me', bearer=bearer, session='forged.session', rpc_id=4) - assert invalid.json()['error']['message'] == 'mcp_session_invalid' - missing = await tool( - http, 'msg_post', {'topic': '/main', 'body': 'needs session'}, bearer=bearer, rpc_id=5 - ) - assert missing.json()['result']['structuredContent']['error']['code'] == 'mcp_session_required' - import asyncio - - args = {'topic': '/main', 'body': 'concurrent exactly once'} - results = await asyncio.gather( - *( - tool(http, 'msg_post', args, bearer=bearer, session=session, rpc_id='concurrent') - for _ in range(4) - ) - ) - assert all(data(result) == data(results[0]) for result in results) - separate = (await rpc(http, 'initialize', bearer=bearer)).headers['mcp-session-id'] - data(await tool(http, 'msg_post', args, bearer=bearer, session=separate, rpc_id='concurrent')) - async with app.metadata.transaction(write=False) as tx: - assert ( - tx.one("SELECT COUNT(*) FROM resources WHERE type='post' AND parent='t_main'")[0] == 2 - ) - - -@pytest.mark.asyncio -async def test_invitation_retry_acceptance_and_send_keep_one_write_plan(mcp_oauth): - app, _, _, http = mcp_oauth - other_key, other, _ = await register(app, 'mcp-invited') - tokens = await mcp_tokens(mcp_oauth, 'msg.mcp.read msg.mcp.message') - bearer = tokens['access_token'] - session = (await rpc(http, 'initialize', bearer=bearer)).headers['mcp-session-id'] - args = {'recipient': '@mcp-invited', 'body': 'hello, can we talk?'} - invited = data( - await tool(http, 'msg_send', args, bearer=bearer, session=session, rpc_id='invite') - ) - assert invited['state'] == 'pending' and invited['needs_acceptance'] is True - assert invited['sent'] is False - repeated = data( - await tool(http, 'msg_send', args, bearer=bearer, session=session, rpc_id='invite') - ) - assert repeated == invited - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url - ) as restarted: - assert ( - data( - await tool( - restarted, 'msg_send', args, bearer=bearer, session=session, rpc_id='invite' - ) - ) - == invited - ) - other_context = (app, other_key, other, http) - other_tokens = await mcp_tokens(other_context, 'msg.mcp.read msg.mcp.message') - other_bearer = other_tokens['access_token'] - other_session = (await rpc(http, 'initialize', bearer=other_bearer)).headers['mcp-session-id'] - accepted = data( - await tool( - http, - 'msg_dm_decide', - {'ref': {'id': invited['conversation_id']}, 'accept': True}, - bearer=other_bearer, - session=other_session, - rpc_id='accept', - ) - ) - assert accepted['state'] == 'active' - assert ( - data(await tool(http, 'msg_send', args, bearer=bearer, session=session, rpc_id='invite')) - == invited - ) - new_args = dict(args, body='active message ' * 100) - actual = data( - await tool(http, 'msg_send', new_args, bearer=bearer, session=session, rpc_id='active') - ) - assert actual['sent'] is True - assert ( - data( - await tool(http, 'msg_send', new_args, bearer=bearer, session=session, rpc_id='active') - ) - == actual - ) - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url - ) as restarted: - assert ( - data( - await tool( - restarted, 'msg_send', new_args, bearer=bearer, session=session, rpc_id='active' - ) - ) - == actual - ) - async with app.metadata.transaction(write=False) as tx: - assert ( - tx.one( - 'SELECT COUNT(*) FROM resources WHERE parent=? AND type=?', - (invited['conversation_id'], 'post'), - )[0] - == 2 - ) - inbox = data( - await tool( - http, - 'msg_inbox', - {'limit': 20}, - bearer=other_bearer, - session=other_session, - rpc_id='inbox', - ) - ) - assert inbox['items'] - assert any( - item.get('sender_contact', {}).get('name') == '@oauth-owner' for item in inbox['items'] - ) - assert new_args['body'] not in canonical(inbox).decode() diff --git a/tests/test_mcp_oauth.py b/tests/test_mcp_oauth.py deleted file mode 100644 index d7e8f3d5..00000000 --- a/tests/test_mcp_oauth.py +++ /dev/null @@ -1,375 +0,0 @@ -"""Resource-bound MCP OAuth, with real PostgreSQL and the existing login fixture.""" - -import gzip -import hashlib -from dataclasses import FrozenInstanceError, replace -from datetime import timedelta -from urllib.parse import parse_qs, urlencode, urlsplit - -import httpx -import pytest -import test_oauth -from test_oauth import browser_login, device_tokens -from test_service import NOW, call, register - -from msg.core.codec import b64, canonical, loads, unb64, wire -from msg.core.requests import request_for -from msg.oauth_config import CHATGPT_CLIENT, LEGACY_SCOPES, MCP_SCOPE_OPERATIONS, load_oauth -from msg.security.oauth import get, resource_execution, save -from msg.transports.http_common import body_bytes -from msg.transports.mcp_auth import ( - MCPIdentity, - authenticate_mcp, - challenge, - protected_resource_metadata, -) -from msg.transports.oauth_http import OAuthBoundary, csrf - -oauth = test_oauth.oauth - - -@pytest.fixture -async def mcp_oauth(oauth): - app, key, subject, http = oauth - app.settings = replace( - app.settings, - oauth=replace(app.settings.oauth, clients=(*app.settings.oauth.clients, CHATGPT_CLIENT)), - ) - app.authenticator.oauth_config = app.settings.oauth - return app, key, subject, http - - -async def mcp_code(context, scope='msg.mcp.read offline_access', *, decision='approve'): - app, _, _, http = context - cookie = await browser_login(context) - verifier = 'm' * 64 - args = { - 'client_id': 'msg-chatgpt', - 'response_type': 'code', - 'redirect_uri': CHATGPT_CLIENT.redirect_uris[0], - 'scope': scope, - 'resource': app.settings.service_url + '/-/mcp', - 'state': 'state-with-enough-entropy', - 'code_challenge_method': 'S256', - 'code_challenge': b64(hashlib.sha256(verifier.encode()).digest()), - } - response = await http.post( - '/oauth/authorize', - data=dict(args, csrf=csrf(cookie), decision=decision), - headers={'Origin': app.settings.service_url}, - ) - assert response.status_code == 303, response.text - query = parse_qs(urlsplit(response.headers['location']).query) - assert query['iss'] == [app.settings.service_url] - assert query['state'] == [args['state']] - return args, verifier, query - - -async def mcp_tokens(context, scope='msg.mcp.read offline_access'): - app, _, _, http = context - args, verifier, query = await mcp_code(context, scope) - data = { - 'client_id': args['client_id'], - 'grant_type': 'authorization_code', - 'code': query['code'][0], - 'redirect_uri': args['redirect_uri'], - 'code_verifier': verifier, - 'resource': args['resource'], - } - response = await http.post('/oauth/token', data=data) - assert response.status_code == 200, response.text - result = response.json() - assert result['resource'] == args['resource'] - return result - - -async def identity_response( - context, token=None, *, path='/-/mcp', body=None, method='POST', headers=None, compressed=False -): - app, _, _, _ = context - - async def downstream(scope, receive, send): - from starlette.requests import Request - - message = loads( - await body_bytes(Request(scope, receive), app.settings.server.limits.max_request_bytes) - ) - assert isinstance(message, dict) - identity = scope['state']['msg_mcp_identity'] - # Only explicitly selected public facts ever leave the request context. - payload = {'authenticated': identity is not None} - if identity is not None: - payload.update(subject=identity.subject, operations=sorted(identity.operations)) - from starlette.responses import JSONResponse - - await JSONResponse(payload)(scope, receive, send) - - boundary = OAuthBoundary(downstream, service=app) - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=boundary), base_url=app.settings.service_url - ) as http: - http.cookies.set('msg_session', 'this-cookie-is-not-an-mcp-credential') - values = {'Authorization': 'Bearer ' + token} if token is not None else {} - values.update(headers or {}) - message = body or {'jsonrpc': '2.0', 'id': 1, 'method': 'tools/list'} - payload = {'json': message} - if compressed: - values['Content-Encoding'] = 'gzip' - payload = {'content': gzip.compress(canonical(message))} - return await http.request(method, path, headers=values, **payload) - - -def test_mcp_client_defaults_and_exact_redirects(): - config = load_oauth({'enabled': True}, 'https://msg.example') - cli = next(c for c in config.clients if c.client_id == 'msg-cli') - assert cli.scopes == LEGACY_SCOPES - chatgpt = next(c for c in config.clients if c.client_id == 'msg-chatgpt') - assert chatgpt == CHATGPT_CLIENT - assert chatgpt.redirect_uris == ('https://chatgpt.com/connector_platform_oauth_redirect',) - assert not chatgpt.scopes & {'msg.read', 'msg.write'} - assert all( - not operation.startswith(('identity.', 'root.', 'system.', 'money.', 'cert.')) - for operations in MCP_SCOPE_OPERATIONS.values() - for operation in operations - ) - - -@pytest.mark.asyncio -async def test_mcp_discovery_issuer_pkce_and_deny(mcp_oauth): - app, _, _, http = mcp_oauth - for path in ( - '/.well-known/oauth-protected-resource', - '/.well-known/oauth-protected-resource/-/mcp', - '/.well-known/oauth-protected-resource/-/mcp/raw', - ): - response = await http.get(path) - assert response.status_code == 200, response.text - assert response.json() == protected_resource_metadata(app) - assert (await http.post(path)).status_code == 405 - metadata = (await http.get('/.well-known/oauth-authorization-server')).json() - assert metadata['authorization_response_iss_parameter_supported'] is True - assert metadata['code_challenge_methods_supported'] == ['S256'] - assert metadata['token_endpoint_auth_methods_supported'] == ['none'] - assert 'registration_endpoint' not in metadata - assert not metadata.get('client_id_metadata_document_supported', False) - _, _, query = await mcp_code(mcp_oauth, decision='deny') - assert query['error'] == ['access_denied'] and 'code' not in query - args, _, _ = await mcp_code(mcp_oauth) - for changed, code in [ - ({'resource': 'https://other.example/-/mcp'}, 'invalid_target'), - ({'resource': app.settings.service_url + '/-/mcp/raw'}, 'invalid_target'), - ({'resource': ''}, 'invalid_target'), - ({'redirect_uri': CHATGPT_CLIENT.redirect_uris[0] + '/other'}, 'invalid_redirect_uri'), - ({'code_challenge_method': 'plain'}, 'invalid_request'), - ]: - response = await http.get('/oauth/authorize?' + urlencode(dict(args, **changed))) - assert response.status_code == 400 and response.json()['error'] == code - args.pop('resource') - response = await http.get('/oauth/authorize?' + urlencode(args)) - assert response.status_code == 400 and response.json()['error'] == 'invalid_target' - - -@pytest.mark.asyncio -async def test_mcp_identity_frozen_scope_and_secret_projection(mcp_oauth): - app, _, subject, _ = mcp_oauth - tokens = await mcp_tokens(mcp_oauth) - identity = await authenticate_mcp(app, 'Bearer ' + tokens['access_token']) - assert isinstance(identity, MCPIdentity) and identity.subject == subject - assert identity.credential_type == 'oauth' and identity.session_binding.startswith('family:') - assert identity.operations <= MCP_SCOPE_OPERATIONS['msg.mcp.read'] - assert 'discovery.get@1' in identity.operations - assert not identity.operations & {'content.post_create@2', 'discussion.ack@1'} - with pytest.raises(FrozenInstanceError): - identity.subject = 'other' - encoded = tokens['access_token'].partition('.')[2] - assert encoded not in repr(identity) and str(identity.token) not in repr(identity) - for path in ('/-/mcp', '/-/mcp/raw'): - response = await identity_response(mcp_oauth, tokens['access_token'], path=path) - assert response.status_code == 200 and response.json()['subject'] == subject - assert encoded not in response.text and 'credential_id' not in response.text - assert (await identity_response(mcp_oauth, path=path)).json() == {'authenticated': False} - - -@pytest.mark.asyncio -async def test_mcp_wrong_audience_legacy_cookie_and_mixed_proof(mcp_oauth): - app, _, subject, http = mcp_oauth - legacy = await device_tokens(mcp_oauth) - assert (await identity_response(mcp_oauth, legacy['access_token'])).status_code == 401 - assert (await identity_response(mcp_oauth)).json() == {'authenticated': False} - tokens = await mcp_tokens(mcp_oauth) - assert ( - await identity_response(mcp_oauth, tokens['access_token'], compressed=True) - ).status_code == 200 - mixed = { - 'jsonrpc': '2.0', - 'id': 1, - 'method': 'tools/call', - 'params': { - 'name': 'discovery.get', - 'arguments': {'packet': {'proof': {'token': 'other-secret'}}}, - }, - } - response = await identity_response(mcp_oauth, tokens['access_token'], body=mixed) - assert response.status_code == 400 and response.json()['error'] == 'ambiguous_credentials' - assert 'other-secret' not in response.text - mixed['params']['arguments']['packet'] = canonical( - mixed['params']['arguments']['packet'] - ).decode() - response = await identity_response(mcp_oauth, tokens['access_token'], body=mixed) - assert response.status_code == 400 and response.json()['error'] == 'ambiguous_credentials' - assert ( - await identity_response(mcp_oauth, tokens['access_token'], method='GET') - ).status_code == 405 - assert ( - await identity_response( - mcp_oauth, tokens['access_token'], headers={'x-msg-request': 'proof'} - ) - ).status_code == 400 - # A token for the MCP audience is not accepted by the generic bearer adapter. - packet = request_for( - 'discovery.get', {'id': '/main'}, app.settings.service_url, subject=subject - ) - response = await http.post( - '/-/p/discovery.get', - content=canonical(packet), - headers={'Authorization': 'Bearer ' + tokens['access_token']}, - ) - assert response.status_code == 401 - bound_packet = request_for( - 'discovery.get', - {'id': '/main'}, - app.settings.service_url, - subject=subject, - token=( - tokens['access_token'].partition('.')[0], - unb64(tokens['access_token'].partition('.')[2]), - ), - expires_at=app.clock() + timedelta(minutes=3), - ) - response = await http.post('/-/p/discovery.get', content=canonical(bound_packet)) - assert response.status_code == 400, response.text - assert response.json()['error']['code'] == 'invalid_grant' - for token in ('malformed', 't_oauth_missing.bad', tokens['access_token'] + '!', ' '): - response = await identity_response(mcp_oauth, token) - assert response.status_code == 401, response.text - assert response.headers['www-authenticate'] == challenge( - app, 'invalid_token', ('msg.mcp.read',) - ) - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - 'mutation', ['expiry', 'token', 'parent', 'family', 'authority', 'resource'] -) -async def test_mcp_live_revocation_and_resource_fence(mcp_oauth, mutation): - app, key, subject, _ = mcp_oauth - tokens = await mcp_tokens(mcp_oauth) - identity = await authenticate_mcp(app, 'Bearer ' + tokens['access_token']) - if mutation == 'expiry': - app._oauth_clock[0] = NOW + timedelta(seconds=901) - else: - async with app.metadata.transaction(write=True) as tx: - if mutation in {'token', 'parent'}: - credential = await tx.credential( - identity.credential_id if mutation == 'token' else key.key_id - ) - user = await tx.subject(subject) - await tx.save_credential(replace(credential, revoked_at=NOW), user.auth_version) - elif mutation in {'family', 'resource'}: - _, family = get(tx, identity.session_binding, app.clock()) - if mutation == 'family': - family['revoked'] = True - else: - family['resource'] = 'https://other.example/-/mcp' - save(tx, identity.session_binding, family) - else: - user = await tx.subject(subject) - await tx.update_identity( - replace(user, auth_version=user.auth_version + 1), user.auth_version - ) - response = await identity_response(mcp_oauth, tokens['access_token']) - assert response.status_code == 401, response.text - - -@pytest.mark.asyncio -async def test_mcp_refresh_binding_no_expansion_and_scope_narrowing(mcp_oauth): - app, _, _, http = mcp_oauth - tokens = await mcp_tokens(mcp_oauth, 'msg.mcp.read msg.mcp.post offline_access') - args = { - 'client_id': 'msg-chatgpt', - 'grant_type': 'refresh_token', - 'refresh_token': tokens['refresh_token'], - 'resource': app.settings.service_url + '/-/mcp', - } - for patch, code in [ - ({'resource': 'https://other.example'}, 'invalid_target'), - ({'scope': 'msg.mcp.read msg.mcp.message msg.mcp.post offline_access'}, 'invalid_scope'), - ]: - response = await http.post('/oauth/token', json=dict(args, **patch)) - assert response.status_code == 400 and response.json()['error'] == code - missing = dict(args) - missing.pop('resource') - assert (await http.post('/oauth/token', json=missing)).json()['error'] == 'invalid_target' - response = await http.post('/oauth/token', json=dict(args, scope='msg.mcp.read offline_access')) - assert response.status_code == 200, response.text - new = response.json() - identity = await authenticate_mcp(app, 'Bearer ' + new['access_token']) - assert identity.operations <= MCP_SCOPE_OPERATIONS['msg.mcp.read'] - assert (await identity_response(mcp_oauth, tokens['access_token'])).status_code == 401 - replay = await http.post('/oauth/token', json=args) - assert replay.json()['error'] == 'invalid_grant' - assert (await identity_response(mcp_oauth, new['access_token'])).status_code == 401 - - -@pytest.mark.asyncio -async def test_mcp_readonly_cannot_write_and_cross_account_acl(mcp_oauth): - app, _, subject, _ = mcp_oauth - tokens = await mcp_tokens(mcp_oauth) - credential, _, value = tokens['access_token'].partition('.') - proof = (credential, unb64(value)) - other_key, other, _ = await register(app, 'mcp-other') - with resource_execution(app.settings.service_url + '/-/mcp'): - result = await call( - app, - 'content.post_create', - {'parent': '/main', 'body': 'not allowed'}, - token=proof, - subject=subject, - contract_version=2, - ) - assert result.status == 'error' and result.error.code == 'credential_ceiling' - result = await call( - app, 'discovery.get', {'id': other, 'fields': ['id']}, token=proof, subject=other - ) - assert result.status == 'error' and result.error.code in { - 'delegation_required', - 'subject_mismatch', - } - own = await call( - app, - 'content.post_create', - {'parent': '/main', 'body': 'private'}, - key=other_key, - subject=other, - ) - assert own.status == 'ok', wire(own) - private = await call( - app, - 'content.chmod', - {'id': own.resources[0].id, 'mode': '0600'}, - key=other_key, - subject=other, - expected=((own.resources[0].id, own.data['generation']),), - ) - assert private.status == 'ok', wire(private) - result = await call( - app, - 'discovery.get', - {'id': own.resources[0].id, 'fields': ['content']}, - token=proof, - subject=subject, - ) - assert result.status == 'error' and result.error.code in { - 'permission_denied', - 'access_denied', - } diff --git a/tests/test_mcp_tools.py b/tests/test_mcp_tools.py index afd2037d..900e58cf 100644 --- a/tests/test_mcp_tools.py +++ b/tests/test_mcp_tools.py @@ -52,7 +52,7 @@ def identity(operations=ALL, **kwargs): credential_id=SECRET, token=SECRET, operations=operations, - credential_type='oauth_token', + credential_type='key-grant', expires_at=kwargs.get('expires_at'), session_binding=SECRET, ) @@ -117,15 +117,7 @@ def test_default_schemas_hide_envelopes_and_credentials(registry): Draft202012Validator.check_schema(tool['inputSchema']) assert not forbidden.intersection(_property_names(tool['inputSchema'])) assert tool['inputSchema']['additionalProperties'] is False - assert tool['securitySchemes'] == tool['_meta']['securitySchemes'] - assert tool['securitySchemes'][0]['type'] == ( - 'noauth' if tool['name'] in PUBLIC else 'oauth2' - ) - assert all( - scope in {'msg.mcp.read', 'msg.mcp.message', 'msg.mcp.post'} - for scheme in tool['securitySchemes'] - for scope in scheme.get('scopes', ()) - ) + assert 'securitySchemes' not in tool and '_meta' not in tool anonymous[0]['inputSchema']['properties']['token'] = {} assert 'token' not in tool_catalog(registry)[0]['inputSchema']['properties'] @@ -148,9 +140,7 @@ def test_catalog_uses_exact_installed_versions_and_ceilings(registry): assert read == PUBLIC | {'msg_inbox', 'msg_notifications'} message = tool_catalog(registry, identity(DEPENDENCIES['msg_send'] | REPLY_ALTERNATIVES[0])) reply = next(tool for tool in message if tool['name'] == 'msg_reply') - assert reply['securitySchemes'] == [ - {'type': 'oauth2', 'scopes': ['msg.mcp.read', 'msg.mcp.message']} - ] + assert 'securitySchemes' not in reply assert 'msg_post' not in {tool['name'] for tool in message} @@ -187,7 +177,7 @@ async def test_me_returns_no_identity_secret(registry): 'ack', 'decide_dm', ] - assert result['data']['credential_type'] == 'oauth_token' + assert result['data']['credential_type'] == 'key-grant' assert SECRET not in json.dumps(result) assert call.calls == [ ('discovery.get', {'id': 's_current', 'fields': ['id', 'name', 'type']}, 1) @@ -631,20 +621,19 @@ async def call(*args, **kwargs): assert set(result['error']) <= {'code', 'message', 'hint', 'retryable'} -async def test_connect_requests_all_limited_scopes_and_returns_host_challenge(registry): +async def test_connect_explains_native_signing_without_external_authority(registry): catalog = tool_catalog(registry) connect = next(tool for tool in catalog if tool['name'] == 'msg_connect') - assert connect['securitySchemes'][1]['scopes'] == [ - 'msg.mcp.read', - 'msg.mcp.message', - 'msg.mcp.post', - ] + assert 'securitySchemes' not in connect + assert 'oauth' not in json.dumps(catalog).lower() async def call(*args, **kwargs): pytest.fail('connect is handled by the host, not a business operation') result = await run_tool('msg_connect', {}, call=call) - assert result['error']['code'] == 'authentication_required' + assert result['status'] == 'ok' + assert result['data']['authentication'] == 'public-key-signature' + assert '_meta' not in result async def test_send_new_conversation_keeps_full_intro_and_returns_pending(registry): @@ -769,7 +758,7 @@ async def test_expired_executor_credential_has_safe_reconnect_hint(registry): ) result = await run_tool('msg_resolve', {'address': '/main'}, call=call, identity=identity()) assert result['error']['code'] == 'credential_expired' - assert 'Reconnect' in result['error']['hint'] and SECRET not in json.dumps(result) + assert 'local signing key' in result['error']['hint'] and SECRET not in json.dumps(result) @pytest.mark.parametrize( diff --git a/tests/test_navigation_details.py b/tests/test_navigation_details.py index 1d8846fe..ce85bf46 100644 --- a/tests/test_navigation_details.py +++ b/tests/test_navigation_details.py @@ -45,7 +45,7 @@ def test_compact_account_preserves_routes_and_escapes_full_name(): assert len(navigation.links) == 8 assert navigation.links[0]['href'] == '/@long%22%3Caccount%3E' assert navigation.links[-2] == {'href': '/bookmarks', 'data-i18n': 'saved'} - assert navigation.links[-1]['href'] == '/oauth/logout' + assert navigation.links[-1]['href'] == '/session/logout' home = home_html(account={'name': name}).decode().split('', 1)[0] assert home.count('