diff --git a/.gitignore b/.gitignore index cc68ad44b..87466a51d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ !.env.docker !.env.production *serviceAccountKey.json +httpsms-gw-firebase-adminsdk-*.json +httpsms-pre-deployment.md android/app/debug/ *main.exe* android/app/release/ diff --git a/android/app/google-services.json b/android/app/google-services.json index 4db4c0640..aafa63a31 100644 --- a/android/app/google-services.json +++ b/android/app/google-services.json @@ -1,44 +1,25 @@ { "project_info": { - "project_number": "877524083399", - "project_id": "httpsms-86c51", - "storage_bucket": "httpsms-86c51.appspot.com" + "project_number": "87264986792", + "project_id": "httpsms-gw", + "storage_bucket": "httpsms-gw.firebasestorage.app" }, "client": [ { "client_info": { - "mobilesdk_app_id": "1:877524083399:android:5ceb08a909fd24a96514e2", + "mobilesdk_app_id": "1:87264986792:android:e3e6e39956e0bcf61da49d", "android_client_info": { "package_name": "com.httpsms" } }, - "oauth_client": [ - { - "client_id": "877524083399-londfmd80qqlb2gebpbhctavlmqtla3b.apps.googleusercontent.com", - "client_type": 1, - "android_info": { - "package_name": "com.httpsms", - "certificate_hash": "58df9f7489b8bb5e3cd57f01a03d5e0068d8c185" - } - }, - { - "client_id": "877524083399-7pn57hd8k2js6tc93i428ko1m51mj50r.apps.googleusercontent.com", - "client_type": 3 - } - ], "api_key": [ { - "current_key": "AIzaSyBKiokmk5qhE2Zc5F97KWzOvRe8FDDuxDg" + "current_key": "AIzaSyB_Ymoydr76Kesgfh4_UcA5Wk6rJlBP0cU" } ], "services": { "appinvite_service": { - "other_platform_oauth_client": [ - { - "client_id": "877524083399-7pn57hd8k2js6tc93i428ko1m51mj50r.apps.googleusercontent.com", - "client_type": 3 - } - ] + "other_platform_oauth_client": [] } } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 86ca0a514..cc742ef15 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -14,6 +14,7 @@ + diff --git a/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt b/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt index 51fa21cdc..b7f71d600 100644 --- a/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt +++ b/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt @@ -5,6 +5,7 @@ import com.httpsms.Constants.Companion.MAX_MMS_ATTACHMENT_SIZE import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient +import okhttp3.Protocol import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import timber.log.Timber @@ -23,13 +24,18 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) { private val apiKeyHeader = "x-api-key" private val clientVersionHeader = "X-Client-Version" private val jsonMediaType = "application/json; charset=utf-8".toMediaType() - private val client = OkHttpClient.Builder().retryOnConnectionFailure(true).build() + private val client = OkHttpClient.Builder() + .protocols(gatewayProtocols()) + .retryOnConnectionFailure(true) + .build() init { getLogger(OkHttpClient::class.java.name).level = Level.FINE } companion object { + internal fun gatewayProtocols(): List = listOf(Protocol.HTTP_1_1) + fun create(context: Context): HttpSmsApiService { return HttpSmsApiService( Settings.getApiKeyOrDefault(context), diff --git a/android/app/src/main/java/com/httpsms/LoginActivity.kt b/android/app/src/main/java/com/httpsms/LoginActivity.kt index 17bd6db66..9945f7eae 100644 --- a/android/app/src/main/java/com/httpsms/LoginActivity.kt +++ b/android/app/src/main/java/com/httpsms/LoginActivity.kt @@ -140,6 +140,11 @@ class LoginActivity : AppCompatActivity() { permissions += Manifest.permission.POST_NOTIFICATIONS } + // Android 17 blocks LAN sockets by default for apps targeting SDK 37+. + if (Build.VERSION.SDK_INT >= 37) { + permissions += Manifest.permission.ACCESS_LOCAL_NETWORK + } + requestPermissionLauncher.launch(permissions) Timber.d("creating permissions launcher") diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 02dfa1b63..e4c5637a6 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -14,7 +14,7 @@ Phone Number Send Heartbeat Server URL - https://api.httpsms.com + https://smsapi.yangdy.com httpsms.com - %s Disable Battery Optimization Enable SMS Permission diff --git a/android/app/src/test/java/com/httpsms/HttpSmsApiServiceTest.kt b/android/app/src/test/java/com/httpsms/HttpSmsApiServiceTest.kt new file mode 100644 index 000000000..f9511d9cd --- /dev/null +++ b/android/app/src/test/java/com/httpsms/HttpSmsApiServiceTest.kt @@ -0,0 +1,12 @@ +package com.httpsms + +import okhttp3.Protocol +import org.junit.Assert.assertEquals +import org.junit.Test + +class HttpSmsApiServiceTest { + @Test + fun gatewayRequestsUseHttp11ForCompatibilityWithTheSharedTlsProxy() { + assertEquals(listOf(Protocol.HTTP_1_1), HttpSmsApiService.gatewayProtocols()) + } +} diff --git a/deploy/.gitignore b/deploy/.gitignore new file mode 100644 index 000000000..1926c8651 --- /dev/null +++ b/deploy/.gitignore @@ -0,0 +1 @@ +*.pre.env diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 000000000..f2825d2ea --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,40 @@ +# PRE Compose deployment + +Use `--env-file deploy/web.pre.env` whenever you invoke this Compose file. Compose +uses that option for `${CLOUDFLARE_TURNSTILE_SITE_KEY}` interpolation in the +viewer web build arguments; the `web` service's `env_file` alone does not provide +values for build interpolation. + +For example, from the repository root: + +```sh +docker compose --env-file deploy/web.pre.env -f deploy/httpsms-pre-compose.yml build viewer-web +docker compose --env-file deploy/web.pre.env -f deploy/httpsms-pre-compose.yml up -d +``` + +`CLOUDFLARE_TURNSTILE_SITE_KEY` is a public widget key. Search is disabled by +default through `VIEWER_SEARCH_ENABLED=false`. Keep it false unless +`deploy/viewer.pre.env` contains the exact Nginx source address in +`TRUSTED_PROXIES` and the forwarded client IP chain has been verified. Only +then set `VIEWER_SEARCH_ENABLED=true` and rebuild `viewer-web`. A missing site +key also keeps search hidden. Keep BFF credentials in `deploy/viewer.pre.env`; +they are loaded at container runtime and are never build arguments. + +The BFF enforces Redis-backed limits across replicas: login is limited to 120 +requests per observed client IP per 15 minutes, callback to 120 per observed IP per 15 +minutes, session/thread/message reads to 60 per session per minute, search to 20 +per session per minute, and logout to 10 per session per minute. Each route also +has an independent site-wide ceiling (1200 login/callback requests per 15 +minutes; 6000 session/thread/message reads, 2000 searches, or 1000 logout +requests per minute) to bound temporary rate-key storage and total request +volume. If Redis is +unavailable, these requests fail closed with a generic 503. The BFF derives +client IP through Fiber's trusted-proxy allowlist; it does not read forwarded IP +headers directly. `web/nginx.conf` replaces the incoming forwarding headers +with `$remote_addr`, so the allowlist must contain only the exact source address +of the Nginx container on its path to `viewer-bff`. With an empty allowlist, +Fiber sees the Nginx container address for every anonymous login/callback; the +120 per-IP limit caps each route at 120 requests across all users per 15 +minutes. The separate 1200 request site-wide ceiling still applies. This mode +remains header-spoof-safe; do not trust a broad or operator-controlled client +header to work around that aggregation. diff --git a/deploy/api.pre.env.example b/deploy/api.pre.env.example new file mode 100644 index 000000000..ed6f0b0d2 --- /dev/null +++ b/deploy/api.pre.env.example @@ -0,0 +1,31 @@ +ENV=local +GCP_PROJECT_ID=httpsms-docker +USE_HTTP_LOGGER=true +ENTITLEMENT_ENABLED=false +EVENTS_QUEUE_TYPE=emulator +EVENTS_QUEUE_NAME=events-pre +EVENTS_QUEUE_ENDPOINT=http://api:8000/v1/events +EVENTS_QUEUE_USER_API_KEY=generate-a-random-key +EVENTS_QUEUE_USER_ID=generate-a-uuid +FIREBASE_CREDENTIALS= +SMTP_FROM_NAME=httpSMS +SMTP_FROM_EMAIL=httpsms@sms.yangdy.com +APP_URL=https://sms.yangdy.com +APP_NAME=httpSMS +APP_HOST=0.0.0.0 +APP_PORT=8000 +SWAGGER_HOST=sms.yangdy.com +DATABASE_URL=postgresql://httpsms:change-me@postgres:5432/httpsms +DATABASE_URL_DEDICATED=postgresql://httpsms:change-me@postgres:5432/httpsms +# Shared Redis service also stores Passport viewer sessions. +REDIS_URL=redis://redis:6379/0 +GCS_BUCKET_NAME= +AXIOM_TOKEN= +AXIOM_DATASET_EVENTS= +AXIOM_DATASET_METRICS= +PUSHER_APP_ID= +PUSHER_KEY= +PUSHER_SECRET= +PUSHER_CLUSTER= +CLOUDFLARE_TURNSTILE_SECRET_KEY= +CORS_ALLOW_ORIGINS=https://sms.yangdy.com diff --git a/deploy/httpsms-pre-compose.yml b/deploy/httpsms-pre-compose.yml new file mode 100644 index 000000000..acc228860 --- /dev/null +++ b/deploy/httpsms-pre-compose.yml @@ -0,0 +1,106 @@ +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_DB: httpsms + POSTGRES_USER: httpsms + env_file: + - ./postgres.pre.env + volumes: + - postgres:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 10 + redis: + image: redis:7-alpine + volumes: + - redis:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + api: + build: + context: ../api + ports: + - "0.0.0.0:18000:8000" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + env_file: + - ./api.pre.env + restart: unless-stopped + web: + build: + context: ../web + args: + API_BASE_URL: https://smsapi.yangdy.com + APP_URL: https://sms.yangdy.com + APP_NAME: httpSMS + APP_GITHUB_URL: https://github.com/NdoleStudio/httpsms + APP_DOCUMENTATION_URL: https://docs.httpsms.com + APP_DOWNLOAD_URL: https://sms.yangdy.com/HttpSms.apk + APP_ENV: production + FIREBASE_API_KEY: AIzaSyB_Ymoydr76Kesgfh4_UcA5Wk6rJlBP0cU + FIREBASE_AUTH_DOMAIN: httpsms-gw.firebaseapp.com + FIREBASE_PROJECT_ID: httpsms-gw + FIREBASE_STORAGE_BUCKET: httpsms-gw.firebasestorage.app + FIREBASE_MESSAGING_SENDER_ID: "87264986792" + FIREBASE_APP_ID: 1:87264986792:web:406ec7d78ca8975c1da49d + FIREBASE_MEASUREMENT_ID: G-5H1BPY7SBW + ports: + - "0.0.0.0:13000:3000" + depends_on: + api: + condition: service_started + viewer-bff: + condition: service_healthy + viewer-web: + condition: service_healthy + env_file: + - ./web.pre.env + restart: unless-stopped + viewer-bff: + build: + context: ../viewer/bff + dockerfile: Dockerfile + env_file: + - ./viewer.pre.env + extra_hosts: + - "passport.yangdy.com:host-gateway" + depends_on: + redis: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/health"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 30s + viewer-web: + build: + context: ../viewer/web + dockerfile: Dockerfile + args: + # Compose interpolation reads --env-file, not the web service's env_file. + VITE_TURNSTILE_SITE_KEY: ${CLOUDFLARE_TURNSTILE_SITE_KEY:-} + # Search is opt-in only after TRUSTED_PROXIES is configured and verified. + VITE_VIEWER_SEARCH_ENABLED: ${VIEWER_SEARCH_ENABLED:-false} + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/viewer/"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 10s +volumes: + redis: + postgres: diff --git a/deploy/postgres.pre.env.example b/deploy/postgres.pre.env.example new file mode 100644 index 000000000..baf889bd6 --- /dev/null +++ b/deploy/postgres.pre.env.example @@ -0,0 +1 @@ +POSTGRES_PASSWORD=replace-with-the-same-password-used-in-api.pre.env diff --git a/deploy/viewer.pre.env.example b/deploy/viewer.pre.env.example new file mode 100644 index 000000000..abedf00c5 --- /dev/null +++ b/deploy/viewer.pre.env.example @@ -0,0 +1,20 @@ +# Passport OIDC provider values for the HTTPSMS pre environment. +PASSPORT_ISSUER=https://passport.example.com +PASSPORT_CLIENT_ID=replace-with-passport-client-id +PASSPORT_CLIENT_SECRET=replace-with-passport-client-secret +PASSPORT_REDIRECT_URL=https://sms.yangdy.com/viewer-api/callback + +# Generate a random value of at least 32 bytes. +SESSION_SECRET=replace-with-random-secret-at-least-32-bytes + +# Reuse the existing Redis container; do not expose this service publicly. +REDIS_URL=redis://redis:6379/0 + +# HTTPSMS API gateway. +# BFF and gateway share the HTTPSMS Compose network in pre. +GATEWAY_API_URL=http://api:8000/v1 +GATEWAY_API_KEY=replace-with-httpsms-gateway-key +APP_ENV=pre +# Search remains disabled unless the web build is explicitly enabled and Nginx's +# exact source address has been configured and verified against this allowlist. +TRUSTED_PROXIES= diff --git a/deploy/web.pre.env.example b/deploy/web.pre.env.example new file mode 100644 index 000000000..523edf84a --- /dev/null +++ b/deploy/web.pre.env.example @@ -0,0 +1,18 @@ +API_BASE_URL=https://smsapi.yangdy.com +APP_URL=https://sms.yangdy.com +APP_NAME=httpSMS +APP_GITHUB_URL=https://github.com/NdoleStudio/httpsms +APP_DOCUMENTATION_URL=https://docs.httpsms.com +APP_DOWNLOAD_URL=https://sms.yangdy.com/HttpSms.apk +APP_ENV=production +FIREBASE_API_KEY=AIzaSyB_Ymoydr76Kesgfh4_UcA5Wk6rJlBP0cU +FIREBASE_AUTH_DOMAIN=httpsms-gw.firebaseapp.com +FIREBASE_PROJECT_ID=httpsms-gw +FIREBASE_STORAGE_BUCKET=httpsms-gw.firebasestorage.app +FIREBASE_MESSAGING_SENDER_ID=87264986792 +FIREBASE_APP_ID=1:87264986792:web:406ec7d78ca8975c1da49d +FIREBASE_MEASUREMENT_ID=G-5H1BPY7SBW +# Public site key; Compose passes this as a viewer build arg. Leave empty to disable viewer search. +CLOUDFLARE_TURNSTILE_SITE_KEY= +# Keep false unless viewer.pre.env has an exact, verified Nginx address in TRUSTED_PROXIES. +VIEWER_SEARCH_ENABLED=false diff --git a/docs/superpowers/plans/2026-10-12-sms-passport-readonly-viewer.md b/docs/superpowers/plans/2026-10-12-sms-passport-readonly-viewer.md new file mode 100644 index 000000000..aa574fb8b --- /dev/null +++ b/docs/superpowers/plans/2026-10-12-sms-passport-readonly-viewer.md @@ -0,0 +1,440 @@ +# SMS Passport 只读消息查看器 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Default to `superpowers:subagent-driven-development` to implement this plan task-by-task. Only use `superpowers:executing-plans` if the user explicitly asks for inline execution. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 建立 Passport 登录的 SMS 只读查看器,使管理员为每个 Passport 用户绑定一个手机号,且用户只能读取该号码的消息和会话。 + +**Architecture:** 保留现有 httpSMS Firebase 控制台和收发 API。Passport 使用 `httpsms` 应用档案提供手机号授权;独立 Go BFF 执行 OIDC 授权码 + PKCE、Redis 服务端会话和按号码固定的网关 API 请求;独立 Vue 静态前端通过现有 Web Nginx 同源挂载在 `/viewer/`。附件首期不开放。 + +**Tech Stack:** Passport Spring Boot / Spring Authorization Server / React / JPA;SMS Viewer Go / Fiber v3 / `go-oidc` / `oauth2` / Redis;Vue 3 + Vite;Docker Compose、Nginx。 + +--- + +## 实施范围与仓库边界 + +本计划跨两个仓库,必须分别隔离开发并独立提交。执行前设置 `HTTPSMS_ROOT` 为本计划所在的 httpsms worktree 根目录;`PASSPORT_ROOT` 为现有同级 Passport 主 checkout;`PASSPORT_WT="$PASSPORT_ROOT/.worktrees/sms-passport-viewer"`。下文 Passport 路径均相对 `PASSPORT_WT`,httpsms 路径均相对 `HTTPSMS_ROOT`。不要假设从一个仓库的 worktree 可以用相对路径引用另一个仓库。 + +- `httpsms`:当前仓库;本计划文件、Go BFF、Vue viewer、Nginx 和预发 Compose。 +- `passport`:同级 Passport 仓库;应用 schema、用户资料管理、档案唯一性和 OIDC profile policy。 + +执行时先在 `httpsms` 的功能 worktree 建立/更新计划,再在 `passport` 创建同名主题的独立 worktree。禁止把两个仓库的文件放进同一个 Git worktree。Passport 先完成档案 API 和配置,Viewer 后续对接该接口;两个仓库用同一个 `sub` 和 `phone_number` 契约集成。 + +阶段依赖: + +```mermaid +flowchart LR + A[Passport 手机号档案] --> B[Go BFF 与 OIDC 会话] + B --> C[Vue 只读查看器] + C --> D[同源代理与 Docker 配置] + D --> E[预发隔离验收] +``` + +## 文件职责地图 + +### Passport 仓库(相对 Passport 根目录) + +- `passport-server/src/main/java/com/yuanyuan/passport/profile/ProfileSchemas.java`:注册 `httpsms-v1`,验证单个 E.164 `phone_number`。 +- `passport-server/src/main/java/com/yuanyuan/passport/profile/ProfileService.java`、`ApplicationRepository.java`、`ApplicationProfileRepository.java`:创建应用、写入/读取手机号档案、串行化唯一性检查并审计。 +- `passport-server/src/main/java/com/yuanyuan/passport/api/AdminUserController.java`:用户创建请求支持可选的 SMS 手机号,并在事务中建立应用档案。 +- `passport-web/src/App.jsx`:管理员用户创建表单收集号码并传给管理 API。 +- `passport-web/src/profiles/profileUi.jsx`、`UserProfiles.jsx`:管理员编辑手机号时显示专用标签;普通用户端只读/不展示敏感绑定字段。 +- 对应的 `passport-server/src/test/java/...` 和 `passport-web/src/**/*.test.jsx`:校验、事务、权限与 UI 行为。 +- `docs/application-user-profiles.md`:记载 `httpsms` 应用档案与 client policy 配置步骤。 + +### httpsms 仓库 + +- `viewer/bff/main.go`、`config.go`:独立 Fiber 服务启动、环境配置校验、健康检查。 +- `viewer/bff/oidc.go`、`session.go`:Passport discovery、授权码 + PKCE、ID token/nonce 校验、UserInfo sub 一致性、Redis session 和安全 Cookie。 +- `viewer/bff/passport_profile.go`:将经验证 UserInfo 中 `app_profile.app_key=httpsms` 的 `phone_number` 解析为授权号码,并拒绝空值/非法 E.164 值。 +- `viewer/bff/gateway.go`:只封装已允许的 GET 网关接口,使用固定 API base URL 和服务端 API Key;owner 始终来自 Passport 档案。 +- `viewer/bff/handlers.go`:session、logout、threads、thread messages、全局搜索路由;无任意路径代理。 +- `viewer/bff/*_test.go`:使用 `httptest` 模拟 Passport、网关和 Redis/session 边界。 +- `viewer/bff/Dockerfile`、`viewer/web/Dockerfile`:独立构建镜像。 +- `viewer/web/`:Vue 3 + Vite 只读 UI,支持登录、无绑定提示、线程列表、对话详情、分页、错误状态和 Turnstile 搜索。 +- `viewer/web/src/api.ts`:只访问同源 `/viewer-api/*`,统一 `credentials: include` 和错误处理。 +- `viewer/web/src/components/`、`src/views/`:可复用的会话列表、短信气泡、状态提示和页面布局。 +- `web/nginx.conf`:把 `/viewer/` 与 `/viewer-api/` 路由到 viewer 容器;现有 SPA fallback 不得吞掉这两个前缀。 +- `deploy/httpsms-pre-compose.yml`、`deploy/*.pre.env.example`:加入 Viewer 服务、内部网络和非敏感配置模板,不写入实际密钥。 +- `docs/`:部署、Passport client 和预发验收说明。 + +## 接口契约 + +### Viewer BFF + +| 方法与路径 | 用途 | 授权规则 | +|---|---|---| +| `GET /viewer-api/session` | 返回登录状态、显示名、掩码手机号 | 未登录返回 401;不返回 token | +| `GET /viewer-api/login` | 创建 state、nonce、PKCE verifier 并跳转 Passport | 只允许本地固定 redirect URI | +| `GET /viewer-api/callback` | 兑换 code、验证 ID token、UserInfo 并建 session | 验证 issuer/audience/nonce/state/PKCE 和 UserInfo sub == ID token sub | +| `POST /viewer-api/logout` | 删除服务端 session 并清 cookie | 校验 CSRF token | +| `GET /viewer-api/threads?skip=&query=` | 查询当前号码会话 | BFF 注入固定 `owner`;limit 上限 20 | +| `GET /viewer-api/threads/{threadRef}/messages?skip=&query=` | 读取当前号码与指定联系人的消息 | `threadRef` 是 BFF 对线程 ID、联系人、sub、号码和过期时间签发的 HMAC 引用;用户不可指定 owner/contact;limit 上限 20 | +| `POST /viewer-api/search` | 跨会话搜索 | Turnstile token 放 JSON body;BFF 注入唯一 `owners`;保留网关验证;limit 上限 200;校验 CSRF | + +Viewer 不提供 `/messages/{id}` 或 `/attachments/*` 的无约束透明代理。对话详情由受 owner 限制的会话查询构建;如果必须单条读取,则先验证该 message 对应的 owner。所有未知路由、非 allowlist method 和不支持 query 参数返回 404/405。 + +网关接口映射: + +- 会话:`GET /v1/message-threads?owner=&skip=&limit=20`。 +- 对话:先验签短期 `threadRef`,要求其中 sub/号码等于当前会话;然后调用 `GET /v1/messages?owner=&contact=<签名引用中的联系人>&skip=&limit=20`。BFF 只为网关按 owner 查询返回的 thread 签发引用,因此浏览器不能篡改 contact 或 thread ID。 +- 搜索:`GET /v1/messages/search?owners=&query=&skip=&limit<=200`,并转发前端 Turnstile token。IP 使用 Fiber 配置的可信代理解析值,忽略任意用户自带的 `X-Forwarded-For`。若预发实测 Turnstile 无法在此链路通过验证,则一期关闭全局搜索 UI,只保留会话内 `query` 过滤,不得删去网关验证码。 + +### Passport 档案契约 + +OIDC scope:`openid profile app_profile`。Viewer client 的 policy 绑定 `app_key=httpsms` 且 `readableFields=[phone_number]`。UserInfo 中的号码位置: + +```json +{ + "sub": "稳定 Passport subject", + "app_profile": { + "app_key": "httpsms", + "schema_version": 1, + "attributes": {"phone_number": "+8613812345678"} + } +} +``` + +绑定字段仅由管理员写入。Profile API 写入应在锁定 `httpsms` Application 行的事务里检查其他用户没有相同的规范号码,随后写 ApplicationProfile 并产生既有 ProfileAudit。这样在没有数据库原生 JSON 字段唯一索引时仍可串行化并发管理员写入。未提供号码表示无授权 profile;禁止把空字符串当作号码。 + +## 任务清单 + +### Task 1:准备 Passport 隔离 worktree 与测试基线 + +**Files:** +- Create: `$PASSPORT_WT` worktree(不提交到源码) + +- [ ] **Step 1: 检查 Passport 工作区状态和 worktree 目录** + +Run: `git -C "$PASSPORT_ROOT" status --short && git -C "$PASSPORT_ROOT" check-ignore -v .worktrees` +Expected: 记录现有未提交文件;确认 `.worktrees/` 被忽略。不要覆盖或清理已有更改。 + +- [ ] **Step 2: 建立独立 Passport 功能分支** + +Run: `git -C "$PASSPORT_ROOT" worktree add "$PASSPORT_WT" -b feat/sms-passport-viewer` +Expected: Passport worktree 位于独立目录,分支名为 `feat/sms-passport-viewer`。 + +- [ ] **Step 3: 建立并记录各仓库干净基线** + +在 Passport worktree 运行 `mvn -pl passport-server test`、`npm --prefix passport-web test`;在当前 httpsms worktree 运行 `cd api && go test ./...`。记录既有失败和耗时后才开始实现;若基线失败,先定位为环境问题或既有失败,不得把它误报为本次引入。 + +### Task 2:Passport schema 支持 httpsms E.164 号码 + +**Files (relative to `$PASSPORT_WT`):** +- Modify: `passport-server/src/main/java/com/yuanyuan/passport/profile/ProfileSchemas.java` +- Modify: `passport-server/src/test/java/com/yuanyuan/passport/profile/ProfileSchemasTest.java` + +- [ ] **Step 1: 先写 schema 校验测试** + +为 `httpsms-v1` 增加通过案例 `+8613812345678`,以及拒绝本地格式、空白、超长、非字符串和未知字段的案例。运行 `mvn -pl passport-server -Dtest=ProfileSchemasTest test`,期望新增案例先因未知 schema/字段失败。 + +- [ ] **Step 2: 增加 schema 与号码校验** + +在 `fields` 中注册 `httpsms-v1` 的 `phone_number`,在 `validate` 中要求值是规范 E.164(`+` 加 8 至 15 位数字,首位国家码不为 0),不做静默重格式化;管理员必须提交规范化号码。手机号可选性由空档案表示,不接受属性 map 中空 `phone_number`。 + +- [ ] **Step 3: 运行 Passport schema 测试并提交** + +Run: `cd "$PASSPORT_WT" && mvn -pl passport-server -Dtest=ProfileSchemasTest test` +Expected: PASS;提交只包含 Passport schema 和测试文件,commit message `feat: support httpsms phone profile`。 + +### Task 3:Passport profile 唯一性、并发和审计 + +**Files (relative to `$PASSPORT_WT`):** +- Modify: `passport-server/src/main/java/com/yuanyuan/passport/profile/ApplicationRepository.java` +- Modify: `passport-server/src/main/java/com/yuanyuan/passport/profile/ProfileService.java` +- Modify: `passport-server/src/main/java/com/yuanyuan/passport/profile/ApplicationProfileRepository.java` +- Modify: `passport-server/src/test/java/com/yuanyuan/passport/profile/ProfileApiTest.java` + +- [ ] **Step 1: 为重复号码和并发写入写失败测试** + +测试同一个号码不能绑定给第二个 Passport 用户;同用户更新同一个号码允许;号码更换后旧号码可以给另一用户使用。并发执行两个用户绑定同一号码时恰好一个成功,另一个返回 409。 + +- [ ] **Step 2: 使用应用行锁串行化 httpsms 绑定** + +在 `ApplicationRepository` 增加锁定指定 appKey 的 `PESSIMISTIC_WRITE` 查询。在 `ProfileService.write` 对 `httpsms` 档案先锁定应用行,再查其他 userId 的 `httpsms.phone_number`;重复返回 `409 Conflict`。保持现有 user lock、版本检查和 ProfileAudit,不在日志中写号码明文。 + +- [ ] **Step 3: 运行 profile API 测试并提交** + +Run: `cd "$PASSPORT_WT" && mvn -pl passport-server -Dtest=ProfileApiTest test` +Expected: PASS;提交仅含 Passport repository/service/test,message `feat: enforce unique httpsms phone binding`。 + +### Task 4:Passport 新建用户时原子设置手机号 + +**Files (relative to `$PASSPORT_WT`):** +- Modify: `passport-server/src/main/java/com/yuanyuan/passport/api/AdminUserController.java` +- Modify: `passport-server/src/test/java/com/yuanyuan/passport/AuthFeatureApiTest.java` + +- [ ] **Step 1: 写管理 API 行为测试** + +`POST /api/admin/users` 接受可选 `httpsmsPhoneNumber`。号码有效时,创建用户响应包括 `id`,读取 `/api/admin/users/{id}/profiles` 能看到 httpsms phone_number;号码非法或已占用时响应 400/409,数据库不残留半创建用户。省略字段时保留原有创建行为。 + +- [ ] **Step 2: 在单事务中创建用户与 profile** + +给 create 方法加 `@Transactional`;保存 PassportUser 后调用 `ProfileService.write` 写 `httpsms-v1` profile,`externalUserId` 置空、`expectedVersion` 置空、actor 取当前 admin username。新增 DTO 字段 validation 限长;不在 User entity 加全局手机号字段。 + +- [ ] **Step 3: 跑 API 测试并提交** + +Run: `cd "$PASSPORT_WT" && mvn -pl passport-server -Dtest=AuthFeatureApiTest test` +Expected: PASS;commit `feat: bind httpsms phone during admin user creation`。 + +### Task 5:Passport 管理 UI 创建和维护绑定 + +**Files (relative to `$PASSPORT_WT`):** +- Modify: `passport-web/src/App.jsx` +- Modify: `passport-web/src/profiles/UserProfiles.jsx` +- Modify: `passport-web/src/profiles/profileUi.jsx` +- Modify: `passport-web/src/profiles/Profiles.test.jsx` + +- [ ] **Step 1: 为创建表单写 UI 测试** + +测试管理员新增用户表单显示“短信查看手机号”,创建时将 E.164 值加入 `POST /api/admin/users` body;空值可创建普通 Passport 用户;非法号码显示 API 验证错误并保留表单输入。 + +- [ ] **Step 2: 为已有用户 profile 加专用号码标签** + +增加 `phone_number` 的中英文标签。在 admin UserProfiles 的 httpsms profile editor 中允许管理员写入;个人 profile 页面只展示掩码号码且不能修改。手机号变更调用既有 profile API,期待版本冲突沿用既有冲突处理。 + +- [ ] **Step 3: 跑前端测试和构建并提交** + +Run: `cd "$PASSPORT_WT" && npm --prefix passport-web test && npm --prefix passport-web run build` +Expected: 全部 PASS;commit `feat: manage httpsms phone bindings in passport admin`。 + +### Task 6:Passport 应用和 client profile policy 配置说明 + +**Files (relative to `$PASSPORT_WT`):** +- Modify: `docs/application-user-profiles.md` +- Modify: `README.md` + +- [ ] **Step 1: 记录预发/生产配置步骤** + +文档给出注册 `httpsms` / `httpsms-v1`,建立 confidential authorization_code client,配置精确回调 URI、`openid profile app_profile`,绑定 appKey 并将 readableFields 限定为 `phone_number` 的步骤。说明不同环境 client 和 redirect URI 分离,禁止把 client secret 配置进前端。 + +- [ ] **Step 2: 检查并提交文档** + +核对档案 JSON 与 BFF 接口契约一致,commit `docs: document httpsms OIDC profile setup`。 + +### Task 7:建立 Go Viewer BFF 骨架和配置校验 + +**Files:** +- Create: `viewer/bff/go.mod` +- Create: `viewer/bff/main.go` +- Create: `viewer/bff/config.go` +- Create: `viewer/bff/config_test.go` + +- [ ] **Step 1: 写必填环境变量校验测试** + +测试缺少任一 `PASSPORT_ISSUER`、`PASSPORT_CLIENT_ID`、`PASSPORT_CLIENT_SECRET`、`PASSPORT_REDIRECT_URL`、`SESSION_SECRET`、`REDIS_URL`、`GATEWAY_API_URL`、`GATEWAY_API_KEY` 时拒绝启动;验证 callback URL 必须为 HTTPS(仅 test/dev 可 localhost)。 + +- [ ] **Step 2: 建立独立模块与健康路由** + +使用 Fiber v3、`github.com/coreos/go-oidc/v3/oidc`、`golang.org/x/oauth2`、`github.com/redis/go-redis/v9`。配置对象不包含可被 JSON 序列化的 secret;`GET /health` 只返回状态,不显示依赖配置。 + +- [ ] **Step 3: 构建 BFF 容器并提交** + +Run: `cd viewer/bff && go test ./... && go build ./...` +Expected: PASS;commit `feat: scaffold sms viewer bff`。 + +### Task 8:Passport OIDC 与 Redis 服务端会话 + +**Files:** +- Create: `viewer/bff/oidc.go` +- Create: `viewer/bff/session.go` +- Create: `viewer/bff/oidc_test.go` +- Create: `viewer/bff/session_test.go` +- Modify: `viewer/bff/main.go` + +- [ ] **Step 1: 写 OIDC state/nonce/PKCE 测试** + +使用 httptest issuer/JWKS 测试:state 一次性、nonce 必须匹配、PKCE verifier 必须绑定授权请求、错误 issuer/audience/签名/过期 token 拒绝、UserInfo sub 与 ID token sub 不同拒绝、有效 token 登录成功。 + +- [ ] **Step 2: 实现 OIDC authorization code flow** + +通过 discovery 初始化 issuer;authorization request 使用 `openid profile app_profile`、S256 PKCE、随机 state/nonce;回调固定在配置 redirect URI。回调清除一次性 state,兑换 code,验证 ID token,并通过 UserInfo 获取 `sub` 与 `app_profile`。不接受 URL 参数选择 redirect_uri 或 client id。 + +- [ ] **Step 3: 实现 Redis session 与安全 cookie** + +cookie 仅持不可预测的 session ID;Redis 保存 sub、显示名、绑定号码、受保护 token、最近 profile 校验时间与过期时间。每个 BFF 请求在 profile 校验超过 60 秒时重新读取 UserInfo;使用 refresh token 更新失效 access token,Passport 未签发 refresh token 或刷新失败时销毁 session 并要求重新登录。Cookie 使用 HttpOnly、Secure、SameSite=Lax、Path=/viewer-api。登录后换新 session ID;logout 只接受 POST + CSRF,删除 Redis session 和 cookie。 + +- [ ] **Step 4: 跑 OIDC/session 测试并提交** + +Run: `cd viewer/bff && go test ./...` +Expected: PASS;commit `feat: add passport oidc sessions to sms viewer`。 + +### Task 9:Passport profile 解析和无授权 fail-closed + +**Files:** +- Create: `viewer/bff/passport_profile.go` +- Create: `viewer/bff/passport_profile_test.go` +- Modify: `viewer/bff/oidc.go` + +- [ ] **Step 1: 写 UserInfo 档案解析测试** + +覆盖 app_key 不匹配、缺少 app_profile、缺少 phone_number、非规范号码、多份/重复 app profile、sub 空值,均不能创建可读 session;合法 httpsms profile 产生规范的唯一号码。 + +- [ ] **Step 2: 对照 UserInfo sub 并 fail closed** + +只接受 `sub` 与已验证 ID token `sub` 相同且 `app_profile.app_key` 为 `httpsms` 的响应。手机号未设置时建立无授权 session 状态供 UI 显示,但任何网关调用函数必须返回 `ErrNoPhoneBinding`。 + +- [ ] **Step 3: 跑档案测试并提交** + +Run: `cd viewer/bff && go test ./...` +Expected: PASS;commit `feat: enforce httpsms profile authorization in viewer`。 + +### Task 10:实现只读 Gateway API client + +**Files:** +- Create: `viewer/bff/gateway.go` +- Create: `viewer/bff/gateway_test.go` +- Modify: `viewer/bff/config.go` + +- [ ] **Step 1: 写网关请求约束测试** + +用 httptest server 验证消息/会话请求始终带 `X-API-Key`、URL host 只能来自固定配置、owner 来自 session 而非用户 query、contact 从已校验 thread 派生、分页/limit 被限制,所有非 GET 请求和未知上游路径拒绝。 + +- [ ] **Step 2: 实现 allowlist client** + +定义 `ListThreads(ctx, owner, query, skip)`、`ListMessages(ctx, owner, contact, query, skip)`、`SearchMessages(ctx, owner, query, skip, turnstileToken, remoteIP)`。校验 owner E.164;强制 `limit<=20`(搜索 `<=200`);JSON decode 限制响应体大小;上游错误统一脱敏;不允许调用附件和写 API。 + +- [ ] **Step 3: 实现可信代理 IP 取值** + +只使用 Fiber 明确配置的 trusted proxies 解析客户端 IP;默认忽略客户端自带的 forwarded header。Turnstile token 只用于搜索验证,不保存、不记录。 + +- [ ] **Step 4: 跑 Gateway client 测试并提交** + +Run: `cd viewer/bff && go test ./...` +Expected: PASS;commit `feat: add constrained read-only gateway client`。 + +### Task 11:实现 BFF session、thread、message、search 路由 + +**Files:** +- Create: `viewer/bff/handlers.go` +- Create: `viewer/bff/handlers_test.go` +- Modify: `viewer/bff/main.go` + +- [ ] **Step 1: 写未授权与 owner 注入测试** + +覆盖无 session 返回 401、无绑定号码返回 403 且上游未收到请求、查询中伪造 `owner` 被拒绝或忽略、thread 请求固定 owner、读取其他号码 thread/message 返回 404。 + +- [ ] **Step 2: 加入只读接口和严格参数解析** + +实现接口契约表中的 session、logout、threads、thread messages、search。公开 request DTO 不含 owner 字段;服务端从 session 取 owner。Thread message route 从 gateway load/verify thread 后用其 contact 查询 messages。只公开 GET + `POST logout`,其余 method 返回 405。 + +- [ ] **Step 3: 应用 no-store、CSRF、错误响应** + +所有含短信数据响应设置 `Cache-Control: no-store`;logout 验证同步 CSRF token。无绑定、上游超时、Passport 失效分别返回固定错误码,不带短信正文、token、API Key 和上游 body。 + +- [ ] **Step 4: 跑 BFF handler 测试并提交** + +Run: `cd viewer/bff && go test ./...` +Expected: PASS;commit `feat: add read-only sms viewer api`。 + +### Task 12:实现 Vue Viewer UI + +**Files:** +- Create: `viewer/web/package.json` +- Create: `viewer/web/package-lock.json` +- Create: `viewer/web/vite.config.ts` +- Create: `viewer/web/src/main.ts` +- Create: `viewer/web/src/api.ts` +- Create: `viewer/web/src/App.vue` +- Create: `viewer/web/src/views/InboxView.vue` +- Create: `viewer/web/src/components/ThreadList.vue` +- Create: `viewer/web/src/components/MessageList.vue` +- Create: `viewer/web/src/components/AccessState.vue` +- Create: `viewer/web/src/**/*.test.ts` + +- [ ] **Step 1: 建立 Vue/Vite app 与 UI 测试** + +覆盖未登录跳转 `/viewer-api/login`、无号码绑定提示且不请求 threads、授权号码掩码显示、分页参数、空数据/加载错误、无发送和删除按钮。API wrapper 默认 `credentials: 'include'` 且绝不设置 API Key 或 bearer token。 + +- [ ] **Step 2: 实现会话列表和消息阅读** + +会话列表使用 Viewer BFF DTO,不在浏览器中拼网关 URL。选择会话时只传 thread ID;详情从 BFF 请求。保留最近消息时间、联系人、方向和状态;正文使用文本渲染,不用不可信 HTML。附件展示固定“附件暂不支持查看”提示,不渲染上游 URL。 + +- [ ] **Step 3: 实现 Turnstile 搜索** + +全局搜索使用现有 Turnstile site key(仅公开 site key),拿到 token 后以 JSON body POST 到 `/viewer-api/search`,避免 token 出现在 URL、浏览器 history 和代理 query log。BFF 固定 owners 并使用可信客户端 IP。若预发 Turnstile 联调失败,删除全局搜索控件,保留线程内筛选,不能绕过验证码。 + +- [ ] **Step 4: 跑前端测试和静态构建并提交** + +Run: `cd viewer/web && npm ci && npm test && npm run build` +Expected: PASS;commit `feat: add read-only sms viewer ui`。 + +### Task 13:Docker 和同源 Nginx 部署 + +**Files:** +- Modify: `web/nginx.conf` +- Modify: `deploy/httpsms-pre-compose.yml` +- Modify: `deploy/api.pre.env.example` +- Modify: `deploy/web.pre.env.example` +- Create: `deploy/viewer.pre.env.example` +- Create: `viewer/bff/Dockerfile` +- Create: `viewer/web/Dockerfile` + +- [ ] **Step 1: 为 proxy 路由写配置检查** + +确认 `/viewer/` 与 `/viewer-api/` 在 root SPA fallback 前匹配;静态资源 base path 为 `/viewer/`,API path 保持 `/viewer-api/`,Nginx 不缓存 viewer API 响应。不要把真实 client secret、session secret、gateway key 放入 compose build args。 + +- [ ] **Step 2: 加入内部 Compose 服务** + +增加 `viewer-bff` 和 `viewer-web` 服务。BFF 仅暴露 Docker 内部端口,连接现有 Redis;viewer-web 只提供静态资源;两者不 publish 公网 host port。通过 env_file 注入 Viewer 专属配置,镜像只复制构建产物和可执行文件。 + +- [ ] **Step 3: 加入 Nginx upstream 和健康检查** + +配置 `/viewer/` 到静态 viewer,`/viewer-api/` 到 BFF,保留既有 `/` Nuxt SPA 路由。避免 Nginx access/error log 输出 query 中的 Turnstile token;为 callback 的 query 日志关闭或脱敏。 + +- [ ] **Step 4: 构建容器并提交** + +Run: 在预发部署目录执行 `docker compose -f httpsms-pre-compose.yml config --quiet` 与 `docker compose -f httpsms-pre-compose.yml build viewer-bff viewer-web`;不要打印 `docker compose config` 的展开结果,因为其中含环境配置。 +Expected: Compose config 无解析错误、两个镜像构建成功;commit `feat: deploy passport sms viewer services`。 + +### Task 14:Passport 与 Viewer 端到端安全验收 + +**Files:** +- Create: `docs/sms-viewer-pre-deployment.md` +- Modify: `viewer/bff/*_test.go`、`viewer/web/src/**/*.test.ts`(仅修复验收发现的问题) + +- [ ] **Step 1: 自动化双用户隔离测试** + +构造 user A/profile phone A 和 user B/profile phone B;A 请求 threads、search、thread messages 时断言所有网关查询只包含 phone A;篡改 thread ID、contact、owner 和 page 参数均不能获得 B 数据。无绑定用户不能触发任何 gateway request。 + +- [ ] **Step 2: 自动化 OIDC 与错误 fail-closed 测试** + +覆盖 state/nonce/PKCE、错误 issuer/audience、禁用 Passport 用户、UserInfo 不匹配、Redis 不可用、Passport 超时、Gateway 超时、Turnstile 无效;所有失败都没有全量/无 owner 请求。 + +- [ ] **Step 3: 执行全仓验证** + +Run: `cd "$HTTPSMS_ROOT/viewer/bff" && go test ./... && go vet ./...`; `cd "$HTTPSMS_ROOT/viewer/web" && npm test && npm run build`; `cd "$PASSPORT_WT" && mvn -pl passport-server test && npm --prefix passport-web test && npm --prefix passport-web run build`。 +Expected: 全部通过。不要把本机已有失败忽略;记录每个失败的根因和修复提交。 + +- [ ] **Step 4: 执行预发 OAuth 联调** + +用两个预发 Passport 用户绑定不同号码,验证登录、回调、Profile claims、列表分页、thread message、Turnstile 搜索、登出和管理员改绑生效时间。使用浏览器 Network 和容器日志确认没有 secret/token/完整短信正文泄漏。不得触发短信发送或生产部署。 + +- [ ] **Step 5: 完成发布手册并提交** + +`docs/sms-viewer-pre-deployment.md` 写明 Passport app/client 注册、env 注入、compose 重建、健康检查、两用户隔离 smoke、回滚步骤和附件限制;commit `docs: add sms viewer pre-deployment runbook`。 + +## 发布和回滚顺序 + +1. 先部署 Passport server/web,再登记 httpsms application 和预发 confidential client。 +2. 在 Passport 管理台创建至少两个隔离测试用户并分别设置号码。 +3. 将 viewer secret 注入预发 secret 文件;不得复制到 Git、Dockerfile 或 build args。 +4. 启动 viewer-bff 与 viewer-web;验证健康检查和同源路由。 +5. 完成 Task 14 的两个用户隔离与 OAuth 联调后,再将查看器入口开放给目标用户。 +6. 回滚时先关闭 `/viewer/` proxy 路由并停 viewer 服务;Passport 档案保留,不影响主短信网关、Android 或 Firebase 用户。 + +## 设计覆盖自检 + +- Passport 管理员创建/编辑手机号、E.164 校验、唯一性、审计:Tasks 2–6。 +- OIDC state/nonce/PKCE、sub 校验、会话、CSRF、登出:Tasks 7–9。 +- 服务端号码授权、列表/会话/详情/搜索、分页和 Turnstile:Tasks 10–11。 +- 独立只读 UI、无绑定状态、附件关闭:Task 12。 +- 同源路由、Compose、密钥边界、预发:Tasks 13–14。 +- fail-closed、两用户隔离、依赖故障、登出和改绑:Tasks 8–14。 +- 公开附件路由:首期不暴露;后续单独网关变更,符合设计边界。 + +## 执行约束 + +- 执行前加载 `test-driven-development`、`verification-before-completion`、`frontend-ui-standards`(UI 任务)及两个仓库相关规范。 +- 每个任务按“测试先行、最小实现、定向验证、独立提交”完成。此文件本身不执行测试、不修改应用代码、不发布。 +- Passport client secret、session secret、gateway API key、Turnstile token 和用户短信正文不进入计划以外的日志、截图、命令输出或 Git。 +- 搜索若受 Turnstile 限制无法通过 BFF 验收,首期退回线程内查询,不移除验证码、不从浏览器直接连接共享网关 API。 diff --git a/docs/superpowers/specs/2026-10-12-sms-passport-readonly-viewer-design.md b/docs/superpowers/specs/2026-10-12-sms-passport-readonly-viewer-design.md new file mode 100644 index 000000000..228850284 --- /dev/null +++ b/docs/superpowers/specs/2026-10-12-sms-passport-readonly-viewer-design.md @@ -0,0 +1,198 @@ +# SMS Passport 只读消息查看器设计 + +## 1. 背景与目标 + +短信网关的 Web 控制台与 API 当前使用 Firebase 身份。预发网关由多人共用一个网关账号和 API Key,网关内部因此把所有短信记录视作同一个用户的数据。目标是在不改变短信收发流程的前提下,让 Passport 用户登录一个独立的只读界面,并且每人只能查看管理员为其绑定的手机号所关联的消息。 + +本设计保留现有短信网关控制台、Android 客户端、Firebase 登录和短信发送 API。新界面只提供消息查看,不向终端浏览器下发网关 API Key,也不提供发送、删除、标记已读或修改联系人等写操作。 + +## 2. 代码现状与已验证能力 + +### 2.1 短信网关 + +- `GET /v1/messages` 支持 `owner`(网关手机号)、`contact`、关键词、分页参数;适合按用户手机号读取某个号码与联系人的消息。 +- `GET /v1/message-threads` 支持 `owner`、关键词及分页;可获取该号码的会话列表。 +- `GET /v1/messages/search` 支持 `owners` 列表及关键词、类型、状态、分页,但其现有接口还要求 Cloudflare Turnstile token。 +- `GET /v1/messages/:messageID` 的查询权限当前按网关内部 `user_id` 检查,不按手机号检查。 +- 附件下载路由 `/v1/attachments/:userID/:messageID/:attachmentIndex/:filename` 当前没有认证中间件。 +- `owner` 是查询筛选条件,不是权限校验。共享网关 API Key 可以访问共享网关用户的数据;因此浏览器不能直接携带该 Key,也不能自行决定 `owner`。 + +### 2.2 Passport + +- Passport 提供 OAuth2/OIDC 授权码流程,产品边界规定 Web 应用使用 confidential 服务端客户端;不采用浏览器 public SPA client。 +- 稳定 `sub` 是 Passport 用户身份。短信查看器使用 `sub` 作为唯一用户标识,不按用户名、邮箱或可变显示名称进行关联。 +- 管理员新增用户的请求当前没有手机号字段。 +- Passport 已有应用档案机制,UserInfo 可按客户端策略和 `app_profile` scope 返回指定应用档案字段;profile API 支持服务端读取应用档案。 +- 当前已知 `httpsms` 应用档案 schema 尚不存在,需要新增。 + +## 3. 设计决策 + +### 3.1 独立只读查看器 + +增加独立的查看器前端和 BFF(Backend for Frontend),不把 Passport 登录塞入现有 Firebase 主控制台。查看器使用同源部署入口,例如 `https://sms.yangdy.com/viewer/`;反向代理将静态查看器路由到前端容器,将 `/viewer-api/*` 路由到 BFF。这样浏览器到查看器 API 使用同源安全 Cookie,不需要将 OAuth token 暴露给 Nuxt 页面或处理跨域 bearer token。 + +BFF 负责 OIDC 授权码 + PKCE、会话 Cookie、CSRF 防护、Passport 档案读取、手机号授权检查和网关 API 调用。Passport client secret、refresh token(如启用)及网关 API Key 只存在 BFF 的服务端密钥配置中。 + +### 3.2 Passport 用户档案承载号码绑定 + +新增 Passport 应用 `httpsms`,领域档案 schema `httpsms-v1`,初期字段为: + +- `phone_number`:单个规范化 E.164 手机号,例如 `+8613812345678`。 + +管理员在 Passport 创建用户时录入手机号;创建用户和 httpsms 档案在同一服务端事务中保存。管理员编辑用户时可以更改号码。号码为空时,该用户仍可登录 Passport,但查看器返回“尚未授权手机号”,不显示任何网关数据。 + +每个用户一期仅绑定一个手机号;同一号码只允许分配给一个 Passport 用户。服务端校验格式、规范化结果与唯一性。不能只在前端校验或依靠隐藏表单字段。档案更改需要记录审计事件,至少包括操作者、用户 sub、变更前后号码和时间;普通用户不能自行编辑该绑定。 + +Passport 客户端策略仅允许 SMS 查看器客户端读取 `httpsms.phone_number`。不要把手机号加入所有 OIDC client 的通用 `profile` 或 email scope。 + +### 3.3 服务端强制号码范围 + +BFF 从已经验证的 OIDC 会话确定 `sub`,从可信的 Passport 用户档案取得 `phone_number`,并在服务端将该号码作为网关查询的 `owner`/`owners` 参数。查看器 API 不接收浏览器指定的任意 owner;如 UI 请求中出现 owner,只允许与服务器解析出的授权号码完全匹配,否则返回 403。 + +网关 API Key 使用只读专用凭据。若当前网关无法为 Key 限定只读权限,则 BFF 仍只暴露明确列出的 GET 数据接口,禁止代理任意路径、任意 HTTP method、任意 query 或请求头。BFF 不透传通用网关错误、密钥或内部数据。 + +### 3.4 只读资源范围 + +一期包含: + +- 按授权手机号分页列出短信。 +- 按联系人及关键词查看单个号码的对话。 +- 查看会话摘要及消息详情。 +- 可选的类型/状态/时间筛选;服务端固定号码过滤。 + +不包含发送短信、批量发送、删除消息、修改会话、编辑联系人、API Key 管理、电话设备配置和 webhook 管理。 + +详情读取必须先以共享网关身份读取消息,再由 BFF 确认该消息的 `owner` 等于当前授权手机号;否则统一返回 404,避免泄漏消息是否存在。会话详情同样需要验证其 owner。所有路由都采用默认拒绝和显式 GET allowlist。 + +## 4. 请求流程 + +```mermaid +sequenceDiagram + actor U as 用户浏览器 + participant V as 查看器前端 + participant B as SMS Viewer BFF + participant P as Passport OIDC + participant G as httpSMS API + + U->>V: 打开只读查看器 + V->>B: GET /viewer-api/session + B-->>V: 未登录 + V->>B: GET /viewer-api/login + B->>P: 授权码 + PKCE 授权请求 + P->>U: Passport 登录 + P->>B: 授权码回调 + B->>P: 服务端兑换 token、验证 issuer/audience/nonce/state + B->>P: 以用户 access token 查询 UserInfo + P-->>B: sub + httpsms.phone_number + B->>B: 建立服务端会话 + B-->>U: HttpOnly 安全 Cookie + U->>B: GET /viewer-api/messages?skip=0 + B->>B: 从会话取得 sub 和绑定号码 + B->>G: GET /v1/messages?owner=绑定号码 + G-->>B: 该号码的消息页 + B-->>U: 只返回允许显示的数据 +``` + +BFF 必须验证 OIDC issuer、client audience、签名、过期时间、state、nonce 和 PKCE;回调地址精确登记在 Passport。登录后轮换会话 ID。登出时销毁 BFF 会话,并按 Passport 能力撤销令牌或发起 Passport logout。 + +## 5. 会话与浏览器安全 + +- Cookie 设置 `HttpOnly`, `Secure`, `SameSite=Lax`, `Path=/viewer-api`;生产禁止非 HTTPS。 +- 会话使用服务端存储,设定空闲及绝对过期时间;不得将 access token、refresh token 或手机号放入 localStorage/sessionStorage。 +- 所有非 GET BFF 请求使用 CSRF token;一期接口只有读取操作,但 login/logout 及未来写路由仍须正确保护。 +- 登录后和权限变化后轮换会话 ID;绑定号码变更应使旧会话失效。首期可通过短期 session + 每次请求重新读取或短 TTL 缓存 Passport profile 实现及时生效,缓存 TTL 目标不超过 60 秒。 +- 页面响应设置 `Cache-Control: no-store`,避免共享设备浏览器缓存短信内容。 +- 日志不记录短信正文、完整手机号、令牌、API Key、回调参数或个人档案原文;手机号需掩码后才可出现在审计/诊断日志。 +- 限制登录回调和只读 API 请求速率;不允许开放重定向。 + +## 6. 附件安全边界 + +当前网关附件下载 endpoint 是公开路由,且使用共享网关 `userID` 路径。这不满足严格的按手机号授权边界。 + +一期建议查看器不展示或链接 MMS 附件,只呈现文本消息和附件存在提示。不得将网关附件 URL 直接交给浏览器。若产品要求查看附件,必须另行修复网关附件授权:通过网关消息记录验证 API Key 用户身份和消息 owner,或让 BFF 通过可信服务端存储接口读取,并在返回前确认附件所属消息的 owner 与当前授权号码一致。仅在 BFF 前端隐藏附件链接不构成保护,因为原公开 URL 仍可直接访问。 + +## 7. 部署拓扑 + +```mermaid +flowchart LR + Browser[用户浏览器] -->|HTTPS 同源| Proxy[现有 Web 反向代理] + Proxy -->|/viewer/| Frontend[SMS Viewer 静态前端] + Proxy -->|/viewer-api/| BFF[SMS Viewer BFF] + BFF -->|OIDC / UserInfo| Passport[passport.yangdy.com] + BFF -->|只读 API Key| Gateway[现有短信网关 API] +``` + +查看器作为独立 Docker 服务加入预发 compose,网关现有 API 容器和数据库 schema 不需要因登录/号码隔离而改动。前端由同源反向代理提供;BFF 只在 Docker 内网可达,不直接暴露宿主机公网端口。Secret 通过预发环境文件/secret 注入,不进入仓库或镜像层。 + +部署配置至少包含 Passport issuer、client ID、client secret、精确 redirect URI、session secret、session store 配置、短信网关 API base URL、只读 API Key。应新增预发专用 Passport client;生产 client 与预发 client 分开注册和授权。 + +## 8. 错误行为 + +| 情况 | 响应/行为 | +|---|---| +| 未登录或会话过期 | API 返回 401,前端重新走 Passport 登录 | +| Passport 用户无手机号档案 | 返回 403/明确的“等待管理员授权”状态,不请求网关 | +| 手机号格式非法或绑定冲突 | Passport 管理保存时拒绝,显示可操作错误 | +| 请求号码与档案号码不一致 | 返回 403,不查询网关 | +| 消息或会话 owner 不匹配 | 返回 404,不披露资源存在性 | +| Passport 或网关依赖不可用 | 503;不得降级为不带 owner 的全量查询 | +| 网关返回异常 | BFF 返回脱敏错误和关联 request ID | +| 用户被禁用/档案读取被撤销 | 新请求拒绝;已有 BFF session 依赖短 TTL/revocation 策略及时失效 | + +任何依赖故障均 fail closed,绝不退回 Firebase 共享账号身份或无过滤查询。 + +## 9. 实施阶段 + +### 阶段 A:Passport 号码档案 + +1. 注册 `httpsms` 应用及 `httpsms-v1` schema,增加 `phone_number` E.164 字段和唯一分配校验。 +2. 扩展 Passport 管理员创建/编辑用户流程,可录入、更新号码;确保用户创建与应用档案保存具备事务性或有明确回滚处理。 +3. 增加档案变更审计;确保普通用户没有自助修改号码入口。 +4. 增加 OIDC client 的 profile policy,只允许查看器读取该字段。 + +### 阶段 B:查看器 BFF 与界面 + +1. 建立独立只读查看器前端和 BFF,完成 Passport OIDC authorization code + PKCE 和服务端会话。 +2. 完成 `/viewer-api/session`、`/viewer-api/logout`、消息列表、会话列表、消息详情和关键词查询接口。 +3. BFF 从 Passport `sub` 解析档案号码,网关查询固定传入该号码;对所有详情/会话响应进行 owner 校验。 +4. 前端提供登录状态、无授权状态、消息列表/会话详情、分页、加载失败和空状态;不提供写操作。 +5. 一期隐藏 MMS 附件内容和下载链接,并在 UI 中清楚呈现“附件暂不支持查看”。 + +### 阶段 C:预发配置与发布 + +1. 新建 Passport 预发 OAuth client,配置唯一回调 URI、`openid` 和受控应用档案读取权限。 +2. 加入 Viewer Docker 服务和反向代理路由;注入独立密钥,确认容器日志和镜像不泄漏 secret。 +3. 给两名不同用户配置不同测试号码,验证隔离和无授权行为。 +4. 发布后仅开放只读查看器入口;原 SMS 控制台和 Android 收发链路不改变。 + +### 阶段 D:附件(后续独立变更) + +只有在 MMS 是必须需求时才实施。先为网关附件下载加入身份和 owner 校验,验证不能通过修改路径取得他人附件,再由 BFF 代理暴露受授权附件。 + +## 10. 验收标准 + +1. Passport 管理员创建用户时可设置一个 E.164 手机号;非法、重复号码不能保存。 +2. 用户 A 登录后只能看到号码 A 的消息、会话和搜索结果;浏览器修改 query 参数不能切换到号码 B。 +3. 用户 A 通过更改消息 ID、会话 ID、联系人、分页参数等方式,不能读取号码 B 资源;未授权资源表现为 404。 +4. 无手机号绑定的合法 Passport 用户可进入无授权说明页,但没有任何网关 API 调用和数据返回。 +5. 浏览器存储、静态资源、网络响应和前端日志均不包含网关 API Key、Passport client secret 或 OAuth token。 +6. OAuth state/nonce/PKCE 错误、回调地址错误、过期/禁用用户、Passport/网关不可用均 fail closed。 +7. 登出后旧会话不可继续读取数据;号码绑定修改在设计的最大缓存窗口内生效。 +8. 查看器不暴露短信发送、删除、编辑接口;MMS 附件一期不可直接下载。 +9. 原 Firebase 主控制台、Android 客户端、短信发送和接收流程保持可用。 + +## 11. 风险与待实施核实项 + +- 当前网关搜索 API 要求 Turnstile token。BFF 应确认现有验证策略是否能由服务端取得有效 token;若不能,一期使用 owner 固定的普通消息/会话接口完成浏览,暂不复用全局搜索接口,或单独为 BFF 定义受保护的 server-to-server 搜索凭据,不能关闭公开防护后让浏览器绕过。 +- 网关 API Key 若权限覆盖共享账号所有号码,BFF 是强权限边界,必须进行路径 allowlist、审计、速率限制和严格的号码约束。 +- Passport UserInfo 中应用档案读取依赖客户端 policy、scope 和已授权范围三者同时允许;预发需用真实 OAuth 流程确认 `httpsms.phone_number` 可读取且其他 client 不可读取。 +- 公开附件路由是现有网关隔离的已知缺口。查看器一期不展示附件,但若安全要求覆盖所有已经公开的附件 URL,仍需单独修复网关该路由。 + +## 12. 范围外 + +- 替换短信网关主控制台的 Firebase 登录。 +- 修改 Android 登录、短信收发或共享网关账号模型。 +- 为其他业务系统增加 Passport 用户手机号权限。 +- 在一期开放自助号码认领/短信验证码验证。 +- MMS 附件查看和下载。 +- 在此设计文档阶段修改应用代码、运行测试或部署预发。 diff --git a/viewer/bff/Dockerfile b/viewer/bff/Dockerfile new file mode 100644 index 000000000..020bd0d84 --- /dev/null +++ b/viewer/bff/Dockerfile @@ -0,0 +1,16 @@ +FROM golang:1.26-alpine AS build + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/viewer-bff . + +FROM alpine:3.22 +RUN apk add --no-cache ca-certificates wget \ + && addgroup -S -g 10001 viewer \ + && adduser -S -D -H -u 10001 -G viewer viewer +COPY --from=build /out/viewer-bff /usr/local/bin/viewer-bff +USER 10001:10001 +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/viewer-bff"] diff --git a/viewer/bff/config.go b/viewer/bff/config.go new file mode 100644 index 000000000..40bff7275 --- /dev/null +++ b/viewer/bff/config.go @@ -0,0 +1,140 @@ +package main + +import ( + "fmt" + "net" + "net/url" + "os" + "strings" +) + +// config deliberately keeps every field unexported so encoding/json cannot +// serialize configuration or credentials accidentally. +type config struct { + passportIssuer string + passportClientID string + passportClientSecret string + passportRedirectURL string + sessionSecret string + redisURL string + gatewayAPIURL string + gatewayAPIKey string + appEnv string + // TRUSTED_PROXIES must list the exact proxy IPs/CIDRs that connect to Fiber. + // Nginx must overwrite/sanitize X-Forwarded-For before this allowlist is enabled. + trustedProxies []string +} + +func validProxyCIDR(value string) bool { + if net.ParseIP(value) != nil { + return true + } + _, _, err := net.ParseCIDR(value) + return err == nil +} + +func loadConfig(getenv func(string) string) (config, error) { + if getenv == nil { + getenv = os.Getenv + } + values := map[string]*string{} + cfg := config{} + values["PASSPORT_ISSUER"] = &cfg.passportIssuer + values["PASSPORT_CLIENT_ID"] = &cfg.passportClientID + values["PASSPORT_CLIENT_SECRET"] = &cfg.passportClientSecret + values["PASSPORT_REDIRECT_URL"] = &cfg.passportRedirectURL + values["SESSION_SECRET"] = &cfg.sessionSecret + values["REDIS_URL"] = &cfg.redisURL + values["GATEWAY_API_URL"] = &cfg.gatewayAPIURL + values["GATEWAY_API_KEY"] = &cfg.gatewayAPIKey + + for key, target := range values { + *target = strings.TrimSpace(getenv(key)) + if *target == "" { + return config{}, fmt.Errorf("required environment variable %s is missing", key) + } + } + if len(cfg.sessionSecret) < 32 { + return config{}, fmt.Errorf("SESSION_SECRET must be at least 32 bytes") + } + cfg.appEnv = strings.ToLower(strings.TrimSpace(getenv("APP_ENV"))) + if err := validatePassportIssuer(cfg.passportIssuer, cfg.appEnv); err != nil { + return config{}, err + } + for _, proxy := range strings.Split(getenv("TRUSTED_PROXIES"), ",") { + proxy = strings.TrimSpace(proxy) + if proxy != "" { + if !validProxyCIDR(proxy) { + return config{}, fmt.Errorf("TRUSTED_PROXIES must contain IP addresses or CIDRs") + } + cfg.trustedProxies = append(cfg.trustedProxies, proxy) + } + } + if err := validateCallbackURL(cfg.passportRedirectURL, cfg.appEnv); err != nil { + return config{}, err + } + if err := validateGatewayURL(cfg.gatewayAPIURL, cfg.appEnv); err != nil { + return config{}, err + } + return cfg, nil +} + +func validatePassportIssuer(rawURL, appEnv string) error { + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.Opaque != "" || parsed.User != nil || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || parsed.RawFragment != "" || strings.Contains(rawURL, "#") { + return fmt.Errorf("PASSPORT_ISSUER must be an absolute URL without userinfo, query, or fragment") + } + scheme := strings.ToLower(parsed.Scheme) + if scheme == "https" { + return nil + } + localMode := appEnv == "test" || appEnv == "dev" || appEnv == "development" + host := strings.ToLower(parsed.Hostname()) + localHost := host == "localhost" || host == "127.0.0.1" || host == "::1" + if localMode && scheme == "http" && localHost { + return nil + } + return fmt.Errorf("PASSPORT_ISSUER must use HTTPS (HTTP is allowed only for localhost in test/dev)") +} + +func validateCallbackURL(rawURL, appEnv string) error { + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Host == "" { + return fmt.Errorf("PASSPORT_REDIRECT_URL must be an absolute URL") + } + if parsed.Scheme == "https" { + return nil + } + localMode := appEnv == "test" || appEnv == "dev" || appEnv == "development" + localHost := parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1" || parsed.Hostname() == "::1" + if localMode && parsed.Scheme == "http" && localHost { + return nil + } + return fmt.Errorf("PASSPORT_REDIRECT_URL must use HTTPS (HTTP is allowed only for localhost in test/dev)") +} + +// validateGatewayURL permits only the API origin, optionally with its /v1 prefix. +// Gateway routes are appended internally so deployment configuration cannot redirect +// credentials or requests to an arbitrary path. +func validateGatewayURL(rawURL, appEnv string) error { + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && strings.TrimSuffix(parsed.Path, "/") != "/v1") { + return fmt.Errorf("GATEWAY_API_URL must be an API origin or origin ending in /v1") + } + if !allowsGatewayURLScheme(parsed, appEnv) { + return fmt.Errorf("GATEWAY_API_URL must use HTTPS (HTTP is allowed only for localhost in test/dev)") + } + return nil +} + +func allowsGatewayURLScheme(parsed *url.URL, appEnv string) bool { + if parsed.Scheme == "https" { + return true + } + localMode := appEnv == "test" || appEnv == "dev" || appEnv == "development" + loopback := parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1" || parsed.Hostname() == "::1" + if localMode && parsed.Scheme == "http" && loopback { + return true + } + return appEnv == "pre" && parsed.Scheme == "http" && parsed.Host == "api:8000" && parsed.Path == "/v1" +} diff --git a/viewer/bff/config_test.go b/viewer/bff/config_test.go new file mode 100644 index 000000000..42f798266 --- /dev/null +++ b/viewer/bff/config_test.go @@ -0,0 +1,159 @@ +package main + +import ( + "encoding/json" + "io" + "net/http/httptest" + "strings" + "testing" +) + +func validEnv() map[string]string { + return map[string]string{ + "PASSPORT_ISSUER": "https://passport.example.test", + "PASSPORT_CLIENT_ID": "client-id", + "PASSPORT_CLIENT_SECRET": "client-secret", + "PASSPORT_REDIRECT_URL": "https://viewer.example.test/viewer-api/callback", + "SESSION_SECRET": "session-secret-at-least-32-bytes-long", + "REDIS_URL": "redis://localhost:6379/0", + "GATEWAY_API_URL": "https://gateway.example.test", + "GATEWAY_API_KEY": "gateway-secret", + "APP_ENV": "production", + } +} + +func TestLoadConfigRequiresEverySetting(t *testing.T) { + keys := []string{"PASSPORT_ISSUER", "PASSPORT_CLIENT_ID", "PASSPORT_CLIENT_SECRET", "PASSPORT_REDIRECT_URL", "SESSION_SECRET", "REDIS_URL", "GATEWAY_API_URL", "GATEWAY_API_KEY"} + for _, key := range keys { + t.Run(key, func(t *testing.T) { + env := validEnv() + delete(env, key) + _, err := loadConfig(func(name string) string { return env[name] }) + if err == nil || !strings.Contains(err.Error(), key) { + t.Fatalf("loadConfig() error = %v, want missing %s", err, key) + } + }) + } +} + +func TestLoadConfigRequiresStrongSessionSecretAndValidTrustedProxy(t *testing.T) { + env := validEnv() + env["SESSION_SECRET"] = "weak" + if _, err := loadConfig(func(k string) string { return env[k] }); err == nil || !strings.Contains(err.Error(), "SESSION_SECRET") { + t.Fatalf("weak session secret accepted: %v", err) + } + env = validEnv() + env["TRUSTED_PROXIES"] = "not-an-ip" + if _, err := loadConfig(func(k string) string { return env[k] }); err == nil || !strings.Contains(err.Error(), "TRUSTED_PROXIES") { + t.Fatalf("invalid proxy accepted: %v", err) + } +} + +func TestLoadConfigRequiresHTTPSCallbackOutsideLocalDevelopment(t *testing.T) { + env := validEnv() + env["PASSPORT_REDIRECT_URL"] = "http://viewer.example.test/viewer-api/callback" + _, err := loadConfig(func(name string) string { return env[name] }) + if err == nil || !strings.Contains(err.Error(), "PASSPORT_REDIRECT_URL") { + t.Fatalf("loadConfig() error = %v, want callback URL validation error", err) + } +} + +func TestLoadConfigRejectsInsecureOrAmbiguousPassportIssuer(t *testing.T) { + for _, issuer := range []string{ + "http://passport.example.test", + "https://user:password@passport.example.test", + "https://passport.example.test?discovery=other", + "https://passport.example.test#fragment", + } { + t.Run(issuer, func(t *testing.T) { + env := validEnv() + env["PASSPORT_ISSUER"] = issuer + if _, err := loadConfig(func(name string) string { return env[name] }); err == nil || !strings.Contains(err.Error(), "PASSPORT_ISSUER") { + t.Fatalf("loadConfig() accepted issuer %q or returned wrong error: %v", issuer, err) + } + }) + } +} + +func TestLoadConfigAllowsHTTPPassportIssuerOnlyForLocalDevelopment(t *testing.T) { + for _, mode := range []string{"test", "dev", "development"} { + t.Run(mode, func(t *testing.T) { + env := validEnv() + env["APP_ENV"] = mode + env["PASSPORT_ISSUER"] = "http://localhost:8081/issuer" + if _, err := loadConfig(func(name string) string { return env[name] }); err != nil { + t.Fatalf("local HTTP issuer rejected: %v", err) + } + }) + } +} + +func TestLoadConfigAllowsOnlyTheInternalHTTPSMSGatewayOverHTTPInPre(t *testing.T) { + env := validEnv() + env["APP_ENV"] = "pre" + env["GATEWAY_API_URL"] = "http://api:8000/v1" + if _, err := loadConfig(func(name string) string { return env[name] }); err != nil { + t.Fatalf("internal pre gateway rejected: %v", err) + } + env["GATEWAY_API_URL"] = "http://other:8000/v1" + if _, err := loadConfig(func(name string) string { return env[name] }); err == nil { + t.Fatal("non-gateway pre HTTP URL accepted") + } +} + +func TestLoadConfigAllowsLocalhostCallbackInTestAndDevelopment(t *testing.T) { + for _, mode := range []string{"test", "development"} { + t.Run(mode, func(t *testing.T) { + env := validEnv() + env["APP_ENV"] = mode + env["PASSPORT_REDIRECT_URL"] = "http://localhost:8080/viewer-api/callback" + if _, err := loadConfig(func(name string) string { return env[name] }); err != nil { + t.Fatalf("loadConfig() error = %v", err) + } + }) + } +} + +func TestConfigJSONDoesNotExposeSecrets(t *testing.T) { + env := validEnv() + cfg, err := loadConfig(func(name string) string { return env[name] }) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{env["PASSPORT_CLIENT_SECRET"], env["SESSION_SECRET"], env["GATEWAY_API_KEY"]} { + if strings.Contains(string(encoded), secret) { + t.Fatalf("JSON output exposed secret %q", secret) + } + } +} + +func TestHealthReturnsOnlyStatus(t *testing.T) { + response, err := newApp().Test(httptest.NewRequest("GET", "/health", nil)) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != 200 || string(body) != `{"status":"ok"}` { + t.Fatalf("health response = %d %s, want 200 with status only", response.StatusCode, body) + } +} + +func TestGatewayURLIsRestrictedToOriginOrV1(t *testing.T) { + for _, value := range []string{"https://gateway.example.test/v1/other", "https://user:pass@gateway.example.test", "https://gateway.example.test/?x=y"} { + t.Run(value, func(t *testing.T) { + env := validEnv() + env["GATEWAY_API_URL"] = value + if _, err := loadConfig(func(name string) string { return env[name] }); err == nil { + t.Fatalf("accepted gateway URL %q", value) + } + }) + } +} diff --git a/viewer/bff/gateway.go b/viewer/bff/gateway.go new file mode 100644 index 000000000..e96dca9f7 --- /dev/null +++ b/viewer/bff/gateway.go @@ -0,0 +1,135 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +const gatewayResponseLimit = 2 << 20 + +var e164Pattern = regexp.MustCompile(`^\+[1-9][0-9]{7,14}$`) +var errGatewayRequest = errors.New("invalid gateway request") + +// gateway exposes only the three fixed, read-only message queries needed by the viewer. +type gateway struct { + base *url.URL + key string + client *http.Client +} + +func newGateway(cfg config, client *http.Client) (*gateway, error) { + u, err := url.Parse(strings.TrimSpace(cfg.gatewayAPIURL)) + if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && strings.TrimSuffix(u.Path, "/") != "/v1") || strings.TrimSpace(cfg.gatewayAPIKey) == "" { + return nil, errors.New("invalid gateway configuration") + } + appEnv := strings.ToLower(strings.TrimSpace(cfg.appEnv)) + if !allowsGatewayURLScheme(u, appEnv) { + return nil, errors.New("invalid gateway configuration") + } + u.Path = strings.TrimSuffix(u.Path, "/") + if client == nil { + client = &http.Client{} + } + if u.Path == "" { + u.Path = "/v1" + } + clientCopy := *client + clientCopy.Timeout = 10 * time.Second + clientCopy.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse } + return &gateway{base: u, key: cfg.gatewayAPIKey, client: &clientCopy}, nil +} + +func validE164(v string) bool { return e164Pattern.MatchString(v) } +func (g *gateway) ListThreads(ctx context.Context, owner, query string, skip int) (json.RawMessage, error) { + if !validE164(owner) || len(query) > 2000 { + return nil, errGatewayRequest + } + return g.get(ctx, "/message-threads", url.Values{"owner": {owner}, "query": {query}, "skip": {strconv.Itoa(skip)}, "limit": {"20"}}) +} +func (g *gateway) ListMessages(ctx context.Context, owner, contact, query string, skip int) (json.RawMessage, error) { + if !validE164(owner) || !validE164(contact) || len(query) > 2000 { + return nil, errGatewayRequest + } + return g.get(ctx, "/messages", url.Values{"owner": {owner}, "contact": {contact}, "query": {query}, "skip": {strconv.Itoa(skip)}, "limit": {"20"}}) +} +func (g *gateway) SearchMessages(ctx context.Context, owner, query string, skip int, turnstileToken, remoteIP string) (json.RawMessage, error) { + if !validE164(owner) || len(query) > 2000 || strings.TrimSpace(turnstileToken) == "" || !validIP(remoteIP) { + return nil, errGatewayRequest + } + return g.getWithHeaders(ctx, "/messages/search", url.Values{"owners": {owner}, "query": {query}, "skip": {strconv.Itoa(skip)}, "limit": {"200"}}, http.Header{"token": []string{turnstileToken}, "X-Forwarded-For": []string{remoteIP}}) +} +func validIP(v string) bool { return net.ParseIP(strings.TrimSpace(v)) != nil } +func (g *gateway) get(ctx context.Context, path string, q url.Values) (json.RawMessage, error) { + return g.getWithHeaders(ctx, path, q, nil) +} +func (g *gateway) getWithHeaders(ctx context.Context, path string, q url.Values, headers http.Header) (json.RawMessage, error) { + if skip, err := strconv.Atoi(q.Get("skip")); err != nil || skip < 0 || skip > 1000000 { + return nil, errGatewayRequest + } + limit, err := strconv.Atoi(q.Get("limit")) + if err != nil || limit < 1 || limit > 200 { + return nil, errGatewayRequest + } + endpoint := *g.base + endpoint.Path = strings.TrimSuffix(endpoint.Path, "/") + path + endpoint.RawQuery = q.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, errors.New("gateway request failed") + } + req.Header.Set("X-API-Key", g.key) + for name, values := range headers { + for _, v := range values { + req.Header.Add(name, v) + } + } + resp, err := g.client.Do(req) + if err != nil { + return nil, errors.New("gateway unavailable") + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, gatewayResponseLimit+1)) + if err != nil || len(body) > gatewayResponseLimit { + return nil, errors.New("invalid gateway response") + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("gateway request failed (%d)", resp.StatusCode) + } + var payload any + if err = json.Unmarshal(body, &payload); err != nil { + return nil, errors.New("invalid gateway response") + } + stripAttachmentURLs(payload) + clean, err := json.Marshal(payload) + if err != nil { + return nil, errors.New("invalid gateway response") + } + return clean, nil +} +func stripAttachmentURLs(v any) { + switch x := v.(type) { + case map[string]any: + for k, value := range x { + lower := strings.ToLower(k) + if strings.Contains(lower, "attachment") && (strings.Contains(lower, "url") || lower == "attachments" || lower == "attachment") { + delete(x, k) + continue + } + stripAttachmentURLs(value) + } + case []any: + for _, value := range x { + stripAttachmentURLs(value) + } + } +} diff --git a/viewer/bff/gateway_test.go b/viewer/bff/gateway_test.go new file mode 100644 index 000000000..92eea9e98 --- /dev/null +++ b/viewer/bff/gateway_test.go @@ -0,0 +1,129 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGatewayFixedReadRoutesAndBinding(t *testing.T) { + var got []*http.Request + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = append(got, r) + if r.Header.Get("X-API-Key") != "secret" { + t.Errorf("missing API key") + } + _, _ = w.Write([]byte(`{"data":[{"id":"x","attachments":[{"url":"https://evil.test/a"}]}]}`)) + })) + defer server.Close() + g, err := newGateway(config{gatewayAPIURL: server.URL + "/v1", gatewayAPIKey: "secret", appEnv: "test"}, server.Client()) + if err != nil { + t.Fatal(err) + } + if _, err = g.ListThreads(context.Background(), "+15551234567", "hello", 5); err != nil { + t.Fatal(err) + } + if _, err = g.ListMessages(context.Background(), "+15551234567", "+15557654321", "hello", 7); err != nil { + t.Fatal(err) + } + if _, err = g.SearchMessages(context.Background(), "+15551234567", "hello", 9, "turnstile-secret", "203.0.113.4"); err != nil { + t.Fatal(err) + } + paths := []string{"/v1/message-threads", "/v1/messages", "/v1/messages/search"} + for i, r := range got { + if r.URL.Path != paths[i] || r.Method != "GET" { + t.Errorf("request %s %s", r.Method, r.URL) + } + if r.URL.Query().Get("owner") != "+15551234567" && i < 2 { + t.Errorf("owner missing: %s", r.URL.RawQuery) + } + if r.URL.Query().Get("owners") != "+15551234567" && i == 2 { + t.Errorf("owners missing") + } + if r.URL.Query().Get("limit") == "" { + t.Errorf("limit missing") + } + } + if got[2].Header.Get("token") != "turnstile-secret" { + t.Error("token missing") + } +} + +func TestGatewayOriginBaseUsesV1Routes(t *testing.T) { + var gotPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + defer server.Close() + gateway, err := newGateway(config{gatewayAPIURL: server.URL, gatewayAPIKey: "secret", appEnv: "test"}, server.Client()) + if err != nil { + t.Fatal(err) + } + if _, err := gateway.ListThreads(context.Background(), "+15551234567", "", 0); err != nil { + t.Fatal(err) + } + if gotPath != "/v1/message-threads" { + t.Fatalf("gateway path = %q, want /v1/message-threads", gotPath) + } +} + +func TestGatewayRejectsInvalidInputsAndStripsAttachmentURLs(t *testing.T) { + called := false + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + _, _ = w.Write([]byte(`{"attachment_url":"https://evil.test/a"}`)) + })) + defer s.Close() + g, _ := newGateway(config{gatewayAPIURL: s.URL, gatewayAPIKey: "k", appEnv: "test"}, s.Client()) + if _, e := g.ListThreads(context.Background(), "owner", "", 0); e == nil { + t.Fatal("invalid owner accepted") + } + if _, e := g.ListMessages(context.Background(), "+15551234567", "bad", "", 0); e == nil { + t.Fatal("invalid contact accepted") + } + if called { + t.Fatal("invalid input reached upstream") + } + raw, e := g.ListThreads(context.Background(), "+15551234567", "", 0) + if e != nil { + t.Fatal(e) + } + var data map[string]any + if e = json.Unmarshal(raw, &data); e != nil { + t.Fatal(e) + } + enc, _ := json.Marshal(data) + if strings.Contains(string(enc), "https://") { + t.Fatalf("attachment URL leaked: %s", enc) + } +} + +func TestGatewayConfigCannotChangeUpstreamHostOrPath(t *testing.T) { + for _, base := range []string{"https://good.test/evil", "https://good.test/v1/evil", "https://u:p@good.test"} { + if _, e := newGateway(config{gatewayAPIURL: base, gatewayAPIKey: "k"}, http.DefaultClient); e == nil { + t.Errorf("accepted %s", base) + } + } +} + +func TestGatewayRejectsRemoteCleartextAndAllowsLoopbackInTest(t *testing.T) { + if _, err := newGateway(config{gatewayAPIURL: "http://api.example.test/v1", gatewayAPIKey: "secret", appEnv: "test"}, nil); err == nil { + t.Fatal("remote cleartext gateway accepted") + } + if _, err := newGateway(config{gatewayAPIURL: "http://127.0.0.1:8080/v1", gatewayAPIKey: "secret", appEnv: "test"}, nil); err != nil { + t.Fatalf("loopback test gateway rejected: %v", err) + } +} + +func TestGatewayAllowsOnlyComposeAPIServiceOverHTTPInPre(t *testing.T) { + if _, err := newGateway(config{gatewayAPIURL: "http://api:8000/v1", gatewayAPIKey: "secret", appEnv: "pre"}, nil); err != nil { + t.Fatalf("internal pre gateway rejected: %v", err) + } + if _, err := newGateway(config{gatewayAPIURL: "http://other:8000/v1", gatewayAPIKey: "secret", appEnv: "pre"}, nil); err == nil { + t.Fatal("non-gateway pre HTTP URL accepted") + } +} diff --git a/viewer/bff/go.mod b/viewer/bff/go.mod new file mode 100644 index 000000000..9539cc8ea --- /dev/null +++ b/viewer/bff/go.mod @@ -0,0 +1,33 @@ +module github.com/NdoleStudio/httpsms/viewer/bff + +go 1.26.0 + +require ( + github.com/alicebob/miniredis/v2 v2.35.0 + github.com/coreos/go-oidc/v3 v3.17.0 + github.com/gofiber/fiber/v3 v3.5.0 + github.com/redis/go-redis/v9 v9.22.0 + golang.org/x/oauth2 v0.36.0 +) + +require ( + github.com/andybalholm/brotli v1.2.2 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/gofiber/schema v1.8.3 // indirect + github.com/gofiber/utils/v2 v2.4.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.19.2 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/tinylib/msgp v1.6.4 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.73.0 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect +) diff --git a/viewer/bff/go.sum b/viewer/bff/go.sum new file mode 100644 index 000000000..07c0be90d --- /dev/null +++ b/viewer/bff/go.sum @@ -0,0 +1,72 @@ +github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= +github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/gofiber/fiber/v3 v3.5.0 h1:dk7TOUH6DXJGtOLsN2XEG+0ZML7cznzHILTVozbNEK8= +github.com/gofiber/fiber/v3 v3.5.0/go.mod h1:GOVDTW+gjJvfe0iJyVujbQ1Lnx+JUjFySJRI/9/xX/w= +github.com/gofiber/schema v1.8.3 h1:06ZedxIYjngzc0095PYy7uWnFnbRflWFpikvZH61fDc= +github.com/gofiber/schema v1.8.3/go.mod h1:jWnnZdhcW1mHyV+VnfRxKJDPNcepJsTZ9RIWxrr32Ng= +github.com/gofiber/utils/v2 v2.4.1 h1:E2X9G8O5Mn7b2GDb0JU3IUk42Rw2npuhhepIbuJQ2po= +github.com/gofiber/utils/v2 v2.4.1/go.mod h1:I+RTsgMUdzFuifVc3LOEkfh32wQW9BfRl7l5RYjamW4= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= +github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= +github.com/shamaton/msgpack/v3 v3.2.0 h1:1q2Ms+MWmuRju+PuDMSFDB7p7621npeX4zprJN5Zck8= +github.com/shamaton/msgpack/v3 v3.2.0/go.mod h1:sgBYvEiyz8JR1NC3yGRoPVME9xXovpnh3l/plW1nfRo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.73.0 h1:ocTOORnBWtJ+P8t/6wAjdkchMzdfHmWx2VD/DPbgZ7s= +github.com/valyala/fasthttp v1.73.0/go.mod h1:EtXQDHaR+5P18p8wqDRFpUhxr108Ga9mXvVJXHRrN2k= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/viewer/bff/handlers.go b/viewer/bff/handlers.go new file mode 100644 index 000000000..88550b12d --- /dev/null +++ b/viewer/bff/handlers.go @@ -0,0 +1,337 @@ +package main + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gofiber/fiber/v3" +) + +type viewerGateway interface { + ListThreads(context.Context, string, string, int) (json.RawMessage, error) + ListMessages(context.Context, string, string, string, int) (json.RawMessage, error) + SearchMessages(context.Context, string, string, int, string, string) (json.RawMessage, error) +} +type viewerHandlerConfig struct { + store sessionStore + oidc *oidcClient + gateway viewerGateway + limiter requestLimiter + sessionSecret, expectedOrigin string + searchEnabled bool +} +type threadRefClaims struct { + Subject string `json:"sub"` + Owner string `json:"owner"` + ThreadID string `json:"thread_id"` + Contact string `json:"contact"` + Expires int64 `json:"exp"` +} + +func newViewerHandlers(cfg viewerHandlerConfig) *fiber.App { + app := fiber.New(fiber.Config{BodyLimit: 16 << 10}) + registerViewerHandlers(app, cfg) + return app +} +func registerViewerHandlers(app *fiber.App, cfg viewerHandlerConfig) { + h := &viewerHandlers{cfg: cfg} + app.Get("/viewer-api/threads", h.threads) + app.Get("/viewer-api/threads/:threadRef/messages", h.messages) + app.Post("/viewer-api/search", h.search) + app.All("/viewer-api/threads", func(c fiber.Ctx) error { setNoStore(c); return c.SendStatus(http.StatusMethodNotAllowed) }) + app.All("/viewer-api/threads/:threadRef/messages", func(c fiber.Ctx) error { setNoStore(c); return c.SendStatus(http.StatusMethodNotAllowed) }) + app.All("/viewer-api/search", func(c fiber.Ctx) error { setNoStore(c); return c.SendStatus(http.StatusMethodNotAllowed) }) + app.Use(func(c fiber.Ctx) error { return c.SendStatus(http.StatusNotFound) }) +} + +type viewerHandlers struct{ cfg viewerHandlerConfig } + +func (h *viewerHandlers) session(c fiber.Ctx) (viewerSession, string, error) { + v, id, err := loadAndValidateSession(c, h.cfg.store, h.cfg.oidc) + if err != nil { + _ = c.SendStatus(http.StatusUnauthorized) + return v, "", err + } + if err = requirePhoneBinding(v); err != nil { + _ = c.SendStatus(http.StatusForbidden) + return v, "", err + } + return v, id, nil +} +func setNoStore(c fiber.Ctx) { c.Set("Cache-Control", "no-store") } +func (h *viewerHandlers) threads(c fiber.Ctx) error { + setNoStore(c) + if !enforceRequestLimit(c, h.cfg.limiter, "threads", requestLimitIdentity(c, true), 60, 6000, time.Minute) { + return nil + } + v, _, err := h.session(c) + if err != nil { + return nil + } + skip, err := pagination(c) + if err != nil { + return c.SendStatus(http.StatusBadRequest) + } + query := c.Query("query") + if len(query) > 2000 { + return c.SendStatus(http.StatusBadRequest) + } + raw, err := h.cfg.gateway.ListThreads(c.Context(), v.PhoneNumber, query, skip) + if err != nil { + return genericViewerFailure(c) + } + var payload any + if json.Unmarshal(raw, &payload) != nil { + return genericViewerFailure(c) + } + if err = validateOwnerRows(payload, v.PhoneNumber); err != nil { + return genericViewerFailure(c) + } + if err = replaceThreadIDs(payload, v, h.cfg.sessionSecret); err != nil { + return genericViewerFailure(c) + } + return c.Type("json").Send(mustJSON(payload)) +} +func (h *viewerHandlers) messages(c fiber.Ctx) error { + setNoStore(c) + if !enforceRequestLimit(c, h.cfg.limiter, "messages", requestLimitIdentity(c, true), 60, 6000, time.Minute) { + return nil + } + v, _, err := h.session(c) + if err != nil { + return nil + } + ref := c.Params("threadRef") + claims, err := verifyThreadRef(ref, h.cfg.sessionSecret, v, time.Now()) + if err != nil { + return c.SendStatus(http.StatusNotFound) + } + skip, err := pagination(c) + if err != nil { + return c.SendStatus(http.StatusBadRequest) + } + query := c.Query("query") + if len(query) > 2000 { + return c.SendStatus(http.StatusBadRequest) + } + raw, err := h.cfg.gateway.ListMessages(c.Context(), claims.Owner, claims.Contact, query, skip) + if err != nil { + return genericViewerFailure(c) + } + var payload any + if json.Unmarshal(raw, &payload) != nil || validateMessageRows(payload, claims.Owner, claims.Contact) != nil { + return genericViewerFailure(c) + } + return c.Type("json").Send(mustJSON(payload)) +} +func (h *viewerHandlers) search(c fiber.Ctx) error { + setNoStore(c) + if !enforceRequestLimit(c, h.cfg.limiter, "search", requestLimitIdentity(c, true), 20, 2000, time.Minute) { + return nil + } + v, _, err := h.session(c) + if err != nil { + return nil + } + if !h.cfg.searchEnabled || h.cfg.gateway == nil { + return c.SendStatus(http.StatusServiceUnavailable) + } + if c.Get("Origin") == "" || c.Get("Origin") != h.cfg.expectedOrigin { + return c.SendStatus(http.StatusForbidden) + } + if !constantTimeEqual(c.Get("X-CSRF-Token"), v.CSRFToken) { + return c.SendStatus(http.StatusForbidden) + } + var req struct { + Query string `json:"query"` + Skip int `json:"skip"` + TurnstileToken string `json:"turnstile_token"` + } + body := c.Body() + if rejectDuplicateJSONKeys(body) != nil { + return c.SendStatus(http.StatusBadRequest) + } + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + if decoder.Decode(&req) != nil || decoder.Decode(new(any)) != io.EOF || len(req.Query) > 2000 || req.Skip < 0 || req.Skip > 1000000 || strings.TrimSpace(req.TurnstileToken) == "" { + return c.SendStatus(http.StatusBadRequest) + } + ip := c.IP() + if !validIP(ip) { + return c.SendStatus(http.StatusServiceUnavailable) + } + raw, err := h.cfg.gateway.SearchMessages(c.Context(), v.PhoneNumber, req.Query, req.Skip, req.TurnstileToken, ip) + if err != nil { + return genericViewerFailure(c) + } + var payload any + if json.Unmarshal(raw, &payload) != nil || validateOwnerRows(payload, v.PhoneNumber) != nil { + return genericViewerFailure(c) + } + return c.Type("json").Send(mustJSON(payload)) +} +func pagination(c fiber.Ctx) (int, error) { + skip := 0 + var err error + if x := c.Query("skip"); x != "" { + skip, err = strconv.Atoi(x) + if err != nil { + return 0, err + } + } + if x := c.Query("limit"); x != "" { + limit, parseErr := strconv.Atoi(x) + if parseErr != nil || limit != 20 { + return 0, errors.New("invalid pagination") + } + } + if skip < 0 || skip > 1000000 { + return 0, errors.New("invalid pagination") + } + return skip, nil +} +func replaceThreadIDs(payload any, s viewerSession, secret string) error { + rows, err := responseRows(payload) + if err != nil { + return err + } + for _, item := range rows { + m, ok := item.(map[string]any) + if !ok { + return errors.New("invalid thread") + } + id, ok := m["id"].(string) + if !ok || id == "" { + return errors.New("missing thread id") + } + contact, ok := m["contact"].(string) + if !ok || !validE164(contact) { + return errors.New("invalid thread contact") + } + ref, err := signThreadRef(threadRefClaims{Subject: s.Subject, Owner: s.PhoneNumber, ThreadID: id, Contact: contact, Expires: time.Now().Add(5 * time.Minute).Unix()}, secret) + if err != nil { + return err + } + stripUpstreamIDs(m) + m["owner"] = s.PhoneNumber + m["thread_ref"] = ref + } + return nil +} + +func validateOwnerRows(payload any, expected string) error { + rows, err := responseRows(payload) + if err != nil { + return err + } + for _, item := range rows { + m, ok := item.(map[string]any) + if !ok { + return errors.New("invalid gateway row") + } + owner, ok := m["owner"].(string) + if !ok || owner != expected { + return errors.New("gateway row owner mismatch") + } + } + return nil +} + +func validateMessageRows(payload any, expectedOwner, expectedContact string) error { + if err := validateOwnerRows(payload, expectedOwner); err != nil { + return err + } + rows, err := responseRows(payload) + if err != nil { + return err + } + for _, item := range rows { + m := item.(map[string]any) + contact, ok := m["contact"].(string) + if !ok || contact != expectedContact { + return errors.New("gateway row contact mismatch") + } + } + return nil +} + +func responseRows(payload any) ([]any, error) { + switch x := payload.(type) { + case []any: + return x, nil + case map[string]any: + if a, ok := x["data"].([]any); ok { + return a, nil + } else { + return nil, errors.New("invalid gateway response") + } + default: + return nil, errors.New("invalid gateway response") + } +} +func stripUpstreamIDs(value any) { + switch item := value.(type) { + case map[string]any: + for key, child := range item { + lower := strings.ToLower(key) + if lower == "id" || strings.HasSuffix(lower, "_id") { + delete(item, key) + continue + } + stripUpstreamIDs(child) + } + case []any: + for _, child := range item { + stripUpstreamIDs(child) + } + } +} +func signThreadRef(claims threadRefClaims, secret string) (string, error) { + if len(secret) < 32 || claims.Subject == "" || !validE164(claims.Owner) || !validE164(claims.Contact) || claims.ThreadID == "" { + return "", errors.New("invalid signed reference") + } + b, err := json.Marshal(claims) + if err != nil { + return "", err + } + body := base64.RawURLEncoding.EncodeToString(b) + m := hmac.New(sha256.New, []byte(secret)) + _, _ = m.Write([]byte(body)) + return body + "." + base64.RawURLEncoding.EncodeToString(m.Sum(nil)), nil +} +func verifyThreadRef(ref, secret string, s viewerSession, now time.Time) (threadRefClaims, error) { + var c threadRefClaims + parts := strings.Split(ref, ".") + if len(parts) != 2 || len(secret) < 32 { + return c, errors.New("invalid reference") + } + sig, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return c, errors.New("invalid reference") + } + m := hmac.New(sha256.New, []byte(secret)) + _, _ = m.Write([]byte(parts[0])) + if !hmac.Equal(sig, m.Sum(nil)) { + return c, errors.New("invalid reference") + } + b, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil || json.Unmarshal(b, &c) != nil || c.Subject != s.Subject || c.Owner != s.PhoneNumber || c.Expires <= now.Unix() || c.ThreadID == "" || !validE164(c.Contact) { + return threadRefClaims{}, fmt.Errorf("invalid reference") + } + return c, nil +} +func mustJSON(v any) []byte { b, _ := json.Marshal(v); return b } +func genericViewerFailure(c fiber.Ctx) error { + return c.Status(http.StatusBadGateway).SendString("request failed") +} diff --git a/viewer/bff/handlers_test.go b/viewer/bff/handlers_test.go new file mode 100644 index 000000000..17415c871 --- /dev/null +++ b/viewer/bff/handlers_test.go @@ -0,0 +1,480 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/gofiber/fiber/v3" + "github.com/redis/go-redis/v9" + "golang.org/x/oauth2" +) + +type fakeViewerGateway struct { + threadsOwner string + messageOwner string + messageContact string + searchOwner string + calls int + threads json.RawMessage + messages json.RawMessage + search json.RawMessage +} + +type fixedRequestLimiter struct { + mu sync.Mutex + allowed bool + err error + calls int + keys []string +} + +func (l *fixedRequestLimiter) Allow(_ context.Context, key string, _ int, _ time.Duration) (bool, error) { + l.mu.Lock() + defer l.mu.Unlock() + l.calls++ + l.keys = append(l.keys, key) + return l.allowed, l.err +} + +func (g *fakeViewerGateway) ListThreads(_ context.Context, owner, _ string, _ int) (json.RawMessage, error) { + g.calls++ + g.threadsOwner = owner + if g.threads != nil { + return g.threads, nil + } + return json.RawMessage(`{"data":[{"id":"upstream-id","user_id":"private","last_message_id":"private-message","owner":"+15550000000","contact":"+15551111111","contact_details":{"id":"private-contact","name":"Friend"}}]}`), nil +} +func (g *fakeViewerGateway) ListMessages(_ context.Context, owner, contact, _ string, _ int) (json.RawMessage, error) { + g.calls++ + g.messageOwner = owner + g.messageContact = contact + if g.messages != nil { + return g.messages, nil + } + return json.RawMessage(`{"data":[]}`), nil +} +func (g *fakeViewerGateway) SearchMessages(_ context.Context, owner, _ string, _ int, _, _ string) (json.RawMessage, error) { + g.calls++ + g.searchOwner = owner + if g.search != nil { + return g.search, nil + } + return json.RawMessage(`{"data":[]}`), nil +} + +func viewerTestApp(session *viewerSession, g *fakeViewerGateway) (*http.Cookie, *fiber.App) { + store := newSessionMemory() + id := "" + if session != nil { + v := *session + v.ProfileValidatedAt = time.Now() + v.ExpiresAt = time.Now().Add(time.Hour) + id, _ = store.createSession(context.Background(), v) + } + app := newViewerHandlers(viewerHandlerConfig{store: store, oidc: &oidcClient{}, gateway: g, limiter: &fixedRequestLimiter{allowed: true}, sessionSecret: "a sufficiently long session secret for hmac", expectedOrigin: "https://viewer.example.test", searchEnabled: true}) + cookie := &http.Cookie{Name: sessionCookieName, Value: id} + return cookie, app +} + +func TestViewerThreadsRequireSessionAndPhoneBinding(t *testing.T) { + g := &fakeViewerGateway{} + for _, tc := range []struct { + name string + s *viewerSession + want int + }{{"no session", nil, 401}, {"no binding", &viewerSession{Subject: "u"}, 403}} { + t.Run(tc.name, func(t *testing.T) { + cookie, app := viewerTestApp(tc.s, g) + req := httptest.NewRequest("GET", "/viewer-api/threads?owner=+15559999999", nil) + if tc.s != nil { + req.AddCookie(cookie) + } + resp, _ := app.Test(req) + resp.Body.Close() + if resp.StatusCode != tc.want { + t.Fatalf("status=%d", resp.StatusCode) + } + if g.calls != 0 { + t.Fatal("gateway called before phone binding") + } + }) + } +} + +func TestViewerThreadsIgnoreForgedOwnerAndSignedRefFixesMessageOwner(t *testing.T) { + g := &fakeViewerGateway{} + cookie, app := viewerTestApp(&viewerSession{Subject: "user-a", PhoneNumber: "+15550000000"}, g) + req := httptest.NewRequest("GET", "/viewer-api/threads?owner=+15559999999", nil) + req.AddCookie(cookie) + resp, _ := app.Test(req) + defer resp.Body.Close() + if resp.StatusCode != 200 || g.threadsOwner != "+15550000000" { + t.Fatalf("status=%d owner=%q", resp.StatusCode, g.threadsOwner) + } + if resp.Header.Get("Cache-Control") != "no-store" { + t.Fatalf("Cache-Control=%q", resp.Header.Get("Cache-Control")) + } + var payload struct { + Data []map[string]any `json:"data"` + } + if json.NewDecoder(resp.Body).Decode(&payload) != nil || len(payload.Data) != 1 { + t.Fatal("invalid threads response") + } + ref, _ := payload.Data[0]["thread_ref"].(string) + if ref == "" || payload.Data[0]["id"] != nil { + t.Fatalf("upstream id not replaced: %#v", payload.Data[0]) + } + if payload.Data[0]["user_id"] != nil || payload.Data[0]["last_message_id"] != nil || payload.Data[0]["contact_details"].(map[string]any)["id"] != nil { + t.Fatalf("upstream IDs leaked: %#v", payload.Data[0]) + } + detail := httptest.NewRequest("GET", "/viewer-api/threads/"+ref+"/messages", nil) + detail.AddCookie(cookie) + r2, _ := app.Test(detail) + r2.Body.Close() + if r2.StatusCode != 200 || g.messageOwner != "+15550000000" || g.messageContact != "+15551111111" { + t.Fatalf("status=%d owner/contact=%q/%q", r2.StatusCode, g.messageOwner, g.messageContact) + } +} + +func TestViewerRejectsGatewayRecordsWithoutExactOwnerMatch(t *testing.T) { + owner := "+15550000000" + other := "+15559999999" + secret := "a sufficiently long session secret for hmac" + ref, err := signThreadRef(threadRefClaims{Subject: "user-a", Owner: owner, ThreadID: "id", Contact: "+15551111111", Expires: time.Now().Add(time.Minute).Unix()}, secret) + if err != nil { + t.Fatal(err) + } + cases := []struct { + name, path string + prepare func(*fakeViewerGateway) + }{ + {"threads", "/viewer-api/threads", func(g *fakeViewerGateway) { + g.threads = json.RawMessage(`{"data":[{"id":"owned-id","owner":"` + owner + `","contact":"+15551111111"},{"id":"foreign-id","owner":"` + other + `","contact":"+15552222222","content":"foreign-thread-secret"}]}`) + }}, + {"thread missing owner", "/viewer-api/threads", func(g *fakeViewerGateway) { + g.threads = json.RawMessage(`{"data":[{"id":"thread-without-owner","contact":"+15551111111","content":"missing-owner-secret"}]}`) + }}, + {"messages", "/viewer-api/threads/" + ref + "/messages", func(g *fakeViewerGateway) { + g.messages = json.RawMessage(`{"data":[{"owner":"` + owner + `","contact":"+15551111111","content":"mine"},{"owner":"` + other + `","contact":"+15552222222","content":"foreign-message-secret"}]}`) + }}, + {"messages wrong contact", "/viewer-api/threads/" + ref + "/messages", func(g *fakeViewerGateway) { + g.messages = json.RawMessage(`{"data":[{"owner":"` + owner + `","contact":"+15552222222","content":"other-thread-secret"}]}`) + }}, + {"search", "/viewer-api/search", func(g *fakeViewerGateway) { + g.search = json.RawMessage(`{"data":[{"owner":"` + owner + `","content":"mine"},{"owner":"` + other + `","content":"foreign-search-secret"}]}`) + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + g := &fakeViewerGateway{} + tc.prepare(g) + cookie, app := viewerTestApp(&viewerSession{Subject: "user-a", PhoneNumber: owner, CSRFToken: "csrf"}, g) + method := http.MethodGet + body := "" + if tc.name == "search" { + method = http.MethodPost + body = `{"query":"x","turnstile_token":"token"}` + } + req := httptest.NewRequest(method, tc.path, strings.NewReader(body)) + req.AddCookie(cookie) + if tc.name == "search" { + req.Header.Set("Origin", "https://viewer.example.test") + req.Header.Set("X-CSRF-Token", "csrf") + } + resp, requestErr := app.Test(req) + if requestErr != nil { + t.Fatal(requestErr) + } + defer resp.Body.Close() + content, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusBadGateway || strings.Contains(string(content), "foreign-") || strings.Contains(string(content), "missing-owner-secret") || strings.Contains(string(content), "other-thread-secret") { + t.Fatalf("status=%d response=%q; expected fail-closed response without foreign records", resp.StatusCode, content) + } + }) + } +} + +func TestRateLimitsProtectLoginCallbackAndReadEndpoints(t *testing.T) { + limiter := &fixedRequestLimiter{} + store := newSessionMemory() + app := buildOIDCApp(oidcConfig{issuer: "https://passport.example.test", redirectURL: "https://viewer.example.test/viewer-api/callback", sessionTTL: time.Hour, sessionSecret: "a sufficiently long session secret for hmac", limiter: limiter}, store, &oidcClient{}) + for _, path := range []string{"/viewer-api/login", "/viewer-api/callback"} { + resp, err := app.Test(httptest.NewRequest(http.MethodGet, path, nil)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusTooManyRequests || resp.Header.Get("Retry-After") == "" { + t.Fatalf("%s status=%d Retry-After=%q", path, resp.StatusCode, resp.Header.Get("Retry-After")) + } + } + if limiter.calls != 2 { + t.Fatalf("login/callback limiter calls=%d want 2", limiter.calls) + } + + g := &fakeViewerGateway{} + readLimiter := &fixedRequestLimiter{} + cookie, _ := viewerTestApp(&viewerSession{Subject: "u", PhoneNumber: "+15550000000", CSRFToken: "csrf"}, g) + store = newSessionMemory() + id, err := store.createSession(context.Background(), viewerSession{Subject: "u", PhoneNumber: "+15550000000", CSRFToken: "csrf", ProfileValidatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)}) + if err != nil { + t.Fatal(err) + } + cookie = &http.Cookie{Name: sessionCookieName, Value: id} + ref, err := signThreadRef(threadRefClaims{Subject: "u", Owner: "+15550000000", ThreadID: "id", Contact: "+15551111111", Expires: time.Now().Add(time.Minute).Unix()}, "a sufficiently long session secret for hmac") + if err != nil { + t.Fatal(err) + } + readApp := newViewerHandlers(viewerHandlerConfig{store: store, oidc: &oidcClient{}, gateway: g, limiter: readLimiter, sessionSecret: "a sufficiently long session secret for hmac", expectedOrigin: "https://viewer.example.test", searchEnabled: true}) + requests := []*http.Request{ + httpTestRequest(http.MethodGet, "/viewer-api/threads", "", cookie), + httpTestRequest(http.MethodGet, "/viewer-api/threads/"+ref+"/messages", "", cookie), + httpTestRequest(http.MethodPost, "/viewer-api/search", `{"query":"x","turnstile_token":"token"}`, cookie), + } + requests[2].Header.Set("Origin", "https://viewer.example.test") + requests[2].Header.Set("X-CSRF-Token", "csrf") + for _, req := range requests { + resp, err := readApp.Test(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusTooManyRequests || resp.Header.Get("Cache-Control") != "no-store" { + t.Fatalf("%s status=%d Cache-Control=%q", req.URL.Path, resp.StatusCode, resp.Header.Get("Cache-Control")) + } + } + if readLimiter.calls != len(requests) || g.calls != 0 { + t.Fatalf("limiter calls=%d gateway calls=%d", readLimiter.calls, g.calls) + } +} + +func TestRateLimitsProtectSessionAndLogout(t *testing.T) { + limiter := &fixedRequestLimiter{} + store := newSessionMemory() + id, err := store.createSession(context.Background(), viewerSession{Subject: "u", PhoneNumber: "+15550000000", CSRFToken: "csrf", ProfileValidatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)}) + if err != nil { + t.Fatal(err) + } + app := buildOIDCApp(oidcConfig{redirectURL: "https://viewer.example.test/viewer-api/callback", limiter: limiter}, store, &oidcClient{}) + for _, tc := range []struct { + method, path string + }{{http.MethodGet, "/viewer-api/session"}, {http.MethodPost, "/viewer-api/logout"}} { + req := httpTestRequest(tc.method, tc.path, "", &http.Cookie{Name: sessionCookieName, Value: id}) + if tc.path == "/viewer-api/logout" { + req.Header.Set("Origin", "https://viewer.example.test") + req.Header.Set("X-CSRF-Token", "csrf") + } + resp, err := app.Test(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusTooManyRequests { + t.Fatalf("%s status=%d want 429", tc.path, resp.StatusCode) + } + } +} + +func TestRateLimitStorageFailureFailsClosedWithGenericResponse(t *testing.T) { + limiter := &fixedRequestLimiter{err: errors.New("redis credential or address detail")} + store := newSessionMemory() + id, err := store.createSession(context.Background(), viewerSession{Subject: "u", PhoneNumber: "+15550000000", ProfileValidatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)}) + if err != nil { + t.Fatal(err) + } + app := newViewerHandlers(viewerHandlerConfig{store: store, oidc: &oidcClient{}, limiter: limiter}) + resp, err := app.Test(httpTestRequest(http.MethodGet, "/viewer-api/threads", "", &http.Cookie{Name: sessionCookieName, Value: id})) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusServiceUnavailable || strings.Contains(string(body), "redis credential") { + t.Fatalf("status=%d body=%q, want generic fail-closed response", resp.StatusCode, body) + } +} + +func TestAnonymousLoginAndCallbackRateLimitsCannotBeBypassedWithForwardedHeaderSpoofing(t *testing.T) { + server := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: server.Addr()}) + defer client.Close() + limiter := newRedisRateLimiter(client) + app := buildOIDCApp(oidcConfig{issuer: "https://passport.example.test", redirectURL: "https://viewer.example.test/viewer-api/callback", sessionTTL: time.Hour, sessionSecret: "a sufficiently long session secret for hmac", limiter: limiter}, newSessionMemory(), &oidcClient{oauth: oauth2.Config{ClientID: "client", RedirectURL: "https://viewer.example.test/viewer-api/callback"}}) + for _, tc := range []struct { + path string + limit int + allowedStatus int + }{{"/viewer-api/login", 120, http.StatusSeeOther}, {"/viewer-api/callback", 120, http.StatusBadRequest}} { + t.Run(tc.path, func(t *testing.T) { + for i := 0; i <= tc.limit; i++ { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + req.Header.Set("X-Forwarded-For", "198.51.100."+strconv.Itoa(i+1)) + req.Header.Set("X-Real-IP", "192.0.2."+strconv.Itoa(i+1)) + resp, err := app.Test(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if i < tc.limit && resp.StatusCode != tc.allowedStatus { + t.Fatalf("request %d status=%d, want %d", i+1, resp.StatusCode, tc.allowedStatus) + } + if i == tc.limit && (resp.StatusCode != http.StatusTooManyRequests || resp.Header.Get("Retry-After") == "") { + t.Fatalf("request %d status=%d Retry-After=%q", i+1, resp.StatusCode, resp.Header.Get("Retry-After")) + } + } + }) + } +} + +func httpTestRequest(method, path, body string, cookie *http.Cookie) *http.Request { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.AddCookie(cookie) + return req +} + +func TestSearchDisabledWithoutTrustedProxyConfiguration(t *testing.T) { + store := newSessionMemory() + id, err := store.createSession(context.Background(), viewerSession{Subject: "u", PhoneNumber: "+15550000000", CSRFToken: "csrf", ProfileValidatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)}) + if err != nil { + t.Fatal(err) + } + app := buildOIDCApp(testOIDCConfig("https://passport.example.test"), store, &oidcClient{}) + req := httptest.NewRequest("POST", "/viewer-api/search", strings.NewReader(`{"query":"x","turnstile_token":"secret"}`)) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: id}) + req.Header.Set("Origin", "https://viewer.example.test") + req.Header.Set("X-CSRF-Token", "csrf") + req.Header.Set("Content-Type", "application/json") + resp, _ := app.Test(req) + resp.Body.Close() + if resp.StatusCode != 503 { + t.Fatalf("status=%d", resp.StatusCode) + } +} + +func TestSearchAuthenticationPrecedesAvailabilityAndOriginChecks(t *testing.T) { + t.Run("disabled search still authenticates first", func(t *testing.T) { + app := newViewerHandlers(viewerHandlerConfig{store: newSessionMemory(), oidc: &oidcClient{}, limiter: &fixedRequestLimiter{allowed: true}, expectedOrigin: "https://viewer.example.test"}) + resp, err := app.Test(httptest.NewRequest("POST", "/viewer-api/search", strings.NewReader(`{}`))) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 401 { + t.Fatalf("status=%d want 401", resp.StatusCode) + } + }) + t.Run("missing origin still authenticates first", func(t *testing.T) { + cookie, app := viewerTestApp(nil, &fakeViewerGateway{}) + resp, err := app.Test(withCookie(httptest.NewRequest("POST", "/viewer-api/search", strings.NewReader(`{}`)), cookie)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 401 { + t.Fatalf("status=%d want 401", resp.StatusCode) + } + }) +} + +func TestViewerRejectsForgedAndCrossUserThreadRefs(t *testing.T) { + g := &fakeViewerGateway{} + cookieA, appA := viewerTestApp(&viewerSession{Subject: "user-a", PhoneNumber: "+15550000000"}, g) + req := httptest.NewRequest("GET", "/viewer-api/threads", nil) + req.AddCookie(cookieA) + resp, _ := appA.Test(req) + var p struct { + Data []map[string]any `json:"data"` + } + _ = json.NewDecoder(resp.Body).Decode(&p) + resp.Body.Close() + ref := p.Data[0]["thread_ref"].(string) + for _, tc := range []struct { + name, ref string + session *viewerSession + }{{"forged", ref + "x", &viewerSession{Subject: "user-a", PhoneNumber: "+15550000000"}}, {"other user", ref, &viewerSession{Subject: "user-b", PhoneNumber: "+15552222222"}}} { + t.Run(tc.name, func(t *testing.T) { + cookie, app := viewerTestApp(tc.session, g) + before := g.calls + r, _ := app.Test(withCookie(httptest.NewRequest("GET", "/viewer-api/threads/"+tc.ref+"/messages", nil), cookie)) + r.Body.Close() + if r.StatusCode != 404 || g.calls != before { + t.Fatalf("status=%d gateway calls=%d", r.StatusCode, g.calls-before) + } + }) + } +} + +func withCookie(r *http.Request, c *http.Cookie) *http.Request { r.AddCookie(c); return r } + +func TestViewerPaginationAndMethodsAreBounded(t *testing.T) { + g := &fakeViewerGateway{} + cookie, app := viewerTestApp(&viewerSession{Subject: "u", PhoneNumber: "+15550000000"}, g) + for _, path := range []string{"/viewer-api/threads?skip=-1", "/viewer-api/threads?skip=1000001", "/viewer-api/threads?limit=21", "/viewer-api/threads?limit=1"} { + r, _ := app.Test(withCookie(httptest.NewRequest("GET", path, nil), cookie)) + r.Body.Close() + if r.StatusCode != 400 { + t.Fatalf("%s status=%d", path, r.StatusCode) + } + } + oldRoute, _ := app.Test(withCookie(httptest.NewRequest("GET", "/viewer-api/threads/messages?thread_ref=forged", nil), cookie)) + oldRoute.Body.Close() + if oldRoute.StatusCode != 404 { + t.Fatalf("old query route status=%d", oldRoute.StatusCode) + } + bad := httptest.NewRequest("PUT", "/viewer-api/threads", strings.NewReader("")) + bad.AddCookie(cookie) + r, _ := app.Test(bad) + r.Body.Close() + if r.StatusCode != 405 { + t.Fatalf("method status=%d", r.StatusCode) + } +} + +func TestViewerSearchRequiresOriginCSRFAndKeepsOwnerServerSide(t *testing.T) { + g := &fakeViewerGateway{} + cookie, app := viewerTestApp(&viewerSession{Subject: "u", PhoneNumber: "+15550000000", CSRFToken: "csrf"}, g) + for _, tc := range []struct { + name, origin, csrf string + body string + want int + }{ + {"origin required", "", "csrf", `{"query":"x","turnstile_token":"secret"}`, 403}, + {"csrf required", "https://viewer.example.test", "bad", `{"query":"x","turnstile_token":"secret"}`, 403}, + {"owner is not accepted", "https://viewer.example.test", "csrf", `{"query":"x","owner":"+15559999999","turnstile_token":"secret"}`, 400}, + {"duplicate fields rejected", "https://viewer.example.test", "csrf", `{"query":"x","query":"y","turnstile_token":"secret"}`, 400}, + {"trailing JSON rejected", "https://viewer.example.test", "csrf", `{"query":"x","turnstile_token":"secret"} {}`, 400}, + } { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest("POST", "/viewer-api/search", strings.NewReader(tc.body)) + req.AddCookie(cookie) + req.Header.Set("Origin", tc.origin) + req.Header.Set("X-CSRF-Token", tc.csrf) + req.Header.Set("Content-Type", "application/json") + resp, _ := app.Test(req) + resp.Body.Close() + if resp.StatusCode != tc.want { + t.Fatalf("status=%d want %d", resp.StatusCode, tc.want) + } + }) + } + req := httptest.NewRequest("POST", "/viewer-api/search", strings.NewReader(`{"query":"needle","skip":0,"turnstile_token":"secret"}`)) + req.AddCookie(cookie) + req.Header.Set("Origin", "https://viewer.example.test") + req.Header.Set("X-CSRF-Token", "csrf") + req.Header.Set("Content-Type", "application/json") + resp, _ := app.Test(req) + resp.Body.Close() + if resp.StatusCode != 200 || g.searchOwner != "+15550000000" { + t.Fatalf("status=%d owner=%q", resp.StatusCode, g.searchOwner) + } +} diff --git a/viewer/bff/main.go b/viewer/bff/main.go new file mode 100644 index 000000000..5e7e55e17 --- /dev/null +++ b/viewer/bff/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "log" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/redis/go-redis/v9" +) + +func newApp() *fiber.App { + app := fiber.New() + app.Get("/health", func(c fiber.Ctx) error { + return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"}) + }) + return app +} + +func main() { + cfg, err := loadConfig(nil) + if err != nil { + log.Fatal(err) + } + redisOptions, err := redis.ParseURL(cfg.redisURL) + if err != nil { + log.Fatal("invalid REDIS_URL") + } + redisClient := redis.NewClient(redisOptions) + defer redisClient.Close() + if err := redisClient.Ping(context.Background()).Err(); err != nil { + log.Fatal("Redis unavailable") + } + gatewayClient, err := newGateway(cfg, nil) + if err != nil { + log.Fatal("Gateway setup failed") + } + limiter := newRedisRateLimiter(redisClient) + app, err := newOIDCApp(oidcConfig{issuer: cfg.passportIssuer, clientID: cfg.passportClientID, clientSecret: cfg.passportClientSecret, redirectURL: cfg.passportRedirectURL, sessionTTL: 12 * time.Hour, sessionSecret: cfg.sessionSecret, gateway: gatewayClient, limiter: limiter, trustedProxies: cfg.trustedProxies}, newRedisSessionStore(redisClient)) + if err != nil { + log.Fatal("Passport OIDC setup failed") + } + if err := app.Listen(":8080"); err != nil { + log.Fatal(err) + } +} diff --git a/viewer/bff/oidc.go b/viewer/bff/oidc.go new file mode 100644 index 000000000..319150804 --- /dev/null +++ b/viewer/bff/oidc.go @@ -0,0 +1,321 @@ +package main + +import ( + "context" + "errors" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gofiber/fiber/v3" + "golang.org/x/oauth2" +) + +type oidcConfig struct { + issuer, clientID, clientSecret, redirectURL string + sessionTTL time.Duration + sessionSecret string + gateway viewerGateway + limiter requestLimiter + trustedProxies []string +} + +const loginFlowCookieName = "passport_flow" + +type identityProfile = passportProfile +type oidcClient struct { + oauth oauth2.Config + verifier *oidc.IDTokenVerifier + profileURL string + httpClient *http.Client +} + +func newOIDCClient(ctx context.Context, c oidcConfig) (*oidcClient, error) { + p, e := oidc.NewProvider(ctx, c.issuer) + if e != nil { + return nil, errors.New("passport discovery failed") + } + raw := map[string]any{} + if e = p.Claims(&raw); e != nil { + return nil, errors.New("passport discovery failed") + } + profile, _ := raw["userinfo_endpoint"].(string) + if profile == "" { + return nil, errors.New("passport userinfo endpoint missing") + } + oauth := oauth2.Config{ClientID: c.clientID, ClientSecret: c.clientSecret, Endpoint: p.Endpoint(), RedirectURL: c.redirectURL, Scopes: []string{oidc.ScopeOpenID, "profile", "app_profile"}} + return &oidcClient{oauth: oauth, verifier: p.Verifier(&oidc.Config{ClientID: c.clientID}), profileURL: profile, httpClient: &http.Client{Timeout: 10 * time.Second}}, nil +} + +func newOIDCApp(c oidcConfig, store sessionStore) (*fiber.App, error) { + client, e := newOIDCClient(context.Background(), c) + if e != nil { + return nil, e + } + return buildOIDCApp(c, store, client), nil +} + +func buildOIDCApp(c oidcConfig, store sessionStore, client *oidcClient) *fiber.App { + app := fiber.New(fiber.Config{BodyLimit: 16 << 10, TrustProxy: len(c.trustedProxies) > 0, EnableIPValidation: true, ProxyHeader: fiber.HeaderXForwardedFor, TrustProxyConfig: fiber.TrustProxyConfig{Proxies: c.trustedProxies}}) + app.Get("/health", func(ctx fiber.Ctx) error { return ctx.JSON(fiber.Map{"status": "ok"}) }) + app.Get("/viewer-api/login", func(ctx fiber.Ctx) error { + if !enforceRequestLimit(ctx, c.limiter, "login", requestLimitIdentity(ctx, false), 120, 1200, 15*time.Minute) { + return nil + } + state, e := randomValue(32) + if e != nil { + return genericFailure(ctx) + } + nonce, e := randomValue(32) + if e != nil { + return genericFailure(ctx) + } + verifier, e := randomValue(32) + if e != nil { + return genericFailure(ctx) + } + browserSecret, e := randomValue(32) + if e != nil { + return genericFailure(ctx) + } + if e = store.putState(ctx.Context(), state, loginState{Nonce: nonce, Verifier: verifier, BrowserBinding: browserBinding(browserSecret)}, 10*time.Minute); e != nil { + return genericFailure(ctx) + } + setLoginFlowCookie(ctx, browserSecret) + return ctx.Redirect().To(client.oauth.AuthCodeURL(state, oauth2.SetAuthURLParam("nonce", nonce), oauth2.S256ChallengeOption(verifier))) + }) + app.All("/viewer-api/login", func(ctx fiber.Ctx) error { return ctx.SendStatus(http.StatusMethodNotAllowed) }) + app.Get("/viewer-api/callback", func(ctx fiber.Ctx) error { + if !enforceRequestLimit(ctx, c.limiter, "callback", requestLimitIdentity(ctx, false), 120, 1200, 15*time.Minute) { + clearLoginFlowCookie(ctx) + return nil + } + browserSecret := ctx.Cookies(loginFlowCookieName) + clearLoginFlowCookie(ctx) + state := ctx.Query("state") + if state == "" || ctx.Query("error") != "" { + return ctx.SendStatus(http.StatusBadRequest) + } + pending, e := store.consumeState(ctx.Context(), state) + if e != nil { + return ctx.SendStatus(http.StatusBadRequest) + } + if browserSecret == "" || !constantTimeEqual(pending.BrowserBinding, browserBinding(browserSecret)) { + return ctx.SendStatus(http.StatusBadRequest) + } + token, e := client.oauth.Exchange(client.oauthContext(ctx.Context()), ctx.Query("code"), oauth2.VerifierOption(pending.Verifier)) + if e != nil { + return genericFailure(ctx) + } + rawID, ok := token.Extra("id_token").(string) + if !ok || rawID == "" { + return genericFailure(ctx) + } + idToken, e := client.verifier.Verify(ctx.Context(), rawID) + if e != nil { + return genericFailure(ctx) + } + var claims struct { + Nonce string `json:"nonce"` + Subject string `json:"sub"` + } + if idToken.Claims(&claims) != nil || claims.Nonce != pending.Nonce || claims.Subject == "" { + return genericFailure(ctx) + } + profile, e := client.userInfo(ctx.Context(), token.AccessToken) + if e != nil || profile.Subject != claims.Subject { + return genericFailure(ctx) + } + csrf, e := randomValue(32) + if e != nil { + return genericFailure(ctx) + } + now := time.Now() + expiry := now.Add(c.sessionTTL) + session := refreshIdleDeadline(viewerSession{Subject: claims.Subject, DisplayName: profile.DisplayName, PhoneNumber: profile.PhoneNumber, AccessToken: token.AccessToken, AccessTokenExpiresAt: token.Expiry, RefreshToken: token.RefreshToken, IDToken: rawID, ProfileValidatedAt: now, ExpiresAt: expiry, CSRFToken: csrf}, now, sessionIdleTTL) + id, e := store.createSession(ctx.Context(), session) + if e != nil { + return genericFailure(ctx) + } + previousID := ctx.Cookies(sessionCookieName) + if previousID != "" && previousID != id { + if e = store.deleteSession(ctx.Context(), previousID); e != nil { + _ = store.deleteSession(ctx.Context(), id) + return genericFailure(ctx) + } + } + setSessionCookie(ctx, id, session.IdleExpiresAt) + return ctx.Redirect().Status(http.StatusSeeOther).To("/") + }) + app.All("/viewer-api/callback", func(ctx fiber.Ctx) error { return ctx.SendStatus(http.StatusMethodNotAllowed) }) + app.Get("/viewer-api/session", func(ctx fiber.Ctx) error { + setNoStore(ctx) + if !enforceRequestLimit(ctx, c.limiter, "session", requestLimitIdentity(ctx, true), 60, 6000, time.Minute) { + return nil + } + v, _, e := loadAndValidateSession(ctx, store, client) + if e != nil { + return ctx.SendStatus(http.StatusUnauthorized) + } + return ctx.JSON(fiber.Map{"csrf_token": v.CSRFToken, "display_name": v.DisplayName, "phone_number_masked": maskPhone(v.PhoneNumber)}) + }) + app.All("/viewer-api/session", func(ctx fiber.Ctx) error { setNoStore(ctx); return ctx.SendStatus(http.StatusMethodNotAllowed) }) + app.Post("/viewer-api/logout", func(ctx fiber.Ctx) error { + setNoStore(ctx) + if !enforceRequestLimit(ctx, c.limiter, "logout", requestLimitIdentity(ctx, true), 10, 1000, time.Minute) { + return nil + } + id := ctx.Cookies(sessionCookieName) + if origin := ctx.Get("Origin"); origin == "" || origin != originOf(c.redirectURL) { + return ctx.SendStatus(http.StatusForbidden) + } + v, e := store.loadSession(ctx.Context(), id) + if e != nil { + return clearSessionCookie(ctx) + } + if !constantTimeEqual(ctx.Get("X-CSRF-Token"), v.CSRFToken) { + return ctx.SendStatus(http.StatusForbidden) + } + if e = store.deleteSession(ctx.Context(), id); e != nil { + return genericFailure(ctx) + } + return clearSessionCookie(ctx) + }) + app.All("/viewer-api/logout", func(ctx fiber.Ctx) error { setNoStore(ctx); return ctx.SendStatus(http.StatusMethodNotAllowed) }) + registerViewerHandlers(app, viewerHandlerConfig{store: store, oidc: client, gateway: c.gateway, limiter: c.limiter, sessionSecret: c.sessionSecret, expectedOrigin: originOf(c.redirectURL), searchEnabled: len(c.trustedProxies) > 0 && c.gateway != nil}) + return app +} + +func maskPhone(phone string) string { + if !validE164(phone) { + return "" + } + if len(phone) < 7 { + return "••••" + } + return phone[:3] + strings.Repeat("•", len(phone)-5) + phone[len(phone)-2:] +} + +func (c *oidcClient) userInfo(ctx context.Context, access string) (identityProfile, error) { + var out identityProfile + req, e := http.NewRequestWithContext(ctx, http.MethodGet, c.profileURL, nil) + if e != nil { + return out, e + } + req.Header.Set("Authorization", "Bearer "+access) + resp, e := c.httpClient.Do(req) + if e != nil { + return out, e + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return out, errors.New("userinfo request failed") + } + raw, e := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if e != nil { + return out, errors.New("invalid userinfo response") + } + return parsePassportProfile(raw, "") +} +func (c *oidcClient) oauthContext(ctx context.Context) context.Context { + return context.WithValue(ctx, oauth2.HTTPClient, c.httpClient) +} +func loadAndValidateSession(ctx fiber.Ctx, store sessionStore, client *oidcClient) (viewerSession, string, error) { + id := ctx.Cookies(sessionCookieName) + v, e := store.loadSession(ctx.Context(), id) + if e != nil { + return v, "", e + } + if time.Since(v.ProfileValidatedAt) > 60*time.Second { + unlock, e := store.lockSession(ctx.Context(), id) + if e != nil { + return v, id, e + } + defer unlock() + v, e = store.loadSession(ctx.Context(), id) + if e != nil { + return v, id, e + } + if time.Since(v.ProfileValidatedAt) <= 60*time.Second { + } else { + accessToken := v.AccessToken + var fresh *oauth2.Token + if !v.AccessTokenExpiresAt.After(time.Now()) { + if v.RefreshToken == "" { + _ = store.deleteSession(ctx.Context(), id) + return v, id, errors.New("reauthentication required") + } + expiry := v.AccessTokenExpiresAt + if expiry.IsZero() { + expiry = time.Now().Add(-time.Second) + } + fresh, e = client.oauth.TokenSource(client.oauthContext(ctx.Context()), &oauth2.Token{AccessToken: v.AccessToken, RefreshToken: v.RefreshToken, Expiry: expiry}).Token() + if e != nil { + _ = store.deleteSession(ctx.Context(), id) + return v, id, errors.New("reauthentication required") + } + accessToken = fresh.AccessToken + } + profile, e := client.userInfo(ctx.Context(), accessToken) + if e != nil || profile.Subject != v.Subject { + _ = store.deleteSession(ctx.Context(), id) + return v, id, errors.New("reauthentication required") + } + if fresh != nil { + v.AccessToken = fresh.AccessToken + v.AccessTokenExpiresAt = fresh.Expiry + if fresh.RefreshToken != "" { + v.RefreshToken = fresh.RefreshToken + } + } + v.DisplayName = profile.DisplayName + v.PhoneNumber = profile.PhoneNumber + v.ProfileValidatedAt = time.Now() + if e = store.saveSessionIfExists(ctx.Context(), id, v); e != nil { + return v, id, e + } + } + } + v = refreshIdleDeadline(v, time.Now(), sessionIdleTTL) + if e = store.saveSessionIfExists(ctx.Context(), id, v); e != nil { + return v, id, e + } + setSessionCookie(ctx, id, v.IdleExpiresAt) + return v, id, nil +} +func setSessionCookie(ctx fiber.Ctx, id string, expiry time.Time) { + ctx.Cookie(&fiber.Cookie{Name: sessionCookieName, Value: id, Path: "/viewer-api", HTTPOnly: true, Secure: true, SameSite: "Lax", Expires: expiry}) +} +func setLoginFlowCookie(ctx fiber.Ctx, value string) { + ctx.Cookie(&fiber.Cookie{Name: loginFlowCookieName, Value: value, Path: "/viewer-api", HTTPOnly: true, Secure: true, SameSite: "Lax", MaxAge: 600, Expires: time.Now().Add(10 * time.Minute)}) +} +func clearLoginFlowCookie(ctx fiber.Ctx) { + ctx.Cookie(&fiber.Cookie{Name: loginFlowCookieName, Value: "", Path: "/viewer-api", HTTPOnly: true, Secure: true, SameSite: "Lax", MaxAge: -1}) +} +func clearSessionCookie(ctx fiber.Ctx) error { + ctx.Cookie(&fiber.Cookie{Name: sessionCookieName, Value: "", Path: "/viewer-api", HTTPOnly: true, Secure: true, SameSite: "Lax", MaxAge: -1}) + return ctx.SendStatus(http.StatusNoContent) +} +func originOf(raw string) string { + u, e := url.Parse(raw) + if e != nil { + return "" + } + return strings.ToLower(u.Scheme + "://" + u.Host) +} +func genericFailure(ctx fiber.Ctx) error { + return ctx.Status(http.StatusBadGateway).SendString("authentication failed") +} +func constantTimeEqual(a, b string) bool { + if len(a) != len(b) { + return false + } + var diff byte + for i := range a { + diff |= a[i] ^ b[i] + } + return diff == 0 +} diff --git a/viewer/bff/oidc_test.go b/viewer/bff/oidc_test.go new file mode 100644 index 000000000..e4e1edeb6 --- /dev/null +++ b/viewer/bff/oidc_test.go @@ -0,0 +1,438 @@ +package main + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func TestAuthorizationUsesBoundStateNonceAndPKCE(t *testing.T) { + issuer := newTestIssuer(t, testClaims{subject: "user-1", audience: "viewer-client"}) + defer issuer.server.Close() + store := newMemorySessions() + client, err := newOIDCClient(context.Background(), testOIDCConfig(issuer.server.URL)) + if err != nil { + t.Fatal(err) + } + app := buildOIDCApp(testOIDCConfig(issuer.server.URL), store, client) + resp, err := app.Test(httptest.NewRequest("GET", "/viewer-api/login", nil)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + location, err := url.Parse(resp.Header.Get("Location")) + if err != nil { + t.Fatal(err) + } + if location.Query().Get("client_id") != "viewer-client" || location.Query().Get("redirect_uri") != "https://viewer.example.test/viewer-api/callback" { + t.Fatalf("unexpected authorization parameters: %v", location.Query()) + } + if location.Query().Get("code_challenge_method") != "S256" || location.Query().Get("scope") != "openid profile app_profile" { + t.Fatalf("missing PKCE/scopes: %v", location.Query()) + } + state, nonce := location.Query().Get("state"), location.Query().Get("nonce") + if state == "" || nonce == "" { + t.Fatal("state and nonce must be random and non-empty") + } + flowCookie := cookieNamed(resp, loginFlowCookieName) + if flowCookie == nil || !flowCookie.HttpOnly || !flowCookie.Secure || flowCookie.Path != "/viewer-api" || flowCookie.SameSite != http.SameSiteLaxMode || flowCookie.MaxAge <= 0 { + t.Fatalf("login flow cookie has unsafe attributes: %#v", flowCookie) + } + if strings.Contains(flowCookie.Value, state) || strings.Contains(flowCookie.Value, nonce) { + t.Fatal("login flow cookie exposed OIDC state or nonce") + } + if strings.Contains(resp.Header.Get("Location"), flowCookie.Value) { + t.Fatal("authorization URL exposed login flow secret") + } + if _, ok := store.state(state); !ok { + t.Fatal("authorization state was not stored") + } + pending, _ := store.state(state) + digest := sha256.Sum256([]byte(pending.Verifier)) + if want := base64.RawURLEncoding.EncodeToString(digest[:]); location.Query().Get("code_challenge") != want { + t.Fatal("PKCE verifier is not bound to the authorization request") + } +} + +func TestCallbackConsumesStateOnceAndVerifiesLogin(t *testing.T) { + issuer := newTestIssuer(t, testClaims{subject: "user-1", audience: "viewer-client"}) + defer issuer.server.Close() + store := newMemorySessions() + oldID, err := store.createSession(context.Background(), viewerSession{Subject: "old", ExpiresAt: time.Now().Add(time.Hour)}) + if err != nil { + t.Fatal(err) + } + client, err := newOIDCClient(context.Background(), testOIDCConfig(issuer.server.URL)) + if err != nil { + t.Fatal(err) + } + app := buildOIDCApp(testOIDCConfig(issuer.server.URL), store, client) + loginReq := httptest.NewRequest("GET", "/viewer-api/login", nil) + loginReq.AddCookie(&http.Cookie{Name: sessionCookieName, Value: oldID}) + login, _ := app.Test(loginReq) + defer login.Body.Close() + params, _ := url.Parse(login.Header.Get("Location")) + state := params.Query().Get("state") + issuer.expectedNonce = params.Query().Get("nonce") + issuer.expectedVerifier, _ = store.stateVerifier(state) + flowCookie := cookieNamed(login, loginFlowCookieName) + callback := func() *http.Response { + request := httptest.NewRequest("GET", "/viewer-api/callback?state="+url.QueryEscape(state)+"&code=auth-code", nil) + request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: oldID}) + request.AddCookie(flowCookie) + r, e := app.Test(request) + if e != nil { + t.Fatal(e) + } + return r + } + first := callback() + if first.StatusCode != http.StatusSeeOther { + t.Fatalf("callback status=%d issuer-stage=%s badVerifier=%t, want redirect", first.StatusCode, issuer.stage, issuer.badVerifier) + } + sessionCookie := cookieNamed(first, sessionCookieName) + if sessionCookie == nil { + t.Fatal("login callback did not set session cookie") + } + if flowCookie := cookieNamed(first, loginFlowCookieName); flowCookie == nil || flowCookie.MaxAge >= 0 { + t.Fatal("callback did not clear login flow cookie") + } + cookie := sessionCookie.String() + first.Body.Close() + for _, required := range []string{"httponly", "samesite=lax", "path=/viewer-api", "secure"} { + if !strings.Contains(strings.ToLower(cookie), required) { + t.Fatalf("session cookie missing %q: %s", required, cookie) + } + } + if strings.Contains(cookie, "access-secret") || strings.Contains(cookie, "refresh-secret") || strings.Contains(cookie, "Test User") { + t.Fatalf("cookie exposed session material: %s", cookie) + } + if _, err := store.loadSession(context.Background(), sessionCookie.Value); err != nil { + t.Fatal("opaque cookie did not identify stored session") + } + savedSession, err := store.loadSession(context.Background(), sessionCookie.Value) + if err != nil { + t.Fatal(err) + } + if !savedSession.AccessTokenExpiresAt.After(time.Now()) || !savedSession.ExpiresAt.After(savedSession.AccessTokenExpiresAt) { + t.Fatal("session did not retain access-token expiry separately from session expiry") + } + if len(store.sessions) != 1 { + t.Fatalf("sessions=%d, want one", len(store.sessions)) + } + if _, err := store.loadSession(context.Background(), oldID); err == nil { + t.Fatal("successful login did not invalidate previous session ID") + } + second := callback() + defer second.Body.Close() + if second.StatusCode != http.StatusBadRequest { + t.Fatalf("replayed state status=%d, want 400", second.StatusCode) + } +} + +func TestCallbackAllowsUserWithoutAppProfileToWaitForBinding(t *testing.T) { + issuer := newTestIssuer(t, testClaims{subject: "unbound-user", audience: "viewer-client"}) + defer issuer.server.Close() + issuer.userInfoClaims = map[string]any{"sub": "unbound-user", "name": "Waiting User"} + store := newMemorySessions() + gateway := &fakeViewerGateway{} + cfg := testOIDCConfig(issuer.server.URL) + cfg.gateway = gateway + cfg.sessionSecret = "a sufficiently long session secret for hmac" + client, err := newOIDCClient(context.Background(), cfg) + if err != nil { + t.Fatal(err) + } + app := buildOIDCApp(cfg, store, client) + + login, err := app.Test(httptest.NewRequest("GET", "/viewer-api/login", nil)) + if err != nil { + t.Fatal(err) + } + defer login.Body.Close() + params, err := url.Parse(login.Header.Get("Location")) + if err != nil { + t.Fatal(err) + } + state := params.Query().Get("state") + issuer.expectedNonce = params.Query().Get("nonce") + issuer.expectedVerifier, _ = store.stateVerifier(state) + flowCookie := cookieNamed(login, loginFlowCookieName) + callback := httptest.NewRequest("GET", "/viewer-api/callback?state="+url.QueryEscape(state)+"&code=auth-code", nil) + callback.AddCookie(flowCookie) + loggedIn, err := app.Test(callback) + if err != nil { + t.Fatal(err) + } + defer loggedIn.Body.Close() + if loggedIn.StatusCode != http.StatusSeeOther { + t.Fatalf("callback status=%d", loggedIn.StatusCode) + } + sessionCookie := cookieNamed(loggedIn, sessionCookieName) + if sessionCookie == nil { + t.Fatal("callback did not create session") + } + session, err := store.loadSession(context.Background(), sessionCookie.Value) + if err != nil { + t.Fatal(err) + } + if session.Subject != "unbound-user" || session.DisplayName != "Waiting User" || session.PhoneNumber != "" { + t.Fatalf("unexpected unbound session: subject=%q name=%q phone=%q", session.Subject, session.DisplayName, session.PhoneNumber) + } + + for _, tc := range []struct{ method, path, body string }{ + {http.MethodGet, "/viewer-api/threads", ""}, + {http.MethodGet, "/viewer-api/threads/untrusted-ref/messages", ""}, + {http.MethodPost, "/viewer-api/search", `{"query":"x","turnstile_token":"token"}`}, + } { + req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) + req.AddCookie(sessionCookie) + if tc.method == http.MethodPost { + req.Header.Set("Origin", "https://viewer.example.test") + } + resp, err := app.Test(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Errorf("%s %s status=%d, want 403", tc.method, tc.path, resp.StatusCode) + } + } + if gateway.calls != 0 { + t.Fatalf("gateway called %d times for unbound session", gateway.calls) + } +} + +func TestCallbackRejectsStateStartedInDifferentBrowser(t *testing.T) { + issuer := newTestIssuer(t, testClaims{subject: "attacker", audience: "viewer-client"}) + defer issuer.server.Close() + store := newMemorySessions() + app, err := newOIDCApp(testOIDCConfig(issuer.server.URL), store) + if err != nil { + t.Fatal(err) + } + attackerLogin, err := app.Test(httptest.NewRequest("GET", "/viewer-api/login", nil)) + if err != nil { + t.Fatal(err) + } + defer attackerLogin.Body.Close() + params, _ := url.Parse(attackerLogin.Header.Get("Location")) + state := params.Query().Get("state") + issuer.expectedNonce = params.Query().Get("nonce") + issuer.expectedVerifier, _ = store.stateVerifier(state) + victimCallback := httptest.NewRequest("GET", "/viewer-api/callback?state="+url.QueryEscape(state)+"&code=attacker-code", nil) + resp, err := app.Test(victimCallback) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusSeeOther || len(store.sessions) != 0 { + t.Fatalf("callback from a different browser was accepted: status=%d sessions=%d", resp.StatusCode, len(store.sessions)) + } + if issuer.stage == "token" { + t.Fatal("browser mismatch reached token exchange") + } +} + +func TestCallbackRejectsInvalidIDTokenAndSubMismatch(t *testing.T) { + for _, tc := range []struct { + name string + claims testClaims + userSub string + }{ + {"wrong issuer", testClaims{subject: "u", audience: "viewer-client", issuer: "https://wrong.example"}, "u"}, + {"wrong audience", testClaims{subject: "u", audience: "other-client"}, "u"}, + {"expired", testClaims{subject: "u", audience: "viewer-client", expired: true}, "u"}, + {"userinfo subject mismatch", testClaims{subject: "u", audience: "viewer-client"}, "different"}, + } { + t.Run(tc.name, func(t *testing.T) { + issuer := newTestIssuer(t, tc.claims) + defer issuer.server.Close() + issuer.userSub = tc.userSub + store := newMemorySessions() + app, err := newOIDCApp(testOIDCConfig(issuer.server.URL), store) + if err != nil { + t.Fatal(err) + } + login, _ := app.Test(httptest.NewRequest("GET", "/viewer-api/login", nil)) + defer login.Body.Close() + p, _ := url.Parse(login.Header.Get("Location")) + state := p.Query().Get("state") + issuer.expectedNonce = p.Query().Get("nonce") + issuer.expectedVerifier, _ = store.stateVerifier(state) + callbackReq := httptest.NewRequest("GET", "/viewer-api/callback?state="+url.QueryEscape(state)+"&code=x", nil) + callbackReq.AddCookie(cookieNamed(login, loginFlowCookieName)) + resp, err := app.Test(callbackReq) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusSeeOther || len(store.sessions) != 0 { + t.Fatalf("invalid identity accepted: status %d sessions %d", resp.StatusCode, len(store.sessions)) + } + }) + } +} + +func TestCallbackRejectsBadSignatureAndNonce(t *testing.T) { + for _, mutate := range []string{"signature", "nonce"} { + t.Run(mutate, func(t *testing.T) { + issuer := newTestIssuer(t, testClaims{subject: "u", audience: "viewer-client"}) + defer issuer.server.Close() + store := newMemorySessions() + app, err := newOIDCApp(testOIDCConfig(issuer.server.URL), store) + if err != nil { + t.Fatal(err) + } + login, _ := app.Test(httptest.NewRequest("GET", "/viewer-api/login", nil)) + defer login.Body.Close() + p, _ := url.Parse(login.Header.Get("Location")) + state := p.Query().Get("state") + issuer.expectedNonce = p.Query().Get("nonce") + issuer.expectedVerifier, _ = store.stateVerifier(state) + if mutate == "signature" { + issuer.signWithOtherKey = true + } else { + issuer.tokenNonce = "wrong" + } + callbackReq := httptest.NewRequest("GET", "/viewer-api/callback?state="+url.QueryEscape(state)+"&code=x", nil) + callbackReq.AddCookie(cookieNamed(login, loginFlowCookieName)) + resp, err := app.Test(callbackReq) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusSeeOther || len(store.sessions) > 0 { + t.Fatalf("bad token accepted: %d", resp.StatusCode) + } + }) + } +} + +func cookieNamed(response *http.Response, name string) *http.Cookie { + for _, cookie := range response.Cookies() { + if cookie.Name == name { + return cookie + } + } + return nil +} + +type testClaims struct { + subject, audience, issuer string + expired bool +} +type testIssuer struct { + server *httptest.Server + key, other *rsa.PrivateKey + expectedNonce, tokenNonce, expectedVerifier, userSub string + signWithOtherKey bool + stage string + badVerifier bool + userInfoClaims any +} + +func newTestIssuer(t *testing.T, claims testClaims) *testIssuer { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + other, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + i := &testIssuer{key: key, other: other, tokenNonce: "", userSub: claims.subject} + issuerURL := "" + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"issuer": issuerURL, "authorization_endpoint": issuerURL + "/authorize", "token_endpoint": issuerURL + "/token", "userinfo_endpoint": issuerURL + "/userinfo", "jwks_uri": issuerURL + "/jwks"}) + }) + mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) { + pub := &i.key.PublicKey + n := base64.RawURLEncoding.EncodeToString(pub.N.Bytes()) + e := base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}) + json.NewEncoder(w).Encode(map[string]any{"keys": []any{map[string]any{"kty": "RSA", "use": "sig", "kid": "test", "alg": "RS256", "n": n, "e": e}}}) + }) + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + i.stage = "token" + _ = r.ParseForm() + if r.Form.Get("code_verifier") != i.expectedVerifier { + i.badVerifier = true + http.Error(w, "bad verifier", 400) + return + } + now := time.Now() + iss := claims.issuer + if iss == "" { + iss = issuerURL + } + exp := now.Add(time.Hour).Unix() + if claims.expired { + exp = now.Add(-time.Minute).Unix() + } + tokNonce := i.tokenNonce + if tokNonce == "" { + tokNonce = i.expectedNonce + } + payload := map[string]any{"iss": iss, "sub": claims.subject, "aud": claims.audience, "exp": exp, "iat": now.Add(-time.Minute).Unix(), "nonce": tokNonce} + signed := i.key + if i.signWithOtherKey { + signed = i.other + } + idToken, err := signTestJWT(signed, payload) + if err != nil { + http.Error(w, "sign", 500) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"access_token": "access-secret", "id_token": idToken, "token_type": "Bearer", "expires_in": 3600}) + }) + mux.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) { + i.stage = "userinfo" + if i.userInfoClaims != nil { + _ = json.NewEncoder(w).Encode(i.userInfoClaims) + return + } + json.NewEncoder(w).Encode(map[string]any{"sub": i.userSub, "name": "Test User", "app_profile": map[string]any{"app_key": "httpsms", "schema_version": 1, "attributes": map[string]any{"phone_number": "+15550000000"}}}) + }) + i.server = httptest.NewServer(mux) + issuerURL = i.server.URL + return i +} +func signTestJWT(key *rsa.PrivateKey, claims map[string]any) (string, error) { + header, _ := json.Marshal(map[string]string{"alg": "RS256", "kid": "test", "typ": "JWT"}) + payload, _ := json.Marshal(claims) + a := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload) + h := sha256.Sum256([]byte(a)) + sig, e := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, h[:]) + if e != nil { + return "", e + } + return a + "." + base64.RawURLEncoding.EncodeToString(sig), nil +} +func testOIDCConfig(issuer string) oidcConfig { + return oidcConfig{issuer: issuer, clientID: "viewer-client", clientSecret: "client-secret", redirectURL: "https://viewer.example.test/viewer-api/callback", sessionTTL: 12 * time.Hour, limiter: &fixedRequestLimiter{allowed: true}} +} + +type memorySessions struct{ *sessionMemory } + +func newMemorySessions() *memorySessions { return &memorySessions{newSessionMemory()} } +func (s *memorySessions) state(k string) (loginState, bool) { + return s.getState(context.Background(), k) +} +func (s *memorySessions) stateVerifier(k string) (string, bool) { + x, ok := s.state(k) + return x.Verifier, ok +} diff --git a/viewer/bff/passport_profile.go b/viewer/bff/passport_profile.go new file mode 100644 index 000000000..60aeed4de --- /dev/null +++ b/viewer/bff/passport_profile.go @@ -0,0 +1,128 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "regexp" +) + +var ErrNoPhoneBinding = errors.New("passport profile has no phone binding") + +var canonicalE164 = regexp.MustCompile(`^\+[1-9][0-9]{7,14}$`) + +type passportProfile struct { + Subject string + DisplayName string + PhoneNumber string +} + +// parsePassportProfile only trusts the application-scoped Passport claim. An +// empty expectedSubject is useful while fetching UserInfo; callers must then +// compare the returned subject to the verified ID token subject. +func parsePassportProfile(raw []byte, expectedSubject string) (passportProfile, error) { + var out passportProfile + if err := rejectDuplicateJSONKeys(raw); err != nil { + return out, errors.New("invalid userinfo response") + } + var claims struct { + Subject string `json:"sub"` + Name string `json:"name"` + Profile json.RawMessage `json:"app_profile"` + } + if err := json.Unmarshal(raw, &claims); err != nil || claims.Subject == "" || (expectedSubject != "" && claims.Subject != expectedSubject) { + return out, errors.New("invalid userinfo response") + } + out.Subject, out.DisplayName = claims.Subject, claims.Name + if len(claims.Profile) == 0 || bytes.Equal(bytes.TrimSpace(claims.Profile), []byte("null")) { + return out, nil + } + var profile struct { + AppKey string `json:"app_key"` + Attributes struct { + PhoneNumber *string `json:"phone_number"` + } `json:"attributes"` + } + var profileShape map[string]json.RawMessage + if err := json.Unmarshal(claims.Profile, &profileShape); err != nil || profileShape == nil { + return out, errors.New("invalid userinfo response") + } + attributes, ok := profileShape["attributes"] + if !ok || string(attributes) == "null" || json.Unmarshal(attributes, &profile.Attributes) != nil { + return out, errors.New("invalid userinfo response") + } + if err := json.Unmarshal(claims.Profile, &profile); err != nil || profile.AppKey != "httpsms" { + return out, errors.New("invalid userinfo response") + } + if profile.Attributes.PhoneNumber != nil { + if !canonicalE164.MatchString(*profile.Attributes.PhoneNumber) { + return out, errors.New("invalid userinfo response") + } + out.PhoneNumber = *profile.Attributes.PhoneNumber + } + return out, nil +} + +func requirePhoneBinding(session viewerSession) error { + if !canonicalE164.MatchString(session.PhoneNumber) { + return ErrNoPhoneBinding + } + return nil +} + +// rejectDuplicateJSONKeys walks every object, since encoding/json otherwise +// silently accepts duplicate claims and keeps the last value. +func rejectDuplicateJSONKeys(raw []byte) error { + d := json.NewDecoder(bytes.NewReader(raw)) + d.UseNumber() + if err := walkJSONValue(d); err != nil { + return err + } + if _, err := d.Token(); err != io.EOF { + return fmt.Errorf("trailing JSON") + } + return nil +} + +func walkJSONValue(d *json.Decoder) error { + tok, err := d.Token() + if err != nil { + return err + } + delim, ok := tok.(json.Delim) + if !ok { + return nil + } + switch delim { + case '{': + seen := map[string]bool{} + for d.More() { + keyToken, err := d.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok || seen[key] { + return errors.New("duplicate JSON key") + } + seen[key] = true + if err := walkJSONValue(d); err != nil { + return err + } + } + _, err = d.Token() + return err + case '[': + for d.More() { + if err := walkJSONValue(d); err != nil { + return err + } + } + _, err = d.Token() + return err + default: + return errors.New("invalid JSON delimiter") + } +} diff --git a/viewer/bff/passport_profile_test.go b/viewer/bff/passport_profile_test.go new file mode 100644 index 000000000..128e2f3a4 --- /dev/null +++ b/viewer/bff/passport_profile_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "errors" + "testing" +) + +func TestParsePassportProfile(t *testing.T) { + tests := []struct { + name, raw string + wantPhone string + wantErr bool + }{ + {"valid nested profile", `{"sub":"user-1","name":"Test","app_profile":{"app_key":"httpsms","schema_version":1,"attributes":{"phone_number":"+15550000000"}}}`, "+15550000000", false}, + {"no phone binding", `{"sub":"user-1","name":"Test","app_profile":{"app_key":"httpsms","schema_version":1,"attributes":{}}}`, "", false}, + {"app mismatch", `{"sub":"user-1","app_profile":{"app_key":"other","attributes":{"phone_number":"+15550000000"}}}`, "", true}, + {"missing app profile awaiting binding", `{"sub":"user-1","name":"Test"}`, "", false}, + {"null app profile awaiting binding", `{"sub":"user-1","name":"Test","app_profile":null}`, "", false}, + {"malformed profile attributes", `{"sub":"user-1","app_profile":{"app_key":"httpsms","attributes":[]}}`, "", true}, + {"non canonical phone", `{"sub":"user-1","app_profile":{"app_key":"httpsms","attributes":{"phone_number":"15550000000"}}}`, "", true}, + {"duplicate app profile", `{"sub":"user-1","app_profile":{"app_key":"httpsms","attributes":{}},"app_profile":{"app_key":"httpsms","attributes":{}}}`, "", true}, + {"empty subject", `{"sub":"","app_profile":{"app_key":"httpsms","attributes":{}}}`, "", true}, + {"top-level phone ignored", `{"sub":"user-1","phone_number":"+15550000000","app_profile":{"app_key":"httpsms","attributes":{}}}`, "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parsePassportProfile([]byte(tt.raw), "user-1") + if (err != nil) != tt.wantErr { + t.Fatalf("err=%v, wantErr=%v", err, tt.wantErr) + } + if err == nil && got.PhoneNumber != tt.wantPhone { + t.Fatalf("phone=%q, want %q", got.PhoneNumber, tt.wantPhone) + } + }) + } +} + +func TestRequirePhoneBinding(t *testing.T) { + if err := requirePhoneBinding(viewerSession{PhoneNumber: "+15550000000"}); err != nil { + t.Fatalf("bound session rejected: %v", err) + } + if err := requirePhoneBinding(viewerSession{}); !errors.Is(err, ErrNoPhoneBinding) { + t.Fatalf("err=%v, want ErrNoPhoneBinding", err) + } +} diff --git a/viewer/bff/rate_limit.go b/viewer/bff/rate_limit.go new file mode 100644 index 000000000..db616d561 --- /dev/null +++ b/viewer/bff/rate_limit.go @@ -0,0 +1,86 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "strconv" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/redis/go-redis/v9" +) + +type requestLimiter interface { + Allow(context.Context, string, int, time.Duration) (bool, error) +} + +type redisRateLimiter struct{ client *redis.Client } + +func newRedisRateLimiter(client *redis.Client) *redisRateLimiter { + return &redisRateLimiter{client: client} +} + +func (l *redisRateLimiter) Allow(ctx context.Context, key string, max int, window time.Duration) (bool, error) { + if l == nil || l.client == nil || key == "" || max < 1 || window <= 0 { + return false, errors.New("rate limiter unavailable") + } + windowMS := window.Milliseconds() + if windowMS < 1 { + windowMS = 1 + } + count, err := l.client.Eval(ctx, `local n = redis.call('INCR', KEYS[1]); if n == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]); end; return n`, []string{key}, windowMS).Int64() + if err != nil { + return false, err + } + return count <= int64(max), nil +} + +func enforceRequestLimit(c fiber.Ctx, limiter requestLimiter, route, identity string, max, globalMax int, window time.Duration) bool { + setNoStore(c) + if limiter == nil { + c.Status(fiber.StatusServiceUnavailable).SendString("temporarily unavailable") + return false + } + // The independent route-wide ceiling bounds both request volume and the + // number of per-identity keys created in each window. + if globalMax < max { + globalMax = max + } + if !allowRateRequest(c, limiter, route, "global", globalMax, window) { + return false + } + return allowRateRequest(c, limiter, route, identity, max, window) +} + +func allowRateRequest(c fiber.Ctx, limiter requestLimiter, route, principal string, max int, window time.Duration) bool { + digest := sha256.Sum256([]byte(principal)) + key := "sms-viewer:rate:" + route + ":" + hex.EncodeToString(digest[:16]) + allowed, err := limiter.Allow(c.Context(), key, max, window) + if err != nil { + c.Status(fiber.StatusServiceUnavailable).SendString("temporarily unavailable") + return false + } + if !allowed { + seconds := int(window.Seconds()) + if seconds < 1 { + seconds = 1 + } + c.Set("Retry-After", strconv.Itoa(seconds)) + c.Status(fiber.StatusTooManyRequests).SendString("too many requests") + return false + } + return true +} + +func requestLimitIdentity(c fiber.Ctx, sessionScoped bool) string { + if sessionScoped { + if id := c.Cookies(sessionCookieName); id != "" { + return "session:" + id + } + } + // Fiber only resolves forwarded client IPs when the immediate proxy is in + // TRUSTED_PROXIES. Do not read X-Forwarded-For or X-Real-IP directly here. + return "ip:" + c.IP() +} diff --git a/viewer/bff/rate_limit_test.go b/viewer/bff/rate_limit_test.go new file mode 100644 index 000000000..d423613c7 --- /dev/null +++ b/viewer/bff/rate_limit_test.go @@ -0,0 +1,86 @@ +package main + +import ( + "context" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/gofiber/fiber/v3" + "github.com/redis/go-redis/v9" +) + +func TestRedisRateLimitIsSharedAcrossInstancesAndExpires(t *testing.T) { + server := miniredis.RunT(t) + clientA := redis.NewClient(&redis.Options{Addr: server.Addr()}) + defer clientA.Close() + clientB := redis.NewClient(&redis.Options{Addr: server.Addr()}) + defer clientB.Close() + limiterA := newRedisRateLimiter(clientA) + limiterB := newRedisRateLimiter(clientB) + window := time.Minute + for i, limiter := range []requestLimiter{limiterA, limiterB} { + allowed, err := limiter.Allow(context.Background(), "test:shared", 2, window) + if err != nil || !allowed { + t.Fatalf("request %d allowed=%t err=%v, want allowed", i+1, allowed, err) + } + } + if allowed, err := limiterA.Allow(context.Background(), "test:shared", 2, window); err != nil || allowed { + t.Fatalf("limit boundary allowed=%t err=%v, want denied", allowed, err) + } + server.FastForward(window) + if allowed, err := limiterB.Allow(context.Background(), "test:shared", 2, window); err != nil || !allowed { + t.Fatalf("expired window allowed=%t err=%v, want allowed", allowed, err) + } +} + +func TestRouteCeilingBoundsExpiringIdentityKeyCount(t *testing.T) { + server := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: server.Addr()}) + defer client.Close() + limiter := newRedisRateLimiter(client) + app := fiber.New() + app.Get("/limited", func(c fiber.Ctx) error { + if !enforceRequestLimit(c, limiter, "test", c.Query("principal"), 1, 1000, time.Minute) { + return nil + } + return c.SendStatus(fiber.StatusNoContent) + }) + for i := 0; i <= 1000; i++ { + req := httptest.NewRequest("GET", "/limited?principal="+strconv.Itoa(i), nil) + resp, err := app.Test(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if i < 1000 && resp.StatusCode != fiber.StatusNoContent { + t.Fatalf("request %d status=%d, want 204", i, resp.StatusCode) + } + if i == 1000 && resp.StatusCode != fiber.StatusTooManyRequests { + t.Fatalf("request after route ceiling status=%d, want 429", resp.StatusCode) + } + } + if keys := server.Keys(); len(keys) != 1001 { + t.Fatalf("created %d Redis keys, want one route key plus 1000 identity keys", len(keys)) + } +} + +func TestMissingRateLimiterFailsClosed(t *testing.T) { + app := fiber.New() + app.Get("/limited", func(c fiber.Ctx) error { + if !enforceRequestLimit(c, nil, "test", "client", 1, 10, time.Minute) { + return nil + } + return c.SendStatus(fiber.StatusNoContent) + }) + resp, err := app.Test(httptest.NewRequest("GET", "/limited", nil)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != fiber.StatusServiceUnavailable { + t.Fatalf("nil limiter status=%d, want 503", resp.StatusCode) + } +} diff --git a/viewer/bff/session.go b/viewer/bff/session.go new file mode 100644 index 000000000..cc279f124 --- /dev/null +++ b/viewer/bff/session.go @@ -0,0 +1,314 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "sync" + "time" + + "github.com/redis/go-redis/v9" +) + +const sessionCookieName = "viewer_session" +const sessionIdleTTL = 30 * time.Minute + +type viewerSession struct { + Subject string `json:"sub"` + DisplayName string `json:"display_name,omitempty"` + PhoneNumber string `json:"phone_number,omitempty"` + AccessToken string `json:"access_token"` + AccessTokenExpiresAt time.Time `json:"access_token_expires_at"` + RefreshToken string `json:"refresh_token,omitempty"` + IDToken string `json:"id_token,omitempty"` + ProfileValidatedAt time.Time `json:"profile_validated_at"` + IdleExpiresAt time.Time `json:"idle_expires_at"` + ExpiresAt time.Time `json:"expires_at"` + CSRFToken string `json:"csrf_token"` +} +type loginState struct { + Nonce string `json:"nonce"` + Verifier string `json:"verifier"` + BrowserBinding string `json:"browser_binding"` +} +type sessionStore interface { + putState(context.Context, string, loginState, time.Duration) error + consumeState(context.Context, string) (loginState, error) + createSession(context.Context, viewerSession) (string, error) + loadSession(context.Context, string) (viewerSession, error) + deleteSession(context.Context, string) error + saveSession(context.Context, string, viewerSession) error + saveSessionIfExists(context.Context, string, viewerSession) error + lockSession(context.Context, string) (func(), error) +} + +type redisSessionStore struct { + client *redis.Client + prefix string +} + +func newRedisSessionStore(c *redis.Client) *redisSessionStore { + return &redisSessionStore{client: c, prefix: "sms-viewer:"} +} +func (s *redisSessionStore) stateKey(k string) string { return s.prefix + "state:" + k } +func (s *redisSessionStore) sessionKey(k string) string { return s.prefix + "session:" + k } +func (s *redisSessionStore) putState(ctx context.Context, k string, v loginState, ttl time.Duration) error { + b, e := json.Marshal(v) + if e != nil { + return e + } + return s.client.Set(ctx, s.stateKey(k), b, ttl).Err() +} +func (s *redisSessionStore) consumeState(ctx context.Context, k string) (loginState, error) { + var out loginState + raw, e := s.client.GetDel(ctx, s.stateKey(k)).Bytes() + if e != nil { + return out, e + } + e = json.Unmarshal(raw, &out) + return out, e +} +func (s *redisSessionStore) createSession(ctx context.Context, v viewerSession) (string, error) { + id, e := randomValue(32) + if e != nil { + return "", e + } + v = refreshIdleDeadline(v, time.Now(), sessionIdleTTL) + if e = s.saveSession(ctx, id, v); e != nil { + return "", e + } + return id, nil +} +func (s *redisSessionStore) saveSession(ctx context.Context, id string, v viewerSession) error { + v = normalizeSessionDeadlines(v, time.Now()) + b, e := json.Marshal(v) + if e != nil { + return e + } + ttl := sessionTTL(v, time.Now()) + if ttl <= 0 { + return errors.New("session expired") + } + return s.client.Set(ctx, s.sessionKey(id), b, ttl).Err() +} +func (s *redisSessionStore) saveSessionIfExists(ctx context.Context, id string, v viewerSession) error { + v = normalizeSessionDeadlines(v, time.Now()) + b, e := json.Marshal(v) + if e != nil { + return e + } + ttl := sessionTTL(v, time.Now()) + if ttl <= 0 { + return errors.New("session expired") + } + ttlMillis := ttl.Milliseconds() + if ttlMillis < 1 { + ttlMillis = 1 + } + result, e := s.client.Eval(ctx, `local raw = redis.call('GET', KEYS[1]); if not raw then return 0 end; local current = cjson.decode(raw); local incoming = cjson.decode(ARGV[1]); if current.idle_expires_at and incoming.idle_expires_at and current.idle_expires_at > incoming.idle_expires_at then incoming.idle_expires_at = current.idle_expires_at end; local pttl = redis.call('PTTL', KEYS[1]); local ttl = tonumber(ARGV[2]); if pttl > ttl then ttl = pttl end; redis.call('SET', KEYS[1], cjson.encode(incoming), 'PX', ttl); return 1`, []string{s.sessionKey(id)}, b, ttlMillis).Int64() + if e != nil { + return e + } + if result != 1 { + return errors.New("session no longer exists") + } + return nil +} +func (s *redisSessionStore) lockSession(ctx context.Context, id string) (func(), error) { + key := s.prefix + "lock:" + id + token, e := randomValue(24) + if e != nil { + return nil, e + } + for { + acquired, e := s.client.SetNX(ctx, key, token, 30*time.Second).Result() + if e != nil { + return nil, e + } + if acquired { + unlock := func() { + unlockCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = s.client.Eval(unlockCtx, `if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end`, []string{key}, token).Err() + } + return unlock, nil + } + timer := time.NewTimer(20 * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return nil, ctx.Err() + case <-timer.C: + } + } +} +func (s *redisSessionStore) loadSession(ctx context.Context, id string) (viewerSession, error) { + var v viewerSession + if id == "" { + return v, redis.Nil + } + raw, e := s.client.Get(ctx, s.sessionKey(id)).Bytes() + if e != nil { + return v, e + } + e = json.Unmarshal(raw, &v) + if e == nil && !sessionActive(v, time.Now()) { + e = redis.Nil + } + return v, e +} +func (s *redisSessionStore) deleteSession(ctx context.Context, id string) error { + if id == "" { + return nil + } + return s.client.Del(ctx, s.sessionKey(id)).Err() +} + +type sessionMemory struct { + mu sync.Mutex + lockMu sync.Mutex + locks map[string]chan struct{} + states map[string]loginState + sessions map[string]viewerSession +} + +func newSessionMemory() *sessionMemory { + return &sessionMemory{states: map[string]loginState{}, sessions: map[string]viewerSession{}, locks: map[string]chan struct{}{}} +} +func (s *sessionMemory) putState(_ context.Context, k string, v loginState, _ time.Duration) error { + s.mu.Lock() + defer s.mu.Unlock() + s.states[k] = v + return nil +} +func (s *sessionMemory) consumeState(_ context.Context, k string) (loginState, error) { + s.mu.Lock() + defer s.mu.Unlock() + v, ok := s.states[k] + delete(s.states, k) + if !ok { + return v, errors.New("state missing") + } + return v, nil +} +func (s *sessionMemory) createSession(_ context.Context, v viewerSession) (string, error) { + id, e := randomValue(32) + if e != nil { + return "", e + } + s.mu.Lock() + defer s.mu.Unlock() + s.sessions[id] = refreshIdleDeadline(v, time.Now(), sessionIdleTTL) + return id, nil +} +func (s *sessionMemory) loadSession(_ context.Context, id string) (viewerSession, error) { + s.mu.Lock() + defer s.mu.Unlock() + v, ok := s.sessions[id] + if !ok || !sessionActive(v, time.Now()) { + delete(s.sessions, id) + return v, errors.New("session missing") + } + return v, nil +} +func (s *sessionMemory) deleteSession(_ context.Context, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.sessions, id) + return nil +} +func (s *sessionMemory) saveSession(_ context.Context, id string, v viewerSession) error { + s.mu.Lock() + defer s.mu.Unlock() + s.sessions[id] = normalizeSessionDeadlines(v, time.Now()) + return nil +} +func (s *sessionMemory) saveSessionIfExists(_ context.Context, id string, v viewerSession) error { + s.mu.Lock() + defer s.mu.Unlock() + if existing, ok := s.sessions[id]; !ok || !sessionActive(existing, time.Now()) { + delete(s.sessions, id) + return errors.New("session no longer exists") + } + if existing := s.sessions[id]; existing.IdleExpiresAt.After(v.IdleExpiresAt) { + v.IdleExpiresAt = existing.IdleExpiresAt + } + s.sessions[id] = normalizeSessionDeadlines(v, time.Now()) + return nil +} +func (s *sessionMemory) lockSession(ctx context.Context, id string) (func(), error) { + s.lockMu.Lock() + lock := s.locks[id] + if lock == nil { + lock = make(chan struct{}, 1) + s.locks[id] = lock + } + s.lockMu.Unlock() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case lock <- struct{}{}: + return func() { <-lock }, nil + } +} +func (s *sessionMemory) getState(_ context.Context, k string) (loginState, bool) { + s.mu.Lock() + defer s.mu.Unlock() + v, ok := s.states[k] + return v, ok +} + +func refreshIdleDeadline(v viewerSession, now time.Time, idleTTL time.Duration) viewerSession { + if idleTTL <= 0 || v.ExpiresAt.IsZero() { + return v + } + if v.IdleExpiresAt.After(v.ExpiresAt) { + v.IdleExpiresAt = v.ExpiresAt + } + deadline := now.Add(idleTTL) + if deadline.After(v.ExpiresAt) { + deadline = v.ExpiresAt + } + if v.IdleExpiresAt.Before(deadline) { + v.IdleExpiresAt = deadline + } + return v +} + +func normalizeSessionDeadlines(v viewerSession, now time.Time) viewerSession { + v.ExpiresAt = v.ExpiresAt.UTC() + v.IdleExpiresAt = v.IdleExpiresAt.UTC() + if v.IdleExpiresAt.IsZero() { + v = refreshIdleDeadline(v, now, sessionIdleTTL) + } + if v.IdleExpiresAt.After(v.ExpiresAt) { + v.IdleExpiresAt = v.ExpiresAt + } + return v +} + +func sessionTTL(v viewerSession, now time.Time) time.Duration { + deadline := v.IdleExpiresAt + if deadline.IsZero() || deadline.After(v.ExpiresAt) { + deadline = v.ExpiresAt + } + return deadline.Sub(now) +} + +func sessionActive(v viewerSession, now time.Time) bool { + return v.ExpiresAt.After(now) && v.IdleExpiresAt.After(now) +} +func randomValue(bytes int) (string, error) { + b := make([]byte, bytes) + if _, e := rand.Read(b); e != nil { + return "", e + } + return base64.RawURLEncoding.EncodeToString(b), nil +} +func browserBinding(value string) string { + digest := sha256.Sum256([]byte(value)) + return base64.RawURLEncoding.EncodeToString(digest[:]) +} diff --git a/viewer/bff/session_test.go b/viewer/bff/session_test.go new file mode 100644 index 000000000..451fa6fd8 --- /dev/null +++ b/viewer/bff/session_test.go @@ -0,0 +1,457 @@ +package main + +import ( + "context" + "encoding/json" + "github.com/alicebob/miniredis/v2" + "github.com/gofiber/fiber/v3" + "github.com/redis/go-redis/v9" + "golang.org/x/oauth2" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +func newSessionTestApp(store sessionStore) *fiber.App { + c := testOIDCConfig("https://passport.example.test") + return buildOIDCApp(c, store, &oidcClient{}) +} + +func TestLocalhostHTTPCallbackStillSetsSecureSessionCookie(t *testing.T) { + cfg := testOIDCConfig("https://passport.example.test") + cfg.redirectURL = "http://localhost:8080/viewer-api/callback" + if err := validateCallbackURL(cfg.redirectURL, "development"); err != nil { + t.Fatal(err) + } + app := fiber.New() + app.Get("/cookie", func(ctx fiber.Ctx) error { + setSessionCookie(ctx, "opaque-session-id", time.Now().Add(time.Hour)) + return ctx.SendStatus(200) + }) + resp, err := app.Test(httptest.NewRequest("GET", "/cookie", nil)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if cookie := strings.ToLower(resp.Header.Get("Set-Cookie")); !strings.Contains(cookie, "secure") { + t.Fatalf("HTTP localhost callback omitted Secure: %s", cookie) + } +} + +func TestLogoutRequiresPOSTAndCSRFOrigin(t *testing.T) { + store := newMemorySessions() + _, err := store.createSession(context.Background(), viewerSession{Subject: "u", CSRFToken: "csrf", ExpiresAt: time.Now().Add(time.Hour)}) + if err != nil { + t.Fatal(err) + } + app := newSessionTestApp(store) + get, _ := app.Test(httptest.NewRequest("GET", "/viewer-api/logout", nil)) + get.Body.Close() + if get.StatusCode != 405 { + t.Fatalf("GET logout status=%d", get.StatusCode) + } + bad := httptest.NewRequest("POST", "/viewer-api/logout", strings.NewReader("")) + bad.Header.Set("Origin", "https://evil.example") + bad.Header.Set("X-CSRF-Token", "csrf") + resp, err := app.Test(bad) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 403 { + t.Fatalf("bad origin status=%d", resp.StatusCode) + } +} + +func TestLogoutWithOriginAndCSRFDeletesSessionAndCookie(t *testing.T) { + store := newMemorySessions() + id, err := store.createSession(context.Background(), viewerSession{Subject: "u", CSRFToken: "csrf", ExpiresAt: time.Now().Add(time.Hour)}) + if err != nil { + t.Fatal(err) + } + app := newSessionTestApp(store) + req := httptest.NewRequest("POST", "/viewer-api/logout", nil) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: id}) + req.Header.Set("Origin", "https://viewer.example.test") + req.Header.Set("X-CSRF-Token", "csrf") + resp, err := app.Test(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 204 { + t.Fatalf("logout status=%d, want 204", resp.StatusCode) + } + if _, err := store.loadSession(context.Background(), id); err == nil { + t.Fatal("logout left server-side session") + } + if cookie := resp.Header.Get("Set-Cookie"); !strings.Contains(strings.ToLower(cookie), "max-age=0") && !strings.Contains(strings.ToLower(cookie), "expires=") { + t.Fatalf("logout did not expire cookie: %s", cookie) + } + if !strings.Contains(strings.ToLower(resp.Header.Get("Set-Cookie")), "secure") { + t.Fatal("logout cookie omitted Secure") + } +} + +func TestAuthenticatedSessionResponseOmitsSessionIDAndTokens(t *testing.T) { + store := newMemorySessions() + id, err := store.createSession(context.Background(), viewerSession{Subject: "u", DisplayName: "User", PhoneNumber: "+15550000000", AccessToken: "access-secret", RefreshToken: "refresh-secret", CSRFToken: "csrf", ProfileValidatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)}) + if err != nil { + t.Fatal(err) + } + app := newSessionTestApp(store) + req := httptest.NewRequest("GET", "/viewer-api/session", nil) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: id}) + resp, err := app.Test(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("session status=%d", resp.StatusCode) + } + var payload map[string]any + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if _, ok := payload["session_id"]; ok { + t.Fatal("session response exposed opaque session ID") + } + if len(payload) != 3 || payload["csrf_token"] != "csrf" || payload["display_name"] != "User" || payload["phone_number_masked"] != "+15•••••••00" { + t.Fatalf("unexpected public session DTO: %#v", payload) + } + if _, ok := payload["sub"]; ok { + t.Fatal("session response exposed Passport subject") + } + body, _ := json.Marshal(payload) + for _, secret := range []string{"access-secret", "refresh-secret", id} { + if strings.Contains(string(body), secret) { + t.Fatalf("session response exposed %q", secret) + } + } +} + +func TestStaleProfileWithoutRefreshTokenDestroysSession(t *testing.T) { + store := newMemorySessions() + id, err := store.createSession(context.Background(), viewerSession{Subject: "u", AccessToken: "access", ProfileValidatedAt: time.Now().Add(-61 * time.Second), ExpiresAt: time.Now().Add(time.Hour)}) + if err != nil { + t.Fatal(err) + } + app := newSessionTestApp(store) + req := httptest.NewRequest("GET", "/viewer-api/session", nil) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: id}) + resp, err := app.Test(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 401 { + t.Fatalf("stale profile status=%d, want 401", resp.StatusCode) + } + if _, err := store.loadSession(context.Background(), id); err == nil { + t.Fatal("session without refresh token was retained") + } +} + +func TestStaleProfileRefreshesTokenAndRevalidatesUserInfo(t *testing.T) { + var refreshed bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/token": + _ = r.ParseForm() + if r.Form.Get("grant_type") != "refresh_token" { + t.Error("refresh grant was not used") + } + refreshed = true + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"fresh-access","refresh_token":"fresh-refresh","token_type":"Bearer","expires_in":3600}`)) + case "/userinfo": + if r.Header.Get("Authorization") != "Bearer fresh-access" { + t.Error("userinfo did not use refreshed access token") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"sub":"u","name":"Updated","app_profile":{"app_key":"httpsms","schema_version":1,"attributes":{"phone_number":"+15551111111"}}}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + store := newMemorySessions() + id, err := store.createSession(context.Background(), viewerSession{Subject: "u", AccessToken: "expired-access", RefreshToken: "old-refresh", ProfileValidatedAt: time.Now().Add(-61 * time.Second), ExpiresAt: time.Now().Add(time.Hour)}) + if err != nil { + t.Fatal(err) + } + client := &oidcClient{oauth: oauth2.Config{ClientID: "client", ClientSecret: "secret", Endpoint: oauth2.Endpoint{TokenURL: server.URL + "/token"}}, profileURL: server.URL + "/userinfo", httpClient: server.Client()} + app := buildOIDCApp(testOIDCConfig("https://passport.example.test"), store, client) + req := httptest.NewRequest("GET", "/viewer-api/session", nil) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: id}) + resp, err := app.Test(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("session validation status=%d", resp.StatusCode) + } + if !refreshed { + t.Fatal("stale profile did not refresh token") + } + saved, err := store.loadSession(context.Background(), id) + if err != nil { + t.Fatal(err) + } + if saved.AccessToken != "fresh-access" || saved.RefreshToken != "fresh-refresh" || saved.DisplayName != "Updated" || saved.PhoneNumber != "+15551111111" || time.Since(saved.ProfileValidatedAt) > time.Minute { + t.Fatal("refreshed profile or token state was not persisted") + } +} + +func TestStaleProfileUsesUnexpiredAccessTokenWithoutRefreshing(t *testing.T) { + var refreshCalls int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/token" { + refreshCalls++ + http.Error(w, "unexpected refresh", http.StatusBadRequest) + return + } + if r.Header.Get("Authorization") != "Bearer live-access" { + t.Error("userinfo did not use current access token") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"sub":"u","name":"Current","app_profile":{"app_key":"httpsms","schema_version":1,"attributes":{"phone_number":"+15550000000"}}}`)) + })) + defer server.Close() + store := newMemorySessions() + id, err := store.createSession(context.Background(), viewerSession{Subject: "u", AccessToken: "live-access", AccessTokenExpiresAt: time.Now().Add(time.Hour), RefreshToken: "refresh", ProfileValidatedAt: time.Now().Add(-61 * time.Second), ExpiresAt: time.Now().Add(time.Hour)}) + if err != nil { + t.Fatal(err) + } + client := &oidcClient{oauth: oauth2.Config{ClientID: "client", ClientSecret: "secret", Endpoint: oauth2.Endpoint{TokenURL: server.URL + "/token"}}, profileURL: server.URL + "/userinfo", httpClient: server.Client()} + app := buildOIDCApp(testOIDCConfig("https://passport.example.test"), store, client) + req := httptest.NewRequest("GET", "/viewer-api/session", nil) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: id}) + resp, err := app.Test(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("session validation status=%d, want 200", resp.StatusCode) + } + if refreshCalls != 0 { + t.Fatalf("refresh calls=%d, want none", refreshCalls) + } +} + +func TestConcurrentStaleRequestsRefreshOnlyOnce(t *testing.T) { + var refreshCalls atomic.Int32 + started := make(chan struct{}, 2) + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/token": + refreshCalls.Add(1) + started <- struct{}{} + <-release + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"fresh-access","refresh_token":"fresh-refresh","token_type":"Bearer","expires_in":3600}`)) + case "/userinfo": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"sub":"u","name":"User","app_profile":{"app_key":"httpsms","attributes":{}}}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + store := newMemorySessions() + id, err := store.createSession(context.Background(), viewerSession{Subject: "u", AccessToken: "expired", RefreshToken: "refresh", AccessTokenExpiresAt: time.Now().Add(-time.Second), ProfileValidatedAt: time.Now().Add(-61 * time.Second), ExpiresAt: time.Now().Add(time.Hour)}) + if err != nil { + t.Fatal(err) + } + client := &oidcClient{oauth: oauth2.Config{ClientID: "client", ClientSecret: "secret", Endpoint: oauth2.Endpoint{TokenURL: server.URL + "/token"}}, profileURL: server.URL + "/userinfo", httpClient: server.Client()} + app := buildOIDCApp(testOIDCConfig("https://passport.example.test"), store, client) + results := make(chan int, 2) + for i := 0; i < 2; i++ { + go func() { + req := httptest.NewRequest("GET", "/viewer-api/session", nil) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: id}) + resp, e := app.Test(req) + if e != nil { + results <- 0 + return + } + resp.Body.Close() + results <- resp.StatusCode + }() + } + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("no refresh started") + } + time.Sleep(75 * time.Millisecond) + close(release) + for i := 0; i < 2; i++ { + if status := <-results; status != 200 { + t.Fatalf("concurrent session status=%d", status) + } + } + if calls := refreshCalls.Load(); calls != 1 { + t.Fatalf("refresh calls=%d, want one", calls) + } +} + +func TestLogoutDuringRefreshDoesNotResurrectSession(t *testing.T) { + started := make(chan struct{}, 1) + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/token": + started <- struct{}{} + <-release + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"fresh-access","refresh_token":"fresh-refresh","token_type":"Bearer","expires_in":3600}`)) + case "/userinfo": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"sub":"u","name":"User","app_profile":{"app_key":"httpsms","attributes":{}}}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + store := newMemorySessions() + id, err := store.createSession(context.Background(), viewerSession{Subject: "u", AccessToken: "expired", RefreshToken: "refresh", AccessTokenExpiresAt: time.Now().Add(-time.Second), ProfileValidatedAt: time.Now().Add(-61 * time.Second), ExpiresAt: time.Now().Add(time.Hour), CSRFToken: "csrf"}) + if err != nil { + t.Fatal(err) + } + client := &oidcClient{oauth: oauth2.Config{ClientID: "client", ClientSecret: "secret", Endpoint: oauth2.Endpoint{TokenURL: server.URL + "/token"}}, profileURL: server.URL + "/userinfo", httpClient: server.Client()} + app := buildOIDCApp(testOIDCConfig("https://passport.example.test"), store, client) + validated := make(chan int, 1) + go func() { + req := httptest.NewRequest("GET", "/viewer-api/session", nil) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: id}) + resp, e := app.Test(req) + if e != nil { + validated <- 0 + return + } + resp.Body.Close() + validated <- resp.StatusCode + }() + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("refresh did not start") + } + logoutReq := httptest.NewRequest("POST", "/viewer-api/logout", nil) + logoutReq.AddCookie(&http.Cookie{Name: sessionCookieName, Value: id}) + logoutReq.Header.Set("Origin", "https://viewer.example.test") + logoutReq.Header.Set("X-CSRF-Token", "csrf") + logoutResp, err := app.Test(logoutReq) + if err != nil { + t.Fatal(err) + } + logoutResp.Body.Close() + if logoutResp.StatusCode != 204 { + t.Fatalf("logout status=%d", logoutResp.StatusCode) + } + close(release) + if status := <-validated; status != 401 { + t.Fatalf("in-flight validation status=%d, want 401", status) + } + if _, err := store.loadSession(context.Background(), id); err == nil { + t.Fatal("refresh resurrected logged-out session") + } +} + +func TestSessionStoreExpiresAndDeletesServerSideData(t *testing.T) { + store := newMemorySessions() + id, err := store.createSession(context.Background(), viewerSession{Subject: "u", AccessToken: "secret", ExpiresAt: time.Now().Add(-time.Second)}) + if err != nil { + t.Fatal(err) + } + if _, err := store.loadSession(context.Background(), id); err == nil { + t.Fatal("expired session was returned") + } +} + +func TestIdleDeadlineSlidesButNeverBeyondAbsoluteDeadline(t *testing.T) { + now := time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC) + abs := now.Add(12 * time.Hour) + s := viewerSession{ExpiresAt: abs, IdleExpiresAt: now.Add(5 * time.Minute)} + got := refreshIdleDeadline(s, now.Add(4*time.Minute), 30*time.Minute) + if !got.IdleExpiresAt.Equal(now.Add(34 * time.Minute)) { + t.Fatalf("idle deadline=%s, want sliding deadline %s", got.IdleExpiresAt, now.Add(34*time.Minute)) + } + got = refreshIdleDeadline(got, abs.Add(-time.Minute), 30*time.Minute) + if !got.IdleExpiresAt.Equal(abs) { + t.Fatalf("idle deadline=%s, want absolute deadline %s", got.IdleExpiresAt, abs) + } + got.IdleExpiresAt = abs.Add(time.Hour) + got = refreshIdleDeadline(got, now, 30*time.Minute) + if !got.IdleExpiresAt.Equal(abs) { + t.Fatalf("preexisting idle deadline=%s exceeded absolute deadline %s", got.IdleExpiresAt, abs) + } +} + +func TestSessionExpiresAtIdleDeadlineAndRenewalPreservesAbsoluteDeadline(t *testing.T) { + server := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: server.Addr()}) + defer client.Close() + store := newRedisSessionStore(client) + now := time.Now() + absolute := now.Add(2 * time.Hour) + id, err := store.createSession(context.Background(), viewerSession{Subject: "u", ExpiresAt: absolute, IdleExpiresAt: now.Add(2 * time.Second)}) + if err != nil { + t.Fatal(err) + } + server.FastForward(1500 * time.Millisecond) + session, err := store.loadSession(context.Background(), id) + if err != nil { + t.Fatal(err) + } + session = refreshIdleDeadline(session, time.Now(), 30*time.Minute) + if err := store.saveSessionIfExists(context.Background(), id, session); err != nil { + t.Fatal(err) + } + if ttl := server.TTL(store.sessionKey(id)); ttl < 29*time.Minute || ttl > 30*time.Minute { + t.Fatalf("renewed Redis TTL=%s, want approximately 30 minutes", ttl) + } + server.FastForward(31 * time.Minute) + if _, err := store.loadSession(context.Background(), id); err == nil { + t.Fatal("idle-expired session was returned") + } +} + +func TestAuthorizedSessionReadSlidesIdleDeadlineAndCookie(t *testing.T) { + store := newMemorySessions() + now := time.Now() + id, err := store.createSession(context.Background(), viewerSession{Subject: "u", CSRFToken: "csrf", ProfileValidatedAt: now, ExpiresAt: now.Add(2 * time.Hour), IdleExpiresAt: now.Add(time.Minute)}) + if err != nil { + t.Fatal(err) + } + app := newSessionTestApp(store) + req := httptest.NewRequest("GET", "/viewer-api/session", nil) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: id}) + resp, err := app.Test(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status=%d", resp.StatusCode) + } + saved, err := store.loadSession(context.Background(), id) + if err != nil { + t.Fatal(err) + } + if !saved.IdleExpiresAt.After(now.Add(29*time.Minute)) || saved.IdleExpiresAt.After(saved.ExpiresAt) { + t.Fatalf("activity idle deadline=%s, absolute=%s", saved.IdleExpiresAt, saved.ExpiresAt) + } + cookie := cookieNamed(resp, sessionCookieName) + if cookie == nil || cookie.Expires.After(saved.IdleExpiresAt.Add(time.Second)) || cookie.Expires.Before(saved.IdleExpiresAt.Add(-time.Second)) { + t.Fatalf("sliding session cookie expiry=%v, want idle deadline %s", cookie, saved.IdleExpiresAt) + } +} diff --git a/viewer/web/.gitignore b/viewer/web/.gitignore new file mode 100644 index 000000000..f4e2c6d6b --- /dev/null +++ b/viewer/web/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/viewer/web/Dockerfile b/viewer/web/Dockerfile new file mode 100644 index 000000000..7a7e66b39 --- /dev/null +++ b/viewer/web/Dockerfile @@ -0,0 +1,16 @@ +FROM node:22-alpine AS build + +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +ARG VITE_TURNSTILE_SITE_KEY= +ARG VITE_VIEWER_SEARCH_ENABLED=false +ENV VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY} \ + VITE_VIEWER_SEARCH_ENABLED=${VITE_VIEWER_SEARCH_ENABLED} +RUN npm run build + +FROM nginx:stable-alpine +COPY --from=build /app/dist /usr/share/nginx/html/viewer +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/viewer/web/index.html b/viewer/web/index.html new file mode 100644 index 000000000..5955b2811 --- /dev/null +++ b/viewer/web/index.html @@ -0,0 +1,2 @@ + +Messages · HTTPSMS
diff --git a/viewer/web/package-lock.json b/viewer/web/package-lock.json new file mode 100644 index 000000000..887a91e00 --- /dev/null +++ b/viewer/web/package-lock.json @@ -0,0 +1,2579 @@ +{ + "name": "httpsms-readonly-viewer", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "httpsms-readonly-viewer", + "version": "1.0.0", + "dependencies": { + "vue": "^3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "@vue/test-utils": "^2.4.6", + "jsdom": "^25.0.1", + "typescript": "~5.7.2", + "vite": "^5.4.21", + "vitest": "^2.1.8", + "vue-tsc": "^2.2.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@one-ini/wasm": { + "version": "0.2.1", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.5.tgz", + "integrity": "sha512-MrfgRQSpD5hkkRg6XI16B6EDLb+6+xp8xje6bpdSwkYw+qxrBKbUVrRAQu9h82UT+QumSIVQ8HXsXjPd2dlhqw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.5.tgz", + "integrity": "sha512-+uu2p/R9Tzn8GpoQyX1pAo5dmKT1DhXevfaCzqzmPFhnLjHLWw7uWkaVGlxmhwYoRiYYhsGfRcD5cTBpL7rgSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.5", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.5.tgz", + "integrity": "sha512-wg0zQEKkEvFZf1UaaX1UELOoPs//OSKUG7D+yITuqOsAvza7LvHTv5Wu4nmMfzNG7EYI2jp2PJC7Id6OETmsYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.5.tgz", + "integrity": "sha512-CoJmWcpEMuwin+TZ/ev/H9uDyF/uxUKQSWXdmIPn/UBx/uGV9+4ebMp0FwnlhWR9PE1AdzYsKf3bZevhif38+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.5.tgz", + "integrity": "sha512-PxZnLAaTUAL5xG0W2BXLeYPKrK2b3LIASV2/e/Kf7PZvSLI1iAQIm9B9aJD+YyFIT7hc6qaMV3+OOO35R28LfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.5.tgz", + "integrity": "sha512-uPo3IvnONjhbFXwWgroBeFwOicuhT7ZwQrnzkzmOtBJzeQbkUJUE1SJDfDI31ijQ5+ZUIA6sPrdUMxTSnxUWAg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.5.tgz", + "integrity": "sha512-ywI8Jyuc4LyHYTjC0mw4yA1ulvZBkHusdpWnoiEBsmBuHvSnt90btTBGCZ+s/2y+YNNfVAEKy33nPo5UI9zTmg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.5.tgz", + "integrity": "sha512-tmuXJoQ/3ovSr637aykZMdQteU/CB2APAz2CrvsbibuoHZi1BS1Db13cVViBdEARGwXCLn1DSJ8wsjoThLJPGA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.5.tgz", + "integrity": "sha512-5FMjSYNcUOHx7oW8Qu9KB6jGPpvF/8sG7+BDHwU/Ihh1/6FMJ9ySmsFG4tJ1qbNRuP8OLzCBDso4sF0mQaLuFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.5.tgz", + "integrity": "sha512-K8ZJ+neLDbUkIrL8dqF/5LtuVCrKwo27kREgOlxjBEjlOXwHvDnHLU8Z/UKv/B49qi7e8hsSDOyoJwhwqhvX4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.5.tgz", + "integrity": "sha512-NlVxsq7PFPv0DBKGuNWRDKixQYQCjM53zl04mxEcKMBmJbCBIUm1JdPfIWDRHyOOFzzYPmLQzfeEwh4716ayQg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.5.tgz", + "integrity": "sha512-ARhRHJYs5eCFet5azfrHD97vOkD8OzNW2RSMlvo5CcBqXaTOLfp0OHDtUQh7/K5AL5fUYoFp2fgC9KDg4vVYMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.5.tgz", + "integrity": "sha512-atSgreM5C7jpxd9aEXN+6vJ5wTd05iYw+S3FiQpw7VsoEl8B90llbq35ARON+GOHj5QlVQEq2vOKWCHTob/BSQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.5.tgz", + "integrity": "sha512-vmlmwBFGnjVWxSRoVabNkyYuscSmg52Pd0fM8jEbNT3Jmka4A6HcB84x4uAmuumznWfBVaxKOqU6jbEgomWKCA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.5.tgz", + "integrity": "sha512-hhJrP6S9WkbsbcHgUkvCBS5Cn8IdsG8XjlLZyxjnv9+NdoYfbbZE4TWJYr8apdaj1M5t5Y1id2StnEslxpNFyA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.5.tgz", + "integrity": "sha512-NNVCyN9pGrwFmQa4bf+Cnf5aVH3g6ZpnlOg1Cs/H9swIHT9bRixijfzUqyEIWm1WtiAuR1LJ+rJH6zpBm8kgQA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.5.tgz", + "integrity": "sha512-dR9nd6OKnvSS60Ifz/L7Mj5zzYyoqyRn2NwTuaOONKaa3xsVGXyp8YusMbvFyEOR94DxnwgcbgsFqkHd29ZSzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.5.tgz", + "integrity": "sha512-1tzsMjaqhsh3bAO6SJiJqh8hIr9sSPRd+i2vPPxJHod8/XYnf9TEnK0z/u3r1z1AkI347X0KbRSg9dWcxeGahw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.5.tgz", + "integrity": "sha512-01mWMQblKoXXsmk28KXtpbA96w7g/aOWUoV3AC16fdHdqkykGalqyyM+5u8M7uP5/MEMiiPv69Hv7iv/hEH/dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.5.tgz", + "integrity": "sha512-trdnMPb/DNm4V/CgsaoGgr90HfQQ071wfK0KoIs4YpLvjBgVQGXhGabg2Z6nK4EcYNilrb9ZZPcsP6+zpJIdxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.5.tgz", + "integrity": "sha512-x2LxZlfdfsw99T6/qb9EqE/Qi84JWzibjyOJyhJsgFJy6CafaTiqIXCCnslCGrV5XWNh7fwrS3FbRENcuKnooA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.5.tgz", + "integrity": "sha512-/Qd/9aDQIo0+jmItgBmPIs8195YVbDxRRApn47GyufgbutCDOh7eP5vSKKlI4QNFeaI+wKQAkSMsPnF3EuMpaA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.5.tgz", + "integrity": "sha512-ngUJ/XvOWrqbd2IMsfXfh2xXFLTP3sY7sCKvxbGvYdBZ1KmXFd3ewDZ9n3/GNzAUgzRuhLT/UvJRivbNoAQRBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.5.tgz", + "integrity": "sha512-IhV6ew/DuzUWwHaYmlh7MrPCV3lKFUq/amTEwysi4r/mwNPAEfAMh5dYWeTSPTeMy2pFHGMEWYtPkHzfX3tG5A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.43", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.43", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.43", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.43", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.43", + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-ssr": "3.5.43", + "@vue/shared": "3.5.43", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.28", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.43", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/language-core/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/language-core/node_modules/brace-expansion": { + "version": "2.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@vue/language-core/node_modules/minimatch": { + "version": "9.0.9", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.43", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.43", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.43", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/runtime-core": "3.5.43", + "@vue/shared": "3.5.43", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.43", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.43", + "license": "MIT" + }, + "node_modules/@vue/test-utils": { + "version": "2.5.1", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^2.0.0", + "vue-component-type-helpers": "^3.0.0" + }, + "peerDependencies": { + "@vue/compiler-dom": "3.x", + "@vue/server-renderer": "3.x", + "vue": "3.x" + }, + "peerDependenciesMeta": { + "@vue/server-renderer": { + "optional": true + } + } + }, + "node_modules/abbrev": { + "version": "5.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/config-chain": { + "version": "1.1.13", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/de-indent": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/editorconfig": { + "version": "3.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.2.1", + "commander": "^14.0.3", + "minimatch": "~10.2.4", + "semver": "^7.7.4" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "dev": true, + "license": "ISC" + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/js-beautify": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^3.0.2", + "glob": "^13.0.6", + "js-cookie": "^3.0.8", + "nopt": "^10.0.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "25.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nopt": { + "version": "10.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^5.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/nwsapi": { + "version": "2.2.28", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.3", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.29", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "dev": true, + "license": "ISC" + }, + "node_modules/punycode": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rollup": { + "version": "4.64.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.5", + "@rollup/rollup-android-arm64": "4.64.5", + "@rollup/rollup-darwin-arm64": "4.64.5", + "@rollup/rollup-darwin-x64": "4.64.5", + "@rollup/rollup-freebsd-arm64": "4.64.5", + "@rollup/rollup-freebsd-x64": "4.64.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.5", + "@rollup/rollup-linux-arm-musleabihf": "4.64.5", + "@rollup/rollup-linux-arm64-gnu": "4.64.5", + "@rollup/rollup-linux-arm64-musl": "4.64.5", + "@rollup/rollup-linux-loong64-gnu": "4.64.5", + "@rollup/rollup-linux-loong64-musl": "4.64.5", + "@rollup/rollup-linux-ppc64-gnu": "4.64.5", + "@rollup/rollup-linux-ppc64-musl": "4.64.5", + "@rollup/rollup-linux-riscv64-gnu": "4.64.5", + "@rollup/rollup-linux-riscv64-musl": "4.64.5", + "@rollup/rollup-linux-s390x-gnu": "4.64.5", + "@rollup/rollup-linux-x64-gnu": "4.64.5", + "@rollup/rollup-linux-x64-musl": "4.64.5", + "@rollup/rollup-openbsd-x64": "4.64.5", + "@rollup/rollup-openharmony-arm64": "4.64.5", + "@rollup/rollup-win32-arm64-msvc": "4.64.5", + "@rollup/rollup-win32-ia32-msvc": "4.64.5", + "@rollup/rollup-win32-x64-gnu": "4.64.5", + "@rollup/rollup-win32-x64-msvc": "4.64.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/estree-walker": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.43", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-sfc": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/server-renderer": "3.5.43", + "@vue/shared": "3.5.43" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.13", + "dev": true, + "license": "MIT" + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "dev": true, + "license": "MIT" + } + } +} diff --git a/viewer/web/package.json b/viewer/web/package.json new file mode 100644 index 000000000..3553d57d0 --- /dev/null +++ b/viewer/web/package.json @@ -0,0 +1,23 @@ +{ + "name": "httpsms-readonly-viewer", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "test": "vitest run" + }, + "dependencies": { + "vue": "^3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "@vue/test-utils": "^2.4.6", + "jsdom": "^25.0.1", + "typescript": "~5.7.2", + "vite": "^5.4.21", + "vitest": "^2.1.8", + "vue-tsc": "^2.2.0" + } +} diff --git a/viewer/web/src/App.vue b/viewer/web/src/App.vue new file mode 100644 index 000000000..734bb3b4a --- /dev/null +++ b/viewer/web/src/App.vue @@ -0,0 +1,90 @@ + + + diff --git a/viewer/web/src/__tests__/api.test.ts b/viewer/web/src/__tests__/api.test.ts new file mode 100644 index 000000000..7c665773a --- /dev/null +++ b/viewer/web/src/__tests__/api.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { api, navigation } from '../api' + +afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() }) + +describe('same-origin viewer API client', () => { + it('sends session and signed-reference requests with cookie credentials only', async () => { + const fetcher = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ data: [] }), { status: 200 }))) + vi.stubGlobal('fetch', fetcher) + await api.threads(20, 'Alex') + expect(fetcher).toHaveBeenCalledWith('/viewer-api/threads?skip=20&query=Alex', expect.objectContaining({ credentials: 'include' })) + expect(fetcher.mock.calls[0][1]?.headers).not.toHaveProperty('Authorization') + await api.messages('signed ref/with space', 0) + expect(String(fetcher.mock.calls[1][0])).toContain('/viewer-api/threads/signed%20ref%2Fwith%20space/messages') + }) + + it('keeps Turnstile token in the search JSON body and never in the URL', async () => { + const fetcher = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ data: [] }), { status: 200 }))) + vi.stubGlobal('fetch', fetcher) + await api.search('urgent', 0, 'private-turnstile-token', 'csrf-value') + const [url, init] = fetcher.mock.calls[0] + expect(url).toBe('/viewer-api/search') + expect(String(url)).not.toContain('private-turnstile-token') + expect(JSON.parse(String(init?.body))).toEqual({ query: 'urgent', skip: 0, turnstile_token: 'private-turnstile-token' }) + expect(init?.credentials).toBe('include') + expect(init?.headers).toMatchObject({ 'X-CSRF-Token': 'csrf-value' }) + }) + + it('logs out with the supplied CSRF token and accepts the no-content response', async () => { + const fetcher = vi.fn(() => Promise.resolve(new Response(null, { status: 204 }))) + vi.stubGlobal('fetch', fetcher) + await api.logout('csrf-secret') + expect(fetcher).toHaveBeenCalledWith('/viewer-api/logout', expect.objectContaining({ + method: 'POST', + credentials: 'include', + headers: expect.objectContaining({ 'X-CSRF-Token': 'csrf-secret' }), + })) + }) + + it('hands unauthorized navigation to the login route', async () => { + const fetcher = vi.fn().mockResolvedValue(new Response('', { status: 401 })) + const redirect = vi.spyOn(navigation, 'toLogin').mockImplementation(() => {}) + vi.stubGlobal('fetch', fetcher) + await expect(api.session()).rejects.toThrow('Authentication required') + expect(redirect).toHaveBeenCalledOnce() + }) +}) diff --git a/viewer/web/src/__tests__/viewer.test.ts b/viewer/web/src/__tests__/viewer.test.ts new file mode 100644 index 000000000..cdb9fea33 --- /dev/null +++ b/viewer/web/src/__tests__/viewer.test.ts @@ -0,0 +1,313 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import App from '../App.vue' +import { navigation } from '../api' + +const session = { csrf_token: 'csrf', display_name: 'Riley', phone_number_masked: '+1•••0100' } +const thread = { thread_ref: 'signed:opaque / ref', contact: '+14155550123', order_timestamp: '2026-10-11T18:30:00Z', last_message_content: 'Hello there' } +const message = { content: 'hello', type: 'mobile-originated', status: 'received', created_at: '2026-10-11T18:31:00Z', received_at: '2026-10-11T18:31:02Z', attachments: ['https://private.example/image.jpg'] } + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + return { promise, resolve, reject } +} +function json(data: unknown, status = 200) { + return Promise.resolve(new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } })) +} +function setup(fetcher: typeof fetch) { + vi.stubGlobal('fetch', fetcher) + return mount(App) +} +function enableSearch() { + vi.stubEnv('VITE_TURNSTILE_SITE_KEY', 'public-site-key') + vi.stubEnv('VITE_VIEWER_SEARCH_ENABLED', 'true') +} +afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.restoreAllMocks(); delete window.turnstile }) + +describe('read-only viewer access states', () => { + it('hides search when the Turnstile key exists but search is not explicitly enabled', async () => { + vi.stubEnv('VITE_TURNSTILE_SITE_KEY', 'public-site-key') + const fetcher = vi.fn((input: RequestInfo | URL) => String(input).endsWith('/session') ? json(session) : json({ data: [thread] })) + const wrapper = setup(fetcher as typeof fetch) + await flushPromises() + expect(wrapper.find('#global-search').exists()).toBe(false) + }) + + it('hides search when explicitly enabled without a Turnstile key', async () => { + vi.stubEnv('VITE_VIEWER_SEARCH_ENABLED', 'true') + const fetcher = vi.fn((input: RequestInfo | URL) => String(input).endsWith('/session') ? json(session) : json({ data: [thread] })) + const wrapper = setup(fetcher as typeof fetch) + await flushPromises() + expect(wrapper.find('#global-search').exists()).toBe(false) + }) + + it('redirects an unauthenticated visitor to the BFF login route', async () => { + const fetcher = vi.fn().mockResolvedValue(new Response('', { status: 401 })) + const redirect = vi.spyOn(navigation, 'toLogin').mockImplementation(() => {}) + setup(fetcher as typeof fetch) + await flushPromises() + expect(redirect).toHaveBeenCalledOnce() + expect(fetcher).toHaveBeenCalledTimes(1) + }) + + it('shows unbound notice and never requests threads without a masked phone', async () => { + const fetcher = vi.fn().mockResolvedValue(json({ ...session, phone_number_masked: '' })) + const wrapper = setup(fetcher as typeof fetch) + await flushPromises() + expect(wrapper.text()).toContain('No phone number is linked to this account') + expect(fetcher).toHaveBeenCalledTimes(1) + }) + + it('shows masked account number, threads, and message details without actions', async () => { + const fetcher = vi.fn((input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/session')) return json(session) + if (url.includes('/messages')) return json({ data: [message] }) + return json({ data: [thread] }) + }) + const wrapper = setup(fetcher as typeof fetch) + await flushPromises() + expect(wrapper.text()).toContain('+1•••0100') + expect(wrapper.text()).toContain('+14155550123') + await wrapper.get('[data-testid="thread-row"]').trigger('click') + await flushPromises() + expect(fetcher.mock.calls.some(([url]) => String(url).includes(encodeURIComponent(thread.thread_ref)))).toBe(true) + expect(wrapper.text()).toContain('hello') + expect(wrapper.text()).toContain('Attachments are not available in this viewer.') + expect(wrapper.find('script').exists()).toBe(false) + expect(wrapper.find('a[href="https://private.example/image.jpg"]').exists()).toBe(false) + expect(wrapper.find('button[aria-label*="send" i], button[aria-label*="delete" i]').exists()).toBe(false) + }) + + it('logs out with CSRF, clears inbox content, and returns to login', async () => { + const redirect = vi.spyOn(navigation, 'toLogin').mockImplementation(() => {}) + const fetcher = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.endsWith('/session')) return json(session) + if (url.endsWith('/logout')) return Promise.resolve(new Response(null, { status: 204 })) + if (url.includes('/messages')) return json({ data: [message] }) + return json({ data: [thread] }) + }) + const wrapper = setup(fetcher as typeof fetch) + await flushPromises() + await wrapper.get('[data-testid="thread-row"]').trigger('click') + await flushPromises() + expect(wrapper.text()).toContain('hello') + await wrapper.get('[data-testid="logout"]').trigger('click') + await flushPromises() + const logoutCall = fetcher.mock.calls.find(([url]) => String(url).endsWith('/logout')) + expect(logoutCall?.[1]?.method).toBe('POST') + expect(logoutCall?.[1]?.headers).toMatchObject({ 'X-CSRF-Token': 'csrf' }) + expect(wrapper.text()).not.toContain('hello') + expect(wrapper.text()).not.toContain('+14155550123') + expect(redirect).toHaveBeenCalledOnce() + }) + + it('shows an explicit empty state when global search succeeds with no matches', async () => { + enableSearch() + window.turnstile = { + render: (_container, options) => { queueMicrotask(() => (options.callback as (token: string) => void)('challenge-token')); return 'widget-1' }, + execute: () => {}, + remove: () => {}, + reset: () => {}, + } + const fetcher = vi.fn((input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/session')) return json(session) + if (url.endsWith('/search')) return json({ data: [] }) + return json({ data: [thread] }) + }) + const wrapper = setup(fetcher as typeof fetch) + await flushPromises() + await wrapper.get('#global-search').setValue('nothing here') + await wrapper.get('.global-search-row button').trigger('click') + await flushPromises() + expect(wrapper.text()).toContain('No search results found.') + delete window.turnstile + }) + + it('uses query filtering and offset pagination without exposing limit controls', async () => { + const fetcher = vi.fn((input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/session')) return json(session) + if (url.includes('skip=0')) return json({ data: Array.from({ length: 20 }, (_, index) => ({ ...thread, contact: `+1415555${String(index).padStart(4, '0')}` })) }) + return json({ data: [] }) + }) + const wrapper = setup(fetcher as typeof fetch) + await flushPromises() + await wrapper.get('[data-testid="thread-filter"]').setValue('Casey') + await new Promise(resolve => setTimeout(resolve, 300)) + await flushPromises() + await wrapper.get('[data-testid="next-page"]').trigger('click') + await flushPromises() + expect(fetcher.mock.calls.some(([url]) => String(url).includes('query=Casey') && String(url).includes('skip=20'))).toBe(true) + }) + + it('ignores an old thread response immediately when filter input changes', async () => { + const older = deferred() + const fetcher = vi.fn((input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/session')) return json(session) + if (url.includes('query=next')) return json({ data: [{ ...thread, contact: '+14155550199' }] }) + return older.promise + }) + const wrapper = setup(fetcher as typeof fetch) + await flushPromises() + await wrapper.get('[data-testid="thread-filter"]').setValue('next') + await new Promise(resolve => setTimeout(resolve, 80)) + older.resolve(new Response(JSON.stringify({ data: [{ ...thread, contact: '+14155550001' }] }), { status: 200 })) + await flushPromises() + expect(wrapper.text()).not.toContain('+14155550001') + expect(wrapper.find('[data-testid="thread-list-loading"]').exists()).toBe(true) + await new Promise(resolve => setTimeout(resolve, 300)) + await flushPromises() + expect(wrapper.text()).toContain('+14155550199') + expect(wrapper.text()).not.toContain('+14155550001') + }) + + it('keeps the current filtered inbox when an older request fails later', async () => { + const older = deferred() + const current = deferred() + let threadCalls = 0 + const fetcher = vi.fn((input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/session')) return json(session) + threadCalls += 1 + return threadCalls === 1 ? older.promise : current.promise + }) + const wrapper = setup(fetcher as typeof fetch) + await flushPromises() + await wrapper.get('[data-testid="thread-filter"]').setValue('current') + await new Promise(resolve => setTimeout(resolve, 300)) + await flushPromises() + older.resolve(new Response('', { status: 503 })) + await flushPromises() + expect(wrapper.find('[data-state="error"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="thread-list-loading"]').exists()).toBe(true) + current.resolve(new Response(JSON.stringify({ data: [{ ...thread, contact: '+14155550999' }] }), { status: 200 })) + await flushPromises() + expect(wrapper.text()).toContain('+14155550999') + expect(wrapper.text()).not.toContain('Could not load conversations') + }) + + it('keeps the current page when an older page request finishes later', async () => { + const laterPage = deferred() + const firstPage = Array.from({ length: 20 }, (_, index) => ({ ...thread, thread_ref: `first-${index}`, contact: `+1415555${String(index).padStart(4, '0')}` })) + const fetcher = vi.fn((input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/session')) return json(session) + if (url.includes('skip=20')) return laterPage.promise + return json({ data: firstPage }) + }) + const wrapper = setup(fetcher as typeof fetch) + await flushPromises() + await wrapper.get('[data-testid="next-page"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="thread-filter"]').setValue('current') + await new Promise(resolve => setTimeout(resolve, 300)) + await flushPromises() + laterPage.resolve(new Response(JSON.stringify({ data: [{ ...thread, thread_ref: 'stale-page', contact: '+14155550999' }] }), { status: 200 })) + await flushPromises() + expect(wrapper.text()).toContain('+14155550000') + expect(wrapper.text()).not.toContain('+14155550999') + expect(wrapper.text()).toContain('Page 1') + }) + + it('keeps the latest selected conversation when message requests finish out of order', async () => { + const firstThread = { ...thread, thread_ref: 'ref-first', contact: '+14155550111' } + const secondThread = { ...thread, thread_ref: 'ref-second', contact: '+14155550222' } + const firstMessages = deferred() + const secondMessages = deferred() + const fetcher = vi.fn((input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/session')) return json(session) + if (url.includes('/messages')) return url.includes('ref-first') ? firstMessages.promise : secondMessages.promise + return json({ data: [firstThread, secondThread] }) + }) + const wrapper = setup(fetcher as typeof fetch) + await flushPromises() + await wrapper.findAll('[data-testid="thread-row"]')[0].trigger('click') + await wrapper.findAll('[data-testid="thread-row"]')[1].trigger('click') + secondMessages.resolve(new Response(JSON.stringify({ data: [{ ...message, content: 'Latest conversation reply' }] }), { status: 200 })) + await flushPromises() + firstMessages.resolve(new Response(JSON.stringify({ data: [{ ...message, content: 'Stale conversation reply' }] }), { status: 200 })) + await flushPromises() + expect(wrapper.text()).toContain('+14155550222') + expect(wrapper.text()).toContain('Latest conversation reply') + expect(wrapper.text()).not.toContain('Stale conversation reply') + }) + + it.each(['error-callback', 'timeout-callback', 'expired-callback'] as const)('cleans up a failed Turnstile %s widget and permits a later search', async (failureCallback) => { + enableSearch() + const reset = vi.fn() + const remove = vi.fn() + let executions = 0 + let lastTurnstileOptions: Record = {} + window.turnstile = { + render: (_container, options) => { lastTurnstileOptions = options; return `widget-${executions + 1}` }, + execute: (_widgetId) => { + executions += 1 + const options = lastTurnstileOptions + if (executions === 1) (options[failureCallback] as () => void)() + else (options.callback as (token: string) => void)('retry-token') + }, + remove, + reset, + } + const fetcher = vi.fn((input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/session')) return json(session) + if (url.endsWith('/search')) return json({ data: [{ ...message, content: 'Found after retry' }] }) + return json({ data: [thread] }) + }) + const wrapper = setup(fetcher as typeof fetch) + await flushPromises() + await wrapper.get('#global-search').setValue('find it') + await wrapper.get('.global-search-row button').trigger('click') + await flushPromises() + expect(wrapper.text()).toContain('Search verification failed. Please try again.') + expect(reset).toHaveBeenCalledWith('widget-1') + expect(remove).toHaveBeenCalledWith('widget-1') + expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/search'))).toBe(false) + await wrapper.get('.global-search-row button').trigger('click') + await flushPromises() + expect(wrapper.text()).toContain('Found after retry') + const searchCall = fetcher.mock.calls.find(([url]) => String(url).endsWith('/search')) + expect(searchCall?.[0]).toBe('/viewer-api/search') + expect(String(searchCall?.[0])).not.toContain('retry-token') + expect(JSON.parse(String(searchCall?.[1]?.body)).turnstile_token).toBe('retry-token') + }) + + it('shows retryable guidance when the Turnstile script has not loaded yet', async () => { + enableSearch() + delete window.turnstile + const fetcher = vi.fn((input: RequestInfo | URL) => String(input).endsWith('/session') ? json(session) : json({ data: [thread] })) + const wrapper = setup(fetcher as typeof fetch) + await flushPromises() + await wrapper.get('#global-search').setValue('find it') + await wrapper.get('.global-search-row button').trigger('click') + await flushPromises() + expect(wrapper.text()).toContain('Search verification is still loading. Please try again.') + expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/search'))).toBe(false) + }) + + it('shows an explicit empty inbox when the BFF returns no threads', async () => { + const fetcher = vi.fn((input: RequestInfo | URL) => String(input).endsWith('/session') ? json(session) : json({ data: [] })) + const wrapper = setup(fetcher as typeof fetch) + await flushPromises() + expect(wrapper.text()).toContain('No conversations match this view.') + }) + + it('shows explicit empty and retryable load errors', async () => { + const fetcher = vi.fn().mockImplementation((input: RequestInfo | URL) => { + if (String(input).endsWith('/session')) return json(session) + return new Response('', { status: 503 }) + }) + const wrapper = setup(fetcher as typeof fetch) + await flushPromises() + expect(wrapper.text()).toContain('Could not load conversations') + expect(wrapper.find('[data-testid="thread-list-loading"]').exists()).toBe(false) + }) +}) diff --git a/viewer/web/src/api.ts b/viewer/web/src/api.ts new file mode 100644 index 000000000..fb8295fb7 --- /dev/null +++ b/viewer/web/src/api.ts @@ -0,0 +1,59 @@ +export interface Session { + csrf_token: string + display_name: string + phone_number_masked: string +} +export interface Thread { + thread_ref: string + contact: string + order_timestamp?: string + last_message_content?: string | null + status?: string + unread_count?: number +} +export interface Message { + content?: string + type?: string + status?: string + created_at?: string + sent_at?: string | null + received_at?: string | null + attachments?: string[] +} +interface DataResponse { data: T[] } +const ROOT = '/viewer-api' +export const navigation = { toLogin: () => window.location.assign(`${ROOT}/login`) } + +async function request(path: string, init: RequestInit = {}): Promise { + const response = await fetch(`${ROOT}${path}`, { + ...init, + credentials: 'include', + headers: { Accept: 'application/json', ...init.headers }, + }) + if (response.status === 401) { + navigation.toLogin() + throw new Error('Authentication required') + } + if (!response.ok) throw new Error(`Request failed (${response.status})`) + if (response.status === 204) return undefined as T + return response.json() as Promise +} +function params(skip: number, query: string): string { + const search = new URLSearchParams({ skip: String(skip) }) + if (query.trim()) search.set('query', query.trim()) + return `?${search.toString()}` +} +export const api = { + session: () => request('/session'), + threads: (skip = 0, query = '') => request>(`/threads${params(skip, query)}`), + messages: (threadRef: string, skip = 0, query = '') => request>(`/threads/${encodeURIComponent(threadRef)}/messages${params(skip, query)}`), + search: (query: string, skip: number, token: string, csrf: string) => request>('/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }, + body: JSON.stringify({ query, skip, turnstile_token: token }), + }), + logout: (csrf: string) => request('/logout', { + method: 'POST', + headers: { 'X-CSRF-Token': csrf }, + }), +} diff --git a/viewer/web/src/components/AccessState.vue b/viewer/web/src/components/AccessState.vue new file mode 100644 index 000000000..b36ccb3e1 --- /dev/null +++ b/viewer/web/src/components/AccessState.vue @@ -0,0 +1,12 @@ + + diff --git a/viewer/web/src/components/InboxView.vue b/viewer/web/src/components/InboxView.vue new file mode 100644 index 000000000..33afca70d --- /dev/null +++ b/viewer/web/src/components/InboxView.vue @@ -0,0 +1,193 @@ + + diff --git a/viewer/web/src/components/MessageList.vue b/viewer/web/src/components/MessageList.vue new file mode 100644 index 000000000..75e0111f1 --- /dev/null +++ b/viewer/web/src/components/MessageList.vue @@ -0,0 +1,24 @@ + + diff --git a/viewer/web/src/components/ThreadList.vue b/viewer/web/src/components/ThreadList.vue new file mode 100644 index 000000000..3573d670d --- /dev/null +++ b/viewer/web/src/components/ThreadList.vue @@ -0,0 +1,22 @@ + + diff --git a/viewer/web/src/env.d.ts b/viewer/web/src/env.d.ts new file mode 100644 index 000000000..90801e02f --- /dev/null +++ b/viewer/web/src/env.d.ts @@ -0,0 +1,9 @@ +/// +interface Window { + turnstile?: { + render: (container: string, options: Record) => string + execute: (widgetId: string) => void + remove: (widgetId: string) => void + reset: (widgetId: string) => void + } +} diff --git a/viewer/web/src/main.ts b/viewer/web/src/main.ts new file mode 100644 index 000000000..d43f03510 --- /dev/null +++ b/viewer/web/src/main.ts @@ -0,0 +1,12 @@ +import { createApp } from 'vue' +import App from './App.vue' + +if (import.meta.env.VITE_VIEWER_SEARCH_ENABLED === 'true' && import.meta.env.VITE_TURNSTILE_SITE_KEY) { + const script = document.createElement('script') + script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit' + script.async = true + script.defer = true + document.head.append(script) +} + +createApp(App).mount('#app') diff --git a/viewer/web/tsconfig.json b/viewer/web/tsconfig.json new file mode 100644 index 000000000..65903f2c8 --- /dev/null +++ b/viewer/web/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2022", "useDefineForClassFields": true, "module": "ESNext", + "moduleResolution": "Bundler", "strict": true, "jsx": "preserve", + "resolveJsonModule": true, "isolatedModules": true, "esModuleInterop": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], "skipLibCheck": true, + "types": ["vite/client", "vitest/globals"] + }, + "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"] +} diff --git a/viewer/web/vite.config.ts b/viewer/web/vite.config.ts new file mode 100644 index 000000000..659f0d46e --- /dev/null +++ b/viewer/web/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + base: '/viewer/', + plugins: [vue()], + test: { environment: 'jsdom', include: ['src/**/*.test.ts'] }, +}) diff --git a/web/Dockerfile b/web/Dockerfile index f283fd32a..0f9139dff 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -3,12 +3,42 @@ FROM node:22-alpine AS build WORKDIR /app +ARG API_BASE_URL +ARG APP_URL +ARG APP_NAME +ARG APP_GITHUB_URL +ARG APP_DOCUMENTATION_URL +ARG APP_DOWNLOAD_URL +ARG APP_ENV +ARG FIREBASE_API_KEY +ARG FIREBASE_AUTH_DOMAIN +ARG FIREBASE_PROJECT_ID +ARG FIREBASE_STORAGE_BUCKET +ARG FIREBASE_MESSAGING_SENDER_ID +ARG FIREBASE_APP_ID +ARG FIREBASE_MEASUREMENT_ID + +ENV API_BASE_URL=$API_BASE_URL \ + APP_URL=$APP_URL \ + APP_NAME=$APP_NAME \ + APP_GITHUB_URL=$APP_GITHUB_URL \ + APP_DOCUMENTATION_URL=$APP_DOCUMENTATION_URL \ + APP_DOWNLOAD_URL=$APP_DOWNLOAD_URL \ + APP_ENV=$APP_ENV \ + FIREBASE_API_KEY=$FIREBASE_API_KEY \ + FIREBASE_AUTH_DOMAIN=$FIREBASE_AUTH_DOMAIN \ + FIREBASE_PROJECT_ID=$FIREBASE_PROJECT_ID \ + FIREBASE_STORAGE_BUCKET=$FIREBASE_STORAGE_BUCKET \ + FIREBASE_MESSAGING_SENDER_ID=$FIREBASE_MESSAGING_SENDER_ID \ + FIREBASE_APP_ID=$FIREBASE_APP_ID \ + FIREBASE_MEASUREMENT_ID=$FIREBASE_MEASUREMENT_ID + COPY package.json pnpm-lock.yaml ./ # Install pnpm RUN npm install -g pnpm -RUN pnpm install +RUN pnpm install --allow-build @firebase/util --allow-build @parcel/watcher --allow-build esbuild --allow-build protobufjs --allow-build unrs-resolver COPY . . RUN pnpm run generate diff --git a/web/nginx.conf b/web/nginx.conf index a66ccfd7d..d6bcb0808 100644 --- a/web/nginx.conf +++ b/web/nginx.conf @@ -1,8 +1,46 @@ +upstream viewer_bff { + server viewer-bff:8080; +} + +upstream viewer_web { + server viewer-web:80; +} + server { listen 3000; server_name localhost; root /usr/share/nginx/html; index index.html index.htm; + absolute_redirect off; + + location = /viewer-api/callback { + access_log off; + error_log /dev/null crit; + add_header Cache-Control "no-store" always; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect / /viewer/; + proxy_pass http://viewer_bff; + } + + location ^~ /viewer-api/ { + access_log off; + error_log /dev/null crit; + add_header Cache-Control "no-store" always; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect / /viewer/; + proxy_pass http://viewer_bff; + } + + location ^~ /viewer/ { + proxy_set_header Host $host; + proxy_pass http://viewer_web; + } location / { try_files $uri $uri/ /index.html;