- PersonaSeparationTests: two personas of one login follow the same account and post; nothing the API maps for one contains the other's id, username or the root id. - tools/smoke/mastodon-api.sh checks what a client meets first (instance v1/v2, discovery, app registration, client credentials, an app token refused by a user endpoint, the public timeline, revocation) and, given a persona token, verify_credentials, home and notifications. deploy.yml runs it after every deploy. - OAuthPruner removes invalid tokens and authorizations older than two weeks, every six hours. - The consent page no longer sets form-action, which browsers apply to the redirect back to the client after the form is posted. CLAUDE.md gains the Mastodon API layout and invariants. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
113 lines
4.5 KiB
C#
113 lines
4.5 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();
|
|
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();
|
|
}
|
|
}
|
|
}
|