Files
Commilitia-Drop/internal/jwtauth/middleware_test.go
T
admin 90a3790a98 扫码登录:cdrop 自签会话原语 + 薄账户层 + 受限访客 + 2FA step-up
身份仍源自 OAuth provider(user_id = OIDC sub),cdrop 在其上自维护一薄层:
自签会话令牌 + accounts 表,并新增扫码快速登录。实施蓝本见 AUTH.md。

后端 · 自签会话原语
- web_sessions 加 kind/scope/granted_by 列(bootstrap 幂等迁移);既有 oidc
  会话行为不变,新增 self(滑动续期)/ guest(受限·不续)两类
- cdrop 自签 HS256 会话 access token,密钥派生自 SESSION_SECRET,与落盘 AES、
  shortcut HS256 三密钥域隔离;jwtauth.verifySelfToken 无状态校验、靠 typ 区分
- requireFullSession 守卫:guest 会话不得改账号 / 再批准设备 / 签长效 token
- /auth/refresh 按 kind 分流:self/guest 纯自签、不触 IdP

后端 · 扫码登录(internal/httpapi/qr.go)
- qr/start・status・request・approve・deny 五端点 + login_requests 表 + reaper
- 三密钥分离:QR 仅含批准信息,会话只投递给持私有 poll_secret 的原设备
  (偷拍 QR 者无 poll_secret 领不到会话、未登录批不了准)
- 会话在新设备侧领取(cookie 不经手机)、单次消费、短 TTL

后端 · 薄账户层与 2FA step-up
- accounts 表(键 sub,不含任何凭证):exchange/refresh upsert match_key /
  显示名 / 头像 / roles,供显示与未来管理员开启迁移标记时跨源关联
- step-up(默认关):开启后 qr/approve 要求新鲜 prompt=login 授权码,后端就地
  换 id_token、JWKS 验签 + auth_time 窗口 + sub 匹配,provider 2FA 于此往返强制

前端(web/)
- 显码页 /link/new + 批准页 /link + net/qr.ts,对齐 Theme B、复用 AuthShell
  与聚珍排版管线、零新全局样式
- step-up 再认证流:批准前跑 prompt=login PKCE,回调分叉(不消费一次性 code、
  独立 state key)后带 step_up_code/verifier 调 approve
- 三语 i18n qr.*;新增 qrcode 依赖

修复 · clipboard sweeper(早已提交的损坏)
- ClearExpiredClipboards 因 clipboard.sql 全角注释触发 sqlc 1.31 多字节偏移
  bug,生成 SQL 被截断为「UPDATE clipboard_state SET content =」,sweeper 运行
  期报「incomplete input」、过期剪贴板内容从未清除(短 TTL 暴露保护失效)
- 注释改纯 ASCII 并加 bug 警告,重生成得完整 SQL;prod 已验证 sweeper 由 ERROR
  转为正常清理(cleared count=1)

构建
- .dockerignore:排除本地 node_modules 等,避免宿主原生二进制污染镜像内 vite 构建
- Dockerfile.base:GOPROXY 改为可经 --build-arg 覆盖(默认仍官方代理,受限网络
  构建时传区域镜像即可,仓库不固化区域值)

文档
- 新增 AUTH.md(账户与登录实施蓝本);README 特性;.env.example /
  compose.snippet 增配置项(QR / 自签会话 TTL / step-up / match_claim)

测试
- 自签 token 密钥域隔离、扫码端到端(领取 / 单次 / poll_secret 校验 / deny /
  step-up 门)、extractIdentity、web_sessions 升级迁移
2026-06-21 22:43:36 +08:00

568 lines
19 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
}
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
}
// 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)
}
}
}
// 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}).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)
}
}