One sign-in for decePubClient: the root JWT exchanged for a persona's token
The first-party client signs in on /clientapi and exchanges that JWT on /oauth/token (RFC 8693, subject_token_type jwt, avatar_id) for one persona's Mastodon token. Only the seeded public application `decepub` holds the grant; RootJwtSubjectToken validates the JWT through RootJwt, which JwtBearer now shares (signature, lifetime, ban, deletion, session stamp). The issued token names the avatar, never the root. Owner decision recorded in ROADMAP; it supersedes "moving decePubClient onto the Mastodon API is out of scope". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
c7e8760ffe
commit
2cd75176a4
10 files changed
+432
-30
No files matched your search
@@ -0,0 +1,218 @@
|
||||
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<HttpResponseMessage> 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<string> 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<string>();
|
||||
}
|
||||
|
||||
static async Task AssertRefused(HttpResponseMessage response, string error = "invalid_grant")
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal(error, (await response.JsonBody())["error"]?.GetValue<string>());
|
||||
}
|
||||
|
||||
async Task<HttpStatusCode> 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<string>();
|
||||
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<IConfiguration>()["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<IRootSessions>().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<string>());
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user