Google OAuth2 Service Account Access Token Request gives 'Invalid Request' Response - c#

I'm trying to communicate with my app's enabled BigQuery API via the server to server method.
I've ticked all the boxes on this Google guide for constructing my JWT as best I can in C#.
And I've Base64Url encoded everything that was necessary.
However, the only response I get from google is a 400 Bad Request
"error" : "invalid_request"
I've made sure of all of the following from these other SO questions:
The signature is properly encrypted using RSA and SHA256
I am using POST and using application/x-www-form-urlencoded content type
Escaped all the backslashes in the claim set
Tried various grant_type and assertion values in the POST data
I get the same result when I use Fiddler. The error message is frustratingly lacking in detail! What else can I try?! Here's my code:
class Program
{
static void Main(string[] args)
{
// certificate
var certificate = new X509Certificate2(#"<Path to my certificate>.p12", "notasecret");
// header
var header = new { typ = "JWT", alg = "RS256" };
// claimset
var times = GetExpiryAndIssueDate();
var claimset = new
{
iss = "<email address of the client id of my app>",
scope = "https://www.googleapis.com/auth/bigquery",
aud = "https://accounts.google.com/o/oauth2/token",
iat = times[0],
exp = times[1],
};
// encoded header
var headerSerialized = JsonConvert.SerializeObject(header);
var headerBytes = Encoding.UTF8.GetBytes(headerSerialized);
var headerEncoded = Base64UrlEncode(headerBytes);
// encoded claimset
var claimsetSerialized = JsonConvert.SerializeObject(claimset);
var claimsetBytes = Encoding.UTF8.GetBytes(claimsetSerialized);
var claimsetEncoded = Base64UrlEncode(claimsetBytes);
// input
var input = headerEncoded + "." + claimsetEncoded;
var inputBytes = Encoding.UTF8.GetBytes(input);
// signiture
var rsa = certificate.PrivateKey as RSACryptoServiceProvider;
var cspParam = new CspParameters
{
KeyContainerName = rsa.CspKeyContainerInfo.KeyContainerName,
KeyNumber = rsa.CspKeyContainerInfo.KeyNumber == KeyNumber.Exchange ? 1 : 2
};
var aescsp = new RSACryptoServiceProvider(cspParam) { PersistKeyInCsp = false };
var signatureBytes = aescsp.SignData(inputBytes, "SHA256");
var signatureEncoded = Base64UrlEncode(signatureBytes);
// jwt
var jwt = headerEncoded + "." + claimsetEncoded + "." + signatureEncoded;
Console.WriteLine(jwt);
var client = new HttpClient();
var uri = "https://accounts.google.com/o/oauth2/token";
var post = new Dictionary<string, string>
{
{"assertion", jwt},
{"grant_type", "urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer"}
};
var content = new FormUrlEncodedContent(post);
var result = client.PostAsync(uri, content).Result;
Console.WriteLine(result);
Console.WriteLine(result.Content.ReadAsStringAsync().Result);
Console.ReadLine();
}
private static int[] GetExpiryAndIssueDate()
{
var utc0 = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc);
var issueTime = DateTime.Now;
var iat = (int)issueTime.Subtract(utc0).TotalSeconds;
var exp = (int)issueTime.AddMinutes(55).Subtract(utc0).TotalSeconds;
return new[]{iat, exp};
}
private static string Base64UrlEncode(byte[] input)
{
var output = Convert.ToBase64String(input);
output = output.Split('=')[0]; // Remove any trailing '='s
output = output.Replace('+', '-'); // 62nd char of encoding
output = output.Replace('/', '_'); // 63rd char of encoding
return output;
}
}

Looks like my guess in the comment above was correct. I got your code working by changing:
"urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer"
to:
"urn:ietf:params:oauth:grant-type:jwt-bearer"
Looks like you were accidentally double-encoding it.
I now get a response which looks something like:
{
"access_token" : "1/_5pUwJZs9a545HSeXXXXXuNGITp1XtHhZXXxxyyaacqkbc",
"token_type" : "Bearer",
"expires_in" : 3600
}
Edited Note: please make sure to have the correct date/time/timezone/dst configuration on your server. Having the clock off by even a few seconds will result in an invalid_grant error. http://www.time.gov will give the official time from the US govt, including in UTC.

Be sure to use DateTime.UtcNow instead of DateTime.Now in the GetExpiryAndIssueDate method.

Related

Algotrading: MEXC Authentication issue C#

I'm building a MEXC connector and I use their official docs here: https://mxcdevelop.github.io/APIDoc/open.api.v2.en.html#request-format
I have an issue with the authentication method they provided. I have an authentication function which I took from their example code in the docs (which is here). I use RestSharp to connect to the REST API. However, I get authentication errors when I try to post a spot trade order. Other methods work fine which still have authentication. Here is my Place Order method:
public async Task<PlaceOrder> GetPlaceOrderAsync(string symbol, string price, string quantity, string trade_type, string order_type)
{
PlaceOrder result = new PlaceOrder();
var Params = new Dictionary<string, string>
{
{"order_type", order_type },
{"price" , price },
{"quantity", quantity },
{"symbol" , symbol },
{"trade_type", trade_type },
};
var serializedParams = JsonConvert.SerializeObject(Params);
var paramString = getRequestParamString(Params);
var encodedString = urlEncode(paramString);
var Timestamp = DateTime.UtcNow.ToUnixTimeMilliseconds().ToString();
var signature = sign(Timestamp, paramString);
var cleanedSignature = signature.Replace("-", "");
var restClient = new RestClient();
var restRequest = new RestRequest("https://www.mexc.com/open/api/v2/order/place?" +
"api_key=" + this.ApiKey +
"&req_time=" + Timestamp +
"&sign=" + cleanedSignature,
Method.POST);
restRequest.AddHeader("ApiKey", this.ApiKey);
restRequest.AddHeader("Signature", cleanedSignature.ToLowerInvariant());
restRequest.AddHeader("Request-Time", Timestamp);
restRequest.AddHeader("content-type", "application/json");
restRequest.AddBody(serializedParams);
restRequest.RequestFormat = DataFormat.Json;
var restResponse = await restClient.ExecuteAsync(restRequest);
result = JsonConvert.DeserializeObject<PlaceOrder>(restResponse.Content);
return result;
What I get in return as a response is this:
"{"msg":"authorize failed","code":401}"
Which indicates that there is something wrong with the authentication but I have used the same authentication method in other private methods which require authentication. For comparison here is a User Balance checking method, note that balance method does not require any parameters so I'm sending an empty dictionary:
public async Task<Balance> GetBalanceAsync()
{
var Params = new Dictionary<string, string>
{
};
var paramString = getRequestParamString(Params);
var encodedString = urlEncode(paramString);
var Timestamp = DateTime.UtcNow.ToUnixTimeMilliseconds().ToString();
var signature = sign(Timestamp, encodedString);
var cleanedSignature = signature.Replace("-", "");
Balance result = new Balance();
var restClient = new RestClient();
var restRequest = new RestRequest("https://www.mexc.com/open/api/v2/account/info", Method.GET);
restRequest.AddHeader("ApiKey", this.ApiKey);
restRequest.AddHeader("Signature", cleanedSignature.ToLowerInvariant());
restRequest.AddHeader("Request-Time", Timestamp);
var restResponse = await restClient.ExecuteAsync(restRequest);
result = JsonConvert.DeserializeObject<Balance>(restResponse.Content);
return result;
}
If anyone could point out something that I can't see I would really appreciate it. MEXC integration is not very widespread on the net so I'm struggling to find other examples.

Facebook data deletion callback gives "Unable to confirm request was received"

I just convert PHP sample code to C#. This is my endpoint:
public async Task<IHttpActionResult> Delete([FromBody] FbModel fbModel)
{
var res = fbModel.signed_request.Split(new char[] { '.' }, 2);
var secret = "ababababababababababababa";
if (res.Length > 1)
{
var sig = res[0];
var json = base64decode(res[1]);
var data = Newtonsoft.Json.JsonConvert.DeserializeObject<FacebookDeletionDto>(json);
if (string.IsNullOrEmpty(data.algorithm) || data.algorithm.ToUpper() != "HMAC-SHA256")
throw new Exception("Unknown algorithm:" + data.algorithm + ". Expected HMAC-SHA256");
var expected_sig = hmacSHA256(res[1], secret);
if (expected_sig != sig)
throw new Exception("Invalid signature:" + sig + ". Expected" + expected_sig);
var returnJson = new { Url = $"https://myperfectsite.com/fb/info/{data.user_id}", confirmation_code = $"{data.user_id}" };
return Ok(returnJson);
}
return null;
}
This code running perfectly and gives me json.
My endpoint return URL and confirmation code in JSON. But in facebook confirmation page it gives me this error :"Unable to confirm request was received
"App name" sent an invalid response to your request. Contact "App name" directly to request it delete info it has about you."
Facebook provides only the following info:
Return a JSON response that contains a URL where the user can check the status
of their deletion request and an alphanumeric confirmation code.
The JSON response has the following form:
{ url: '<url>', confirmation_code: '<code>' }
I ran into the same problem where FB would not accept our server response.
We ultimately fixed the problem by outputting the JSON response in EXACTLY the same sample format. Property names lowercase and not quoted, single quotes around the values, and a value that was alphanumeric (no symbols) and not too long (20 char length worked).
EDIT: Looking back at it, looks like we also used a dynamic type for the response.
dynamic response = new ExpandoObject();
response.url = "<url>";
response.confirmation_code = "<code>";
return response;
I notice that while in facebook documentation "expected_sig" must be equal to "sig" which must be the result of base64_url decoding of $encoded_sig in your code "expected_sig" is compared to the sign not decoded.
Hope this could be helpful.
var sig = res[0];
var json = base64decode(res[1]);
...
if (expected_sig != sig)
throw new Exception(...)
Correct should be as follow:
var sig = base64decode(res[0]);
var json = base64decode(res[1]);
...
if (expected_sig != sig)
throw new Exception(...)
Instead of:
Delete([FromBody] FbModel fbModel)
I use:
string signed_request = Request.Form["signed_request"];
Here is my working code:
public async Task<IActionResult> Delete()
{
string signed_request = Request.Form["signed_request"];
if (!String.IsNullOrEmpty(signed_request))
{
string[] split = signed_request.Split('.');
string signatureRaw = base64decode(split[0]);
string dataRaw = base64decode(split[1]);
// the decoded signature
byte[] signature = Convert.FromBase64String(signatureRaw);
byte[] dataBuffer = Convert.FromBase64String(dataRaw);
// JSON object
var json = Encoding.UTF8.GetString(dataBuffer);
byte[] appSecretBytes = Encoding.UTF8.GetBytes("SecretKey");
System.Security.Cryptography.HMAC hmac = new System.Security.Cryptography.HMACSHA256(appSecretBytes);
byte[] expectedHash = hmac.ComputeHash(Encoding.UTF8.GetBytes(split[1]));
if (!expectedHash.SequenceEqual(signature))
{
throw new Exception("Invalid signature");
}
var fbUser = JsonConvert.DeserializeObject<FacebookUserDTO>(json);
return Ok(new { url = $"https://myperfectsite.com/fb/info/{fbUser.user_id}", confirmation_code = $"{fbUser.user_id}" });
}
}

Create a valid JWT Token for DocuSign API

Ι try to create a valid jwt token
From settings i create an RSA keypairs and i get the private key without the "-----BEGIN RSA PRIVATE KEY----------END RSA PRIVATE KEY-----
"
var rsaPrivateKey = #"MIIEogIBAAKCAQEAoGujdXbYVy68a4CSWz963SpYxVs20/..............HQ/jW8pFom6gJreCDkca5axYo/gXp3W3rQHFTkooTNbOk2MyFMZUqRD3aCG1wuUW3w8TgGX4slrLDV0pP4=";
var jwt = Sign(rsaPrivateKey);
I follow the instructions here https://developers.docusign.com/docs/admin-api/admin101/application-auth/ and after a lot of hours i create this method
public string Sign(string privateKey)
{
List<string> segments = new List<string>();
var header = new { alg = "RS256", typ = "JWT" };
//For production environments, use account.docusign.com
var payload = new
{
iss = "4f489d61-dc8b------a828-3992e670dcbc",
iat = (Int32)(DateTime.UtcNow.Subtract(new DateTime(1970, 1, 1))).TotalSeconds,
aud = "account-d.docusign.com",
scope = "signature impersonation"
};
byte[] headerBytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(header, Formatting.None));
byte[] payloadBytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(payload, Formatting.None));
segments.Add(Base64UrlEncode(headerBytes));
segments.Add(Base64UrlEncode(payloadBytes));
string stringToSign = string.Join(".", segments.ToArray());
byte[] bytesToSign = Encoding.UTF8.GetBytes(stringToSign);
byte[] keyBytes = Convert.FromBase64String(privateKey);
var privKeyObj = Asn1Object.FromByteArray(keyBytes);
var privStruct = RsaPrivateKeyStructure.GetInstance((Asn1Sequence)privKeyObj);
ISigner sig = SignerUtilities.GetSigner("SHA256withRSA");
sig.Init(true, new RsaKeyParameters(true, privStruct.Modulus, privStruct.PrivateExponent));
sig.BlockUpdate(bytesToSign, 0, bytesToSign.Length);
byte[] signature = sig.GenerateSignature();
segments.Add(Base64UrlEncode(signature));
return string.Join(".", segments.ToArray());
}
private static string Base64UrlEncode(byte[] input)
{
var output = Convert.ToBase64String(input);
output = output.Split('=')[0]; // Remove any trailing '='s
output = output.Replace('+', '-'); // 62nd char of encoding
output = output.Replace('/', '_'); // 63rd char of encoding
return output;
}
When i check the JWT validation in this tool https://jwt.io/#debugger-io, i get invalid signature error.
How can i fix the token ?? I cant proceed with Step 2 Obtain the access token...
I'm sorry you having problems with JWT. I would recommend you use the DocuSign C# SDK instead of trying to write your own code.
Then you can find the example of how to use JWT here - https://github.com/docusign/code-examples-csharp.
The specific code relevant to JWT is here - https://github.com/docusign/code-examples-csharp/blob/38c2eb46948a3cbf55edcce758f88d775f80cae9/launcher-csharp/Common/RequestItemService.cs under the UpdateUserFromJWT() method.
Common problems with JWT:
Not obtaining consent.
Using public token instead of private.
Using malform token. Token must be exactly, including new-lines, as provided.
Not using correct UserId (GUID) in the request.
Not requesting "impersonation" scope in consent (#1 above).

How to generate JWT Bearer Flow OAuth access tokens from a .net core client?

I'm having trouble getting my .NET Core client to generate OAuth access tokens for a salesforce endpoint that requires OAuth of type 'JWT Bearer Flow'.
It seems there are limited .NET Framework examples that show a .NET client doing this, however none that show a .NET Core client doing it
e.g.
https://salesforce.stackexchange.com/questions/53662/oauth-jwt-token-bearer-flow-returns-invalid-client-credentials
So in my .NET Core 3.1 app i've generated a self signed certificate, added the private key to the above example's code when loading in the certificate, however a System.InvalidCastExceptionexception exception occurs on this line:
var rsa = certificate.GetRSAPrivateKey() as RSACryptoServiceProvider;
Exception:
System.InvalidCastException: 'Unable to cast object of type 'System.Security.Cryptography.RSACng' to type 'System.Security.Cryptography.RSACryptoServiceProvider'.'
It appears that this private key is used in the JWT Bearer Flow as part of the signature, and perhaps RSACryptoServiceProvider is not used in .NET core as it was in .NET Framework.
My question is this - is there actually a way in .NET Core to generate access tokens for the OAuth JWT Bearer Flow?
Full code that I'm using:
static void Main(string[] args)
{
Console.WriteLine("Hello World!");
var token = GetAccessToken();
}
static dynamic GetAccessToken()
{
// get the certificate
var certificate = new X509Certificate2(#"C:\temp\cert.pfx");
// create a header
var header = new { alg = "RS256" };
// create a claimset
var expiryDate = GetExpiryDate();
var claimset = new
{
iss = "xxxxxx",
prn = "xxxxxx",
aud = "https://test.salesforce.com",
exp = expiryDate
};
// encoded header
var headerSerialized = JsonConvert.SerializeObject(header);
var headerBytes = Encoding.UTF8.GetBytes(headerSerialized);
var headerEncoded = ToBase64UrlString(headerBytes);
// encoded claimset
var claimsetSerialized = JsonConvert.SerializeObject(claimset);
var claimsetBytes = Encoding.UTF8.GetBytes(claimsetSerialized);
var claimsetEncoded = ToBase64UrlString(claimsetBytes);
// input
var input = headerEncoded + "." + claimsetEncoded;
var inputBytes = Encoding.UTF8.GetBytes(input);
// signature
var rsa = (RSACryptoServiceProvider) certificate.GetRSAPrivateKey();
var cspParam = new CspParameters
{
KeyContainerName = rsa.CspKeyContainerInfo.KeyContainerName,
KeyNumber = rsa.CspKeyContainerInfo.KeyNumber == KeyNumber.Exchange ? 1 : 2
};
var aescsp = new RSACryptoServiceProvider(cspParam) { PersistKeyInCsp = false };
var signatureBytes = aescsp.SignData(inputBytes, "SHA256");
var signatureEncoded = ToBase64UrlString(signatureBytes);
// jwt
var jwt = headerEncoded + "." + claimsetEncoded + "." + signatureEncoded;
var client = new WebClient();
client.Encoding = Encoding.UTF8;
var uri = "https://login.salesforce.com/services/oauth2/token";
var content = new NameValueCollection();
content["assertion"] = jwt;
content["grant_type"] = "urn:ietf:params:oauth:grant-type:jwt-bearer";
string response = Encoding.UTF8.GetString(client.UploadValues(uri, "POST", content));
var result = JsonConvert.DeserializeObject<dynamic>(response);
return result;
}
static int GetExpiryDate()
{
var utc0 = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc);
var currentUtcTime = DateTime.UtcNow;
var exp = (int)currentUtcTime.AddMinutes(4).Subtract(utc0).TotalSeconds;
return exp;
}
static string ToBase64UrlString(byte[] input)
{
return Convert.ToBase64String(input).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
Well - it turns out posting to stackoverflow gets the brain cogs turning.
The answer ended up being doing a deep dive to find a similar issue here and using the solution from x509certificate2 sign for jwt in .net core 2.1
I ended up replacing the following code:
var cspParam = new CspParameters
{
KeyContainerName = rsa.CspKeyContainerInfo.KeyContainerName,
KeyNumber = rsa.CspKeyContainerInfo.KeyNumber == KeyNumber.Exchange ? 1 : 2
};
var aescsp = new RSACryptoServiceProvider(cspParam) { PersistKeyInCsp = false };
var signatureBytes = aescsp.SignData(inputBytes, "SHA256");
var signatureEncoded = ToBase64UrlString(signatureBytes);
With this code which makes use of the System.IdentityModel.Tokens.Jwt nuget package:
var signingCredentials = new X509SigningCredentials(certificate, "RS256");
var signature = JwtTokenUtilities.CreateEncodedSignature(input, signingCredentials);
Full code after solution:
static void Main(string[] args)
{
Console.WriteLine("Hello World!");
var token = GetAccessToken();
}
static dynamic GetAccessToken()
{
// get the certificate
var certificate = new X509Certificate2(#"C:\temp\cert.pfx");
// create a header
var header = new { alg = "RS256" };
// create a claimset
var expiryDate = GetExpiryDate();
var claimset = new
{
iss = "xxxxx",
prn = "xxxxx",
aud = "https://test.salesforce.com",
exp = expiryDate
};
// encoded header
var headerSerialized = JsonConvert.SerializeObject(header);
var headerBytes = Encoding.UTF8.GetBytes(headerSerialized);
var headerEncoded = ToBase64UrlString(headerBytes);
// encoded claimset
var claimsetSerialized = JsonConvert.SerializeObject(claimset);
var claimsetBytes = Encoding.UTF8.GetBytes(claimsetSerialized);
var claimsetEncoded = ToBase64UrlString(claimsetBytes);
// input
var input = headerEncoded + "." + claimsetEncoded;
var inputBytes = Encoding.UTF8.GetBytes(input);
var signingCredentials = new X509SigningCredentials(certificate, "RS256");
var signature = JwtTokenUtilities.CreateEncodedSignature(input, signingCredentials);
// jwt
var jwt = headerEncoded + "." + claimsetEncoded + "." + signature;
var client = new WebClient();
client.Encoding = Encoding.UTF8;
var uri = "https://test.salesforce.com/services/oauth2/token";
var content = new NameValueCollection();
content["assertion"] = jwt;
content["grant_type"] = "urn:ietf:params:oauth:grant-type:jwt-bearer";
string response = Encoding.UTF8.GetString(client.UploadValues(uri, "POST", content));
var result = JsonConvert.DeserializeObject<dynamic>(response);
return result;
}
static int GetExpiryDate()
{
var utc0 = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc);
var currentUtcTime = DateTime.UtcNow;
var exp = (int)currentUtcTime.AddMinutes(4).Subtract(utc0).TotalSeconds;
return exp;
}
static string ToBase64UrlString(byte[] input)
{
return Convert.ToBase64String(input).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
I am replying to this question just because such a similar answer would have helped me a lot when I landed on this page the first time.
First of all you don't have to generate the JWT from the C# client.
To generate a JWT token you can use this website: https://jwt.io/
There is a very well done video showing how to generate a JWT token:
https://www.youtube.com/watch?v=cViU2-xVscA&t=1680s
Once generated, use it from your C# client to call the get access_token endpoint
https://developer.salesforce.com/docs/atlas.en-us.api_iot.meta/api_iot/qs_auth_access_token.htm
(Watch the video on YT)
If all is correct you will get the access_token
To run the API calls, all you need is the access_token and not the JWT.
Once you have it add it to the HTTP calls like this
public static void AddBearerToken(this HttpRequestMessage request, string accessToken)
{
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
}
From time to time the access_token will expire. To check its validity you can call the token introspect api
https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oidc_token_introspection_endpoint.htm&type=5
You need to pass two additional parameters: client_id and client_secret
The client_id is the Consumer Key. You get it from the Connected App in Salesforce
The client_server is the Consumer Secret. You get it from Connected App in Salesforce
If the introspect token API returns a response with
{ active: false, ... }
it means that the access_token is expired and you need to issue a new one.
To issue a new access_token simply call the "/services/oauth2/token" again using the same JWT.

Bad device token apple push notification

I have stuck in apple push notification. Following the document in https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html#//apple_ref/doc/uid/TP40008194-CH11-SW1 I created the header and payload with the private key to generate token, but after call api: https://api.development.push.apple.com:443/3/device/, It told bad device token. Check in jwt.io it said invalid token.
Anyone know this problem or idea.
Thank you !
Here is the code .net core:
var header = JsonConvert.SerializeObject(new { alg = "ES256", kid = keyId });
var payload = JsonConvert.SerializeObject(new { iss = teamId, iat = ToEpoch(DateTime.UtcNow) });
var key = CngKey.Import(Convert.FromBase64String(p8privateKey), CngKeyBlobFormat.Pkcs8PrivateBlob);
using (ECDsaCng dsa = new ECDsaCng(key))
{
dsa.HashAlgorithm = CngAlgorithm.Sha256;
var headerBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(header));
var payloadBasae64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(payload));
var unsignedJwtData = System.Convert.ToBase64String(Encoding.UTF8.GetBytes(header)) + "." + System.Convert.ToBase64String(Encoding.UTF8.GetBytes(payload));
var unsignedJwtDataBytes = Encoding.UTF8.GetBytes(unsignedJwtData);
var signature =
dsa.SignData(unsignedJwtDataBytes);
return unsignedJwtData + "." + System.Convert.ToBase64String(signature);
}
}

Categories

Resources