Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 48 additions & 26 deletions src/Briefcase.ApiService/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ public async Task<IActionResult> 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));
Expand All @@ -60,10 +60,10 @@ public async Task<IActionResult> 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));
Expand All @@ -82,6 +82,7 @@ public async Task<IActionResult> 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)
Expand All @@ -90,8 +91,11 @@ public async Task<IActionResult> 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));
Expand Down Expand Up @@ -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}" });
Expand All @@ -163,7 +168,8 @@ public IActionResult OAuthRedirect(
redirect_uri,
client_redirect_uri,
device_name,
device_platform);
device_platform,
installation_id);
return Redirect(authorizationUrl);
}

Expand Down Expand Up @@ -255,10 +261,10 @@ public async Task<IActionResult> 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);

Expand All @@ -276,42 +282,58 @@ public async Task<IActionResult> 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<Device?> 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<Platform>(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<string> CreateRefreshTokenAsync(Guid userId)
private async Task<string> 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),
Expand Down
76 changes: 64 additions & 12 deletions src/Briefcase.ApiService/Controllers/DevicesController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,29 +16,37 @@ namespace Briefcase.ApiService.Controllers;
[ApiController]
[Authorize]
[Route("api/devices")]
public class DevicesController(AppDbContext db, TokenService tokenService, IHubContext<MessageHub> hub) : ControllerBase
public class DevicesController(
AppDbContext db,
TokenService tokenService,
IHubContext<MessageHub> 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<IActionResult> 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<IActionResult> RemoveDevice(Guid id)
{
Expand All @@ -49,11 +57,49 @@ public async Task<IActionResult> 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<IActionResult> 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()
Expand Down Expand Up @@ -82,6 +128,7 @@ public async Task<IActionResult> ClaimDevice([FromBody] ClaimDeviceRequest reque
{
Id = Guid.NewGuid(),
UserId = userId,
InstallationId = request.InstallationId,
Name = request.DeviceName,
Platform = request.Platform,
LastSeenAt = DateTime.UtcNow,
Expand All @@ -91,7 +138,7 @@ public async Task<IActionResult> 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));
}

Expand All @@ -118,6 +165,7 @@ public async Task<IActionResult> CreateLoginCode([FromBody] CreateLoginCodeReque
Code = code,
DeviceName = request.DeviceName,
Platform = platform,
InstallationId = request.InstallationId,
ExpiresAt = DateTime.UtcNow.AddMinutes(LoginCodeTtlMinutes),
CreatedAt = DateTime.UtcNow,
};
Expand Down Expand Up @@ -153,18 +201,21 @@ public async Task<IActionResult> 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));
Expand Down Expand Up @@ -197,12 +248,13 @@ await hub.Clients.Group($"login-code:{normalized}")
return Ok(new ApproveLoginCodeResponse(entry.DeviceName, entry.Platform));
}

private async Task<string> CreateRefreshTokenAsync(Guid userId)
private async Task<string> 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),
Expand Down
12 changes: 12 additions & 0 deletions src/Briefcase.ApiService/Hubs/MessageHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,18 @@ 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()
{
var userId = GetUserId();
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();
}

Expand All @@ -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);
}

Expand Down Expand Up @@ -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;
}
6 changes: 4 additions & 2 deletions src/Briefcase.ApiService/Models/AuthModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading