/*
* Copyright (c) 2023 Vaughn Nugent
*
* Library: VNLib
* Package: VNLib.Plugins.Essentials.Sessions.OAuth
* File: AccessTokenEndpoint.cs
*
* AccessTokenEndpoint.cs is part of VNLib.Plugins.Essentials.Sessions.OAuth which is part of the larger
* VNLib collection of libraries and utilities.
*
* VNLib.Plugins.Essentials.Sessions.OAuth 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.
*
* VNLib.Plugins.Essentials.Sessions.OAuth 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.Json;
using System.Threading.Tasks;
using VNLib.Utils.Memory;
using VNLib.Hashing.IdentityUtility;
using VNLib.Plugins.Essentials;
using VNLib.Plugins.Essentials.Oauth;
using VNLib.Plugins.Essentials.Endpoints;
using VNLib.Plugins.Essentials.Oauth.Tokens;
using VNLib.Plugins.Essentials.Oauth.Applications;
using VNLib.Plugins.Essentials.Extensions;
using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Extensions.Loading.Sql;
using VNLib.Plugins.Extensions.Validation;
namespace VNLib.Plugins.Sessions.OAuth.Endpoints
{
///
/// Grants authorization to OAuth2 clients to protected resources
/// with access tokens
///
[ConfigurationName(O2SessionProviderEntry.OAUTH2_CONFIG_KEY)]
internal sealed class AccessTokenEndpoint : ResourceEndpointBase
{
private readonly IApplicationTokenFactory TokenFactory;
private readonly ApplicationStore Applications;
private readonly IAsyncLazy JWTVerificationKey;
//override protection settings to allow most connections to authenticate
///
protected override ProtectionSettings EndpointProtectionSettings { get; } = new()
{
DisableBrowsersOnly = true,
DisableSessionsRequired = true
};
public AccessTokenEndpoint(PluginBase pbase, IConfigScope config)
{
string? path = config["token_path"].GetString();;
InitPathAndLog(path, pbase.Log);
//Get the session provider, as its a token factory
TokenFactory = pbase.GetOrCreateSingleton();
Applications = new(pbase.GetContextOptions(), pbase.GetOrCreateSingleton());
//Try to get the application token key for verifying signed application JWTs
JWTVerificationKey = pbase.TryGetSecretAsync("application_token_key").ToJsonWebKey().AsLazy();
}
protected override async ValueTask PostAsync(HttpEntity entity)
{
//Check for refresh token
if (entity.RequestArgs.IsArgumentSet("grant_type", "refresh_token"))
{
//process a refresh token
}
//See if we have an application authorized with JWT
else if (entity.RequestArgs.IsArgumentSet("grant_type", "application"))
{
if(entity.RequestArgs.TryGetNonEmptyValue("token", out string? appJwt))
{
//Try to get and verify the app
UserApplication? app = GetApplicationFromJwt(appJwt);
//generate token
return await GenerateTokenAsync(entity, app);
}
}
//Check for grant_type parameter from the request body
else if (entity.RequestArgs.IsArgumentSet("grant_type", "client_credentials"))
{
//Get client id and secret (and make sure theyre not empty
if (entity.RequestArgs.TryGetNonEmptyValue("client_id", out string? clientId) &&
entity.RequestArgs.TryGetNonEmptyValue("client_secret", out string? secret))
{
if (!ValidatorExtensions.OnlyAlphaNumRegx.IsMatch(clientId))
{
//respond with error message
entity.CloseResponseError(HttpStatusCode.UnprocessableEntity, ErrorType.InvalidRequest, "Invalid client_id");
return VfReturnType.VirtualSkip;
}
if (!ValidatorExtensions.OnlyAlphaNumRegx.IsMatch(secret))
{
//respond with error message
entity.CloseResponseError(HttpStatusCode.UnprocessableEntity, ErrorType.InvalidRequest, "Invalid client_secret");
return VfReturnType.VirtualSkip;
}
//Convert the clientid and secret to lowercase
clientId = clientId.ToLower(null);
secret = secret.ToLower(null);
//Convert secret to private string that is unreferrenced
using PrivateString secretPv = new(secret, false);
//Get the application from apps store
UserApplication? app = await Applications.VerifyAppAsync(clientId, secretPv);
return await GenerateTokenAsync(entity, app);
}
}
entity.CloseResponseError(HttpStatusCode.BadRequest, ErrorType.InvalidClient, "Invalid grant type");
//Default to bad request
return VfReturnType.VirtualSkip;
}
private UserApplication? GetApplicationFromJwt(string jwtData)
{
ReadOnlyJsonWebKey? verificationKey = JWTVerificationKey.Value;
//Not enabled
if (verificationKey == null)
{
return null;
}
//Parse application token
using JsonWebToken jwt = JsonWebToken.Parse(jwtData);
//verify the application jwt
if (!jwt.VerifyFromJwk(verificationKey))
{
return null;
}
using JsonDocument doc = jwt.GetPayload();
//Get expiration time
DateTimeOffset exp = doc.RootElement.GetProperty("exp").GetDateTimeOffset();
//Check if token is expired
return exp < DateTimeOffset.UtcNow ? null : UserApplication.FromJwtDoc(doc.RootElement);
}
private async Task GenerateTokenAsync(HttpEntity entity, UserApplication? app)
{
if (app == null)
{
//App was not found or the credentials do not match
entity.CloseResponseError(HttpStatusCode.UnprocessableEntity, ErrorType.InvalidClient, "The credentials are invalid or do not exist");
return VfReturnType.VirtualSkip;
}
IOAuth2TokenResult? result = await TokenFactory.CreateAccessTokenAsync(entity, app, entity.EventCancellation);
if (result == null)
{
entity.CloseResponseError(HttpStatusCode.TooManyRequests, ErrorType.TemporarilyUnabavailable, "You have reached the maximum number of valid tokens for this application");
return VfReturnType.VirtualSkip;
}
//Create the new response message
OauthTokenResponseMessage tokenMessage = new()
{
AccessToken = result.AccessToken,
IdToken = result.IdentityToken,
//set expired as seconds in int form
Expires = result.ExpiresSeconds,
RefreshToken = result.RefreshToken,
TokenType = result.TokenType
};
//Respond with the token message
entity.CloseResponseJson(HttpStatusCode.OK, tokenMessage);
return VfReturnType.VirtualSkip;
}
}
}