166 lines
7.4 KiB
C#
166 lines
7.4 KiB
C#
using MongoDB.Bson;
|
|||
|
|
using MongoDB.Driver;
|
||
|
|
using MongoDB.Entities;
|
||
|
|
|
||
|
|
using PrivaPub.Models.User;
|
||
|
|
|
||
|
|
using System.Net;
|
||
|
|
using System.Net.Http.Json;
|
||
|
|
using System.Text;
|
||
|
|
using System.Text.Json.Nodes;
|
||
|
|
using System.Text.RegularExpressions;
|
||
|
|
|
||
|
|
namespace PrivaPub.Tests.Support.Host
|
||
|
|
{
|
||
|
|
public sealed record OAuthApp(string ClientId, string ClientSecret, string Scopes)
|
||
|
|
{
|
||
|
|
public string Query(string extra = default) =>
|
||
|
|
$"client_id={Uri.EscapeDataString(ClientId)}&redirect_uri={Uri.EscapeDataString(ClientApi.OutOfBand)}&response_type=code&scope={Uri.EscapeDataString(Scopes)}"
|
||
|
|
+ (extra == default ? string.Empty : "&" + extra);
|
||
|
|
|
||
|
|
public string ReturnUrl(string extra = default) => "/oauth/authorize?" + Query(extra);
|
||
|
|
}
|
||
|
|
|
||
|
|
public static partial class ClientApi
|
||
|
|
{
|
||
|
|
public const string OutOfBand = "urn:ietf:wg:oauth:2.0:oob";
|
||
|
|
|
||
|
|
public static HttpClient ClientAt(this PrivaPubHost host, string address, bool cookies = false)
|
||
|
|
{
|
||
|
|
var client = host.Client(cookies);
|
||
|
|
client.DefaultRequestHeaders.Remove(PrivaPubHost.ClientHeader);
|
||
|
|
client.DefaultRequestHeaders.Add(PrivaPubHost.ClientHeader, address);
|
||
|
|
return client;
|
||
|
|
}
|
||
|
|
|
||
|
|
public static async Task<JsonObject> JsonBody(this HttpResponseMessage response) =>
|
||
|
|
JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsObject();
|
||
|
|
|
||
|
|
public static async Task<JsonArray> JsonItems(this HttpResponseMessage response) =>
|
||
|
|
JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsArray();
|
||
|
|
|
||
|
|
public static Task<HttpResponseMessage> PostJson(this HttpClient client, string path, object body) =>
|
||
|
|
client.PostAsJsonAsync(path, body);
|
||
|
|
|
||
|
|
public static Task<HttpResponseMessage> Form(this HttpClient client, string path, params (string Key, string Value)[] fields) =>
|
||
|
|
client.PostAsync(path, new FormUrlEncodedContent(fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value))));
|
||
|
|
|
||
|
|
public static async Task<OAuthApp> RegisterApp(this HttpClient client, string scopes = "read write follow")
|
||
|
|
{
|
||
|
|
var response = await client.Form("/api/v1/apps", ("client_name", "privapub-tests"), ("redirect_uris", OutOfBand), ("scopes", scopes));
|
||
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||
|
|
var app = await response.JsonBody();
|
||
|
|
return new OAuthApp(app["client_id"]!.GetValue<string>(), app["client_secret"]!.GetValue<string>(), scopes);
|
||
|
|
}
|
||
|
|
|
||
|
|
public static async Task<HttpResponseMessage> SignIn(this HttpClient client, Root root, string returnUrl, string password = default)
|
||
|
|
{
|
||
|
|
var login = await client.GetStringAsync("/oauth/login?returnUrl=" + Uri.EscapeDataString(returnUrl));
|
||
|
|
return await client.PostAsync("/oauth/login", new FormUrlEncodedContent(new Dictionary<string, string>
|
||
|
|
{
|
||
|
|
["returnUrl"] = returnUrl,
|
||
|
|
["__RequestVerificationToken"] = Antiforgery(login),
|
||
|
|
["userName"] = root.UserName,
|
||
|
|
["password"] = password ?? root.Password
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
public static string Antiforgery(string page) => AntiforgeryToken().Match(page).Groups[1].Value;
|
||
|
|
|
||
|
|
public static async Task<List<KeyValuePair<string, string>>> Choice(this HttpClient client, OAuthApp app)
|
||
|
|
{
|
||
|
|
var page = await client.GetStringAsync(app.ReturnUrl() + "&signed_in=1");
|
||
|
|
return HiddenInput().Matches(page).Select(m => new KeyValuePair<string, string>(m.Groups[1].Value, WebUtility.HtmlDecode(m.Groups[2].Value))).ToList();
|
||
|
|
}
|
||
|
|
|
||
|
|
public static Task<HttpResponseMessage> Decide(this HttpClient client, IEnumerable<KeyValuePair<string, string>> choice, string avatarId, string decision = "allow") =>
|
||
|
|
client.PostAsync("/oauth/authorize", new FormUrlEncodedContent(choice.Append(new("avatarId", avatarId)).Append(new("decision", decision))));
|
||
|
|
|
||
|
|
public static async Task<HttpResponseMessage> Choose(this HttpClient client, OAuthApp app, string avatarId, string decision = "allow") =>
|
||
|
|
await client.Decide(await client.Choice(app), avatarId, decision);
|
||
|
|
|
||
|
|
public static string CodeIn(string page) => CodeElement().Match(page) is { Success: true } match ? match.Groups[1].Value : default;
|
||
|
|
|
||
|
|
public static async Task<string> Code(this HttpClient client, OAuthApp app, Persona persona)
|
||
|
|
{
|
||
|
|
Assert.Equal(HttpStatusCode.Redirect, (await client.SignIn(persona.Root, app.ReturnUrl())).StatusCode);
|
||
|
|
var code = CodeIn(await (await client.Choose(app, persona.Id)).Content.ReadAsStringAsync());
|
||
|
|
Assert.False(string.IsNullOrEmpty(code));
|
||
|
|
return code;
|
||
|
|
}
|
||
|
|
|
||
|
|
public static Task<HttpResponseMessage> Exchange(this HttpClient client, OAuthApp app, string code) =>
|
||
|
|
client.Form("/oauth/token", ("grant_type", "authorization_code"), ("code", code), ("client_id", app.ClientId),
|
||
|
|
("client_secret", app.ClientSecret), ("redirect_uri", OutOfBand));
|
||
|
|
|
||
|
|
public static async Task<string> Token(this HttpClient client, OAuthApp app, Persona persona)
|
||
|
|
{
|
||
|
|
var response = await client.Exchange(app, await client.Code(app, persona));
|
||
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||
|
|
return (await response.JsonBody())["access_token"]!.GetValue<string>();
|
||
|
|
}
|
||
|
|
|
||
|
|
public static async Task<JsonObject> Group(this PrivaPubHost host, Persona owner, bool community, string password = default, string name = "group")
|
||
|
|
{
|
||
|
|
using var client = host.As(owner.Root.Jwt);
|
||
|
|
var response = await client.PostJson("/clientapi/group/insert", new
|
||
|
|
{
|
||
|
|
avatarId = owner.Id,
|
||
|
|
userName = $"{name}{Guid.NewGuid():N}"[..20],
|
||
|
|
name,
|
||
|
|
isCommunity = community,
|
||
|
|
invitationPassword = password
|
||
|
|
});
|
||
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||
|
|
return await response.JsonBody();
|
||
|
|
}
|
||
|
|
|
||
|
|
public static async Task Ban(string rootId, bool banned = true) =>
|
||
|
|
await DB.Default.Update<RootUser>().MatchID(rootId).Modify(u => u.IsBanned, banned).ExecuteAsync();
|
||
|
|
|
||
|
|
public static async Task<JsonObject> Follow(this PrivaPubHost host, RemoteActor follower, string origin, string userName)
|
||
|
|
{
|
||
|
|
var follow = new JsonObject
|
||
|
|
{
|
||
|
|
["id"] = $"{origin}/follows/{Guid.NewGuid():N}",
|
||
|
|
["type"] = "Follow",
|
||
|
|
["actor"] = follower.Id,
|
||
|
|
["object"] = $"{PrivaPubHost.Base}/peasants/{userName}"
|
||
|
|
};
|
||
|
|
using var client = host.Client();
|
||
|
|
Assert.Equal(HttpStatusCode.Accepted, (await client.SendAsync(follower.SignedPost($"/peasants/{userName}/mouth", follow))).StatusCode);
|
||
|
|
Assert.Equal(1, await host.RunInbox(follow["id"]!.GetValue<string>()));
|
||
|
|
return follow;
|
||
|
|
}
|
||
|
|
|
||
|
|
public static async Task<List<BsonDocument>> StoredTokens(string clientId)
|
||
|
|
{
|
||
|
|
var database = DB.Default.Database();
|
||
|
|
var application = await database.GetCollection<BsonDocument>("openiddict.applications")
|
||
|
|
.Find(Builders<BsonDocument>.Filter.Eq("client_id", clientId)).FirstAsync();
|
||
|
|
return await database.GetCollection<BsonDocument>("openiddict.tokens")
|
||
|
|
.Find(Builders<BsonDocument>.Filter.Eq("application_id", application["_id"])).ToListAsync();
|
||
|
|
}
|
||
|
|
|
||
|
|
public static bool IsAccessToken(BsonDocument token) =>
|
||
|
|
token.GetValue("type", BsonNull.Value) is { IsString: true } type && type.AsString.EndsWith("access_token", StringComparison.Ordinal);
|
||
|
|
|
||
|
|
public static string JwtPayload(string jwt)
|
||
|
|
{
|
||
|
|
var parts = jwt.Split('.');
|
||
|
|
if (parts.Length != 3)
|
||
|
|
return string.Empty;
|
||
|
|
var segment = parts[1].Replace('-', '+').Replace('_', '/');
|
||
|
|
return Encoding.UTF8.GetString(Convert.FromBase64String(segment.PadRight(segment.Length + (4 - segment.Length % 4) % 4, '=')));
|
||
|
|
}
|
||
|
|
|
||
|
|
[GeneratedRegex("name=\"__RequestVerificationToken\" type=\"hidden\" value=\"([^\"]*)\"")]
|
||
|
|
private static partial Regex AntiforgeryToken();
|
||
|
|
|
||
|
|
[GeneratedRegex("<input type=\"hidden\" name=\"([^\"]*)\" value=\"([^\"]*)\"")]
|
||
|
|
private static partial Regex HiddenInput();
|
||
|
|
|
||
|
|
[GeneratedRegex("<code>([^<]*)</code>")]
|
||
|
|
private static partial Regex CodeElement();
|
||
|
|
}
|
||
|
|
}
|