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
@@ -1,4 +1,4 @@
|
||||
# CLAUDE.md
|
||||
# CLAUDE.md
|
||||
|
||||
Guidance for working in this repository. `docs/ROADMAP.md` holds the owner's decisions and the phased plan to full
|
||||
ActivityPub interop; read it before changing anything federation-, privacy- or API-shaped. `docs/INTEROP.md` is the
|
||||
@@ -29,8 +29,8 @@ Privacy features in the model:
|
||||
|
||||
The intended client is the Mastodon client API (Tusky, Elk, Phanpy, Ivory), where each avatar logs in as its own
|
||||
account. The private `/clientapi` covers what Mastodon can't express. `thepra/decePubClient`
|
||||
(https://decepub.thepra.dev) is the owner's own Pleroma-FE-like PWA. It still shows mock data and is not wired to the
|
||||
server.
|
||||
(https://decepub.thepra.dev) is the owner's own Pleroma-FE-like PWA. It signs in once on `/clientapi` and exchanges that
|
||||
JWT for each persona's Mastodon token (`PersonaExchange`), then uses both APIs.
|
||||
|
||||
## Route names are deliberate
|
||||
|
||||
@@ -221,6 +221,12 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
|
||||
|
||||
1. **A token is one persona.** Its subject is the avatar id; the root id lives only in the fifteen-minute `/oauth`
|
||||
cookie used while choosing the persona, and never in a token, an authorization or a response.
|
||||
- **The first-party client exchanges the root JWT for one persona's token** (RFC 8693 on `/oauth/token`,
|
||||
`subject_token_type` `urn:ietf:params:oauth:token-type:jwt`, `avatar_id`; `Api/Mastodon/Auth/PersonaExchange.cs`).
|
||||
Only the seeded public application (`ClientApi:FirstPartyClientId`, default `decepub`) holds the grant.
|
||||
`RootJwtSubjectToken` validates the JWT through `RootJwt`, the same rules as JwtBearer (signature, lifetime, ban,
|
||||
deletion, session stamp). The issued token is the same kind as the authorization code flow's, so revocation and
|
||||
`RootSessions` cover it.
|
||||
2. `/api/*` authenticates with OpenIddict validation, everything else with the old JWT (`PrivaPub` policy scheme).
|
||||
Every `/api` request re-checks that the persona's root is neither banned nor deleted (`MastodonController`).
|
||||
3. Read parameters through `Params` (query, form and JSON merged Rails-style), never MVC binding. A value type read
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,14 @@ namespace PrivaPub.Api.Mastodon.Auth
|
||||
.SetTokenEndpointUris("/oauth/token")
|
||||
.SetRevocationEndpointUris("/oauth/revoke")
|
||||
.SetConfigurationEndpointUris("/.well-known/openid-configuration", "/.well-known/oauth-authorization-server");
|
||||
options.AllowAuthorizationCodeFlow().AllowClientCredentialsFlow();
|
||||
options.AllowAuthorizationCodeFlow().AllowClientCredentialsFlow().AllowTokenExchangeFlow();
|
||||
//the only subject token accepted is the root JWT, for PersonaExchange; OpenIddict's own tokens are not exchanged
|
||||
options.Configure(server =>
|
||||
{
|
||||
server.SubjectTokenTypes.Clear();
|
||||
server.SubjectTokenTypes.Add(PersonaExchange.SubjectTokenType);
|
||||
});
|
||||
options.AddEventHandler(RootJwtSubjectToken.Descriptor);
|
||||
options.RegisterScopes(MastodonScopes.All);
|
||||
options.UseReferenceAccessTokens();
|
||||
options.SetAccessTokenLifetime(null);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server;
|
||||
|
||||
using PrivaPub.Services;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Security.Claims;
|
||||
|
||||
using static OpenIddict.Abstractions.OpenIddictConstants;
|
||||
using static OpenIddict.Server.OpenIddictServerEvents;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Auth
|
||||
{
|
||||
// One sign-in for the first-party client (decePubClient): it signs in on /clientapi, then exchanges that root JWT for one
|
||||
// persona's Mastodon token (RFC 8693, grant_type urn:ietf:params:oauth:grant-type:token-exchange, subject_token_type
|
||||
// urn:ietf:params:oauth:token-type:jwt, avatar_id). Only the seeded first-party application holds the grant permission,
|
||||
// and the issued token is the same kind the authorization code flow issues: subject the avatar, never the root.
|
||||
public static class PersonaExchange
|
||||
{
|
||||
public const string SubjectTokenType = "urn:ietf:params:oauth:token-type:jwt";
|
||||
public const string AvatarParameter = "avatar_id";
|
||||
//the root the subject token belongs to; it lives on the subject token's principal only, which is never stored or issued
|
||||
public const string RootClaim = "privapub_root";
|
||||
|
||||
public static string FirstPartyClientId(IConfiguration configuration) =>
|
||||
configuration["ClientApi:FirstPartyClientId"] is { Length: > 0 } clientId ? clientId : "decepub";
|
||||
|
||||
//the first-party application: public (a browser keeps no secret), token exchange only, no redirect
|
||||
public static async Task EnsureFirstPartyClient(IServiceProvider services, CancellationToken token)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var applications = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();
|
||||
var clientId = FirstPartyClientId(scope.ServiceProvider.GetRequiredService<IConfiguration>());
|
||||
var descriptor = new OpenIddictApplicationDescriptor
|
||||
{
|
||||
ClientId = clientId,
|
||||
ClientType = ClientTypes.Public,
|
||||
ConsentType = ConsentTypes.Implicit,
|
||||
DisplayName = "decePubClient",
|
||||
Permissions =
|
||||
{
|
||||
Permissions.Endpoints.Token,
|
||||
Permissions.Endpoints.Revocation,
|
||||
Permissions.GrantTypes.TokenExchange
|
||||
}
|
||||
};
|
||||
foreach (var scopeName in new[] { "read", "write", "follow" })
|
||||
descriptor.Permissions.Add(Permissions.Prefixes.Scope + scopeName);
|
||||
|
||||
var existing = await applications.FindByClientIdAsync(clientId, token);
|
||||
if (existing == default)
|
||||
{
|
||||
await applications.CreateAsync(descriptor, token);
|
||||
return;
|
||||
}
|
||||
await applications.PopulateAsync(existing, descriptor, token);
|
||||
await applications.UpdateAsync(existing, token);
|
||||
}
|
||||
}
|
||||
|
||||
// Validates a root JWT given as a token exchange subject token, before OpenIddict tries it as one of its own tokens.
|
||||
// The principal it builds names the root under PersonaExchange.RootClaim for TokenController, which signs in a fresh
|
||||
// identity for the avatar.
|
||||
public sealed class RootJwtSubjectToken : IOpenIddictServerHandler<ValidateTokenContext>
|
||||
{
|
||||
public static OpenIddictServerHandlerDescriptor Descriptor { get; } = OpenIddictServerHandlerDescriptor.CreateBuilder<ValidateTokenContext>()
|
||||
.UseScopedHandler<RootJwtSubjectToken>()
|
||||
.SetOrder(OpenIddictServerHandlers.Protection.ValidateIdentityModelToken.Descriptor.Order - 500)
|
||||
.SetType(OpenIddictServerHandlerType.Custom)
|
||||
.Build();
|
||||
|
||||
readonly IConfiguration _configuration;
|
||||
readonly DbEntities _dbEntities;
|
||||
|
||||
public RootJwtSubjectToken(IConfiguration configuration, DbEntities dbEntities)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_dbEntities = dbEntities;
|
||||
}
|
||||
|
||||
public async ValueTask HandleAsync(ValidateTokenContext context)
|
||||
{
|
||||
if (context.ValidTokenTypes.Count != 1 || !context.ValidTokenTypes.Contains(PersonaExchange.SubjectTokenType))
|
||||
return;
|
||||
|
||||
var root = await RootJwt.Root(context.Token, _configuration, _dbEntities, context.Transaction.CancellationToken);
|
||||
if (root == default)
|
||||
{
|
||||
context.Reject(Errors.InvalidGrant, "The session token is not valid.");
|
||||
return;
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role);
|
||||
identity.SetClaim(PersonaExchange.RootClaim, root.ID);
|
||||
var principal = new ClaimsPrincipal(identity)
|
||||
.SetTokenType(PersonaExchange.SubjectTokenType)
|
||||
.SetCreationDate(DateTimeOffset.UtcNow);
|
||||
if (context.ValidPresenters.Count > 0)
|
||||
principal.SetPresenters(context.ValidPresenters);
|
||||
context.Principal = principal;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.AspNetCore;
|
||||
using Microsoft.AspNetCore;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
@@ -48,9 +48,33 @@ namespace PrivaPub.Api.Mastodon.Auth
|
||||
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
if (request.IsTokenExchangeGrantType())
|
||||
return await Persona(request, token);
|
||||
|
||||
return Refuse(Errors.UnsupportedGrantType, "The grant type is not supported.");
|
||||
}
|
||||
|
||||
//PersonaExchange: the root JWT, validated by RootJwtSubjectToken, for a token of one of the root's personas
|
||||
async Task<IActionResult> Persona(OpenIddictRequest request, CancellationToken token)
|
||||
{
|
||||
var subject = (await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)).Principal;
|
||||
var rootId = subject?.GetClaim(PersonaExchange.RootClaim);
|
||||
var avatarId = (string)request[PersonaExchange.AvatarParameter];
|
||||
if (string.IsNullOrEmpty(rootId) || string.IsNullOrEmpty(avatarId))
|
||||
return Refuse(Errors.InvalidGrant, "The persona can no longer be used.");
|
||||
var owns = await _dbEntities.RootToAvatars.Match(r => r.RootId == rootId && r.AvatarId == avatarId).ExecuteAnyAsync(token);
|
||||
var avatar = owns ? await _dbEntities.Avatars.MatchID(avatarId).ExecuteFirstAsync(token) : default;
|
||||
if (avatar == default || !await Usable(avatarId, token))
|
||||
return Refuse(Errors.InvalidGrant, "The persona can no longer be used.");
|
||||
|
||||
var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role);
|
||||
identity.SetClaim(Claims.Subject, avatar.ID);
|
||||
identity.SetClaim(Claims.Name, avatar.UserName);
|
||||
identity.SetScopes(MastodonScopes.Parse(request.Scope).Intersect(new[] { "read", "write", "follow" }));
|
||||
identity.SetDestinations(_ => new[] { Destinations.AccessToken });
|
||||
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
async Task<bool> Usable(string avatarId, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(avatarId))
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
using PrivaPub.Services;
|
||||
|
||||
using System.Text;
|
||||
|
||||
namespace PrivaPub.Extensions
|
||||
{
|
||||
public static class AddAuthExtension
|
||||
@@ -15,16 +12,7 @@ namespace PrivaPub.Extensions
|
||||
#if DEBUG
|
||||
options.RequireHttpsMetadata = false;
|
||||
#endif
|
||||
options.TokenValidationParameters = new()
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = configuration["AppConfiguration:Jwt:Issuer"],
|
||||
ValidAudience = configuration["AppConfiguration:Jwt:Audience"],
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["AppConfiguration:Jwt:Key"]))
|
||||
};
|
||||
options.TokenValidationParameters = RootJwt.Parameters(configuration);
|
||||
options.Events = new JwtEvents();
|
||||
});
|
||||
return builder;
|
||||
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using MongoDB.Bson;
|
||||
@@ -186,6 +186,7 @@ try
|
||||
var passwordHasher = app.Services.GetService(typeof(IPasswordHasher)) as IPasswordHasher;
|
||||
await dbClient.Init(passwordHasher);
|
||||
await app.Services.GetRequiredService<PrivaPub.Federation.Moderation.IDomainBlocks>().Reload(CancellationToken.None);
|
||||
await PrivaPub.Api.Mastodon.Auth.PersonaExchange.EnsureFirstPartyClient(app.Services, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -28,15 +28,10 @@ namespace PrivaPub.Services
|
||||
? default
|
||||
: await context.HttpContext.RequestServices.GetRequiredService<DbEntities>().RootUsers.MatchID(rootId)
|
||||
.ExecuteFirstAsync(context.HttpContext.RequestAborted);
|
||||
if (root is not { IsBanned: false, DeletedAt: null })
|
||||
var refusal = RootJwt.Refusal(root, context.Principal);
|
||||
if (refusal != default)
|
||||
{
|
||||
context.Fail("The account can no longer be used.");
|
||||
return;
|
||||
}
|
||||
// a password recovery ends every session made before it (RootSessions gives the root a new stamp)
|
||||
if ((root.SessionStamp ?? string.Empty) != (context.Principal.FindFirst(AuthTokenManager.SessionStamp)?.Value ?? string.Empty))
|
||||
{
|
||||
context.Fail("The account's sessions were ended.");
|
||||
context.Fail(refusal);
|
||||
return;
|
||||
}
|
||||
if (context.Principal.Identity is not ClaimsIdentity identity)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.IdentityModel.JsonWebTokens;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
using PrivaPub.Extensions;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
|
||||
namespace PrivaPub.Services
|
||||
{
|
||||
// The /clientapi token: how it is validated (JwtBearer through AddPrivaPubAuth, and the persona token exchange on
|
||||
// /oauth/token), and when a valid one can no longer be used. One place, so the two never drift apart.
|
||||
public static class RootJwt
|
||||
{
|
||||
public static TokenValidationParameters Parameters(IConfiguration configuration) => new()
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = configuration["AppConfiguration:Jwt:Issuer"],
|
||||
ValidAudience = configuration["AppConfiguration:Jwt:Audience"],
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["AppConfiguration:Jwt:Key"]))
|
||||
};
|
||||
|
||||
//why a token that validated can no longer be used, or default when it can: a token outlives a ban, a removal and a
|
||||
//password recovery (RootSessions gives the root a new stamp), so the database has the last word
|
||||
public static string Refusal(RootUser root, ClaimsPrincipal principal)
|
||||
{
|
||||
if (root is not { IsBanned: false, DeletedAt: null })
|
||||
return "The account can no longer be used.";
|
||||
if ((root.SessionStamp ?? string.Empty) != (principal.FindFirst(AuthTokenManager.SessionStamp)?.Value ?? string.Empty))
|
||||
return "The account's sessions were ended.";
|
||||
return default;
|
||||
}
|
||||
|
||||
//the root a token belongs to, when the token is valid and can still be used; default otherwise, never an exception
|
||||
public static async Task<RootUser> Root(string jwt, IConfiguration configuration, DbEntities dbEntities, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(jwt))
|
||||
return default;
|
||||
var result = await new JsonWebTokenHandler { MapInboundClaims = false }.ValidateTokenAsync(jwt, Parameters(configuration));
|
||||
if (!result.IsValid)
|
||||
return default;
|
||||
var principal = new ClaimsPrincipal(result.ClaimsIdentity);
|
||||
var rootId = principal.GetUserId();
|
||||
var root = string.IsNullOrEmpty(rootId) ? default : await dbEntities.RootUsers.MatchID(rootId).ExecuteFirstAsync(token);
|
||||
return Refusal(root, principal) == default ? root : default;
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -1,4 +1,4 @@
|
||||
# PrivaPub roadmap
|
||||
# PrivaPub roadmap
|
||||
|
||||
Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federation gap audit of commit `075c222`, research on .NET ActivityPub libraries, and the owner's decisions. Each phase ends in a tagged deploy plus verification; tick phases off here as they land.
|
||||
|
||||
@@ -151,7 +151,7 @@ and circles (see Owner decisions).
|
||||
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| Client interface | **Mastodon client API**, so Tusky, Elk, Phanpy, Ivory and the official apps work. Each avatar is its own Mastodon account: at OAuth authorize, the logged-in RootUser picks the avatar the token is for. PrivaPub-only features (avatars, groups, contacts, range posts) stay on `/clientapi`. Moving decePubClient onto the Mastodon API is out of scope. |
|
||||
| Client interface | **Mastodon client API**, so Tusky, Elk, Phanpy, Ivory and the official apps work. Each avatar is its own Mastodon account: at OAuth authorize, the logged-in RootUser picks the avatar the token is for. PrivaPub-only features (avatars, groups, contacts, range posts) stay on `/clientapi`. ~~Moving decePubClient onto the Mastodon API is out of scope.~~ *Superseded 2026-10-04: decePubClient uses both APIs with one sign-in, see "Owner decisions on decePubClient".* |
|
||||
| Personas | **Unlinkable to other users and servers.** The admin can still see the link in the database. No root IDs next to IPs in logs, NodeInfo counts nothing that links personas, blocks, mutes and notifications are per avatar, and invitation signup no longer names the avatar after the root username. |
|
||||
| Location-ranged posts | **Local only, never federated.** Shown to local users within the radius, with coordinates rounded on storage. |
|
||||
| Groups | **Per group, two kinds.** A *community* federates per FEP-1b12, Lemmy-compatible: it Announces the activity, uses `audience`, and accepts posts from non-followers. A *circle* is invitation-only: posts are addressed to the members collection, and objects are served only to signed requests from members. |
|
||||
@@ -197,6 +197,12 @@ and circles (see Owner decisions).
|
||||
| What circles and located posts reveal | **Unchanged**: circles still answer WebFinger, and circle and located posts still count in a persona's post count, "a good balance for the fediverse to work". |
|
||||
| Public `/stargazing` statistics | **Later**, as decided on 2026-10-03; the crawler and the admin API keep collecting meanwhile. |
|
||||
|
||||
### Owner decisions on decePubClient (2026-10-04)
|
||||
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| Sign-in | **One login.** decePubClient signs in on `/clientapi` and exchanges that JWT for one persona's Mastodon token (RFC 8693 token exchange on `/oauth/token`, `PersonaExchange`), one token per persona it uses. Only the seeded first-party application may exchange; the token names the persona, never the root, like any other. |
|
||||
|
||||
## Libraries (researched; no maintained .NET ActivityPub library exists, so Letterbook and Iceshrimp.NET both wrote their own)
|
||||
|
||||
| Area | Choice |
|
||||
@@ -621,7 +627,6 @@ report at least 10 users; smaller ones fold into one "small servers" aggregate.
|
||||
- S3 storage.
|
||||
- JSON-LD and LD-signature processing. FEP-8b32 proofs need neither (JCS), so they are in P8.
|
||||
- **Deferred:** video transcoding (remux only for now).
|
||||
- **Out of scope:** moving decePubClient onto the Mastodon API.
|
||||
|
||||
## Verification (per phase)
|
||||
|
||||
|
||||
Reference in new issue
Block a user