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
120 lines
4.9 KiB
C#
120 lines
4.9 KiB
C#
using Microsoft.AspNetCore;
|
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
using Microsoft.Extensions.Options;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
|
|
using MongoDB.Driver;
|
|
using MongoDB.Entities;
|
|
|
|
using OpenIddict.Abstractions;
|
|
using OpenIddict.Server;
|
|
using OpenIddict.Server.AspNetCore;
|
|
|
|
using PrivaPub.Models;
|
|
|
|
using System.Net;
|
|
|
|
using static OpenIddict.Server.OpenIddictServerEvents;
|
|
|
|
namespace PrivaPub.Api.Mastodon.Auth
|
|
{
|
|
public static class OAuthSetup
|
|
{
|
|
public const string LoginScheme = "PrivaPub.OAuth";
|
|
public const string OutOfBand = "urn:ietf:wg:oauth:2.0:oob";
|
|
|
|
public static IServiceCollection PrivaPubOAuth(this IServiceCollection services, IWebHostEnvironment environment)
|
|
{
|
|
services.AddSingleton<IMongoDatabase>(_ => DB.Default.Database());
|
|
services.AddHostedService<OAuthPruner>();
|
|
|
|
services.AddAuthentication()
|
|
.AddCookie(LoginScheme, options =>
|
|
{
|
|
options.Cookie.Name = "privapub.oauth";
|
|
options.Cookie.Path = "/oauth";
|
|
options.Cookie.HttpOnly = true;
|
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
|
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
|
options.ExpireTimeSpan = TimeSpan.FromMinutes(15);
|
|
options.SlidingExpiration = false;
|
|
options.LoginPath = "/oauth/login";
|
|
});
|
|
|
|
services.AddOpenIddict()
|
|
.AddCore(options => options.UseMongoDb())
|
|
.AddServer(options =>
|
|
{
|
|
options.SetAuthorizationEndpointUris("/oauth/authorize")
|
|
.SetTokenEndpointUris("/oauth/token")
|
|
.SetRevocationEndpointUris("/oauth/revoke")
|
|
.SetConfigurationEndpointUris("/.well-known/openid-configuration", "/.well-known/oauth-authorization-server");
|
|
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);
|
|
options.SetAuthorizationCodeLifetime(TimeSpan.FromMinutes(10));
|
|
options.DisableAccessTokenEncryption();
|
|
|
|
var aspNetCore = options.UseAspNetCore()
|
|
.EnableAuthorizationEndpointPassthrough()
|
|
.EnableTokenEndpointPassthrough();
|
|
if (!environment.IsProduction())
|
|
aspNetCore.DisableTransportSecurityRequirement();
|
|
|
|
options.AddEventHandler<ApplyAuthorizationResponseContext>(builder => builder.UseInlineHandler(OutOfBandCode)
|
|
.SetOrder(OpenIddictServerAspNetCoreHandlers.Authentication.ProcessFormPostResponse.Descriptor.Order - 1_000)
|
|
.SetType(OpenIddictServerHandlerType.Custom));
|
|
options.AddEventHandler<ApplyTokenResponseContext>(builder => builder.UseInlineHandler(context =>
|
|
{
|
|
if (context.Response.AccessToken != default)
|
|
context.Response["created_at"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
|
return default;
|
|
}));
|
|
})
|
|
.AddValidation(options =>
|
|
{
|
|
options.UseLocalServer();
|
|
options.UseAspNetCore();
|
|
});
|
|
|
|
services.AddOptions<OpenIddictServerOptions>().Configure<IOptionsMonitor<AppConfiguration>>((options, app) =>
|
|
{
|
|
options.Issuer = new Uri(app.CurrentValue.BackendBaseAddress.TrimEnd('/') + "/");
|
|
options.SigningCredentials.Add(new SigningCredentials(OidcKey.Load(OidcKey.Signing), SecurityAlgorithms.RsaSha256));
|
|
options.EncryptionCredentials.Add(new EncryptingCredentials(OidcKey.Load(OidcKey.Encryption),
|
|
SecurityAlgorithms.RsaOAEP, SecurityAlgorithms.Aes256CbcHmacSha512));
|
|
});
|
|
return services;
|
|
}
|
|
|
|
static async ValueTask OutOfBandCode(ApplyAuthorizationResponseContext context)
|
|
{
|
|
if (context.RedirectUri != OutOfBand || context.Response.Code == default)
|
|
return;
|
|
var http = context.Transaction.GetHttpRequest()?.HttpContext;
|
|
if (http == default)
|
|
return;
|
|
var code = WebUtility.HtmlEncode(context.Response.Code);
|
|
http.Response.StatusCode = StatusCodes.Status200OK;
|
|
http.Response.ContentType = "text/html; charset=utf-8";
|
|
http.Response.Headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'";
|
|
http.Response.Headers["Cache-Control"] = "no-store";
|
|
await http.Response.WriteAsync($$"""
|
|
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Authorization code</title><style>body{font:16px/1.5 system-ui,sans-serif;max-width:480px;margin:48px auto;padding:0 16px}
|
|
code{display:block;font-size:1.1rem;padding:12px;border:1px solid #ccc;word-break:break-all}</style></head>
|
|
<body><h1>Authorization code</h1><p>Copy this code into the application:</p><code>{{code}}</code></body></html>
|
|
""");
|
|
context.HandleRequest();
|
|
}
|
|
}
|
|
}
|