鉴权并入 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:
@@ -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
|
||||
}
|
||||
+70
-72
@@ -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 tokens:iOS 快捷指令用的长效 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 token(HS256,密钥派生自
|
||||
// SessionSecret)。QRLoginEnabled 关闭则 /api/auth/qr/* 返回 503;其余为各 TTL。
|
||||
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"`
|
||||
// 扫码登录(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"`
|
||||
}
|
||||
|
||||
// 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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+116
-43
@@ -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 {
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
DeviceID string `json:"device_id"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
-- cdrop MVP schema (PROJECT_BRIEF.md §4)
|
||||
-- 以 IF NOT EXISTS 形式由 internal/db/bootstrap.go 启动期单事务执行。
|
||||
|
||||
-- devices:cdrop 托管设备(扫码批准 / 原生配对的设备)。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 (
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, name)
|
||||
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 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 单独泄露也换不出可用 cookie;refresh_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 续);self/guest=cdrop 自签会话,无 IdP refresh_token,
|
||||
-- refresh 时仅按本行自签 access token、不触 IdP。既有行经 bootstrap 的幂等 ALTER
|
||||
-- 补列后默认视为 oidc/full,行为不变。
|
||||
kind TEXT NOT NULL DEFAULT 'oidc',
|
||||
-- scope=full/guest。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);
|
||||
|
||||
-- accounts:cdrop 侧「薄账户数据层」(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),consumed/expired 后不可复用,由 reaper 清理。
|
||||
|
||||
+8
-41
@@ -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,10 +15,14 @@ type ClipboardState struct {
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
LastSeen int64 `json:"last_seen"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 = ?;
|
||||
@@ -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 < ?;
|
||||
|
||||
@@ -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 = ?;
|
||||
@@ -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 = ?;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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] + "…"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+48
-19
@@ -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-up(403 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,
|
||||
Name: name,
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 + "/"
|
||||
}
|
||||
+109
-177
@@ -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"`
|
||||
ExpiresIn int `json:"expires_in,omitempty"`
|
||||
User *userResp `json:"user,omitempty"`
|
||||
DeviceName string `json:"device_name,omitempty"`
|
||||
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,63 +216,74 @@ 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)
|
||||
if err != nil {
|
||||
slog.Error("mint session token failed", "err", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "mint failed"})
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, raw)
|
||||
|
||||
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,
|
||||
ExpiresIn: expiresIn,
|
||||
User: &user,
|
||||
DeviceName: req.NewDeviceName,
|
||||
Tier: tier,
|
||||
AccessTTL: accessTTL,
|
||||
RefreshTTL: refreshTTL,
|
||||
Sliding: true,
|
||||
Label: req.NewDeviceName,
|
||||
Meta: deviceID,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("broker mint failed", "err", err, "user", req.ApproverUserID)
|
||||
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: 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}
|
||||
writeJSON(w, http.StatusOK, qrStatusResp{
|
||||
Status: "approved",
|
||||
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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+330
-354
@@ -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),
|
||||
siteOrigin: "https://drop.example.net",
|
||||
queries: q,
|
||||
hub: hub.New(q),
|
||||
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
|
||||
}
|
||||
|
||||
// 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 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)
|
||||
s.handleDeleteDevice(w, r)
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete device: got %d, want 204 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
if w := revokeByID(s, id, "u"); w.Code != http.StatusNoContent {
|
||||
t.Fatalf("revoke: got %d %s, want 204", w.Code, w.Body.String())
|
||||
if !st.revoked["sid-1"] {
|
||||
t.Error("broker session sid-1 was not revoked")
|
||||
}
|
||||
if _, err := s.queries.GetWebSession(ctx, id); err == nil {
|
||||
t.Errorf("session still present after revoke")
|
||||
if st.lastRevokeApp != "cdrop" {
|
||||
t.Errorf("revoke X-Broker-App: got %q, want cdrop", st.lastRevokeApp)
|
||||
}
|
||||
devs, _ := s.queries.ListDevicesByUser(ctx, "u")
|
||||
for _, d := range devs {
|
||||
if d.Name == "Laptop" {
|
||||
t.Errorf("device 'Laptop' not deleted on session revoke")
|
||||
}
|
||||
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))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("delete device without step-up: got %d, want 403", w.Code)
|
||||
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("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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+92
-100
@@ -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,99 +92,94 @@ 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.
|
||||
// Clipboard, transfer, messaging, presence, push — guests included.
|
||||
r.Get("/clipboard", s.handleClipboardGet)
|
||||
r.Put("/clipboard", s.handleClipboardPut)
|
||||
// Lightweight version probe — no content; lets pollers detect change
|
||||
// cheaply before fetching the full body.
|
||||
r.Get("/clipboard/version", s.handleClipboardVersion)
|
||||
|
||||
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(requireScope(shortcutScope))
|
||||
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.
|
||||
r.Get("/clipboard/version", s.handleClipboardVersion)
|
||||
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)
|
||||
r.Get("/devices", s.handleDevices)
|
||||
r.Get("/push/vapid-key", s.handlePushVAPIDKey)
|
||||
r.Post("/push/subscribe", s.handlePushSubscribe)
|
||||
r.Delete("/push/subscribe", s.handlePushUnsubscribe)
|
||||
r.Get("/calls/credentials", s.handleCallsCredentials)
|
||||
|
||||
// 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/{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 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)
|
||||
})
|
||||
|
||||
// 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)
|
||||
r.Get("/hub/events", s.handleEvents)
|
||||
r.Post("/hub/signal", s.handleSignal)
|
||||
r.Post("/message", s.handleMessage)
|
||||
r.Get("/devices", s.handleDevices)
|
||||
r.Get("/push/vapid-key", s.handlePushVAPIDKey)
|
||||
r.Post("/push/subscribe", s.handlePushSubscribe)
|
||||
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).
|
||||
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.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)
|
||||
r.Get("/auth/sessions", s.handleSessionsList)
|
||||
r.Delete("/auth/sessions/{id}", s.handleSessionRevoke)
|
||||
})
|
||||
|
||||
r.Route("/transfer", func(r chi.Router) {
|
||||
r.Post("/initiate", s.handleTransferInit)
|
||||
r.Post("/{id}/accept", s.transitionHandler(transfer.StateAccepted, ""))
|
||||
r.Post("/{id}/cancel", s.transitionHandler(transfer.StateCancelled, ""))
|
||||
r.Post("/{id}/p2p", s.transitionHandler(transfer.StateP2PActive, transfer.ModeP2P))
|
||||
r.Post("/{id}/fallback", s.transitionHandler(transfer.StateRelayActive, transfer.ModeRelay))
|
||||
r.Post("/{id}/done", s.transitionHandler(transfer.StateDone, ""))
|
||||
r.Post("/{id}/fail", s.transitionHandler(transfer.StateFailed, ""))
|
||||
})
|
||||
r.Route("/relay/{id}", func(r chi.Router) {
|
||||
r.Post("/chunk", s.handleRelayChunk)
|
||||
r.Get("/stream", s.handleRelayStream)
|
||||
})
|
||||
r.Route("/transfer", func(r chi.Router) {
|
||||
r.Post("/initiate", s.handleTransferInit)
|
||||
r.Post("/{id}/accept", s.transitionHandler(transfer.StateAccepted, ""))
|
||||
r.Post("/{id}/cancel", s.transitionHandler(transfer.StateCancelled, ""))
|
||||
r.Post("/{id}/p2p", s.transitionHandler(transfer.StateP2PActive, transfer.ModeP2P))
|
||||
r.Post("/{id}/fallback", s.transitionHandler(transfer.StateRelayActive, transfer.ModeRelay))
|
||||
r.Post("/{id}/done", s.transitionHandler(transfer.StateDone, ""))
|
||||
r.Post("/{id}/fail", s.transitionHandler(transfer.StateFailed, ""))
|
||||
})
|
||||
r.Route("/relay/{id}", func(r chi.Router) {
|
||||
r.Post("/chunk", s.handleRelayChunk)
|
||||
r.Get("/stream", s.handleRelayStream)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -223,12 +205,16 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
type meResp struct {
|
||||
UserID string `json:"user_id"`
|
||||
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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
-169
@@ -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)
|
||||
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"})
|
||||
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)
|
||||
// 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
|
||||
}
|
||||
if claims.Guest() {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "full session required"})
|
||||
return
|
||||
}
|
||||
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"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
LastUsedAt int64 `json:"last_used_at"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Online bool `json:"online"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
LastUsedAt int64 `json:"last_used_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)
|
||||
// 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 {
|
||||
typeByID[d.DeviceID] = d.Type
|
||||
}
|
||||
if row.DeviceName != "" {
|
||||
named[row.DeviceName] = struct{}{}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
typ := typeByID[sess.Meta]
|
||||
if typ == "" {
|
||||
typ = "browser"
|
||||
}
|
||||
scope := "full"
|
||||
if jwtauth.ScopeTier(sess.Scope) == "guest" {
|
||||
scope = "guest"
|
||||
}
|
||||
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,
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
for _, d := range devs {
|
||||
if !isNativeDeviceType(d.Type) {
|
||||
continue
|
||||
}
|
||||
if _, ok := named[d.Name]; ok {
|
||||
continue
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+11
-9
@@ -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()),
|
||||
LastSeen: time.Now().Unix(),
|
||||
})
|
||||
// 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
|
||||
}
|
||||
|
||||
+49
-32
@@ -37,7 +37,11 @@ const clientBuffer = 32
|
||||
type Client struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
ch chan Event
|
||||
// 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
|
||||
}
|
||||
|
||||
func (c *Client) Events() <-chan Event { return c.ch }
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+77
-340
@@ -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
|
||||
cfg *config.Config
|
||||
store Store
|
||||
}
|
||||
|
||||
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
|
||||
// (0x20–0x7E), 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 (0x20–0x7E), 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":
|
||||
|
||||
+115
-540
@@ -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
|
||||
}
|
||||
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)
|
||||
// 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
|
||||
}
|
||||
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),
|
||||
}
|
||||
tok, err := jwt.Signed(sig).Claims(std).Claims(map[string]any{"scope": scope}).Serialize()
|
||||
if err != nil {
|
||||
t.Fatalf("serialize: %v", err)
|
||||
}
|
||||
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
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := SanitizeDeviceName(c.in); got != c.want {
|
||||
t.Errorf("SanitizeDeviceName(%q) = %q, want %q", c.in, got, c.want)
|
||||
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)
|
||||
}
|
||||
if c.Guest() != tc.guest {
|
||||
t.Errorf("Guest(%q): got %v, want %v", tc.scope, c.Guest(), tc.guest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
func TestSanitizeDeviceName(t *testing.T) {
|
||||
if got := SanitizeDeviceName(" Alice's Mac "); got != "Alice's Mac" {
|
||||
t.Errorf("trim: got %q", got)
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
if got := normalizeDeviceType("rogue"); got != "browser" {
|
||||
t.Errorf("unknown type should fall back to browser; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user