diff --git a/src/Briefcase.ApiService/Controllers/AuthController.cs b/src/Briefcase.ApiService/Controllers/AuthController.cs index f05e698..3a330f4 100644 --- a/src/Briefcase.ApiService/Controllers/AuthController.cs +++ b/src/Briefcase.ApiService/Controllers/AuthController.cs @@ -38,10 +38,10 @@ public async Task Register([FromBody] RegisterRequest request) db.Users.Add(user); await db.SaveChangesAsync(); - await UpsertDeviceAsync(user.Id, request.DeviceName, request.DevicePlatform); + var device = await UpsertDeviceAsync(user.Id, request.DeviceName, request.DevicePlatform, request.InstallationId); - var (accessToken, expiresAt) = tokenService.GenerateAccessToken(user.Id, user.Email); - var refreshToken = await CreateRefreshTokenAsync(user.Id); + var (accessToken, expiresAt) = tokenService.GenerateAccessToken(user.Id, user.Email, device?.Id); + var refreshToken = await CreateRefreshTokenAsync(user.Id, device?.Id); SetRefreshTokenCookie(refreshToken); return Ok(new AuthResponse(accessToken, refreshToken, expiresAt)); @@ -60,10 +60,10 @@ public async Task Login([FromBody] LoginRequest request) return Unauthorized(new ProblemDetails { Title = "Invalid email or password." }); } - await UpsertDeviceAsync(user.Id, request.DeviceName, request.DevicePlatform); + var device = await UpsertDeviceAsync(user.Id, request.DeviceName, request.DevicePlatform, request.InstallationId); - var (accessToken, expiresAt) = tokenService.GenerateAccessToken(user.Id, user.Email); - var refreshToken = await CreateRefreshTokenAsync(user.Id); + var (accessToken, expiresAt) = tokenService.GenerateAccessToken(user.Id, user.Email, device?.Id); + var refreshToken = await CreateRefreshTokenAsync(user.Id, device?.Id); SetRefreshTokenCookie(refreshToken); return Ok(new AuthResponse(accessToken, refreshToken, expiresAt)); @@ -82,6 +82,7 @@ public async Task Refresh([FromBody] RefreshRequest? request) var stored = await db.RefreshTokens .Include(r => r.User) + .Include(r => r.Device) .FirstOrDefaultAsync(r => r.Token == refreshTokenValue); if (stored is null || !stored.IsActive) @@ -90,8 +91,11 @@ public async Task Refresh([FromBody] RefreshRequest? request) // Revoke the used token (rotation) stored.RevokedAt = DateTime.UtcNow; - var (accessToken, expiresAt) = tokenService.GenerateAccessToken(stored.UserId, stored.User.Email); - var newRefreshToken = await CreateRefreshTokenAsync(stored.UserId); + if (stored.Device is not null) + stored.Device.LastSeenAt = DateTime.UtcNow; + + var (accessToken, expiresAt) = tokenService.GenerateAccessToken(stored.UserId, stored.User.Email, stored.DeviceId); + var newRefreshToken = await CreateRefreshTokenAsync(stored.UserId, stored.DeviceId); SetRefreshTokenCookie(newRefreshToken); return Ok(new AuthResponse(accessToken, newRefreshToken, expiresAt)); @@ -147,7 +151,8 @@ public IActionResult OAuthRedirect( [FromQuery] string redirect_uri, [FromQuery] string? client_redirect_uri, [FromQuery] string? device_name, - [FromQuery] string? device_platform) + [FromQuery] string? device_platform, + [FromQuery] string? installation_id) { if (!oAuthService.IsProviderSupported(provider)) return BadRequest(new ProblemDetails { Title = $"Unsupported OAuth provider: {provider}" }); @@ -163,7 +168,8 @@ public IActionResult OAuthRedirect( redirect_uri, client_redirect_uri, device_name, - device_platform); + device_platform, + installation_id); return Redirect(authorizationUrl); } @@ -255,10 +261,10 @@ public async Task OAuthCallback(string provider, [FromQuery] stri await db.SaveChangesAsync(); } - await UpsertDeviceAsync(user.Id, pendingState.DeviceName, pendingState.DevicePlatform); + var device = await UpsertDeviceAsync(user.Id, pendingState.DeviceName, pendingState.DevicePlatform, pendingState.InstallationId); - var (accessToken, expiresAt) = tokenService.GenerateAccessToken(user.Id, user.Email); - var refreshToken = await CreateRefreshTokenAsync(user.Id); + var (accessToken, expiresAt) = tokenService.GenerateAccessToken(user.Id, user.Email, device?.Id); + var refreshToken = await CreateRefreshTokenAsync(user.Id, device?.Id); SetRefreshTokenCookie(refreshToken); @@ -276,42 +282,58 @@ public async Task OAuthCallback(string provider, [FromQuery] stri return Ok(new AuthResponse(accessToken, refreshToken, expiresAt)); } - private async Task UpsertDeviceAsync(Guid userId, string? deviceName, string? devicePlatform) + private async Task UpsertDeviceAsync(Guid userId, string? deviceName, string? devicePlatform, string? installationId) { - if (string.IsNullOrWhiteSpace(deviceName)) - return; + if (string.IsNullOrWhiteSpace(deviceName) && string.IsNullOrWhiteSpace(installationId)) + return null; var platform = Enum.TryParse(devicePlatform, true, out var p) ? p : Platform.Web; + var name = string.IsNullOrWhiteSpace(deviceName) ? platform.ToString() : deviceName; - var device = await db.Devices - .FirstOrDefaultAsync(d => d.UserId == userId && d.Name == deviceName && d.Platform == platform); + Device? device = null; - if (device is not null) + if (!string.IsNullOrWhiteSpace(installationId)) { - device.LastSeenAt = DateTime.UtcNow; + device = await db.Devices + .FirstOrDefaultAsync(d => d.UserId == userId && d.InstallationId == installationId); } - else + + // Rows created before installation ids existed are adopted by name + platform. + device ??= await db.Devices + .FirstOrDefaultAsync(d => d.UserId == userId && d.InstallationId == null && d.Name == name && d.Platform == platform); + + if (device is null) { - db.Devices.Add(new Device + device = new Device { Id = Guid.NewGuid(), UserId = userId, - Name = deviceName, + InstallationId = installationId, + Name = name, Platform = platform, CreatedAt = DateTime.UtcNow, - LastSeenAt = DateTime.UtcNow, - }); + }; + db.Devices.Add(device); + } + else + { + device.InstallationId ??= installationId; + device.Name = name; + device.Platform = platform; } + device.LastSeenAt = DateTime.UtcNow; await db.SaveChangesAsync(); + return device; } - private async Task CreateRefreshTokenAsync(Guid userId) + private async Task CreateRefreshTokenAsync(Guid userId, Guid? deviceId) { var token = new RefreshToken { Id = Guid.NewGuid(), UserId = userId, + DeviceId = deviceId, Token = tokenService.GenerateRefreshToken(), CreatedAt = DateTime.UtcNow, ExpiresAt = DateTime.UtcNow.AddDays(tokenService.RefreshTokenDays), diff --git a/src/Briefcase.ApiService/Controllers/DevicesController.cs b/src/Briefcase.ApiService/Controllers/DevicesController.cs index f47aa3d..5e2626a 100644 --- a/src/Briefcase.ApiService/Controllers/DevicesController.cs +++ b/src/Briefcase.ApiService/Controllers/DevicesController.cs @@ -16,29 +16,37 @@ namespace Briefcase.ApiService.Controllers; [ApiController] [Authorize] [Route("api/devices")] -public class DevicesController(AppDbContext db, TokenService tokenService, IHubContext hub) : ControllerBase +public class DevicesController( + AppDbContext db, + TokenService tokenService, + IHubContext hub, + DeviceSessionValidator deviceSessions) : ControllerBase { private Guid GetUserId() => Guid.Parse(User.FindFirstValue(JwtRegisteredClaimNames.Sub)!); - private static DeviceResponse ToResponse(Device d) => new( - d.Id, d.Name, d.Platform, d.LastSeenAt, d.CreatedAt); + private Guid? GetDeviceId() => + Guid.TryParse(User.FindFirstValue(TokenService.DeviceIdClaimType), out var id) ? id : null; + + private static DeviceResponse ToResponse(Device d, Guid? currentDeviceId) => new( + d.Id, d.Name, d.Platform, d.LastSeenAt, d.CreatedAt, d.Id == currentDeviceId); // GET /api/devices → list registered devices for the current user [HttpGet] public async Task GetDevices() { var userId = GetUserId(); + var currentDeviceId = GetDeviceId(); + var devices = await db.Devices .Where(d => d.UserId == userId) .OrderByDescending(d => d.LastSeenAt) - .Select(d => ToResponse(d)) .ToListAsync(); - return Ok(devices); + return Ok(devices.Select(d => ToResponse(d, currentDeviceId))); } - // DELETE /api/devices/{id} → remove a device + // DELETE /api/devices/{id} → remove a device and revoke its session [HttpDelete("{id:guid}")] public async Task RemoveDevice(Guid id) { @@ -49,11 +57,49 @@ public async Task RemoveDevice(Guid id) if (device is null) return NotFound(); + // Refresh tokens cascade with the device row. db.Devices.Remove(device); await db.SaveChangesAsync(); + + await RevokeDeviceSessionAsync(device.Id); return NoContent(); } + // POST /api/devices/sign-out-others → revoke every session except the caller's + [HttpPost("sign-out-others")] + public async Task SignOutOtherDevices() + { + var userId = GetUserId(); + var currentDeviceId = GetDeviceId(); + + var others = await db.Devices + .Where(d => d.UserId == userId && (currentDeviceId == null || d.Id != currentDeviceId)) + .ToListAsync(); + + db.Devices.RemoveRange(others); + + // Sessions that predate per-device binding have no device row to cascade from. + var orphanTokens = await db.RefreshTokens + .Where(r => r.UserId == userId && r.DeviceId == null && r.RevokedAt == null) + .ToListAsync(); + + foreach (var token in orphanTokens) + token.RevokedAt = DateTime.UtcNow; + + await db.SaveChangesAsync(); + + foreach (var device in others) + await RevokeDeviceSessionAsync(device.Id); + + return Ok(new SignOutOthersResponse(others.Count)); + } + + private async Task RevokeDeviceSessionAsync(Guid deviceId) + { + deviceSessions.Invalidate(deviceId); + await hub.Clients.Group($"device:{deviceId}").SendAsync(MessageHub.SessionRevoked, new { deviceId }); + } + // POST /api/devices/pair-code → generate a short-lived signed QR pairing token (JWT, 5 min TTL) [HttpPost("pair-code")] public IActionResult GeneratePairCode() @@ -82,6 +128,7 @@ public async Task ClaimDevice([FromBody] ClaimDeviceRequest reque { Id = Guid.NewGuid(), UserId = userId, + InstallationId = request.InstallationId, Name = request.DeviceName, Platform = request.Platform, LastSeenAt = DateTime.UtcNow, @@ -91,7 +138,7 @@ public async Task ClaimDevice([FromBody] ClaimDeviceRequest reque db.Devices.Add(device); await db.SaveChangesAsync(); - var (accessToken, expiresAt) = tokenService.GenerateAccessToken(user.Id, user.Email); + var (accessToken, expiresAt) = tokenService.GenerateAccessToken(user.Id, user.Email, device.Id); return Ok(new AuthResponse(accessToken, string.Empty, expiresAt)); } @@ -118,6 +165,7 @@ public async Task CreateLoginCode([FromBody] CreateLoginCodeReque Code = code, DeviceName = request.DeviceName, Platform = platform, + InstallationId = request.InstallationId, ExpiresAt = DateTime.UtcNow.AddMinutes(LoginCodeTtlMinutes), CreatedAt = DateTime.UtcNow, }; @@ -153,18 +201,21 @@ public async Task PollLoginCode(string code) // Redeem the approval: register the device, mint tokens, and consume the code. entry.IsConsumed = true; - db.Devices.Add(new Device + var device = new Device { Id = Guid.NewGuid(), UserId = user.Id, + InstallationId = entry.InstallationId, Name = entry.DeviceName, Platform = entry.Platform, LastSeenAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow, - }); + }; + + db.Devices.Add(device); - var (accessToken, expiresAt) = tokenService.GenerateAccessToken(user.Id, user.Email); - var refreshToken = await CreateRefreshTokenAsync(user.Id); + var (accessToken, expiresAt) = tokenService.GenerateAccessToken(user.Id, user.Email, device.Id); + var refreshToken = await CreateRefreshTokenAsync(user.Id, device.Id); await db.SaveChangesAsync(); return Ok(new LoginCodePollResponse("approved", accessToken, refreshToken, expiresAt)); @@ -197,12 +248,13 @@ await hub.Clients.Group($"login-code:{normalized}") return Ok(new ApproveLoginCodeResponse(entry.DeviceName, entry.Platform)); } - private async Task CreateRefreshTokenAsync(Guid userId) + private async Task CreateRefreshTokenAsync(Guid userId, Guid? deviceId) { var token = new RefreshToken { Id = Guid.NewGuid(), UserId = userId, + DeviceId = deviceId, Token = tokenService.GenerateRefreshToken(), CreatedAt = DateTime.UtcNow, ExpiresAt = DateTime.UtcNow.AddDays(tokenService.RefreshTokenDays), diff --git a/src/Briefcase.ApiService/Hubs/MessageHub.cs b/src/Briefcase.ApiService/Hubs/MessageHub.cs index 825b9d2..96a8d8a 100644 --- a/src/Briefcase.ApiService/Hubs/MessageHub.cs +++ b/src/Briefcase.ApiService/Hubs/MessageHub.cs @@ -23,6 +23,7 @@ public class MessageHub(TransferSessionService sessions) : Hub public const string ShareLinkRevoked = nameof(ShareLinkRevoked); public const string E2eeSettingsChanged = nameof(E2eeSettingsChanged); public const string LoginCodeApproved = nameof(LoginCodeApproved); + public const string SessionRevoked = nameof(SessionRevoked); public override async Task OnConnectedAsync() { @@ -30,6 +31,10 @@ public override async Task OnConnectedAsync() if (userId is not null) await Groups.AddToGroupAsync(Context.ConnectionId, userId); + var deviceId = GetDeviceId(); + if (deviceId is not null) + await Groups.AddToGroupAsync(Context.ConnectionId, $"device:{deviceId}"); + await base.OnConnectedAsync(); } @@ -39,6 +44,10 @@ public override async Task OnDisconnectedAsync(Exception? exception) if (userId is not null) await Groups.RemoveFromGroupAsync(Context.ConnectionId, userId); + var deviceId = GetDeviceId(); + if (deviceId is not null) + await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"device:{deviceId}"); + await base.OnDisconnectedAsync(exception); } @@ -85,4 +94,7 @@ public async Task LeaveLoginCode(string code) private string? GetUserId() => Context.User?.FindFirst(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub)?.Value; + + private string? GetDeviceId() => + Context.User?.FindFirst(TokenService.DeviceIdClaimType)?.Value; } diff --git a/src/Briefcase.ApiService/Models/AuthModels.cs b/src/Briefcase.ApiService/Models/AuthModels.cs index 6882b6d..8f0a314 100644 --- a/src/Briefcase.ApiService/Models/AuthModels.cs +++ b/src/Briefcase.ApiService/Models/AuthModels.cs @@ -7,13 +7,15 @@ public record RegisterRequest( [Required, MinLength(8), MaxLength(128)] string Password, [Required, MaxLength(100)] string DisplayName, [MaxLength(200)] string? DeviceName = null, - string? DevicePlatform = null); + string? DevicePlatform = null, + [MaxLength(64)] string? InstallationId = null); public record LoginRequest( [Required, EmailAddress] string Email, [Required] string Password, [MaxLength(200)] string? DeviceName = null, - string? DevicePlatform = null); + string? DevicePlatform = null, + [MaxLength(64)] string? InstallationId = null); public record RefreshRequest( [Required] string RefreshToken); diff --git a/src/Briefcase.ApiService/Models/DeviceModels.cs b/src/Briefcase.ApiService/Models/DeviceModels.cs index 5c5eda0..5850635 100644 --- a/src/Briefcase.ApiService/Models/DeviceModels.cs +++ b/src/Briefcase.ApiService/Models/DeviceModels.cs @@ -8,18 +8,21 @@ public record DeviceResponse( string Name, Platform Platform, DateTime LastSeenAt, - DateTime CreatedAt); + DateTime CreatedAt, + bool IsCurrent = false); public record ClaimDeviceRequest( [Required] string Token, [Required, MaxLength(100)] string DeviceName, - [Required] Platform Platform); + [Required] Platform Platform, + [MaxLength(64)] string? InstallationId = null); public record PairCodeResponse(string Token, DateTime ExpiresAt); public record CreateLoginCodeRequest( [Required, MaxLength(100)] string DeviceName, - [MaxLength(20)] string? Platform = null); + [MaxLength(20)] string? Platform = null, + [MaxLength(64)] string? InstallationId = null); public record LoginCodeResponse(string Code, DateTime ExpiresAt); @@ -33,3 +36,5 @@ public record LoginCodePollResponse( string? AccessToken = null, string? RefreshToken = null, DateTime? AccessTokenExpiresAt = null); + +public record SignOutOthersResponse(int RemovedCount); diff --git a/src/Briefcase.ApiService/Program.cs b/src/Briefcase.ApiService/Program.cs index 88ebbb4..9ec3f40 100644 --- a/src/Briefcase.ApiService/Program.cs +++ b/src/Briefcase.ApiService/Program.cs @@ -57,6 +57,7 @@ builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); builder.Services.AddHttpClient(); // ── File Storage (MinIO / S3-compatible) ───────────────────────────────────── @@ -138,6 +139,19 @@ context.Token = accessToken; } return Task.CompletedTask; + }, + + // Tokens minted before per-device sessions carry no device claim and stay valid + // until they expire; everything newer dies as soon as its device is removed. + OnTokenValidated = async context => + { + var deviceIdClaim = context.Principal?.FindFirst(TokenService.DeviceIdClaimType)?.Value; + if (!Guid.TryParse(deviceIdClaim, out var deviceId)) + return; + + var validator = context.HttpContext.RequestServices.GetRequiredService(); + if (!await validator.IsActiveAsync(deviceId)) + context.Fail("Device session has been revoked."); } }; }); diff --git a/src/Briefcase.ApiService/Services/DeviceSessionValidator.cs b/src/Briefcase.ApiService/Services/DeviceSessionValidator.cs new file mode 100644 index 0000000..46a3ad3 --- /dev/null +++ b/src/Briefcase.ApiService/Services/DeviceSessionValidator.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Briefcase.Infrastructure.Persistence; + +namespace Briefcase.ApiService.Services; + +/// +/// Decides whether an access token bound to a device is still usable. +/// Results are cached in-process; makes removal take effect immediately. +/// Note: with more than one API instance this cache is per-instance, so a distributed +/// backplane would be required for cross-instance immediacy. +/// +public class DeviceSessionValidator(IServiceScopeFactory scopeFactory, IMemoryCache cache) +{ + private static readonly TimeSpan ActiveTtl = TimeSpan.FromMinutes(5); + private static readonly TimeSpan RevokedTtl = TimeSpan.FromMinutes(15); + + private static string CacheKey(Guid deviceId) => $"device-active:{deviceId}"; + + public async Task IsActiveAsync(Guid deviceId) + { + if (cache.TryGetValue(CacheKey(deviceId), out var cached)) + return cached; + + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var exists = await db.Devices.AnyAsync(d => d.Id == deviceId); + + cache.Set(CacheKey(deviceId), exists, exists ? ActiveTtl : RevokedTtl); + return exists; + } + + public void Invalidate(Guid deviceId) => + cache.Set(CacheKey(deviceId), false, RevokedTtl); +} diff --git a/src/Briefcase.ApiService/Services/OAuthService.cs b/src/Briefcase.ApiService/Services/OAuthService.cs index 7006bba..e9de2f7 100644 --- a/src/Briefcase.ApiService/Services/OAuthService.cs +++ b/src/Briefcase.ApiService/Services/OAuthService.cs @@ -23,7 +23,8 @@ public string NormalizeProvider(string provider) => string redirectUri, string? clientRedirectUri = null, string? deviceName = null, - string? devicePlatform = null) + string? devicePlatform = null, + string? installationId = null) { provider = NormalizeProvider(provider); var config = GetProviderConfig(provider); @@ -39,6 +40,7 @@ public string NormalizeProvider(string provider) => clientRedirectUri, deviceName, devicePlatform, + installationId, DateTime.UtcNow); var callbackUrl = redirectUri.TrimEnd('/'); @@ -213,6 +215,7 @@ public record OAuthPendingState( string? ClientRedirectUri, string? DeviceName, string? DevicePlatform, + string? InstallationId, DateTime CreatedAt); public record OAuthTokenResponse(string AccessToken, string? IdToken); public record OAuthUserInfo(string ProviderKey, string Email, string Name, string? AvatarUrl); diff --git a/src/Briefcase.ApiService/Services/TokenService.cs b/src/Briefcase.ApiService/Services/TokenService.cs index b33b868..a788c89 100644 --- a/src/Briefcase.ApiService/Services/TokenService.cs +++ b/src/Briefcase.ApiService/Services/TokenService.cs @@ -8,7 +8,10 @@ namespace Briefcase.ApiService.Services; public class TokenService(IConfiguration configuration) { - public (string Token, DateTime ExpiresAt) GenerateAccessToken(Guid userId, string email) + /// Claim carrying the id of the Device row this session belongs to. + public const string DeviceIdClaimType = "did"; + + public (string Token, DateTime ExpiresAt) GenerateAccessToken(Guid userId, string email, Guid? deviceId = null) { var secret = configuration["Jwt:Secret"]!; var issuer = configuration["Jwt:Issuer"]; @@ -19,13 +22,16 @@ public class TokenService(IConfiguration configuration) var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var expiresAt = DateTime.UtcNow.AddMinutes(minutes); - var claims = new[] + var claims = new List { - new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), - new Claim(JwtRegisteredClaimNames.Email, email), - new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new(JwtRegisteredClaimNames.Sub, userId.ToString()), + new(JwtRegisteredClaimNames.Email, email), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), }; + if (deviceId is not null) + claims.Add(new Claim(DeviceIdClaimType, deviceId.Value.ToString())); + var token = new JwtSecurityToken( issuer: issuer, audience: audience, diff --git a/src/Briefcase.Components/Pages/DevicesPage.razor b/src/Briefcase.Components/Pages/DevicesPage.razor index b832137..72f25ee 100644 --- a/src/Briefcase.Components/Pages/DevicesPage.razor +++ b/src/Briefcase.Components/Pages/DevicesPage.razor @@ -85,10 +85,14 @@ else
@device.Name @device.Platform + @if (device.IsCurrent) + { + This device + }
Last seen: @device.LastSeenAt.ToString("g")
- diff --git a/src/Briefcase.Components/Pages/SettingsPage.razor b/src/Briefcase.Components/Pages/SettingsPage.razor index 5be1932..cdf7fac 100644 --- a/src/Briefcase.Components/Pages/SettingsPage.razor +++ b/src/Briefcase.Components/Pages/SettingsPage.razor @@ -29,11 +29,28 @@
@device.Name @device.Platform + @if (device.IsCurrent) + { + This device + }
Last seen: @device.LastSeenAt.ToString("g") } + + @if (_devices.Count > 1) + { +

Other devices lose access immediately and must sign in again.

+ + } + + @if (!string.IsNullOrEmpty(_devicesMessage)) + { +
@_devicesMessage
+ } } @@ -257,6 +274,9 @@ @code { private IReadOnlyList? _devices; + private bool _isSigningOutOthers; + private string? _devicesMessage; + private bool _devicesSuccess; private ChangePasswordModel _passwordModel = new(); private string? _passwordMessage; private bool _passwordSuccess; @@ -288,6 +308,29 @@ catch { _rememberPassphrase = false; } } + private async Task SignOutOtherDevices() + { + _isSigningOutOthers = true; + _devicesMessage = null; + + try + { + var removed = await DeviceService.SignOutOtherDevicesAsync(); + _devices = await DeviceService.GetDevicesAsync(); + _devicesMessage = $"Signed out {removed} other device(s)."; + _devicesSuccess = true; + } + catch (Exception ex) + { + _devicesMessage = $"Failed to sign out other devices: {ex.Message}"; + _devicesSuccess = false; + } + finally + { + _isSigningOutOthers = false; + } + } + private async Task HandleChangePassword() { _isChangingPassword = true; diff --git a/src/Briefcase.Components/Services/AuthService.cs b/src/Briefcase.Components/Services/AuthService.cs index 2edb952..2b96613 100644 --- a/src/Briefcase.Components/Services/AuthService.cs +++ b/src/Briefcase.Components/Services/AuthService.cs @@ -64,7 +64,7 @@ public async Task TryRestoreSessionAsync() public async Task LoginAsync(string email, string password) { - var response = await httpClient.PostAsJsonAsync("api/auth/login", new { email, password, deviceName = deviceInfoProvider.DeviceName, devicePlatform = deviceInfoProvider.Platform }); + var response = await httpClient.PostAsJsonAsync("api/auth/login", new { email, password, deviceName = deviceInfoProvider.DeviceName, devicePlatform = deviceInfoProvider.Platform, installationId = deviceInfoProvider.InstallationId }); if (!response.IsSuccessStatusCode) { @@ -81,7 +81,7 @@ public async Task LoginAsync(string email, string password) public async Task RegisterAsync(string email, string password, string displayName) { - var response = await httpClient.PostAsJsonAsync("api/auth/register", new { email, password, displayName, deviceName = deviceInfoProvider.DeviceName, devicePlatform = deviceInfoProvider.Platform }); + var response = await httpClient.PostAsJsonAsync("api/auth/register", new { email, password, displayName, deviceName = deviceInfoProvider.DeviceName, devicePlatform = deviceInfoProvider.Platform, installationId = deviceInfoProvider.InstallationId }); if (!response.IsSuccessStatusCode) { @@ -111,7 +111,8 @@ public string BuildExternalLoginUrl(string provider, string clientRedirectUri) $"redirect_uri={Uri.EscapeDataString(callbackUri)}" + $"&client_redirect_uri={Uri.EscapeDataString(clientRedirectUri)}" + $"&device_name={Uri.EscapeDataString(deviceInfoProvider.DeviceName)}" + - $"&device_platform={Uri.EscapeDataString(deviceInfoProvider.Platform)}"; + $"&device_platform={Uri.EscapeDataString(deviceInfoProvider.Platform)}" + + $"&installation_id={Uri.EscapeDataString(deviceInfoProvider.InstallationId ?? string.Empty)}"; return new Uri(httpClient.BaseAddress, $"api/auth/oauth/{encodedProvider}?{query}").ToString(); } diff --git a/src/Briefcase.Components/Services/IDeviceInfoProvider.cs b/src/Briefcase.Components/Services/IDeviceInfoProvider.cs index c722bff..7bc9d6b 100644 --- a/src/Briefcase.Components/Services/IDeviceInfoProvider.cs +++ b/src/Briefcase.Components/Services/IDeviceInfoProvider.cs @@ -4,6 +4,9 @@ public interface IDeviceInfoProvider { string DeviceName { get; } string Platform { get; } + + /// Stable id for this install so the server can bind sessions to this device. + string? InstallationId { get; } } /// @@ -13,4 +16,5 @@ public class DefaultDeviceInfoProvider : IDeviceInfoProvider { public string DeviceName => "Web Browser"; public string Platform => "Web"; + public string? InstallationId => null; } diff --git a/src/Briefcase.Components/Services/IDeviceService.cs b/src/Briefcase.Components/Services/IDeviceService.cs index a7ed45b..7c8d893 100644 --- a/src/Briefcase.Components/Services/IDeviceService.cs +++ b/src/Briefcase.Components/Services/IDeviceService.cs @@ -6,6 +6,10 @@ public interface IDeviceService { Task> GetDevicesAsync(); Task RemoveDeviceAsync(Guid deviceId); + + /// Removes every other device of this account and revokes their sessions. + Task SignOutOtherDevicesAsync(); + Task GeneratePairCodeAsync(); Task ClaimDeviceAsync(string token); diff --git a/src/Briefcase.Components/Services/IMessageStreamService.cs b/src/Briefcase.Components/Services/IMessageStreamService.cs index 91dda66..1b045a8 100644 --- a/src/Briefcase.Components/Services/IMessageStreamService.cs +++ b/src/Briefcase.Components/Services/IMessageStreamService.cs @@ -19,6 +19,9 @@ public interface IMessageStreamService /// Raised when a message is trashed or permanently deleted on any device. event Func? MessageRemoved; + /// Raised when this device was removed from the account and its session revoked. + event Func? SessionRevoked; + /// /// Opens the authenticated SignalR connection and begins receiving events. /// Idempotent — calling it while already connected is a no-op. diff --git a/src/Briefcase.Domain/Entities/Device.cs b/src/Briefcase.Domain/Entities/Device.cs index 3678ee0..d613906 100644 --- a/src/Briefcase.Domain/Entities/Device.cs +++ b/src/Briefcase.Domain/Entities/Device.cs @@ -13,11 +13,18 @@ public class Device { public Guid Id { get; set; } public Guid UserId { get; set; } + + /// Opaque id generated and persisted by the client install; identifies this device across logins. + public string? InstallationId { get; set; } + public string Name { get; set; } = string.Empty; public Platform Platform { get; set; } public string? PushToken { get; set; } public DateTime LastSeenAt { get; set; } public DateTime CreatedAt { get; set; } + /// Set by the API when serialising for the device that is making the request. + public bool IsCurrent { get; set; } + public User User { get; set; } = null!; } diff --git a/src/Briefcase.Domain/Entities/DeviceLoginCode.cs b/src/Briefcase.Domain/Entities/DeviceLoginCode.cs index f78cc77..d8a0832 100644 --- a/src/Briefcase.Domain/Entities/DeviceLoginCode.cs +++ b/src/Briefcase.Domain/Entities/DeviceLoginCode.cs @@ -12,6 +12,9 @@ public class DeviceLoginCode public string DeviceName { get; set; } = string.Empty; public Platform Platform { get; set; } + /// Installation id of the device that requested the code. + public string? InstallationId { get; set; } + /// Set when an authenticated device approves the code. public Guid? UserId { get; set; } public bool IsApproved { get; set; } diff --git a/src/Briefcase.Domain/Entities/RefreshToken.cs b/src/Briefcase.Domain/Entities/RefreshToken.cs index b10f5dd..80b8755 100644 --- a/src/Briefcase.Domain/Entities/RefreshToken.cs +++ b/src/Briefcase.Domain/Entities/RefreshToken.cs @@ -4,6 +4,10 @@ public class RefreshToken { public Guid Id { get; set; } public Guid UserId { get; set; } + + /// Device this session belongs to; removing the device revokes the token. + public Guid? DeviceId { get; set; } + public string Token { get; set; } = string.Empty; public DateTime ExpiresAt { get; set; } public DateTime CreatedAt { get; set; } @@ -13,4 +17,5 @@ public class RefreshToken public bool IsActive => !IsRevoked && !IsExpired; public User User { get; set; } = null!; + public Device? Device { get; set; } } diff --git a/src/Briefcase.Infrastructure/Persistence/AppDbContext.cs b/src/Briefcase.Infrastructure/Persistence/AppDbContext.cs index 12f3b64..83a7f70 100644 --- a/src/Briefcase.Infrastructure/Persistence/AppDbContext.cs +++ b/src/Briefcase.Infrastructure/Persistence/AppDbContext.cs @@ -50,10 +50,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { e.HasKey(d => d.Id); e.Property(d => d.Name).IsRequired().HasMaxLength(100); + e.Property(d => d.InstallationId).HasMaxLength(64); + e.HasIndex(d => new { d.UserId, d.InstallationId }) + .IsUnique() + .HasFilter("\"InstallationId\" IS NOT NULL"); e.Property(d => d.Platform) .HasConversion() .HasMaxLength(20); e.Property(d => d.PushToken).HasMaxLength(512); + e.Ignore(d => d.IsCurrent); e.HasOne(d => d.User) .WithMany(u => u.Devices) @@ -142,6 +147,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(r => r.UserId) .OnDelete(DeleteBehavior.Cascade); + e.HasOne(r => r.Device) + .WithMany() + .HasForeignKey(r => r.DeviceId) + .OnDelete(DeleteBehavior.Cascade); + e.Ignore(r => r.IsRevoked); e.Ignore(r => r.IsExpired); e.Ignore(r => r.IsActive); @@ -154,6 +164,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) e.HasIndex(c => c.Code).IsUnique(); e.Property(c => c.Code).IsRequired().HasMaxLength(16); e.Property(c => c.DeviceName).IsRequired().HasMaxLength(100); + e.Property(c => c.InstallationId).HasMaxLength(64); e.Property(c => c.Platform) .HasConversion() .HasMaxLength(20); diff --git a/src/Briefcase.Infrastructure/Persistence/Migrations/20260813084800_AddDeviceSessions.Designer.cs b/src/Briefcase.Infrastructure/Persistence/Migrations/20260813084800_AddDeviceSessions.Designer.cs new file mode 100644 index 0000000..1673eb0 --- /dev/null +++ b/src/Briefcase.Infrastructure/Persistence/Migrations/20260813084800_AddDeviceSessions.Designer.cs @@ -0,0 +1,585 @@ +// +using System; +using Briefcase.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Briefcase.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260813084800_AddDeviceSessions")] + partial class AddDeviceSessions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Briefcase.Domain.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InstallationId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("PushToken") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "InstallationId") + .IsUnique() + .HasFilter("\"InstallationId\" IS NOT NULL"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("Briefcase.Domain.Entities.DeviceLoginCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InstallationId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("IsApproved") + .HasColumnType("boolean"); + + b.Property("IsConsumed") + .HasColumnType("boolean"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("DeviceLoginCodes"); + }); + + modelBuilder.Entity("Briefcase.Domain.Entities.ExternalLogin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ProviderKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ProviderKey") + .IsUnique(); + + b.ToTable("ExternalLogins"); + }); + + modelBuilder.Entity("Briefcase.Domain.Entities.FileAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlobPath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OriginalName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PreviewBlobPath") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("FileAttachments"); + }); + + modelBuilder.Entity("Briefcase.Domain.Entities.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptionIV") + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("FileId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsPermanentlyDeleted") + .HasColumnType("boolean"); + + b.Property("IsPinned") + .HasColumnType("boolean"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("PermanentlyDeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PinnedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FileId"); + + b.HasIndex("UserId", "IsDeleted", "IsPermanentlyDeleted", "CreatedAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("Briefcase.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceId") + .HasColumnType("uuid"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Briefcase.Domain.Entities.ShareLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsOneTime") + .HasColumnType("boolean"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("ViewCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("MessageId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("ShareLinks"); + }); + + modelBuilder.Entity("Briefcase.Domain.Entities.TransferSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClaimedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("TransferSessions"); + }); + + modelBuilder.Entity("Briefcase.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Briefcase.Domain.Entities.UserE2eeSettings", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("KdfAlgorithm") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("KdfParams") + .IsRequired() + .HasColumnType("text"); + + b.Property("KdfSalt") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("KeyVerifier") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId"); + + b.ToTable("UserE2eeSettings"); + }); + + modelBuilder.Entity("Briefcase.Domain.Entities.UserSettings", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Language") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId"); + + b.ToTable("UserSettings"); + }); + + modelBuilder.Entity("Briefcase.Domain.Entities.Device", b => + { + b.HasOne("Briefcase.Domain.Entities.User", "User") + .WithMany("Devices") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Briefcase.Domain.Entities.DeviceLoginCode", b => + { + b.HasOne("Briefcase.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Briefcase.Domain.Entities.ExternalLogin", b => + { + b.HasOne("Briefcase.Domain.Entities.User", "User") + .WithMany("ExternalLogins") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Briefcase.Domain.Entities.FileAttachment", b => + { + b.HasOne("Briefcase.Domain.Entities.User", "User") + .WithMany("FileAttachments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Briefcase.Domain.Entities.Message", b => + { + b.HasOne("Briefcase.Domain.Entities.FileAttachment", "FileAttachment") + .WithMany() + .HasForeignKey("FileId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Briefcase.Domain.Entities.User", "User") + .WithMany("Messages") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FileAttachment"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Briefcase.Domain.Entities.RefreshToken", b => + { + b.HasOne("Briefcase.Domain.Entities.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Briefcase.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Briefcase.Domain.Entities.ShareLink", b => + { + b.HasOne("Briefcase.Domain.Entities.Message", "Message") + .WithMany("ShareLinks") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Briefcase.Domain.Entities.User", "User") + .WithMany("ShareLinks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Message"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Briefcase.Domain.Entities.UserE2eeSettings", b => + { + b.HasOne("Briefcase.Domain.Entities.User", "User") + .WithOne("E2eeSettings") + .HasForeignKey("Briefcase.Domain.Entities.UserE2eeSettings", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Briefcase.Domain.Entities.UserSettings", b => + { + b.HasOne("Briefcase.Domain.Entities.User", "User") + .WithOne("Settings") + .HasForeignKey("Briefcase.Domain.Entities.UserSettings", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Briefcase.Domain.Entities.Message", b => + { + b.Navigation("ShareLinks"); + }); + + modelBuilder.Entity("Briefcase.Domain.Entities.User", b => + { + b.Navigation("Devices"); + + b.Navigation("E2eeSettings"); + + b.Navigation("ExternalLogins"); + + b.Navigation("FileAttachments"); + + b.Navigation("Messages"); + + b.Navigation("Settings"); + + b.Navigation("ShareLinks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Briefcase.Infrastructure/Persistence/Migrations/20260813084800_AddDeviceSessions.cs b/src/Briefcase.Infrastructure/Persistence/Migrations/20260813084800_AddDeviceSessions.cs new file mode 100644 index 0000000..8141459 --- /dev/null +++ b/src/Briefcase.Infrastructure/Persistence/Migrations/20260813084800_AddDeviceSessions.cs @@ -0,0 +1,92 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Briefcase.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddDeviceSessions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Devices_UserId", + table: "Devices"); + + migrationBuilder.AddColumn( + name: "DeviceId", + table: "RefreshTokens", + type: "uuid", + nullable: true); + + migrationBuilder.AddColumn( + name: "InstallationId", + table: "Devices", + type: "character varying(64)", + maxLength: 64, + nullable: true); + + migrationBuilder.AddColumn( + name: "InstallationId", + table: "DeviceLoginCodes", + type: "character varying(64)", + maxLength: 64, + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_DeviceId", + table: "RefreshTokens", + column: "DeviceId"); + + migrationBuilder.CreateIndex( + name: "IX_Devices_UserId_InstallationId", + table: "Devices", + columns: new[] { "UserId", "InstallationId" }, + unique: true, + filter: "\"InstallationId\" IS NOT NULL"); + + migrationBuilder.AddForeignKey( + name: "FK_RefreshTokens_Devices_DeviceId", + table: "RefreshTokens", + column: "DeviceId", + principalTable: "Devices", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_RefreshTokens_Devices_DeviceId", + table: "RefreshTokens"); + + migrationBuilder.DropIndex( + name: "IX_RefreshTokens_DeviceId", + table: "RefreshTokens"); + + migrationBuilder.DropIndex( + name: "IX_Devices_UserId_InstallationId", + table: "Devices"); + + migrationBuilder.DropColumn( + name: "DeviceId", + table: "RefreshTokens"); + + migrationBuilder.DropColumn( + name: "InstallationId", + table: "Devices"); + + migrationBuilder.DropColumn( + name: "InstallationId", + table: "DeviceLoginCodes"); + + migrationBuilder.CreateIndex( + name: "IX_Devices_UserId", + table: "Devices", + column: "UserId"); + } + } +} diff --git a/src/Briefcase.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/Briefcase.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index e787111..98cc262 100644 --- a/src/Briefcase.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Briefcase.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -31,6 +31,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("InstallationId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + b.Property("LastSeenAt") .HasColumnType("timestamp with time zone"); @@ -53,7 +57,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("UserId"); + b.HasIndex("UserId", "InstallationId") + .IsUnique() + .HasFilter("\"InstallationId\" IS NOT NULL"); b.ToTable("Devices"); }); @@ -80,6 +86,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ExpiresAt") .HasColumnType("timestamp with time zone"); + b.Property("InstallationId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + b.Property("IsApproved") .HasColumnType("boolean"); @@ -243,6 +253,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("DeviceId") + .HasColumnType("uuid"); + b.Property("ExpiresAt") .HasColumnType("timestamp with time zone"); @@ -259,6 +272,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("DeviceId"); + b.HasIndex("Token") .IsUnique(); @@ -483,12 +498,19 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Briefcase.Domain.Entities.RefreshToken", b => { + b.HasOne("Briefcase.Domain.Entities.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade); + b.HasOne("Briefcase.Domain.Entities.User", "User") .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.Navigation("Device"); + b.Navigation("User"); }); diff --git a/src/Briefcase.Maui/Services/MauiDeviceInfoProvider.cs b/src/Briefcase.Maui/Services/MauiDeviceInfoProvider.cs index a4f91c2..e590009 100644 --- a/src/Briefcase.Maui/Services/MauiDeviceInfoProvider.cs +++ b/src/Briefcase.Maui/Services/MauiDeviceInfoProvider.cs @@ -4,8 +4,24 @@ namespace Briefcase.Maui.Services; public class MauiDeviceInfoProvider : IDeviceInfoProvider { + private const string InstallationIdKey = "installation_id"; + public string DeviceName => DeviceInfo.Current.Name; + public string? InstallationId + { + get + { + var id = Preferences.Default.Get(InstallationIdKey, null); + if (string.IsNullOrEmpty(id)) + { + id = Guid.NewGuid().ToString(); + Preferences.Default.Set(InstallationIdKey, id); + } + return id; + } + } + public string Platform => DeviceInfo.Current.Platform switch { var p when p == DevicePlatform.WinUI => "Windows", diff --git a/src/Briefcase.Maui/Services/MauiDeviceService.cs b/src/Briefcase.Maui/Services/MauiDeviceService.cs index d3ef027..a85641d 100644 --- a/src/Briefcase.Maui/Services/MauiDeviceService.cs +++ b/src/Briefcase.Maui/Services/MauiDeviceService.cs @@ -6,7 +6,7 @@ namespace Briefcase.Maui.Services; -public class MauiDeviceService(IHttpClientFactory httpClientFactory) : IDeviceService +public class MauiDeviceService(IHttpClientFactory httpClientFactory, IDeviceInfoProvider deviceInfo) : IDeviceService { private HttpClient CreateClient() => httpClientFactory.CreateClient("ApiClient"); @@ -23,6 +23,15 @@ public async Task RemoveDeviceAsync(Guid deviceId) response.EnsureSuccessStatusCode(); } + public async Task SignOutOtherDevicesAsync() + { + var client = CreateClient(); + var response = await client.PostAsync("api/devices/sign-out-others", null); + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadFromJsonAsync(); + return result?.RemovedCount ?? 0; + } + public async Task GeneratePairCodeAsync() { var client = CreateClient(); @@ -35,14 +44,27 @@ public async Task GeneratePairCodeAsync() public async Task ClaimDeviceAsync(string token) { var client = CreateClient(); - var response = await client.PostAsJsonAsync("api/devices/claim", new { token }); + var response = await client.PostAsJsonAsync("api/devices/claim", new + { + token, + deviceName = deviceInfo.DeviceName, + platform = Enum.TryParse(deviceInfo.Platform, true, out var platform) + ? platform + : Briefcase.Domain.Entities.Platform.Web, + installationId = deviceInfo.InstallationId, + }); response.EnsureSuccessStatusCode(); } public async Task GenerateLoginCodeAsync(string deviceName, string platform) { var client = CreateClient(); - var response = await client.PostAsJsonAsync("api/devices/login-code", new { deviceName, platform }); + var response = await client.PostAsJsonAsync("api/devices/login-code", new + { + deviceName, + platform, + installationId = deviceInfo.InstallationId, + }); response.EnsureSuccessStatusCode(); var result = await response.Content.ReadFromJsonAsync(); return result is null @@ -115,6 +137,8 @@ public async Task ApproveLoginCodeAsync(string code) private record PairCodeResponse(string Token); + private record SignOutOthersResponse(int RemovedCount); + private record LoginCodeResponse(string Code, DateTime ExpiresAt); private record ApproveLoginCodeResponse(string DeviceName); diff --git a/src/Briefcase.Maui/Services/MauiMessageStreamService.cs b/src/Briefcase.Maui/Services/MauiMessageStreamService.cs index e2bab5b..8783c3a 100644 --- a/src/Briefcase.Maui/Services/MauiMessageStreamService.cs +++ b/src/Briefcase.Maui/Services/MauiMessageStreamService.cs @@ -21,6 +21,7 @@ public class MauiMessageStreamService(IHttpClientFactory httpClientFactory, ITok public event Func? MessageCreated; public event Func? MessageUpdated; public event Func? MessageRemoved; + public event Func? SessionRevoked; public async Task StartAsync(CancellationToken cancellationToken = default) { @@ -68,6 +69,7 @@ private void RegisterHandlers(HubConnection connection, Uri apiBaseAddress) connection.On("MessageUpdated", payload => RaiseUpsert(payload, apiBaseAddress, MessageUpdated)); connection.On("MessageTrashed", RaiseRemoved); connection.On("MessageDeleted", RaiseRemoved); + connection.On("SessionRevoked", _ => SessionRevoked?.Invoke() ?? Task.CompletedTask); } private async Task RaiseUpsert(JsonElement payload, Uri apiBaseAddress, Func? handler) diff --git a/src/Briefcase.React/src/auth/AuthContext.tsx b/src/Briefcase.React/src/auth/AuthContext.tsx index 6798b44..b282fa7 100644 --- a/src/Briefcase.React/src/auth/AuthContext.tsx +++ b/src/Briefcase.React/src/auth/AuthContext.tsx @@ -8,8 +8,9 @@ import { useState, type ReactNode, } from 'react' -import { apiFetch } from '../lib/apiClient' +import { apiFetch, onUnauthorized } from '../lib/apiClient' import { API_BASE_URL } from '../lib/config' +import { messageStream } from '../realtime/messageStream' import type { AuthResponse, ExternalAuthProvider } from '../types' import { tokenStorage } from './tokenStorage' import { deviceInfo } from './deviceInfo' @@ -96,6 +97,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { password, deviceName: deviceInfo.deviceName, devicePlatform: deviceInfo.platform, + installationId: deviceInfo.installationId, }, }) if (!res.ok) throw new AuthException(await readProblemTitle(res, 'Login failed.')) @@ -115,6 +117,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { displayName, deviceName: deviceInfo.deviceName, devicePlatform: deviceInfo.platform, + installationId: deviceInfo.installationId, }, }) if (!res.ok) throw new AuthException(await readProblemTitle(res, 'Registration failed.')) @@ -158,7 +161,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { `redirect_uri=${encodeURIComponent(callbackUri)}` + `&client_redirect_uri=${encodeURIComponent(clientRedirectUri)}` + `&device_name=${encodeURIComponent(deviceInfo.deviceName)}` + - `&device_platform=${encodeURIComponent(deviceInfo.platform)}` + `&device_platform=${encodeURIComponent(deviceInfo.platform)}` + + `&installation_id=${encodeURIComponent(deviceInfo.installationId)}` return `${base}/api/auth/oauth/${encodedProvider}?${query}` }, []) @@ -184,6 +188,22 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, [refresh, clearAuth]) + // Another device removed this one from the devices list. + useEffect(() => { + return messageStream.onSessionRevoked(() => { + messageStream.stop().catch(() => { }) + clearAuth() + }) + }, [clearAuth]) + + // The session is no longer usable (revoked device, expired refresh token, …). + useEffect(() => { + return onUnauthorized(() => { + messageStream.stop().catch(() => { }) + clearAuth() + }) + }, [clearAuth]) + const value = useMemo( () => ({ isAuthenticated, diff --git a/src/Briefcase.React/src/auth/deviceInfo.ts b/src/Briefcase.React/src/auth/deviceInfo.ts index 30a03e3..e1cc814 100644 --- a/src/Briefcase.React/src/auth/deviceInfo.ts +++ b/src/Briefcase.React/src/auth/deviceInfo.ts @@ -9,8 +9,42 @@ function detectBrowser(): string { return 'Browser' } +function detectDevice(): string | null { + const ua = navigator.userAgent + if (/iPhone/.test(ua)) return 'iPhone' + if (/iPad/.test(ua)) return 'iPad' + + const android = ua.match(/Android [^;]+;\s*([^;)]+?)\s+Build\//) + if (android?.[1]) return android[1].trim() + if (/Android/.test(ua)) return 'Android' + if (/Windows/.test(ua)) return 'Windows' + if (/CrOS/.test(ua)) return 'ChromeOS' + if (/Mac OS X/.test(ua)) return 'macOS' + if (/Linux/.test(ua)) return 'Linux' + return null +} + +function detectDeviceName(): string { + const browser = detectBrowser() + const device = detectDevice() + return device ? `${browser} on ${device}` : `${browser} (Web)` +} + +const INSTALLATION_ID_KEY = 'briefcase_installation_id' + +/** Stable per-install id so the server can bind sessions to this browser profile. */ +function getInstallationId(): string { + let id = localStorage.getItem(INSTALLATION_ID_KEY) + if (!id) { + id = crypto.randomUUID() + localStorage.setItem(INSTALLATION_ID_KEY, id) + } + return id +} + export const deviceInfo = { - deviceName: `${detectBrowser()} (Web)`, + deviceName: detectDeviceName(), // Server expects the platform as a string here (matches IDeviceInfoProvider.Platform). platform: 'Web', + installationId: getInstallationId(), } diff --git a/src/Briefcase.React/src/i18n/locales/en.json b/src/Briefcase.React/src/i18n/locales/en.json index f857a50..3289f55 100644 --- a/src/Briefcase.React/src/i18n/locales/en.json +++ b/src/Briefcase.React/src/i18n/locales/en.json @@ -138,7 +138,8 @@ "loading": "Loading devices…", "none": "No registered devices.", "lastSeen": "Last seen: {{date}}", - "remove": "Remove" + "remove": "Remove", + "thisDevice": "This device" }, "transfer": { "sendTitle": "Send Message", @@ -203,6 +204,11 @@ "loadingDevices": "Loading devices…", "noDevices": "No registered devices.", "lastSeen": "Last seen: {{date}}", + "thisDevice": "This device", + "signOutOthers": "Sign out all other devices", + "signOutOthersHint": "Other devices lose access immediately and must sign in again.", + "signOutOthersDone": "Signed out {{count}} other device(s).", + "signOutOthersFailed": "Failed to sign out other devices: {{error}}", "languageSection": "Language", "languageHint": "Choose the language used across the app. This applies to your account on every device.", "changePasswordSection": "Change Password", diff --git a/src/Briefcase.React/src/i18n/locales/uk.json b/src/Briefcase.React/src/i18n/locales/uk.json index d914495..29cdad7 100644 --- a/src/Briefcase.React/src/i18n/locales/uk.json +++ b/src/Briefcase.React/src/i18n/locales/uk.json @@ -138,7 +138,8 @@ "loading": "Завантаження пристроїв…", "none": "Немає зареєстрованих пристроїв.", "lastSeen": "Востаннє в мережі: {{date}}", - "remove": "Видалити" + "remove": "Видалити", + "thisDevice": "Цей пристрій" }, "transfer": { "sendTitle": "Надіслати повідомлення", @@ -203,6 +204,11 @@ "loadingDevices": "Завантаження пристроїв…", "noDevices": "Немає зареєстрованих пристроїв.", "lastSeen": "Востаннє в мережі: {{date}}", + "thisDevice": "Цей пристрій", + "signOutOthers": "Вийти на всіх інших пристроях", + "signOutOthersHint": "Інші пристрої одразу втратять доступ і мають увійти знову.", + "signOutOthersDone": "Виконано вихід на {{count}} інших пристроях.", + "signOutOthersFailed": "Не вдалося вийти на інших пристроях: {{error}}", "languageSection": "Мова", "languageHint": "Оберіть мову інтерфейсу. Вона застосовується до вашого акаунта на всіх пристроях.", "changePasswordSection": "Змінити пароль", diff --git a/src/Briefcase.React/src/lib/apiClient.ts b/src/Briefcase.React/src/lib/apiClient.ts index ad2b7f0..0aa1c7d 100644 --- a/src/Briefcase.React/src/lib/apiClient.ts +++ b/src/Briefcase.React/src/lib/apiClient.ts @@ -15,6 +15,14 @@ export class ApiError extends Error { // A single in-flight refresh shared across concurrent 401s. let refreshPromise: Promise | null = null +const unauthorizedHandlers = new Set<() => void>() + +/** Notified when a request stays 401 after a refresh attempt, i.e. the session is gone. */ +export function onUnauthorized(handler: () => void): () => void { + unauthorizedHandlers.add(handler) + return () => unauthorizedHandlers.delete(handler) +} + async function tryRefresh(): Promise { const refreshToken = tokenStorage.getRefreshToken() if (!refreshToken) return false @@ -83,6 +91,11 @@ export async function apiFetch(path: string, options: RequestOptions = {}): Prom if (refreshed) { res = await doFetch(path, options) } + + if (res.status === 401) { + tokenStorage.clear() + unauthorizedHandlers.forEach((handler) => handler()) + } } return res diff --git a/src/Briefcase.React/src/pages/DevicesPage.tsx b/src/Briefcase.React/src/pages/DevicesPage.tsx index 90a1598..2066a96 100644 --- a/src/Briefcase.React/src/pages/DevicesPage.tsx +++ b/src/Briefcase.React/src/pages/DevicesPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react' import QRCode from 'qrcode' import { Trans, useTranslation } from 'react-i18next' import { Platform, platformLabel, type Device } from '../types' +import { deviceInfo } from '../auth/deviceInfo' import { devicesApi } from '../services/devices' import { QrScanner } from '../components/QrScanner' import { TrashIcon } from '../components/icons' @@ -42,7 +43,7 @@ export function DevicesPage() { const handlePairCodeScanned = async (token: string) => { try { - await devicesApi.claim(token, 'Paired device', Platform.Web) + await devicesApi.claim(token, deviceInfo.deviceName, Platform.Web) } finally { setShowScanner(false) await loadDevices() @@ -154,10 +155,16 @@ export function DevicesPage() {
{device.name} {platformLabel(device.platform)} + {device.isCurrent && {t('devices.thisDevice')}}
{t('devices.lastSeen', { date: new Date(device.lastSeenAt).toLocaleString() })}
- diff --git a/src/Briefcase.React/src/pages/SettingsPage.tsx b/src/Briefcase.React/src/pages/SettingsPage.tsx index db59aff..d1f087e 100644 --- a/src/Briefcase.React/src/pages/SettingsPage.tsx +++ b/src/Briefcase.React/src/pages/SettingsPage.tsx @@ -16,6 +16,9 @@ export function SettingsPage() { const navigate = useNavigate() const [devices, setDevices] = useState(null) + const [isSigningOutOthers, setIsSigningOutOthers] = useState(false) + const [devicesMessage, setDevicesMessage] = useState(null) + const [devicesSuccess, setDevicesSuccess] = useState(false) // Language const [languageMessage, setLanguageMessage] = useState(null) @@ -68,6 +71,22 @@ export function SettingsPage() { setUnlocked(e2eeService.isUnlocked) } + const handleSignOutOthers = async () => { + setIsSigningOutOthers(true) + setDevicesMessage(null) + try { + const result = await devicesApi.signOutOthers() + setDevices(await devicesApi.list()) + setDevicesMessage(t('settings.signOutOthersDone', { count: result.removedCount })) + setDevicesSuccess(true) + } catch (err) { + setDevicesMessage(t('settings.signOutOthersFailed', { error: err instanceof Error ? err.message : String(err) })) + setDevicesSuccess(false) + } finally { + setIsSigningOutOthers(false) + } + } + const handleChangePassword = async (e: React.FormEvent) => { e.preventDefault() setPasswordMessage(null) @@ -236,12 +255,22 @@ export function SettingsPage() {
{d.name} {platformLabel(d.platform)} + {d.isCurrent && {t('settings.thisDevice')}}
{t('settings.lastSeen', { date: new Date(d.lastSeenAt).toLocaleString() })} ))} )} + {devices !== null && devices.length > 1 && ( + <> +

{t('settings.signOutOthersHint')}

+ + + )} + {devicesMessage &&
{devicesMessage}
} {/* ── Language ── */} diff --git a/src/Briefcase.React/src/realtime/messageStream.ts b/src/Briefcase.React/src/realtime/messageStream.ts index cbeb2d5..14e8efe 100644 --- a/src/Briefcase.React/src/realtime/messageStream.ts +++ b/src/Briefcase.React/src/realtime/messageStream.ts @@ -4,6 +4,7 @@ import type { Message } from '../types' type MessageHandler = (message: Message) => void type RemovedHandler = (id: string) => void +type VoidHandler = () => void /** * Single authenticated SignalR connection to /hubs/messages that fans out @@ -16,6 +17,7 @@ class MessageStreamService { private readonly created = new Set() private readonly updated = new Set() private readonly removed = new Set() + private readonly sessionRevoked = new Set() onCreated(handler: MessageHandler): () => void { this.created.add(handler) @@ -29,6 +31,10 @@ class MessageStreamService { this.removed.add(handler) return () => this.removed.delete(handler) } + onSessionRevoked(handler: VoidHandler): () => void { + this.sessionRevoked.add(handler) + return () => this.sessionRevoked.delete(handler) + } async start(): Promise { if (this.connection) return @@ -40,6 +46,7 @@ class MessageStreamService { connection.on('MessageRestored', (m: Message) => this.created.forEach((h) => h(m))) connection.on('MessageTrashed', (p: { id: string }) => this.removed.forEach((h) => h(p.id))) connection.on('MessageDeleted', (p: { id: string }) => this.removed.forEach((h) => h(p.id))) + connection.on('SessionRevoked', () => this.sessionRevoked.forEach((h) => h())) this.starting = connection .start() diff --git a/src/Briefcase.React/src/services/devices.ts b/src/Briefcase.React/src/services/devices.ts index 473b9bf..f45baf5 100644 --- a/src/Briefcase.React/src/services/devices.ts +++ b/src/Briefcase.React/src/services/devices.ts @@ -1,6 +1,7 @@ import { HubConnectionBuilder } from '@microsoft/signalr' import { api } from '../lib/apiClient' import { apiUrl } from '../lib/config' +import { deviceInfo } from '../auth/deviceInfo' import { type Device, type LoginCodeResponse, @@ -21,16 +22,29 @@ export const devicesApi = { return api.del(`api/devices/${id}`) }, + signOutOthers(): Promise<{ removedCount: number }> { + return api.post<{ removedCount: number }>('api/devices/sign-out-others') + }, + async generatePairCode(): Promise { return api.post('api/devices/pair-code') }, claim(token: string, deviceName: string, platform: number): Promise { - return api.post('api/devices/claim', { token, deviceName, platform }) + return api.post('api/devices/claim', { + token, + deviceName, + platform, + installationId: deviceInfo.installationId, + }) }, generateLoginCode(deviceName: string, platform: string): Promise { - return api.post('api/devices/login-code', { deviceName, platform }) + return api.post('api/devices/login-code', { + deviceName, + platform, + installationId: deviceInfo.installationId, + }) }, pollLoginCode(code: string): Promise { diff --git a/src/Briefcase.React/src/types/index.ts b/src/Briefcase.React/src/types/index.ts index 9871fe3..a394553 100644 --- a/src/Briefcase.React/src/types/index.ts +++ b/src/Briefcase.React/src/types/index.ts @@ -46,6 +46,7 @@ export interface Device { platform: Platform lastSeenAt: string createdAt: string + isCurrent: boolean } export interface AuthResponse { diff --git a/tests/Briefcase.IntegrationTests/DeviceSessionTests.cs b/tests/Briefcase.IntegrationTests/DeviceSessionTests.cs new file mode 100644 index 0000000..16b49f3 --- /dev/null +++ b/tests/Briefcase.IntegrationTests/DeviceSessionTests.cs @@ -0,0 +1,143 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Briefcase.ApiService.Models; + +namespace Briefcase.IntegrationTests; + +[TestClass] +[DoNotParallelize] +public sealed class DeviceSessionTests +{ + private static ApiWebApplicationFactory _factory = null!; + private static HttpClient _client = null!; + + [ClassInitialize] + public static void ClassInit(TestContext _) + { + _factory = new ApiWebApplicationFactory(); + _factory.EnsureDatabaseCreated(); + _client = _factory.CreateClient(); + } + + [ClassCleanup] + public static async Task ClassCleanup() + { + _client.Dispose(); + await _factory.DisposeAsync(); + } + + private static HttpRequestMessage Authed(HttpMethod method, string url, string accessToken) + { + var request = new HttpRequestMessage(method, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + return request; + } + + /// Registers a user, then signs a second device in, returning both sessions. + private static async Task<(AuthResponse First, AuthResponse Second)> CreateTwoDeviceSessionsAsync() + { + var email = $"devices_{Guid.NewGuid():N}@test.com"; + + var registerResponse = await _client.PostAsJsonAsync("/api/auth/register", new RegisterRequest( + email, "Password123!", "User", "Laptop", "Windows", $"install-{Guid.NewGuid():N}")); + registerResponse.EnsureSuccessStatusCode(); + var first = (await registerResponse.Content.ReadFromJsonAsync())!; + + var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new LoginRequest( + email, "Password123!", "Phone", "Android", $"install-{Guid.NewGuid():N}")); + loginResponse.EnsureSuccessStatusCode(); + var second = (await loginResponse.Content.ReadFromJsonAsync())!; + + return (first, second); + } + + private static async Task> ListDevicesAsync(string accessToken) + { + var response = await _client.SendAsync(Authed(HttpMethod.Get, "/api/devices", accessToken)); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync>())!; + } + + [TestMethod] + public async Task GetDevices_MarksTheCallingDeviceAsCurrent() + { + var (first, _) = await CreateTwoDeviceSessionsAsync(); + + var devices = await ListDevicesAsync(first.AccessToken); + + Assert.AreEqual(2, devices.Count); + Assert.AreEqual(1, devices.Count(d => d.IsCurrent)); + Assert.AreEqual("Laptop", devices.Single(d => d.IsCurrent).Name); + } + + [TestMethod] + public async Task RemoveDevice_RevokesThatDevicesAccessToken() + { + var (first, second) = await CreateTwoDeviceSessionsAsync(); + var phone = (await ListDevicesAsync(first.AccessToken)).Single(d => d.Name == "Phone"); + + var remove = await _client.SendAsync(Authed(HttpMethod.Delete, $"/api/devices/{phone.Id}", first.AccessToken)); + Assert.AreEqual(HttpStatusCode.NoContent, remove.StatusCode); + + var revoked = await _client.SendAsync(Authed(HttpMethod.Get, "/api/devices", second.AccessToken)); + Assert.AreEqual(HttpStatusCode.Unauthorized, revoked.StatusCode); + + var remaining = await _client.SendAsync(Authed(HttpMethod.Get, "/api/devices", first.AccessToken)); + Assert.AreEqual(HttpStatusCode.OK, remaining.StatusCode); + } + + [TestMethod] + public async Task RemoveDevice_RevokesThatDevicesRefreshToken() + { + var (first, second) = await CreateTwoDeviceSessionsAsync(); + var phone = (await ListDevicesAsync(first.AccessToken)).Single(d => d.Name == "Phone"); + + await _client.SendAsync(Authed(HttpMethod.Delete, $"/api/devices/{phone.Id}", first.AccessToken)); + + var refreshRevoked = await _client.PostAsJsonAsync("/api/auth/refresh", new RefreshRequest(second.RefreshToken)); + Assert.AreEqual(HttpStatusCode.Unauthorized, refreshRevoked.StatusCode); + + var refreshOk = await _client.PostAsJsonAsync("/api/auth/refresh", new RefreshRequest(first.RefreshToken)); + Assert.AreEqual(HttpStatusCode.OK, refreshOk.StatusCode); + } + + [TestMethod] + public async Task Login_SameInstallationId_ReusesTheSameDeviceRow() + { + var email = $"reuse_{Guid.NewGuid():N}@test.com"; + var installationId = $"install-{Guid.NewGuid():N}"; + + var register = await _client.PostAsJsonAsync("/api/auth/register", new RegisterRequest( + email, "Password123!", "User", "Laptop", "Windows", installationId)); + register.EnsureSuccessStatusCode(); + + var login = await _client.PostAsJsonAsync("/api/auth/login", new LoginRequest( + email, "Password123!", "Laptop renamed", "Windows", installationId)); + login.EnsureSuccessStatusCode(); + var session = (await login.Content.ReadFromJsonAsync())!; + + var devices = await ListDevicesAsync(session.AccessToken); + + Assert.AreEqual(1, devices.Count); + Assert.AreEqual("Laptop renamed", devices[0].Name); + } + + [TestMethod] + public async Task SignOutOthers_KeepsCallerAndRevokesTheRest() + { + var (first, second) = await CreateTwoDeviceSessionsAsync(); + + var response = await _client.SendAsync(Authed(HttpMethod.Post, "/api/devices/sign-out-others", first.AccessToken)); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.AreEqual(1, result!.RemovedCount); + + var revoked = await _client.SendAsync(Authed(HttpMethod.Get, "/api/devices", second.AccessToken)); + Assert.AreEqual(HttpStatusCode.Unauthorized, revoked.StatusCode); + + var devices = await ListDevicesAsync(first.AccessToken); + Assert.AreEqual(1, devices.Count); + Assert.IsTrue(devices[0].IsCurrent); + } +}