/* * Copyright (c) 2022 Vaughn Nugent * * Library: VNLib * Package: OAuth2ClientApplications * File: ApplicationEndpoint.cs * * ApplicationEndpoint.cs is part of OAuth2ClientApplications which is part of the larger * VNLib collection of libraries and utilities. * * OAuth2ClientApplications is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * OAuth2ClientApplications is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see https://www.gnu.org/licenses/. */ 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 sealed 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 ApplicationStore Applications; private readonly int MaxAppsPerUser; private readonly string MaxAppOverloadMessage; private static readonly UserAppValidator Validator = new(); public ApplicationEndpoint(PluginBase plugin, IReadOnlyDictionary 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"; } protected override async ValueTask 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 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 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 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(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 CreateAppAsync(HttpEntity entity) { ValErrWebMessage webm = new(); //Get the application from client UserApplication? newApp = await entity.GetJsonFromFileAsync(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 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(); } } }