登录子系统:OIDC 统一自签 token + 吊销即时生效 + 设备并入会话列表 + 应用内扫码 + 禁则修复

接续 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
This commit is contained in:
2026-06-22 11:55:02 +08:00
parent 90a3790a98
commit 018a77b13a
45 changed files with 2235 additions and 299 deletions
+4
View File
@@ -74,6 +74,10 @@ func Bootstrap(ctx context.Context, d *sql.DB) error {
"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)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit bootstrap: %w", err)
}
+23
View File
@@ -27,6 +27,29 @@ func (q *Queries) DeleteDevice(ctx context.Context, arg DeleteDeviceParams) (int
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)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const deleteStaleDevices = `-- name: DeleteStaleDevices :exec
DELETE FROM devices
WHERE last_seen < ?
+4 -1
View File
@@ -73,7 +73,10 @@ CREATE TABLE IF NOT EXISTS web_sessions (
-- 但不能改账号、不能再批准别的设备、不能签发长效 token(路由层 requireFullSession 守门)。
scope TEXT NOT NULL DEFAULT 'full',
-- granted_by=扫码批准者的 session id,供审计与「吊销我批准过的借用设备」连带吊销。
granted_by TEXT NOT NULL DEFAULT ''
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);
+1
View File
@@ -101,4 +101,5 @@ type WebSession struct {
Kind string `json:"kind"`
Scope string `json:"scope"`
GrantedBy string `json:"granted_by"`
SteppedUpAt int64 `json:"stepped_up_at"`
}
+14
View File
@@ -15,6 +15,20 @@ ORDER BY name;
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: DeleteDevice :execrows
DELETE FROM devices
WHERE user_id = ? AND name = ?;
+8 -1
View File
@@ -9,10 +9,17 @@ INSERT INTO web_sessions (id, user_id, refresh_token, device_name, user_agent, c
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
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 = ?
+20 -1
View File
@@ -126,7 +126,7 @@ func (q *Queries) DeleteWebSessionForUser(ctx context.Context, arg DeleteWebSess
}
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
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 = ?
`
@@ -146,6 +146,7 @@ func (q *Queries) GetWebSession(ctx context.Context, id string) (WebSession, err
&i.Kind,
&i.Scope,
&i.GrantedBy,
&i.SteppedUpAt,
)
return i, err
}
@@ -230,6 +231,24 @@ func (q *Queries) RotateWebSession(ctx context.Context, arg RotateWebSessionPara
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 = ?
+63 -21
View File
@@ -19,7 +19,9 @@ import (
// 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 access_token still flows back to the browser unchanged.
// 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"`
@@ -120,33 +122,58 @@ func (s *Server) handleAuthExchange(w http.ResponseWriter, r *http.Request) {
// Refresh the thin accounts row (display name / avatar / roles / match_key).
s.upsertAccount(r.Context(), identity)
// Stash the refresh_token server-side and hand the browser an opaque,
// HttpOnly cookie instead. Guarded so a deploy without the encryption key (or
// an IdP that returned no refresh_token) still logs the user in — they just
// don't get passwordless re-login and re-auth on the next access-token expiry.
// 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 != "" {
if err := s.createWebSession(r, w, user.ID, tr.RefreshToken); err != nil {
slog.Error("create web session failed", "err", err)
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: tr.AccessToken,
ExpiresIn: tr.ExpiresIn,
AccessToken: accessToken,
ExpiresIn: expiresIn,
User: user,
})
}
// createWebSession encrypts the refresh_token, persists a new session row, and
// sets the session cookie on the response.
func (s *Server) createWebSession(r *http.Request, w http.ResponseWriter, userID, refreshToken string) error {
// 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
return "", err
}
enc, err := s.encryptRefresh(refreshToken)
if err != nil {
return err
return "", err
}
now := time.Now()
if err := s.queries.CreateWebSession(r.Context(), db.CreateWebSessionParams{
@@ -159,10 +186,10 @@ func (s *Server) createWebSession(r *http.Request, w http.ResponseWriter, userID
LastUsedAt: now.Unix(),
ExpiresAt: now.Add(webSessionTTL).Unix(),
}); err != nil {
return err
return "", err
}
setSessionCookie(w, raw)
return nil
return id, nil
}
// refreshResp is what the browser sees: a fresh access_token plus the identity
@@ -282,9 +309,23 @@ func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) {
}
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: tr.AccessToken,
ExpiresIn: tr.ExpiresIn,
AccessToken: accessToken,
ExpiresIn: expiresIn,
User: user,
DeviceName: sess.DeviceName,
})
@@ -369,10 +410,11 @@ type tokenIdentity struct {
// 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 — for OIDC sessions the auth middleware verifies
// the access_token on every API call via JWKS, so any tamper surfaces there. (Once
// OIDC sessions migrate to cdrop self-signed access tokens, this becomes the only
// trust point and MUST then verify the id_token signature — AUTH.md §5.)
// 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 == "" {
+8
View File
@@ -44,7 +44,15 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) {
// handleDeleteDevice 注销设备:删除 (user, name) 行 + Kick 在线 SSE + 立刻广播
// 新的 presence。请求方若注销的是自己当前设备,前端需同步调用 logout,否则
// 任何后续带 X-Device-Name 的认证请求都会被中间件再次 UPSERT 回来。
//
// 这是登录会话列表里「原生客户端(桌面 / iOS)」一栏的登出路径——它们用 IdP 令牌、
// 不入 web_sessions,故按设备名吊销(AUTH.md §4)。注销设备属敏感动作,与吊销网页
// 会话同口径要求最近一次 step-up403 step_up_required,前端据此发起再认证)。
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"})
return
}
claims, _ := jwtauth.ClaimsFromContext(r.Context())
name := chi.URLParam(r, "name")
if name == "" {
+67 -9
View File
@@ -285,7 +285,9 @@ func (s *Server) handleQRRequest(w http.ResponseWriter, r *http.Request) {
RequestIP: req.RequestIp,
ExpiresAt: req.ExpiresAt,
Status: req.Status,
StepUp: s.cfg.StepUpEnabled,
// 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,10 +311,14 @@ func (s *Server) handleQRApprove(w http.ResponseWriter, r *http.Request) {
// 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) within the freshness window (AUTH.md §6).
if s.cfg.StepUpEnabled && !s.verifyStepUp(r, claims.UserID, body.StepUpCode, body.StepUpVerifier) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"})
return
// 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
@@ -367,6 +373,50 @@ 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
@@ -401,11 +451,19 @@ func (s *Server) verifyStepUp(r *http.Request, userID, code, verifier string) bo
if sub != userID {
return false
}
maxAge := int64(s.cfg.StepUpMaxAgeSeconds)
if maxAge <= 0 {
maxAge = 300
// 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 time.Now().Unix()-authTime <= maxAge
return true
}
// lookupQRRequest fetches a request and verifies the approval_code in constant
+294 -1
View File
@@ -12,11 +12,13 @@ import (
"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"
)
@@ -35,6 +37,7 @@ func newQRTestServer(t *testing.T) *Server {
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,
@@ -42,7 +45,8 @@ func newQRTestServer(t *testing.T) *Server {
QRGuestTTLSeconds: 3600, QRPersistTTLHours: 168,
SessionTokenTTLSeconds: 900,
},
queries: db.New(conn),
queries: q,
hub: hub.New(q),
sessionKey: deriveSessionKey(qrTestSecret),
sessionTokenKey: jwtauth.DeriveSessionTokenKey(qrTestSecret),
siteOrigin: "https://drop.example.net",
@@ -222,6 +226,106 @@ func TestQRFlow_PendingLongPoll(t *testing.T) {
}
}
// 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 {
@@ -241,3 +345,192 @@ func selfTokenScope(t *testing.T, token string) string {
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)
}
}
+16
View File
@@ -176,6 +176,13 @@ func (s *Server) routes() {
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) {
@@ -219,6 +226,10 @@ type meResp struct {
UserID string `json:"user_id"`
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).
Scope string `json:"scope"`
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
@@ -228,10 +239,15 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
return
}
device, _ := jwtauth.DeviceNameFromContext(r.Context())
scope := "full"
if claims.Guest() {
scope = "guest"
}
writeJSON(w, http.StatusOK, meResp{
UserID: claims.UserID,
Groups: claims.Groups,
DeviceName: device,
Scope: scope,
})
}
+218
View File
@@ -0,0 +1,218 @@
package httpapi
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"commilitia.net/cdrop/internal/db"
"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).
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)
}
type sessionView struct {
ID string `json:"id"`
DeviceName string `json:"device_name"`
Kind string `json:"kind"` // oidc | self | guest | 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"`
}
// 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).
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)
if err != nil {
slog.Error("list sessions failed", "err", err, "user", claims.UserID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
current := ""
if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" {
current = sessionID(c.Value)
}
// Cookieless callers (native apps) can't match by cookie; fall back to their
// own device name to mark the "current" row.
callerDevice, _ := jwtauth.DeviceNameFromContext(r.Context())
out := make([]sessionView, 0, len(rows))
named := make(map[string]struct{}, len(rows))
for _, row := range rows {
if row.ExpiresAt < now {
continue // hide expired sessions (reaped separately)
}
if row.DeviceName != "" {
named[row.DeviceName] = struct{}{}
}
out = append(out, sessionView{
ID: row.ID,
DeviceName: row.DeviceName,
Kind: row.Kind,
Scope: row.Scope,
Current: row.ID == current || (current == "" && row.DeviceName != "" && row.DeviceName == callerDevice),
Online: row.DeviceName != "" && s.hub.Online(claims.UserID, row.DeviceName),
CreatedAt: row.CreatedAt,
LastUsedAt: row.LastUsedAt,
ExpiresAt: row.ExpiresAt,
})
}
// Native clients keep no web_session — surface them from the devices registry,
// skipping any whose name already has a web_session (no double-listing).
if devs, derr := s.queries.ListDevicesByUser(r.Context(), claims.UserID); derr == nil {
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.
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"})
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)
}
+13 -5
View File
@@ -11,10 +11,14 @@ import (
// DeviceSweepInterval is how often the device sweeper wakes up.
const DeviceSweepInterval = 1 * time.Hour
// RunDeviceSweeper deletes devices whose last_seen is older than ttl.
// Per user requirement: device registration must not outlive the longest
// valid refresh_token (brief §2: 196h sliding window). Anything ≤ TTL is fine;
// 0 disables the sweeper.
// 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.
func RunDeviceSweeper(ctx context.Context, queries *db.Queries, ttl time.Duration) {
if ttl <= 0 {
slog.Info("device sweeper disabled (ttl <= 0)")
@@ -29,10 +33,14 @@ func RunDeviceSweeper(ctx context.Context, queries *db.Queries, ttl time.Duratio
case <-ctx.Done():
return
case <-t.C:
cutoff := time.Now().Add(-ttl).Unix()
now := time.Now()
cutoff := 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 -13
View File
@@ -19,13 +19,16 @@ import (
"commilitia.net/cdrop/internal/db"
)
// Store is the subset of *db.Queries the auth middleware uses: device upserts
// plus the shortcut-token lookups needed to honour revocation. Declared as an
// interface so tests can swap in a fake.
// 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.
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)
}
type Authenticator struct {
@@ -108,7 +111,7 @@ func (a *Authenticator) verify(ctx context.Context, token string, r *http.Reques
// 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(token); err == nil {
if c, err := a.verifySelfToken(ctx, token); err == nil {
return c, nil
}
if c, err := a.verifyHS256(ctx, token); err == nil {
@@ -119,9 +122,13 @@ func (a *Authenticator) verify(ctx context.Context, token string, r *http.Reques
// verifySelfToken validates a cdrop self-signed session access token (AUTH.md
// §3.1): HS256 over DeriveSessionTokenKey, carrying typ=session and a full/guest
// scope. Stateless by design — no DB lookup, so it stays cheap on every request;
// revocation rides the short TTL (the session row is re-checked at /auth/refresh).
func (a *Authenticator) verifySelfToken(token string) (*Claims, error) {
// 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")
}
@@ -147,6 +154,16 @@ func (a *Authenticator) verifySelfToken(token string) (*Claims, error) {
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
}
@@ -265,8 +282,9 @@ func (a *Authenticator) verifyRS256(ctx context.Context, token string) (*Claims,
// 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). Used for step-up re-auth
// (AUTH.md §6): the caller checks sub matches and auth_time is recent enough.
// 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")
@@ -300,10 +318,10 @@ func (a *Authenticator) VerifyIDToken(ctx context.Context, token string) (subjec
if std.Subject == "" {
return "", 0, errors.New("missing subject claim")
}
at, ok := numericClaim(custom["auth_time"])
if !ok {
return "", 0, errors.New("missing auth_time 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
}
+34 -4
View File
@@ -22,9 +22,10 @@ import (
// 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
calls []db.UpsertDeviceParams
err error
tokens map[string]db.ShortcutToken
revokedSIDs map[string]bool
}
func (f *fakeDeviceUpserter) UpsertDevice(_ context.Context, arg db.UpsertDeviceParams) error {
@@ -45,6 +46,15 @@ func (f *fakeDeviceUpserter) TouchShortcutTokenUsed(_ context.Context, _ db.Touc
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())
@@ -489,6 +499,26 @@ func TestSanitizeDeviceName(t *testing.T) {
}
}
// A self-signed session token is bound to its web_session row: once the row is
// revoked (deleted), the token is rejected on its next request even though it is
// still cryptographically valid and unexpired — "log out this device" is immediate
// (AUTH.md §1/§3.1).
func TestSelfToken_RejectedAfterSessionRevoked(t *testing.T) {
secret := "test-session-secret-at-least-32-bytes-long"
exp := time.Now().Add(time.Hour)
live := New(&config.Config{AuthMode: "prod", SessionSecret: secret}, &fakeDeviceUpserter{})
if c, _ := serveBearer(live, signSelfToken(t, secret, "user-x", "session", "full", exp)); c != http.StatusOK {
t.Fatalf("token with a live session: got %d, want 200", c)
}
revoked := New(&config.Config{AuthMode: "prod", SessionSecret: secret},
&fakeDeviceUpserter{revokedSIDs: map[string]bool{"test-sid": true}})
if c, _ := serveBearer(revoked, signSelfToken(t, secret, "user-x", "session", "full", exp)); c != http.StatusUnauthorized {
t.Errorf("token whose session was revoked must be rejected, got %d", c)
}
}
// 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 {
@@ -505,7 +535,7 @@ func signSelfToken(t *testing.T, secret, sub, typ, scope string, exp time.Time)
IssuedAt: jwt.NewNumericDate(time.Now()),
Expiry: jwt.NewNumericDate(exp),
}
tok, err := jwt.Signed(sig).Claims(std).Claims(map[string]any{"typ": typ, "scope": scope}).Serialize()
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)
}