From d2356d56e35afbd4619f08518f7bf980ac481b94 Mon Sep 17 00:00:00 2001 From: sunny Date: Tue, 3 Nov 2026 18:11:19 +0800 Subject: [PATCH] feat(auth): expose SSO login and settings with browser coverage --- .dockerignore | 2 + .github/workflows/sso.yml | 39 ++ .gitignore | 2 + README.md | 148 ++++- cmd/vocat/main.go | 9 +- docker-compose.sso-test.yml | 40 ++ docs/README.zh-CN.md | 104 +++- internal/server/server.go | 20 + internal/server/sso_auth.go | 400 +++++++++++++ internal/server/sso_auth_test.go | 715 ++++++++++++++++++++++++ internal/server/sso_settings.go | 212 +++++++ web/e2e/fixture/main.go | 373 ++++++++++++ web/e2e/sso.spec.ts | 316 +++++++++++ web/package-lock.json | 51 ++ web/package.json | 2 + web/playwright.config.ts | 31 + web/src/App.tsx | 5 +- web/src/api.ts | 32 ++ web/src/components/settings/SSOCard.tsx | 309 ++++++++++ web/src/lib/i18n-en.ts | 68 +++ web/src/lib/safeRedirect.ts | 21 + web/src/lib/sso.ts | 125 +++++ web/src/pages/LoginPage.tsx | 49 +- web/src/pages/SettingsPage.tsx | 2 + web/src/types.ts | 60 ++ web/test/ssoRedirect.test.mjs | 24 + web/test/ssoSettingsModel.test.mjs | 174 ++++++ 27 files changed, 3323 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/sso.yml create mode 100644 docker-compose.sso-test.yml create mode 100644 internal/server/sso_auth.go create mode 100644 internal/server/sso_auth_test.go create mode 100644 internal/server/sso_settings.go create mode 100644 web/e2e/fixture/main.go create mode 100644 web/e2e/sso.spec.ts create mode 100644 web/playwright.config.ts create mode 100644 web/src/components/settings/SSOCard.tsx create mode 100644 web/src/lib/safeRedirect.ts create mode 100644 web/src/lib/sso.ts create mode 100644 web/test/ssoRedirect.test.mjs create mode 100644 web/test/ssoSettingsModel.test.mjs diff --git a/.dockerignore b/.dockerignore index 0601a0ca..79d9a9b8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,6 +10,8 @@ data/ **/node_modules/ web/dist/ web/build/ +web/test-results/ +web/playwright-report/ **/__pycache__/ *.pyc *.pyo diff --git a/.github/workflows/sso.yml b/.github/workflows/sso.yml new file mode 100644 index 00000000..2426e4e4 --- /dev/null +++ b/.github/workflows/sso.yml @@ -0,0 +1,39 @@ +name: SSO + +on: + pull_request: + paths: ["cmd/vocat/**", "internal/auth/**", "internal/store/**", "internal/server/**", "internal/sso/**", "internal/netguard/**", "web/**", "go.mod", "go.sum", ".github/workflows/sso.yml"] + push: + branches: [master] + paths: ["cmd/vocat/**", "internal/auth/**", "internal/store/**", "internal/server/**", "internal/sso/**", "internal/netguard/**", "web/**", "go.mod", "go.sum", ".github/workflows/sso.yml"] + +permissions: + contents: read + +jobs: + authentication: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - uses: actions/setup-node@v6 + with: + node-version: "22" + cache: npm + cache-dependency-path: web/package-lock.json + - run: npm --prefix web ci --no-audit --no-fund + - run: npm --prefix web test + - run: npm --prefix web run build + - run: go test ./... + - run: go vet ./... + - run: go test -race ./internal/auth ./internal/store ./internal/server ./internal/sso + - run: npm --prefix web exec -- playwright install --with-deps chromium + - run: npm --prefix web run test:e2e + - uses: actions/upload-artifact@v6 + if: failure() + with: + name: sso-browser-failures + path: web/test-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index c86c4484..03cf1c22 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ vc.jar web/dist/ web/build/ web/node_modules/ +web/test-results/ +web/playwright-report/ # ---- Go build ---- *.test diff --git a/README.md b/README.md index 54f7826e..b62cb845 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ The backend is written in Go, the interface is built with React and TypeScript, | Proxy routing | Upstream SOCKS routing, device bindings, country rules, TCP reachability checks, and UDP Associate checks for WiFi Calling data paths. | | Notifications | New inbound SMS forwarding through Telegram, Bark, email, Pushplus, and signed webhooks. Each SMS is delivered as an individual notification. | | Telegram bot | Device status, installed-profile listing and switching, WiFi Calling controls, and SMS sending. Sensitive actions require administrator confirmation. | -| Operations | Authentication, CSRF protection, access policies, audit events, live logs, log retention, health checks, responsive layout, dark mode, and English/Chinese application UI. | +| Operations | Password and SSO authentication (Pocket ID, GitHub, Generic OAuth2), CSRF protection, access policies, audit events, live logs, log retention, health checks, responsive layout, dark mode, and English/Chinese application UI. | | Distribution | Static Linux binaries, systemd installation script, self-update with SHA-256 verification, Docker image, GHCR publishing, and GitHub Actions release builds. | ## Supported hardware @@ -280,6 +280,152 @@ configuration cannot set or overwrite the administrator username or password. Do not store Telegram tokens, SMTP passwords, webhook secrets, SIM credentials, or other private data in the repository. Configure them through the application settings or protected environment files. +## Single sign-on (SSO) + +VoCat supports Pocket ID through OpenID Connect, GitHub.com OAuth Apps, and +Generic OAuth2 providers. Select one provider in **Settings → Single sign-on +(SSO)**. SSO starts disabled and password login remains available. Sign in with +the local administrator first, save a provider, and explicitly bind an external +account before enabling SSO. Other external accounts cannot register or become +administrators, including accounts sharing the bound account's email address. + +### Pocket ID + +1. Use an HTTPS origin for VoCat, such as `https://vocat.example.com`, and set + `VOCAT_SECURE_COOKIES=true`. In the SSO settings choose **Pocket ID**. The + public origin initially uses the current browser address; edit the visible + **VoCat public origin** field if necessary. The compact callback URL and Copy + button appear immediately, so the URL can be copied + before creating the OIDC client or saving any credentials: + `https://vocat.example.com/api/auth/sso/callback`. +2. In Pocket ID create a separate **Confidential** OIDC client for VoCat. Register + the exact callback URL and verify that the intended user or group is allowed + to use this client. VoCat and Komari need separate clients and local sessions. +3. In VoCat enter Pocket ID's exact public `APP_URL` as the Issuer, and enter the + Client ID and Client Secret issued for the client created in step 2. The + Client ID field includes a reminder of where to find this value. + Scopes and PKCE S256 are handled by VoCat. + Token authentication is selected from validated Discovery metadata, preferring + `client_secret_basic` over `client_secret_post`. Save while SSO remains disabled. + Origin edits update the displayed callback immediately; they take effect for + authorization after saving. +4. Check the saved configuration; success displays **Configuration check passed**. + Then bind the account. Complete Pocket ID's Passkey login and consent, and + confirm the returned binding in VoCat. + Configuration checking validates metadata and connectivity; it cannot verify + a client secret or user authorization without this login. +5. Enable SSO and save. Use a private browser window to verify SSO login, return + to the requested page, and the first state-changing request. + +The settings page shows the bound account and an unbind action. To change the +external account, unbind it first, then bind the intended account and enable SSO +again. + +The Issuer is matched exactly, including its path and trailing slash. Pocket ID +login uses the verified ID Token's `issuer + sub`; a UserInfo endpoint or email +claim is not required. Discovery and its authorization, Token and JWKS endpoints +must use the configured Issuer's origin. Deployments using `INTERNAL_APP_URL` +with a different backend origin are not supported in this release. Keep the +public `APP_URL` as the Issuer. Existing explicitly +configured authentication methods and scopes are preserved when saving. + +### GitHub and Generic OAuth2 + +For GitHub, create an OAuth App on GitHub.com with the same exact callback URL, +select **GitHub**, enter its Client ID and Secret, and follow the save, check, +bind, enable sequence above. Endpoints are fixed to GitHub.com; the default +scope is `read:user`, and PKCE S256 is required. The binding follows the stable +GitHub numeric account ID, so changing a login name does not change the account. +GitHub Enterprise is outside this release's scope. + +For Generic OAuth2, supply the authorization, Token, and UserInfo URLs in addition +to the client credentials. Scopes, the top-level user ID field (default `id`), +Token authentication and PKCE compatibility options are displayed when Generic +OAuth2 is selected. +UserInfo is requested with a Bearer access token. The ID must be a +nonempty string or an integer; large integer IDs retain their exact value. +The default Token authentication is `client_secret_post` and PKCE is S256. +Disable PKCE only for a provider that lacks PKCE support. +Generic OAuth2 does not perform OIDC ID Token verification. + +### Network, credentials and sessions + +VoCat's existing source-network policy also applies to SSO routes. Configuring +SSO does not make an installation publicly accessible. The browser must reach +VoCat and the authorization endpoint; the VoCat process must reach Discovery, +Token and JWKS/UserInfo endpoints with trusted TLS certificates. Saving the exact +Pocket ID HTTPS Issuer or Generic endpoint URLs approves +those addresses for HTTPS connections, including RFC1918/IPv6 ULA private +addresses. GitHub's fixed endpoints remain restricted to public addresses. + +For a Generic provider's internal HTTP service, explicitly enter its full HTTP URL +in the Token/UserInfo field. +Every DNS candidate and actual connection for these HTTP destinations must be a +permitted private address. Pocket ID's Discovery cannot approve another origin; +its endpoints must use the configured Issuer's origin. The Issuer, Discovery fetch +URL and browser authorization endpoint always require HTTPS. Loopback, link-local, +cloud metadata and other special-use destinations remain blocked for IdP +connections. Install private CA certificates in the host/container trust store; +there is no TLS verification bypass option. The browser's localhost or HTTPS +origin for VoCat is independent of these IdP connection permissions. + +The supplied Compose deployment uses host networking. Verify DNS, routing and CA +trust from the actual container; do not assume bridge-network service names are +available. Reverse proxies must preserve the configured public origin. VoCat +does not derive callback URLs from forwarded host headers. Root-path deployment +is required. For local Vite development, use one consistent loopback origin, +such as `http://127.0.0.1:5173`, for the public origin and callback registration; +IdP transport exceptions belong only in test fixtures. + +Session and CSRF cookies retain `SameSite=Strict`; short-lived authorization +cookies use `SameSite=Lax` for top-level GET callbacks. Saving any SSO +configuration, including disabling it or rotating the secret, revokes all SSO +sessions. Unbinding an account revokes that binding's SSO sessions. +Password sessions and API tokens survive those actions. Changing the local +administrator's password revokes all sessions and API tokens, cancels in-flight +authorizations, and preserves the external binding. The UI requests sign-in +again when a successful operation revokes the current SSO session. + +Leave the secret input empty to preserve an existing secret, or enter a new +secret to replace it. The settings page does not offer a secret-clearing control. +Changing the provider, client +ID, Issuer, or Generic identity endpoints/ID field requires a new identity +namespace, new credentials, and a new binding. Existing bindings require explicit +reset confirmation. To change the bound account, unbind it and then create a new +binding. Client secrets are held only on the server in the protected SQLite +database; the sensitive-setting flag redacts responses and does not encrypt the +database. Third-party tokens are not persisted or returned to the browser. + +Signing out ends only the current VoCat session. Disabling an IdP account or +signing out of Pocket ID does not immediately revoke an existing VoCat session; +it expires according to `VOCAT_SESSION_TTL`, or can be revoked by disabling SSO +or unbinding in VoCat. The public capabilities endpoint is +`GET /api/auth/providers`. SSO settings and binding APIs require a browser +session and CSRF for writes; Bearer tokens cannot manage them. Configuration +revision conflicts return `409 sso_config_changed`; changed identity sources +return `409 sso_rebind_required`. + +### Upgrade and rollback + +SSO adds SQLite schema version 29. Upgrades preserve the existing administrator, +sessions, API tokens and device data; SSO remains disabled until configured. +Before upgrading, record the old binary or image digest and back up configuration, +CA material and the complete persistent data directory while VoCat is stopped, +or use SQLite's backup API/`.backup` to obtain a consistent live snapshot. Copying +only a running WAL-mode database file is not a reliable backup. + +Disable SSO in the new version to recover from provider problems while keeping +password login and current business data. Returning to a schema-28 binary +requires stopping VoCat and restoring the matching pre-upgrade data and +configuration snapshot. Merely reverting the container image or lowering +`PRAGMA user_version` cannot downgrade the database. Restore into a separate +directory/volume where possible and retain the newer database for investigation. +Restoring a snapshot loses messages and task changes recorded after that backup. +For Docker, update by pulling the image and recreating the container. + +Before a production release, complete the real Pocket ID Passkey/access-policy +test and an upgrade/rollback rehearsal for that deployment. + ## API tokens and MCP Non-browser clients authenticate against the same `/api/...` endpoints with diff --git a/cmd/vocat/main.go b/cmd/vocat/main.go index c24c1505..613c5c52 100644 --- a/cmd/vocat/main.go +++ b/cmd/vocat/main.go @@ -665,6 +665,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error { return nil } go handler.StartLogRetentionLoop(pollContext, time.Minute) + handler.StartSSOCleanup(pollContext) go handler.StartSMSSyncLoop(pollContext, 15*time.Second) handler.StartCellularDataReconciler(pollContext) handler.StartTelegramBot(pollContext) @@ -678,9 +679,11 @@ func run(logger *slog.Logger, logs *loghub.Hub) error { Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, - WriteTimeout: 30 * time.Second, - IdleTimeout: 90 * time.Second, - MaxHeaderBytes: 1 << 20, + // Leave time to report a controlled error after the 30s SSO + // verification budget expires. + WriteTimeout: 40 * time.Second, + IdleTimeout: 90 * time.Second, + MaxHeaderBytes: 1 << 20, } } plainHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/docker-compose.sso-test.yml b/docker-compose.sso-test.yml new file mode 100644 index 00000000..68636c6d --- /dev/null +++ b/docker-compose.sso-test.yml @@ -0,0 +1,40 @@ +# Local web/SSO testing on Docker Desktop or OrbStack, without modem devices. +# Build: docker compose -f docker-compose.sso-test.yml build +# Initialize once (reads the admin password from stdin): +# docker compose -f docker-compose.sso-test.yml run --rm -T \ +# --entrypoint /opt/vocat/bin/vocat vocat bootstrap-admin +# Start: docker compose -f docker-compose.sso-test.yml up -d --wait +# Visit http://127.0.0.1:7575 and use that same origin in SSO settings. +name: vocat-sso-test + +services: + vocat: + image: vocat:sso-dev + pull_policy: never + build: + context: . + dockerfile: Dockerfile + args: + VERSION: 0.1.0-sso-dev + container_name: vocat-sso-test + restart: unless-stopped + ports: + - "127.0.0.1:7575:7575" + environment: + VOCAT_CONTAINER: docker + VOCAT_ADDR: 0.0.0.0:7575 + VOCAT_DATABASE_PATH: /opt/vocat/data/vocat.db + VOCAT_SECURE_COOKIES: "false" + TZ: Asia/Shanghai + volumes: + - vocat-sso-test-data:/opt/vocat/data + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:7575/readyz"] + interval: 10s + timeout: 3s + start_period: 60s + retries: 3 + +volumes: + vocat-sso-test-data: + name: vocat-sso-test-data diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 911e6295..7c6758b5 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -47,7 +47,7 @@ Vocat 是一款面向 Quectel EC20/EC25 系列蜂窝模组的开源 Web 控制 | 代理路由 | 上游 SOCKS 路由、设备绑定、国家规则、TCP 可达性检查以及面向 WiFi Calling 数据路径的 UDP Associate 检查。 | | 通知 | 通过 Telegram、Bark、邮件、Pushplus 以及签名 Webhook 转发新入站短信,每条短信单独推送。 | | Telegram 机器人 | 设备状态、已安装配置文件列表与切换、WiFi Calling 控制以及短信发送。敏感操作需要管理员确认。 | -| 运维 | 鉴权、CSRF 防护、访问策略、审计事件、实时日志、日志留存、健康检查、响应式布局、深色模式以及中英文应用界面。 | +| 运维 | 密码与 SSO 鉴权(Pocket ID、GitHub、Generic OAuth2)、CSRF 防护、访问策略、审计事件、实时日志、日志留存、健康检查、响应式布局、深色模式以及中英文应用界面。 | | 分发 | 静态 Linux 二进制、systemd 安装脚本、带 SHA-256 校验的自更新、Docker 镜像、GHCR 发布以及 GitHub Actions 发布构建。 | ## 支持的硬件 @@ -226,6 +226,108 @@ Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT 请勿将 Telegram token、SMTP 密码、Webhook 密钥、SIM 凭据或其他私密数据存放在仓库中。请通过应用设置或受保护的环境文件来配置它们。 +## 单点登录(SSO) + +VoCat 支持 Pocket ID(OpenID Connect)、GitHub.com OAuth App 和 Generic OAuth2, +在**设置 → 单点登录(SSO)**中选择其中一个身份提供方。SSO 默认关闭,密码登录始终可用。 +先使用本地管理员登录、保存配置并主动绑定外部账号,再启用 SSO。 +未绑定的外部用户不能注册或获得管理员权限;相同邮箱也不会自动合并账号。 + +### 配置 Pocket ID + +1. 为 VoCat 配置 HTTPS 根路径入口,例如 `https://vocat.example.com`,并设置 + `VOCAT_SECURE_COOKIES=true`。在 SSO 设置选择 **Pocket ID**,公开入口初始使用当前 + 浏览器访问地址,需要调整时直接编辑**VoCat 公开地址**。页面立即显示紧凑的回调 URL 和复制按钮, + 无需先保存或填写客户端凭据: + `https://vocat.example.com/api/auth/sso/callback`。 +2. 在 Pocket ID 为 VoCat 单独创建 **Confidential** OIDC Client,登记精确回调地址, + 并确认目标用户或用户组获准使用该客户端。VoCat 与 Komari 应分别注册客户端, + 各自签发本地会话。 +3. 在 VoCat 填写 Pocket ID 精确的公开 `APP_URL` 作为 Issuer,并填入新客户端的 + Client ID 和 Client Secret。Client ID 字段提示为“填写身份服务中为 VoCat 创建的客户端 ID”。 + Scopes 和 PKCE S256 由程序处理,Token 客户端认证根据 + 已验证的 Discovery 自动选择,优先 Basic,其次 Post。保持 SSO 关闭并保存。 + 修改访问地址会立即更新显示的回调地址,保存后用于实际授权。 +4. 检查已保存的配置,成功时显示“配置检查通过”。然后发起账号绑定,完成 Pocket ID Passkey 登录和授权, + 返回 VoCat 确认绑定状态。配置检查仅验证元数据、结构和可达性;真实绑定前不能 + 据此认定 Client Secret 正确或用户获准访问。 +5. 启用并保存 SSO,在隐身窗口验证登录、原页面恢复及登录后的第一次写操作。 + +设置页显示已绑定账号和解绑操作。更换外部账号时,先解绑,再绑定目标账号并重新启用 SSO。 + +Issuer 按发布值精确匹配,包括路径和尾斜杠。Pocket ID 使用经过验证的 ID Token 的 +`issuer + sub` 关联账号,不依赖 UserInfo 端点或邮箱。Discovery、授权、Token 和 JWKS +端点必须与配置的 Issuer 同 origin;首期不支持通过 `INTERNAL_APP_URL` 发布不同 origin +的分离后端。Issuer 仍填写公开 `APP_URL`。已保存的显式客户端认证方式和 Scopes 会继续保留。 + +### GitHub 和 Generic OAuth2 + +GitHub:在 GitHub.com 新建 OAuth App,登记相同的精确回调地址;在 VoCat 选择 +**GitHub**,填写 Client ID 和 Secret,依次保存、检查、绑定、启用。 +端点固定为 GitHub.com,默认 scope 为 `read:user`,强制使用 PKCE S256。 +绑定使用稳定数值账号 ID,修改 GitHub 用户名不会更换绑定身份。首期不支持 GitHub Enterprise。 + +Generic OAuth2:除客户端凭据外,还需填写授权、Token、UserInfo URL。Scopes、顶层 +用户 ID 字段名(默认 `id`)、Token 认证和 PKCE 兼容选项在选择 Generic 时直接显示。 +UserInfo 通过 Bearer access token 请求;用户 ID +必须为非空字符串或整数,大整数保留精确值。默认 Token 认证为 `client_secret_post`, +默认 PKCE 为 S256。只有确实不支持 PKCE 的旧服务才使用关闭选项。 +Generic OAuth2 不提供 OIDC ID Token 验证。 + +### 网络、凭据与会话 + +SSO 路由继续受现有来源网络策略限制,启用 SSO 不会将内网入口自动开放到公网。 +浏览器须能访问 VoCat 和授权页,VoCat 进程须能访问 Discovery、Token 和 JWKS/UserInfo +端点并信任其 TLS 证书。管理员保存的 Pocket ID HTTPS Issuer 或 Generic 各端点 URL +即为精确 origin 许可,可连接 RFC1918/IPv6 ULA 私网 HTTPS 服务; +GitHub 固定端点仍仅允许公网地址。 + +Generic 的内部服务需要 HTTP 时,在 Token/UserInfo 字段显式填写完整 HTTP URL。 +每次 DNS 解析的所有候选和实际拨号地址都必须为获许可的私网。 +Pocket ID 的 Discovery 不会自动批准其他 origin,其端点必须与配置的 Issuer 同 origin。 +Issuer、Discovery 获取地址与浏览器授权页始终要求 HTTPS。IdP 连接继续拒绝 loopback、 +link-local、云元数据和其他特殊用途地址。私有 CA 应安装到主机或容器的受控信任库, +不提供跳过 TLS 验证的开关。VoCat 自身使用 localhost 或 HTTPS 入口,与这些 IdP 出站 +许可相互独立。 + +当前 Compose 使用 host network,应在实际容器内验证 DNS、路由和 CA,不假定桥接网络 +service name 可用。反代入口必须与已保存的公开 origin 一致;回调地址不从任意转发 Host +头推导。首期只支持根路径部署。本地 Vite 开发时统一使用一个 loopback origin,例如 +`http://127.0.0.1:5173`,并在身份平台登记同一回调地址;IdP 出站网络例外仅用于测试 fixture。 + +主 Session 和 CSRF Cookie 保持 `SameSite=Strict`,短期授权 Cookie 使用 +`SameSite=Lax` 以支持顶层 GET 跨站回调。任何成功的 SSO 配置保存,包括停用或轮换密钥, +都会撤销已有 SSO 会话;解绑会撤销旧绑定的 SSO 会话。这些操作保留密码会话和 +API Token。修改本地管理员密码会撤销全部 Session 和 API Token、取消在途授权,并保留 +外部绑定。当前 SSO 会话被成功操作撤销时,界面会提示重新登录。 + +密钥输入留空代表保留旧值,输入新密钥可替换原值;设置页不提供清除已保存密钥的控件。 +更换 Provider、Client ID、Issuer,或 Generic 的身份端点/ID 字段时,会切换身份命名空间, +需要重新填写凭据并绑定;已有绑定时必须明确确认清除。更换绑定账号应先解绑,再重新绑定。 +Client Secret 仅保存在服务器受权限保护的 SQLite 中;Sensitive 标志用于接口脱敏, +不代表数据库加密。第三方 Token 不持久化,也不返回浏览器。 + +普通退出仅结束当前 VoCat 会话。在 IdP 停用用户或退出 Pocket ID,不会立即撤销既有 +VoCat 会话;它会按 `VOCAT_SESSION_TTL` 到期,也可在 VoCat 停用 SSO 或解绑以立即撤销。 +公开登录能力接口为 `GET /api/auth/providers`;SSO 设置和绑定管理仅接受浏览器 Session, +写操作还要求 CSRF,Bearer 不可管理这些凭据。版本冲突返回 `409 sso_config_changed`, +身份源变更需确认时返回 `409 sso_rebind_required`。 + +### 升级与回滚 + +SSO 增加 SQLite schema 29。升级保留原管理员、Session、API Token 和设备业务数据, +且不会自动启用 SSO。升级前记录旧二进制版本或镜像 digest、配置和 CA,并在停服务后 +备份完整持久化目录;在线备份应使用 SQLite backup API 或 `.backup`。 +WAL 模式下不能只裸拷贝正在运行的 `.db` 文件作为可靠备份。 + +配置故障时优先在新版中关闭 SSO,继续使用密码登录并保留当前业务数据。 +回退到只支持 schema 28 的旧程序时,必须停服务并恢复匹配的升级前数据与配置快照。 +仅回退镜像或手工降低 `PRAGMA user_version` 无法降级数据库。建议将旧快照恢复到独立 +目录/卷后切换,并保留新库排查;恢复快照会失去备份后收到的短信和任务变更。 +Docker 通过拉取镜像并重建容器升级。 + +生产发布前,应在实际部署完成 Pocket ID Passkey/访问策略联调和升级、回滚演练。 + ## Telegram 机器人 启用 Telegram 通知并配置好 Chat ID 与 Admin ID 后,机器人支持: diff --git a/internal/server/server.go b/internal/server/server.go index 8bb3c229..c8842b47 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -23,6 +23,7 @@ import ( "vocat/internal/extensions" "vocat/internal/httpsmode" "vocat/internal/loghub" + "vocat/internal/sso" "vocat/internal/store" "vocat/internal/update" "vocat/internal/vowifi" @@ -53,12 +54,16 @@ type Options struct { // MCP, when set, mounts the MCP Streamable HTTP endpoint at /mcp behind // bearer-token authentication. nil keeps the endpoint off. MCP *MCPHandlers + // SSO optionally injects a flow service for protocol and browser tests. + SSO *sso.Service } // Server is the single HTTP handler for the JSON API and embedded SPA. type Server struct { store *store.Store auth *auth.Service + sso *sso.Service + ssoLimiter *ssoRateLimiter devices DeviceController vowifi VoWiFiController ussdSessions ussdSessionStore @@ -131,6 +136,8 @@ func New(options Options) (*Server, error) { server := &Server{ store: options.Store, auth: options.Auth, + sso: options.SSO, + ssoLimiter: newSSORateLimiter(), devices: options.Devices, vowifi: options.VoWiFi, ussdSessions: newUSSDSessionStore(), @@ -160,6 +167,12 @@ func New(options Options) (*Server, error) { updateApply: update.ApplyLatest, updateRestart: update.RestartService, } + if server.sso == nil { + server.sso = sso.New(options.Store, sso.Options{}) + } + if err := server.sso.Cleanup(context.Background()); err != nil { + return nil, fmt.Errorf("server: clean expired SSO flows: %w", err) + } server.cellularDataRuntime() server.loadAccessConfig(context.Background()) server.loadUILanguage(context.Background()) @@ -173,6 +186,13 @@ func New(options Options) (*Server, error) { mux.HandleFunc("/api/auth/login", server.handleLogin) mux.HandleFunc("/api/auth/session", server.handleSession) mux.HandleFunc("/api/auth/logout", server.handleLogout) + mux.HandleFunc("/api/auth/providers", server.handleAuthProviders) + mux.HandleFunc("/api/auth/sso/start", server.handleSSOStart) + mux.HandleFunc("/api/auth/sso/callback", server.handleSSOCallback) + mux.HandleFunc("/api/auth/sso/bind", server.handleSSOBind) + mux.HandleFunc("/api/auth/sso/binding", server.handleSSOUnbind) + mux.HandleFunc("/api/settings/sso", server.handleSSOSettings) + mux.HandleFunc("/api/settings/sso/check", server.handleSSOCheck) mux.HandleFunc("/mcp", server.handleMCP) mux.HandleFunc("/api", server.handleAPI) mux.HandleFunc("/api/", server.handleAPI) diff --git a/internal/server/sso_auth.go b/internal/server/sso_auth.go new file mode 100644 index 00000000..0b35dd1a --- /dev/null +++ b/internal/server/sso_auth.go @@ -0,0 +1,400 @@ +package server + +import ( + "context" + "crypto/subtle" + "encoding/hex" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "vocat/internal/auth" + "vocat/internal/sso" + "vocat/internal/store" +) + +const ( + ssoCookiePrefix = "vocat_sso_" + ssoStartPerMinute = 10 + ssoManagePerMinute = 5 +) + +type ssoRateWindow struct { + start time.Time + count int +} +type ssoRateLimiter struct { + mu sync.Mutex + windows map[string]ssoRateWindow +} + +func newSSORateLimiter() *ssoRateLimiter { + return &ssoRateLimiter{windows: make(map[string]ssoRateWindow)} +} + +func (limiter *ssoRateLimiter) allow(key string, limit int) bool { + limiter.mu.Lock() + defer limiter.mu.Unlock() + now := time.Now() + if len(limiter.windows) >= 2048 { + for candidate, window := range limiter.windows { + if now.Sub(window.start) >= time.Minute { + delete(limiter.windows, candidate) + } + } + } + window := limiter.windows[key] + if window.start.IsZero() || now.Sub(window.start) >= time.Minute { + if window.start.IsZero() && len(limiter.windows) >= 10000 { + return false + } + window = ssoRateWindow{start: now} + } + if window.count >= limit { + return false + } + window.count++ + limiter.windows[key] = window + return true +} + +// requireBrowserSession deliberately rejects Bearer before reading cookies, +// including requests carrying both kinds of credential. +func (s *Server) requireBrowserSession(w http.ResponseWriter, r *http.Request) (auth.AuthenticatedSession, bool) { + w.Header().Set("Cache-Control", "no-store") + if _, present := bearerToken(r); present { + writeError(w, http.StatusForbidden, "session_required", "SSO credentials are managed from a browser session") + return auth.AuthenticatedSession{}, false + } + token, ok := s.sessionToken(w, r) + if !ok { + return auth.AuthenticatedSession{}, false + } + var session auth.AuthenticatedSession + var err error + if mutatingMethod(r.Method) { + csrf, valid := s.validateDoubleSubmitCSRF(w, r) + if !valid { + return auth.AuthenticatedSession{}, false + } + session, err = s.auth.ValidateCSRF(r.Context(), token, csrf) + } else { + session, err = s.auth.Authenticate(r.Context(), token) + } + if err != nil { + switch { + case errors.Is(err, auth.ErrUnauthorized): + s.authenticationRequired(w, r) + case errors.Is(err, auth.ErrInvalidCSRF): + writeError(w, http.StatusForbidden, "invalid_csrf", "CSRF validation failed") + default: + writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred") + } + return auth.AuthenticatedSession{}, false + } + return session, true +} + +func (s *Server) handleAuthProviders(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if !requireMethod(w, r, http.MethodGet) { + return + } + snapshot, err := s.sso.Snapshot(r.Context()) + if err != nil { + s.writeSSOError(w, err) + return + } + capability := map[string]any{"enabled": false} + if snapshot.Config.Enabled && snapshot.Binding != nil && s.ssoCookiePolicyAllows(snapshot.Config.PublicOrigin) && sso.ValidateComplete(snapshot.Config, snapshot.ClientSecret) == nil { + capability = map[string]any{"enabled": true, "display_name": snapshot.Config.DisplayName} + } + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"sso": capability}}) +} + +func (s *Server) handleSSOStart(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Referrer-Policy", "no-referrer") + if !requireMethod(w, r, http.MethodGet) { + return + } + ip := s.currentAccessConfig().clientIP(r).String() + if !s.ssoLimiter.allow("start:"+ip, ssoStartPerMinute) { + w.Header().Set("Retry-After", "60") + s.writeSSOError(w, store.ErrSSORateLimited) + return + } + query, err := url.ParseQuery(r.URL.RawQuery) + if err != nil || len(query["redirect"]) > 1 || len(r.URL.RawQuery) > 16384 { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid redirect parameter") + return + } + snapshot, err := s.sso.Snapshot(r.Context()) + if err != nil { + s.writeSSOError(w, err) + return + } + if !s.ssoCookiePolicyAllows(snapshot.Config.PublicOrigin) { + writeError(w, http.StatusBadRequest, "invalid_sso_config", "HTTPS SSO requires VOCAT_SECURE_COOKIES=true") + return + } + started, err := s.sso.Start(r.Context(), sso.StartRequest{Purpose: "login", ExpectedRevision: snapshot.Config.Revision, ReturnPath: query.Get("redirect"), BrowserFlowIDs: s.ssoBrowserFlows(r)}) + if err != nil { + s.writeSSOError(w, err) + return + } + s.setSSOFlowCookie(w, started.Flow, started.BrowserToken, false) + s.auditSSO(r, "", "auth.sso.start", started.Flow, "success") + http.Redirect(w, r, started.AuthorizationURL, http.StatusFound) +} + +func (s *Server) allowSSOManagement(w http.ResponseWriter, r *http.Request, session auth.AuthenticatedSession, operation string) bool { + ip := s.currentAccessConfig().clientIP(r).String() + if !s.ssoLimiter.allow(operation+":ip:"+ip, ssoManagePerMinute) || !s.ssoLimiter.allow(operation+":session:"+hex.EncodeToString(session.TokenHash()), ssoManagePerMinute) { + w.Header().Set("Retry-After", "60") + s.writeSSOError(w, store.ErrSSORateLimited) + return false + } + return true +} + +func (s *Server) handleSSOBind(w http.ResponseWriter, r *http.Request) { + if !requireMethod(w, r, http.MethodPost) { + return + } + session, ok := s.requireBrowserSession(w, r) + if !ok { + return + } + if !s.allowSSOManagement(w, r, session, "bind") { + return + } + var request struct { + ExpectedRevision *int64 `json:"expected_revision"` + } + if err := s.decodeJSON(w, r, &request); err != nil || request.ExpectedRevision == nil || *request.ExpectedRevision < 0 { + writeError(w, http.StatusBadRequest, "invalid_request", "a saved configuration revision is required") + return + } + started, err := s.sso.Start(r.Context(), sso.StartRequest{ + Purpose: "bind", ExpectedRevision: *request.ExpectedRevision, ReturnPath: "/settings", + AdminID: session.Principal.ID, SessionHash: session.TokenHash(), SessionExpiresAt: session.ExpiresAt, + BrowserFlowIDs: s.ssoBrowserFlows(r), + }) + if err != nil { + s.writeSSOError(w, err) + return + } + s.setSSOFlowCookie(w, started.Flow, started.BrowserToken, false) + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"authorization_url": started.AuthorizationURL}}) +} + +func (s *Server) handleSSOUnbind(w http.ResponseWriter, r *http.Request) { + if !requireMethod(w, r, http.MethodDelete) { + return + } + session, ok := s.requireBrowserSession(w, r) + if !ok { + return + } + var request struct { + ExpectedRevision *int64 `json:"expected_revision"` + } + if err := s.decodeJSON(w, r, &request); err != nil || request.ExpectedRevision == nil || *request.ExpectedRevision < 0 { + writeError(w, http.StatusBadRequest, "invalid_request", "a saved configuration revision is required") + return + } + snapshot, revoked, err := s.store.UnbindSSO(r.Context(), *request.ExpectedRevision, session.TokenHash()) + if err != nil { + s.writeSSOError(w, err) + return + } + if revoked { + s.clearAuthCookies(w) + } + s.recordAudit(r.Context(), session.Principal.Username, "auth.sso.unbind", "sso", fmt.Sprintf("%s:%d", snapshot.Config.Kind, snapshot.Config.Revision), "success", requestRemoteHost(r)) + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"reauthentication_required": revoked}}) +} + +func (s *Server) handleSSOCallback(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Referrer-Policy", "no-referrer") + if !requireMethod(w, r, http.MethodGet) { + return + } + query, err := url.ParseQuery(r.URL.RawQuery) + if err != nil || len(r.URL.RawQuery) > 16384 || len(query["state"]) != 1 || len(query.Get("state")) > 256 { + s.redirectSSOError(w, r, nil, "invalid_flow") + return + } + for _, name := range []string{"code", "error", "iss", "error_description", "error_uri"} { + if len(query[name]) > 1 || len(query.Get(name)) > 4096 { + s.redirectSSOError(w, r, nil, "invalid_flow") + return + } + } + flow, err := s.sso.FindFlow(r.Context(), query.Get("state")) + if err != nil { + s.redirectSSOError(w, r, nil, "invalid_flow") + return + } + cookie, err := r.Cookie(ssoCookiePrefix + flow.ID) + if err != nil || subtle.ConstantTimeCompare(auth.TokenHash(cookie.Value), flow.BrowserTokenHash) != 1 { + s.redirectSSOError(w, r, nil, "invalid_flow") + return + } + claimed, err := s.sso.Claim(r.Context(), query.Get("state"), cookie.Value) + s.setSSOFlowCookie(w, flow, "", true) + if err != nil { + s.redirectSSOError(w, r, &flow, ssoCallbackError(err)) + return + } + flow = claimed + defer func() { + cleanupContext, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 2*time.Second) + defer cancel() + _ = s.store.DeleteSSOFlow(cleanupContext, flow.ID) + }() + if _, present := query["iss"]; present && query.Get("iss") == "" { + s.redirectSSOError(w, r, &flow, "invalid_identity") + return + } + code, providerError := query.Get("code"), query.Get("error") + if code == "" && providerError != "" { + category := "provider_unavailable" + if providerError == "access_denied" { + category = "access_denied" + } + s.redirectSSOError(w, r, &flow, category) + return + } + if code == "" || providerError != "" { + s.redirectSSOError(w, r, &flow, "invalid_flow") + return + } + if flow.Purpose == "login" && !s.ssoCookiePolicyAllows(flow.RedirectURI) { + s.redirectSSOError(w, r, &flow, "config_changed") + return + } + identity, err := s.sso.Verify(r.Context(), flow, code, query.Get("iss")) + if err != nil { + s.redirectSSOError(w, r, &flow, ssoCallbackError(err)) + return + } + if flow.Purpose == "bind" { + _, err := s.store.FinalizeSSOBinding(r.Context(), flow, identity) + if err != nil { + s.redirectSSOError(w, r, &flow, ssoCallbackError(err)) + return + } + s.auditSSO(r, "", "auth.sso.bind", flow, "success") + http.Redirect(w, r, "/settings?sso_result=bound", http.StatusSeeOther) + return + } + admin, err := s.store.CurrentAdmin(r.Context()) + if err != nil { + s.redirectSSOError(w, r, &flow, "invalid_flow") + return + } + credentials, err := s.auth.PrepareCredentials(auth.Principal{ID: admin.ID, Username: admin.Username}) + if err != nil { + s.redirectSSOError(w, r, &flow, "provider_unavailable") + return + } + _, err = s.store.FinalizeSSOLogin(r.Context(), flow, identity, store.SSOSessionMaterial{ + TokenHash: auth.TokenHash(credentials.SessionToken), CSRFHash: auth.TokenHash(credentials.CSRFToken), ExpiresAt: credentials.ExpiresAt, + }) + if err != nil { + s.redirectSSOError(w, r, &flow, ssoCallbackError(err)) + return + } + s.setAuthCookies(w, credentials.SessionToken, credentials.CSRFToken, credentials.ExpiresAt) + s.auditSSO(r, admin.Username, "auth.sso.login", flow, "success") + http.Redirect(w, r, sso.SafeRedirect(flow.ReturnPath, ""), http.StatusSeeOther) +} + +func (s *Server) redirectSSOError(w http.ResponseWriter, r *http.Request, flow *store.SSOFlow, category string) { + query := url.Values{"sso_error": {category}} + path := "/login" + if flow != nil { + if flow.Purpose == "bind" { + path = "/settings" + } else { + query.Set("redirect", sso.SafeRedirect(flow.ReturnPath, "")) + } + // Invalid callbacks are independently bounded to avoid log amplification. + if s.ssoLimiter.allow("callback-log:"+requestRemoteHost(r), ssoStartPerMinute) { + s.auditSSO(r, "", "auth.sso."+flow.Purpose, *flow, category) + } + } + http.Redirect(w, r, path+"?"+query.Encode(), http.StatusSeeOther) +} + +func ssoCallbackError(err error) string { + switch { + case errors.Is(err, store.ErrSSOConfigChanged), errors.Is(err, store.ErrSSODisabled): + return "config_changed" + case errors.Is(err, store.ErrSSOFlowExpired): + return "flow_expired" + case errors.Is(err, store.ErrSSOIdentityNotBound): + return "identity_not_bound" + case errors.Is(err, store.ErrSSOBindingExists): + return "binding_exists" + case errors.Is(err, sso.ErrInvalidIdentity), errors.Is(err, store.ErrSSOInvalidIdentity): + return "invalid_identity" + case errors.Is(err, store.ErrSSOInvalidFlow), errors.Is(err, store.ErrNotFound): + return "invalid_flow" + default: + return "provider_unavailable" + } +} + +func (s *Server) setSSOFlowCookie(w http.ResponseWriter, flow store.SSOFlow, token string, clear bool) { + maxAge := int(time.Until(flow.ExpiresAt).Seconds()) + if maxAge < 1 { + maxAge = 1 + } + cookie := &http.Cookie{Name: ssoCookiePrefix + flow.ID, Value: token, Path: "/api/auth/sso", HttpOnly: true, Secure: strings.HasPrefix(flow.RedirectURI, "https://"), SameSite: http.SameSiteLaxMode, Expires: flow.ExpiresAt, MaxAge: maxAge} + if clear { + cookie.Value = "" + cookie.MaxAge = -1 + cookie.Expires = time.Unix(1, 0) + } + http.SetCookie(w, cookie) +} + +func (s *Server) ssoCookiePolicyAllows(origin string) bool { + return !strings.HasPrefix(origin, "https://") || s.secureCookies +} + +func (s *Server) ssoBrowserFlows(r *http.Request) []string { + ids := make([]string, 0, store.SSOMaxBrowserFlows) + for _, cookie := range r.Cookies() { + if id, ok := strings.CutPrefix(cookie.Name, ssoCookiePrefix); ok && len(id) <= 64 && len(id) >= 16 { + ids = append(ids, id) + if len(ids) >= 100 { + break + } + } + } + return ids +} + +func (s *Server) auditSSO(r *http.Request, actor, action string, flow store.SSOFlow, outcome string) { + kind := "changed" + if snapshot, err := s.sso.Snapshot(r.Context()); err == nil && snapshot.Config.ProviderInstanceID == flow.ProviderInstanceID { + kind = snapshot.Config.Kind + } + if actor == "" && flow.AdminID != 0 { + if admin, err := s.store.CurrentAdmin(r.Context()); err == nil && admin.ID == flow.AdminID { + actor = admin.Username + } + } + s.recordAudit(r.Context(), actor, action, "sso", fmt.Sprintf("%s:%d", kind, flow.ConfigRevision), outcome, requestRemoteHost(r)) +} + +func (s *Server) StartSSOCleanup(ctx context.Context) { go s.sso.RunCleanup(ctx) } diff --git a/internal/server/sso_auth_test.go b/internal/server/sso_auth_test.go new file mode 100644 index 00000000..84542447 --- /dev/null +++ b/internal/server/sso_auth_test.go @@ -0,0 +1,715 @@ +package server + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "sync/atomic" + "testing" + "testing/fstest" + "time" + + "golang.org/x/crypto/bcrypt" + + "github.com/go-jose/go-jose/v4" + + "vocat/internal/auth" + "vocat/internal/sso" + "vocat/internal/store" +) + +type ssoTestTransport func(*http.Request) (*http.Response, error) + +func (transport ssoTestTransport) RoundTrip(r *http.Request) (*http.Response, error) { + return transport(r) +} + +type ssoHTTPFixture struct { + server *httptest.Server + client *http.Client + store *store.Store + auth *auth.Service + handler *Server + providerClient *http.Client +} + +func newSSOHTTPFixture(t *testing.T) *ssoHTTPFixture { + t.Helper() + database, err := store.Open(context.Background(), ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { database.Close() }) + authentication, err := auth.New(database, auth.Options{SessionTTL: time.Hour, BcryptCost: bcrypt.MinCost}) + if err != nil { + t.Fatal(err) + } + if err := authentication.EnsureAdmin(context.Background(), "admin", "correct-password"); err != nil { + t.Fatal(err) + } + providerClient := &http.Client{Transport: ssoTestTransport(func(r *http.Request) (*http.Response, error) { + body := "" + switch r.URL.Path { + case "/token": + if err := r.ParseForm(); err != nil { + return nil, err + } + if r.Form.Get("client_secret") != "fixture-client-secret" || r.Form.Get("code_verifier") == "" || !strings.HasSuffix(r.Form.Get("redirect_uri"), "/api/auth/sso/callback") { + return nil, fmt.Errorf("invalid token request") + } + body = `{"access_token":"fixture-external-token","token_type":"Bearer"}` + case "/user": + if r.Header.Get("Authorization") != "Bearer fixture-external-token" { + return nil, fmt.Errorf("missing access token") + } + body = `{"ExternalID":900719925474099312345,"name":"Fixture account","email":"fixture@example.test"}` + default: + return nil, fmt.Errorf("unexpected IdP endpoint") + } + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil + })} + handler, err := New(Options{Store: database, Auth: authentication, + Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("fixture")}}, + SSO: sso.New(database, sso.Options{HTTPClient: providerClient}), + }) + if err != nil { + t.Fatal(err) + } + httpServer := httptest.NewServer(handler) + t.Cleanup(httpServer.Close) + jar, _ := cookiejar.New(nil) + return &ssoHTTPFixture{server: httpServer, store: database, auth: authentication, handler: handler, providerClient: providerClient, client: &http.Client{Jar: jar, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}} +} + +func (fixture *ssoHTTPFixture) request(t *testing.T, client *http.Client, method, path string, payload any, csrf string, cookies ...*http.Cookie) (*http.Response, map[string]any) { + t.Helper() + var reader io.Reader + if payload != nil { + encoded, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + reader = bytes.NewReader(encoded) + } + r, err := http.NewRequest(method, fixture.server.URL+path, reader) + if err != nil { + t.Fatal(err) + } + r.Header.Set("Accept", "application/json") + if payload != nil { + r.Header.Set("Content-Type", "application/json") + } + if csrf != "" { + r.Header.Set(csrfHeaderName, csrf) + } + for _, cookie := range cookies { + r.AddCookie(cookie) + } + response, err := client.Do(r) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + raw, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + var body map[string]any + _ = json.Unmarshal(raw, &body) + return response, body +} + +func (fixture *ssoHTTPFixture) login(t *testing.T) string { + t.Helper() + response, body := fixture.request(t, fixture.client, "POST", "/api/auth/login", map[string]any{"username": "admin", "password": "correct-password"}, "") + if response.StatusCode != 200 { + t.Fatalf("login = %d: %#v", response.StatusCode, body) + } + return body["data"].(map[string]any)["csrf_token"].(string) +} + +func (fixture *ssoHTTPFixture) config() map[string]any { + return map[string]any{"kind": "generic", "display_name": "Fixture", "enabled": false, + "public_origin": fixture.server.URL, "client_id": "fixture-client", "auth_url": "https://id.example.test/authorize", + "token_url": "https://id.example.test/token", "user_info_url": "https://id.example.test/user", "user_id_field": "ExternalID", + "scopes": []string{"profile"}, "token_auth_method": "client_secret_post", "pkce_mode": "s256"} +} + +func ssoFlowCookie(t *testing.T, response *http.Response) *http.Cookie { + t.Helper() + for _, cookie := range response.Cookies() { + if strings.HasPrefix(cookie.Name, ssoCookiePrefix) { + if !cookie.HttpOnly || cookie.SameSite != http.SameSiteLaxMode || cookie.Path != "/api/auth/sso" || cookie.Domain != "" { + t.Fatalf("invalid flow cookie: %#v", cookie) + } + return cookie + } + } + t.Fatal("flow cookie missing") + return nil +} + +func assertSSOResponseFields(t *testing.T, data map[string]any, fields ...string) { + t.Helper() + if len(data) != len(fields) { + t.Fatalf("unexpected response fields: %#v, want %v", data, fields) + } + for _, field := range fields { + if _, ok := data[field]; !ok { + t.Errorf("response field %q missing from %#v", field, data) + } + } +} + +func TestSSOHTTPBindLoginAndRevocation(t *testing.T) { + f := newSSOHTTPFixture(t) + ctx := context.Background() + csrf := f.login(t) + apiCredential, err := f.auth.CreateAPIToken(ctx, "persistent-token", false, 0) + if err != nil { + t.Fatal(err) + } + config := f.config() + response, body := f.request(t, f.client, "PUT", "/api/settings/sso", map[string]any{"expected_revision": 0, "config": config, "client_secret": "fixture-client-secret"}, csrf) + if response.StatusCode != 200 { + t.Fatalf("save = %d: %#v", response.StatusCode, body) + } + assertSSOResponseFields(t, body["data"].(map[string]any), "config", "binding", "configured", "has_client_secret", "reauthentication_required") + encoded, _ := json.Marshal(body) + if strings.Contains(string(encoded), "fixture-client-secret") || strings.Contains(string(encoded), "fixture-external-token") { + t.Fatal("response leaked secret") + } + response, body = f.request(t, f.client, "POST", "/api/settings/sso/check", map[string]any{"expected_revision": 1}, csrf) + if response.StatusCode != 200 || body["data"].(map[string]any)["revision"] != float64(1) { + t.Fatalf("check = %d: %#v", response.StatusCode, body) + } + assertSSOResponseFields(t, body["data"].(map[string]any), "revision") + response, body = f.request(t, f.client, "POST", "/api/auth/sso/bind", map[string]any{"expected_revision": 1}, csrf) + if response.StatusCode != 200 { + t.Fatalf("bind = %d: %#v", response.StatusCode, body) + } + assertSSOResponseFields(t, body["data"].(map[string]any), "authorization_url") + flowCookie := ssoFlowCookie(t, response) + authorize, _ := url.Parse(body["data"].(map[string]any)["authorization_url"].(string)) + if authorize.Query().Get("code_challenge_method") != "S256" { + t.Fatal("PKCE missing") + } + callback := "/api/auth/sso/callback?code=fixture-code&state=" + url.QueryEscape(authorize.Query().Get("state")) + anonymous := &http.Client{CheckRedirect: f.client.CheckRedirect} + // This request intentionally carries no main Strict session cookie. + response, _ = f.request(t, anonymous, "GET", callback, nil, "", flowCookie) + if response.StatusCode != 303 || response.Header.Get("Location") != "/settings?sso_result=bound" { + t.Fatalf("cross-site bind = %d %s", response.StatusCode, response.Header.Get("Location")) + } + for _, cookie := range response.Cookies() { + if cookie.Name == sessionCookieName || cookie.Name == csrfCookieName { + t.Fatal("binding must not create or clear main credentials") + } + } + snapshot, err := f.store.SSOSnapshot(ctx) + if err != nil || snapshot.Binding == nil || snapshot.Binding.Subject != "900719925474099312345" || snapshot.Config.Revision != 2 { + t.Fatalf("bound snapshot: %#v, %v", snapshot, err) + } + config["enabled"] = true + response, body = f.request(t, f.client, "PUT", "/api/settings/sso", map[string]any{"expected_revision": 2, "config": config}, csrf) + if response.StatusCode != 200 { + t.Fatalf("enable = %d: %#v", response.StatusCode, body) + } + response, body = f.request(t, anonymous, "GET", "/api/auth/providers", nil, "") + if response.StatusCode != 200 || body["data"].(map[string]any)["sso"].(map[string]any)["enabled"] != true { + t.Fatalf("providers = %#v", body) + } + assertSSOResponseFields(t, body["data"].(map[string]any), "sso") + assertSSOResponseFields(t, body["data"].(map[string]any)["sso"].(map[string]any), "enabled", "display_name") + response, _ = f.request(t, anonymous, "GET", "/api/auth/sso/start?redirect="+url.QueryEscape("/devices/modem-1?tab=sms#latest"), nil, "") + if response.StatusCode != 302 { + t.Fatalf("start = %d", response.StatusCode) + } + flowCookie = ssoFlowCookie(t, response) + authorize, _ = url.Parse(response.Header.Get("Location")) + callback = "/api/auth/sso/callback?code=fixture-code&state=" + url.QueryEscape(authorize.Query().Get("state")) + // An unrelated browser cannot consume the valid flow. + response, _ = f.request(t, anonymous, "GET", callback, nil, "") + if !strings.Contains(response.Header.Get("Location"), "invalid_flow") { + t.Fatal("missing browser association accepted") + } + response, _ = f.request(t, anonymous, "GET", callback, nil, "", flowCookie) + if response.StatusCode != 303 || response.Header.Get("Location") != "/devices/modem-1?tab=sms#latest" { + t.Fatalf("SSO login = %d %s", response.StatusCode, response.Header.Get("Location")) + } + var sessionCookie, csrfCookie *http.Cookie + for _, cookie := range response.Cookies() { + if cookie.Name == sessionCookieName { + sessionCookie = cookie + } + if cookie.Name == csrfCookieName { + csrfCookie = cookie + } + } + if sessionCookie == nil || csrfCookie == nil || sessionCookie.SameSite != http.SameSiteStrictMode { + t.Fatal("missing Strict local credentials") + } + if authenticated, err := f.auth.Authenticate(ctx, sessionCookie.Value); err != nil || authenticated.LoginMethod != "sso" { + t.Fatalf("SSO session = %#v %v", authenticated, err) + } + // Replay fails without clearing the successful local session. + response, _ = f.request(t, anonymous, "GET", callback, nil, "", flowCookie, sessionCookie, csrfCookie) + for _, cookie := range response.Cookies() { + if cookie.Name == sessionCookieName { + t.Fatal("replay cleared a valid session") + } + } + config["enabled"] = false + response, body = f.request(t, anonymous, "PUT", "/api/settings/sso", map[string]any{"expected_revision": 3, "config": config}, csrfCookie.Value, sessionCookie, csrfCookie) + if response.StatusCode != 200 || body["data"].(map[string]any)["reauthentication_required"] != true { + t.Fatalf("SSO save reauth = %d: %#v", response.StatusCode, body) + } + if _, err := f.auth.Authenticate(ctx, sessionCookie.Value); err == nil { + t.Fatal("SSO session survived saving config") + } + if _, err := f.auth.AuthenticateAPIToken(ctx, apiCredential.Token); err != nil { + t.Fatalf("API token revoked: %v", err) + } + response, body = f.request(t, f.client, "GET", "/api/settings/sso", nil, "") + if response.StatusCode != 200 { + t.Fatal("password session revoked") + } + assertSSOResponseFields(t, body["data"].(map[string]any), "config", "binding", "configured", "has_client_secret") + // A restart with SecureCookies disabled must not expose a previously saved + // HTTPS SSO configuration or deliver fresh insecure primary credentials. + snapshot, err = f.store.SSOSnapshot(ctx) + if err != nil { + t.Fatal(err) + } + snapshot.Config.Enabled, snapshot.Config.PublicOrigin = true, "https://vocat.example.test" + if _, _, err := f.store.SaveSSOConfiguration(ctx, store.SSOSaveRequest{ExpectedRevision: snapshot.Config.Revision, Config: snapshot.Config}); err != nil { + t.Fatal(err) + } + response, body = f.request(t, anonymous, "GET", "/api/auth/providers", nil, "") + if body["data"].(map[string]any)["sso"].(map[string]any)["enabled"] != false { + t.Fatal("restart advertised HTTPS SSO with insecure cookies") + } + assertSSOResponseFields(t, body["data"].(map[string]any), "sso") + assertSSOResponseFields(t, body["data"].(map[string]any)["sso"].(map[string]any), "enabled") + response, body = f.request(t, anonymous, "GET", "/api/auth/sso/start", nil, "") + if response.StatusCode != 400 || body["error"].(map[string]any)["code"] != "invalid_sso_config" { + t.Fatal("restart accepted HTTPS SSO with insecure cookies") + } + snapshot, err = f.store.SSOSnapshot(ctx) + if err != nil { + t.Fatal(err) + } + response, body = f.request(t, f.client, "DELETE", "/api/auth/sso/binding", map[string]any{"expected_revision": snapshot.Config.Revision}, csrf) + if response.StatusCode != http.StatusOK || body["data"].(map[string]any)["reauthentication_required"] != false { + t.Fatalf("password-session unbind = %d: %#v", response.StatusCode, body) + } + assertSSOResponseFields(t, body["data"].(map[string]any), "reauthentication_required") +} + +func TestSSOCredentialManagementRejectsBearerAndKeepsSession(t *testing.T) { + f := newSSOHTTPFixture(t) + csrf := f.login(t) + credential, err := f.auth.CreateAPIToken(context.Background(), "management-probe", false, 0) + if err != nil { + t.Fatal(err) + } + for _, endpoint := range []struct{ method, path string }{ + {"GET", "/api/settings/sso"}, {"PUT", "/api/settings/sso"}, {"POST", "/api/settings/sso/check"}, {"POST", "/api/auth/sso/bind"}, {"DELETE", "/api/auth/sso/binding"}, + } { + r, _ := http.NewRequest(endpoint.method, f.server.URL+endpoint.path, strings.NewReader(`{}`)) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("Authorization", "Bearer "+credential.Token) + r.Header.Set(csrfHeaderName, csrf) + response, err := f.client.Do(r) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != 403 { + t.Errorf("mixed Bearer+Cookie %s = %d", endpoint.path, response.StatusCode) + } + } + response, _ := f.request(t, f.client, "GET", "/api/auth/session", nil, "") + if response.StatusCode != 200 { + t.Fatal("rejected management request revoked local session") + } +} + +func TestSSORemovedManagementFieldsRejectedWithoutChangingState(t *testing.T) { + f := newSSOHTTPFixture(t) + csrf := f.login(t) + response, body := f.request(t, f.client, "PUT", "/api/settings/sso", map[string]any{"expected_revision": 0, "config": f.config(), "client_secret": "fixture-client-secret"}, csrf) + if response.StatusCode != http.StatusOK { + t.Fatalf("save = %d: %#v", response.StatusCode, body) + } + before, err := f.store.SSOSnapshot(context.Background()) + if err != nil { + t.Fatal(err) + } + for _, field := range []struct { + name string + value any + }{ + {"replace_existing", true}, {"replace_existing", false}, {"current_password", "wrong"}, + } { + payload := map[string]any{"expected_revision": before.Config.Revision, field.name: field.value} + response, body = f.request(t, f.client, "POST", "/api/auth/sso/bind", payload, csrf) + if response.StatusCode != http.StatusBadRequest || body["error"].(map[string]any)["code"] != "invalid_request" { + t.Fatalf("removed bind field %s was accepted: %d %#v", field.name, response.StatusCode, body) + } + for _, cookie := range response.Cookies() { + if strings.HasPrefix(cookie.Name, ssoCookiePrefix) { + t.Fatal("rejected bind request created a flow cookie") + } + } + } + for _, clear := range []bool{true, false} { + response, body = f.request(t, f.client, "PUT", "/api/settings/sso", map[string]any{"expected_revision": before.Config.Revision, "config": f.config(), "clear_client_secret": clear}, csrf) + if response.StatusCode != http.StatusBadRequest || body["error"].(map[string]any)["code"] != "invalid_request" { + t.Fatalf("removed clear field was accepted: %d %#v", response.StatusCode, body) + } + } + after, err := f.store.SSOSnapshot(context.Background()) + if err != nil || !reflect.DeepEqual(before, after) { + t.Fatalf("rejected obsolete fields changed configuration, secret, or binding: %v", err) + } + response, _ = f.request(t, f.client, "GET", "/api/auth/session", nil, "") + if response.StatusCode != http.StatusOK { + t.Fatal("obsolete management fields revoked the password session") + } +} + +func TestSSOSecretAndRevisionRequestValidation(t *testing.T) { + f := newSSOHTTPFixture(t) + csrf := f.login(t) + for _, secret := range []any{"", nil, "********", "••••••"} { + response, _ := f.request(t, f.client, "PUT", "/api/settings/sso", map[string]any{"expected_revision": 0, "config": f.config(), "client_secret": secret}, csrf) + if response.StatusCode != 400 { + t.Errorf("invalid secret %v = %d", secret, response.StatusCode) + } + } + config := f.config() + config["revision"] = 99 + response, _ := f.request(t, f.client, "PUT", "/api/settings/sso", map[string]any{"expected_revision": 0, "config": config}, csrf) + if response.StatusCode != 400 { + t.Fatal("accepted read-only config revision") + } + response, _ = f.request(t, f.client, "PUT", "/api/settings/sso", map[string]any{"config": f.config()}, csrf) + if response.StatusCode != 400 { + t.Fatal("accepted missing expected_revision") + } + response, _ = f.request(t, f.client, "PUT", "/api/settings/sso", map[string]any{"expected_revision": 10, "config": f.config()}, csrf) + if response.StatusCode != 409 { + t.Fatal("accepted stale revision") + } + // Scheme normalization must precede the HTTPS/Secure cookie policy check. + config = f.config() + config["enabled"], config["public_origin"] = true, "HTTPS://vocat.example.test" + response, body := f.request(t, f.client, "PUT", "/api/settings/sso", map[string]any{"expected_revision": 0, "config": config}, csrf) + if response.StatusCode != 400 || body["error"].(map[string]any)["code"] != "invalid_sso_config" { + t.Fatalf("uppercase HTTPS bypassed secure cookie requirement: %d %#v", response.StatusCode, body) + } +} + +func TestSSOSettingsSaveExplicitPrivateBackendDraftWithoutConnectionOptions(t *testing.T) { + f := newSSOHTTPFixture(t) + csrf := f.login(t) + config := f.config() + config["auth_url"] = "https://id.nas.invalid/authorize" + config["token_url"] = "http://192.168.1.20:1411/token" + config["user_info_url"] = "http://id.nas.invalid:1411/userinfo" + response, body := f.request(t, f.client, "PUT", "/api/settings/sso", map[string]any{"expected_revision": 0, "config": config}, csrf) + if response.StatusCode != http.StatusOK { + t.Fatalf("explicit private backend draft was not saved offline: %d %#v", response.StatusCode, body) + } + saved := body["data"].(map[string]any)["config"].(map[string]any) + for _, field := range []string{"auth_url", "token_url", "user_info_url"} { + if saved[field] != config[field] { + t.Errorf("saved %s changed: %v want %v", field, saved[field], config[field]) + } + } + supportedFields := map[string]bool{ + "revision": true, "enabled": true, "kind": true, "display_name": true, + "public_origin": true, "client_id": true, "issuer_url": true, "auth_url": true, "token_url": true, + "user_info_url": true, "user_id_field": true, "scopes": true, "token_auth_method": true, "pkce_mode": true, + } + for field := range saved { + if !supportedFields[field] { + t.Errorf("settings API returned an unsupported configuration field: %s", field) + } + } + config["arbitrary_network_permission"] = true + response, _ = f.request(t, f.client, "PUT", "/api/settings/sso", map[string]any{"expected_revision": saved["revision"], "config": config}, csrf) + if response.StatusCode != http.StatusBadRequest { + t.Fatalf("settings API accepted an unsupported network permission: %d", response.StatusCode) + } +} + +func TestSSOCheckFetchesDiscoveryAndJWKSWithoutBindingAnAccount(t *testing.T) { + f := newSSOHTTPFixture(t) + csrf := f.login(t) + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + jwks, err := json.Marshal(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{Key: &key.PublicKey, KeyID: "check-key", Use: "sig", Algorithm: string(jose.ES256)}}}) + if err != nil { + t.Fatal(err) + } + var discoveryCalls, jwksCalls, credentialCalls atomic.Int64 + var rejectDiscovery, rejectJWKS atomic.Bool + f.providerClient.Transport = ssoTestTransport(func(r *http.Request) (*http.Response, error) { + status, body := http.StatusOK, "" + switch r.URL.Path { + case "/.well-known/openid-configuration": + discoveryCalls.Add(1) + metadata := map[string]any{ + "issuer": "https://id.example.test", "authorization_endpoint": "https://id.example.test/authorize", + "token_endpoint": "https://id.example.test/token", "jwks_uri": "https://id.example.test/jwks", + "id_token_signing_alg_values_supported": []string{"ES256"}, "code_challenge_methods_supported": []string{"S256"}, + "token_endpoint_auth_methods_supported": []string{"client_secret_basic"}, "response_types_supported": []string{"code"}, + } + if rejectDiscovery.Load() { + metadata["issuer"] = "https://different.example.test" + } + encoded, _ := json.Marshal(metadata) + body = string(encoded) + case "/jwks": + jwksCalls.Add(1) + body = string(jwks) + if rejectJWKS.Load() { + status, body = http.StatusServiceUnavailable, "unavailable" + } + default: + credentialCalls.Add(1) + return nil, fmt.Errorf("configuration check contacted a credential endpoint") + } + if r.Method != http.MethodGet || r.Header.Get("Authorization") != "" { + t.Error("configuration metadata request carried credentials or used a non-GET method") + } + return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil + }) + config := map[string]any{"kind": "pocketid", "enabled": false, "public_origin": f.server.URL, "client_id": "check-client", "issuer_url": "https://id.example.test"} + response, body := f.request(t, f.client, "PUT", "/api/settings/sso", map[string]any{"expected_revision": 0, "config": config, "client_secret": "check-only-secret"}, csrf) + if response.StatusCode != http.StatusOK || discoveryCalls.Load() != 0 || jwksCalls.Load() != 0 { + t.Fatalf("disabled draft save performed remote validation or failed: %d %#v", response.StatusCode, body) + } + before, err := f.store.SSOSnapshot(context.Background()) + if err != nil { + t.Fatal(err) + } + response, body = f.request(t, f.client, "POST", "/api/settings/sso/check", map[string]any{"expected_revision": before.Config.Revision}, csrf) + if response.StatusCode != http.StatusOK || discoveryCalls.Load() != 1 || jwksCalls.Load() != 1 || credentialCalls.Load() != 0 { + t.Fatalf("check skipped real Discovery/JWKS or contacted credential endpoints: %d %#v", response.StatusCode, body) + } + assertSSOResponseFields(t, body["data"].(map[string]any), "revision") + for _, cookie := range response.Cookies() { + if cookie.Name == sessionCookieName || cookie.Name == csrfCookieName { + t.Fatal("configuration check issued local credentials") + } + } + rejectJWKS.Store(true) + response, body = f.request(t, f.client, "POST", "/api/settings/sso/check", map[string]any{"expected_revision": before.Config.Revision}, csrf) + if response.StatusCode != http.StatusBadGateway || body["error"].(map[string]any)["code"] != "sso_provider_unavailable" || jwksCalls.Load() != 2 { + t.Fatalf("failed JWKS fetch was ignored: %d %#v", response.StatusCode, body) + } + rejectDiscovery.Store(true) + response, body = f.request(t, f.client, "POST", "/api/settings/sso/check", map[string]any{"expected_revision": before.Config.Revision}, csrf) + if response.StatusCode != http.StatusBadGateway || body["error"].(map[string]any)["code"] != "sso_provider_unavailable" || discoveryCalls.Load() != 3 || jwksCalls.Load() != 2 { + t.Fatalf("invalid Discovery was ignored: %d %#v", response.StatusCode, body) + } + after, err := f.store.SSOSnapshot(context.Background()) + if err != nil || !reflect.DeepEqual(before, after) || after.Binding != nil || credentialCalls.Load() != 0 { + t.Fatalf("configuration checks changed authorization state: %v", err) + } +} + +func TestSSOBindCallbackPreservesSameIdentityAndRejectsDifferentIdentity(t *testing.T) { + f := newSSOHTTPFixture(t) + csrf := f.login(t) + response, body := f.request(t, f.client, "PUT", "/api/settings/sso", map[string]any{"expected_revision": 0, "config": f.config(), "client_secret": "fixture-client-secret"}, csrf) + if response.StatusCode != http.StatusOK { + t.Fatalf("save = %d: %#v", response.StatusCode, body) + } + callback := func(revision int64) *http.Response { + t.Helper() + response, body := f.request(t, f.client, "POST", "/api/auth/sso/bind", map[string]any{"expected_revision": revision}, csrf) + if response.StatusCode != http.StatusOK { + t.Fatalf("bind = %d: %#v", response.StatusCode, body) + } + authorize, _ := url.Parse(body["data"].(map[string]any)["authorization_url"].(string)) + anonymous := &http.Client{CheckRedirect: f.client.CheckRedirect} + response, _ = f.request(t, anonymous, "GET", "/api/auth/sso/callback?code=fixture-code&state="+url.QueryEscape(authorize.Query().Get("state")), nil, "", ssoFlowCookie(t, response)) + for _, cookie := range response.Cookies() { + if cookie.Name == sessionCookieName || cookie.Name == csrfCookieName { + t.Fatal("binding callback altered main credentials") + } + } + return response + } + response = callback(1) + if response.StatusCode != http.StatusSeeOther || response.Header.Get("Location") != "/settings?sso_result=bound" { + t.Fatalf("initial bind = %d %s", response.StatusCode, response.Header.Get("Location")) + } + first, err := f.store.SSOSnapshot(context.Background()) + if err != nil || first.Binding == nil { + t.Fatal("initial binding was not stored") + } + response = callback(first.Config.Revision) + if response.StatusCode != http.StatusSeeOther || response.Header.Get("Location") != "/settings?sso_result=bound" { + t.Fatalf("same identity bind = %d %s", response.StatusCode, response.Header.Get("Location")) + } + same, err := f.store.SSOSnapshot(context.Background()) + if err != nil || same.Binding == nil || same.Binding.ID != first.Binding.ID || same.Config.Revision != first.Config.Revision+1 { + t.Fatalf("same identity bind changed the binding identity: %v", err) + } + baseTransport := f.providerClient.Transport + f.providerClient.Transport = ssoTestTransport(func(r *http.Request) (*http.Response, error) { + response, err := baseTransport.RoundTrip(r) + if err == nil && r.URL.Path == "/user" { + response.Body.Close() + response.Body = io.NopCloser(strings.NewReader(`{"ExternalID":"different-account","name":"Other account"}`)) + } + return response, err + }) + response = callback(same.Config.Revision) + if response.StatusCode != http.StatusSeeOther || response.Header.Get("Location") != "/settings?sso_error=binding_exists" { + t.Fatalf("different identity bind = %d %s", response.StatusCode, response.Header.Get("Location")) + } + after, err := f.store.SSOSnapshot(context.Background()) + if err != nil || !reflect.DeepEqual(same, after) { + t.Fatalf("binding conflict changed existing state: %v", err) + } + response, _ = f.request(t, f.client, "GET", "/api/auth/session", nil, "") + if response.StatusCode != http.StatusOK { + t.Fatal("binding conflict revoked the initiating password session") + } +} + +func TestSSOCallbackRechecksConfigAfterBlockedTokenExchange(t *testing.T) { + for _, purpose := range []string{"bind", "login"} { + t.Run(purpose, func(t *testing.T) { + f := newSSOHTTPFixture(t) + csrf := f.login(t) + response, body := f.request(t, f.client, "PUT", "/api/settings/sso", map[string]any{"expected_revision": 0, "config": f.config(), "client_secret": "fixture-client-secret"}, csrf) + if response.StatusCode != 200 { + t.Fatalf("save: %d %#v", response.StatusCode, body) + } + anonymous := &http.Client{CheckRedirect: f.client.CheckRedirect} + startBinding := func(revision int64) (*http.Cookie, string) { + t.Helper() + response, body := f.request(t, f.client, "POST", "/api/auth/sso/bind", map[string]any{"expected_revision": revision}, csrf) + if response.StatusCode != 200 { + t.Fatalf("bind: %d %#v", response.StatusCode, body) + } + u, _ := url.Parse(body["data"].(map[string]any)["authorization_url"].(string)) + return ssoFlowCookie(t, response), "/api/auth/sso/callback?code=fixture-code&state=" + url.QueryEscape(u.Query().Get("state")) + } + flowCookie, callback := startBinding(1) + if purpose == "login" { + response, _ = f.request(t, anonymous, "GET", callback, nil, "", flowCookie) + if response.StatusCode != 303 || !strings.Contains(response.Header.Get("Location"), "sso_result=bound") { + t.Fatal("initial binding failed") + } + config := f.config() + config["enabled"] = true + response, body = f.request(t, f.client, "PUT", "/api/settings/sso", map[string]any{"expected_revision": 2, "config": config}, csrf) + if response.StatusCode != 200 { + t.Fatalf("enable: %d %#v", response.StatusCode, body) + } + response, _ = f.request(t, anonymous, "GET", "/api/auth/sso/start", nil, "") + flowCookie = ssoFlowCookie(t, response) + u, _ := url.Parse(response.Header.Get("Location")) + callback = "/api/auth/sso/callback?code=fixture-code&state=" + url.QueryEscape(u.Query().Get("state")) + } + entered, release := make(chan struct{}), make(chan struct{}) + baseTransport := f.providerClient.Transport + f.providerClient.Transport = ssoTestTransport(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/token" { + close(entered) + select { + case <-release: + case <-r.Context().Done(): + return nil, r.Context().Err() + } + } + return baseTransport.RoundTrip(r) + }) + defer func() { + select { + case <-release: + default: + close(release) + } + }() + result := make(chan *httptest.ResponseRecorder, 1) + go func() { + r := httptest.NewRequest("GET", callback, nil) + r.RemoteAddr = "127.0.0.1:12345" + r.AddCookie(flowCookie) + w := httptest.NewRecorder() + f.handler.ServeHTTP(w, r) + result <- w + }() + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("token exchange was not reached") + } + snapshot, err := f.store.SSOSnapshot(context.Background()) + if err != nil { + t.Fatal(err) + } + snapshot.Config.Enabled = false + if _, _, err := f.store.SaveSSOConfiguration(context.Background(), store.SSOSaveRequest{ExpectedRevision: snapshot.Config.Revision, Config: snapshot.Config}); err != nil { + t.Fatal(err) + } + close(release) + select { + case w := <-result: + if w.Code != 303 || !strings.Contains(w.Header().Get("Location"), "sso_error=config_changed") { + t.Fatalf("late callback = %d %s", w.Code, w.Header().Get("Location")) + } + for _, cookie := range w.Result().Cookies() { + if cookie.Name == sessionCookieName { + t.Fatal("late callback issued or cleared local session") + } + } + case <-time.After(5 * time.Second): + t.Fatal("callback did not finish") + } + }) + } +} + +func TestSSORoutesKeepNetworkPolicyAndIndependentRateLimits(t *testing.T) { + f := newSSOHTTPFixture(t) + for _, path := range []string{"/api/auth/providers", "/api/auth/sso/start", "/api/auth/sso/callback", "/api/settings/sso"} { + r := httptest.NewRequest("GET", path, nil) + r.RemoteAddr = "203.0.113.1:443" + w := httptest.NewRecorder() + f.handler.ServeHTTP(w, r) + if w.Code != 403 || !strings.Contains(w.Body.String(), "network_access_denied") { + t.Errorf("SSO route bypassed network policy: %s %d", path, w.Code) + } + } + for attempt := 0; attempt <= ssoStartPerMinute; attempt++ { + response, _ := f.request(t, f.client, "GET", "/api/auth/sso/start", nil, "") + if attempt == ssoStartPerMinute && response.StatusCode != 429 { + t.Fatal("start limiter did not reject") + } + } + // IdP failures and start throttling do not enter password lockout state. + f.login(t) +} diff --git a/internal/server/sso_settings.go b/internal/server/sso_settings.go new file mode 100644 index 00000000..a800aaf6 --- /dev/null +++ b/internal/server/sso_settings.go @@ -0,0 +1,212 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "vocat/internal/auth" + "vocat/internal/sso" + "vocat/internal/store" +) + +// Write DTO intentionally omits the store-owned revision and namespace. The +// JSON decoder rejects attempts to set them, or to disable password login. +type ssoConfigInput struct { + Enabled bool `json:"enabled"` + Kind string `json:"kind"` + DisplayName string `json:"display_name"` + PublicOrigin string `json:"public_origin"` + ClientID string `json:"client_id"` + IssuerURL string `json:"issuer_url,omitempty"` + AuthURL string `json:"auth_url,omitempty"` + TokenURL string `json:"token_url,omitempty"` + UserInfoURL string `json:"user_info_url,omitempty"` + UserIDField string `json:"user_id_field,omitempty"` + Scopes []string `json:"scopes"` + TokenAuthMethod string `json:"token_auth_method"` + PKCEMode string `json:"pkce_mode"` +} + +func (input ssoConfigInput) config() store.SSOConfig { + return store.SSOConfig{ + Enabled: input.Enabled, Kind: input.Kind, DisplayName: input.DisplayName, + PublicOrigin: input.PublicOrigin, ClientID: input.ClientID, IssuerURL: input.IssuerURL, + AuthURL: input.AuthURL, TokenURL: input.TokenURL, UserInfoURL: input.UserInfoURL, UserIDField: input.UserIDField, + Scopes: input.Scopes, TokenAuthMethod: input.TokenAuthMethod, PKCEMode: input.PKCEMode, + } +} + +// The read contract exposes the revision clients need for writes, while keeping +// the store's identity namespace private and present in persisted configuration. +type ssoConfigView struct { + Revision int64 `json:"revision"` + ssoConfigInput +} + +func ssoPublicConfig(config store.SSOConfig) ssoConfigView { + return ssoConfigView{ + Revision: config.Revision, + ssoConfigInput: ssoConfigInput{ + Enabled: config.Enabled, Kind: config.Kind, DisplayName: config.DisplayName, + PublicOrigin: config.PublicOrigin, ClientID: config.ClientID, IssuerURL: config.IssuerURL, + AuthURL: config.AuthURL, TokenURL: config.TokenURL, UserInfoURL: config.UserInfoURL, + UserIDField: config.UserIDField, Scopes: config.Scopes, + TokenAuthMethod: config.TokenAuthMethod, PKCEMode: config.PKCEMode, + }, + } +} + +func (s *Server) handleSSOSettings(w http.ResponseWriter, r *http.Request) { + session, ok := s.requireBrowserSession(w, r) + if !ok { + return + } + switch r.Method { + case http.MethodGet: + snapshot, err := s.sso.Snapshot(r.Context()) + if err != nil { + s.writeSSOError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": ssoSettingsDTO(snapshot)}) + case http.MethodPut: + var request struct { + ExpectedRevision *int64 `json:"expected_revision"` + Config *ssoConfigInput `json:"config"` + ClientSecret json.RawMessage `json:"client_secret"` + ResetBinding bool `json:"reset_binding"` + } + if err := s.decodeJSON(w, r, &request); err != nil || request.ExpectedRevision == nil || *request.ExpectedRevision < 0 || request.Config == nil { + writeError(w, http.StatusBadRequest, "invalid_request", "config and expected_revision are required; read-only fields cannot be submitted") + return + } + var secret *string + if len(request.ClientSecret) > 0 { + var value string + if string(request.ClientSecret) == "null" || json.Unmarshal(request.ClientSecret, &value) != nil || strings.TrimSpace(value) == "" || strings.Trim(value, "*•● ") == "" || len(value) > 4096 { + writeError(w, http.StatusBadRequest, "invalid_request", "client_secret must be a nonempty new value; omit it to keep the saved secret") + return + } + secret = &value + } + config, err := sso.NormalizeConfig(request.Config.config()) + if err != nil { + s.writeSSOError(w, err) + return + } + if config.Enabled && strings.HasPrefix(config.PublicOrigin, "https://") && !s.secureCookies { + writeError(w, http.StatusBadRequest, "invalid_sso_config", "HTTPS SSO requires VOCAT_SECURE_COOKIES=true") + return + } + snapshot, revoked, err := s.sso.Save(r.Context(), store.SSOSaveRequest{ + ExpectedRevision: *request.ExpectedRevision, Config: config, + ClientSecret: secret, + ResetBinding: request.ResetBinding, CurrentSessionHash: session.TokenHash(), + }) + if err != nil { + s.writeSSOError(w, err) + return + } + if revoked { + s.clearAuthCookies(w) + } + s.recordAudit(r.Context(), session.Principal.Username, "settings.sso.update", "sso", fmt.Sprintf("%s:%d", snapshot.Config.Kind, snapshot.Config.Revision), "success", requestRemoteHost(r)) + data := ssoSettingsDTO(snapshot) + data["reauthentication_required"] = revoked + writeJSON(w, http.StatusOK, map[string]any{"data": data}) + default: + w.Header().Set("Allow", "GET, PUT") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } +} + +func ssoSettingsDTO(snapshot store.SSOSnapshot) map[string]any { + config := snapshot.Config + if !snapshot.HasConfiguration { + config, _ = sso.NormalizeConfig(store.SSOConfig{Kind: "pocketid"}) + } + return map[string]any{ + "config": ssoPublicConfig(config), "binding": ssoBindingDTO(snapshot.Binding), + "configured": sso.ValidateComplete(config, snapshot.ClientSecret) == nil, + "has_client_secret": snapshot.ClientSecret != "", + } +} + +func ssoBindingDTO(binding *store.SSOBinding) map[string]any { + value := map[string]any{"bound": false, "display_name": "", "email": "", "created_at": nil, "last_login_at": nil} + if binding != nil { + value["bound"], value["display_name"], value["email"] = true, binding.DisplayName, binding.Email + value["created_at"] = binding.CreatedAt.Format(time.RFC3339) + if !binding.LastLoginAt.IsZero() { + value["last_login_at"] = binding.LastLoginAt.Format(time.RFC3339) + } + } + return value +} + +func (s *Server) handleSSOCheck(w http.ResponseWriter, r *http.Request) { + if !requireMethod(w, r, http.MethodPost) { + return + } + session, ok := s.requireBrowserSession(w, r) + if !ok { + return + } + if !s.allowSSOManagement(w, r, session, "check") { + return + } + var request struct { + ExpectedRevision *int64 `json:"expected_revision"` + } + if err := s.decodeJSON(w, r, &request); err != nil || request.ExpectedRevision == nil || *request.ExpectedRevision < 0 { + writeError(w, http.StatusBadRequest, "invalid_request", "a saved configuration revision is required") + return + } + if err := s.sso.Check(r.Context(), *request.ExpectedRevision); err != nil { + s.writeSSOError(w, err) + return + } + s.recordAudit(r.Context(), session.Principal.Username, "settings.sso.check", "sso", fmt.Sprint(*request.ExpectedRevision), "success", requestRemoteHost(r)) + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{ + "revision": *request.ExpectedRevision, + }}) +} + +// Protocol errors can contain OAuth response bodies. Only fixed messages cross +// the HTTP or logging boundary; the original error is never printed here. +func (s *Server) writeSSOError(w http.ResponseWriter, err error) { + w.Header().Set("Cache-Control", "no-store") + status, code, message := http.StatusInternalServerError, "internal_error", "an internal error occurred" + switch { + case errors.Is(err, auth.ErrUnauthorized): + status, code, message = http.StatusUnauthorized, "unauthorized", "authentication is required" + case errors.Is(err, store.ErrSSOInvalidFlow): + status, code, message = http.StatusUnauthorized, "unauthorized", "the initiating browser session is no longer valid" + case errors.Is(err, store.ErrSSOConfigChanged): + status, code, message = http.StatusConflict, "sso_config_changed", "SSO configuration changed; reload and try again" + case errors.Is(err, store.ErrSSORebindRequired): + status, code, message = http.StatusConflict, "sso_rebind_required", "changing this identity source requires explicit reset and a new binding" + case errors.Is(err, store.ErrSSOBindingExists): + status, code, message = http.StatusConflict, "sso_binding_exists", "an account is already bound; unbind it before binding another account" + case errors.Is(err, store.ErrSSOIdentityNotBound): + status, code, message = http.StatusConflict, "sso_identity_not_bound", "bind an account before enabling SSO" + case errors.Is(err, store.ErrSSODisabled): + status, code, message = http.StatusForbidden, "sso_disabled", "SSO login is disabled" + case errors.Is(err, store.ErrSSONotConfigured): + status, code, message = http.StatusBadRequest, "sso_not_configured", "save all required settings and the client secret first" + case errors.Is(err, sso.ErrInvalidConfiguration): + status, code, message = http.StatusBadRequest, "invalid_sso_config", "invalid SSO configuration; check the provider fields, URL and HTTPS settings" + case errors.Is(err, store.ErrSSORateLimited): + status, code, message = http.StatusTooManyRequests, "sso_rate_limited", "too many SSO attempts; complete an existing flow or try again later" + case errors.Is(err, context.DeadlineExceeded): + status, code, message = http.StatusGatewayTimeout, "sso_provider_unavailable", "the identity provider timed out" + case errors.Is(err, sso.ErrProviderUnavailable), errors.Is(err, sso.ErrInvalidIdentity): + status, code, message = http.StatusBadGateway, "sso_provider_unavailable", "the identity provider could not complete the request" + } + writeError(w, status, code, message) +} diff --git a/web/e2e/fixture/main.go b/web/e2e/fixture/main.go new file mode 100644 index 00000000..1475f351 --- /dev/null +++ b/web/e2e/fixture/main.go @@ -0,0 +1,373 @@ +// This executable exists only for browser tests. It runs the real VoCat server +// with an isolated SQLite database and a signed, local OIDC identity provider. +// Test-only endpoints and the loopback TLS transport are never in cmd/vocat. +package main + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "html/template" + "io" + "log" + "math/big" + "net" + "net/http" + "net/url" + "os" + "os/signal" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + "vocat/internal/auth" + "vocat/internal/server" + "vocat/internal/sso" + "vocat/internal/store" + "vocat/web" +) + +const ( + appAddress = "127.0.0.1:17475" + idpAddress = "127.0.0.1:17476" + appOrigin = "https://vocat.test:17475" + idpOrigin = "https://pocket.test:17476" + clientID = "vocat-browser-test" + clientSecret = "browser-test-client-secret" + localPassword = "browser-test-local-password" +) + +type authorization struct{ State, Nonce, Challenge, Redirect string } +type callbackTrace struct { + Cookies []string `json:"cookies"` + ClearedCookies []string `json:"cleared_cookies"` + Status int `json:"status"` +} +type statistics struct { + Callbacks []callbackTrace `json:"callbacks"` + TokenRequests int `json:"token_requests"` + PKCEVerified bool `json:"pkce_verified"` + BasicVerified bool `json:"basic_verified"` + RedirectVerified bool `json:"redirect_verified"` +} + +type fixture struct { + handler atomic.Pointer[server.Server] + client *http.Client + directory string + key *rsa.PrivateKey + mu sync.Mutex + requests map[string]authorization + codes map[string]authorization + stats statistics + databases []*store.Store + sequence int +} + +func main() { + if err := run(); err != nil { + log.Fatal(err) + } +} + +func run() error { + certificate, roots, err := testCertificate() + if err != nil { + return err + } + directory, err := os.MkdirTemp("", "vocat-browser-sso-") + if err != nil { + return err + } + defer os.RemoveAll(directory) + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return err + } + transport := &http.Transport{Proxy: nil, TLSClientConfig: &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}, + DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil || (host != "pocket.test" && host != "vocat.test") { + return nil, errors.New("test transport refused a non-fixture host") + } + return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, net.JoinHostPort("127.0.0.1", port)) + }, + } + defer transport.CloseIdleConnections() + f := &fixture{directory: directory, key: key, client: &http.Client{Transport: transport, Timeout: 10 * time.Second}} + if err := f.reset(); err != nil { + return err + } + defer func() { + for _, database := range f.databases { + database.Close() + } + }() + + servers := []*http.Server{ + {Addr: appAddress, Handler: http.HandlerFunc(f.serveApp), ReadHeaderTimeout: 5 * time.Second, ErrorLog: log.New(io.Discard, "", 0)}, + {Addr: idpAddress, Handler: http.HandlerFunc(f.serveIDP), ReadHeaderTimeout: 5 * time.Second, ErrorLog: log.New(io.Discard, "", 0)}, + } + failures := make(chan error, 2) + for _, srv := range servers { + listener, err := net.Listen("tcp", srv.Addr) + if err != nil { + return err + } + listener = tls.NewListener(listener, &tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12}) + go func(srv *http.Server, listener net.Listener) { failures <- srv.Serve(listener) }(srv, listener) + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + fmt.Println("VoCat SSO browser fixture ready") + select { + case <-ctx.Done(): + case err := <-failures: + if !errors.Is(err, http.ErrServerClosed) { + return err + } + } + for _, srv := range servers { + _ = srv.Close() + } + return nil +} + +func (f *fixture) reset() error { + f.mu.Lock() + defer f.mu.Unlock() + f.sequence++ + database, err := store.Open(context.Background(), filepath.Join(f.directory, fmt.Sprintf("test-%d.db", f.sequence))) + if err != nil { + return err + } + authentication, err := auth.New(database, auth.Options{SessionTTL: time.Hour, BcryptCost: 4}) + if err != nil { + database.Close() + return err + } + if err := authentication.EnsureAdmin(context.Background(), "admin", localPassword); err != nil { + database.Close() + return err + } + handler, err := server.New(server.Options{Store: database, Auth: authentication, Assets: web.Dist, SecureCookies: true, + SSO: sso.New(database, sso.Options{HTTPClient: f.client})}) + if err != nil { + database.Close() + return err + } + f.databases = append(f.databases, database) + f.requests = make(map[string]authorization) + f.codes = make(map[string]authorization) + f.stats = statistics{Callbacks: []callbackTrace{}} + f.handler.Store(handler) + return nil +} + +func (f *fixture) serveApp(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/__e2e/") { + if r.URL.Path == "/__e2e/ready" { + w.WriteHeader(http.StatusNoContent) + return + } + if r.Header.Get("X-VoCat-E2E") != "fixture" { + http.Error(w, "test header required", http.StatusForbidden) + return + } + switch r.URL.Path { + case "/__e2e/reset": + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if err := f.reset(); err != nil { + http.Error(w, "reset failed", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + case "/__e2e/stats": + f.mu.Lock() + defer f.mu.Unlock() + jsonResponse(w, f.stats) + default: + http.NotFound(w, r) + } + return + } + if r.URL.Path == "/api/auth/sso/callback" { + trace := callbackTrace{Cookies: []string{}, ClearedCookies: []string{}} + for _, cookie := range r.Cookies() { + trace.Cookies = append(trace.Cookies, cookie.Name) + } + capture := &callbackWriter{ResponseWriter: w, trace: &trace} + f.handler.Load().ServeHTTP(capture, r) + f.mu.Lock() + f.stats.Callbacks = append(f.stats.Callbacks, trace) + f.mu.Unlock() + return + } + f.handler.Load().ServeHTTP(w, r) +} + +type callbackWriter struct { + http.ResponseWriter + trace *callbackTrace +} + +func (w *callbackWriter) WriteHeader(status int) { + w.trace.Status = status + response := &http.Response{Header: w.Header()} + for _, cookie := range response.Cookies() { + if cookie.MaxAge < 0 { + w.trace.ClearedCookies = append(w.trace.ClearedCookies, cookie.Name) + } + } + w.ResponseWriter.WriteHeader(status) +} + +var authorizationPage = template.Must(template.New("authorize").Parse(`Test Pocket ID

Test Pocket ID

Continue as the test administrator.

`)) + +func (f *fixture) serveIDP(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/openid-configuration": + jsonResponse(w, map[string]any{"issuer": idpOrigin, "authorization_endpoint": idpOrigin + "/authorize", "token_endpoint": idpOrigin + "/token", "jwks_uri": idpOrigin + "/jwks", + "response_types_supported": []string{"code"}, "response_modes_supported": []string{"query"}, "grant_types_supported": []string{"authorization_code"}, + "code_challenge_methods_supported": []string{"S256"}, "token_endpoint_auth_methods_supported": []string{"client_secret_basic"}, "id_token_signing_alg_values_supported": []string{"RS256"}}) + case "/jwks": + jsonResponse(w, map[string]any{"keys": []any{map[string]any{"kty": "RSA", "kid": "browser-test-key", "alg": "RS256", "use": "sig", "n": base64.RawURLEncoding.EncodeToString(f.key.N.Bytes()), "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(f.key.E)).Bytes())}}}) + case "/authorize": + q := r.URL.Query() + if q.Get("client_id") != clientID || q.Get("redirect_uri") != appOrigin+sso.CallbackPath || q.Get("response_type") != "code" || q.Get("response_mode") != "query" || q.Get("code_challenge_method") != "S256" || len(q.Get("nonce")) < 32 || len(q.Get("state")) < 32 || len(q.Get("code_challenge")) < 32 || !strings.Contains(" "+q.Get("scope")+" ", " openid ") { + http.Error(w, "invalid authorization request", http.StatusBadRequest) + return + } + id := randomID() + f.mu.Lock() + f.requests[id] = authorization{State: q.Get("state"), Nonce: q.Get("nonce"), Challenge: q.Get("code_challenge"), Redirect: q.Get("redirect_uri")} + f.mu.Unlock() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = authorizationPage.Execute(w, id) + case "/decision": + if r.Method != http.MethodPost || r.ParseForm() != nil { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + f.mu.Lock() + authorization, ok := f.requests[r.Form.Get("request")] + delete(f.requests, r.Form.Get("request")) + if !ok { + f.mu.Unlock() + http.Error(w, "request expired", http.StatusBadRequest) + return + } + q := url.Values{"state": {authorization.State}, "iss": {idpOrigin}} + if r.Form.Get("decision") == "allow" { + code := randomID() + f.codes[code] = authorization + q.Set("code", code) + } else { + q.Set("error", "access_denied") + } + f.mu.Unlock() + http.Redirect(w, r, authorization.Redirect+"?"+q.Encode(), http.StatusSeeOther) + case "/token": + if r.Method != http.MethodPost || r.ParseForm() != nil { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + user, secret, basic := r.BasicAuth() + user, _ = url.QueryUnescape(user) + secret, _ = url.QueryUnescape(secret) + f.mu.Lock() + authorization, ok := f.codes[r.Form.Get("code")] + delete(f.codes, r.Form.Get("code")) + digest := sha256.Sum256([]byte(r.Form.Get("code_verifier"))) + pkce := authorization.Challenge == base64.RawURLEncoding.EncodeToString(digest[:]) && r.Form.Get("code_verifier") != "" + redirect := authorization.Redirect == r.Form.Get("redirect_uri") && authorization.Redirect == appOrigin+sso.CallbackPath + validBasic := basic && user == clientID && secret == clientSecret + if !ok || !validBasic || !pkce || !redirect || r.Form.Get("grant_type") != "authorization_code" || r.URL.RawQuery != "" { + f.mu.Unlock() + w.WriteHeader(http.StatusBadRequest) + jsonResponse(w, map[string]string{"error": "invalid_grant"}) + return + } + f.stats.TokenRequests++ + f.stats.PKCEVerified = pkce + f.stats.BasicVerified = validBasic + f.stats.RedirectVerified = redirect + f.mu.Unlock() + now := time.Now().Unix() + token, err := f.signedToken(map[string]any{"iss": idpOrigin, "aud": []string{clientID}, "sub": "browser-test-account", "iat": now, "exp": now + 300, "nonce": authorization.Nonce, "name": "Browser test administrator"}) + if err != nil { + http.Error(w, "signing failed", http.StatusInternalServerError) + return + } + jsonResponse(w, map[string]any{"access_token": "browser-test-transient-access-token", "refresh_token": "browser-test-must-never-persist", "token_type": "Bearer", "expires_in": 300, "id_token": token}) + default: + http.NotFound(w, r) + } +} + +func (f *fixture) signedToken(claims map[string]any) (string, error) { + header, _ := json.Marshal(map[string]string{"typ": "JWT", "alg": "RS256", "kid": "browser-test-key"}) + body, err := json.Marshal(claims) + if err != nil { + return "", err + } + input := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(body) + digest := sha256.Sum256([]byte(input)) + signature, err := rsa.SignPKCS1v15(rand.Reader, f.key, crypto.SHA256, digest[:]) + if err != nil { + return "", err + } + return input + "." + base64.RawURLEncoding.EncodeToString(signature), nil +} + +func jsonResponse(w http.ResponseWriter, value any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(value) +} +func randomID() string { + var value [32]byte + if _, err := rand.Read(value[:]); err != nil { + panic(err) + } + return base64.RawURLEncoding.EncodeToString(value[:]) +} + +func testCertificate() (tls.Certificate, *x509.CertPool, error) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return tls.Certificate{}, nil, err + } + now := time.Now() + certificate := &x509.Certificate{SerialNumber: big.NewInt(now.UnixNano()), Subject: pkix.Name{CommonName: "VoCat browser test CA"}, NotBefore: now.Add(-time.Hour), NotAfter: now.Add(24 * time.Hour), + IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, DNSNames: []string{"vocat.test", "pocket.test", "localhost"}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}} + der, err := x509.CreateCertificate(rand.Reader, certificate, certificate, &key.PublicKey, key) + if err != nil { + return tls.Certificate{}, nil, err + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + tlsCertificate, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return tls.Certificate{}, nil, err + } + roots := x509.NewCertPool() + roots.AppendCertsFromPEM(certPEM) + return tlsCertificate, roots, nil +} diff --git a/web/e2e/sso.spec.ts b/web/e2e/sso.spec.ts new file mode 100644 index 00000000..90c5c9af --- /dev/null +++ b/web/e2e/sso.spec.ts @@ -0,0 +1,316 @@ +import { expect, test, type BrowserContext, type Page } from "@playwright/test"; + +const APP = "https://vocat.test:17475"; +const IDP = "https://pocket.test:17476"; +const FIXTURE = "https://127.0.0.1:17475"; +const PASSWORD = "browser-test-local-password"; +const SECRET = "browser-test-client-secret"; + +async function prepare(context: BrowserContext) { + await context.addInitScript(() => localStorage.setItem("vocat_disclaimer_agreed_at", String(Date.now()))); +} + +async function passwordLogin(page: Page, target = "/settings") { + await page.goto(`/login?${new URLSearchParams({ redirect: target })}`); + await page.locator('input[autocomplete="username"]').fill("admin"); + await page.locator('input[autocomplete="current-password"]').fill(PASSWORD); + await page.getByRole("button", { name: "Sign In", exact: true }).click(); + await expect(page).toHaveURL(APP + target); +} + +function card(page: Page) { return page.getByRole("region", { name: "Single sign-on (SSO)", exact: true }); } + +async function settings(page: Page) { + await expect(card(page).getByLabel("Identity provider", { exact: true })).toHaveValue("pocketid"); +} + +async function save(page: Page, navigates = false) { + const response = page.waitForResponse((response) => response.url() === APP + "/api/settings/sso" && response.request().method() === "PUT"); + await card(page).getByRole("button", { name: "Save SSO settings", exact: true }).click(); + const result = await response; + expect(result.status()).toBe(200); + // A successful revocation starts a document navigation immediately; Chromium + // then releases the prior document's response bodies. Verify its visible + // success and cookies below instead of reading a released CDP resource. + return navigates ? undefined : (await result.json()).data; +} + +async function approve(page: Page) { + await expect(page).toHaveURL(new RegExp(`^${IDP.replaceAll(".", "\\.")}/authorize\\?`)); + await page.getByRole("button", { name: "Authorize test account", exact: true }).click(); +} + +async function checkSession(page: Page) { + return page.evaluate(async () => { + const response = await fetch("/api/auth/session", { credentials: "include" }); + return { status: response.status, body: await response.json() }; + }); +} + +test.beforeEach(async ({ context, request }) => { + const reset = await request.post(FIXTURE + "/__e2e/reset", { headers: { "X-VoCat-E2E": "fixture" } }); + expect(reset.status()).toBe(204); + await prepare(context); +}); + +test("password login preserves the full target and rejects unsafe logged-in redirects", async ({ page }) => { + await passwordLogin(page, "/settings?tab=security#sso"); + await settings(page); + await expect(card(page).getByLabel("VoCat public origin", { exact: true })).toHaveValue(APP); + await expect(card(page).getByRole("switch", { name: "Enable SSO sign-in", exact: true })).toBeDisabled(); + for (const redirect of ["https://evil.example/", "//evil.example/", "/%252f%252fevil.example/", "/login?redirect=/settings", "/%6cogin?redirect=/settings", "/api/%61uth/sso/start", "/api/auth/sso/start"]) { + await page.goto(`/login?${new URLSearchParams({ redirect })}`); + await expect(page).toHaveURL(APP + "/"); + } +}); + +test("callback URL is available before creating the OIDC client and follows unsaved origin edits", async ({ page, context }) => { + await passwordLogin(page); + await settings(page); + await expect(card(page).getByText(APP + "/api/auth/sso/callback", { exact: true })).toBeVisible(); + const copy = card(page).getByRole("button", { name: "Copy", exact: true }); + await expect(copy).toBeEnabled(); + await context.grantPermissions(["clipboard-read", "clipboard-write"], { origin: APP }); + await copy.click(); + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(APP + "/api/auth/sso/callback"); + await expect(card(page).getByLabel("Client ID", { exact: true })).toHaveValue(""); + await expect(card(page).getByPlaceholder("Enter a new secret", { exact: true })).toHaveValue(""); + await expect(card(page).getByText("Enter the Pocket ID instance URL", { exact: true })).toBeVisible(); + await expect(card(page).getByText("Enter the client ID created for VoCat in your identity service", { exact: true })).toBeVisible(); + + const origin = card(page).getByLabel("VoCat public origin", { exact: true }); + await origin.fill("https://vocat.nas.test:8443"); + await expect(card(page).getByText("https://vocat.nas.test:8443/api/auth/sso/callback", { exact: true })).toBeVisible(); + await expect(copy).toBeEnabled(); + await copy.click(); + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("https://vocat.nas.test:8443/api/auth/sso/callback"); + const persisted = await page.evaluate(async () => (await (await fetch("/api/settings/sso", { credentials: "include" })).json()).data.config); + expect(persisted.revision).toBe(0); + expect(persisted.public_origin).toBe(""); + + await origin.fill("https://vocat.nas.test:8443/with-a-path"); + await expect(copy).toBeDisabled(); + await expect(card(page).getByText("https://vocat.nas.test:8443/api/auth/sso/callback", { exact: true })).toHaveCount(0); +}); + +test("cross-site Pocket ID binding, cancellation, SSO login and revocation keep cookie and CSRF boundaries", async ({ page, context, browser, request }) => { + await passwordLogin(page); + await settings(page); + const originalSession = (await context.cookies(APP)).find((cookie) => cookie.name === "vocat_session"); + expect(originalSession?.sameSite).toBe("Strict"); + expect(originalSession?.secure).toBe(true); + expect(originalSession?.httpOnly).toBe(true); + + await card(page).getByLabel("Pocket ID Issuer", { exact: true }).fill(IDP); + await card(page).getByLabel("Client ID", { exact: true }).fill("vocat-browser-test"); + await card(page).getByPlaceholder("Enter a new secret", { exact: true }).fill(SECRET); + await expect(card(page).getByRole("button", { name: "Check settings", exact: true })).toBeDisabled(); + const configured = await save(page); + expect(configured.config.enabled).toBe(false); + expect(configured.config.token_auth_method).toBe("auto"); + expect(configured.has_client_secret).toBe(true); + expect(configured.configured).toBe(true); + expect(Object.keys(configured).sort()).toEqual(["binding", "config", "configured", "has_client_secret", "reauthentication_required"]); + expect(Object.hasOwn(configured.config, "provider_instance_id")).toBe(false); + expect(JSON.stringify(configured)).not.toContain(SECRET); + await expect(card(page).getByPlaceholder("Enter a new secret", { exact: true })).toHaveValue(""); + await expect(card(page).getByText(APP + "/api/auth/sso/callback", { exact: true })).toBeVisible(); + await expect(card(page).getByRole("checkbox")).toHaveCount(0); + + const checked = page.waitForResponse(APP + "/api/settings/sso/check"); + await card(page).getByRole("button", { name: "Check settings", exact: true }).click(); + const check = await checked; + expect(check.status()).toBe(200); + const checkBody = await check.json(); + expect(checkBody.data).toEqual({ revision: configured.config.revision }); + await expect(card(page).getByRole("status")).toHaveText("Configuration check passed"); + + // A real navigation to a different site removes Strict main cookies from + // the top-level callback. A separate Lax cookie proves browser association. + const parallelTab = await context.newPage(); + await parallelTab.goto(APP + "/settings"); + await settings(parallelTab); + const bindingRequest = page.waitForRequest((request) => request.url() === APP + "/api/auth/sso/bind" && request.method() === "POST"); + await card(page).getByRole("button", { name: "Bind external account", exact: true }).click(); + expect((await bindingRequest).postDataJSON()).toEqual({ expected_revision: configured.config.revision }); + await card(parallelTab).getByRole("button", { name: "Bind external account", exact: true }).click(); + const pendingCookies = (await context.cookies(APP + "/api/auth/sso/callback")).filter((cookie) => cookie.name.startsWith("vocat_sso_")); + expect(pendingCookies).toHaveLength(2); + expect(pendingCookies.every((cookie) => cookie.sameSite === "Lax" && cookie.httpOnly && cookie.secure && cookie.path === "/api/auth/sso")).toBe(true); + await page.getByRole("button", { name: "Cancel authorization", exact: true }).click(); + await expect(page).toHaveURL(APP + "/settings"); + await expect(card(page).getByRole("alert")).toHaveText("Authentication was cancelled. You can try again."); + expect((await context.cookies(APP)).find((cookie) => cookie.name === "vocat_session")?.value).toBe(originalSession?.value); + + // Completing the other tab still works after the first flow was cancelled. + await approve(parallelTab); + await expect(parallelTab).toHaveURL(APP + "/settings"); + await page.goto(APP + "/settings"); + await expect(page).toHaveURL(APP + "/settings"); + await expect(card(page).getByText("Browser test administrator", { exact: true })).toBeVisible(); + for (const name of ["Bind external account", "Verify existing binding", "Replace account"]) { + await expect(card(page).getByRole("button", { name, exact: true })).toHaveCount(0); + } + await expect(card(page).getByRole("button", { name: "Unbind account", exact: true })).toBeVisible(); + expect((await context.cookies(APP)).find((cookie) => cookie.name === "vocat_session")?.value).toBe(originalSession?.value); + + const statsResponse = await request.get(FIXTURE + "/__e2e/stats", { headers: { "X-VoCat-E2E": "fixture" } }); + const stats = await statsResponse.json(); + expect(stats.callbacks).toHaveLength(2); + for (const callback of stats.callbacks) { + expect(callback.status).toBe(303); + expect(callback.cookies.some((name: string) => name.startsWith("vocat_sso_"))).toBe(true); + expect(callback.cookies).not.toContain("vocat_session"); + expect(callback.cookies).not.toContain("vocat_csrf"); + expect(callback.cleared_cookies).not.toContain("vocat_session"); + expect(callback.cleared_cookies).not.toContain("vocat_csrf"); + } + expect(stats.pkce_verified).toBe(true); + expect(stats.basic_verified).toBe(true); + expect(stats.redirect_verified).toBe(true); + expect(stats.token_requests).toBe(1); + await parallelTab.close(); + + await card(page).getByRole("switch", { name: "Enable SSO sign-in", exact: true }).click(); + const enabled = await save(page); + expect(enabled.config.enabled).toBe(true); + expect(enabled.reauthentication_required).toBe(false); + // The SPA scrolls inside a fixed-height shell. Give the whole card enough + // vertical room for an element capture while retaining each responsive width. + await page.setViewportSize({ width: 1280, height: 1800 }); + await card(page).screenshot({ path: test.info().outputPath("sso-settings-desktop.png") }); + await page.setViewportSize({ width: 390, height: 2200 }); + await card(page).screenshot({ path: test.info().outputPath("sso-settings-mobile.png") }); + + const anonymous = await browser.newContext({ ignoreHTTPSErrors: true }); + await prepare(anonymous); + const loginPage = await anonymous.newPage(); + const target = "/sms?keyword=%E6%B5%8B%E8%AF%95#latest"; + await loginPage.goto(APP + `/login?${new URLSearchParams({ redirect: target })}`); + await loginPage.getByRole("link", { name: "Continue with Pocket ID", exact: true }).click(); + await loginPage.getByRole("button", { name: "Cancel authorization", exact: true }).click(); + await expect(loginPage).toHaveURL(APP + `/login?${new URLSearchParams({ redirect: target })}`); + await expect(loginPage.getByRole("alert")).toHaveText("Authentication was cancelled. You can try again."); + await expect(loginPage.getByRole("link", { name: "Continue with Pocket ID", exact: true })).toBeVisible(); + + // Manual retry creates a fresh flow; no automatic authorization loop. + await loginPage.getByRole("link", { name: "Continue with Pocket ID", exact: true }).click(); + await approve(loginPage); + await expect(loginPage).toHaveURL(APP + target); + await expect.poll(async () => loginPage.evaluate(() => !!sessionStorage.getItem("vocat.csrf"))).toBe(true); + const session = await checkSession(loginPage); + expect(session.status).toBe(200); + expect(session.body.data.authenticated).toBe(true); + const firstWrite = await loginPage.evaluate(async () => { + const response = await fetch("/api/settings/preferences", { + method: "PUT", credentials: "include", + headers: { "Content-Type": "application/json", "X-CSRF-Token": sessionStorage.getItem("vocat.csrf") || "" }, + body: JSON.stringify({ language: "en" }), + }); + return response.status; + }); + expect(firstWrite).toBe(200); + const ssoSession = (await anonymous.cookies(APP)).find((cookie) => cookie.name === "vocat_session"); + expect(ssoSession?.value).not.toBe(originalSession?.value); + expect(ssoSession?.sameSite).toBe("Strict"); + const storage = await loginPage.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage } })); + expect(storage).not.toContain("browser-test-must-never-persist"); + expect(storage).not.toContain("browser-test-transient-access-token"); + + // Saving from an SSO session is a successful operation that explicitly + // revokes that session. The UI must display success before reauthentication. + await loginPage.goto(APP + "/settings"); + await settings(loginPage); + await card(loginPage).getByLabel("Sign-in button label", { exact: true }).fill("Browser SSO"); + await save(loginPage, true); + await expect(loginPage).toHaveURL(APP + "/login?redirect=%2Fsettings"); + await expect(loginPage.getByRole("status").filter({ hasText: "SSO settings saved. Sign in again." })).toBeVisible(); + expect((await anonymous.cookies(APP)).find((cookie) => cookie.name === "vocat_session")).toBeUndefined(); + expect((await checkSession(page)).status).toBe(200); + + await loginPage.getByRole("link", { name: "Continue with Browser SSO", exact: true }).click(); + await approve(loginPage); + await expect(loginPage).toHaveURL(APP + "/settings"); + await settings(loginPage); + await card(loginPage).getByRole("button", { name: "Unbind account", exact: true }).click(); + const unboundResponse = loginPage.waitForResponse((response) => response.url() === APP + "/api/auth/sso/binding" && response.request().method() === "DELETE"); + await loginPage.getByRole("alertdialog").getByRole("button", { name: "Unbind account", exact: true }).click(); + const unbound = await unboundResponse; + expect(unbound.status()).toBe(200); + await expect(loginPage).toHaveURL(APP + "/login?redirect=%2Fsettings"); + await expect(loginPage.getByRole("status").filter({ hasText: "External account unbound and SSO sign-in disabled." })).toBeVisible(); + await expect(loginPage.getByRole("link", { name: "Continue with Browser SSO", exact: true })).toHaveCount(0); + expect((await checkSession(page)).status).toBe(200); + await anonymous.close(); +}); + +test("expanded settings preserve saved origins and expose protocol choices only for Generic OAuth2", async ({ page }) => { + await passwordLogin(page); + await settings(page); + const canonicalOrigin = "https://vocat.nas.test"; + const seeded = await page.evaluate(async ({ appOrigin, idpOrigin, secret }) => { + const session = await (await fetch("/api/auth/session", { credentials: "include" })).json(); + const response = await fetch("/api/settings/sso", { + method: "PUT", credentials: "include", + headers: { "Content-Type": "application/json", "X-CSRF-Token": session.data.csrf_token }, + body: JSON.stringify({ + expected_revision: 0, client_secret: secret, + config: { + kind: "pocketid", enabled: false, display_name: "NAS sign-in", public_origin: appOrigin, + issuer_url: idpOrigin, client_id: "vocat-browser-test", scopes: ["openid", "email"], + token_auth_method: "client_secret_post", pkce_mode: "s256", + }, + }), + }); + return response.status; + }, { appOrigin: canonicalOrigin, idpOrigin: IDP, secret: SECRET }); + expect(seeded).toBe(200); + await page.reload(); + await settings(page); + + // Existing callbacks and protocol choices survive a form simplification, + // including a canonical origin different from the current browser origin. + await expect(card(page).getByLabel("VoCat public origin", { exact: true })).toHaveValue(canonicalOrigin); + await expect(card(page).getByLabel("VoCat public origin", { exact: true })).toBeVisible(); + await expect(card(page).getByLabel("Sign-in button label", { exact: true })).toBeVisible(); + await expect(card(page).locator("details")).toHaveCount(0); + await expect(card(page).getByLabel("Identity service connection", { exact: true })).toHaveCount(0); + await expect(card(page).getByLabel("Other backend addresses", { exact: true })).toHaveCount(0); + await expect(card(page).getByText(canonicalOrigin + "/api/auth/sso/callback", { exact: true })).toBeVisible(); + await expect(card(page).getByLabel("Token client authentication", { exact: true })).toHaveCount(0); + await expect(card(page).getByLabel("PKCE", { exact: true })).toHaveCount(0); + await expect(card(page).getByLabel("Scopes (space separated)", { exact: true })).toHaveCount(0); + + await card(page).getByLabel("Sign-in button label", { exact: true }).fill("My NAS sign-in"); + const saved = await save(page); + expect(saved.config).toMatchObject({ + public_origin: canonicalOrigin, display_name: "My NAS sign-in", scopes: ["openid", "email"], + token_auth_method: "client_secret_post", pkce_mode: "s256", + }); + expect(saved.has_client_secret).toBe(true); + for (const obsolete of ["allowed_backend_origins", "allow_private_network", "allow_insecure_http", "provider_instance_id"]) { + expect(Object.hasOwn(saved.config, obsolete)).toBe(false); + } + expect(Object.hasOwn(saved, "callback_url")).toBe(false); + + await card(page).getByLabel("Client ID", { exact: true }).fill("different-client"); + await card(page).getByRole("button", { name: "Save SSO settings", exact: true }).click(); + await expect(card(page).getByRole("alert")).toHaveText("The identity source changed. Enter a new client secret."); + const unchanged = await page.evaluate(async () => (await (await fetch("/api/settings/sso", { credentials: "include" })).json()).data); + expect(unchanged.config.revision).toBe(saved.config.revision); + expect(unchanged.has_client_secret).toBe(true); + + await card(page).getByLabel("Identity provider", { exact: true }).selectOption("generic"); + await expect(card(page).getByLabel("Token client authentication", { exact: true })).toBeVisible(); + await expect(card(page).getByLabel("PKCE", { exact: true })).toBeVisible(); + await card(page).getByLabel("Identity provider", { exact: true }).selectOption("github"); + await expect(card(page).getByLabel("Token client authentication", { exact: true })).toHaveCount(0); + await expect(card(page).getByLabel("PKCE", { exact: true })).toHaveCount(0); + await expect(card(page).getByLabel("Identity service connection", { exact: true })).toHaveCount(0); +}); + +test("provider metadata failures leave password sign-in usable", async ({ page }) => { + await page.route("**/api/auth/providers", (route) => route.abort()); + await passwordLogin(page); + await settings(page); +}); diff --git a/web/package-lock.json b/web/package-lock.json index 12176c4a..bdb20971 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -24,6 +24,7 @@ "react-router-dom": "^7.18.4" }, "devDependencies": { + "@playwright/test": "^1.62.1", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^4.7.0", @@ -996,6 +997,22 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -2280,6 +2297,25 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, "node_modules/playwright-core": { "version": "1.62.1", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", @@ -2293,6 +2329,21 @@ "node": ">=20" } }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", diff --git a/web/package.json b/web/package.json index 78ca0a7c..07f641b2 100644 --- a/web/package.json +++ b/web/package.json @@ -7,6 +7,7 @@ "dev": "vite --host 127.0.0.1", "build": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json && vite build", "test": "node --test test/*.test.mjs", + "test:e2e": "playwright test", "preview": "vite preview --host 127.0.0.1" }, "dependencies": { @@ -26,6 +27,7 @@ "react-router-dom": "^7.18.4" }, "devDependencies": { + "@playwright/test": "^1.62.1", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^4.7.0", diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 00000000..701d9e07 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from "@playwright/test"; +import { fileURLToPath } from "node:url"; + +export default defineConfig({ + testDir: "./e2e", + testMatch: "**/*.spec.ts", + fullyParallel: false, + workers: 1, + retries: process.env.CI ? 1 : 0, + timeout: 60_000, + expect: { timeout: 10_000 }, + reporter: process.env.CI ? "github" : "list", + use: { + baseURL: "https://vocat.test:17475", + browserName: "chromium", + ignoreHTTPSErrors: true, // Ephemeral fixture CA; production always validates TLS. + trace: "retain-on-failure", + screenshot: "only-on-failure", + launchOptions: { + args: ["--host-resolver-rules=MAP vocat.test 127.0.0.1, MAP pocket.test 127.0.0.1", "--no-proxy-server"], + }, + }, + webServer: { + command: `${process.env.VOCAT_GO || "go"} run ./web/e2e/fixture`, + cwd: fileURLToPath(new URL("..", import.meta.url)), + url: "https://127.0.0.1:17475/__e2e/ready", + ignoreHTTPSErrors: true, + reuseExistingServer: false, + timeout: 180_000, + }, +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index 203b5a29..63153e11 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -18,6 +18,7 @@ import AutomaticTasksPage from "./pages/AutomaticTasksPage"; import LogsPage from "./pages/LogsPage"; import SettingsPage from "./pages/SettingsPage"; import ExtensionPage from "./pages/ExtensionPage"; +import { safeRedirect } from "./lib/safeRedirect"; const THEME_KEY = "theme"; const DISCLAIMER_KEY = "vocat_disclaimer_agreed_at"; @@ -55,7 +56,9 @@ function RequireAuth({ children }: { children: ReactElement }) { function LoginLayout({ isDark, onToggleTheme }: { isDark: boolean; onToggleTheme: () => void }) { const { ready, isAuthenticated } = useAuth(); - if (ready && isAuthenticated) return ; + const location = useLocation(); + const redirect = safeRedirect(new URLSearchParams(location.search).get("redirect"), window.location.origin); + if (ready && isAuthenticated) return ; return ; } diff --git a/web/src/api.ts b/web/src/api.ts index 0f8b8f98..e4299faf 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -5,6 +5,11 @@ import type { LoginResponse, SecuritySettings, Session, + SSOProviders, + SSOSettings, + SSOSaveRequest, + SSOCheckResult, + SSOUnbindResult, } from "./types"; import { tl } from "./lib/i18n"; @@ -240,6 +245,33 @@ export function updateLoggingSettings(settings: { return api("/settings/logging", { method: "PUT", body: settings }); } +export function getSSOProviders() { + return api("/auth/providers", { cache: "no-store" }); +} + +export function getSSOSettings() { + return api("/settings/sso", { cache: "no-store" }); +} + +export function saveSSOSettings(request: SSOSaveRequest) { + return api("/settings/sso", { method: "PUT", body: request }); +} + +export function checkSSOSettings(expectedRevision: number) { + return api("/settings/sso/check", { method: "POST", body: { expectedRevision } }); +} + +export function bindSSOAccount(expectedRevision: number) { + return api<{ authorizationUrl: string }>("/auth/sso/bind", { + method: "POST", + body: { expectedRevision }, + }); +} + +export function unbindSSOAccount(expectedRevision: number) { + return api("/auth/sso/binding", { method: "DELETE", body: { expectedRevision } }); +} + export function apiMessage(error: unknown) { if (error instanceof ApiError) { const suffix = error.requestId ? `(${tl("请求")} ${error.requestId})` : ""; diff --git a/web/src/components/settings/SSOCard.tsx b/web/src/components/settings/SSOCard.tsx new file mode 100644 index 00000000..e0082f7a --- /dev/null +++ b/web/src/components/settings/SSOCard.tsx @@ -0,0 +1,309 @@ +import { useCallback, useEffect, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { CheckmarkRegular, KeyRegular } from "@fluentui/react-icons"; +import { + ApiError, bindSSOAccount, checkSSOSettings, getSSOSettings, + notifyUnauthorized, saveSSOSettings, unbindSSOAccount, +} from "../../api"; +import type { SSOConfigInput, SSOProviderKind, SSOSettings } from "../../types"; +import { useI18n } from "../../lib/i18n"; +import { + buildSSOSaveRequest, changeSSOProvider, defaultSSOConfig, ssoCallbackPreview, ssoConfigInput, + ssoErrorMessage, ssoIdentitySourceChanged, SSO_RESULT_MESSAGES, +} from "../../lib/sso"; +import { Button, Input, Switch, confirmDialog, message } from "../ui"; +import { CardDecor, CardIcon, CardTitle } from "./Cards"; +import { copyText, Field, PasswordInput } from "./controls"; + +const SELECT_CLASS = "h-8 w-full rounded-lg border border-[#dcdfe6] bg-white px-3 text-sm text-gray-900 outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-500/25 disabled:opacity-60 dark:border-white/15 dark:bg-gray-900 dark:text-gray-100"; +const EMPTY_BINDING = { bound: false, displayName: "", email: "", createdAt: null, lastLoginAt: null }; + +export function SSOCard() { + const { t, lang } = useI18n(); + const [searchParams, setSearchParams] = useSearchParams(); + const [saved, setSaved] = useState(null); + const [draft, setDraft] = useState(() => defaultSSOConfig(window.location.origin)); + const [secret, setSecret] = useState(""); + const [scopeText, setScopeText] = useState("openid profile email"); + const [loading, setLoading] = useState(true); + const [loadFailed, setLoadFailed] = useState(false); + const [working, setWorking] = useState<"save" | "check" | "bind" | "unbind" | null>(null); + const [checkedRevision, setCheckedRevision] = useState(null); + const [operationError, setOperationError] = useState(null); + const [callbackNotice, setCallbackNotice] = useState<{ error: boolean; text: string } | null>(null); + + const applySaved = useCallback((data: SSOSettings) => { + setSaved(data); + const input = ssoConfigInput({ ...defaultSSOConfig(), ...data.config }); + setDraft(data.config.revision === 0 ? { ...input, publicOrigin: window.location.origin } : input); + setScopeText(input.scopes.join(" ")); + setSecret(""); + setCheckedRevision(null); + }, []); + + const load = useCallback(async () => { + setLoading(true); + setLoadFailed(false); + try { + applySaved(await getSSOSettings()); + } catch { + setLoadFailed(true); + } finally { + setLoading(false); + } + }, [applySaved]); + + useEffect(() => { void load(); }, [load]); + + useEffect(() => { + const error = searchParams.get("sso_error"); + const result = searchParams.get("sso_result"); + if (!error && !result) return; + if (error) setCallbackNotice({ error: true, text: ssoErrorMessage(error) }); + else if (result && Object.hasOwn(SSO_RESULT_MESSAGES, result)) setCallbackNotice({ error: false, text: SSO_RESULT_MESSAGES[result] }); + const next = new URLSearchParams(searchParams); + next.delete("sso_error"); + next.delete("sso_result"); + setSearchParams(next, { replace: true }); + }, [searchParams, setSearchParams]); + + const revision = saved?.config.revision ?? 0; + const baseline = saved ? ssoConfigInput({ ...defaultSSOConfig(), ...saved.config }) : defaultSSOConfig(); + const sourceChanged = saved ? ssoIdentitySourceChanged(baseline, draft) : false; + const dirty = JSON.stringify(ssoConfigInput(draft)) !== JSON.stringify(baseline) || !!secret; + const busy = loading || working !== null; + const binding = saved?.binding ?? EMPTY_BINDING; + const canUseSaved = !!saved?.configured && revision > 0 && !dirty && !busy; + const callbackPreview = ssoCallbackPreview(draft.publicOrigin); + const originChanged = revision > 0 && callbackPreview !== ssoCallbackPreview(baseline.publicOrigin); + + function update(patch: Partial) { + setOperationError(null); + setDraft((current) => ({ ...current, ...patch })); + } + + function fail(error: unknown) { + const code = error instanceof ApiError ? error.code : error instanceof Error ? error.message : ""; + const translated: Record = { + sso_config_changed: "SSO 配置已变化,请重新加载后重试。", + sso_rebind_required: "身份源已变化,需要清除旧绑定并重新绑定。", + sso_binding_exists: "已绑定外部账号,请先解绑后再绑定。", + sso_not_configured: "请先补全并保存 SSO 配置和 Client Secret。", + sso_identity_not_bound: "请先绑定外部账号,再启用 SSO 登录。", + invalid_sso_config: "SSO 配置无效,请检查地址、Scope、PKCE 和 HTTPS Cookie 设置。", + sso_disabled: "SSO 登录尚未启用。", + sso_provider_unavailable: "身份提供方暂时不可用,请稍后重试或使用密码登录。", + sso_rate_limited: "操作过于频繁,请稍后重试。", + sso_invalid_secret: "请填写有效的 Client Secret,不能提交掩码。", + session_required: "请使用浏览器管理员会话管理 SSO。", + }; + setOperationError(Object.hasOwn(translated, code) ? translated[code] : "SSO 操作未完成,请检查配置后重试。"); + } + + function reauthenticate(result: "config_saved" | "unbound") { + notifyUnauthorized(); + // A document navigation also resets pending protected-page requests. A + // client-only navigation can race RequireAuth and discard this result. + window.location.replace(`/login?${new URLSearchParams({ sso_result: result, redirect: "/settings" }).toString()}`); + } + + async function save() { + if (!saved) return; + setOperationError(null); + let resetBinding = false; + if (sourceChanged && binding.bound) { + resetBinding = await confirmDialog( + t("身份源变化会清除旧绑定并关闭 SSO 登录。请重新填写密钥,保存后重新绑定外部账号。"), + t("更换身份源"), + { confirmText: t("清除绑定并保存"), confirmVariant: "danger", type: "warning" }, + ); + if (!resetBinding) return; + } + if (sourceChanged && saved.hasClientSecret && !secret) { + setOperationError("身份源已变化,请重新填写 Client Secret。"); + return; + } + setWorking("save"); + try { + const result = await saveSSOSettings(buildSSOSaveRequest( + sourceChanged ? { ...draft, enabled: false } : draft, revision, secret, resetBinding, + )); + message.success(t("SSO 配置已保存")); + if (result.reauthenticationRequired) reauthenticate("config_saved"); + else applySaved(result); + } catch (error) { + fail(error); + } finally { + setWorking(null); + } + } + + async function check() { + if (!canUseSaved) return; + setWorking("check"); + setOperationError(null); + try { + const result = await checkSSOSettings(revision); + setCheckedRevision(result.revision); + message.success(t("配置检查通过")); + } catch (error) { + setCheckedRevision(null); + fail(error); + } finally { + setWorking(null); + } + } + + async function bind() { + if (!canUseSaved || binding.bound) return; + setWorking("bind"); + setOperationError(null); + try { + const result = await bindSSOAccount(revision); + // The server generated this address from the saved provider. Perform a + // top-level navigation so the flow cookie survives the IdP round trip. + window.location.assign(result.authorizationUrl); + } catch (error) { + fail(error); + setWorking(null); + } + } + + async function unbind() { + if (!saved || !binding.bound || !await confirmDialog( + t("解绑会立即关闭 SSO 登录,并撤销此外部账号建立的所有 SSO 会话。"), + t("确认解绑外部账号"), + { confirmText: t("解绑账号"), confirmVariant: "danger", type: "warning" }, + )) return; + setWorking("unbind"); + setOperationError(null); + try { + const result = await unbindSSOAccount(revision); + message.success(t("外部账号已解绑,SSO 登录已停用。")); + if (result.reauthenticationRequired) reauthenticate("unbound"); + else await load(); + } catch (error) { + fail(error); + } finally { + setWorking(null); + } + } + + function textField(label: string, key: "displayName" | "publicOrigin" | "clientId" | "issuerUrl" | "authUrl" | "tokenUrl" | "userInfoUrl" | "userIdField", placeholder?: string, hint?: string) { + return + update({ [key]: event.target.value })} /> + ; + } + + function time(value: string | null) { + if (!value) return t("从未使用"); + return new Date(value).toLocaleString(lang === "zh" ? "zh-CN" : "en-US"); + } + + return ( +
+ +
+
+ + +
+ +
+ + {loading ?

{t("正在加载 SSO 配置…")}

: loadFailed ? ( +
+ {t("SSO 配置加载失败")} +
+ ) : ( +
+ {callbackNotice ?

{t(callbackNotice.text)}

: null} + {operationError ?
+ {t(operationError)} + {operationError === "SSO 配置已变化,请重新加载后重试。" ? : null} +
: null} + +
+ + + + {textField("VoCat 公开地址", "publicOrigin", "https://vocat.example.com")} + {draft.kind === "pocketid" ? textField("Pocket ID Issuer", "issuerUrl", "https://pocket-id.example.com", t("填写 Pocket ID 的实例地址")) : null} + {textField("Client ID", "clientId", undefined, t("填写身份服务中为 VoCat 创建的客户端 ID"))} + + { setSecret(value); setOperationError(null); }} /> + + {textField("登录按钮名称", "displayName")} + {draft.kind === "generic" ? <> + {textField("授权地址", "authUrl", "https://id.example.com/authorize")} + {textField("Token 地址", "tokenUrl", "https://id.example.com/token")} + {textField("UserInfo 地址", "userInfoUrl", "https://id.example.com/userinfo")} + {textField("用户 ID 字段", "userIdField", "id")} + + { setScopeText(event.target.value); update({ scopes: event.target.value.split(/\s+/).filter(Boolean) }); }} /> + + + + + + + + : null} +
+ + +
+

{callbackPreview || t("请填写有效的 VoCat 公开地址以生成回调地址。")}

+ +
+
+ + {sourceChanged && revision > 0 ?

{t("身份源变化后旧密钥不会继承。保存时将停用 SSO;已有绑定需要确认清除,然后重新绑定。")}

: null} + +
+
+
+

{t("管理员外部账号")}

+ {binding.bound ? <> +

{binding.displayName || t("已绑定外部账号")}

+ {binding.email ?

{binding.email}

: null} +

{t("绑定时间")}:{time(binding.createdAt)}

+

{t("上次 SSO 登录")}:{time(binding.lastLoginAt)}

+ :

{t("尚未绑定。先保存完整配置,再检查并绑定当前管理员的外部账号。")}

} +
+
+ + {binding.bound ? ( + + ) : ( + + )} +
+
+ {dirty ?

{t("有未保存的修改。请先保存,再检查配置、绑定或解绑。")}

: null} + {checkedRevision === revision ?

{t("配置检查通过")}

: null} +
+

{t("开放 SSO 登录")}

{t("完成绑定后启用并保存,登录页才会显示 SSO 入口。本地密码登录始终保留。")}

+ update({ enabled })} /> +
+

{t("每次保存配置都会撤销现有 SSO 会话,需要重新登录。密码登录会话和 API 令牌继续有效。")}

+
+
+ )} +
+ ); +} diff --git a/web/src/lib/i18n-en.ts b/web/src/lib/i18n-en.ts index 0ad831b2..11b7f7e8 100644 --- a/web/src/lib/i18n-en.ts +++ b/web/src/lib/i18n-en.ts @@ -5,6 +5,74 @@ * 富文本片段(嵌套链接/代码块的说明框)不走字典,在组件里按语言分支渲染。 */ export const EN_DICT: Record = { + "单点登录(SSO)": "Single sign-on (SSO)", + "先绑定管理员账号,再开放外部身份登录": "Bind the administrator account before enabling external sign-in", + "保存 SSO 配置": "Save SSO settings", + "正在加载 SSO 配置…": "Loading SSO settings…", + "SSO 配置加载失败": "Failed to load SSO settings", + "身份提供方": "Identity provider", + "填写 Pocket ID 的实例地址": "Enter the Pocket ID instance URL", + "填写身份服务中为 VoCat 创建的客户端 ID": "Enter the client ID created for VoCat in your identity service", + "登录按钮名称": "Sign-in button label", + "VoCat 公开地址": "VoCat public origin", + "填写客户端密钥;仅保存在服务器,不会回显。": "Enter the client secret. It stays on the server and is never returned.", + "已保存密钥;留空表示保持不变。": "A secret is saved. Leave blank to keep it.", + "输入新密钥": "Enter a new secret", + "授权地址": "Authorization URL", + "Token 地址": "Token URL", + "UserInfo 地址": "UserInfo URL", + "用户 ID 字段": "User ID field", + "回调地址": "Callback URL", + "请填写有效的 VoCat 公开地址以生成回调地址。": "Enter a valid VoCat public origin to generate the callback URL.", + "访问地址有未保存的修改,保存后生效。": "The access address has unsaved changes. Save to apply them.", + "仅旧版身份服务不支持 PKCE 时才关闭。": "Disable only for a legacy identity service that does not support PKCE.", + "Scopes(空格分隔)": "Scopes (space separated)", + "Token 客户端认证": "Token client authentication", + "Pocket ID 和 GitHub 始终使用 S256。仅旧版 Generic 服务不支持时才关闭。": "Pocket ID and GitHub always use S256. Disable it only for a legacy Generic provider that does not support PKCE.", + "身份源变化后旧密钥不会继承。保存时将停用 SSO;已有绑定需要确认清除,然后重新绑定。": "Changing the identity source does not carry over the old secret. Saving disables SSO and requires confirmation to clear an existing binding, followed by a new binding.", + "管理员外部账号": "Administrator's external account", + "已绑定外部账号": "External account bound", + "绑定时间": "Bound on", + "上次 SSO 登录": "Last SSO sign-in", + "尚未绑定。先保存完整配置,再检查并绑定当前管理员的外部账号。": "No account is bound. Save the complete settings, check them, then bind the administrator's external account.", + "检查配置": "Check settings", + "绑定外部账号": "Bind external account", + "解绑账号": "Unbind account", + "有未保存的修改。请先保存,再检查配置、绑定或解绑。": "There are unsaved changes. Save them before checking settings, binding or unbinding an account.", + "开放 SSO 登录": "Enable SSO sign-in", + "完成绑定后启用并保存,登录页才会显示 SSO 入口。本地密码登录始终保留。": "After binding an account, enable SSO and save to show it on the sign-in page. Local password sign-in remains available.", + "每次保存配置都会撤销现有 SSO 会话,需要重新登录。密码登录会话和 API 令牌继续有效。": "Saving settings revokes existing SSO sessions and requires signing in again. Local password sessions and API tokens remain valid.", + "身份源变化会清除旧绑定并关闭 SSO 登录。请重新填写密钥,保存后重新绑定外部账号。": "Changing the identity source clears the old binding and disables SSO. Enter a new secret, save, then bind the external account again.", + "更换身份源": "Change identity source", + "清除绑定并保存": "Clear binding and save", + "身份源已变化,请重新填写 Client Secret。": "The identity source changed. Enter a new client secret.", + "SSO 配置已保存": "SSO settings saved", + "配置检查通过": "Configuration check passed", + "解绑会立即关闭 SSO 登录,并撤销此外部账号建立的所有 SSO 会话。": "Unbinding immediately disables SSO and revokes all SSO sessions created by this account.", + "确认解绑外部账号": "Unbind external account?", + "SSO 配置已变化,请重新加载后重试。": "SSO settings changed. Reload and try again.", + "身份源已变化,需要清除旧绑定并重新绑定。": "The identity source changed. Clear the old binding and bind the account again.", + "已绑定外部账号,请先解绑后再绑定。": "An external account is already bound. Unbind it before binding another account.", + "请先补全并保存 SSO 配置和 Client Secret。": "Complete and save the SSO settings and client secret first.", + "SSO 登录尚未启用。": "SSO sign-in is not enabled.", + "请先绑定外部账号,再启用 SSO 登录。": "Bind an external account before enabling SSO sign-in.", + "SSO 配置无效,请检查地址、Scope、PKCE 和 HTTPS Cookie 设置。": "The SSO settings are invalid. Check the URLs, scopes, PKCE and HTTPS cookie settings.", + "操作过于频繁,请稍后重试。": "Too many requests. Try again later.", + "请填写有效的 Client Secret,不能提交掩码。": "Enter a valid client secret. A masked value cannot be saved.", + "请使用浏览器管理员会话管理 SSO。": "Use an administrator browser session to manage SSO.", + "SSO 操作未完成,请检查配置后重试。": "The SSO operation did not complete. Check the settings and try again.", + "已取消身份验证,可以手动重试。": "Authentication was cancelled. You can try again.", + "授权请求无效或已使用,请重新发起。": "The authorization request is invalid or already used. Start again.", + "授权请求已过期,请重新发起。": "The authorization request expired. Start again.", + "此外部账号尚未绑定到当前管理员。请先使用密码登录并绑定。": "This external account is not bound to the administrator. Sign in with your password and bind it first.", + "身份提供方暂时不可用,请稍后重试或使用密码登录。": "The identity provider is unavailable. Try again later or sign in with your password.", + "身份验证未通过,请重新发起或使用密码登录。": "Identity verification failed. Start again or sign in with your password.", + "SSO 操作未完成,请重新发起。": "The SSO operation did not complete. Start again.", + "外部账号已绑定。确认绑定状态后可启用 SSO 登录。": "External account bound. Confirm the binding, then enable SSO sign-in.", + "外部账号已解绑,SSO 登录已停用。": "External account unbound and SSO sign-in disabled.", + "SSO 配置已保存,请重新登录。": "SSO settings saved. Sign in again.", + "通过 {provider} 登录": "Continue with {provider}", + "重新加载": "Reload", "API 令牌": "API tokens", "供脚本与 MCP 客户端使用的 Bearer 凭据;创建请在设备上执行 vocat token create": "Bearer credentials for scripts and MCP clients; create them on the device with vocat token create", "尚未创建任何 API 令牌。在设备上运行 vocat token create --read-only --ttl 720h <名称> 后,令牌会显示在这里。": "No API tokens yet. Run vocat token create --read-only --ttl 720h on the device and the token will appear here.", diff --git a/web/src/lib/safeRedirect.ts b/web/src/lib/safeRedirect.ts new file mode 100644 index 00000000..0c959f77 --- /dev/null +++ b/web/src/lib/safeRedirect.ts @@ -0,0 +1,21 @@ +/** Accept a path already decoded once by URLSearchParams. Never decode it again. */ +export function safeRedirect(value: string | null | undefined, origin = "https://vocat.invalid"): string { + if (!value || value.length > 4096 || new TextEncoder().encode(value).length > 4096 || !value.startsWith("/") || value.startsWith("//")) return "/"; + if (/[\\\u0000-\u0020\u007f]/.test(value) || /%(?![0-9a-f]{2})/i.test(value)) return "/"; + const pathname = value.split(/[?#]/, 1)[0]; + // Encoded path separators, dot segments and another encoding layer cause + // browser/server parsing differences. Query values are preserved verbatim. + if (/%(?:2f|5c|2e|25|0[0-9a-f]|1[0-9a-f]|7f)/i.test(pathname)) return "/"; + try { + const base = new URL(origin); + const parsed = new URL(value, base); + if (parsed.origin !== base.origin || parsed.pathname.startsWith("//")) return "/"; + // Inspect the path as the router sees it, including unreserved encoded + // letters. The query and returned value are never decoded a second time. + const routerPath = decodeURIComponent(parsed.pathname); + if (/^\/(?:login(?:\/|$)|api\/auth(?:\/|$))/i.test(routerPath)) return "/"; + return value; + } catch { + return "/"; + } +} diff --git a/web/src/lib/sso.ts b/web/src/lib/sso.ts new file mode 100644 index 00000000..0c1a3844 --- /dev/null +++ b/web/src/lib/sso.ts @@ -0,0 +1,125 @@ +import type { SSOConfigInput, SSOProviderKind, SSOSaveRequest } from "../types"; + +export function defaultSSOConfig(origin = ""): SSOConfigInput { + return { + enabled: false, + kind: "pocketid", + displayName: "Pocket ID", + publicOrigin: origin, + clientId: "", + issuerUrl: "", + authUrl: "", + tokenUrl: "", + userInfoUrl: "", + userIdField: "", + scopes: ["openid", "profile", "email"], + tokenAuthMethod: "auto", + pkceMode: "s256", + }; +} + +/** Preview only: authorization continues to use the saved server configuration. */ +export function ssoCallbackPreview(publicOrigin: string): string | null { + if (!publicOrigin || publicOrigin.trim() !== publicOrigin || new TextEncoder().encode(publicOrigin).length > 4096 + || /[\p{Cc}\\?#%]/u.test(publicOrigin)) return null; + // Check the raw path before URL can normalize dot segments or extra slashes. + const match = /^(https?):\/\/(\[[^\]]+\]|[^:/@\[\]]+)(?::([0-9]+))?\/?$/i.exec(publicOrigin); + if (!match) return null; + const [, scheme, rawHost, port] = match; + if (port !== undefined && (Number(port) < 1 || Number(port) > 65535)) return null; + try { + const parsed = new URL(publicOrigin); + const host = rawHost.toLowerCase(); + if (!host.startsWith("[") && parsed.hostname !== host) return null; + if (parsed.protocol === "http:") { + const octets = host.split("."); + const ipv4Loopback = octets.length === 4 && octets[0] === "127" + && octets.every((part) => /^(0|[1-9][0-9]{0,2})$/.test(part) && Number(part) <= 255); + const ipv6Loopback = host.startsWith("[") && (parsed.hostname === "[::1]" + || /^\[::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}\]$/.test(parsed.hostname)); + if (host !== "localhost" && !ipv4Loopback && !ipv6Loopback) return null; + } + // Match the server's origin spelling, including IPv6 and nonstandard ports. + const suffix = port && !((parsed.protocol === "https:" && port === "443") || (parsed.protocol === "http:" && port === "80")) ? `:${port}` : ""; + return `${scheme.toLowerCase()}://${host}${suffix}/api/auth/sso/callback`; + } catch { + return null; + } +} + +/** Copy only writable fields; GET metadata must never leak into a PUT. */ +export function ssoConfigInput(value: SSOConfigInput): SSOConfigInput { + const common = { + enabled: !!value.enabled, + kind: value.kind, + displayName: value.displayName ?? "", + publicOrigin: value.publicOrigin ?? "", + clientId: value.clientId ?? "", + issuerUrl: value.kind === "pocketid" ? value.issuerUrl ?? "" : "", + authUrl: value.kind === "generic" ? value.authUrl ?? "" : "", + tokenUrl: value.kind === "generic" ? value.tokenUrl ?? "" : "", + userInfoUrl: value.kind === "generic" ? value.userInfoUrl ?? "" : "", + userIdField: value.kind === "generic" ? value.userIdField ?? "" : "", + scopes: [...new Set(value.scopes ?? [])], + tokenAuthMethod: value.tokenAuthMethod, + pkceMode: value.kind === "generic" ? value.pkceMode : "s256" as const, + }; + return common; +} + +export function changeSSOProvider(value: SSOConfigInput, kind: SSOProviderKind): SSOConfigInput { + return { + ...defaultSSOConfig(value.publicOrigin), + kind, + displayName: kind === "pocketid" ? "Pocket ID" : kind === "github" ? "GitHub" : "OAuth2", + scopes: kind === "pocketid" ? ["openid", "profile", "email"] : kind === "github" ? ["read:user"] : [], + tokenAuthMethod: kind === "pocketid" ? "auto" : kind === "generic" ? "client_secret_post" : "client_secret_basic", + userIdField: kind === "generic" ? "id" : "", + }; +} + +export function ssoIdentitySourceChanged(before: SSOConfigInput, after: SSOConfigInput): boolean { + if (before.kind !== after.kind || before.clientId !== after.clientId) return true; + if (after.kind === "pocketid") return before.issuerUrl !== after.issuerUrl; + if (after.kind === "generic") { + return before.authUrl !== after.authUrl || before.tokenUrl !== after.tokenUrl + || before.userInfoUrl !== after.userInfoUrl || before.userIdField !== after.userIdField; + } + return false; +} + +export function buildSSOSaveRequest( + config: SSOConfigInput, + expectedRevision: number, + secret: string, + resetBinding = false, +): SSOSaveRequest { + if (secret && (/^[*•●\s]+$/.test(secret) || !secret.trim())) throw new Error("sso_invalid_secret"); + return { + expectedRevision, + config: ssoConfigInput(config), + ...(secret ? { clientSecret: secret } : {}), + ...(resetBinding ? { resetBinding: true } : {}), + }; +} + +export const SSO_ERROR_MESSAGES: Record = { + access_denied: "已取消身份验证,可以手动重试。", + invalid_flow: "授权请求无效或已使用,请重新发起。", + flow_expired: "授权请求已过期,请重新发起。", + config_changed: "SSO 配置已变化,请重新加载后重试。", + identity_not_bound: "此外部账号尚未绑定到当前管理员。请先使用密码登录并绑定。", + binding_exists: "已绑定外部账号,请先解绑后再绑定。", + provider_unavailable: "身份提供方暂时不可用,请稍后重试或使用密码登录。", + invalid_identity: "身份验证未通过,请重新发起或使用密码登录。", +}; + +export const SSO_RESULT_MESSAGES: Record = { + bound: "外部账号已绑定。确认绑定状态后可启用 SSO 登录。", + unbound: "外部账号已解绑,SSO 登录已停用。", + config_saved: "SSO 配置已保存,请重新登录。", +}; + +export function ssoErrorMessage(code: string): string { + return Object.hasOwn(SSO_ERROR_MESSAGES, code) ? SSO_ERROR_MESSAGES[code] : "SSO 操作未完成,请重新发起。"; +} diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index 3120a5a7..be7c6d2e 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -1,10 +1,14 @@ -import { useState, type FormEvent } from "react"; +import { useEffect, useState, type FormEvent } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { ArrowRightRegular, LockClosedRegular, PersonRegular } from "@fluentui/react-icons"; import { useAuth } from "../store/auth"; import { useI18n } from "../lib/i18n"; import { message } from "../components/ui/message"; import { BrandLogo } from "../components/shell/BrandLogo"; +import { getSSOProviders } from "../api"; +import type { SSOProviders } from "../types"; +import { safeRedirect } from "../lib/safeRedirect"; +import { ssoErrorMessage, SSO_RESULT_MESSAGES } from "../lib/sso"; const INPUT_CLASS = "w-full rounded-lg border border-gray-200 bg-white/70 py-3 pl-10 pr-4 font-mono text-sm text-gray-900 placeholder-gray-400 outline-none transition-all focus:border-indigo-500/40 focus:ring-2 focus:ring-indigo-500/25 dark:border-white/10 dark:bg-black/20 dark:text-gray-100 dark:placeholder-gray-500"; @@ -13,10 +17,33 @@ export default function LoginPage() { const { login } = useAuth(); const { t } = useI18n(); const navigate = useNavigate(); - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [working, setWorking] = useState(false); + const [providers, setProviders] = useState(null); + const [ssoNotice, setSSONotice] = useState<{ error: boolean; text: string } | null>(null); + const redirect = safeRedirect(searchParams.get("redirect"), window.location.origin); + + useEffect(() => { + let cancelled = false; + void getSSOProviders().then((data) => { + if (!cancelled) setProviders(data); + }).catch(() => { /* Password login remains available when metadata cannot load. */ }); + return () => { cancelled = true; }; + }, []); + + useEffect(() => { + const error = searchParams.get("sso_error"); + const result = searchParams.get("sso_result"); + if (!error && !result) return; + if (error) setSSONotice({ error: true, text: ssoErrorMessage(error) }); + else if (result && Object.hasOwn(SSO_RESULT_MESSAGES, result)) setSSONotice({ error: false, text: SSO_RESULT_MESSAGES[result] }); + const next = new URLSearchParams(searchParams); + next.delete("sso_error"); + next.delete("sso_result"); + setSearchParams(next, { replace: true }); + }, [searchParams, setSearchParams]); async function submit(event: FormEvent) { event.preventDefault(); @@ -30,8 +57,7 @@ export default function LoginPage() { setWorking(false); if (ok) { message.success(t("欢迎回来")); - const redirect = searchParams.get("redirect"); - navigate(redirect ? decodeURIComponent(redirect) : "/", { replace: true }); + navigate(redirect, { replace: true }); } else { message.error(t("登录失败,请检查凭证")); } @@ -53,6 +79,11 @@ export default function LoginPage() {

{t("高通模块专业测试工具")}

+ {ssoNotice ? ( +
+ {t(ssoNotice.text)} +
+ ) : null}
@@ -97,6 +128,16 @@ export default function LoginPage() { {!working && } + {providers?.sso.enabled ? ( + + ) : null}

vocat © 2026

diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index c7c0ab9d..4354c073 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -7,6 +7,7 @@ import { CardDecor, CardIcon, CardTitle, SecurityCard, SystemInfoCard } from ".. import type { PasswordForm, UpdateInfo } from "../components/settings/Cards"; import { NetworkAccessCard } from "../components/settings/NetworkAccessCard"; import { ApiTokensCard } from "../components/settings/ApiTokensCard"; +import { SSOCard } from "../components/settings/SSOCard"; import type { NetworkAccessForm } from "../components/settings/NetworkAccessCard"; import { NotificationDeviceScope, SegmentedTabs } from "../components/settings/controls"; import { tf, useI18n } from "../lib/i18n"; @@ -649,6 +650,7 @@ export default function SettingsPage() { )}
+
); diff --git a/web/src/types.ts b/web/src/types.ts index 46f2963e..e68daf95 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -545,3 +545,63 @@ export type Notice = { title: string; detail?: string; } | null; + +export type SSOProviderKind = "pocketid" | "github" | "generic"; + +export interface SSOConfigInput { + enabled: boolean; + kind: SSOProviderKind; + displayName: string; + publicOrigin: string; + clientId: string; + issuerUrl: string; + authUrl: string; + tokenUrl: string; + userInfoUrl: string; + userIdField: string; + scopes: string[]; + tokenAuthMethod: "auto" | "client_secret_basic" | "client_secret_post"; + pkceMode: "s256" | "disabled"; +} + +export interface SSOConfig extends SSOConfigInput { + revision: number; +} + +export interface SSOBinding { + bound: boolean; + displayName: string; + email: string; + createdAt: string | null; + lastLoginAt: string | null; +} + +export interface SSOSettings { + config: SSOConfig; + binding: SSOBinding; + configured: boolean; + hasClientSecret: boolean; + reauthenticationRequired?: boolean; +} + +export interface SSOSaveRequest { + expectedRevision: number; + config: SSOConfigInput; + clientSecret?: string; + resetBinding?: boolean; +} + +export interface SSOProviders { + sso: { + enabled: boolean; + displayName?: string; + }; +} + +export interface SSOCheckResult { + revision: number; +} + +export interface SSOUnbindResult { + reauthenticationRequired: boolean; +} diff --git a/web/test/ssoRedirect.test.mjs b/web/test/ssoRedirect.test.mjs new file mode 100644 index 00000000..aacf937a --- /dev/null +++ b/web/test/ssoRedirect.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import ts from "typescript"; + +const source = await readFile(new URL("../src/lib/safeRedirect.ts", import.meta.url), "utf8"); +const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 } }); +const { safeRedirect } = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputText).toString("base64")}`); +const vectors = JSON.parse(await readFile(new URL("./fixtures/ssoRedirect.json", import.meta.url), "utf8")); + +test("front-end redirects match the shared server security vectors", () => { + for (const { input, expected } of vectors) { + assert.equal(safeRedirect(input, "https://vocat.example.com"), expected, JSON.stringify(input)); + } +}); + +test("URLSearchParams decodes a redirect exactly once and keeps query encodings", () => { + const original = "/sms?keyword=%E6%B5%8B%E8%AF%95&encoded=%252f#latest"; + const query = new URLSearchParams({ redirect: original }); + assert.equal(safeRedirect(new URLSearchParams(query.toString()).get("redirect")), original); + assert.equal(safeRedirect(new URLSearchParams("redirect=%252F%252Fevil.example").get("redirect")), "/"); + assert.equal(safeRedirect("/sms?keyword=" + "a".repeat(4096)), "/"); + assert.equal(safeRedirect("/" + "测".repeat(1366)), "/"); +}); diff --git a/web/test/ssoSettingsModel.test.mjs b/web/test/ssoSettingsModel.test.mjs new file mode 100644 index 00000000..8a3d6382 --- /dev/null +++ b/web/test/ssoSettingsModel.test.mjs @@ -0,0 +1,174 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import ts from "typescript"; + +const source = await readFile(new URL("../src/lib/sso.ts", import.meta.url), "utf8"); +const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 } }); +const { defaultSSOConfig, changeSSOProvider, ssoCallbackPreview, ssoIdentitySourceChanged, buildSSOSaveRequest, ssoErrorMessage } = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputText).toString("base64")}`); + +test("Pocket ID starts as an unsaved disabled draft with PKCE and automatic client authentication", () => { + const config = defaultSSOConfig("https://vocat.example.com"); + assert.equal(config.kind, "pocketid"); + assert.equal(config.enabled, false); + assert.equal(config.pkceMode, "s256"); + assert.equal(config.tokenAuthMethod, "auto"); + assert.deepEqual(config.scopes, ["openid", "profile", "email"]); +}); + +test("callback URLs are available before saving credentials, including loopback development", () => { + for (const origin of ["http://localhost:5173", "http://127.0.0.1:8080", "http://[::1]:8080", "https://vocat.nas.test:8443"]) { + const draft = defaultSSOConfig(origin); + assert.equal(draft.clientId, ""); + assert.equal(ssoCallbackPreview(draft.publicOrigin), `${origin}/api/auth/sso/callback`); + } +}); + +test("callback preview normalizes origins using the server's rules", () => { + for (const [input, origin] of [ + ["HTTPS://VoCat.Example:443/", "https://vocat.example"], + ["http://LOCALHOST:80/", "http://localhost"], + ["http://127.12.34.56:8080/", "http://127.12.34.56:8080"], + ["http://[0:0:0:0:0:0:0:1]:8080/", "http://[0:0:0:0:0:0:0:1]:8080"], + ["http://[::ffff:127.0.0.1]:8080", "http://[::ffff:127.0.0.1]:8080"], + ["https://VoCat.Example:0443/", "https://vocat.example:0443"], + ]) assert.equal(ssoCallbackPreview(input), `${origin}/api/auth/sso/callback`, input); +}); + +test("unsaved origin edits update the callback preview without changing the saved configuration", () => { + const saved = { ...defaultSSOConfig("https://canonical.example"), revision: 7 }; + const draft = { ...saved, publicOrigin: "https://vocat.nas.test:8443" }; + assert.equal(ssoCallbackPreview(draft.publicOrigin), "https://vocat.nas.test:8443/api/auth/sso/callback"); + assert.equal(ssoCallbackPreview(saved.publicOrigin), "https://canonical.example/api/auth/sso/callback"); + assert.equal(saved.revision, 7); +}); + +test("invalid public origins never produce a copyable callback URL", () => { + for (const origin of [ + "", "/", "vocat.example", "//vocat.example", "https:vocat.example", "ftp://vocat.example", + "https://vocat.example/vocat", "https://vocat.example/./", "https://vocat.example/a/../", "https://vocat.example/%2e/", + "https://vocat.example?", "https://vocat.example/?query=1", "https://vocat.example#", "https://vocat.example/#fragment", + "https://user:secret@vocat.example", "https://@vocat.example", "https://vocat.example\\path", + " https://vocat.example", "https://vocat.example ", "https://vocat.\nexample", "https://vocat.example\u0085", + "https://vocat.example:0", "https://vocat.example:", "https://vocat.example:65536", "https://[::1", + "http://vocat.example", "http://192.168.1.20:8080", "http://localhost.", + "http://127.1", "http://127.00.0.1", "http://2130706433", "http://0x7f000001", "http://[::2]", + ]) assert.equal(ssoCallbackPreview(origin), null, origin); +}); + +test("saving omits the unchanged secret and never sends read-only metadata", () => { + const config = { ...defaultSSOConfig(), revision: 3, providerInstanceId: "private-server-namespace" }; + const body = buildSSOSaveRequest(config, 3, ""); + assert.equal(Object.hasOwn(body, "clientSecret"), false); + assert.equal(Object.hasOwn(body, "clearClientSecret"), false); + assert.equal(Object.hasOwn(body.config, "revision"), false); + assert.equal(Object.hasOwn(body.config, "providerInstanceId"), false); + assert.equal(body.expectedRevision, 3); + assert.equal(buildSSOSaveRequest(config, 3, "new-secret").clientSecret, "new-secret"); + assert.equal(Object.hasOwn(buildSSOSaveRequest({ ...config, enabled: true }, 3, ""), "clientSecret"), false); +}); + +test("secret replacement rejects masks and binding reset remains explicit", () => { + const config = defaultSSOConfig(); + for (const mask of ["********", "••••••", "●●●", " ", " **** "]) { + assert.throws(() => buildSSOSaveRequest(config, 1, mask), /sso_invalid_secret/); + } + assert.equal(Object.hasOwn(buildSSOSaveRequest(config, 1, ""), "resetBinding"), false); + const reset = buildSSOSaveRequest(config, 1, "new-secret", true); + assert.equal(reset.resetBinding, true); + assert.equal(reset.clientSecret, "new-secret"); + assert.equal(Object.hasOwn(reset, "clearClientSecret"), false); +}); + +test("provider changes discard endpoint fields and use the new provider defaults", () => { + const generic = changeSSOProvider(defaultSSOConfig(), "generic"); + Object.assign(generic, { authUrl: "https://old.example/auth", tokenUrl: "https://old.example/token", userInfoUrl: "https://old.example/user", clientId: "old", enabled: true }); + const pocket = changeSSOProvider(generic, "pocketid"); + assert.equal(pocket.authUrl, ""); + assert.equal(pocket.clientId, ""); + assert.equal(pocket.enabled, false); + assert.equal(pocket.pkceMode, "s256"); + assert.equal(pocket.tokenAuthMethod, "auto"); + const payload = buildSSOSaveRequest({ ...pocket, userIdField: "Old_Claim_Name" }, 3, ""); + assert.equal(payload.config.userIdField, ""); + const genericPayload = buildSSOSaveRequest({ ...generic, userIdField: "User_ID" }, 3, ""); + assert.equal(genericPayload.config.userIdField, "User_ID"); +}); + +test("saving a label edit preserves saved origin and hidden Pocket ID protocol settings", () => { + for (const tokenAuthMethod of ["client_secret_basic", "client_secret_post", "auto"]) { + const saved = { + ...defaultSSOConfig("https://canonical.example"), + clientId: "saved-client", + issuerUrl: "https://id.example", + scopes: ["openid", "email"], + tokenAuthMethod, + }; + const request = buildSSOSaveRequest({ ...saved, displayName: "New label" }, 12, ""); + assert.deepEqual(request.config, { ...saved, displayName: "New label" }); + assert.equal(request.expectedRevision, 12); + assert.equal(Object.hasOwn(request, "clientSecret"), false); + } +}); + +test("Generic retains explicitly configured endpoints and protocol settings", () => { + const saved = { + ...changeSSOProvider(defaultSSOConfig("https://canonical.example"), "generic"), + clientId: "generic-client", + authUrl: "https://id.example/authorize", + tokenUrl: "http://192.168.1.20:1411/token", + userInfoUrl: "http://id.nas.example:1411/userinfo", + userIdField: "External_ID", + scopes: ["profile", "custom"], + tokenAuthMethod: "client_secret_basic", + pkceMode: "disabled", + }; + const request = buildSSOSaveRequest({ ...saved, displayName: "New label" }, 8, ""); + assert.deepEqual(request.config, { ...saved, displayName: "New label" }); + assert.equal(Object.hasOwn(request, "clientSecret"), false); +}); + +test("localhost, NAS and public VoCat origins do not change explicitly configured IdP addresses", () => { + for (const publicOrigin of ["http://localhost:5173", "https://vocat.nas.example:8443", "https://vocat.example"]) { + const pocket = { ...defaultSSOConfig(publicOrigin), issuerUrl: "https://id.nas.example:8443" }; + const pocketRequest = buildSSOSaveRequest(pocket, 4, ""); + assert.equal(pocketRequest.config.publicOrigin, publicOrigin); + assert.equal(pocketRequest.config.issuerUrl, pocket.issuerUrl); + const generic = { + ...changeSSOProvider(pocket, "generic"), + authUrl: "https://id.nas.example:8443/authorize", + tokenUrl: "http://192.168.1.20:1411/token", + userInfoUrl: "http://id.nas.example:1411/userinfo", + }; + assert.deepEqual(buildSSOSaveRequest(generic, 4, "").config, generic); + } +}); + +test("SSO save payload contains only the supported provider fields", () => { + const config = { ...defaultSSOConfig(), arbitraryNetworkPermission: true }; + const request = buildSSOSaveRequest(config, 4, ""); + assert.deepEqual(Object.keys(request.config).sort(), [ + "enabled", "kind", "displayName", "publicOrigin", "clientId", "issuerUrl", "authUrl", "tokenUrl", "userInfoUrl", + "userIdField", "scopes", "tokenAuthMethod", "pkceMode", + ].sort()); + assert.equal(Object.hasOwn(request.config, "arbitraryNetworkPermission"), false); +}); + +test("only identity source changes require rebinding", () => { + const pocket = { ...defaultSSOConfig(), clientId: "vocat", issuerUrl: "https://id.example" }; + assert.equal(ssoIdentitySourceChanged(pocket, { ...pocket, displayName: "Custom", scopes: ["openid"], publicOrigin: "https://new-vocat.example" }), false); + assert.equal(ssoIdentitySourceChanged(pocket, { ...pocket, issuerUrl: "https://id.example/" }), true); + assert.equal(ssoIdentitySourceChanged(pocket, { ...pocket, clientId: "new" }), true); + const generic = changeSSOProvider(pocket, "generic"); + for (const field of ["authUrl", "tokenUrl", "userInfoUrl", "userIdField", "clientId"]) { + assert.equal(ssoIdentitySourceChanged(generic, { ...generic, [field]: "changed" }), true, field); + } +}); + +test("callback errors use a fixed whitelist, including prototype keys", () => { + assert.match(ssoErrorMessage("access_denied"), /取消/); + assert.match(ssoErrorMessage("binding_exists"), /先解绑/); + for (const code of ["", "__proto__", "constructor", "raw-secret"]) { + assert.equal(ssoErrorMessage(code), "SSO 操作未完成,请重新发起。"); + } +});