Files
sm-user-plugin/device_auth_test.go
Jakub Zych cc3636cea9 fix: a device revoke cannot lose to a concurrent refresh
The revoke now reads the device's jti with UPDATE ... RETURNING under the
row lock, and the refresh's hand-over (classes.FollowDeviceJTI) reports a
session revoked meanwhile, so the refresh answers the blacklisted-token
401 without a token. A hand-over error fails the refresh closed instead
of leaving the session on a stale jti.
2026-10-26 16:00:43 +01:00

1174 lines
49 KiB
Go

package user
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"strings"
"sync"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"github.com/golang-jwt/jwt/v5"
)
// TestDeviceAuthURL pins the auth_url template and the session TTL (D-11)
// without a database.
func TestDeviceAuthURL(t *testing.T) {
if got := classes.DeviceAuthURL(nil, "tok"); got != "/device/scan/tok" {
t.Fatalf("auth_url without an app = %q", got)
}
if got := classes.DeviceAuthTTL(nil); got != 5*time.Minute {
t.Fatalf("ttl without an app = %s", got)
}
app := backpack.New(sessionConfig(t))
set := func(key string, value any) {
t.Helper()
if err := app.Config.Set(key, value); err != nil {
t.Fatal(err)
}
}
for _, step := range []struct {
key string
value any
want string
}{
{"", nil, "/device/scan/tok-1_A"},
{"app.url", "https://api.example.test", "https://api.example.test/device/scan/tok-1_A"},
{"app.url", "https://api.example.test///", "https://api.example.test/device/scan/tok-1_A"},
{"golem15.user.frontend_url", " https://www.example.test/ ", "https://www.example.test/device/scan/tok-1_A"},
{"golem15.user.device_auth.auth_url", " ", "https://www.example.test/device/scan/tok-1_A"},
{"golem15.user.device_auth.auth_url", "{frontend_url}/link?device={token}", "https://www.example.test/link?device=tok-1_A"},
{"golem15.user.device_auth.auth_url", "https://tv.example.test/pair/{token}/{token}", "https://tv.example.test/pair/tok-1_A/tok-1_A"},
{"golem15.user.device_auth.auth_url", "{frontend_url}/pair", "https://www.example.test/pair"},
} {
if step.key != "" {
set(step.key, step.value)
}
if got := classes.DeviceAuthURL(app, "tok-1_A"); got != step.want {
t.Fatalf("after %s=%v: auth_url = %q, want %q", step.key, step.value, got, step.want)
}
}
for _, c := range []struct {
minutes any
want time.Duration
}{
{nil, 5 * time.Minute},
{10, 10 * time.Minute},
{1, time.Minute},
{0, 5 * time.Minute},
{-3, 5 * time.Minute},
} {
if c.minutes != nil {
set("golem15.user.device_auth.ttl_minutes", c.minutes)
}
if got := classes.DeviceAuthTTL(app); got != c.want {
t.Fatalf("ttl_minutes %v = %s, want %s", c.minutes, got, c.want)
}
}
}
// TestDeviceDisplayName pins PHP's device_name accessor order: a stored
// name wins; otherwise iPhone, iPad, Android, Windows, Macintosh and Linux
// are checked in that order (an iPhone's "like Mac OS X" stays an iPhone,
// Android's "Linux" stays Android), then Unknown Device.
func TestDeviceDisplayName(t *testing.T) {
stored := "Living room TV"
empty := ""
for _, c := range []struct {
name string
stored *string
ua string
want string
}{
{"stored_wins", &stored, "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)", stored},
{"empty_stored_parses", &empty, "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "Windows PC"},
{"iphone_before_mac", nil, "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15", "iPhone"},
{"ipad", nil, "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)", "iPad"},
{"ipad_desktop_mode_is_mac", nil, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Version/17.0 Safari", "Mac"},
{"android_before_linux", nil, "Mozilla/5.0 (Linux; Android 14; Pixel 8)", "Android Device"},
{"windows", nil, "Mozilla/5.0 (Windows NT 10.0)", "Windows PC"},
{"mac", nil, "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)", "Mac"},
{"linux", nil, "Mozilla/5.0 (X11; Linux x86_64)", "Linux PC"},
{"smart_tv_on_linux", nil, "Mozilla/5.0 (SMART-TV; Linux; Tizen 7.0)", "Linux PC"},
{"case_sensitive_like_php", nil, "mozilla/5.0 (iphone)", "Unknown Device"},
{"curl", nil, "curl/8.0", "Unknown Device"},
{"no_user_agent", nil, "", "Unknown Device"},
} {
t.Run(c.name, func(t *testing.T) {
if got := classes.DeviceDisplayName(c.stored, c.ua); got != c.want {
t.Fatalf("DeviceDisplayName(%q) = %q, want %q", c.ua, got, c.want)
}
})
}
}
// ---------------------------------------------------------------------------
// Device flow through the assembled routes (Postgres).
// envInitiate posts devices/initiate through the router.
func envInitiate(t *testing.T, env *adminEnv, body any) (deviceInitiated, *httptest.ResponseRecorder) {
t.Helper()
rec := env.call(t, http.MethodPost, "/_user/api/v1/devices/initiate", "", body)
var out struct {
Data deviceInitiated `json:"data"`
}
if rec.Code == http.StatusOK {
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
}
return out.Data, rec
}
func envNewDevice(t *testing.T, env *adminEnv) deviceInitiated {
t.Helper()
dev, rec := envInitiate(t, env, map[string]any{})
if rec.Code != http.StatusOK {
t.Fatalf("initiate = %d %s", rec.Code, rec.Body.String())
}
return dev
}
func envPoll(t *testing.T, env *adminEnv, token string) *httptest.ResponseRecorder {
t.Helper()
return env.call(t, http.MethodGet, "/_user/api/v1/devices/status/"+token, "", nil)
}
func envAuthorize(t *testing.T, env *adminEnv, session string, body any) *httptest.ResponseRecorder {
t.Helper()
return env.call(t, http.MethodPost, "/_user/api/v1/devices/authorize", session, body)
}
func envDeviceRow(t *testing.T, env *adminEnv, token string) models.DeviceAuthSession {
t.Helper()
var row models.DeviceAuthSession
if err := env.gdb.Where("token = ?", classes.HashAPIToken(token)).Take(&row).Error; err != nil {
t.Fatal(err)
}
return row
}
func envSetDevice(t *testing.T, env *adminEnv, id uint, values map[string]any) {
t.Helper()
if err := env.gdb.Model(&models.DeviceAuthSession{}).Where("id = ?", id).Updates(values).Error; err != nil {
t.Fatal(err)
}
}
// envSession signs a fresh user in and returns it with its session token.
func envSession(t *testing.T, env *adminEnv, label string) (*models.User, string) {
t.Helper()
user := env.seedUser(t, label, nil)
status, token := env.userLogin(t, user.Email, "correct-horse")
if status != http.StatusOK || token == "" {
t.Fatalf("login %s = %d", label, status)
}
return user, token
}
func wantBody(t *testing.T, rec *httptest.ResponseRecorder, status int, body string) {
t.Helper()
if rec.Code != status || rec.Body.String() != body {
t.Fatalf("answer = %d %s, want %d %s", rec.Code, rec.Body.String(), status, body)
}
}
const (
deviceAuthorizedBody = `{"message":"Device authorized successfully","success":true}`
deviceInvalidBody = `{"error":"Invalid or expired code"}`
deviceCodeLengthBody = `{"error":"Please enter a valid 8-character code"}`
deviceUnauthorized = `{"error":"Unauthorized"}`
deviceExpiredBody = `{"message":"Authorization session has expired","status":"expired","success":true}`
deviceNotFoundBody = `{"error":"Session not found"}`
deviceRevokedOKBody = `{"message":"Device revoked successfully","success":true}`
deviceNotFoundRevoke = `{"error":"Device not found"}`
deviceNameTooLongBody = `{"error":"The device name may not be greater than 255 characters.","errors":{"device_name":["The device name may not be greater than 255 characters."]}}`
)
func deviceStatusBody(status string) string {
return `{"data":{"status":"` + status + `"},"success":true}`
}
// TestDeviceInitiate pins POST devices/initiate: PHP's answer shape, the
// stored pending row, the TTL setting, the device_name bound, the bounded
// cleanup of stale sessions and the retry of an insert whose short code
// collides with a stored one.
func TestDeviceInitiate(t *testing.T) {
if testing.Short() {
t.Skip("Postgres")
}
env := newAdminEnv(t)
t.Run("answer_and_row", func(t *testing.T) {
before := time.Now()
rec := env.send(t, http.MethodPost, "/_user/api/v1/devices/initiate", "", []byte(`{"device_name":"Kitchen TV"}`), "application/json",
map[string]string{"User-Agent": "Mozilla/5.0 (SMART-TV; Linux; Tizen 7.0)"})
if rec.Code != http.StatusOK {
t.Fatalf("initiate = %d %s", rec.Code, rec.Body.String())
}
var raw map[string]map[string]any
var top map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &top); err != nil || top["success"] != true || len(top) != 2 {
t.Fatalf("initiate body %s", rec.Body.String())
}
_ = json.Unmarshal(rec.Body.Bytes(), &raw)
data := raw["data"]
keys := make([]string, 0, len(data))
for k := range data {
keys = append(keys, k)
}
slices.Sort(keys)
if strings.Join(keys, ",") != "auth_url,expires_at,short_code,token" {
t.Fatalf("data keys = %v", keys)
}
token, _ := data["token"].(string)
code, _ := data["short_code"].(string)
if len(token) != 64 || !deviceShortCodeRE.MatchString(code) {
t.Fatalf("token %q, short code %q", token, code)
}
if data["auth_url"] != "https://app.example.test/device/scan/"+token {
t.Fatalf("auth_url = %v", data["auth_url"])
}
expires, err := time.Parse("2006-01-02T15:04:05-07:00", data["expires_at"].(string))
if err != nil || !strings.HasSuffix(data["expires_at"].(string), "+00:00") {
t.Fatalf("expires_at %v: %v", data["expires_at"], err)
}
if d := expires.Sub(before); d < 4*time.Minute+58*time.Second || d > 5*time.Minute+2*time.Second {
t.Fatalf("expires_at is %s after the request, want 5m", d)
}
row := envDeviceRow(t, env, token)
if row.Status != models.DeviceStatusPending || row.UserID != nil || row.ShortCode == nil || *row.ShortCode != code ||
row.DeviceName == nil || *row.DeviceName != "Kitchen TV" || row.DeviceIP == nil || !strings.HasPrefix(*row.DeviceIP, "198.51.100.") ||
row.DeviceUserAgent == nil || *row.DeviceUserAgent != "Mozilla/5.0 (SMART-TV; Linux; Tizen 7.0)" ||
row.ConfirmedAt != nil || row.UsedAt != nil || row.JWTJTI != nil || row.AuthIP != nil || row.SessionID != nil {
t.Fatalf("stored row = %+v", row)
}
})
t.Run("ttl_setting", func(t *testing.T) {
if err := env.app.Config.Set("golem15.user.device_auth.ttl_minutes", 10); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = env.app.Config.Set("golem15.user.device_auth.ttl_minutes", 5) })
dev := envNewDevice(t, env)
row := envDeviceRow(t, env, dev.Token)
if d := time.Until(*row.ExpiresAt); d < 9*time.Minute+50*time.Second || d > 10*time.Minute+time.Second {
t.Fatalf("expires in %s, want 10m", d)
}
})
t.Run("device_name", func(t *testing.T) {
var before, after int64
env.gdb.Model(&models.DeviceAuthSession{}).Count(&before)
_, rec := envInitiate(t, env, map[string]any{"device_name": strings.Repeat("n", 256)})
wantBody(t, rec, http.StatusUnprocessableEntity, deviceNameTooLongBody)
_, rec = envInitiate(t, env, map[string]any{"device_name": strings.Repeat("ł", 256)})
wantBody(t, rec, http.StatusUnprocessableEntity, deviceNameTooLongBody)
env.gdb.Model(&models.DeviceAuthSession{}).Count(&after)
if after != before {
t.Fatalf("a refused initiate stored %d rows", after-before)
}
long := strings.Repeat("ł", 255)
dev, rec := envInitiate(t, env, map[string]any{"device_name": long})
if rec.Code != http.StatusOK {
t.Fatalf("255 characters = %d %s", rec.Code, rec.Body.String())
}
if row := envDeviceRow(t, env, dev.Token); row.DeviceName == nil || *row.DeviceName != long {
t.Fatal("the 255-character name was not stored as sent")
}
dev = envNewDevice(t, env)
if row := envDeviceRow(t, env, dev.Token); row.DeviceName != nil {
t.Fatalf("no device_name stored %q", *row.DeviceName)
}
})
t.Run("cleanup_is_bounded", func(t *testing.T) {
stamp := adminStamp()
batch := 0
insert := func(n int, status, expires string) {
t.Helper()
batch++
if err := env.gdb.Exec(`INSERT INTO golem15_user_device_auth_sessions (token, status, expires_at)
SELECT 'cleanup-' || ? || '-' || ? || '-' || g, ?, `+expires+` FROM generate_series(1, ?) g`, stamp, strconv.Itoa(batch), status, n).Error; err != nil {
t.Fatal(err)
}
}
insert(503, "pending", "NOW() - INTERVAL '2 hours'")
insert(2, "expired", "NOW() - INTERVAL '3 hours'")
insert(1, "pending", "NOW() - INTERVAL '30 minutes'")
insert(1, "confirmed", "NOW() - INTERVAL '2 hours'")
insert(1, "used", "NOW() - INTERVAL '2 hours'")
insert(1, "revoked", "NOW() - INTERVAL '2 hours'")
insert(1, "pending", "NULL")
stale := func() int64 {
var n int64
if err := env.gdb.Raw(`SELECT COUNT(*) FROM golem15_user_device_auth_sessions
WHERE status IN ('pending', 'expired') AND expires_at < NOW() - INTERVAL '1 hour'`).Scan(&n).Error; err != nil {
t.Fatal(err)
}
return n
}
mine := func(where string) int64 {
var n int64
if err := env.gdb.Raw(`SELECT COUNT(*) FROM golem15_user_device_auth_sessions WHERE token LIKE ? AND `+where, "cleanup-"+stamp+"-%").Scan(&n).Error; err != nil {
t.Fatal(err)
}
return n
}
before := stale()
if before < 505 {
t.Fatalf("stale rows before = %d", before)
}
envNewDevice(t, env)
if after := stale(); after != before-500 {
t.Fatalf("one initiate deleted %d stale rows, want 500", before-after)
}
for envNewDevice(t, env); stale() > 0; envNewDevice(t, env) {
}
if n := mine(`status IN ('pending', 'expired') AND expires_at < NOW() - INTERVAL '1 hour'`); n != 0 {
t.Fatalf("%d stale rows survived", n)
}
if n := mine(`status = 'pending' AND (expires_at IS NULL OR expires_at > NOW() - INTERVAL '1 hour')`); n != 2 {
t.Fatalf("recent and undated pending rows left = %d, want 2", n)
}
if n := mine(`status IN ('confirmed', 'used', 'revoked')`); n != 3 {
t.Fatalf("confirmed, used and revoked rows left = %d, want 3", n)
}
})
t.Run("short_code_collision_retry", func(t *testing.T) {
const taken = "QQQQ-QQQQ"
if err := env.gdb.Exec(`INSERT INTO golem15_user_device_auth_sessions (token, short_code, status, expires_at)
VALUES (?, ?, 'pending', NOW() + INTERVAL '5 minutes')`, "collision-"+adminStamp(), taken).Error; err != nil {
t.Fatal(err)
}
// A BEFORE INSERT trigger gives the first `collide` inserts the
// stored short code, so the unique index refuses them exactly as a
// random collision would.
collide := func(n int) {
t.Helper()
for _, stmt := range []string{
`DROP TRIGGER IF EXISTS device_force_collision ON golem15_user_device_auth_sessions`,
`DROP SEQUENCE IF EXISTS device_collision_seq`,
`CREATE SEQUENCE device_collision_seq`,
`CREATE OR REPLACE FUNCTION device_force_collision() RETURNS trigger AS $$
BEGIN
IF nextval('device_collision_seq') <= ` + strconv.Itoa(n) + ` THEN
NEW.short_code := '` + taken + `';
END IF;
RETURN NEW;
END $$ LANGUAGE plpgsql`,
`CREATE TRIGGER device_force_collision BEFORE INSERT ON golem15_user_device_auth_sessions
FOR EACH ROW EXECUTE FUNCTION device_force_collision()`,
} {
if err := env.gdb.Exec(stmt).Error; err != nil {
t.Fatal(err)
}
}
}
t.Cleanup(func() {
_ = env.gdb.Exec(`DROP TRIGGER IF EXISTS device_force_collision ON golem15_user_device_auth_sessions`).Error
_ = env.gdb.Exec(`DROP FUNCTION IF EXISTS device_force_collision()`).Error
_ = env.gdb.Exec(`DROP SEQUENCE IF EXISTS device_collision_seq`).Error
})
var before, after int64
fresh := func(n *int64) {
t.Helper()
if err := env.gdb.Raw(`SELECT COUNT(*) FROM golem15_user_device_auth_sessions WHERE status = 'pending' AND expires_at > NOW()`).Scan(n).Error; err != nil {
t.Fatal(err)
}
}
collide(2)
dev := envNewDevice(t, env)
if dev.ShortCode == taken || !deviceShortCodeRE.MatchString(dev.ShortCode) {
t.Fatalf("retried initiate answered short code %q", dev.ShortCode)
}
var calls int64
if err := env.gdb.Raw(`SELECT last_value FROM device_collision_seq`).Scan(&calls).Error; err != nil || calls != 3 {
t.Fatalf("insert attempts = %d (%v), want 3", calls, err)
}
if row := envDeviceRow(t, env, dev.Token); row.ShortCode == nil || *row.ShortCode != dev.ShortCode {
t.Fatalf("stored short code = %v", row.ShortCode)
}
fresh(&before)
collide(5)
_, rec := envInitiate(t, env, map[string]any{})
if rec.Code != http.StatusInternalServerError || strings.Contains(rec.Body.String(), "token") {
t.Fatalf("five collisions = %d %s, want the opaque 500", rec.Code, rec.Body.String())
}
fresh(&after)
if after != before {
t.Fatalf("a failed initiate stored %d rows", after-before)
}
})
}
// TestDeviceStatus pins GET devices/status/{token} for every state: an
// unknown token, pending, a pending or confirmed session past expires_at
// (PHP's flat expired body; the row becomes expired), an expired row, and
// used and revoked sessions, which never become expired (Pitfall 13).
func TestDeviceStatus(t *testing.T) {
if testing.Short() {
t.Skip("Postgres")
}
env := newAdminEnv(t)
_, session := envSession(t, env, "dst-owner")
past := time.Now().Add(-time.Minute)
t.Run("unknown", func(t *testing.T) {
wantBody(t, envPoll(t, env, strings.Repeat("z", 64)), http.StatusNotFound, deviceNotFoundBody)
wantBody(t, envPoll(t, env, "short"), http.StatusNotFound, deviceNotFoundBody)
dev := envNewDevice(t, env)
// The stored hash is not a token: polling with it finds nothing.
wantBody(t, envPoll(t, env, classes.HashAPIToken(dev.Token)), http.StatusNotFound, deviceNotFoundBody)
})
t.Run("pending", func(t *testing.T) {
dev := envNewDevice(t, env)
wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceStatusBody("pending"))
wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceStatusBody("pending"))
})
t.Run("expired_pending", func(t *testing.T) {
dev := envNewDevice(t, env)
row := envDeviceRow(t, env, dev.Token)
envSetDevice(t, env, row.ID, map[string]any{"expires_at": past})
wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceExpiredBody)
if got := envDeviceRow(t, env, dev.Token).Status; got != models.DeviceStatusExpired {
t.Fatalf("row status = %s", got)
}
wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceExpiredBody)
})
t.Run("expired_confirmed_hands_nothing_over", func(t *testing.T) {
dev := envNewDevice(t, env)
wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody)
row := envDeviceRow(t, env, dev.Token)
envSetDevice(t, env, row.ID, map[string]any{"expires_at": past})
wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceExpiredBody)
row = envDeviceRow(t, env, dev.Token)
if row.Status != models.DeviceStatusExpired || row.UsedAt != nil || row.JWTJTI != nil {
t.Fatalf("row = %+v", row)
}
})
t.Run("expired_row_without_expires_at_passing", func(t *testing.T) {
dev := envNewDevice(t, env)
row := envDeviceRow(t, env, dev.Token)
envSetDevice(t, env, row.ID, map[string]any{"status": models.DeviceStatusExpired})
wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceExpiredBody)
})
t.Run("used_and_revoked_never_expire", func(t *testing.T) {
for _, status := range []string{models.DeviceStatusUsed, models.DeviceStatusRevoked} {
dev := envNewDevice(t, env)
row := envDeviceRow(t, env, dev.Token)
envSetDevice(t, env, row.ID, map[string]any{"status": status, "expires_at": time.Now().Add(-24 * time.Hour)})
wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceStatusBody(status))
if got := envDeviceRow(t, env, dev.Token).Status; got != status {
t.Fatalf("%s row became %s", status, got)
}
}
})
}
// TestDeviceAuthorize pins POST devices/authorize: the short-code forms
// and refusals with PHP's 422 and 400 bodies, the token path, sessions
// bound to another user, a repeated authorize by the same user, the body-
// only fields, expired, used and revoked sessions, concurrent authorizes by
// two users binding exactly one, and PHP's 401 for a missing, invalid,
// foreign-signed or ended session token.
func TestDeviceAuthorize(t *testing.T) {
if testing.Short() {
t.Skip("Postgres")
}
env := newAdminEnv(t)
owner, session := envSession(t, env, "dau-owner")
other, otherSession := envSession(t, env, "dau-other")
t.Run("short_code_forms", func(t *testing.T) {
for _, form := range []func(string) string{
func(c string) string { return c },
strings.ToLower,
func(c string) string { return strings.ReplaceAll(c, "-", "") },
func(c string) string { return strings.ToLower(strings.ReplaceAll(c, "-", "")) },
func(c string) string { return c[:2] + "-" + c[2:4] + c[5:] },
func(c string) string { return strings.ToLower(c[:4]) + c[4:] },
// Surrounding whitespace is trimmed from the field.
func(c string) string { return " " + c + " " },
} {
dev := envNewDevice(t, env)
code := form(dev.ShortCode)
wantBody(t, envAuthorize(t, env, session, map[string]string{"short_code": code}), http.StatusOK, deviceAuthorizedBody)
row := envDeviceRow(t, env, dev.Token)
if row.Status != models.DeviceStatusConfirmed || row.UserID == nil || *row.UserID != owner.ID ||
row.ConfirmedAt == nil || row.AuthIP == nil || !strings.HasPrefix(*row.AuthIP, "198.51.100.") {
t.Fatalf("short code %q: row = %+v", code, row)
}
}
})
t.Run("short_code_refusals", func(t *testing.T) {
dev := envNewDevice(t, env)
for _, c := range []struct {
code string
status int
body string
}{
{"", http.StatusUnprocessableEntity, deviceCodeLengthBody},
{"ABCDEFG", http.StatusUnprocessableEntity, deviceCodeLengthBody},
{"ABCD-EFG", http.StatusUnprocessableEntity, deviceCodeLengthBody},
{"ABCDEFGHJ", http.StatusUnprocessableEntity, deviceCodeLengthBody},
{"ABCD-EFGH-J", http.StatusUnprocessableEntity, deviceCodeLengthBody},
// Inner spaces count toward the length; only hyphens are
// removed before the length check, as in PHP.
{strings.ReplaceAll(dev.ShortCode, "-", " "), http.StatusUnprocessableEntity, deviceCodeLengthBody},
{"ABCD-EFGO", http.StatusBadRequest, deviceInvalidBody},
{"ABCD-EFG0", http.StatusBadRequest, deviceInvalidBody},
{"ABCD-EFG1", http.StatusBadRequest, deviceInvalidBody},
{"ABCD-EFGI", http.StatusBadRequest, deviceInvalidBody},
{"ABCD-EFGL", http.StatusBadRequest, deviceInvalidBody},
{"ABC EFGH", http.StatusBadRequest, deviceInvalidBody},
} {
wantBody(t, envAuthorize(t, env, session, map[string]string{"short_code": c.code}), c.status, c.body)
}
// A well-formed code nobody holds.
unknown := "2222-2222"
if dev.ShortCode == unknown {
unknown = "3333-3333"
}
wantBody(t, envAuthorize(t, env, session, map[string]string{"short_code": unknown}), http.StatusBadRequest, deviceInvalidBody)
if row := envDeviceRow(t, env, dev.Token); row.Status != models.DeviceStatusPending || row.UserID != nil {
t.Fatalf("refusals touched the session: %+v", row)
}
})
t.Run("token_path_and_missing_fields", func(t *testing.T) {
dev := envNewDevice(t, env)
wantBody(t, envAuthorize(t, env, session, map[string]string{"token": strings.Repeat("y", 64)}), http.StatusBadRequest, deviceInvalidBody)
wantBody(t, envAuthorize(t, env, session, map[string]string{"token": classes.HashAPIToken(dev.Token)}), http.StatusBadRequest, deviceInvalidBody)
wantBody(t, envAuthorize(t, env, session, map[string]string{}), http.StatusBadRequest, deviceInvalidBody)
// The fields come from the body only, never the query string.
wantBody(t, env.call(t, http.MethodPost, "/_user/api/v1/devices/authorize?token="+dev.Token, session, map[string]string{}),
http.StatusBadRequest, deviceInvalidBody)
wantBody(t, env.call(t, http.MethodPost, "/_user/api/v1/devices/authorize?short_code="+dev.ShortCode, session, map[string]string{}),
http.StatusBadRequest, deviceInvalidBody)
if row := envDeviceRow(t, env, dev.Token); row.Status != models.DeviceStatusPending {
t.Fatalf("status = %s", row.Status)
}
wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody)
// short_code wins when both are sent, as in PHP.
dev2 := envNewDevice(t, env)
wantBody(t, envAuthorize(t, env, session, map[string]string{"short_code": "ABC", "token": dev2.Token}), http.StatusUnprocessableEntity, deviceCodeLengthBody)
})
t.Run("bound_to_another_user", func(t *testing.T) {
dev := envNewDevice(t, env)
wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody)
wantBody(t, envAuthorize(t, env, otherSession, map[string]string{"token": dev.Token}), http.StatusBadRequest, deviceInvalidBody)
wantBody(t, envAuthorize(t, env, otherSession, map[string]string{"short_code": dev.ShortCode}), http.StatusBadRequest, deviceInvalidBody)
if row := envDeviceRow(t, env, dev.Token); row.UserID == nil || *row.UserID != owner.ID {
t.Fatalf("the session moved to %v", row.UserID)
}
// A pending session already carrying a user_id binds only to it.
pre := envNewDevice(t, env)
row := envDeviceRow(t, env, pre.Token)
envSetDevice(t, env, row.ID, map[string]any{"user_id": owner.ID})
wantBody(t, envAuthorize(t, env, otherSession, map[string]string{"token": pre.Token}), http.StatusBadRequest, deviceInvalidBody)
wantBody(t, envAuthorize(t, env, session, map[string]string{"token": pre.Token}), http.StatusOK, deviceAuthorizedBody)
})
t.Run("same_user_again", func(t *testing.T) {
dev := envNewDevice(t, env)
wantBody(t, envAuthorize(t, env, session, map[string]string{"short_code": dev.ShortCode}), http.StatusOK, deviceAuthorizedBody)
first := envDeviceRow(t, env, dev.Token)
time.Sleep(10 * time.Millisecond)
wantBody(t, envAuthorize(t, env, session, map[string]string{"short_code": dev.ShortCode}), http.StatusOK, deviceAuthorizedBody)
wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody)
again := envDeviceRow(t, env, dev.Token)
if !again.ConfirmedAt.Equal(*first.ConfirmedAt) || *again.AuthIP != *first.AuthIP {
t.Fatal("repeating the authorize rewrote the confirmation")
}
})
t.Run("expired_used_revoked", func(t *testing.T) {
expired := envNewDevice(t, env)
row := envDeviceRow(t, env, expired.Token)
envSetDevice(t, env, row.ID, map[string]any{"expires_at": time.Now().Add(-time.Second)})
wantBody(t, envAuthorize(t, env, session, map[string]string{"short_code": expired.ShortCode}), http.StatusBadRequest, deviceInvalidBody)
if got := envDeviceRow(t, env, expired.Token).Status; got != models.DeviceStatusExpired {
t.Fatalf("expired session status = %s", got)
}
// A confirmed session past expires_at cannot be authorized again
// either, not even by its own user.
late := envNewDevice(t, env)
wantBody(t, envAuthorize(t, env, session, map[string]string{"token": late.Token}), http.StatusOK, deviceAuthorizedBody)
row = envDeviceRow(t, env, late.Token)
envSetDevice(t, env, row.ID, map[string]any{"expires_at": time.Now().Add(-time.Second)})
wantBody(t, envAuthorize(t, env, session, map[string]string{"token": late.Token}), http.StatusBadRequest, deviceInvalidBody)
for _, status := range []string{models.DeviceStatusUsed, models.DeviceStatusRevoked} {
dev := envNewDevice(t, env)
row := envDeviceRow(t, env, dev.Token)
envSetDevice(t, env, row.ID, map[string]any{"status": status, "user_id": owner.ID})
wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusBadRequest, deviceInvalidBody)
if got := envDeviceRow(t, env, dev.Token).Status; got != status {
t.Fatalf("%s session became %s", status, got)
}
}
})
t.Run("concurrent_two_users_bind_one", func(t *testing.T) {
for round := range 3 {
dev := envNewDevice(t, env)
var (
wg sync.WaitGroup
mu sync.Mutex
ok = map[uint]int{}
no = map[uint]int{}
)
for i := range 10 {
who, tok := owner.ID, session
if i%2 == 1 {
who, tok = other.ID, otherSession
}
wg.Add(1)
go func() {
defer wg.Done()
rec := envAuthorize(t, env, tok, map[string]string{"short_code": dev.ShortCode})
mu.Lock()
defer mu.Unlock()
switch {
case rec.Code == http.StatusOK && rec.Body.String() == deviceAuthorizedBody:
ok[who]++
case rec.Code == http.StatusBadRequest && rec.Body.String() == deviceInvalidBody:
no[who]++
default:
t.Errorf("round %d: unexpected answer %d %s", round, rec.Code, rec.Body.String())
}
}()
}
wg.Wait()
row := envDeviceRow(t, env, dev.Token)
if row.UserID == nil {
t.Fatalf("round %d: no user bound", round)
}
winner := *row.UserID
loser := owner.ID
if winner == owner.ID {
loser = other.ID
}
if ok[winner] != 5 || no[winner] != 0 || ok[loser] != 0 || no[loser] != 5 {
t.Fatalf("round %d: winner %d ok=%d no=%d, loser ok=%d no=%d", round, winner, ok[winner], no[winner], ok[loser], no[loser])
}
}
})
t.Run("unauthorized", func(t *testing.T) {
dev := envNewDevice(t, env)
body := map[string]string{"short_code": dev.ShortCode}
wantBody(t, envAuthorize(t, env, "", body), http.StatusUnauthorized, deviceUnauthorized)
wantBody(t, envAuthorize(t, env, "not-a-jwt", body), http.StatusUnauthorized, deviceUnauthorized)
foreign := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": strconv.FormatUint(uint64(owner.ID), 10), "jti": "dau-foreign", "prv": prvHash,
"iat": time.Now().Unix(), "nbf": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(),
})
signed, err := foreign.SignedString([]byte("another-secret-entirely-another-secret"))
if err != nil {
t.Fatal(err)
}
wantBody(t, envAuthorize(t, env, signed, body), http.StatusUnauthorized, deviceUnauthorized)
_, ended := envSession(t, env, "dau-ended")
env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/logout", ended, nil)
wantBody(t, envAuthorize(t, env, ended, body), http.StatusUnauthorized, deviceUnauthorized)
if row := envDeviceRow(t, env, dev.Token); row.Status != models.DeviceStatusPending || row.UserID != nil {
t.Fatalf("an unauthorized call touched the session: %+v", row)
}
})
}
// TestDeviceHandOverConcurrent pins the one-time hand-over (D-12a): of 50
// concurrent polls of a confirmed session exactly one receives a token, the
// others answer used, and one login event with source device fires; a
// session whose user was deactivated or banned after the authorize hands
// nothing over and becomes revoked.
func TestDeviceHandOverConcurrent(t *testing.T) {
if testing.Short() {
t.Skip("Postgres")
}
env := newAdminEnv(t)
events := listenLoginEvents(env.app, "test.device-hand-over")
owner, session := envSession(t, env, "dho-owner")
t.Run("fifty_polls_one_token", func(t *testing.T) {
dev := envNewDevice(t, env)
wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody)
before := events.count()
var (
wg sync.WaitGroup
mu sync.Mutex
tokens []string
used int
)
for range 50 {
wg.Add(1)
go func() {
defer wg.Done()
rec := envPoll(t, env, dev.Token)
var body struct {
Success bool `json:"success"`
Data map[string]any `json:"data"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &body)
mu.Lock()
defer mu.Unlock()
switch {
case rec.Code == http.StatusOK && body.Data["token"] != nil:
tokens = append(tokens, body.Data["token"].(string))
case rec.Code == http.StatusOK && rec.Body.String() == deviceStatusBody("used"):
used++
default:
t.Errorf("unexpected poll answer %d %s", rec.Code, rec.Body.String())
}
}()
}
wg.Wait()
if len(tokens) != 1 || used != 49 {
t.Fatalf("50 polls: %d tokens, %d used", len(tokens), used)
}
sub, _, _, jti, err := bouncer.VerifyClaims(tokens[0], sessionSecret)
if err != nil || sub != strconv.FormatUint(uint64(owner.ID), 10) {
t.Fatalf("device token sub %q: %v", sub, err)
}
row := envDeviceRow(t, env, dev.Token)
if row.Status != models.DeviceStatusUsed || row.JWTJTI == nil || *row.JWTJTI != jti || row.UsedAt == nil || row.LastActivityAt == nil {
t.Fatalf("row after the hand-over = %+v", row)
}
env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", tokens[0], nil)
events.mu.Lock()
fired := events.got[before:]
events.mu.Unlock()
if len(fired) != 1 || fired[0].Source != classes.LoginSourceDevice || fired[0].User.ID != owner.ID {
t.Fatalf("login events = %+v", fired)
}
})
for _, c := range []struct {
name string
after func(*testing.T, *models.User)
}{
{"deactivated_user", func(t *testing.T, u *models.User) {
if err := env.gdb.Model(u).Update("deleted_at", time.Now()).Error; err != nil {
t.Fatal(err)
}
}},
{"banned_user", func(t *testing.T, u *models.User) { env.seedThrottle(t, u, "", true, nil) }},
} {
t.Run(c.name+"_hands_nothing_over", func(t *testing.T) {
user, userSession := envSession(t, env, "dho-"+c.name)
dev := envNewDevice(t, env)
wantBody(t, envAuthorize(t, env, userSession, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody)
c.after(t, user)
before := events.count()
wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceStatusBody("revoked"))
wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceStatusBody("revoked"))
row := envDeviceRow(t, env, dev.Token)
if row.Status != models.DeviceStatusRevoked || row.JWTJTI != nil || row.UsedAt != nil {
t.Fatalf("row = %+v", row)
}
if events.count() != before {
t.Fatal("a refused hand-over fired a login event")
}
})
}
t.Run("deleted_user_cascades", func(t *testing.T) {
user, userSession := envSession(t, env, "dho-gone")
dev := envNewDevice(t, env)
wantBody(t, envAuthorize(t, env, userSession, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody)
if err := env.gdb.Exec(`DELETE FROM user_throttle WHERE user_id = ?`, user.ID).Error; err != nil {
t.Fatal(err)
}
if err := env.gdb.Unscoped().Delete(&models.User{}, user.ID).Error; err != nil {
t.Fatal(err)
}
wantBody(t, envPoll(t, env, dev.Token), http.StatusNotFound, deviceNotFoundBody)
})
}
// TestDeviceListAndRevoke pins GET devices and DELETE devices/{id}: only
// the caller's confirmed and used sessions, most recent activity first
// (nulls last, then newest id), is_current for the requesting token, the
// display name, PHP's item keys and an empty list as []; a revoke of a
// foreign or unknown id is 404, a non-numeric id never reaches the
// handler, a repeated revoke answers success, and a revoked device is
// signed out and no longer listed.
func TestDeviceListAndRevoke(t *testing.T) {
if testing.Short() {
t.Skip("Postgres")
}
env := newAdminEnv(t)
owner, session := envSession(t, env, "dlr-owner")
_, otherSession := envSession(t, env, "dlr-other")
t.Run("empty", func(t *testing.T) {
_, fresh := envSession(t, env, "dlr-empty")
wantBody(t, env.call(t, http.MethodGet, "/_user/api/v1/devices", fresh, nil), http.StatusOK, `{"devices":[],"success":true}`)
wantBody(t, env.call(t, http.MethodGet, "/_user/api/v1/devices", "", nil), http.StatusUnauthorized, deviceUnauthorized)
})
tokA, _, idA := envDevice(t, env, session, "A")
_, _, idB := envDevice(t, env, session, "B")
_, _, idC := envDevice(t, env, session, "")
_, _, idD := envDevice(t, env, session, "D")
_, _, foreign := envDevice(t, env, otherSession, "Foreign")
hour := time.Now().Add(-time.Hour).Truncate(time.Second)
envSetDevice(t, env, idA, map[string]any{"last_activity_at": time.Now().Add(-3 * time.Hour)})
envSetDevice(t, env, idB, map[string]any{"last_activity_at": hour})
envSetDevice(t, env, idC, map[string]any{"last_activity_at": nil, "device_user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)"})
envSetDevice(t, env, idD, map[string]any{"last_activity_at": hour})
// Sessions of the owner that are not listed.
for _, status := range []string{models.DeviceStatusPending, models.DeviceStatusExpired, models.DeviceStatusRevoked} {
row := models.DeviceAuthSession{TokenHash: "dlr-" + status + "-" + adminStamp(), UserID: &owner.ID, Status: status}
if err := env.gdb.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
t.Run("order_names_and_current", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/devices", tokA, nil)
var raw struct {
Success bool `json:"success"`
Devices []map[string]any `json:"devices"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil || !raw.Success {
t.Fatalf("list %s: %v", rec.Body.String(), err)
}
var ids []uint
for _, d := range raw.Devices {
keys := make([]string, 0, len(d))
for k := range d {
keys = append(keys, k)
}
slices.Sort(keys)
if strings.Join(keys, ",") != "authorized_at,device_ip,device_name,id,is_current,last_activity" {
t.Fatalf("item keys = %v", keys)
}
ids = append(ids, uint(d["id"].(float64)))
}
want := []uint{idD, idB, idA, idC}
if !slices.Equal(ids, want) {
t.Fatalf("order = %v, want %v (D and B tie on activity, newest id first; C has none)", ids, want)
}
names := map[uint]string{}
current := map[uint]bool{}
for _, d := range raw.Devices {
names[uint(d["id"].(float64))] = d["device_name"].(string)
current[uint(d["id"].(float64))] = d["is_current"].(bool)
if at, _ := d["authorized_at"].(string); !strings.HasSuffix(at, "+00:00") {
t.Fatalf("authorized_at = %v", d["authorized_at"])
}
}
if names[idA] != "A" || names[idC] != "iPhone" || names[idD] != "D" {
t.Fatalf("names = %v", names)
}
if !current[idA] || current[idB] || current[idC] || current[idD] {
t.Fatalf("is_current = %v, want only A", current)
}
if last := raw.Devices[3]["last_activity"]; last != nil {
t.Fatalf("last_activity of C = %v, want null", last)
}
for _, d := range envDevices(t, env, session).Devices {
if d.IsCurrent {
t.Fatal("the owner's own login session is not a device")
}
}
})
t.Run("revoke", func(t *testing.T) {
path := func(id uint) string { return "/_user/api/v1/devices/" + strconv.FormatUint(uint64(id), 10) }
wantBody(t, env.call(t, http.MethodDelete, path(idB), "", nil), http.StatusUnauthorized, deviceUnauthorized)
wantBody(t, env.call(t, http.MethodDelete, path(foreign), session, nil), http.StatusNotFound, deviceNotFoundRevoke)
wantBody(t, env.call(t, http.MethodDelete, path(999999999), session, nil), http.StatusNotFound, deviceNotFoundRevoke)
wantBody(t, env.call(t, http.MethodDelete, "/_user/api/v1/devices/0", session, nil), http.StatusNotFound, deviceNotFoundRevoke)
for _, bad := range []string{"abc", "1a", "-1"} {
// The digit constraint keeps these from the handler, which
// would answer 401 without a session.
rec := env.call(t, http.MethodDelete, "/_user/api/v1/devices/"+bad, "", nil)
if rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("DELETE /devices/%s = %d %s", bad, rec.Code, rec.Body.String())
}
}
env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", tokA, nil)
wantBody(t, env.call(t, http.MethodDelete, path(idA), session, nil), http.StatusOK, deviceRevokedOKBody)
wantBody(t, env.call(t, http.MethodDelete, path(idA), session, nil), http.StatusOK, deviceRevokedOKBody)
env.expect(t, http.StatusUnauthorized, http.MethodGet, "/_user/api/v1/fetch", tokA, nil)
// A device may revoke itself; a revoked device cannot list.
wantBody(t, env.call(t, http.MethodGet, "/_user/api/v1/devices", tokA, nil), http.StatusUnauthorized, deviceUnauthorized)
var foreignRow models.DeviceAuthSession
if err := env.gdb.Where("id = ?", foreign).Take(&foreignRow).Error; err != nil || foreignRow.Status != models.DeviceStatusUsed {
t.Fatalf("the foreign device = %+v %v", foreignRow, err)
}
// A used row without a jti (a Winter import) is revoked too.
envSetDevice(t, env, idC, map[string]any{"jwt_jti": nil})
wantBody(t, env.call(t, http.MethodDelete, path(idC), session, nil), http.StatusOK, deviceRevokedOKBody)
var left []uint
for _, d := range envDevices(t, env, session).Devices {
left = append(left, d.ID)
}
if !slices.Equal(left, []uint{idD, idB}) {
t.Fatalf("listed after revokes = %v", left)
}
env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", session, nil)
})
}
// TestDeviceRevokeAfterRefresh pins D-12b through refresh: after one and
// after two refreshes the row tracks the newest jti, a revoke signs out
// every token of the lineage and refuses the next refresh, and the
// blacklist entry lasts the whole refresh window. A refresh that fails
// leaves the row as it was.
func TestDeviceRevokeAfterRefresh(t *testing.T) {
if testing.Short() {
t.Skip("Postgres")
}
env := newAdminEnv(t)
owner, session := envSession(t, env, "drr-owner")
refresh := func(t *testing.T, token string) string {
t.Helper()
rec := env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/refresh", token, nil)
var body struct {
Token string `json:"token"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Token == "" {
t.Fatalf("refresh %s: %v", rec.Body.String(), err)
}
return body.Token
}
jtiOf := func(t *testing.T, token string) string {
t.Helper()
_, _, _, jti, err := bouncer.VerifyClaims(token, sessionSecret)
if err != nil {
t.Fatal(err)
}
return jti
}
for _, n := range []int{1, 2} {
t.Run(strconv.Itoa(n)+"_refreshes", func(t *testing.T) {
first, _, id := envDevice(t, env, session, "Desk "+strconv.Itoa(n))
chain := []string{first}
for range n {
chain = append(chain, refresh(t, chain[len(chain)-1]))
var row models.DeviceAuthSession
if err := env.gdb.Where("id = ?", id).Take(&row).Error; err != nil {
t.Fatal(err)
}
if row.JWTJTI == nil || *row.JWTJTI != jtiOf(t, chain[len(chain)-1]) || row.Status != models.DeviceStatusUsed {
t.Fatalf("row after refresh %d = %+v", len(chain)-1, row)
}
}
latest := chain[len(chain)-1]
env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", latest, nil)
start := time.Now()
wantBody(t, env.call(t, http.MethodDelete, "/_user/api/v1/devices/"+strconv.FormatUint(uint64(id), 10), session, nil), http.StatusOK, deviceRevokedOKBody)
for i, tok := range chain {
if rec := env.call(t, http.MethodGet, "/_user/api/v1/fetch", tok, nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("token %d of the lineage still works after the revoke: %d", i, rec.Code)
}
}
env.expect(t, http.StatusUnauthorized, http.MethodPost, "/_user/api/v1/refresh", latest, nil)
var expires time.Time
if err := env.gdb.Raw(`SELECT expires_at FROM jwt_blacklist WHERE jti = ?`, jtiOf(t, latest)).Scan(&expires).Error; err != nil {
t.Fatal(err)
}
if window := start.Add(controllers.RefreshTTL(env.app)); expires.Before(window) {
t.Fatalf("blacklist entry ends %s, before the refresh window %s", expires, window)
}
env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", session, nil)
})
}
t.Run("failed_refresh_leaves_the_row", func(t *testing.T) {
token, jti, id := envDevice(t, env, session, "Desk fail")
var before models.DeviceAuthSession
if err := env.gdb.Where("id = ?", id).Take(&before).Error; err != nil {
t.Fatal(err)
}
// The device's jti in a token signed with another key.
forged := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": strconv.FormatUint(uint64(owner.ID), 10), "jti": jti, "prv": prvHash,
"iat": time.Now().Unix(), "nbf": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(),
})
signed, err := forged.SignedString([]byte("another-secret-entirely-another-secret"))
if err != nil {
t.Fatal(err)
}
env.expect(t, http.StatusUnauthorized, http.MethodPost, "/_user/api/v1/refresh", signed, nil)
// The device token itself, refused by the subject check once the
// user is banned.
banned, bannedSession := envSession(t, env, "drr-banned")
bannedToken, bannedJTI, bannedID := envDevice(t, env, bannedSession, "Banned desk")
env.seedThrottle(t, banned, "", true, nil)
env.expect(t, http.StatusUnauthorized, http.MethodPost, "/_user/api/v1/refresh", bannedToken, nil)
for _, c := range []struct {
id uint
jti string
}{{id, jti}, {bannedID, bannedJTI}} {
var row models.DeviceAuthSession
if err := env.gdb.Where("id = ?", c.id).Take(&row).Error; err != nil {
t.Fatal(err)
}
if row.JWTJTI == nil || *row.JWTJTI != c.jti || row.Status != models.DeviceStatusUsed {
t.Fatalf("a failed refresh moved the row: %+v", row)
}
}
if after := refresh(t, token); jtiOf(t, after) == jti {
t.Fatal("the device token did not rotate")
}
})
}
// TestDeviceRevokeRacesRefresh pins that a revoke and a refresh of the same
// device cannot leave the device signed in, whichever commits first. A
// refresh whose device session was revoked after the token passed the
// blacklist check (the revoke committed between bouncer.Refresh and the
// hand-over) is refused with the blacklisted-token answer and hands out no
// token; a revoke reads the jti under the row lock the hand-over takes, so
// a device that refreshes in a tight loop while it is revoked always ends
// with its last token refused.
func TestDeviceRevokeRacesRefresh(t *testing.T) {
if testing.Short() {
t.Skip("Postgres")
}
env := newAdminEnv(t)
const blacklistedBody = `{"error":"Could not refresh token","msg":"The token has been blacklisted"}`
t.Run("revoked_after_the_blacklist_check", func(t *testing.T) {
_, session := envSession(t, env, "drace-check")
token, jti, id := envDevice(t, env, session, "Race desk")
// The state a revoke leaves when it commits after bouncer.Refresh
// checked the blacklist: the row is revoked and the token the
// refresh presented is not blacklisted (yet).
envSetDevice(t, env, id, map[string]any{"status": models.DeviceStatusRevoked})
wantBody(t, env.call(t, http.MethodPost, "/_user/api/v1/refresh", token, nil), http.StatusUnauthorized, blacklistedBody)
var row models.DeviceAuthSession
if err := env.gdb.Where("id = ?", id).Take(&row).Error; err != nil {
t.Fatal(err)
}
if row.JWTJTI == nil || *row.JWTJTI != jti || row.Status != models.DeviceStatusRevoked {
t.Fatalf("a refused refresh moved the revoked row: %+v", row)
}
if rec := env.call(t, http.MethodGet, "/_user/api/v1/fetch", token, nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("the presented token still works: %d", rec.Code)
}
env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", session, nil)
})
t.Run("refresh_loop_and_revoke", func(t *testing.T) {
for round := range 8 {
// A user per round keeps the refreshes inside the per-user
// user-api bucket.
_, session := envSession(t, env, "drace-loop-"+strconv.Itoa(round))
token, _, id := envDevice(t, env, session, "Loop desk")
var (
wg sync.WaitGroup
last = token
status int
)
wg.Add(1)
go func() {
defer wg.Done()
for range 100 {
rec := env.call(t, http.MethodPost, "/_user/api/v1/refresh", last, nil)
if status = rec.Code; status != http.StatusOK {
return
}
var body struct {
Token string `json:"token"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Token == "" {
t.Errorf("refresh %s: %v", rec.Body.String(), err)
return
}
last = body.Token
}
}()
time.Sleep(time.Duration(round) * 2 * time.Millisecond)
wantBody(t, env.call(t, http.MethodDelete, "/_user/api/v1/devices/"+strconv.FormatUint(uint64(id), 10), session, nil), http.StatusOK, deviceRevokedOKBody)
wg.Wait()
if status != http.StatusUnauthorized {
t.Fatalf("round %d: the refresh loop ended with %d, want the 401 of a revoked device", round, status)
}
if rec := env.call(t, http.MethodGet, "/_user/api/v1/fetch", last, nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("round %d: the device's last token still works after the revoke: %d", round, rec.Code)
}
if rec := env.call(t, http.MethodPost, "/_user/api/v1/refresh", last, nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("round %d: the device's last token still refreshes after the revoke: %d", round, rec.Code)
}
env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", session, nil)
}
})
}
// TestDeviceTokensAtRest pins D-06 for device sessions: the token column is
// the SHA-256 hex of the token, never the token; no column holds the token
// or the session JWT the device received; the model hides token,
// short_code and jwt_jti from JSON.
func TestDeviceTokensAtRest(t *testing.T) {
if testing.Short() {
t.Skip("Postgres")
}
env := newAdminEnv(t)
_, session := envSession(t, env, "dtr-owner")
dev := envNewDevice(t, env)
sum := sha256.Sum256([]byte(dev.Token))
row := envDeviceRow(t, env, dev.Token)
if row.TokenHash != hex.EncodeToString(sum[:]) || row.TokenHash == dev.Token || len(row.TokenHash) != 64 {
t.Fatalf("token column = %q", row.TokenHash)
}
rowJSON := func() string {
t.Helper()
var out string
if err := env.gdb.Raw(`SELECT row_to_json(s)::text FROM golem15_user_device_auth_sessions s WHERE id = ?`, row.ID).Scan(&out).Error; err != nil {
t.Fatal(err)
}
return out
}
if strings.Contains(rowJSON(), dev.Token) {
t.Fatal("a column holds the device token")
}
wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody)
rec := envPoll(t, env, dev.Token)
var polled struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &polled); err != nil || polled.Data.Token == "" {
t.Fatalf("hand-over %s", rec.Body.String())
}
stored := rowJSON()
if strings.Contains(stored, dev.Token) || strings.Contains(stored, polled.Data.Token) {
t.Fatal("a column holds the device token or its session JWT")
}
raw, err := json.Marshal(envDeviceRow(t, env, dev.Token))
if err != nil {
t.Fatal(err)
}
for _, secret := range []string{row.TokenHash, dev.ShortCode, `"JWTJTI"`, `"TokenHash"`, `"ShortCode"`} {
if strings.Contains(string(raw), secret) {
t.Fatalf("the model's JSON carries %s: %s", secret, raw)
}
}
}