diff --git a/services/SonicRelay.Api/Endpoints/DiscordActivityEndpoints.cs b/services/SonicRelay.Api/Endpoints/DiscordActivityEndpoints.cs index 39ff0f1..0bf888d 100644 --- a/services/SonicRelay.Api/Endpoints/DiscordActivityEndpoints.cs +++ b/services/SonicRelay.Api/Endpoints/DiscordActivityEndpoints.cs @@ -71,15 +71,23 @@ public static IEndpointRouteBuilder MapDiscordActivityEndpoints(this IEndpointRo if (identity is null || !await ActivityPresenceService.IsAuthorizedAsync(db, discord, identity, time.GetUtcNow(), ct)) return Results.Unauthorized(); var token = RelayCapability.NewToken(); var grant = RelayCapability.Create("grant", token, time.GetUtcNow(), 60); - CopyContext(identity, grant); db.LaunchCapabilities.Add(grant); await db.SaveChangesAsync(ct); + CopyContext(identity, grant); + // The grant can only be redeemed with the bearer that created it. + grant.Code = identity.Id.ToString("N"); + db.LaunchCapabilities.Add(grant); await db.SaveChangesAsync(ct); return Results.Ok(new { grant = token, grant.ExpiresAt }); }); - group.MapPost("/viewer-grants/redeem", async (GrantRequest request, AppDbContext db, IDiscordActivityValidator discord, + group.MapPost("/viewer-grants/redeem", async (GrantRequest request, HttpContext context, AppDbContext db, IDiscordActivityValidator discord, IParticipantAdmissionLock admissionLock, TurnCredentialService turn, TimeProvider time, CancellationToken ct) => { var now = time.GetUtcNow(); var grant = await RelayCapability.FindAsync(db, request.Grant, "grant", now, ct); if (grant?.SessionId is not { } sessionId) return Results.Unauthorized(); + var header = context.Request.Headers.Authorization.ToString(); + var identity = await RelayCapability.FindAsync(db, header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? header[7..] : "", "identity", now, ct); + if (identity is null || grant.Code != identity.Id.ToString("N") || grant.SessionId != identity.SessionId + || grant.InstanceId != identity.InstanceId || grant.UserId != identity.UserId) + return Results.Unauthorized(); using var admission = await admissionLock.AcquireAsync(sessionId, Guid.Empty, ct); now = time.GetUtcNow(); if (!await ActivityPresenceService.IsAuthorizedAsync(db, discord, grant, now, ct)) return Results.Unauthorized(); diff --git a/services/SonicRelay.Api/Endpoints/DiscordActivityLaunchIntentEndpoints.cs b/services/SonicRelay.Api/Endpoints/DiscordActivityLaunchIntentEndpoints.cs index 018b521..9be7835 100644 --- a/services/SonicRelay.Api/Endpoints/DiscordActivityLaunchIntentEndpoints.cs +++ b/services/SonicRelay.Api/Endpoints/DiscordActivityLaunchIntentEndpoints.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Options; using SonicRelay.Api.Services; using SonicRelay.Application.Abstractions; +using SonicRelay.Domain.LaunchIntents; using SonicRelay.Domain.Sessions; using SonicRelay.Infrastructure.Persistence; @@ -15,6 +16,8 @@ public static class DiscordActivityLaunchIntentEndpoints { public sealed record ActivityRequest(string Code, string GuildId, string ChannelId, string RequestedByUserId, int TtlSeconds = 120); + public sealed record ReadyActivityRequest(string GuildId, string ChannelId, + string RequestedByUserId, int TtlSeconds = 120); public sealed record TokenRequest(string Token); public sealed record BindRequest(Guid SessionId); @@ -39,6 +42,42 @@ public static IEndpointRouteBuilder MapDiscordActivityLaunchIntentEndpoints(this await db.SaveChangesAsync(ct); return Results.Ok(new { intent.Id, intent.ExpiresAt }); }); + bot.MapPost("/{intentId:guid}/activity", async (Guid intentId, ReadyActivityRequest request, + AppDbContext db, TimeProvider time, CancellationToken ct) => + { + if (!ValidContext(request.GuildId, request.ChannelId, request.RequestedByUserId) + || request.TtlSeconds is < 30 or > 900) return Results.BadRequest(); + var source = await db.ShareLaunchIntents.AsNoTracking().SingleOrDefaultAsync(x => x.Id == intentId, ct); + if (source?.Status != ShareLaunchIntentStatuses.Ready || source.SessionId is not { } sessionId + || source.GuildId != request.GuildId || source.ChannelId != request.ChannelId + || !await db.StreamSessions.AnyAsync(x => x.Id == sessionId && x.Mode == SessionModes.ScreenShare + && x.Status != SessionStatuses.Ended && x.Status != SessionStatuses.Expired, ct)) + return Results.NotFound(); + var intent = RelayCapability.Create("activity", RelayCapability.NewToken(), time.GetUtcNow(), request.TtlSeconds); + intent.SessionId = sessionId; + intent.GuildId = request.GuildId; + intent.ChannelId = request.ChannelId; + intent.UserId = request.RequestedByUserId; + db.LaunchCapabilities.Add(intent); + await db.SaveChangesAsync(ct); + return Results.Ok(new { intent.Id, intent.ExpiresAt }); + }); + bot.MapPost("/{intentId:guid}/watch", async (Guid intentId, ReadyActivityRequest request, + LaunchIntentService service, IOptions options, CancellationToken ct) => + { + if (!ValidContext(request.GuildId, request.ChannelId, request.RequestedByUserId)) return Results.BadRequest(); + try + { + var (token, expiresAt) = await service.CreateWatchCapabilityForReadyIntentAsync( + intentId, request.GuildId, request.ChannelId, request.TtlSeconds, ct); + return Results.Ok(new { launchUrl = $"{options.Value.PublicBaseUrl.TrimEnd('/')}/open/watch/{token}", expiresAt }); + } + catch (LaunchIntentException error) when (error.Code == "session_full") + { + return Results.Conflict(new { code = error.Code }); + } + catch (LaunchIntentException) { return Results.NotFound(); } + }); var device = app.MapGroup("/api/launch-intents").WithTags("Launch intents") .RequireAuthorization("DeviceAuthenticated"); diff --git a/services/SonicRelay.Api/Services/LaunchIntentService.cs b/services/SonicRelay.Api/Services/LaunchIntentService.cs index 61788b3..46eb990 100644 --- a/services/SonicRelay.Api/Services/LaunchIntentService.cs +++ b/services/SonicRelay.Api/Services/LaunchIntentService.cs @@ -166,6 +166,17 @@ public async Task MarkPublishedAsync(Guid intentId, string? messageId, Cancellat return token.Value; } + public async Task<(string Token, DateTimeOffset ExpiresAt)> CreateWatchCapabilityForReadyIntentAsync( + Guid intentId, string guildId, string channelId, int? ttlSeconds, CancellationToken ct) + { + var intent = await db.ShareLaunchIntents.AsNoTracking().SingleOrDefaultAsync(x => x.Id == intentId, ct); + if (intent?.Status != ShareLaunchIntentStatuses.Ready || intent.SessionId is not { } sessionId + || intent.GuildId != guildId || intent.ChannelId != channelId) + throw new LaunchIntentException("session_unavailable"); + return await CreateWatchCapabilityForSessionAsync(sessionId, ttlSeconds, ct) + ?? throw new LaunchIntentException("session_unavailable"); + } + public async Task ResolveWatchCapabilityAsync(string? token, CancellationToken ct) { if (string.IsNullOrWhiteSpace(token) || token.Length > 128) diff --git a/tests/SonicRelay.Api.IntegrationTests/LaunchIntentActivityTests.cs b/tests/SonicRelay.Api.IntegrationTests/LaunchIntentActivityTests.cs index 399b8ac..b98c7bc 100644 --- a/tests/SonicRelay.Api.IntegrationTests/LaunchIntentActivityTests.cs +++ b/tests/SonicRelay.Api.IntegrationTests/LaunchIntentActivityTests.cs @@ -39,7 +39,7 @@ private sealed class Discord : IDiscordActivityValidator public Task IsPresentAsync(DiscordActivityIdentity identity, CancellationToken ct) => Task.FromResult(Present); } private static SonicRelayApiFactory Factory() => new(new Dictionary - { ["LaunchIntents:ServiceToken"] = "bot-secret", ["RelayLaunch:PublicBaseUrl"] = "https://relay.example" }); + { ["LaunchIntents:ServiceToken"] = "bot-secret", ["LaunchIntents:PublicBaseUrl"] = "https://relay.example", ["RelayLaunch:PublicBaseUrl"] = "https://relay.example" }); private static HttpClient Bot(Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory factory) { var bot = factory.CreateClient(); bot.DefaultRequestHeaders.Authorization = new("Bearer", "bot-secret"); return bot; @@ -91,6 +91,12 @@ public async Task Bot_is_restricted_and_desktop_redemption_is_single_use_and_own Assert.Equal(JsonValueKind.String, defaultWatch.GetProperty("watchLaunchUrl").ValueKind); var readyItems = await Json(await bot.GetAsync("/api/launch-intents/pending")); Assert.Equal("session_ready", readyItems[0].GetProperty("status").GetString()); + var click = await Json(await bot.PostAsJsonAsync($"/api/launch-intents/{id}/activity", new { guildId = "1", channelId = "2", requestedByUserId = "4", ttlSeconds = 120 })); + Assert.NotEqual(Guid.Empty, click.GetProperty("id").GetGuid()); + Assert.Equal(HttpStatusCode.NotFound, (await bot.PostAsJsonAsync($"/api/launch-intents/{id}/activity", new { guildId = "9", channelId = "2", requestedByUserId = "4" })).StatusCode); + var personal = await Json(await bot.PostAsJsonAsync($"/api/launch-intents/{id}/watch", new { guildId = "1", channelId = "2", requestedByUserId = "4", ttlSeconds = 120 })); + Assert.StartsWith("https://relay.example/open/watch/", personal.GetProperty("launchUrl").GetString()); + Assert.Equal(HttpStatusCode.NotFound, (await bot.PostAsJsonAsync($"/api/launch-intents/{id}/watch", new { guildId = "9", channelId = "2", requestedByUserId = "4" })).StatusCode); } [Fact] @@ -108,6 +114,12 @@ public async Task Activity_requires_invoker_bootstrap_and_enforces_single_use_gr viewer.DefaultRequestHeaders.Authorization = new("Bearer", identity.GetProperty("accessToken").GetString()); Assert.Equal(HttpStatusCode.Unauthorized, (await viewer.PostAsJsonAsync("/api/sessions/", new { })).StatusCode); var grant = await Json(await viewer.PostAsync("/api/discord/activity/viewer-grants", null)); + using var unauthenticated = factory.CreateClient(); + Assert.Equal(HttpStatusCode.Unauthorized, (await unauthenticated.PostAsJsonAsync("/api/discord/activity/viewer-grants/redeem", new { grant = grant.GetProperty("grant").GetString() })).StatusCode); + var secondIdentity = await Json(await viewer.PostAsJsonAsync("/api/discord/activity/authorize", new { code = "valid", instanceId = "instance" })); + viewer.DefaultRequestHeaders.Authorization = new("Bearer", secondIdentity.GetProperty("accessToken").GetString()); + Assert.Equal(HttpStatusCode.Unauthorized, (await viewer.PostAsJsonAsync("/api/discord/activity/viewer-grants/redeem", new { grant = grant.GetProperty("grant").GetString() })).StatusCode); + viewer.DefaultRequestHeaders.Authorization = new("Bearer", identity.GetProperty("accessToken").GetString()); var admitted = await Json(await viewer.PostAsJsonAsync("/api/discord/activity/viewer-grants/redeem", new { grant = grant.GetProperty("grant").GetString() })); Assert.Equal(screen.GetProperty("id").GetGuid(), admitted.GetProperty("sessionId").GetGuid()); Assert.False(admitted.TryGetProperty("accessToken", out _)); Assert.True(admitted.TryGetProperty("signalingToken", out _));