From 3d6b8e00d37285d9d6158f50db4d724114649510 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Wed, 23 Aug 2023 15:37:15 -0300 Subject: [PATCH] Add signing function and update deployment dir This was previously ignored from a glob too. Fixes #31 --- .github/workflows/build.yml | 4 +- .gitignore | 2 +- src/App/Auth0.cs | 73 +++++++++++++++++++++++++++++++ src/App/Signing.cs | 85 +++++++++++++++++++++++++++++++++++++ src/App/Startup.cs | 12 ++++++ 5 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 src/App/Auth0.cs create mode 100644 src/App/Signing.cs create mode 100644 src/App/Startup.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 395f2b40..7115afbf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,7 +29,7 @@ jobs: run: | dotnet build -m:1 dotnet build ./samples/dotnet/ - dotnet build src/App --output ./output + dotnet build src/App --output ./app - name: 🧪 test run: dotnet test --filter SponsorLink=true @@ -48,7 +48,7 @@ jobs: uses: Azure/functions-action@v1 with: app-name: ${{ vars.AZURE_APPNAME }} - package: ./output + package: ./app - name: 🚀 sleet env: diff --git a/.gitignore b/.gitignore index 0c18de79..4bf9f2ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ +/app bin -app obj artifacts pack diff --git a/src/App/Auth0.cs b/src/App/Auth0.cs new file mode 100644 index 00000000..30c041ba --- /dev/null +++ b/src/App/Auth0.cs @@ -0,0 +1,73 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net.Http.Headers; +using System.Security.Claims; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; + +namespace Devlooped.SponsorLink; + +static class Auth0 +{ + static readonly IConfigurationManager configuration; + + static readonly string Issuer = "https://sponsorlink.us.auth0.com/"; + static readonly string Audience = "https://sponsorlink.devlooped.com"; + + static Auth0() + { + var documentRetriever = new HttpDocumentRetriever { RequireHttps = true }; + + configuration = new ConfigurationManager( + $"{Issuer}.well-known/openid-configuration", + new OpenIdConnectConfigurationRetriever(), + documentRetriever + ); + } + + public static async Task ValidateTokenAsync(AuthenticationHeaderValue value) + { + if (value?.Scheme != "Bearer") + return null; + + var config = await configuration.GetConfigurationAsync(CancellationToken.None); + + var validationParameter = new TokenValidationParameters + { + RequireSignedTokens = true, + ValidAudience = Audience, + ValidateAudience = true, + ValidIssuer = Issuer, + ValidateIssuer = true, + ValidateIssuerSigningKey = true, + ValidateLifetime = true, + IssuerSigningKeys = config.SigningKeys + }; + + ClaimsPrincipal? result = default; + var tries = 0; + + while (result == null && tries <= 1) + { + try + { + var handler = new JwtSecurityTokenHandler(); + return handler.ValidateToken(value.Parameter, validationParameter, out var _); + } + catch (SecurityTokenSignatureKeyNotFoundException) + { + // This exception is thrown if the signature key of the JWT could not be found. + // This could be the case when the issuer changed its signing keys, so we trigger a + // refresh and retry validation. + configuration.RequestRefresh(); + tries++; + } + catch (SecurityTokenException) + { + return null; + } + } + + return result; + } +} diff --git a/src/App/Signing.cs b/src/App/Signing.cs new file mode 100644 index 00000000..ebb458ac --- /dev/null +++ b/src/App/Signing.cs @@ -0,0 +1,85 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Web.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.WebJobs; +using Microsoft.Azure.WebJobs.Extensions.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; + +namespace Devlooped.SponsorLink; + +public static class Signing +{ + static RSA? rsa; + + [FunctionName("sign")] + public static async Task RunAsync( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "sign")] HttpRequestMessage req, IConfiguration configuration, ILogger logger) + { + rsa ??= InitializeKey(configuration, logger); + if (rsa == null) + return new InternalServerErrorResult(); + + if (req.Headers.Authorization is null || + await Auth0.ValidateTokenAsync(req.Headers.Authorization) is not ClaimsPrincipal principal || + principal.FindFirst(ClaimTypes.NameIdentifier)?.Value.Split('|')?[1] is not string id) + return new UnauthorizedResult(); + + var validation = new TokenValidationParameters + { + RequireExpirationTime = true, + ValidAudience = "SponsorLink", + ValidIssuer = "Devlooped", + ValidateIssuerSigningKey = false, + }; + + if (req.Content == null) + return new BadRequestResult(); + + var jwt = new JwtSecurityTokenHandler().ReadJwtToken(await req.Content.ReadAsStringAsync()); + if (jwt.Issuer != "Devlooped" || jwt.Audiences.FirstOrDefault() != "SponsorLink") + return new BadRequestResult(); + + // "sub" claim must match between token claims and principal + if (jwt.Claims.FirstOrDefault(c => c.Type == "sub")?.Value != id) + return new BadRequestResult(); + + var signing = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256); + + var signed = new JwtSecurityToken( + issuer: jwt.Issuer, + audience: jwt.Audiences.First(), + claims: jwt.Claims, + // Expire at the end of the month + expires: new DateTime(DateTime.Today.Year, DateTime.Today.Month + 1, 1, 0, 0, 0, DateTimeKind.Utc), + signingCredentials: signing); + + // Serialize the token and return as a string + var body = new JwtSecurityTokenHandler().WriteToken(signed); + + return new ContentResult + { + Content = body, + ContentType = "text/plain", + StatusCode = (int)HttpStatusCode.OK, + }; + } + + static RSA? InitializeKey(IConfiguration configuration, ILogger logger) + { + if (configuration["SPONSORLINK_KEY"] is not string key) + { + logger.LogError("Missing SPONSORLINK_KEY configuration"); + return null; + } + + var rsa = RSA.Create(); + rsa.ImportRSAPrivateKey(Convert.FromBase64String(key), out _); + + return rsa; + } +} diff --git a/src/App/Startup.cs b/src/App/Startup.cs new file mode 100644 index 00000000..9ce1c4d9 --- /dev/null +++ b/src/App/Startup.cs @@ -0,0 +1,12 @@ +using Microsoft.Azure.Functions.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; + +[assembly: FunctionsStartup(typeof(Startup))] + +public class Startup : FunctionsStartup +{ + public override void Configure(IFunctionsHostBuilder builder) { } + + public override void ConfigureAppConfiguration(IFunctionsConfigurationBuilder builder) + => builder.ConfigurationBuilder.AddUserSecrets(ThisAssembly.Project.UserSecretsId); +} \ No newline at end of file