鉴权并入 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:
+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"])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user