feat(auth): misc fediverse auth improvements
- remove automatic app validation - add force refresh option to GetFediverseUrlAsync - pass state to mastodon authorization URI
This commit is contained in:
parent
142ff36d3a
commit
4e9c4af4a5
9 changed files with 143 additions and 180 deletions
|
@ -3,7 +3,7 @@ using System.Net;
|
|||
using System.Web;
|
||||
using Foxnouns.Backend.Database;
|
||||
using Foxnouns.Backend.Database.Models;
|
||||
using Duration = NodaTime.Duration;
|
||||
using Foxnouns.Backend.Extensions;
|
||||
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
|
||||
|
||||
namespace Foxnouns.Backend.Services.Auth;
|
||||
|
@ -35,12 +35,6 @@ public partial class FediverseAuthService
|
|||
$"Application created on Mastodon-compatible instance {instance} was null"
|
||||
);
|
||||
|
||||
var token = await GetMastodonAppTokenAsync(
|
||||
instance,
|
||||
mastodonApp.ClientId,
|
||||
mastodonApp.ClientSecret
|
||||
);
|
||||
|
||||
FediverseApplication app;
|
||||
|
||||
if (existingAppId == null)
|
||||
|
@ -52,8 +46,6 @@ public partial class FediverseAuthService
|
|||
ClientSecret = mastodonApp.ClientSecret,
|
||||
Domain = instance,
|
||||
InstanceType = FediverseInstanceType.MastodonApi,
|
||||
AccessToken = token,
|
||||
TokenValidUntil = _clock.GetCurrentInstant() + Duration.FromDays(60),
|
||||
};
|
||||
|
||||
_db.Add(app);
|
||||
|
@ -67,8 +59,6 @@ public partial class FediverseAuthService
|
|||
app.ClientId = mastodonApp.ClientId;
|
||||
app.ClientSecret = mastodonApp.ClientSecret;
|
||||
app.InstanceType = FediverseInstanceType.MastodonApi;
|
||||
app.AccessToken = null;
|
||||
app.TokenValidUntil = null;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
@ -76,8 +66,14 @@ public partial class FediverseAuthService
|
|||
return app;
|
||||
}
|
||||
|
||||
private async Task<FediverseUser> GetMastodonUserAsync(FediverseApplication app, string code)
|
||||
private async Task<FediverseUser> GetMastodonUserAsync(
|
||||
FediverseApplication app,
|
||||
string code,
|
||||
string state
|
||||
)
|
||||
{
|
||||
await _keyCacheService.ValidateAuthStateAsync(state);
|
||||
|
||||
var tokenResp = await _client.PostAsync(
|
||||
MastodonTokenUri(app.Domain),
|
||||
new FormUrlEncodedContent(
|
||||
|
@ -122,109 +118,27 @@ public partial class FediverseAuthService
|
|||
|
||||
private record MastodonTokenResponse([property: J("access_token")] string AccessToken);
|
||||
|
||||
// TODO: Mastodon's OAuth documentation doesn't specify a "state" parameter. that feels... wrong
|
||||
// https://docs.joinmastodon.org/methods/oauth/
|
||||
private async Task<string> GenerateMastodonAuthUrlAsync(FediverseApplication app)
|
||||
private async Task<string> GenerateMastodonAuthUrlAsync(
|
||||
FediverseApplication app,
|
||||
bool forceRefresh
|
||||
)
|
||||
{
|
||||
try
|
||||
if (forceRefresh)
|
||||
{
|
||||
await ValidateMastodonAppAsync(app);
|
||||
}
|
||||
catch (FoxnounsError.RemoteAuthError e)
|
||||
{
|
||||
_logger.Error(
|
||||
e,
|
||||
"Error validating app token for {AppId} on {Instance}",
|
||||
app.Id,
|
||||
app.Domain
|
||||
_logger.Information(
|
||||
"An app credentials refresh was requested for {ApplicationId}, creating a new application",
|
||||
app.Id
|
||||
);
|
||||
|
||||
app = await CreateMastodonApplicationAsync(app.Domain, existingAppId: app.Id);
|
||||
}
|
||||
|
||||
var state = HttpUtility.UrlEncode(await _keyCacheService.GenerateAuthStateAsync());
|
||||
|
||||
return $"https://{app.Domain}/oauth/authorize?response_type=code"
|
||||
+ $"&client_id={app.ClientId}"
|
||||
+ $"&scope={HttpUtility.UrlEncode("read:accounts")}"
|
||||
+ $"&redirect_uri={HttpUtility.UrlEncode(MastodonRedirectUri(app.Domain))}";
|
||||
}
|
||||
|
||||
private async Task ValidateMastodonAppAsync(FediverseApplication app)
|
||||
{
|
||||
// If we don't have an access token stored, or it's too old, get one
|
||||
// When doing this we don't need to fetch the application info
|
||||
if (app.AccessToken == null || app.TokenValidUntil < _clock.GetCurrentInstant())
|
||||
{
|
||||
_logger.Debug(
|
||||
"Application {AppId} on instance {Instance} has no valid token, fetching it",
|
||||
app.Id,
|
||||
app.Domain
|
||||
);
|
||||
|
||||
app.AccessToken = await GetMastodonAppTokenAsync(
|
||||
app.Domain,
|
||||
app.ClientId,
|
||||
app.ClientSecret
|
||||
);
|
||||
app.TokenValidUntil = _clock.GetCurrentInstant() + Duration.FromDays(60);
|
||||
|
||||
_db.Update(app);
|
||||
await _db.SaveChangesAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Debug(
|
||||
"Checking whether application {AppId} on instance {Instance} is still valid",
|
||||
app.Id,
|
||||
app.Domain
|
||||
);
|
||||
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, MastodonCurrentAppUri(app.Domain));
|
||||
req.Headers.Add("Authorization", $"Bearer {app.AccessToken}");
|
||||
|
||||
var resp = await _client.SendAsync(req);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await resp.Content.ReadAsStringAsync();
|
||||
throw new FoxnounsError.RemoteAuthError(
|
||||
"Verifying app credentials returned an error",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> GetMastodonAppTokenAsync(
|
||||
string instance,
|
||||
string clientId,
|
||||
string clientSecret
|
||||
)
|
||||
{
|
||||
var resp = await _client.PostAsync(
|
||||
MastodonTokenUri(instance),
|
||||
new FormUrlEncodedContent(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "grant_type", "client_credentials" },
|
||||
{ "client_id", clientId },
|
||||
{ "client_secret", clientSecret },
|
||||
}
|
||||
)
|
||||
);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await resp.Content.ReadAsStringAsync();
|
||||
throw new FoxnounsError.RemoteAuthError(
|
||||
"Requesting app token returned an error",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
var token = (await resp.Content.ReadFromJsonAsync<MastodonTokenResponse>())?.AccessToken;
|
||||
if (token == null)
|
||||
{
|
||||
throw new FoxnounsError($"Token response from instance {instance} was invalid");
|
||||
}
|
||||
|
||||
return token;
|
||||
+ $"&redirect_uri={HttpUtility.UrlEncode(MastodonRedirectUri(app.Domain))}"
|
||||
+ $"&state={state}";
|
||||
}
|
||||
|
||||
private static string MastodonTokenUri(string instance) => $"https://{instance}/oauth/token";
|
||||
|
@ -232,9 +146,6 @@ public partial class FediverseAuthService
|
|||
private static string MastodonCurrentUserUri(string instance) =>
|
||||
$"https://{instance}/api/v1/accounts/verify_credentials";
|
||||
|
||||
private static string MastodonCurrentAppUri(string instance) =>
|
||||
$"https://{instance}/api/v1/apps/verify_credentials";
|
||||
|
||||
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")]
|
||||
private record PartialMastodonApplication(
|
||||
[property: J("name")] string Name,
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
using Foxnouns.Backend.Database;
|
||||
using Foxnouns.Backend.Database.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
|
||||
|
||||
namespace Foxnouns.Backend.Services.Auth;
|
||||
|
@ -10,26 +9,27 @@ public partial class FediverseAuthService
|
|||
{
|
||||
private const string NodeInfoRel = "http://nodeinfo.diaspora.software/ns/schema/2.0";
|
||||
|
||||
private readonly ILogger _logger;
|
||||
private readonly HttpClient _client;
|
||||
private readonly DatabaseContext _db;
|
||||
private readonly ILogger _logger;
|
||||
private readonly Config _config;
|
||||
private readonly DatabaseContext _db;
|
||||
private readonly KeyCacheService _keyCacheService;
|
||||
private readonly ISnowflakeGenerator _snowflakeGenerator;
|
||||
private readonly IClock _clock;
|
||||
|
||||
public FediverseAuthService(
|
||||
ILogger logger,
|
||||
Config config,
|
||||
DatabaseContext db,
|
||||
ISnowflakeGenerator snowflakeGenerator,
|
||||
IClock clock
|
||||
KeyCacheService keyCacheService,
|
||||
ISnowflakeGenerator snowflakeGenerator
|
||||
)
|
||||
{
|
||||
_logger = logger.ForContext<FediverseAuthService>();
|
||||
_config = config;
|
||||
_db = db;
|
||||
_keyCacheService = keyCacheService;
|
||||
_snowflakeGenerator = snowflakeGenerator;
|
||||
_clock = clock;
|
||||
_logger = logger.ForContext<FediverseAuthService>();
|
||||
|
||||
_client = new HttpClient();
|
||||
_client.DefaultRequestHeaders.Remove("User-Agent");
|
||||
_client.DefaultRequestHeaders.Remove("Accept");
|
||||
|
@ -37,10 +37,10 @@ public partial class FediverseAuthService
|
|||
_client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
}
|
||||
|
||||
public async Task<string> GenerateAuthUrlAsync(string instance)
|
||||
public async Task<string> GenerateAuthUrlAsync(string instance, bool forceRefresh)
|
||||
{
|
||||
var app = await GetApplicationAsync(instance);
|
||||
return await GenerateAuthUrlAsync(app);
|
||||
return await GenerateAuthUrlAsync(app, forceRefresh);
|
||||
}
|
||||
|
||||
// thank you, gargron and syuilo, for agreeing on a name for *once* in your lives,
|
||||
|
@ -96,21 +96,25 @@ public partial class FediverseAuthService
|
|||
);
|
||||
}
|
||||
|
||||
private async Task<string> GenerateAuthUrlAsync(FediverseApplication app) =>
|
||||
private async Task<string> GenerateAuthUrlAsync(FediverseApplication app, bool forceRefresh) =>
|
||||
app.InstanceType switch
|
||||
{
|
||||
FediverseInstanceType.MastodonApi => await GenerateMastodonAuthUrlAsync(app),
|
||||
FediverseInstanceType.MastodonApi => await GenerateMastodonAuthUrlAsync(
|
||||
app,
|
||||
forceRefresh
|
||||
),
|
||||
FediverseInstanceType.MisskeyApi => throw new NotImplementedException(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null),
|
||||
};
|
||||
|
||||
public async Task<FediverseUser> GetRemoteFediverseUserAsync(
|
||||
FediverseApplication app,
|
||||
string code
|
||||
string code,
|
||||
string state
|
||||
) =>
|
||||
app.InstanceType switch
|
||||
{
|
||||
FediverseInstanceType.MastodonApi => await GetMastodonUserAsync(app, code),
|
||||
FediverseInstanceType.MastodonApi => await GetMastodonUserAsync(app, code, state),
|
||||
FediverseInstanceType.MisskeyApi => throw new NotImplementedException(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null),
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue