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

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

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

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

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

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

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

配置 / 删旧栈:
- config 改 broker 接入(CDROP_BROKER_* / CDROP_PUBLIC_URL / 按档 TTL),prod 强校验 broker 配置 + PUBLIC_URL(CSRF Origin 守卫不失效)
- 删 auth.go / selftoken.go / shortcut.go / jwks.go + 三表(web_sessions / accounts / shortcut_tokens)及验证链;.env.example / compose.snippet.yaml / Caddyfile.snippet 更新为 broker 模型(人机分流 + 公开端点放行 + X-Auth-Meta 透传)
- 测试全重写:QR / 会话含 mock broker(R1 列举 + R2 幂等);hub 加 close-vs-send 并发回归;config 加 prod 必填校验
This commit is contained in:
2026-06-26 02:07:11 +08:00
parent c79b176b87
commit 10cf36ecee
104 changed files with 7533 additions and 5318 deletions
+40 -34
View File
@@ -1,41 +1,47 @@
package jwtauth
import "context"
import (
"context"
"strings"
)
// Claims is the authenticated identity for a request. In prod it is sourced from the
// Auth Broker at the edge: broker /verify authenticates the caller and injects X-Auth-*
// headers this process trusts (Caddy strips any client-supplied X-Auth-* at the trust
// boundary, so only the broker can set them). In dev it is synthesised from the dev token.
type Claims struct {
UserID string
Groups []string
// JTI and Scopes are populated only for HS256 shortcut tokens; a full OIDC /
// dev session leaves them empty. A non-empty JTI marks a *scoped* token —
// one allowed to reach only the endpoints its Scopes grant (the route layer
// enforces this). This keeps a leaked shortcut token's blast radius minimal.
JTI string
Scopes []string
// SessionScope is set only for cdrop self-signed session tokens (scan-login):
// "full" or "guest". Empty for OIDC / dev sessions and scoped shortcut tokens.
// A "guest" session is capability-limited (requireFullSession rejects it on
// account-management routes) even though it is not Scoped().
SessionScope string
UserID string // X-Auth-Subject (Casdoor sub)
Name string // X-Auth-Name (display name); may be empty
Avatar string // X-Auth-Avatar (profile picture URL from the broker account); may be empty
Groups []string // X-Auth-Roles, comma-split
// Scope is the raw X-Auth-Scope: a global SSO user is "full"; a cdrop delegated
// session is "app:cdrop:<tier>". Tier() reads the capability grade off the end.
Scope string
// DeviceID is X-Auth-Meta: the cdrop device_id this session was minted for — the
// join key to the devices row. Empty for an unmanaged caller (e.g. a global SSO
// browser that never paired through cdrop).
DeviceID string
}
// Scoped reports whether these claims came from a scoped shortcut token rather
// than a full login session.
func (c *Claims) Scoped() bool { return c.JTI != "" }
// ScopeTier returns the capability grade — the last colon-separated segment of a broker
// scope ("app:cdrop:guest" → "guest", "full" → "full"). A tierless scope is its own tier.
// Shared by Claims.Tier() and the session-list overlay so the two never diverge.
func ScopeTier(scope string) string {
if i := strings.LastIndex(scope, ":"); i >= 0 {
return scope[i+1:]
}
return scope
}
// Tier returns the capability grade off the caller's scope (see ScopeTier).
func (c *Claims) Tier() string {
return ScopeTier(c.Scope)
}
// Guest reports whether these claims came from a restricted guest session (a
// scan-login borrow). Guest sessions can transfer files but not manage the
// account, approve other devices, or mint long-lived tokens.
func (c *Claims) Guest() bool { return c.SessionScope == "guest" }
// HasScope reports whether the claims grant the named scope.
func (c *Claims) HasScope(scope string) bool {
for _, s := range c.Scopes {
if s == scope {
return true
}
}
return false
}
// scan-login borrow). Guest sessions can transfer files but not manage devices,
// approve other devices, or mint long-lived tokens.
func (c *Claims) Guest() bool { return c.Tier() == "guest" }
type ctxKey int
@@ -50,9 +56,9 @@ func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
return c, ok
}
// ContextWithClaims attaches claims to a context — the inverse of
// ClaimsFromContext. The auth middleware uses this; it is also the seam handlers
// and tests use to inject claims directly.
// ContextWithClaims attaches claims to a context — the inverse of ClaimsFromContext.
// The auth middleware uses this; it is also the seam handlers and tests use to inject
// claims directly.
func ContextWithClaims(ctx context.Context, c *Claims) context.Context {
return context.WithValue(ctx, claimsCtxKey, c)
}
@@ -63,7 +69,7 @@ func DeviceNameFromContext(ctx context.Context) (string, bool) {
}
// DeviceTypeFromContext returns the client-declared device type set by the auth
// middleware (browser / macos / windows / linux), defaulting to "browser".
// middleware (browser / macos / windows / linux / ios), defaulting to "browser".
func DeviceTypeFromContext(ctx context.Context) string {
t, ok := ctx.Value(deviceTypeCtxKey).(string)
if !ok || t == "" {
+6 -13
View File
@@ -11,14 +11,11 @@ import (
// DeviceSweepInterval is how often the device sweeper wakes up.
const DeviceSweepInterval = 1 * time.Hour
// RunDeviceSweeper prunes the devices table on a timer so it stays aligned with
// the session list (AUTH.md §4): it drops (1) devices whose last_seen is older
// than ttl — registration must not outlive the longest valid refresh_token
// (brief §2: 196h sliding window) — and (2) browser devices whose web_session is
// gone (revoked or expired), which would otherwise linger until the stale cutoff
// now that users can no longer remove devices by hand. Native and shortcut
// devices keep no web_session and are pruned by the stale cutoff only. ttl ≤ 0
// disables the sweeper.
// RunDeviceSweeper prunes the devices table on a timer: it drops devices whose
// last_seen is older than ttl, so a registration never outlives the broker session's
// refresh window (a device idle past the window has no live broker session anyway).
// The session is the broker's source of truth; this only reaps stale local rows. ttl
// ≤ 0 disables the sweeper.
func RunDeviceSweeper(ctx context.Context, queries *db.Queries, ttl time.Duration) {
if ttl <= 0 {
slog.Info("device sweeper disabled (ttl <= 0)")
@@ -33,14 +30,10 @@ func RunDeviceSweeper(ctx context.Context, queries *db.Queries, ttl time.Duratio
case <-ctx.Done():
return
case <-t.C:
now := time.Now()
cutoff := now.Add(-ttl).Unix()
cutoff := time.Now().Add(-ttl).Unix()
if err := queries.DeleteStaleDevices(ctx, cutoff); err != nil {
slog.Error("device sweeper failed", "err", err)
}
if _, err := queries.DeleteOrphanBrowserDevices(ctx, now.Unix()); err != nil {
slog.Error("device sweeper: orphan browser cleanup failed", "err", err)
}
}
}
}
+7 -6
View File
@@ -31,15 +31,16 @@ func TestDeleteStaleDevices_RemovesOnlyOld(t *testing.T) {
old := now.Add(-200 * time.Hour)
fresh := now.Add(-1 * time.Hour)
upsert := func(name string, ts time.Time) {
if err := q.UpsertDevice(ctx, db.UpsertDeviceParams{
UserID: "alice", Name: name, Type: "browser", LastSeen: ts.Unix(),
create := func(name string, ts time.Time) {
if err := q.CreateDevice(ctx, db.CreateDeviceParams{
DeviceID: name, UserID: "alice", Name: name, Type: "browser",
Tier: "full", CreatedAt: ts.Unix(), LastSeen: ts.Unix(),
}); err != nil {
t.Fatalf("upsert %s: %v", name, err)
t.Fatalf("create %s: %v", name, err)
}
}
upsert("old-device", old)
upsert("fresh-device", fresh)
create("old-device", old)
create("fresh-device", fresh)
// Cutoff = "older than 196 hours from now" mimics RunDeviceSweeper math.
cutoff := now.Add(-196 * time.Hour).Unix()
-113
View File
@@ -1,113 +0,0 @@
package jwtauth
import (
"context"
"crypto/rsa"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"sync"
"time"
"github.com/go-jose/go-jose/v4"
)
// jwksCache fetches & caches the Casdoor JWKS.
//
// Behavior:
// - successful fetch: refreshes the entire key set
// - kid miss: forces a single refresh attempt
// - stale-while-error: if cached entry exists, return it even when refresh fails
type jwksCache struct {
url string
ttl time.Duration
mu sync.RWMutex
keys map[string]*rsa.PublicKey
fetchedAt time.Time
httpClient *http.Client
}
func newJWKSCache(url string, ttl time.Duration) *jwksCache {
return &jwksCache{
url: url,
ttl: ttl,
keys: map[string]*rsa.PublicKey{},
httpClient: &http.Client{Timeout: 5 * time.Second},
}
}
func (j *jwksCache) GetKey(ctx context.Context, kid string) (*rsa.PublicKey, error) {
if kid == "" {
return nil, errors.New("missing kid")
}
j.mu.RLock()
cached, found := j.keys[kid]
fresh := !j.fetchedAt.IsZero() && time.Since(j.fetchedAt) < j.ttl
j.mu.RUnlock()
if found && fresh {
return cached, nil
}
if err := j.refresh(ctx); err != nil {
if found {
slog.Warn("jwks refresh failed; returning stale key",
"err", err, "kid", kid)
return cached, nil
}
return nil, fmt.Errorf("jwks refresh: %w", err)
}
j.mu.RLock()
defer j.mu.RUnlock()
if k, ok := j.keys[kid]; ok {
return k, nil
}
return nil, fmt.Errorf("kid %q not found in JWKS", kid)
}
func (j *jwksCache) refresh(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, j.url, nil)
if err != nil {
return err
}
resp, err := j.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
var set jose.JSONWebKeySet
if err := json.Unmarshal(body, &set); err != nil {
return fmt.Errorf("parse JWKS: %w", err)
}
next := map[string]*rsa.PublicKey{}
for _, k := range set.Keys {
pk, ok := k.Key.(*rsa.PublicKey)
if !ok || k.KeyID == "" {
continue
}
next[k.KeyID] = pk
}
j.mu.Lock()
defer j.mu.Unlock()
j.keys = next
j.fetchedAt = time.Now()
return nil
}
+77 -340
View File
@@ -2,354 +2,131 @@ package jwtauth
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"commilitia.net/cdrop/internal/config"
"commilitia.net/cdrop/internal/db"
)
// Store is the subset of *db.Queries the auth middleware uses: device upserts,
// the shortcut-token lookups needed to honour revocation, and the web_session
// lookup that makes a self-signed session token's revocation take effect at once
// (a deleted row → the token is rejected on its next request, not after TTL).
// Declared as an interface so tests can swap in a fake.
// Store is the subset of *db.Queries the auth middleware uses: refreshing a managed
// device's last_seen + tier on each request. Declared as an interface so tests can
// swap in a fake.
type Store interface {
UpsertDevice(ctx context.Context, arg db.UpsertDeviceParams) error
GetShortcutToken(ctx context.Context, jti string) (db.ShortcutToken, error)
TouchShortcutTokenUsed(ctx context.Context, arg db.TouchShortcutTokenUsedParams) error
GetWebSession(ctx context.Context, id string) (db.WebSession, error)
TouchDevice(ctx context.Context, arg db.TouchDeviceParams) error
}
// Authenticator turns each request's identity into Claims. After the Auth Broker
// migration (path A) cdrop no longer verifies tokens itself: in prod the broker
// authenticates at the edge and injects X-Auth-* headers this process trusts; in dev
// the claims are synthesised from the dev token.
type Authenticator struct {
cfg *config.Config
store Store
jwks *jwksCache
hsKey []byte
sessionTokenKey []byte
cfg *config.Config
store Store
}
func New(cfg *config.Config, store Store) *Authenticator {
a := &Authenticator{
cfg: cfg,
store: store,
}
if cfg.HS256Secret != "" {
a.hsKey = DeriveHS256Key(cfg.HS256Secret)
}
// Self-signed session tokens (scan-login) are keyed off SessionSecret; nil
// when unset (dev) disables verifySelfToken, matching the minting side.
a.sessionTokenKey = DeriveSessionTokenKey(cfg.SessionSecret)
if cfg.AuthMode == "prod" && cfg.OIDCJWKSURL != "" {
a.jwks = newJWKSCache(cfg.OIDCJWKSURL, 10*time.Minute)
}
return a
return &Authenticator{cfg: cfg, store: store}
}
func (a *Authenticator) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := bearerToken(r)
if !ok {
unauthorized(w, "missing bearer token")
return
}
claims, err := a.verify(r.Context(), token, r)
claims, err := a.authenticate(r)
if err != nil {
slog.Warn("auth failed", "err", err, "path", r.URL.Path)
unauthorized(w, "invalid token")
unauthorized(w, "unauthorized")
return
}
deviceName := SanitizeDeviceName(r.Header.Get("X-Device-Name"))
deviceType := normalizeDeviceType(r.Header.Get("X-Device-Type"))
if claims.Scoped() {
// The backend authoritatively knows a scoped token is the iOS
// Shortcut, so it labels the device "shortcut" regardless of any
// header. ("ios" stays reserved for a real native client.)
deviceType = "shortcut"
}
// Register a device only when the client names itself (X-Device-Name).
// A nameless request — e.g. a polling shortcut hitting /api/clipboard/version
// without the header — must NOT be registered: the old random UA fallback
// minted a fresh "Unknown Device" row on every request and flooded the list.
if deviceName != "" {
if err := a.store.UpsertDevice(r.Context(), db.UpsertDeviceParams{
UserID: claims.UserID,
Name: deviceName,
Type: deviceType,
// Keep the managed device's last_seen + tier fresh. Identity is the
// broker-issued device_id (X-Auth-Meta); the row is created at scan-login
// collect, so this only ever updates — an unmanaged caller (no device_id,
// e.g. a global SSO browser) is skipped, and a missing row no-ops.
if claims.DeviceID != "" {
if err := a.store.TouchDevice(r.Context(), db.TouchDeviceParams{
LastSeen: time.Now().Unix(),
Tier: claims.Tier(),
DeviceID: claims.DeviceID,
UserID: claims.UserID,
}); err != nil {
// non-fatal: log and continue so transient DB errors don't 401 users
slog.Error("device upsert failed",
"err", err, "user", claims.UserID, "device", deviceName)
// non-fatal: log and continue so a transient DB error doesn't 401 users
slog.Error("device touch failed",
"err", err, "user", claims.UserID, "device", claims.DeviceID)
}
}
ctx := context.WithValue(r.Context(), claimsCtxKey, claims)
ctx := ContextWithClaims(r.Context(), claims)
ctx = context.WithValue(ctx, deviceCtxKey, deviceName)
ctx = context.WithValue(ctx, deviceTypeCtxKey, deviceType)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (a *Authenticator) verify(ctx context.Context, token string, r *http.Request) (*Claims, error) {
// authenticate resolves the request identity. prod trusts the broker's edge-injected
// X-Auth-* headers; dev synthesises claims from the dev token.
func (a *Authenticator) authenticate(r *http.Request) (*Claims, error) {
if a.cfg.AuthMode == "dev" {
return a.verifyDev(token, r)
return a.devClaims(r)
}
// cdrop self-signed session tokens first: distinct key + typ=session, so a
// shortcut token (different key, requires jti) never validates here and a
// session token never falls through to the shortcut path's DB lookup.
if c, err := a.verifySelfToken(ctx, token); err == nil {
return c, nil
// prod: the request reached cdrop only by passing broker /verify at the edge,
// which injected these headers. Caddy strips any client-supplied X-Auth-* at the
// trust boundary, so their presence is the broker's say-so. No subject → the
// request did not authenticate.
sub := r.Header.Get("X-Auth-Subject")
if sub == "" {
return nil, errors.New("missing X-Auth-Subject (request did not pass broker /verify)")
}
if c, err := a.verifyHS256(ctx, token); err == nil {
return c, nil
}
return a.verifyRS256(ctx, token)
return &Claims{
UserID: sub,
Name: r.Header.Get("X-Auth-Name"),
Avatar: r.Header.Get("X-Auth-Avatar"),
Groups: splitRoles(r.Header.Get("X-Auth-Roles")),
Scope: r.Header.Get("X-Auth-Scope"),
DeviceID: r.Header.Get("X-Auth-Meta"),
}, nil
}
// verifySelfToken validates a cdrop self-signed session access token (AUTH.md
// §3.1): HS256 over DeriveSessionTokenKey, carrying typ=session and a full/guest
// scope. This is the unified browser token — scan-login (self/guest) AND OIDC web
// logins both ride it now, so the browser holds one token type. After the cheap
// signature/exp/scope checks it does ONE indexed lookup of the token's sid against
// web_sessions: a deleted row means the session was revoked, and the token is
// rejected on its very next request (immediate "log out this device"). The IdP
// RS256 path (verifyRS256) remains only for the desktop client's loopback tokens.
func (a *Authenticator) verifySelfToken(ctx context.Context, token string) (*Claims, error) {
if len(a.sessionTokenKey) == 0 {
return nil, errors.New("session token key not configured")
}
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.HS256})
if err != nil {
return nil, err
}
var std jwt.Claims
custom := map[string]any{}
if err := parsed.Claims(a.sessionTokenKey, &std, &custom); err != nil {
return nil, err
}
if err := std.ValidateWithLeeway(jwt.Expected{Time: time.Now()}, 30*time.Second); err != nil {
return nil, err
}
if typ, _ := custom["typ"].(string); typ != "session" {
return nil, errors.New("not a session token")
}
if std.Subject == "" {
return nil, errors.New("session token missing subject")
}
scope, _ := custom["scope"].(string)
if scope != "full" && scope != "guest" {
return nil, errors.New("session token invalid scope")
}
// Bind the token to its live web_session row (sid): once that row is revoked
// (deleted), the token is rejected on its very next request — "log out this
// device" takes effect immediately rather than after the access token's TTL.
sid, _ := custom["sid"].(string)
if sid == "" {
return nil, errors.New("session token missing sid")
}
if _, err := a.store.GetWebSession(ctx, sid); err != nil {
return nil, errors.New("session revoked")
}
return &Claims{UserID: std.Subject, SessionScope: scope}, nil
}
func (a *Authenticator) verifyDev(token string, r *http.Request) (*Claims, error) {
if subtle.ConstantTimeCompare([]byte(token), []byte(a.cfg.DevToken)) != 1 {
// devClaims authenticates the local dev token and synthesises claims. X-Dev-User sets
// the subject (default "dev-user"); X-Dev-Scope simulates a tier ("guest" → restricted,
// else full); X-Dev-Device optionally sets a device_id to exercise device flows.
func (a *Authenticator) devClaims(r *http.Request) (*Claims, error) {
token, ok := bearerToken(r)
if !ok || subtle.ConstantTimeCompare([]byte(token), []byte(a.cfg.DevToken)) != 1 {
return nil, errors.New("invalid dev token")
}
userID := r.Header.Get("X-Dev-User")
if userID == "" {
userID = "dev-user"
}
return &Claims{UserID: userID, Groups: []string{"dev"}}, nil
scope := r.Header.Get("X-Dev-Scope")
if scope == "" {
scope = "full"
}
return &Claims{
UserID: userID,
Name: userID,
Groups: []string{"dev"},
Scope: scope,
DeviceID: r.Header.Get("X-Dev-Device"),
}, nil
}
func (a *Authenticator) verifyHS256(ctx context.Context, token string) (*Claims, error) {
if len(a.hsKey) == 0 {
return nil, errors.New("HS256 secret not configured")
// splitRoles parses a comma-separated X-Auth-Roles header into a role slice,
// trimming whitespace and dropping empties.
func splitRoles(raw string) []string {
if raw == "" {
return nil
}
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.HS256})
if err != nil {
return nil, err
}
var std jwt.Claims
custom := map[string]any{}
if err := parsed.Claims(a.hsKey, &std, &custom); err != nil {
return nil, err
}
if err := std.ValidateWithLeeway(jwt.Expected{Time: time.Now()}, 30*time.Second); err != nil {
return nil, err
}
claims, err := claimsFromJWT(std, custom)
if err != nil {
return nil, err
}
// HS256 is only ever used to mint scoped shortcut tokens, and those ALWAYS
// carry a jti. A validly-signed HS256 token without one must be rejected
// outright: otherwise it would fall through here as a full, unscoped account
// session with a self-declared subject — far beyond the clipboard-only
// surface HS256 is meant for. Requiring the jti keeps a leaked HS256 secret's
// blast radius pinned to the shortcut scope (clipboard, and nothing else).
if std.ID == "" {
return nil, errors.New("HS256 token missing jti")
}
// The signature alone is not enough — the token must still be present and not
// revoked in the store, so a leaked or retired token can be killed
// server-side. The stored row is also the authoritative source of scopes
// (never trust scopes off the wire).
row, err := a.store.GetShortcutToken(ctx, std.ID)
if err != nil {
return nil, fmt.Errorf("shortcut token lookup: %w", err)
}
if row.Revoked != 0 {
return nil, errors.New("shortcut token revoked")
}
if row.UserID != claims.UserID {
return nil, errors.New("shortcut token subject mismatch")
}
claims.JTI = std.ID
claims.Scopes = strings.Fields(row.Scopes)
a.touchTokenAsync(std.ID)
return claims, nil
}
// touchTokenAsync records a shortcut token's last-use time off the request path
// — it's audit metadata, so a slow or failing write must never delay or fail
// the request it belongs to.
func (a *Authenticator) touchTokenAsync(jti string) {
now := time.Now().Unix()
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := a.store.TouchShortcutTokenUsed(ctx, db.TouchShortcutTokenUsedParams{
LastUsedAt: &now,
Jti: jti,
}); err != nil {
slog.Warn("touch shortcut token failed", "err", err, "jti", jti)
}
}()
}
func (a *Authenticator) verifyRS256(ctx context.Context, token string) (*Claims, error) {
if a.jwks == nil {
return nil, errors.New("JWKS not configured")
}
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.RS256})
if err != nil {
return nil, err
}
if len(parsed.Headers) == 0 {
return nil, errors.New("missing token headers")
}
kid := parsed.Headers[0].KeyID
key, err := a.jwks.GetKey(ctx, kid)
if err != nil {
return nil, err
}
var std jwt.Claims
custom := map[string]any{}
if err := parsed.Claims(key, &std, &custom); err != nil {
return nil, err
}
expected := jwt.Expected{Time: time.Now()}
if a.cfg.OIDCIssuer != "" {
expected.Issuer = a.cfg.OIDCIssuer
}
if a.cfg.OIDCAudience != "" {
expected.AnyAudience = parseAudiences(a.cfg.OIDCAudience)
}
if err := std.ValidateWithLeeway(expected, 30*time.Second); err != nil {
return nil, err
}
return claimsFromJWT(std, custom)
}
// VerifyIDToken validates an OIDC id_token (RS256 via JWKS, issuer + audience)
// from a fresh prompt=login exchange and returns its subject and auth_time (the
// epoch second of the actual end-user authentication, or 0 when the IdP omits the
// claim — Casdoor does). Used for step-up re-auth (AUTH.md §6): the caller checks
// sub matches and, only when auth_time is present, that it is recent enough.
func (a *Authenticator) VerifyIDToken(ctx context.Context, token string) (subject string, authTime int64, err error) {
if a.jwks == nil {
return "", 0, errors.New("JWKS not configured")
}
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.RS256})
if err != nil {
return "", 0, err
}
if len(parsed.Headers) == 0 {
return "", 0, errors.New("missing token headers")
}
key, err := a.jwks.GetKey(ctx, parsed.Headers[0].KeyID)
if err != nil {
return "", 0, err
}
var std jwt.Claims
custom := map[string]any{}
if err := parsed.Claims(key, &std, &custom); err != nil {
return "", 0, err
}
expected := jwt.Expected{Time: time.Now()}
if a.cfg.OIDCIssuer != "" {
expected.Issuer = a.cfg.OIDCIssuer
}
if a.cfg.OIDCAudience != "" {
expected.AnyAudience = parseAudiences(a.cfg.OIDCAudience)
}
if err := std.ValidateWithLeeway(expected, 30*time.Second); err != nil {
return "", 0, err
}
if std.Subject == "" {
return "", 0, errors.New("missing subject claim")
}
// auth_time is OPTIONAL: required by OIDC only when the IdP chooses to honour
// max_age, and Casdoor omits it entirely. Absent → 0; the caller treats the
// fresh single-use prompt=login code as the freshness bound instead.
at, _ := numericClaim(custom["auth_time"])
return std.Subject, at, nil
}
// numericClaim coerces a JSON number claim (float64 from stdlib unmarshal, or
// json.Number / int64) to int64.
func numericClaim(v any) (int64, bool) {
switch n := v.(type) {
case float64:
return int64(n), true
case int64:
return n, true
case json.Number:
if i, err := n.Int64(); err == nil {
return i, true
}
}
return 0, false
}
// parseAudiences splits a comma-separated OIDCAudience config into a jwt.Audience
// set. Multiple values let one backend accept tokens minted for several OAuth
// clients (the web app and the desktop client carry different `aud`); go-jose's
// AnyAudience passes when the token's audience matches any one entry. Whitespace
// around entries is trimmed and empties dropped, so a plain single value behaves
// exactly as before.
func parseAudiences(raw string) jwt.Audience {
parts := strings.Split(raw, ",")
out := make(jwt.Audience, 0, len(parts))
out := make([]string, 0, len(parts))
for _, p := range parts {
if s := strings.TrimSpace(p); s != "" {
out = append(out, s)
@@ -358,21 +135,6 @@ func parseAudiences(raw string) jwt.Audience {
return out
}
func claimsFromJWT(std jwt.Claims, custom map[string]any) (*Claims, error) {
if std.Subject == "" {
return nil, errors.New("missing subject claim")
}
c := &Claims{UserID: std.Subject}
if g, ok := custom["groups"].([]any); ok {
for _, item := range g {
if s, ok := item.(string); ok {
c.Groups = append(c.Groups, s)
}
}
}
return c, nil
}
func bearerToken(r *http.Request) (string, bool) {
h := r.Header.Get("Authorization")
const prefix = "Bearer "
@@ -396,36 +158,12 @@ func unauthorized(w http.ResponseWriter, reason string) {
})
}
// DeriveHS256Key turns a configured secret of any length into a fixed 32-byte
// HMAC key. HS256 requires >= 32 bytes; hashing guarantees that (and keeps the
// minting and verifying sides in lockstep) so a short CDROP_HS256_SECRET can't
// make shortcut-token signing fail. Both signing (httpapi) and verifying use it.
func DeriveHS256Key(secret string) []byte {
sum := sha256.Sum256([]byte(secret))
return sum[:]
}
// DeriveSessionTokenKey derives the HMAC key for cdrop's self-signed session
// access tokens from CDROP_SESSION_SECRET. Domain-separated from both the at-rest
// refresh-token AES key (plain sha256(secret), in httpapi) and the shortcut-token
// key (DeriveHS256Key) so one secret yields three independent keys — a session
// token can never validate as a shortcut token, or vice versa. Empty secret →
// nil, which disables both minting and verifying (dev / unconfigured prod).
func DeriveSessionTokenKey(secret string) []byte {
if secret == "" {
return nil
}
sum := sha256.Sum256([]byte("cdrop-session-jwt\x00" + secret))
return sum[:]
}
// SanitizeDeviceName enforces the global ASCII-only device-name policy. Device
// names ride in the X-Device-Name HTTP header, which can't carry non-ASCII
// reliably (and browser fetch rejects such header values outright), so the name
// is restricted to printable ASCII everywhere. Here we keep only printable ASCII
// (0x200x7E), trim, and cap the length as a server-side backstop; clients also
// validate the name up front for a clear error. Empty after sanitising → the
// caller falls back to a UA-derived default.
// SanitizeDeviceName enforces the global ASCII-only device-name policy. Device names
// ride in the X-Device-Name HTTP header, which can't carry non-ASCII reliably (and
// browser fetch rejects such header values outright), so the name is restricted to
// printable ASCII everywhere. Here we keep only printable ASCII (0x200x7E), trim, and
// cap the length as a server-side backstop; clients also validate up front. Empty after
// sanitising → the caller falls back to a default.
func SanitizeDeviceName(raw string) string {
var b strings.Builder
for _, r := range raw {
@@ -440,10 +178,9 @@ func SanitizeDeviceName(raw string) string {
return name
}
// normalizeDeviceType whitelists the client-declared X-Device-Type so a device
// row only ever carries a known kind; anything unrecognised (incl. empty) falls
// back to "browser", the default web client. Desktop clients send macos/windows;
// "ios" is reserved for a future native iOS client (the Shortcut never sends it).
// normalizeDeviceType whitelists the client-declared X-Device-Type so a device row
// only ever carries a known kind; anything unrecognised (incl. empty) falls back to
// "browser". Native clients send macos/windows/linux/ios.
func normalizeDeviceType(raw string) string {
switch t := strings.ToLower(strings.TrimSpace(raw)); t {
case "macos", "windows", "linux", "ios", "browser":
+115 -540
View File
@@ -2,596 +2,171 @@ package jwtauth
import (
"context"
"crypto/rand"
"crypto/rsa"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"commilitia.net/cdrop/internal/config"
"commilitia.net/cdrop/internal/db"
)
// fakeDeviceUpserter records UpsertDevice calls without touching SQL and serves
// shortcut-token lookups from an in-memory map (empty → "not found", so plain
// device-only tests are unaffected).
type fakeDeviceUpserter struct {
calls []db.UpsertDeviceParams
err error
tokens map[string]db.ShortcutToken
revokedSIDs map[string]bool
type fakeDeviceStore struct {
touched []db.TouchDeviceParams
}
func (f *fakeDeviceUpserter) UpsertDevice(_ context.Context, arg db.UpsertDeviceParams) error {
f.calls = append(f.calls, arg)
return f.err
}
func (f *fakeDeviceUpserter) GetShortcutToken(_ context.Context, jti string) (db.ShortcutToken, error) {
if t, ok := f.tokens[jti]; ok {
return t, nil
}
return db.ShortcutToken{}, sql.ErrNoRows
}
// TouchShortcutTokenUsed is a no-op in tests: it runs on a detached goroutine
// (touchTokenAsync), so recording into the fake here would race the test body.
func (f *fakeDeviceUpserter) TouchShortcutTokenUsed(_ context.Context, _ db.TouchShortcutTokenUsedParams) error {
func (f *fakeDeviceStore) TouchDevice(_ context.Context, arg db.TouchDeviceParams) error {
f.touched = append(f.touched, arg)
return nil
}
// GetWebSession backs the self-token revocation check. revokedSIDs lets a test
// simulate a revoked session (deleted row); any other sid resolves to a live row.
func (f *fakeDeviceUpserter) GetWebSession(_ context.Context, id string) (db.WebSession, error) {
if f.revokedSIDs[id] {
return db.WebSession{}, sql.ErrNoRows
}
return db.WebSession{ID: id, UserID: "user-x"}, nil
}
// echo handler that responds with the claims+device discovered in context.
func echoMe(w http.ResponseWriter, r *http.Request) {
claims, _ := ClaimsFromContext(r.Context())
dev, _ := DeviceNameFromContext(r.Context())
resp := map[string]any{
"user_id": claims.UserID,
"groups": claims.Groups,
"device": dev,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
func TestDevMode_AcceptsTokenAndUsesXDevUser(t *testing.T) {
cfg := &config.Config{AuthMode: "dev", DevToken: "secret-token"}
dev := &fakeDeviceUpserter{}
a := New(cfg, dev)
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.Header.Set("Authorization", "Bearer secret-token")
req.Header.Set("X-Dev-User", "alice")
req.Header.Set("X-Device-Name", "tab-1")
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
}
var got map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if got["user_id"] != "alice" {
t.Errorf("user_id: got %v, want alice", got["user_id"])
}
if got["device"] != "tab-1" {
t.Errorf("device: got %v, want tab-1", got["device"])
}
if len(dev.calls) != 1 {
t.Errorf("UpsertDevice calls: got %d, want 1", len(dev.calls))
}
}
func TestDevMode_DefaultsUserIDWhenHeaderMissing(t *testing.T) {
cfg := &config.Config{AuthMode: "dev", DevToken: "t"}
a := New(cfg, &fakeDeviceUpserter{})
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.Header.Set("Authorization", "Bearer t")
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200", rr.Code)
}
var got map[string]any
_ = json.Unmarshal(rr.Body.Bytes(), &got)
if got["user_id"] != "dev-user" {
t.Errorf("user_id: got %v, want dev-user", got["user_id"])
}
}
func TestDevMode_RejectsWrongToken(t *testing.T) {
cfg := &config.Config{AuthMode: "dev", DevToken: "right"}
a := New(cfg, &fakeDeviceUpserter{})
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.Header.Set("Authorization", "Bearer wrong")
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("status: got %d, want 401", rr.Code)
}
}
func TestDevMode_RejectsMissingHeader(t *testing.T) {
cfg := &config.Config{AuthMode: "dev", DevToken: "x"}
a := New(cfg, &fakeDeviceUpserter{})
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("status: got %d, want 401", rr.Code)
}
}
// --- prod RS256 path ---
func newRSAKey(t *testing.T) *rsa.PrivateKey {
t.Helper()
k, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa keygen: %v", err)
}
return k
}
func startJWKSServer(t *testing.T, kid string, pub *rsa.PublicKey) *httptest.Server {
t.Helper()
jwk := jose.JSONWebKey{Key: pub, KeyID: kid, Algorithm: "RS256", Use: "sig"}
set := jose.JSONWebKeySet{Keys: []jose.JSONWebKey{jwk}}
body, err := json.Marshal(set)
if err != nil {
t.Fatalf("marshal jwks: %v", err)
}
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
// runMiddleware drives a request through the auth middleware and captures the claims
// the downstream handler sees (nil if the request was rejected before reaching it).
func runMiddleware(a *Authenticator, r *http.Request) (*httptest.ResponseRecorder, *Claims) {
var captured *Claims
h := a.Middleware(http.HandlerFunc(func(w http.ResponseWriter, rr *http.Request) {
if c, ok := ClaimsFromContext(rr.Context()); ok {
captured = c
}
w.WriteHeader(http.StatusOK)
}))
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w, captured
}
func signRS256(t *testing.T, priv *rsa.PrivateKey, kid string, claims jwt.Claims, custom map[string]any) string {
t.Helper()
signer, err := jose.NewSigner(
jose.SigningKey{Algorithm: jose.RS256, Key: priv},
(&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", kid),
)
if err != nil {
t.Fatalf("new signer: %v", err)
}
tok, err := jwt.Signed(signer).Claims(claims).Claims(custom).Serialize()
if err != nil {
t.Fatalf("sign: %v", err)
}
return tok
}
func TestMiddleware_ProdReadsAuthHeaders(t *testing.T) {
a := New(&config.Config{AuthMode: "prod"}, &fakeDeviceStore{})
r := httptest.NewRequest(http.MethodGet, "/api/me", nil)
r.Header.Set("X-Auth-Subject", "user-1")
r.Header.Set("X-Auth-Scope", "app:cdrop:guest")
r.Header.Set("X-Auth-Meta", "dev_abc")
r.Header.Set("X-Auth-Name", "Alice")
r.Header.Set("X-Auth-Roles", "admin, user")
func TestProdMode_AcceptsValidRS256(t *testing.T) {
priv := newRSAKey(t)
kid := "key-1"
srv := startJWKSServer(t, kid, &priv.PublicKey)
defer srv.Close()
cfg := &config.Config{
AuthMode: "prod",
OIDCJWKSURL: srv.URL,
OIDCIssuer: "https://oauth.example/",
OIDCAudience: "cdrop",
w, c := runMiddleware(a, r)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200", w.Code)
}
a := New(cfg, &fakeDeviceUpserter{})
now := time.Now()
std := jwt.Claims{
Issuer: "https://oauth.example/",
Subject: "user-bob",
Audience: jwt.Audience{"cdrop"},
IssuedAt: jwt.NewNumericDate(now),
Expiry: jwt.NewNumericDate(now.Add(time.Hour)),
if c == nil {
t.Fatal("claims missing from context")
}
tok := signRS256(t, priv, kid, std, map[string]any{"groups": []any{"users"}})
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.Header.Set("Authorization", "Bearer "+tok)
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
if c.UserID != "user-1" || c.DeviceID != "dev_abc" || c.Name != "Alice" {
t.Errorf("claims wrong: %+v", c)
}
var got map[string]any
_ = json.Unmarshal(rr.Body.Bytes(), &got)
if got["user_id"] != "user-bob" {
t.Errorf("user_id: got %v, want user-bob", got["user_id"])
if !c.Guest() {
t.Error("app:cdrop:guest scope should mark Guest()")
}
if len(c.Groups) != 2 || c.Groups[0] != "admin" || c.Groups[1] != "user" {
t.Errorf("groups: got %v", c.Groups)
}
}
func TestProdMode_RejectsExpiredRS256(t *testing.T) {
priv := newRSAKey(t)
kid := "key-1"
srv := startJWKSServer(t, kid, &priv.PublicKey)
defer srv.Close()
cfg := &config.Config{
AuthMode: "prod",
OIDCJWKSURL: srv.URL,
OIDCIssuer: "https://oauth.example/",
OIDCAudience: "cdrop",
func TestMiddleware_ProdMissingSubjectRejected(t *testing.T) {
a := New(&config.Config{AuthMode: "prod"}, &fakeDeviceStore{})
r := httptest.NewRequest(http.MethodGet, "/api/me", nil)
w, c := runMiddleware(a, r)
if w.Code != http.StatusUnauthorized {
t.Fatalf("no X-Auth-Subject: got %d, want 401", w.Code)
}
a := New(cfg, &fakeDeviceUpserter{})
past := time.Now().Add(-time.Hour)
std := jwt.Claims{
Issuer: "https://oauth.example/",
Subject: "user-bob",
Audience: jwt.Audience{"cdrop"},
IssuedAt: jwt.NewNumericDate(past),
Expiry: jwt.NewNumericDate(past.Add(time.Minute)), // already 59min stale
}
tok := signRS256(t, priv, kid, std, nil)
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.Header.Set("Authorization", "Bearer "+tok)
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("status: got %d, want 401; body=%s", rr.Code, rr.Body.String())
if c != nil {
t.Error("handler must not run on rejected request")
}
}
func TestProdMode_RejectsWrongIssuer(t *testing.T) {
priv := newRSAKey(t)
kid := "key-1"
srv := startJWKSServer(t, kid, &priv.PublicKey)
defer srv.Close()
cfg := &config.Config{
AuthMode: "prod",
OIDCJWKSURL: srv.URL,
OIDCIssuer: "https://oauth.example/",
OIDCAudience: "cdrop",
func TestMiddleware_TouchesManagedDevice(t *testing.T) {
fs := &fakeDeviceStore{}
a := New(&config.Config{AuthMode: "prod"}, fs)
r := httptest.NewRequest(http.MethodGet, "/api/me", nil)
r.Header.Set("X-Auth-Subject", "user-1")
r.Header.Set("X-Auth-Scope", "app:cdrop:full")
r.Header.Set("X-Auth-Meta", "dev_x")
runMiddleware(a, r)
if len(fs.touched) != 1 {
t.Fatalf("touch count: got %d, want 1", len(fs.touched))
}
a := New(cfg, &fakeDeviceUpserter{})
now := time.Now()
std := jwt.Claims{
Issuer: "https://attacker.example/",
Subject: "user-bob",
Audience: jwt.Audience{"cdrop"},
IssuedAt: jwt.NewNumericDate(now),
Expiry: jwt.NewNumericDate(now.Add(time.Hour)),
}
tok := signRS256(t, priv, kid, std, nil)
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.Header.Set("Authorization", "Bearer "+tok)
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("status: got %d, want 401; body=%s", rr.Code, rr.Body.String())
if fs.touched[0].DeviceID != "dev_x" || fs.touched[0].Tier != "full" || fs.touched[0].UserID != "user-1" {
t.Errorf("touch params: %+v", fs.touched[0])
}
}
// Multi-audience (R1): one backend serving web + desktop clients. A desktop
// token carries aud=<desktop client_id>; it must pass when OIDCAudience lists
// both client_ids comma-separated, and still be rejected when its aud is in
// neither.
func TestProdMode_AcceptsSecondAudienceInList(t *testing.T) {
priv := newRSAKey(t)
kid := "key-1"
srv := startJWKSServer(t, kid, &priv.PublicKey)
defer srv.Close()
cfg := &config.Config{
AuthMode: "prod",
OIDCJWKSURL: srv.URL,
OIDCIssuer: "https://oauth.example/",
OIDCAudience: "cdrop-web , cdrop-desktop", // spaces trimmed
}
a := New(cfg, &fakeDeviceUpserter{})
now := time.Now()
std := jwt.Claims{
Issuer: "https://oauth.example/",
Subject: "user-bob",
Audience: jwt.Audience{"cdrop-desktop"}, // the desktop client's aud
IssuedAt: jwt.NewNumericDate(now),
Expiry: jwt.NewNumericDate(now.Add(time.Hour)),
}
tok := signRS256(t, priv, kid, std, nil)
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.Header.Set("Authorization", "Bearer "+tok)
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
func TestMiddleware_SkipsTouchWhenUnmanaged(t *testing.T) {
fs := &fakeDeviceStore{}
a := New(&config.Config{AuthMode: "prod"}, fs)
r := httptest.NewRequest(http.MethodGet, "/api/me", nil)
r.Header.Set("X-Auth-Subject", "user-1")
r.Header.Set("X-Auth-Scope", "full")
// no X-Auth-Meta → unmanaged caller (e.g. a global SSO browser)
runMiddleware(a, r)
if len(fs.touched) != 0 {
t.Errorf("unmanaged caller should not touch a device row; got %d", len(fs.touched))
}
}
func TestProdMode_RejectsAudienceNotInList(t *testing.T) {
priv := newRSAKey(t)
kid := "key-1"
srv := startJWKSServer(t, kid, &priv.PublicKey)
defer srv.Close()
cfg := &config.Config{
AuthMode: "prod",
OIDCJWKSURL: srv.URL,
OIDCIssuer: "https://oauth.example/",
OIDCAudience: "cdrop-web,cdrop-desktop",
func TestMiddleware_DevMode(t *testing.T) {
a := New(&config.Config{AuthMode: "dev", DevToken: "devtok"}, &fakeDeviceStore{})
r := httptest.NewRequest(http.MethodGet, "/api/me", nil)
r.Header.Set("Authorization", "Bearer devtok")
r.Header.Set("X-Dev-User", "dev-alice")
r.Header.Set("X-Dev-Scope", "guest")
w, c := runMiddleware(a, r)
if w.Code != http.StatusOK || c == nil {
t.Fatalf("dev auth failed: %d", w.Code)
}
a := New(cfg, &fakeDeviceUpserter{})
now := time.Now()
std := jwt.Claims{
Issuer: "https://oauth.example/",
Subject: "user-bob",
Audience: jwt.Audience{"some-other-client"},
IssuedAt: jwt.NewNumericDate(now),
Expiry: jwt.NewNumericDate(now.Add(time.Hour)),
}
tok := signRS256(t, priv, kid, std, nil)
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.Header.Set("Authorization", "Bearer "+tok)
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("status: got %d, want 401; body=%s", rr.Code, rr.Body.String())
if c.UserID != "dev-alice" || !c.Guest() {
t.Errorf("dev claims wrong: %+v", c)
}
}
// A nameless request (no X-Device-Name) must NOT register a device — preventing
// the random-fallback "Unknown Device" flood from polling clients.
func TestNamelessRequestSkipsDeviceUpsert(t *testing.T) {
cfg := &config.Config{AuthMode: "dev", DevToken: "t"}
store := &fakeDeviceUpserter{}
a := New(cfg, store)
req := httptest.NewRequest(http.MethodGet, "/api/clipboard/version", nil)
req.Header.Set("Authorization", "Bearer t") // no X-Device-Name
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200", rr.Code)
}
if len(store.calls) != 0 {
t.Errorf("nameless request must not upsert a device, got %d calls", len(store.calls))
func TestMiddleware_DevModeBadToken(t *testing.T) {
a := New(&config.Config{AuthMode: "dev", DevToken: "devtok"}, &fakeDeviceStore{})
r := httptest.NewRequest(http.MethodGet, "/api/me", nil)
r.Header.Set("Authorization", "Bearer wrong")
w, _ := runMiddleware(a, r)
if w.Code != http.StatusUnauthorized {
t.Fatalf("bad dev token: got %d, want 401", w.Code)
}
}
func signHS256(t *testing.T, secret, sub, jti, scope string, exp time.Time) string {
t.Helper()
sig, err := jose.NewSigner(
jose.SigningKey{Algorithm: jose.HS256, Key: DeriveHS256Key(secret)},
(&jose.SignerOptions{}).WithType("JWT"),
)
if err != nil {
t.Fatalf("signer: %v", err)
func TestClaimsTier(t *testing.T) {
cases := []struct {
scope string
tier string
guest bool
}{
{"app:cdrop:guest", "guest", true},
{"app:cdrop:full", "full", false},
{"full", "full", false},
{"app:cdrop", "cdrop", false},
{"", "", false},
}
std := jwt.Claims{
Subject: sub,
ID: jti,
IssuedAt: jwt.NewNumericDate(time.Now()),
Expiry: jwt.NewNumericDate(exp),
}
tok, err := jwt.Signed(sig).Claims(std).Claims(map[string]any{"scope": scope}).Serialize()
if err != nil {
t.Fatalf("serialize: %v", err)
}
return tok
}
func serveBearer(a *Authenticator, tok string) (int, *Claims) {
req := httptest.NewRequest(http.MethodGet, "/api/clipboard", nil)
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("X-Device-Name", "iPhone")
rr := httptest.NewRecorder()
var got *Claims
a.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got, _ = ClaimsFromContext(r.Context())
w.WriteHeader(http.StatusOK)
})).ServeHTTP(rr, req)
return rr.Code, got
}
// A valid HS256 shortcut token authenticates and carries its jti + stored scope;
// revocation, an unknown jti, and a subject/owner mismatch are all rejected even
// though the signature is valid — the store is the authority.
func TestShortcutToken_HS256VerifyRevokeAndScope(t *testing.T) {
secret := "test-hs256-secret-at-least-32-bytes-long" // HS256 needs >= 32 bytes
cfg := &config.Config{AuthMode: "prod", HS256Secret: secret}
exp := time.Now().Add(time.Hour)
store := &fakeDeviceUpserter{tokens: map[string]db.ShortcutToken{
"jti-1": {Jti: "jti-1", UserID: "user-x", Scopes: "clipboard", ExpiresAt: exp.Unix()},
}}
a := New(cfg, store)
tok := signHS256(t, secret, "user-x", "jti-1", "clipboard", exp)
code, claims := serveBearer(a, tok)
if code != http.StatusOK {
t.Fatalf("valid token: got %d, want 200", code)
}
if claims == nil || !claims.Scoped() || claims.JTI != "jti-1" || !claims.HasScope("clipboard") {
t.Fatalf("claims not populated as scoped clipboard token: %+v", claims)
}
// The device registers under the (ASCII) X-Device-Name the request carried.
if len(store.calls) == 0 || store.calls[len(store.calls)-1].Name != "iPhone" {
t.Errorf("expected device name from header, got %+v", store.calls)
}
// Subject mismatch: stored row owned by a different user than the token's sub.
store.tokens["jti-1"] = db.ShortcutToken{Jti: "jti-1", UserID: "someone-else", Scopes: "clipboard", ExpiresAt: exp.Unix()}
if code, _ := serveBearer(a, tok); code != http.StatusUnauthorized {
t.Errorf("subject mismatch: got %d, want 401", code)
}
// Revoked row → reject.
store.tokens["jti-1"] = db.ShortcutToken{Jti: "jti-1", UserID: "user-x", Scopes: "clipboard", Revoked: 1, ExpiresAt: exp.Unix()}
if code, _ := serveBearer(a, tok); code != http.StatusUnauthorized {
t.Errorf("revoked token: got %d, want 401", code)
}
// Unknown jti (signed but no stored row) → reject.
ghost := signHS256(t, secret, "user-x", "ghost", "clipboard", exp)
if code, _ := serveBearer(a, ghost); code != http.StatusUnauthorized {
t.Errorf("unknown jti: got %d, want 401", code)
}
}
// An HS256 token WITHOUT a jti must be rejected outright (G1): the HS256 path
// only ever signs scoped shortcut tokens, which always carry a jti. A jti-less
// but validly-signed token would otherwise fall through as a full, unscoped
// account session with a self-declared subject — so a leaked HS256 secret could
// mint arbitrary-subject sessions far beyond the clipboard scope. Requiring the
// jti pins a leaked secret's blast radius to clipboard-only.
func TestShortcutToken_HS256RejectsMissingJTI(t *testing.T) {
secret := "test-hs256-secret-at-least-32-bytes-long"
cfg := &config.Config{AuthMode: "prod", HS256Secret: secret}
a := New(cfg, &fakeDeviceUpserter{})
tok := signHS256(t, secret, "user-x", "", "clipboard", time.Now().Add(time.Hour))
if code, _ := serveBearer(a, tok); code != http.StatusUnauthorized {
t.Errorf("jti-less HS256 token must be rejected, got %d", code)
}
}
func TestSanitizeDeviceName(t *testing.T) {
cases := []struct{ in, want string }{
{"iPhone", "iPhone"},
{" My iPad ", "My iPad"},
{"我的iPhone", "iPhone"}, // CJK stripped
{"我的电脑", ""}, // all non-ASCII → empty (caller falls back)
{"Mac Book", "MacBook"}, // NBSP dropped
}
for _, c := range cases {
if got := SanitizeDeviceName(c.in); got != c.want {
t.Errorf("SanitizeDeviceName(%q) = %q, want %q", c.in, got, c.want)
for _, tc := range cases {
c := &Claims{Scope: tc.scope}
if c.Tier() != tc.tier {
t.Errorf("Tier(%q): got %q, want %q", tc.scope, c.Tier(), tc.tier)
}
if c.Guest() != tc.guest {
t.Errorf("Guest(%q): got %v, want %v", tc.scope, c.Guest(), tc.guest)
}
}
}
// A self-signed session token is bound to its web_session row: once the row is
// revoked (deleted), the token is rejected on its next request even though it is
// still cryptographically valid and unexpired — "log out this device" is immediate
// (AUTH.md §1/§3.1).
func TestSelfToken_RejectedAfterSessionRevoked(t *testing.T) {
secret := "test-session-secret-at-least-32-bytes-long"
exp := time.Now().Add(time.Hour)
live := New(&config.Config{AuthMode: "prod", SessionSecret: secret}, &fakeDeviceUpserter{})
if c, _ := serveBearer(live, signSelfToken(t, secret, "user-x", "session", "full", exp)); c != http.StatusOK {
t.Fatalf("token with a live session: got %d, want 200", c)
func TestSanitizeDeviceName(t *testing.T) {
if got := SanitizeDeviceName(" Alice's Mac "); got != "Alice's Mac" {
t.Errorf("trim: got %q", got)
}
revoked := New(&config.Config{AuthMode: "prod", SessionSecret: secret},
&fakeDeviceUpserter{revokedSIDs: map[string]bool{"test-sid": true}})
if c, _ := serveBearer(revoked, signSelfToken(t, secret, "user-x", "session", "full", exp)); c != http.StatusUnauthorized {
t.Errorf("token whose session was revoked must be rejected, got %d", c)
// Non-ASCII is stripped (the name rides an HTTP header).
if got := SanitizeDeviceName("名字abc"); got != "abc" {
t.Errorf("non-ascii strip: got %q", got)
}
}
// signSelfToken mints a cdrop self-signed session token the way httpapi.mintSessionToken
// does: HS256 over DeriveSessionTokenKey, with typ + scope custom claims and no jti.
func signSelfToken(t *testing.T, secret, sub, typ, scope string, exp time.Time) string {
t.Helper()
sig, err := jose.NewSigner(
jose.SigningKey{Algorithm: jose.HS256, Key: DeriveSessionTokenKey(secret)},
(&jose.SignerOptions{}).WithType("JWT"),
)
if err != nil {
t.Fatalf("signer: %v", err)
func TestNormalizeDeviceType(t *testing.T) {
for _, in := range []string{"macos", "windows", "linux", "ios", "browser"} {
if got := normalizeDeviceType(in); got != in {
t.Errorf("normalizeDeviceType(%q): got %q", in, got)
}
}
std := jwt.Claims{
Subject: sub,
IssuedAt: jwt.NewNumericDate(time.Now()),
Expiry: jwt.NewNumericDate(exp),
}
tok, err := jwt.Signed(sig).Claims(std).Claims(map[string]any{"typ": typ, "scope": scope, "sid": "test-sid"}).Serialize()
if err != nil {
t.Fatalf("serialize: %v", err)
}
return tok
}
// A cdrop self-signed session token authenticates as a full or guest session;
// guest is capability-limited (Guest() true). A wrong typ or an unknown scope is
// rejected, and the SessionSecret-derived key is domain-isolated from the HS256
// shortcut key so the two token families never cross-validate (AUTH.md §3.1).
func TestSelfToken_ScopeAndKeyIsolation(t *testing.T) {
secret := "test-session-secret-at-least-32-bytes-long"
a := New(&config.Config{AuthMode: "prod", SessionSecret: secret}, &fakeDeviceUpserter{})
exp := time.Now().Add(time.Hour)
// Full session: authenticates, not scoped, not guest.
code, claims := serveBearer(a, signSelfToken(t, secret, "user-x", "session", "full", exp))
if code != http.StatusOK {
t.Fatalf("full session token: got %d, want 200", code)
}
if claims == nil || claims.UserID != "user-x" || claims.SessionScope != "full" || claims.Scoped() || claims.Guest() {
t.Fatalf("full session claims wrong: %+v", claims)
}
// Guest session: authenticates and is marked guest (capability-limited).
code, claims = serveBearer(a, signSelfToken(t, secret, "user-x", "session", "guest", exp))
if code != http.StatusOK || claims == nil || !claims.Guest() || claims.Scoped() {
t.Fatalf("guest session: code=%d claims=%+v", code, claims)
}
// Wrong typ (not "session") signed with the session key → rejected.
if c, _ := serveBearer(a, signSelfToken(t, secret, "user-x", "other", "full", exp)); c != http.StatusUnauthorized {
t.Errorf("wrong typ: got %d, want 401", c)
}
// Unknown scope → rejected (no privilege-by-typo).
if c, _ := serveBearer(a, signSelfToken(t, secret, "user-x", "session", "admin", exp)); c != http.StatusUnauthorized {
t.Errorf("invalid scope: got %d, want 401", c)
}
// Key-domain isolation: with BOTH secrets set, the two families stay disjoint —
// a session token never becomes scoped, a shortcut token never becomes a session.
hsSecret := "test-hs256-secret-at-least-32-bytes-long"
store := &fakeDeviceUpserter{tokens: map[string]db.ShortcutToken{
"jti-1": {Jti: "jti-1", UserID: "user-x", Scopes: "clipboard", ExpiresAt: exp.Unix()},
}}
ab := New(&config.Config{AuthMode: "prod", SessionSecret: secret, HS256Secret: hsSecret}, store)
if _, sc := serveBearer(ab, signSelfToken(t, secret, "user-x", "session", "guest", exp)); sc == nil || sc.Scoped() || !sc.Guest() {
t.Errorf("session token leaked into scoped path: %+v", sc)
}
if _, hc := serveBearer(ab, signHS256(t, hsSecret, "user-x", "jti-1", "clipboard", exp)); hc == nil || !hc.Scoped() || hc.SessionScope != "" {
t.Errorf("shortcut token leaked into session path: %+v", hc)
}
// No SessionSecret on the server → self tokens are unverifiable (key nil).
noSecret := New(&config.Config{AuthMode: "prod", HS256Secret: hsSecret}, &fakeDeviceUpserter{})
if c, _ := serveBearer(noSecret, signSelfToken(t, secret, "user-x", "session", "full", exp)); c != http.StatusUnauthorized {
t.Errorf("self token without server SessionSecret must be rejected, got %d", c)
if got := normalizeDeviceType("rogue"); got != "browser" {
t.Errorf("unknown type should fall back to browser; got %q", got)
}
}