using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using PrivaPub.Api.Mastodon.Auth; using PrivaPub.Services; using PrivaPub.Tests.Support; using PrivaPub.Tests.Support.Host; using System.Net; using System.Security.Claims; using System.Text; namespace PrivaPub.Tests.Http { // The first-party client's one sign-in: the /clientapi JWT exchanged for one persona's Mastodon token (PersonaExchange). [Trait("Category", "Integration")] public sealed class PersonaExchangeTests : IAsyncLifetime { const string Grant = "urn:ietf:params:oauth:grant-type:token-exchange"; const string FirstParty = "decepub"; PrivaPubHost _host; public async ValueTask InitializeAsync() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); _host = await PrivaPubHost.Shared(); } public ValueTask DisposeAsync() => ValueTask.CompletedTask; async Task Exchange(string jwt, string avatarId, string clientId = FirstParty, string clientSecret = default, string subjectTokenType = PersonaExchange.SubjectTokenType) { var fields = new List<(string, string)> { ("grant_type", Grant), ("client_id", clientId), ("subject_token", jwt), ("subject_token_type", subjectTokenType), (PersonaExchange.AvatarParameter, avatarId), ("scope", "read write follow") }; if (clientSecret != default) fields.Add(("client_secret", clientSecret)); using var client = _host.Client(); return await client.Form("/oauth/token", fields.Where(f => f.Item2 != default).ToArray()); } async Task Token(Persona persona) { var response = await Exchange(persona.Root.Jwt, persona.Id); Assert.Equal(HttpStatusCode.OK, response.StatusCode); return (await response.JsonBody())["access_token"]!.GetValue(); } static async Task AssertRefused(HttpResponseMessage response, string error = "invalid_grant") { Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal(error, (await response.JsonBody())["error"]?.GetValue()); } async Task Me(string token) { using var api = _host.As(token); return (await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)).StatusCode; } static string Jwt(string rootId, string key, DateTime expires) => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor { Issuer = PrivaPubHost.Base, Audience = PrivaPubHost.Base, Subject = new ClaimsIdentity(new[] { new Claim(ClaimTypes.UserData, rootId) }), NotBefore = expires.AddHours(-2), IssuedAt = expires.AddHours(-2), Expires = expires, SigningCredentials = new(new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)), SecurityAlgorithms.HmacSha512) }); [Fact] public async Task The_root_jwt_becomes_one_personas_token_that_never_names_the_root() { var persona = await _host.Persona(await _host.SignUp(), "exchange"); var root = persona.Root; var response = await Exchange(root.Jwt, persona.Id); var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.DoesNotContain(root.Id, body); Assert.DoesNotContain(root.UserName, body); var token = (await response.JsonBody())["access_token"]!.GetValue(); using var api = _host.As(token); var account = await api.GetStringAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken); Assert.Contains($"\"id\":\"{persona.Id}\"", account); Assert.DoesNotContain(root.Id, account); foreach (var entry in await ClientApi.StoredTokens(FirstParty)) { var payload = entry.Contains("payload") && entry["payload"].IsString ? ClientApi.JwtPayload(entry["payload"].AsString) : string.Empty; Assert.DoesNotContain(root.Id, entry.ToString() + payload); Assert.DoesNotContain(root.UserName, entry.ToString() + payload); } } [Fact] public async Task Each_persona_of_the_root_gets_its_own_token() { var root = await _host.SignUp(); var first = await _host.Persona(root, "first"); var second = await _host.Persona(root, "second"); using var firstApi = _host.As(await Token(first)); using var secondApi = _host.As(await Token(second)); Assert.Contains($"\"id\":\"{first.Id}\"", await firstApi.GetStringAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)); Assert.Contains($"\"id\":\"{second.Id}\"", await secondApi.GetStringAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)); } [Fact] public async Task Another_roots_persona_is_refused() { var mine = await _host.SignUp(); var theirs = await _host.Persona(await _host.SignUp(), "theirs"); await AssertRefused(await Exchange(mine.Jwt, theirs.Id)); await AssertRefused(await Exchange(mine.Jwt, "000000000000000000000000")); await AssertRefused(await Exchange(mine.Jwt, "not an id")); } [Fact] public async Task A_token_that_is_not_a_valid_root_jwt_is_refused() { var persona = await _host.Persona(await _host.SignUp(), "forged"); var key = _host.Services.GetRequiredService()["AppConfiguration:Jwt:Key"]!; await AssertRefused(await Exchange("garbage", persona.Id)); await AssertRefused(await Exchange(Jwt(persona.Root.Id, key, DateTime.UtcNow.AddMinutes(-10)), persona.Id)); await AssertRefused(await Exchange(Jwt(persona.Root.Id, new string('k', 64), DateTime.UtcNow.AddHours(1)), persona.Id)); } [Fact] public async Task Ended_sessions_refuse_the_jwt_and_revoke_the_exchanged_tokens() { var persona = await _host.Persona(await _host.SignUp(), "ended"); var token = await Token(persona); Assert.Equal(HttpStatusCode.OK, await Me(token)); using (var scope = _host.Services.CreateScope()) await scope.ServiceProvider.GetRequiredService().Revoke(persona.Root.Id, TestContext.Current.CancellationToken); await AssertRefused(await Exchange(persona.Root.Jwt, persona.Id)); Assert.Equal(HttpStatusCode.Unauthorized, await Me(token)); } [Fact] public async Task A_banned_root_is_refused() { var persona = await _host.Persona(await _host.SignUp(), "banned"); await ClientApi.Ban(persona.Root.Id); try { await AssertRefused(await Exchange(persona.Root.Jwt, persona.Id)); } finally { await ClientApi.Ban(persona.Root.Id, banned: false); } } [Fact] public async Task A_revoked_token_no_longer_works() { var persona = await _host.Persona(await _host.SignUp(), "revoked"); var token = await Token(persona); using var client = _host.Client(); var revoked = await client.Form("/oauth/revoke", ("token", token), ("client_id", FirstParty)); Assert.Equal(HttpStatusCode.OK, revoked.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, await Me(token)); } [Fact] public async Task Only_the_first_party_client_may_exchange() { var persona = await _host.Persona(await _host.SignUp(), "thirdparty"); using var client = _host.Client(); var app = await client.RegisterApp(); var response = await Exchange(persona.Root.Jwt, persona.Id, app.ClientId, app.ClientSecret); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal("unauthorized_client", (await response.JsonBody())["error"]?.GetValue()); } [Fact] public async Task A_mastodon_token_is_not_a_subject_token() { var persona = await _host.Persona(await _host.SignUp(), "mastodon"); var token = await Token(persona); await AssertRefused(await Exchange(token, persona.Id, subjectTokenType: "urn:ietf:params:oauth:token-type:access_token"), "invalid_request"); await AssertRefused(await Exchange(token, persona.Id)); } [Fact] public async Task Missing_parameters_are_a_client_error() { var persona = await _host.Persona(await _host.SignUp(), "missing"); await AssertRefused(await Exchange(persona.Root.Jwt, default)); await AssertRefused(await Exchange(default, persona.Id), "invalid_request"); await AssertRefused(await Exchange(persona.Root.Jwt, persona.Id, subjectTokenType: default), "invalid_request"); } } }