018a77b13a
接续 90a3790(扫码 A0+A1+B + step-up 雏形)。蓝本见 AUTH.md。
OIDC 统一到 cdrop 自签 token
- /auth/exchange 先 JWKS 验 id_token 签名(自签 token 的唯一信任锚)再签 cdrop
自签 HS256 access token(绑 sid),不再把 IdP RS256 下发浏览器;验签失败仅告警
并回退 RS256(会话仍建、下次 refresh 自愈),登录不中断
- /auth/refresh oidc 路径仍打 IdP 轮换 refresh_token + 探测 IdP 侧吊销,但同样回
自签 token;createWebSession 改返回行 id
- verify 链保留 RS256 分支仅供桌面端 loopback(其即时吊销留后续)
吊销即时生效 + 被吊销设备自知回退
- verifySelfToken 每请求按 sid 查 web_sessions 行,行删即拒 → 吊销在被吊销设备
下一次请求即生效(不等 TTL),oidc / self / guest 一致
- 前端仅在「服务端以 401 确证会话失效」时 forceLogout 清登录态、回登录页;短期连接
失败(5xx / 网络)一律保留登录态(refreshTokens 改三态 refreshed/invalid/transient,
cookieRefresh 改 discriminated 结果)
- __root 反应式守卫:prod 下失登录态且非认证路由即跳登录页;新增 auth.sessionLost.* 三语
设备列表并入会话列表 + 孤儿自动清除
- GET /api/auth/sessions 统一 web_sessions 与原生客户端设备(桌面 / iOS,自 devices
登记表呈现,native=true,离线也列);同名不重复列出,排除 shortcut 与无会话 browser
- 吊销网页会话连带删其设备登记(无同名在用会话时);原生「登出」走
DELETE /api/devices/{name}(同要求 step-up)
- 新增 DeleteOrphanBrowserDevices(browser 型且无存活 web_session),接入设备清扫器(1h)
应用内扫码 + 聚珍崩溃 / CJK 禁则修复
- 登录页内置 getUserMedia + jsqr 扫码,不再外跳系统应用(避免开错浏览器)
- 修聚珍(cjk-autospace)MutationObserver 与 React 重渲染同子树冲突致的 insertBefore
崩溃:扫码 / 显码 / 批准三状态机页整页跳过聚珍(data-jz-skip)
- 修流动正文未跑 CJK 禁则(jinze 为段落级、须 opt-in):设置 / 快捷指令 / 桌面页一批
hint 补 data-jz-level="paragraph" + justify,短标签 / 状态仍留文本级
step-up 完善
- auth_time 改可选(Casdoor 不下发,原强制致死循环);stepped_up_at 按会话绑定、
StepUpMaxAgeSeconds 窗口内不复要求;新增 POST /api/auth/stepup
- 批准页 persist 编进 step-up returnTo 防整页跳转丢失;scope 随档绑定(信任=full /
仅此次=guest),默认偏安全的「仅此次」
文档与测试
- AUTH.md:折中架构、accounts 薄表、令牌架构、扫码流程、step-up、§4.4 统一会话列表
- 新增 Go 测试:OIDC 自签验证、verifySelfToken 吊销即拒、会话吊销连带删设备、
统一会话列表(含原生 / 不含 shortcut / 不重复)、孤儿清扫、删设备需 step-up
537 lines
19 KiB
Go
537 lines
19 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"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/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"
|
|
|
|
// 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 {
|
|
t.Helper()
|
|
conn, err := db.Open(filepath.Join(t.TempDir(), "qr.db"))
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = conn.Close() })
|
|
if err := db.Bootstrap(context.Background(), conn); err != nil {
|
|
t.Fatalf("bootstrap: %v", err)
|
|
}
|
|
q := db.New(conn)
|
|
return &Server{
|
|
cfg: &config.Config{
|
|
AuthMode: "prod", SessionSecret: qrTestSecret,
|
|
QRLoginEnabled: true, QRRequestTTLSeconds: 120,
|
|
QRGuestTTLSeconds: 3600, QRPersistTTLHours: 168,
|
|
SessionTokenTTLSeconds: 900,
|
|
},
|
|
queries: q,
|
|
hub: hub.New(q),
|
|
sessionKey: deriveSessionKey(qrTestSecret),
|
|
sessionTokenKey: jwtauth.DeriveSessionTokenKey(qrTestSecret),
|
|
siteOrigin: "https://drop.example.net",
|
|
}
|
|
}
|
|
|
|
func qrStart(t *testing.T, s *Server, deviceName string) (qrStartResp, string) {
|
|
t.Helper()
|
|
body := fmt.Sprintf(`{"device_name":%q,"device_type":"browser"}`, deviceName)
|
|
r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/start", strings.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
s.handleQRStart(w, r)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("qr/start: %d %s", w.Code, w.Body.String())
|
|
}
|
|
var resp qrStartResp
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode start: %v", err)
|
|
}
|
|
u, err := url.Parse(resp.QRPayload)
|
|
if err != nil {
|
|
t.Fatalf("qr_payload not a url: %v", err)
|
|
}
|
|
return resp, u.Query().Get("c")
|
|
}
|
|
|
|
func qrApprove(t *testing.T, s *Server, requestID, code, persist, approver string) int {
|
|
t.Helper()
|
|
body := fmt.Sprintf(`{"request_id":%q,"approval_code":%q,"scope":"guest","persist":%q}`, requestID, code, 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"}))
|
|
w := httptest.NewRecorder()
|
|
s.handleQRApprove(w, r)
|
|
return w.Code
|
|
}
|
|
|
|
func qrStatus(s *Server, requestID, pollSecret string) *httptest.ResponseRecorder {
|
|
r := httptest.NewRequest(http.MethodGet, "/api/auth/qr/status?request_id="+url.QueryEscape(requestID), nil)
|
|
r.Header.Set("X-Poll-Secret", pollSecret)
|
|
w := httptest.NewRecorder()
|
|
s.handleQRStatus(w, r)
|
|
return w
|
|
}
|
|
|
|
func decodeStatus(t *testing.T, w *httptest.ResponseRecorder) qrStatusResp {
|
|
t.Helper()
|
|
var resp qrStatusResp
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode status: %v (%s)", err, w.Body.String())
|
|
}
|
|
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.
|
|
func TestQRFlow_ApproveCollectGuestSingleUse(t *testing.T) {
|
|
s := newQRTestServer(t)
|
|
start, code := qrStart(t, s, "Borrowed Laptop")
|
|
|
|
if c := qrApprove(t, s, start.RequestID, code, "once", "approver-1"); c != http.StatusNoContent {
|
|
t.Fatalf("approve: got %d, want 204", c)
|
|
}
|
|
|
|
w := qrStatus(s, start.RequestID, start.PollSecret)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: %d %s", w.Code, w.Body.String())
|
|
}
|
|
got := decodeStatus(t, w)
|
|
if got.Status != "approved" || got.AccessToken == "" || got.DeviceName != "Borrowed Laptop" {
|
|
t.Fatalf("collected status wrong: %+v", got)
|
|
}
|
|
if got.ExpiresIn != 900 {
|
|
t.Errorf("expires_in: got %d, want 900", got.ExpiresIn)
|
|
}
|
|
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")
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
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)
|
|
}
|
|
|
|
// Single use: a second collection finds the request consumed.
|
|
if got2 := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got2.Status != "expired" {
|
|
t.Errorf("second collection: got %q, want expired (consumed)", got2.Status)
|
|
}
|
|
}
|
|
|
|
// A persistent ("trust this device") approval yields a sliding self session.
|
|
func TestQRFlow_PersistYieldsSelfSession(t *testing.T) {
|
|
s := newQRTestServer(t)
|
|
start, code := qrStart(t, s, "Home PC")
|
|
if c := qrApprove(t, s, start.RequestID, code, "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)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Denial propagates to the polling device.
|
|
func TestQRFlow_Deny(t *testing.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"}))
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
qrStatusPollWindow = 50 * time.Millisecond
|
|
defer func() { qrStatusPollWindow = saved }()
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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).
|
|
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) {
|
|
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)
|
|
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 := probe(full); c != http.StatusOK {
|
|
t.Errorf("full token on requireFullSession 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)
|
|
}
|
|
|
|
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)
|
|
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"})
|
|
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)
|
|
}
|
|
|
|
if w := revokeByID(s, id, "u"); w.Code != http.StatusNoContent {
|
|
t.Fatalf("revoke: got %d %s, want 204", w.Code, w.Body.String())
|
|
}
|
|
if _, err := s.queries.GetWebSession(ctx, id); err == nil {
|
|
t.Errorf("session still present after revoke")
|
|
}
|
|
devs, _ := s.queries.ListDevicesByUser(ctx, "u")
|
|
for _, d := range devs {
|
|
if d.Name == "Laptop" {
|
|
t.Errorf("device 'Laptop' not deleted on session revoke")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
r := httptest.NewRequest(http.MethodGet, "/api/auth/sessions", nil)
|
|
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(),
|
|
&jwtauth.Claims{UserID: "u", SessionScope: "full"}))
|
|
w := httptest.NewRecorder()
|
|
s.handleSessionsList(w, r)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("sessions list: got %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: %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 !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")
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
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"})
|
|
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)
|
|
}
|
|
}
|