鉴权并入 Auth Broker:委派设备会话统一模型 + 四端迁移

后端(委托 Auth Broker,路径 A):
- 删自建鉴权(OIDC exchange / 自签会话 / step-up / shortcut / web_sessions / accounts),cdrop 不再存任何凭证;鉴权中间件改读边缘注入的 X-Auth-Subject/Scope/Meta/Name/Roles 头(dev 旁路保留);Claims 加 Tier() / Guest()
- internal/brokerclient:mint / revoke(带 X-Broker-App)/ refresh / ListSessions(R1 列举),直连内网、吊销幂等

统一会话模型“委派设备会话”(Delegated Device Sessions):
- 每个客户端(浏览器 / 桌面 / 扫码设备)=一条带 meta(device_id) + label 的 broker 机器会话;Broker 作设备会话唯一注册表(R1 按用户+app 列举 + R2 按 (user,app,meta) 幂等铸造),cdrop 退化为薄覆盖层、不再自存权威会话表
- 新增代铸端点 POST /api/auth/device-session:凭边缘已验明的 X-Auth-Subject 委托 broker 铸 / 轮换设备会话(meta=device_id、按调用方 tier 防越权、sameOrigin CSRF、per-IP 限流);R2 幂等保证同一 device_id 重登原地轮换、不堆重复设备
- 会话列表=R1 权威 + 叠加 type(本地缓存)/ online(Hub presence,按设备名)/ current(meta 匹配本请求 X-Auth-Meta)+ 过滤 meta=""(device-authorize 引导会话残留);devices 表降级为 type/presence 薄缓存(非会话权威),device_id 主键、upsert 按 user 限定
- 吊销按 device_id → 缓存优先 / R1 兜底解析 sid → broker 吊销 + X-Broker-App;扫码登录保留三密钥编排,collect 改委托 broker 铸 + 落缓存行

Web 前端:
- 登录走 broker 全局 SSO 代跳(/api/auth/login 302);bootstrap 经 /api/me 注入身份后代铸设备会话(稳定 device_id 存 localStorage、Web Locks 跨 tab 串行防重复铸造);refresh 走 /api/auth/refresh
- 设备管理按 device_id;改名=同 device_id 重代铸(R2 原地轮换换 label、不产生重复行);登录页反应式守卫修登录回环
- 去 OIDC PKCE / step-up(删 oauth.callback / stepUp)

桌面客户端(Wails):
- loopback PKCE(RFC 8252)改指 broker 设备授权流(/device/authorize + /device/token)拿引导令牌,再代铸出带 meta 的托管设备会话——与浏览器同模型、同管理、同吊销;身份取自代铸响应(修“显示名显示为 UUID”);refresh 保留显示名;稳定 device_id 入桌面配置

iOS 客户端(arch A,原生 SwiftUI + 离屏无头 WebView 引擎 + 原生↔JS 桥):
- 引擎 / 文件管理 / 设备管理 / 应用图标 / 本地化(此前实现,随本次落入版本库)
- 鉴权=引擎自刷(boot 注入 refresh_token)+ broker 轮换经 sessionRotated 回报原生更新 Keychain;去 cookie 同步;Session 加 refreshToken / deviceId

实时 / 健壮性:
- presence 走 Hub union(设备表行 ∪ 表外实时连接,按名去重、live-only 标在线)
- Hub 通道 close 一律在写锁内、非阻塞 send 一律在读锁内,消除 close-vs-send 闭通道 send panic(revoke 每次 Kick 后该路径变热)

配置 / 删旧栈:
- config 改 broker 接入(CDROP_BROKER_* / CDROP_PUBLIC_URL / 按档 TTL),prod 强校验 broker 配置 + PUBLIC_URL(CSRF Origin 守卫不失效)
- 删 auth.go / selftoken.go / shortcut.go / jwks.go + 三表(web_sessions / accounts / shortcut_tokens)及验证链;.env.example / compose.snippet.yaml / Caddyfile.snippet 更新为 broker 模型(人机分流 + 公开端点放行 + X-Auth-Meta 透传)
- 测试全重写:QR / 会话含 mock broker(R1 列举 + R2 幂等);hub 加 close-vs-send 并发回归;config 加 prod 必填校验
This commit is contained in:
2026-06-26 02:07:11 +08:00
parent c79b176b87
commit 10cf36ecee
104 changed files with 7533 additions and 5318 deletions
+23 -24
View File
@@ -3,21 +3,22 @@
CDROP_AUTH_MODE=dev
CDROP_DEV_TOKEN=replace-with-32-byte-random-base64
# prod 模式所需(填入你的 OIDC provider,如自建 Casdoor / Keycloak
# prod 模式所需(Auth Broker 路径 A)。cdrop 把身份与会话生命周期委托给 broker:每个受控
# 请求在边缘由 broker /verify(经 Caddy)鉴权并注入 X-Auth-* 头;扫码批准 / 设备续期 / 吊销
# 经 broker 内部 + 公开 API。cdrop 不再自做 OIDC、不存任何凭证。
# CDROP_AUTH_MODE=prod
# CDROP_OIDC_AUTHORIZE_URL=https://your-idp.example/login/oauth/authorize
# CDROP_OIDC_TOKEN_URL=https://your-idp.example/api/login/oauth/access_token
# CDROP_OIDC_JWKS_URL=https://your-idp.example/.well-known/jwks
# CDROP_OIDC_ISSUER=https://your-idp.example/
# prod 强制非空:填你的 OAuth client_id(多值逗号分隔,web + 桌面端共用时填同一个)
# CDROP_OIDC_AUDIENCE=your-client-id
# CDROP_OIDC_CLIENT_ID=your-client-id
# CDROP_OIDC_REDIRECT_URI=https://your-domain.example/oauth/callback
# CDROP_OIDC_SCOPES=openid profile email
# prod 强制非空:浏览器免重登会话的 refresh_token 落盘加密密钥(任意长度高熵串,
# 内部 SHA-256 派生为 32 字节 AES-256 密钥)。留空则 prod 启动期校验失败、拒启动
# CDROP_SESSION_SECRET=
# CDROP_HS256_SECRET=
# broker 内网地址(docker 内网直连,绝不经公网反代——以保 /internal/* 与 /refresh 可达)。
# prod 强制非空。
# CDROP_BROKER_BASE_URL=http://broker-internal-ip:8080
# broker /internal/* 端点共享密钥(作 X-Internal-Key 发送)。prod 强制非空,向运维取、勿硬编码。
# CDROP_BROKER_INTERNAL_KEY=
# broker 公开源(浏览器全局 SSO 登录跳转 / 原生客户端设备授权流的目标)。留空则
# /api/auth/login 与原生登录不可用。
# CDROP_BROKER_PUBLIC_URL=https://sso.your-domain.example
# 本应用在 broker apps 注册表里的 key(默认 cdrop)。
# CDROP_BROKER_APP=cdrop
# 部署公开源(拼扫码 QR 链接 / CSRF Origin 校验 / Web Push 默认联系标识)
# CDROP_PUBLIC_URL=https://drop.your-domain.example
# CDROP_TURN_URL=stun:your-stun.example:3478
# 可选:Web PushVAPID)推送通知。用 `just vapid-keygen` 生成一对密钥;二者须同时
@@ -27,22 +28,20 @@ CDROP_DEV_TOKEN=replace-with-32-byte-random-base64
# CDROP_VAPID_PRIVATE_KEY=
# CDROP_VAPID_SUBJECT=mailto:you@your-domain.example
# 可选:扫码登录(QR与 cdrop 自签会话。SESSION_SECRET 已设时默认开启;下列均有默认值,
# 不配即用默认,各项可单独调整 / 关闭对应行为
# 可选:扫码登录(QR+ 按档令牌 TTL(批准后经 broker 委托签发对应 tier 的会话)。下列均有
# 默认值,不配即用默认。
# CDROP_QR_LOGIN_ENABLED=true # 关闭则 /api/auth/qr/* 返回 503
# CDROP_QR_REQUEST_TTL_SECONDS=120 # 二维码 / 登录请求有效期(秒)
# CDROP_QR_GUEST_TTL_SECONDS=3600 # 仅此次(受限访客)会话 TTL(秒)
# CDROP_QR_PERSIST_TTL_HOURS=168 # 信任此设备(持久)会话 TTL(小时
# CDROP_SESSION_TOKEN_TTL_SECONDS=900 # cdrop 自签 access token TTL(秒)
# CDROP_STEP_UP_ENABLED=false # 批准扫码设备等敏感动作要求 provider 再认证
# CDROP_STEP_UP_MAX_AGE_SECONDS=300 # step-up 的 auth_time 新鲜度窗口(秒)
# CDROP_ACCOUNT_MATCH_CLAIM=email # 写入 accounts.match_key 的 JWT claim
# CDROP_FULL_ACCESS_TTL_SECONDS=900 # full 设备 access token TTL(秒)
# CDROP_FULL_REFRESH_TTL_SECONDS=604800 # full 设备 refresh TTL(秒,7 天,滑动
# CDROP_GUEST_ACCESS_TTL_SECONDS=900 # guest(访客借用)access token TTL(秒)
# CDROP_GUEST_REFRESH_TTL_SECONDS=86400 # guest refresh TTL(秒,1 天,滑动)
# 通用
CDROP_DB_PATH=./cdrop.db
CDROP_LISTEN=:8080
# Device row max age (sliding via auth-middleware UPSERT and SSE keepalive).
# Bound by brief §2 refresh_token sliding window (196h ≈ 8 days). Default 196.
# 设备行最大存活(鉴权中间件 TouchDevice 与 SSE keepalive 滑动刷新 last_seen)。
# 不应短于 broker 会话的 refresh 窗口(full 7 天)。默认 196 小时。
CDROP_DEVICE_TTL_HOURS=196
# Cloudflare Realtime TURN(可选;不配则 /api/calls/credentials 返回 STUN-only fallback)。
+1 -1
View File
@@ -84,7 +84,7 @@ func main() {
go transfer.RunSweeper(ctx, transfers)
go relayMgr.RunReaper(ctx)
go jwtauth.RunDeviceSweeper(ctx, queries, time.Duration(cfg.DeviceTTLHours)*time.Hour)
go httpapi.RunWebSessionReaper(ctx, queries)
go httpapi.RunLoginRequestReaper(ctx, queries)
if cfg.ClipboardTTLSec > 0 {
go clipboard.RunSweeper(ctx, clip)
}
+43 -29
View File
@@ -13,26 +13,12 @@ import (
"cdrop-desktop/platform"
)
// loginResultFromToken decodes the identity out of the exchanged tokens and
// assembles the payload the WebView store consumes. Shared by login + refresh.
func loginResultFromToken(tok *platform.TokenResult) (*platform.LoginResult, error) {
user, err := platform.UserFromToken(tok.IDToken, tok.AccessToken)
if err != nil {
return nil, err
}
return &platform.LoginResult{
AccessToken: tok.AccessToken,
RefreshToken: tok.RefreshToken,
ExpiresIn: tok.ExpiresIn,
User: user,
}, nil
}
// App is the Wails application context bound to the WebView.
type App struct {
ctx context.Context
apiBase string
token *platform.TokenResult // in-memory copy; refresh_token persists in the OS secret store (see session.go)
user platform.UserInfo // display identity from 代铸; preserved across refresh (machine tokens are nameless)
quitting bool // set by the menu bar "Quit" so beforeClose allows the real exit
startHidden bool // set by main when launched via autostart: come up to the menu bar, no window
@@ -58,6 +44,7 @@ func (a *App) startup(ctx context.Context) {
RefreshToken: s.RefreshToken,
ExpiresIn: s.ExpiresIn,
}
a.user = s.User
}
// Keep the autostart entry in sync with this binary: when it's enabled,
// re-apply so an entry written by an older build (no --hidden flag) or one
@@ -222,17 +209,40 @@ func (a *App) StartLogin() {
flow := platform.NewFlow(cfg, func(u string) {
runtime.BrowserOpenURL(a.ctx, u)
})
tok, err := flow.Login(a.ctx)
// Step 1: device-authorize → a bootstrap machine token (proves identity, no meta).
boot, err := flow.Login(a.ctx)
if err != nil {
runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()})
return
}
res, err := loginResultFromToken(tok)
// Step 2: 代铸 the bootstrap token into a cdrop-managed device session bound to this
// device's stable device_id, so the desktop is managed exactly like a browser. The
// device session's tokens supersede the bootstrap (which is discarded, left to expire).
deviceID, err := platform.ResolveDeviceID()
if err != nil {
runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()})
return
}
a.token = tok
ds, err := platform.MintDeviceSession(a.ctx, a.apiBase, boot.AccessToken, deviceID, platform.ResolveDeviceName(), platform.DeviceType())
if err != nil {
runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()})
return
}
// Identity from the verified 代铸 response (X-Auth-Subject / X-Auth-Name / X-Auth-Avatar),
// so the real display name and picture show rather than the subject UUID a nameless
// machine token yields.
user := platform.UserInfo{ID: ds.UserID, Name: ds.Name, Avatar: ds.Avatar}
if user.Name == "" {
user.Name = user.ID
}
res := &platform.LoginResult{
AccessToken: ds.AccessToken,
RefreshToken: ds.RefreshToken,
ExpiresIn: ds.ExpiresIn,
User: user,
}
a.token = &platform.TokenResult{AccessToken: ds.AccessToken, RefreshToken: ds.RefreshToken, ExpiresIn: ds.ExpiresIn}
a.user = user
if err := platform.SaveSession(*res); err != nil {
runtime.LogWarningf(a.ctx, "persist session failed: %v", err)
}
@@ -262,9 +272,15 @@ func (a *App) Refresh() (*platform.SessionView, error) {
if tok.RefreshToken == "" {
tok.RefreshToken = a.token.RefreshToken // providers that don't rotate: keep the old one
}
res, err := loginResultFromToken(tok)
if err != nil {
return nil, err
// Preserve the identity established at login: the refreshed access token is a nameless
// machine token (it carries meta, not the display name), so re-deriving identity from it
// would regress the name to the subject UUID. The device session keeps its meta across
// rotation, so the device stays the same managed device.
res := &platform.LoginResult{
AccessToken: tok.AccessToken,
RefreshToken: tok.RefreshToken,
ExpiresIn: tok.ExpiresIn,
User: a.user,
}
a.token = tok
if err := platform.SaveSession(*res); err != nil {
@@ -368,11 +384,11 @@ func (a *App) EffectiveDownloadDir() string {
return platform.ResolveDownloadDir()
}
// resolveOAuthConfig prefers explicit env overrides (dev / a pinned client);
// otherwise it fetches /api/auth/config from the backend and reuses the web
// app's client_id for the desktop's loopback PKCE flow (see desktop/PLAN.md §3).
// resolveOAuthConfig prefers explicit env overrides (dev / a pinned broker);
// otherwise it fetches /api/auth/config from the backend for the broker's public URL,
// then runs the broker device-authorization flow against it.
func (a *App) resolveOAuthConfig() (platform.OAuthConfig, error) {
if env := oauthConfigFromEnv(); env.ClientID != "" {
if env := oauthConfigFromEnv(); env.BrokerURL != "" {
return env, nil
}
return platform.FetchOAuthConfig(a.ctx, a.apiBase)
@@ -380,10 +396,8 @@ func (a *App) resolveOAuthConfig() (platform.OAuthConfig, error) {
func oauthConfigFromEnv() platform.OAuthConfig {
return platform.OAuthConfig{
AuthorizeURL: os.Getenv("CDROP_OAUTH_AUTHORIZE_URL"),
TokenURL: os.Getenv("CDROP_OAUTH_TOKEN_URL"),
ClientID: os.Getenv("CDROP_OAUTH_CLIENT_ID"),
Scopes: os.Getenv("CDROP_OAUTH_SCOPES"),
BrokerURL: os.Getenv("CDROP_BROKER_URL"),
App: envOr("CDROP_BROKER_APP", "cdrop"),
}
}
+7 -9
View File
@@ -44,20 +44,18 @@ func main() {
// Tell the backend this is a native desktop client, not a browser, so the
// device list shows the right kind. The page forwards it as X-Device-Type.
deviceType := ""
switch runtime.GOOS {
case "darwin":
deviceType = "macos"
case "windows":
deviceType = "windows"
case "linux":
deviceType = "linux"
}
deviceType := platform.DeviceType()
// Restore the persisted login + device name by injecting them into index.html
// at load — the wails:// scheme has no persistent WebView storage (see
// session.go).
session, _ := platform.LoadSession()
// Heal a stale display identity (older builds baked the subject UUID and no avatar into
// the on-disk session) from the authoritative /api/me, and re-persist, BEFORE injecting it
// — so the name + picture are correct on every launch instead of depending on a fragile
// per-launch WebView /api/me (the source of the intermittent UUID regression). Bounded +
// best-effort: offline keeps the persisted identity.
session = platform.HealIdentity(session, app.apiBase)
// When the OS starts us at login, the autostart entry appends a --hidden flag
// (see SetLaunchAtLogin). Come up straight to the menu bar / tray with no
+41
View File
@@ -1,10 +1,13 @@
package platform
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"os"
"path/filepath"
"runtime"
)
// DesktopConfig is the desktop-only preference set, persisted as JSON under the
@@ -14,6 +17,44 @@ type DesktopConfig struct {
LaunchAtLogin bool `json:"launch_at_login"`
DeviceName string `json:"device_name"` // empty = use the hostname
DownloadDir string `json:"download_dir"` // empty = system Downloads dir
// DeviceID is this device's stable opaque cdrop id — the broker `meta` and the
// session<->device join key. Minted once on first login and persisted so re-logins
// rotate the one managed device session (broker R2) instead of spawning duplicates.
DeviceID string `json:"device_id,omitempty"`
}
// DeviceType maps the host OS to the cdrop device kind, shown in the unified device list
// and sent as device_type when 代铸ing this device's session.
func DeviceType() string {
switch runtime.GOOS {
case "darwin":
return "macos"
case "windows":
return "windows"
case "linux":
return "linux"
default:
return "browser"
}
}
// ResolveDeviceID returns the persisted stable device_id, minting and persisting a fresh
// one ("dev_" + base64url(16 random bytes)) on first use. The format matches the backend's
// newDeviceID, so it passes validDeviceID and the broker's meta validation.
func ResolveDeviceID() (string, error) {
cfg, _ := LoadConfig()
if cfg.DeviceID != "" {
return cfg.DeviceID, nil
}
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
cfg.DeviceID = "dev_" + base64.RawURLEncoding.EncodeToString(b)
if err := SaveConfig(cfg); err != nil {
return "", err
}
return cfg.DeviceID, nil
}
// ResolveDeviceName returns the persisted device name, or the hostname when the
+13 -16
View File
@@ -10,11 +10,11 @@ import (
"time"
)
// FetchOAuthConfig pulls the provider coordinates from the cdrop backend's
// /api/auth/config. The desktop reuses the same OAuth client as the web app
// (the client_id published there) and runs its own loopback PKCE flow against
// the provider, instead of the browser's in-page redirect + /api/auth/exchange
// proxy. apiBase is the backend origin, e.g. https://drop.commilitia.net.
// FetchOAuthConfig pulls the Auth Broker coordinates from the cdrop backend's
// /api/auth/config (broker_url = the broker's public origin). The desktop runs the
// broker device-authorization flow (RFC 8252 loopback PKCE) against that broker,
// minting a cdrop-scoped machine token. apiBase is the backend origin, e.g.
// https://drop.commilitia.net.
func FetchOAuthConfig(ctx context.Context, apiBase string) (OAuthConfig, error) {
endpoint := strings.TrimRight(apiBase, "/") + "/api/auth/config"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
@@ -34,23 +34,20 @@ func FetchOAuthConfig(ctx context.Context, apiBase string) (OAuthConfig, error)
}
var c struct {
AuthorizeURL string `json:"authorize_url"`
TokenURL string `json:"token_url"`
ClientID string `json:"client_id"`
Scopes string `json:"scopes"`
BrokerURL string `json:"broker_url"`
App string `json:"broker_app"`
}
if err := json.NewDecoder(resp.Body).Decode(&c); err != nil {
return OAuthConfig{}, fmt.Errorf("oauth: decode config: %w", err)
}
cfg := OAuthConfig{
AuthorizeURL: c.AuthorizeURL,
TokenURL: c.TokenURL,
ClientID: c.ClientID,
Scopes: c.Scopes,
app := c.App
if app == "" {
app = "cdrop"
}
if cfg.AuthorizeURL == "" || cfg.TokenURL == "" || cfg.ClientID == "" {
return OAuthConfig{}, errors.New("oauth: backend config missing authorize_url / token_url / client_id")
cfg := OAuthConfig{BrokerURL: c.BrokerURL, App: app}
if cfg.BrokerURL == "" {
return OAuthConfig{}, errors.New("oauth: backend config missing broker_url")
}
return cfg, nil
}
+24 -19
View File
@@ -16,11 +16,8 @@ func TestFetchOAuthConfig_Success(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"auth_mode": "prod",
"authorize_url": "https://casdoor.example/login/oauth/authorize",
"token_url": "https://casdoor.example/api/login/oauth/access_token",
"client_id": "web-client-id",
"redirect_uri": "https://drop.example/oauth/callback",
"scopes": "openid profile groups",
"broker_url": "https://sso.example.net",
"broker_app": "cdrop",
})
}))
defer srv.Close()
@@ -30,30 +27,39 @@ func TestFetchOAuthConfig_Success(t *testing.T) {
if err != nil {
t.Fatalf("FetchOAuthConfig: %v", err)
}
if cfg.ClientID != "web-client-id" {
t.Errorf("client_id = %q, want web-client-id (reused web client)", cfg.ClientID)
if cfg.BrokerURL != "https://sso.example.net" {
t.Errorf("broker_url = %q", cfg.BrokerURL)
}
if cfg.TokenURL != "https://casdoor.example/api/login/oauth/access_token" {
t.Errorf("token_url = %q", cfg.TokenURL)
if cfg.App != "cdrop" {
t.Errorf("app = %q, want cdrop", cfg.App)
}
if cfg.Scopes != "openid profile groups" {
t.Errorf("scopes = %q", cfg.Scopes)
}
func TestFetchOAuthConfig_DefaultsApp(t *testing.T) {
// broker_app omitted → defaults to "cdrop".
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"broker_url": "https://sso.example.net"})
}))
defer srv.Close()
cfg, err := FetchOAuthConfig(context.Background(), srv.URL)
if err != nil {
t.Fatalf("FetchOAuthConfig: %v", err)
}
if cfg.App != "cdrop" {
t.Errorf("app = %q, want default cdrop", cfg.App)
}
}
func TestFetchOAuthConfig_Incomplete(t *testing.T) {
// backend missing token_url / client_id (e.g. dev mode) must be rejected.
// backend missing broker_url (native login not configured) must be rejected.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"auth_mode": "dev",
"authorize_url": "https://casdoor.example/login/oauth/authorize",
})
_ = json.NewEncoder(w).Encode(map[string]any{"auth_mode": "prod"})
}))
defer srv.Close()
if _, err := FetchOAuthConfig(context.Background(), srv.URL); err == nil {
t.Fatal("want error for incomplete backend config, got nil")
t.Fatal("want error for missing broker_url, got nil")
}
}
@@ -62,7 +68,6 @@ func TestFetchOAuthConfig_BadStatus(t *testing.T) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
if _, err := FetchOAuthConfig(context.Background(), srv.URL); err == nil {
t.Fatal("want error for 500 status, got nil")
}
+89
View File
@@ -0,0 +1,89 @@
package platform
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
)
// 代铸 (proxy-mint) on the desktop. The device-authorization flow yields a bootstrap
// machine token (scope app:cdrop, no meta) that proves the user's identity but is not a
// cdrop-managed device. This call exchanges it for a managed device session bound to this
// device's stable device_id, so the desktop joins cdrop's unified device list and is
// managed exactly like a browser — the same model, not a separate native-only track.
// DeviceSessionResult is the managed device session minted by 代铸: the device tokens that
// supersede the bootstrap token, plus the verified identity (so a native client, which only
// ever sees nameless machine tokens, shows the real display name instead of the sub UUID).
type DeviceSessionResult struct {
AccessToken string
RefreshToken string
ExpiresIn int
DeviceID string
UserID string
Name string
Avatar string
}
// MintDeviceSession POSTs to {apiBase}/api/auth/device-session with the bootstrap token as
// Bearer; cdrop vouches for the verified subject and has the broker mint (R2-idempotent by
// user+app+meta) a session bound to deviceID. Re-login with the same deviceID rotates the
// one session rather than piling up duplicates.
func MintDeviceSession(ctx context.Context, apiBase, bootstrapToken, deviceID, deviceName, deviceType string) (*DeviceSessionResult, error) {
body, err := json.Marshal(map[string]string{
"device_id": deviceID,
"device_name": deviceName,
"device_type": deviceType,
})
if err != nil {
return nil, fmt.Errorf("device-session: marshal: %w", err)
}
endpoint := strings.TrimRight(apiBase, "/") + "/api/auth/device-session"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("device-session: build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+bootstrapToken)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("device-session: request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("device-session: status %d", resp.StatusCode)
}
var dr struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
DeviceID string `json:"device_id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Avatar string `json:"avatar"`
}
if err := json.NewDecoder(resp.Body).Decode(&dr); err != nil {
return nil, fmt.Errorf("device-session: decode: %w", err)
}
if dr.AccessToken == "" {
return nil, errors.New("device-session: response missing access token")
}
return &DeviceSessionResult{
AccessToken: dr.AccessToken,
RefreshToken: dr.RefreshToken,
ExpiresIn: dr.ExpiresIn,
DeviceID: dr.DeviceID,
UserID: dr.UserID,
Name: dr.Name,
Avatar: dr.Avatar,
}, nil
}
+81
View File
@@ -0,0 +1,81 @@
package platform
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// MintDeviceSession sends the bootstrap token as Bearer + the device descriptor, and maps
// the response into the device-session result the desktop then rides.
func TestMintDeviceSession(t *testing.T) {
var gotAuth string
var gotBody map[string]string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/auth/device-session" {
http.NotFound(w, r)
return
}
gotAuth = r.Header.Get("Authorization")
_ = json.NewDecoder(r.Body).Decode(&gotBody)
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "acc-x", "refresh_token": "rtk-x", "expires_in": 900,
"device_id": "dev_abc", "user_id": "sub-1", "name": "Commilitia",
"avatar": "https://example.net/a.png",
})
}))
defer srv.Close()
res, err := MintDeviceSession(context.Background(), srv.URL, "boot-token", "dev_abc", "Mac", "macos")
if err != nil {
t.Fatalf("MintDeviceSession: %v", err)
}
if gotAuth != "Bearer boot-token" {
t.Errorf("Authorization: got %q, want Bearer boot-token", gotAuth)
}
if gotBody["device_id"] != "dev_abc" || gotBody["device_name"] != "Mac" || gotBody["device_type"] != "macos" {
t.Errorf("request body: %+v", gotBody)
}
if res.AccessToken != "acc-x" || res.RefreshToken != "rtk-x" || res.DeviceID != "dev_abc" ||
res.UserID != "sub-1" || res.Name != "Commilitia" || res.ExpiresIn != 900 ||
res.Avatar != "https://example.net/a.png" {
t.Errorf("result: %+v", res)
}
}
// A non-200 from the endpoint surfaces as an error rather than a half-built session.
func TestMintDeviceSession_ErrorStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}))
defer srv.Close()
if _, err := MintDeviceSession(context.Background(), srv.URL, "boot", "dev_abc", "Mac", "macos"); err == nil {
t.Fatal("expected error on non-200, got nil")
}
}
// ResolveDeviceID mints a validMeta-safe "dev_"-prefixed id and persists it (stable across
// calls). Uses an isolated config dir so it doesn't touch the real one.
func TestResolveDeviceID(t *testing.T) {
// os.UserConfigDir honours XDG_CONFIG_HOME on Linux and HOME on macOS.
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv("HOME", dir)
id, err := ResolveDeviceID()
if err != nil {
t.Fatalf("ResolveDeviceID: %v", err)
}
if !strings.HasPrefix(id, "dev_") || len(id) < 8 {
t.Errorf("device id format: %q", id)
}
again, err := ResolveDeviceID()
if err != nil {
t.Fatalf("ResolveDeviceID (2): %v", err)
}
if again != id {
t.Errorf("device id not stable: %q != %q", again, id)
}
}
+104
View File
@@ -0,0 +1,104 @@
package platform
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strings"
"time"
)
// HealIdentity refreshes a persisted session's display identity (name + avatar) from the
// authoritative /api/me and re-persists it, BEFORE the session is injected into the WebView.
//
// Why: a session minted by an older build derived identity from the broker's nameless machine
// token, baking the subject UUID (and no avatar) into the on-disk session. The newer login path
// records the real name/picture, but an already-logged-in user keeps the stale session. Correcting
// it only in the WebView (per launch, via /api/me) is fragile — when the boot access token has
// expired and the refresh is momentarily flaky, the correction silently fails and the UUID shows
// again (the reported intermittent regression). Healing the persisted session here fixes it at the
// source: once a single launch succeeds, the on-disk identity is correct and every later boot is
// correct without any /api/me dependency.
//
// Synchronous and bounded so it can run before wails.Run; best-effort — on any network failure it
// returns the session unchanged (offline simply keeps whatever was last persisted, which a prior
// successful heal already corrected). If the access token has expired it refreshes once (and the
// rotated refresh token is persisted so it is not lost).
func HealIdentity(s *LoginResult, apiBase string) *LoginResult {
if s == nil || s.AccessToken == "" {
return s
}
// Once healed, the persisted name is a real display name (not the subject UUID); skip the
// /api/me round-trip then so it costs nothing on every later launch. A stale session (name
// still equal to the UUID) falls through and heals — fetching the avatar in the same pass.
if s.User.Name != "" && s.User.Name != s.User.ID {
return s
}
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
info, status := fetchMe(ctx, apiBase, s.AccessToken)
tokenRotated := false
if status == http.StatusUnauthorized && s.RefreshToken != "" {
if cfg, err := FetchOAuthConfig(ctx, apiBase); err == nil {
if tok, rerr := NewFlow(cfg, func(string) {}).Refresh(ctx, s.RefreshToken); rerr == nil && tok.AccessToken != "" {
s.AccessToken = tok.AccessToken
if tok.RefreshToken != "" {
s.RefreshToken = tok.RefreshToken // broker rotates refresh; the old one is now dead
}
s.ExpiresIn = tok.ExpiresIn
tokenRotated = true
info, status = fetchMe(ctx, apiBase, s.AccessToken)
}
}
}
changed := tokenRotated // a rotated refresh token must be persisted, regardless of the heal outcome
if status == http.StatusOK && info.ID != "" {
// Replace a stale name only with a real display name (not the subject UUID).
if info.Name != "" && info.Name != info.ID && info.Name != s.User.Name {
s.User.Name = info.Name
changed = true
}
if info.Avatar != "" && info.Avatar != s.User.Avatar {
s.User.Avatar = info.Avatar
changed = true
}
}
if changed {
if err := SaveSession(*s); err != nil {
slog.Warn("cdrop: heal identity save failed", "err", err)
}
}
return s
}
// fetchMe GETs /api/me with the access token, returning the identity and HTTP status (0 on a
// transport error). It hits the public origin directly (no WebView proxy needed here).
func fetchMe(ctx context.Context, apiBase, accessToken string) (UserInfo, int) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(apiBase, "/")+"/api/me", nil)
if err != nil {
return UserInfo{}, 0
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/json")
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
return UserInfo{}, 0
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return UserInfo{}, resp.StatusCode
}
var m struct {
UserID string `json:"user_id"`
Name string `json:"name"`
Avatar string `json:"avatar"`
}
if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
return UserInfo{}, 0
}
return UserInfo{ID: m.UserID, Name: m.Name, Avatar: m.Avatar}, http.StatusOK
}
+66
View File
@@ -0,0 +1,66 @@
package platform
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
keyring "github.com/zalando/go-keyring"
)
// A session whose name is still the subject UUID (minted by an older build) heals from
// /api/me — name + avatar corrected and re-persisted, so later launches are correct without
// any further /api/me.
func TestHealIdentity_StaleNameHealed(t *testing.T) {
keyring.MockInit()
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv("HOME", dir)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/me" {
_ = json.NewEncoder(w).Encode(map[string]any{
"user_id": "sub-1", "name": "Commilitia", "avatar": "https://a.net/x.png",
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
s := &LoginResult{
AccessToken: "acc", RefreshToken: "rtk", ExpiresIn: 900,
User: UserInfo{ID: "sub-1", Name: "sub-1"}, // stale: name == subject UUID
}
out := HealIdentity(s, srv.URL)
if out.User.Name != "Commilitia" {
t.Errorf("name: got %q, want Commilitia", out.User.Name)
}
if out.User.Avatar != "https://a.net/x.png" {
t.Errorf("avatar: got %q, want the /api/me avatar", out.User.Avatar)
}
loaded, err := LoadSession()
if err != nil || loaded == nil || loaded.User.Name != "Commilitia" || loaded.User.Avatar != "https://a.net/x.png" {
t.Errorf("heal not persisted: %+v (err %v)", loaded, err)
}
}
// A session that already has a real display name is left untouched and never hits /api/me.
func TestHealIdentity_HealthyNameSkips(t *testing.T) {
called := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
s := &LoginResult{AccessToken: "acc", User: UserInfo{ID: "sub-1", Name: "Commilitia"}}
out := HealIdentity(s, srv.URL)
if called {
t.Error("a healthy identity must skip the /api/me round-trip")
}
if out.User.Name != "Commilitia" {
t.Errorf("name changed unexpectedly: %q", out.User.Name)
}
}
+69 -68
View File
@@ -1,10 +1,12 @@
// Package platform implements cdrop desktop's native capabilities — the Go
// shell that the shared web UI drives over Wails bindings.
//
// This file owns the OAuth login flow: an RFC 8252 loopback redirect against
// Casdoor with PKCE as a public client (no secret). The authorization code
// exchange happens here in Go on purpose, so the PKCE verifier and the
// resulting tokens never enter the WebView / JS context.
// This file owns the login flow: the Auth Broker device-authorization flow (RFC
// 8252 loopback redirect with PKCE, as a public client no secret). The desktop
// opens the system browser at the broker's consent page; on approval the broker
// redirects to the loopback /callback with a one-time code, which Go exchanges
// (with the PKCE verifier) for a broker machine token. The verifier and the
// resulting tokens never enter the WebView / JS context — refresh is Go-driven.
package platform
import (
@@ -22,29 +24,25 @@ import (
"time"
)
// OAuthConfig carries the provider coordinates. For Casdoor the token endpoint
// is /api/login/oauth/access_token (non-standard) and authorize is
// /login/oauth/authorize; the desktop registers as its own public client.
// OAuthConfig carries the Auth Broker coordinates. BrokerURL is the broker's PUBLIC
// origin (e.g. https://sso.commilitia.net); App is this app's key in the broker apps
// registry ("cdrop"). The app's loopback redirect must be registered in the broker's
// apps.json (redirect_uris) for the device flow to accept it.
type OAuthConfig struct {
AuthorizeURL string
TokenURL string
ClientID string
Scopes string // space-separated; empty defaults to "openid profile groups"
BrokerURL string
App string
}
// TokenResult is the subset of the token response the shell keeps. IDToken is
// captured so the identity can be decoded from the richer id_token claims (it's
// present when the openid scope is granted — our default scopes include it).
// TokenResult is the subset of the broker token response the shell keeps. ExpiresIn
// is derived from the broker's access_expires (unix) at parse time.
type TokenResult struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
AccessToken string
RefreshToken string
ExpiresIn int
}
// Flow runs one interactive login. openURL is injected so production uses
// Wails' runtime.BrowserOpenURL while tests substitute a fake user-agent.
// Flow runs one interactive login. openURL is injected so production uses Wails'
// runtime.BrowserOpenURL while tests substitute a fake user-agent.
type Flow struct {
cfg OAuthConfig
openURL func(string)
@@ -63,15 +61,17 @@ func NewFlow(cfg OAuthConfig, openURL func(string)) *Flow {
}
}
// Login performs the loopback PKCE flow and returns the exchanged tokens.
// Login performs the broker device-authorization loopback PKCE flow and returns the
// minted machine token.
//
// Steps: generate verifier/challenge/state → bind an ephemeral 127.0.0.1
// listener → open the system browser at the authorize URL → wait for the
// browser to hit the loopback /callback with the code → validate state →
// exchange code+verifier at the token endpoint. The verifier never leaves Go.
// Steps: generate verifier/challenge/state → bind an ephemeral 127.0.0.1 listener →
// open the system browser at the broker's /device/authorize (which sends the user
// through broker SSO if needed, then shows a consent page) → wait for the browser to
// hit the loopback /callback with the code → validate state → exchange code+verifier
// at /device/token. The verifier never leaves Go.
func (f *Flow) Login(ctx context.Context) (*TokenResult, error) {
if f.cfg.AuthorizeURL == "" || f.cfg.TokenURL == "" || f.cfg.ClientID == "" {
return nil, errors.New("oauth: incomplete config (authorize_url / token_url / client_id)")
if f.cfg.BrokerURL == "" || f.cfg.App == "" {
return nil, errors.New("oauth: incomplete config (broker_url / app)")
}
verifier, challenge, err := newPKCE()
@@ -132,14 +132,13 @@ func (f *Flow) Login(ctx context.Context) (*TokenResult, error) {
go func() { _ = srv.Serve(ln) }()
defer srv.Close()
authURL := f.cfg.AuthorizeURL + "?" + url.Values{
"response_type": {"code"},
"client_id": {f.cfg.ClientID},
authURL := strings.TrimRight(f.cfg.BrokerURL, "/") + "/device/authorize?" + url.Values{
"app": {f.cfg.App},
"redirect_uri": {redirectURI},
"scope": {f.scopes()},
"state": {state},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
"description": {"cdrop 桌面客户端"},
}.Encode()
f.openURL(authURL)
@@ -156,52 +155,50 @@ func (f *Flow) Login(ctx context.Context) (*TokenResult, error) {
}
}
func (f *Flow) scopes() string {
if s := strings.TrimSpace(f.cfg.Scopes); s != "" {
return s
}
return "openid profile groups"
}
// exchange trades the authorization code + PKCE verifier for tokens. Casdoor
// accepts form-encoded public-client requests (no client_secret). authorize and
// token must carry the identical redirect_uri, so we pass the same value.
// exchange trades the authorization code + PKCE verifier (+ the same redirect_uri)
// for the broker machine token at /device/token.
func (f *Flow) exchange(ctx context.Context, code, verifier, redirectURI string) (*TokenResult, error) {
return f.postToken(ctx, url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"code_verifier": {verifier},
"client_id": {f.cfg.ClientID},
"redirect_uri": {redirectURI},
return f.postJSON(ctx, strings.TrimRight(f.cfg.BrokerURL, "/")+"/device/token", map[string]string{
"code": code,
"code_verifier": verifier,
"redirect_uri": redirectURI,
})
}
// Refresh exchanges a refresh_token for a fresh token (public client, no
// secret). Call it when the access token nears expiry; the access token never
// leaves Go, so refresh is driven from the shell, not the WebView.
// Refresh exchanges a refresh credential for a fresh access token (+ rotated refresh)
// at the broker's /refresh. Call it when the access token nears expiry; the refresh
// credential never leaves Go, so refresh is driven from the shell, not the WebView.
func (f *Flow) Refresh(ctx context.Context, refreshToken string) (*TokenResult, error) {
if f.cfg.TokenURL == "" || f.cfg.ClientID == "" {
return nil, errors.New("oauth: incomplete config (token_url / client_id)")
if f.cfg.BrokerURL == "" {
return nil, errors.New("oauth: incomplete config (broker_url)")
}
if refreshToken == "" {
return nil, errors.New("oauth: empty refresh token")
}
return f.postToken(ctx, url.Values{
"grant_type": {"refresh_token"},
"refresh_token": {refreshToken},
"client_id": {f.cfg.ClientID},
"scope": {f.scopes()},
return f.postJSON(ctx, strings.TrimRight(f.cfg.BrokerURL, "/")+"/refresh", map[string]string{
"refresh": refreshToken,
})
}
// postToken POSTs a form-encoded request to the token endpoint and decodes the
// response. Shared by the authorization-code exchange and refresh.
func (f *Flow) postToken(ctx context.Context, form url.Values) (*TokenResult, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, f.cfg.TokenURL, strings.NewReader(form.Encode()))
// brokerTokenResp is the broker's response shape for /device/token and /refresh.
type brokerTokenResp struct {
Access string `json:"access"`
Refresh string `json:"refresh"`
AccessExpires int64 `json:"access_expires"`
}
// postJSON POSTs a JSON body to a broker token endpoint and decodes the response.
// Shared by the device-token exchange and refresh.
func (f *Flow) postJSON(ctx context.Context, endpoint string, body map[string]string) (*TokenResult, error) {
buf, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("oauth: marshal token request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(buf)))
if err != nil {
return nil, fmt.Errorf("oauth: build token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := f.client.Do(req)
@@ -212,14 +209,18 @@ func (f *Flow) postToken(ctx context.Context, form url.Values) (*TokenResult, er
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("oauth: token endpoint status %d", resp.StatusCode)
}
var tok TokenResult
if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil {
var tr brokerTokenResp
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
return nil, fmt.Errorf("oauth: decode token: %w", err)
}
if tok.AccessToken == "" {
return nil, errors.New("oauth: token response missing access_token")
if tr.Access == "" {
return nil, errors.New("oauth: token response missing access")
}
return &tok, nil
expiresIn := int(tr.AccessExpires - time.Now().Unix())
if expiresIn < 0 {
expiresIn = 0
}
return &TokenResult{AccessToken: tr.Access, RefreshToken: tr.Refresh, ExpiresIn: expiresIn}, nil
}
// --- PKCE (RFC 7636) ---
+64 -70
View File
@@ -10,6 +10,7 @@ import (
"net/url"
"strings"
"testing"
"time"
)
func TestNewPKCE(t *testing.T) {
@@ -32,45 +33,55 @@ func TestNewPKCE(t *testing.T) {
}
}
// TestLogin_Success drives the whole loopback flow with a fake Casdoor token
// endpoint and a fake browser, with no real network or prod dependency.
func TestLogin_Success(t *testing.T) {
var gotForm url.Values
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
t.Errorf("token endpoint ParseForm: %v", err)
// decodeBody reads a JSON request body into a map.
func decodeBody(t *testing.T, r *http.Request) map[string]string {
t.Helper()
var m map[string]string
if err := json.NewDecoder(r.Body).Decode(&m); err != nil {
t.Fatalf("decode body: %v", err)
}
gotForm = r.PostForm
return m
}
// TestLogin_Success drives the whole broker device-authorization loopback flow with a
// fake /device/token endpoint and a fake browser — no real network or prod dependency.
func TestLogin_Success(t *testing.T) {
var gotBody map[string]string
brokerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/device/token" {
t.Errorf("token path = %s, want /device/token", r.URL.Path)
}
gotBody = decodeBody(t, r)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "at-123",
"refresh_token": "rt-456",
"expires_in": 3600,
"token_type": "Bearer",
"id": "sid-1",
"app": "cdrop",
"access": "at-123",
"refresh": "rtk-456",
"access_expires": time.Now().Add(15 * time.Minute).Unix(),
})
}))
defer tokenSrv.Close()
defer brokerSrv.Close()
cfg := OAuthConfig{
AuthorizeURL: "https://casdoor.example/login/oauth/authorize",
TokenURL: tokenSrv.URL,
ClientID: "cdrop-desktop",
}
cfg := OAuthConfig{BrokerURL: brokerSrv.URL, App: "cdrop"}
// Fake browser: parse the authorize URL, assert it carries PKCE, then GET
// the loopback redirect with a code + the same state (authorized).
// Fake browser: parse the /device/authorize URL, assert it carries app + PKCE,
// then GET the loopback redirect with a code + the same state (approved).
openURL := func(authURL string) {
u, err := url.Parse(authURL)
if err != nil {
t.Errorf("parse authURL: %v", err)
return
}
if !strings.HasSuffix(u.Path, "/device/authorize") {
t.Errorf("authorize path = %s, want /device/authorize", u.Path)
}
q := u.Query()
if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" {
t.Errorf("authorize request missing PKCE challenge: %v", q)
}
if q.Get("client_id") != "cdrop-desktop" {
t.Errorf("authorize client_id = %q", q.Get("client_id"))
if q.Get("app") != "cdrop" {
t.Errorf("authorize app = %q", q.Get("app"))
}
cb := q.Get("redirect_uri") + "?code=auth-code-xyz&state=" + url.QueryEscape(q.Get("state"))
resp, err := http.Get(cb)
@@ -86,37 +97,29 @@ func TestLogin_Success(t *testing.T) {
t.Fatalf("Login: %v", err)
}
if tok.AccessToken != "at-123" {
t.Errorf("access_token = %q, want at-123", tok.AccessToken)
t.Errorf("access = %q, want at-123", tok.AccessToken)
}
if tok.RefreshToken != "rt-456" {
t.Errorf("refresh_token = %q, want rt-456", tok.RefreshToken)
if tok.RefreshToken != "rtk-456" {
t.Errorf("refresh = %q, want rtk-456", tok.RefreshToken)
}
if tok.ExpiresIn != 3600 {
t.Errorf("expires_in = %d, want 3600", tok.ExpiresIn)
if tok.ExpiresIn <= 0 || tok.ExpiresIn > 900 {
t.Errorf("expires_in = %d, want ~900 (derived from access_expires)", tok.ExpiresIn)
}
// The exchange must carry the code + PKCE verifier and, as a public client,
// no client_secret.
if gotForm.Get("grant_type") != "authorization_code" {
t.Errorf("grant_type = %q", gotForm.Get("grant_type"))
// The exchange must carry the code, the PKCE verifier, and the same redirect_uri.
if gotBody["code"] != "auth-code-xyz" {
t.Errorf("code = %q", gotBody["code"])
}
if gotForm.Get("code") != "auth-code-xyz" {
t.Errorf("code = %q", gotForm.Get("code"))
}
if gotForm.Get("code_verifier") == "" {
if gotBody["code_verifier"] == "" {
t.Error("token exchange missing code_verifier")
}
if gotForm.Get("client_secret") != "" {
t.Error("public client must not send client_secret")
if !strings.HasPrefix(gotBody["redirect_uri"], "http://127.0.0.1:") {
t.Errorf("redirect_uri = %q, want loopback", gotBody["redirect_uri"])
}
}
func TestLogin_StateMismatch(t *testing.T) {
cfg := OAuthConfig{
AuthorizeURL: "https://casdoor.example/login/oauth/authorize",
TokenURL: "https://casdoor.example/token",
ClientID: "cdrop-desktop",
}
cfg := OAuthConfig{BrokerURL: "https://sso.example.net", App: "cdrop"}
openURL := func(authURL string) {
u, _ := url.Parse(authURL)
redirect := u.Query().Get("redirect_uri")
@@ -132,55 +135,46 @@ func TestLogin_StateMismatch(t *testing.T) {
}
func TestLogin_IncompleteConfig(t *testing.T) {
_, err := NewFlow(OAuthConfig{ClientID: "only-id"}, func(string) {}).Login(context.Background())
_, err := NewFlow(OAuthConfig{App: "only-app"}, func(string) {}).Login(context.Background())
if err == nil {
t.Fatal("want error for incomplete config, got nil")
}
}
func TestRefresh_Success(t *testing.T) {
var gotForm url.Values
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
gotForm = r.PostForm
var gotBody map[string]string
brokerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/refresh" {
t.Errorf("refresh path = %s, want /refresh", r.URL.Path)
}
gotBody = decodeBody(t, r)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "new-at",
"refresh_token": "new-rt",
"expires_in": 3600,
"access": "new-at",
"refresh": "new-rtk",
"access_expires": time.Now().Add(15 * time.Minute).Unix(),
})
}))
defer tokenSrv.Close()
defer brokerSrv.Close()
cfg := OAuthConfig{
AuthorizeURL: "https://casdoor.example/login/oauth/authorize",
TokenURL: tokenSrv.URL,
ClientID: "cdrop-desktop",
}
tok, err := NewFlow(cfg, func(string) {}).Refresh(context.Background(), "old-rt")
cfg := OAuthConfig{BrokerURL: brokerSrv.URL, App: "cdrop"}
tok, err := NewFlow(cfg, func(string) {}).Refresh(context.Background(), "old-rtk")
if err != nil {
t.Fatalf("Refresh: %v", err)
}
if tok.AccessToken != "new-at" {
t.Errorf("access_token = %q, want new-at", tok.AccessToken)
t.Errorf("access = %q, want new-at", tok.AccessToken)
}
if gotForm.Get("grant_type") != "refresh_token" {
t.Errorf("grant_type = %q", gotForm.Get("grant_type"))
if tok.RefreshToken != "new-rtk" {
t.Errorf("refresh = %q, want new-rtk (rotated)", tok.RefreshToken)
}
if gotForm.Get("refresh_token") != "old-rt" {
t.Errorf("refresh_token = %q", gotForm.Get("refresh_token"))
}
if gotForm.Get("client_secret") != "" {
t.Error("public client must not send client_secret on refresh")
if gotBody["refresh"] != "old-rtk" {
t.Errorf("sent refresh = %q, want old-rtk", gotBody["refresh"])
}
}
func TestRefresh_EmptyToken(t *testing.T) {
cfg := OAuthConfig{
AuthorizeURL: "https://x/authorize",
TokenURL: "https://x/token",
ClientID: "c",
}
cfg := OAuthConfig{BrokerURL: "https://sso.example.net", App: "cdrop"}
if _, err := NewFlow(cfg, func(string) {}).Refresh(context.Background(), ""); err == nil {
t.Fatal("want error for empty refresh token")
}
+58 -6
View File
@@ -1,12 +1,64 @@
# 并入你的主 Caddyfile(站点 block)。换上你的域名与服务名。
# cdropdrop.<your-domain>)的 Caddy 接线示例。换上你的域名、cdrop 服务名(NN-cdrop)、
# broker 内网地址(BROKER-IP:PORT)。
#
# cdrop 迁移到 Auth Broker(路径 A)后,边缘由 broker /verify 终结鉴权并注入 X-Auth-* 头:
# - 含 Authorization: Bearer 的请求 → 机器分支(QR 配对设备 / iOS / 桌面,持 broker 机器令牌)
# - 不含 Bearer 的请求 → 人类分支(全新浏览器全局 SSO,带 broker 域 cookie
# - 一组公开端点必须放行不鉴权,否则未登录设备无从登录 / 刷新。
#
# 前置:用 xcaddy 把 caddybroker 编进 caddy-custom,并在全局块声明
# { order broker before reverse_proxy }
# 否则 `handle { broker ... }` 报 "not an ordered HTTP handler"。
#
# 关键开关:
# - flush_interval -1 :禁用 reverse_proxy 对 SSE 长响应的缓冲,让探测帧
# 与 ping 立即送达浏览器;任何反代器忽略此项都会让 /api/hub/events 看似无事件
# - h3 建议在 global block 启用(站点级不必重复声明)
# - NN-cdrop 中的 NN 与 compose 服务条目保持一致
# - flush_interval -1 :禁用 reverse_proxy 对 SSE 长响应的缓冲,否则 /api/hub/events 看似无事件
# - copy_headers 须含 X-Auth-Metacdrop 据它定位托管设备);caddybroker 默认已含,
# 但旧 caddy-custom 镜像须**重建**才生效——接 cdrop 那次必须连带重建。
your-domain.example {
drop.your-domain.example {
import tls_min
# 公开端点(放行不鉴权):登录引导(302 跳 broker/ 原生取 broker 坐标 / 令牌刷新代理 /
# 扫码公开端点(新设备未登录,凭 poll_secret 自证)。
@public path /api/auth/login /api/auth/config /api/auth/refresh /api/auth/qr/start /api/auth/qr/status
handle @public {
reverse_proxy NN-cdrop:8080 {
flush_interval -1
}
}
# 受控 /api/* 机器分支:含 Bearer → broker /verify 验机器令牌 → 注入 X-Auth-*。
@apiBearer {
path /api/*
header Authorization Bearer*
}
handle @apiBearer {
broker BROKER-IP:8080 {
app cdrop
copy_headers X-Auth-Subject X-Auth-Name X-Auth-Roles X-Auth-Scope X-Auth-Kind X-Auth-Meta
}
reverse_proxy NN-cdrop:8080 {
flush_interval -1
}
}
# 受控 /api/* 人类分支:无 Bearer → broker /verify 验全局 SSO cookie → 注入 X-Auth-*。
@api path /api/*
handle @api {
broker BROKER-IP:8080 {
app cdrop
copy_headers X-Auth-Subject X-Auth-Name X-Auth-Roles X-Auth-Scope X-Auth-Kind X-Auth-Meta
}
reverse_proxy NN-cdrop:8080 {
flush_interval -1
}
}
# 静态 SPA 壳(其余路径):放行不鉴权,前端自行引导登录。
handle {
encode gzip
reverse_proxy NN-cdrop:8080 {
flush_interval -1
}
}
}
+16 -18
View File
@@ -21,33 +21,31 @@ NN-cdrop:
CDROP_DEVICE_TTL_HOURS: "196"
# ---- prod 切换时启用以下,并把 CDROP_AUTH_MODE 改成 prod、删掉 DEV_TOKEN ----
# CDROP_OIDC_AUTHORIZE_URL: https://your-idp.example/login/oauth/authorize
# CDROP_OIDC_TOKEN_URL: https://your-idp.example/api/login/oauth/access_token
# JWKS 可走 IdP 内网地址省一跳;也可用公网 https。
# CDROP_OIDC_JWKS_URL: https://your-idp.example/.well-known/jwks
# CDROP_OIDC_ISSUER: https://your-idp.example/
# prod 强制非空(空则跳过 audience 校验 = 同 JWKS 其他应用可冒充)
# CDROP_OIDC_AUDIENCE: <你的 OAuth client_id>
# CDROP_OIDC_CLIENT_ID: <你的 OAuth client_id>
# CDROP_OIDC_REDIRECT_URI: https://your-domain.example/oauth/callback
# CDROP_OIDC_SCOPES: "openid profile email"
# prod 强制非空:浏览器免重登会话 refresh_token 落盘加密密钥(SHA-256 派生 AES-256
# CDROP_SESSION_SECRET: REPLACE_WITH_RANDOM_HEX64
# CDROP_HS256_SECRET: REPLACE_WITH_RANDOM_HEX64
# cdrop 委托 Auth Broker(路径 A):身份与会话生命周期归 broker,cdrop 不存凭证。
# broker 内网地址(docker 内网直连,绝不经公网反代)。prod 强制非空。
# CDROP_BROKER_BASE_URL: http://broker-internal-ip:8080
# broker /internal/* 共享密钥(X-Internal-Key)。prod 强制非空,向运维取。
# CDROP_BROKER_INTERNAL_KEY: REPLACE_WITH_BROKER_INTERNAL_KEY
# broker 公开源(浏览器全局 SSO 登录跳转 / 原生设备授权流目标)。
# CDROP_BROKER_PUBLIC_URL: https://sso.your-domain.example
# 本应用在 broker apps 注册表里的 key(默认 cdrop)。
# CDROP_BROKER_APP: cdrop
# 部署公开源(扫码 QR 链接 / CSRF Origin / Web Push 默认联系标识)。
# CDROP_PUBLIC_URL: https://drop.your-domain.example
# ---- 可选:Web Push(VAPID)推送通知(不配则推送惰性关闭)----
# 用 `just vapid-keygen` 生成一对,二者须同时设置(半对即拒启动)
# CDROP_VAPID_PUBLIC_KEY: <just vapid-keygen 输出>
# CDROP_VAPID_PRIVATE_KEY: <just vapid-keygen 输出>
# ---- 可选:扫码登录(QR与 cdrop 自签会话(SESSION_SECRET 已设时默认开启----
# ---- 可选:扫码登录(QR+ 按档令牌 TTL(批准后经 broker 委托签发对应 tier----
# 均有默认值;下列按需覆盖。关 QR_LOGIN_ENABLED 则 /api/auth/qr/* 返回 503。
# CDROP_QR_LOGIN_ENABLED: "true"
# CDROP_QR_REQUEST_TTL_SECONDS: "120" # 二维码 / 登录请求有效期
# CDROP_QR_GUEST_TTL_SECONDS: "3600" # 仅此次(受限访客)会话 TTL
# CDROP_QR_PERSIST_TTL_HOURS: "168" # 信任此设备(持久)会话 TTL
# CDROP_SESSION_TOKEN_TTL_SECONDS: "900" # cdrop 自签 access token TTL
# CDROP_ACCOUNT_MATCH_CLAIM: "email" # 写入 accounts.match_key 的 claim
# CDROP_FULL_ACCESS_TTL_SECONDS: "900" # full 设备 access TTL
# CDROP_FULL_REFRESH_TTL_SECONDS: "604800" # full 设备 refresh TTL7 天)
# CDROP_GUEST_ACCESS_TTL_SECONDS: "900" # guest 访客 access TTL
# CDROP_GUEST_REFRESH_TTL_SECONDS: "86400" # guest refresh TTL1 天)
# ---- 可选:Cloudflare Realtime TURN over TLS(不配则前端 STUN-only 兜底)----
# CDROP_CF_TURN_KEY_ID: <dash.cloudflare.com → Realtime → TURN App>
-1
View File
@@ -6,7 +6,6 @@ require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/httprate v0.15.0
github.com/go-jose/go-jose/v4 v4.1.4
github.com/knadh/koanf/parsers/yaml v1.1.0
github.com/knadh/koanf/providers/env/v2 v2.0.0
github.com/knadh/koanf/providers/file v1.2.1
-2
View File
@@ -10,8 +10,6 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
+243
View File
@@ -0,0 +1,243 @@
// Package brokerclient is cdrop's thin client for the Auth Broker's HTTP API
// (path A). cdrop delegates session minting, revocation, and refresh to the broker
// rather than self-signing; this package wraps the three calls cdrop makes.
//
// Every call goes DIRECTLY to the broker's internal network address (e.g.
// http://broker-internal-ip:8080), never through the public reverse proxy — the broker's
// /internal/* endpoints 404 any request carrying X-Forwarded-For, which a request
// relayed through the edge always has. The Go HTTP client never sets that header, so
// a direct dial passes the guard; the shared X-Internal-Key is the second factor.
package brokerclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// ErrNotFound is returned for a broker 404. Revocation maps it to success
// (idempotent: an unknown / already-gone / not-ours sid needs no action).
var ErrNotFound = errors.New("brokerclient: not found")
// Client talks to one Auth Broker instance on behalf of one app.
type Client struct {
baseURL string
internalKey string
app string
httpClient *http.Client
}
// New builds a Client. baseURL is the broker's internal origin; internalKey is the
// shared secret for /internal/*; app is this app's key in the broker apps registry.
func New(baseURL, internalKey, app string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
internalKey: internalKey,
app: app,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// MintParams is one delegated-session request. The app vouches for UserID (the
// subject the broker just verified for the approver). Tier is appended to the app
// scope (app:<app>:<tier>); Meta is opaque correlation data (cdrop's device_id) the
// broker echoes back on /verify as X-Auth-Meta.
type MintParams struct {
UserID string
Tier string
AccessTTL int
RefreshTTL int
Sliding bool
Label string
Meta string
}
// Session is a freshly minted delegated session. SID is the broker's session id —
// cdrop stores it privately to revoke later; it never travels in a /verify header.
type Session struct {
SID string
Access string
Refresh string
AccessExpires int64
RefreshExpires int64
}
type mintReqWire struct {
UserID string `json:"user_id"`
App string `json:"app"`
Tier string `json:"tier,omitempty"`
AccessTTL int `json:"access_ttl,omitempty"`
RefreshTTL int `json:"refresh_ttl,omitempty"`
Sliding bool `json:"sliding,omitempty"`
Label string `json:"label,omitempty"`
Meta string `json:"meta,omitempty"`
}
type sessionWire struct {
ID string `json:"id"`
Access string `json:"access"`
Refresh string `json:"refresh"`
AccessExpires int64 `json:"access_expires"`
RefreshExpires int64 `json:"refresh_expires"`
}
// MintSession asks the broker to mint a session for the vouched user (POST
// /internal/sessions). The plaintext access + refresh come back exactly once.
func (c *Client) MintSession(ctx context.Context, p MintParams) (Session, error) {
body := mintReqWire{
UserID: p.UserID, App: c.app, Tier: p.Tier,
AccessTTL: p.AccessTTL, RefreshTTL: p.RefreshTTL, Sliding: p.Sliding,
Label: p.Label, Meta: p.Meta,
}
headers := map[string]string{"X-Internal-Key": c.internalKey}
var out sessionWire
if err := c.do(ctx, http.MethodPost, "/internal/sessions", headers, body, http.StatusOK, &out); err != nil {
return Session{}, err
}
return Session{
SID: out.ID, Access: out.Access, Refresh: out.Refresh,
AccessExpires: out.AccessExpires, RefreshExpires: out.RefreshExpires,
}, nil
}
// RevokeSession revokes a delegated session by sid (DELETE /internal/sessions/{sid}).
// X-Broker-App scopes the revoke to this app: the broker confirms the session belongs
// to cdrop before tearing it down (a mismatch 404s). A 404 is treated as success —
// revocation is idempotent.
func (c *Client) RevokeSession(ctx context.Context, sid string) error {
headers := map[string]string{
"X-Internal-Key": c.internalKey,
"X-Broker-App": c.app,
}
err := c.do(ctx, http.MethodDelete, "/internal/sessions/"+url.PathEscape(sid), headers, nil, http.StatusNoContent, nil)
if errors.Is(err, ErrNotFound) {
return nil
}
return err
}
// Refreshed is the result of rolling a session's access token. The broker rotates
// the refresh credential, so the old one is now dead and the new one must be stored.
type Refreshed struct {
Access string
Refresh string
AccessExpires int64
RefreshExpires int64
}
// RefreshSession exchanges a refresh credential for a fresh access token and a
// rotated refresh credential (POST /refresh — a public broker endpoint, no internal
// key). ErrNotFound is not used here; an invalid/expired/rotated credential is a 401.
func (c *Client) RefreshSession(ctx context.Context, refresh string) (Refreshed, error) {
body := map[string]string{"refresh": refresh}
var out sessionWire
if err := c.do(ctx, http.MethodPost, "/refresh", nil, body, http.StatusOK, &out); err != nil {
return Refreshed{}, err
}
return Refreshed{
Access: out.Access, Refresh: out.Refresh,
AccessExpires: out.AccessExpires, RefreshExpires: out.RefreshExpires,
}, nil
}
// SessionInfo is one of the user's delegated device sessions, as returned by the broker's
// R1 listing (GET /internal/sessions?user_id=&app=). After the unified-session-model
// rework this IS the authoritative device list: cdrop overlays type (local cache), online
// (hub presence), and current (meta == this request's X-Auth-Meta) on top of it, and never
// keeps a parallel authoritative session table. SID is the broker session id — the revoke
// handle cdrop stores privately; it is never exposed to clients. Meta is cdrop's device_id
// (the session<->device join key). The broker returns only kind==machine sessions for this
// app and never includes credentials.
type SessionInfo struct {
SID string
Scope string
Label string
Meta string
CreatedAt int64
LastUsedAt int64
ExpiresAt int64
}
type sessionInfoWire struct {
ID string `json:"id"`
Scope string `json:"scope"`
Label string `json:"label"`
Meta string `json:"meta"`
CreatedAt int64 `json:"created_at"`
LastUsedAt int64 `json:"last_used_at"`
ExpiresAt int64 `json:"expires_at"`
}
// ListSessions lists the user's delegated device sessions for this app (R1). It hits the
// same internal trust boundary as MintSession — X-Internal-Key over a direct internal dial,
// so the request carries no X-Forwarded-For and passes the broker's guard. cdrop vouches
// for userID (the subject the edge already verified). The broker returns only kind==machine
// sessions for app==c.app; cdrop treats the result as the authoritative device list.
func (c *Client) ListSessions(ctx context.Context, userID string) ([]SessionInfo, error) {
q := url.Values{"user_id": {userID}, "app": {c.app}}
headers := map[string]string{"X-Internal-Key": c.internalKey}
var out struct {
Sessions []sessionInfoWire `json:"sessions"`
}
if err := c.do(ctx, http.MethodGet, "/internal/sessions?"+q.Encode(), headers, nil, http.StatusOK, &out); err != nil {
return nil, err
}
sessions := make([]SessionInfo, 0, len(out.Sessions))
for _, s := range out.Sessions {
sessions = append(sessions, SessionInfo{
SID: s.ID, Scope: s.Scope, Label: s.Label, Meta: s.Meta,
CreatedAt: s.CreatedAt, LastUsedAt: s.LastUsedAt, ExpiresAt: s.ExpiresAt,
})
}
return sessions, nil
}
// do issues one request and decodes a successful response into out (when non-nil).
// A response whose status differs from wantStatus is an error; 404 maps to
// ErrNotFound so callers can special-case it.
func (c *Client) do(ctx context.Context, method, path string, headers map[string]string, body any, wantStatus int, out any) error {
var reader io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("brokerclient: marshal body: %w", err)
}
reader = bytes.NewReader(buf)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
if err != nil {
return fmt.Errorf("brokerclient: build request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("brokerclient: %s %s: %w", method, path, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return ErrNotFound
}
if resp.StatusCode != wantStatus {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("brokerclient: %s %s: status %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(snippet)))
}
if out != nil {
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("brokerclient: decode response: %w", err)
}
}
return nil
}
+68 -70
View File
@@ -27,35 +27,38 @@ type Config struct {
// window at 196h (≈8 days) — devices must not outlive that. Default 196h.
DeviceTTLHours int `koanf:"device_ttl_hours"`
// OIDC settings. Generic OAuth/OIDC provider compatible (Casdoor included).
OIDCAuthorizeURL string `koanf:"oidc_authorize_url"`
OIDCTokenURL string `koanf:"oidc_token_url"`
OIDCJWKSURL string `koanf:"oidc_jwks_url"`
OIDCIssuer string `koanf:"oidc_issuer"`
// OIDCAudience accepts a comma-separated list. A token passes when its `aud`
// matches any one entry — this lets one backend serve several OAuth clients
// that carry different audiences (e.g. the web app and the desktop client).
// Empty disables audience checking; a single value behaves as before.
OIDCAudience string `koanf:"oidc_audience"`
OIDCClientID string `koanf:"oidc_client_id"`
OIDCClientSecret string `koanf:"oidc_client_secret"`
OIDCRedirectURI string `koanf:"oidc_redirect_uri"`
OIDCScopes string `koanf:"oidc_scopes"`
// PublicURL 是部署的公开源(scheme://host),如 https://drop.commilitia.net。
// 用于拼扫码登录的 QR 链接、作 Web Push 的默认联系标识。dev 留空时回退到请求自身
// 的 Host。
PublicURL string `koanf:"public_url"`
HS256Secret string `koanf:"hs256_secret"`
// Auth Broker 接入(路径 A)。cdrop 把身份与会话生命周期委托给 broker:每个受控
// 请求在边缘由 broker /verify(经 Caddy)鉴权并注入 X-Auth-* 头,cdrop 直接信任;
// 扫码批准时经 broker 内部 API 委托签发会话,cdrop 不再自签。
// BrokerBaseURL —— broker 内网地址(如 http://broker-internal-ip:8080),在 docker
// 内网直连(绝不经公网反代,以保 /internal/* 与 /refresh 可达)。
// BrokerInternalKey —— broker /internal/* 端点的共享密钥(作 X-Internal-Key 发送),
// prod 必填。
// BrokerApp —— 本应用在 broker apps 注册表里的 key(默认 "cdrop")。
BrokerBaseURL string `koanf:"broker_base_url"`
BrokerInternalKey string `koanf:"broker_internal_key"`
BrokerApp string `koanf:"broker_app"`
// SessionSecret keys the AES-256-GCM encryption of browser refresh_tokens at
// rest in web_sessions (the "passwordless re-login" feature). Any-length
// high-entropy string; it's SHA-256'd into a 32-byte key. The key lives only
// here (container env), never in the DB file, so an exfiltrated SQLite file
// can't be decrypted. Required in prod — refuse to boot without it.
SessionSecret string `koanf:"session_secret"`
// BrokerPublicURL is the broker's PUBLIC origin (e.g. https://sso.commilitia.net)
// — where a browser is sent to log in via global SSO. Distinct from BrokerBaseURL
// (the internal address cdrop's server-to-server calls use). GET /api/auth/login
// 302-redirects here; empty disables that redirect (native-only deployments).
BrokerPublicURL string `koanf:"broker_public_url"`
// Shortcut tokensiOS 快捷指令用的长效 HS256 token。TTLDays 是签发有效期
// (默认 365 天),MaxPerUser 是每用户未吊销且未过期的 token 上限(默认 10)。
// 需配 HS256Secret 才启用,否则签发端点返回 503。
ShortcutTokenTTLDays int `koanf:"shortcut_token_ttl_days"`
ShortcutMaxPerUser int `koanf:"shortcut_max_per_user"`
// 按档令牌 TTL(秒):cdrop 经 broker 委托签发会话时请求的 access / refresh 寿命。
// full=可信设备(access 短、refresh 一周、滑动);guest=扫码借用(同样的短 access、
// refresh 一天)。两档都在 broker 的 app 上限内(access 3600 / refresh 604800),
// clampTTL 不会压它们。broker /verify 每请求查活,短 access 只决定续期频率、不影响
// 吊销即时性。
FullAccessTTLSeconds int `koanf:"full_access_ttl_seconds"`
FullRefreshTTLSeconds int `koanf:"full_refresh_ttl_seconds"`
GuestAccessTTLSeconds int `koanf:"guest_access_ttl_seconds"`
GuestRefreshTTLSeconds int `koanf:"guest_refresh_ttl_seconds"`
// Cloudflare Realtime TURN (https://developers.cloudflare.com/realtime/turn/).
// When both fields are set, /api/calls/credentials returns short-lived
@@ -83,31 +86,26 @@ type Config struct {
VAPIDPrivateKey string `koanf:"vapid_private_key"`
VAPIDSubject string `koanf:"vapid_subject"`
// 扫码登录(AUTH.md §4+ cdrop 自签会话(§3)。cdrop 为「没有 IdP refresh_token」
// 的会话(扫码批准的设备、受限访客借用)自签短效 access tokenHS256,密钥派生自
// SessionSecret)。QRLoginEnabled 关闭则 /api/auth/qr/* 返回 503;其余为各 TTL
// 扫码登录(QR):新设备出码、已登录设备扫码批准,经 Auth Broker 委托签发会话。
// QRLoginEnabled 关闭则 /api/auth/qr/* 返回 503。QRRequestTTLSeconds 是一条扫码
// 请求的存活秒数(默认 120)。批准后铸的会话寿命取上面的按档 TTL(full / guest
QRLoginEnabled bool `koanf:"qr_login_enabled"`
QRRequestTTLSeconds int `koanf:"qr_request_ttl_seconds"`
QRGuestTTLSeconds int `koanf:"qr_guest_ttl_seconds"`
QRPersistTTLHours int `koanf:"qr_persist_ttl_hours"`
SessionTokenTTLSeconds int `koanf:"session_token_ttl_seconds"`
// StepUpEnabled 给敏感动作(批准扫码设备)加 provider 再认证门(AUTH.md §6):开启后
// qr/approve 必须带一份新鲜的 prompt=login 授权码,后端就地换取 id_token、验签并校验
// auth_time 在窗口内。默认关。StepUpMaxAgeSeconds 是该新鲜度窗口(秒),默认 300。
StepUpEnabled bool `koanf:"step_up_enabled"`
StepUpMaxAgeSeconds int `koanf:"step_up_max_age_seconds"`
// AccountMatchClaim 是写入 accounts.match_key 的 JWT claim,供管理员开启「迁移
// 标记」时跨 provider 关联同一账号(AUTH.md §2.1/§7)。默认 email。
AccountMatchClaim string `koanf:"account_match_claim"`
}
// QRLoginOn reports whether scan-login is enabled and the self-signed session
// machinery it relies on is keyable (SessionSecret present). Without the secret
// cdrop can't sign session tokens, so QR stays off regardless of the flag.
// QRLoginOn reports whether scan-login is enabled and the broker it delegates
// minting to is configured. Without BrokerBaseURL cdrop can't mint a session on
// approval, so QR stays off regardless of the flag.
func (c *Config) QRLoginOn() bool {
return c.QRLoginEnabled && c.SessionSecret != ""
return c.QRLoginEnabled && c.BrokerBaseURL != ""
}
// BrokerAppOrDefault returns the configured broker app key, defaulting to "cdrop".
func (c *Config) BrokerAppOrDefault() string {
if c.BrokerApp != "" {
return c.BrokerApp
}
return "cdrop"
}
// Load reads config from optional ./config.yaml then overrides with CDROP_* env.
@@ -119,18 +117,15 @@ func Load() (*Config, error) {
k.Set("db_path", "./cdrop.db")
k.Set("listen", ":8080")
k.Set("device_ttl_hours", 196)
k.Set("oidc_scopes", "openid profile email")
k.Set("clipboard_max_bytes", 65536)
k.Set("clipboard_debounce_sec", 3)
k.Set("shortcut_token_ttl_days", 365)
k.Set("shortcut_max_per_user", 10)
k.Set("qr_login_enabled", true)
k.Set("qr_request_ttl_seconds", 120)
k.Set("qr_guest_ttl_seconds", 3600)
k.Set("qr_persist_ttl_hours", 168)
k.Set("session_token_ttl_seconds", 900)
k.Set("account_match_claim", "email")
k.Set("step_up_max_age_seconds", 300)
k.Set("broker_app", "cdrop")
k.Set("full_access_ttl_seconds", 900)
k.Set("full_refresh_ttl_seconds", 604800)
k.Set("guest_access_ttl_seconds", 900)
k.Set("guest_refresh_ttl_seconds", 86400)
if _, err := os.Stat("config.yaml"); err == nil {
if err := k.Load(file.Provider("config.yaml"), yaml.Parser()); err != nil {
@@ -166,24 +161,27 @@ func (c *Config) validate() error {
return errors.New("CDROP_AUTH_MODE=dev requires CDROP_DEV_TOKEN; refusing to start")
}
case "prod":
// Audience MUST be set in prod (R6). With it empty, RS256 audience
// checking is skipped entirely, so ANY token the IdP mints for ANY
// application sharing this JWKS would validate here — a user of a
// sibling Casdoor app could impersonate a cdrop user. The web + desktop
// client_ids go in CDROP_OIDC_AUDIENCE (comma-separated); refuse to boot
// without it rather than run wide open.
if c.OIDCAudience == "" {
// cdrop delegates identity + session lifecycle to the Auth Broker (path A):
// without its internal address and shared key, QR-approve can't mint and the
// service is non-functional. Refuse to boot rather than half-run.
if c.BrokerBaseURL == "" {
return errors.New(
"CDROP_AUTH_MODE=prod requires CDROP_OIDC_AUDIENCE " +
"(comma-separated OAuth client_ids); refusing to start")
"CDROP_AUTH_MODE=prod requires CDROP_BROKER_BASE_URL " +
"(the Auth Broker internal address); refusing to start")
}
// SessionSecret keys at-rest encryption of browser refresh_tokens. Empty
// would either disable passwordless re-login or, worse, tempt a plaintext
// fallback; require it so the encryption path is always live in prod.
if c.SessionSecret == "" {
if c.BrokerInternalKey == "" {
return errors.New(
"CDROP_AUTH_MODE=prod requires CDROP_SESSION_SECRET " +
"(keys web-session refresh_token encryption); refusing to start")
"CDROP_AUTH_MODE=prod requires CDROP_BROKER_INTERNAL_KEY " +
"(shared secret for the broker /internal/* API); refusing to start")
}
// PublicURL fixes the deployment origin the CSRF Origin check compares against
// (deriveSiteOrigin → sameOrigin). Without it sameOrigin fails OPEN, silently
// disabling the only cdrop-side CSRF guard on the state-changing POST endpoints
// (device-session mint, logout). Require it in prod so the guard never fails open.
if c.PublicURL == "" {
return errors.New(
"CDROP_AUTH_MODE=prod requires CDROP_PUBLIC_URL " +
"(the deployment origin, used for the CSRF check); refusing to start")
}
default:
return fmt.Errorf("CDROP_AUTH_MODE must be \"dev\" or \"prod\", got %q", c.AuthMode)
@@ -212,7 +210,7 @@ func (c *Config) VAPIDSubjectOrDefault() string {
if c.VAPIDSubject != "" {
return c.VAPIDSubject
}
if u, err := url.Parse(c.OIDCRedirectURI); err == nil && u.Scheme != "" && u.Host != "" {
if u, err := url.Parse(c.PublicURL); err == nil && u.Scheme != "" && u.Host != "" {
return u.Scheme + "://" + u.Host
}
return "https://drop.commilitia.net"
+30 -17
View File
@@ -24,35 +24,48 @@ func TestValidate_DevWithDevTokenOK(t *testing.T) {
}
func TestValidate_ProdOK(t *testing.T) {
c := &Config{AuthMode: "prod", OIDCAudience: "cdrop-web,cdrop-desktop", SessionSecret: "s3cr3t"}
c := &Config{AuthMode: "prod", BrokerBaseURL: "http://broker:8080", BrokerInternalKey: "s3cr3t", PublicURL: "https://drop.example.net"}
if err := c.validate(); err != nil {
t.Fatalf("prod mode with audience + session secret should pass; got %v", err)
t.Fatalf("prod mode with broker config should pass; got %v", err)
}
}
func TestValidate_ProdRequiresSessionSecret(t *testing.T) {
// SessionSecret keys at-rest encryption of browser refresh_tokens; without it
// passwordless re-login can't run safely. Refuse to boot in prod.
c := &Config{AuthMode: "prod", OIDCAudience: "cdrop-web", SessionSecret: ""}
func TestValidate_ProdRequiresPublicURL(t *testing.T) {
// PublicURL fixes the CSRF Origin check's expected value; without it sameOrigin fails
// open, silently disabling the guard on the state-changing POST endpoints. Refuse to boot.
c := &Config{AuthMode: "prod", BrokerBaseURL: "http://broker:8080", BrokerInternalKey: "k", PublicURL: ""}
err := c.validate()
if err == nil {
t.Fatal("prod without CDROP_SESSION_SECRET must reject; got nil error")
t.Fatal("prod without CDROP_PUBLIC_URL must reject; got nil error")
}
if !strings.Contains(err.Error(), "CDROP_SESSION_SECRET") {
t.Errorf("error must mention CDROP_SESSION_SECRET; got %q", err.Error())
if !strings.Contains(err.Error(), "CDROP_PUBLIC_URL") {
t.Errorf("error must mention CDROP_PUBLIC_URL; got %q", err.Error())
}
}
func TestValidate_ProdRequiresAudience(t *testing.T) {
// R6: prod with an empty audience leaves RS256 audience checking off, so a
// token minted for any sibling app sharing the JWKS would validate. Refuse.
c := &Config{AuthMode: "prod", OIDCAudience: ""}
func TestValidate_ProdRequiresBrokerBaseURL(t *testing.T) {
// cdrop delegates session lifecycle to the Auth Broker; without its address the
// service can't mint on approval. Refuse to boot in prod.
c := &Config{AuthMode: "prod", BrokerBaseURL: "", BrokerInternalKey: "k"}
err := c.validate()
if err == nil {
t.Fatal("prod without CDROP_OIDC_AUDIENCE must reject; got nil error")
t.Fatal("prod without CDROP_BROKER_BASE_URL must reject; got nil error")
}
if !strings.Contains(err.Error(), "CDROP_OIDC_AUDIENCE") {
t.Errorf("error must mention CDROP_OIDC_AUDIENCE; got %q", err.Error())
if !strings.Contains(err.Error(), "CDROP_BROKER_BASE_URL") {
t.Errorf("error must mention CDROP_BROKER_BASE_URL; got %q", err.Error())
}
}
func TestValidate_ProdRequiresBrokerInternalKey(t *testing.T) {
// The shared key guards the broker's /internal/* mint API; without it cdrop
// can't authenticate to the broker. Refuse to boot in prod.
c := &Config{AuthMode: "prod", BrokerBaseURL: "http://broker:8080", BrokerInternalKey: ""}
err := c.validate()
if err == nil {
t.Fatal("prod without CDROP_BROKER_INTERNAL_KEY must reject; got nil error")
}
if !strings.Contains(err.Error(), "CDROP_BROKER_INTERNAL_KEY") {
t.Errorf("error must mention CDROP_BROKER_INTERNAL_KEY; got %q", err.Error())
}
}
@@ -91,7 +104,7 @@ func TestVAPIDSubjectOrDefault(t *testing.T) {
if got := explicit.VAPIDSubjectOrDefault(); got != "mailto:ops@example.com" {
t.Fatalf("explicit subject: got %q", got)
}
derived := &Config{OIDCRedirectURI: "https://drop.example.com/oauth/callback"}
derived := &Config{PublicURL: "https://drop.example.com"}
if got := derived.VAPIDSubjectOrDefault(); got != "https://drop.example.com" {
t.Fatalf("derived subject: got %q, want scheme://host", got)
}
-76
View File
@@ -1,76 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: accounts.sql
package db
import (
"context"
)
const getAccount = `-- name: GetAccount :one
SELECT user_id, match_key, display_name, avatar_url, roles, provider, created_at, last_login_at
FROM accounts
WHERE user_id = ?
`
func (q *Queries) GetAccount(ctx context.Context, userID string) (Account, error) {
row := q.db.QueryRowContext(ctx, getAccount, userID)
var i Account
err := row.Scan(
&i.UserID,
&i.MatchKey,
&i.DisplayName,
&i.AvatarUrl,
&i.Roles,
&i.Provider,
&i.CreatedAt,
&i.LastLoginAt,
)
return i, err
}
const upsertAccount = `-- name: UpsertAccount :exec
INSERT INTO accounts (user_id, match_key, display_name, avatar_url, roles, provider, created_at, last_login_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
match_key = excluded.match_key,
display_name = excluded.display_name,
avatar_url = excluded.avatar_url,
roles = excluded.roles,
provider = excluded.provider,
last_login_at = excluded.last_login_at
`
type UpsertAccountParams struct {
UserID string `json:"user_id"`
MatchKey string `json:"match_key"`
DisplayName string `json:"display_name"`
AvatarUrl string `json:"avatar_url"`
Roles string `json:"roles"`
Provider string `json:"provider"`
CreatedAt int64 `json:"created_at"`
LastLoginAt int64 `json:"last_login_at"`
}
// accounts: cdrop-side thin account data (AUTH.md 2.1). Keyed on user_id (= OIDC
// sub). No credentials live here; password / second factor stay at the OAuth
// provider. Upserted on every successful OIDC exchange.
//
// ASCII only: sqlc 1.31.x miscomputes byte offsets on multibyte comments and
// corrupts every generated SQL const in the file. Keep this file pure ASCII.
func (q *Queries) UpsertAccount(ctx context.Context, arg UpsertAccountParams) error {
_, err := q.db.ExecContext(ctx, upsertAccount,
arg.UserID,
arg.MatchKey,
arg.DisplayName,
arg.AvatarUrl,
arg.Roles,
arg.Provider,
arg.CreatedAt,
arg.LastLoginAt,
)
return err
}
+41 -18
View File
@@ -59,24 +59,12 @@ func Bootstrap(ctx context.Context, d *sql.DB) error {
"ALTER TABLE clipboard_state ADD COLUMN origin_ts INTEGER NOT NULL DEFAULT 0"); err != nil {
return fmt.Errorf("migrate clipboard_state.origin_ts: %w", err)
}
// web_sessions gained kind/scope/granted_by for cdrop self-signed sessions
// (AUTH.md §2.2). Existing rows predate these columns; backfill them so a
// pre-existing browser session keeps behaving as a normal IdP-backed login.
if err := ensureColumn(ctx, tx, "web_sessions", "kind",
"ALTER TABLE web_sessions ADD COLUMN kind TEXT NOT NULL DEFAULT 'oidc'"); err != nil {
return fmt.Errorf("migrate web_sessions.kind: %w", err)
}
if err := ensureColumn(ctx, tx, "web_sessions", "scope",
"ALTER TABLE web_sessions ADD COLUMN scope TEXT NOT NULL DEFAULT 'full'"); err != nil {
return fmt.Errorf("migrate web_sessions.scope: %w", err)
}
if err := ensureColumn(ctx, tx, "web_sessions", "granted_by",
"ALTER TABLE web_sessions ADD COLUMN granted_by TEXT NOT NULL DEFAULT ''"); err != nil {
return fmt.Errorf("migrate web_sessions.granted_by: %w", err)
}
if err := ensureColumn(ctx, tx, "web_sessions", "stepped_up_at",
"ALTER TABLE web_sessions ADD COLUMN stepped_up_at INTEGER NOT NULL DEFAULT 0"); err != nil {
return fmt.Errorf("migrate web_sessions.stepped_up_at: %w", err)
// devices moved to a stable opaque device_id primary key (Auth Broker path A);
// the old table was keyed (user_id, name). Device rows are ephemeral registrations
// re-created on the next request / scan, so a legacy table is dropped and recreated
// rather than column-migrated (SQLite can't ALTER a primary key in place).
if err := recreateDevicesIfLegacy(ctx, tx); err != nil {
return fmt.Errorf("migrate devices to device_id: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit bootstrap: %w", err)
@@ -84,6 +72,41 @@ func Bootstrap(ctx context.Context, d *sql.DB) error {
return nil
}
// recreateDevicesIfLegacy drops and recreates the devices table when it predates the
// device_id primary key (the legacy (user_id, name) shape). On a fresh DB the init
// schema already created the new shape, so device_id is present and this no-ops. The
// dropped rows are ephemeral device registrations, re-created on the next request or
// scan-login, so no durable data is lost.
func recreateDevicesIfLegacy(ctx context.Context, tx *sql.Tx) error {
has, err := columnExists(ctx, tx, "devices", "device_id")
if err != nil {
return err
}
if has {
return nil
}
stmts := []string{
"DROP TABLE IF EXISTS devices",
`CREATE TABLE devices (
device_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
tier TEXT NOT NULL DEFAULT 'full',
broker_sid TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL
)`,
"CREATE INDEX IF NOT EXISTS idx_devices_user ON devices (user_id)",
}
for _, s := range stmts {
if _, err := tx.ExecContext(ctx, s); err != nil {
return err
}
}
return nil
}
// ensureColumn runs addSQL only when table lacks the named column — an idempotent
// additive migration for already-created tables.
func ensureColumn(ctx context.Context, tx *sql.Tx, table, column, addSQL string) error {
+30 -20
View File
@@ -19,7 +19,7 @@ func TestBootstrapCreatesAllTables(t *testing.T) {
t.Fatalf("bootstrap: %v", err)
}
want := []string{"accounts", "clipboard_state", "devices", "login_requests", "push_subscriptions", "shortcut_tokens", "transfer_sessions", "web_sessions"}
want := []string{"clipboard_state", "devices", "login_requests", "push_subscriptions", "transfer_sessions"}
rows, err := d.Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
if err != nil {
t.Fatalf("query tables: %v", err)
@@ -115,10 +115,11 @@ func TestBootstrapAddsClipboardVersionToLegacyDB(t *testing.T) {
}
}
// Simulates the prod upgrade for scan-login: a web_sessions table created before
// kind/scope/granted_by existed must gain them, with an existing row defaulting
// to a normal OIDC-backed full session so its behaviour is unchanged (AUTH.md §2.2).
func TestBootstrapAddsWebSessionColumnsToLegacyDB(t *testing.T) {
// Simulates the prod upgrade to the Auth Broker model: a legacy devices table keyed
// (user_id, name) must be recreated with the device_id primary key. The ephemeral
// device rows are dropped (re-created on the next request / scan), and the migration
// is idempotent.
func TestBootstrapRecreatesLegacyDevices(t *testing.T) {
tmp := filepath.Join(t.TempDir(), "legacy.db")
d, err := Open(tmp)
if err != nil {
@@ -126,15 +127,12 @@ func TestBootstrapAddsWebSessionColumnsToLegacyDB(t *testing.T) {
}
defer d.Close()
_, err = d.Exec(`CREATE TABLE web_sessions (
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, refresh_token TEXT NOT NULL,
device_name TEXT NOT NULL DEFAULT '', user_agent TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL, last_used_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`)
if err != nil {
if _, err := d.Exec(`CREATE TABLE devices (
user_id TEXT NOT NULL, name TEXT NOT NULL, type TEXT NOT NULL,
last_seen INTEGER NOT NULL, PRIMARY KEY (user_id, name))`); err != nil {
t.Fatalf("legacy schema: %v", err)
}
if _, err := d.Exec(`INSERT INTO web_sessions (id, user_id, refresh_token, created_at, last_used_at, expires_at)
VALUES ('s', 'u', 'enc', 1, 1, 9999999999)`); err != nil {
if _, err := d.Exec(`INSERT INTO devices (user_id, name, type, last_seen) VALUES ('u','old','browser',1)`); err != nil {
t.Fatalf("seed row: %v", err)
}
@@ -142,17 +140,29 @@ func TestBootstrapAddsWebSessionColumnsToLegacyDB(t *testing.T) {
t.Fatalf("bootstrap: %v", err)
}
var kind, scope, grantedBy string
if err := d.QueryRow(`SELECT kind, scope, granted_by FROM web_sessions WHERE id='s'`).
Scan(&kind, &scope, &grantedBy); err != nil {
t.Fatalf("select new columns (missing?): %v", err)
// The table now has the device_id-keyed shape (this insert would fail otherwise).
if _, err := d.Exec(`INSERT INTO devices (device_id, user_id, name, type, tier, broker_sid, created_at, last_seen)
VALUES ('dev_1','u','New','browser','full','sid',1,1)`); err != nil {
t.Fatalf("new schema insert (device_id missing?): %v", err)
}
if kind != "oidc" || scope != "full" || grantedBy != "" {
t.Errorf("legacy row defaults: got kind=%q scope=%q granted_by=%q, want oidc/full/empty",
kind, scope, grantedBy)
// The legacy row was dropped with the table.
var legacy int
if err := d.QueryRow(`SELECT COUNT(*) FROM devices WHERE name='old'`).Scan(&legacy); err != nil {
t.Fatalf("count legacy: %v", err)
}
// Idempotent: second bootstrap must not error on already-present columns.
if legacy != 0 {
t.Errorf("legacy device row should be dropped on recreate; got %d", legacy)
}
// Idempotent: a second bootstrap leaves the new shape and its rows intact.
if err := Bootstrap(context.Background(), d); err != nil {
t.Fatalf("second bootstrap: %v", err)
}
var kept int
if err := d.QueryRow(`SELECT COUNT(*) FROM devices WHERE device_id='dev_1'`).Scan(&kept); err != nil {
t.Fatalf("count after second bootstrap: %v", err)
}
if kept != 1 {
t.Errorf("device row should survive idempotent bootstrap; got %d", kept)
}
}
+115 -42
View File
@@ -9,41 +9,70 @@ import (
"context"
)
const createDevice = `-- name: CreateDevice :exec
INSERT INTO devices (device_id, user_id, name, type, tier, broker_sid, created_at, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (device_id) DO UPDATE SET
name = excluded.name,
type = excluded.type,
tier = excluded.tier,
broker_sid = excluded.broker_sid,
last_seen = excluded.last_seen
WHERE devices.user_id = excluded.user_id
`
type CreateDeviceParams struct {
DeviceID string `json:"device_id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Type string `json:"type"`
Tier string `json:"tier"`
BrokerSid string `json:"broker_sid"`
CreatedAt int64 `json:"created_at"`
LastSeen int64 `json:"last_seen"`
}
// devices: cdrop-managed devices (scan-login / native pairing). device_id is a
// stable opaque cdrop-generated id that doubles as the session<->device join key
// (passed to the broker as meta, echoed back as X-Auth-Meta on /verify). broker_sid
// is the broker's session id, stored privately to revoke on device removal. tier is
// a redundant cache of the broker scope (full/guest). name is human-readable and can
// change without changing the device's identity (device_id is the key).
//
// ASCII only: sqlc 1.31.x miscomputes byte offsets on multibyte comments and corrupts
// every generated SQL const in the file. Keep this file pure ASCII.
// CreateDevice records a device on scan-login collect / proxy-mint (or native pairing). Keyed
// on device_id, so a re-pair with the same id refreshes the row (incl. the new broker_sid).
// The WHERE on the upsert scopes the update to the owning user so a (cryptographically
// impossible) cross-user device_id collision can never reassign the row owner; user_id is
// immutable for a given device_id.
func (q *Queries) CreateDevice(ctx context.Context, arg CreateDeviceParams) error {
_, err := q.db.ExecContext(ctx, createDevice,
arg.DeviceID,
arg.UserID,
arg.Name,
arg.Type,
arg.Tier,
arg.BrokerSid,
arg.CreatedAt,
arg.LastSeen,
)
return err
}
const deleteDevice = `-- name: DeleteDevice :execrows
DELETE FROM devices
WHERE user_id = ? AND name = ?
WHERE device_id = ? AND user_id = ?
`
type DeleteDeviceParams struct {
DeviceID string `json:"device_id"`
UserID string `json:"user_id"`
Name string `json:"name"`
}
func (q *Queries) DeleteDevice(ctx context.Context, arg DeleteDeviceParams) (int64, error) {
result, err := q.db.ExecContext(ctx, deleteDevice, arg.UserID, arg.Name)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const deleteOrphanBrowserDevices = `-- name: DeleteOrphanBrowserDevices :execrows
DELETE FROM devices
WHERE type = 'browser'
AND NOT EXISTS (
SELECT 1 FROM web_sessions ws
WHERE ws.user_id = devices.user_id
AND ws.device_name = devices.name
AND ws.expires_at > ?
)
`
// DeleteOrphanBrowserDevices removes browser device rows with no live web_session
// (the session was revoked or expired), keeping the device list aligned with the
// session list. The ? is the current epoch second. Native (macos/windows/linux/ios)
// and shortcut devices are left alone: they legitimately keep no web_session.
func (q *Queries) DeleteOrphanBrowserDevices(ctx context.Context, expiresAt int64) (int64, error) {
result, err := q.db.ExecContext(ctx, deleteOrphanBrowserDevices, expiresAt)
result, err := q.db.ExecContext(ctx, deleteDevice, arg.DeviceID, arg.UserID)
if err != nil {
return 0, err
}
@@ -60,8 +89,30 @@ func (q *Queries) DeleteStaleDevices(ctx context.Context, lastSeen int64) error
return err
}
const getDevice = `-- name: GetDevice :one
SELECT device_id, user_id, name, type, tier, broker_sid, created_at, last_seen
FROM devices
WHERE device_id = ?
`
func (q *Queries) GetDevice(ctx context.Context, deviceID string) (Device, error) {
row := q.db.QueryRowContext(ctx, getDevice, deviceID)
var i Device
err := row.Scan(
&i.DeviceID,
&i.UserID,
&i.Name,
&i.Type,
&i.Tier,
&i.BrokerSid,
&i.CreatedAt,
&i.LastSeen,
)
return i, err
}
const listDevicesByUser = `-- name: ListDevicesByUser :many
SELECT user_id, name, type, last_seen
SELECT device_id, user_id, name, type, tier, broker_sid, created_at, last_seen
FROM devices
WHERE user_id = ?
ORDER BY name
@@ -77,9 +128,13 @@ func (q *Queries) ListDevicesByUser(ctx context.Context, userID string) ([]Devic
for rows.Next() {
var i Device
if err := rows.Scan(
&i.DeviceID,
&i.UserID,
&i.Name,
&i.Type,
&i.Tier,
&i.BrokerSid,
&i.CreatedAt,
&i.LastSeen,
); err != nil {
return nil, err
@@ -95,27 +150,45 @@ func (q *Queries) ListDevicesByUser(ctx context.Context, userID string) ([]Devic
return items, nil
}
const upsertDevice = `-- name: UpsertDevice :exec
INSERT INTO devices (user_id, name, type, last_seen)
VALUES (?, ?, ?, ?)
ON CONFLICT (user_id, name) DO UPDATE SET
type = excluded.type,
last_seen = excluded.last_seen
const renameDevice = `-- name: RenameDevice :execrows
UPDATE devices SET name = ?
WHERE device_id = ? AND user_id = ?
`
type UpsertDeviceParams struct {
UserID string `json:"user_id"`
type RenameDeviceParams struct {
Name string `json:"name"`
Type string `json:"type"`
LastSeen int64 `json:"last_seen"`
DeviceID string `json:"device_id"`
UserID string `json:"user_id"`
}
func (q *Queries) UpsertDevice(ctx context.Context, arg UpsertDeviceParams) error {
_, err := q.db.ExecContext(ctx, upsertDevice,
arg.UserID,
arg.Name,
arg.Type,
func (q *Queries) RenameDevice(ctx context.Context, arg RenameDeviceParams) (int64, error) {
result, err := q.db.ExecContext(ctx, renameDevice, arg.Name, arg.DeviceID, arg.UserID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const touchDevice = `-- name: TouchDevice :exec
UPDATE devices SET last_seen = ?, tier = ? WHERE device_id = ? AND user_id = ?
`
type TouchDeviceParams struct {
LastSeen int64 `json:"last_seen"`
Tier string `json:"tier"`
DeviceID string `json:"device_id"`
UserID string `json:"user_id"`
}
// TouchDevice refreshes last_seen + tier on each authenticated request. Update-only:
// the row is created at collect time, so a missing row (e.g. a device authorized via
// the broker's own device flow, not cdrop's) simply isn't cdrop-managed and no-ops.
func (q *Queries) TouchDevice(ctx context.Context, arg TouchDeviceParams) error {
_, err := q.db.ExecContext(ctx, touchDevice,
arg.LastSeen,
arg.Tier,
arg.DeviceID,
arg.UserID,
)
return err
}
+11 -61
View File
@@ -1,24 +1,23 @@
-- cdrop MVP schema (PROJECT_BRIEF.md §4)
-- 以 IF NOT EXISTS 形式由 internal/db/bootstrap.go 启动期单事务执行。
-- devicescdrop 托管设备(扫码批准 / 原生配对的设备)。device_id 是 cdrop 生成的
-- 稳定不透明 id,作设备身份与「会话↔设备」的 join key——经 broker mint 时作 meta 传入、
-- 由 broker /verify 回显成 X-Auth-Meta。broker_sid 是 broker 返回的会话 id,私存用于
-- 吊销(删设备)。tier=full/guest(冗余缓存,权威在 broker scope)。name 是人类可读
-- 设备名,可重命名而身份不变(device_id 不变)。
CREATE TABLE IF NOT EXISTS devices (
device_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
last_seen INTEGER NOT NULL,
PRIMARY KEY (user_id, name)
tier TEXT NOT NULL DEFAULT 'full',
broker_sid TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS shortcut_tokens (
jti TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
label TEXT NOT NULL,
scopes TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
last_used_at INTEGER,
revoked INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_devices_user ON devices (user_id);
CREATE TABLE IF NOT EXISTS transfer_sessions (
id TEXT PRIMARY KEY,
@@ -50,37 +49,6 @@ CREATE TABLE IF NOT EXISTS clipboard_state (
origin_ts INTEGER NOT NULL DEFAULT 0
);
-- web_sessions:浏览器端「免重登」。cookie 里只放不透明随机串,本表 id 存其
-- SHA-256,故 DB 单独泄露也换不出可用 cookierefresh_token 以 AES-256-GCM 落盘
-- (密钥在容器环境、不在库内)。仅浏览器使用——桌面端 refresh_token 由 Go keyring
-- 持有、走自有 loopback flow,绝不入此表。device_name 随会话存,使 PWA 存储被清后
-- 开机仍能恢复本机设备名。
CREATE TABLE IF NOT EXISTS web_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
refresh_token TEXT NOT NULL,
device_name TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
last_used_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
-- kind 区分会话来源(AUTH.md §2.2):oidc=绑定 IdP refresh_token 的正常浏览器
-- 登录(refresh 时向 IdP 续);selfguestcdrop 自签会话,无 IdP refresh_token
-- refresh 时仅按本行自签 access token、不触 IdP。既有行经 bootstrap 的幂等 ALTER
-- 补列后默认视为 oidc/full,行为不变。
kind TEXT NOT NULL DEFAULT 'oidc',
-- scopefullguest。guest(扫码受限借用设备)服务端强制受限:能收发文件,
-- 但不能改账号、不能再批准别的设备、不能签发长效 token(路由层 requireFullSession 守门)。
scope TEXT NOT NULL DEFAULT 'full',
-- granted_by=扫码批准者的 session id,供审计与「吊销我批准过的借用设备」连带吊销。
granted_by TEXT NOT NULL DEFAULT '',
-- stepped_up_at=本会话最近一次通过 step-up 再认证的 Unix 秒(AUTH.md §6)。在
-- StepUpMaxAgeSeconds 窗口内,敏感动作不再重复要求再认证(避免每次都弹再登录)。
stepped_up_at INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_web_sessions_expires ON web_sessions (expires_at);
-- push_subscriptions:浏览器 / PWA 的 Web Push 订阅端点。当某设备「页面已关闭」
-- (无活的 SSE 连接)时,服务端按此表向其推送系统通知;页面开着时事件仍走 SSE,
-- 前端自弹 toast,不触发 push。id 是 SHA-256(endpoint) 的 hex,使同一浏览器重复
@@ -105,24 +73,6 @@ CREATE TABLE IF NOT EXISTS push_subscriptions (
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_device
ON push_subscriptions (user_id, device_name);
-- accountscdrop 侧「薄账户数据层」(AUTH.md §2.1)。身份来源仍是 OAuth provider
-- user_id 继续等于 OIDC sub;本表只存 cdrop 自维护的账户元数据,不含任何凭证(密码 /
-- 第二因子全在 provider)。OIDC exchange 成功后 upsert:捕获 match_key(默认 email
-- claim,仅在管理员开启「迁移标记」时用作跨源关联的 join key)、显示名 / 头像、roles
-- groups claim 缓存,admin 判定用)。
CREATE TABLE IF NOT EXISTS accounts (
user_id TEXT PRIMARY KEY,
match_key TEXT NOT NULL DEFAULT '',
display_name TEXT NOT NULL DEFAULT '',
avatar_url TEXT NOT NULL DEFAULT '',
roles TEXT NOT NULL DEFAULT '',
provider TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
last_login_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_accounts_match_key ON accounts (match_key);
-- login_requests:扫码登录的待批准请求(AUTH.md §2.3、§4)。三方密钥分离——
-- poll_secret 存其 SHA-256(新设备私有、QR 不含、领取会话凭它);approval_code 随 QR
-- 给批准方。短 TTL(默认 120s),consumedexpired 后不可复用,由 reaper 清理。
+4 -37
View File
@@ -4,17 +4,6 @@
package db
type Account struct {
UserID string `json:"user_id"`
MatchKey string `json:"match_key"`
DisplayName string `json:"display_name"`
AvatarUrl string `json:"avatar_url"`
Roles string `json:"roles"`
Provider string `json:"provider"`
CreatedAt int64 `json:"created_at"`
LastLoginAt int64 `json:"last_login_at"`
}
type ClipboardState struct {
UserID string `json:"user_id"`
ContentType string `json:"content_type"`
@@ -26,9 +15,13 @@ type ClipboardState struct {
}
type Device struct {
DeviceID string `json:"device_id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Type string `json:"type"`
Tier string `json:"tier"`
BrokerSid string `json:"broker_sid"`
CreatedAt int64 `json:"created_at"`
LastSeen int64 `json:"last_seen"`
}
@@ -62,17 +55,6 @@ type PushSubscription struct {
LastUsedAt int64 `json:"last_used_at"`
}
type ShortcutToken struct {
Jti string `json:"jti"`
UserID string `json:"user_id"`
Label string `json:"label"`
Scopes string `json:"scopes"`
CreatedAt int64 `json:"created_at"`
ExpiresAt int64 `json:"expires_at"`
LastUsedAt *int64 `json:"last_used_at"`
Revoked int64 `json:"revoked"`
}
type TransferSession struct {
ID string `json:"id"`
UserID string `json:"user_id"`
@@ -88,18 +70,3 @@ type TransferSession struct {
FinishedAt *int64 `json:"finished_at"`
FailReason *string `json:"fail_reason"`
}
type WebSession struct {
ID string `json:"id"`
UserID string `json:"user_id"`
RefreshToken string `json:"refresh_token"`
DeviceName string `json:"device_name"`
UserAgent string `json:"user_agent"`
CreatedAt int64 `json:"created_at"`
LastUsedAt int64 `json:"last_used_at"`
ExpiresAt int64 `json:"expires_at"`
Kind string `json:"kind"`
Scope string `json:"scope"`
GrantedBy string `json:"granted_by"`
SteppedUpAt int64 `json:"stepped_up_at"`
}
-22
View File
@@ -1,22 +0,0 @@
-- accounts: cdrop-side thin account data (AUTH.md 2.1). Keyed on user_id (= OIDC
-- sub). No credentials live here; password / second factor stay at the OAuth
-- provider. Upserted on every successful OIDC exchange.
--
-- ASCII only: sqlc 1.31.x miscomputes byte offsets on multibyte comments and
-- corrupts every generated SQL const in the file. Keep this file pure ASCII.
-- name: UpsertAccount :exec
INSERT INTO accounts (user_id, match_key, display_name, avatar_url, roles, provider, created_at, last_login_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
match_key = excluded.match_key,
display_name = excluded.display_name,
avatar_url = excluded.avatar_url,
roles = excluded.roles,
provider = excluded.provider,
last_login_at = excluded.last_login_at;
-- name: GetAccount :one
SELECT user_id, match_key, display_name, avatar_url, roles, provider, created_at, last_login_at
FROM accounts
WHERE user_id = ?;
+44 -24
View File
@@ -1,34 +1,54 @@
-- name: UpsertDevice :exec
INSERT INTO devices (user_id, name, type, last_seen)
VALUES (?, ?, ?, ?)
ON CONFLICT (user_id, name) DO UPDATE SET
-- devices: cdrop-managed devices (scan-login / native pairing). device_id is a
-- stable opaque cdrop-generated id that doubles as the session<->device join key
-- (passed to the broker as meta, echoed back as X-Auth-Meta on /verify). broker_sid
-- is the broker's session id, stored privately to revoke on device removal. tier is
-- a redundant cache of the broker scope (full/guest). name is human-readable and can
-- change without changing the device's identity (device_id is the key).
--
-- ASCII only: sqlc 1.31.x miscomputes byte offsets on multibyte comments and corrupts
-- every generated SQL const in the file. Keep this file pure ASCII.
-- CreateDevice records a device on scan-login collect / proxy-mint (or native pairing). Keyed
-- on device_id, so a re-pair with the same id refreshes the row (incl. the new broker_sid).
-- The WHERE on the upsert scopes the update to the owning user so a (cryptographically
-- impossible) cross-user device_id collision can never reassign the row owner; user_id is
-- immutable for a given device_id.
-- name: CreateDevice :exec
INSERT INTO devices (device_id, user_id, name, type, tier, broker_sid, created_at, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (device_id) DO UPDATE SET
name = excluded.name,
type = excluded.type,
last_seen = excluded.last_seen;
tier = excluded.tier,
broker_sid = excluded.broker_sid,
last_seen = excluded.last_seen
WHERE devices.user_id = excluded.user_id;
-- TouchDevice refreshes last_seen + tier on each authenticated request. Update-only:
-- the row is created at collect time, so a missing row (e.g. a device authorized via
-- the broker's own device flow, not cdrop's) simply isn't cdrop-managed and no-ops.
-- name: TouchDevice :exec
UPDATE devices SET last_seen = ?, tier = ? WHERE device_id = ? AND user_id = ?;
-- name: ListDevicesByUser :many
SELECT user_id, name, type, last_seen
SELECT device_id, user_id, name, type, tier, broker_sid, created_at, last_seen
FROM devices
WHERE user_id = ?
ORDER BY name;
-- name: DeleteStaleDevices :exec
DELETE FROM devices
WHERE last_seen < ?;
-- DeleteOrphanBrowserDevices removes browser device rows with no live web_session
-- (the session was revoked or expired), keeping the device list aligned with the
-- session list. The ? is the current epoch second. Native (macos/windows/linux/ios)
-- and shortcut devices are left alone: they legitimately keep no web_session.
-- name: DeleteOrphanBrowserDevices :execrows
DELETE FROM devices
WHERE type = 'browser'
AND NOT EXISTS (
SELECT 1 FROM web_sessions ws
WHERE ws.user_id = devices.user_id
AND ws.device_name = devices.name
AND ws.expires_at > ?
);
-- name: GetDevice :one
SELECT device_id, user_id, name, type, tier, broker_sid, created_at, last_seen
FROM devices
WHERE device_id = ?;
-- name: DeleteDevice :execrows
DELETE FROM devices
WHERE user_id = ? AND name = ?;
WHERE device_id = ? AND user_id = ?;
-- name: RenameDevice :execrows
UPDATE devices SET name = ?
WHERE device_id = ? AND user_id = ?;
-- name: DeleteStaleDevices :exec
DELETE FROM devices
WHERE last_seen < ?;
-32
View File
@@ -1,32 +0,0 @@
-- shortcut_tokens: long-lived, scope-limited, revocable HS256 tokens for the
-- iOS Shortcut clipboard sync. Count backs the per-user cap; Revoke is scoped by
-- user_id to block cross-user revocation; Touch records last use asynchronously.
-- name: InsertShortcutToken :exec
INSERT INTO shortcut_tokens (jti, user_id, label, scopes, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?);
-- name: GetShortcutToken :one
SELECT jti, user_id, label, scopes, created_at, expires_at, last_used_at, revoked
FROM shortcut_tokens
WHERE jti = ?;
-- name: ListShortcutTokensByUser :many
SELECT jti, user_id, label, scopes, created_at, expires_at, last_used_at, revoked
FROM shortcut_tokens
WHERE user_id = ?
ORDER BY created_at DESC;
-- name: CountActiveShortcutTokensByUser :one
SELECT COUNT(*) FROM shortcut_tokens
WHERE user_id = ? AND revoked = 0 AND expires_at > ?;
-- name: RevokeShortcutToken :execrows
UPDATE shortcut_tokens
SET revoked = 1
WHERE jti = ? AND user_id = ?;
-- name: TouchShortcutTokenUsed :exec
UPDATE shortcut_tokens
SET last_used_at = ?
WHERE jti = ?;
-69
View File
@@ -1,69 +0,0 @@
-- web_sessions: browser "passwordless re-login". The opaque cookie token is
-- never stored; id holds its SHA-256, so a DB leak yields no usable cookie.
-- refresh_token is AES-256-GCM ciphertext (key lives in the container env, not
-- the DB). 7-day sliding window: every refresh rotates the token and pushes
-- expires_at forward; idle sessions are reaped by DeleteExpiredWebSessions.
-- name: CreateWebSession :exec
INSERT INTO web_sessions (id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?);
-- name: GetWebSession :one
SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by, stepped_up_at
FROM web_sessions
WHERE id = ?;
-- SetSessionSteppedUp stamps the moment this session last passed step-up re-auth;
-- sensitive actions within the freshness window then skip re-auth (AUTH.md 6).
-- name: SetSessionSteppedUp :exec
UPDATE web_sessions
SET stepped_up_at = ?
WHERE id = ?;
-- name: RotateWebSession :exec
UPDATE web_sessions
SET refresh_token = ?, last_used_at = ?, expires_at = ?
WHERE id = ?;
-- name: SetWebSessionDevice :exec
UPDATE web_sessions
SET device_name = ?
WHERE id = ?;
-- name: DeleteWebSession :exec
DELETE FROM web_sessions
WHERE id = ?;
-- name: DeleteExpiredWebSessions :execrows
DELETE FROM web_sessions
WHERE expires_at < ?;
-- CreateSelfSession inserts a cdrop self-signed session (kind self/guest) that has
-- no IdP refresh_token (stored empty): refresh mints a fresh access token straight
-- from this row without touching the IdP. Used by QR scan-login (AUTH.md 4).
-- name: CreateSelfSession :exec
INSERT INTO web_sessions (id, user_id, refresh_token, device_name, user_agent, kind, scope, granted_by, created_at, last_used_at, expires_at)
VALUES (?, ?, '', ?, ?, ?, ?, ?, ?, ?, ?);
-- SlideWebSession pushes a session's sliding window forward without rotating any
-- token. Self/guest sessions use it on refresh (they have no refresh_token to
-- rotate); oidc sessions keep using RotateWebSession.
-- name: SlideWebSession :exec
UPDATE web_sessions
SET last_used_at = ?, expires_at = ?
WHERE id = ?;
-- ListWebSessionsByUser lists a user's sessions for the management UI (revoke
-- borrowed / guest devices). granted_by ties a scan-approved session to its
-- approver for cascade revocation.
-- name: ListWebSessionsByUser :many
SELECT id, user_id, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by
FROM web_sessions
WHERE user_id = ?
ORDER BY last_used_at DESC;
-- DeleteWebSessionForUser drops one session scoped to its owner, so a user can
-- only revoke their own.
-- name: DeleteWebSessionForUser :execrows
DELETE FROM web_sessions
WHERE id = ? AND user_id = ?;
-153
View File
@@ -1,153 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: shortcut_tokens.sql
package db
import (
"context"
)
const countActiveShortcutTokensByUser = `-- name: CountActiveShortcutTokensByUser :one
SELECT COUNT(*) FROM shortcut_tokens
WHERE user_id = ? AND revoked = 0 AND expires_at > ?
`
type CountActiveShortcutTokensByUserParams struct {
UserID string `json:"user_id"`
ExpiresAt int64 `json:"expires_at"`
}
func (q *Queries) CountActiveShortcutTokensByUser(ctx context.Context, arg CountActiveShortcutTokensByUserParams) (int64, error) {
row := q.db.QueryRowContext(ctx, countActiveShortcutTokensByUser, arg.UserID, arg.ExpiresAt)
var count int64
err := row.Scan(&count)
return count, err
}
const getShortcutToken = `-- name: GetShortcutToken :one
SELECT jti, user_id, label, scopes, created_at, expires_at, last_used_at, revoked
FROM shortcut_tokens
WHERE jti = ?
`
func (q *Queries) GetShortcutToken(ctx context.Context, jti string) (ShortcutToken, error) {
row := q.db.QueryRowContext(ctx, getShortcutToken, jti)
var i ShortcutToken
err := row.Scan(
&i.Jti,
&i.UserID,
&i.Label,
&i.Scopes,
&i.CreatedAt,
&i.ExpiresAt,
&i.LastUsedAt,
&i.Revoked,
)
return i, err
}
const insertShortcutToken = `-- name: InsertShortcutToken :exec
INSERT INTO shortcut_tokens (jti, user_id, label, scopes, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
`
type InsertShortcutTokenParams struct {
Jti string `json:"jti"`
UserID string `json:"user_id"`
Label string `json:"label"`
Scopes string `json:"scopes"`
CreatedAt int64 `json:"created_at"`
ExpiresAt int64 `json:"expires_at"`
}
// shortcut_tokens: long-lived, scope-limited, revocable HS256 tokens for the
// iOS Shortcut clipboard sync. Count backs the per-user cap; Revoke is scoped by
// user_id to block cross-user revocation; Touch records last use asynchronously.
func (q *Queries) InsertShortcutToken(ctx context.Context, arg InsertShortcutTokenParams) error {
_, err := q.db.ExecContext(ctx, insertShortcutToken,
arg.Jti,
arg.UserID,
arg.Label,
arg.Scopes,
arg.CreatedAt,
arg.ExpiresAt,
)
return err
}
const listShortcutTokensByUser = `-- name: ListShortcutTokensByUser :many
SELECT jti, user_id, label, scopes, created_at, expires_at, last_used_at, revoked
FROM shortcut_tokens
WHERE user_id = ?
ORDER BY created_at DESC
`
func (q *Queries) ListShortcutTokensByUser(ctx context.Context, userID string) ([]ShortcutToken, error) {
rows, err := q.db.QueryContext(ctx, listShortcutTokensByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ShortcutToken
for rows.Next() {
var i ShortcutToken
if err := rows.Scan(
&i.Jti,
&i.UserID,
&i.Label,
&i.Scopes,
&i.CreatedAt,
&i.ExpiresAt,
&i.LastUsedAt,
&i.Revoked,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const revokeShortcutToken = `-- name: RevokeShortcutToken :execrows
UPDATE shortcut_tokens
SET revoked = 1
WHERE jti = ? AND user_id = ?
`
type RevokeShortcutTokenParams struct {
Jti string `json:"jti"`
UserID string `json:"user_id"`
}
func (q *Queries) RevokeShortcutToken(ctx context.Context, arg RevokeShortcutTokenParams) (int64, error) {
result, err := q.db.ExecContext(ctx, revokeShortcutToken, arg.Jti, arg.UserID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const touchShortcutTokenUsed = `-- name: TouchShortcutTokenUsed :exec
UPDATE shortcut_tokens
SET last_used_at = ?
WHERE jti = ?
`
type TouchShortcutTokenUsedParams struct {
LastUsedAt *int64 `json:"last_used_at"`
Jti string `json:"jti"`
}
func (q *Queries) TouchShortcutTokenUsed(ctx context.Context, arg TouchShortcutTokenUsedParams) error {
_, err := q.db.ExecContext(ctx, touchShortcutTokenUsed, arg.LastUsedAt, arg.Jti)
return err
}
-286
View File
@@ -1,286 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: web_sessions.sql
package db
import (
"context"
)
const createSelfSession = `-- name: CreateSelfSession :exec
INSERT INTO web_sessions (id, user_id, refresh_token, device_name, user_agent, kind, scope, granted_by, created_at, last_used_at, expires_at)
VALUES (?, ?, '', ?, ?, ?, ?, ?, ?, ?, ?)
`
type CreateSelfSessionParams struct {
ID string `json:"id"`
UserID string `json:"user_id"`
DeviceName string `json:"device_name"`
UserAgent string `json:"user_agent"`
Kind string `json:"kind"`
Scope string `json:"scope"`
GrantedBy string `json:"granted_by"`
CreatedAt int64 `json:"created_at"`
LastUsedAt int64 `json:"last_used_at"`
ExpiresAt int64 `json:"expires_at"`
}
// CreateSelfSession inserts a cdrop self-signed session (kind self/guest) that has
// no IdP refresh_token (stored empty): refresh mints a fresh access token straight
// from this row without touching the IdP. Used by QR scan-login (AUTH.md 4).
func (q *Queries) CreateSelfSession(ctx context.Context, arg CreateSelfSessionParams) error {
_, err := q.db.ExecContext(ctx, createSelfSession,
arg.ID,
arg.UserID,
arg.DeviceName,
arg.UserAgent,
arg.Kind,
arg.Scope,
arg.GrantedBy,
arg.CreatedAt,
arg.LastUsedAt,
arg.ExpiresAt,
)
return err
}
const createWebSession = `-- name: CreateWebSession :exec
INSERT INTO web_sessions (id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`
type CreateWebSessionParams struct {
ID string `json:"id"`
UserID string `json:"user_id"`
RefreshToken string `json:"refresh_token"`
DeviceName string `json:"device_name"`
UserAgent string `json:"user_agent"`
CreatedAt int64 `json:"created_at"`
LastUsedAt int64 `json:"last_used_at"`
ExpiresAt int64 `json:"expires_at"`
}
// web_sessions: browser "passwordless re-login". The opaque cookie token is
// never stored; id holds its SHA-256, so a DB leak yields no usable cookie.
// refresh_token is AES-256-GCM ciphertext (key lives in the container env, not
// the DB). 7-day sliding window: every refresh rotates the token and pushes
// expires_at forward; idle sessions are reaped by DeleteExpiredWebSessions.
func (q *Queries) CreateWebSession(ctx context.Context, arg CreateWebSessionParams) error {
_, err := q.db.ExecContext(ctx, createWebSession,
arg.ID,
arg.UserID,
arg.RefreshToken,
arg.DeviceName,
arg.UserAgent,
arg.CreatedAt,
arg.LastUsedAt,
arg.ExpiresAt,
)
return err
}
const deleteExpiredWebSessions = `-- name: DeleteExpiredWebSessions :execrows
DELETE FROM web_sessions
WHERE expires_at < ?
`
func (q *Queries) DeleteExpiredWebSessions(ctx context.Context, expiresAt int64) (int64, error) {
result, err := q.db.ExecContext(ctx, deleteExpiredWebSessions, expiresAt)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const deleteWebSession = `-- name: DeleteWebSession :exec
DELETE FROM web_sessions
WHERE id = ?
`
func (q *Queries) DeleteWebSession(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, deleteWebSession, id)
return err
}
const deleteWebSessionForUser = `-- name: DeleteWebSessionForUser :execrows
DELETE FROM web_sessions
WHERE id = ? AND user_id = ?
`
type DeleteWebSessionForUserParams struct {
ID string `json:"id"`
UserID string `json:"user_id"`
}
// DeleteWebSessionForUser drops one session scoped to its owner, so a user can
// only revoke their own.
func (q *Queries) DeleteWebSessionForUser(ctx context.Context, arg DeleteWebSessionForUserParams) (int64, error) {
result, err := q.db.ExecContext(ctx, deleteWebSessionForUser, arg.ID, arg.UserID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getWebSession = `-- name: GetWebSession :one
SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by, stepped_up_at
FROM web_sessions
WHERE id = ?
`
func (q *Queries) GetWebSession(ctx context.Context, id string) (WebSession, error) {
row := q.db.QueryRowContext(ctx, getWebSession, id)
var i WebSession
err := row.Scan(
&i.ID,
&i.UserID,
&i.RefreshToken,
&i.DeviceName,
&i.UserAgent,
&i.CreatedAt,
&i.LastUsedAt,
&i.ExpiresAt,
&i.Kind,
&i.Scope,
&i.GrantedBy,
&i.SteppedUpAt,
)
return i, err
}
const listWebSessionsByUser = `-- name: ListWebSessionsByUser :many
SELECT id, user_id, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by
FROM web_sessions
WHERE user_id = ?
ORDER BY last_used_at DESC
`
type ListWebSessionsByUserRow struct {
ID string `json:"id"`
UserID string `json:"user_id"`
DeviceName string `json:"device_name"`
UserAgent string `json:"user_agent"`
CreatedAt int64 `json:"created_at"`
LastUsedAt int64 `json:"last_used_at"`
ExpiresAt int64 `json:"expires_at"`
Kind string `json:"kind"`
Scope string `json:"scope"`
GrantedBy string `json:"granted_by"`
}
// ListWebSessionsByUser lists a user's sessions for the management UI (revoke
// borrowed / guest devices). granted_by ties a scan-approved session to its
// approver for cascade revocation.
func (q *Queries) ListWebSessionsByUser(ctx context.Context, userID string) ([]ListWebSessionsByUserRow, error) {
rows, err := q.db.QueryContext(ctx, listWebSessionsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListWebSessionsByUserRow
for rows.Next() {
var i ListWebSessionsByUserRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.DeviceName,
&i.UserAgent,
&i.CreatedAt,
&i.LastUsedAt,
&i.ExpiresAt,
&i.Kind,
&i.Scope,
&i.GrantedBy,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const rotateWebSession = `-- name: RotateWebSession :exec
UPDATE web_sessions
SET refresh_token = ?, last_used_at = ?, expires_at = ?
WHERE id = ?
`
type RotateWebSessionParams struct {
RefreshToken string `json:"refresh_token"`
LastUsedAt int64 `json:"last_used_at"`
ExpiresAt int64 `json:"expires_at"`
ID string `json:"id"`
}
func (q *Queries) RotateWebSession(ctx context.Context, arg RotateWebSessionParams) error {
_, err := q.db.ExecContext(ctx, rotateWebSession,
arg.RefreshToken,
arg.LastUsedAt,
arg.ExpiresAt,
arg.ID,
)
return err
}
const setSessionSteppedUp = `-- name: SetSessionSteppedUp :exec
UPDATE web_sessions
SET stepped_up_at = ?
WHERE id = ?
`
type SetSessionSteppedUpParams struct {
SteppedUpAt int64 `json:"stepped_up_at"`
ID string `json:"id"`
}
// SetSessionSteppedUp stamps the moment this session last passed step-up re-auth;
// sensitive actions within the freshness window then skip re-auth (AUTH.md 6).
func (q *Queries) SetSessionSteppedUp(ctx context.Context, arg SetSessionSteppedUpParams) error {
_, err := q.db.ExecContext(ctx, setSessionSteppedUp, arg.SteppedUpAt, arg.ID)
return err
}
const setWebSessionDevice = `-- name: SetWebSessionDevice :exec
UPDATE web_sessions
SET device_name = ?
WHERE id = ?
`
type SetWebSessionDeviceParams struct {
DeviceName string `json:"device_name"`
ID string `json:"id"`
}
func (q *Queries) SetWebSessionDevice(ctx context.Context, arg SetWebSessionDeviceParams) error {
_, err := q.db.ExecContext(ctx, setWebSessionDevice, arg.DeviceName, arg.ID)
return err
}
const slideWebSession = `-- name: SlideWebSession :exec
UPDATE web_sessions
SET last_used_at = ?, expires_at = ?
WHERE id = ?
`
type SlideWebSessionParams struct {
LastUsedAt int64 `json:"last_used_at"`
ExpiresAt int64 `json:"expires_at"`
ID string `json:"id"`
}
// SlideWebSession pushes a session's sliding window forward without rotating any
// token. Self/guest sessions use it on refresh (they have no refresh_token to
// rotate); oidc sessions keep using RotateWebSession.
func (q *Queries) SlideWebSession(ctx context.Context, arg SlideWebSessionParams) error {
_, err := q.db.ExecContext(ctx, slideWebSession, arg.LastUsedAt, arg.ExpiresAt, arg.ID)
return err
}
-496
View File
@@ -1,496 +0,0 @@
package httpapi
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"commilitia.net/cdrop/internal/db"
)
// PROJECT_BRIEF.md §2 keeps the browser path on standard OIDC PKCE. Backend
// proxies the token endpoint so the browser doesn't need a CORS-friendly OIDC
// provider. The browser is then handed a cdrop self-signed access token (sid-bound
// to its web_sessions row), not the IdP's RS256 token, so OIDC web sessions verify
// statefully and revoke immediately — unified with scan-login (AUTH.md §5).
type authConfigResp struct {
AuthMode string `json:"auth_mode"`
AuthorizeURL string `json:"authorize_url"`
// TokenURL is published so native clients (the desktop app) can run their
// own loopback PKCE flow directly against the provider, reusing this same
// client_id. The browser doesn't need it — it proxies through /api/auth/exchange.
TokenURL string `json:"token_url"`
ClientID string `json:"client_id"`
RedirectURI string `json:"redirect_uri"`
Scopes string `json:"scopes"`
}
// handleAuthConfig publishes everything the frontend needs to start the PKCE
// authorize redirect. No auth required (it's all public OIDC metadata).
func (s *Server) handleAuthConfig(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, authConfigResp{
AuthMode: s.cfg.AuthMode,
AuthorizeURL: s.cfg.OIDCAuthorizeURL,
TokenURL: s.cfg.OIDCTokenURL,
ClientID: s.cfg.OIDCClientID,
RedirectURI: s.cfg.OIDCRedirectURI,
Scopes: s.cfg.OIDCScopes,
})
}
type exchangeReq struct {
Code string `json:"code"`
CodeVerifier string `json:"code_verifier"`
}
type tokenResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}
type userResp struct {
ID string `json:"id"`
Name string `json:"name"`
Avatar string `json:"avatar,omitempty"`
}
// exchangeResp deliberately omits refresh_token: the durable credential never
// reaches the browser. It's encrypted into the server-side web_sessions row and
// represented to the client only by the HttpOnly session cookie set alongside.
type exchangeResp struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
User userResp `json:"user"`
}
func (s *Server) handleAuthExchange(w http.ResponseWriter, r *http.Request) {
var req exchangeReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if req.Code == "" || req.CodeVerifier == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing code or code_verifier"})
return
}
if s.cfg.OIDCTokenURL == "" {
writeJSON(w, http.StatusInternalServerError,
map[string]string{"error": "OIDC token URL not configured"})
return
}
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", req.Code)
form.Set("code_verifier", req.CodeVerifier)
form.Set("client_id", s.cfg.OIDCClientID)
form.Set("redirect_uri", s.cfg.OIDCRedirectURI)
// Casdoor's "cdrop" application is a confidential client; PKCE is layered
// on top of the standard secret. Skip the secret if not configured to keep
// public-client OIDC providers happy.
if s.cfg.OIDCClientSecret != "" {
form.Set("client_secret", s.cfg.OIDCClientSecret)
}
tr, err := postOIDCToken(r.Context(), s.cfg.OIDCTokenURL, form)
if err != nil {
slog.Warn("oidc exchange failed", "err", err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
identity, err := extractIdentity(tr.IDToken, tr.AccessToken, s.cfg.AccountMatchClaim)
if err != nil {
writeJSON(w, http.StatusBadGateway,
map[string]string{"error": "extract user: " + err.Error()})
return
}
user := identity.User
// Refresh the thin accounts row (display name / avatar / roles / match_key).
s.upsertAccount(r.Context(), identity)
// By default the browser holds a cdrop self-signed access token (sid-bound),
// not the IdP's RS256 token — so the OIDC web session is verified statefully on
// every request (verifySelfToken → web_sessions lookup) and revoking the row
// logs this device out on its very next call, exactly like scan-login. We mint
// it only after stashing the refresh_token server-side (encrypted, behind an
// HttpOnly cookie) so the session is durable. The IdP RS256 token stays the
// fallback for degraded configs (no encryption key / no refresh_token / an
// id_token that fails verification) — it is still independently verified per
// request via JWKS (verifyRS256), and the next refresh upgrades the device to
// the unified self-token model. (AUTH.md §5; desktop keeps its RS256 token.)
accessToken, expiresIn := tr.AccessToken, tr.ExpiresIn
if len(s.sessionKey) > 0 && tr.RefreshToken != "" {
id, cerr := s.createWebSession(r, w, user.ID, tr.RefreshToken)
if cerr != nil {
slog.Error("create web session failed", "err", cerr)
} else {
// The id_token is the SOLE trust anchor for the minted self token (the
// IdP RS256 token no longer reaches the browser to be re-verified), so
// its signature MUST validate here and its subject must match. On
// failure we keep the durable session but hand back the per-request-
// verified RS256 token; the next refresh upgrades this device to the
// self-token model. (Should not happen with a healthy IdP — warn ops.)
sub, _, verr := s.auth.VerifyIDToken(r.Context(), tr.IDToken)
if verr != nil || sub != user.ID {
slog.Warn("oidc id_token verification failed; using RS256 fallback",
"err", verr, "sub_match", sub == user.ID)
} else if tok, ein, merr := s.mintSessionToken(user.ID, id, "full"); merr != nil {
slog.Error("mint session token failed", "err", merr)
} else {
accessToken, expiresIn = tok, ein
}
}
}
writeJSON(w, http.StatusOK, exchangeResp{
AccessToken: accessToken,
ExpiresIn: expiresIn,
User: user,
})
}
// createWebSession encrypts the refresh_token, persists a new session row, and
// sets the session cookie on the response. Returns the session row id so the
// caller can bind a cdrop self-signed access token to it (sid).
func (s *Server) createWebSession(r *http.Request, w http.ResponseWriter, userID, refreshToken string) (string, error) {
raw, id, err := newSessionToken()
if err != nil {
return "", err
}
enc, err := s.encryptRefresh(refreshToken)
if err != nil {
return "", err
}
now := time.Now()
if err := s.queries.CreateWebSession(r.Context(), db.CreateWebSessionParams{
ID: id,
UserID: userID,
RefreshToken: enc,
DeviceName: "",
UserAgent: truncate(r.UserAgent(), 256),
CreatedAt: now.Unix(),
LastUsedAt: now.Unix(),
ExpiresAt: now.Add(webSessionTTL).Unix(),
}); err != nil {
return "", err
}
setSessionCookie(w, raw)
return id, nil
}
// refreshResp is what the browser sees: a fresh access_token plus the identity
// and device name recovered from the session. No refresh_token — it stays
// server-side. This same endpoint, called with the cookie and no body on app
// boot, IS the passwordless re-login path.
type refreshResp struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
User userResp `json:"user"`
DeviceName string `json:"device_name"`
}
func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) {
if s.cfg.OIDCTokenURL == "" {
writeJSON(w, http.StatusInternalServerError,
map[string]string{"error": "OIDC token URL not configured"})
return
}
if !s.sameOrigin(r) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad origin"})
return
}
c, err := r.Cookie(sessionCookieName)
if err != nil || c.Value == "" {
clearSessionCookie(w)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "no session"})
return
}
id := sessionID(c.Value)
// Serialise concurrent refreshes of this session, then read the row INSIDE the
// lock: a racing tab may already have rotated the refresh_token, and we must
// spend the current one, not a stale copy the IdP would reject.
unlock := s.lockRefresh(id)
defer unlock()
sess, err := s.queries.GetWebSession(r.Context(), id)
if err != nil {
// Unknown / deleted session → not authenticated. Clear the stale cookie.
clearSessionCookie(w)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid session"})
return
}
now := time.Now()
if sess.ExpiresAt < now.Unix() {
_ = s.queries.DeleteWebSession(r.Context(), id)
clearSessionCookie(w)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "session expired"})
return
}
// Self-signed sessions (scan-login: kind self/guest) carry no IdP refresh_token.
// Mint a fresh cdrop access token straight from the row, slide the window for
// persistent sessions, re-arm the cookie — never touching the IdP (AUTH.md §3.1).
if sess.Kind != "oidc" {
s.refreshSelfSession(w, r, sess, c.Value, now)
return
}
refreshToken, err := s.decryptRefresh(sess.RefreshToken)
if err != nil {
// Corrupt ciphertext or rotated key — the session is unusable, drop it.
_ = s.queries.DeleteWebSession(r.Context(), id)
clearSessionCookie(w)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid session"})
return
}
form := url.Values{}
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", refreshToken)
form.Set("client_id", s.cfg.OIDCClientID)
if s.cfg.OIDCClientSecret != "" {
form.Set("client_secret", s.cfg.OIDCClientSecret)
}
tr, err := postOIDCToken(r.Context(), s.cfg.OIDCTokenURL, form)
if err != nil {
// The IdP rejected the refresh_token (expired / revoked) — the session is
// dead end to end, so destroy it rather than leave a row that always 401s.
slog.Warn("oidc refresh failed", "err", err)
_ = s.queries.DeleteWebSession(r.Context(), id)
clearSessionCookie(w)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "refresh rejected"})
return
}
identity, err := extractIdentity(tr.IDToken, tr.AccessToken, s.cfg.AccountMatchClaim)
if err != nil {
writeJSON(w, http.StatusBadGateway,
map[string]string{"error": "extract user: " + err.Error()})
return
}
user := identity.User
// Keep the thin accounts row fresh on every successful refresh too.
s.upsertAccount(r.Context(), identity)
// Rotate: Casdoor issues a new refresh_token on each grant; fall back to the
// existing one if it didn't. Slide the window forward and re-arm the cookie.
newRefresh := tr.RefreshToken
if newRefresh == "" {
newRefresh = refreshToken
}
if enc, encErr := s.encryptRefresh(newRefresh); encErr == nil {
if err := s.queries.RotateWebSession(r.Context(), db.RotateWebSessionParams{
RefreshToken: enc,
LastUsedAt: now.Unix(),
ExpiresAt: now.Add(webSessionTTL).Unix(),
ID: id,
}); err != nil {
slog.Error("rotate web session failed", "err", err)
}
} else {
slog.Error("encrypt rotated refresh token failed", "err", encErr)
}
setSessionCookie(w, c.Value)
// Hand back a cdrop self-signed access token (sid-bound), not the IdP RS256
// token: the browser holds one uniform token type and revoking the session row
// logs this device out on its next request. The IdP round-trip above still ran
// — it rotated the refresh_token and surfaced IdP-side revocation (a rejected
// refresh deletes the session above) — its access_token simply no longer ships.
// Mint from the row's canonical user_id; degrade to the IdP token only if the
// HS256 key is unconfigured. (AUTH.md §5.)
accessToken, expiresIn := tr.AccessToken, tr.ExpiresIn
if tok, ein, mErr := s.mintSessionToken(sess.UserID, id, "full"); mErr != nil {
slog.Error("mint session token failed", "err", mErr)
} else {
accessToken, expiresIn = tok, ein
}
writeJSON(w, http.StatusOK, refreshResp{
AccessToken: accessToken,
ExpiresIn: expiresIn,
User: user,
DeviceName: sess.DeviceName,
})
}
// refreshSelfSession renews a cdrop self-signed session without contacting the
// IdP: it mints a fresh access token from the row. Persistent "self" sessions
// slide their inactivity window; one-time "guest" borrows do not (their fixed
// expires_at caps the borrow at QR_GUEST_TTL). Identity is enriched from the
// accounts row when present, else falls back to the bare user_id.
func (s *Server) refreshSelfSession(w http.ResponseWriter, r *http.Request, sess db.WebSession, rawCookie string, now time.Time) {
token, expiresIn, err := s.mintSessionToken(sess.UserID, sess.ID, sess.Scope)
if err != nil {
slog.Error("mint session token failed", "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "mint failed"})
return
}
if sess.Kind == "self" {
newExp := now.Add(time.Duration(s.cfg.QRPersistTTLHours) * time.Hour)
if err := s.queries.SlideWebSession(r.Context(), db.SlideWebSessionParams{
LastUsedAt: now.Unix(),
ExpiresAt: newExp.Unix(),
ID: sess.ID,
}); err != nil {
slog.Error("slide web session failed", "err", err)
}
setSessionCookie(w, rawCookie)
}
user := userResp{ID: sess.UserID, Name: sess.UserID}
if acct, err := s.queries.GetAccount(r.Context(), sess.UserID); err == nil {
if acct.DisplayName != "" {
user.Name = acct.DisplayName
}
user.Avatar = acct.AvatarUrl
}
writeJSON(w, http.StatusOK, refreshResp{
AccessToken: token,
ExpiresIn: expiresIn,
User: user,
DeviceName: sess.DeviceName,
})
}
var oidcHTTPClient = &http.Client{Timeout: 10 * time.Second}
func postOIDCToken(ctx interface{ Done() <-chan struct{} }, tokenURL string, form url.Values) (*tokenResp, error) {
req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := oidcHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("oidc unreachable: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("oidc status %d: %s", resp.StatusCode, truncate(string(body), 200))
}
var tr tokenResp
if err := json.Unmarshal(body, &tr); err != nil {
return nil, fmt.Errorf("oidc malformed: %w", err)
}
if tr.AccessToken == "" {
return nil, fmt.Errorf("oidc returned no access_token")
}
return &tr, nil
}
// tokenIdentity is what cdrop reads out of an OIDC id_token at login: the display
// identity plus the thin-account fields — match_key (for the admin-gated migration
// relink) and roles (the groups claim cache). AUTH.md §2.1.
type tokenIdentity struct {
User userResp
MatchKey string
Roles []string
}
// extractIdentity parses {sub, preferred_username | name | email, avatar | picture,
// matchClaim, groups} from the id_token if available, else the access_token. The
// signature is NOT verified here — callers verify separately around it: at login
// (handleAuthExchange) the id_token signature is checked via VerifyIDToken before
// the extracted subject is trusted to mint a self token; at refresh the IdP just
// authenticated the refresh_token over a TLS back-channel, so the token it returns
// is trusted by transport. Either way the extracted fields are sound. (AUTH.md §5.)
func extractIdentity(idToken, accessToken, matchClaim string) (tokenIdentity, error) {
pick := idToken
if pick == "" {
pick = accessToken
}
parsed, err := jwt.ParseSigned(pick, []jose.SignatureAlgorithm{
jose.RS256, jose.RS384, jose.RS512,
jose.ES256, jose.ES384, jose.ES512,
jose.HS256,
})
if err != nil {
return tokenIdentity{}, err
}
var std jwt.Claims
custom := map[string]any{}
if err := parsed.UnsafeClaimsWithoutVerification(&std, &custom); err != nil {
return tokenIdentity{}, err
}
id := tokenIdentity{User: userResp{ID: std.Subject}}
if v, ok := custom["preferred_username"].(string); ok && v != "" {
id.User.Name = v
} else if v, ok := custom["name"].(string); ok && v != "" {
id.User.Name = v
} else if v, ok := custom["email"].(string); ok && v != "" {
id.User.Name = v
} else {
id.User.Name = id.User.ID
}
// Casdoor 的 OIDC id_token 暴露 avatar(自定义) 与 picture(标准 claim) 两种字段;
// 不同 IdP 实现各异,所以都查一遍。值为空字符串视为未提供,前端落到字母方块回退。
if v, ok := custom["avatar"].(string); ok && v != "" {
id.User.Avatar = v
} else if v, ok := custom["picture"].(string); ok && v != "" {
id.User.Avatar = v
}
// match_key feeds the admin-gated cross-provider relink; pick the configured
// claim (default email). Absent / non-string → empty, which simply never matches.
if matchClaim != "" {
if v, ok := custom[matchClaim].(string); ok {
id.MatchKey = v
}
}
if g, ok := custom["groups"].([]any); ok {
for _, item := range g {
if r, ok := item.(string); ok && r != "" {
id.Roles = append(id.Roles, r)
}
}
}
return id, nil
}
// upsertAccount refreshes the caller's thin accounts row from their OIDC identity
// (AUTH.md §2.1). Best-effort — a write failure must never fail the login itself.
func (s *Server) upsertAccount(ctx context.Context, id tokenIdentity) {
if id.User.ID == "" {
return
}
now := time.Now().Unix()
if err := s.queries.UpsertAccount(ctx, db.UpsertAccountParams{
UserID: id.User.ID,
MatchKey: id.MatchKey,
DisplayName: id.User.Name,
AvatarUrl: id.User.Avatar,
Roles: strings.Join(id.Roles, ","),
Provider: "oidc",
CreatedAt: now,
LastLoginAt: now,
}); err != nil {
slog.Error("upsert account failed", "err", err, "user", id.User.ID)
}
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
-57
View File
@@ -1,57 +0,0 @@
package httpapi
import (
"testing"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
)
// extractIdentity pulls the display identity plus the thin-account fields
// (match_key from the configured claim, roles from groups) out of an id_token.
// It does not verify the signature, so a throwaway HS256 key suffices here.
func TestExtractIdentity(t *testing.T) {
key := []byte("any-throwaway-signing-key-32-bytes!!")
sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: key}, (&jose.SignerOptions{}).WithType("JWT"))
if err != nil {
t.Fatalf("signer: %v", err)
}
tok, err := jwt.Signed(sig).
Claims(jwt.Claims{Subject: "sub-123", Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour))}).
Claims(map[string]any{
"preferred_username": "alice",
"email": "alice@example.net",
"avatar": "https://a/av.png",
"groups": []any{"admins", "staff"},
}).Serialize()
if err != nil {
t.Fatalf("sign: %v", err)
}
id, err := extractIdentity(tok, "", "email")
if err != nil {
t.Fatalf("extract: %v", err)
}
if id.User.ID != "sub-123" || id.User.Name != "alice" || id.User.Avatar != "https://a/av.png" {
t.Errorf("user wrong: %+v", id.User)
}
if id.MatchKey != "alice@example.net" {
t.Errorf("match_key: got %q, want alice@example.net", id.MatchKey)
}
if len(id.Roles) != 2 || id.Roles[0] != "admins" || id.Roles[1] != "staff" {
t.Errorf("roles: got %v, want [admins staff]", id.Roles)
}
// No name claim → falls back to sub; absent match claim → empty (never matches).
tok2, _ := jwt.Signed(sig).
Claims(jwt.Claims{Subject: "sub-x", Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour))}).
Serialize()
id2, err := extractIdentity(tok2, "", "email")
if err != nil {
t.Fatalf("extract2: %v", err)
}
if id2.User.Name != "sub-x" || id2.MatchKey != "" || len(id2.Roles) != 0 {
t.Errorf("fallback wrong: %+v", id2)
}
}
+158
View File
@@ -0,0 +1,158 @@
package httpapi
import (
"encoding/json"
"log/slog"
"net/http"
"strings"
"time"
"commilitia.net/cdrop/internal/brokerclient"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/jwtauth"
)
// 代铸 (proxy-mint). The unified-session-model endpoint that makes every login — browser
// global-SSO and native device-authorize alike — a cdrop-managed device session, so all
// clients share one device list and one management surface.
//
// The caller has already been verified at the edge (X-Auth-Subject) by either a global-SSO
// cookie (browser) or a device-authorize bootstrap token (desktop/native). cdrop vouches for
// that subject and asks the broker (R2: idempotent by user+app+meta) to mint or rotate a
// device session bound to the client's stable device_id. Re-logins with the same device_id
// rotate the same session instead of piling up — the same trust model as scan-login collect.
type deviceSessionReq struct {
// DeviceID is the client's stable opaque id (persisted client-side). Empty on first
// contact — the server then mints one and returns it for the client to persist.
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
DeviceType string `json:"device_type"` // browser | macos | windows | linux | ios
}
type deviceSessionResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
// UserID + Name + Avatar are the verified identity (X-Auth-Subject / X-Auth-Name /
// X-Auth-Avatar). They let a native client — which only ever sees nameless machine
// tokens — populate its identity with the real display name and picture instead of the
// subject UUID, without a /api/me round-trip.
UserID string `json:"user_id"`
Name string `json:"name"`
Avatar string `json:"avatar,omitempty"`
}
// handleDeviceSession mints (or idempotently rotates) the caller's cdrop device session.
func (s *Server) handleDeviceSession(w http.ResponseWriter, r *http.Request) {
// CSRF: a cookie-authenticated browser fetch carries a matching Origin; a cross-site
// forgery would not (blocking forged mints that would reintroduce phantom devices). The
// desktop's Go-initiated call sends no Origin and is allowed.
if !s.sameOrigin(r) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad origin"})
return
}
claims, _ := jwtauth.ClaimsFromContext(r.Context())
var req deviceSessionReq
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
deviceID := strings.TrimSpace(req.DeviceID)
if deviceID == "" {
var err error
if deviceID, err = newDeviceID(); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "id gen"})
return
}
} else if !validDeviceID(deviceID) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid device_id"})
return
}
deviceName := jwtauth.SanitizeDeviceName(req.DeviceName)
if deviceName == "" {
deviceName = "New device"
}
deviceType := qrDeviceType(req.DeviceType)
// Mint at the caller's current trust tier: an SSO / device-authorize login is full, a
// restricted guest stays guest. This stops a borrowed (guest) browser from minting
// itself a full device session.
tier := "full"
if claims.Guest() {
tier = "guest"
}
accessTTL, refreshTTL := s.tierTTLs(tier)
sess, err := s.broker.MintSession(r.Context(), brokerclient.MintParams{
UserID: claims.UserID,
Tier: tier,
AccessTTL: accessTTL,
RefreshTTL: refreshTTL,
Sliding: true,
Label: deviceName,
Meta: deviceID,
})
if err != nil {
slog.Error("device-session mint failed", "err", err, "user", claims.UserID)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "mint failed"})
return
}
now := time.Now().Unix()
if err := s.queries.CreateDevice(r.Context(), db.CreateDeviceParams{
DeviceID: deviceID,
UserID: claims.UserID,
Name: deviceName,
Type: deviceType,
Tier: tier,
BrokerSid: sess.SID,
CreatedAt: now,
LastSeen: now,
}); err != nil {
// The session is minted and usable; a failed cache-row write only costs the local
// type/presence overlay, so proceed rather than strand the device without tokens.
slog.Error("device-session cache write failed", "err", err, "user", claims.UserID, "device", deviceID)
}
name := claims.Name
if name == "" {
name = claims.UserID
}
expiresIn := int(sess.AccessExpires - now)
if expiresIn < 0 {
expiresIn = 0
}
writeJSON(w, http.StatusOK, deviceSessionResp{
AccessToken: sess.Access,
RefreshToken: sess.Refresh,
ExpiresIn: expiresIn,
DeviceID: deviceID,
DeviceName: deviceName,
UserID: claims.UserID,
Name: name,
Avatar: claims.Avatar,
})
}
// validDeviceID accepts a cdrop device_id: the "dev_" prefix plus pure [A-Za-z0-9_-], capped
// in length. This both recognises cdrop's own ids (newDeviceID) and guarantees the value is
// control-byte-free, so it is safe to pass to the broker as meta (echoed into X-Auth-Meta).
func validDeviceID(id string) bool {
if !strings.HasPrefix(id, "dev_") || len(id) > 128 {
return false
}
for _, c := range id {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '_', c == '-':
default:
return false
}
}
return true
}
+47 -18
View File
@@ -1,6 +1,7 @@
package httpapi
import (
"encoding/json"
"log/slog"
"net/http"
@@ -11,8 +12,10 @@ import (
)
type deviceItem struct {
DeviceID string `json:"device_id"`
Name string `json:"name"`
Type string `json:"type"`
Tier string `json:"tier"`
Online bool `json:"online"`
LastSeen int64 `json:"last_seen"`
}
@@ -32,8 +35,10 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) {
out := make([]deviceItem, 0, len(devs))
for _, d := range devs {
out = append(out, deviceItem{
DeviceID: d.DeviceID,
Name: d.Name,
Type: d.Type,
Tier: d.Tier,
Online: s.hub.Online(claims.UserID, d.Name),
LastSeen: d.LastSeen,
})
@@ -41,40 +46,64 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"devices": out})
}
// handleDeleteDevice 注销设备:删除 (user, name) 行 + Kick 在线 SSE + 立刻广播
// 新的 presence。请求方若注销的是自己当前设备,前端需同步调用 logout,否则
// 任何后续带 X-Device-Name 的认证请求都会被中间件再次 UPSERT 回来。
//
// 这是登录会话列表里「原生客户端(桌面 / iOS)」一栏的登出路径——它们用 IdP 令牌、
// 不入 web_sessions,故按设备名吊销(AUTH.md §4)。注销设备属敏感动作,与吊销网页
// 会话同口径要求最近一次 step-up403 step_up_required,前端据此发起再认证)。
// handleDeleteDevice removes a device: it revokes the device's broker session (so it
// can no longer refresh and dies within its short access TTL), then drops the local
// row, kicks its live SSE, and re-broadcasts presence. Identified by the stable
// device_id; the caller must own it. A full session is required (route-gated) — the
// old step-up requirement is gone, since a full-tier session is itself trusted.
func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request) {
if s.cfg.StepUpEnabled && !s.sessionRecentlySteppedUp(r) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"})
claims, _ := jwtauth.ClaimsFromContext(r.Context())
deviceID := chi.URLParam(r, "device_id")
if deviceID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device id"})
return
}
status, ok := s.revokeDevice(r, claims.UserID, deviceID)
if !ok {
writeJSON(w, status, map[string]string{"error": revokeErrorMsg(status)})
return
}
w.WriteHeader(http.StatusNoContent)
}
type renameDeviceReq struct {
Name string `json:"name"`
}
// handleRenameDevice changes a device's human-readable name. The device's identity is
// device_id, so a rename never changes which session / row it is — fixing the old
// model where the name was the key and renaming meant losing the device. Full session.
func (s *Server) handleRenameDevice(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
name := chi.URLParam(r, "name")
deviceID := chi.URLParam(r, "device_id")
if deviceID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device id"})
return
}
var body renameDeviceReq
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
name := jwtauth.SanitizeDeviceName(body.Name)
if name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device name"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "empty device name"})
return
}
rows, err := s.queries.DeleteDevice(r.Context(), db.DeleteDeviceParams{
UserID: claims.UserID,
n, err := s.queries.RenameDevice(r.Context(), db.RenameDeviceParams{
Name: name,
DeviceID: deviceID,
UserID: claims.UserID,
})
if err != nil {
slog.Error("delete device failed", "err", err, "user", claims.UserID, "name", name)
slog.Error("rename device failed", "err", err, "user", claims.UserID, "device", deviceID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
if rows == 0 {
if n == 0 {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "device not found"})
return
}
s.hub.Kick(claims.UserID, name)
s.hub.PublishPresence(r.Context(), claims.UserID)
w.WriteHeader(http.StatusNoContent)
}
+59
View File
@@ -0,0 +1,59 @@
package httpapi
import (
"net/http"
"net/url"
"strings"
)
type authConfigResp struct {
AuthMode string `json:"auth_mode"`
BrokerURL string `json:"broker_url"`
BrokerApp string `json:"broker_app"`
}
// handleAuthConfig publishes the Auth Broker coordinates native clients (the desktop)
// need to run the broker device-authorization flow: the broker's public URL + this
// app's key in the broker apps registry. Public (it bootstraps native login).
func (s *Server) handleAuthConfig(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, authConfigResp{
AuthMode: s.cfg.AuthMode,
BrokerURL: s.cfg.BrokerPublicURL,
BrokerApp: s.cfg.BrokerAppOrDefault(),
})
}
// handleAuthLogin 302-redirects the browser to the Auth Broker's public login page
// for global SSO (the broker handles Casdoor and sets its domain cookie; on return the
// edge injects X-Auth from that cookie). cdrop owns this redirect so the broker's
// public URL lives in one server-side config. Public (it bootstraps login). The rd
// (return target) is constrained to this deployment's own origin — an open-redirect
// guard — defaulting to the site root.
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
if s.cfg.BrokerPublicURL == "" {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "login redirect not configured"})
return
}
rd := s.safeReturnTarget(r.URL.Query().Get("rd"))
dest := strings.TrimRight(s.cfg.BrokerPublicURL, "/") + "/login?rd=" + url.QueryEscape(rd)
http.Redirect(w, r, dest, http.StatusFound)
}
// safeReturnTarget validates a post-login return URL against this deployment's origin,
// falling back to the site root. It blocks open redirects: only a same-origin absolute
// URL (or, when no site origin is configured in dev, a site-relative path) is accepted.
func (s *Server) safeReturnTarget(rd string) string {
if s.siteOrigin == "" {
// dev: accept only a site-relative path, never an absolute URL.
if strings.HasPrefix(rd, "/") && !strings.HasPrefix(rd, "//") {
return rd
}
return "/"
}
if u, err := url.Parse(rd); err == nil && u.Scheme != "" && u.Host != "" {
if u.Scheme+"://"+u.Host == s.siteOrigin {
return rd
}
}
return s.siteOrigin + "/"
}
+94 -162
View File
@@ -9,10 +9,10 @@ import (
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"time"
"commilitia.net/cdrop/internal/brokerclient"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/jwtauth"
)
@@ -49,12 +49,23 @@ type qrStartResp struct {
ExpiresAt int64 `json:"expires_at"`
}
// userResp is the display identity handed to a client. After the broker migration
// cdrop no longer holds an accounts table; the approver's name is best-effort (the new
// device refreshes it from X-Auth-Name via /api/me once its token is live).
type userResp struct {
ID string `json:"id"`
Name string `json:"name"`
Avatar string `json:"avatar,omitempty"`
}
type qrStatusResp struct {
Status string `json:"status"` // pending | approved | denied | expired
AccessToken string `json:"access_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresIn int `json:"expires_in,omitempty"`
User *userResp `json:"user,omitempty"`
DeviceName string `json:"device_name,omitempty"`
DeviceID string `json:"device_id,omitempty"`
}
type qrRequestResp struct {
@@ -63,19 +74,13 @@ type qrRequestResp struct {
RequestIP string `json:"request_ip"`
ExpiresAt int64 `json:"expires_at"`
Status string `json:"status"`
// StepUp tells the approver UI a fresh re-auth is required before approving.
StepUp bool `json:"step_up"`
}
type qrApproveReq struct {
RequestID string `json:"request_id"`
ApprovalCode string `json:"approval_code"`
Scope string `json:"scope"` // full | guest (this milestone always guest)
Scope string `json:"scope"` // full | guest
Persist string `json:"persist"` // once | persist
// Step-up (when CDROP_STEP_UP_ENABLED): a fresh prompt=login PKCE code+verifier
// the backend exchanges and checks for a recent auth_time (AUTH.md §6).
StepUpCode string `json:"step_up_code"`
StepUpVerifier string `json:"step_up_verifier"`
}
type qrDenyReq struct {
@@ -191,10 +196,13 @@ func (s *Server) handleQRStatus(w http.ResponseWriter, r *http.Request) {
}
}
// collectQRSession turns an approved request into a live session for the new
// device. ConsumeLoginRequest is the single-use guard: only the poll that flips
// approved→consumed proceeds, so a duplicated/raced poll can't mint a second
// session.
// collectQRSession turns an approved request into a live session for the new device.
// ConsumeLoginRequest is the single-use guard: only the poll that flips
// approved→consumed proceeds, so a duplicated/raced poll can't mint a second session.
// The session itself is minted by the Auth Broker (path A): cdrop generates a stable
// device_id, has the broker mint a scoped access+refresh bound to it, and records the
// device row (with the broker's sid for later revocation). The new device holds the
// broker tokens directly — no cdrop cookie or self-signed token.
func (s *Server) collectQRSession(w http.ResponseWriter, r *http.Request, req db.LoginRequest) {
n, err := s.queries.ConsumeLoginRequest(r.Context(), req.ID)
if err != nil {
@@ -208,65 +216,76 @@ func (s *Server) collectQRSession(w http.ResponseWriter, r *http.Request, req db
return
}
raw, id, err := newSessionToken()
tier := "guest"
if req.GrantScope == "full" {
tier = "full"
}
accessTTL, refreshTTL := s.tierTTLs(tier)
deviceID, err := newDeviceID()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "id gen"})
return
}
scope := req.GrantScope
if scope != "full" {
scope = "guest"
}
// Persistent borrows slide their window (kind self); one-time borrows are kind
// guest with a fixed short expiry that refresh never extends (AUTH.md §3.1).
now := time.Now()
kind := "guest"
exp := now.Add(time.Duration(s.cfg.QRGuestTTLSeconds) * time.Second)
if req.GrantPersist == "persist" {
kind = "self"
exp = now.Add(time.Duration(s.cfg.QRPersistTTLHours) * time.Hour)
}
if err := s.queries.CreateSelfSession(r.Context(), db.CreateSelfSessionParams{
ID: id,
sess, err := s.broker.MintSession(r.Context(), brokerclient.MintParams{
UserID: req.ApproverUserID,
DeviceName: req.NewDeviceName,
UserAgent: req.UserAgent,
Kind: kind,
Scope: scope,
GrantedBy: "qr",
CreatedAt: now.Unix(),
LastUsedAt: now.Unix(),
ExpiresAt: exp.Unix(),
}); err != nil {
slog.Error("create self session failed", "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
token, expiresIn, err := s.mintSessionToken(req.ApproverUserID, id, scope)
Tier: tier,
AccessTTL: accessTTL,
RefreshTTL: refreshTTL,
Sliding: true,
Label: req.NewDeviceName,
Meta: deviceID,
})
if err != nil {
slog.Error("mint session token failed", "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "mint failed"})
slog.Error("broker mint failed", "err", err, "user", req.ApproverUserID)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "mint failed"})
return
}
setSessionCookie(w, raw)
now := time.Now().Unix()
if err := s.queries.CreateDevice(r.Context(), db.CreateDeviceParams{
DeviceID: deviceID,
UserID: req.ApproverUserID,
Name: req.NewDeviceName,
Type: req.NewDeviceType,
Tier: tier,
BrokerSid: sess.SID,
CreatedAt: now,
LastSeen: now,
}); err != nil {
// The session is already minted and usable; a failed device-row write only
// costs local management state (revoke / list), so proceed rather than strand
// the new device without its tokens.
slog.Error("create device failed", "err", err, "user", req.ApproverUserID, "device", deviceID)
}
expiresIn := int(sess.AccessExpires - now)
if expiresIn < 0 {
expiresIn = 0
}
// Best-effort display identity; the new device refreshes its real name from
// X-Auth-Name via /api/me once its token is live at the edge.
user := userResp{ID: req.ApproverUserID, Name: req.ApproverUserID}
if acct, err := s.queries.GetAccount(r.Context(), req.ApproverUserID); err == nil {
if acct.DisplayName != "" {
user.Name = acct.DisplayName
}
user.Avatar = acct.AvatarUrl
}
writeJSON(w, http.StatusOK, qrStatusResp{
Status: "approved",
AccessToken: token,
AccessToken: sess.Access,
RefreshToken: sess.Refresh,
ExpiresIn: expiresIn,
User: &user,
DeviceName: req.NewDeviceName,
DeviceID: deviceID,
})
}
// tierTTLs returns the configured access + refresh TTLs (seconds) for a tier.
func (s *Server) tierTTLs(tier string) (accessTTL, refreshTTL int) {
if tier == "guest" {
return s.cfg.GuestAccessTTLSeconds, s.cfg.GuestRefreshTTLSeconds
}
return s.cfg.FullAccessTTLSeconds, s.cfg.FullRefreshTTLSeconds
}
// handleQRRequest (full session) lets the approver see what they are about to
// authorise. The approval_code (from the QR) gates the lookup, so only someone
// who scanned the code can read the request's device info.
@@ -285,9 +304,6 @@ func (s *Server) handleQRRequest(w http.ResponseWriter, r *http.Request) {
RequestIP: req.RequestIp,
ExpiresAt: req.ExpiresAt,
Status: req.Status,
// Tell the approver UI to re-auth only if step-up is on AND this session
// hasn't recently stepped up (within the window) — no repeated prompts (#3).
StepUp: s.cfg.StepUpEnabled && !s.sessionRecentlySteppedUp(r),
})
}
@@ -309,19 +325,10 @@ func (s *Server) handleQRApprove(w http.ResponseWriter, r *http.Request) {
}
claims, _ := jwtauth.ClaimsFromContext(r.Context())
// Step-up: approving a new device into your account is sensitive. When enabled,
// require a fresh prompt=login re-auth (the provider's 2FA, if configured, is
// enforced during that exchange) — UNLESS this session already stepped up within
// the freshness window, so the same session isn't re-prompted repeatedly (#3).
if s.cfg.StepUpEnabled && !s.sessionRecentlySteppedUp(r) {
if !s.verifyStepUp(r, claims.UserID, body.StepUpCode, body.StepUpVerifier) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"})
return
}
s.recordStepUp(r)
}
scope := "guest" // this milestone grants restricted guest sessions only
// Step-up (a fresh re-auth before approving a device) is deferred to the broker
// post-migration (/login?switch=1); a full-tier session is trusted to approve.
// The route is already gated by requireFullSession, so a guest can't reach here.
scope := "guest"
if body.Scope == "full" {
scope = "full"
}
@@ -329,6 +336,13 @@ func (s *Server) handleQRApprove(w http.ResponseWriter, r *http.Request) {
if body.Persist == "persist" {
persist = "persist"
}
// 原生客户端(非浏览器:iOS / macOS / Windows / Linux)只接受完整权限会话——用户原则:
// 原生 App 不允许受限访客(受限访客仅是 Web / PWA 的权宜)。批准端只给「信任并继续 / 拒绝」,
// 后端在此再兜底强制 full + persist,无论批准请求送来什么 scope。
if req.NewDeviceType != "" && req.NewDeviceType != "browser" {
scope = "full"
persist = "persist"
}
now := time.Now()
n, err := s.queries.ApproveLoginRequest(r.Context(), db.ApproveLoginRequestParams{
ApproverUserID: claims.UserID,
@@ -373,99 +387,6 @@ func (s *Server) handleQRDeny(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// sessionFromCookie resolves the caller's web_session row from the cdrop_session
// cookie. The scan-approval endpoints live under /api/auth/*, which the cookie's
// Path covers, so both OIDC and self sessions can be located here.
func (s *Server) sessionFromCookie(r *http.Request) (db.WebSession, bool) {
c, err := r.Cookie(sessionCookieName)
if err != nil || c.Value == "" {
return db.WebSession{}, false
}
sess, err := s.queries.GetWebSession(r.Context(), sessionID(c.Value))
if err != nil {
return db.WebSession{}, false
}
return sess, true
}
// sessionRecentlySteppedUp reports whether the caller's session passed step-up
// within StepUpMaxAgeSeconds — so a sensitive action skips re-auth and the same
// session isn't re-prompted repeatedly (#3, AUTH.md §6).
func (s *Server) sessionRecentlySteppedUp(r *http.Request) bool {
sess, ok := s.sessionFromCookie(r)
if !ok || sess.SteppedUpAt == 0 {
return false
}
window := int64(s.cfg.StepUpMaxAgeSeconds)
if window <= 0 {
window = 300
}
return time.Now().Unix()-sess.SteppedUpAt <= window
}
// recordStepUp stamps the caller's session as freshly stepped-up.
func (s *Server) recordStepUp(r *http.Request) {
sess, ok := s.sessionFromCookie(r)
if !ok {
return
}
if err := s.queries.SetSessionSteppedUp(r.Context(), db.SetSessionSteppedUpParams{
SteppedUpAt: time.Now().Unix(),
ID: sess.ID,
}); err != nil {
slog.Warn("record step-up failed", "err", err)
}
}
// verifyStepUp exchanges a fresh prompt=login authorization code and confirms it
// proves a recent interactive re-authentication by the same user: the id_token's
// signature validates (JWKS), its sub matches the caller, and its auth_time is
// within StepUpMaxAgeSeconds. Any failure → false (the caller answers 403). The
// code is single-use at the IdP, so it can't be replayed (AUTH.md §6).
func (s *Server) verifyStepUp(r *http.Request, userID, code, verifier string) bool {
if code == "" || verifier == "" || s.cfg.OIDCTokenURL == "" {
return false
}
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("code_verifier", verifier)
form.Set("client_id", s.cfg.OIDCClientID)
form.Set("redirect_uri", s.cfg.OIDCRedirectURI)
if s.cfg.OIDCClientSecret != "" {
form.Set("client_secret", s.cfg.OIDCClientSecret)
}
tr, err := postOIDCToken(r.Context(), s.cfg.OIDCTokenURL, form)
if err != nil {
slog.Warn("step-up exchange failed", "err", err)
return false
}
if tr.IDToken == "" {
return false
}
sub, authTime, err := s.auth.VerifyIDToken(r.Context(), tr.IDToken)
if err != nil {
slog.Warn("step-up id_token invalid", "err", err)
return false
}
if sub != userID {
return false
}
// Enforce the freshness window only when the IdP supplied auth_time (Casdoor
// omits it). When absent, the fresh single-use prompt=login code — exchanged
// once, just now — is itself the bound on how recent the re-auth was.
if authTime > 0 {
maxAge := int64(s.cfg.StepUpMaxAgeSeconds)
if maxAge <= 0 {
maxAge = 300
}
if time.Now().Unix()-authTime > maxAge {
return false
}
}
return true
}
// lookupQRRequest fetches a request and verifies the approval_code in constant
// time. It writes the error response itself and returns ok=false on any failure
// (missing args, unknown request, bad code, expired), so callers just early-return.
@@ -530,3 +451,14 @@ func randB64(n int) (string, error) {
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// newDeviceID mints a stable opaque device identifier ("dev_" + base64url(16 random
// bytes)). It is cdrop's session<->device join key: passed to the broker as meta and
// echoed back as X-Auth-Meta. Pure [A-Za-z0-9_-], no control bytes (validMeta-safe).
func newDeviceID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return "dev_" + base64.RawURLEncoding.EncodeToString(b), nil
}
+326 -350
View File
@@ -13,21 +13,115 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"commilitia.net/cdrop/internal/brokerclient"
"commilitia.net/cdrop/internal/config"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/hub"
"commilitia.net/cdrop/internal/jwtauth"
)
const qrTestSecret = "qr-test-session-secret-at-least-32-bytes"
// mockBrokerState records what the fake Auth Broker was asked to do, so tests can
// assert cdrop delegated correctly (mint params, revoke + its X-Broker-App scope).
type mockBrokerState struct {
mintCount int
lastMint map[string]any
revoked map[string]bool
lastRevokeApp string
// sessions holds the live (non-revoked) delegated sessions keyed by sid, so the mock
// can answer R1 (GET /internal/sessions) and enforce R2 idempotency (same user+app+meta
// → same sid).
sessions map[string]*mockSession
}
// newQRTestServer builds a Server with only the fields the scan-login handlers
// touch, backed by a fresh file-based sqlite (an in-memory DB would give each
// pooled connection its own empty schema).
func newQRTestServer(t *testing.T) *Server {
type mockSession struct {
sid, userID, app, meta, label, scope string
createdAt, lastUsedAt int64
}
// newMockBroker stands in for the Auth Broker's internal API: POST /internal/sessions
// mints (R2-idempotent by user+app+meta) a session, GET /internal/sessions lists the
// user's live machine sessions (R1), and DELETE /internal/sessions/{sid} revokes one.
func newMockBroker(t *testing.T) (*brokerclient.Client, *mockBrokerState) {
t.Helper()
st := &mockBrokerState{revoked: map[string]bool{}, sessions: map[string]*mockSession{}}
str := func(m map[string]any, k string) string {
if v, ok := m[k].(string); ok {
return v
}
return ""
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/internal/sessions":
st.mintCount += 1
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
st.lastMint = body
userID, app, meta, label, tier := str(body, "user_id"), str(body, "app"), str(body, "meta"), str(body, "label"), str(body, "tier")
scope := "app:" + app
if tier != "" {
scope += ":" + tier
}
// R2 idempotency: same (user, app, meta) with non-empty meta rotates in place.
sid := ""
if meta != "" {
for _, sess := range st.sessions {
if !st.revoked[sess.sid] && sess.userID == userID && sess.app == app && sess.meta == meta {
sid = sess.sid
break
}
}
}
if sid == "" {
sid = fmt.Sprintf("sid-%d", st.mintCount)
}
now := time.Now().Unix()
created := now
if existing, ok := st.sessions[sid]; ok {
created = existing.createdAt // rotation preserves CreatedAt
}
st.sessions[sid] = &mockSession{sid: sid, userID: userID, app: app, meta: meta, label: label, scope: scope, createdAt: created, lastUsedAt: now}
_ = json.NewEncoder(w).Encode(map[string]any{
"id": sid, "app": "cdrop",
"access": "acc-" + sid, "refresh": "rtk-" + sid,
"access_expires": time.Now().Add(15 * time.Minute).Unix(),
"refresh_expires": time.Now().Add(24 * time.Hour).Unix(),
})
case r.Method == http.MethodGet && r.URL.Path == "/internal/sessions":
q := r.URL.Query()
userID, app := q.Get("user_id"), q.Get("app")
if userID == "" || app == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
out := []map[string]any{}
for _, sess := range st.sessions {
if st.revoked[sess.sid] || sess.userID != userID || sess.app != app {
continue
}
out = append(out, map[string]any{
"id": sess.sid, "scope": sess.scope, "label": sess.label, "meta": sess.meta,
"created_at": sess.createdAt, "last_used_at": sess.lastUsedAt,
"expires_at": time.Now().Add(24 * time.Hour).Unix(),
})
}
_ = json.NewEncoder(w).Encode(map[string]any{"sessions": out})
case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/internal/sessions/"):
st.revoked[strings.TrimPrefix(r.URL.Path, "/internal/sessions/")] = true
st.lastRevokeApp = r.Header.Get("X-Broker-App")
w.WriteHeader(http.StatusNoContent)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
return brokerclient.New(srv.URL, "test-key", "cdrop"), st
}
// newQRTestServer builds a Server with only the fields the scan-login + device
// handlers touch, backed by a fresh file-based sqlite and a mock broker.
func newQRTestServer(t *testing.T) (*Server, *mockBrokerState) {
t.Helper()
conn, err := db.Open(filepath.Join(t.TempDir(), "qr.db"))
if err != nil {
@@ -38,19 +132,24 @@ func newQRTestServer(t *testing.T) *Server {
t.Fatalf("bootstrap: %v", err)
}
q := db.New(conn)
return &Server{
broker, st := newMockBroker(t)
s := &Server{
cfg: &config.Config{
AuthMode: "prod", SessionSecret: qrTestSecret,
QRLoginEnabled: true, QRRequestTTLSeconds: 120,
QRGuestTTLSeconds: 3600, QRPersistTTLHours: 168,
SessionTokenTTLSeconds: 900,
AuthMode: "prod",
QRLoginEnabled: true,
QRRequestTTLSeconds: 120,
FullAccessTTLSeconds: 900,
FullRefreshTTLSeconds: 604800,
GuestAccessTTLSeconds: 900,
GuestRefreshTTLSeconds: 86400,
BrokerBaseURL: "http://broker", // non-empty so QRLoginOn() is true
},
queries: q,
hub: hub.New(q),
sessionKey: deriveSessionKey(qrTestSecret),
sessionTokenKey: jwtauth.DeriveSessionTokenKey(qrTestSecret),
broker: broker,
siteOrigin: "https://drop.example.net",
}
return s, st
}
func qrStart(t *testing.T, s *Server, deviceName string) (qrStartResp, string) {
@@ -73,11 +172,11 @@ func qrStart(t *testing.T, s *Server, deviceName string) (qrStartResp, string) {
return resp, u.Query().Get("c")
}
func qrApprove(t *testing.T, s *Server, requestID, code, persist, approver string) int {
func qrApprove(t *testing.T, s *Server, requestID, code, scope, persist, approver string) int {
t.Helper()
body := fmt.Sprintf(`{"request_id":%q,"approval_code":%q,"scope":"guest","persist":%q}`, requestID, code, persist)
body := fmt.Sprintf(`{"request_id":%q,"approval_code":%q,"scope":%q,"persist":%q}`, requestID, code, scope, persist)
r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/approve", strings.NewReader(body))
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: approver, SessionScope: "full"}))
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: approver, Scope: "full"}))
w := httptest.NewRecorder()
s.handleQRApprove(w, r)
return w.Code
@@ -100,14 +199,15 @@ func decodeStatus(t *testing.T, w *httptest.ResponseRecorder) qrStatusResp {
return resp
}
// The happy path: a new device starts a request, the approver authorises it as a
// one-time guest, and the new device collects a live guest session — cookie set,
// access token minted, request single-use thereafter.
// Happy path: a new device starts a request, the approver authorises it as a guest,
// and the new device collects a live guest session minted by the broker — broker
// access + refresh handed back, a device row recorded with the broker sid, the mint
// scoped to the device_id, and the request single-use thereafter.
func TestQRFlow_ApproveCollectGuestSingleUse(t *testing.T) {
s := newQRTestServer(t)
s, st := newQRTestServer(t)
start, code := qrStart(t, s, "Borrowed Laptop")
if c := qrApprove(t, s, start.RequestID, code, "once", "approver-1"); c != http.StatusNoContent {
if c := qrApprove(t, s, start.RequestID, code, "guest", "once", "approver-1"); c != http.StatusNoContent {
t.Fatalf("approve: got %d, want 204", c)
}
@@ -116,34 +216,31 @@ func TestQRFlow_ApproveCollectGuestSingleUse(t *testing.T) {
t.Fatalf("status: %d %s", w.Code, w.Body.String())
}
got := decodeStatus(t, w)
if got.Status != "approved" || got.AccessToken == "" || got.DeviceName != "Borrowed Laptop" {
if got.Status != "approved" || got.AccessToken == "" || got.RefreshToken == "" {
t.Fatalf("collected status wrong: %+v", got)
}
if got.ExpiresIn != 900 {
t.Errorf("expires_in: got %d, want 900", got.ExpiresIn)
if got.DeviceName != "Borrowed Laptop" || got.DeviceID == "" {
t.Fatalf("collected device wrong: %+v", got)
}
var hasCookie bool
for _, ck := range w.Result().Cookies() {
if ck.Name == sessionCookieName && ck.Value != "" {
hasCookie = true
}
}
if !hasCookie {
t.Error("collection must set the session cookie on the new device's response")
if !strings.HasPrefix(got.DeviceID, "dev_") {
t.Errorf("device_id not opaque dev_ token: %q", got.DeviceID)
}
// The minted token is a properly signed guest session token.
if scope := selfTokenScope(t, got.AccessToken); scope != "guest" {
t.Fatalf("minted token scope: got %q, want guest", scope)
// The broker was asked to mint at tier guest with our device_id as meta.
if st.mintCount != 1 {
t.Fatalf("mint count: got %d, want 1", st.mintCount)
}
if st.lastMint["tier"] != "guest" || st.lastMint["meta"] != got.DeviceID {
t.Errorf("mint params wrong: %+v", st.lastMint)
}
// A guest (one-time) session row exists for the approver and is non-sliding.
rows, err := s.queries.ListWebSessionsByUser(context.Background(), "approver-1")
if err != nil || len(rows) != 1 {
t.Fatalf("expected one session row, got %d (err=%v)", len(rows), err)
// A device row exists for the approver, tier guest, bound to the broker sid.
dev, err := s.queries.GetDevice(context.Background(), got.DeviceID)
if err != nil {
t.Fatalf("device row missing: %v", err)
}
if rows[0].Kind != "guest" || rows[0].Scope != "guest" {
t.Errorf("session kind/scope: got %s/%s, want guest/guest", rows[0].Kind, rows[0].Scope)
if dev.UserID != "approver-1" || dev.Tier != "guest" || dev.BrokerSid != "sid-1" {
t.Errorf("device row wrong: %+v", dev)
}
// Single use: a second collection finds the request consumed.
@@ -152,288 +249,131 @@ func TestQRFlow_ApproveCollectGuestSingleUse(t *testing.T) {
}
}
// A persistent ("trust this device") approval yields a sliding self session.
func TestQRFlow_PersistYieldsSelfSession(t *testing.T) {
s := newQRTestServer(t)
// A full approval mints a full-tier session.
func TestQRFlow_ApproveCollectFull(t *testing.T) {
s, st := newQRTestServer(t)
start, code := qrStart(t, s, "Home PC")
if c := qrApprove(t, s, start.RequestID, code, "persist", "approver-2"); c != http.StatusNoContent {
if c := qrApprove(t, s, start.RequestID, code, "full", "persist", "approver-2"); c != http.StatusNoContent {
t.Fatalf("approve: got %d, want 204", c)
}
if got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got.Status != "approved" {
t.Fatalf("collect: %+v", got)
got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret))
if got.Status != "approved" {
t.Fatalf("status: %+v", got)
}
rows, _ := s.queries.ListWebSessionsByUser(context.Background(), "approver-2")
if len(rows) != 1 || rows[0].Kind != "self" {
t.Fatalf("persistent approval must create a kind=self session, got %+v", rows)
if st.lastMint["tier"] != "full" {
t.Errorf("mint tier: got %v, want full", st.lastMint["tier"])
}
dev, err := s.queries.GetDevice(context.Background(), got.DeviceID)
if err != nil || dev.Tier != "full" {
t.Errorf("device row: %+v (err=%v)", dev, err)
}
}
// The poll_secret is the new device's only credential: a wrong one is rejected
// even for a real, approved request, so a QR photographer can't collect it.
func TestQRFlow_WrongPollSecretRejected(t *testing.T) {
s := newQRTestServer(t)
s, _ := newQRTestServer(t)
start, code := qrStart(t, s, "Laptop")
_ = qrApprove(t, s, start.RequestID, code, "once", "approver-3")
if w := qrStatus(s, start.RequestID, "not-the-secret"); w.Code != http.StatusForbidden {
t.Errorf("wrong poll secret: got %d, want 403", w.Code)
}
// The real secret still works afterwards (the bad attempt didn't consume it).
if got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got.Status != "approved" {
t.Errorf("real secret after a bad attempt: got %q, want approved", got.Status)
_ = qrApprove(t, s, start.RequestID, code, "guest", "once", "approver-1")
w := qrStatus(s, start.RequestID, "not-the-secret")
if w.Code != http.StatusForbidden {
t.Fatalf("wrong poll secret: got %d, want 403", w.Code)
}
}
// Denial propagates to the polling device.
func TestQRFlow_Deny(t *testing.T) {
s := newQRTestServer(t)
s, _ := newQRTestServer(t)
start, code := qrStart(t, s, "Laptop")
body := fmt.Sprintf(`{"request_id":%q,"approval_code":%q}`, start.RequestID, code)
r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/deny", strings.NewReader(body))
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "approver-4", SessionScope: "full"}))
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "approver-1", Scope: "full"}))
w := httptest.NewRecorder()
s.handleQRDeny(w, r)
if w.Code != http.StatusNoContent {
t.Fatalf("deny: got %d, want 204", w.Code)
}
if got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got.Status != "denied" {
t.Errorf("status after deny: got %q, want denied", got.Status)
t.Errorf("after deny: got %q, want denied", got.Status)
}
}
// With step-up enabled, approving without a fresh re-auth is refused (the gate
// rejects an empty step-up proof before any IdP call). AUTH.md §6.
func TestQRFlow_StepUpRequiredRejectsWithoutReauth(t *testing.T) {
s := newQRTestServer(t)
s.cfg.StepUpEnabled = true
start, code := qrStart(t, s, "Laptop")
if c := qrApprove(t, s, start.RequestID, code, "once", "approver-su"); c != http.StatusForbidden {
t.Errorf("approve without step-up proof: got %d, want 403", c)
}
}
// A pending request long-polls then reports pending for the client to re-poll.
func TestQRFlow_PendingLongPoll(t *testing.T) {
saved := qrStatusPollWindow
old := qrStatusPollWindow
qrStatusPollWindow = 50 * time.Millisecond
defer func() { qrStatusPollWindow = saved }()
defer func() { qrStatusPollWindow = old }()
s := newQRTestServer(t)
s, _ := newQRTestServer(t)
start, _ := qrStart(t, s, "Laptop")
w := qrStatus(s, start.RequestID, start.PollSecret)
if got := decodeStatus(t, w); got.Status != "pending" {
t.Errorf("pending long-poll: got %q, want pending", got.Status)
got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret))
if got.Status != "pending" {
t.Errorf("unapproved long-poll: got %q, want pending", got.Status)
}
}
// noopAuthStore satisfies jwtauth.Store for middleware wiring in tests (no device
// upsert happens without an X-Device-Name header).
type noopAuthStore struct{}
func (noopAuthStore) UpsertDevice(context.Context, db.UpsertDeviceParams) error { return nil }
func (noopAuthStore) GetShortcutToken(context.Context, string) (db.ShortcutToken, error) {
return db.ShortcutToken{}, fmt.Errorf("none")
}
func (noopAuthStore) TouchShortcutTokenUsed(context.Context, db.TouchShortcutTokenUsedParams) error {
return nil
}
func (noopAuthStore) GetWebSession(_ context.Context, id string) (db.WebSession, error) {
return db.WebSession{ID: id}, nil // session always live in these tests
}
// End-to-end through the REAL chain (auth.Middleware → requireFullSession): a
// guest session token is rejected 403 on an account-management route, a full one
// passes. This is the backend enforcement behind AUTH.md §3.2 (guest can't approve
// devices / remove devices / mint tokens).
// requireFullSession lets a full session through and blocks a restricted guest.
func TestRequireFullSession_GuestBlockedFullPasses(t *testing.T) {
secret := "test-session-secret-at-least-32-bytes-ok"
srv := &Server{
cfg: &config.Config{SessionTokenTTLSeconds: 900},
sessionTokenKey: jwtauth.DeriveSessionTokenKey(secret),
}
guest, _, err := srv.mintSessionToken("u", "sid", "guest")
if err != nil {
t.Fatalf("mint guest: %v", err)
}
full, _, err := srv.mintSessionToken("u", "sid", "full")
if err != nil {
t.Fatalf("mint full: %v", err)
}
a := jwtauth.New(&config.Config{AuthMode: "prod", SessionSecret: secret}, noopAuthStore{})
handler := a.Middleware(requireFullSession(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
handler := requireFullSession(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})))
probe := func(tok string) int {
r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/approve", nil)
r.Header.Set("Authorization", "Bearer "+tok)
}))
check := func(scope string) int {
r := httptest.NewRequest(http.MethodGet, "/x", nil)
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "u", Scope: scope}))
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
return w.Code
}
if c := probe(guest); c != http.StatusForbidden {
t.Errorf("guest token on requireFullSession route: got %d, want 403", c)
if c := check("app:cdrop:guest"); c != http.StatusForbidden {
t.Errorf("guest on full-only route: got %d, want 403", c)
}
if c := probe(full); c != http.StatusOK {
t.Errorf("full token on requireFullSession route: got %d, want 200", c)
if c := check("app:cdrop:full"); c != http.StatusOK {
t.Errorf("full on full-only route: got %d, want 200", c)
}
}
// Revoking a session is sensitive: with step-up enabled it is refused (403
// step_up_required) until the caller's session has recently stepped up, then it
// proceeds (here to 400 missing-id, i.e. past the gate). Step-up state is keyed on
// the cookie session, so it is per-device (AUTH.md §1/§6).
func TestSessionRevoke_RequiresStepUp(t *testing.T) {
s := newQRTestServer(t)
s.cfg.StepUpEnabled = true
s.cfg.StepUpMaxAgeSeconds = 300
raw, id, err := newSessionToken()
if err != nil {
t.Fatalf("token: %v", err)
}
now := time.Now().Unix()
if err := s.queries.CreateSelfSession(context.Background(), db.CreateSelfSessionParams{
ID: id, UserID: "u", DeviceName: "", UserAgent: "", Kind: "oidc", Scope: "full",
GrantedBy: "", CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600,
}); err != nil {
t.Fatalf("create session: %v", err)
// Deleting a device revokes its broker session (scoped by X-Broker-App) and drops the
// local row.
func TestDeleteDevice_RevokesBrokerSession(t *testing.T) {
s, st := newQRTestServer(t)
start, code := qrStart(t, s, "Old Phone")
_ = qrApprove(t, s, start.RequestID, code, "full", "persist", "owner")
got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret))
if got.DeviceID == "" {
t.Fatal("no device_id from collect")
}
revoke := func() int {
r := httptest.NewRequest(http.MethodDelete, "/api/auth/sessions/x", nil)
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: raw})
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(),
&jwtauth.Claims{UserID: "u", SessionScope: "full"}))
w := httptest.NewRecorder()
s.handleSessionRevoke(w, r)
return w.Code
}
// Not stepped up → blocked before any deletion.
if c := revoke(); c != http.StatusForbidden {
t.Fatalf("revoke without step-up: got %d, want 403", c)
}
// Record step-up on this (cookie) session → gate now passes (400 = missing id,
// reached past the step-up check).
if err := s.queries.SetSessionSteppedUp(context.Background(), db.SetSessionSteppedUpParams{
SteppedUpAt: now, ID: id,
}); err != nil {
t.Fatalf("set stepped up: %v", err)
}
if c := revoke(); c == http.StatusForbidden {
t.Errorf("revoke after step-up still 403; gate did not honour stepped_up_at")
}
}
// selfTokenScope verifies a self-signed session token under the test's session
// key (proving the signature) and returns its scope claim.
func selfTokenScope(t *testing.T, token string) string {
t.Helper()
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.HS256})
if err != nil {
t.Fatalf("parse token: %v", err)
}
var std jwt.Claims
custom := map[string]any{}
if err := parsed.Claims(jwtauth.DeriveSessionTokenKey(qrTestSecret), &std, &custom); err != nil {
t.Fatalf("verify token signature: %v", err)
}
if typ, _ := custom["typ"].(string); typ != "session" {
t.Fatalf("token typ: got %q, want session", typ)
}
scope, _ := custom["scope"].(string)
return scope
}
// revokeByID drives handleSessionRevoke with the chi {id} param and full claims,
// step-up off (the gate is exercised separately).
func revokeByID(s *Server, id, userID string) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodDelete, "/api/auth/sessions/"+id, nil)
r := httptest.NewRequest(http.MethodDelete, "/api/devices/"+got.DeviceID, nil)
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "owner", Scope: "full"}))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", id)
ctx := context.WithValue(r.Context(), chi.RouteCtxKey, rctx)
ctx = jwtauth.ContextWithClaims(ctx, &jwtauth.Claims{UserID: userID, SessionScope: "full"})
rctx.URLParams.Add("device_id", got.DeviceID)
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
s.handleSessionRevoke(w, r.WithContext(ctx))
return w
s.handleDeleteDevice(w, r)
if w.Code != http.StatusNoContent {
t.Fatalf("delete device: got %d, want 204 (%s)", w.Code, w.Body.String())
}
// Revoking a web session removes its device registration too, so a revoked device
// disappears from the device list at once (the user can no longer remove devices
// by hand). AUTH.md §4.
func TestSessionRevoke_DeletesDevice(t *testing.T) {
s := newQRTestServer(t) // step-up off by default
ctx := context.Background()
now := time.Now().Unix()
_, id, err := newSessionToken()
if err != nil {
t.Fatalf("token: %v", err)
if !st.revoked["sid-1"] {
t.Error("broker session sid-1 was not revoked")
}
if err := s.queries.CreateSelfSession(ctx, db.CreateSelfSessionParams{
ID: id, UserID: "u", DeviceName: "Laptop", UserAgent: "", Kind: "oidc", Scope: "full",
GrantedBy: "", CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600,
}); err != nil {
t.Fatalf("create session: %v", err)
}
if err := s.queries.UpsertDevice(ctx, db.UpsertDeviceParams{
UserID: "u", Name: "Laptop", Type: "browser", LastSeen: now,
}); err != nil {
t.Fatalf("upsert device: %v", err)
}
if w := revokeByID(s, id, "u"); w.Code != http.StatusNoContent {
t.Fatalf("revoke: got %d %s, want 204", w.Code, w.Body.String())
}
if _, err := s.queries.GetWebSession(ctx, id); err == nil {
t.Errorf("session still present after revoke")
}
devs, _ := s.queries.ListDevicesByUser(ctx, "u")
for _, d := range devs {
if d.Name == "Laptop" {
t.Errorf("device 'Laptop' not deleted on session revoke")
if st.lastRevokeApp != "cdrop" {
t.Errorf("revoke X-Broker-App: got %q, want cdrop", st.lastRevokeApp)
}
if _, err := s.queries.GetDevice(context.Background(), got.DeviceID); err == nil {
t.Error("device row should be gone after delete")
}
}
// The session list unifies web_sessions with native client devices (desktop / iOS)
// that have no web_session, while never double-listing a device that already has a
// session and excluding shortcut-token devices. AUTH.md §4.
func TestSessionsList_IncludesNativeDevices(t *testing.T) {
s := newQRTestServer(t)
ctx := context.Background()
now := time.Now().Unix()
_, id, err := newSessionToken()
if err != nil {
t.Fatalf("token: %v", err)
}
if err := s.queries.CreateSelfSession(ctx, db.CreateSelfSessionParams{
ID: id, UserID: "u", DeviceName: "Web", UserAgent: "", Kind: "oidc", Scope: "full",
GrantedBy: "", CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600,
}); err != nil {
t.Fatalf("create session: %v", err)
}
for _, d := range []struct{ name, typ string }{
{"Web", "browser"}, // has a web_session — must not be double-listed
{"Desk", "macos"}, // native, no session — must appear as native
{"iShortcut", "shortcut"}, // scoped token — excluded from session list
} {
if err := s.queries.UpsertDevice(ctx, db.UpsertDeviceParams{
UserID: "u", Name: d.name, Type: d.typ, LastSeen: now,
}); err != nil {
t.Fatalf("upsert device %s: %v", d.name, err)
}
}
// The session list surfaces the user's devices with their tier as scope.
func TestSessionsList_ShowsDevices(t *testing.T) {
s, _ := newQRTestServer(t)
start, code := qrStart(t, s, "Tablet")
_ = qrApprove(t, s, start.RequestID, code, "guest", "once", "owner")
got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret))
r := httptest.NewRequest(http.MethodGet, "/api/auth/sessions", nil)
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(),
&jwtauth.Claims{UserID: "u", SessionScope: "full"}))
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "owner", Scope: "app:cdrop:guest", DeviceID: got.DeviceID}))
w := httptest.NewRecorder()
s.handleSessionsList(w, r)
if w.Code != http.StatusOK {
t.Fatalf("sessions list: got %d %s", w.Code, w.Body.String())
t.Fatalf("sessions list: %d %s", w.Code, w.Body.String())
}
var resp struct {
Sessions []sessionView `json:"sessions"`
@@ -441,96 +381,132 @@ func TestSessionsList_IncludesNativeDevices(t *testing.T) {
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
var web, webNativeDup, desk, shortcut bool
for _, v := range resp.Sessions {
switch {
case v.DeviceName == "Web" && !v.Native && v.Kind == "oidc":
web = true
case v.DeviceName == "Web" && v.Native:
webNativeDup = true
case v.DeviceName == "Desk" && v.Native && v.Kind == "macos":
desk = true
case v.DeviceName == "iShortcut":
shortcut = true
if len(resp.Sessions) != 1 {
t.Fatalf("session count: got %d, want 1", len(resp.Sessions))
}
}
if !web {
t.Errorf("web session for 'Web' missing")
}
if webNativeDup {
t.Errorf("'Web' double-listed as a native device")
}
if !desk {
t.Errorf("native device 'Desk' missing from session list")
}
if shortcut {
t.Errorf("shortcut device leaked into session list")
sv := resp.Sessions[0]
if sv.DeviceID != got.DeviceID || sv.Scope != "guest" || !sv.Current {
t.Errorf("session view wrong: %+v", sv)
}
}
// The sweeper drops browser devices whose web_session is gone (orphans) while
// keeping browser devices that still have a live session and all native devices.
func TestDeleteOrphanBrowserDevices(t *testing.T) {
s := newQRTestServer(t)
ctx := context.Background()
now := time.Now().Unix()
// Browser "Kept" has a live session; browser "Orphan" has none; native "Desk"
// (macos) legitimately has no session and must survive.
_, id, err := newSessionToken()
if err != nil {
t.Fatalf("token: %v", err)
// deviceSession drives the 代铸 endpoint with the given identity and returns the response.
func deviceSession(t *testing.T, s *Server, userID, scope, deviceID, name, dtype, origin string) deviceSessionResp {
t.Helper()
body := fmt.Sprintf(`{"device_id":%q,"device_name":%q,"device_type":%q}`, deviceID, name, dtype)
r := httptest.NewRequest(http.MethodPost, "/api/auth/device-session", strings.NewReader(body))
if origin != "" {
r.Header.Set("Origin", origin)
}
if err := s.queries.CreateSelfSession(ctx, db.CreateSelfSessionParams{
ID: id, UserID: "u", DeviceName: "Kept", Kind: "oidc", Scope: "full",
CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600,
}); err != nil {
t.Fatalf("create session: %v", err)
}
for _, d := range []struct{ name, typ string }{
{"Kept", "browser"}, {"Orphan", "browser"}, {"Desk", "macos"},
} {
if err := s.queries.UpsertDevice(ctx, db.UpsertDeviceParams{
UserID: "u", Name: d.name, Type: d.typ, LastSeen: now,
}); err != nil {
t.Fatalf("upsert %s: %v", d.name, err)
}
}
if _, err := s.queries.DeleteOrphanBrowserDevices(ctx, now); err != nil {
t.Fatalf("sweep: %v", err)
}
devs, _ := s.queries.ListDevicesByUser(ctx, "u")
got := map[string]bool{}
for _, d := range devs {
got[d.Name] = true
}
if !got["Kept"] {
t.Errorf("browser 'Kept' with a live session was wrongly swept")
}
if got["Orphan"] {
t.Errorf("orphan browser 'Orphan' was not swept")
}
if !got["Desk"] {
t.Errorf("native 'Desk' was wrongly swept (it keeps no web_session)")
}
}
// Removing a native device (the session list's logout path for desktop / iOS) is
// sensitive and requires a recent step-up, mirroring web-session revocation.
func TestDeleteDevice_RequiresStepUp(t *testing.T) {
s := newQRTestServer(t)
s.cfg.StepUpEnabled = true
s.cfg.StepUpMaxAgeSeconds = 300
r := httptest.NewRequest(http.MethodDelete, "/api/devices/Desk", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("name", "Desk")
ctx := context.WithValue(r.Context(), chi.RouteCtxKey, rctx)
ctx = jwtauth.ContextWithClaims(ctx, &jwtauth.Claims{UserID: "u", SessionScope: "full"})
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: userID, Name: "Commilitia", Avatar: "https://example.net/a.png", Scope: scope}))
w := httptest.NewRecorder()
s.handleDeleteDevice(w, r.WithContext(ctx))
s.handleDeviceSession(w, r)
if w.Code != http.StatusOK {
t.Fatalf("device-session: %d %s", w.Code, w.Body.String())
}
var resp deviceSessionResp
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode device-session: %v", err)
}
return resp
}
// listSessions calls the session-management list for a caller riding device deviceID.
func listSessions(t *testing.T, s *Server, userID, scope, deviceID string) []sessionView {
t.Helper()
r := httptest.NewRequest(http.MethodGet, "/api/auth/sessions", nil)
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: userID, Scope: scope, DeviceID: deviceID}))
w := httptest.NewRecorder()
s.handleSessionsList(w, r)
if w.Code != http.StatusOK {
t.Fatalf("sessions list: %d %s", w.Code, w.Body.String())
}
var resp struct {
Sessions []sessionView `json:"sessions"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode list: %v", err)
}
return resp.Sessions
}
// 代铸 turns an edge-verified login into a managed device session that joins the unified
// list as the current device, with its type overlaid and the verified display name returned.
func TestDeviceSession_MintsManagedDevice(t *testing.T) {
s, st := newQRTestServer(t)
resp := deviceSession(t, s, "owner", "full", "dev_browser01", "Laptop", "browser", "")
if resp.AccessToken == "" || resp.RefreshToken == "" {
t.Fatal("device-session returned no tokens")
}
if resp.DeviceID != "dev_browser01" {
t.Errorf("device_id: got %q", resp.DeviceID)
}
if resp.Name != "Commilitia" {
t.Errorf("name: got %q, want the verified X-Auth-Name not the subject UUID", resp.Name)
}
if resp.Avatar != "https://example.net/a.png" {
t.Errorf("avatar: got %q, want the verified X-Auth-Avatar", resp.Avatar)
}
if st.lastMint["tier"] != "full" || st.lastMint["meta"] != "dev_browser01" || st.lastMint["label"] != "Laptop" {
t.Errorf("mint params: %+v", st.lastMint)
}
list := listSessions(t, s, "owner", "app:cdrop:full", "dev_browser01")
if len(list) != 1 || list[0].DeviceID != "dev_browser01" || list[0].Kind != "browser" || !list[0].Current {
t.Errorf("unified list wrong: %+v", list)
}
}
// Re-login with the same device_id rotates the one session (R2), not piling up duplicates,
// and adopts the latest label — the fix for the "duplicate phantom devices" regression.
func TestDeviceSession_Idempotent(t *testing.T) {
s, _ := newQRTestServer(t)
_ = deviceSession(t, s, "owner", "full", "dev_same01", "Laptop", "browser", "")
_ = deviceSession(t, s, "owner", "full", "dev_same01", "Laptop Renamed", "browser", "")
list := listSessions(t, s, "owner", "app:cdrop:full", "dev_same01")
if len(list) != 1 {
t.Fatalf("idempotent re-mint: got %d sessions, want 1", len(list))
}
if list[0].DeviceName != "Laptop Renamed" {
t.Errorf("rotation should adopt the latest label: got %q", list[0].DeviceName)
}
}
// A meta-less machine session (a desktop device-authorize bootstrap) must not surface as a
// phantom device — the unified list filters it out, leaving only真正的托管设备.
func TestSessionsList_FiltersMetalessBootstrap(t *testing.T) {
s, _ := newQRTestServer(t)
if _, err := s.broker.MintSession(context.Background(), brokerclient.MintParams{
UserID: "owner", Tier: "full", Label: "bootstrap",
}); err != nil {
t.Fatalf("bootstrap mint: %v", err)
}
_ = deviceSession(t, s, "owner", "full", "dev_real01", "Laptop", "browser", "")
list := listSessions(t, s, "owner", "app:cdrop:full", "dev_real01")
if len(list) != 1 || list[0].DeviceID != "dev_real01" {
t.Fatalf("metaless bootstrap not filtered: %+v", list)
}
}
// A cookie-authenticated mint must carry a same-origin Origin; a cross-site forgery (which
// would reintroduce phantom devices) is rejected.
func TestDeviceSession_RejectsCrossOrigin(t *testing.T) {
s, _ := newQRTestServer(t)
body := `{"device_id":"dev_x01","device_name":"X","device_type":"browser"}`
r := httptest.NewRequest(http.MethodPost, "/api/auth/device-session", strings.NewReader(body))
r.Header.Set("Origin", "https://evil.example.net")
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "owner", Scope: "full"}))
w := httptest.NewRecorder()
s.handleDeviceSession(w, r)
if w.Code != http.StatusForbidden {
t.Fatalf("delete device without step-up: got %d, want 403", w.Code)
t.Fatalf("cross-origin device-session: got %d, want 403", w.Code)
}
}
// A restricted guest minting its device session stays guest — no escalation to full.
func TestDeviceSession_GuestTierNotEscalated(t *testing.T) {
s, st := newQRTestServer(t)
_ = deviceSession(t, s, "owner", "app:cdrop:guest", "dev_guest01", "Borrowed", "browser", "")
if st.lastMint["tier"] != "guest" {
t.Errorf("guest caller minted tier %v, want guest", st.lastMint["tier"])
}
}
+61
View File
@@ -0,0 +1,61 @@
package httpapi
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"time"
)
type refreshReq struct {
RefreshToken string `json:"refresh_token"`
}
type refreshResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
}
// handleAuthRefresh proxies a token refresh to the Auth Broker. The SPA holds a
// broker-minted access + refresh; when the access expires it POSTs the refresh token
// here and cdrop relays it to the broker's /refresh, returning the rotated pair. The
// proxy keeps the browser same-origin (no broker CORS) and means cdrop stores no
// credential — it only forwards. Public (the access token is expired, which is the
// point); Origin-checked for CSRF.
func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) {
if !s.sameOrigin(r) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad origin"})
return
}
var req refreshReq
if err := json.NewDecoder(io.LimitReader(r.Body, 8192)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if req.RefreshToken == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing refresh token"})
return
}
res, err := s.broker.RefreshSession(r.Context(), req.RefreshToken)
if err != nil {
// A rejected refresh (expired / rotated / revoked) is the client's cue to
// re-authenticate; relay it as 401. (A broker outage also lands here — rare,
// and re-login is the safe fallback.)
slog.Warn("broker refresh failed", "err", err)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "refresh rejected"})
return
}
expiresIn := int(res.AccessExpires - time.Now().Unix())
if expiresIn < 0 {
expiresIn = 0
}
writeJSON(w, http.StatusOK, refreshResp{
AccessToken: res.Access,
RefreshToken: res.Refresh,
ExpiresIn: expiresIn,
})
}
-39
View File
@@ -1,39 +0,0 @@
package httpapi
import (
"time"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
)
// mintSessionToken signs a short-lived cdrop self-signed session access token
// (AUTH.md §3.1): HS256 over the SessionSecret-derived key, sub=userID, sid=the
// web_sessions row id, typ=session, scope=full|guest. The matching verifier is
// jwtauth.verifySelfToken. Returns the token and its lifetime in seconds (for the
// client's expires_in). Used by scan-login and self/guest session refresh — never
// touches the IdP.
func (s *Server) mintSessionToken(userID, sid, scope string) (string, int, error) {
ttl := time.Duration(s.cfg.SessionTokenTTLSeconds) * time.Second
now := time.Now()
sig, err := jose.NewSigner(
jose.SigningKey{Algorithm: jose.HS256, Key: s.sessionTokenKey},
(&jose.SignerOptions{}).WithType("JWT"),
)
if err != nil {
return "", 0, err
}
std := jwt.Claims{
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
Expiry: jwt.NewNumericDate(now.Add(ttl)),
}
tok, err := jwt.Signed(sig).
Claims(std).
Claims(map[string]any{"typ": "session", "scope": scope, "sid": sid}).
Serialize()
if err != nil {
return "", 0, err
}
return tok, int(ttl / time.Second), nil
}
+58 -66
View File
@@ -6,13 +6,13 @@ import (
"encoding/json"
"log/slog"
"net/http"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/httprate"
"commilitia.net/cdrop/internal/brokerclient"
"commilitia.net/cdrop/internal/calls"
"commilitia.net/cdrop/internal/clipboard"
"commilitia.net/cdrop/internal/config"
@@ -38,23 +38,11 @@ type Server struct {
push *push.Sender // inert when VAPID keys unset → push endpoints 503
mux *chi.Mux
// sessionKey encrypts browser refresh_tokens at rest (web_sessions); nil when
// CDROP_SESSION_SECRET is unset (dev), which disables passwordless re-login.
// siteOrigin is the deployment's scheme://host, used for the CSRF Origin check.
sessionKey []byte
// broker delegates session lifecycle to the Auth Broker (mint / revoke / refresh).
broker *brokerclient.Client
// siteOrigin is the deployment's scheme://host, used for the CSRF Origin check
// and the scan-login QR link.
siteOrigin string
// sessionTokenKey signs cdrop's self-signed session access tokens (scan-login;
// AUTH.md §3.1), derived from SessionSecret in a separate domain from sessionKey.
// nil when SessionSecret is unset → minting is unavailable (QR stays off).
sessionTokenKey []byte
// refreshLocks serialise concurrent refreshes of the same web session (all of
// a user's browser tabs share one cookie → one refresh_token). Striped so the
// lock set stays bounded; collisions just serialise unrelated sessions, which
// is harmless. Prevents a multi-tab race from spending a one-time-use
// refresh_token twice and logging the user out everywhere.
refreshLocks [refreshLockStripes]sync.Mutex
}
func New(
@@ -82,9 +70,8 @@ func New(
push: pushSender,
mux: chi.NewRouter(),
sessionKey: deriveSessionKey(cfg.SessionSecret),
siteOrigin: deriveSiteOrigin(cfg.OIDCRedirectURI),
sessionTokenKey: jwtauth.DeriveSessionTokenKey(cfg.SessionSecret),
broker: brokerclient.New(cfg.BrokerBaseURL, cfg.BrokerInternalKey, cfg.BrokerAppOrDefault()),
siteOrigin: deriveSiteOrigin(cfg.PublicURL),
}
s.routes()
return s
@@ -105,52 +92,54 @@ func (s *Server) routes() {
s.mux.NotFound(webui.Handler().ServeHTTP)
s.mux.Route("/api", func(r chi.Router) {
// Public OIDC PKCE plumbing — no auth required (it bootstraps it).
// Public navigation: 302 to the broker's global-SSO login (bootstraps login).
r.Get("/auth/login", s.handleAuthLogin)
// Public: broker coordinates for native clients' device-authorization flow.
r.Get("/auth/config", s.handleAuthConfig)
// Rate-limit the credential-bearing OAuth endpoints (G4): they proxy to
// the IdP, so without a cap cdrop is a brute-force / token-pivot relay.
// Per-IP (RealIP is mounted above); 60/min is ample for real logins and
// hourly refresh even behind a shared NAT, while choking a scripted flood.
// Public, rate-limited (per-IP; RealIP is mounted above). No bearer: the new
// device isn't logged in yet (its poll_secret is the only credential), and
// refresh runs precisely when the access token is expired. 60/min is ample
// for real use behind a shared NAT while choking a scripted flood.
r.Group(func(r chi.Router) {
r.Use(httprate.LimitByIP(60, time.Minute))
r.Post("/auth/exchange", s.handleAuthExchange)
// Token refresh — proxied to the Auth Broker, so the SPA stays same-origin
// and cdrop stores no credential (it just relays the rotated pair).
r.Post("/auth/refresh", s.handleAuthRefresh)
// Cookie-authenticated (no bearer): the HttpOnly session cookie is the
// only credential. logout drops the server session; device persists
// this browser's name into the session for PWA-eviction recovery.
r.Post("/auth/logout", s.handleAuthLogout)
r.Post("/auth/device", s.handleAuthDevice)
// Scan-login: the new device opens a request and long-polls status.
// Public (no bearer — the device isn't logged in yet); the private
// poll_secret in the X-Poll-Secret header is the only credential.
// Scan-login: the new device opens a request and long-polls status. The
// private poll_secret in the X-Poll-Secret header is the only credential.
r.Post("/auth/qr/start", s.handleQRStart)
r.Get("/auth/qr/status", s.handleQRStatus)
})
// Protected routes. gzip / compress is intentionally NOT mounted —
// Authenticated routes. Every request past here carries the broker's
// edge-injected X-Auth-* identity (prod) or a dev token. Guests and full
// sessions both reach this tier. gzip / compress is intentionally NOT mounted —
// it would buffer the SSE stream.
r.Group(func(r chi.Router) {
r.Use(s.auth.Middleware)
// Clipboard is the one capability a scoped shortcut token may reach
// (iOS Shortcut sync) — it must carry the "clipboard" scope. Full
// login sessions pass requireScope unconditionally.
r.Group(func(r chi.Router) {
r.Use(requireScope(shortcutScope))
// Clipboard, transfer, messaging, presence, push — guests included.
r.Get("/clipboard", s.handleClipboardGet)
r.Put("/clipboard", s.handleClipboardPut)
// Lightweight version probe — no content; lets pollers (iOS
// Shortcut) detect change cheaply before fetching the full body.
// Lightweight version probe — no content; lets pollers detect change
// cheaply before fetching the full body.
r.Get("/clipboard/version", s.handleClipboardVersion)
})
// Everything else requires a full login session; scoped shortcut
// tokens are rejected, so a leaked token's blast radius stays the
// clipboard and nothing more (including token self-management).
r.Group(func(r chi.Router) {
r.Use(rejectScoped)
r.Get("/me", s.handleMe)
r.Post("/me/disconnect", s.handleDisconnect)
// 代铸: turn an edge-verified login (SSO cookie or device-authorize bootstrap)
// into a cdrop-managed device session bound to a stable device_id, so browser
// and native clients share one device list. Authenticated but not full-only —
// it mints at the caller's own tier. Rate-limited per IP: with R2 idempotency a
// normal client mints about once per login, so a generous cap simply bounds an
// authenticated identity that loops fresh device_ids to mint unbounded sessions.
r.Group(func(r chi.Router) {
r.Use(httprate.LimitByIP(20, time.Minute))
r.Post("/auth/device-session", s.handleDeviceSession)
})
// Logout revokes the calling device's own broker session (self-service).
r.Post("/auth/logout", s.handleLogout)
r.Get("/hub/events", s.handleEvents)
r.Post("/hub/signal", s.handleSignal)
r.Post("/message", s.handleMessage)
@@ -160,27 +149,21 @@ func (s *Server) routes() {
r.Delete("/push/subscribe", s.handlePushUnsubscribe)
r.Get("/calls/credentials", s.handleCallsCredentials)
// Account-management surface: restricted guest (scan-login borrow)
// sessions are rejected here too — they can transfer files but not
// remove devices, nor mint / list / revoke long-lived shortcut
// tokens. Full / OIDC / dev sessions pass (AUTH.md §3.2).
// Full sessions only — restricted guests (scan-login borrows) are
// rejected: they transfer files but can't manage devices, approve other
// devices, or revoke sessions.
r.Group(func(r chi.Router) {
r.Use(requireFullSession)
r.Delete("/devices/{name}", s.handleDeleteDevice)
r.Post("/shortcut/issue", s.handleShortcutIssue)
r.Get("/shortcut", s.handleShortcutList)
r.Delete("/shortcut/{jti}", s.handleShortcutRevoke)
// Scan-login approval side: the logged-in approver views and
// authorises the new device. Guest sessions can't reach here, so a
// borrowed device can't approve further devices.
r.Delete("/devices/{device_id}", s.handleDeleteDevice)
r.Patch("/devices/{device_id}", s.handleRenameDevice)
// Scan-login approval side: the logged-in approver views and authorises
// the new device. Guests can't reach here, so a borrowed device can't
// approve further devices.
r.Get("/auth/qr/request", s.handleQRRequest)
r.Post("/auth/qr/approve", s.handleQRApprove)
r.Post("/auth/qr/deny", s.handleQRDeny)
// Session management: list logins with their permission level,
// really revoke one (logout), and the standalone step-up the
// revoke flow re-uses. All under /api/auth so the session cookie
// (Path=/api/auth) is available for step-up state.
r.Post("/auth/stepup", s.handleStepUp)
// Session management: list logged-in devices with their permission
// level and revoke one (logout that device).
r.Get("/auth/sessions", s.handleSessionsList)
r.Delete("/auth/sessions/{id}", s.handleSessionRevoke)
})
@@ -200,7 +183,6 @@ func (s *Server) routes() {
})
})
})
})
}
type healthResp struct {
@@ -224,11 +206,15 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
type meResp struct {
UserID string `json:"user_id"`
// Name is the display name from the broker (X-Auth-Name); falls back to user_id.
Name string `json:"name"`
// Avatar is the broker account's picture URL (X-Auth-Avatar); empty when none.
Avatar string `json:"avatar,omitempty"`
Groups []string `json:"groups"`
DeviceName string `json:"device_name"`
// Scope is the session's capability level: "guest" (scan-login borrow,
// capability-limited) or "full" (normal login). The UI hides account-management
// affordances on guest sessions (AUTH.md §3.2).
// affordances on guest sessions.
Scope string `json:"scope"`
}
@@ -243,8 +229,14 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
if claims.Guest() {
scope = "guest"
}
name := claims.Name
if name == "" {
name = claims.UserID
}
writeJSON(w, http.StatusOK, meResp{
UserID: claims.UserID,
Name: name,
Avatar: claims.Avatar,
Groups: claims.Groups,
DeviceName: device,
Scope: scope,
+25 -215
View File
@@ -2,183 +2,46 @@ package httpapi
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/url"
"time"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/jwtauth"
)
// Browser "passwordless re-login" (web only — desktop persists its own
// refresh_token in the OS keyring and never touches these endpoints).
//
// The durable credential (Casdoor's ~15 KB refresh_token) stays server-side in
// web_sessions, encrypted at rest. The browser only ever holds an opaque cookie
// token whose SHA-256 is the row's primary key — so a leaked DB yields neither a
// usable cookie nor a decryptable token. The cookie is HttpOnly (XSS can't read
// it), Secure (HTTPS only), SameSite=Lax + Origin-checked (CSRF), and Path-scoped
// to /api/auth so it rides only these four endpoints, not every API call.
// Shared HTTP helpers for the auth surface. After the Auth Broker migration (path A)
// cdrop no longer keeps server-side sessions, cookies, or refresh tokens — identity
// and session lifecycle live in the broker. What remains here is the CSRF origin
// check, the scan-login poll-secret hash, and the login-request reaper.
const (
sessionCookieName = "cdrop_session"
sessionCookiePath = "/api/auth"
// webSessionTTL is the sliding inactivity window: each refresh pushes
// expires_at this far forward. The effective cap is min(this, the IdP's own
// refresh_token validity) — once Casdoor retires the refresh_token, refresh
// 401s and the user re-logs in regardless.
webSessionTTL = 7 * 24 * time.Hour
// refreshLockStripes bounds the per-session refresh lock set (see Server).
refreshLockStripes = 256
)
// lockRefresh serialises refreshes of one session id, returning the unlock fn.
// Callers must re-read the session row after acquiring it: a concurrent refresh
// may have already rotated the refresh_token.
func (s *Server) lockRefresh(id string) func() {
idx := stripeIndex(id)
s.refreshLocks[idx].Lock()
return func() { s.refreshLocks[idx].Unlock() }
}
// stripeIndex folds a session id into a stripe with FNV-1a — bounded, no cleanup.
func stripeIndex(id string) int {
var h uint32 = 2166136261
for i := 0; i < len(id); i++ {
h = (h ^ uint32(id[i])) * 16777619
}
return int(h % refreshLockStripes)
}
// deriveSessionKey turns a config secret of any length into a 32-byte AES key
// (mirrors jwtauth.DeriveHS256Key). Empty secret → nil (feature disabled).
func deriveSessionKey(secret string) []byte {
if secret == "" {
return nil
}
sum := sha256.Sum256([]byte(secret))
return sum[:]
}
// deriveSiteOrigin extracts scheme://host from the configured redirect_uri to
// give the CSRF Origin check a fixed expected value. Empty (dev) disables it.
func deriveSiteOrigin(redirectURI string) string {
if redirectURI == "" {
// deriveSiteOrigin extracts scheme://host from the configured public URL to give the
// CSRF Origin check a fixed expected value (and the scan-login QR its link origin).
// Empty / unparseable (dev) disables the origin check.
func deriveSiteOrigin(publicURL string) string {
if publicURL == "" {
return ""
}
u, err := url.Parse(redirectURI)
u, err := url.Parse(publicURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return ""
}
return u.Scheme + "://" + u.Host
}
// newSessionToken mints a fresh opaque cookie token plus its storage id. raw
// goes in Set-Cookie; id (hex SHA-256 of raw) is the DB key, so the stored row
// never contains a usable cookie value.
func newSessionToken() (raw, id string, err error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", "", err
}
raw = base64.RawURLEncoding.EncodeToString(b)
return raw, sessionID(raw), nil
}
// sessionID hashes an opaque token to its storage form (hex SHA-256). The scan-login
// poll_secret is stored as this hash — the new device holds the plaintext, so a leaked
// DB never yields a usable secret.
func sessionID(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
// encryptRefresh seals a refresh_token with AES-256-GCM. Output is
// base64(nonce || ciphertext+tag); the key is s.sessionKey (env-derived).
func (s *Server) encryptRefresh(plain string) (string, error) {
if len(s.sessionKey) == 0 {
return "", errors.New("session key not configured")
}
gcm, err := newGCM(s.sessionKey)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
ct := gcm.Seal(nonce, nonce, []byte(plain), nil)
return base64.StdEncoding.EncodeToString(ct), nil
}
func (s *Server) decryptRefresh(enc string) (string, error) {
if len(s.sessionKey) == 0 {
return "", errors.New("session key not configured")
}
raw, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
return "", err
}
gcm, err := newGCM(s.sessionKey)
if err != nil {
return "", err
}
if len(raw) < gcm.NonceSize() {
return "", errors.New("ciphertext too short")
}
nonce, ct := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
plain, err := gcm.Open(nil, nonce, ct, nil)
if err != nil {
return "", err
}
return string(plain), nil
}
func newGCM(key []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
func setSessionCookie(w http.ResponseWriter, raw string) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: raw,
Path: sessionCookiePath,
MaxAge: int(webSessionTTL / time.Second),
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
func clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: sessionCookiePath,
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
// sameOrigin is belt-and-suspenders CSRF defence atop SameSite=Lax: when the
// browser sends an Origin header (always, on fetch POST) it must match the
// deployment's own origin. Absent Origin (non-browser clients) is allowed, as is
// an unconfigured site origin (dev).
// sameOrigin is belt-and-suspenders CSRF defence: when the browser sends an Origin
// header (always, on fetch POST) it must match the deployment's own origin. Absent
// Origin (non-browser clients) is allowed, as is an unconfigured site origin (dev).
func (s *Server) sameOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" || s.siteOrigin == "" {
@@ -187,61 +50,17 @@ func (s *Server) sameOrigin(r *http.Request) bool {
return origin == s.siteOrigin
}
// handleAuthLogout destroys the server-side session and clears the cookie.
// Cookie-authenticated (no bearer): the cookie is the only thing that proves
// which session to drop.
func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" {
if err := s.queries.DeleteWebSession(r.Context(), sessionID(c.Value)); err != nil {
slog.Warn("web session delete failed", "err", err)
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
}
clearSessionCookie(w)
w.WriteHeader(http.StatusNoContent)
return s[:n] + "…"
}
type sessionDeviceReq struct {
DeviceName string `json:"device_name"`
}
// handleAuthDevice persists this browser's device name into its session row so
// it survives PWA storage eviction: on the next boot, /auth/refresh hands the
// name back and the client re-hydrates selfDeviceName without a trip to /setup.
func (s *Server) handleAuthDevice(w http.ResponseWriter, r *http.Request) {
if !s.sameOrigin(r) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad origin"})
return
}
c, err := r.Cookie(sessionCookieName)
if err != nil || c.Value == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "no session"})
return
}
var req sessionDeviceReq
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
name := jwtauth.SanitizeDeviceName(req.DeviceName)
if name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "empty device name"})
return
}
if err := s.queries.SetWebSessionDevice(r.Context(), db.SetWebSessionDeviceParams{
DeviceName: name,
ID: sessionID(c.Value),
}); err != nil {
slog.Error("set web session device failed", "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "persist failed"})
return
}
w.WriteHeader(http.StatusNoContent)
}
// RunWebSessionReaper periodically reclaims expired web_sessions rows. Lazy
// deletion on access covers the hot path; this sweeps sessions that simply go
// idle and are never touched again.
func RunWebSessionReaper(ctx context.Context, q *db.Queries) {
// RunLoginRequestReaper periodically reclaims expired scan-login request rows. They
// are short-lived (default 120s) and already rejected at use time; this sweeps the
// stragglers that are opened and never collected.
func RunLoginRequestReaper(ctx context.Context, q *db.Queries) {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
@@ -249,16 +68,7 @@ func RunWebSessionReaper(ctx context.Context, q *db.Queries) {
case <-ctx.Done():
return
case <-ticker.C:
now := time.Now().Unix()
n, err := q.DeleteExpiredWebSessions(ctx, now)
if err != nil {
slog.Warn("web session reaper failed", "err", err)
} else if n > 0 {
slog.Info("web sessions reaped", "count", n)
}
// Scan-login requests are short-lived (default 120s); sweep the
// stragglers here too. Expired rows are already rejected at use time.
if n, err := q.DeleteExpiredLoginRequests(ctx, now); err != nil {
if n, err := q.DeleteExpiredLoginRequests(ctx, time.Now().Unix()); err != nil {
slog.Warn("login request reaper failed", "err", err)
} else if n > 0 {
slog.Info("login requests reaped", "count", n)
-123
View File
@@ -1,123 +0,0 @@
package httpapi
import (
"encoding/base64"
"strings"
"testing"
)
func TestEncryptRefreshRoundTrip(t *testing.T) {
s := &Server{sessionKey: deriveSessionKey("a-strong-session-secret")}
plain := strings.Repeat("refresh.token.", 2000) // ~28 KB, mimics Casdoor's large JWT
enc, err := s.encryptRefresh(plain)
if err != nil {
t.Fatalf("encrypt: %v", err)
}
if strings.Contains(enc, plain) {
t.Fatal("ciphertext leaks plaintext")
}
got, err := s.decryptRefresh(enc)
if err != nil {
t.Fatalf("decrypt: %v", err)
}
if got != plain {
t.Fatalf("roundtrip mismatch: len(got)=%d, len(want)=%d", len(got), len(plain))
}
}
func TestEncryptRefreshNonceIsRandom(t *testing.T) {
s := &Server{sessionKey: deriveSessionKey("secret")}
a, _ := s.encryptRefresh("same plaintext")
b, _ := s.encryptRefresh("same plaintext")
if a == b {
t.Fatal("two encryptions of the same plaintext are identical — nonce not random")
}
}
func TestDecryptRefreshRejectsTamper(t *testing.T) {
s := &Server{sessionKey: deriveSessionKey("secret")}
enc, err := s.encryptRefresh("token")
if err != nil {
t.Fatalf("encrypt: %v", err)
}
raw, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
t.Fatalf("decode: %v", err)
}
raw[len(raw)-1] ^= 0xFF // flip a bit in the GCM tag
if _, err := s.decryptRefresh(base64.StdEncoding.EncodeToString(raw)); err == nil {
t.Fatal("tampered ciphertext must fail authentication")
}
}
func TestDecryptRefreshRejectsWrongKey(t *testing.T) {
enc, err := (&Server{sessionKey: deriveSessionKey("key-one")}).encryptRefresh("token")
if err != nil {
t.Fatalf("encrypt: %v", err)
}
if _, err := (&Server{sessionKey: deriveSessionKey("key-two")}).decryptRefresh(enc); err == nil {
t.Fatal("decryption under a different key must fail")
}
}
func TestEncryptRefreshNoKey(t *testing.T) {
s := &Server{sessionKey: nil}
if _, err := s.encryptRefresh("token"); err == nil {
t.Fatal("encrypt without a key must error")
}
}
func TestSessionIDDeterministicAndHashed(t *testing.T) {
raw, id, err := newSessionToken()
if err != nil {
t.Fatalf("newSessionToken: %v", err)
}
if id != sessionID(raw) {
t.Fatal("newSessionToken id must equal sessionID(raw)")
}
if id == raw || strings.Contains(id, raw) {
t.Fatal("stored id must be a hash of the cookie value, not the value itself")
}
// SHA-256 hex is always 64 chars.
if len(id) != 64 {
t.Fatalf("session id length: got %d, want 64", len(id))
}
}
func TestNewSessionTokenUnique(t *testing.T) {
seen := make(map[string]bool)
for i := 0; i < 1000; i++ {
raw, _, err := newSessionToken()
if err != nil {
t.Fatalf("newSessionToken: %v", err)
}
if seen[raw] {
t.Fatal("duplicate session token generated")
}
seen[raw] = true
}
}
func TestDeriveSessionKey(t *testing.T) {
if deriveSessionKey("") != nil {
t.Fatal("empty secret must yield a nil key (feature disabled)")
}
if got := len(deriveSessionKey("x")); got != 32 {
t.Fatalf("derived key length: got %d, want 32 (AES-256)", got)
}
}
func TestDeriveSiteOrigin(t *testing.T) {
cases := []struct{ in, want string }{
{"https://drop.example.net/oauth/callback", "https://drop.example.net"},
{"http://localhost:5173/oauth/callback", "http://localhost:5173"},
{"", ""},
{"not a url", ""},
}
for _, c := range cases {
if got := deriveSiteOrigin(c.in); got != c.want {
t.Errorf("deriveSiteOrigin(%q) = %q, want %q", c.in, got, c.want)
}
}
}
+149 -161
View File
@@ -1,11 +1,8 @@
package httpapi
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
@@ -13,206 +10,197 @@ import (
"commilitia.net/cdrop/internal/jwtauth"
)
// Session management (AUTH.md §1, §6): list the caller's logins with their
// capability level, and really revoke one (delete the row → its access token can
// no longer refresh and dies within its short TTL). Revocation is a sensitive
// action, so it requires a recent step-up. A standalone step-up endpoint lets the
// UI establish that re-auth once (per session/device) and reuse it across actions
// within the window — the same session isn't re-prompted repeatedly (#3).
// Session management. After the unified-session-model rework, every logged-in client —
// browser, desktop, QR-paired device — is one delegated device session in the broker. The
// broker (R1: GET /internal/sessions) is the single authoritative device list; cdrop no
// longer keeps a parallel authoritative session table. The local `devices` rows survive only
// as a type/presence cache: they supply each device's type for the list overlay and back the
// real-time presence view (which is cdrop's domain, keyed by device name). Listing reads R1
// and overlays type/online/current; revoking calls the broker (the session's source of truth)
// then drops the local cache row.
type stepUpReq struct {
Code string `json:"code"`
Verifier string `json:"verifier"`
}
// handleStepUp exchanges a fresh prompt=login code and, on success, stamps the
// caller's session as stepped-up (per-session, cookie-keyed). Sensitive actions
// within StepUpMaxAgeSeconds then skip re-auth. 403 on failure; 204 when step-up
// is disabled (nothing to establish).
func (s *Server) handleStepUp(w http.ResponseWriter, r *http.Request) {
if !s.cfg.StepUpEnabled {
w.WriteHeader(http.StatusNoContent)
// requireFullSession rejects restricted guest sessions (scan-login borrow): they can
// transfer files but not manage devices, approve other devices, or revoke sessions.
// A full / dev session passes (AUTH Broker scope tier — Claims.Guest reads X-Auth-Scope).
func requireFullSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := jwtauth.ClaimsFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "no session"})
return
}
claims, _ := jwtauth.ClaimsFromContext(r.Context())
var body stepUpReq
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192)).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
if claims.Guest() {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "full session required"})
return
}
if !s.verifyStepUp(r, claims.UserID, body.Code, body.Verifier) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"})
return
}
s.recordStepUp(r)
w.WriteHeader(http.StatusNoContent)
next.ServeHTTP(w, r)
})
}
type sessionView struct {
ID string `json:"id"`
ID string `json:"id"` // device_id (the revoke handle exposed to the client)
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
Kind string `json:"kind"` // oidc | self | guest | macos | windows | linux | ios
Kind string `json:"kind"` // device type: browser | macos | windows | linux | ios
Scope string `json:"scope"` // full | guest
Current bool `json:"current"`
Online bool `json:"online"` // device currently connected (SSE) — UI sorts online first
// Native marks a device that authenticates with an IdP token and keeps no
// web_session row (desktop / iOS). It is surfaced from the devices registry so
// the session list covers every device; its "logout" routes to the device
// endpoint rather than the web-session one.
Native bool `json:"native"`
Online bool `json:"online"`
CreatedAt int64 `json:"created_at"`
LastUsedAt int64 `json:"last_used_at"`
ExpiresAt int64 `json:"expires_at"`
}
// isNativeDeviceType reports whether a device type is a real native client (one
// that authenticates via the IdP and keeps no web_session). browser devices are
// expected to carry a web_session; "shortcut" tokens have their own panel.
func isNativeDeviceType(t string) bool {
switch t {
case "macos", "windows", "linux", "ios":
return true
default:
return false
}
}
// handleSessionsList returns the caller's login sessions with their scope, so the
// UI can show the permission level (完整 / 受限访客) and offer real logout. The list
// is unified: web_sessions (browser / scan-login) PLUS native client devices
// (desktop / iOS) that have no web_session of their own — so every logged-in
// device appears in one place and orphaned device rows fall away (AUTH.md §4).
// handleSessionsList returns the caller's logged-in devices with their permission level
// (完整 / 受限访客) and live online flag, so the UI can show each and offer logout. The
// authoritative list is the broker's delegated device sessions (R1); cdrop overlays the
// device type (local cache), the online dot (hub presence, keyed by device name), and the
// "current" flag (the session whose meta is this request's X-Auth-Meta).
func (s *Server) handleSessionsList(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
now := time.Now().Unix()
rows, err := s.queries.ListWebSessionsByUser(r.Context(), claims.UserID)
sessions, err := s.broker.ListSessions(r.Context(), claims.UserID)
if err != nil {
slog.Error("list sessions failed", "err", err, "user", claims.UserID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "broker"})
return
}
current := ""
if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" {
current = sessionID(c.Value)
}
// Cookieless callers (native apps) can't match by cookie; fall back to their
// own device name to mark the "current" row.
callerDevice, _ := jwtauth.DeviceNameFromContext(r.Context())
out := make([]sessionView, 0, len(rows))
named := make(map[string]struct{}, len(rows))
for _, row := range rows {
if row.ExpiresAt < now {
continue // hide expired sessions (reaped separately)
}
if row.DeviceName != "" {
named[row.DeviceName] = struct{}{}
}
out = append(out, sessionView{
ID: row.ID,
DeviceName: row.DeviceName,
Kind: row.Kind,
Scope: row.Scope,
Current: row.ID == current || (current == "" && row.DeviceName != "" && row.DeviceName == callerDevice),
Online: row.DeviceName != "" && s.hub.Online(claims.UserID, row.DeviceName),
CreatedAt: row.CreatedAt,
LastUsedAt: row.LastUsedAt,
ExpiresAt: row.ExpiresAt,
})
}
// Native clients keep no web_session — surface them from the devices registry,
// skipping any whose name already has a web_session (no double-listing).
if devs, derr := s.queries.ListDevicesByUser(r.Context(), claims.UserID); derr == nil {
// device_id -> cached type, for the type overlay (a missing cache falls back to
// "browser"). The cache is bounded by the background device sweeper (last_seen TTL); a
// logged-out device's row is reaped there, not on this read path — this GET stays
// side-effect-free, and the session list itself is always R1-authoritative regardless of
// any stale cache row (the row only ever supplies a type for a device that is in R1).
typeByID := map[string]string{}
if devs, err := s.queries.ListDevicesByUser(r.Context(), claims.UserID); err == nil {
for _, d := range devs {
if !isNativeDeviceType(d.Type) {
typeByID[d.DeviceID] = d.Type
}
}
out := make([]sessionView, 0, len(sessions))
for _, sess := range sessions {
// A meta-less session is a non-cdrop-managed machine session — a desktop
// device-authorize bootstrap that the desktop replaces via 代铸 right away. It has
// no device_id, so it isn't a managed device and must not show as a phantom row.
if sess.Meta == "" {
continue
}
if _, ok := named[d.Name]; ok {
continue
typ := typeByID[sess.Meta]
if typ == "" {
typ = "browser"
}
scope := "full"
if jwtauth.ScopeTier(sess.Scope) == "guest" {
scope = "guest"
}
out = append(out, sessionView{
ID: "device:" + d.Name,
DeviceName: d.Name,
Kind: d.Type,
Scope: "full",
Native: true,
Current: current == "" && d.Name != "" && d.Name == callerDevice,
Online: s.hub.Online(claims.UserID, d.Name),
CreatedAt: d.LastSeen,
LastUsedAt: d.LastSeen,
ExpiresAt: 0,
ID: sess.Meta,
DeviceID: sess.Meta,
DeviceName: sess.Label,
Kind: typ,
Scope: scope,
Current: sess.Meta == claims.DeviceID,
Online: s.hub.Online(claims.UserID, sess.Label),
CreatedAt: sess.CreatedAt,
LastUsedAt: sess.LastUsedAt,
})
}
}
writeJSON(w, http.StatusOK, map[string]any{"sessions": out})
}
// deviceNameHasLiveSession reports whether the user still has a non-expired
// web_session bound to deviceName — used to avoid deleting a device that another
// live session (e.g. a re-login that left the old row) still depends on.
func (s *Server) deviceNameHasLiveSession(ctx context.Context, userID, deviceName string) bool {
rows, err := s.queries.ListWebSessionsByUser(ctx, userID)
if err != nil {
return false
}
now := time.Now().Unix()
for _, ws := range rows {
if ws.DeviceName == deviceName && ws.ExpiresAt >= now {
return true
}
}
return false
}
// handleSessionRevoke deletes a session row (scoped to its owner), really
// invalidating that login. Requires a recent step-up (403 step_up_required
// otherwise). Kicks the device's live SSE so it drops immediately.
// handleSessionRevoke logs out a device by id (= device_id): it resolves the device's broker
// session, revokes it, and drops the local cache row. Full session required (route-gated).
func (s *Server) handleSessionRevoke(w http.ResponseWriter, r *http.Request) {
if s.cfg.StepUpEnabled && !s.sessionRecentlySteppedUp(r) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"})
return
}
claims, _ := jwtauth.ClaimsFromContext(r.Context())
id := chi.URLParam(r, "id")
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing id"})
return
}
// Read the device name before deletion so we can kick its SSE.
deviceName := ""
if sess, err := s.queries.GetWebSession(r.Context(), id); err == nil && sess.UserID == claims.UserID {
deviceName = sess.DeviceName
}
n, err := s.queries.DeleteWebSessionForUser(r.Context(), db.DeleteWebSessionForUserParams{
ID: id,
UserID: claims.UserID,
})
if err != nil {
slog.Error("revoke session failed", "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
status, ok := s.revokeDevice(r, claims.UserID, id)
if !ok {
writeJSON(w, status, map[string]string{"error": revokeErrorMsg(status)})
return
}
if n == 0 {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
if deviceName != "" {
// Drop the device registration too, so a revoked device disappears from the
// device list at once (we no longer remove devices by hand) — unless another
// live session still uses this name.
if !s.deviceNameHasLiveSession(r.Context(), claims.UserID, deviceName) {
if _, derr := s.queries.DeleteDevice(r.Context(), db.DeleteDeviceParams{
UserID: claims.UserID,
Name: deviceName,
}); derr != nil {
slog.Warn("delete device on session revoke failed", "err", derr)
}
}
s.hub.Kick(claims.UserID, deviceName)
s.hub.PublishPresence(r.Context(), claims.UserID)
}
w.WriteHeader(http.StatusNoContent)
}
// revokeDevice tears down a device's broker session and local cache row, then kicks its live
// SSE and re-broadcasts presence. Returns the HTTP status to report and whether it succeeded;
// the caller writes the response. Shared by device deletion, session revocation, and logout
// (they are the same operation). The broker session id is resolved cache-first (the local row
// holds the stable broker_sid) and falls back to R1 — the authoritative list — so a missing or
// pruned cache row still revokes correctly and stays authorized to this user.
func (s *Server) revokeDevice(r *http.Request, userID, deviceID string) (int, bool) {
sid, name := "", ""
if dev, err := s.queries.GetDevice(r.Context(), deviceID); err == nil && dev.UserID == userID {
sid = dev.BrokerSid
name = dev.Name
}
if sid == "" {
sessions, err := s.broker.ListSessions(r.Context(), userID)
if err != nil {
slog.Error("revoke: list sessions failed", "err", err, "user", userID)
return http.StatusBadGateway, false
}
for _, sess := range sessions {
if sess.Meta == deviceID {
sid = sess.SID
name = sess.Label
break
}
}
}
if sid == "" {
// Not this user's device, or already gone from both the cache and the broker.
return http.StatusNotFound, false
}
// Revoke the broker session first so the device can't refresh; a 404 (already gone) is
// idempotent success inside RevokeSession.
if err := s.broker.RevokeSession(r.Context(), sid); err != nil {
slog.Error("broker revoke failed", "err", err, "user", userID, "sid", sid)
return http.StatusBadGateway, false
}
if _, err := s.queries.DeleteDevice(r.Context(), db.DeleteDeviceParams{
DeviceID: deviceID,
UserID: userID,
}); err != nil {
// The session is already revoked; a failed cache delete is non-fatal (the sweeper
// and the next list-prune clean it). Report success so the client sees the logout.
slog.Warn("delete device cache failed", "err", err, "user", userID, "device", deviceID)
}
if name != "" {
s.hub.Kick(userID, name)
}
s.hub.PublishPresence(r.Context(), userID)
return http.StatusNoContent, true
}
// handleLogout logs out the calling device by revoking its own broker session (so it can no
// longer refresh) and dropping its cache row. The client also discards its tokens. A caller
// with no managed device (no X-Auth-Meta) just succeeds — there is nothing server-side to
// revoke. Origin-checked for CSRF.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if !s.sameOrigin(r) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad origin"})
return
}
claims, _ := jwtauth.ClaimsFromContext(r.Context())
if claims.DeviceID != "" {
if status, ok := s.revokeDevice(r, claims.UserID, claims.DeviceID); !ok && status != http.StatusNotFound {
slog.Warn("logout revoke failed", "status", status, "user", claims.UserID)
}
}
w.WriteHeader(http.StatusNoContent)
}
func revokeErrorMsg(status int) string {
switch status {
case http.StatusNotFound:
return "device not found"
case http.StatusBadGateway:
return "revoke failed"
default:
return "db"
}
}
-260
View File
@@ -1,260 +0,0 @@
package httpapi
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/jwtauth"
)
// shortcutScope is the single scope a shortcut token is granted; the route layer
// only lets a token carrying it reach the clipboard endpoints (see server.go).
const shortcutScope = "clipboard"
// requireScope gates a route group that scoped shortcut tokens may also reach: a
// scoped token must carry the named scope, while a full login session always
// passes (it has no scope restriction).
func requireScope(scope string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := jwtauth.ClaimsFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "claims missing"})
return
}
if claims.Scoped() && !claims.HasScope(scope) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "insufficient scope"})
return
}
next.ServeHTTP(w, r)
})
}
}
// rejectScoped blocks scoped shortcut tokens outright — for every route only a
// full login session may touch. A leaked clipboard token thus reaches nothing
// here: not the device list, transfers, signalling, nor token self-management.
func rejectScoped(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := jwtauth.ClaimsFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "claims missing"})
return
}
if claims.Scoped() {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "session token required"})
return
}
next.ServeHTTP(w, r)
})
}
// requireFullSession rejects restricted guest sessions (scan-login borrow): they
// may transfer files but not reach account-management surfaces — removing devices,
// approving more devices, or minting / listing / revoking long-lived tokens. A
// borrowed device thus can't escalate. Full / OIDC / dev sessions pass; scoped
// shortcut tokens are already blocked upstream by rejectScoped (AUTH.md §3.2).
func requireFullSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := jwtauth.ClaimsFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "claims missing"})
return
}
if claims.Guest() {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "full session required"})
return
}
next.ServeHTTP(w, r)
})
}
// 这些端点只由登录会话调用(路由层 rejectScoped 守门)——用 token 不能自管 token
// 防止泄漏的快捷指令 token 自我续期或越权。
type shortcutIssueReq struct {
Label string `json:"label"`
}
type shortcutIssueResp struct {
// Token 仅在签发时返回这一次,服务端不留可还原副本(只存 jti 等元数据)。
Token string `json:"token"`
Jti string `json:"jti"`
Label string `json:"label"`
Scopes []string `json:"scopes"`
ExpiresAt int64 `json:"expires_at"`
}
type shortcutTokenView struct {
Jti string `json:"jti"`
Label string `json:"label"`
Scopes []string `json:"scopes"`
CreatedAt int64 `json:"created_at"`
ExpiresAt int64 `json:"expires_at"`
LastUsedAt *int64 `json:"last_used_at"`
Revoked bool `json:"revoked"`
}
// handleShortcutIssue mints a long-lived, clipboard-scoped HS256 token, stores
// its metadata, and returns the token string once. Disabled (503) when no HS256
// secret is configured.
func (s *Server) handleShortcutIssue(w http.ResponseWriter, r *http.Request) {
if len(s.cfg.HS256Secret) == 0 {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "shortcut tokens not enabled"})
return
}
claims, _ := jwtauth.ClaimsFromContext(r.Context())
var req shortcutIssueReq
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
label := strings.TrimSpace(req.Label)
if label == "" {
label = "iOS Shortcut"
}
if len(label) > 64 {
label = label[:64]
}
now := time.Now()
active, err := s.queries.CountActiveShortcutTokensByUser(r.Context(), db.CountActiveShortcutTokensByUserParams{
UserID: claims.UserID,
ExpiresAt: now.Unix(),
})
if err != nil {
slog.Error("count shortcut tokens failed", "err", err, "user", claims.UserID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
if active >= int64(s.cfg.ShortcutMaxPerUser) {
writeJSON(w, http.StatusConflict, map[string]string{"error": "too many active tokens"})
return
}
jti, err := randomTokenID()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "id gen"})
return
}
expires := now.Add(time.Duration(s.cfg.ShortcutTokenTTLDays) * 24 * time.Hour)
token, err := signShortcutToken(s.cfg.HS256Secret, claims.UserID, jti, shortcutScope, now, expires)
if err != nil {
slog.Error("sign shortcut token failed", "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "sign"})
return
}
if err := s.queries.InsertShortcutToken(r.Context(), db.InsertShortcutTokenParams{
Jti: jti,
UserID: claims.UserID,
Label: label,
Scopes: shortcutScope,
CreatedAt: now.Unix(),
ExpiresAt: expires.Unix(),
}); err != nil {
slog.Error("insert shortcut token failed", "err", err, "user", claims.UserID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
writeJSON(w, http.StatusCreated, shortcutIssueResp{
Token: token,
Jti: jti,
Label: label,
Scopes: []string{shortcutScope},
ExpiresAt: expires.Unix(),
})
}
// handleShortcutList returns the caller's tokens (metadata only, never the token
// string).
func (s *Server) handleShortcutList(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
rows, err := s.queries.ListShortcutTokensByUser(r.Context(), claims.UserID)
if err != nil {
slog.Error("list shortcut tokens failed", "err", err, "user", claims.UserID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
out := make([]shortcutTokenView, 0, len(rows))
for _, t := range rows {
out = append(out, shortcutTokenView{
Jti: t.Jti,
Label: t.Label,
Scopes: strings.Fields(t.Scopes),
CreatedAt: t.CreatedAt,
ExpiresAt: t.ExpiresAt,
LastUsedAt: t.LastUsedAt,
Revoked: t.Revoked != 0,
})
}
writeJSON(w, http.StatusOK, map[string]any{"tokens": out})
}
// handleShortcutRevoke flips the revoked flag (scoped to the caller's user_id so
// one user can't revoke another's). verifyHS256 reads the flag on every request,
// so revocation takes effect on the token's next use.
func (s *Server) handleShortcutRevoke(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
jti := chi.URLParam(r, "jti")
if jti == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing jti"})
return
}
n, err := s.queries.RevokeShortcutToken(r.Context(), db.RevokeShortcutTokenParams{
Jti: jti,
UserID: claims.UserID,
})
if err != nil {
slog.Error("revoke shortcut token failed", "err", err, "user", claims.UserID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
if n == 0 {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
w.WriteHeader(http.StatusNoContent)
}
// signShortcutToken mints a compact HS256 JWT: sub=userID, jti, scope (space-
// delimited OAuth-style, informational — the store row is authoritative), iat,
// exp. The shared HS256 secret is the same one verifyHS256 checks against.
func signShortcutToken(secret, userID, jti, scope string, iat, exp time.Time) (string, error) {
sig, err := jose.NewSigner(
jose.SigningKey{Algorithm: jose.HS256, Key: jwtauth.DeriveHS256Key(secret)},
(&jose.SignerOptions{}).WithType("JWT"),
)
if err != nil {
return "", err
}
std := jwt.Claims{
Subject: userID,
ID: jti,
IssuedAt: jwt.NewNumericDate(iat),
Expiry: jwt.NewNumericDate(exp),
}
return jwt.Signed(sig).Claims(std).Claims(map[string]any{"scope": scope}).Serialize()
}
// randomTokenID returns a 128-bit URL-safe random id for the jti.
func randomTokenID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b[:]), nil
}
+9 -7
View File
@@ -38,6 +38,7 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
http.Error(w, "missing device", http.StatusBadRequest)
return
}
deviceType := jwtauth.DeviceTypeFromContext(r.Context())
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
@@ -50,7 +51,7 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
}
flusher.Flush()
client := s.hub.Connect(r.Context(), claims.UserID, deviceName)
client := s.hub.Connect(r.Context(), claims.UserID, deviceName, deviceType)
defer s.hub.Disconnect(client)
slog.Debug("sse connected", "user", claims.UserID, "device", deviceName)
@@ -74,14 +75,15 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
}
flusher.Flush()
// Refresh last_seen so a connection-only client (no other API calls)
// keeps its device row alive past TTL — matches the sliding
// refresh_token semantics requested for device persistence.
_ = s.queries.UpsertDevice(r.Context(), db.UpsertDeviceParams{
UserID: claims.UserID,
Name: deviceName,
Type: jwtauth.DeviceTypeFromContext(r.Context()),
// keeps its managed device row alive past the sweeper TTL while connected.
if claims.DeviceID != "" {
_ = s.queries.TouchDevice(r.Context(), db.TouchDeviceParams{
LastSeen: time.Now().Unix(),
Tier: claims.Tier(),
DeviceID: claims.DeviceID,
UserID: claims.UserID,
})
}
case <-r.Context().Done():
return
}
+48 -31
View File
@@ -37,6 +37,10 @@ const clientBuffer = 32
type Client struct {
UserID string
DeviceID string
// Type is the client-declared device type (browser / macos / windows / linux / ios).
// It lets presence label a live device that has no devices-table row (a global-SSO
// browser or a broker-authenticated desktop).
Type string
ch chan Event
}
@@ -67,10 +71,13 @@ func New(devices DeviceLister) *Hub {
// Connect registers a new SSE client and announces presence to the user's other devices.
// If a client for (userID, deviceID) already exists (e.g., a tab refresh), its channel is closed.
func (h *Hub) Connect(ctx context.Context, userID, deviceID string) *Client {
// deviceType is the caller's declared device type, surfaced in presence for a live device
// that has no devices-table row.
func (h *Hub) Connect(ctx context.Context, userID, deviceID, deviceType string) *Client {
c := &Client{
UserID: userID,
DeviceID: deviceID,
Type: deviceType,
ch: make(chan Event, clientBuffer),
}
@@ -115,10 +122,15 @@ func (h *Hub) Disconnect(c *Client) {
// SendTo routes an event to a specific (userID, deviceID). Reports whether
// the target was online and the event was queued.
//
// The send happens while still holding the read lock so it can never race a Kick / Connect-
// replace / Close that closes the channel (those hold the write lock): a send on a closed
// channel panics even inside a select, so close-vs-send must be mutually exclusive. The send
// is non-blocking (select default), so holding the read lock across it is brief.
func (h *Hub) SendTo(userID, deviceID string, ev Event) bool {
h.mu.RLock()
defer h.mu.RUnlock()
c, ok := h.users[userID][deviceID]
h.mu.RUnlock()
if !ok {
return false
}
@@ -132,10 +144,12 @@ func (h *Hub) SendTo(userID, deviceID string, ev Event) bool {
}
}
// Broadcast fans an event out to every live client of a user.
// Broadcast fans an event out to every live client of a user. The non-blocking sends run
// under the read lock so they can't race a concurrent channel close (see SendTo).
func (h *Hub) Broadcast(userID string, ev Event) {
clients := h.snapshotClients(userID)
for _, c := range clients {
h.mu.RLock()
defer h.mu.RUnlock()
for _, c := range h.users[userID] {
select {
case c.ch <- ev:
default:
@@ -153,20 +167,20 @@ func (h *Hub) Online(userID, deviceID string) bool {
return ok
}
// Kick force-removes a (userID, deviceID) entry from the hub and closes its
// event channel; the SSE handler exits on the next iteration. 与 Connect 的
// 旧通道关闭模式一致(与并发 SendTo 之间存在极窄竞态,但 Kick 罕用,可接受)。
// Kick force-removes a (userID, deviceID) entry from the hub and closes its event channel;
// the SSE handler exits on the next iteration. The close happens UNDER the write lock — the
// same discipline as Connect-replace and Close — so it can never race a send from
// publishPresence / Broadcast / SendTo (those hold the read lock), which would otherwise
// panic on a send to a closed channel. revokeDevice now Kicks on every logout / device
// delete / session revoke, so this path is hot, not rare.
func (h *Hub) Kick(userID, deviceID string) {
h.mu.Lock()
c, ok := h.users[userID][deviceID]
if ok {
defer h.mu.Unlock()
if c, ok := h.users[userID][deviceID]; ok {
delete(h.users[userID], deviceID)
if len(h.users[userID]) == 0 {
delete(h.users, userID)
}
}
h.mu.Unlock()
if ok {
close(c.ch)
}
}
@@ -194,44 +208,47 @@ func (h *Hub) Close() {
h.users = map[string]map[string]*Client{}
}
func (h *Hub) snapshotClients(userID string) []*Client {
h.mu.RLock()
defer h.mu.RUnlock()
src := h.users[userID]
out := make([]*Client, 0, len(src))
for _, c := range src {
out = append(out, c)
}
return out
}
func (h *Hub) publishPresence(ctx context.Context, userID string) {
devs, err := h.devices.ListDevicesByUser(ctx, userID)
if err != nil {
// Log but continue with an empty managed-device set: a transient DB error must
// not blank out the presence of live, unmanaged devices that need no row.
slog.Error("presence: list devices failed", "user", userID, "err", err)
return
}
now := time.Now().Unix()
// Hold the read lock across the build AND the sends: the non-blocking sends below must
// be mutually exclusive with any channel close (Kick / Connect-replace / Close hold the
// write lock), or a send could hit a closed channel and panic.
h.mu.RLock()
defer h.mu.RUnlock()
live := h.users[userID]
items := make([]PresenceDevice, 0, len(devs))
seen := make(map[string]bool, len(devs))
items := make([]PresenceDevice, 0, len(devs)+len(live))
for _, d := range devs {
_, online := live[d.Name]
items = append(items, PresenceDevice{
Name: d.Name, Type: d.Type, Online: online, LastSeen: d.LastSeen,
})
seen[d.Name] = true
}
clients := make([]*Client, 0, len(live))
for _, c := range live {
clients = append(clients, c)
// Live connections without a devices-table row — a global-SSO browser before 代铸, or a
// device whose cache row hasn't landed yet. They are reachable on the hub (clipboard /
// signaling already route to them), so they must appear as online peers too.
for name, c := range live {
if seen[name] {
continue
}
items = append(items, PresenceDevice{
Name: name, Type: c.Type, Online: true, LastSeen: now,
})
}
h.mu.RUnlock()
ev := Event{
Type: "presence",
Data: map[string]any{"devices": items},
}
for _, c := range clients {
for _, c := range live {
select {
case c.ch <- ev:
default:
+57 -9
View File
@@ -2,6 +2,8 @@ package hub
import (
"context"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
@@ -44,7 +46,7 @@ func TestConnectFiresPresenceEvent(t *testing.T) {
h := New(lister)
defer h.Close()
c := h.Connect(context.Background(), "alice", "tab-1")
c := h.Connect(context.Background(), "alice", "tab-1", "browser")
defer h.Disconnect(c)
ev := waitForEvent(t, c, "presence", time.Second)
@@ -65,11 +67,11 @@ func TestSecondClientSeesFirstAsOnline(t *testing.T) {
h := New(lister)
defer h.Close()
c1 := h.Connect(context.Background(), "alice", "tab-1")
c1 := h.Connect(context.Background(), "alice", "tab-1", "browser")
defer h.Disconnect(c1)
_ = waitForEvent(t, c1, "presence", time.Second)
c2 := h.Connect(context.Background(), "alice", "tab-2")
c2 := h.Connect(context.Background(), "alice", "tab-2", "browser")
defer h.Disconnect(c2)
// c2 receives its own presence (announced on Connect).
@@ -94,12 +96,28 @@ func TestSecondClientSeesFirstAsOnline(t *testing.T) {
}
}
// A live device with no devices-table row (global-SSO browser / broker-auth desktop)
// must still appear in presence as online, labelled by its declared type.
func TestLiveOnlyDeviceAppearsInPresence(t *testing.T) {
h := New(&fakeLister{}) // empty managed-device set
defer h.Close()
c := h.Connect(context.Background(), "alice", "macbook", "macos")
defer h.Disconnect(c)
ev := waitForEvent(t, c, "presence", time.Second)
devs, _ := ev.Data.(map[string]any)["devices"].([]PresenceDevice)
if len(devs) != 1 || devs[0].Name != "macbook" || devs[0].Type != "macos" || !devs[0].Online {
t.Errorf("live-only device should appear online in presence: %+v", devs)
}
}
func TestSendToRoutesEvent(t *testing.T) {
lister := &fakeLister{}
h := New(lister)
defer h.Close()
c := h.Connect(context.Background(), "alice", "tab-1")
c := h.Connect(context.Background(), "alice", "tab-1", "browser")
defer h.Disconnect(c)
_ = waitForEvent(t, c, "presence", time.Second)
@@ -124,11 +142,11 @@ func TestReconnectClosesOldChannel(t *testing.T) {
h := New(&fakeLister{})
defer h.Close()
old := h.Connect(context.Background(), "alice", "tab-1")
old := h.Connect(context.Background(), "alice", "tab-1", "browser")
// drain the initial presence so we can detect close
<-old.Events()
_ = h.Connect(context.Background(), "alice", "tab-1")
_ = h.Connect(context.Background(), "alice", "tab-1", "browser")
select {
case _, ok := <-old.Events():
@@ -146,7 +164,7 @@ func TestDisconnectRemovesFromOnlineSet(t *testing.T) {
})
defer h.Close()
c := h.Connect(context.Background(), "alice", "tab-1")
c := h.Connect(context.Background(), "alice", "tab-1", "browser")
if !h.Online("alice", "tab-1") {
t.Fatal("client should be online after Connect")
}
@@ -156,6 +174,36 @@ func TestDisconnectRemovesFromOnlineSet(t *testing.T) {
}
}
// TestConcurrentKickAndPresenceNoPanic hammers Kick (which closes channels under the write
// lock) against publishPresence / Broadcast / SendTo (non-blocking sends under the read lock).
// Before the close-vs-send fix this raced into a "send on closed channel" panic that crashed
// the whole process; now close and send are mutually exclusive. Run with -race.
func TestConcurrentKickAndPresenceNoPanic(t *testing.T) {
h := New(&fakeLister{
devices: []db.Device{{UserID: "u", Name: "d", Type: "browser"}},
})
defer h.Close()
const workers = 16
const iters = 200
var wg sync.WaitGroup
for i := 0; i < workers; i += 1 {
wg.Add(1)
go func(n int) {
defer wg.Done()
name := fmt.Sprintf("d%d", n)
for j := 0; j < iters; j += 1 {
h.Connect(context.Background(), "u", name, "browser")
h.PublishPresence(context.Background(), "u")
h.Broadcast("u", Event{Type: "x"})
h.SendTo("u", name, Event{Type: "y"})
h.Kick("u", name)
}
}(i)
}
wg.Wait()
}
// TestGracePeriod uses a tiny grace so the test runs fast.
func TestGracePeriodDelaysOfflinePresence(t *testing.T) {
h := New(&fakeLister{
@@ -167,9 +215,9 @@ func TestGracePeriodDelaysOfflinePresence(t *testing.T) {
h.grace = 100 * time.Millisecond
defer h.Close()
c1 := h.Connect(context.Background(), "alice", "tab-1")
c1 := h.Connect(context.Background(), "alice", "tab-1", "browser")
defer h.Disconnect(c1)
c2 := h.Connect(context.Background(), "alice", "tab-2")
c2 := h.Connect(context.Background(), "alice", "tab-2", "browser")
// Drain initial presence frames
_ = waitForEvent(t, c1, "presence", time.Second)
_ = waitForEvent(t, c1, "presence", time.Second)
+40 -34
View File
@@ -1,41 +1,47 @@
package jwtauth
import "context"
import (
"context"
"strings"
)
// Claims is the authenticated identity for a request. In prod it is sourced from the
// Auth Broker at the edge: broker /verify authenticates the caller and injects X-Auth-*
// headers this process trusts (Caddy strips any client-supplied X-Auth-* at the trust
// boundary, so only the broker can set them). In dev it is synthesised from the dev token.
type Claims struct {
UserID string
Groups []string
// JTI and Scopes are populated only for HS256 shortcut tokens; a full OIDC /
// dev session leaves them empty. A non-empty JTI marks a *scoped* token —
// one allowed to reach only the endpoints its Scopes grant (the route layer
// enforces this). This keeps a leaked shortcut token's blast radius minimal.
JTI string
Scopes []string
// SessionScope is set only for cdrop self-signed session tokens (scan-login):
// "full" or "guest". Empty for OIDC / dev sessions and scoped shortcut tokens.
// A "guest" session is capability-limited (requireFullSession rejects it on
// account-management routes) even though it is not Scoped().
SessionScope string
UserID string // X-Auth-Subject (Casdoor sub)
Name string // X-Auth-Name (display name); may be empty
Avatar string // X-Auth-Avatar (profile picture URL from the broker account); may be empty
Groups []string // X-Auth-Roles, comma-split
// Scope is the raw X-Auth-Scope: a global SSO user is "full"; a cdrop delegated
// session is "app:cdrop:<tier>". Tier() reads the capability grade off the end.
Scope string
// DeviceID is X-Auth-Meta: the cdrop device_id this session was minted for — the
// join key to the devices row. Empty for an unmanaged caller (e.g. a global SSO
// browser that never paired through cdrop).
DeviceID string
}
// Scoped reports whether these claims came from a scoped shortcut token rather
// than a full login session.
func (c *Claims) Scoped() bool { return c.JTI != "" }
// ScopeTier returns the capability grade — the last colon-separated segment of a broker
// scope ("app:cdrop:guest" → "guest", "full" → "full"). A tierless scope is its own tier.
// Shared by Claims.Tier() and the session-list overlay so the two never diverge.
func ScopeTier(scope string) string {
if i := strings.LastIndex(scope, ":"); i >= 0 {
return scope[i+1:]
}
return scope
}
// Tier returns the capability grade off the caller's scope (see ScopeTier).
func (c *Claims) Tier() string {
return ScopeTier(c.Scope)
}
// Guest reports whether these claims came from a restricted guest session (a
// scan-login borrow). Guest sessions can transfer files but not manage the
// account, approve other devices, or mint long-lived tokens.
func (c *Claims) Guest() bool { return c.SessionScope == "guest" }
// HasScope reports whether the claims grant the named scope.
func (c *Claims) HasScope(scope string) bool {
for _, s := range c.Scopes {
if s == scope {
return true
}
}
return false
}
// scan-login borrow). Guest sessions can transfer files but not manage devices,
// approve other devices, or mint long-lived tokens.
func (c *Claims) Guest() bool { return c.Tier() == "guest" }
type ctxKey int
@@ -50,9 +56,9 @@ func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
return c, ok
}
// ContextWithClaims attaches claims to a context — the inverse of
// ClaimsFromContext. The auth middleware uses this; it is also the seam handlers
// and tests use to inject claims directly.
// ContextWithClaims attaches claims to a context — the inverse of ClaimsFromContext.
// The auth middleware uses this; it is also the seam handlers and tests use to inject
// claims directly.
func ContextWithClaims(ctx context.Context, c *Claims) context.Context {
return context.WithValue(ctx, claimsCtxKey, c)
}
@@ -63,7 +69,7 @@ func DeviceNameFromContext(ctx context.Context) (string, bool) {
}
// DeviceTypeFromContext returns the client-declared device type set by the auth
// middleware (browser / macos / windows / linux), defaulting to "browser".
// middleware (browser / macos / windows / linux / ios), defaulting to "browser".
func DeviceTypeFromContext(ctx context.Context) string {
t, ok := ctx.Value(deviceTypeCtxKey).(string)
if !ok || t == "" {
+6 -13
View File
@@ -11,14 +11,11 @@ import (
// DeviceSweepInterval is how often the device sweeper wakes up.
const DeviceSweepInterval = 1 * time.Hour
// RunDeviceSweeper prunes the devices table on a timer so it stays aligned with
// the session list (AUTH.md §4): it drops (1) devices whose last_seen is older
// than ttl — registration must not outlive the longest valid refresh_token
// (brief §2: 196h sliding window) — and (2) browser devices whose web_session is
// gone (revoked or expired), which would otherwise linger until the stale cutoff
// now that users can no longer remove devices by hand. Native and shortcut
// devices keep no web_session and are pruned by the stale cutoff only. ttl ≤ 0
// disables the sweeper.
// RunDeviceSweeper prunes the devices table on a timer: it drops devices whose
// last_seen is older than ttl, so a registration never outlives the broker session's
// refresh window (a device idle past the window has no live broker session anyway).
// The session is the broker's source of truth; this only reaps stale local rows. ttl
// ≤ 0 disables the sweeper.
func RunDeviceSweeper(ctx context.Context, queries *db.Queries, ttl time.Duration) {
if ttl <= 0 {
slog.Info("device sweeper disabled (ttl <= 0)")
@@ -33,14 +30,10 @@ func RunDeviceSweeper(ctx context.Context, queries *db.Queries, ttl time.Duratio
case <-ctx.Done():
return
case <-t.C:
now := time.Now()
cutoff := now.Add(-ttl).Unix()
cutoff := time.Now().Add(-ttl).Unix()
if err := queries.DeleteStaleDevices(ctx, cutoff); err != nil {
slog.Error("device sweeper failed", "err", err)
}
if _, err := queries.DeleteOrphanBrowserDevices(ctx, now.Unix()); err != nil {
slog.Error("device sweeper: orphan browser cleanup failed", "err", err)
}
}
}
}
+7 -6
View File
@@ -31,15 +31,16 @@ func TestDeleteStaleDevices_RemovesOnlyOld(t *testing.T) {
old := now.Add(-200 * time.Hour)
fresh := now.Add(-1 * time.Hour)
upsert := func(name string, ts time.Time) {
if err := q.UpsertDevice(ctx, db.UpsertDeviceParams{
UserID: "alice", Name: name, Type: "browser", LastSeen: ts.Unix(),
create := func(name string, ts time.Time) {
if err := q.CreateDevice(ctx, db.CreateDeviceParams{
DeviceID: name, UserID: "alice", Name: name, Type: "browser",
Tier: "full", CreatedAt: ts.Unix(), LastSeen: ts.Unix(),
}); err != nil {
t.Fatalf("upsert %s: %v", name, err)
t.Fatalf("create %s: %v", name, err)
}
}
upsert("old-device", old)
upsert("fresh-device", fresh)
create("old-device", old)
create("fresh-device", fresh)
// Cutoff = "older than 196 hours from now" mimics RunDeviceSweeper math.
cutoff := now.Add(-196 * time.Hour).Unix()
-113
View File
@@ -1,113 +0,0 @@
package jwtauth
import (
"context"
"crypto/rsa"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"sync"
"time"
"github.com/go-jose/go-jose/v4"
)
// jwksCache fetches & caches the Casdoor JWKS.
//
// Behavior:
// - successful fetch: refreshes the entire key set
// - kid miss: forces a single refresh attempt
// - stale-while-error: if cached entry exists, return it even when refresh fails
type jwksCache struct {
url string
ttl time.Duration
mu sync.RWMutex
keys map[string]*rsa.PublicKey
fetchedAt time.Time
httpClient *http.Client
}
func newJWKSCache(url string, ttl time.Duration) *jwksCache {
return &jwksCache{
url: url,
ttl: ttl,
keys: map[string]*rsa.PublicKey{},
httpClient: &http.Client{Timeout: 5 * time.Second},
}
}
func (j *jwksCache) GetKey(ctx context.Context, kid string) (*rsa.PublicKey, error) {
if kid == "" {
return nil, errors.New("missing kid")
}
j.mu.RLock()
cached, found := j.keys[kid]
fresh := !j.fetchedAt.IsZero() && time.Since(j.fetchedAt) < j.ttl
j.mu.RUnlock()
if found && fresh {
return cached, nil
}
if err := j.refresh(ctx); err != nil {
if found {
slog.Warn("jwks refresh failed; returning stale key",
"err", err, "kid", kid)
return cached, nil
}
return nil, fmt.Errorf("jwks refresh: %w", err)
}
j.mu.RLock()
defer j.mu.RUnlock()
if k, ok := j.keys[kid]; ok {
return k, nil
}
return nil, fmt.Errorf("kid %q not found in JWKS", kid)
}
func (j *jwksCache) refresh(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, j.url, nil)
if err != nil {
return err
}
resp, err := j.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
var set jose.JSONWebKeySet
if err := json.Unmarshal(body, &set); err != nil {
return fmt.Errorf("parse JWKS: %w", err)
}
next := map[string]*rsa.PublicKey{}
for _, k := range set.Keys {
pk, ok := k.Key.(*rsa.PublicKey)
if !ok || k.KeyID == "" {
continue
}
next[k.KeyID] = pk
}
j.mu.Lock()
defer j.mu.Unlock()
j.keys = next
j.fetchedAt = time.Now()
return nil
}
+75 -338
View File
@@ -2,354 +2,131 @@ package jwtauth
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"commilitia.net/cdrop/internal/config"
"commilitia.net/cdrop/internal/db"
)
// Store is the subset of *db.Queries the auth middleware uses: device upserts,
// the shortcut-token lookups needed to honour revocation, and the web_session
// lookup that makes a self-signed session token's revocation take effect at once
// (a deleted row → the token is rejected on its next request, not after TTL).
// Declared as an interface so tests can swap in a fake.
// Store is the subset of *db.Queries the auth middleware uses: refreshing a managed
// device's last_seen + tier on each request. Declared as an interface so tests can
// swap in a fake.
type Store interface {
UpsertDevice(ctx context.Context, arg db.UpsertDeviceParams) error
GetShortcutToken(ctx context.Context, jti string) (db.ShortcutToken, error)
TouchShortcutTokenUsed(ctx context.Context, arg db.TouchShortcutTokenUsedParams) error
GetWebSession(ctx context.Context, id string) (db.WebSession, error)
TouchDevice(ctx context.Context, arg db.TouchDeviceParams) error
}
// Authenticator turns each request's identity into Claims. After the Auth Broker
// migration (path A) cdrop no longer verifies tokens itself: in prod the broker
// authenticates at the edge and injects X-Auth-* headers this process trusts; in dev
// the claims are synthesised from the dev token.
type Authenticator struct {
cfg *config.Config
store Store
jwks *jwksCache
hsKey []byte
sessionTokenKey []byte
}
func New(cfg *config.Config, store Store) *Authenticator {
a := &Authenticator{
cfg: cfg,
store: store,
}
if cfg.HS256Secret != "" {
a.hsKey = DeriveHS256Key(cfg.HS256Secret)
}
// Self-signed session tokens (scan-login) are keyed off SessionSecret; nil
// when unset (dev) disables verifySelfToken, matching the minting side.
a.sessionTokenKey = DeriveSessionTokenKey(cfg.SessionSecret)
if cfg.AuthMode == "prod" && cfg.OIDCJWKSURL != "" {
a.jwks = newJWKSCache(cfg.OIDCJWKSURL, 10*time.Minute)
}
return a
return &Authenticator{cfg: cfg, store: store}
}
func (a *Authenticator) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := bearerToken(r)
if !ok {
unauthorized(w, "missing bearer token")
return
}
claims, err := a.verify(r.Context(), token, r)
claims, err := a.authenticate(r)
if err != nil {
slog.Warn("auth failed", "err", err, "path", r.URL.Path)
unauthorized(w, "invalid token")
unauthorized(w, "unauthorized")
return
}
deviceName := SanitizeDeviceName(r.Header.Get("X-Device-Name"))
deviceType := normalizeDeviceType(r.Header.Get("X-Device-Type"))
if claims.Scoped() {
// The backend authoritatively knows a scoped token is the iOS
// Shortcut, so it labels the device "shortcut" regardless of any
// header. ("ios" stays reserved for a real native client.)
deviceType = "shortcut"
}
// Register a device only when the client names itself (X-Device-Name).
// A nameless request — e.g. a polling shortcut hitting /api/clipboard/version
// without the header — must NOT be registered: the old random UA fallback
// minted a fresh "Unknown Device" row on every request and flooded the list.
if deviceName != "" {
if err := a.store.UpsertDevice(r.Context(), db.UpsertDeviceParams{
UserID: claims.UserID,
Name: deviceName,
Type: deviceType,
// Keep the managed device's last_seen + tier fresh. Identity is the
// broker-issued device_id (X-Auth-Meta); the row is created at scan-login
// collect, so this only ever updates — an unmanaged caller (no device_id,
// e.g. a global SSO browser) is skipped, and a missing row no-ops.
if claims.DeviceID != "" {
if err := a.store.TouchDevice(r.Context(), db.TouchDeviceParams{
LastSeen: time.Now().Unix(),
Tier: claims.Tier(),
DeviceID: claims.DeviceID,
UserID: claims.UserID,
}); err != nil {
// non-fatal: log and continue so transient DB errors don't 401 users
slog.Error("device upsert failed",
"err", err, "user", claims.UserID, "device", deviceName)
// non-fatal: log and continue so a transient DB error doesn't 401 users
slog.Error("device touch failed",
"err", err, "user", claims.UserID, "device", claims.DeviceID)
}
}
ctx := context.WithValue(r.Context(), claimsCtxKey, claims)
ctx := ContextWithClaims(r.Context(), claims)
ctx = context.WithValue(ctx, deviceCtxKey, deviceName)
ctx = context.WithValue(ctx, deviceTypeCtxKey, deviceType)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (a *Authenticator) verify(ctx context.Context, token string, r *http.Request) (*Claims, error) {
// authenticate resolves the request identity. prod trusts the broker's edge-injected
// X-Auth-* headers; dev synthesises claims from the dev token.
func (a *Authenticator) authenticate(r *http.Request) (*Claims, error) {
if a.cfg.AuthMode == "dev" {
return a.verifyDev(token, r)
return a.devClaims(r)
}
// cdrop self-signed session tokens first: distinct key + typ=session, so a
// shortcut token (different key, requires jti) never validates here and a
// session token never falls through to the shortcut path's DB lookup.
if c, err := a.verifySelfToken(ctx, token); err == nil {
return c, nil
// prod: the request reached cdrop only by passing broker /verify at the edge,
// which injected these headers. Caddy strips any client-supplied X-Auth-* at the
// trust boundary, so their presence is the broker's say-so. No subject → the
// request did not authenticate.
sub := r.Header.Get("X-Auth-Subject")
if sub == "" {
return nil, errors.New("missing X-Auth-Subject (request did not pass broker /verify)")
}
if c, err := a.verifyHS256(ctx, token); err == nil {
return c, nil
}
return a.verifyRS256(ctx, token)
return &Claims{
UserID: sub,
Name: r.Header.Get("X-Auth-Name"),
Avatar: r.Header.Get("X-Auth-Avatar"),
Groups: splitRoles(r.Header.Get("X-Auth-Roles")),
Scope: r.Header.Get("X-Auth-Scope"),
DeviceID: r.Header.Get("X-Auth-Meta"),
}, nil
}
// verifySelfToken validates a cdrop self-signed session access token (AUTH.md
// §3.1): HS256 over DeriveSessionTokenKey, carrying typ=session and a full/guest
// scope. This is the unified browser token — scan-login (self/guest) AND OIDC web
// logins both ride it now, so the browser holds one token type. After the cheap
// signature/exp/scope checks it does ONE indexed lookup of the token's sid against
// web_sessions: a deleted row means the session was revoked, and the token is
// rejected on its very next request (immediate "log out this device"). The IdP
// RS256 path (verifyRS256) remains only for the desktop client's loopback tokens.
func (a *Authenticator) verifySelfToken(ctx context.Context, token string) (*Claims, error) {
if len(a.sessionTokenKey) == 0 {
return nil, errors.New("session token key not configured")
}
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.HS256})
if err != nil {
return nil, err
}
var std jwt.Claims
custom := map[string]any{}
if err := parsed.Claims(a.sessionTokenKey, &std, &custom); err != nil {
return nil, err
}
if err := std.ValidateWithLeeway(jwt.Expected{Time: time.Now()}, 30*time.Second); err != nil {
return nil, err
}
if typ, _ := custom["typ"].(string); typ != "session" {
return nil, errors.New("not a session token")
}
if std.Subject == "" {
return nil, errors.New("session token missing subject")
}
scope, _ := custom["scope"].(string)
if scope != "full" && scope != "guest" {
return nil, errors.New("session token invalid scope")
}
// Bind the token to its live web_session row (sid): once that row is revoked
// (deleted), the token is rejected on its very next request — "log out this
// device" takes effect immediately rather than after the access token's TTL.
sid, _ := custom["sid"].(string)
if sid == "" {
return nil, errors.New("session token missing sid")
}
if _, err := a.store.GetWebSession(ctx, sid); err != nil {
return nil, errors.New("session revoked")
}
return &Claims{UserID: std.Subject, SessionScope: scope}, nil
}
func (a *Authenticator) verifyDev(token string, r *http.Request) (*Claims, error) {
if subtle.ConstantTimeCompare([]byte(token), []byte(a.cfg.DevToken)) != 1 {
// devClaims authenticates the local dev token and synthesises claims. X-Dev-User sets
// the subject (default "dev-user"); X-Dev-Scope simulates a tier ("guest" → restricted,
// else full); X-Dev-Device optionally sets a device_id to exercise device flows.
func (a *Authenticator) devClaims(r *http.Request) (*Claims, error) {
token, ok := bearerToken(r)
if !ok || subtle.ConstantTimeCompare([]byte(token), []byte(a.cfg.DevToken)) != 1 {
return nil, errors.New("invalid dev token")
}
userID := r.Header.Get("X-Dev-User")
if userID == "" {
userID = "dev-user"
}
return &Claims{UserID: userID, Groups: []string{"dev"}}, nil
scope := r.Header.Get("X-Dev-Scope")
if scope == "" {
scope = "full"
}
return &Claims{
UserID: userID,
Name: userID,
Groups: []string{"dev"},
Scope: scope,
DeviceID: r.Header.Get("X-Dev-Device"),
}, nil
}
func (a *Authenticator) verifyHS256(ctx context.Context, token string) (*Claims, error) {
if len(a.hsKey) == 0 {
return nil, errors.New("HS256 secret not configured")
// splitRoles parses a comma-separated X-Auth-Roles header into a role slice,
// trimming whitespace and dropping empties.
func splitRoles(raw string) []string {
if raw == "" {
return nil
}
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.HS256})
if err != nil {
return nil, err
}
var std jwt.Claims
custom := map[string]any{}
if err := parsed.Claims(a.hsKey, &std, &custom); err != nil {
return nil, err
}
if err := std.ValidateWithLeeway(jwt.Expected{Time: time.Now()}, 30*time.Second); err != nil {
return nil, err
}
claims, err := claimsFromJWT(std, custom)
if err != nil {
return nil, err
}
// HS256 is only ever used to mint scoped shortcut tokens, and those ALWAYS
// carry a jti. A validly-signed HS256 token without one must be rejected
// outright: otherwise it would fall through here as a full, unscoped account
// session with a self-declared subject — far beyond the clipboard-only
// surface HS256 is meant for. Requiring the jti keeps a leaked HS256 secret's
// blast radius pinned to the shortcut scope (clipboard, and nothing else).
if std.ID == "" {
return nil, errors.New("HS256 token missing jti")
}
// The signature alone is not enough — the token must still be present and not
// revoked in the store, so a leaked or retired token can be killed
// server-side. The stored row is also the authoritative source of scopes
// (never trust scopes off the wire).
row, err := a.store.GetShortcutToken(ctx, std.ID)
if err != nil {
return nil, fmt.Errorf("shortcut token lookup: %w", err)
}
if row.Revoked != 0 {
return nil, errors.New("shortcut token revoked")
}
if row.UserID != claims.UserID {
return nil, errors.New("shortcut token subject mismatch")
}
claims.JTI = std.ID
claims.Scopes = strings.Fields(row.Scopes)
a.touchTokenAsync(std.ID)
return claims, nil
}
// touchTokenAsync records a shortcut token's last-use time off the request path
// — it's audit metadata, so a slow or failing write must never delay or fail
// the request it belongs to.
func (a *Authenticator) touchTokenAsync(jti string) {
now := time.Now().Unix()
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := a.store.TouchShortcutTokenUsed(ctx, db.TouchShortcutTokenUsedParams{
LastUsedAt: &now,
Jti: jti,
}); err != nil {
slog.Warn("touch shortcut token failed", "err", err, "jti", jti)
}
}()
}
func (a *Authenticator) verifyRS256(ctx context.Context, token string) (*Claims, error) {
if a.jwks == nil {
return nil, errors.New("JWKS not configured")
}
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.RS256})
if err != nil {
return nil, err
}
if len(parsed.Headers) == 0 {
return nil, errors.New("missing token headers")
}
kid := parsed.Headers[0].KeyID
key, err := a.jwks.GetKey(ctx, kid)
if err != nil {
return nil, err
}
var std jwt.Claims
custom := map[string]any{}
if err := parsed.Claims(key, &std, &custom); err != nil {
return nil, err
}
expected := jwt.Expected{Time: time.Now()}
if a.cfg.OIDCIssuer != "" {
expected.Issuer = a.cfg.OIDCIssuer
}
if a.cfg.OIDCAudience != "" {
expected.AnyAudience = parseAudiences(a.cfg.OIDCAudience)
}
if err := std.ValidateWithLeeway(expected, 30*time.Second); err != nil {
return nil, err
}
return claimsFromJWT(std, custom)
}
// VerifyIDToken validates an OIDC id_token (RS256 via JWKS, issuer + audience)
// from a fresh prompt=login exchange and returns its subject and auth_time (the
// epoch second of the actual end-user authentication, or 0 when the IdP omits the
// claim — Casdoor does). Used for step-up re-auth (AUTH.md §6): the caller checks
// sub matches and, only when auth_time is present, that it is recent enough.
func (a *Authenticator) VerifyIDToken(ctx context.Context, token string) (subject string, authTime int64, err error) {
if a.jwks == nil {
return "", 0, errors.New("JWKS not configured")
}
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.RS256})
if err != nil {
return "", 0, err
}
if len(parsed.Headers) == 0 {
return "", 0, errors.New("missing token headers")
}
key, err := a.jwks.GetKey(ctx, parsed.Headers[0].KeyID)
if err != nil {
return "", 0, err
}
var std jwt.Claims
custom := map[string]any{}
if err := parsed.Claims(key, &std, &custom); err != nil {
return "", 0, err
}
expected := jwt.Expected{Time: time.Now()}
if a.cfg.OIDCIssuer != "" {
expected.Issuer = a.cfg.OIDCIssuer
}
if a.cfg.OIDCAudience != "" {
expected.AnyAudience = parseAudiences(a.cfg.OIDCAudience)
}
if err := std.ValidateWithLeeway(expected, 30*time.Second); err != nil {
return "", 0, err
}
if std.Subject == "" {
return "", 0, errors.New("missing subject claim")
}
// auth_time is OPTIONAL: required by OIDC only when the IdP chooses to honour
// max_age, and Casdoor omits it entirely. Absent → 0; the caller treats the
// fresh single-use prompt=login code as the freshness bound instead.
at, _ := numericClaim(custom["auth_time"])
return std.Subject, at, nil
}
// numericClaim coerces a JSON number claim (float64 from stdlib unmarshal, or
// json.Number / int64) to int64.
func numericClaim(v any) (int64, bool) {
switch n := v.(type) {
case float64:
return int64(n), true
case int64:
return n, true
case json.Number:
if i, err := n.Int64(); err == nil {
return i, true
}
}
return 0, false
}
// parseAudiences splits a comma-separated OIDCAudience config into a jwt.Audience
// set. Multiple values let one backend accept tokens minted for several OAuth
// clients (the web app and the desktop client carry different `aud`); go-jose's
// AnyAudience passes when the token's audience matches any one entry. Whitespace
// around entries is trimmed and empties dropped, so a plain single value behaves
// exactly as before.
func parseAudiences(raw string) jwt.Audience {
parts := strings.Split(raw, ",")
out := make(jwt.Audience, 0, len(parts))
out := make([]string, 0, len(parts))
for _, p := range parts {
if s := strings.TrimSpace(p); s != "" {
out = append(out, s)
@@ -358,21 +135,6 @@ func parseAudiences(raw string) jwt.Audience {
return out
}
func claimsFromJWT(std jwt.Claims, custom map[string]any) (*Claims, error) {
if std.Subject == "" {
return nil, errors.New("missing subject claim")
}
c := &Claims{UserID: std.Subject}
if g, ok := custom["groups"].([]any); ok {
for _, item := range g {
if s, ok := item.(string); ok {
c.Groups = append(c.Groups, s)
}
}
}
return c, nil
}
func bearerToken(r *http.Request) (string, bool) {
h := r.Header.Get("Authorization")
const prefix = "Bearer "
@@ -396,36 +158,12 @@ func unauthorized(w http.ResponseWriter, reason string) {
})
}
// DeriveHS256Key turns a configured secret of any length into a fixed 32-byte
// HMAC key. HS256 requires >= 32 bytes; hashing guarantees that (and keeps the
// minting and verifying sides in lockstep) so a short CDROP_HS256_SECRET can't
// make shortcut-token signing fail. Both signing (httpapi) and verifying use it.
func DeriveHS256Key(secret string) []byte {
sum := sha256.Sum256([]byte(secret))
return sum[:]
}
// DeriveSessionTokenKey derives the HMAC key for cdrop's self-signed session
// access tokens from CDROP_SESSION_SECRET. Domain-separated from both the at-rest
// refresh-token AES key (plain sha256(secret), in httpapi) and the shortcut-token
// key (DeriveHS256Key) so one secret yields three independent keys — a session
// token can never validate as a shortcut token, or vice versa. Empty secret →
// nil, which disables both minting and verifying (dev / unconfigured prod).
func DeriveSessionTokenKey(secret string) []byte {
if secret == "" {
return nil
}
sum := sha256.Sum256([]byte("cdrop-session-jwt\x00" + secret))
return sum[:]
}
// SanitizeDeviceName enforces the global ASCII-only device-name policy. Device
// names ride in the X-Device-Name HTTP header, which can't carry non-ASCII
// reliably (and browser fetch rejects such header values outright), so the name
// is restricted to printable ASCII everywhere. Here we keep only printable ASCII
// (0x200x7E), trim, and cap the length as a server-side backstop; clients also
// validate the name up front for a clear error. Empty after sanitising → the
// caller falls back to a UA-derived default.
// SanitizeDeviceName enforces the global ASCII-only device-name policy. Device names
// ride in the X-Device-Name HTTP header, which can't carry non-ASCII reliably (and
// browser fetch rejects such header values outright), so the name is restricted to
// printable ASCII everywhere. Here we keep only printable ASCII (0x200x7E), trim, and
// cap the length as a server-side backstop; clients also validate up front. Empty after
// sanitising → the caller falls back to a default.
func SanitizeDeviceName(raw string) string {
var b strings.Builder
for _, r := range raw {
@@ -440,10 +178,9 @@ func SanitizeDeviceName(raw string) string {
return name
}
// normalizeDeviceType whitelists the client-declared X-Device-Type so a device
// row only ever carries a known kind; anything unrecognised (incl. empty) falls
// back to "browser", the default web client. Desktop clients send macos/windows;
// "ios" is reserved for a future native iOS client (the Shortcut never sends it).
// normalizeDeviceType whitelists the client-declared X-Device-Type so a device row
// only ever carries a known kind; anything unrecognised (incl. empty) falls back to
// "browser". Native clients send macos/windows/linux/ios.
func normalizeDeviceType(raw string) string {
switch t := strings.ToLower(strings.TrimSpace(raw)); t {
case "macos", "windows", "linux", "ios", "browser":
+113 -538
View File
@@ -2,596 +2,171 @@ package jwtauth
import (
"context"
"crypto/rand"
"crypto/rsa"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"commilitia.net/cdrop/internal/config"
"commilitia.net/cdrop/internal/db"
)
// fakeDeviceUpserter records UpsertDevice calls without touching SQL and serves
// shortcut-token lookups from an in-memory map (empty → "not found", so plain
// device-only tests are unaffected).
type fakeDeviceUpserter struct {
calls []db.UpsertDeviceParams
err error
tokens map[string]db.ShortcutToken
revokedSIDs map[string]bool
type fakeDeviceStore struct {
touched []db.TouchDeviceParams
}
func (f *fakeDeviceUpserter) UpsertDevice(_ context.Context, arg db.UpsertDeviceParams) error {
f.calls = append(f.calls, arg)
return f.err
}
func (f *fakeDeviceUpserter) GetShortcutToken(_ context.Context, jti string) (db.ShortcutToken, error) {
if t, ok := f.tokens[jti]; ok {
return t, nil
}
return db.ShortcutToken{}, sql.ErrNoRows
}
// TouchShortcutTokenUsed is a no-op in tests: it runs on a detached goroutine
// (touchTokenAsync), so recording into the fake here would race the test body.
func (f *fakeDeviceUpserter) TouchShortcutTokenUsed(_ context.Context, _ db.TouchShortcutTokenUsedParams) error {
func (f *fakeDeviceStore) TouchDevice(_ context.Context, arg db.TouchDeviceParams) error {
f.touched = append(f.touched, arg)
return nil
}
// GetWebSession backs the self-token revocation check. revokedSIDs lets a test
// simulate a revoked session (deleted row); any other sid resolves to a live row.
func (f *fakeDeviceUpserter) GetWebSession(_ context.Context, id string) (db.WebSession, error) {
if f.revokedSIDs[id] {
return db.WebSession{}, sql.ErrNoRows
// runMiddleware drives a request through the auth middleware and captures the claims
// the downstream handler sees (nil if the request was rejected before reaching it).
func runMiddleware(a *Authenticator, r *http.Request) (*httptest.ResponseRecorder, *Claims) {
var captured *Claims
h := a.Middleware(http.HandlerFunc(func(w http.ResponseWriter, rr *http.Request) {
if c, ok := ClaimsFromContext(rr.Context()); ok {
captured = c
}
return db.WebSession{ID: id, UserID: "user-x"}, nil
}
// echo handler that responds with the claims+device discovered in context.
func echoMe(w http.ResponseWriter, r *http.Request) {
claims, _ := ClaimsFromContext(r.Context())
dev, _ := DeviceNameFromContext(r.Context())
resp := map[string]any{
"user_id": claims.UserID,
"groups": claims.Groups,
"device": dev,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
func TestDevMode_AcceptsTokenAndUsesXDevUser(t *testing.T) {
cfg := &config.Config{AuthMode: "dev", DevToken: "secret-token"}
dev := &fakeDeviceUpserter{}
a := New(cfg, dev)
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.Header.Set("Authorization", "Bearer secret-token")
req.Header.Set("X-Dev-User", "alice")
req.Header.Set("X-Device-Name", "tab-1")
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
}
var got map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if got["user_id"] != "alice" {
t.Errorf("user_id: got %v, want alice", got["user_id"])
}
if got["device"] != "tab-1" {
t.Errorf("device: got %v, want tab-1", got["device"])
}
if len(dev.calls) != 1 {
t.Errorf("UpsertDevice calls: got %d, want 1", len(dev.calls))
}
}
func TestDevMode_DefaultsUserIDWhenHeaderMissing(t *testing.T) {
cfg := &config.Config{AuthMode: "dev", DevToken: "t"}
a := New(cfg, &fakeDeviceUpserter{})
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.Header.Set("Authorization", "Bearer t")
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200", rr.Code)
}
var got map[string]any
_ = json.Unmarshal(rr.Body.Bytes(), &got)
if got["user_id"] != "dev-user" {
t.Errorf("user_id: got %v, want dev-user", got["user_id"])
}
}
func TestDevMode_RejectsWrongToken(t *testing.T) {
cfg := &config.Config{AuthMode: "dev", DevToken: "right"}
a := New(cfg, &fakeDeviceUpserter{})
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.Header.Set("Authorization", "Bearer wrong")
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("status: got %d, want 401", rr.Code)
}
}
func TestDevMode_RejectsMissingHeader(t *testing.T) {
cfg := &config.Config{AuthMode: "dev", DevToken: "x"}
a := New(cfg, &fakeDeviceUpserter{})
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("status: got %d, want 401", rr.Code)
}
}
// --- prod RS256 path ---
func newRSAKey(t *testing.T) *rsa.PrivateKey {
t.Helper()
k, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa keygen: %v", err)
}
return k
}
func startJWKSServer(t *testing.T, kid string, pub *rsa.PublicKey) *httptest.Server {
t.Helper()
jwk := jose.JSONWebKey{Key: pub, KeyID: kid, Algorithm: "RS256", Use: "sig"}
set := jose.JSONWebKeySet{Keys: []jose.JSONWebKey{jwk}}
body, err := json.Marshal(set)
if err != nil {
t.Fatalf("marshal jwks: %v", err)
}
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
w.WriteHeader(http.StatusOK)
}))
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w, captured
}
func signRS256(t *testing.T, priv *rsa.PrivateKey, kid string, claims jwt.Claims, custom map[string]any) string {
t.Helper()
signer, err := jose.NewSigner(
jose.SigningKey{Algorithm: jose.RS256, Key: priv},
(&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", kid),
)
if err != nil {
t.Fatalf("new signer: %v", err)
}
tok, err := jwt.Signed(signer).Claims(claims).Claims(custom).Serialize()
if err != nil {
t.Fatalf("sign: %v", err)
}
return tok
}
func TestMiddleware_ProdReadsAuthHeaders(t *testing.T) {
a := New(&config.Config{AuthMode: "prod"}, &fakeDeviceStore{})
r := httptest.NewRequest(http.MethodGet, "/api/me", nil)
r.Header.Set("X-Auth-Subject", "user-1")
r.Header.Set("X-Auth-Scope", "app:cdrop:guest")
r.Header.Set("X-Auth-Meta", "dev_abc")
r.Header.Set("X-Auth-Name", "Alice")
r.Header.Set("X-Auth-Roles", "admin, user")
func TestProdMode_AcceptsValidRS256(t *testing.T) {
priv := newRSAKey(t)
kid := "key-1"
srv := startJWKSServer(t, kid, &priv.PublicKey)
defer srv.Close()
cfg := &config.Config{
AuthMode: "prod",
OIDCJWKSURL: srv.URL,
OIDCIssuer: "https://oauth.example/",
OIDCAudience: "cdrop",
w, c := runMiddleware(a, r)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200", w.Code)
}
a := New(cfg, &fakeDeviceUpserter{})
now := time.Now()
std := jwt.Claims{
Issuer: "https://oauth.example/",
Subject: "user-bob",
Audience: jwt.Audience{"cdrop"},
IssuedAt: jwt.NewNumericDate(now),
Expiry: jwt.NewNumericDate(now.Add(time.Hour)),
if c == nil {
t.Fatal("claims missing from context")
}
tok := signRS256(t, priv, kid, std, map[string]any{"groups": []any{"users"}})
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.Header.Set("Authorization", "Bearer "+tok)
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
if c.UserID != "user-1" || c.DeviceID != "dev_abc" || c.Name != "Alice" {
t.Errorf("claims wrong: %+v", c)
}
var got map[string]any
_ = json.Unmarshal(rr.Body.Bytes(), &got)
if got["user_id"] != "user-bob" {
t.Errorf("user_id: got %v, want user-bob", got["user_id"])
if !c.Guest() {
t.Error("app:cdrop:guest scope should mark Guest()")
}
if len(c.Groups) != 2 || c.Groups[0] != "admin" || c.Groups[1] != "user" {
t.Errorf("groups: got %v", c.Groups)
}
}
func TestProdMode_RejectsExpiredRS256(t *testing.T) {
priv := newRSAKey(t)
kid := "key-1"
srv := startJWKSServer(t, kid, &priv.PublicKey)
defer srv.Close()
cfg := &config.Config{
AuthMode: "prod",
OIDCJWKSURL: srv.URL,
OIDCIssuer: "https://oauth.example/",
OIDCAudience: "cdrop",
func TestMiddleware_ProdMissingSubjectRejected(t *testing.T) {
a := New(&config.Config{AuthMode: "prod"}, &fakeDeviceStore{})
r := httptest.NewRequest(http.MethodGet, "/api/me", nil)
w, c := runMiddleware(a, r)
if w.Code != http.StatusUnauthorized {
t.Fatalf("no X-Auth-Subject: got %d, want 401", w.Code)
}
a := New(cfg, &fakeDeviceUpserter{})
past := time.Now().Add(-time.Hour)
std := jwt.Claims{
Issuer: "https://oauth.example/",
Subject: "user-bob",
Audience: jwt.Audience{"cdrop"},
IssuedAt: jwt.NewNumericDate(past),
Expiry: jwt.NewNumericDate(past.Add(time.Minute)), // already 59min stale
}
tok := signRS256(t, priv, kid, std, nil)
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.Header.Set("Authorization", "Bearer "+tok)
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("status: got %d, want 401; body=%s", rr.Code, rr.Body.String())
if c != nil {
t.Error("handler must not run on rejected request")
}
}
func TestProdMode_RejectsWrongIssuer(t *testing.T) {
priv := newRSAKey(t)
kid := "key-1"
srv := startJWKSServer(t, kid, &priv.PublicKey)
defer srv.Close()
cfg := &config.Config{
AuthMode: "prod",
OIDCJWKSURL: srv.URL,
OIDCIssuer: "https://oauth.example/",
OIDCAudience: "cdrop",
func TestMiddleware_TouchesManagedDevice(t *testing.T) {
fs := &fakeDeviceStore{}
a := New(&config.Config{AuthMode: "prod"}, fs)
r := httptest.NewRequest(http.MethodGet, "/api/me", nil)
r.Header.Set("X-Auth-Subject", "user-1")
r.Header.Set("X-Auth-Scope", "app:cdrop:full")
r.Header.Set("X-Auth-Meta", "dev_x")
runMiddleware(a, r)
if len(fs.touched) != 1 {
t.Fatalf("touch count: got %d, want 1", len(fs.touched))
}
a := New(cfg, &fakeDeviceUpserter{})
now := time.Now()
std := jwt.Claims{
Issuer: "https://attacker.example/",
Subject: "user-bob",
Audience: jwt.Audience{"cdrop"},
IssuedAt: jwt.NewNumericDate(now),
Expiry: jwt.NewNumericDate(now.Add(time.Hour)),
}
tok := signRS256(t, priv, kid, std, nil)
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.Header.Set("Authorization", "Bearer "+tok)
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("status: got %d, want 401; body=%s", rr.Code, rr.Body.String())
if fs.touched[0].DeviceID != "dev_x" || fs.touched[0].Tier != "full" || fs.touched[0].UserID != "user-1" {
t.Errorf("touch params: %+v", fs.touched[0])
}
}
// Multi-audience (R1): one backend serving web + desktop clients. A desktop
// token carries aud=<desktop client_id>; it must pass when OIDCAudience lists
// both client_ids comma-separated, and still be rejected when its aud is in
// neither.
func TestProdMode_AcceptsSecondAudienceInList(t *testing.T) {
priv := newRSAKey(t)
kid := "key-1"
srv := startJWKSServer(t, kid, &priv.PublicKey)
defer srv.Close()
cfg := &config.Config{
AuthMode: "prod",
OIDCJWKSURL: srv.URL,
OIDCIssuer: "https://oauth.example/",
OIDCAudience: "cdrop-web , cdrop-desktop", // spaces trimmed
}
a := New(cfg, &fakeDeviceUpserter{})
now := time.Now()
std := jwt.Claims{
Issuer: "https://oauth.example/",
Subject: "user-bob",
Audience: jwt.Audience{"cdrop-desktop"}, // the desktop client's aud
IssuedAt: jwt.NewNumericDate(now),
Expiry: jwt.NewNumericDate(now.Add(time.Hour)),
}
tok := signRS256(t, priv, kid, std, nil)
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.Header.Set("Authorization", "Bearer "+tok)
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
func TestMiddleware_SkipsTouchWhenUnmanaged(t *testing.T) {
fs := &fakeDeviceStore{}
a := New(&config.Config{AuthMode: "prod"}, fs)
r := httptest.NewRequest(http.MethodGet, "/api/me", nil)
r.Header.Set("X-Auth-Subject", "user-1")
r.Header.Set("X-Auth-Scope", "full")
// no X-Auth-Meta → unmanaged caller (e.g. a global SSO browser)
runMiddleware(a, r)
if len(fs.touched) != 0 {
t.Errorf("unmanaged caller should not touch a device row; got %d", len(fs.touched))
}
}
func TestProdMode_RejectsAudienceNotInList(t *testing.T) {
priv := newRSAKey(t)
kid := "key-1"
srv := startJWKSServer(t, kid, &priv.PublicKey)
defer srv.Close()
cfg := &config.Config{
AuthMode: "prod",
OIDCJWKSURL: srv.URL,
OIDCIssuer: "https://oauth.example/",
OIDCAudience: "cdrop-web,cdrop-desktop",
func TestMiddleware_DevMode(t *testing.T) {
a := New(&config.Config{AuthMode: "dev", DevToken: "devtok"}, &fakeDeviceStore{})
r := httptest.NewRequest(http.MethodGet, "/api/me", nil)
r.Header.Set("Authorization", "Bearer devtok")
r.Header.Set("X-Dev-User", "dev-alice")
r.Header.Set("X-Dev-Scope", "guest")
w, c := runMiddleware(a, r)
if w.Code != http.StatusOK || c == nil {
t.Fatalf("dev auth failed: %d", w.Code)
}
a := New(cfg, &fakeDeviceUpserter{})
now := time.Now()
std := jwt.Claims{
Issuer: "https://oauth.example/",
Subject: "user-bob",
Audience: jwt.Audience{"some-other-client"},
IssuedAt: jwt.NewNumericDate(now),
Expiry: jwt.NewNumericDate(now.Add(time.Hour)),
}
tok := signRS256(t, priv, kid, std, nil)
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.Header.Set("Authorization", "Bearer "+tok)
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("status: got %d, want 401; body=%s", rr.Code, rr.Body.String())
if c.UserID != "dev-alice" || !c.Guest() {
t.Errorf("dev claims wrong: %+v", c)
}
}
// A nameless request (no X-Device-Name) must NOT register a device — preventing
// the random-fallback "Unknown Device" flood from polling clients.
func TestNamelessRequestSkipsDeviceUpsert(t *testing.T) {
cfg := &config.Config{AuthMode: "dev", DevToken: "t"}
store := &fakeDeviceUpserter{}
a := New(cfg, store)
req := httptest.NewRequest(http.MethodGet, "/api/clipboard/version", nil)
req.Header.Set("Authorization", "Bearer t") // no X-Device-Name
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200", rr.Code)
}
if len(store.calls) != 0 {
t.Errorf("nameless request must not upsert a device, got %d calls", len(store.calls))
func TestMiddleware_DevModeBadToken(t *testing.T) {
a := New(&config.Config{AuthMode: "dev", DevToken: "devtok"}, &fakeDeviceStore{})
r := httptest.NewRequest(http.MethodGet, "/api/me", nil)
r.Header.Set("Authorization", "Bearer wrong")
w, _ := runMiddleware(a, r)
if w.Code != http.StatusUnauthorized {
t.Fatalf("bad dev token: got %d, want 401", w.Code)
}
}
func signHS256(t *testing.T, secret, sub, jti, scope string, exp time.Time) string {
t.Helper()
sig, err := jose.NewSigner(
jose.SigningKey{Algorithm: jose.HS256, Key: DeriveHS256Key(secret)},
(&jose.SignerOptions{}).WithType("JWT"),
)
if err != nil {
t.Fatalf("signer: %v", err)
func TestClaimsTier(t *testing.T) {
cases := []struct {
scope string
tier string
guest bool
}{
{"app:cdrop:guest", "guest", true},
{"app:cdrop:full", "full", false},
{"full", "full", false},
{"app:cdrop", "cdrop", false},
{"", "", false},
}
std := jwt.Claims{
Subject: sub,
ID: jti,
IssuedAt: jwt.NewNumericDate(time.Now()),
Expiry: jwt.NewNumericDate(exp),
for _, tc := range cases {
c := &Claims{Scope: tc.scope}
if c.Tier() != tc.tier {
t.Errorf("Tier(%q): got %q, want %q", tc.scope, c.Tier(), tc.tier)
}
tok, err := jwt.Signed(sig).Claims(std).Claims(map[string]any{"scope": scope}).Serialize()
if err != nil {
t.Fatalf("serialize: %v", err)
if c.Guest() != tc.guest {
t.Errorf("Guest(%q): got %v, want %v", tc.scope, c.Guest(), tc.guest)
}
return tok
}
func serveBearer(a *Authenticator, tok string) (int, *Claims) {
req := httptest.NewRequest(http.MethodGet, "/api/clipboard", nil)
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("X-Device-Name", "iPhone")
rr := httptest.NewRecorder()
var got *Claims
a.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got, _ = ClaimsFromContext(r.Context())
w.WriteHeader(http.StatusOK)
})).ServeHTTP(rr, req)
return rr.Code, got
}
// A valid HS256 shortcut token authenticates and carries its jti + stored scope;
// revocation, an unknown jti, and a subject/owner mismatch are all rejected even
// though the signature is valid — the store is the authority.
func TestShortcutToken_HS256VerifyRevokeAndScope(t *testing.T) {
secret := "test-hs256-secret-at-least-32-bytes-long" // HS256 needs >= 32 bytes
cfg := &config.Config{AuthMode: "prod", HS256Secret: secret}
exp := time.Now().Add(time.Hour)
store := &fakeDeviceUpserter{tokens: map[string]db.ShortcutToken{
"jti-1": {Jti: "jti-1", UserID: "user-x", Scopes: "clipboard", ExpiresAt: exp.Unix()},
}}
a := New(cfg, store)
tok := signHS256(t, secret, "user-x", "jti-1", "clipboard", exp)
code, claims := serveBearer(a, tok)
if code != http.StatusOK {
t.Fatalf("valid token: got %d, want 200", code)
}
if claims == nil || !claims.Scoped() || claims.JTI != "jti-1" || !claims.HasScope("clipboard") {
t.Fatalf("claims not populated as scoped clipboard token: %+v", claims)
}
// The device registers under the (ASCII) X-Device-Name the request carried.
if len(store.calls) == 0 || store.calls[len(store.calls)-1].Name != "iPhone" {
t.Errorf("expected device name from header, got %+v", store.calls)
}
// Subject mismatch: stored row owned by a different user than the token's sub.
store.tokens["jti-1"] = db.ShortcutToken{Jti: "jti-1", UserID: "someone-else", Scopes: "clipboard", ExpiresAt: exp.Unix()}
if code, _ := serveBearer(a, tok); code != http.StatusUnauthorized {
t.Errorf("subject mismatch: got %d, want 401", code)
}
// Revoked row → reject.
store.tokens["jti-1"] = db.ShortcutToken{Jti: "jti-1", UserID: "user-x", Scopes: "clipboard", Revoked: 1, ExpiresAt: exp.Unix()}
if code, _ := serveBearer(a, tok); code != http.StatusUnauthorized {
t.Errorf("revoked token: got %d, want 401", code)
}
// Unknown jti (signed but no stored row) → reject.
ghost := signHS256(t, secret, "user-x", "ghost", "clipboard", exp)
if code, _ := serveBearer(a, ghost); code != http.StatusUnauthorized {
t.Errorf("unknown jti: got %d, want 401", code)
}
}
// An HS256 token WITHOUT a jti must be rejected outright (G1): the HS256 path
// only ever signs scoped shortcut tokens, which always carry a jti. A jti-less
// but validly-signed token would otherwise fall through as a full, unscoped
// account session with a self-declared subject — so a leaked HS256 secret could
// mint arbitrary-subject sessions far beyond the clipboard scope. Requiring the
// jti pins a leaked secret's blast radius to clipboard-only.
func TestShortcutToken_HS256RejectsMissingJTI(t *testing.T) {
secret := "test-hs256-secret-at-least-32-bytes-long"
cfg := &config.Config{AuthMode: "prod", HS256Secret: secret}
a := New(cfg, &fakeDeviceUpserter{})
tok := signHS256(t, secret, "user-x", "", "clipboard", time.Now().Add(time.Hour))
if code, _ := serveBearer(a, tok); code != http.StatusUnauthorized {
t.Errorf("jti-less HS256 token must be rejected, got %d", code)
}
}
func TestSanitizeDeviceName(t *testing.T) {
cases := []struct{ in, want string }{
{"iPhone", "iPhone"},
{" My iPad ", "My iPad"},
{"我的iPhone", "iPhone"}, // CJK stripped
{"我的电脑", ""}, // all non-ASCII → empty (caller falls back)
{"Mac Book", "MacBook"}, // NBSP dropped
if got := SanitizeDeviceName(" Alice's Mac "); got != "Alice's Mac" {
t.Errorf("trim: got %q", got)
}
for _, c := range cases {
if got := SanitizeDeviceName(c.in); got != c.want {
t.Errorf("SanitizeDeviceName(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// A self-signed session token is bound to its web_session row: once the row is
// revoked (deleted), the token is rejected on its next request even though it is
// still cryptographically valid and unexpired — "log out this device" is immediate
// (AUTH.md §1/§3.1).
func TestSelfToken_RejectedAfterSessionRevoked(t *testing.T) {
secret := "test-session-secret-at-least-32-bytes-long"
exp := time.Now().Add(time.Hour)
live := New(&config.Config{AuthMode: "prod", SessionSecret: secret}, &fakeDeviceUpserter{})
if c, _ := serveBearer(live, signSelfToken(t, secret, "user-x", "session", "full", exp)); c != http.StatusOK {
t.Fatalf("token with a live session: got %d, want 200", c)
}
revoked := New(&config.Config{AuthMode: "prod", SessionSecret: secret},
&fakeDeviceUpserter{revokedSIDs: map[string]bool{"test-sid": true}})
if c, _ := serveBearer(revoked, signSelfToken(t, secret, "user-x", "session", "full", exp)); c != http.StatusUnauthorized {
t.Errorf("token whose session was revoked must be rejected, got %d", c)
// Non-ASCII is stripped (the name rides an HTTP header).
if got := SanitizeDeviceName("名字abc"); got != "abc" {
t.Errorf("non-ascii strip: got %q", got)
}
}
// signSelfToken mints a cdrop self-signed session token the way httpapi.mintSessionToken
// does: HS256 over DeriveSessionTokenKey, with typ + scope custom claims and no jti.
func signSelfToken(t *testing.T, secret, sub, typ, scope string, exp time.Time) string {
t.Helper()
sig, err := jose.NewSigner(
jose.SigningKey{Algorithm: jose.HS256, Key: DeriveSessionTokenKey(secret)},
(&jose.SignerOptions{}).WithType("JWT"),
)
if err != nil {
t.Fatalf("signer: %v", err)
}
std := jwt.Claims{
Subject: sub,
IssuedAt: jwt.NewNumericDate(time.Now()),
Expiry: jwt.NewNumericDate(exp),
}
tok, err := jwt.Signed(sig).Claims(std).Claims(map[string]any{"typ": typ, "scope": scope, "sid": "test-sid"}).Serialize()
if err != nil {
t.Fatalf("serialize: %v", err)
}
return tok
}
// A cdrop self-signed session token authenticates as a full or guest session;
// guest is capability-limited (Guest() true). A wrong typ or an unknown scope is
// rejected, and the SessionSecret-derived key is domain-isolated from the HS256
// shortcut key so the two token families never cross-validate (AUTH.md §3.1).
func TestSelfToken_ScopeAndKeyIsolation(t *testing.T) {
secret := "test-session-secret-at-least-32-bytes-long"
a := New(&config.Config{AuthMode: "prod", SessionSecret: secret}, &fakeDeviceUpserter{})
exp := time.Now().Add(time.Hour)
// Full session: authenticates, not scoped, not guest.
code, claims := serveBearer(a, signSelfToken(t, secret, "user-x", "session", "full", exp))
if code != http.StatusOK {
t.Fatalf("full session token: got %d, want 200", code)
}
if claims == nil || claims.UserID != "user-x" || claims.SessionScope != "full" || claims.Scoped() || claims.Guest() {
t.Fatalf("full session claims wrong: %+v", claims)
}
// Guest session: authenticates and is marked guest (capability-limited).
code, claims = serveBearer(a, signSelfToken(t, secret, "user-x", "session", "guest", exp))
if code != http.StatusOK || claims == nil || !claims.Guest() || claims.Scoped() {
t.Fatalf("guest session: code=%d claims=%+v", code, claims)
}
// Wrong typ (not "session") signed with the session key → rejected.
if c, _ := serveBearer(a, signSelfToken(t, secret, "user-x", "other", "full", exp)); c != http.StatusUnauthorized {
t.Errorf("wrong typ: got %d, want 401", c)
}
// Unknown scope → rejected (no privilege-by-typo).
if c, _ := serveBearer(a, signSelfToken(t, secret, "user-x", "session", "admin", exp)); c != http.StatusUnauthorized {
t.Errorf("invalid scope: got %d, want 401", c)
}
// Key-domain isolation: with BOTH secrets set, the two families stay disjoint —
// a session token never becomes scoped, a shortcut token never becomes a session.
hsSecret := "test-hs256-secret-at-least-32-bytes-long"
store := &fakeDeviceUpserter{tokens: map[string]db.ShortcutToken{
"jti-1": {Jti: "jti-1", UserID: "user-x", Scopes: "clipboard", ExpiresAt: exp.Unix()},
}}
ab := New(&config.Config{AuthMode: "prod", SessionSecret: secret, HS256Secret: hsSecret}, store)
if _, sc := serveBearer(ab, signSelfToken(t, secret, "user-x", "session", "guest", exp)); sc == nil || sc.Scoped() || !sc.Guest() {
t.Errorf("session token leaked into scoped path: %+v", sc)
}
if _, hc := serveBearer(ab, signHS256(t, hsSecret, "user-x", "jti-1", "clipboard", exp)); hc == nil || !hc.Scoped() || hc.SessionScope != "" {
t.Errorf("shortcut token leaked into session path: %+v", hc)
}
// No SessionSecret on the server → self tokens are unverifiable (key nil).
noSecret := New(&config.Config{AuthMode: "prod", HS256Secret: hsSecret}, &fakeDeviceUpserter{})
if c, _ := serveBearer(noSecret, signSelfToken(t, secret, "user-x", "session", "full", exp)); c != http.StatusUnauthorized {
t.Errorf("self token without server SessionSecret must be rejected, got %d", c)
func TestNormalizeDeviceType(t *testing.T) {
for _, in := range []string{"macos", "windows", "linux", "ios", "browser"} {
if got := normalizeDeviceType(in); got != in {
t.Errorf("normalizeDeviceType(%q): got %q", in, got)
}
}
if got := normalizeDeviceType("rogue"); got != "browser" {
t.Errorf("unknown type should fall back to browser; got %q", got)
}
}
+8
View File
@@ -0,0 +1,8 @@
# CDrop.xcodeproj 由 xcodegen 从 project.yml 生成——不提交,用 `xcodegen generate` 重建。
CDrop.xcodeproj/
# Xcode 构建产物
build/
DerivedData/
*.xcuserstate
.DS_Store
+40
View File
@@ -0,0 +1,40 @@
# cdrop iOS 真机自测手册
账号到位即可照做。模拟器构建不需签名,已全程 compile + 跑通;**真机**要付费 ADP + 签名 + 实体 iPhone,下面是 turnkey 步骤 + 只能真机验的清单。
## 前置
- 付费 Apple Developer Program$99/年)。
- 一台 iPhoneiOS 26),Mac 装 Xcode 26。
- 已登录的 cdrop(网页 / 桌面)一个——用来扫码批准本机登录。
## 出工程 + 签名
1. `cd ios/CDrop && xcodegen generate`(从 `project.yml` 生成 `CDrop.xcodeproj`)。
2. Xcode 打开 `CDrop.xcodeproj` → 选 `CDrop` target → Signing & Capabilities
- **Team** 选你的 ADP 团队;**Bundle Identifier** 用你账号下可注册的(默认 `net.commilitia.cdrop`,前缀按你的改)。
- 让 Xcode 自动管理签名(Automatically manage signing)。
3. 连 iPhone、信任、选作运行目标 → Run。
- `project.yml``CODE_SIGNING_ALLOWED: NO` 是给模拟器的;Xcode 真机自动签名会覆盖它,无需手改。
## 引擎可达性(让传输真能连)
- 真机上引擎 WebView 默认加载 `https://drop.commilitia.net/engine.html`——**该文件要先随 web 部署到 prod**(`vite build` 已产出 `dist/engine.html`,随正常 web 发布上线)。
- 或本机联调:设环境变量 `CDROP_ENGINE_URL` 指向可达引擎、`CDROP_API_BASE` 指向后端。
## 后续功能要补的 capability(按需,各自要在 Developer Portal 配 App ID
- **App Groups**`group.net.commilitia.cdrop`):Share Extension / 控制中心控件与主 app 共享数据。
- **Push Notifications** + APNs `.p8`:传输事件推送。
- **Keychain Sharing**:跨 app / 扩展共享登录态。
- **Associated Domains**:若改用 OIDC Universal Link 回调(扫码登录则不需要)。
## 只能真机验的清单
- [ ] 液态玻璃在真机渲染(含陀螺仪高光 / 动效)。
- [ ] 扫码登录:本机显码 → 用已登录 cdrop 扫码批准 → app 进入主界面。
- [ ] **本地网络权限**:首次同内网传输弹 `NSLocalNetworkUsageDescription` 提示;授予后 ICE 收到 host 候选(R-iOS-6);拒绝则回退中继仍可传。
- [ ] 发送:选文件 → 选设备 → 对端收到。
- [ ] 接收:对端发来 → 落到 Files(沙盒 Documents)。
- [ ] 后台:传输中切后台 → `BGContinuedProcessingTask` 续传行为(R-iOS-1 / R-iOS-3)。
- [ ] 大文件:发送侧内存(当前整文件入内存,大文件需转分块流式 = R-iOS-4)。
## 已知 TODO(真机阶段一并收)
- 会话 Keychain 持久化 + 自动续期(当前会话在内存、重启需重扫;续期端点 `/api/auth/refresh` 已可用,靠 cookie)。
- 发送侧大文件分块流式(R-iOS-4)。
- 剪贴板 App Intent + 控制中心控件(决策 C)、Share Extension——需上面的 capability + 账号才能跑。
@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "icon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+223
View File
@@ -0,0 +1,223 @@
import Foundation
import Observation
import UIKit
// + iOS web /
// /api/auth/qr/status / internal/httpapi/
// qr.go REST WebView engine.html
//
// Auth Broker Aaccess_token 900sWebView refresh_token
// POST /api/auth/refresh {refresh_token} cdrop broker refresh.gobroker
// refresh sessionRotated Keychainrefresh / authExpired
// cdrop_session cookie
@MainActor
@Observable
final class AuthManager
{
struct User: Codable
{
let id: String
let name: String
let avatar: String?
}
struct Session: Codable
{
let accessToken: String
// refreshTokenbroker access
let refreshToken: String
let user: User
let deviceName: String
// deviceIdcdrop id= broker meta X-Auth-Meta
let deviceId: String
let scope: String
}
var session: Session?
var qrPayload: String?
var statusText: String = t("ios.login.generating")
// denied/expired/
var qrExpired = false
// startQRLogin
// statusText / qrPayload
private var loginGeneration = 0
// Keychain app Keychain.swift
private static let keychainService = "net.commilitia.cdrop.session"
private static let keychainAccount = "session"
// app /
// guest 1h 401
init()
{
if let data = Keychain.load(service: Self.keychainService, account: Self.keychainAccount),
let restored = try? JSONDecoder().decode(Session.self, from: data)
{
// guest full
if restored.scope == "full" { session = restored }
else { Keychain.delete(service: Self.keychainService, account: Self.keychainAccount) }
}
}
// 线 CDROP_API_BASE
private var apiBase: String
{
ProcessInfo.processInfo.environment["CDROP_API_BASE"] ?? "https://drop.commilitia.net"
}
private struct QRStart: Decodable
{
let request_id: String
let poll_secret: String
let qr_payload: String
let expires_at: Int64
}
private struct QRStatus: Decodable
{
let status: String
let access_token: String?
let refresh_token: String?
let device_id: String?
let expires_in: Int?
let user: User?
let device_name: String?
}
// startQRLogin + /
//
func startQRLogin() async
{
loginGeneration += 1
let gen = loginGeneration
qrExpired = false
qrPayload = nil
statusText = t("ios.login.generating")
do
{
let start = try await qrStart()
if gen != loginGeneration { return }
qrPayload = start.qr_payload
statusText = t("ios.login.waiting")
try await poll(requestID: start.request_id, pollSecret: start.poll_secret, gen: gen)
}
catch
{
if gen != loginGeneration { return }
statusText = t("ios.login.failed")
qrExpired = true
}
}
func logout()
{
session = nil
qrPayload = nil
statusText = t("ios.login.generating")
Keychain.delete(service: Self.keychainService, account: Self.keychainAccount)
}
// Keychain
private func persist()
{
guard let session, let data = try? JSONEncoder().encode(session) else { return }
Keychain.save(data, service: Self.keychainService, account: Self.keychainAccount)
}
// updateSession broker sessionRotated
// + Keychain使 refresh /
func updateSession(accessToken: String, refreshToken: String)
{
guard let cur = session else { return }
session = Session(accessToken: accessToken, refreshToken: refreshToken,
user: cur.user, deviceName: cur.deviceName,
deviceId: cur.deviceId, scope: cur.scope)
persist()
}
// CDROP_DEBUG_SESSION=1
// prod + 401 engine.html WKWebView
func debugSession()
{
session = Session(accessToken: "debug", refreshToken: "",
user: User(id: "debug", name: "Simulator", avatar: nil),
deviceName: "Simulator", deviceId: "", scope: "guest")
}
private func qrStart() async throws -> QRStart
{
var req = URLRequest(url: URL(string: "\(apiBase)/api/auth/qr/start")!)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("ios", forHTTPHeaderField: "X-Device-Type")
req.httpBody = try JSONSerialization.data(
withJSONObject: [ "device_name": deviceName(), "device_type": "ios" ])
let (data, _) = try await URLSession.shared.data(for: req)
return try JSONDecoder().decode(QRStart.self, from: data)
}
private func poll(requestID: String, pollSecret: String, gen: Int) async throws
{
while session == nil && gen == loginGeneration
{
var comp = URLComponents(string: "\(apiBase)/api/auth/qr/status")!
comp.queryItems = [ URLQueryItem(name: "request_id", value: requestID) ]
var req = URLRequest(url: comp.url!)
req.setValue(pollSecret, forHTTPHeaderField: "X-Poll-Secret")
let (data, _) = try await URLSession.shared.data(for: req)
if gen != loginGeneration { return } //
let st = try JSONDecoder().decode(QRStatus.self, from: data)
switch st.status
{
case "approved":
if let token = st.access_token, let user = st.user
{
// App iOS/ full访 Web/PWA
// guest /api/me scope
// guest
let scope = await fetchScope(token: token)
if gen != loginGeneration { return }
if scope != "full"
{
statusText = t("ios.login.needFull")
qrExpired = true
return
}
session = Session(accessToken: token,
refreshToken: st.refresh_token ?? "",
user: user,
deviceName: st.device_name ?? deviceName(),
deviceId: st.device_id ?? "",
scope: "full")
persist()
}
return
case "denied", "expired":
statusText = t("ios.login.expired")
qrExpired = true
return
default:
continue // pending 25s
}
}
}
// access token /api/me full / guest full
//
private func fetchScope(token: String) async -> String
{
var req = URLRequest(url: URL(string: "\(apiBase)/api/me")!)
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
guard let (data, _) = try? await URLSession.shared.data(for: req),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let scope = obj["scope"] as? String
else { return "" }
return scope
}
private func deviceName() -> String
{
return DeviceNameStore.value
}
}
+53
View File
@@ -0,0 +1,53 @@
import Foundation
import Security
// Keychain app generic-password access groupKeychain **
// app / extension ADP entitlement app entitlement
// ios/PLAN.md §9
//
// access_token + user使 app /
// kSecAttrAccessibleAfterFirstUnlock
//
enum Keychain
{
static func save(_ data: Data, service: String, account: String)
{
let base: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
SecItemDelete(base as CFDictionary) // duplicate item
var attrs = base
attrs[kSecValueData as String] = data
attrs[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
SecItemAdd(attrs as CFDictionary, nil)
}
static func load(service: String, account: String) -> Data?
{
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var out: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &out) == errSecSuccess,
let data = out as? Data
else { return nil }
return data
}
static func delete(service: String, account: String)
{
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
SecItemDelete(query as CFDictionary)
}
}
+86
View File
@@ -0,0 +1,86 @@
import CoreImage.CIFilterBuiltins
import SwiftUI
import UIKit
// + cdrop AuthManager
struct LoginView: View
{
@Environment(AuthManager.self) private var auth
var body: some View
{
VStack(spacing: 24)
{
Spacer()
Text(t("app.brand"))
.font(.largeTitle)
.bold()
qrArea
Text(auth.statusText)
.font(.callout)
.foregroundStyle(auth.qrExpired ? Color.red : Color.secondary)
refreshButton
Text(t("ios.login.guide"))
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
Spacer()
}
.padding()
.tint(.cdropAccent)
.task { await auth.startQRLogin() }
}
// glassProminent
// @ViewBuilder
@ViewBuilder
private var refreshButton: some View
{
let button = Button { Task { await auth.startQRLogin() } }
label: { Label(t("ios.login.refresh"), systemImage: "arrow.clockwise") }
if auth.qrExpired { button.buttonStyle(.glassProminent) }
else { button.buttonStyle(.glass) }
}
@ViewBuilder
private var qrArea: some View
{
if let payload = auth.qrPayload, let img = qrImage(payload)
{
Image(uiImage: img)
.interpolation(.none)
.resizable()
.frame(width: 220, height: 220)
.padding(16)
.background(.white, in: RoundedRectangle(cornerRadius: 16))
.opacity(auth.qrExpired ? 0.25 : 1) //
.overlay
{
if auth.qrExpired
{
Image(systemName: "arrow.clockwise.circle.fill")
.font(.system(size: 56))
.foregroundStyle(.secondary)
}
}
}
else
{
ProgressView()
.frame(width: 220, height: 220)
}
}
// CoreImage payload /link?r=..&c=.. URL
private func qrImage(_ string: String) -> UIImage?
{
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(string.utf8)
guard let output = filter.outputImage else { return nil }
let scaled = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
let ctx = CIContext()
guard let cg = ctx.createCGImage(scaled, from: scaled.extent) else { return nil }
return UIImage(cgImage: cg)
}
}
+51
View File
@@ -0,0 +1,51 @@
import SwiftUI
// cdrop iOS arch A ios/PLAN.md SwiftUI +
// WKWebView EngineController AuthManager
@main
struct CDropApp: App
{
@State private var auth = AuthManager()
@State private var engine = EngineController()
var body: some Scene
{
WindowGroup
{
AppRoot(auth: auth, engine: engine)
}
}
}
//
struct AppRoot: View
{
let auth: AuthManager
let engine: EngineController
var body: some View
{
Group
{
if auth.session != nil
{
RootView()
.environment(engine)
.environment(auth)
}
else
{
LoginView()
.environment(auth)
}
}
.onAppear
{
engine.auth = auth
if ProcessInfo.processInfo.environment["CDROP_DEBUG_SESSION"] == "1"
{
auth.debugSession()
}
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import Foundation
import UIKit
// UserDefaults qr/start device_name
// (userID, deviceName) presence线
// internal/httpapi/qr.go
enum DeviceNameStore
{
private static let key = "cdrop.deviceName"
static var value: String
{
get
{
if let v = UserDefaults.standard.string(forKey: key), !v.isEmpty { return v }
let sys = UIDevice.current.name
return sys.isEmpty ? "iPhone" : sys
}
set
{
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
UserDefaults.standard.set(trimmed.isEmpty ? nil : trimmed, forKey: key)
}
}
}
@@ -0,0 +1,121 @@
import Foundation
// desktop/platform/download.go app
// DocumentssaveWhole begin / append / finalizeabort
// 线 EngineController 线 IO
//
final class DownloadManager
{
private struct Active
{
let handle: FileHandle
let tmpURL: URL
}
private var active: [String: Active] = [:]
private func downloadDir() -> URL
{
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
}
// Documents .part /
// / / Files app
func receivedFiles() -> [URL]
{
let urls = (try? FileManager.default.contentsOfDirectory(
at: downloadDir(),
includingPropertiesForKeys: [ .contentModificationDateKey ],
options: [ .skipsHiddenFiles ])) ?? []
return urls
.filter { $0.pathExtension != "part" && !$0.lastPathComponent.hasPrefix(".cdrop-") }
.sorted { a, b in modDate(a) > modDate(b) }
}
func deleteReceivedFile(_ url: URL)
{
try? FileManager.default.removeItem(at: url)
}
private func modDate(_ url: URL) -> Date
{
return (try? url.resourceValues(forKeys: [ .contentModificationDateKey ]))?
.contentModificationDate ?? .distantPast
}
// saveDownload
func saveWhole(name: String, data: Data) throws -> String
{
let url = uniqueURL(for: sanitize(name))
try data.write(to: url)
return url.path
}
func begin(sessionId: String) throws
{
cleanup(sessionId) // re-begin
let tmp = downloadDir().appendingPathComponent(".cdrop-\(sanitize(sessionId)).part")
FileManager.default.createFile(atPath: tmp.path, contents: nil)
let handle = try FileHandle(forWritingTo: tmp)
active[sessionId] = Active(handle: handle, tmpURL: tmp)
}
func append(sessionId: String, data: Data) throws
{
guard let a = active[sessionId] else { throw Err.unknownSession }
try a.handle.write(contentsOf: data)
}
func finalize(sessionId: String, name: String) throws -> String
{
guard let a = active[sessionId] else { throw Err.unknownSession }
try a.handle.close()
active[sessionId] = nil
let dest = uniqueURL(for: sanitize(name))
try FileManager.default.moveItem(at: a.tmpURL, to: dest)
return dest.path
}
func abort(sessionId: String)
{
cleanup(sessionId)
}
private func cleanup(_ sessionId: String)
{
guard let a = active[sessionId] else { return }
try? a.handle.close()
try? FileManager.default.removeItem(at: a.tmpURL)
active[sessionId] = nil
}
// (1) (2) uniquePath
private func uniqueURL(for name: String) -> URL
{
let dir = downloadDir()
let base = (name as NSString).deletingPathExtension
let ext = (name as NSString).pathExtension
var candidate = dir.appendingPathComponent(name)
var i = 1
while FileManager.default.fileExists(atPath: candidate.path)
{
let next = ext.isEmpty ? "\(base) (\(i))" : "\(base) (\(i)).\(ext)"
candidate = dir.appendingPathComponent(next)
i += 1
}
return candidate
}
// sanitize穿 sanitizeFileName
private func sanitize(_ name: String) -> String
{
let last = (name as NSString).lastPathComponent
let cleaned = last.replacingOccurrences(of: "/", with: "_")
return cleaned.isEmpty ? "download" : cleaned
}
enum Err: Error
{
case unknownSession
}
}
@@ -0,0 +1,537 @@
import Foundation
import Observation
import UIKit
import WebKit
// presence web store DeviceInfolastSeen ms epoch
// Decodable JS WKWebView bool NSNumber
// / JSONDecoder
// web hub.ts handlePresence
struct DeviceItem: Identifiable, Equatable
{
let name: String
let type: String
let online: Bool
let lastSeen: Double
var id: String { name }
}
// web engine/main.ts toWire ice*
// P2P TURN
struct TransferItem: Identifiable, Equatable
{
let sessionId: String
let direction: String
let fileName: String
let fileSize: Int
let state: String
let mode: String?
let peerName: String
let phase: String?
let bytesTransferred: Int?
let bytesPerSec: Double?
let iceConn: String?
let iceLocal: String?
let iceRemote: String?
var id: String { sessionId }
}
// JS web/src/net/ios.tsios/PLAN.md §2
// "cdropEngine" WKScriptMessageHandler { id, method, payload }RPC
// { notify, payload }RPC evaluateJavaScript
// window.__cdropEngineResolve(id, ok, value) window.__cdropEngineEvent
@MainActor
@Observable
final class EngineController: NSObject
{
// SwiftUI
var status: String = t("ios.engine.disconnected")
var deviceName: String = ""
// UI demo
var devices: [DeviceItem] = [] // 线 / 线 presence presence
var transfers: [TransferItem] = [] // transfers
var history: [TransferItem] = [] // transferDone 30
// SSE / + presence +
// warn/error 401
var hubConnected = false
var hubReconnecting = false
var presenceCount = 0
var lastEngineLog = ""
// / /
var clipboardStatus = ""
var deviceActionStatus = ""
// WebRTC origin PLAN R-iOS-3线
// https CDROP_ENGINE_URL http://localhostlocalhost
// WebRTC Windows loopback
private var engineURL: URL
{
if let s = ProcessInfo.processInfo.environment["CDROP_ENGINE_URL"], let u = URL(string: s)
{
return u
}
return URL(string: "https://drop.commilitia.net/engine.html")!
}
// boot app AppRoot.onAppear
var auth: AuthManager?
private var webView: WKWebView?
private let downloads = DownloadManager()
// id URL cdrop-file://<id>
// WKURLSchemeHandler
private var outgoing: [String: URL] = [:]
override init()
{
super.init()
}
// makeWebView WebView __CDROP_BOOT__device_type:"ios"
// cdrop-file scheme JS
func makeWebView() -> WKWebView
{
if let existing = webView { return existing }
let ucc = WKUserContentController()
ucc.add(self, name: "cdropEngine")
// boot nulldevice_type ios
// api_base /api prod URL
let debug = ProcessInfo.processInfo.environment["CDROP_DEBUG_SESSION"] == "1"
let boot = "window.__CDROP_BOOT__ = { session: \(sessionJSON()), "
+ "device_name: \(jsString(currentDeviceName())), "
+ "api_base: \(jsString(apiBaseForBoot())), device_type: \"ios\", debug: \(debug) };"
ucc.addUserScript(WKUserScript(source: boot,
injectionTime: .atDocumentStart,
forMainFrameOnly: true))
let cfg = WKWebViewConfiguration()
cfg.userContentController = ucc
cfg.setURLSchemeHandler(self, forURLScheme: "cdrop-file")
// Auth Broker refresh_tokenPOST /api/auth/refresh
// broker cdrop_session cookie URLSession cookie WebView
let wv = WKWebView(frame: .zero, configuration: cfg)
// engine.html WebView
// / handler engine.html
// bundle
var request = URLRequest(url: engineURL)
request.cachePolicy = .reloadIgnoringLocalCacheData
wv.load(request)
webView = wv
deviceName = currentDeviceName()
return wv
}
// SSE WebView UI makeWebView
// boot auth.session
func reset()
{
sendCommand("shutdown", payload: [:])
webView = nil
devices = []
transfers = []
history = []
status = t("ios.engine.disconnected")
deviceName = ""
}
// Documents Files app /
func receivedFiles() -> [URL] { downloads.receivedFiles() }
func deleteReceivedFile(_ url: URL) { downloads.deleteReceivedFile(url) }
//
// name (1)退
func receivedFile(matching name: String) -> URL?
{
let files = downloads.receivedFiles()
if let exact = files.first(where: { $0.lastPathComponent == name }) { return exact }
let base = (name as NSString).deletingPathExtension
return files.first { $0.lastPathComponent.hasPrefix(base) }
}
// 线 /
func sendableDevices() -> [DeviceItem]
{
return devices.filter { $0.online && $0.name != deviceName }
}
// UIPasteboard
func uploadClipboard()
{
let content = UIPasteboard.general.string ?? ""
guard !content.isEmpty else { clipboardStatus = t("ios.clipboard.empty"); return }
clipboardStatus = t("ios.clipboard.uploading")
sendCommand("clipboardUpload", payload: [ "content": content ])
}
// UIPasteboard handleNotify "clipboard"
func pullClipboard()
{
clipboardStatus = t("ios.clipboard.pulling")
sendCommand("clipboardPull", payload: [:])
}
// /
func revokeDevice(_ name: String)
{
deviceActionStatus = ""
sendCommand("revokeDevice", payload: [ "name": name ])
}
// JSwindow.__cdropEngineEvent
func sendCommand(_ name: String, payload: [String: Any])
{
guard let json = jsonString(payload) else { return }
webView?.evaluateJavaScript("window.__cdropEngineEvent(\(jsString(name)), \(json));")
}
// sendFile cdrop-file://<id> +
func sendFile(to target: String, fileURL: URL)
{
let ref = stageOutgoingFile(fileURL)
var size = 0
if let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
let n = attrs[.size] as? Int { size = n }
sendCommand("sendFile", payload: [
"target": target,
"url": ref,
"name": fileURL.lastPathComponent,
"size": size,
])
}
// cdrop-file://<id> URL
// start...Access
private func stageOutgoingFile(_ url: URL) -> String
{
let id = UUID().uuidString
_ = url.startAccessingSecurityScopedResource()
outgoing[id] = url
return "cdrop-file://\(id)"
}
}
// MARK: - JS RPC +
extension EngineController: WKScriptMessageHandler
{
func userContentController(_ uc: WKUserContentController, didReceive message: WKScriptMessage)
{
guard let body = message.body as? [String: Any] else { return }
if let id = body["id"] as? Int
{
handleRPC(id: id,
method: body["method"] as? String ?? "",
payload: body["payload"] as? [String: Any] ?? [:])
return
}
if let notify = body["notify"] as? String
{
handleNotify(notify, payload: body["payload"])
}
}
private func handleRPC(id: Int, method: String, payload: [String: Any])
{
do
{
switch method
{
case "saveDownload":
let name = payload["name"] as? String ?? "download"
let path = try downloads.saveWhole(name: name, data: decodeBase64(payload["data"]))
resolve(id: id, ok: true, value: path)
case "beginDownload":
try downloads.begin(sessionId: payload["sessionId"] as? String ?? "")
resolve(id: id, ok: true, value: nil)
case "appendDownload":
try downloads.append(sessionId: payload["sessionId"] as? String ?? "",
data: decodeBase64(payload["data"]))
resolve(id: id, ok: true, value: nil)
case "finalizeDownload":
let path = try downloads.finalize(sessionId: payload["sessionId"] as? String ?? "",
name: payload["name"] as? String ?? "download")
resolve(id: id, ok: true, value: path)
case "abortDownload":
downloads.abort(sessionId: payload["sessionId"] as? String ?? "")
resolve(id: id, ok: true, value: nil)
default:
resolve(id: id, ok: false, value: "unknown method: \(method)")
}
}
catch
{
resolve(id: id, ok: false, value: "\(error)")
}
}
private func handleNotify(_ name: String, payload: Any?)
{
switch name
{
case "ready":
status = t("ios.engine.ready")
case "probe":
// WebRTC-in-WKWebView arch A R-iOS-3
if let p = payload as? [String: Any]
{
let secure = (p["secure"] as? Bool) ?? false
let rtc = (p["rtc"] as? String) ?? "?"
let host = (p["host"] as? Int) ?? 0
let srflx = (p["srflx"] as? Int) ?? 0
status = "安全:\(secure) RTC:\(rtc) host:\(host) srflx:\(srflx)"
}
case "presence":
// JS NSArray`as? [[String: Any]]`
// Foundation 便 [Any]
// as? [String: Any]
if let p = payload as? [String: Any], let raw = p["devices"] as? [Any]
{
devices = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseDevice) }
}
case "transfers":
if let p = payload as? [String: Any], let raw = p["active"] as? [Any]
{
transfers = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseTransfer) }
}
case "transferDone":
// sessionId
if let p = payload as? [String: Any], let item = Self.parseTransfer(p)
{
transfers.removeAll { $0.sessionId == item.sessionId }
history.removeAll { $0.sessionId == item.sessionId }
history.insert(item, at: 0)
if history.count > 30 { history.removeLast(history.count - 30) }
}
case "sendStarted":
// transfers
break
case "hubState":
if let p = payload as? [String: Any]
{
hubConnected = Self.boolOf(p["connected"])
hubReconnecting = Self.boolOf(p["reconnecting"])
presenceCount = Self.intOf(p["presenceCount"])
}
case "log":
if let p = payload as? [String: Any], let msg = p["msg"] as? String
{
lastEngineLog = msg
}
case "clipboard":
// UIPasteboard
if let p = payload as? [String: Any], let content = p["content"] as? String, !content.isEmpty
{
UIPasteboard.general.string = content
clipboardStatus = t("ios.clipboard.pulled")
}
else
{
clipboardStatus = t("ios.clipboard.empty")
}
case "clipboardUploaded":
clipboardStatus = t("ios.clipboard.uploaded")
case "deviceRevoked":
deviceActionStatus = t("ios.devices.revoked")
case "error":
// error /
if let p = payload as? [String: Any]
{
let stage = p["stage"] as? String ?? ""
let msg = p["message"] as? String ?? ""
if stage == "clipboard" { clipboardStatus = t("ios.clipboard.failed") }
else if stage == "revoke"
{
deviceActionStatus = msg == "step_up_required"
? t("ios.devices.revokeStepUp") : t("ios.devices.revokeFailed")
}
else { status = "错误:\(msg)" }
}
case "sessionRotated":
// broker refresh Keychain refresh
if let p = payload as? [String: Any],
let access = p["access_token"] as? String,
let refresh = p["refresh_token"] as? String
{
auth?.updateSession(accessToken: access, refreshToken: refresh)
}
case "authExpired":
// refresh / Keychain
auth?.logout()
reset()
default:
break
}
}
private func resolve(id: Int, ok: Bool, value: Any?)
{
webView?.evaluateJavaScript("window.__cdropEngineResolve(\(id), \(ok), \(jsonValue(value)));")
}
}
// MARK: - schemecdrop-file
extension EngineController: WKURLSchemeHandler
{
// cdrop-file://<id> JS fetch
// PLAN §2 / R-iOS-4v1 /
// didReceive jetsam R-iOS-4
func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask)
{
guard let url = urlSchemeTask.request.url, let id = url.host, let fileURL = outgoing[id]
else
{
respond(urlSchemeTask, status: 404)
return
}
guard let data = try? Data(contentsOf: fileURL)
else
{
respond(urlSchemeTask, status: 404)
return
}
let resp = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1",
headerFields: [
"Content-Type": "application/octet-stream",
"Content-Length": "\(data.count)",
])!
urlSchemeTask.didReceive(resp)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
}
func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask)
{
// v1
}
private func respond(_ task: any WKURLSchemeTask, status: Int)
{
guard let url = task.request.url,
let resp = HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: nil)
else { return }
task.didReceive(resp)
task.didFinish()
}
}
// MARK: - DeviceItem
extension EngineController
{
// WKWebView NSNumber / Int / Double / Bool
//
static func parseDevice(_ d: [String: Any]) -> DeviceItem?
{
guard let name = d["name"] as? String, let type = d["type"] as? String else { return nil }
return DeviceItem(name: name,
type: type,
online: boolOf(d["online"]),
lastSeen: doubleOf(d["lastSeen"]))
}
static func parseTransfer(_ t: [String: Any]) -> TransferItem?
{
guard let sessionId = t["sessionId"] as? String else { return nil }
let ice = t["ice"] as? [String: Any]
return TransferItem(sessionId: sessionId,
direction: t["direction"] as? String ?? "outgoing",
fileName: t["fileName"] as? String ?? "",
fileSize: intOf(t["fileSize"]),
state: t["state"] as? String ?? "PENDING",
mode: t["mode"] as? String,
peerName: t["peerName"] as? String ?? "",
phase: t["phase"] as? String,
bytesTransferred: t["bytesTransferred"].map { intOf($0) },
bytesPerSec: t["bytesPerSec"].map { doubleOf($0) },
iceConn: ice?["conn"] as? String,
iceLocal: ice?["local"] as? String,
iceRemote: ice?["remote"] as? String)
}
static func boolOf(_ v: Any?) -> Bool
{
if let b = v as? Bool { return b }
if let n = v as? NSNumber { return n.boolValue }
return false
}
static func intOf(_ v: Any?) -> Int
{
if let i = v as? Int { return i }
if let n = v as? NSNumber { return n.intValue }
if let d = v as? Double { return Int(d) }
return 0
}
static func doubleOf(_ v: Any?) -> Double
{
if let d = v as? Double { return d }
if let n = v as? NSNumber { return n.doubleValue }
if let i = v as? Int { return Double(i) }
return 0
}
}
// MARK: -
private extension EngineController
{
func decodeBase64(_ v: Any?) -> Data
{
guard let s = v as? String, let d = Data(base64Encoded: s) else { return Data() }
return d
}
func jsonString(_ obj: [String: Any]) -> String?
{
guard JSONSerialization.isValidJSONObject(obj),
let d = try? JSONSerialization.data(withJSONObject: obj),
let s = String(data: d, encoding: .utf8)
else { return nil }
return s
}
// JS JSON nil null
// JSONSerialization
func jsonValue(_ v: Any?) -> String
{
guard let v = v else { return "null" }
guard let d = try? JSONSerialization.data(withJSONObject: [v]),
let s = String(data: d, encoding: .utf8)
else { return "null" }
return String(s.dropFirst().dropLast())
}
func jsString(_ s: String) -> String
{
return jsonValue(s)
}
// boot JSON nullapi_base
func sessionJSON() -> String
{
guard let s = auth?.session else { return "null" }
return "{ access_token: \(jsString(s.accessToken)), "
+ "refresh_token: \(jsString(s.refreshToken)), "
+ "user: { id: \(jsString(s.user.id)), name: \(jsString(s.user.name)) } }"
}
func currentDeviceName() -> String
{
return auth?.session?.deviceName ?? "iPhone"
}
func apiBaseForBoot() -> String
{
return ProcessInfo.processInfo.environment["CDROP_API_BASE"] ?? ""
}
}
@@ -0,0 +1,19 @@
import SwiftUI
import WebKit
// EngineController WKWebView SwiftUI UI
// controller JS ios/PLAN.md arch A
struct EngineWebView: UIViewRepresentable
{
let controller: EngineController
func makeUIView(context: Context) -> WKWebView
{
return controller.makeWebView()
}
func updateUIView(_ uiView: WKWebView, context: Context)
{
//
}
}
+66
View File
@@ -0,0 +1,66 @@
import Foundation
// i18n app bundle <locale>.json web web/scripts/emit-i18n.mjs
// ios/PLAN.md §5退 zh-CN JSON 退
// zh-CN loader locale
enum I18n
{
private static let dict: [String: String] = load()
static func t(_ key: String) -> String
{
return dict[key] ?? key
}
// web t(key, vars) {{name}}catalog
// settings.lastSeen {{time}} {{...}} UI
static func t(_ key: String, _ args: [String: String]) -> String
{
var s = dict[key] ?? key
for (name, value) in args
{
s = s.replacingOccurrences(of: "{{\(name)}}", with: value)
}
return s
}
private static func load() -> [String: String]
{
guard let url = resourceURL(preferredLocale()) ?? resourceURL("zh-CN"),
let data = try? Data(contentsOf: url),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: String]
else { return [:] }
return obj
}
private static func resourceURL(_ loc: String) -> URL?
{
return Bundle.main.url(forResource: loc, withExtension: "json", subdirectory: "i18n")
?? Bundle.main.url(forResource: loc, withExtension: "json")
}
private static func preferredLocale() -> String
{
for lang in Locale.preferredLanguages
{
if lang.hasPrefix("zh-Hant") || lang.hasPrefix("zh-TW") || lang.hasPrefix("zh-HK")
{
return "zh-TW"
}
if lang.hasPrefix("zh") { return "zh-CN" }
if lang.hasPrefix("en") { return "en-US" }
}
return "zh-CN"
}
}
// web t()
func t(_ key: String) -> String
{
return I18n.t(key)
}
func t(_ key: String, _ args: [String: String]) -> String
{
return I18n.t(key, args)
}
+49
View File
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>zh-Hans</string>
<key>CFBundleDisplayName</key>
<string>Commilitia Drop</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>zh-Hans</string>
<string>zh-Hant</string>
<string>en</string>
</array>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_cdrop._tcp</string>
</array>
<key>NSCameraUsageDescription</key>
<string>Commilitia Drop 使用相机扫描二维码登录新设备。</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Commilitia Drop 需要访问本地网络以发现同内网设备并建立直连传输。</string>
<key>UIFileSharingEnabled</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
</dict>
</plist>
+72
View File
@@ -0,0 +1,72 @@
import QuickLook
import SwiftUI
// URL Identifiablesheet(item:)
struct PreviewItem: Identifiable
{
let id = UUID()
let url: URL
}
// sheetQuickLookView NavigationStack + toolbar
// @Environment(\.dismiss) sheet item
struct FilePreviewSheet: View
{
@Environment(\.dismiss) private var dismiss
let item: PreviewItem
var body: some View
{
NavigationStack
{
QuickLookView(url: item.url)
.ignoresSafeArea()
.navigationTitle(item.url.lastPathComponent)
.navigationBarTitleDisplayMode(.inline)
.toolbar
{
ToolbarItem(placement: .topBarTrailing)
{
Button(t("ios.files.done")) { dismiss() }
}
}
}
}
}
// QLPreviewController SwiftUI SwiftUI
// NavigationStack toolbarQL chrome
// Done UINavigationController Done
// QL
struct QuickLookView: UIViewControllerRepresentable
{
let url: URL
func makeUIViewController(context: Context) -> QLPreviewController
{
let controller = QLPreviewController()
controller.dataSource = context.coordinator
return controller
}
func updateUIViewController(_ controller: QLPreviewController, context: Context)
{
controller.reloadData()
}
func makeCoordinator() -> Coordinator { Coordinator(url: url) }
final class Coordinator: NSObject, QLPreviewControllerDataSource
{
let url: URL
init(url: URL) { self.url = url }
func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 }
func previewController(_ controller: QLPreviewController,
previewItemAt index: Int) -> QLPreviewItem
{
return url as NSURL
}
}
}
+358
View File
@@ -0,0 +1,358 @@
{
"app.brand": "Commilitia Drop",
"deviceType.browser": "Browser",
"deviceType.macos": "macOS client",
"deviceType.windows": "Windows client",
"deviceType.linux": "Linux client",
"deviceType.ios": "iOS client",
"deviceType.shortcut": "Shortcut",
"app.connected": "Online",
"app.connecting": "Connecting…",
"app.disconnectedTitle": "Reconnecting",
"app.disconnectedHint": "Lost connection to the server — retrying automatically. No need to refresh.",
"auth.sessionLost.title": "Signed out",
"auth.sessionLost.body": "This device's login was revoked or expired. Please sign in again.",
"nav.accountMenu": "Account menu",
"nav.thisDeviceLabel": "This device: ",
"nav.settings": "Settings",
"nav.signOut": "Sign out",
"nav.backToHome": "← Home",
"nav.language": "语言 / Language",
"nav.theme": "主题 / Theme",
"nav.theme.light": "Light",
"nav.theme.dark": "Dark",
"nav.theme.system": "Follow system",
"nav.themeToggle": "Toggle theme",
"home.greeting": "Hello, {{name}}",
"home.deviceList.title": "Devices",
"home.deviceList.onlineSuffix": "({{count}} online)",
"home.deviceList.showOffline": "Show offline ({{count}})",
"home.deviceList.hideOffline": "Hide offline",
"home.deviceList.empty": "No peer devices yet. Open the app in another tab with a different device name (Settings → Device name) to see it here.",
"home.tabs.file": "File",
"home.tabs.message": "Message",
"home.tabs.clipboard": "Clipboard",
"home.file.dropzone": "Drop a file here or click to browse",
"home.file.selectedSize": "{{name}} ({{size}})",
"home.file.rejected": "File rejected",
"home.cta.selectDeviceAndFile": "Select a device and a file",
"home.cta.selectFile": "Select a file",
"home.cta.selectDevice": "Select a device",
"home.cta.sendTo": "Send to {{name}}",
"home.message.placeholder": "Type a message to {{name}}…",
"home.message.placeholderEmpty": "Select a device first",
"home.message.hint": "Enter to send, Shift+Enter for newline. Max 4 KB.",
"home.message.title": "Messages",
"home.message.countSuffix": "({{count}})",
"home.message.clearAll": "Clear all",
"home.transfer.active": "Active",
"home.transfer.history": "Recent transfers",
"home.clipboard.cloudTitle": "Latest from cloud",
"home.clipboard.cloudFrom": "From {{name}} · {{time}}",
"home.clipboard.cloudFromSelf": "From this device · {{time}}",
"home.clipboard.cloudEmpty": "Nothing synced yet.",
"home.clipboard.copyToLocal": "Copy to local clipboard",
"home.clipboard.copyToLocalSuccess": "Copied to local clipboard",
"home.clipboard.uploadTitle": "Sync local clipboard",
"home.clipboard.uploadHint": "Plain text only, max 64 KB. Styles (bold / color) are stripped.",
"home.clipboard.uploadButton": "Upload local clipboard",
"home.clipboard.uploading": "Uploading…",
"home.clipboard.uploadEmpty": "Local clipboard is empty",
"home.clipboard.refresh": "Refresh",
"home.clipboard.clear": "Clear cloud",
"home.clipboard.clearConfirm": "Clear the cloud clipboard? All devices will see empty content.",
"home.clipboard.clearSuccess": "Cloud clipboard cleared",
"home.clipboard.hidden": "Content hidden — click Reveal to show",
"home.clipboard.reveal": "Reveal",
"home.clipboard.hide": "Hide",
"setup.title": "Name this device",
"setup.help": "The name is shown to your other devices. Avoid spaces, ≤32 chars.",
"setup.field": "Device name",
"setup.save": "Save and continue",
"login.title": "Sign in",
"login.dev.help": "Dev mode: pick any user identifier. Two tabs with the same identifier simulate two devices of one user.",
"login.dev.field": "Dev user id",
"login.dev.continue": "Continue",
"login.prod.help": "Sign in via Casdoor.",
"login.prod.button": "Sign in with Casdoor",
"oauth.failed": "Sign-in failed",
"oauth.signingIn": "Signing in…",
"oauth.missingParams": "Missing 'code' or 'state' query parameter",
"qr.entry.fromLogin": "Sign in with a QR code",
"qr.entry.scanNewDevice": "Scan to link a device",
"qr.scan.title": "Scan to link a device",
"qr.scan.subtitle": "Point the code shown on the other device at the frame. Approval stays on this signed-in device.",
"qr.scan.starting": "Starting the camera…",
"qr.scan.aiming": "Line the code up inside the frame",
"qr.scan.foreign": "That isn't a sign-in code for this site. Aim at the code from “Sign in with a QR code” on the new device.",
"qr.scan.noCamera": "This device has no camera available. Use a phone or tablet with a camera to scan instead.",
"qr.scan.denied": "Camera access was blocked. Allow the camera in your browser's site settings, then reopen this page.",
"qr.scan.error": "Couldn't open the camera. Try again, or scan from a device with a camera.",
"qr.scan.howto1": "On the new device, open “Sign in with a QR code” to show a code.",
"qr.scan.howto2": "Line that code up in the frame above — approval starts automatically.",
"qr.scan.back": "Back",
"qr.show.title": "Sign in with a QR code",
"qr.show.subtitle": "Name this device, then scan the code from a signed-in phone to let it into your account.",
"qr.show.nameHint": "Shown to your other devices. ASCII characters only.",
"qr.show.generate": "Generate code",
"qr.show.scanTitle": "Scan to approve",
"qr.show.howto1": "Open the camera or cdrop on another signed-in device.",
"qr.show.howto2": "Scan the code above and approve this device.",
"qr.show.waiting": "Waiting for approval…",
"qr.show.approved": "Approved — signing you in…",
"qr.show.expired": "This code has expired. Generate a new one and scan again.",
"qr.show.denied": "This request was declined. If that was you, generate a new code and try again.",
"qr.show.regenerate": "Generate a new code",
"qr.show.back": "Back to home",
"qr.show.backToName": "Back",
"qr.approve.title": "Approve a new device",
"qr.approve.subtitle": "Confirm this device may sign in to your account. Check the device and origin first.",
"qr.approve.loading": "Reading device details…",
"qr.approve.badLink": "This approval link is incomplete. Generate a new code on the device and scan it again.",
"qr.approve.signInFirst": "Sign in to your account first, then approve this device.",
"qr.approve.trustLabel": "Trust duration",
"qr.approve.trust.title": "Trust this device (7 days)",
"qr.approve.trust.hint": "Skip approval for 7 days, renewed on each use.",
"qr.approve.once.title": "Just this once (1 hour)",
"qr.approve.once.hint": "Signed in for 1 hour, then scan again.",
"qr.approve.scopeFull": "This device signs fully into your account for 7 days: it can send and receive files and messages, and manage your account and devices.",
"qr.approve.scopeGuest": "Signs in as a limited guest — it can send and receive files and messages, but can't change your account or approve other devices; expires after 1 hour.",
"qr.approve.nativeFull": "This is a native app device — it signs in with full access (trusted, valid for 7 days). Native apps don't support limited guest sessions.",
"qr.approve.trustContinue": "Trust & Continue",
"qr.approve.approve": "Approve sign-in",
"qr.approve.stepUp.notice": "For security, verify your identity before approving — the button below takes you through a fresh sign-in.",
"qr.approve.stepUp.button": "Verify and approve",
"qr.approve.stepUp.rejected": "Identity verification didn't pass or has expired. Verify again to confirm it's you, then approve.",
"qr.approve.deny": "Decline",
"qr.approve.doneApprovedTitle": "Approved",
"qr.approve.doneApproved": "This device is now linked to your account. You can send and receive files on it.",
"qr.approve.doneDeniedTitle": "Declined",
"qr.approve.doneDenied": "The request was declined. That device won't sign in to your account.",
"qr.approve.expiredTitle": "Code expired",
"qr.approve.expired": "This code is no longer valid. Generate a new one on the device and scan again.",
"qr.approve.errorTitle": "Something went wrong",
"qr.approve.error": "That didn't go through. Try again, or generate a new code on the device.",
"qr.approve.backHome": "Back to home",
"settings.title": "Settings",
"settings.currentDevice.title": "This device",
"settings.deviceName.field": "Device name",
"settings.deviceName.save": "Save",
"settings.deviceName.unchanged": "Name is unchanged",
"settings.deviceName.empty": "Name cannot be empty",
"settings.deviceName.asciiOnly": "Device name must use ASCII characters only (letters, numbers, symbols).",
"settings.deviceName.success": "Device name updated",
"settings.unregister.current": "Unregister this device",
"settings.unregister.currentHint": "Removes this device's registration and signs you out.",
"settings.unregister.currentConfirm": "Unregistering this device will remove its registration and sign you out. Continue?",
"settings.error.delete": "Action failed: {{message}}",
"settings.guest.badge": "Limited guest",
"settings.guest.title": "This device is a limited guest",
"settings.guest.body": "This device is signed in as a limited guest: it can send and receive files, messages, and clipboard, but cannot manage the account, remove devices, or authorize new ones. To get full access, sign in again on your main device.",
"settings.sessions.title": "Login sessions",
"settings.sessions.hint": "Every device currently signed in to this account — browser, desktop, and mobile clients; online ones are listed first. Signing a device out ends its session immediately; it must sign in again on that device to continue.",
"settings.sessions.guestNote": "Limited guests can't manage login sessions. To manage them, sign in fully on your main device.",
"settings.sessions.empty": "No other login sessions.",
"settings.sessions.loadError": "Failed to load login sessions.",
"settings.sessions.retry": "Retry",
"settings.sessions.current": "This device",
"settings.sessions.scopeFull": "Full",
"settings.sessions.scopeGuest": "Limited guest",
"settings.sessions.kind.oidc": "Account login",
"settings.sessions.kind.self": "Local account",
"settings.sessions.kind.guest": "Scanned guest",
"settings.sessions.lastUsed": "Last active {{time}}",
"settings.sessions.neverUsed": "Not active yet",
"settings.sessions.signOut": "Sign out",
"settings.sessions.signingOut": "Signing out…",
"settings.sessions.signOutCurrent": "Sign out this device",
"settings.sessions.confirmOther": "Sign out device \"{{name}}\"? Its session will be invalidated immediately and it must sign in again.",
"settings.sessions.confirmCurrent": "Signing out this device is the same as logging out. Continue?",
"settings.sessions.revoked": "Signed that device out",
"settings.sessions.stepUpRejected": "Identity verification failed or expired. Please try again.",
"settings.online": "Online",
"settings.offline": "Offline",
"settings.lastSeen": "Last seen {{time}}",
"settings.push.title": "Push notifications",
"settings.push.hint": "When this page is closed, new files, messages, and transfer results arrive as system notifications; while it's open, you'll see in-app alerts instead.",
"settings.push.enable": "Enable push",
"settings.push.enabled": "Enabled",
"settings.push.disable": "Turn off",
"settings.push.deniedHint": "Notifications are blocked by the browser. Allow them in this site's settings, then try again.",
"settings.push.denied": "Notification permission denied",
"settings.push.enableOk": "Push notifications enabled",
"settings.push.disableOk": "Push notifications turned off",
"settings.push.failed": "Couldn't enable push. Please try again.",
"notify.incoming.title": "Incoming file",
"notify.incoming.body": "{{sender}} is sending {{file}}",
"notify.transfer.doneTitle": "Transfer complete",
"notify.transfer.failedTitle": "Transfer failed",
"settings.account.title": "Account",
"settings.account.signOut": "Sign out",
"settings.desktop.title": "Desktop",
"settings.desktop.clipboardSync": "Auto-sync clipboard",
"settings.desktop.clipboardSyncHint": "Copies are uploaded to the cloud, and clipboard updates from your other devices are applied here.",
"settings.desktop.launchAtLogin": "Launch at login",
"settings.desktop.launchAtLoginHint": "Start cdrop in the background after you sign in (lives in the menu bar).",
"settings.desktop.downloadDir": "Download folder",
"settings.desktop.downloadDirHint": "Received files are saved here. Leave unset to use the system Downloads folder.",
"settings.desktop.downloadDirChoose": "Change…",
"settings.desktop.downloadDirReset": "Reset to default",
"settings.desktop.downloadDirPicker": "Choose download folder",
"settings.desktop.ttlNote": "For security, cloud clipboard content is cleared automatically after 5 minutes.",
"settings.shortcut.title": "iOS Shortcut",
"settings.shortcut.intro": "Issue a long-lived, clipboard-only, revocable token for the iOS Shortcuts app. Even if leaked it can only read/write the clipboard — nothing else.",
"settings.shortcut.labelField": "Label",
"settings.shortcut.labelPlaceholder": "e.g. My iPhone",
"settings.shortcut.issue": "Issue",
"settings.shortcut.empty": "No tokens issued yet.",
"settings.shortcut.loadError": "Failed to load tokens.",
"settings.shortcut.retry": "Retry",
"settings.shortcut.revoke": "Revoke",
"settings.shortcut.revoking": "Revoking…",
"settings.shortcut.revokeConfirm": "Revoking immediately disables any Shortcut using this token. Continue?",
"settings.shortcut.revoked": "Revoked",
"settings.shortcut.revokedToast": "Token revoked",
"settings.shortcut.expired": "Expired",
"settings.shortcut.expiresAt": "Expires {{date}}",
"settings.shortcut.lastUsed": "Last used {{time}}",
"settings.shortcut.neverUsed": "Never used",
"settings.shortcut.unavailable": "Shortcut tokens are not enabled on the server.",
"settings.shortcut.tooMany": "You have reached the active-token limit. Revoke an old one first.",
"settings.shortcut.issueError": "Failed: {{message}}",
"settings.shortcut.issuedTitle": "Token issued",
"settings.shortcut.onceWarning": "This token is shown only once. Copy it now — you won't be able to see it again after closing.",
"settings.shortcut.tokenLabel": "Token (Bearer)",
"settings.shortcut.copy": "Copy",
"settings.shortcut.copyFailed": "Copy failed — select and copy manually.",
"settings.shortcut.copied": "Copied",
"settings.shortcut.baseLabel": "Server address",
"settings.shortcut.guideTitle": "Set up on iOS (manual sync)",
"settings.shortcut.guideIntro": "Build two shortcuts in the iPhone Shortcuts app, filling in the server address and token above on first run:",
"settings.shortcut.guideDeviceName": "Add header X-Device-Name: your-device-name (ASCII only) to both shortcuts so it shows as one device in the list.",
"settings.shortcut.guidePullTitle": "Pull (cloud → local clipboard)",
"settings.shortcut.guidePull1": "'Get Contents of URL': set the URL to your-server-address + /api/clipboard, method GET, add header Authorization: Bearer your-token.",
"settings.shortcut.guidePull2": "'Get Dictionary Value': key content.",
"settings.shortcut.guidePull3": "If non-empty, 'Copy to Clipboard'; optionally add 'Show Notification'.",
"settings.shortcut.guidePushTitle": "Push (local clipboard → cloud)",
"settings.shortcut.guidePush1": "'Get Clipboard'; stop if empty.",
"settings.shortcut.guidePush2": "'Get Contents of URL': same URL, method PUT, headers Authorization: Bearer your-token and Content-Type: application/json, JSON body: {\"content\": clipboard, \"content_type\": \"text/plain\"}.",
"settings.shortcut.guidePush3": "Optionally add 'Show Notification'.",
"settings.shortcut.guideToleranceNote": "A single run may fail on a flaky network — just retry. The automatic polling version (next phase) retries silently in the background.",
"settings.shortcut.done": "Done",
"transfer.savedTo": "Saved to {{path}}",
"transfer.saveFailed": "Failed to save file: {{error}}",
"transfer.action.cancel": "Cancel",
"transfer.action.delete": "Delete",
"transfer.action.relayNow": "Relay now",
"transfer.action.details": "Details",
"transfer.state.PENDING": "Pending",
"transfer.state.ACCEPTED": "Accepted",
"transfer.state.P2P_ACTIVE": "Transferring",
"transfer.state.RELAY_ACTIVE": "Relaying",
"transfer.state.DONE": "Done",
"transfer.state.FAILED": "Failed",
"transfer.state.CANCELLED": "Cancelled",
"transfer.mode.p2p": "P2P",
"transfer.mode.relay": "Relay",
"transfer.phase.initializing": "Initializing…",
"transfer.phase.waiting_accept": "Waiting for the other side to accept…",
"transfer.phase.ice_gathering": "Gathering network candidates…",
"transfer.phase.ice_checking": "Establishing direct tunnel…",
"transfer.phase.ice_connected": "Tunnel established",
"transfer.phase.dc_open": "Channel open",
"transfer.phase.transferring": "Transferring",
"transfer.phase.completing": "Finalizing…",
"transfer.phase.fallback_pending": "Falling back to relay…",
"transfer.phase.relay_uploading": "Uploading via relay",
"transfer.phase.relay_downloading": "Downloading via relay",
"transfer.stalled": "Looks stalled ({{seconds}}s without progress)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/s",
"transfer.debug.toggle": "Debug",
"transfer.debug.iceGathering": "Gathering",
"transfer.debug.iceConnection": "Connection",
"transfer.debug.candidatesLocal": "Local candidates",
"transfer.debug.candidatesRemote": "Remote candidates",
"transfer.debug.selectedPair": "Selected pair",
"transfer.debug.noPair": "No nominated pair yet",
"transfer.debug.candidatesLine": "host {{host}} · mDNS {{mdns}} · srflx {{srflx}} · prflx {{prflx}} · relay {{relay}}",
"transfer.debug.dataChannel": "Data channel",
"transfer.debug.dcLine": "{{state}} · buffered {{buffered}}",
"time.justNow": "just now",
"time.secondsAgo": "{{n}}s ago",
"time.minutesAgo": "{{n}}m ago",
"time.hoursAgo": "{{n}}h ago",
"common.dismiss": "Dismiss",
"common.cancel": "Cancel",
"common.confirm": "Confirm",
"errors.messageEmpty": "Message is empty",
"errors.messageOverflow": "Message exceeds 4 KB; send a file instead",
"errors.noReceiver": "No receiver selected",
"errors.devTokenMissing": "Dev mode: VITE_CDROP_DEV_TOKEN is not set in .env.local",
"errors.noAccessToken": "No access token; user must log in",
"errors.clipboardUnavailable": "Browser clipboard API unavailable (requires HTTPS + permission)",
"errors.clipboardOverflow": "Content too large; exceeds {{max}} byte limit",
"errors.clipboardWriteFailed": "Failed to write to local clipboard: {{message}}",
"ios.tab.transfer": "Transfers",
"ios.tab.devices": "Devices",
"ios.tab.settings": "Settings",
"ios.login.generating": "Generating code…",
"ios.login.waiting": "Waiting for approval",
"ios.login.guide": "Scan to approve from another signed-in device",
"ios.login.expired": "Code expired — please try again",
"ios.login.failed": "Sign-in failed — please try again",
"ios.login.refresh": "Refresh QR Code",
"ios.login.needFull": "This device needs full access — choose \"Trust this device\" when approving.",
"ios.send.pickDevice": "Choose a device",
"ios.transfer.sending": "Sending",
"ios.settings.engine": "Engine",
"ios.settings.status": "Status",
"ios.settings.device": "Device",
"ios.settings.clipboard": "Clipboard",
"ios.settings.clipboardNote": "Upload pushes this device's clipboard to your others; Pull writes the latest cloud clipboard.",
"ios.clipboard.upload": "Upload Clipboard",
"ios.clipboard.pull": "Pull Clipboard",
"ios.clipboard.uploading": "Uploading…",
"ios.clipboard.pulling": "Pulling…",
"ios.clipboard.uploaded": "Uploaded to cloud clipboard",
"ios.clipboard.pulled": "Written to clipboard",
"ios.clipboard.empty": "Clipboard is empty",
"ios.clipboard.failed": "Clipboard sync failed",
"ios.devices.remove": "Remove Device",
"ios.devices.thisDevice": "This device",
"ios.devices.revoked": "Device removed",
"ios.devices.revokeFailed": "Remove failed",
"ios.devices.revokeStepUp": "Re-verify on the web to remove this device",
"ios.engine.disconnected": "Disconnected",
"ios.engine.ready": "Ready",
"ios.transfer.empty": "No transfers yet",
"ios.transfer.incoming": "Receive",
"ios.transfer.outgoing": "Send",
"ios.send.noDevices": "No online devices to send to",
"ios.devices.empty": "No devices yet",
"ios.detail.title": "Transfer Details",
"ios.detail.direction": "Direction",
"ios.detail.peer": "Peer",
"ios.detail.state": "State",
"ios.detail.phase": "Phase",
"ios.detail.channel": "Channel",
"ios.detail.size": "Size",
"ios.detail.progress": "Progress",
"ios.detail.speed": "Speed",
"ios.settings.account": "Account",
"ios.settings.user": "User",
"ios.settings.logout": "Sign Out",
"ios.settings.deviceName": "Device Name",
"ios.settings.deviceNameNote": "Renaming takes effect on next sign-in",
"ios.settings.deviceCount": "Known Devices",
"ios.settings.signaling": "Signaling",
"ios.settings.presenceEvents": "Presence Events",
"ios.settings.reconnecting": "Reconnecting",
"ios.settings.lastLog": "Last Log",
"ios.tab.files": "Files",
"ios.files.title": "Received Files",
"ios.files.empty": "No files received yet",
"ios.files.share": "Share",
"ios.files.done": "Done",
"ios.detail.openFile": "Open File"
}
+358
View File
@@ -0,0 +1,358 @@
{
"app.brand": "Commilitia Drop",
"deviceType.browser": "浏览器",
"deviceType.macos": "macOS 客户端",
"deviceType.windows": "Windows 客户端",
"deviceType.linux": "Linux 客户端",
"deviceType.ios": "iOS 客户端",
"deviceType.shortcut": "快捷指令",
"app.connected": "已连接",
"app.connecting": "正在连接…",
"app.disconnectedTitle": "正在重连",
"app.disconnectedHint": "与服务器的连接已断开,正在自动重连,无需刷新页面。",
"auth.sessionLost.title": "登录已失效",
"auth.sessionLost.body": "此设备的登录凭据已被注销或过期,请重新登录。",
"nav.accountMenu": "账号菜单",
"nav.thisDeviceLabel": "本机:",
"nav.settings": "设置",
"nav.signOut": "退出登录",
"nav.backToHome": "← 主页",
"nav.language": "语言 / Language",
"nav.theme": "主题 / Theme",
"nav.theme.light": "浅色",
"nav.theme.dark": "深色",
"nav.theme.system": "跟随系统",
"nav.themeToggle": "切换主题",
"home.greeting": "你好,{{name}}",
"home.deviceList.title": "设备",
"home.deviceList.onlineSuffix": "{{count}} 台在线)",
"home.deviceList.showOffline": "显示离线设备({{count}}",
"home.deviceList.hideOffline": "隐藏离线设备",
"home.deviceList.empty": "暂无其他设备。在另一标签页或设备上以相同账户登录,并设置一个不同的设备名(设置 → 设备名称),即可在此处看到它。",
"home.tabs.file": "文件",
"home.tabs.message": "消息",
"home.tabs.clipboard": "剪贴板",
"home.file.dropzone": "拖放文件到此处或点击选择",
"home.file.selectedSize": "{{name}}{{size}}",
"home.file.rejected": "文件已拒绝",
"home.cta.selectDeviceAndFile": "请选择设备与文件",
"home.cta.selectFile": "请选择文件",
"home.cta.selectDevice": "请选择设备",
"home.cta.sendTo": "发送到 {{name}}",
"home.message.placeholder": "发送消息到 {{name}}…",
"home.message.placeholderEmpty": "请先选择设备",
"home.message.hint": "回车发送,Shift + 回车换行,最长4 KB。",
"home.message.title": "消息",
"home.message.countSuffix": "{{count}}",
"home.message.clearAll": "全部清除",
"home.transfer.active": "进行中",
"home.transfer.history": "最近的传输",
"home.clipboard.cloudTitle": "云端最新",
"home.clipboard.cloudFrom": "来自 {{name}} · {{time}}",
"home.clipboard.cloudFromSelf": "来自本机 · {{time}}",
"home.clipboard.cloudEmpty": "尚未同步任何内容。",
"home.clipboard.copyToLocal": "复制到本机剪贴板",
"home.clipboard.copyToLocalSuccess": "已复制到本机剪贴板",
"home.clipboard.uploadTitle": "同步本机剪贴板",
"home.clipboard.uploadHint": "仅纯文本,最大64 KB;样式(粗体/颜色等)会被剥除。",
"home.clipboard.uploadButton": "上传本机剪贴板",
"home.clipboard.uploading": "上传中…",
"home.clipboard.uploadEmpty": "本机剪贴板为空",
"home.clipboard.refresh": "刷新",
"home.clipboard.clear": "清空云端",
"home.clipboard.clearConfirm": "确认清空云端剪贴板?所有设备将看到空内容。",
"home.clipboard.clearSuccess": "已清空云端剪贴板",
"home.clipboard.hidden": "内容已隐藏 · 点击“显示”查看",
"home.clipboard.reveal": "显示",
"home.clipboard.hide": "隐藏",
"setup.title": "为本机命名",
"setup.help": "此名称会展示给你的其他设备。建议避免空格,长度不超过 32 字符。",
"setup.field": "设备名称",
"setup.save": "保存并继续",
"login.title": "登录",
"login.dev.help": "Dev模式:随意填写一个用户标识。同标识的两个标签页将模拟同一用户的两台设备。",
"login.dev.field": "Dev用户标识",
"login.dev.continue": "继续",
"login.prod.help": "通过Casdoor登录。",
"login.prod.button": "使用Casdoor登录",
"oauth.failed": "登录失败",
"oauth.signingIn": "正在登录…",
"oauth.missingParams": "缺少code或state查询参数",
"qr.entry.fromLogin": "扫码登录此设备",
"qr.entry.scanNewDevice": "扫码登录新设备",
"qr.scan.title": "扫码登录新设备",
"qr.scan.subtitle": "把另一台设备显示的二维码对准取景框。批准全程留在这台已登录设备上。",
"qr.scan.starting": "正在启动相机…",
"qr.scan.aiming": "把二维码对准取景框",
"qr.scan.foreign": "这不是本站的登录二维码。请对准新设备上“扫码登录此设备”生成的码。",
"qr.scan.noCamera": "这台设备没有可用的摄像头。请改用带相机的手机或平板扫码。",
"qr.scan.denied": "相机权限被拒绝。请在浏览器站点设置里允许使用相机,再重新打开此页。",
"qr.scan.error": "无法打开相机。请重试,或改用带相机的设备扫码。",
"qr.scan.howto1": "在新设备上打开“扫码登录此设备”,生成一张二维码。",
"qr.scan.howto2": "把那张二维码对准上方取景框,识别后会自动进入批准。",
"qr.scan.back": "返回",
"qr.show.title": "扫码登录此设备",
"qr.show.subtitle": "先给这台设备起个名字,再用已登录的手机扫码批准它接入你的账号。",
"qr.show.nameHint": "此名称会展示给你的其他设备,仅可使用 ASCII 字符。",
"qr.show.generate": "生成二维码",
"qr.show.scanTitle": "用手机扫码批准",
"qr.show.howto1": "在另一台已登录的设备上打开相机或 cdrop。",
"qr.show.howto2": "扫描上方二维码,按提示批准此设备。",
"qr.show.waiting": "等待批准…",
"qr.show.approved": "已批准,正在进入…",
"qr.show.expired": "二维码已过期。重新生成一张再扫。",
"qr.show.denied": "这次接入被拒绝。如确为你本人操作,重新生成二维码再试。",
"qr.show.regenerate": "重新生成二维码",
"qr.show.back": "返回主页",
"qr.show.backToName": "返回上一步",
"qr.approve.title": "批准新设备",
"qr.approve.subtitle": "确认让这台设备登录你的账号。请核对设备与来源无误。",
"qr.approve.loading": "正在读取设备信息…",
"qr.approve.badLink": "这个批准链接不完整。请在新设备上重新生成二维码再扫。",
"qr.approve.signInFirst": "先登录你的账号,再批准这台设备接入。",
"qr.approve.trustLabel": "信任时长",
"qr.approve.trust.title": "信任此设备(7 天)",
"qr.approve.trust.hint": "7 天内免再次批准,每次使用自动续期。",
"qr.approve.once.title": "仅此一次(1 小时)",
"qr.approve.once.hint": "登录有效 1 小时,到期需重新扫码。",
"qr.approve.scopeFull": "这台设备将完整登录你的账号、7 天内有效:可正常收发文件与消息,并能管理账号与设备。",
"qr.approve.scopeGuest": "以受限访客身份登录——可收发文件与消息,但不能更改账号、不能批准其他设备;1 小时后失效。",
"qr.approve.nativeFull": "这是一台原生应用设备,将以完整权限登录(信任此设备、7 天内有效)。原生应用不支持受限访客。",
"qr.approve.trustContinue": "信任并继续",
"qr.approve.approve": "批准登录",
"qr.approve.stepUp.notice": "为安全起见,批准前需先验证你的身份——点下方按钮会引导你重新登录一次。",
"qr.approve.stepUp.button": "验证并批准",
"qr.approve.stepUp.rejected": "身份验证未通过或已过期。请再验证一次,确认是你本人后即可批准。",
"qr.approve.deny": "拒绝",
"qr.approve.doneApprovedTitle": "已批准",
"qr.approve.doneApproved": "这台设备已接入你的账号,现在即可在它上面收发文件。",
"qr.approve.doneDeniedTitle": "已拒绝",
"qr.approve.doneDenied": "这次接入已被拒绝,那台设备不会登录你的账号。",
"qr.approve.expiredTitle": "二维码已过期",
"qr.approve.expired": "这张二维码已失效。请在新设备上重新生成再扫。",
"qr.approve.errorTitle": "出错了",
"qr.approve.error": "操作没能完成。请重试,或在新设备上重新生成二维码。",
"qr.approve.backHome": "返回主页",
"settings.title": "设置",
"settings.currentDevice.title": "当前设备",
"settings.deviceName.field": "设备名称",
"settings.deviceName.save": "保存",
"settings.deviceName.unchanged": "新名称与当前一致",
"settings.deviceName.empty": "名称不能为空",
"settings.deviceName.asciiOnly": "设备名称只能使用 ASCII 字符(英文字母、数字、符号)。",
"settings.deviceName.success": "设备名称已更新",
"settings.unregister.current": "注销当前设备",
"settings.unregister.currentHint": "将退出登录并移除此设备的注册。",
"settings.unregister.currentConfirm": "注销当前设备会移除此设备的注册并退出登录,确认继续?",
"settings.error.delete": "操作失败:{{message}}",
"settings.guest.badge": "受限访客",
"settings.guest.title": "这台设备是受限访客",
"settings.guest.body": "本设备以受限访客身份登录:可正常收发文件、消息与剪贴板,但不能管理账号、移除设备或授权新设备。需要完整权限,请在你的主设备上重新登录。",
"settings.sessions.title": "登录会话",
"settings.sessions.hint": "这里列出当前登录此账号的所有设备,含浏览器、桌面与移动客户端;在线的排在上方。登出某台设备会立即结束它的登录,需在该设备上重新登录才能继续使用。",
"settings.sessions.guestNote": "受限访客无法管理登录会话;如需管理,请在你的主设备上完整登录。",
"settings.sessions.empty": "暂无其他登录会话。",
"settings.sessions.loadError": "登录会话加载失败。",
"settings.sessions.retry": "重试",
"settings.sessions.current": "本机",
"settings.sessions.scopeFull": "完整",
"settings.sessions.scopeGuest": "受限访客",
"settings.sessions.kind.oidc": "账号登录",
"settings.sessions.kind.self": "本地账号",
"settings.sessions.kind.guest": "扫码访客",
"settings.sessions.lastUsed": "最近活跃 {{time}}",
"settings.sessions.neverUsed": "尚未活跃",
"settings.sessions.signOut": "登出",
"settings.sessions.signingOut": "正在登出…",
"settings.sessions.signOutCurrent": "登出本机",
"settings.sessions.confirmOther": "确认登出设备“{{name}}”?该会话将立即失效,需重新登录。",
"settings.sessions.confirmCurrent": "登出本机会等同于退出登录,确认继续?",
"settings.sessions.revoked": "已登出该设备",
"settings.sessions.stepUpRejected": "身份验证未通过或已过期,请再试一次。",
"settings.online": "在线",
"settings.offline": "离线",
"settings.lastSeen": "上次活跃 {{time}}",
"settings.push.title": "推送通知",
"settings.push.hint": "页面关闭时,新文件、消息和传输结果会以系统通知提醒;页面打开时仍走应用内提示。",
"settings.push.enable": "启用推送",
"settings.push.enabled": "已启用",
"settings.push.disable": "关闭推送",
"settings.push.deniedHint": "通知权限已被浏览器拒绝,请在浏览器的站点设置中允许后重试。",
"settings.push.denied": "通知权限被拒绝",
"settings.push.enableOk": "已启用推送通知",
"settings.push.disableOk": "已关闭推送通知",
"settings.push.failed": "启用推送失败,请重试",
"notify.incoming.title": "收到文件",
"notify.incoming.body": "{{sender}} 发来 {{file}}",
"notify.transfer.doneTitle": "传输完成",
"notify.transfer.failedTitle": "传输失败",
"settings.account.title": "账号",
"settings.account.signOut": "退出登录",
"settings.desktop.title": "桌面",
"settings.desktop.clipboardSync": "剪贴板自动同步",
"settings.desktop.clipboardSyncHint": "复制即上传到云端,并接收其他设备的剪贴板更新。",
"settings.desktop.launchAtLogin": "开机自启",
"settings.desktop.launchAtLoginHint": "登录系统后自动在后台启动 cdrop(菜单栏常驻)。",
"settings.desktop.downloadDir": "下载目录",
"settings.desktop.downloadDirHint": "接收到的文件保存到此目录;留空则使用系统下载目录。",
"settings.desktop.downloadDirChoose": "更改…",
"settings.desktop.downloadDirReset": "恢复默认",
"settings.desktop.downloadDirPicker": "选择下载目录",
"settings.desktop.ttlNote": "出于安全考虑,云端剪贴板内容会在 5 分钟后自动清除。",
"settings.shortcut.title": "iOS 快捷指令",
"settings.shortcut.intro": "为 iOS“快捷指令”签发长效、仅限剪贴板、可随时吊销的专用令牌。即便令牌泄漏也只能读写剪贴板、无法触及其他数据。",
"settings.shortcut.labelField": "备注名称",
"settings.shortcut.labelPlaceholder": "例如:我的 iPhone",
"settings.shortcut.issue": "签发",
"settings.shortcut.empty": "尚未签发任何令牌。",
"settings.shortcut.loadError": "令牌列表加载失败。",
"settings.shortcut.retry": "重试",
"settings.shortcut.revoke": "吊销",
"settings.shortcut.revoking": "正在吊销…",
"settings.shortcut.revokeConfirm": "吊销后使用此令牌的快捷指令将立即失效,确认继续?",
"settings.shortcut.revoked": "已吊销",
"settings.shortcut.revokedToast": "令牌已吊销",
"settings.shortcut.expired": "已过期",
"settings.shortcut.expiresAt": "到期 {{date}}",
"settings.shortcut.lastUsed": "上次使用 {{time}}",
"settings.shortcut.neverUsed": "尚未使用",
"settings.shortcut.unavailable": "服务器未启用快捷指令令牌。",
"settings.shortcut.tooMany": "活跃令牌已达上限,请先吊销旧令牌。",
"settings.shortcut.issueError": "操作失败:{{message}}",
"settings.shortcut.issuedTitle": "令牌已签发",
"settings.shortcut.onceWarning": "令牌仅显示这一次,请立即复制保存;关闭后无法再次查看。",
"settings.shortcut.tokenLabel": "令牌(Bearer",
"settings.shortcut.copy": "复制",
"settings.shortcut.copyFailed": "复制失败,请手动选择复制",
"settings.shortcut.copied": "已复制",
"settings.shortcut.baseLabel": "服务器地址",
"settings.shortcut.guideTitle": "在 iOS 上手动搭建(手动同步)",
"settings.shortcut.guideIntro": "在 iPhone 的“快捷指令”App 中新建两个指令,首次运行时填入上面的服务器地址与令牌:",
"settings.shortcut.guideDeviceName": "两个指令都加请求头 X-Device-Name: 你的设备名(仅 ASCII),让它在设备列表里统一显示。",
"settings.shortcut.guidePullTitle": "拉取(云端 → 本机剪贴板)",
"settings.shortcut.guidePull1": "“获取 URL 内容”:地址设为 你的服务器地址 加 /api/clipboard,方法 GET,请求头加 Authorization: Bearer 你的令牌。",
"settings.shortcut.guidePull2": "“获取词典值”:取键 content。",
"settings.shortcut.guidePull3": "内容非空时用“拷贝到剪贴板”写入,可再加“显示通知”提示已拉取。",
"settings.shortcut.guidePushTitle": "上传(本机剪贴板 → 云端)",
"settings.shortcut.guidePush1": "“获取剪贴板”;若为空则停止。",
"settings.shortcut.guidePush2": "“获取 URL 内容”:地址同上,方法 PUT,请求头加 Authorization: Bearer 你的令牌 与 Content-Type: application/json,请求体选 JSON{\"content\": 剪贴板, \"content_type\": \"text/plain\"}。",
"settings.shortcut.guidePush3": "可加“显示通知”提示已上传。",
"settings.shortcut.guideToleranceNote": "网络不稳定时单次运行可能失败,重试即可;自动轮询版会在后台静默重试(下一阶段提供)。",
"settings.shortcut.done": "完成",
"transfer.savedTo": "已保存到 {{path}}",
"transfer.saveFailed": "保存文件失败:{{error}}",
"transfer.action.cancel": "取消",
"transfer.action.delete": "删除",
"transfer.action.relayNow": "立即中继",
"transfer.action.details": "详情",
"transfer.state.PENDING": "等待中",
"transfer.state.ACCEPTED": "已接受",
"transfer.state.P2P_ACTIVE": "传输中",
"transfer.state.RELAY_ACTIVE": "中继中",
"transfer.state.DONE": "已完成",
"transfer.state.FAILED": "失败",
"transfer.state.CANCELLED": "已取消",
"transfer.mode.p2p": "P2P",
"transfer.mode.relay": "中继",
"transfer.phase.initializing": "正在初始化…",
"transfer.phase.waiting_accept": "等待对端接受…",
"transfer.phase.ice_gathering": "收集网络候选…",
"transfer.phase.ice_checking": "建立直连通道…",
"transfer.phase.ice_connected": "通道已连接",
"transfer.phase.dc_open": "信道已打开",
"transfer.phase.transferring": "传输中",
"transfer.phase.completing": "收尾中…",
"transfer.phase.fallback_pending": "切换到中继路径…",
"transfer.phase.relay_uploading": "中继上传中",
"transfer.phase.relay_downloading": "中继下载中",
"transfer.stalled": "似乎卡住了({{seconds}} 秒无进度)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/秒",
"transfer.debug.toggle": "调试信息",
"transfer.debug.iceGathering": "候选收集",
"transfer.debug.iceConnection": "连接状态",
"transfer.debug.candidatesLocal": "本端候选",
"transfer.debug.candidatesRemote": "对端候选",
"transfer.debug.selectedPair": "选中候选对",
"transfer.debug.noPair": "尚未选定候选对",
"transfer.debug.candidatesLine": "host {{host}} · mDNS {{mdns}} · srflx {{srflx}} · prflx {{prflx}} · relay {{relay}}",
"transfer.debug.dataChannel": "数据信道",
"transfer.debug.dcLine": "{{state}} · 待发送 {{buffered}}",
"time.justNow": "刚刚",
"time.secondsAgo": "{{n}} 秒前",
"time.minutesAgo": "{{n}} 分钟前",
"time.hoursAgo": "{{n}} 小时前",
"common.dismiss": "移除",
"common.cancel": "取消",
"common.confirm": "确认",
"errors.messageEmpty": "消息为空",
"errors.messageOverflow": "消息超过4 KB;请改用文件传输",
"errors.noReceiver": "未选择接收方",
"errors.devTokenMissing": "Dev模式:未在.env.local设置VITE_CDROP_DEV_TOKEN",
"errors.noAccessToken": "无访问令牌:用户必须先登录",
"errors.clipboardUnavailable": "浏览器不支持剪贴板API(需HTTPS与权限)",
"errors.clipboardOverflow": "内容过大,超过 {{max}} 字节上限",
"errors.clipboardWriteFailed": "写入本机剪贴板失败:{{message}}",
"ios.tab.transfer": "传输",
"ios.tab.devices": "设备",
"ios.tab.settings": "设置",
"ios.login.generating": "正在生成二维码…",
"ios.login.waiting": "等待扫码批准",
"ios.login.guide": "用另一台已登录的设备扫码批准",
"ios.login.expired": "二维码已失效,请重试",
"ios.login.failed": "登录失败,请重试",
"ios.login.refresh": "刷新二维码",
"ios.login.needFull": "此设备需要完整权限,批准时请选择“信任此设备”",
"ios.send.pickDevice": "选择接收设备",
"ios.transfer.sending": "发送中",
"ios.settings.engine": "引擎",
"ios.settings.status": "状态",
"ios.settings.device": "设备",
"ios.settings.clipboard": "剪贴板",
"ios.settings.clipboardNote": "上传把本机剪贴板推到其他设备;拉取写入最新云剪贴板。",
"ios.clipboard.upload": "上传剪贴板",
"ios.clipboard.pull": "拉取剪贴板",
"ios.clipboard.uploading": "正在上传…",
"ios.clipboard.pulling": "正在拉取…",
"ios.clipboard.uploaded": "已上传到云剪贴板",
"ios.clipboard.pulled": "已写入本机剪贴板",
"ios.clipboard.empty": "剪贴板为空",
"ios.clipboard.failed": "剪贴板同步失败",
"ios.devices.remove": "移除设备",
"ios.devices.thisDevice": "本机",
"ios.devices.revoked": "已移除设备",
"ios.devices.revokeFailed": "移除失败",
"ios.devices.revokeStepUp": "需在网页端重新验证后才能移除",
"ios.engine.disconnected": "未连接",
"ios.engine.ready": "已就绪",
"ios.transfer.empty": "暂无传输",
"ios.transfer.incoming": "接收",
"ios.transfer.outgoing": "发送",
"ios.send.noDevices": "没有在线设备,无法发送",
"ios.devices.empty": "暂无设备",
"ios.detail.title": "传输详情",
"ios.detail.direction": "方向",
"ios.detail.peer": "对端",
"ios.detail.state": "状态",
"ios.detail.phase": "阶段",
"ios.detail.channel": "通道",
"ios.detail.size": "大小",
"ios.detail.progress": "进度",
"ios.detail.speed": "速度",
"ios.settings.account": "账户",
"ios.settings.user": "用户",
"ios.settings.logout": "退出登录",
"ios.settings.deviceName": "设备名称",
"ios.settings.deviceNameNote": "改名将在下次登录后生效",
"ios.settings.deviceCount": "已知设备",
"ios.settings.signaling": "信令连接",
"ios.settings.presenceEvents": "在线事件",
"ios.settings.reconnecting": "重连中",
"ios.settings.lastLog": "最近日志",
"ios.tab.files": "文件",
"ios.files.title": "收到的文件",
"ios.files.empty": "还没有收到文件",
"ios.files.share": "分享",
"ios.files.done": "完成",
"ios.detail.openFile": "打开文件"
}
+358
View File
@@ -0,0 +1,358 @@
{
"app.brand": "Commilitia Drop",
"deviceType.browser": "瀏覽器",
"deviceType.macos": "macOS 用戶端",
"deviceType.windows": "Windows 用戶端",
"deviceType.linux": "Linux 用戶端",
"deviceType.ios": "iOS 用戶端",
"deviceType.shortcut": "捷徑",
"app.connected": "已連線",
"app.connecting": "正在連線…",
"app.disconnectedTitle": "正在重新連線",
"app.disconnectedHint": "與伺服器的連線已中斷,正在自動重新連線,無需重新整理頁面。",
"auth.sessionLost.title": "登入已失效",
"auth.sessionLost.body": "此裝置的登入憑證已被撤銷或逾期,請重新登入。",
"nav.accountMenu": "帳號選單",
"nav.thisDeviceLabel": "本機:",
"nav.settings": "設定",
"nav.signOut": "登出",
"nav.backToHome": "← 首頁",
"nav.language": "語言 / Language",
"nav.theme": "主題 / Theme",
"nav.theme.light": "淺色",
"nav.theme.dark": "深色",
"nav.theme.system": "跟隨系統",
"nav.themeToggle": "切換主題",
"home.greeting": "你好,{{name}}",
"home.deviceList.title": "裝置",
"home.deviceList.onlineSuffix": "{{count}} 部線上)",
"home.deviceList.showOffline": "顯示離線裝置({{count}}",
"home.deviceList.hideOffline": "隱藏離線裝置",
"home.deviceList.empty": "尚無其他裝置。在另一個分頁或裝置上以同一帳號登入,並設定一個不同的裝置名稱(設定 → 裝置名稱),即可在此處看到它。",
"home.tabs.file": "檔案",
"home.tabs.message": "訊息",
"home.tabs.clipboard": "剪貼簿",
"home.file.dropzone": "拖曳檔案到此處或點選選擇",
"home.file.selectedSize": "{{name}}{{size}}",
"home.file.rejected": "檔案已拒絕",
"home.cta.selectDeviceAndFile": "請選擇裝置與檔案",
"home.cta.selectFile": "請選擇檔案",
"home.cta.selectDevice": "請選擇裝置",
"home.cta.sendTo": "傳送到 {{name}}",
"home.message.placeholder": "傳送訊息給 {{name}}…",
"home.message.placeholderEmpty": "請先選擇裝置",
"home.message.hint": "Enter傳送,Shift + Enter換行,最長4 KB。",
"home.message.title": "訊息",
"home.message.countSuffix": "{{count}}",
"home.message.clearAll": "全部清除",
"home.transfer.active": "進行中",
"home.transfer.history": "最近的傳輸",
"home.clipboard.cloudTitle": "雲端最新",
"home.clipboard.cloudFrom": "來自 {{name}} · {{time}}",
"home.clipboard.cloudFromSelf": "來自本機 · {{time}}",
"home.clipboard.cloudEmpty": "尚未同步任何內容。",
"home.clipboard.copyToLocal": "複製到本機剪貼簿",
"home.clipboard.copyToLocalSuccess": "已複製到本機剪貼簿",
"home.clipboard.uploadTitle": "同步本機剪貼簿",
"home.clipboard.uploadHint": "僅純文字,最大64 KB;樣式(粗體 / 顏色等)會被剝除。",
"home.clipboard.uploadButton": "上傳本機剪貼簿",
"home.clipboard.uploading": "上傳中…",
"home.clipboard.uploadEmpty": "本機剪貼簿為空",
"home.clipboard.refresh": "重新整理",
"home.clipboard.clear": "清空雲端",
"home.clipboard.clearConfirm": "確認清空雲端剪貼簿?所有裝置將看到空內容。",
"home.clipboard.clearSuccess": "已清空雲端剪貼簿",
"home.clipboard.hidden": "內容已隱藏 · 點擊「顯示」查看",
"home.clipboard.reveal": "顯示",
"home.clipboard.hide": "隱藏",
"setup.title": "為本機命名",
"setup.help": "此名稱會顯示給你的其他裝置。建議避免空格,長度不超過 32 個字元。",
"setup.field": "裝置名稱",
"setup.save": "儲存並繼續",
"login.title": "登入",
"login.dev.help": "Dev模式:隨意填寫一個使用者識別碼。同識別碼的兩個分頁將模擬同一使用者的兩部裝置。",
"login.dev.field": "Dev使用者識別碼",
"login.dev.continue": "繼續",
"login.prod.help": "透過Casdoor登入。",
"login.prod.button": "透過Casdoor登入",
"oauth.failed": "登入失敗",
"oauth.signingIn": "正在登入…",
"oauth.missingParams": "缺少code或state查詢參數",
"qr.entry.fromLogin": "掃碼登入此裝置",
"qr.entry.scanNewDevice": "掃碼登入新裝置",
"qr.scan.title": "掃碼登入新裝置",
"qr.scan.subtitle": "把另一部裝置顯示的 QR 碼對準取景框。批准全程留在這部已登入裝置上。",
"qr.scan.starting": "正在啟動相機…",
"qr.scan.aiming": "把 QR 碼對準取景框",
"qr.scan.foreign": "這不是本站的登入 QR 碼。請對準新裝置上「掃碼登入此裝置」產生的碼。",
"qr.scan.noCamera": "這部裝置沒有可用的相機。請改用有相機的手機或平板掃碼。",
"qr.scan.denied": "相機權限被拒絕。請在瀏覽器網站設定裡允許使用相機,再重新開啟此頁。",
"qr.scan.error": "無法開啟相機。請重試,或改用有相機的裝置掃碼。",
"qr.scan.howto1": "在新裝置上開啟「掃碼登入此裝置」,產生一張 QR 碼。",
"qr.scan.howto2": "把那張 QR 碼對準上方取景框,辨識後會自動進入批准。",
"qr.scan.back": "返回",
"qr.show.title": "掃碼登入此裝置",
"qr.show.subtitle": "先為這部裝置命名,再用已登入的手機掃碼批准它接入你的帳號。",
"qr.show.nameHint": "此名稱會顯示給你的其他裝置,僅可使用 ASCII 字元。",
"qr.show.generate": "產生 QR 碼",
"qr.show.scanTitle": "用手機掃碼批准",
"qr.show.howto1": "在另一部已登入的裝置上開啟相機或 cdrop。",
"qr.show.howto2": "掃描上方 QR 碼,依提示批准此裝置。",
"qr.show.waiting": "等待批准…",
"qr.show.approved": "已批准,正在進入…",
"qr.show.expired": "QR 碼已逾時。重新產生一張再掃。",
"qr.show.denied": "這次接入被拒絕。如確為你本人操作,重新產生 QR 碼再試。",
"qr.show.regenerate": "重新產生 QR 碼",
"qr.show.back": "返回首頁",
"qr.show.backToName": "返回上一步",
"qr.approve.title": "批准新裝置",
"qr.approve.subtitle": "確認讓這部裝置登入你的帳號。請核對裝置與來源無誤。",
"qr.approve.loading": "正在讀取裝置資訊…",
"qr.approve.badLink": "這個批准連結不完整。請在新裝置上重新產生 QR 碼再掃。",
"qr.approve.signInFirst": "先登入你的帳號,再批准這部裝置接入。",
"qr.approve.trustLabel": "信任時長",
"qr.approve.trust.title": "信任此裝置(7 天)",
"qr.approve.trust.hint": "7 天內免再次批准,每次使用自動續期。",
"qr.approve.once.title": "僅此一次(1 小時)",
"qr.approve.once.hint": "登入有效 1 小時,逾時需重新掃碼。",
"qr.approve.scopeFull": "這部裝置將完整登入你的帳號、7 天內有效:可正常收發檔案與訊息,並能管理帳號與裝置。",
"qr.approve.scopeGuest": "以受限訪客身分登入——可收發檔案與訊息,但不能變更帳號、不能批准其他裝置;1 小時後失效。",
"qr.approve.nativeFull": "這是一台原生應用裝置,將以完整權限登入(信任此裝置、7 天內有效)。原生應用不支援受限訪客。",
"qr.approve.trustContinue": "信任並繼續",
"qr.approve.approve": "批准登入",
"qr.approve.stepUp.notice": "為安全起見,批准前需先驗證你的身分——點下方按鈕會引導你重新登入一次。",
"qr.approve.stepUp.button": "驗證並批准",
"qr.approve.stepUp.rejected": "身分驗證未通過或已逾時。請再驗證一次,確認是你本人後即可批准。",
"qr.approve.deny": "拒絕",
"qr.approve.doneApprovedTitle": "已批准",
"qr.approve.doneApproved": "這部裝置已接入你的帳號,現在即可在它上面收發檔案。",
"qr.approve.doneDeniedTitle": "已拒絕",
"qr.approve.doneDenied": "這次接入已被拒絕,那部裝置不會登入你的帳號。",
"qr.approve.expiredTitle": "QR 碼已逾時",
"qr.approve.expired": "這張 QR 碼已失效。請在新裝置上重新產生再掃。",
"qr.approve.errorTitle": "出錯了",
"qr.approve.error": "操作沒能完成。請重試,或在新裝置上重新產生 QR 碼。",
"qr.approve.backHome": "返回首頁",
"settings.title": "設定",
"settings.currentDevice.title": "目前裝置",
"settings.deviceName.field": "裝置名稱",
"settings.deviceName.save": "儲存",
"settings.deviceName.unchanged": "新名稱與目前一致",
"settings.deviceName.empty": "名稱不能為空",
"settings.deviceName.asciiOnly": "裝置名稱只能使用 ASCII 字元(英文字母、數字、符號)。",
"settings.deviceName.success": "裝置名稱已更新",
"settings.unregister.current": "解除註冊本裝置",
"settings.unregister.currentHint": "將登出並移除本裝置的註冊。",
"settings.unregister.currentConfirm": "解除註冊本裝置會移除其註冊並登出,確認繼續?",
"settings.error.delete": "操作失敗:{{message}}",
"settings.guest.badge": "受限訪客",
"settings.guest.title": "這部裝置是受限訪客",
"settings.guest.body": "本裝置以受限訪客身分登入:可正常收發檔案、訊息與剪貼簿,但不能管理帳號、移除裝置或授權新裝置。需要完整權限,請在你的主裝置上重新登入。",
"settings.sessions.title": "登入工作階段",
"settings.sessions.hint": "這裡列出目前登入此帳號的所有裝置,含瀏覽器、桌面與行動用戶端;線上的排在上方。登出某台裝置會立即結束它的登入,需在該裝置上重新登入才能繼續使用。",
"settings.sessions.guestNote": "受限訪客無法管理登入工作階段;如需管理,請在你的主裝置上完整登入。",
"settings.sessions.empty": "尚無其他登入工作階段。",
"settings.sessions.loadError": "登入工作階段載入失敗。",
"settings.sessions.retry": "重試",
"settings.sessions.current": "本機",
"settings.sessions.scopeFull": "完整",
"settings.sessions.scopeGuest": "受限訪客",
"settings.sessions.kind.oidc": "帳號登入",
"settings.sessions.kind.self": "本機帳號",
"settings.sessions.kind.guest": "掃碼訪客",
"settings.sessions.lastUsed": "最近活躍 {{time}}",
"settings.sessions.neverUsed": "尚未活躍",
"settings.sessions.signOut": "登出",
"settings.sessions.signingOut": "正在登出…",
"settings.sessions.signOutCurrent": "登出本機",
"settings.sessions.confirmOther": "確認登出裝置「{{name}}」?該工作階段將立即失效,需重新登入。",
"settings.sessions.confirmCurrent": "登出本機等同於登出帳號,確認繼續?",
"settings.sessions.revoked": "已登出該裝置",
"settings.sessions.stepUpRejected": "身分驗證未通過或已過期,請再試一次。",
"settings.online": "線上",
"settings.offline": "離線",
"settings.lastSeen": "上次活躍 {{time}}",
"settings.push.title": "推播通知",
"settings.push.hint": "頁面關閉時,新檔案、訊息與傳輸結果會以系統通知提醒;頁面開啟時仍走應用內提示。",
"settings.push.enable": "啟用推播",
"settings.push.enabled": "已啟用",
"settings.push.disable": "關閉推播",
"settings.push.deniedHint": "通知權限已被瀏覽器拒絕,請在瀏覽器的網站設定中允許後重試。",
"settings.push.denied": "通知權限被拒絕",
"settings.push.enableOk": "已啟用推播通知",
"settings.push.disableOk": "已關閉推播通知",
"settings.push.failed": "啟用推播失敗,請重試",
"notify.incoming.title": "收到檔案",
"notify.incoming.body": "{{sender}} 傳來 {{file}}",
"notify.transfer.doneTitle": "傳輸完成",
"notify.transfer.failedTitle": "傳輸失敗",
"settings.account.title": "帳號",
"settings.account.signOut": "登出",
"settings.desktop.title": "桌面",
"settings.desktop.clipboardSync": "剪貼簿自動同步",
"settings.desktop.clipboardSyncHint": "複製後即上傳雲端,並接收其他裝置的剪貼簿更新。",
"settings.desktop.launchAtLogin": "開機自動啟動",
"settings.desktop.launchAtLoginHint": "登入系統後自動在背景啟動 cdrop(常駐選單列)。",
"settings.desktop.downloadDir": "下載資料夾",
"settings.desktop.downloadDirHint": "接收到的檔案會儲存至此資料夾;留空則使用系統下載資料夾。",
"settings.desktop.downloadDirChoose": "變更…",
"settings.desktop.downloadDirReset": "還原預設",
"settings.desktop.downloadDirPicker": "選擇下載資料夾",
"settings.desktop.ttlNote": "基於安全考量,雲端剪貼簿內容會在 5 分鐘後自動清除。",
"settings.shortcut.title": "iOS 捷徑",
"settings.shortcut.intro": "為 iOS「捷徑」簽發長效、僅限剪貼簿、可隨時撤銷的專用權杖。即使權杖外洩也只能讀寫剪貼簿、無法觸及其他資料。",
"settings.shortcut.labelField": "備註名稱",
"settings.shortcut.labelPlaceholder": "例如:我的 iPhone",
"settings.shortcut.issue": "簽發",
"settings.shortcut.empty": "尚未簽發任何權杖。",
"settings.shortcut.loadError": "權杖清單載入失敗。",
"settings.shortcut.retry": "重試",
"settings.shortcut.revoke": "撤銷",
"settings.shortcut.revoking": "正在撤銷…",
"settings.shortcut.revokeConfirm": "撤銷後使用此權杖的捷徑將立即失效,確認繼續?",
"settings.shortcut.revoked": "已撤銷",
"settings.shortcut.revokedToast": "權杖已撤銷",
"settings.shortcut.expired": "已過期",
"settings.shortcut.expiresAt": "到期 {{date}}",
"settings.shortcut.lastUsed": "上次使用 {{time}}",
"settings.shortcut.neverUsed": "尚未使用",
"settings.shortcut.unavailable": "伺服器未啟用捷徑權杖。",
"settings.shortcut.tooMany": "使用中的權杖已達上限,請先撤銷舊權杖。",
"settings.shortcut.issueError": "操作失敗:{{message}}",
"settings.shortcut.issuedTitle": "權杖已簽發",
"settings.shortcut.onceWarning": "權杖僅顯示這一次,請立即複製保存;關閉後無法再次檢視。",
"settings.shortcut.tokenLabel": "權杖(Bearer",
"settings.shortcut.copy": "複製",
"settings.shortcut.copyFailed": "複製失敗,請手動選取複製",
"settings.shortcut.copied": "已複製",
"settings.shortcut.baseLabel": "伺服器位址",
"settings.shortcut.guideTitle": "在 iOS 上手動建立(手動同步)",
"settings.shortcut.guideIntro": "在 iPhone 的「捷徑」App 中新增兩個捷徑,首次執行時填入上方的伺服器位址與權杖:",
"settings.shortcut.guideDeviceName": "兩個捷徑都加標頭 X-Device-Name: 你的裝置名稱(僅 ASCII),讓它在裝置清單中統一顯示。",
"settings.shortcut.guidePullTitle": "拉取(雲端 → 本機剪貼簿)",
"settings.shortcut.guidePull1": "「取得 URL 內容」:網址設為 你的伺服器位址 加 /api/clipboard,方法 GET,標頭加 Authorization: Bearer 你的權杖。",
"settings.shortcut.guidePull2": "「取得字典值」:取鍵 content。",
"settings.shortcut.guidePull3": "內容非空時用「複製到剪貼簿」寫入,可再加「顯示通知」提示已拉取。",
"settings.shortcut.guidePushTitle": "上傳(本機剪貼簿 → 雲端)",
"settings.shortcut.guidePush1": "「取得剪貼簿」;若為空則停止。",
"settings.shortcut.guidePush2": "「取得 URL 內容」:網址同上,方法 PUT,標頭加 Authorization: Bearer 你的權杖 與 Content-Type: application/json,請求內容選 JSON{\"content\": 剪貼簿, \"content_type\": \"text/plain\"}。",
"settings.shortcut.guidePush3": "可加「顯示通知」提示已上傳。",
"settings.shortcut.guideToleranceNote": "網路不穩時單次執行可能失敗,重試即可;自動輪詢版會在背景靜默重試(下一階段提供)。",
"settings.shortcut.done": "完成",
"transfer.savedTo": "已儲存至 {{path}}",
"transfer.saveFailed": "儲存檔案失敗:{{error}}",
"transfer.action.cancel": "取消",
"transfer.action.delete": "刪除",
"transfer.action.relayNow": "立即中繼",
"transfer.action.details": "詳情",
"transfer.state.PENDING": "等待中",
"transfer.state.ACCEPTED": "已接受",
"transfer.state.P2P_ACTIVE": "傳輸中",
"transfer.state.RELAY_ACTIVE": "中繼中",
"transfer.state.DONE": "已完成",
"transfer.state.FAILED": "失敗",
"transfer.state.CANCELLED": "已取消",
"transfer.mode.p2p": "P2P",
"transfer.mode.relay": "中繼",
"transfer.phase.initializing": "正在初始化…",
"transfer.phase.waiting_accept": "等待對端接受…",
"transfer.phase.ice_gathering": "收集網路候選…",
"transfer.phase.ice_checking": "建立直連通道…",
"transfer.phase.ice_connected": "通道已連線",
"transfer.phase.dc_open": "通道已開啟",
"transfer.phase.transferring": "傳輸中",
"transfer.phase.completing": "收尾中…",
"transfer.phase.fallback_pending": "切換到中繼路徑…",
"transfer.phase.relay_uploading": "中繼上傳中",
"transfer.phase.relay_downloading": "中繼下載中",
"transfer.stalled": "似乎卡住了({{seconds}} 秒無進度)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/秒",
"transfer.debug.toggle": "偵錯資訊",
"transfer.debug.iceGathering": "候選收集",
"transfer.debug.iceConnection": "連線狀態",
"transfer.debug.candidatesLocal": "本端候選",
"transfer.debug.candidatesRemote": "對端候選",
"transfer.debug.selectedPair": "選中候選對",
"transfer.debug.noPair": "尚未選定候選對",
"transfer.debug.candidatesLine": "host {{host}} · mDNS {{mdns}} · srflx {{srflx}} · prflx {{prflx}} · relay {{relay}}",
"transfer.debug.dataChannel": "資料通道",
"transfer.debug.dcLine": "{{state}} · 待傳送 {{buffered}}",
"time.justNow": "剛剛",
"time.secondsAgo": "{{n}} 秒前",
"time.minutesAgo": "{{n}} 分鐘前",
"time.hoursAgo": "{{n}} 小時前",
"common.dismiss": "移除",
"common.cancel": "取消",
"common.confirm": "確認",
"errors.messageEmpty": "訊息為空",
"errors.messageOverflow": "訊息超過4 KB;請改用檔案傳輸",
"errors.noReceiver": "未選擇接收方",
"errors.devTokenMissing": "Dev模式:未在.env.local設定VITE_CDROP_DEV_TOKEN",
"errors.noAccessToken": "無存取權杖:使用者必須先登入",
"errors.clipboardUnavailable": "瀏覽器不支援剪貼簿API(需HTTPS與權限)",
"errors.clipboardOverflow": "內容過大,超過 {{max}} 位元組上限",
"errors.clipboardWriteFailed": "寫入本機剪貼簿失敗:{{message}}",
"ios.tab.transfer": "傳輸",
"ios.tab.devices": "裝置",
"ios.tab.settings": "設定",
"ios.login.generating": "正在產生 QR 碼…",
"ios.login.waiting": "等待掃碼核准",
"ios.login.guide": "用另一台已登入的裝置掃碼核准",
"ios.login.expired": "QR 碼已失效,請重試",
"ios.login.failed": "登入失敗,請重試",
"ios.login.refresh": "重新整理 QR 碼",
"ios.login.needFull": "此裝置需要完整權限,批准時請選擇「信任此裝置」",
"ios.send.pickDevice": "選擇接收裝置",
"ios.transfer.sending": "傳送中",
"ios.settings.engine": "引擎",
"ios.settings.status": "狀態",
"ios.settings.device": "裝置",
"ios.settings.clipboard": "剪貼簿",
"ios.settings.clipboardNote": "上傳把本機剪貼簿推到其他裝置;拉取寫入最新雲端剪貼簿。",
"ios.clipboard.upload": "上傳剪貼簿",
"ios.clipboard.pull": "拉取剪貼簿",
"ios.clipboard.uploading": "正在上傳…",
"ios.clipboard.pulling": "正在拉取…",
"ios.clipboard.uploaded": "已上傳到雲端剪貼簿",
"ios.clipboard.pulled": "已寫入本機剪貼簿",
"ios.clipboard.empty": "剪貼簿是空的",
"ios.clipboard.failed": "剪貼簿同步失敗",
"ios.devices.remove": "移除裝置",
"ios.devices.thisDevice": "本機",
"ios.devices.revoked": "已移除裝置",
"ios.devices.revokeFailed": "移除失敗",
"ios.devices.revokeStepUp": "需在網頁端重新驗證後才能移除",
"ios.engine.disconnected": "未連線",
"ios.engine.ready": "已就緒",
"ios.transfer.empty": "尚無傳輸",
"ios.transfer.incoming": "接收",
"ios.transfer.outgoing": "傳送",
"ios.send.noDevices": "沒有上線裝置,無法傳送",
"ios.devices.empty": "尚無裝置",
"ios.detail.title": "傳輸詳情",
"ios.detail.direction": "方向",
"ios.detail.peer": "對端",
"ios.detail.state": "狀態",
"ios.detail.phase": "階段",
"ios.detail.channel": "通道",
"ios.detail.size": "大小",
"ios.detail.progress": "進度",
"ios.detail.speed": "速度",
"ios.settings.account": "帳戶",
"ios.settings.user": "使用者",
"ios.settings.logout": "登出",
"ios.settings.deviceName": "裝置名稱",
"ios.settings.deviceNameNote": "改名將在下次登入後生效",
"ios.settings.deviceCount": "已知裝置",
"ios.settings.signaling": "信令連線",
"ios.settings.presenceEvents": "上線事件",
"ios.settings.reconnecting": "重新連線中",
"ios.settings.lastLog": "最近日誌",
"ios.tab.files": "檔案",
"ios.files.title": "收到的檔案",
"ios.files.empty": "尚未收到檔案",
"ios.files.share": "分享",
"ios.files.done": "完成",
"ios.detail.openFile": "開啟檔案"
}
+685
View File
@@ -0,0 +1,685 @@
import SwiftUI
import UIKit
import UniformTypeIdentifiers
// + iOS 26 SDK WebView
//
// ios/PLAN.md §8.1 i18nt()web catalog /
// presence / EngineControllerarch A
struct RootView: View
{
@Environment(EngineController.self) private var engine
// CDROP_TAB
@State private var selection = ProcessInfo.processInfo.environment["CDROP_TAB"] ?? "transfer"
var body: some View
{
TabView(selection: $selection)
{
Tab(t("ios.tab.transfer"), systemImage: "arrow.up.arrow.down", value: "transfer")
{
NavigationStack
{
TransferListView()
}
}
Tab(t("ios.tab.devices"), systemImage: "laptopcomputer.and.iphone", value: "devices")
{
NavigationStack
{
DeviceListView()
}
}
Tab(t("ios.tab.files"), systemImage: "folder", value: "files")
{
NavigationStack
{
ReceivedFilesView()
}
}
Tab(t("ios.tab.settings"), systemImage: "gearshape", value: "settings")
{
NavigationStack
{
SettingsView()
}
}
}
.tabBarMinimizeBehavior(.onScrollDown)
.tint(.cdropAccent)
.background
{
EngineWebView(controller: engine)
.frame(width: 0, height: 0)
.opacity(0)
.allowsHitTesting(false)
}
}
}
struct TransferListView: View
{
@Environment(EngineController.self) private var engine
@State private var showImporter = false
@State private var pickedURL: URL?
@State private var showDevicePicker = false
@State private var showNoDevices = false
// 线 EngineController.sendableDevices
private var sendableDevices: [DeviceItem]
{
engine.sendableDevices()
}
var body: some View
{
ScrollView
{
if engine.transfers.isEmpty && engine.history.isEmpty
{
ContentUnavailableView(t("ios.transfer.empty"), systemImage: "tray")
.padding(.top, 80)
}
else
{
VStack(spacing: 16)
{
if !engine.transfers.isEmpty
{
sectionHeader(t("home.transfer.active"))
ForEach(engine.transfers)
{ item in
transferLink(item)
}
}
if !engine.history.isEmpty
{
sectionHeader(t("home.transfer.history"))
ForEach(engine.history)
{ item in
transferLink(item)
}
}
}
.padding()
}
}
.navigationTitle(t("ios.tab.transfer"))
.overlay(alignment: .bottomTrailing)
{
Button { showImporter = true }
label:
{
Image(systemName: "paperplane.fill")
.font(.title2)
.padding(18)
}
.buttonStyle(.glassProminent)
.padding(24)
}
.fileImporter(isPresented: $showImporter, allowedContentTypes: [ .item ])
{ result in
if case .success(let url) = result
{
pickedURL = url
if sendableDevices.isEmpty { showNoDevices = true }
else { showDevicePicker = true }
}
}
.confirmationDialog(t("ios.send.pickDevice"), isPresented: $showDevicePicker, titleVisibility: .visible)
{
ForEach(sendableDevices)
{ dev in
Button(dev.name) { startSend(to: dev.name) }
}
Button(t("common.cancel"), role: .cancel) { }
}
.alert(t("ios.send.noDevices"), isPresented: $showNoDevices) { }
}
private func sectionHeader(_ title: String) -> some View
{
HStack
{
Text(title)
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
}
}
private func transferLink(_ item: TransferItem) -> some View
{
NavigationLink
{
TransferDetailView(sessionId: item.sessionId)
}
label:
{
TransferCardView(item: item)
}
.buttonStyle(.plain)
}
private func startSend(to device: String)
{
guard let url = pickedURL else { return }
engine.sendFile(to: device, fileURL: url)
pickedURL = nil
}
}
// / / /
// transfer.* catalog
struct TransferCardView: View
{
let item: TransferItem
var body: some View
{
VStack(alignment: .leading, spacing: 8)
{
HStack(spacing: 6)
{
Image(systemName: item.direction == "incoming"
? "arrow.down.circle" : "arrow.up.circle")
.foregroundStyle(.secondary)
Text(item.fileName)
.font(.headline)
.lineLimit(1)
Spacer()
}
if isActive(item)
{
ProgressView(value: progressOf(item))
}
Text(detailLine)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
}
//
private var detailLine: String
{
var parts: [String] = []
if isActive(item), let phase = item.phase
{
parts.append(t("transfer.phase.\(phase)"))
}
else
{
parts.append(t("transfer.state.\(item.state)"))
}
parts.append(formatBytes(item.fileSize))
if isActive(item), let rate = item.bytesPerSec, rate > 0
{
parts.append(t("transfer.bytesRate", [ "rate": formatBytes(Int(rate)) ]))
}
return parts.joined(separator: " · ")
}
}
// TODO #5 sessionId /
// transfers history sessionId
struct TransferDetailView: View
{
@Environment(EngineController.self) private var engine
let sessionId: String
@State private var previewItem: PreviewItem?
private var item: TransferItem?
{
engine.transfers.first { $0.sessionId == sessionId }
?? engine.history.first { $0.sessionId == sessionId }
}
var body: some View
{
Group
{
if let item
{
List
{
Section
{
LabeledContent(t("ios.detail.direction"),
value: item.direction == "incoming"
? t("ios.transfer.incoming") : t("ios.transfer.outgoing"))
LabeledContent(t("ios.detail.peer"), value: item.peerName)
LabeledContent(t("ios.detail.state"), value: t("transfer.state.\(item.state)"))
if let phase = item.phase
{
LabeledContent(t("ios.detail.phase"), value: t("transfer.phase.\(phase)"))
}
if let mode = item.mode, !mode.isEmpty
{
LabeledContent(t("ios.detail.channel"), value: t("transfer.mode.\(mode)"))
}
}
Section
{
LabeledContent(t("ios.detail.size"), value: formatBytes(item.fileSize))
if let sent = item.bytesTransferred
{
LabeledContent(t("ios.detail.progress"),
value: "\(formatBytes(sent)) / \(formatBytes(item.fileSize))")
}
if let rate = item.bytesPerSec, rate > 0
{
LabeledContent(t("ios.detail.speed"),
value: t("transfer.bytesRate", [ "rate": formatBytes(Int(rate)) ]))
}
ProgressView(value: progressOf(item))
}
//
if item.direction == "incoming", item.state == "DONE",
let url = engine.receivedFile(matching: item.fileName)
{
Section
{
Button { previewItem = PreviewItem(url: url) }
label: { Label(t("ios.detail.openFile"), systemImage: "doc") }
}
}
// ICE "relay" P2P TURN
if item.iceConn != nil || item.iceLocal != nil
{
Section
{
if let conn = item.iceConn
{
LabeledContent(t("transfer.debug.iceConnection"), value: conn)
}
if let local = item.iceLocal, let remote = item.iceRemote
{
LabeledContent(t("transfer.debug.selectedPair"), value: "\(local)\(remote)")
}
}
}
}
}
else
{
ContentUnavailableView(t("ios.transfer.empty"), systemImage: "tray")
}
}
.navigationTitle(item?.fileName ?? t("ios.detail.title"))
.navigationBarTitleDisplayMode(.inline)
.sheet(item: $previewItem) { item in FilePreviewSheet(item: item) }
}
}
struct DeviceListView: View
{
@Environment(EngineController.self) private var engine
@State private var revokeTarget: DeviceItem?
@State private var showRevoke = false
//
private var allDevices: [DeviceItem]
{
engine.devices.sorted { a, b in isSelf(a) && !isSelf(b) }
}
private func isSelf(_ dev: DeviceItem) -> Bool { dev.name == engine.deviceName }
var body: some View
{
Group
{
if engine.devices.isEmpty
{
ContentUnavailableView(t("ios.devices.empty"), systemImage: "laptopcomputer.slash")
}
else
{
List
{
if !engine.deviceActionStatus.isEmpty
{
Text(engine.deviceActionStatus)
.font(.caption)
.foregroundStyle(.secondary)
}
ForEach(allDevices)
{ dev in
deviceRow(dev)
.contentShape(Rectangle())
.onTapGesture { if !isSelf(dev) { revokeTarget = dev; showRevoke = true } }
.swipeActions(edge: .trailing)
{
if !isSelf(dev)
{
Button(role: .destructive) { revokeTarget = dev; showRevoke = true }
label: { Label(t("ios.devices.remove"), systemImage: "trash") }
}
}
}
}
}
}
.navigationTitle(t("ios.tab.devices"))
.confirmationDialog(revokeTarget?.name ?? "", isPresented: $showRevoke, titleVisibility: .visible)
{
Button(t("ios.devices.remove"), role: .destructive)
{
if let target = revokeTarget { engine.revokeDevice(target.name) }
}
Button(t("common.cancel"), role: .cancel) { }
}
}
private func deviceRow(_ dev: DeviceItem) -> some View
{
Label
{
VStack(alignment: .leading, spacing: 2)
{
HStack(spacing: 6)
{
Text(dev.name)
if isSelf(dev)
{
Text(t("ios.devices.thisDevice"))
.font(.caption2)
.foregroundStyle(Color.cdropAccent)
}
}
Text(dev.online
? t("settings.online")
: t("settings.lastSeen", [ "time": relativeTime(dev.lastSeen) ]))
.font(.caption)
.foregroundStyle(.secondary)
}
}
icon:
{
Image(systemName: deviceSymbol(dev.type))
.foregroundStyle(dev.online ? Color.green : Color.secondary)
}
}
}
struct SettingsView: View
{
@Environment(EngineController.self) private var engine
@Environment(AuthManager.self) private var auth
// UserDefaults
@State private var deviceNameDraft = DeviceNameStore.value
var body: some View
{
List
{
if let user = auth.session?.user
{
Section
{
LabeledContent(t("ios.settings.user"), value: user.name)
TextField(t("ios.settings.deviceName"), text: $deviceNameDraft)
.submitLabel(.done)
.onChange(of: deviceNameDraft) { DeviceNameStore.value = deviceNameDraft }
Button(role: .destructive) { logout() }
label:
{
Text(t("ios.settings.logout"))
}
}
header:
{
Text(t("ios.settings.account"))
}
footer:
{
Text(t("ios.settings.deviceNameNote"))
}
}
Section(t("ios.settings.engine"))
{
LabeledContent(t("ios.settings.status"), value: engine.status)
LabeledContent(t("ios.settings.device"),
value: engine.deviceName.isEmpty ? "" : engine.deviceName)
LabeledContent(t("ios.settings.signaling"), value: signalingState)
LabeledContent(t("ios.settings.presenceEvents"), value: "\(engine.presenceCount)")
LabeledContent(t("ios.settings.deviceCount"), value: "\(engine.devices.count)")
if !engine.lastEngineLog.isEmpty
{
VStack(alignment: .leading, spacing: 2)
{
Text(t("ios.settings.lastLog"))
.font(.caption)
.foregroundStyle(.secondary)
Text(engine.lastEngineLog)
.font(.caption2)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
}
Section
{
Button { engine.uploadClipboard() }
label: { Label(t("ios.clipboard.upload"), systemImage: "arrow.up.doc.on.clipboard") }
Button { engine.pullClipboard() }
label: { Label(t("ios.clipboard.pull"), systemImage: "arrow.down.doc") }
if !engine.clipboardStatus.isEmpty
{
Text(engine.clipboardStatus)
.font(.caption)
.foregroundStyle(.secondary)
}
}
header:
{
Text(t("ios.settings.clipboard"))
}
footer:
{
Text(t("ios.settings.clipboardNote"))
}
}
.navigationTitle(t("ios.tab.settings"))
}
// / / 便
private var signalingState: String
{
if engine.hubConnected { return t("app.connected") }
if engine.hubReconnecting { return t("ios.settings.reconnecting") }
return t("ios.engine.disconnected")
}
// SSE + WebView Keychain auth
private func logout()
{
engine.reset()
auth.logout()
}
}
// TODO #2 + send Documents
// QuickLook / / 线 / Info.plist
// UIFileSharingEnabled + LSSupportsOpeningDocumentsInPlace Files app iPhone
// engine.sendFile
struct ReceivedFilesView: View
{
@Environment(EngineController.self) private var engine
@State private var files: [URL] = []
@State private var previewItem: PreviewItem?
@State private var pickedURL: URL?
@State private var showDevicePicker = false
@State private var showNoDevices = false
var body: some View
{
Group
{
if files.isEmpty
{
ContentUnavailableView(t("ios.files.empty"), systemImage: "tray")
}
else
{
List
{
ForEach(files, id: \.self)
{ url in
Button { previewItem = PreviewItem(url: url) }
label: { fileRow(url) }
.buttonStyle(.plain)
.swipeActions(edge: .leading)
{
Button { startSend(url) }
label: { Label(t("ios.transfer.outgoing"), systemImage: "paperplane") }
.tint(.cdropAccent)
}
.contextMenu
{
ShareLink(item: url)
{ Label(t("ios.files.share"), systemImage: "square.and.arrow.up") }
Button { startSend(url) }
label: { Label(t("ios.transfer.outgoing"), systemImage: "paperplane") }
Button(role: .destructive) { delete(url) }
label: { Label(t("transfer.action.delete"), systemImage: "trash") }
}
}
.onDelete { offsets in deleteAt(offsets) } // + EditButton
}
}
}
.navigationTitle(t("ios.files.title"))
.toolbar
{
if !files.isEmpty
{
ToolbarItem(placement: .topBarTrailing) { EditButton() }
}
}
.sheet(item: $previewItem) { item in FilePreviewSheet(item: item) }
.onAppear { reload() }
.onChange(of: engine.history.count) { reload() }
.confirmationDialog(t("ios.send.pickDevice"), isPresented: $showDevicePicker, titleVisibility: .visible)
{
ForEach(engine.sendableDevices())
{ dev in
Button(dev.name) { send(to: dev.name) }
}
Button(t("common.cancel"), role: .cancel) { }
}
.alert(t("ios.send.noDevices"), isPresented: $showNoDevices) { }
}
private func fileRow(_ url: URL) -> some View
{
HStack(spacing: 10)
{
Image(systemName: "doc")
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2)
{
Text(url.lastPathComponent)
.lineLimit(1)
Text(fileSizeString(url))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private func reload() { files = engine.receivedFiles() }
private func delete(_ url: URL)
{
engine.deleteReceivedFile(url)
reload()
}
private func deleteAt(_ offsets: IndexSet)
{
for i in offsets { engine.deleteReceivedFile(files[i]) }
reload()
}
private func startSend(_ url: URL)
{
pickedURL = url
if engine.sendableDevices().isEmpty { showNoDevices = true }
else { showDevicePicker = true }
}
private func send(to device: String)
{
guard let url = pickedURL else { return }
engine.sendFile(to: device, fileURL: url)
pickedURL = nil
}
}
// MARK: -
// /
private func isActive(_ item: TransferItem) -> Bool
{
return item.state != "DONE" && item.state != "FAILED" && item.state != "CANCELLED"
}
private func progressOf(_ item: TransferItem) -> Double
{
guard item.fileSize > 0, let sent = item.bytesTransferred else { return 0 }
return min(1.0, Double(sent) / Double(item.fileSize))
}
private func formatBytes(_ bytes: Int) -> String
{
let f = ByteCountFormatter()
f.countStyle = .file
return f.string(fromByteCount: Int64(bytes))
}
private func fileSizeString(_ url: URL) -> String
{
let size = (try? url.resourceValues(forKeys: [ .fileSizeKey ]))?.fileSize ?? 0
return formatBytes(size)
}
// lastSeen epoch devices.last_seennow.Unix()web formatRelative
// settings.lastSeen {{time}}
private func relativeTime(_ epochSec: Double) -> String
{
let date = Date(timeIntervalSince1970: epochSec)
let f = RelativeDateTimeFormatter()
f.unitsStyle = .short
return f.localizedString(for: date, relativeTo: Date())
}
// SF Symbol web mapKind
private func deviceSymbol(_ type: String) -> String
{
let s = type.lowercased()
if s.contains("mac") || s == "darwin" { return "laptopcomputer" }
if s.contains("win") { return "pc" }
if s.contains("linux") { return "terminal" }
if s.contains("ipad") { return "ipad" }
if s.contains("ios") || s.contains("iphone") { return "iphone" }
if s.contains("android") { return "candybarphone" }
if s == "shortcut" { return "bolt" }
if s == "browser" { return "globe" }
return "desktopcomputer"
}
// web accent #644AC9 / #9580FF Asset
// Catalog Color Set UIColor
extension Color
{
static let cdropAccent = Color(uiColor: UIColor
{ trait in
trait.userInterfaceStyle == .dark
? UIColor(red: 0.584, green: 0.502, blue: 1.0, alpha: 1)
: UIColor(red: 0.392, green: 0.290, blue: 0.788, alpha: 1)
})
}
+11
View File
@@ -0,0 +1,11 @@
# cdrop iOS 待办(择期处理)
2026-06-24 记录:扫码登录端到端验通后用户列出,留待后续处理(“A”任务覆盖的数据侧除外)。
1. **登录会话显示修复**(未做):iOS 客户端在(web)“登录会话”列表中误显示为“本地账号”,应按 device_type 显示“iOS 客户端”(`deviceType.ios`)。疑似列表按会话 kind(self / 扫码自签)而非设备类型贴标签——需让扫码自签会话也带 device_type 标签呈现。
2. **设备 / session 管理非空壳**(数据侧已做,管理操作未做):列表数据已由“A”接真实 presence(`DeviceListView``engine.devices`)。剩余:会话管理操作(查看 / 吊销其他会话等)。
3. **禁止临时登录**(未做):iOS app 不应允许 guest / 临时(once)登录;扫码登录应强制 full / persist(“信任此设备”)scope。当前后端 qr 默认 guest、`AuthManager` 也写死 `scope: "guest"`——需 iOS 侧走 full。
4. **退出登录**(已做):设置页“账户”区已加登出(destructive),清 Keychain 会话 + 复位引擎 + 回登录页(`SettingsView.logout``auth.logout()` + `engine.reset()`)。
5. **传输详情**(已做):`TransferDetailView` 按 sessionId 实时查引擎态,展示方向 / 对端 / 状态 / 阶段 / 通道 / 大小 / 进度 / 速度;卡片可点入。剩余可选:ICE 候选明细(`transfer.debug.*` catalog 已有,未接)。
> **A 已完成**:引擎 presence / 传输 → 原生 UI 数据绑定(三屏全替 demo)+ `TransferDetailView` + Keychain 会话持久化 + 登出 + i18n 插值。闭合 #4 / #5#2 数据侧;prod 已部署 `engine.html`presence 生效)。**剩余择期**#1device_type 标签)、#3(强制 full 登录)、#2 会话管理操作、#5 ICE 明细(可选)。全部仍未提交(按用户序:真机测完再 commit)。
+52
View File
@@ -0,0 +1,52 @@
name: CDrop
options:
bundleIdPrefix: net.commilitia
deploymentTarget:
iOS: "26.0"
createIntermediateGroups: true
settings:
base:
SWIFT_VERSION: "5.0"
CODE_SIGNING_ALLOWED: "NO"
CODE_SIGNING_REQUIRED: "NO"
targets:
CDrop:
type: application
platform: iOS
deploymentTarget: "26.0"
sources:
- path: Sources
info:
path: Sources/Info.plist
properties:
CFBundleDisplayName: Commilitia Drop
# 声明支持的语言,使系统控件(EditButton 的「编辑」、滑动删除的「删除」等系统字符
# 串)跟随设备语言本地化,而非永远英文。开发区域设简体。
CFBundleDevelopmentRegion: zh-Hans
CFBundleLocalizations:
- zh-Hans
- zh-Hant
- en
UILaunchScreen: {}
# 让接收文件落地的 Documents 目录在 Files app「我的 iPhone / Commilitia Drop」下
# 可见、可就地打开(收到的文件有处可开、可被其他 app 取用)。
UIFileSharingEnabled: true
LSSupportsOpeningDocumentsInPlace: true
NSAppTransportSecurity:
NSAllowsLocalNetworking: true
NSLocalNetworkUsageDescription: "Commilitia Drop 需要访问本地网络以发现同内网设备并建立直连传输。"
NSBonjourServices:
- "_cdrop._tcp"
NSCameraUsageDescription: "Commilitia Drop 使用相机扫描二维码登录新设备。"
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.cdrop
TARGETED_DEVICE_FAMILY: "1,2"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
schemes:
CDrop:
build:
targets:
CDrop: all
run:
config: Debug
+60
View File
@@ -0,0 +1,60 @@
# cdrop web ↔ iOS 同步检查单
> 机制,不是清单快照。webReact)与 iOSSwiftUI)视图层**有意分叉**(液态玻璃决定不可能像素一致)。一致性**优先靠强约束同步**(单一真源、机械消费、漂移不可能,见 `ios/PLAN.md` §5);本文件**只装强约束管不住、需人判定的残余**。
> 关联:`ios/PLAN.md` 决策 D / §5。
---
## 何时跑(触发)
- 任一 PR 改了用户可见 UI / 流程 / 元素 → 过下方“残余项”。
- 改了后端 API 字段 → 过残余项 3。
- 发布任一端前 → 全表过一遍 + 更新“漂移日志”。
---
## 残余项(强约束之外,逐项人工核对)
### 1. 元素 / 屏 / 功能存在性对等
任一端新增用户可见的元素 / 屏 / 功能 → 确认各端都有对应(或登记为残余项 4 的“允许分叉”)。机制无法逼一个 SwiftUI View 自动长出,只能此处提醒。
### 2. 非引擎驱动的新流程 / 状态
不在共享传输引擎里的纯 UI 流程 / 状态(设置项、登录分支、空 / 错 / 加载态)→ 两端手对。**引擎驱动的传输行为不在此列**(强约束已保,无需手对)。
### 3. 后端 API 字段变更
后端改了请求 / 响应字段 → 提醒**两端瘦客户端都跟改**(后端是契约源,但客户端类型手维护)。
### 4. 允许分叉登记(明确 OK 不一致)
- **视觉材质**:液态玻璃 vs Theme B。
- **导航外壳**:原生 `NavigationStack` / `TabView` vs web 布局。
- **字体 / 排版**:系统字体 + Dynamic Type vs Orbit Gothic / Noto;间距 / 圆角 / 字阶各用平台母语。
- **平台专属**:控制中心控件、Share Extension(仅 iOS);Web Push 订阅 UI、桌面设置区块(非 iOS)。
---
## PR 自检片段(复制进 PR 描述)
- [ ] 新增 / 改动的用户可见元素 / 屏 / 功能,各端都有或已登记允许分叉?
- [ ] 新增非引擎流程 / 状态,两端已对齐?
- [ ] 改了后端字段?两端客户端都跟改?
- [ ] 新增文案走了 i18n 单一源(非硬编码)?
---
## 漂移日志
记录每次“已同步”的两端基准,使漂移可见:
| 日期 | web commit | iOS commit | 同步内容 |
|---|---|---|---|
| 2026-06-24 | 建档(iOS 未开工) | — | 初始:架构 A 定、引擎 / i18n / 品牌 / API 四强约束确立,检查单建立 |
| 2026-06-24 | 引擎入口 net/ios.ts + engine/main.ts(未提交) | — | web 侧无头引擎入口 + iOS 桥 + 接收 sink 接入 + Vite 多入口;tsc / build 双过 |
| 2026-06-24 | — | ios/CDrop/ scaffold(未提交) | iOS 原生壳:液态玻璃 TabView + 离屏引擎 WebView 宿主 + 桥 + 落盘管理器;编过 iphonesimulator26.5 |
| 2026-06-24 | — | ios/CDrop/ 登录+发送(未提交) | 扫码登录新设备侧(AuthManager + LoginView,对 prod qr/start 验通显码)+ 发送路径(cdrop-file scheme 供文件 + 文件选择器)+ 会话注入引擎 boot |
| 2026-06-24 | i18n locales + scripts/emit-i18n.mjs(未提交) | ios/CDrop i18n(未提交) | **强约束②落地**web locales 加 `ios.*` 键 → `emit-i18n.mjs` 产出 JSON → iOS `t()` 读(loader I18n.swift)。用户可见**去代号 cdrop**(品牌用 `app.brand`=Commilitia Drop),显示名改 Commilitia Drop。**改 locale 后须重跑 `node web/scripts/emit-i18n.mjs`**(已记入脚本头) |
| 2026-06-24 | engine/main.ts 加 presence 推送 + 初始快照(未提交) | RootView / EngineController / AuthManager 真数据绑定(未提交) | **A 任务**:引擎订阅 `store.devices``presence` 事件 + boot 后补发 presence/transfers 初始快照;原生 `EngineController` 解码进 `@Observable` devices/transfers/history`RootView` 三屏全替 demo→真数据 + 新增 `TransferDetailView`TODO #5)。i18n 加 `ios.detail.*` / `ios.settings.account|user|logout` / `ios.transfer.empty|incoming|outgoing` / `ios.send.noDevices` / `ios.devices.empty`,传输态文案复用共享 `transfer.state|phase|mode|bytesRate`。iOS `t()``{{name}}` 插值变体对齐 web。Keychain 会话持久化 + 设置页登出(闭合 TODO #4)。web tsc/build + xcodebuild iphonesimulator26.5 双过,模拟器三屏空态截图验。**已部署 prod**:`engine.html``engine-BB8iav7K.js`(含 presence 推送),真会话下即生效 |
| 2026-06-24 | engine/main.tstransfers 推送节流 ~3Hz + toWire 加 ice 摘要(未提交,**待部署** | EngineController 手动容忍解析 + ICE 字段;RootView 详情页 ICE 行 + 收到文件视图 + 设备名编辑 + 设备计数;DownloadManager listFiles/delete;新 DeviceNameStore / QuickLookPreviewproject.yml 加文件共享键 + 品牌化权限串(未提交) | **首轮真机测试反馈修复**:① 速度数字 ~10Hz 闪 → 节流活跃传输推送到 3Hz(iOS-only,亦降 P2P 接收主线程抖动);② 设备列表空 → 原生改手动容忍解析(严格 JSONDecoder 遇 bool/int 失配整组失败的隐患),加「已知设备」计数诊断;③ 收到文件无处开 / Files 不可见 / 阻塞发送 → `UIFileSharingEnabled`+`LSSupportsOpeningDocumentsInPlace`Documents 已是落地目录)+ 应用内「收到的文件」视图(QuickLook 预览 / 系统分享 / 转发在线设备 / 删除);④ 设备名不可改 → `DeviceNameStore`UserDefaults)+ 设置页编辑框(后端令牌烤名、改名下次登录生效);⑤ P2P <100KB/srelay 1MB/s)→ 详情页暴露选中 ICE 候选对诊断是否 TURN 中继(不盲改共享 p2p.ts 水位)。i18n 加 `ios.files.*`/`ios.settings.deviceName|deviceNameNote|deviceCount`ICE 行复用 `transfer.debug.*`。web 双过 + xcodebuild 编过 + 模拟器设置 / 传输屏截图验。**已部署 prod**:`engine.html``engine-BC6mZ9zP.js`(含节流 + ice 摘要) |
+198 -69
View File
@@ -1,119 +1,248 @@
# cdrop iOS 客户端实施计划
> WKWebView 原生壳 · 复用 `web/` bundle · APNs 推送 + 原生剪贴板 / 分享
> 状态:**规划中,待 Apple Developer 账号**。本文是“账号到位即可开工”的准备文档——把架构、里程碑、以及哪些活现在就能干 / 哪些硬等签名讲清楚
> 关联:根 `README.md``desktop/PLAN.md`(同“原生壳复用同一份 UI”思路的先例)、`internal/push`Web Push 服务端,APNs 通道与之并列)。记忆 `cdrop-state` / `cdrop-deploy-pointers`
> 原生 SwiftUI 界面(液态玻璃)· 离屏无头 WebView 复用传输引擎 · 控制中心剪贴板 + Share Extension + APNs
> 状态:**架构已定(2026-06-24 修订)**,账号无关部分即刻可开工;真机 / 推送 / 扩展实测硬等 Apple Developer 账号
> 关联:根 `README.md``desktop/PLAN.md``internal/push``ios/PARITY.md`web↔iOS 同步检查单)。记忆 `cdrop-state` / `cdrop-deploy-pointers`
---
## 0. 范围、定位与关键决策
## 0. 定位与关键决策2026-06-24 修订)
**定位**:与桌面端同理——iOS 客户端不是“另写一套 UI”,而是“带原生外壳的同一份 UI”。UI 复用 `web/src` 的 React 应用(WKWebView 加载),价值全在原生层补浏览器在 iOS 上做不到的事
定位变更:iOS 客户端**不再**是“web UI 装进 WebView”,而是“**原生 SwiftUI 界面 + 复用的无头传输引擎**”。起因——新增“服从 iOS 设计语言(液态玻璃)”硬要求,与“WebView 里跑 React UI”直接冲突:液态玻璃是 iOS 26 系统级材质,只有原生 SwiftUI 的 `.glassEffect` 拿得到,网页只能 CSS 仿。故 UI 转原生,但**保留引擎复用**
**为什么要原生壳(而非仅靠 PWA)**:任务 2 的 Web Push 已让 iOS 16.4+ 已安装的 PWA 能收推送,所以“通知”本身不必等原生。原生壳的真正价值在三处 iOS 网页端做不到的能力:
### 决策 A(修订):架构 = 原生 SwiftUI 壳 + 离屏无头 WebView 引擎
1. **原生剪贴板**iOS 对网页 `navigator.clipboard` 限制极严(仅用户手势触发、无法后台监听),而 cdrop 的核心是剪贴板同步。原生 `UIPasteboard` 才能做到顺畅读写
2. **Share Extension**:从任意 app 的分享菜单直接“发送到 cdrop”,网页无法注册。
3. **后台与通知**:原生 APNs + 后台执行窗口,配合通知点击深链回 app。
取代 2026-06-18 的“WebView 复用 React UI”决策
**范围**:常驻 iOS 客户端,复用 web UI + 原生层(剪贴板 / 分享 / APNs / 登录态 Keychain)。
- 所有可见 UI 用**原生 SwiftUI**iOS 26 液态玻璃;旧版优雅回落,见 §7)。
- 文件传输的 **P2P + relay + 会话 + 信令逻辑 = 现有 `web/src` 的 JS 引擎本体**,跑在**离屏(无 UI)WKWebView** 里,原生经桥驱动。零重写、保住同内网 P2P、行为与 web 天然同构(上轮刚修的“按已交付字节算进度”“抽干再标完成”等自动一致)。
- **兜底方案 B**:若真机上无头 WKWebView 跑 WebRTC DataChannel 不稳,退“全原生 + relay-only 重写 + 放弃 P2P”。该验证留到真机可测时优先做(R-iOS-3)。
**非范围(推后)**iPad 专属布局、Apple Watch、App Clip、原生 SwiftUI 重写 UI(明确不做——UI 复用 web)、Android。
**桥方向与桌面相反(关键,易踩坑)**
### 决策 A:架构 = WKWebView 原生壳 + APNs(已定)
- 桌面(Wails):React UI 在 WebView 顶层,调 DOWN 到 Go 拿原生能力。
- iOSarch A):SwiftUI 原生在顶层,调 DOWN 到离屏 WebView 的 JS 引擎跑传输。
- 即 iOS 的 WebView 是“**无头 worker**”,不是“UI 容器”。需要一个**独立的无头引擎入口**(不加载 React、只装传输模块 + RPC 桥),而非复用 React app 再隐藏 UI。
用户在 2026-06-18 的选型中选定**WKWebView 原生壳 + APNs**(而非 PWA-only,也非全原生 SwiftUI)。理由:复用现有 web UI、与桌面瘦客户端架构一致、工作量中等,同时拿到原生剪贴板 / 分享 / 后台。
### 决策 B(修订):分发 = 非 App Store,但仍守苹果开发标准
### 决策 B:硬等 Apple Developer 账号
- **必须付费 Apple Developer ProgramADP$99/年)**cdrop 四项能力——APNs、App Groups、Keychain 共享、Associated Domains——**免费 Personal Team 一律不支持**(账号策略,非 OS 封锁),且免费证书 7 天过期。故“等账号”=等**付费 ADP**。
- **分发走 Ad Hoc**(最贴近桌面 Dropbox 旁加载的等价物):100 台 / 年上限、设备 UDID 须预先登记、profile 年度更新。自用(自己几台设备)足够;比桌面“下载即用”多一道 UDID 登记摩擦——iOS 无更省的合规途径。
- 备选 **TestFlight**:体验最好(无需 UDID、可达万级),但**需经 Apple Beta 审核**、build 90 天过期——与“不上架”初衷部分相悖,留作扩面再议。
- **EnterpriseADEP $299/年)**:无设备上限,但仅限“内部员工”,对外分发违反协议、证书可被吊销——不取。
- **不过审 ≠ 不守标准**:仍遵守 HIG、液态玻璃规范、App Intents / WidgetKit 约定、隐私(权限说明 / just-in-time 请求)、entitlements 最小化。原 R-iOS-5Guideline 4.2 过审风险)在 Ad Hoc 下作废;若走 TestFlight 则 Beta 审核仍在。
iOS 的真机调试、TestFlight / App Store 分发、APNs 全部要求 Apple Developer Program 成员资格 + 签名证书 + Provisioning Profile。**账号到位前**只能:写服务端 APNs 通道(账号无关的代码结构)、scaffold Xcode 工程、在模拟器跑 UI(模拟器不收 APNs)。**账号到位后**才能:真机推送、剪贴板 / 分享扩展真机验证、分发。
### 决策 C(新):剪贴板 = 控制中心两控件,零推送
- 通道用**持久的 `/api/clipboard`**(REST、LWW、版本探针、作用域令牌可访问),不用 ephemeral 的 `/api/message`
- iOS 18 控制中心控件(`ControlWidget` + App Intent)提供“上 / 下”两件:**上** = 读 `UIPasteboard``PUT /api/clipboard`**下** = `GET` → 写 `UIPasteboard`。用户主动点、**零推送**(频繁剪贴板变动推送干扰极大)。
- 平台天花板:程序化**读**剪贴板弹一次系统横幅(写不弹);后台无法监听复制。故“后台自动同步”降级为“一键发送 / 一键拉取”。
- 这条链**纯原生 URLSession,不依赖无头 WebView**——与文件传输引擎彻底解耦,互不拖累。
### 决策 D(新):视图层分叉,先吃强约束、残余进检查单
接受 webReact)与 iOS(SwiftUI)视图层是两套实现(液态玻璃决定不可能像素一致)。一致性按两层管,详见 §5:
- **强约束同步**(机制保证、漂移不可能):① 传输行为靠 arch A 共享引擎;② i18n 文案单一源(iOS 读 web 那份 JSON);③ 品牌资产 + 主色单一源;④ 后端 API 即共享契约。
- **检查单**(机制管不住、人判定的残余):元素 / 屏 / 功能存在性对等、非引擎驱动的新流程、后端字段变更提醒、允许分叉登记。
- **明确不做**:设计令牌 codegen 管线(过度工程,且与液态玻璃系统材质 / 语义色 / SF Symbol 母语相冲)——间距 / 圆角 / 字阶归“允许分叉”,仅品牌主色 + logo 作共享源。
### 决策 E(新):权限策略 = 持久权限首启显式询问 + 缺失则降级或阻塞提示
- **持久型权限(本地网络、相机)在首次启动的引导中显式询问**:先用一屏说明用途、再触发系统弹窗——既满足“首启显式获取”,又不踩 HIG 的“无上下文裸弹窗”反模式。这类权限**一次授予长期持久**(iOS 18 有重装后状态不重置的已知 bug,需 UI 兜底)。
- **缺失 / 拒绝时按特性二选一**:① **有兜底则降级**——本地网络→回退中继并提示“仅中继、无法同内网直连”;通知→无后台推送但前台仍可用。② **无兜底则提示“无权限”并阻塞该特性**——相机→扫码不可用,提示 + 跳系统设置;阻塞的是**该特性**、非整个 app。
- 不拿任一权限当**整个 app** 的门槛(HIG 5.1.2)。详见 §9.2 逐权限映射。
---
## 1. 与任务 2(Web Push)服务端模型的关系
## 1. 后端现状(决定 iOS 能做什么,2026-06-24 扫描)
任务 2 已落地服务端推送底座,iOS 的 APNs 通道**复用同一套设计**,只换发送后端:
| 维度 | Web Push(已建) | iOS APNs(待建) |
| 能力 | 结论 | 对 iOS 的含义 |
|---|---|---|
| 凭据 | 自签 VAPID 公私钥 | Apple APNs Auth Key`.p8` + Key ID + Team ID |
| 订阅令牌 | endpoint + p256dh/auth | APNs device token(每安装一枚) |
| 存储 | `push_subscriptions` 表 | 同表加 `platform` 列区分,或新 `apns_tokens` |
| 收件定位 | `(user_id, device_name)` | 同上——iOS 设备登记 `device_type=ios` |
| 触发门控 | `Hub.SendTo` 未投递才推 | **完全相同**——前台 WebView 在线即走 SSE,挂起 / 关闭才 APNs |
| 文案渲染 | 服务端按订阅 `locale` 渲染 | 同上,复用 `internal/push``localize` |
| 文件传输 | 纯实时(P2P / 实时 relayrelay 是内存环 64MiB、2 分钟空闲释放),**零落盘暂存** | Share Extension 不靠服务端暂存;接收方须同时在线 |
| 剪贴板 `/api/clipboard` | 持久 LWW + REST,作用域令牌可访问 | 决策 C 的纯原生链路 |
| 消息 `/api/message` | 临时、不持久、不可轮询 | 文本同步走剪贴板通道,消息仅尽力而为实时 |
| 作用域令牌 | 仅 `clipboard` 一个 scope`rejectScoped` 挡死 `/transfer` | 发送须完整会话;Share Extension 用交接而非自带令牌 |
| 推送 | 仅 Web PushVAPID),无 APNs | APNs 须新建(§3 |
| 原生鉴权 | IdP bearer(RS256)/ 扫码自签会话;设备隐式登记(`X-Device-Name` | 原生 app 可正常登录持会话 |
**关键**:“页面打开用应用内提示,否则系统通知”的判定在 iOS 上=**app 是否前台**。前台时 WebView 的 SSE 在线、`Hub.SendTo` 投递成功 → 应用内呈现;app 挂起 / 退后台 → SSE 断 → `SendTo` 失败 → 服务端发 APNs。与 Web 端逻辑同构,只是“在线”的物理含义不同
> ⚠️ iOS WebView 退后台后会被系统挂起,SSE 必断。所以 iOS 的后台通知**只能**靠服务端 APNs,**不能**像桌面那样靠常驻进程里的 JS(桌面 Go 进程常驻、iOS 没有等价物)。这正是必须建 APNs 通道、而非复用桌面 `notifyNativeIfBackground` 思路的原因。
Share Extension 落法(据上):**抓文件 → 写 App Group 容器 → 深链唤起主程序**选设备发送(接收方须在线、大文件主程序稳跑、扩展不持完整令牌更安全)
---
## 2. 里程碑
## 2. 桥协议(arch A 的接口契约,账号无关、可先冻结)
原生 ↔ 无头 JS 引擎的 RPC`WKScriptMessageHandler`JS→Swift+ `evaluateJavaScript`Swift→JS),类比 Wails 的 `EventsEmit` / bound methods,但**方向相反**(原生在顶层)。
- **平台判定**`isIOSShell()``web/src/net/ios.ts`)查 `window.webkit.messageHandlers.cdropEngine` 在场(类比 `isDesktop()``window.go.main.App`)。无头入口(`web/src/engine/main.ts`)据此装配桥。
- **注入**:原生在加载 bundle 前经 WKUserScript 注入 `window.__CDROP_BOOT__ = { session, device_name, api_base, device_type:"ios" }`store 在 import 时即水合(沿桌面同一注入范式);JS 永不持有长寿命密钥(refresh_token 只在原生 Keychain)。
- **原生 → 引擎命令**(原生 `evaluateJavaScript``window.__cdropEngineEvent(name, payload)`):`sendFile { target, url, name, size, type? }``cancelTransfer { sessionId }``switchToRelay { sessionId }``session { access_token, user }`(续期令牌)、`shutdown`
- **引擎 → 原生**:① 单向通知(`postMessage({ notify, payload })`):`ready` / `transfers` / `transferDone` / `sendStarted` / `error`;② 请求 / 响应 RPC`postMessage({ id, method, payload })`,原生回调 `window.__cdropEngineResolve(id, ok, value)`):接收落盘 `saveDownload` / `beginDownload` / `appendDownload` / `finalizeDownload` / `abortDownload`data 为 base64,复用桌面同形)。剪贴板不走此桥(决策 C 纯原生)。
- **复用边界**:接收侧字节桥沿用桌面已验证的 base64 分批、有界内存(iOS 上限更小,64MB/16MB 避 jetsam);**发送侧经 `url` 取回文件为唯一新实现点**——当前整文件 fetch 进内存(小 / 中文件可行),大文件按 slice 向原生拉取是真机优化点(R-iOS-4)。
- **已落地(web 侧,账号无关、`tsc`/`vite build` 双过、对现网零影响)**`net/ios.ts`(桥 + `isIOSShell` + 接收封装 + `notifyNative`)、`engine/main.ts`(无头入口)、`incomingSink.ts`(通用混合槽路由 iOS)、`engine.html` + `vite.config.ts` 多入口。
- **加载源(关键)**:无头引擎**必须从安全上下文加载**——`RTCPeerConnection` 在非安全 origin(如自定义 `cdrop://` schemeWebKit 视作 non-secure context)下可能被禁。故引擎页走 `https://`:线上 `drop.commilitia.net` 或应用内本地 https 服务的静态 bundle**自定义 scheme 仅用于 `WKURLSchemeHandler` 给 JS 流式喂文件字节**,不可用它直载引擎页。见 R-iOS-3。
---
## 3. 服务端 APNs 通道(账号无关的结构,`.p8` 到位再落代码)
新增 `internal/apns``internal/push` 并列,只换发送后端:
- **库**APNs 走 HTTP/2 + JWT`.p8` 的 ES256 签名),可用 `github.com/sideshow/apns2`,或自手写(与现有 go-jose 一致)。
- **配置**koanf`CDROP_APNS_*`,全可选,缺失即 iOS 推送惰性关闭):`APNS_KEY_PATH``APNS_KEY_ID``APNS_TEAM_ID``APNS_TOPIC`bundle id)、`APNS_ENV`
- **存储**`push_subscriptions``platform` 列,iOS 行存 APNs token。`SubscriptionID` 仍用 token 哈希保幂等 upsert。
- **端点**`POST /api/push/apns/register {device_token, locale}`(鉴权 + `X-Device-Name`,与 `/api/push/subscribe` 同组)。
- **触发**:在 `transfer:incoming` / `transfer:done|failed` / `message` 的“未投递”分支按 `platform` 分流。文案复用 `internal/push``localize`。**注意——剪贴板不触发推送**(决策 C 走控制中心拉取)。
代码结构账号无关,但真发 / 真测要 `.p8`,故与 iOS 客户端同期落(账号到位后),避免悬空实现。
---
## 4. 里程碑(修订)
| 里程碑 | 内容 | 账号依赖 |
|---|---|---|
| **I0** 账号与证书前置 | Apple Developer Program 加入;App ID`net.commilitia.cdrop`,与桌面 bundle id 一致或加 `.ios` 后缀);APNs Auth Key `.p8`Provisioning Profile | **硬等账号** |
| **I1** WKWebView 壳骨架 | Xcode 工程;WKWebView 加载 `https://drop.commilitia.net`(或内嵌 `web/dist`);注入 `X-Device-Type: ios`;基础生命周期 | 模拟器可跑;真机等账号 |
| **I2** 登录态 | `ASWebAuthenticationSession` 跑 OIDC PKCE(系统级安全浏览器,复用 web client)refresh_token 存 **iOS Keychain**,JS 永不持有(与桌面 Go keyring、web HttpOnly cookie 同策略) | 模拟器可跑 |
| **I3** APNs 推送(核心) | 原生注册 APNs device token → 上报 `POST /api/push/apns/register`;**服务端 APNs 发送通道**(见 §3);通知点击深链回 app | 真机硬等账号 |
| **I4** 原生剪贴板 | `UIPasteboard` 读(用户手势 / 前台)+ 写;与云剪贴板 LWW 模型对接(复用 `/api/clipboard`);说明 iOS 后台无法监听的限制 | 模拟器部分可跑 |
| **I5** Share Extension | 独立 extension target:从任意 app 分享文件 → 唤起 cdrop 发送流程 | 真机硬等账号 |
| **I6** 打磨与分发 | 后台执行窗口;图标 / 启动屏(复用品牌资产);TestFlight → App Store | 硬等账号 |
| **I0** 账号与证书前置 | **付费 ADP($99/年,4 项能力全要)**;主 app + 各扩展 App ID(扩展 id 须主 app 前缀);开启 Push / App Groups / Keychain / Associated Domains;注册 App Group IDAPNs Auth Key `.p8`**仅一次下载**);Distribution 证书 + Ad Hoc Profile(登记设备 UDID | 硬等付费账号 |
| **I1** 原生骨架 + 无头引擎装配 | Xcode 工程(SwiftUI);离屏 WKWebView 载无头引擎入口;`isIOSShell` 注入桥 | 模拟器可跑 UI;引擎入口 web 侧账号无关 |
| **I2** 登录态 | `ASWebAuthenticationSession` PKCE / 扫码refresh_token 存 Keychainsession 注入 JS | 模拟器可跑 |
| **I3** 传输(核心) | 无头引擎桥(send 自定义 scheme 流式 / receive 复用 `Begin`-`Append`-`Finalize`)+ 原生传输 UI(液态玻璃、含进度 / 速度 / 状态) | 真机硬等账号 |
| **I4** 剪贴板 | 控制中心两控件 + App Intents;纯原生 `/api/clipboard` 读写 + 版本探针 | 控件真机硬等账号;REST 链路可先写 |
| **I5** Share Extension | 抓文件 → App Group → 深链主程序选设备发送 | 真机硬等账号 |
| **I6** APNs | 服务端通道(§3)+ 原生注册;不含剪贴板 | `.p8` + 真机硬等账号 |
| **I7** 打磨与分发 | 后台窗口;图标 / 启动屏(复用品牌资产);旁加载分发 | 硬等账号 |
---
## 3. 服务端 APNs 通道(账号无关的结构,可先设计
## 5. 一致性:强约束同步 vs 检查单(决策 D 展开
新增 `internal/push` 的兄弟实现 `internal/apns`(或 `push.Sender` 旁加一个 APNs 后端),与 Web Push 并列:
**检查单是兜底,不是默认。** 先尽量用强约束(单一真源、两端机械消费、漂移不可能),管不住的残余才进检查单(见 `ios/PARITY.md`)。
- **库**APNs 走 HTTP/2 + JWT`.p8` 的 ES256 签名),可用 `github.com/sideshow/apns2` 之类,或自手写(与现有 go-jose 一致)。
- **配置**koanf`CDROP_APNS_*`,全可选——缺失即 iOS 推送惰性关闭,类同 VAPID):`APNS_KEY_PATH``.p8`)、`APNS_KEY_ID``APNS_TEAM_ID``APNS_TOPIC`bundle id)、`APNS_ENV`sandbox / production)。
- **存储**`push_subscriptions``platform TEXT NOT NULL DEFAULT 'web'` 列 + iOS 行存 APNs token(复用 `endpoint` 字段存 token,或加专列)。`SubscriptionID` 仍用 token 的哈希保持幂等 upsert。
- **端点**`POST /api/push/apns/register {device_token, locale}`(鉴权 + `X-Device-Name`,与 `/api/push/subscribe` 同组)。
- **发送**:在 `transfer:incoming` / `message` / `transfer:state(DONE|FAILED)` 的“未投递”分支里,按 `platform` 分流到 Web Push 或 APNs。文案复用 `internal/push``localize`(已支持 zh-CN / zh-TW / en-US)。
### 强约束(机制保证)
> 这部分**代码结构账号无关**,但 APNs 发送要 `.p8` 才能真发 / 真测,故**与 iOS 客户端同期做**(账号到位后),避免落一坨无法验证的代码。设计先定在此
1. **传输行为 → arch A 共享 JS 引擎。** 整套状态机 / 进度 / 完成 / 错误 / 协议无法漂移,零额外成本
2. **i18n 文案 → 单一源。** iOS 原生侧薄 loader 读 web 那份 i18n JSON(随 bundle 或从服务端取),不写转换器、不做 `.xcstrings` 镜像。缺键即露。
3. **品牌资产 + 主色 → 单一源。** 资产沿用 Dropbox 那套(web+desktop 已接);品牌主色 + logo 作小共享常量。
4. **后端 API → 后端即共享契约。** 两端瘦客户端对齐同一 JSON 形状,不加 codegen(过度工程);字段变更的人工残余进检查单。
### 检查单(人判定的残余)
`ios/PARITY.md`:① 元素 / 屏 / 功能存在性对等;② 非引擎驱动的新流程 / 状态;③ 后端 API 字段变更提醒;④ 允许分叉登记。
### 明确不做
设计令牌 codegen 管线——过度工程,且与液态玻璃系统材质 / 语义色 / SF Symbol 母语相冲。间距 / 圆角 / 字阶归“允许分叉”。
---
## 4. 复用边界与平台注入
## 6. 现在能做 vs 等账号(修订)
- **UI 全复用** `web/src`iOS 壳不写业务 UI。
- **平台判定**web 现有 `isDesktop()``window.runtime` + `window.go`(Wails 注入)。iOS 壳需类似注入一个标识(如 `window.__CDROP_IOS__``WKScriptMessageHandler` 桥),前端加 `isIOSShell()`,让原生剪贴板 / 分享走桥、Web Push 入口隐藏(iOS 用 APNs 不用 VAPID)。
- **JS↔原生桥**`WKScriptMessageHandler`JS→Swift+ `evaluateJavaScript`Swift→JS),类比 Wails 的 `EventsEmit` / bound methods。需要的桥方法初稿:`requestApnsToken``readClipboard` / `writeClipboard``saveFile``showNotification`(前台)。
- **登录回跳**`ASWebAuthenticationSession` 的 callback URL scheme 注册(如 `cdrop://oauth/callback`),与 web 的 `redirect_uri` 并存(后端 `/api/auth/config` 已可按 client 下发;或复用 web client 的 loopback 思路)
**账号无关、即刻可做**
- 本计划 + `ios/PARITY.md` 同步检查单(✔ 本轮)。
- web 侧**无头引擎入口**(✔ 已实现,账号无关):`net/ios.ts`(桥 + `isIOSShell()` + 接收封装 + `notifyNative`)、`engine/main.ts`(不加载 React 的独立入口:水合 store → `startHub` → 暴露 RPC + 订阅推事件)、`incomingSink.ts`(通用混合槽路由 iOS,内存上限 64MB/16MB 避 jetsam)、`engine.html` + `vite.config.ts` 多入口。`tsc` / `vite build` 双过、对现网 web / 桌面零影响
- 桥协议契约冻结(§2,✔ 已与实现对齐)。
- i18n 单一源:现有 catalog 已是扁平 `{ key: string }` 结构(`src/i18n/locales/*.ts`),数据形态即可导出 JSON;原生侧薄 loader + 构建期 emit 待原生工程就位时同期落(避免向无消费方空导出)。
- 剪贴板 REST 链路(`/api/clipboard` 读写 + 版本探针)的数据流设计(控件实现等账号,协议可先定)。
- **iOS 原生 scaffold**(✔ 已实现,账号无关):`ios/CDrop/`xcodegen `project.yml`)——液态玻璃壳(TabView + `.tabBarMinimizeBehavior` + `.buttonStyle(.glassProminent)`+ 离屏引擎 `WKWebView` 宿主 + 桥 `EngineController`(对侧契约同 `net/ios.ts`+ `DownloadManager`(落沙盒)。**已编过 `iphonesimulator26.5`iOS 26 SDK,含液态玻璃)**——证 arch A Swift 侧成立。**启动 / 截图待 iOS 26 模拟器运行时(约 7GB,账号无关)下载**(`xcodebuild -downloadPlatform iOS`)。
- **明确不做**(避免悬空 / 过度工程):设计令牌 codegen;APNs 发送代码(`.p8` 到位再落)。(注:原“Xcode 工程等账号”已作废——**模拟器构建不需签名 / Provisioning**,工程已建且 compile-verified;只有真机签名 / APNs 真发 / 分发才等付费 ADP。)
**硬等付费 ADP 账号**:真机签名 / 跑、APNs 真发、Share Extension、控制中心控件真机、旁加载分发。
---
## 5. 风险与未决
## 7. 风险与未决(修订)
- **R-iOS-1 WebView 后台挂起**app 退后台 SSE 必断 → 后台通知只能靠 APNs(已纳入 I3)。前台恢复时 WebView 需重连 SSEweb `startHub` 的重连循环已具备)
- **R-iOS-2 剪贴板后台限制**iOS 不允许后台读剪贴板(隐私);只能前台 / 用户手势。同步语义要据此降级(前台进入时同步一次,而非桌面式实时监听)。iOS 14+ 还有“粘贴需用户确认”横幅。
- **R-iOS-3 远端 vs 内嵌 bundle**WKWebView 加载线上 `drop.commilitia.net`(部署即更新、但离线不可用)vs 内嵌 `web/dist`(随 app 版本、可离线壳)。倾向**内嵌 bundle + API 指向线上**(与桌面一致:`go:embed` 的对应物是 app bundle 里的静态文件),首屏快且可控
- **R-iOS-4 App ID 与 audience**iOS client 若用独立 OAuth client_id,后端 `CDROP_OIDC_AUDIENCE` 需纳入(已支持逗号分隔多值);若复用 web client(如桌面),则无需改。倾向复用 web client(最省)
- **R-iOS-5 审核**App Store 审核对“仅是网页壳”的 app 有顾虑(Guideline 4.2)。原生剪贴板 + 分享扩展 + APNs 提供了足够的原生价值,过审风险可控,但需在审核说明里强调
- **R-iOS-1 后台挂起 / JS 全停**app 退后台即挂起、WKWebView JS(含 WebRTC 事件循环)停摆,DataChannel 无媒体轨后台不保活。文件传输本需前台(双方在线、用户看进度),可接受;用户主动触发的传输用 iOS 26 `BGContinuedProcessingTask` 续跑(带系统进度 UI)。剪贴板走控制中心 App Intent,不依赖常驻 WebView
- **R-iOS-2 剪贴板后台限制**读弹横幅、后台不可监听 → 控制中心手动两控件已据此设计;app 内读用 `UIPasteControl`、探测用 `hasStrings`(均不弹横幅
- **R-iOS-3 无头 WebRTC 真机稳定性 + 安全上下文**arch A 最大未验证点——① `RTCPeerConnection` 须在安全 origin;② `BGContinuedProcessingTask` 能否让 WebView JS 不被挂起待验。**2026-06-24 模拟器已部分证实**iOS 26 WKWebView 从 `http://127.0.0.1``isSecureContext=true`,loopback = 潜在可信源)跑 `RTCPeerConnection` + DataChannel + `createOffer` **RTC:ok**、STUN srflx 候选正常、原生↔JS 桥往返正常 → **arch A 核心成立、不转 B**。注:安全源不止 https,loopback 亦可(可选应用内本地 https/loopback 服务内嵌 bundle,离线可用)。**余真机验**:host / 同内网直连候选 + 本地网络权限(R-iOS-6)、后台挂起续传(BGContinuedProcessingTask
- **R-iOS-4 字节桥**:接收侧复用桌面 `Begin`/`Append`/`Finalize`(base64 分批、有界)已验证;发送侧自定义 scheme(`WKURLSchemeHandler`)流式喂 JS 为新实现点
- **R-iOS-5**(原 App Store 过审)在 Ad Hoc 旁加载下作废;若改走 TestFlight 则 Beta 审核仍在
- **R-iOS-6 本地网络权限(同内网 P2P 硬墙)**WebRTC host 候选 / mDNS 触发 `NSLocalNetworkUsageDescription`(须配 `NSBonjourServices`),拒绝即只剩中继候选 → 回退 relay(cdrop 已有兜底);mDNS 失败不可区分“无服务”,UI 须降级。**不可拿它当核心功能门槛**。
- **R-iOS-7 付费账号 + 分发摩擦**:4 项能力 + 分发全需付费 ADPAd Hoc 100 台 + UDID 预登记是旁加载硬约束——比桌面 Dropbox“下载即用”多一道摩擦。
---
## 6. 现在能做 vs 等账号
## 8. iOS 设计规范要点(HIG · 液态玻璃)
**账号到位前可做(账号无关)**
- 本计划(✔ 本文)。
- 服务端 APNs 通道的**设计冻结**(§3)——代码待 `.p8` 同期落,避免无法验证的悬空实现。
- 前端 `isIOSShell()` 平台注入点的预留(可与 iOS 壳同期,改动小)。
抓取自 Apple HIG / WWDC252026-06-24),只取对 cdrop 界面有约束力的重点。
**硬等 Apple Developer 账号**Xcode 工程真机签名、APNs 真发真测、Share Extension、TestFlight / App Store 分发。
### 8.1 液态玻璃(材质用法)
- **两层模型**:玻璃只用于**功能 / 导航层**Tab bar / Nav bar / Toolbar / 浮动控件 / sheet 边框);**内容层禁用**——列表、设备卡、**传输卡属内容层,不加玻璃**。
- 用 **iOS 26 SDK 编译,Tab bar / Nav bar / Toolbar / sheet 自动玻璃化**;同时**移除所有自定义 bar 背景 / 装饰色**(否则破坏系统玻璃)。
- 自定义悬浮控件(如传输卡上的取消 / 暂停 pill)才手动 `.glassEffect(.regular.interactive())`,多块玻璃须放进 `GlassEffectContainer` 共享采样;主操作用 `.buttonStyle(.glassProminent)`
- **禁忌**:玻璃叠玻璃、滚动内容区铺玻璃、大面积内容玻璃背景、非主操作滥用 tint。
- Tab bar 滚动收起 `.tabBarMinimizeBehavior(.onScrollDown)`;全局传输状态可置 `.tabViewBottomAccessory`(类 Apple Music 迷你条)。圆角走同心 `.rect(cornerRadius: .containerConcentric)`sheet / popover 系统自动同心。
### 8.2 基础(色彩 / 字体 / 图标 / 布局)
- **色彩**:用**语义色**`label` / `secondaryLabel``systemBackground` 层级、`systemFill``separator`),**不硬编码 hex**——暗色 / 对比度 / vibrancy 免费。**品牌主色以 Asset Catalog 的 Color Set(含 Dark 变体)接入**`Color("BrandPrimary")`)——这正是强约束③(§5)在 iOS 侧的落地形态。
- **字体 / Dynamic Type**:系统 text stylesBody 17 / Headline / Title…)+ **必须支持 Dynamic Type**(正文 / 标题 / 主按钮可缩放)。与“字体归允许分叉”不矛盾——分叉的是**字体选择**,Dynamic Type 支持是**硬要求**。
- **SF Symbols**:优先系统符号(自动与文字对齐 / 字重匹配),四种渲染模式(mono / hierarchical / palette / multicolor);品牌专属才自定义 symbol(须注解四模式以获 Dynamic Type / 无障碍)。
- **布局**:内容 / 控件留安全区,**最小命中区 44×44pt**,对比度 **4.5:1**(正文)/ **3:1**(大字 / 非文字),不硬编码宽度。
### 8.3 控件 / App Intents(剪贴板两控件落地)
- 剪贴板“上 / 下”用 **`ControlWidgetButton`**(一次性动作);**SF Symbol 必备**(锁屏 / 操作按钮只显示图标,须独立达意)。跨设备状态靠**推送 reload**(`ControlCenter.reloadControls`)、**不轮询**;控件内**不发网络请求**,经 App Group 容器读主 app 写入。
- **App Intent**:动词+宾语命名、`title` 编译期常量本地化串、**默认后台执行**(返回 dialog / snippet、不开 app);iOS 26 **Interactive Snippets** 可在不开 app 下做“选设备”二级操作。
### 8.4 分享(Share Extension
- 扩展只做轻:内容预览 + 选设备 + 验证 → 写 **App Group 容器** + 交接主 app;**不在扩展内跑传输引擎**(内存 ~120MB,见 §9.1)。宽度系统固定不可改;完成即“已提交”退出,不等传输完成。
### 8.5 通知(APNs
- 文案具体(“文件 X 已接收(3.2MB)”);中断级别用 **`.active`**(传输完成 / 失败),**不滥用 `.timeSensitive`**`threadIdentifier` 按“完成 / 失败 / 消息”分组;**首个有价值操作后**再 just-in-time 请求权限,按类别细分开关。**剪贴板不推送**(决策 C)。
### 8.6 无障碍(玻璃语境)
- Reduce Transparency / Increase Contrast / Reduce Motion:系统对 Tab / Nav 自动回落;**自定义玻璃控件须自行提供不透明回落 + 关动效**。传输进度动画检测 `accessibilityReduceMotion` 降级为渐隐 / 变色。VoiceOver:图标按钮 `accessibilityLabel` 用动词(“发送文件”而非“箭头向上”)。
### 8.7 App 图标(iOS 26
- 用 **Icon Composer** 做分层图标(logo 前景 + 品牌色渐变背景),系统材质自动出高光 / 折射 / 阴影——源稿**不要**烘焙圆角 / 阴影 / 斜面。须验明 / 暗 / clear / tinted 各变体(暗背景下 logo 填充色可辨)。接品牌资产源(强约束③)。
---
## 附:与桌面端的策略对照(一以贯之
## 9. 开发禁区 / 平台约束(硬性 vs 审核 / 账号
| 维度 | 桌面(Wails | iOSWKWebView 壳) |
分级:**【OS 硬】**=系统强制、绕不过;**【账号】**=付费 / 账号策略;**【审核】**=仅 App Store / TestFlight 审核约束(Ad Hoc 旁加载不触发)。
### 9.1 运行 / 生命周期
- **【OS 硬】后台一律挂起、JS 全停**:app 退后台即被挂起,WKWebView 的 JS(含 WebRTC 事件循环)停摆,DataChannel(无媒体轨)后台不保活 → 传输**必须前台发起**;用户主动触发的传输用 iOS 26 **`BGContinuedProcessingTask`** 续跑(带系统进度 UI,能否让 WebView JS 不被挂起须真机验,R-iOS-3)。
- **【OS 硬】无后台常驻监听**:剪贴板 / 消息**不能**后台轮询监听 → 决策 C 的控制中心手动触发正据此。
- **【OS 硬】自定义 scheme = 非安全上下文 + OPFS 单文件 ~10MB**:故引擎从 https 安全源加载(§2)、大文件**不走 OPFS**、经原生桥 `Begin`/`Append`/`Finalize` 落沙盒(接收侧已是此设计)。
- **【OS 硬】Share Extension ~120MB jetsam**:扩展内**装不下** WKWebView + WebRTC 引擎 → 只做交接(§8.4)。
### 9.2 权限 / 隐私 / 网络(落实决策 E 的逐权限映射)
- **【OS 硬】本地网络权限(持久)**:同内网 P2P 直连(WebRTC host 候选 / mDNS)触发 `NSLocalNetworkUsageDescription`**须配 `NSBonjourServices`**;一次授予长期持久 → **首启引导显式询问**(决策 E)。缺失静默失败、拒绝则只剩中继候选 → **降级回退 relay 并提示“仅中继”**(cdrop 已有兜底;mDNS 失败不可区分“无服务”,UI 须降级)。
- **【OS 硬】相机权限(持久,扫码登录)**:`NSCameraUsageDescription` 缺失即崩;首启 / 首次扫码时显式询问。**无兜底 → 拒绝则提示“无权限”并阻塞“扫码”特性**(跳系统设置);登录可走 OIDC 等其他途径,阻塞的是扫码、非整个 app。
- **【OS 硬】Info.plist 用途说明缺失即崩**:相机、相册键(若选图)必声明;剪贴板**无** iOS plist key(横幅系统自动、关不掉)。
- **【OS 硬】ATS 强制 HTTPS / TLS1.2+**cdrop 已全 https,无需例外。
- **【OS 硬】剪贴板**:后台不可读、读内容弹横幅(关不掉)、写不弹、`hasStrings` 探测不弹、`UIPasteControl` 读不弹 → “上”控件接受一次横幅、app 内读用 `UIPasteControl`、“下”控件写无感。
- **【审核】隐私清单 `PrivacyInfo.xcprivacy` + Required Reason API**UserDefaults `CA92.1` 等):上架 / TestFlight 才强制;Ad Hoc 非硬性,但第三方 WebRTC SDK 自带清单仍建议补。
### 9.3 能力 / 扩展 / 签名 / 分发
- **【账号】4 项能力全需付费 ADP**APNs / App Groups / Keychain 共享 / Associated Domains——免费 Personal Team 一律不支持(决策 B / I0)。
- **【OS 硬】扩展归属**Share Extension / ControlWidget 的 bundle id 须**主 app 前缀**、同 Team 签名;与主 app 共享数据**唯一合法途径 = App Group 容器**。
- **【OS 硬 + 账号】Ad Hoc 100 台 / 年 + UDID 预登记**:旁加载的硬上限与摩擦(决策 B)。
- **【OS 硬】APNs 环境隔离**dev profilesandbox、ad-hoc / 分发=productiontoken 不互通。
---
## 附:与桌面端策略对照(修订)
| 维度 | 桌面(Wails | iOSarch A |
|---|---|---|
| UI | 复用 `web/src` | 复用 `web/src` |
| refresh_token | Go keyringAES-GCM | iOS Keychain |
| 登录 | loopback PKCEGo 内) | `ASWebAuthenticationSession`PKCE |
| 后台通知 | Go 常驻进程 + 原生 API(窗口非前台才弹) | **服务端 APNs**app 挂起无常驻进程) |
| 剪贴板 | Go 原生 Monitor 双向实时 | `UIPasteboard`,前台 / 手势(后台受限) |
| 平台判定 | `isDesktop()``window.runtime` | `isIOSShell()`(注入标识) |
| UI | 复用 `web/src` | 原生 SwiftUI(分叉,PARITY 管) |
| 引擎 | Go 原生 | 复用 `web/src` JS(无头) |
| 桥方向 | JS-UI 顶层 → Go | SwiftUI 顶层 → 无头 JS |
| refresh_token | Go keyring | iOS Keychain |
| 登录 | loopback PKCE | `ASWebAuthenticationSession` / 扫码 |
| 后台通知 | Go 常驻进程 + 原生 API | 服务端 APNs |
| 剪贴板 | Go Monitor 实时双向 | 控制中心手动两控件(纯原生 REST) |
| 平台判定 | `isDesktop()``window.runtime` | `isIOSShell()``window.webkit.messageHandlers` |
| 分发 | Dropbox 旁加载 | 旁加载(非 App Store |
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- cdrop 无头传输引擎页(arch A,见 ios/PLAN.md §2)。无 UI / 字体 / 图标——只承载
传输引擎。仅由原生 iOS 壳的离屏 WKWebView 从安全源(https)加载;原生须在本
模块执行前经 WKUserScript 注入 window.__CDROP_BOOT__session / device_name /
api_base / device_type:"ios"),见 src/engine/main.ts。 -->
<title>cdrop engine</title>
</head>
<body>
<script type="module" src="/src/engine/main.ts"></script>
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
import { build } from "esbuild";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
// 把 src/i18n/locales/*.ts 的扁平字典产出为 JSON,供 iOS 原生侧读取(强约束②:i18n 单一
// 真源=web catalog,见 ios/PLAN.md §5)。locale 变更后重跑:node web/scripts/emit-i18n.mjs
// 各 locale 产出完整字典(zh-TW / en-US 缺失键回退 zh-CN),故 iOS loader 无需再做回退。
const webDir = fileURLToPath(new URL("..", import.meta.url));
const repoRoot = join(webDir, "..");
const outDir = join(repoRoot, "ios", "CDrop", "Sources", "Resources", "i18n");
const cacheDir = join(webDir, "node_modules", ".cache");
const tmp = join(cacheDir, "cdrop-i18n-emit.mjs");
const entry = [
'import { zhCN } from "./src/i18n/locales/zh-CN";',
'import { zhTW } from "./src/i18n/locales/zh-TW";',
'import { enUS } from "./src/i18n/locales/en-US";',
"export const all = {",
' "zh-CN": zhCN,',
' "zh-TW": { ...zhCN, ...zhTW },',
' "en-US": { ...zhCN, ...enUS },',
"};",
].join("\n");
const res = await build({
stdin: { contents: entry, resolveDir: webDir, loader: "ts" },
bundle: true,
format: "esm",
write: false,
platform: "neutral",
});
await mkdir(cacheDir, { recursive: true });
await writeFile(tmp, res.outputFiles[0].text);
const mod = await import(pathToFileURL(tmp).href);
await mkdir(outDir, { recursive: true });
for (const [loc, dict] of Object.entries(mod.all))
{
await writeFile(join(outDir, `${loc}.json`), `${JSON.stringify(dict, null, 2)}\n`);
}
console.log("emitted i18n:", Object.keys(mod.all).join(", "), "->", outDir);
+357
View File
@@ -0,0 +1,357 @@
// cdrop 无头传输引擎入口(arch A,见 ios/PLAN.md §2 / §6)。
//
// 不加载 React / 路由 / UI——只复用 web 的传输引擎(P2P + relay + 信令 + 会话),由原生
// iOS 壳经桥(net/ios.ts)驱动。原生在加载本 bundle 前经 WKUserScript 注入
// window.__CDROP_BOOT__session / device_name / api_base / device_type:"ios"),store 在
// import 时即从中水合(见 store/helpers.ts)。本页须从 https 安全源加载——WebRTC 的
// RTCPeerConnection 在非安全 origin 下可能被禁(见 ios/PLAN.md R-iOS-3)。
//
// 命令(原生 → 引擎,经 onNativeEvent):
// sendFile { target, url, name, size, type? } —— 取回 url 指向的待发文件并发起传输
// cancelTransfer { sessionId }
// switchToRelay { sessionId } —— 手动切中继
// session { access_token, refresh_token?, user } —— 原生重登 / 续期后推新令牌对
// shutdown —— 断开 hub、停 ICE 刷新
// 通知(引擎 → 原生,经 notifyNative):
// ready { device } / presence { devices[] } / transfers { active[] } / transferDone {...}
// / sendStarted {...} / error { stage, message }
// sessionRotated { access_token, refresh_token } —— 引擎自刷致 broker 轮换后,回报原生
// 更新 Keychain(重启免用失效旧 refresh
// authExpired {} —— 续期被 401 拒(refresh 过期 / 吊销),原生清 Keychain 回登录页
import { refreshSessionScope } from "../features/auth/auth";
import { fetchClipboard, uploadClipboard } from "../features/clipboard/clipboard";
import { apiFetch } from "../net/api";
import { startHub } from "../features/transfer/hub";
import { refreshICEServers, stopICEServerRefresh } from "../features/transfer/iceServers";
import { cancelTransfer, skipWaitRelay, startOutgoingTransfer } from "../features/transfer/transfer";
import { isIOSShell, notifyNative, onNativeEvent } from "../net/ios";
import { useAppStore } from "../store";
import type { TransferRecord } from "../store/types";
interface SendFilePayload
{
target: string;
url: string;
name: string;
size: number;
type?: string;
}
// 推给原生的精简传输视图:去掉 iceStats 大对象,只留原生 UI 需要的字段 + 一条压缩的
// ICE 摘要(连接态 + 选中候选对),供详情页诊断 P2P 是否走 TURN 中继(pair 含 "relay"
// 即中继路径,解释慢速)。
function toWire(r: TransferRecord)
{
const ice = r.iceStats;
return {
sessionId: r.sessionId,
direction: r.direction,
fileName: r.fileName,
fileSize: r.fileSize,
state: r.state,
mode: r.mode,
peerName: r.peerName,
phase: r.phase,
bytesTransferred: r.bytesTransferred,
bytesPerSec: r.bytesPerSec,
ice: ice
? {
conn: ice.connection,
local: ice.selectedPair?.local,
remote: ice.selectedPair?.remote,
}
: undefined,
};
}
// hub 的生命周期句柄;shutdown 命令据此断开 SSE。
const hubCtrl = new AbortController();
// 活跃传输快照推送节流:接收端每个 64KB chunk 都会更新 storeemitProgress),若每次都
// 过桥推原生,会(1)让速度数字 ~10Hz 闪烁失去参考价值、(2)主线程被 postMessage/序列化抖
// 动堆满拖慢 dc.onmessage(见 p2p.ts 收方看门狗注释)→ 反噬 P2P 吞吐。故合并到 ~3Hz
// 尾沿推送:进度条仍流畅、速度可读,主线程负载大幅下降。终态另由 transferDone 即时推。
const TRANSFERS_MIN_INTERVAL_MS = 350;
let transfersTimer: ReturnType<typeof setTimeout> | null = null;
let pendingActive: unknown[] | null = null;
let lastTransfersAt = 0;
function pushTransfersThrottled(active: unknown[]): void
{
pendingActive = active;
if (transfersTimer !== null) { return; }
const wait = Math.max(0, TRANSFERS_MIN_INTERVAL_MS - (Date.now() - lastTransfersAt));
transfersTimer = setTimeout(() =>
{
transfersTimer = null;
lastTransfersAt = Date.now();
notifyNative("transfers", { active: pendingActive });
pendingActive = null;
}, wait);
}
// 订阅 storeactiveTransfers 引用变化推快照、history 头部新增推完成事件、devices 引用
// 变化推在线列表。原生据 sessionId / name 幂等更新自己的 UI。setDevices / setActive 每次
// 都换数组 / 对象引用,故 !== 即可判变更。
function subscribeStore(): void
{
const st0 = useAppStore.getState();
let prevActive = st0.activeTransfers;
let prevDoneId = st0.history[0]?.sessionId;
let prevDevices = st0.devices;
let prevSse = st0.sseConnected;
let prevAuthed = st0.accessToken !== null;
let prevRefresh = st0.refreshToken;
let presenceCount = 0;
// 把 SSE 连接态 + 收到的 presence 计数推给原生,供设置页诊断「设备空」到底卡在哪:
// SSE 未连接=鉴权/连接问题;已连接但 presence=0=后端没发;presence>0 但列表空=原生侧
// (已用本地隔离证伪——原生侧 OK)。
const pushHubState = (s: ReturnType<typeof useAppStore.getState>): void =>
{
notifyNative("hubState", {
connected: s.sseConnected,
reconnecting: s.sseReconnecting,
presenceCount,
devices: s.devices.length,
});
};
useAppStore.subscribe((s) =>
{
if (s.activeTransfers !== prevActive)
{
prevActive = s.activeTransfers;
pushTransfersThrottled(Object.values(s.activeTransfers).map(toWire));
}
const head = s.history[0];
if (head && head.sessionId !== prevDoneId)
{
prevDoneId = head.sessionId;
notifyNative("transferDone", toWire(head));
}
if (s.devices !== prevDevices)
{
prevDevices = s.devices;
presenceCount += 1;
notifyNative("presence", { devices: s.devices });
pushHubState(s);
}
if (s.sseConnected !== prevSse)
{
prevSse = s.sseConnected;
pushHubState(s);
}
// 引擎自刷(apiFetch 401 → refreshTokens 用注入的 refresh_token 经 /api/auth/refresh
// 续期)后 broker 轮换了 refresh_token。把新令牌对回报原生更新 Keychain,使重启后不再
// 用已轮换失效的旧 refresh。
if (s.refreshToken !== prevRefresh && s.refreshToken)
{
prevRefresh = s.refreshToken;
notifyNative("sessionRotated", {
access_token: s.accessToken,
refresh_token: s.refreshToken,
});
}
// 续期被服务端以 401 拒绝(refresh_token 已过期 / 被吊销)后 forceLogout 清空登录态
// → 通知原生清 Keychain 回登录页,避免「貌似已登录但拉不到任何数据」的死态。仅在确证
// 失效(非瞬时)时触发。
const authed = s.accessToken !== null;
if (prevAuthed && !authed)
{
notifyNative("authExpired", {});
}
prevAuthed = authed;
});
}
// handleSendFile:原生把待发文件经 WKURLSchemeHandler 以 payload.url 暴露,这里取回为
// File 交给引擎发起传输。
//
// R-iOS-4:整文件 fetch 进内存——小 / 中文件可行;大文件在 iOS 上有 jetsam 风险,按
// slice 向原生拉取(不整文件入内存)是真机验证后的优化点,待与原生发送端同期落地。
async function handleSendFile(p: SendFilePayload): Promise<void>
{
try
{
const resp = await fetch(p.url);
if (!resp.ok) { throw new Error(`fetch staged file failed: ${resp.status}`); }
const blob = await resp.blob();
const file = new File([ blob ], p.name, {
type: p.type || blob.type || "application/octet-stream",
});
const sessionId = await startOutgoingTransfer(p.target, file);
notifyNative("sendStarted", { sessionId, name: p.name });
}
catch (e)
{
notifyNative("error", { stage: "send", message: e instanceof Error ? e.message : String(e) });
}
}
// bindCommands:把原生命令接到引擎函数。
function bindCommands(): void
{
onNativeEvent("sendFile", (payload) =>
{
void handleSendFile(payload as SendFilePayload);
});
onNativeEvent("cancelTransfer", (payload) =>
{
void cancelTransfer((payload as { sessionId: string }).sessionId);
});
onNativeEvent("switchToRelay", (payload) =>
{
void skipWaitRelay((payload as { sessionId: string }).sessionId);
});
// 原生 Keychain 续期后推来的新会话令牌 → 写回 store,让 apiFetch 用新令牌(iOS 无
// 浏览器 cookie / Go 进程,token 续期由原生侧持有 refresh_token 后驱动)。
onNativeEvent("session", (payload) =>
{
const p = payload as {
access_token?: string;
refresh_token?: string;
user?: { id: string; name: string; avatar?: string };
};
if (p.access_token && p.user)
{
useAppStore.getState().setAuth({
accessToken: p.access_token,
refreshToken: p.refresh_token ?? null,
user: p.user,
});
}
});
// 剪贴板上行:原生读 UIPasteboard 后把文本送来,经云剪贴板上传(PUT /api/clipboard)。
onNativeEvent("clipboardUpload", (payload) =>
{
const content = (payload as { content?: string }).content ?? "";
if (!content) { return; }
void uploadClipboard(content)
.then(() => notifyNative("clipboardUploaded", {}))
.catch((e) => notifyNative("error", { stage: "clipboard", message: String(e) }));
});
// 剪贴板下行:拉一次云剪贴板,把最新内容推回原生写入 UIPasteboard。
onNativeEvent("clipboardPull", () =>
{
void fetchClipboard()
.then(() =>
{
const c = useAppStore.getState().clipboard;
notifyNative("clipboard", { content: c?.content ?? "", sourceDevice: c?.sourceDevice ?? "" });
})
.catch((e) => notifyNative("error", { stage: "clipboard", message: String(e) }));
});
// 设备管理:移除 / 吊销一台设备(DELETE /api/devices/{name})。需完整会话;若服务端要求
// step-up(403)这里拿不到浏览器再认证流程,回错给原生提示「请在网页端完成」。
onNativeEvent("revokeDevice", (payload) =>
{
const name = (payload as { name?: string }).name ?? "";
if (!name) { return; }
void revokeDevice(name);
});
onNativeEvent("shutdown", () =>
{
hubCtrl.abort();
stopICEServerRefresh();
});
}
async function revokeDevice(name: string): Promise<void>
{
try
{
const r = await apiFetch(`/api/devices/${encodeURIComponent(name)}`, { method: "DELETE" });
if (r.status === 403)
{
notifyNative("error", { stage: "revoke", message: "step_up_required" });
return;
}
if (!r.ok)
{
notifyNative("error", { stage: "revoke", message: `HTTP ${r.status}` });
return;
}
notifyNative("deviceRevoked", { name });
}
catch (e)
{
notifyNative("error", { stage: "revoke", message: String(e) });
}
}
// installLogBridge:把 console.warn / error 过桥给原生(设置页显示「最近日志」)。SSE 失败
// 等都走 console.warn,这样不接 Web Inspector 也能在真机看到失败原因(401 / 网络 / 静默挂起)。
function installLogBridge(): void
{
const safeStr = (a: unknown): string =>
a instanceof Error
? `${a.name}: ${a.message}`
: typeof a === "object" && a !== null
? (() => { try { return JSON.stringify(a); } catch { return String(a); } })()
: String(a);
const wrap = (level: string, orig: (...a: unknown[]) => void) =>
(...args: unknown[]): void =>
{
try { notifyNative("log", { level, msg: args.map(safeStr).join(" ") }); }
catch { /* 永不让日志桥拖垮引擎 */ }
orig(...args);
};
console.warn = wrap("warn", console.warn.bind(console));
console.error = wrap("error", console.error.bind(console));
}
// boot:原生壳内启动引擎。store 已于 import 时从 __CDROP_BOOT__ 水合,故此处同步读取
// 注入的登录态;缺失即报错(原生须在加载本 bundle 前注入)。
function boot(): void
{
if (!isIOSShell())
{
// 非 iOS 壳(误加载 engine.html)——保持惰性,不触发任何网络 / 引擎行为。
// eslint-disable-next-line no-console
console.warn("cdrop engine: not running inside the iOS shell, idle");
return;
}
const { user, selfDeviceName } = useAppStore.getState();
if (!user || !selfDeviceName)
{
notifyNative("error", {
stage: "boot",
message: "missing injected session / device_name (window.__CDROP_BOOT__)",
});
return;
}
installLogBridge(); // 把引擎 console.warn/error 过桥给原生,设置页显示,便于诊断 SSE 失败原因
bindCommands();
subscribeStore();
void startHub(hubCtrl.signal); // SSE 信令循环:presence / 传入 offer / 状态 / 信令
void refreshICEServers(); // 预取 TURN / STUN,首次 WebRTC 即可用
void refreshSessionScope(); // 校正 /api/me 的权限级别
// 启动即推一次当前快照:subscribe 只在「之后」的变更触发,初始态(多为空,但桌面壳
// 复用同入口时可能已有)须主动补发,免原生 UI 等到下一次变更才填。
const init = useAppStore.getState();
notifyNative("ready", { device: selfDeviceName });
notifyNative("presence", { devices: init.devices });
notifyNative("transfers", { active: Object.values(init.activeTransfers).map(toWire) });
// 诊断隔离(仅 CDROP_DEBUG_SESSION 下、prod 用户无此 flag):经真 postMessage 桥推一
// 条假 presence,验证「桥 → 原生解析 → UI 渲染」整条链路是否工作(不依赖真 SSE/鉴权,
// 因 debug token 必 401 拿不到真 presence)。若原生「设备」据此显示出来 → 链路 OK,空
// 列表的真因在上游(SSE 没连 / 后端空);若仍不显示 → 原生侧 bug。
const boot = (window as unknown as { __CDROP_BOOT__?: { debug?: boolean } }).__CDROP_BOOT__;
if (boot?.debug)
{
const nowSec = Math.floor(Date.now() / 1000);
notifyNative("presence", { devices: [
{ name: "Debug-MacBook", type: "macos", online: true, lastSeen: nowSec },
{ name: "Debug-PC", type: "windows", online: false, lastSeen: nowSec - 600 },
] });
}
}
boot();
+258 -304
View File
@@ -3,15 +3,21 @@ import { t } from "../../i18n";
import { apiFetch } from "../../net/api";
import { isDesktop, desktopRefresh, clearDesktopSession } from "../../net/desktop";
import { fetchMeScope } from "../../net/sessions";
import { stashStepUpPending } from "../qr/stepUp";
import { toast } from "../../ui/feedback";
// Auth, post Auth Broker migration (path A). cdrop no longer runs OIDC itself:
// - a brand-new browser logs in via the broker's global SSO (loginRedirect → cdrop
// 302s to the broker login page; on return the edge injects X-Auth from the broker
// domain cookie, so this browser holds NO token — the cookie carries identity);
// - a QR-paired device holds the broker's access + refresh pair directly and renews
// them via /api/auth/refresh (a thin same-origin proxy to the broker).
// ---- dev mode -------------------------------------------------------------
// Dev-mode "login": read ?dev_user=alice (or fall back to localStorage),
// stamp the store with a synthetic User and the build-time dev token.
// This bypasses Casdoor entirely. The middleware on the backend accepts
// any X-Dev-User as long as the bearer matches CDROP_DEV_TOKEN.
// Dev-mode "login": read ?dev_user=alice (or fall back to localStorage), stamp the
// store with a synthetic User and the build-time dev token. Bypasses the broker; the
// backend dev middleware accepts any X-Dev-User as long as the bearer matches
// CDROP_DEV_TOKEN.
export function loginDev(searchParams: URLSearchParams): User
{
const devUser = searchParams.get("dev_user")
@@ -26,292 +32,108 @@ export function loginDev(searchParams: URLSearchParams): User
}
const user: User = { id: devUser, name: devUser };
useAppStore.getState().setAuth({ accessToken: token, user });
useAppStore.getState().setAuth({ accessToken: token, refreshToken: null, user });
return user;
}
// ---- prod login (broker global SSO) ---------------------------------------
// loginRedirect sends the browser to the broker's global-SSO login via cdrop's
// server-side 302 (which holds the broker's public URL). After login the browser
// returns here with a broker domain cookie the edge turns into X-Auth.
export function loginRedirect(): void
{
const rd = encodeURIComponent(window.location.href);
window.location.href = `/api/auth/login?rd=${rd}`;
}
// ---- logout ---------------------------------------------------------------
export function logout()
{
// Fire-and-forget 通知后端立刻把本设备从 Hub 踢掉 + 广播 presence,让对端
// 不必等 30s 宽限期。apiFetch 同步抓取 access_token 后才让出(fetch 已发出),
// 即使紧随其后 clearAuth 也不会污染这次请求的 Authorization 头。失败忽略
// ——只是体验降级回宽限期路径,不影响登出本身。
// Fire-and-forget: tell the backend to kick this device from the Hub immediately
// (so peers don't wait out the 30s grace). apiFetch reads the access token before
// yielding, so the subsequent clearAuth can't strip this request's Authorization.
void apiFetch("/api/me/disconnect", { method: "POST" }).catch(() => { /* ignore */ });
// 浏览器端:删除服务端 session 并清除 HttpOnly cookiecookie 自动随同源请求),
// 否则下次开机 bootstrapAuth 仍会静默免重登回来。桌面端无 cookie session,跳过。
if (!isDesktop() && useAppStore.getState().authMode === "prod")
// Revoke this device's own broker session server-side (self-service logout), so it
// can't be refreshed back. For a global-SSO browser this is a no-op (nothing to
// revoke). Best-effort.
if (useAppStore.getState().authMode === "prod")
{
void fetch("/api/auth/logout", { method: "POST" }).catch(() => { /* ignore */ });
void apiFetch("/api/auth/logout", { method: "POST" }).catch(() => { /* ignore */ });
}
useAppStore.getState().clearAuth();
// 桌面端:删除 Go 侧持久化 session 文件,否则下次启动仍会注入已登录态。
// Desktop: drop the Go-side persisted session, else the next launch re-injects it.
if (isDesktop()) { clearDesktopSession(); }
}
// forceLogout 在「服务端确证会话已失效」时清掉本地登录态——触发点仅一处:
// access token 401 后续期也被服务端以 401 拒绝(refreshTokens 返回 "invalid"
// 即被远程吊销或已过期)。短期连接失败(网络抖动 / 5xx)绝不走到这里(见
// net/api.ts)。与 logout() 不同,它不发任何服务端请求(会话已死,调用只会再次
// 401),只就地清空 + 提示,让 __root 的反应式守卫把界面送回登录页
// (离线设备「下一次使用即得知」)。
//
// 幂等:靠「已登出即跳过」实现——首个 401 同步清空 user/accessToken 后,并发涌入
// 的其余 401 看到已为空便直接返回,不会重复弹 toast、不重复清桌面 session。
// forceLogout clears local auth state when the server authoritatively confirms the
// session is gone (refresh → 401). It sends no request (the session is dead) and just
// clears + toasts; the __root guard then returns the UI to the login page. Idempotent:
// concurrent 401s after the first clear see an empty state and return early.
export function forceLogout(): void
{
const st = useAppStore.getState();
if (!st.user && !st.accessToken)
{
// 已被并发的某个 401 清空,或本就处于登出态:不重复处理(避免 toast 刷屏)。
return;
}
st.clearAuth();
// 桌面端:删除 Go 侧持久化 session,否则下次启动仍注入这枚已失效的登录态。
if (isDesktop()) { clearDesktopSession(); }
toast.error(t("auth.sessionLost.title"), t("auth.sessionLost.body"));
}
// ---- prod (OIDC PKCE) -----------------------------------------------------
// ---- token refresh (broker proxy) -----------------------------------------
interface AuthConfig
{
auth_mode: "dev" | "prod";
authorize_url: string;
client_id: string;
redirect_uri: string;
scopes: string;
}
let cachedConfig: AuthConfig | null = null;
export async function fetchAuthConfig(): Promise<AuthConfig>
{
if (cachedConfig) { return cachedConfig; }
const r = await fetch("/api/auth/config");
if (!r.ok) { throw new Error(`auth config ${r.status}`); }
cachedConfig = await r.json() as AuthConfig;
return cachedConfig;
}
const PKCE_VERIFIER_KEY = "cdrop.pkce_verifier";
const OAUTH_STATE_KEY = "cdrop.oauth_state";
// step-up 再认证用一对独立的 state / verifier 句柄,绝不复用上面那对常规登录键——
// 否则 /oauth/callback 在 step-up 往返里会被当成普通登录、误调 /api/auth/exchange。
// state 走 sessionStorage(仅本次往返、关标签即清,与 step-up 暂存物同生命周期);
// verifier 不单独落键,而是塞进 stepUp.ts 的 PENDING{verifier, returnTo})。
const STEPUP_STATE_KEY = "cdrop.qr.stepup_state";
// loginProd kicks off OIDC PKCE: generate verifier+challenge+state, stash them
// in localStorage, navigate the browser to the provider's /authorize endpoint.
// The callback page completes the flow.
//
// localStorage (not sessionStorage): a standalone PWA on iOS may run the
// provider round-trip in a separate context and relaunch the PWA on the redirect
// back, which wipes sessionStorage and breaks login. localStorage survives that.
// The verifier is consumed + removed immediately on callback and is useless
// without the matching auth code, so persisting it for the round-trip is fine.
export async function loginProd(): Promise<void>
{
const cfg = await fetchAuthConfig();
if (cfg.auth_mode !== "prod")
{
throw new Error(`server reports auth_mode=${cfg.auth_mode}, expected prod`);
}
if (!cfg.authorize_url || !cfg.client_id || !cfg.redirect_uri)
{
throw new Error("OIDC config incomplete (authorize_url / client_id / redirect_uri)");
}
const verifier = generateRandomBase64url(32);
const challenge = await sha256Base64url(verifier);
const state = generateRandomBase64url(16);
localStorage.setItem(PKCE_VERIFIER_KEY, verifier);
localStorage.setItem(OAUTH_STATE_KEY, state);
const params = new URLSearchParams({
client_id: cfg.client_id,
redirect_uri: cfg.redirect_uri,
response_type: "code",
scope: cfg.scopes || "openid profile email",
state,
code_challenge: challenge,
code_challenge_method: "S256",
});
window.location.href = `${cfg.authorize_url}?${params}`;
}
// startStepUpReauth 发起批准页 step-up 再认证:一次新鲜 PKCE 往返,但追加
// prompt=login + max_age=0 强制 provider 现场重证(2FA 即在此处过)。复用既有
// /oauth/callback 作 redirect_uri;跳转前用 sessionStorage 记下「待办」标记 +
// 这次的 verifier + 要回到的批准页 URLreturnTo 走 stepUp.ts 的同款 open-redirect
// 校验,仅允许 /link)。回调检测到「待办」即分叉,不消费 code(见 oauth.callback)。
//
// 注意:这里不写常规 PKCE_VERIFIER_KEY / OAUTH_STATE_KEY,否则普通登录回调会误判。
// verifier 进 PENDING、state 进 STEPUP_STATE_KEY,二者均会在回调与批准页里清掉。
export async function startStepUpReauth(returnTo: string): Promise<void>
{
const cfg = await fetchAuthConfig();
if (cfg.auth_mode !== "prod")
{
throw new Error(`server reports auth_mode=${cfg.auth_mode}, expected prod`);
}
if (!cfg.authorize_url || !cfg.client_id || !cfg.redirect_uri)
{
throw new Error("OIDC config incomplete (authorize_url / client_id / redirect_uri)");
}
const verifier = generateRandomBase64url(32);
const challenge = await sha256Base64url(verifier);
const state = generateRandomBase64url(16);
// 先把「待办」落定(含 returnTo 的 open-redirect 校验);非法即拒,不发起跳转。
if (!stashStepUpPending({ verifier, returnTo }))
{
throw new Error("invalid step-up return path");
}
sessionStorage.setItem(STEPUP_STATE_KEY, state);
const params = new URLSearchParams({
client_id: cfg.client_id,
redirect_uri: cfg.redirect_uri,
response_type: "code",
scope: cfg.scopes || "openid profile email",
state,
code_challenge: challenge,
code_challenge_method: "S256",
prompt: "login",
max_age: "0",
});
window.location.href = `${cfg.authorize_url}?${params}`;
}
// verifyStepUpState 在 /oauth/callback 的 step-up 分叉里校 state(防 CSRF,与常规
// 登录回调同口径)。匹配即消费掉 STEPUP_STATE_KEY 并返回 true;不匹配返回 false。
export function verifyStepUpState(state: string): boolean
{
const expected = sessionStorage.getItem(STEPUP_STATE_KEY);
sessionStorage.removeItem(STEPUP_STATE_KEY);
return !!expected && state === expected;
}
// clearStepUpState 兜底清掉 step-up state(异常路径,避免残留)。
export function clearStepUpState(): void
{
sessionStorage.removeItem(STEPUP_STATE_KEY);
}
interface ExchangeResp
interface BrokerRefreshResp
{
access_token: string;
refresh_token: string;
expires_in: number;
user: { id: string; name: string; avatar?: string };
// refresh_token 不再回传:服务端把它加密存进 web_sessions,浏览器只拿到一枚
// HttpOnly 的 session cookie(随 /api/auth/exchange 响应一并下发)。
}
// completeOAuthLogin runs on /oauth/callback after the provider redirects back.
// Verifies state (CSRF), trades code+verifier for tokens via the backend
// proxy, and stamps the store with the user identity.
//
// Idempotent:若 store 中已有 user + accessToken(典型场景:useEffect 在 React
// 渲染管线中重入,首次已成功 setAuth + 消耗 localStorage,二次入场不能再被
// "PKCE verifier missing" / "state mismatch" 当作失败处理)→ 当作 noop 直接
// resolve,由上层 navigate 接管。
export async function completeOAuthLogin(code: string, state: string): Promise<void>
{
const cur = useAppStore.getState();
if (cur.user && cur.accessToken)
{
return;
}
const expectedState = localStorage.getItem(OAUTH_STATE_KEY);
if (!expectedState || state !== expectedState)
{
throw new Error("OAuth state mismatch (possible CSRF or stale tab)");
}
const verifier = localStorage.getItem(PKCE_VERIFIER_KEY);
if (!verifier)
{
throw new Error("PKCE verifier missing — restart the login flow");
}
const r = await fetch("/api/auth/exchange", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, code_verifier: verifier }),
});
if (!r.ok)
{
const text = await r.text().catch(() => r.statusText);
throw new Error(`token exchange ${r.status}: ${text}`);
}
const data = await r.json() as ExchangeResp;
localStorage.removeItem(PKCE_VERIFIER_KEY);
localStorage.removeItem(OAUTH_STATE_KEY);
useAppStore.getState().setAuth({
accessToken: data.access_token,
user: { id: data.user.id, name: data.user.name, avatar: data.user.avatar },
});
}
interface CookieRefreshResp
{
access_token: string;
expires_in: number;
user: { id: string; name: string; avatar?: string };
device_name?: string;
}
// CookieRefreshResult 区分「服务端确证会话失效」与「短期失败」,使上层只在前者
// 清空登录态:
// ok —— 换到新 access token
// invalid —— /api/auth/refresh 返回 401:服务端查证会话已失效 / 被吊销 / 过期
// (其处理器同时清掉 cookie),凭据确凿不可恢复
// transient —— 5xx / 同源校验 403 / 响应残缺等非确证失败(网络层抛错则根本不进此
// 函数、照常向上抛)——一律保留登录态,按短期错误处理
type CookieRefreshResult =
| { kind: "ok"; accessToken: string; user: User; deviceName: string }
type BrokerRefreshResult =
| { kind: "ok"; accessToken: string; refreshToken: string }
| { kind: "invalid" }
| { kind: "transient" };
// cookieRefresh 用 HttpOnly session cookie 静默换一枚新 access_token。无 body——
// cookie 自动随同源请求发出(Path=/api/auth)。返回见 CookieRefreshResult:只有
// 服务端明确以 401 拒绝才算 invalid,短期失败一律 transient,不误伤登录态。
async function cookieRefresh(): Promise<CookieRefreshResult>
// brokerRefresh exchanges the stored refresh token for a fresh access + rotated
// refresh via cdrop's same-origin proxy to the broker. 401 → invalid (expired /
// rotated / revoked); other non-OK / malformed → transient (keep the session).
async function brokerRefresh(refreshToken: string): Promise<BrokerRefreshResult>
{
const r = await fetch("/api/auth/refresh", { method: "POST" });
if (!r.ok)
let r: Response;
try
{
return { kind: r.status === 401 ? "invalid" : "transient" };
r = await fetch("/api/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
});
}
const data = await r.json().catch(() => null) as CookieRefreshResp | null;
if (!data?.access_token || !data.user?.id) { return { kind: "transient" }; }
return {
kind: "ok",
accessToken: data.access_token,
user: { id: data.user.id, name: data.user.name, avatar: data.user.avatar },
deviceName: data.device_name ?? "",
};
catch
{
return { kind: "transient" };
}
if (!r.ok) { return { kind: r.status === 401 ? "invalid" : "transient" }; }
const data = await r.json().catch(() => null) as BrokerRefreshResp | null;
if (!data?.access_token || !data.refresh_token) { return { kind: "transient" }; }
return { kind: "ok", accessToken: data.access_token, refreshToken: data.refresh_token };
}
// RefreshOutcome 是续期对上层的三态结论;只有 "invalid"(服务端确证失效)才允许
// 上层 forceLogout"transient" 必须保留登录态(短期连接失败不清状态)。
// RefreshOutcome is the three-state conclusion refresh reports upward; only "invalid"
// (server-confirmed dead) lets the caller forceLogout, "transient" must keep state.
export type RefreshOutcome = "refreshed" | "invalid" | "transient";
// refreshTokens 换新 access_tokenapi.ts 在请求拿到 401 时惰性调用。
// refreshTokens renews the access token; api.ts calls it lazily on a 401.
export async function refreshTokens(): Promise<RefreshOutcome>
{
const store = useAppStore.getState();
if (store.authMode !== "prod") { return "transient"; }
// 桌面端:refresh 完全在 Go 内部完成(refresh_token 只在 GoJS 不持有),
// 直接无参调用。Go 侧暂不区分「被吊销」与「短期失败」,故失败一律按 transient
// 处理(不清空登录态)——桌面端即时吊销留后续(AUTH.md §3.1)。
// Desktop: refresh happens entirely inside Go (the refresh token lives in the Go
// process, never in JS). Go does not yet distinguish revoked from transient, so a
// failure is treated as transient (keeps state).
if (isDesktop())
{
const res = await desktopRefresh();
@@ -322,78 +144,210 @@ export async function refreshTokens(): Promise<RefreshOutcome>
return "refreshed";
}
// 浏览器端:走 HttpOnly cookie sessionrefresh_token 在服务端,JS 不持有)。
const res = await cookieRefresh();
if (res.kind !== "ok") { return res.kind; } // "invalid" | "transient",透传给上层
// After 代铸 a browser holds a device-session refresh token and renews via the broker
// proxy below. A browser still on cookie-only identity (代铸 not yet run / failed) holds
// none; a 401 then means the broker cookie is gone too: invalid → re-login.
const refresh = store.refreshToken;
if (!refresh) { return "invalid"; }
const res = await brokerRefresh(refresh);
if (res.kind !== "ok") { return res.kind; }
const cur = useAppStore.getState();
cur.setAuth({ accessToken: res.accessToken, user: res.user });
// 设备名以服务端为权威;本地缺失(PWA 存储被清)时回填。
if (res.deviceName && !cur.selfDeviceName) { cur.setSelfDeviceName(res.deviceName); }
if (!cur.user) { return "transient"; }
cur.setAuth({ accessToken: res.accessToken, refreshToken: res.refreshToken, user: cur.user });
return "refreshed";
}
// bootstrapAuth 在 App 挂载前尝试「免重登」:浏览器端若本会话尚无 access_token
// 用 HttpOnly cookie 静默续期,成功即直接进入已登录态(并回填服务端所记设备名),
// 失败则照常落到登录页。桌面端由注入式水合负责、dev 无 cookie 流程,均直接跳过。
export async function bootstrapAuth(): Promise<void>
{
if (isDesktop()) { return; }
const store = useAppStore.getState();
if (store.authMode !== "prod") { return; }
if (store.accessToken && store.user) { return; } // 同会话已登录(sessionStorage 命中)
// ---- session bootstrap (/api/me) ------------------------------------------
const res = await cookieRefresh();
if (res.kind !== "ok") { return; } // 开机水合失败(含 invalid / transient)→ 照常落登录页
const cur = useAppStore.getState();
cur.setAuth({ accessToken: res.accessToken, user: res.user });
if (res.deviceName && !cur.selfDeviceName) { cur.setSelfDeviceName(res.deviceName); }
interface MeResp
{
user_id: string;
name: string;
avatar?: string;
device_name?: string;
scope?: string;
}
// refreshSessionScope 拉一次 /api/me 校正本会话权限级别(full / guest)并写进
// store。在登录成功 / 开机水合后调用一次,使受限访客的账号管理入口被隐藏。失败
// 静默回退 "full"——只多展示入口,后端仍 403 兜底。dev 模式无 guest 概念,跳过。
// fetchMe confirms the session with the server: a QR device sends its stored Bearer, a
// global-SSO browser relies on the broker cookie (no header). Returns null on any
// non-200 / error, so the caller falls to the login page.
async function fetchMe(): Promise<MeResp | null>
{
const token = useAppStore.getState().accessToken;
const headers: Record<string, string> = {};
if (token) { headers.Authorization = `Bearer ${token}`; }
let r: Response;
try { r = await fetch("/api/me", { headers }); }
catch { return null; }
if (!r.ok) { return null; }
return await r.json().catch(() => null) as MeResp | null;
}
// bootstrapAuth runs before the app mounts: it confirms the session via /api/me and
// hydrates the identity (a stored device token is preserved; a global-SSO browser has
// none). Failure leaves the app logged out → login page. Desktop hydrates from boot
// injection and dev has no broker session; both skip.
export async function bootstrapAuth(): Promise<void>
{
const store = useAppStore.getState();
if (store.authMode !== "prod") { return; }
if (isDesktop())
{
// Desktop identity (name + avatar) comes from the Go boot injection, which heals a stale
// persisted identity from /api/me — refreshing the token if needed and re-persisting —
// BEFORE injecting it (see desktop HealIdentity). Doing it Go-side, at the source, is why
// it no longer depends on a fragile per-launch WebView /api/me. Nothing to do here.
return;
}
const me = await fetchMe();
if (!me?.user_id) { return; }
const cur = useAppStore.getState();
// Use /api/me's name only when it is a real display name. The server falls back to the
// subject UUID when X-Auth-Name is absent; in that case keep an already-known name (e.g.
// rehydrated from a prior login) rather than clobbering it with the UUID.
const verifiedName = me.name && me.name !== me.user_id ? me.name : "";
cur.setAuth({
accessToken: cur.accessToken, // preserve a stored device token, or null (cookie)
user: {
id: me.user_id,
name: verifiedName || cur.user?.name || me.user_id,
avatar: me.avatar || cur.user?.avatar,
},
});
cur.setSessionScope(me.scope === "guest" ? "guest" : "full");
if (me.device_name && !cur.selfDeviceName) { cur.setSelfDeviceName(me.device_name); }
// Upgrade a global-SSO browser into a cdrop-managed device session (代铸) so it joins
// the unified device list and rides a Bearer token like a QR-paired device. No-op if it
// already holds a token or has no device name yet (first-time setup mints it on naming).
await ensureDeviceSession();
}
// ---- 代铸 (managed device session) ----------------------------------------
// DEVICE_ID_KEY persists this browser's stable cdrop device_id (the broker `meta` /
// session<->device join key). It survives sessionStorage token loss so a re-login rotates
// the same session (broker R2 idempotency) instead of spawning a duplicate device.
const DEVICE_ID_KEY = "cdrop.device_id";
interface DeviceSessionResp
{
access_token: string;
refresh_token: string;
expires_in: number;
device_id: string;
device_name: string;
name: string;
}
// ensureDeviceSession turns a global-SSO browser (cookie identity, no token) into a
// cdrop-managed device session: the backend 代铸s a session bound to this browser's stable
// device_id, so the browser appears in the unified device list exactly like a QR-paired
// device and rides a Bearer token thereafter. Idempotent and best-effort:
// - dev / desktop / not-logged-in → skip;
// - already holds a token (QR device or a prior 代铸) → skip;
// - no device name yet → skip (first-time setup calls this again after naming);
// - on any failure the browser keeps working via the broker cookie (degraded: it just
// won't show in the device list until a later 代铸 succeeds).
export async function ensureDeviceSession(): Promise<void>
{
const st = useAppStore.getState();
if (st.authMode !== "prod" || isDesktop()) { return; }
if (!st.user) { return; }
if (st.accessToken) { return; }
if (!st.selfDeviceName) { return; }
// Serialize 代铸 across all same-origin tabs. Without this, two tabs (or a reload before the
// first mint persists device_id) each 代铸 with an EMPTY device_id, and the server mints a
// fresh id for each → duplicate phantom devices (the very regression this rework fixes). The
// first lock holder mints and persists device_id; later holders read the seeded id and the
// broker R2-rotates the one session. Web Locks coordinate across tabs; absent the API, fall
// back to a best-effort single call.
// Wrap in an arrow so the LockManager callback's Lock argument is not passed on as the
// mintDeviceSession `force` flag.
if (typeof navigator !== "undefined" && navigator.locks)
{
await navigator.locks.request("cdrop.device-session", () => mintDeviceSession());
}
else
{
await mintDeviceSession();
}
}
// renameDeviceSession re-代铸s with the (already-updated) selfDeviceName and the same stable
// device_id, so the broker label — hence the unified device list and presence — reflects the
// new name. R2 rotates the one session in place (no duplicate device row); the rotated tokens
// replace the browser's current ones. No-op unless this browser already holds a device session
// (a cookie-only browser adopts the name at its first 代铸; desktop renames in its own client).
export async function renameDeviceSession(): Promise<void>
{
const st = useAppStore.getState();
if (st.authMode !== "prod" || isDesktop()) { return; }
if (!st.user || !st.accessToken || !st.selfDeviceName) { return; }
await mintDeviceSession(true);
}
async function mintDeviceSession(force = false): Promise<void>
{
// Re-read inside the lock: another tab may have just seeded device_id (or this tab may have
// gained a token meanwhile). force=true is the rename path: re-mint even though a token is
// already held, to push the new label to the broker.
const st = useAppStore.getState();
if (!force && st.accessToken) { return; }
const deviceName = st.selfDeviceName;
if (!deviceName) { return; }
const storedId = window.localStorage.getItem(DEVICE_ID_KEY) ?? "";
let r: Response;
try
{
// Raw fetch (not apiFetch): identity rides the broker cookie, and a 401 here must
// not trigger the refresh→forceLogout path (there is no refresh token yet).
r = await fetch("/api/auth/device-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
device_id: storedId,
device_name: deviceName,
device_type: "browser",
}),
});
}
catch { return; }
if (!r.ok) { return; }
const data = await r.json().catch(() => null) as DeviceSessionResp | null;
if (!data?.access_token || !data.device_id) { return; }
window.localStorage.setItem(DEVICE_ID_KEY, data.device_id);
const cur = useAppStore.getState();
if (!cur.user) { return; }
cur.setAuth({
accessToken: data.access_token,
refreshToken: data.refresh_token,
user: cur.user,
});
}
// refreshSessionScope re-reads /api/me to correct the session's capability level
// (full / guest) into the store, so a restricted guest's account-management entries
// stay hidden. Best-effort; failure falls back to "full" (entries only show, backend
// still 403s). dev has no guest concept; skip.
export async function refreshSessionScope(): Promise<void>
{
const store = useAppStore.getState();
if (store.authMode !== "prod") { return; }
if (!store.user || !store.accessToken) { return; }
if (!store.user) { return; }
const scope = await fetchMeScope();
useAppStore.getState().setSessionScope(scope);
}
// syncWebDeviceName 把本机设备名推到服务端 session(cookie 鉴权),让 PWA 存储被清
// 后开机仍能从 cookie session 恢复设备名、不再误跳 /setup。仅浏览器 prod;桌面端走
// persistDesktopDeviceNameGo 持久化),dev 无 session。失败静默——只是体验降级。
export function syncWebDeviceName(name: string): void
// syncWebDeviceName is retained as a no-op after the migration: a QR-paired device's
// name is set server-side at pairing (and renamed via the device API); a global-SSO
// browser keeps no managed device row. The local selfDeviceName still drives presence.
export function syncWebDeviceName(_name: string): void
{
if (isDesktop()) { return; }
if (useAppStore.getState().authMode !== "prod") { return; }
void fetch("/api/auth/device", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device_name: name }),
}).catch(() => { /* ignore */ });
}
// ---- PKCE helpers ---------------------------------------------------------
function generateRandomBase64url(numBytes: number): string
{
const arr = new Uint8Array(numBytes);
crypto.getRandomValues(arr);
return base64urlEncode(arr);
}
async function sha256Base64url(input: string): Promise<string>
{
const buf = new TextEncoder().encode(input);
const hash = await crypto.subtle.digest("SHA-256", buf);
return base64urlEncode(new Uint8Array(hash));
}
function base64urlEncode(bytes: Uint8Array): string
{
let s = "";
for (let i = 0; i < bytes.length; i += 1) { s += String.fromCharCode(bytes[i]); }
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
/* no-op: see comment above */
}
-128
View File
@@ -1,128 +0,0 @@
// 批准页 step-up 再认证的会话级暂存。step_up 开启时,批准方在批准新设备前必须做
// 一次新鲜的 prompt=login 再登录(provider 若开了 2FA 即在此处过),把这次拿到的
// 授权码交给后端就地核验。这段往返跨一次整页跳转(→ provider → /oauth/callback →
// 批准页),故用 sessionStorage 串起三个状态:
//
// 1. 跳转去 provider 前:记下「待办」标记 + 这次的 code_verifier + 要回到的批准页
// URLPENDING)。
// 2. /oauth/callback 落地(检测到 PENDING):不调 /api/auth/exchange(那会在服务端
// 消费掉 code 并轮换会话),转而把本次回调的 {code, verifier} 暂存进 STASH
// 清掉 PENDING,整页跳回批准页 URL。
// 3. 批准页回到后:取出 STASH 的 {code, verifier} → 调 qrApprove 就地核验 → 用完即清。
//
// sessionStorage(非 localStorage):仅本次再认证往返需要,关标签即清、不跨设备;
// code 是一次性的且与本批准会话强绑定,落 localStorage 反而徒增泄漏面。
const PENDING_KEY = "cdrop.qr.stepup_pending"; // { verifier, returnTo }
const STASH_KEY = "cdrop.qr.stepup_stash"; // { code, verifier }
// 登录会话登出的 step-up:跨整页跳转记住「要登出的是哪条会话」。回到设置页后取出,
// 重试那条 DELETE。批准页的 step-up 不用它(要批准的 request 已编在 returnTo 里)。
const REVOKE_KEY = "cdrop.qr.stepup_revoke"; // sessionIdstring
// 仅允许两个站内回流目标,杜绝 open-redirect(外部 URL / 协议相对地址 / 任意路径):
// - /link[?…] ——批准页(扫码登录的 step-up);
// - /settings ——设置页(登录会话登出的 step-up)。
// step-up 往返回来后的整页跳转目标只能是这两处之一;其余一律拒。校验 path 必须以
// "/link" 或 "/settings" 起头且后随串只能是空或 "?…",挡住 "/linkmalicious" /
// "//evil.com"(协议相对)/ "/settings/../x" 之类绕过。
function isSafeReturnPath(path: string): boolean
{
for (const base of [ "/link", "/settings" ])
{
if (path === base) { return true; }
if (path.startsWith(base + "?")) { return true; }
}
return false;
}
export interface StepUpPending
{
verifier: string;
returnTo: string;
}
export interface StepUpStash
{
code: string;
verifier: string;
}
// stashStepUpPending 在跳转去 provider 前记下「待办」。returnTo 非法(防开放重定向)
// 即不写——调用方据返回值判定是否安全发起跳转。
export function stashStepUpPending(pending: StepUpPending): boolean
{
if (typeof window === "undefined") { return false; }
if (!pending.verifier || !isSafeReturnPath(pending.returnTo)) { return false; }
window.sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending));
return true;
}
// peekStepUpPending 只读地探测是否在 step-up 往返中(/oauth/callback 据此分叉,
// 决定不走 /api/auth/exchange)。不消费。
export function peekStepUpPending(): StepUpPending | null
{
if (typeof window === "undefined") { return null; }
const raw = window.sessionStorage.getItem(PENDING_KEY);
if (!raw) { return null; }
const p = safeParse<StepUpPending>(raw);
if (!p?.verifier || !isSafeReturnPath(p.returnTo)) { return null; }
return p;
}
export function clearStepUpPending(): void
{
if (typeof window === "undefined") { return; }
window.sessionStorage.removeItem(PENDING_KEY);
}
// stashStepUpCode 把回调拿到的一次性 code + 配套 verifier 暂存,留给批准页交给
// 后端 approve 核验。前端绝不自己 POST /exchange 消费它。
export function stashStepUpCode(stash: StepUpStash): void
{
if (typeof window === "undefined") { return; }
if (!stash.code || !stash.verifier) { return; }
window.sessionStorage.setItem(STASH_KEY, JSON.stringify(stash));
}
// consumeStepUpCode 取出并清除暂存的 {code, verifier}(一次性,用完即清,避免泄漏
// 与复用)。无 / 非法返回 null。
export function consumeStepUpCode(): StepUpStash | null
{
if (typeof window === "undefined") { return null; }
const raw = window.sessionStorage.getItem(STASH_KEY);
window.sessionStorage.removeItem(STASH_KEY);
if (!raw) { return null; }
const s = safeParse<StepUpStash>(raw);
if (!s?.code || !s.verifier) { return null; }
return s;
}
export function clearStepUpStash(): void
{
if (typeof window === "undefined") { return; }
window.sessionStorage.removeItem(STASH_KEY);
}
// stashPendingRevoke 在发起会话登出的 step-up 再认证前记下待登出的 sessionId,
// 跨整页跳转留到回到设置页后取出重试。
export function stashPendingRevoke(sessionId: string): void
{
if (typeof window === "undefined") { return; }
if (!sessionId) { return; }
window.sessionStorage.setItem(REVOKE_KEY, sessionId);
}
// consumePendingRevoke 取出并清除待登出的 sessionId(一次性,用完即清)。
export function consumePendingRevoke(): string | null
{
if (typeof window === "undefined") { return null; }
const id = window.sessionStorage.getItem(REVOKE_KEY);
window.sessionStorage.removeItem(REVOKE_KEY);
return id || null;
}
function safeParse<T>(raw: string): T | null
{
try { return JSON.parse(raw) as T; }
catch { return null; }
}
+93 -15
View File
@@ -27,6 +27,14 @@ import {
isDesktop,
saveIncomingFileDesktop,
} from "../../net/desktop";
import {
abortIncomingDownloadIOS,
appendIncomingDownloadIOS,
beginIncomingDownloadIOS,
finalizeIncomingDownloadIOS,
isIOSShell,
saveIncomingFileIOS,
} from "../../net/ios";
import { toast } from "../../ui/feedback";
// close() 两态结果:blob=整文件在内存,交给 downloadBlobsaved=桌面大文件已流式
@@ -45,9 +53,13 @@ export interface IncomingSink
cancel(): Promise<void>;
}
// 桌面混合 sink 的内存上限与 spill 批大小:暂存达 CAP 即转流式落盘,之后每满一批刷。
// 混合 sink 的内存上限与 spill 批大小:暂存达 CAP 即转流式落盘,之后每满一批刷。
// 桌面 RAM 充裕、上限放宽;iOS 受 jetsam 限制(无预警直接 kill 进程),更早 spill、
// 批更小,避免接收大文件时内存峰值触发 kill。
const DESKTOP_MEMORY_CAP = 256 * 1024 * 1024;
const DESKTOP_FLUSH_BATCH = 64 * 1024 * 1024;
const IOS_MEMORY_CAP = 64 * 1024 * 1024;
const IOS_FLUSH_BATCH = 16 * 1024 * 1024;
/**
* sink OPFS退
@@ -60,6 +72,10 @@ export async function openIncomingSink(sessionId: string, fileName: string): Pro
{
return openHybridDesktopSink(sessionId, fileName);
}
if (isIOSShell())
{
return openHybridIOSSink(sessionId, fileName);
}
// 注意:直接 navigator.storage?.getDirectory 在 TS 下永远 truthyoptional
// chaining 取的是函数引用,不会调用)。要真探测必须 try-catch 调用。
if (typeof navigator !== "undefined" && navigator.storage)
@@ -74,38 +90,55 @@ export async function openIncomingSink(sessionId: string, fileName: string): Pro
return openMemorySink();
}
// 混合有界内存槽的存储后端配置:内存上限 / spill 批大小 + 四个落盘桥方法(begin /
// append / finalize / abort,与桌面、iOS 各自的原生桥同形)。
interface StreamingSinkConfig
{
cap: number;
batch: number;
begin: (sessionId: string) => Promise<void>;
append: (sessionId: string, bytes: Uint8Array) => Promise<void>;
finalize: (sessionId: string, name: string) => Promise<string>;
abort: (sessionId: string) => Promise<void>;
}
/**
* < CAPclose Blob
* downloadBlob= Go CAP
* Go
* iOS < cap
* close Blob downloadBlob= cap
*
*
*
* write() p2p / relay receiveQueue promise
*
*/
function openHybridDesktopSink(sessionId: string, fileName: string): IncomingSink
function openHybridStreamingSink(
sessionId: string,
fileName: string,
cfg: StreamingSinkConfig,
): IncomingSink
{
let pending: ArrayBuffer[] = [];
let pendingBytes = 0;
let spilled = false;
let closed = false;
// 从 pending 头部取出至多 DESKTOP_FLUSH_BATCH 字节拼成一块 append 给 Go。
// drainAll=true 把剩余全部刷完(spill 起始 / close);否则只在满一批时刷。
// 从 pending 头部取出至多 cfg.batch 字节拼成一块 append 给原生。drainAll=true 把
// 剩余全部刷完(spill 起始 / close);否则只在满一批时刷。
const flush = async (drainAll: boolean): Promise<void> =>
{
while (pendingBytes >= DESKTOP_FLUSH_BATCH || (drainAll && pendingBytes > 0))
while (pendingBytes >= cfg.batch || (drainAll && pendingBytes > 0))
{
let take = 0;
const batch: ArrayBuffer[] = [];
while (pending.length > 0
&& (take === 0 || take + pending[0].byteLength <= DESKTOP_FLUSH_BATCH))
&& (take === 0 || take + pending[0].byteLength <= cfg.batch))
{
const c = pending.shift() as ArrayBuffer;
batch.push(c);
take += c.byteLength;
}
pendingBytes -= take;
await appendIncomingDownloadDesktop(sessionId, concatChunks(batch, take));
await cfg.append(sessionId, concatChunks(batch, take));
}
};
@@ -120,11 +153,11 @@ function openHybridDesktopSink(sessionId: string, fileName: string): IncomingSin
if (!spilled)
{
if (pendingBytes >= DESKTOP_MEMORY_CAP)
if (pendingBytes >= cfg.cap)
{
// 越过内存上限:开始流式落盘,把已暂存的全部刷给 Go,转滚动批。
// 越过内存上限:开始流式落盘,把已暂存的全部刷给原生,转滚动批。
spilled = true;
await beginIncomingDownloadDesktop(sessionId);
await cfg.begin(sessionId);
await flush(true);
}
return;
@@ -141,7 +174,7 @@ function openHybridDesktopSink(sessionId: string, fileName: string): IncomingSin
return { kind: "blob", blob: new Blob(pending) };
}
await flush(true);
const path = await finalizeIncomingDownloadDesktop(sessionId, fileName);
const path = await cfg.finalize(sessionId, fileName);
return { kind: "saved", path };
},
async cancel()
@@ -149,11 +182,37 @@ function openHybridDesktopSink(sessionId: string, fileName: string): IncomingSin
closed = true;
pending = [];
pendingBytes = 0;
if (spilled) { await abortIncomingDownloadDesktop(sessionId); }
if (spilled) { await cfg.abort(sessionId); }
},
};
}
// 桌面后端(Wails → Go)。
function openHybridDesktopSink(sessionId: string, fileName: string): IncomingSink
{
return openHybridStreamingSink(sessionId, fileName, {
cap: DESKTOP_MEMORY_CAP,
batch: DESKTOP_FLUSH_BATCH,
begin: beginIncomingDownloadDesktop,
append: appendIncomingDownloadDesktop,
finalize: finalizeIncomingDownloadDesktop,
abort: abortIncomingDownloadDesktop,
});
}
// iOS 后端(WKWebView 桥 → 原生沙盒)。内存上限更小,避免 jetsam(见 IOS_MEMORY_CAP)。
function openHybridIOSSink(sessionId: string, fileName: string): IncomingSink
{
return openHybridStreamingSink(sessionId, fileName, {
cap: IOS_MEMORY_CAP,
batch: IOS_FLUSH_BATCH,
begin: beginIncomingDownloadIOS,
append: appendIncomingDownloadIOS,
finalize: finalizeIncomingDownloadIOS,
abort: abortIncomingDownloadIOS,
});
}
// concatChunks 把若干 ArrayBuffer 拼成一个 Uint8Array(供 base64 过桥)。
function concatChunks(parts: ArrayBuffer[], totalBytes: number): Uint8Array
{
@@ -275,6 +334,25 @@ export function downloadBlob(blob: Blob, fileName: string, sessionId?: string):
});
return;
}
// iOS 无头壳同理:整文件经桥交原生写入沙盒下载目录;WKWebView 无浏览器下载回退,
// 故失败必须显式报错、绝不静默丢文件(同桌面)。
if (isIOSShell())
{
void saveIncomingFileIOS(fileName, blob)
.then((savedPath) =>
{
toast.ok(t("transfer.savedTo", { path: savedPath }));
if (sessionId) { void cleanupOPFS(sessionId); }
})
.catch((e) =>
{
// eslint-disable-next-line no-console
console.error("ios save failed", e);
toast.error(t("transfer.saveFailed", { error: e instanceof Error ? e.message : String(e) }));
if (sessionId) { void cleanupOPFS(sessionId); }
});
return;
}
browserDownload(blob, fileName, sessionId);
}
+27 -6
View File
@@ -23,6 +23,11 @@ const CHANNEL_NAME = "cdrop-file";
const CHUNK_SIZE = 64 * 1024;
const HIGH_WATERMARK = 16 * 1024 * 1024;
const LOW_WATERMARK = 4 * 1024 * 1024;
// 发送端「读写解耦」:一次读 4 MiB 进内存,再从内存切 CHUNK_SIZE 子片 dc.send。原先
// 每 64 KiB 都 await file.slice().arrayBuffer(),逐片异步文件读在 iOS Safari 上极慢(实测
// 直连 P2P 仅 ~250 KB/s,远低于中继 3 MB/s)。块读把异步文件读次数降 ~64 倍,SCTP 消息
// 大小与流控(HIGH/LOW watermark)完全不变,接收端无感。
const READ_BLOCK_SIZE = 4 * 1024 * 1024;
const ICE_CANDIDATE_POOL = 4;
export interface FileMeta
@@ -498,23 +503,36 @@ class Session
// "已交付字节"随接收端实际收程平滑爬升、lastProgressAt 保持新鲜,不误报"疑似卡住"。
this.startSendProgressPoll();
// 发送块大小固定 64 KiB:实测放大到 256 KiB 在 iOS Safari 上反而把吞吐打到
// <100 KB/sSCTP 大消息分片/重组代价),故维持 CHUNK_SIZE。读写解耦(块读)保留。
const sendChunk = CHUNK_SIZE;
let offset = 0;
while (offset < file.size)
{
// 一次读一大块进内存(异步文件读从每 64 KiB 一次降到每 4 MiB 一次)。
const blockEnd = Math.min(offset + READ_BLOCK_SIZE, file.size);
const block = new Uint8Array(await file.slice(offset, blockEnd).arrayBuffer());
// 从内存块按 sendChunk 子片发送;流控(bufferedAmount/水位)逻辑不变。
let inBlock = 0;
while (inBlock < block.byteLength)
{
// 预测式检查:在 send 之前看"加了这一片之后会不会撞上 HIGH"。
// Chrome WebRTC 的 RTCDataChannel.send 在 bufferedAmount + size 超过
// kMaxQueuedSendDataBytes16 MiB)时直接抛 OperationError;我们 HIGH
// 也设 16 MiB,原本的 `bufferedAmount > HIGH` 检查在 buffer = HIGH - 1
// 时不触发,下一片就会跨过硬上限挂掉。
if (dc.bufferedAmount + CHUNK_SIZE > HIGH_WATERMARK)
if (dc.bufferedAmount + sendChunk > HIGH_WATERMARK)
{
await waitForBufferLow(dc, LOW_WATERMARK);
}
const slice = file.slice(offset, Math.min(offset + CHUNK_SIZE, file.size));
const buf = await slice.arrayBuffer();
const end = Math.min(inBlock + sendChunk, block.byteLength);
// subarray 是零拷贝视图;dc.send 接受 ArrayBufferView,只发该视图字节范围。
const chunk = block.subarray(inBlock, end);
try
{
dc.send(buf);
dc.send(chunk);
}
catch (e)
{
@@ -530,18 +548,21 @@ class Session
bytesSent: this.bytesSent,
});
await waitForBufferLow(dc, LOW_WATERMARK);
dc.send(buf);
dc.send(chunk);
}
else
{
throw e;
}
}
offset += buf.byteLength;
const sent = end - inBlock;
inBlock += sent;
offset += sent;
this.bytesSent = offset;
if (!this.firstByteSent) { this.firstByteSent = true; this.setPhase("transferring"); }
this.emitProgress();
}
}
dc.send(JSON.stringify({ type: "done" }));
this.setPhase("completing");
+66
View File
@@ -145,6 +145,8 @@ export const enUS: Partial<TranslationDict> = {
"qr.approve.once.hint": "Signed in for 1 hour, then scan again.",
"qr.approve.scopeFull": "This device signs fully into your account for 7 days: it can send and receive files and messages, and manage your account and devices.",
"qr.approve.scopeGuest": "Signs in as a limited guest — it can send and receive files and messages, but can't change your account or approve other devices; expires after 1 hour.",
"qr.approve.nativeFull": "This is a native app device — it signs in with full access (trusted, valid for 7 days). Native apps don't support limited guest sessions.",
"qr.approve.trustContinue": "Trust & Continue",
"qr.approve.approve": "Approve sign-in",
"qr.approve.stepUp.notice": "For security, verify your identity before approving — the button below takes you through a fresh sign-in.",
"qr.approve.stepUp.button": "Verify and approve",
@@ -343,4 +345,68 @@ export const enUS: Partial<TranslationDict> = {
"errors.clipboardUnavailable": "Browser clipboard API unavailable (requires HTTPS + permission)",
"errors.clipboardOverflow": "Content too large; exceeds {{max}} byte limit",
"errors.clipboardWriteFailed": "Failed to write to local clipboard: {{message}}",
// ---- iOS client ------------------------------------------------------
"ios.tab.transfer": "Transfers",
"ios.tab.devices": "Devices",
"ios.tab.settings": "Settings",
"ios.login.generating": "Generating code…",
"ios.login.waiting": "Waiting for approval",
"ios.login.guide": "Scan to approve from another signed-in device",
"ios.login.expired": "Code expired — please try again",
"ios.login.failed": "Sign-in failed — please try again",
"ios.login.refresh": "Refresh QR Code",
"ios.login.needFull": "This device needs full access — choose \"Trust this device\" when approving.",
"ios.send.pickDevice": "Choose a device",
"ios.transfer.sending": "Sending",
"ios.settings.engine": "Engine",
"ios.settings.status": "Status",
"ios.settings.device": "Device",
"ios.settings.clipboard": "Clipboard",
"ios.settings.clipboardNote": "Upload pushes this device's clipboard to your others; Pull writes the latest cloud clipboard.",
"ios.clipboard.upload": "Upload Clipboard",
"ios.clipboard.pull": "Pull Clipboard",
"ios.clipboard.uploading": "Uploading…",
"ios.clipboard.pulling": "Pulling…",
"ios.clipboard.uploaded": "Uploaded to cloud clipboard",
"ios.clipboard.pulled": "Written to clipboard",
"ios.clipboard.empty": "Clipboard is empty",
"ios.clipboard.failed": "Clipboard sync failed",
"ios.devices.remove": "Remove Device",
"ios.devices.thisDevice": "This device",
"ios.devices.revoked": "Device removed",
"ios.devices.revokeFailed": "Remove failed",
"ios.devices.revokeStepUp": "Re-verify on the web to remove this device",
"ios.engine.disconnected": "Disconnected",
"ios.engine.ready": "Ready",
"ios.transfer.empty": "No transfers yet",
"ios.transfer.incoming": "Receive",
"ios.transfer.outgoing": "Send",
"ios.send.noDevices": "No online devices to send to",
"ios.devices.empty": "No devices yet",
"ios.detail.title": "Transfer Details",
"ios.detail.direction": "Direction",
"ios.detail.peer": "Peer",
"ios.detail.state": "State",
"ios.detail.phase": "Phase",
"ios.detail.channel": "Channel",
"ios.detail.size": "Size",
"ios.detail.progress": "Progress",
"ios.detail.speed": "Speed",
"ios.settings.account": "Account",
"ios.settings.user": "User",
"ios.settings.logout": "Sign Out",
"ios.settings.deviceName": "Device Name",
"ios.settings.deviceNameNote": "Renaming takes effect on next sign-in",
"ios.settings.deviceCount": "Known Devices",
"ios.settings.signaling": "Signaling",
"ios.settings.presenceEvents": "Presence Events",
"ios.settings.reconnecting": "Reconnecting",
"ios.settings.lastLog": "Last Log",
"ios.tab.files": "Files",
"ios.files.title": "Received Files",
"ios.files.empty": "No files received yet",
"ios.files.share": "Share",
"ios.files.done": "Done",
"ios.detail.openFile": "Open File",
};
+66
View File
@@ -142,6 +142,8 @@ export const zhCN = {
"qr.approve.once.hint": "登录有效 1 小时,到期需重新扫码。",
"qr.approve.scopeFull": "这台设备将完整登录你的账号、7 天内有效:可正常收发文件与消息,并能管理账号与设备。",
"qr.approve.scopeGuest": "以受限访客身份登录——可收发文件与消息,但不能更改账号、不能批准其他设备;1 小时后失效。",
"qr.approve.nativeFull": "这是一台原生应用设备,将以完整权限登录(信任此设备、7 天内有效)。原生应用不支持受限访客。",
"qr.approve.trustContinue": "信任并继续",
"qr.approve.approve": "批准登录",
"qr.approve.stepUp.notice": "为安全起见,批准前需先验证你的身份——点下方按钮会引导你重新登录一次。",
"qr.approve.stepUp.button": "验证并批准",
@@ -339,4 +341,68 @@ export const zhCN = {
"errors.clipboardUnavailable": "浏览器不支持剪贴板API(需HTTPS与权限)",
"errors.clipboardOverflow": "内容过大,超过 {{max}} 字节上限",
"errors.clipboardWriteFailed": "写入本机剪贴板失败:{{message}}",
// ---- iOS 客户端 ------------------------------------------------------
"ios.tab.transfer": "传输",
"ios.tab.devices": "设备",
"ios.tab.settings": "设置",
"ios.login.generating": "正在生成二维码…",
"ios.login.waiting": "等待扫码批准",
"ios.login.guide": "用另一台已登录的设备扫码批准",
"ios.login.expired": "二维码已失效,请重试",
"ios.login.failed": "登录失败,请重试",
"ios.login.refresh": "刷新二维码",
"ios.login.needFull": "此设备需要完整权限,批准时请选择“信任此设备”",
"ios.send.pickDevice": "选择接收设备",
"ios.transfer.sending": "发送中",
"ios.settings.engine": "引擎",
"ios.settings.status": "状态",
"ios.settings.device": "设备",
"ios.settings.clipboard": "剪贴板",
"ios.settings.clipboardNote": "上传把本机剪贴板推到其他设备;拉取写入最新云剪贴板。",
"ios.clipboard.upload": "上传剪贴板",
"ios.clipboard.pull": "拉取剪贴板",
"ios.clipboard.uploading": "正在上传…",
"ios.clipboard.pulling": "正在拉取…",
"ios.clipboard.uploaded": "已上传到云剪贴板",
"ios.clipboard.pulled": "已写入本机剪贴板",
"ios.clipboard.empty": "剪贴板为空",
"ios.clipboard.failed": "剪贴板同步失败",
"ios.devices.remove": "移除设备",
"ios.devices.thisDevice": "本机",
"ios.devices.revoked": "已移除设备",
"ios.devices.revokeFailed": "移除失败",
"ios.devices.revokeStepUp": "需在网页端重新验证后才能移除",
"ios.engine.disconnected": "未连接",
"ios.engine.ready": "已就绪",
"ios.transfer.empty": "暂无传输",
"ios.transfer.incoming": "接收",
"ios.transfer.outgoing": "发送",
"ios.send.noDevices": "没有在线设备,无法发送",
"ios.devices.empty": "暂无设备",
"ios.detail.title": "传输详情",
"ios.detail.direction": "方向",
"ios.detail.peer": "对端",
"ios.detail.state": "状态",
"ios.detail.phase": "阶段",
"ios.detail.channel": "通道",
"ios.detail.size": "大小",
"ios.detail.progress": "进度",
"ios.detail.speed": "速度",
"ios.settings.account": "账户",
"ios.settings.user": "用户",
"ios.settings.logout": "退出登录",
"ios.settings.deviceName": "设备名称",
"ios.settings.deviceNameNote": "改名将在下次登录后生效",
"ios.settings.deviceCount": "已知设备",
"ios.settings.signaling": "信令连接",
"ios.settings.presenceEvents": "在线事件",
"ios.settings.reconnecting": "重连中",
"ios.settings.lastLog": "最近日志",
"ios.tab.files": "文件",
"ios.files.title": "收到的文件",
"ios.files.empty": "还没有收到文件",
"ios.files.share": "分享",
"ios.files.done": "完成",
"ios.detail.openFile": "打开文件",
} as const;
+66
View File
@@ -146,6 +146,8 @@ export const zhTW: Partial<TranslationDict> = {
"qr.approve.once.hint": "登入有效 1 小時,逾時需重新掃碼。",
"qr.approve.scopeFull": "這部裝置將完整登入你的帳號、7 天內有效:可正常收發檔案與訊息,並能管理帳號與裝置。",
"qr.approve.scopeGuest": "以受限訪客身分登入——可收發檔案與訊息,但不能變更帳號、不能批准其他裝置;1 小時後失效。",
"qr.approve.nativeFull": "這是一台原生應用裝置,將以完整權限登入(信任此裝置、7 天內有效)。原生應用不支援受限訪客。",
"qr.approve.trustContinue": "信任並繼續",
"qr.approve.approve": "批准登入",
"qr.approve.stepUp.notice": "為安全起見,批准前需先驗證你的身分——點下方按鈕會引導你重新登入一次。",
"qr.approve.stepUp.button": "驗證並批准",
@@ -343,4 +345,68 @@ export const zhTW: Partial<TranslationDict> = {
"errors.clipboardUnavailable": "瀏覽器不支援剪貼簿API(需HTTPS與權限)",
"errors.clipboardOverflow": "內容過大,超過 {{max}} 位元組上限",
"errors.clipboardWriteFailed": "寫入本機剪貼簿失敗:{{message}}",
// ---- iOS 客戶端 ------------------------------------------------------
"ios.tab.transfer": "傳輸",
"ios.tab.devices": "裝置",
"ios.tab.settings": "設定",
"ios.login.generating": "正在產生 QR 碼…",
"ios.login.waiting": "等待掃碼核准",
"ios.login.guide": "用另一台已登入的裝置掃碼核准",
"ios.login.expired": "QR 碼已失效,請重試",
"ios.login.failed": "登入失敗,請重試",
"ios.login.refresh": "重新整理 QR 碼",
"ios.login.needFull": "此裝置需要完整權限,批准時請選擇「信任此裝置」",
"ios.send.pickDevice": "選擇接收裝置",
"ios.transfer.sending": "傳送中",
"ios.settings.engine": "引擎",
"ios.settings.status": "狀態",
"ios.settings.device": "裝置",
"ios.settings.clipboard": "剪貼簿",
"ios.settings.clipboardNote": "上傳把本機剪貼簿推到其他裝置;拉取寫入最新雲端剪貼簿。",
"ios.clipboard.upload": "上傳剪貼簿",
"ios.clipboard.pull": "拉取剪貼簿",
"ios.clipboard.uploading": "正在上傳…",
"ios.clipboard.pulling": "正在拉取…",
"ios.clipboard.uploaded": "已上傳到雲端剪貼簿",
"ios.clipboard.pulled": "已寫入本機剪貼簿",
"ios.clipboard.empty": "剪貼簿是空的",
"ios.clipboard.failed": "剪貼簿同步失敗",
"ios.devices.remove": "移除裝置",
"ios.devices.thisDevice": "本機",
"ios.devices.revoked": "已移除裝置",
"ios.devices.revokeFailed": "移除失敗",
"ios.devices.revokeStepUp": "需在網頁端重新驗證後才能移除",
"ios.engine.disconnected": "未連線",
"ios.engine.ready": "已就緒",
"ios.transfer.empty": "尚無傳輸",
"ios.transfer.incoming": "接收",
"ios.transfer.outgoing": "傳送",
"ios.send.noDevices": "沒有上線裝置,無法傳送",
"ios.devices.empty": "尚無裝置",
"ios.detail.title": "傳輸詳情",
"ios.detail.direction": "方向",
"ios.detail.peer": "對端",
"ios.detail.state": "狀態",
"ios.detail.phase": "階段",
"ios.detail.channel": "通道",
"ios.detail.size": "大小",
"ios.detail.progress": "進度",
"ios.detail.speed": "速度",
"ios.settings.account": "帳戶",
"ios.settings.user": "使用者",
"ios.settings.logout": "登出",
"ios.settings.deviceName": "裝置名稱",
"ios.settings.deviceNameNote": "改名將在下次登入後生效",
"ios.settings.deviceCount": "已知裝置",
"ios.settings.signaling": "信令連線",
"ios.settings.presenceEvents": "上線事件",
"ios.settings.reconnecting": "重新連線中",
"ios.settings.lastLog": "最近日誌",
"ios.tab.files": "檔案",
"ios.files.title": "收到的檔案",
"ios.files.empty": "尚未收到檔案",
"ios.files.share": "分享",
"ios.files.done": "完成",
"ios.detail.openFile": "開啟檔案",
};
+5 -3
View File
@@ -79,12 +79,14 @@ async function doFetch(input: RequestInfo, init?: RequestInit): Promise<Response
}
else
{
if (!state.accessToken)
// A QR-paired device sends its broker access token as Bearer. A global-SSO
// browser has none — the broker's domain cookie carries identity, so we send
// no Authorization header and let the edge inject X-Auth from that cookie.
if (state.accessToken)
{
throw new Error(t("errors.noAccessToken"));
}
headers.set("Authorization", `Bearer ${state.accessToken}`);
}
}
if (state.selfDeviceName)
{
+179
View File
@@ -0,0 +1,179 @@
// iOS 原生壳的桥接层:仅在 iOS WKWebView「无头引擎」壳中可用。浏览器 / 桌面里
// isIOSShell() 返回 false,所有调用点回退既有 Web 行为,因此本模块对纯 Web 与桌面
// 构建零影响——同一份引擎代码同时部署到浏览器、桌面与 iOS 无头壳。
//
// 方向与桌面(Wails)相反,是本模块存在的根由:
// - 桌面:React UI 在 WebView 顶层,调 DOWN 到 Go 绑定方法(window.go.main.App.*
// 直接返回 Promise)。
// - iOSarch A,见 ios/PLAN.md §2):原生 SwiftUI 在顶层,本 WebView 是「无头
// 传输引擎」。JS→原生只能 postMessage 到 WKScriptMessageHandler(无返回值),
// 原生→JS 用 evaluateJavaScript 回调全局函数。故 callNative 以「请求 id + 全局
// resolver」把单向 postMessage 拼回请求 / 响应语义。
//
// 原生侧须实现的契约(Swift 实现据此对齐):
// - 注册名为 "cdropEngine" 的 WKScriptMessageHandler,收 { id, method, payload }。
// - 处理完一次 callNative 后,evaluateJavaScript 调
// window.__cdropEngineResolve(id, ok, value)ok=false 时 value 为错误串)。
// - 推事件(进度 / 状态 / 传入 offer 等)用 evaluateJavaScript 调
// window.__cdropEngineEvent(name, payload)。
// - 接收落盘四方法 beginDownload / appendDownload / finalizeDownload /
// abortDownload 与桌面 desktop.ts 同形(data 为 base64),落 iOS 沙盒文件。
// WKScriptMessageHandler 的 JS 侧投递接口(仅 postMessage)。
interface WebKitMessageHandler
{
postMessage: (msg: unknown) => void;
}
// 原生注册的引擎消息处理器。其在场即判定为 iOS 无头壳。
function handler(): WebKitMessageHandler | null
{
const wk = (window as unknown as {
webkit?: { messageHandlers?: { cdropEngine?: WebKitMessageHandler } };
}).webkit;
return wk?.messageHandlers?.cdropEngine ?? null;
}
// isIOSShell 仅要求消息处理器在场——原生注入它即代表无头壳就绪。浏览器 / 桌面里
// 不存在 window.webkit.messageHandlers.cdropEngine,故恒为 false。
export function isIOSShell(): boolean
{
return typeof window !== "undefined" && handler() !== null;
}
// ── 请求 / 响应关联(callNative) ──────────────────────────────────────────
interface Pending
{
resolve: (value: unknown) => void;
reject: (err: Error) => void;
}
const pending = new Map<number, Pending>();
let nextRequestId = 1;
// 安装全局 resolver(幂等):原生完成一次调用后经 evaluateJavaScript 调它回交结果。
function installResolver(): void
{
const w = window as unknown as {
__cdropEngineResolve?: (id: number, ok: boolean, value: unknown) => void;
};
if (w.__cdropEngineResolve) { return; }
w.__cdropEngineResolve = (id, ok, value) =>
{
const p = pending.get(id);
if (!p) { return; }
pending.delete(id);
if (ok) { p.resolve(value); }
else { p.reject(new Error(typeof value === "string" ? value : "native error")); }
};
}
// callNative 投递一次 { id, method, payload } 给原生,返回待原生回调 resolve 的
// Promise。桥缺失(非 iOS 壳)直接 reject——调用点应先以 isIOSShell() 守卫。
function callNative<T>(method: string, payload?: unknown): Promise<T>
{
const h = handler();
if (!h) { return Promise.reject(new Error("ios bridge unavailable")); }
installResolver();
const id = nextRequestId;
nextRequestId += 1;
return new Promise<T>((resolve, reject) =>
{
pending.set(id, { resolve: resolve as (value: unknown) => void, reject });
h.postMessage({ id, method, payload });
});
}
// notifyNative 单向投递一条通知给原生(进度 / 状态 / 就绪 / 错误等,无需回执)。原生
// 据消息形状区分:含 id 为 callNative 请求、含 notify 为单向通知。非 iOS 壳为 no-op,
// 故引擎可无条件调用、无需在每个发射点加 isIOSShell() 分支。
export function notifyNative(name: string, payload?: unknown): void
{
const h = handler();
if (!h) { return; }
h.postMessage({ notify: name, payload });
}
// ── 原生 → JS 事件 ─────────────────────────────────────────────────────────
type EventHandler = (payload: unknown) => void;
const eventHandlers = new Map<string, Set<EventHandler>>();
// 安装全局事件入口(幂等):原生用 evaluateJavaScript 调它派发事件。
function installEventSink(): void
{
const w = window as unknown as {
__cdropEngineEvent?: (name: string, payload: unknown) => void;
};
if (w.__cdropEngineEvent) { return; }
w.__cdropEngineEvent = (name, payload) =>
{
const set = eventHandlers.get(name);
if (!set) { return; }
for (const fn of set) { fn(payload); }
};
}
// onNativeEvent 订阅一类原生事件,返回取消订阅函数。非 iOS 壳里仍可调用(只是原生
// 永不派发),保持调用点无需分支。
export function onNativeEvent(name: string, fn: EventHandler): () => void
{
installEventSink();
let set = eventHandlers.get(name);
if (!set)
{
set = new Set();
eventHandlers.set(name, set);
}
set.add(fn);
return () => { set?.delete(fn); };
}
// ── 接收落盘桥(与桌面 desktop.ts 同形,落 iOS 沙盒) ──────────────────────
// bytesToBase64 分块走 btoa,避免一次 String.fromCharCode(...大数组) 触发调用栈溢出。
// 与 desktop.ts 同实现;两边各持一份以保持桥模块互不依赖。
function bytesToBase64(bytes: Uint8Array): string
{
let binary = "";
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK)
{
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(binary);
}
// saveIncomingFileIOS:整文件(接收端小文件路径)经 base64 过桥交原生写入沙盒下载
// 目录,返回最终绝对路径。桥缺失或写盘失败抛错,由调用方回退。
export async function saveIncomingFileIOS(name: string, blob: Blob): Promise<string>
{
const buf = await blob.arrayBuffer();
return callNative<string>("saveDownload", { name, data: bytesToBase64(new Uint8Array(buf)) });
}
// 流式落盘封装:接收端混合 sink 越过内存上限后,把溢出批次交原生写临时文件,finalize
// 时改名落定。与桌面 Begin/Append/Finalize/Abort 同语义(见 incomingSink.ts)。
export async function beginIncomingDownloadIOS(sessionId: string): Promise<void>
{
await callNative<void>("beginDownload", { sessionId });
}
export async function appendIncomingDownloadIOS(sessionId: string, bytes: Uint8Array): Promise<void>
{
await callNative<void>("appendDownload", { sessionId, data: bytesToBase64(bytes) });
}
export async function finalizeIncomingDownloadIOS(sessionId: string, name: string): Promise<string>
{
return callNative<string>("finalizeDownload", { sessionId, name });
}
export async function abortIncomingDownloadIOS(sessionId: string): Promise<void>
{
if (!handler()) { return; }
try { await callNative<void>("abortDownload", { sessionId }); }
catch { /* 取消 / 失败路径,吞掉 */ }
}
+9 -34
View File
@@ -67,8 +67,10 @@ export interface QrApprovedUser
export interface QrStatus
{
status: QrStatusPhase;
// status === "approved" 时一并带回:
// status === "approved" 时一并带回 broker 令牌对 + 设备身份
accessToken?: string;
refreshToken?: string;
deviceId?: string;
expiresIn?: number;
user?: QrApprovedUser;
deviceName?: string;
@@ -78,6 +80,8 @@ interface QrStatusRaw
{
status: QrStatusPhase;
access_token?: string;
refresh_token?: string;
device_id?: string;
expires_in?: number;
user?: QrApprovedUser;
device_name?: string;
@@ -102,6 +106,8 @@ async function qrStatusOnce(
return {
status: data.status,
accessToken: data.access_token,
refreshToken: data.refresh_token,
deviceId: data.device_id,
expiresIn: data.expires_in,
user: data.user,
deviceName: data.device_name,
@@ -174,9 +180,6 @@ export interface QrRequestInfo
requestIp: string;
expiresAt: number;
status: QrStatusPhase;
// step_up 开启时为 true:批准前必须做一次新鲜的 prompt=login 再认证,把这次
// 拿到的授权码 + verifier 交给 approve 就地核验(见 features/qr/stepUp.ts)。
stepUp: boolean;
}
interface QrRequestInfoRaw
@@ -186,7 +189,6 @@ interface QrRequestInfoRaw
request_ip: string;
expires_at: number;
status: QrStatusPhase;
step_up?: boolean;
}
// qrRequest 取待批准设备的信息,批准页据此渲染安全确认。需完整登录(apiFetch 带
@@ -202,35 +204,17 @@ export async function qrRequest(requestId: string, code: string): Promise<QrRequ
requestIp: data.request_ip,
expiresAt: data.expires_at,
status: data.status,
stepUp: data.step_up === true,
};
}
// stepUpRequired 标记 approve 因 step-up 再认证缺失 / 过期被后端拒(403
// {error:"step_up_required"})。批准页据此把用户带回再认证一遭,而非当成普通错误。
export class StepUpRequiredError extends Error
{
constructor()
{
super("step_up_required");
this.name = "StepUpRequiredError";
}
}
// qrApprove 批准该设备登入本账号。scope 决定授予的会话级别:full=完整登录、
// guest=受限访客;与 persist 时长一一对应(full+persist=7 天滑动、guest+once=1 小时)。
// 成功 204
//
// step_up 开启时须带上一次新鲜 prompt=login 再认证拿到的 stepUpCode + stepUpVerifier
// 后端就地向 IdP 换 id_token、验签 + 校 auth_time 新鲜 + sub 匹配;缺失 / 过期 →
// 403 step_up_required(抛 StepUpRequiredError)。step_up 关时这俩字段被后端忽略。
// guest=受限访客(经 broker 委托签发对应 tier 的会话)。成功 204。step-up 二次认证
// 已移除(full 档即视为可信,路由层 requireFullSession 守门)
export async function qrApprove(
requestId: string,
code: string,
scope: QrScope,
persist: QrPersist,
stepUpCode?: string,
stepUpVerifier?: string,
): Promise<void>
{
const body: Record<string, string> = {
@@ -239,11 +223,6 @@ export async function qrApprove(
scope,
persist,
};
if (stepUpCode && stepUpVerifier)
{
body.step_up_code = stepUpCode;
body.step_up_verifier = stepUpVerifier;
}
const r = await apiFetch("/api/auth/qr/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -252,10 +231,6 @@ export async function qrApprove(
if (!r.ok)
{
const text = await r.text().catch(() => r.statusText);
if (r.status === 403 && /step_up_required/.test(text))
{
throw new StepUpRequiredError();
}
throw new Error(`qr approve ${r.status}: ${text}`);
}
}
+7 -53
View File
@@ -33,11 +33,11 @@ export async function fetchMeScope(): Promise<SessionScopeValue>
// 会话「种类」:oidc / self / guest 为网页 / 扫码会话;macos / windows / linux / ios
// 为原生客户端(桌面 / 移动)——它们用 IdP 令牌、不入 web_sessions,由设备登记表呈现。
export type SessionKind =
| "oidc" | "self" | "guest"
| "browser"
| "macos" | "windows" | "linux" | "ios";
const KNOWN_KINDS: readonly SessionKind[] = [
"oidc", "self", "guest", "macos", "windows", "linux", "ios",
"browser", "macos", "windows", "linux", "ios",
];
export interface AuthSession
@@ -50,25 +50,21 @@ export interface AuthSession
current: boolean;
// online=true:该设备当前在线(有活跃 SSE 连接)。用于列表排序与在线指示。
online: boolean;
// native=true:原生客户端(无 web_session),登出走设备登记端点而非会话端点。
native: boolean;
createdAt: number;
lastUsedAt: number;
expiresAt: number;
}
interface AuthSessionRaw
{
id: string;
device_id?: string;
device_name?: string;
kind?: string;
scope?: string;
current?: boolean;
online?: boolean;
native?: boolean;
created_at?: number;
last_used_at?: number;
expires_at?: number;
}
interface SessionsResp
@@ -78,7 +74,7 @@ interface SessionsResp
function normalizeKind(kind: string | undefined): SessionKind
{
return KNOWN_KINDS.includes(kind as SessionKind) ? (kind as SessionKind) : "oidc";
return KNOWN_KINDS.includes(kind as SessionKind) ? (kind as SessionKind) : "browser";
}
// listSessions 拉取当前账号下的全部登录会话(含本机),每条带 online(当前是否在线)。
@@ -96,61 +92,19 @@ export async function listSessions(): Promise<AuthSession[]>
scope: r.scope === "guest" ? "guest" : "full",
current: r.current === true,
online: r.online === true,
native: r.native === true,
createdAt: r.created_at ?? 0,
lastUsedAt: r.last_used_at ?? 0,
expiresAt: r.expires_at ?? 0,
}));
}
// StepUpRequiredErrorDELETE /api/auth/sessions/{id} 因再认证缺失 / 过期被后端拒
// 403 {error:"step_up_required"})。调用方据此发起一次 step-up 再认证而非当成
// 普通错误。
export class StepUpRequiredError extends Error
{
constructor()
{
super("step_up_required");
this.name = "StepUpRequiredError";
}
}
// revokeSession 真正吊销一条登录会话。网页 / 扫码会话走 /auth/sessions/{id}(删会话
// 行 + 连带删设备);原生客户端(id 形如 "device:设备名")无 web_session,改走
// /devices/{name} 按设备名注销。两者均:成功 204;后端要求再认证时抛
// StepUpRequiredError403 step_up_required),其余非 2xx 抛普通错误。
const NATIVE_ID_PREFIX = "device:";
// revokeSession 吊销一条会话(= 一台设备)DELETE /api/auth/sessions/{id}id=device_id)。
// 后端连带 broker 吊销该设备的会话 + 删本地设备行。成功 204。step-up 二次认证已移除。
export async function revokeSession(id: string): Promise<void>
{
const path = id.startsWith(NATIVE_ID_PREFIX)
? `/api/devices/${encodeURIComponent(id.slice(NATIVE_ID_PREFIX.length))}`
: `/api/auth/sessions/${encodeURIComponent(id)}`;
const r = await apiFetch(path, { method: "DELETE" });
const r = await apiFetch(`/api/auth/sessions/${encodeURIComponent(id)}`, { method: "DELETE" });
if (!r.ok)
{
const text = await r.text().catch(() => r.statusText);
if (r.status === 403 && /step_up_required/.test(text))
{
throw new StepUpRequiredError();
}
throw new Error(`revoke session ${r.status}: ${text}`);
}
}
// postStepUp 把一次新鲜 prompt=login 再认证拿到的 {code, verifier} 交给后端就地
// 核验,成功后服务端在本会话记下 stepped_up_at(5 分钟窗口内复用,不再反复要求)。
// 成功 204step-up 未开后端亦 204(视作通过);失败 403。
export async function postStepUp(code: string, verifier: string): Promise<void>
{
const r = await apiFetch("/api/auth/stepup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, verifier }),
});
if (!r.ok)
{
const text = await r.text().catch(() => r.statusText);
throw new Error(`step-up ${r.status}: ${text}`);
}
}
+1 -2
View File
@@ -339,8 +339,7 @@ function isAuthRoute(pathname: string): boolean
|| pathname === "/setup"
|| pathname === "/link"
|| pathname === "/link/new"
|| pathname === "/link/scan"
|| pathname.startsWith("/oauth/");
|| pathname === "/link/scan";
}
function initial(name: string): string
+29 -75
View File
@@ -2,12 +2,11 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { AlertTriangle, Check, Clock, LogIn, ShieldCheck, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { AuthShell } from "../features/auth/AuthShell";
import { loginProd, startStepUpReauth } from "../features/auth/auth";
import { loginRedirect } from "../features/auth/auth";
import { stashLinkReturn } from "../features/qr/returnTo";
import { consumeStepUpCode, type StepUpStash } from "../features/qr/stepUp";
import { isDesktop, startDesktopLogin } from "../net/desktop";
import {
qrApprove, qrDeny, qrRequest, StepUpRequiredError,
qrApprove, qrDeny, qrRequest,
type QrPersist, type QrRequestInfo,
} from "../net/qr";
import { useAppStore } from "../store";
@@ -44,12 +43,8 @@ function LinkApprovePage()
const [ submitting, setSubmitting ] = useState(false);
const [ outcome, setOutcome ] = useState<Outcome>(null);
const [ actionError, setActionError ] = useState<string | null>(null);
// step-uptrue=后端要求批准前先做新鲜 prompt=login 再认证(provider 2FA 即此处过)。
const [ stepUpRequired, setStepUpRequired ] = useState(false);
// 再认证往返回来后由后端二次拒(403 step_up_required),文案提示并允许重试。
const [ stepUpRejected, setStepUpRejected ] = useState(false);
// 未登录:暂存当前 URL 后走标准登录,登录后由首页 beforeLoad 弹回这里。
// 未登录:暂存当前 URL 后走 broker 全局 SSO 登录,登录后由首页 beforeLoad 弹回这里。
const handleSignIn = useCallback(async () =>
{
stashLinkReturn(window.location.pathname + window.location.search);
@@ -60,12 +55,12 @@ function LinkApprovePage()
await startDesktopLogin();
navigate({ to: "/" });
}
else { await loginProd(); }
else { loginRedirect(); }
}
catch (e) { setLoadError(e instanceof Error ? e.message : String(e)); }
}, [ navigate ]);
// 已登录且参数齐全:拉取待批准设备信息(含 step_up 标记)
// 已登录且参数齐全:拉取待批准设备信息。
useEffect(() =>
{
if (!user || !requestId || !code) { return; }
@@ -75,7 +70,6 @@ function LinkApprovePage()
{
if (cancelled) { return; }
setInfo(data);
setStepUpRequired(data.stepUp);
if (data.status === "expired") { setOutcome("expired"); }
else if (data.status === "denied") { setOutcome("denied"); }
else if (data.status === "approved") { setOutcome("approved"); }
@@ -85,69 +79,40 @@ function LinkApprovePage()
return () => { cancelled = true; };
}, [ user, requestId, code ]);
// doApprove 执行批准stash 非空时把 step-up 再认证拿到的 code+verifier 一并交给
// 后端就地核验。403 step_up_requiredStepUpRequiredError)单独走「需重新验证」态,
// 不当成普通错误。
const doApprove = useCallback(async (stash?: StepUpStash) =>
// doApprove 执行批准。step-up 二次认证已移除(full 档即可信,路由层守门)。
const doApprove = useCallback(async () =>
{
if (!requestId || !code) { return; }
setSubmitting(true);
setActionError(null);
setStepUpRejected(false);
try
{
// persist=「信任此设备」→ 完整会话(scope=full);once=「仅此次」→ 受限访客
// scope=guest)。后端按 scope 决定授予的会话级别,二者与 persist 时长一一对应
// scope=guest)。后端按 scope 经 broker 委托签发对应 tier 的会话
const scope = persist === "persist" ? "full" : "guest";
await qrApprove(requestId, code, scope, persist, stash?.code, stash?.verifier);
await qrApprove(requestId, code, scope, persist);
setOutcome("approved");
}
catch (e)
{
if (e instanceof StepUpRequiredError) { setStepUpRejected(true); }
else { setActionError(e instanceof Error ? e.message : String(e)); }
setActionError(e instanceof Error ? e.message : String(e));
}
finally { setSubmitting(false); }
}, [ requestId, code, persist ]);
// 再认证往返回来:检测到暂存的 step-up {code, verifier} → 自动调 approve 完成批准
// → 用完即清(consumeStepUpCode 取出即清)。仅在已登录、设备信息已就绪后跑一次。
// 原生客户端(非浏览器:iOS / 桌面)只接受完整权限会话——用户原则:原生 App 不允许受限
// 访客(仅 Web / PWA 可 guest)。故批准端对原生请求强制信任时长=persistscope=full)、
// 隐藏「仅此次」选项,只给「信任并继续 / 拒绝」。后端 handleQRApprove 亦兜底强制。
const requiresFull = info != null && info.deviceType !== "browser";
useEffect(() =>
{
if (!user || !info || outcome) { return; }
const stash = consumeStepUpCode();
if (!stash) { return; }
void doApprove(stash);
}, [ user, info, outcome, doApprove ]);
if (requiresFull) { setPersist("persist"); }
}, [ requiresFull ]);
// handleApprove:批准按钮step-up 要求且尚无再认证凭证时,先把用户带去做一次
// 新鲜 prompt=login 再认证(往返回来后由上面的 effect 自动接力 approve);否则
// 维持原有直接批准行为不变。
// handleApprove:批准按钮,直接提交(step-up 已移除)。
const handleApprove = async () =>
{
if (!requestId || !code) { return; }
if (stepUpRequired)
{
setSubmitting(true);
setActionError(null);
setStepUpRejected(false);
try
{
// 把当前「信任时长」选择编进 returnTo:step-up 整页跳转回来后页面重载、
// React state 复位,必须从 URL 恢复 persist,否则会回落默认、令本应受限
// 的设备被建成完整会话(#2 根因)。
const back = new URLSearchParams(window.location.search);
back.set("p", persist);
await startStepUpReauth(window.location.pathname + "?" + back.toString());
}
catch (e)
{
setActionError(e instanceof Error ? e.message : String(e));
setSubmitting(false);
}
// 成功则整页跳转去 provider,本组件随之卸载,不再 setSubmitting。
return;
}
await doApprove();
};
@@ -241,7 +206,9 @@ function LinkApprovePage()
</div>
</div>
{/* 信任时长:两选项分段,选中项得 accent 左色条(同 Callout / Activity 语汇)。 */}
{/* 信任时长:两选项分段,选中项得 accent 左色条(同 Callout / Activity
*/}
{!requiresFull && (
<div className={s.choices} role="radiogroup" aria-label={t("qr.approve.trustLabel")}>
<TrustOption
value="persist"
@@ -258,29 +225,18 @@ function LinkApprovePage()
hint={t("qr.approve.once.hint")}
/>
</div>
)}
{/* ==访
*/}
{/* ==
访 */}
<Callout tone="info" icon={<ShieldCheck size={16} />}>
{persist === "persist"
{requiresFull
? t("qr.approve.nativeFull")
: persist === "persist"
? t("qr.approve.scopeFull")
: t("qr.approve.scopeGuest")}
</Callout>
{/* step-up:批准前的明确安全步骤——需先验证身份(provider 2FA 即此处过)。 */}
{stepUpRequired && !stepUpRejected && (
<Callout tone="info" icon={<ShieldCheck size={16} />}>
{t("qr.approve.stepUp.notice")}
</Callout>
)}
{/* 再认证回来仍被后端拒:给方向(凭证过期 / 验证未通过)并允许重试。 */}
{stepUpRejected && (
<Callout tone="warn" icon={<AlertTriangle size={16} />}>
{t("qr.approve.stepUp.rejected")}
</Callout>
)}
{actionError && (
<Callout tone="error" icon={<AlertTriangle size={16} />}>{actionError}</Callout>
)}
@@ -291,13 +247,11 @@ function LinkApprovePage()
size="lg"
block
loading={submitting}
leftIcon={
stepUpRequired ? <ShieldCheck size={16} /> : <Check size={16} />
}
leftIcon={<Check size={16} />}
onClick={() => { void handleApprove(); }}
>
{stepUpRequired
? t("qr.approve.stepUp.button")
{requiresFull
? t("qr.approve.trustContinue")
: t("qr.approve.approve")}
</Button>
<Button
+1
View File
@@ -43,6 +43,7 @@ function LinkNewPage()
if (st.status !== "approved" || !st.accessToken || !st.user) { return; }
setAuth({
accessToken: st.accessToken,
refreshToken: st.refreshToken ?? null,
user: { id: st.user.id, name: st.user.name, avatar: st.user.avatar },
});
// 设备名以批准方下发的为权威(服务端登记的名字),回退到本地输入。
+14 -13
View File
@@ -1,7 +1,7 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { AlertTriangle, LogIn, QrCode } from "lucide-react";
import { useEffect, useState } from "react";
import { fetchAuthConfig, loginDev, loginProd } from "../features/auth/auth";
import { loginDev, loginRedirect } from "../features/auth/auth";
import { isDesktop, startDesktopLogin } from "../net/desktop";
import { AuthShell } from "../features/auth/AuthShell";
import { useAppStore } from "../store";
@@ -21,24 +21,23 @@ function LoginPage()
const navigate = useNavigate();
const search = Route.useSearch();
const localAuthMode = useAppStore((s) => s.authMode);
const user = useAppStore((s) => s.user);
const [ devUser, setDevUser ] = useState(search.dev_user ?? "");
const [ error, setError ] = useState<string | null>(null);
// 前端编译期推断的 mode(DEV/PROD)可能与后端实际部署不一致
// (例如把 prod 构建打到 dev 后端测试),因此以服务器返回的为准。
const [ serverMode, setServerMode ] = useState<"dev" | "prod" | null>(null);
// 已登录态落在 /login 时反应式跳首页。全局 SSO 登录回跳后 rd 指回本页(登录前用户
// 被 __root 守卫从原路由送到了 /login),bootstrapAuth 据 broker cookie 注入身份后
// 若不主动离开,就停在登录页形成“登录成功却又见登录页”的回环。__root 的守卫只把
// 未登录态送进 /login,登录态的反向跳转由此补齐(含 dev 同步登录后的兜底)。
useEffect(() =>
{
let cancelled = false;
fetchAuthConfig().then(
(cfg) => { if (!cancelled) { setServerMode(cfg.auth_mode); } },
() => { /* 回退到编译期 mode */ },
);
return () => { cancelled = true; };
}, []);
if (user) { navigate({ to: "/" }); }
}, [ user, navigate ]);
const effectiveMode = serverMode ?? localAuthMode;
// 后端不再暴露 /api/auth/configOIDC 已委托给 broker);登录模式以前端编译期
// 注入的 authMode 为准(dev / prod)。
const effectiveMode = localAuthMode;
const handleDevLogin = () =>
{
@@ -68,7 +67,9 @@ function LoginPage()
}
else
{
await loginProd();
// 跳到 broker 全局 SSO 登录(cdrop 后端 302 代跳);登录后回跳本页,
// 边缘 human 分支据 broker cookie 注入身份。
loginRedirect();
}
}
catch (e) { setError(e instanceof Error ? e.message : String(e)); }
-127
View File
@@ -1,127 +0,0 @@
import { Loader } from "@mantine/core";
import { createFileRoute } from "@tanstack/react-router";
import { AlertTriangle } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
clearStepUpState, completeOAuthLogin, verifyStepUpState,
} from "../features/auth/auth";
import { AuthShell } from "../features/auth/AuthShell";
import {
clearStepUpPending, peekStepUpPending, stashStepUpCode,
} from "../features/qr/stepUp";
import { t } from "../i18n";
import { Callout } from "../ui/primitives";
export const Route = createFileRoute("/oauth/callback")({
component: CallbackPage,
validateSearch: (search: Record<string, unknown>) =>
({
code: typeof search.code === "string" ? search.code : undefined,
state: typeof search.state === "string" ? search.state : undefined,
error: typeof search.error === "string" ? search.error : undefined,
error_description: typeof search.error_description === "string"
? search.error_description
: undefined,
}),
});
function CallbackPage()
{
const search = Route.useSearch();
const [ err, setErr ] = useState<string | null>(null);
// ref guardcompleteOAuthLogin 内部会消费 localStorage 中的 PKCE_VERIFIER /
// OAUTH_STATE,二次进入必然 throw "state mismatch / verifier missing",在
// navigate(/) 真正完成之前的那一帧引发"登录失败"闪现。
//
// navigate 是异步的:promise resolve → setErr/navigatenavigate 实际生效之间
// 仍可能发生一次 re-renderuseNavigate 返回值在某些版本里非稳定引用,会让
// useEffect deps 抖动重跑)。ref guard 杜绝逻辑重入,cancelled flag 兜底防止
// unmount 后 setErr。
const startedRef = useRef(false);
useEffect(() =>
{
if (startedRef.current) { return; }
startedRef.current = true;
let cancelled = false;
// 把 setErr 推迟一拍:navigate({to:"/"}) 是异步的,promise resolve
// 后 callback 真正 unmount 前可能渲染一帧 err 状态(即使 success path
// 不该走这里,但 setAuth → store 更新 → 父级 re-render → useNavigate
// 引用波动 → effect 二度入场是过去的真实路径,残留闪现)。
//
// 失败时延迟 800ms 显示,给 success 路径足够时间触发卸载;真正失败
// 800ms 内 cleanup 不会被调用,setErr 仍能渲染失败态。
const showErrDeferred = (msg: string) =>
{
console.error("[oauth/callback]", msg);
window.setTimeout(() =>
{
if (!cancelled) { setErr(msg); }
}, 800);
};
if (search.error)
{
showErrDeferred(`${search.error}${search.error_description ?? ""}`);
return;
}
if (!search.code || !search.state)
{
showErrDeferred(t("oauth.missingParams"));
return;
}
// step-up 分叉:批准页发起的 prompt=login 再认证回到这里。此时 code 是要
// 交给后端 approve 去 IdP 换的一次性凭证——前端绝不调 /api/auth/exchange
// 消费它(那会在服务端轮换会话)。只校 state(防 CSRF,同常规登录口径)、
// 把 {code, verifier} 暂存给批准页、清掉「待办」,整页跳回批准页 URL。
const pending = peekStepUpPending();
if (pending)
{
if (!verifyStepUpState(search.state))
{
clearStepUpPending();
showErrDeferred("OAuth state mismatch (possible CSRF or stale tab)");
return;
}
stashStepUpCode({ code: search.code, verifier: pending.verifier });
clearStepUpPending();
// 整页跳转回批准页(returnTo 已在 stash 时通过 open-redirect 校验)。
window.location.replace(pending.returnTo);
return;
}
clearStepUpState(); // 非 step-up 路径兜底:清掉可能残留的 step-up state。
completeOAuthLogin(search.code, search.state).then(
// 整页跳转(window.location.replace),不是路由软导航。Safari 下,从
// Casdoor 回跳后那条客户端导航会复用 OAuth 流程遗留的 HTTP 连接,而这些
// 连接在 Safari 里会卡死后续资源(setup.js / 头像)约 2 分钟——手动刷新即
// 恢复,印证是连接状态问题。整页重载拿到全新连接,绕开该 bug;auth 已在
// sessionStorage,同标签刷新存活。replace 不入历史,避免后退重触已消费的 code。
() => { if (!cancelled) { window.location.replace("/"); } },
(e) => { showErrDeferred(e instanceof Error ? e.message : String(e)); },
);
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅在 mount 时跑一次;search 由 URL 决定,mount 时即定。
if (err)
{
return (
<AuthShell title={t("oauth.failed")} hideBrand>
<Callout tone="error" icon={<AlertTriangle size={16} />}>{err}</Callout>
</AuthShell>
);
}
return (
<AuthShell title={t("oauth.signingIn")} subtitle="正在向 Casdoor 交换访问令牌…" hideBrand>
<div style={{ display: "flex", justifyContent: "center", padding: "var(--space-4) 0" }}>
<Loader color="var(--accent)" />
</div>
</AuthShell>
);
}
+17 -113
View File
@@ -8,14 +8,10 @@ import {
Title,
} from "@mantine/core";
import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router";
import { AlertTriangle, Bell, LogOut, ShieldAlert, Trash2 } from "lucide-react";
import { logout, startStepUpReauth, syncWebDeviceName } from "../features/auth/auth";
import { Bell, LogOut, ShieldAlert, Trash2 } from "lucide-react";
import { logout, renameDeviceSession, syncWebDeviceName } from "../features/auth/auth";
import { DesktopSettings } from "../features/desktop/DesktopSettings";
import {
consumePendingRevoke, consumeStepUpCode, stashPendingRevoke,
} from "../features/qr/stepUp";
import { isDesktop, persistDesktopDeviceName } from "../net/desktop";
import { apiFetch } from "../net/api";
import {
currentPushState,
disablePush,
@@ -24,7 +20,7 @@ import {
type PushState,
} from "../net/push";
import {
listSessions, postStepUp, revokeSession, StepUpRequiredError,
listSessions, revokeSession,
type AuthSession,
} from "../net/sessions";
import { useAppStore } from "../store";
@@ -77,21 +73,14 @@ function SettingsPage()
setRenaming(true);
try
{
// 先在后端把旧设备记录删掉。Hub.Kick 会强制关闭旧的 SSE
// 紧接着 root effect 检测到 selfDeviceName 变化会以新名重新注册。
// 404(旧记录已被 sweeper 清理)也按成功处理。
const r = await apiFetch(
`/api/devices/${encodeURIComponent(selfDeviceName)}`,
{ method: "DELETE" },
);
if (!r.ok && r.status !== 404)
{
const text = await r.text().catch(() => r.statusText);
throw new Error(`${r.status}: ${text}`);
}
// 改名只换 label,不换身份(device_id 稳定):先落本地名(root effect 会以新名重连
// SSE),再以同一 device_id 重新代铸,把新 label 推给 broker、原地轮换那条会话,
// 故统一设备列表与 presence 都显示新名、不产生重复设备行。桌面在此只更新本地名,
// broker label 于下次登录代铸时同步。
setSelfDeviceName(trimmedName);
persistDesktopDeviceName(trimmedName); // 桌面:持久化到 Go 配置,跨重启存活
syncWebDeviceName(trimmedName); // 浏览器:持久化到服务端 session,抗 PWA 存储清除
await renameDeviceSession();
toast.ok(t("settings.deviceName.success"));
}
catch (e)
@@ -106,28 +95,15 @@ function SettingsPage()
}
};
const handleUnregisterSelf = async () =>
const handleUnregisterSelf = () =>
{
if (!selfDeviceName) { return; }
if (!window.confirm(t("settings.unregister.currentConfirm"))) { return; }
try
{
await apiFetch(
`/api/devices/${encodeURIComponent(selfDeviceName)}`,
{ method: "DELETE" },
);
}
catch
{
// 即便 DELETE 失败也按用户意图清理本地并跳转登录。
}
finally
{
// logout() 已自吊销本设备的 broker 会话(/api/auth/logout,按 device_id),故无需再按
// 设备名 DELETE(迁移后设备路由按 device_id,旧的按名删除一律 404、且属冗余)。
setSelfDeviceName(null);
logout();
navigate({ to: "/login", search: { dev_user: undefined } });
}
};
const handleSignOut = () =>
@@ -348,7 +324,6 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
const [ sessions, setSessions ] = useState<AuthSession[] | null>(null);
const [ loadError, setLoadError ] = useState(false);
const [ busyId, setBusyId ] = useState<string | null>(null);
const [ stepUpRejected, setStepUpRejected ] = useState(false);
// reload 拉取并写入列表,同时把结果回传给调用方(step-up 重试路径需在 revoke
// 前从这份列表里查出 current,不能在 revoke 之后再拉——若登出的正是本机会话,
@@ -375,56 +350,15 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
void reload();
}, [ onSignedOutSelf, reload ]);
// 进页面拉一次。返回时若检测到 step-up 往返暂存({code, verifier} + 待登出 id),
// 先 POST /auth/stepup 记录窗口,再重试那条 DELETE——一次性串起整页跳转两端。
// 受限访客无权管理会话、后端 GET 会 403,故根本不发请求、直接走说明态。
// 进页面拉一次列表。受限访客无权管理会话、后端 GET 会 403,故跳过、走说明态。
useEffect(() =>
{
if (isGuest) { return; }
let cancelled = false;
const run = async () =>
{
// step-up 往返恢复:取出暂存(一次性)。无暂存则普通进页只拉列表。
const stash = consumeStepUpCode();
const pendingId = consumePendingRevoke();
const list = await reload();
if (cancelled) { return; }
if (stash && pendingId)
{
// 在 revoke 前就从这份列表里查出待登出的那条(尤其 current 标记):
// 若登出的正是本机会话,revoke 后令牌即失效,不能再 listSessions。
const target = list?.find((x) => x.id === pendingId);
setBusyId(pendingId);
try
{
await postStepUp(stash.code, stash.verifier);
await revokeSession(pendingId);
if (cancelled) { return; }
if (target) { finishRevoke(target); }
else { toast.ok(t("settings.sessions.revoked")); await reload(); }
}
catch (e)
{
if (cancelled) { return; }
if (e instanceof StepUpRequiredError) { setStepUpRejected(true); }
else
{
toast.error(t("settings.error.delete", {
message: e instanceof Error ? e.message : String(e),
}));
}
void reload();
}
finally { if (!cancelled) { setBusyId(null); } }
}
};
void run();
return () => { cancelled = true; };
}, [ isGuest, reload, finishRevoke ]);
}, [ isGuest, reload ]);
// handleRevoke 登出一条会话(= 一台设备)。后端连带 broker 吊销 + 删设备行。
// step-up 二次认证已移除(full 档即可信)。
const handleRevoke = async (sess: AuthSession) =>
{
const confirmMsg = sess.current
@@ -433,38 +367,17 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
if (!window.confirm(confirmMsg)) { return; }
setBusyId(sess.id);
setStepUpRejected(false);
try
{
await revokeSession(sess.id);
finishRevoke(sess);
}
catch (e)
{
if (e instanceof StepUpRequiredError)
{
// 需再认证:暂存待登出 id,发起 prompt=login 往返,回到 /settings 后
// 由 mount effect 接力重试。成功则整页跳走、本组件卸载。
stashPendingRevoke(sess.id);
try
{
await startStepUpReauth("/settings");
return; // 跳转去 provider,不再 setBusyId。
}
catch (reauthErr)
{
toast.error(t("settings.error.delete", {
message: reauthErr instanceof Error ? reauthErr.message : String(reauthErr),
}));
}
}
else
{
toast.error(t("settings.error.delete", {
message: e instanceof Error ? e.message : String(e),
}));
}
}
finally { setBusyId(null); }
};
@@ -485,11 +398,6 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
<Panel title={t("settings.sessions.title")}>
<Stack gap="sm">
<Text size="sm" c="dimmed" className="justify" data-jz-level="paragraph">{t("settings.sessions.hint")}</Text>
{stepUpRejected && (
<Callout tone="warn" icon={<AlertTriangle size={16} />}>
{t("settings.sessions.stepUpRejected")}
</Callout>
)}
{loadError ? (
<Group>
<Text size="sm" style={{ color: "var(--state-error)" }}>
@@ -522,14 +430,10 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
);
}
// 会话种类标签:网页 / 扫码会话用 settings.sessions.kind.*;原生客户端
// macos / windows / linux / ios)复用设备类型标签(「macOS 客户端」等)。
// 会话种类标签:迁移后每条会话即一台设备,统一复用设备类型标签
// 「网页」/「macOS 客户端」等)。
function sessionKindLabel(kind: AuthSession["kind"]): string
{
if (kind === "oidc" || kind === "self" || kind === "guest")
{
return t(`settings.sessions.kind.${kind}` as const);
}
return t(`deviceType.${kind}` as const);
}
+16 -3
View File
@@ -2,7 +2,7 @@ import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { Check, Monitor } from "lucide-react";
import { useState } from "react";
import { AuthShell } from "../features/auth/AuthShell";
import { syncWebDeviceName } from "../features/auth/auth";
import { ensureDeviceSession, syncWebDeviceName } from "../features/auth/auth";
import { persistDesktopDeviceName } from "../net/desktop";
import { useAppStore } from "../store";
import { t } from "../i18n";
@@ -26,9 +26,11 @@ function SetupPage()
const navigate = useNavigate();
const setSelfDeviceName = useAppStore((s) => s.setSelfDeviceName);
const [ name, setName ] = useState(suggestDefault());
const [ saving, setSaving ] = useState(false);
const handleSave = () =>
const handleSave = async () =>
{
if (saving) { return; } // 防重复提交:代铸期间再次点击会多铸一条设备会话
const trimmed = name.trim();
if (!trimmed) { return; }
if (!isAsciiDeviceName(trimmed))
@@ -36,10 +38,21 @@ function SetupPage()
toast.warn(t("settings.deviceName.asciiOnly"));
return;
}
setSaving(true);
try
{
setSelfDeviceName(trimmed);
persistDesktopDeviceName(trimmed); // 桌面:持久化到 Go 配置,跨重启存活
syncWebDeviceName(trimmed); // 浏览器:持久化到服务端 session,抗 PWA 存储清除
// 浏览器:名字既定,立即代铸一条托管设备会话(桌面 / 已持令牌者为 no-op),
// 使本机以此名进入统一设备列表后再进入应用。
await ensureDeviceSession();
navigate({ to: "/" });
}
finally
{
setSaving(false);
}
};
return (
@@ -74,7 +87,7 @@ function SetupPage()
block
leftIcon={<Check size={16} />}
onClick={handleSave}
disabled={!name.trim()}
disabled={!name.trim() || saving}
>
{t("setup.save")}
</Button>

Some files were not shown because too many files have changed in this diff Show More