Files
Commilitia-Drop/internal/jwtauth/middleware_test.go
T
admin 018a77b13a 登录子系统: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
2026-06-22 11:55:02 +08:00

598 lines
21 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
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 {
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)
}))
}
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 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",
}
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)),
}
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())
}
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"])
}
}
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",
}
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())
}
}
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",
}
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())
}
}
// 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 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",
}
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())
}
}
// 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 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)
}
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)
}
}
}
// 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 {
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)
}
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)
}
}