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