aboutsummaryrefslogtreecommitdiff
path: root/Plugins/OAuth2ClientApplications/Endpoints/ApplicationEndpoint.cs
diff options
context:
space:
mode:
Diffstat (limited to 'Plugins/OAuth2ClientApplications/Endpoints/ApplicationEndpoint.cs')
-rw-r--r--Plugins/OAuth2ClientApplications/Endpoints/ApplicationEndpoint.cs359
1 files changed, 359 insertions, 0 deletions
diff --git a/Plugins/OAuth2ClientApplications/Endpoints/ApplicationEndpoint.cs b/Plugins/OAuth2ClientApplications/Endpoints/ApplicationEndpoint.cs
new file mode 100644
index 0000000..757fdac
--- /dev/null
+++ b/Plugins/OAuth2ClientApplications/Endpoints/ApplicationEndpoint.cs
@@ -0,0 +1,359 @@
+using System;
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using System.Threading.Tasks;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+using VNLib.Utils.Memory;
+using VNLib.Utils.Logging;
+using VNLib.Utils.Extensions;
+using VNLib.Plugins;
+using VNLib.Plugins.Essentials;
+using VNLib.Plugins.Essentials.Accounts;
+using VNLib.Plugins.Essentials.Endpoints;
+using VNLib.Plugins.Essentials.Extensions;
+using VNLib.Plugins.Essentials.Oauth.Applications;
+using VNLib.Plugins.Extensions.Validation;
+using VNLib.Plugins.Extensions.Loading;
+using VNLib.Plugins.Extensions.Loading.Sql;
+using static VNLib.Plugins.Essentials.Statics;
+
+namespace OAuth2ClientApplications.Endpoints
+{
+ [ConfigurationName("applications")]
+ internal sealed class ApplicationEndpoint : ProtectedWebEndpoint
+ {
+ private class ApplicationMessage
+ {
+ [JsonPropertyName("name")]
+ public string? ApplicationName { get; set; }
+ [JsonPropertyName("description")]
+ public string? Description { get; set; }
+ [JsonPropertyName("client_id")]
+ public string? ClientID { get; set; }
+ [JsonPropertyName("raw_secret")]
+ public string? RawSecret { get; set; }
+ [JsonPropertyName("Id")]
+ public string? ApplicationID { get; set; }
+ [JsonPropertyName("permissions")]
+ public string? Permissions { get; set; }
+ }
+
+ private readonly Applications Applications;
+ private readonly int MaxAppsPerUser;
+ private readonly string MaxAppOverloadMessage;
+ private readonly Task<JsonDocument?> JwtSigningKey;
+
+ private static readonly UserAppValidator Validator = new();
+
+ public ApplicationEndpoint(PluginBase plugin, IReadOnlyDictionary<string, JsonElement> config)
+ {
+ //Get configuration variables from plugin
+ string? path = config["path"].GetString();
+ MaxAppsPerUser = config["max_apps_per_user"].GetInt32();
+
+ InitPathAndLog(path, plugin.Log);
+ //Load apps
+ Applications = new(plugin.GetContextOptions(), plugin.GetPasswords());
+
+ //Complie overload message
+ MaxAppOverloadMessage = $"You have reached the limit of {MaxAppsPerUser} applications, this application cannot be created";
+
+ //Get jwt signing key
+ JwtSigningKey = plugin.TryGetSecretAsync("jwt_signing_key").ContinueWith(s => s.Result == null ? null : JsonDocument.Parse(s.Result));
+ }
+
+ protected override async ValueTask<VfReturnType> GetAsync(HttpEntity ev)
+ {
+ //Try to get a single application from the database
+
+ //Get a single specific application from an appid
+ if (ev.QueryArgs.TryGetNonEmptyValue("Id", out string? appid))
+ {
+ appid = ValidatorExtensions.OnlyAlphaRegx.Replace(appid, "");
+ //Execute get single app
+ UserApplication? singeApp = await Applications.GetSingleAsync(appid, ev.Session.UserID);
+ if (singeApp == null)
+ {
+ ev.CloseResponse(HttpStatusCode.NotFound);
+ return VfReturnType.VirtualSkip;
+ }
+ ev.CloseResponseJson(HttpStatusCode.OK, singeApp);
+ return VfReturnType.VirtualSkip;
+ }
+ //Process a "get all"
+ else
+ {
+ //Create list to store all applications
+ List<UserApplication> applications = Applications.ListRental.Rent();
+ try
+ {
+ //Get all applications to fill the list
+ _ = await Applications.GetCollectionAsync(applications, ev.Session.UserID, MaxAppsPerUser);
+ //Write response (will convert json as needed before releasing the list)
+ ev.CloseResponseJson(HttpStatusCode.OK, applications);
+ return VfReturnType.VirtualSkip;
+ }
+ finally
+ {
+ //Return the list
+ Applications.ListRental.Return(applications);
+ }
+ }
+ }
+ protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity)
+ {
+ //Default response
+ WebMessage webm = new();
+ //Oauth is only available for local accounts
+ if (!entity.Session.HasLocalAccount())
+ {
+ webm.Result = "OAuth is only available for internal user accounts";
+ entity.CloseResponseJson(HttpStatusCode.Forbidden, webm);
+ return VfReturnType.VirtualSkip;
+ }
+ if (entity.QueryArgs.IsArgumentSet("action", "create"))
+ {
+ return await CreateAppAsync(entity);
+ }
+
+ //Update the application secret
+ else if (entity.QueryArgs.IsArgumentSet("action", "secret"))
+ {
+ using JsonDocument? update = await entity.GetJsonFromFileAsync();
+
+ if(webm.Assert(update != null, "Invalid request"))
+ {
+ entity.CloseResponseJson(HttpStatusCode.BadRequest, webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Update message will include a challenge and an app id
+ string? appId = update.RootElement.GetPropString("Id");
+ string? challenge = update.RootElement.GetPropString("challenge");
+
+ if (string.IsNullOrWhiteSpace(appId))
+ {
+ return VfReturnType.NotFound;
+ }
+
+ /*
+ * A secret update requires a client challenge because
+ * it can log-out active sessions and break access to
+ * other applications
+ */
+
+ if (string.IsNullOrWhiteSpace(challenge) || !entity.Session.VerifyChallenge(challenge))
+ {
+ //return unauthorized
+ webm.Result = "Please check your password";
+ entity.CloseResponseJson(HttpStatusCode.Unauthorized, webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Update the app's secret
+ using PrivateString? secret = await Applications.UpdateSecretAsync(entity.Session.UserID, appId);
+
+ if (webm.Assert(secret != null, "Failed to update the application secret"))
+ {
+ entity.CloseResponseJson(HttpStatusCode.InternalServerError, webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ /*
+ * We must return the secret to the user.
+ *
+ * The PrivateString must be casted and serialized
+ * while the using statment is in scope
+ */
+ ApplicationMessage result = new()
+ {
+ ApplicationID = appId,
+ //Send raw secret
+ RawSecret = (string)secret
+ };
+ //Must write response while password is in scope
+ entity.CloseResponseJson(HttpStatusCode.OK, result);
+ return VfReturnType.VirtualSkip;
+ }
+ else if (entity.QueryArgs.IsArgumentSet("action", "delete"))
+ {
+ using JsonDocument? update = await entity.GetJsonFromFileAsync();
+
+ if(webm.Assert(update != null, "Invalid request"))
+ {
+ entity.CloseResponseJson(HttpStatusCode.BadRequest, webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Update message will include a challenge and an app id
+ string? appId = update.RootElement.GetPropString("Id");
+ string? challenge = update.RootElement.GetPropString("challenge");
+
+ if (string.IsNullOrWhiteSpace(appId))
+ {
+ return VfReturnType.NotFound;
+ }
+
+ /*
+ * A secret update requires a client challenge because
+ * it can log-out active sessions and break access to
+ * other applications
+ */
+ if (string.IsNullOrWhiteSpace(challenge) || !entity.Session.VerifyChallenge(challenge))
+ {
+ webm.Result = "Please check your password";
+ //return unauthorized
+ entity.CloseResponseJson(HttpStatusCode.Unauthorized, webm);
+ return VfReturnType.VirtualSkip;
+ }
+ //Try to delete the app
+ if (await Applications.DeleteAsync(appId, entity.Session.UserID))
+ {
+ entity.CloseResponse(HttpStatusCode.NoContent);
+ return VfReturnType.VirtualSkip;
+ }
+ }
+ else
+ {
+ webm.Result = "The update type specified is not defined";
+ entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm);
+ return VfReturnType.VirtualSkip;
+ }
+ return VfReturnType.BadRequest;
+ }
+ protected override async ValueTask<VfReturnType> PutAsync(HttpEntity entity)
+ {
+ ValErrWebMessage webm = new();
+
+ //Oauth is only available for local accounts
+ if (!entity.Session.HasLocalAccount())
+ {
+ webm.Result = "OAuth is only available for internal user accounts";
+ entity.CloseResponseJson(HttpStatusCode.Forbidden, webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Get the application from client
+ UserApplication? app = await entity.GetJsonFromFileAsync<UserApplication>(SR_OPTIONS);
+
+ if (webm.Assert(app != null, "Application is empty"))
+ {
+ entity.CloseResponseJson(HttpStatusCode.BadRequest, app);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //set user-id
+ app.UserId = entity.Session.UserID;
+ //remove permissions
+ app.Permissions = null;
+
+ //perform validation on the application update (should remove unused fields)
+ if (!Validator.Validate(app, webm))
+ {
+ entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Update the app's meta
+ if (await Applications.UpdateAsync(app))
+ {
+ //Send the app to the client
+ entity.CloseResponse(HttpStatusCode.NoContent);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //The app was not found and could not be updated
+ return VfReturnType.NotFound;
+ }
+
+ private async ValueTask<VfReturnType> CreateAppAsync(HttpEntity entity)
+ {
+ ValErrWebMessage webm = new();
+
+ //Get the application from client
+ UserApplication? newApp = await entity.GetJsonFromFileAsync<UserApplication>(SR_OPTIONS);
+
+ if (webm.Assert(newApp != null, "Application is empty"))
+ {
+ entity.CloseResponseJson(HttpStatusCode.BadRequest, webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Validate the new application
+ if (!Validator.Validate(newApp, webm))
+ {
+ entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //If no premissions are specified, set to "none"
+ if (string.IsNullOrWhiteSpace(newApp.Permissions))
+ {
+ newApp.Permissions = "none";
+ }
+
+ //See if the user has enough room for more apps
+ long appCount = await Applications.GetCountAsync(entity.Session.UserID);
+
+ if (appCount == -1)
+ {
+ webm.Result = $"There was a server error during creation of your application";
+ Log.Error("There was an error retreiving the number of applications for user {id}", entity.Session.UserID);
+ entity.CloseResponseJson(HttpStatusCode.InternalServerError, webm);
+ return VfReturnType.VirtualSkip;
+ }
+ if (webm.Assert(appCount < MaxAppsPerUser, MaxAppOverloadMessage))
+ {
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Parse permission string an re-build it to clean it up
+ newApp.Permissions = ParsePermissions(newApp.Permissions);
+ //Set user-id
+ newApp.UserId = entity.Session.UserID;
+ //Create the new application
+ if (!await Applications.CreateAsync(newApp))
+ {
+ webm.Result = "The was an issue creating your application";
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Make sure to dispose the secret
+ using PrivateString secret = newApp.RawSecret!;
+
+ //Success, now respond to the client with the new app information
+ ApplicationMessage mess = new()
+ {
+ ApplicationID = newApp.Id,
+ ApplicationName = newApp.AppName,
+ RawSecret = (string)secret,
+ ClientID = newApp.ClientId,
+ Description = newApp.AppDescription
+ };
+
+ //Must write response while the secret is in scope
+ entity.CloseResponseJson(HttpStatusCode.Created, mess);
+ return VfReturnType.VirtualSkip;
+ }
+
+ private static string ParsePermissions(string permissions)
+ {
+ StringBuilder builder = new();
+ //Local function for splitting permissions
+ static void SplitCb(ReadOnlySpan<char> permission, StringBuilder builder)
+ {
+ builder.Append(permission);
+ builder.Append(',');
+ }
+ //Split permissions at comma and clean up the entires
+ permissions.AsSpan().Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries, SplitCb, builder);
+ //return the string
+ return builder.ToString();
+ }
+ }
+} \ No newline at end of file