Mastodon clients can sign in: OAuth with a persona per token
OpenIddict 7.7 (MongoDB stores, keys kept in Mongo so tokens survive
restarts) serves /oauth/authorize, /oauth/token, /oauth/revoke and the
discovery documents, including /.well-known/oauth-authorization-server:
- authorization code (PKCE optional) and client credentials, Mastodon's
scopes including the granular ones, non-expiring reference tokens,
`created_at` in the token response, and the urn:ietf:wg:oauth:2.0:oob
page that shows the code;
- /oauth/login signs the private login into a fifteen-minute cookie that
only /oauth sees (rate limited, antiforgery-protected); /oauth/authorize
then asks which persona the application acts as. The token's subject
is that persona's id and nothing else; no root id reaches a token, an
authorization or a log line;
- the token exchange refuses a persona whose login is banned or deleted,
and every API request checks the same.
/api/v1/apps registers applications dynamically, /api/v1/apps/
verify_credentials, /api/v1/instance (v1 and v2, "4.2.0 (compatible;
PrivaPub)") and verify_credentials answer in Mastodon's shapes: snake_case
with explicit nulls, Rails-style parameters from query, form or JSON,
{"error": ...} on failure, Link paging. CORS exposes Link.
/api goes to OpenIddict validation and everything else to the existing
JWT; the JWT failure handler no longer sends the exception and stack trace
to the client.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
e6a6fe46d7
commit
4d9be37c1c
22 files changed
+1394
-7
No files matched your search
@@ -0,0 +1,165 @@
|
||||
using Microsoft.AspNetCore;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server.AspNetCore;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Auth;
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Infrastructure;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Services;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Security.Claims;
|
||||
|
||||
using static OpenIddict.Abstractions.OpenIddictConstants;
|
||||
|
||||
namespace PrivaPub.Web.Pages.OAuth
|
||||
{
|
||||
[EnableRateLimiting(RateLimiting.Accounts)]
|
||||
public class LoginModel : PageModel
|
||||
{
|
||||
readonly IRootUsersService _users;
|
||||
|
||||
public LoginModel(IRootUsersService users)
|
||||
{
|
||||
_users = users;
|
||||
}
|
||||
|
||||
[BindProperty(SupportsGet = true)]
|
||||
public string ReturnUrl { get; set; }
|
||||
|
||||
public string Error { get; private set; }
|
||||
|
||||
public void OnGet() => Harden();
|
||||
|
||||
public async Task<IActionResult> OnPostAsync([FromForm] string userName, [FromForm] string password)
|
||||
{
|
||||
Harden();
|
||||
if (string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(password))
|
||||
{
|
||||
Error = "Enter your username and password.";
|
||||
return Page();
|
||||
}
|
||||
var result = await _users.LoginAsync(new LoginForm { UserName = userName, Password = password });
|
||||
if (!result.IsValid)
|
||||
{
|
||||
Error = "That username and password do not match.";
|
||||
return Page();
|
||||
}
|
||||
var (root, _) = ((RootUser, ClientModels.User.ViewUserSettings))result.Data;
|
||||
var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, root.ID) }, OAuthSetup.LoginScheme);
|
||||
await HttpContext.SignInAsync(OAuthSetup.LoginScheme, new ClaimsPrincipal(identity));
|
||||
|
||||
var target = ReturnUrl?.StartsWith("/oauth/authorize?", StringComparison.Ordinal) == true ? ReturnUrl : "/oauth/authorize";
|
||||
return LocalRedirect(target + (target.Contains('?') ? "&" : "?") + "signed_in=1");
|
||||
}
|
||||
|
||||
void Harden()
|
||||
{
|
||||
Response.Headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'";
|
||||
Response.Headers["Cache-Control"] = "no-store";
|
||||
}
|
||||
}
|
||||
|
||||
[IgnoreAntiforgeryToken]
|
||||
public class AuthorizeModel : PageModel
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly OpenIddict.Abstractions.IOpenIddictApplicationManager _applications;
|
||||
|
||||
public AuthorizeModel(DbEntities dbEntities, ILocalActorService localActors, OpenIddict.Abstractions.IOpenIddictApplicationManager applications)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
_applications = applications;
|
||||
}
|
||||
|
||||
public string ApplicationName { get; private set; }
|
||||
public IReadOnlyList<string> RequestedScopes { get; private set; } = Array.Empty<string>();
|
||||
public IReadOnlyList<LocalActor> Avatars { get; private set; } = Array.Empty<LocalActor>();
|
||||
public IReadOnlyList<KeyValuePair<string, string>> Parameters { get; private set; } = Array.Empty<KeyValuePair<string, string>>();
|
||||
|
||||
public async Task<IActionResult> OnGetAsync(CancellationToken token) => await Show(token);
|
||||
|
||||
public async Task<IActionResult> OnPostAsync([FromForm] string avatarId, [FromForm] string decision, CancellationToken token)
|
||||
{
|
||||
var request = HttpContext.GetOpenIddictServerRequest();
|
||||
var rootId = await SignedInRoot();
|
||||
if (request == default || rootId == default)
|
||||
return await Show(token);
|
||||
|
||||
if (decision != "allow")
|
||||
return Forbid(new AuthenticationProperties(new Dictionary<string, string>
|
||||
{
|
||||
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.AccessDenied,
|
||||
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user denied the request."
|
||||
}), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
|
||||
var owns = !string.IsNullOrEmpty(avatarId)
|
||||
&& await _dbEntities.RootToAvatars.Match(r => r.RootId == rootId && r.AvatarId == avatarId).ExecuteAnyAsync(token);
|
||||
var avatar = owns ? await _localActors.FindById(LocalActorKind.Person, avatarId, token) : default;
|
||||
if (avatar == default)
|
||||
return await Show(token);
|
||||
|
||||
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));
|
||||
identity.SetDestinations(_ => new[] { Destinations.AccessToken });
|
||||
await HttpContext.SignOutAsync(OAuthSetup.LoginScheme);
|
||||
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
async Task<IActionResult> Show(CancellationToken token)
|
||||
{
|
||||
var request = HttpContext.GetOpenIddictServerRequest();
|
||||
if (request == default)
|
||||
return BadRequest();
|
||||
|
||||
var rootId = await SignedInRoot();
|
||||
var forceLogin = string.Equals((string)request["force_login"], "true", StringComparison.OrdinalIgnoreCase)
|
||||
&& Request.Query["signed_in"] != "1";
|
||||
if (rootId == default || forceLogin)
|
||||
{
|
||||
var query = string.Join("&", Request.Query.Where(q => q.Key != "signed_in").Select(q => $"{Uri.EscapeDataString(q.Key)}={Uri.EscapeDataString(q.Value.ToString())}"));
|
||||
return Redirect($"/oauth/login?returnUrl={Uri.EscapeDataString("/oauth/authorize?" + query)}");
|
||||
}
|
||||
|
||||
var application = await _applications.FindByClientIdAsync(request.ClientId, token);
|
||||
ApplicationName = application == default ? "an application" : await _applications.GetDisplayNameAsync(application, token) ?? "an application";
|
||||
RequestedScopes = MastodonScopes.Parse(request.Scope);
|
||||
var avatarIds = (await _dbEntities.RootToAvatars.Match(r => r.RootId == rootId).ExecuteAsync(token)).Select(r => r.AvatarId).ToList();
|
||||
var avatars = new List<LocalActor>();
|
||||
foreach (var id in avatarIds)
|
||||
if (await _localActors.FindById(LocalActorKind.Person, id, token) is { } avatar)
|
||||
avatars.Add(avatar);
|
||||
Avatars = avatars;
|
||||
Parameters = request.GetParameters()
|
||||
.Where(p => p.Key is not ("avatarId" or "decision" or "signed_in"))
|
||||
.Select(p => new KeyValuePair<string, string>(p.Key, (string)p.Value))
|
||||
.ToList();
|
||||
Response.Headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'";
|
||||
Response.Headers["Cache-Control"] = "no-store";
|
||||
return Page();
|
||||
}
|
||||
|
||||
async Task<string> SignedInRoot()
|
||||
{
|
||||
var login = await HttpContext.AuthenticateAsync(OAuthSetup.LoginScheme);
|
||||
var rootId = login.Succeeded ? login.Principal.FindFirstValue(ClaimTypes.NameIdentifier) : default;
|
||||
if (rootId == default)
|
||||
return default;
|
||||
var root = await _dbEntities.RootUsers.MatchID(rootId).ExecuteFirstAsync();
|
||||
return root is { IsBanned: false, DeletedAt: null } ? rootId : default;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user