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
12 changes: 10 additions & 2 deletions services/SonicRelay.Api/Endpoints/DiscordActivityEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

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

Expand All @@ -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<LaunchIntentOptions> 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");
Expand Down
11 changes: 11 additions & 0 deletions services/SonicRelay.Api/Services/LaunchIntentService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Guid> ResolveWatchCapabilityAsync(string? token, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(token) || token.Length > 128)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ private sealed class Discord : IDiscordActivityValidator
public Task<bool> IsPresentAsync(DiscordActivityIdentity identity, CancellationToken ct) => Task.FromResult(Present);
}
private static SonicRelayApiFactory Factory() => new(new Dictionary<string, string?>
{ ["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<Program> factory)
{
var bot = factory.CreateClient(); bot.DefaultRequestHeaders.Authorization = new("Bearer", "bot-secret"); return bot;
Expand Down Expand Up @@ -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]
Expand All @@ -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 _));
Expand Down
Loading