Skip to content

Commit

Permalink
feat: pre-signed URLs for GET/SET (#42)
Browse files Browse the repository at this point in the history
  • Loading branch information
virratanasangpunth authored Apr 6, 2022
1 parent 9131cd8 commit 103b0cf
Show file tree
Hide file tree
Showing 5 changed files with 251 additions and 0 deletions.
4 changes: 4 additions & 0 deletions Momento/Exceptions/InvalidArgumentException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,9 @@ public class InvalidArgumentException : ClientSdkException
public InvalidArgumentException(string message) : base(message)
{
}

public InvalidArgumentException(string message, Exception e) : base(message, e)
{
}
}
}
3 changes: 3 additions & 0 deletions Momento/MomentoSdk.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,19 @@
<None Remove="JWT" />
<None Remove="Exceptions\" />
<None Remove="Responses\" />
<None Remove="Requests\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Google.Protobuf" Version="3.19.0" />
<PackageReference Include="Grpc.Net.Client" Version="2.40.0" />
<PackageReference Include="Grpc.Core" Version="2.41.1" />
<PackageReference Include="Momento" Version="0.8.0" />
<PackageReference Include="JWT" Version="8.4.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.16.0" />
</ItemGroup>
<ItemGroup>
<Folder Include="Exceptions\" />
<Folder Include="Responses\" />
<Folder Include="Requests\" />
</ItemGroup>
</Project>
101 changes: 101 additions & 0 deletions Momento/MomentoSigner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using System;
using System.IdentityModel.Tokens.Jwt;
using Microsoft.IdentityModel.Tokens;
using MomentoSdk.Exceptions;

namespace MomentoSdk
{
public class MomentoSigner
{
private readonly JwtHeader jwtHeader;

public MomentoSigner(string jwkJsonString)
{
try
{
var securityKey = new JsonWebKey(jwkJsonString);
var credentials = new SigningCredentials(securityKey, securityKey.Alg);
this.jwtHeader = new JwtHeader(credentials);
}
catch (Exception e)
{
throw new InvalidArgumentException($"Invalid JWK: {jwkJsonString}", e);
}
}

/// <summary>
/// Create a pre-signed HTTPS URL.
/// </summary>
/// <param name="hostname">Hostname of the SimpleCacheService. Use the value returned from CreateSigningKey's response.</param>
/// <param name="signingRequest">The parameters used for generating a pre-signed URL</param>
/// <returns></returns>
public string CreatePresignedUrl(string hostname, SigningRequest signingRequest)
{
var jwtToken = SignAccessToken(signingRequest);
var cacheName = signingRequest.CacheName;
var cacheKey = signingRequest.CacheKey;

return signingRequest.CacheOperation switch
{
CacheOperation.GET => $"https://{hostname}/cache/get/{cacheName}/{cacheKey}?token={jwtToken}",
CacheOperation.SET => $"https://{hostname}/cache/set/{cacheName}/{cacheKey}?ttl_milliseconds={signingRequest.TtlSeconds * (ulong)1000}&token={jwtToken}",
_ => throw new NotImplementedException($"Unhandled {signingRequest.CacheOperation}")
};
}

/// <summary>
/// Create the signature for auth to be used in JWT.
/// </summary>
/// <param name="hostname">Hostname of the SimpleCacheService. Use the value returned from CreateSigningKey's response.</param>
/// <param name="signingRequest">The parameters used for generating a pre-signed URL</param>
/// <returns></returns>
public string SignAccessToken(SigningRequest signingRequest)
{
var payload = CommonJwtBody(signingRequest.CacheName, signingRequest.CacheKey, signingRequest.ExpiryEpochSeconds);
switch (signingRequest.CacheOperation)
{
case CacheOperation.GET:
{
payload.Add("method", new string[] { "get" });
break;
}
case CacheOperation.SET:
{
payload.Add("method", new string[] { "set" });
payload.Add("ttl", signingRequest.TtlSeconds);
break;
}
default:
{
throw new NotImplementedException($"Unhandled {signingRequest.CacheOperation}");
}
}

return CreateJwtToken(payload);
}

private string CreateJwtToken(JwtPayload jwtPayload)
{
try
{
var jwtToken = new JwtSecurityToken(this.jwtHeader, jwtPayload);
return new JwtSecurityTokenHandler().WriteToken(jwtToken);
}
catch (Exception e)
{
throw new InvalidArgumentException($"Invalid JWK alg: {jwtHeader.Alg}", e);
}
}


private JwtPayload CommonJwtBody(string cacheName, string cacheKey, uint expiryEpochSeconds)
{
return new JwtPayload()
{
{ "exp", expiryEpochSeconds },
{ "cache", cacheName },
{ "key", cacheKey }
};
}
}
}
59 changes: 59 additions & 0 deletions Momento/Requests/SigningRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System;
namespace MomentoSdk
{
public enum CacheOperation
{
SET,
GET
};

public class SigningRequest
{
/// <summary>
/// The name of the cache.
/// </summary>
public string CacheName
{
get;
}
/// <summary>
/// The key of the object.
/// </summary>
public string CacheKey
{
get;
}
/// <summary>
/// The operation performed on the item in the cache.
/// </summary>
public CacheOperation CacheOperation
{
get;
}
/// <summary>
/// The timestamp that the pre-signed URL is valid until.
/// </summary>
public uint ExpiryEpochSeconds
{
get;
}
/// <summary>
/// Time to Live for the item in Cache.
/// This is an optional property that will only be used for CacheOperation.SET
/// </summary>
public uint TtlSeconds
{
get;
set;
}

public SigningRequest(string cacheName, string cacheKey, CacheOperation cacheOperation, uint expiryEpochSeconds)
{
CacheName = cacheName;
CacheKey = cacheKey;
CacheOperation = cacheOperation;
ExpiryEpochSeconds = expiryEpochSeconds;
}

}
}
84 changes: 84 additions & 0 deletions MomentoTest/MomentoSignerTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using System;
using Xunit;
using MomentoSdk;
using System.IdentityModel.Tokens.Jwt;
using Microsoft.IdentityModel.Tokens;
using System.Web;
using MomentoSdk.Exceptions;

namespace MomentoTest
{
public class MomentoSignerTest
{
private const string RS256_JWK = "{\"p\":\"_oJd2v0VrexsvlfO0O0i6MFfgy3yyRh6cUjCOrFxGccFVFsIBfa6zbo78Wsx_kDx75Z4k9x_Mw8lueP7e4nGyzDDODPo2l5ERnVvtcKndN1N9WKp8MJHm7T9FTx6tXf2f6sfUmzDchTGZT3MuZ5K1lYpfAcWb9G7e_1kiUMfgTU\",\"kty\":\"RSA\",\"q\":\"vTSy0-0qS71GDt9e6Tj12XO_bhDVifgrkrSL92llmfRPxv7KQeUE3gDO90YR6K7PHBbcB60Z69lTpk374zniI4E38mvSmB4box0E2ATpzMvX6on-H4mCjUvePd2Qj0JAg2Uyqeze9lunh2C2KXul4VHzBiygCOIHu_r86Wa5iPs\",\"d\":\"Ou0zcMZ6gL3c79W82wqVqZTggJZSDOe_1l4klAvitTeg48nE31nQAzeT0cn8YyjY2nDA9iBc8jGrCnV9HaYhRlCZ_XE8036HVm9Sw1WjmJqDqWyhDsfV4o74jcxn8QNOKLX2NcWJxo11kOWdMh5AMYXQt2xh2yeV4QG996-EnC-8aoBYbRJO93ZTiJss1FPgXfe8gEJnv27ShjD1MjCl2ikGvTa5HOX3aUYvPJezN_HvKfovbp9h77DE5y9V2KewqGI5m25b97CzBn_pcQioiPlzoHck_x9C77GOsySOg3C3Le0fJdGXgMJmyBrDlmjF7YfeRbqwxUSX0CzdubVldQ\",\"e\":\"AQAB\",\"kid\":\"fooKeyId\",\"qi\":\"CnV6ziWMCxQvPe447kifPXAY9fqMv47RcHZmS3Co1EQNnP1CGO5H8tFgAso6IxPWyFexaRqPtX8JYgpjiR3fm-PwjD7RI50XU3cNOw2SRxpdA5KDGgZXecMOx81DRTBPAtDOUII-pmEfvJKXVQCiRnoiFbFqr66nXaO6mTe0Xm4\",\"dp\":\"MsSsli9f8La1pm57md-D1CwmslMrGAQjAJAD9pNIvVye6onSGuZxsvIQXQMGEPLBkApS-SPF19iQrPkWRDlih0ut1Xs9WrntIqTwaLBwmPZAQ8-vmJAYmq3Kwj6zN5m7eRIYiGebwRj1zmI6gVhbE1BSrCP5zMpofL46HMtf8HU\",\"alg\":\"RS256\",\"dq\":\"G5LliOskYdtYrWwyOcz6T1GGEXVUmYHYX83-I_VxQCmRws95DHdi6TO29eR5Ua7AMjjGojvA7lVC0pbE4c2avk_jpmm-TDr_Dht5jD3TEOyYL-8iYNg6dXscDWoP2kDug_eolYkWyVJ8LMeUZKFHgHnf8ANq40CFngiq-RzmZyc\",\"n\":\"vBqjjxPSxtoLutQ5C0ivkbQmjFoJNIMC4CjWDK_gMTU2wF8S5g3FRSsGjqIj0EIWUtQOn_wMU5n98bRRnOwhGVRqFsWNwNwxQ_v_9VTSPBDWOJOd9Zlsey6UjklA7SfikqCgwBK_hDIaWhrlt7vY0Zu2eMdGTVb9_lyfDPfEiv5ONPOnaFK98EP-WYvmfulaWvqTTUVHaRZps4sZsrftsVShupBKjzttADXz9KRePOjUgxIAfg42yAm1YnGIJk36tdUo_HczpDQat0UdI0x4gI3baNdXIYdDazzziJyxbZaD9c7ii1Vm4PAyhAIpxeIh8TDsrzUyTneGaVK6CdTW9w\"}";
private const string ES256_JWK = "{\"kty\":\"EC\",\"d\":\"wLj6mq_IqGAqz40RyR1QiH1KElvhECQ8dQAcu7iRwWE\",\"crv\":\"P-256\",\"kid\":\"fooKeyId\",\"x\":\"pSU203ud3cNnVeCgaho2z-JBao21EHFm4or75sV8RkY\",\"y\":\"uSwevlzSV3kyArKAu7qv7I_ffaXAvAp98YM0zwUA5jA\",\"alg\":\"ES256\"}";
private const string RS384_JWK = "{\"p\":\"_LVm8mWfx8Td7zg7Xupll_2pVGyhwmos18Mi6_vr0DglnDWOg3nsfjaS6j5a_mwgeEHUUjXaORf5zZMI3hVaD9-1WaaErmrUlZuNDvAtgb1MLLzgzP2WYmdAAzSn1-0fOqUiiZtAADEr-gmCrN-ofbDcKmppdwA1DbOaCMmVvc0\",\"kty\":\"RSA\",\"q\":\"yr9ZUQAotA7KllPB7klkeDwfDv2FIz-N7Yms20Nv20oALB3XLWlEkG85AOuYwAk-gXBtu45piM4D3jfHDsP5uycvXdxVGlna56XKUjgfSHW3UdfKxhd9FzOY4MvPG_Aj1rdkuNOPCNUGtRRbXA3HNjhBoJoSg5dbSCwnQzi10rM\",\"d\":\"DsnOr7w7Zo5qmNMr7sfhKvtQOJFaUwS_IhLyyNntmn57FuqlqMO1cv7_uzRer-LbI9dF7J4DVjtf6jEe7CcbYQHMCQqbj2RPrubprqYojjBKVtSlUt9hOIK8DswgyrXX8JnpMsLIw8Mdvyo5EbCw7tt4qKeTePFk-xA7GAqHi3FLisst3ijkpQT5OjbUA1GYMIijzJfcFOgjtbvLghDl1XLW29ZP3K1MYkNtX9P3bbbI5GRwWSo5SsGzzcNCbI4unfHU8MIf-cDUDopeAM6ixjB-OXzz6Fq4J-XeIS0-4vZMYD2l5OuD0gQfkCiYfOyrNPL0KqZxolioRvD1dRzTXQ\",\"e\":\"AQAB\",\"kid\":\"fooKeyId\",\"qi\":\"YWx6rzgadSyrpCc4YTmq_ly_JHXxPO5ddZoWmukVb_tF-3E-sBaOd7jBj4OJnrHqTz2EkwUw8krexoptZEvMPjvLRxdubwgqw6d8SCC-6DKOfUJB43diY56lzIxlIA32BemLx6B0SrBIbgWhY1IkAJlj2AiAh01ygDh1tH-FoKI\",\"dp\":\"x152MZZrUDfIwAolDOTv8dF13d02YSNS7YZN7s95Y3Rod6zpGmD-azSzA4reTwsPMtD8qT9DQvffZIgz3sIJo6xibrAozVILFVz7FGX4APtPNZxt3kvScR_0KJNKN9gjYykU7mtFOuGQSFtodOqfC0qU6AG74t6O_JhNVdF0CaE\",\"alg\":\"RS384\",\"dq\":\"C53ZFT4IFwD99I0KAIgt_IGdWfOGrFVY4XJQ-CMuBod_6QcwrAZrCkeFIZteHiqpbSsu7l8jhtYe_J1_h0YNSf7dxOf57E-XrkwegoV6rWEpRsQxdxYjca_gI4kp7bTdqNDLMZfVizEBeGCZN3YGowGoKPaK9wU2ErWM7loSeOc\",\"n\":\"yCQGvhlh6_RTK3gtJPgllHHUYT_hx9xgeDhLRQAadHd0FjCCBHXLisTABHIu568rn86chznbzN0yLDJ-C5Q8YM46xxJZgDfG3Sq4NlZJcrxjBeTIVXeqWj0W5HDK8PDIYgEZfXmJ4AKeB3PQT5o25zN92ja45bFHk8vHBo_TECYYxHvI7TzbMOs5Z4PvWQnfR34GR0TvD4iHSEq6r0TTrYhdtLRq-PsIOk--0subO7uTEVqLCU99wQmsty0VacmIeezuap2OgR0EG4nlAzjqs8EyZqJKin8b9dRfQm8UOpQ1GbU7pKxymQc7pb1K54YSXFF5lYalcQOWlhmM01bgVw\"}";

[Theory]
[InlineData(RS256_JWK)]
[InlineData(ES256_JWK)]
[InlineData(RS384_JWK)]
public void TestJwkRoundTrip(string jwk)
{
MomentoSigner signer = new MomentoSigner(jwk);
uint expiryEpochSeconds = uint.MaxValue;
var url = signer.CreatePresignedUrl("foobar.com", new SigningRequest("testCacheName", "testCacheKey", CacheOperation.GET, expiryEpochSeconds)); ;

string jwt = HttpUtility.ParseQueryString(new Uri(url).Query).Get("token");

var securityKey = new JsonWebKey(jwk);
TokenValidationParameters validationParameters = new TokenValidationParameters()
{
RequireExpirationTime = false,
ValidateAudience = false,
ValidateIssuer = false,
IssuerSigningKey = securityKey
};
SecurityToken validatedToken;
new JwtSecurityTokenHandler().ValidateToken(jwt, validationParameters, out validatedToken);
Assert.Equal(new DateTime(2106, 02, 07, 06, 28, 15), validatedToken.ValidTo);
}

[Fact]
public void TestPresignedUrlForGet()
{
MomentoSigner signer = new MomentoSigner(RS256_JWK);
uint expiryEpochSeconds = uint.MaxValue;
var url = signer.CreatePresignedUrl("foobar.com", new SigningRequest("testCacheName", "testCacheKey", CacheOperation.GET, expiryEpochSeconds));

Uri uriResult;
bool result = Uri.TryCreate(url, UriKind.Absolute, out uriResult);
Assert.True(result);
Assert.Equal(Uri.UriSchemeHttps, uriResult.Scheme);
Assert.StartsWith("https://foobar.com/cache/get/testCacheName/testCacheKey?token=", url);
}

[Fact]
public void TestPresignedUrlForSet()
{
MomentoSigner signer = new MomentoSigner(RS256_JWK);
uint expiryEpochSeconds = uint.MaxValue;
var req = new SigningRequest("testCacheName", "testCacheKey", CacheOperation.SET, expiryEpochSeconds)
{
TtlSeconds = uint.MaxValue
};
var url = signer.CreatePresignedUrl("foobar.com", req);

Uri uriResult;
bool result = Uri.TryCreate(url, UriKind.Absolute, out uriResult);
Assert.True(result);
Assert.Equal(Uri.UriSchemeHttps, uriResult.Scheme);
Assert.StartsWith("https://foobar.com/cache/set/testCacheName/testCacheKey?ttl_milliseconds=4294967295000&token=", url);
}

[Fact]
public void TestJwkError()
{
uint expiryEpochSeconds = uint.MaxValue;
var invalidJwk = "{\"alg\":\"foo\"}";
MomentoSigner signer = new MomentoSigner(invalidJwk);

Assert.Throws<InvalidArgumentException>(() => signer.SignAccessToken(new SigningRequest("testCacheName", "testCacheKey", CacheOperation.GET, expiryEpochSeconds)));
}
}
}

0 comments on commit 103b0cf

Please sign in to comment.