feat(auth): add device session tracking with configurable limits
Track authenticated sessions per device (parsing user-agent for desktop vs mobile via mileusna/useragent), enforce configurable concurrent device limits (Auth.DeviceLimits), and surface device status and "remove other devices" management in the account dialog. Adds device/session context to the auth module stores and exposes limits through the API. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -95,6 +96,127 @@ func TestGlobalEmailCodeLogin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceLimitsUseConfiguredValuesAndSafeDefaults(t *testing.T) {
|
||||
configured := NewService(NewMemoryStore(), Config{
|
||||
Region: RegionGlobal,
|
||||
TokenSecret: "test-secret",
|
||||
DeviceLimits: DeviceLimits{Desktop: 5, Mobile: 3},
|
||||
})
|
||||
if limits := configured.DeviceLimits(); limits.Desktop != 5 || limits.Mobile != 3 {
|
||||
t.Fatalf("unexpected configured limits %#v", limits)
|
||||
}
|
||||
|
||||
defaults := NewService(NewMemoryStore(), Config{Region: RegionGlobal, TokenSecret: "test-secret"})
|
||||
if limits := defaults.DeviceLimits(); limits.Desktop != 2 || limits.Mobile != 1 {
|
||||
t.Fatalf("unexpected default limits %#v", limits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopDeviceLimitRevokesOldestSession(t *testing.T) {
|
||||
service := NewService(NewMemoryStore(), Config{
|
||||
Region: RegionGlobal,
|
||||
TokenSecret: "test-secret",
|
||||
TokenTTL: time.Hour,
|
||||
CodeTTL: time.Minute,
|
||||
DevReturnCode: true,
|
||||
DeviceLimits: DeviceLimits{Desktop: 1, Mobile: 1},
|
||||
})
|
||||
now := time.Date(2026, 7, 11, 11, 0, 0, 0, time.UTC)
|
||||
service.now = func() time.Time { return now }
|
||||
desktopCtx := ContextWithRequestMetadata(context.Background(), RequestMetadata{
|
||||
UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/149.0.0.0 Safari/537.36",
|
||||
Platform: `"macOS"`,
|
||||
Mobile: "?0",
|
||||
})
|
||||
|
||||
login := func() Session {
|
||||
t.Helper()
|
||||
code, err := service.RequestGlobalEmailCode(desktopCtx, "limit@example.com", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := service.LoginGlobalEmailCode(desktopCtx, "limit@example.com", code.DevCode)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
first := login()
|
||||
firstIdentity, ok := service.IdentityFromBearer(desktopCtx, "Bearer "+first.AccessToken)
|
||||
if !ok {
|
||||
t.Fatal("expected first desktop session to authenticate")
|
||||
}
|
||||
now = now.Add(time.Second)
|
||||
second := login()
|
||||
secondIdentity, ok := service.IdentityFromBearer(desktopCtx, "Bearer "+second.AccessToken)
|
||||
if !ok {
|
||||
t.Fatal("expected newest desktop session to authenticate")
|
||||
}
|
||||
|
||||
if _, ok := service.IdentityFromBearer(desktopCtx, "Bearer "+first.AccessToken); ok {
|
||||
t.Fatal("expected oldest desktop session to be revoked at the configured limit")
|
||||
}
|
||||
secondCtx := ContextWithSessionID(desktopCtx, secondIdentity.SessionID)
|
||||
devices, err := service.ListDevices(secondCtx, second.User.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(devices) != 1 || devices[0].ID != secondIdentity.SessionID || !devices[0].Current {
|
||||
t.Fatalf("expected only the newest desktop session, got %#v (first=%s)", devices, firstIdentity.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDevicesReconcilesSessionsCreatedBeforeLimitEnforcement(t *testing.T) {
|
||||
service := NewService(NewMemoryStore(), Config{
|
||||
Region: RegionGlobal,
|
||||
TokenSecret: "test-secret",
|
||||
TokenTTL: time.Hour,
|
||||
CodeTTL: time.Minute,
|
||||
DevReturnCode: true,
|
||||
DeviceLimits: DeviceLimits{Desktop: 2, Mobile: 1},
|
||||
})
|
||||
now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)
|
||||
service.now = func() time.Time { return now }
|
||||
desktopCtx := ContextWithRequestMetadata(context.Background(), RequestMetadata{
|
||||
UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/149.0.0.0 Safari/537.36",
|
||||
Platform: `"macOS"`,
|
||||
Mobile: "?0",
|
||||
})
|
||||
login := func() Session {
|
||||
t.Helper()
|
||||
code, err := service.RequestGlobalEmailCode(desktopCtx, "reconcile@example.com", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := service.LoginGlobalEmailCode(desktopCtx, "reconcile@example.com", code.DevCode)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
first := login()
|
||||
now = now.Add(time.Second)
|
||||
second := login()
|
||||
secondIdentity, ok := service.IdentityFromBearer(desktopCtx, "Bearer "+second.AccessToken)
|
||||
if !ok {
|
||||
t.Fatal("expected newest session to authenticate")
|
||||
}
|
||||
service.cfg.DeviceLimits.Desktop = 1
|
||||
secondCtx := ContextWithSessionID(desktopCtx, secondIdentity.SessionID)
|
||||
devices, err := service.ListDevices(secondCtx, second.User.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(devices) != 1 || devices[0].ID != secondIdentity.SessionID {
|
||||
t.Fatalf("expected list reconciliation to preserve only the current session, got %#v", devices)
|
||||
}
|
||||
if _, ok := service.IdentityFromBearer(desktopCtx, "Bearer "+first.AccessToken); ok {
|
||||
t.Fatal("expected pre-enforcement excess session to be revoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountProfileUpdatesPersistInStore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := NewService(NewMemoryStore(), Config{
|
||||
@@ -130,36 +252,146 @@ func TestAccountProfileUpdatesPersistInStore(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountDevicesExposeCurrentLocalDeviceOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := NewService(NewMemoryStore(), Config{
|
||||
func TestAccountDevicesTrackAndRevokeOtherSessions(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
service := NewService(store, Config{
|
||||
Region: RegionGlobal,
|
||||
TokenSecret: "test-secret",
|
||||
TokenTTL: time.Hour,
|
||||
CodeTTL: time.Minute,
|
||||
DevReturnCode: true,
|
||||
})
|
||||
now := time.Date(2026, 7, 11, 9, 0, 0, 0, time.UTC)
|
||||
service.now = func() time.Time { return now }
|
||||
|
||||
code, err := service.RequestGlobalEmailCode(ctx, "user@example.com", "")
|
||||
desktopMetadata := RequestMetadata{
|
||||
UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
||||
Platform: `"macOS"`,
|
||||
Mobile: "?0",
|
||||
}
|
||||
desktopLoginCtx := ContextWithRequestMetadata(context.Background(), desktopMetadata)
|
||||
code, err := service.RequestGlobalEmailCode(desktopLoginCtx, "user@example.com", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := service.LoginGlobalEmailCode(ctx, "user@example.com", code.DevCode)
|
||||
desktopSession, err := service.LoginGlobalEmailCode(desktopLoginCtx, "user@example.com", code.DevCode)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
devices, err := service.ListDevices(ctx, session.User.ID)
|
||||
desktopIdentity, ok := service.IdentityFromBearer(desktopLoginCtx, "Bearer "+desktopSession.AccessToken)
|
||||
if !ok {
|
||||
t.Fatal("expected desktop session to authenticate")
|
||||
}
|
||||
|
||||
now = now.Add(2 * time.Minute)
|
||||
mobileMetadata := RequestMetadata{
|
||||
UserAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1",
|
||||
Platform: `"iOS"`,
|
||||
Mobile: "?1",
|
||||
}
|
||||
mobileLoginCtx := ContextWithRequestMetadata(context.Background(), mobileMetadata)
|
||||
code, err = service.RequestGlobalEmailCode(mobileLoginCtx, "user@example.com", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mobileSession, err := service.LoginGlobalEmailCode(mobileLoginCtx, "user@example.com", code.DevCode)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mobileIdentity, ok := service.IdentityFromBearer(mobileLoginCtx, "Bearer "+mobileSession.AccessToken)
|
||||
if !ok {
|
||||
t.Fatal("expected mobile session to authenticate")
|
||||
}
|
||||
mobileCtx := ContextWithSessionID(mobileLoginCtx, mobileIdentity.SessionID)
|
||||
|
||||
devices, err := service.ListDevices(mobileCtx, mobileSession.User.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(devices) != 2 {
|
||||
t.Fatalf("expected two devices, got %#v", devices)
|
||||
}
|
||||
if !devices[0].Current || devices[0].ID != mobileIdentity.SessionID || devices[0].DeviceType != "mobile" || devices[0].System != "iOS" {
|
||||
t.Fatalf("expected current mobile device first, got %#v", devices[0])
|
||||
}
|
||||
if devices[1].Current || devices[1].ID != desktopIdentity.SessionID || devices[1].DeviceType != "desktop" || devices[1].System != "macOS" {
|
||||
t.Fatalf("expected secondary desktop device, got %#v", devices[1])
|
||||
}
|
||||
|
||||
removed, err := service.RemoveOtherDevices(mobileCtx, mobileSession.User.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removed != 1 {
|
||||
t.Fatalf("expected one removable device, got %d", removed)
|
||||
}
|
||||
if _, ok := service.IdentityFromBearer(desktopLoginCtx, "Bearer "+desktopSession.AccessToken); ok {
|
||||
t.Fatal("expected removed desktop session to be rejected")
|
||||
}
|
||||
if _, ok := service.IdentityFromBearer(mobileLoginCtx, "Bearer "+mobileSession.AccessToken); !ok {
|
||||
t.Fatal("expected current mobile session to remain valid")
|
||||
}
|
||||
|
||||
devices, err = service.ListDevices(mobileCtx, mobileSession.User.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(devices) != 1 || !devices[0].Current {
|
||||
t.Fatalf("expected one current device, got %#v", devices)
|
||||
t.Fatalf("expected only the current device after removal, got %#v", devices)
|
||||
}
|
||||
removed, err := service.RemoveOtherDevices(ctx, session.User.ID)
|
||||
removed, err = service.Logout(mobileCtx, mobileSession.User.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removed != 0 {
|
||||
t.Fatalf("expected no removable devices, got %d", removed)
|
||||
if removed != 1 {
|
||||
t.Fatalf("expected logout to revoke one current session, got %d", removed)
|
||||
}
|
||||
if _, ok := service.IdentityFromBearer(mobileLoginCtx, "Bearer "+mobileSession.AccessToken); ok {
|
||||
t.Fatal("expected logged-out session to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyBearerMigratesToRevocableDeviceSession(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
service := NewService(store, Config{Region: RegionGlobal, TokenSecret: "test-secret", TokenTTL: time.Hour})
|
||||
now := time.Date(2026, 7, 11, 10, 0, 0, 0, time.UTC)
|
||||
service.now = func() time.Time { return now }
|
||||
user, err := store.UpsertUser(context.Background(), User{ID: newID(), Region: RegionGlobal, Email: "legacy@example.com", Status: "active"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyToken, _, err := signToken(user.ID, "test-secret", time.Hour, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := ContextWithRequestMetadata(context.Background(), RequestMetadata{
|
||||
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0.0.0 Safari/537.36",
|
||||
})
|
||||
identity, ok := service.IdentityFromBearer(ctx, "Bearer "+legacyToken)
|
||||
if !ok || identity.UserID != user.ID || !strings.HasPrefix(identity.SessionID, "legacy:") {
|
||||
t.Fatalf("expected migrated legacy identity, got %#v / %v", identity, ok)
|
||||
}
|
||||
authenticatedCtx := ContextWithSessionID(ctx, identity.SessionID)
|
||||
devices, err := service.ListDevices(authenticatedCtx, user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(devices) != 1 || !devices[0].Current || devices[0].System != "Windows" {
|
||||
t.Fatalf("unexpected migrated device %#v", devices)
|
||||
}
|
||||
if _, err := service.Logout(authenticatedCtx, user.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := service.IdentityFromBearer(ctx, "Bearer "+legacyToken); ok {
|
||||
t.Fatal("expected revoked legacy token to stay revoked")
|
||||
}
|
||||
|
||||
nonUserToken, _, err := signToken(newID(), "test-secret", time.Hour, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := service.IdentityFromBearer(ctx, "Bearer "+nonUserToken); ok {
|
||||
t.Fatal("expected signed token for a missing user to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user