From 1a1ba2787e7fa03741acb4f13137bceb55a17cbc Mon Sep 17 00:00:00 2001 From: yangdy Date: Mon, 12 Oct 2026 07:42:34 -0700 Subject: [PATCH 01/29] docs: design Passport SMS read-only viewer --- ...-12-sms-passport-readonly-viewer-design.md | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-12-sms-passport-readonly-viewer-design.md 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 附件查看和下载。 +- 在此设计文档阶段修改应用代码、运行测试或部署预发。 From a2df339d5364cfde82d9a6f34c0d6fc4d09c47e3 Mon Sep 17 00:00:00 2001 From: yangdy Date: Mon, 12 Oct 2026 07:48:23 -0700 Subject: [PATCH 02/29] feat: configure HTTPSMS pre gateway deployment --- .gitignore | 2 + android/app/google-services.json | 31 ++------- android/app/src/main/AndroidManifest.xml | 1 + .../java/com/httpsms/HttpSmsApiService.kt | 8 ++- .../main/java/com/httpsms/LoginActivity.kt | 5 ++ android/app/src/main/res/values/strings.xml | 2 +- .../java/com/httpsms/HttpSmsApiServiceTest.kt | 12 ++++ deploy/.gitignore | 1 + deploy/api.pre.env.example | 30 +++++++++ deploy/httpsms-pre-compose.yml | 63 +++++++++++++++++++ deploy/postgres.pre.env.example | 1 + deploy/web.pre.env.example | 15 +++++ web/Dockerfile | 32 +++++++++- web/nginx.conf | 1 + 14 files changed, 176 insertions(+), 28 deletions(-) create mode 100644 android/app/src/test/java/com/httpsms/HttpSmsApiServiceTest.kt create mode 100644 deploy/.gitignore create mode 100644 deploy/api.pre.env.example create mode 100644 deploy/httpsms-pre-compose.yml create mode 100644 deploy/postgres.pre.env.example create mode 100644 deploy/web.pre.env.example 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/api.pre.env.example b/deploy/api.pre.env.example new file mode 100644 index 000000000..3fb30d7b2 --- /dev/null +++ b/deploy/api.pre.env.example @@ -0,0 +1,30 @@ +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 +REDIS_URL=redis://@redis:6379 +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..47b92174c --- /dev/null +++ b/deploy/httpsms-pre-compose.yml @@ -0,0 +1,63 @@ +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 + api: + build: + context: ./src/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: ./src/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 + env_file: + - ./web.pre.env + restart: unless-stopped +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/web.pre.env.example b/deploy/web.pre.env.example new file mode 100644 index 000000000..edfde8615 --- /dev/null +++ b/deploy/web.pre.env.example @@ -0,0 +1,15 @@ +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 +CLOUDFLARE_TURNSTILE_SITE_KEY= 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..546368144 100644 --- a/web/nginx.conf +++ b/web/nginx.conf @@ -3,6 +3,7 @@ server { server_name localhost; root /usr/share/nginx/html; index index.html index.htm; + absolute_redirect off; location / { try_files $uri $uri/ /index.html; From 6f07ab920fb83cee48829ea26534f6a0dbe7ec1c Mon Sep 17 00:00:00 2001 From: yangdy Date: Mon, 12 Oct 2026 08:55:49 -0700 Subject: [PATCH 03/29] docs: add SMS Passport viewer execution plan --- ...2026-10-12-sms-passport-readonly-viewer.md | 440 ++++++++++++++++++ 1 file changed, 440 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-12-sms-passport-readonly-viewer.md 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。 From c5c5d78e72ddc476894ca50462a413728e8dcbf9 Mon Sep 17 00:00:00 2001 From: yangdy Date: Mon, 12 Oct 2026 09:46:45 -0700 Subject: [PATCH 04/29] feat: scaffold sms viewer bff --- viewer/bff/config.go | 66 ++++++++++++++++++++++++++++ viewer/bff/config_test.go | 91 +++++++++++++++++++++++++++++++++++++++ viewer/bff/go.mod | 31 +++++++++++++ viewer/bff/go.sum | 68 +++++++++++++++++++++++++++++ viewer/bff/main.go | 35 +++++++++++++++ 5 files changed, 291 insertions(+) create mode 100644 viewer/bff/config.go create mode 100644 viewer/bff/config_test.go create mode 100644 viewer/bff/go.mod create mode 100644 viewer/bff/go.sum create mode 100644 viewer/bff/main.go diff --git a/viewer/bff/config.go b/viewer/bff/config.go new file mode 100644 index 000000000..7aa5381e4 --- /dev/null +++ b/viewer/bff/config.go @@ -0,0 +1,66 @@ +package main + +import ( + "fmt" + "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 +} + +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) + } + } + cfg.appEnv = strings.ToLower(strings.TrimSpace(getenv("APP_ENV"))) + if err := validateCallbackURL(cfg.passportRedirectURL, cfg.appEnv); err != nil { + return config{}, err + } + return cfg, nil +} + +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)") +} diff --git a/viewer/bff/config_test.go b/viewer/bff/config_test.go new file mode 100644 index 000000000..515939054 --- /dev/null +++ b/viewer/bff/config_test.go @@ -0,0 +1,91 @@ +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 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 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) + } +} diff --git a/viewer/bff/go.mod b/viewer/bff/go.mod new file mode 100644 index 000000000..a67c0239e --- /dev/null +++ b/viewer/bff/go.mod @@ -0,0 +1,31 @@ +module github.com/NdoleStudio/httpsms/viewer/bff + +go 1.26.0 + +require ( + 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 + 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..83cd84090 --- /dev/null +++ b/viewer/bff/go.sum @@ -0,0 +1,68 @@ +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/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/main.go b/viewer/bff/main.go new file mode 100644 index 000000000..6319262bc --- /dev/null +++ b/viewer/bff/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "log" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gofiber/fiber/v3" + "github.com/redis/go-redis/v9" + "golang.org/x/oauth2" +) + +// Keep the planned integration dependencies explicit for the following BFF +// tasks, which will wire OIDC and Redis into the application. +var ( + _ *oidc.Provider + _ *oauth2.Config + _ *redis.Client +) + +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() { + if _, err := loadConfig(nil); err != nil { + log.Fatal(err) + } + if err := newApp().Listen(":8080"); err != nil { + log.Fatal(err) + } +} From 1c72e40c8b71e603a30faf6dc7c836c821640c5f Mon Sep 17 00:00:00 2001 From: yangdy Date: Mon, 12 Oct 2026 09:54:22 -0700 Subject: [PATCH 05/29] feat: add passport oidc sessions to sms viewer --- viewer/bff/main.go | 35 +++-- viewer/bff/oidc.go | 240 ++++++++++++++++++++++++++++++ viewer/bff/oidc_test.go | 293 +++++++++++++++++++++++++++++++++++++ viewer/bff/session.go | 179 ++++++++++++++++++++++ viewer/bff/session_test.go | 187 +++++++++++++++++++++++ 5 files changed, 922 insertions(+), 12 deletions(-) create mode 100644 viewer/bff/oidc.go create mode 100644 viewer/bff/oidc_test.go create mode 100644 viewer/bff/session.go create mode 100644 viewer/bff/session_test.go diff --git a/viewer/bff/main.go b/viewer/bff/main.go index 6319262bc..f1c2d894e 100644 --- a/viewer/bff/main.go +++ b/viewer/bff/main.go @@ -1,20 +1,13 @@ package main import ( + "context" "log" + "net/url" + "time" - "github.com/coreos/go-oidc/v3/oidc" "github.com/gofiber/fiber/v3" "github.com/redis/go-redis/v9" - "golang.org/x/oauth2" -) - -// Keep the planned integration dependencies explicit for the following BFF -// tasks, which will wire OIDC and Redis into the application. -var ( - _ *oidc.Provider - _ *oauth2.Config - _ *redis.Client ) func newApp() *fiber.App { @@ -26,10 +19,28 @@ func newApp() *fiber.App { } func main() { - if _, err := loadConfig(nil); err != nil { + cfg, err := loadConfig(nil) + if err != nil { log.Fatal(err) } - if err := newApp().Listen(":8080"); err != nil { + 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") + } + u, err := url.Parse(cfg.passportRedirectURL) + if err != nil { + log.Fatal("invalid PASSPORT_REDIRECT_URL") + } + app, err := newOIDCApp(oidcConfig{issuer: cfg.passportIssuer, clientID: cfg.passportClientID, clientSecret: cfg.passportClientSecret, redirectURL: cfg.passportRedirectURL, sessionTTL: 12 * time.Hour, secureCookie: u.Scheme == "https"}, 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..70daba32a --- /dev/null +++ b/viewer/bff/oidc.go @@ -0,0 +1,240 @@ +package main + +import ( + "context" + "encoding/json" + "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 + secureCookie bool +} +type identityProfile struct { + Subject string `json:"sub"` + DisplayName string `json:"name"` + PhoneNumber string `json:"phone_number"` +} +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.DefaultClient}, 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() + 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 { + 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) + } + if e = store.putState(ctx.Context(), state, loginState{Nonce: nonce, Verifier: verifier}, 10*time.Minute); e != nil { + return genericFailure(ctx) + } + return ctx.Redirect().To(client.oauth.AuthCodeURL(state, oauth2.SetAuthURLParam("nonce", nonce), oauth2.S256ChallengeOption(verifier))) + }) + app.Get("/viewer-api/callback", func(ctx fiber.Ctx) error { + 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) + } + token, e := client.oauth.Exchange(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) + } + expiry := time.Now().Add(c.sessionTTL) + id, e := store.createSession(ctx.Context(), viewerSession{Subject: claims.Subject, DisplayName: profile.DisplayName, PhoneNumber: profile.PhoneNumber, AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, IDToken: rawID, ProfileValidatedAt: time.Now(), ExpiresAt: expiry, CSRFToken: csrf}) + 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, c, expiry) + return ctx.Redirect().Status(http.StatusSeeOther).To("/") + }) + app.Get("/viewer-api/session", func(ctx fiber.Ctx) error { + 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": v.PhoneNumber, "sub": v.Subject}) + }) + app.Post("/viewer-api/logout", func(ctx fiber.Ctx) error { + 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, c) + } + 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, c) + }) + return app +} + +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") + } + e = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&out) + if e != nil || out.Subject == "" { + return out, errors.New("invalid userinfo response") + } + return out, nil +} +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 { + if v.RefreshToken == "" { + _ = store.deleteSession(ctx.Context(), id) + return v, id, errors.New("reauthentication required") + } + fresh, e := client.oauth.TokenSource(ctx.Context(), &oauth2.Token{AccessToken: v.AccessToken, RefreshToken: v.RefreshToken, Expiry: time.Now().Add(-time.Second)}).Token() + if e != nil { + _ = store.deleteSession(ctx.Context(), id) + return v, id, errors.New("reauthentication required") + } + profile, e := client.userInfo(ctx.Context(), fresh.AccessToken) + if e != nil || profile.Subject != v.Subject { + _ = store.deleteSession(ctx.Context(), id) + return v, id, errors.New("reauthentication required") + } + v.AccessToken = fresh.AccessToken + if fresh.RefreshToken != "" { + v.RefreshToken = fresh.RefreshToken + } + v.DisplayName = profile.DisplayName + v.PhoneNumber = profile.PhoneNumber + v.ProfileValidatedAt = time.Now() + if e = store.saveSession(ctx.Context(), id, v); e != nil { + return v, id, e + } + } + return v, id, nil +} +func setSessionCookie(ctx fiber.Ctx, id string, c oidcConfig, expiry time.Time) { + ctx.Cookie(&fiber.Cookie{Name: sessionCookieName, Value: id, Path: "/viewer-api", HTTPOnly: true, Secure: c.secureCookie, SameSite: "Lax", Expires: expiry}) +} +func clearSessionCookie(ctx fiber.Ctx, c oidcConfig) error { + ctx.Cookie(&fiber.Cookie{Name: sessionCookieName, Value: "", Path: "/viewer-api", HTTPOnly: true, Secure: c.secureCookie, 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..94c75f3f8 --- /dev/null +++ b/viewer/bff/oidc_test.go @@ -0,0 +1,293 @@ +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") + } + 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) + 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}) + 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) + } + cookie := first.Header.Get("Set-Cookie") + 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) + } + parts := strings.Split(strings.Split(cookie, ";")[0], "=") + if len(parts) != 2 { + t.Fatalf("invalid cookie %q", cookie) + } + if _, err := store.loadSession(context.Background(), parts[1]); err != nil { + t.Fatal("opaque cookie did not identify stored session") + } + 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 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) + resp, err := app.Test(httptest.NewRequest("GET", "/viewer-api/callback?state="+url.QueryEscape(state)+"&code=x", nil)) + 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" + } + resp, err := app.Test(httptest.NewRequest("GET", "/viewer-api/callback?state="+url.QueryEscape(state)+"&code=x", nil)) + 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) + } + }) + } +} + +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 +} + +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" + json.NewEncoder(w).Encode(map[string]any{"sub": i.userSub, "name": "Test User", "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: time.Hour, secureCookie: 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/session.go b/viewer/bff/session.go new file mode 100644 index 000000000..b0f4bc335 --- /dev/null +++ b/viewer/bff/session.go @@ -0,0 +1,179 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "sync" + "time" + + "github.com/redis/go-redis/v9" +) + +const sessionCookieName = "viewer_session" + +type viewerSession struct { + Subject string `json:"sub"` + DisplayName string `json:"display_name,omitempty"` + PhoneNumber string `json:"phone_number,omitempty"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + IDToken string `json:"id_token,omitempty"` + ProfileValidatedAt time.Time `json:"profile_validated_at"` + ExpiresAt time.Time `json:"expires_at"` + CSRFToken string `json:"csrf_token"` +} +type loginState struct { + Nonce string `json:"nonce"` + Verifier string `json:"verifier"` +} +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 +} + +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 + } + 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 { + b, e := json.Marshal(v) + if e != nil { + return e + } + ttl := time.Until(v.ExpiresAt) + if ttl <= 0 { + return errors.New("session expired") + } + return s.client.Set(ctx, s.sessionKey(id), b, ttl).Err() +} +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 && !v.ExpiresAt.After(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 + states map[string]loginState + sessions map[string]viewerSession +} + +func newSessionMemory() *sessionMemory { + return &sessionMemory{states: map[string]loginState{}, sessions: map[string]viewerSession{}} +} +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] = v + 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 || !v.ExpiresAt.After(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] = v + return 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 randomValue(bytes int) (string, error) { + b := make([]byte, bytes) + if _, e := rand.Read(b); e != nil { + return "", e + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/viewer/bff/session_test.go b/viewer/bff/session_test.go new file mode 100644 index 000000000..6a91dca12 --- /dev/null +++ b/viewer/bff/session_test.go @@ -0,0 +1,187 @@ +package main + +import ( + "context" + "encoding/json" + "github.com/gofiber/fiber/v3" + "golang.org/x/oauth2" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func newSessionTestApp(store sessionStore) *fiber.App { + c := testOIDCConfig("https://passport.example.test") + return buildOIDCApp(c, store, &oidcClient{}) +} + +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) + } +} + +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") + } + 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","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 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") + } +} From b5272b0d7ccbe19508bcdcc1f155261b41547a1c Mon Sep 17 00:00:00 2001 From: yangdy Date: Mon, 12 Oct 2026 09:55:38 -0700 Subject: [PATCH 06/29] fix: always secure sms viewer session cookie --- viewer/bff/main.go | 7 +------ viewer/bff/oidc.go | 11 +++++------ viewer/bff/oidc_test.go | 2 +- viewer/bff/session_test.go | 21 +++++++++++++++++++++ 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/viewer/bff/main.go b/viewer/bff/main.go index f1c2d894e..9630684ad 100644 --- a/viewer/bff/main.go +++ b/viewer/bff/main.go @@ -3,7 +3,6 @@ package main import ( "context" "log" - "net/url" "time" "github.com/gofiber/fiber/v3" @@ -32,11 +31,7 @@ func main() { if err := redisClient.Ping(context.Background()).Err(); err != nil { log.Fatal("Redis unavailable") } - u, err := url.Parse(cfg.passportRedirectURL) - if err != nil { - log.Fatal("invalid PASSPORT_REDIRECT_URL") - } - app, err := newOIDCApp(oidcConfig{issuer: cfg.passportIssuer, clientID: cfg.passportClientID, clientSecret: cfg.passportClientSecret, redirectURL: cfg.passportRedirectURL, sessionTTL: 12 * time.Hour, secureCookie: u.Scheme == "https"}, newRedisSessionStore(redisClient)) + app, err := newOIDCApp(oidcConfig{issuer: cfg.passportIssuer, clientID: cfg.passportClientID, clientSecret: cfg.passportClientSecret, redirectURL: cfg.passportRedirectURL, sessionTTL: 12 * time.Hour}, newRedisSessionStore(redisClient)) if err != nil { log.Fatal("Passport OIDC setup failed") } diff --git a/viewer/bff/oidc.go b/viewer/bff/oidc.go index 70daba32a..df45d8889 100644 --- a/viewer/bff/oidc.go +++ b/viewer/bff/oidc.go @@ -18,7 +18,6 @@ import ( type oidcConfig struct { issuer, clientID, clientSecret, redirectURL string sessionTTL time.Duration - secureCookie bool } type identityProfile struct { Subject string `json:"sub"` @@ -143,7 +142,7 @@ func buildOIDCApp(c oidcConfig, store sessionStore, client *oidcClient) *fiber.A } v, e := store.loadSession(ctx.Context(), id) if e != nil { - return clearSessionCookie(ctx, c) + return clearSessionCookie(ctx) } if !constantTimeEqual(ctx.Get("X-CSRF-Token"), v.CSRFToken) { return ctx.SendStatus(http.StatusForbidden) @@ -151,7 +150,7 @@ func buildOIDCApp(c oidcConfig, store sessionStore, client *oidcClient) *fiber.A if e = store.deleteSession(ctx.Context(), id); e != nil { return genericFailure(ctx) } - return clearSessionCookie(ctx, c) + return clearSessionCookie(ctx) }) return app } @@ -212,10 +211,10 @@ func loadAndValidateSession(ctx fiber.Ctx, store sessionStore, client *oidcClien return v, id, nil } func setSessionCookie(ctx fiber.Ctx, id string, c oidcConfig, expiry time.Time) { - ctx.Cookie(&fiber.Cookie{Name: sessionCookieName, Value: id, Path: "/viewer-api", HTTPOnly: true, Secure: c.secureCookie, SameSite: "Lax", Expires: expiry}) + ctx.Cookie(&fiber.Cookie{Name: sessionCookieName, Value: id, Path: "/viewer-api", HTTPOnly: true, Secure: true, SameSite: "Lax", Expires: expiry}) } -func clearSessionCookie(ctx fiber.Ctx, c oidcConfig) error { - ctx.Cookie(&fiber.Cookie{Name: sessionCookieName, Value: "", Path: "/viewer-api", HTTPOnly: true, Secure: c.secureCookie, 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 { diff --git a/viewer/bff/oidc_test.go b/viewer/bff/oidc_test.go index 94c75f3f8..3724a563a 100644 --- a/viewer/bff/oidc_test.go +++ b/viewer/bff/oidc_test.go @@ -278,7 +278,7 @@ func signTestJWT(key *rsa.PrivateKey, claims map[string]any) (string, error) { 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: time.Hour, secureCookie: true} + return oidcConfig{issuer: issuer, clientID: "viewer-client", clientSecret: "client-secret", redirectURL: "https://viewer.example.test/viewer-api/callback", sessionTTL: time.Hour} } type memorySessions struct{ *sessionMemory } diff --git a/viewer/bff/session_test.go b/viewer/bff/session_test.go index 6a91dca12..58834d639 100644 --- a/viewer/bff/session_test.go +++ b/viewer/bff/session_test.go @@ -17,6 +17,24 @@ func newSessionTestApp(store sessionStore) *fiber.App { return buildOIDCApp(c, store, &oidcClient{}) } +func TestLocalhostHTTPCallbackStillSetsSecureSessionCookie(t *testing.T) { + cfg := testOIDCConfig("https://passport.example.test") + cfg.redirectURL = "http://localhost:8080/viewer-api/callback" + app := fiber.New() + app.Get("/cookie", func(ctx fiber.Ctx) error { + setSessionCookie(ctx, "opaque-session-id", cfg, 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)}) @@ -67,6 +85,9 @@ func TestLogoutWithOriginAndCSRFDeletesSessionAndCookie(t *testing.T) { 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) { From c36785a34ab18469a3dfa653a6e14f77e188c067 Mon Sep 17 00:00:00 2001 From: yangdy Date: Mon, 12 Oct 2026 10:00:26 -0700 Subject: [PATCH 07/29] fix: bind passport login state to browser --- viewer/bff/oidc.go | 21 ++++++++++++- viewer/bff/oidc_test.go | 69 ++++++++++++++++++++++++++++++++++++----- viewer/bff/session.go | 10 ++++-- 3 files changed, 89 insertions(+), 11 deletions(-) diff --git a/viewer/bff/oidc.go b/viewer/bff/oidc.go index df45d8889..435d6e140 100644 --- a/viewer/bff/oidc.go +++ b/viewer/bff/oidc.go @@ -19,6 +19,9 @@ type oidcConfig struct { issuer, clientID, clientSecret, redirectURL string sessionTTL time.Duration } + +const loginFlowCookieName = "passport_flow" + type identityProfile struct { Subject string `json:"sub"` DisplayName string `json:"name"` @@ -72,12 +75,19 @@ func buildOIDCApp(c oidcConfig, store sessionStore, client *oidcClient) *fiber.A if e != nil { return genericFailure(ctx) } - if e = store.putState(ctx.Context(), state, loginState{Nonce: nonce, Verifier: verifier}, 10*time.Minute); e != nil { + 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.Get("/viewer-api/callback", func(ctx fiber.Ctx) error { + browserSecret := ctx.Cookies(loginFlowCookieName) + clearLoginFlowCookie(ctx) state := ctx.Query("state") if state == "" || ctx.Query("error") != "" { return ctx.SendStatus(http.StatusBadRequest) @@ -86,6 +96,9 @@ func buildOIDCApp(c oidcConfig, store sessionStore, client *oidcClient) *fiber.A 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(ctx.Context(), ctx.Query("code"), oauth2.VerifierOption(pending.Verifier)) if e != nil { return genericFailure(ctx) @@ -213,6 +226,12 @@ func loadAndValidateSession(ctx fiber.Ctx, store sessionStore, client *oidcClien func setSessionCookie(ctx fiber.Ctx, id string, c oidcConfig, 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) diff --git a/viewer/bff/oidc_test.go b/viewer/bff/oidc_test.go index 3724a563a..df63d4dc1 100644 --- a/viewer/bff/oidc_test.go +++ b/viewer/bff/oidc_test.go @@ -44,6 +44,13 @@ func TestAuthorizationUsesBoundStateNonceAndPKCE(t *testing.T) { 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 _, ok := store.state(state); !ok { t.Fatal("authorization state was not stored") } @@ -75,9 +82,11 @@ func TestCallbackConsumesStateOnceAndVerifiesLogin(t *testing.T) { 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) @@ -88,7 +97,14 @@ func TestCallbackConsumesStateOnceAndVerifiesLogin(t *testing.T) { if first.StatusCode != http.StatusSeeOther { t.Fatalf("callback status=%d issuer-stage=%s badVerifier=%t, want redirect", first.StatusCode, issuer.stage, issuer.badVerifier) } - cookie := first.Header.Get("Set-Cookie") + 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) { @@ -98,11 +114,7 @@ func TestCallbackConsumesStateOnceAndVerifiesLogin(t *testing.T) { if strings.Contains(cookie, "access-secret") || strings.Contains(cookie, "refresh-secret") || strings.Contains(cookie, "Test User") { t.Fatalf("cookie exposed session material: %s", cookie) } - parts := strings.Split(strings.Split(cookie, ";")[0], "=") - if len(parts) != 2 { - t.Fatalf("invalid cookie %q", cookie) - } - if _, err := store.loadSession(context.Background(), parts[1]); err != nil { + if _, err := store.loadSession(context.Background(), sessionCookie.Value); err != nil { t.Fatal("opaque cookie did not identify stored session") } if len(store.sessions) != 1 { @@ -118,6 +130,34 @@ func TestCallbackConsumesStateOnceAndVerifiesLogin(t *testing.T) { } } +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)) + } +} + func TestCallbackRejectsInvalidIDTokenAndSubMismatch(t *testing.T) { for _, tc := range []struct { name string @@ -144,7 +184,9 @@ func TestCallbackRejectsInvalidIDTokenAndSubMismatch(t *testing.T) { state := p.Query().Get("state") issuer.expectedNonce = p.Query().Get("nonce") issuer.expectedVerifier, _ = store.stateVerifier(state) - resp, err := app.Test(httptest.NewRequest("GET", "/viewer-api/callback?state="+url.QueryEscape(state)+"&code=x", nil)) + 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) } @@ -177,7 +219,9 @@ func TestCallbackRejectsBadSignatureAndNonce(t *testing.T) { } else { issuer.tokenNonce = "wrong" } - resp, err := app.Test(httptest.NewRequest("GET", "/viewer-api/callback?state="+url.QueryEscape(state)+"&code=x", nil)) + 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) } @@ -189,6 +233,15 @@ func TestCallbackRejectsBadSignatureAndNonce(t *testing.T) { } } +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 diff --git a/viewer/bff/session.go b/viewer/bff/session.go index b0f4bc335..e6bd73fe2 100644 --- a/viewer/bff/session.go +++ b/viewer/bff/session.go @@ -3,6 +3,7 @@ package main import ( "context" "crypto/rand" + "crypto/sha256" "encoding/base64" "encoding/json" "errors" @@ -26,8 +27,9 @@ type viewerSession struct { CSRFToken string `json:"csrf_token"` } type loginState struct { - Nonce string `json:"nonce"` - Verifier string `json:"verifier"` + Nonce string `json:"nonce"` + Verifier string `json:"verifier"` + BrowserBinding string `json:"browser_binding"` } type sessionStore interface { putState(context.Context, string, loginState, time.Duration) error @@ -177,3 +179,7 @@ func randomValue(bytes int) (string, error) { } return base64.RawURLEncoding.EncodeToString(b), nil } +func browserBinding(value string) string { + digest := sha256.Sum256([]byte(value)) + return base64.RawURLEncoding.EncodeToString(digest[:]) +} From 33dd16021cde06b6e975da61a1b8304d79fab3da Mon Sep 17 00:00:00 2001 From: yangdy Date: Mon, 12 Oct 2026 10:04:59 -0700 Subject: [PATCH 08/29] fix: serialize expiry-aware passport token refresh --- viewer/bff/oidc.go | 59 +++++++++---- viewer/bff/oidc_test.go | 15 +++- viewer/bff/session.go | 100 +++++++++++++++++++--- viewer/bff/session_test.go | 164 ++++++++++++++++++++++++++++++++++++- 4 files changed, 310 insertions(+), 28 deletions(-) diff --git a/viewer/bff/oidc.go b/viewer/bff/oidc.go index 435d6e140..fc0dd7a63 100644 --- a/viewer/bff/oidc.go +++ b/viewer/bff/oidc.go @@ -48,7 +48,7 @@ func newOIDCClient(ctx context.Context, c oidcConfig) (*oidcClient, error) { 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.DefaultClient}, nil + 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) { @@ -99,7 +99,7 @@ func buildOIDCApp(c oidcConfig, store sessionStore, client *oidcClient) *fiber.A if browserSecret == "" || !constantTimeEqual(pending.BrowserBinding, browserBinding(browserSecret)) { return ctx.SendStatus(http.StatusBadRequest) } - token, e := client.oauth.Exchange(ctx.Context(), ctx.Query("code"), oauth2.VerifierOption(pending.Verifier)) + token, e := client.oauth.Exchange(client.oauthContext(ctx.Context()), ctx.Query("code"), oauth2.VerifierOption(pending.Verifier)) if e != nil { return genericFailure(ctx) } @@ -127,7 +127,7 @@ func buildOIDCApp(c oidcConfig, store sessionStore, client *oidcClient) *fiber.A return genericFailure(ctx) } expiry := time.Now().Add(c.sessionTTL) - id, e := store.createSession(ctx.Context(), viewerSession{Subject: claims.Subject, DisplayName: profile.DisplayName, PhoneNumber: profile.PhoneNumber, AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, IDToken: rawID, ProfileValidatedAt: time.Now(), ExpiresAt: expiry, CSRFToken: csrf}) + id, e := store.createSession(ctx.Context(), viewerSession{Subject: claims.Subject, DisplayName: profile.DisplayName, PhoneNumber: profile.PhoneNumber, AccessToken: token.AccessToken, AccessTokenExpiresAt: token.Expiry, RefreshToken: token.RefreshToken, IDToken: rawID, ProfileValidatedAt: time.Now(), ExpiresAt: expiry, CSRFToken: csrf}) if e != nil { return genericFailure(ctx) } @@ -138,7 +138,7 @@ func buildOIDCApp(c oidcConfig, store sessionStore, client *oidcClient) *fiber.A return genericFailure(ctx) } } - setSessionCookie(ctx, id, c, expiry) + setSessionCookie(ctx, id, expiry) return ctx.Redirect().Status(http.StatusSeeOther).To("/") }) app.Get("/viewer-api/session", func(ctx fiber.Ctx) error { @@ -189,6 +189,9 @@ func (c *oidcClient) userInfo(ctx context.Context, access string) (identityProfi } return out, nil } +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) @@ -196,34 +199,58 @@ func loadAndValidateSession(ctx fiber.Ctx, store sessionStore, client *oidcClien return v, "", e } if time.Since(v.ProfileValidatedAt) > 60*time.Second { - if v.RefreshToken == "" { - _ = store.deleteSession(ctx.Context(), id) - return v, id, errors.New("reauthentication required") + unlock, e := store.lockSession(ctx.Context(), id) + if e != nil { + return v, id, e } - fresh, e := client.oauth.TokenSource(ctx.Context(), &oauth2.Token{AccessToken: v.AccessToken, RefreshToken: v.RefreshToken, Expiry: time.Now().Add(-time.Second)}).Token() + defer unlock() + v, e = store.loadSession(ctx.Context(), id) if e != nil { - _ = store.deleteSession(ctx.Context(), id) - return v, id, errors.New("reauthentication required") + return v, id, e } - profile, e := client.userInfo(ctx.Context(), fresh.AccessToken) + if time.Since(v.ProfileValidatedAt) <= 60*time.Second { + return v, id, nil + } + 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") } - v.AccessToken = fresh.AccessToken - if fresh.RefreshToken != "" { - v.RefreshToken = fresh.RefreshToken + 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.saveSession(ctx.Context(), id, v); e != nil { + if e = store.saveSessionIfExists(ctx.Context(), id, v); e != nil { return v, id, e } } return v, id, nil } -func setSessionCookie(ctx fiber.Ctx, id string, c oidcConfig, expiry time.Time) { +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) { diff --git a/viewer/bff/oidc_test.go b/viewer/bff/oidc_test.go index df63d4dc1..e383b7756 100644 --- a/viewer/bff/oidc_test.go +++ b/viewer/bff/oidc_test.go @@ -51,6 +51,9 @@ func TestAuthorizationUsesBoundStateNonceAndPKCE(t *testing.T) { 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") } @@ -117,6 +120,13 @@ func TestCallbackConsumesStateOnceAndVerifiesLogin(t *testing.T) { 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)) } @@ -156,6 +166,9 @@ func TestCallbackRejectsStateStartedInDifferentBrowser(t *testing.T) { 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) { @@ -331,7 +344,7 @@ func signTestJWT(key *rsa.PrivateKey, claims map[string]any) (string, error) { 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: time.Hour} + return oidcConfig{issuer: issuer, clientID: "viewer-client", clientSecret: "client-secret", redirectURL: "https://viewer.example.test/viewer-api/callback", sessionTTL: 12 * time.Hour} } type memorySessions struct{ *sessionMemory } diff --git a/viewer/bff/session.go b/viewer/bff/session.go index e6bd73fe2..19301c131 100644 --- a/viewer/bff/session.go +++ b/viewer/bff/session.go @@ -16,15 +16,16 @@ import ( const sessionCookieName = "viewer_session" type viewerSession struct { - Subject string `json:"sub"` - DisplayName string `json:"display_name,omitempty"` - PhoneNumber string `json:"phone_number,omitempty"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token,omitempty"` - IDToken string `json:"id_token,omitempty"` - ProfileValidatedAt time.Time `json:"profile_validated_at"` - ExpiresAt time.Time `json:"expires_at"` - CSRFToken string `json:"csrf_token"` + 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"` + ExpiresAt time.Time `json:"expires_at"` + CSRFToken string `json:"csrf_token"` } type loginState struct { Nonce string `json:"nonce"` @@ -38,6 +39,8 @@ type sessionStore interface { 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 { @@ -87,6 +90,56 @@ func (s *redisSessionStore) saveSession(ctx context.Context, id string, v viewer } return s.client.Set(ctx, s.sessionKey(id), b, ttl).Err() } +func (s *redisSessionStore) saveSessionIfExists(ctx context.Context, id string, v viewerSession) error { + b, e := json.Marshal(v) + if e != nil { + return e + } + ttl := time.Until(v.ExpiresAt) + if ttl <= 0 { + return errors.New("session expired") + } + ttlMillis := ttl.Milliseconds() + if ttlMillis < 1 { + ttlMillis = 1 + } + result, e := s.client.Eval(ctx, `if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end; redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2]); 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 == "" { @@ -111,12 +164,14 @@ func (s *redisSessionStore) deleteSession(ctx context.Context, id string) error 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{}} + 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() @@ -166,6 +221,31 @@ func (s *sessionMemory) saveSession(_ context.Context, id string, v viewerSessio s.sessions[id] = v 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 || !existing.ExpiresAt.After(time.Now()) { + delete(s.sessions, id) + return errors.New("session no longer exists") + } + s.sessions[id] = v + 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() diff --git a/viewer/bff/session_test.go b/viewer/bff/session_test.go index 58834d639..80d2b57f9 100644 --- a/viewer/bff/session_test.go +++ b/viewer/bff/session_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "strings" + "sync/atomic" "testing" "time" ) @@ -20,9 +21,12 @@ func newSessionTestApp(store sessionStore) *fiber.App { 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", cfg, time.Now().Add(time.Hour)) + setSessionCookie(ctx, "opaque-session-id", time.Now().Add(time.Hour)) return ctx.SendStatus(200) }) resp, err := app.Test(httptest.NewRequest("GET", "/cookie", nil)) @@ -196,6 +200,164 @@ func TestStaleProfileRefreshesTokenAndRevalidatesUserInfo(t *testing.T) { } } +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","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"}`)) + 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"}`)) + 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)}) From a2061258e3ae4fcbcfc88d61650db9e8f3fc5523 Mon Sep 17 00:00:00 2001 From: yangdy Date: Mon, 12 Oct 2026 10:07:55 -0700 Subject: [PATCH 09/29] feat: enforce httpsms profile authorization in viewer --- viewer/bff/oidc.go | 13 +-- viewer/bff/oidc_test.go | 2 +- viewer/bff/passport_profile.go | 125 ++++++++++++++++++++++++++++ viewer/bff/passport_profile_test.go | 43 ++++++++++ viewer/bff/session_test.go | 8 +- 5 files changed, 177 insertions(+), 14 deletions(-) create mode 100644 viewer/bff/passport_profile.go create mode 100644 viewer/bff/passport_profile_test.go diff --git a/viewer/bff/oidc.go b/viewer/bff/oidc.go index fc0dd7a63..15121506e 100644 --- a/viewer/bff/oidc.go +++ b/viewer/bff/oidc.go @@ -2,7 +2,6 @@ package main import ( "context" - "encoding/json" "errors" "io" "net/http" @@ -22,11 +21,7 @@ type oidcConfig struct { const loginFlowCookieName = "passport_flow" -type identityProfile struct { - Subject string `json:"sub"` - DisplayName string `json:"name"` - PhoneNumber string `json:"phone_number"` -} +type identityProfile = passportProfile type oidcClient struct { oauth oauth2.Config verifier *oidc.IDTokenVerifier @@ -183,11 +178,11 @@ func (c *oidcClient) userInfo(ctx context.Context, access string) (identityProfi if resp.StatusCode != http.StatusOK { return out, errors.New("userinfo request failed") } - e = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&out) - if e != nil || out.Subject == "" { + raw, e := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if e != nil { return out, errors.New("invalid userinfo response") } - return out, nil + return parsePassportProfile(raw, "") } func (c *oidcClient) oauthContext(ctx context.Context) context.Context { return context.WithValue(ctx, oauth2.HTTPClient, c.httpClient) diff --git a/viewer/bff/oidc_test.go b/viewer/bff/oidc_test.go index e383b7756..c77350e9f 100644 --- a/viewer/bff/oidc_test.go +++ b/viewer/bff/oidc_test.go @@ -326,7 +326,7 @@ func newTestIssuer(t *testing.T, claims testClaims) *testIssuer { }) mux.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) { i.stage = "userinfo" - json.NewEncoder(w).Encode(map[string]any{"sub": i.userSub, "name": "Test User", "phone_number": "+15550000000"}) + 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 diff --git a/viewer/bff/passport_profile.go b/viewer/bff/passport_profile.go new file mode 100644 index 000000000..d33156e31 --- /dev/null +++ b/viewer/bff/passport_profile.go @@ -0,0 +1,125 @@ +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) || len(claims.Profile) == 0 || string(claims.Profile) == "null" { + return out, errors.New("invalid userinfo response") + } + 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 + } + out.Subject, out.DisplayName = claims.Subject, claims.Name + 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..67eb15bd0 --- /dev/null +++ b/viewer/bff/passport_profile_test.go @@ -0,0 +1,43 @@ +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", `{"sub":"user-1"}`, "", 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/session_test.go b/viewer/bff/session_test.go index 80d2b57f9..57d22d774 100644 --- a/viewer/bff/session_test.go +++ b/viewer/bff/session_test.go @@ -165,7 +165,7 @@ func TestStaleProfileRefreshesTokenAndRevalidatesUserInfo(t *testing.T) { t.Error("userinfo did not use refreshed access token") } w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"sub":"u","name":"Updated","phone_number":"+15551111111"}`)) + _, _ = w.Write([]byte(`{"sub":"u","name":"Updated","app_profile":{"app_key":"httpsms","schema_version":1,"attributes":{"phone_number":"+15551111111"}}}`)) default: http.NotFound(w, r) } @@ -212,7 +212,7 @@ func TestStaleProfileUsesUnexpiredAccessTokenWithoutRefreshing(t *testing.T) { t.Error("userinfo did not use current access token") } w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"sub":"u","name":"Current","phone_number":"+15550000000"}`)) + _, _ = w.Write([]byte(`{"sub":"u","name":"Current","app_profile":{"app_key":"httpsms","schema_version":1,"attributes":{"phone_number":"+15550000000"}}}`)) })) defer server.Close() store := newMemorySessions() @@ -251,7 +251,7 @@ func TestConcurrentStaleRequestsRefreshOnlyOnce(t *testing.T) { _, _ = 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"}`)) + _, _ = w.Write([]byte(`{"sub":"u","name":"User","app_profile":{"app_key":"httpsms","attributes":{}}}`)) default: http.NotFound(w, r) } @@ -307,7 +307,7 @@ func TestLogoutDuringRefreshDoesNotResurrectSession(t *testing.T) { _, _ = 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"}`)) + _, _ = w.Write([]byte(`{"sub":"u","name":"User","app_profile":{"app_key":"httpsms","attributes":{}}}`)) default: http.NotFound(w, r) } From cd7cc0342952415bd302e293aff8a2068e3333bb Mon Sep 17 00:00:00 2001 From: yangdy Date: Mon, 12 Oct 2026 10:16:07 -0700 Subject: [PATCH 10/29] feat: add constrained read-only gateway client --- viewer/bff/config.go | 18 ++++++ viewer/bff/config_test.go | 12 ++++ viewer/bff/gateway.go | 128 +++++++++++++++++++++++++++++++++++++ viewer/bff/gateway_test.go | 92 ++++++++++++++++++++++++++ 4 files changed, 250 insertions(+) create mode 100644 viewer/bff/gateway.go create mode 100644 viewer/bff/gateway_test.go diff --git a/viewer/bff/config.go b/viewer/bff/config.go index 7aa5381e4..86914dfde 100644 --- a/viewer/bff/config.go +++ b/viewer/bff/config.go @@ -46,6 +46,9 @@ func loadConfig(getenv func(string) string) (config, error) { 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 } @@ -64,3 +67,18 @@ func validateCallbackURL(rawURL, appEnv string) error { } 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") + } + localMode := appEnv == "test" || appEnv == "dev" || appEnv == "development" + if parsed.Scheme != "https" && !(localMode && parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1" || parsed.Hostname() == "::1")) { + return fmt.Errorf("GATEWAY_API_URL must use HTTPS (HTTP is allowed only for localhost in test/dev)") + } + return nil +} diff --git a/viewer/bff/config_test.go b/viewer/bff/config_test.go index 515939054..6280147c3 100644 --- a/viewer/bff/config_test.go +++ b/viewer/bff/config_test.go @@ -89,3 +89,15 @@ func TestHealthReturnsOnlyStatus(t *testing.T) { 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..d3865ad5b --- /dev/null +++ b/viewer/bff/gateway.go @@ -0,0 +1,128 @@ +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.Scheme != "https" && u.Scheme != "http") || 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") + } + u.Path = strings.TrimSuffix(u.Path, "/") + if client == nil { + client = &http.Client{} + } + 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..c1da62887 --- /dev/null +++ b/viewer/bff/gateway_test.go @@ -0,0 +1,92 @@ +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"}, 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 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"}, 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) + } + } +} From 7bf72f1263b5a0d6d5d047c4e049456667954643 Mon Sep 17 00:00:00 2001 From: yangdy Date: Mon, 12 Oct 2026 10:17:24 -0700 Subject: [PATCH 11/29] fix: reject remote cleartext gateway URLs --- viewer/bff/gateway.go | 7 ++++++- viewer/bff/gateway_test.go | 13 +++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/viewer/bff/gateway.go b/viewer/bff/gateway.go index d3865ad5b..6a9b94289 100644 --- a/viewer/bff/gateway.go +++ b/viewer/bff/gateway.go @@ -29,7 +29,12 @@ type gateway struct { func newGateway(cfg config, client *http.Client) (*gateway, error) { u, err := url.Parse(strings.TrimSpace(cfg.gatewayAPIURL)) - if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && strings.TrimSuffix(u.Path, "/") != "/v1") || strings.TrimSpace(cfg.gatewayAPIKey) == "" { + 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") + } + localMode := strings.EqualFold(strings.TrimSpace(cfg.appEnv), "test") || strings.EqualFold(strings.TrimSpace(cfg.appEnv), "dev") || strings.EqualFold(strings.TrimSpace(cfg.appEnv), "development") + loopback := strings.EqualFold(u.Hostname(), "localhost") || net.ParseIP(u.Hostname()) != nil && net.ParseIP(u.Hostname()).IsLoopback() + if u.Scheme != "https" && !(localMode && u.Scheme == "http" && loopback) { return nil, errors.New("invalid gateway configuration") } u.Path = strings.TrimSuffix(u.Path, "/") diff --git a/viewer/bff/gateway_test.go b/viewer/bff/gateway_test.go index c1da62887..877cb32c4 100644 --- a/viewer/bff/gateway_test.go +++ b/viewer/bff/gateway_test.go @@ -19,7 +19,7 @@ func TestGatewayFixedReadRoutesAndBinding(t *testing.T) { _, _ = 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"}, server.Client()) + g, err := newGateway(config{gatewayAPIURL: server.URL + "/v1", gatewayAPIKey: "secret", appEnv: "test"}, server.Client()) if err != nil { t.Fatal(err) } @@ -59,7 +59,7 @@ func TestGatewayRejectsInvalidInputsAndStripsAttachmentURLs(t *testing.T) { _, _ = w.Write([]byte(`{"attachment_url":"https://evil.test/a"}`)) })) defer s.Close() - g, _ := newGateway(config{gatewayAPIURL: s.URL, gatewayAPIKey: "k"}, s.Client()) + 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") } @@ -90,3 +90,12 @@ func TestGatewayConfigCannotChangeUpstreamHostOrPath(t *testing.T) { } } } + +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) + } +} From f107d842869805003a1b5feeb64b1bcb09f3f2f4 Mon Sep 17 00:00:00 2001 From: yangdy Date: Mon, 12 Oct 2026 10:23:12 -0700 Subject: [PATCH 12/29] feat: add read-only sms viewer api --- viewer/bff/config.go | 24 ++++ viewer/bff/config_test.go | 13 ++ viewer/bff/handlers.go | 274 ++++++++++++++++++++++++++++++++++++ viewer/bff/handlers_test.go | 228 ++++++++++++++++++++++++++++++ viewer/bff/main.go | 6 +- viewer/bff/oidc.go | 24 +++- viewer/bff/session_test.go | 6 + 7 files changed, 572 insertions(+), 3 deletions(-) create mode 100644 viewer/bff/handlers.go create mode 100644 viewer/bff/handlers_test.go diff --git a/viewer/bff/config.go b/viewer/bff/config.go index 86914dfde..e5698a42b 100644 --- a/viewer/bff/config.go +++ b/viewer/bff/config.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "net" "net/url" "os" "strings" @@ -19,6 +20,17 @@ type config struct { 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) { @@ -42,7 +54,19 @@ func loadConfig(getenv func(string) string) (config, error) { 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"))) + 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 } diff --git a/viewer/bff/config_test.go b/viewer/bff/config_test.go index 6280147c3..067daeba3 100644 --- a/viewer/bff/config_test.go +++ b/viewer/bff/config_test.go @@ -36,6 +36,19 @@ func TestLoadConfigRequiresEverySetting(t *testing.T) { } } +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" diff --git a/viewer/bff/handlers.go b/viewer/bff/handlers.go new file mode 100644 index 000000000..4e7921471 --- /dev/null +++ b/viewer/bff/handlers.go @@ -0,0 +1,274 @@ +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 + 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) + 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 = 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) + 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) + } + return c.Type("json").Send(raw) +} +func (h *viewerHandlers) search(c fiber.Ctx) error { + setNoStore(c) + 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) + } + v, _, err := h.session(c) + if err != nil { + return nil + } + 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) + } + return c.Type("json").Send(raw) +} +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 { + var rows []any + switch x := payload.(type) { + case []any: + rows = x + case map[string]any: + if a, ok := x["data"].([]any); ok { + rows = a + } else { + return errors.New("invalid threads response") + } + default: + return errors.New("invalid threads response") + } + 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 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..2a4c10262 --- /dev/null +++ b/viewer/bff/handlers_test.go @@ -0,0 +1,228 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gofiber/fiber/v3" +) + +type fakeViewerGateway struct { + threadsOwner string + messageOwner string + messageContact string + searchOwner string + calls int + threads json.RawMessage +} + +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 + return json.RawMessage(`{"data":[]}`), nil +} +func (g *fakeViewerGateway) SearchMessages(_ context.Context, owner, _ string, _ int, _, _ string) (json.RawMessage, error) { + g.calls++ + g.searchOwner = owner + 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, 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 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 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 index 9630684ad..06cde7357 100644 --- a/viewer/bff/main.go +++ b/viewer/bff/main.go @@ -31,7 +31,11 @@ func main() { if err := redisClient.Ping(context.Background()).Err(); err != nil { log.Fatal("Redis unavailable") } - app, err := newOIDCApp(oidcConfig{issuer: cfg.passportIssuer, clientID: cfg.passportClientID, clientSecret: cfg.passportClientSecret, redirectURL: cfg.passportRedirectURL, sessionTTL: 12 * time.Hour}, newRedisSessionStore(redisClient)) + gatewayClient, err := newGateway(cfg, nil) + if err != nil { + log.Fatal("Gateway setup failed") + } + 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, trustedProxies: cfg.trustedProxies}, newRedisSessionStore(redisClient)) if err != nil { log.Fatal("Passport OIDC setup failed") } diff --git a/viewer/bff/oidc.go b/viewer/bff/oidc.go index 15121506e..78ea13cb5 100644 --- a/viewer/bff/oidc.go +++ b/viewer/bff/oidc.go @@ -17,6 +17,9 @@ import ( type oidcConfig struct { issuer, clientID, clientSecret, redirectURL string sessionTTL time.Duration + sessionSecret string + gateway viewerGateway + trustedProxies []string } const loginFlowCookieName = "passport_flow" @@ -55,7 +58,7 @@ func newOIDCApp(c oidcConfig, store sessionStore) (*fiber.App, error) { } func buildOIDCApp(c oidcConfig, store sessionStore, client *oidcClient) *fiber.App { - app := fiber.New() + 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 { state, e := randomValue(32) @@ -80,6 +83,7 @@ func buildOIDCApp(c oidcConfig, store sessionStore, client *oidcClient) *fiber.A 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 { browserSecret := ctx.Cookies(loginFlowCookieName) clearLoginFlowCookie(ctx) @@ -136,14 +140,18 @@ func buildOIDCApp(c oidcConfig, store sessionStore, client *oidcClient) *fiber.A setSessionCookie(ctx, id, expiry) 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) 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": v.PhoneNumber, "sub": v.Subject}) + 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) id := ctx.Cookies(sessionCookieName) if origin := ctx.Get("Origin"); origin == "" || origin != originOf(c.redirectURL) { return ctx.SendStatus(http.StatusForbidden) @@ -160,9 +168,21 @@ func buildOIDCApp(c oidcConfig, store sessionStore, client *oidcClient) *fiber.A } 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, 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) diff --git a/viewer/bff/session_test.go b/viewer/bff/session_test.go index 57d22d774..1c0594997 100644 --- a/viewer/bff/session_test.go +++ b/viewer/bff/session_test.go @@ -118,6 +118,12 @@ func TestAuthenticatedSessionResponseOmitsSessionIDAndTokens(t *testing.T) { 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) { From b908f87010d34ee4833038e1994b0ea7fc50024d Mon Sep 17 00:00:00 2001 From: yangdy Date: Mon, 12 Oct 2026 10:27:44 -0700 Subject: [PATCH 13/29] fix: prioritize sms viewer authentication --- viewer/bff/handlers.go | 8 ++++---- viewer/bff/handlers_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/viewer/bff/handlers.go b/viewer/bff/handlers.go index 4e7921471..812786eae 100644 --- a/viewer/bff/handlers.go +++ b/viewer/bff/handlers.go @@ -123,16 +123,16 @@ func (h *viewerHandlers) messages(c fiber.Ctx) error { } func (h *viewerHandlers) search(c fiber.Ctx) error { setNoStore(c) + 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) } - v, _, err := h.session(c) - if err != nil { - return nil - } if !constantTimeEqual(c.Get("X-CSRF-Token"), v.CSRFToken) { return c.SendStatus(http.StatusForbidden) } diff --git a/viewer/bff/handlers_test.go b/viewer/bff/handlers_test.go index 2a4c10262..5516f2474 100644 --- a/viewer/bff/handlers_test.go +++ b/viewer/bff/handlers_test.go @@ -134,6 +134,31 @@ func TestSearchDisabledWithoutTrustedProxyConfiguration(t *testing.T) { } } +func TestSearchAuthenticationPrecedesAvailabilityAndOriginChecks(t *testing.T) { + t.Run("disabled search still authenticates first", func(t *testing.T) { + app := newViewerHandlers(viewerHandlerConfig{store: newSessionMemory(), oidc: &oidcClient{}, 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) From ed362dbaf024660589912950ee95cfc1511bb947 Mon Sep 17 00:00:00 2001 From: yangdy Date: Mon, 12 Oct 2026 10:42:08 -0700 Subject: [PATCH 14/29] feat: add read-only sms viewer ui --- viewer/web/.gitignore | 3 + viewer/web/index.html | 2 + viewer/web/package-lock.json | 2579 +++++++++++++++++++++ viewer/web/package.json | 23 + viewer/web/src/App.vue | 89 + viewer/web/src/__tests__/api.test.ts | 36 + viewer/web/src/__tests__/viewer.test.ts | 92 + viewer/web/src/api.ts | 54 + viewer/web/src/components/AccessState.vue | 12 + viewer/web/src/components/InboxView.vue | 99 + viewer/web/src/components/MessageList.vue | 24 + viewer/web/src/components/ThreadList.vue | 22 + viewer/web/src/env.d.ts | 8 + viewer/web/src/main.ts | 12 + viewer/web/tsconfig.json | 10 + viewer/web/vite.config.ts | 7 + 16 files changed, 3072 insertions(+) create mode 100644 viewer/web/.gitignore create mode 100644 viewer/web/index.html create mode 100644 viewer/web/package-lock.json create mode 100644 viewer/web/package.json create mode 100644 viewer/web/src/App.vue create mode 100644 viewer/web/src/__tests__/api.test.ts create mode 100644 viewer/web/src/__tests__/viewer.test.ts create mode 100644 viewer/web/src/api.ts create mode 100644 viewer/web/src/components/AccessState.vue create mode 100644 viewer/web/src/components/InboxView.vue create mode 100644 viewer/web/src/components/MessageList.vue create mode 100644 viewer/web/src/components/ThreadList.vue create mode 100644 viewer/web/src/env.d.ts create mode 100644 viewer/web/src/main.ts create mode 100644 viewer/web/tsconfig.json create mode 100644 viewer/web/vite.config.ts 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/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..1240e1473 --- /dev/null +++ b/viewer/web/src/App.vue @@ -0,0 +1,89 @@ + + + diff --git a/viewer/web/src/__tests__/api.test.ts b/viewer/web/src/__tests__/api.test.ts new file mode 100644 index 000000000..e52c92f94 --- /dev/null +++ b/viewer/web/src/__tests__/api.test.ts @@ -0,0 +1,36 @@ +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('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..9a3f66b1f --- /dev/null +++ b/viewer/web/src/__tests__/viewer.test.ts @@ -0,0 +1,92 @@ +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 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) +} +afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() }) + +describe('read-only viewer access states', () => { + 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('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('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..661854759 --- /dev/null +++ b/viewer/web/src/api.ts @@ -0,0 +1,54 @@ +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})`) + 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 }), + }), +} 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..b205420e2 --- /dev/null +++ b/viewer/web/src/components/InboxView.vue @@ -0,0 +1,99 @@ + + 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..7eebe0fe1 --- /dev/null +++ b/viewer/web/src/env.d.ts @@ -0,0 +1,8 @@ +/// +interface Window { + turnstile?: { + render: (container: string, options: Record) => string + execute: (widgetId: string) => void + remove: (widgetId: string) => void + } +} diff --git a/viewer/web/src/main.ts b/viewer/web/src/main.ts new file mode 100644 index 000000000..0afe9931f --- /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_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..e458a3503 --- /dev/null +++ b/viewer/web/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + test: { environment: 'jsdom', include: ['src/**/*.test.ts'] }, +}) From b5fcaf42d568a423191e04fb9df3bd8f87f657fd Mon Sep 17 00:00:00 2001 From: yangdy Date: Mon, 12 Oct 2026 10:47:30 -0700 Subject: [PATCH 15/29] fix: show empty global search state --- viewer/web/src/__tests__/viewer.test.ts | 24 +++++++++++++++++++++++- viewer/web/src/components/InboxView.vue | 6 ++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/viewer/web/src/__tests__/viewer.test.ts b/viewer/web/src/__tests__/viewer.test.ts index 9a3f66b1f..68b1b331d 100644 --- a/viewer/web/src/__tests__/viewer.test.ts +++ b/viewer/web/src/__tests__/viewer.test.ts @@ -14,7 +14,7 @@ function setup(fetcher: typeof fetch) { vi.stubGlobal('fetch', fetcher) return mount(App) } -afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() }) +afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.restoreAllMocks(); delete window.turnstile }) describe('read-only viewer access states', () => { it('redirects an unauthenticated visitor to the BFF login route', async () => { @@ -55,6 +55,28 @@ describe('read-only viewer access states', () => { expect(wrapper.find('button[aria-label*="send" i], button[aria-label*="delete" i]').exists()).toBe(false) }) + it('shows an explicit empty state when global search succeeds with no matches', async () => { + vi.stubEnv('VITE_TURNSTILE_SITE_KEY', 'public-site-key') + window.turnstile = { + render: (_container, options) => { queueMicrotask(() => (options.callback as (token: string) => void)('challenge-token')); return 'widget-1' }, + execute: () => {}, + remove: () => {}, + } + 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) diff --git a/viewer/web/src/components/InboxView.vue b/viewer/web/src/components/InboxView.vue index b205420e2..f6ce2fd52 100644 --- a/viewer/web/src/components/InboxView.vue +++ b/viewer/web/src/components/InboxView.vue @@ -20,6 +20,7 @@ const searchText = ref('') const searchResults = ref([]) const searchLoading = ref(false) const searchError = ref(false) +const searchCompleted = ref(false) const turnstileKey = import.meta.env.VITE_TURNSTILE_SITE_KEY as string | undefined const canSearch = computed(() => Boolean(turnstileKey)) let filterTimer: ReturnType | undefined @@ -64,8 +65,8 @@ async function getTurnstileToken(): Promise { } async function search() { if (!canSearch.value || !session.value || !searchText.value.trim()) return - searchLoading.value = true; searchError.value = false - try { searchResults.value = (await api.search(searchText.value.trim(), 0, await getTurnstileToken(), session.value.csrf_token)).data } + searchLoading.value = true; searchError.value = false; searchCompleted.value = false + try { searchResults.value = (await api.search(searchText.value.trim(), 0, await getTurnstileToken(), session.value.csrf_token)).data; searchCompleted.value = true } catch { searchError.value = true } finally { searchLoading.value = false } } @@ -86,6 +87,7 @@ watch(messageSkip, loadMessages)
✉

Your messages, in one place

Choose a conversation to read your messages.