diff --git a/README.md b/README.md index 4feda7e..43bfdd8 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,10 @@ See unit tests for more examples. ### OAuth access tokens +`ShopifyOAuthClient` retains only HTTP transport configuration. Supply the client ID and, where required, client secret to each operation. This avoids requiring unused credentials and lets one instance serve multiple apps. `BuildAuthorizationUrl` and `IsValidHmac` are static helpers. + +**Breaking API change:** replace `new ShopifyOAuthClient(clientId, clientSecret, httpClient)` with `new ShopifyOAuthClient(httpClient)`, move credentials to the token method calls, and invoke the helpers on `ShopifyOAuthClient` as shown below. Token response types are unchanged. + #### Generate an application-only signing key pair Generate a P-256 key pair for ES256 or an RSA-3072 key pair for RS256, following [Shopify's application-only access token guide](https://shopify.dev/docs/apps/build/authentication-authorization/application-only-access-tokens): @@ -86,15 +90,15 @@ The test creates a fresh ES256 or RS256 assertion and exchanges it for an access #### Acquire, migrate and refresh tokens -Request an application-only access token using your registered key ID and securely stored P-256 or RSA private key. No client secret is needed; pass an empty string to the existing constructor for this flow: +Request an application-only access token using your registered key ID and securely stored P-256 or RSA private key. No client secret is needed. Credentials are passed to each operation; the constructor accepts only an optional `HttpClient`: ```csharp -var oauthClient = new ShopifyOAuthClient(clientId, clientSecret: string.Empty); +var oauthClient = new ShopifyOAuthClient(); ShopifyClientAssertionAccessToken applicationToken = await oauthClient.RequestClientAssertionAccessTokenAsync( - myShopifyDomain, keyId, privateKeyPem, cancellationToken); + clientId, myShopifyDomain, keyId, privateKeyPem, cancellationToken); ``` -`RequestClientAssertionAccessTokenAsync` creates a fresh ES256 or RS256 assertion internally, with the store's token endpoint as its audience, a 60-second lifetime, and a unique `jti`. It selects ES256 for P-256 keys and RS256 for RSA keys (2048–8192 bits), rejecting unsupported keys. Unencrypted PKCS#8, EC PRIVATE KEY, and RSA PRIVATE KEY PEM formats are supported. Supply exactly one PEM block with optional surrounding whitespace; encrypted keys, multiple blocks, and additional non-whitespace content are rejected. It sends the assertion using the `client_credentials` grant and configured client ID, without a client secret or scope parameter, as described in [Shopify's guide](https://shopify.dev/docs/apps/build/authentication-authorization/application-only-access-tokens#step-5-request-an-access-token). +`RequestClientAssertionAccessTokenAsync` creates a fresh ES256 or RS256 assertion internally, with the store's token endpoint as its audience, a 60-second lifetime, and a unique `jti`. It selects ES256 for P-256 keys and RS256 for RSA keys (2048–8192 bits), rejecting unsupported keys. Unencrypted PKCS#8, EC PRIVATE KEY, and RSA PRIVATE KEY PEM formats are supported. Supply exactly one PEM block with optional surrounding whitespace; encrypted keys, multiple blocks, and additional non-whitespace content are rejected. It sends the assertion using the `client_credentials` grant and supplied client ID, without a client secret or scope parameter, as described in [Shopify's guide](https://shopify.dev/docs/apps/build/authentication-authorization/application-only-access-tokens#step-5-request-an-access-token). Use `applicationToken.access_token` for GraphQL requests and its returned `expires_in` to determine when to request a new token. There is no refresh token. The method does not retry automatically: call it again to retry with a fresh assertion. The default HTTP client rejects redirects; if supplying your own `HttpClient`, disable redirects and automatic retries and do not configure an Authorization header. @@ -102,26 +106,26 @@ Use `applicationToken.access_token` for GraphQL requests and its returned `expir Public apps must use [expiring offline access tokens](https://shopify.dev/docs/apps/build/authentication-authorization/migrate-to-expiring-offline-access-tokens) for the GraphQL Admin API from January 1, 2027. ```csharp -var oauthClient = new ShopifyOAuthClient(clientId, clientSecret); +var oauthClient = new ShopifyOAuthClient(); //migrate a non-expiring offline token. The old token is invalidated, so the new token must be persisted. -ShopifyOfflineAccessToken token = await oauthClient.MigrateToExpiringOfflineAccessTokenAsync(myShopifyDomain, nonExpiringToken); +ShopifyOfflineAccessToken token = await oauthClient.MigrateToExpiringOfflineAccessTokenAsync(clientId, clientSecret, myShopifyDomain, nonExpiringToken); //refresh before expires_in seconds have elapsed since acquisition. The refresh token is rotated, so the new token must be persisted. -token = await oauthClient.RefreshOfflineAccessTokenAsync(myShopifyDomain, token.refresh_token); +token = await oauthClient.RefreshOfflineAccessTokenAsync(clientId, clientSecret, myShopifyDomain, token.refresh_token); //exchange a validated ID (session) token for an expiring offline or an online access token -token = await oauthClient.ExchangeIdTokenForOfflineAccessTokenAsync(myShopifyDomain, idToken); -ShopifyOnlineAccessToken onlineToken = await oauthClient.ExchangeIdTokenForOnlineAccessTokenAsync(myShopifyDomain, idToken); +token = await oauthClient.ExchangeIdTokenForOfflineAccessTokenAsync(clientId, clientSecret, myShopifyDomain, idToken); +ShopifyOnlineAccessToken onlineToken = await oauthClient.ExchangeIdTokenForOnlineAccessTokenAsync(clientId, clientSecret, myShopifyDomain, idToken); //exchange an authorization code from a validated OAuth callback for an expiring offline token -token = await oauthClient.ExchangeCodeForOfflineAccessTokenAsync(myShopifyDomain, offlineCode, expiring: true); +token = await oauthClient.ExchangeCodeForOfflineAccessTokenAsync(clientId, clientSecret, myShopifyDomain, offlineCode, expiring: true); //request a non-expiring offline token instead (using a separate authorization code) -token = await oauthClient.ExchangeCodeForOfflineAccessTokenAsync(myShopifyDomain, nonExpiringOfflineCode, expiring: false); +token = await oauthClient.ExchangeCodeForOfflineAccessTokenAsync(clientId, clientSecret, myShopifyDomain, nonExpiringOfflineCode, expiring: false); //for an online token, the authorization URL must have included grant_options[]=per-user -onlineToken = await oauthClient.ExchangeCodeForOnlineAccessTokenAsync(myShopifyDomain, onlineCode); +onlineToken = await oauthClient.ExchangeCodeForOnlineAccessTokenAsync(clientId, clientSecret, myShopifyDomain, onlineCode); var client = new ShopifyClient(myShopifyDomain, token.access_token); ``` @@ -134,13 +138,13 @@ For the [authorization code flow](https://shopify.dev/docs/apps/build/authentica ### Build an authorization URL -Use `BuildAuthorizationUrl` to start the [authorization code flow](https://shopify.dev/docs/apps/build/authentication-authorization/authenticate-standalone-apps). It validates the shop domain, URL-encodes the parameters and uses the client ID configured on `ShopifyOAuthClient`. It makes no network request and never includes the client secret. +Use `BuildAuthorizationUrl` to start the [authorization code flow](https://shopify.dev/docs/apps/build/authentication-authorization/authenticate-standalone-apps). It validates the shop domain, URL-encodes the parameters and uses the supplied client ID. It makes no network request and never includes the client secret. ```csharp var state = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)); // Store state in the initiating browser's session before redirecting. -var authorizationUrl = oauthClient.BuildAuthorizationUrl( - myShopifyDomain, +var authorizationUrl = ShopifyOAuthClient.BuildAuthorizationUrl( + clientId, myShopifyDomain, scopes: "read_products,write_orders", redirectUri: "https://your-app.example.com/oauth/callback", state: state, @@ -154,7 +158,7 @@ Set `online: true` to include `grant_options[]=per-user` for an online token. Of All OAuth token requests reject domains other than a plain `shop.myshopify.com` hostname before sending credentials. `ShopifyOAuthClient.IsValidShopDomain` also exposes this check for authorization redirects and callbacks. -`oauthClient.IsValidHmac` verifies the OAuth query signature using the configured client secret and a constant-time comparison. Supply every URL-decoded query parameter, including `hmac`, without filtering fields or collapsing duplicates. It rejects missing, malformed or incorrect HMACs and duplicate keys. Before signing, it sorts the parameters and escapes `%` as `%25` and `&` as `%26` in keys and values, plus `=` as `%3D` in keys only. Other characters, including JSON punctuation, spaces and base64 padding, remain literal. This signing format is separate from URL encoding used to transport the callback. It does not validate the state nonce or timestamp freshness, and is not a webhook or app-proxy verifier. +`ShopifyOAuthClient.IsValidHmac` verifies the OAuth query signature using the supplied client secret and a constant-time comparison. Supply every URL-decoded query parameter, including `hmac`, without filtering fields or collapsing duplicates. It rejects missing, malformed or incorrect HMACs and duplicate keys. Before signing, it sorts the parameters and escapes `%` as `%25` and `&` as `%26` in keys and values, plus `=` as `%3D` in keys only. Other characters, including JSON punctuation, spaces and base64 padding, remain literal. This signing format is separate from URL encoding used to transport the callback. It does not validate the state nonce or timestamp freshness, and is not a webhook or app-proxy verifier. For example, in an ASP.NET Core controller callback, with `expectedState` retrieved from the initiating browser's session: @@ -164,7 +168,7 @@ var parameters = Request.Query .ToArray(); var shop = Request.Query["shop"].ToString(); -if (!oauthClient.IsValidHmac(parameters) || !ShopifyOAuthClient.IsValidShopDomain(shop)) +if (!ShopifyOAuthClient.IsValidHmac(parameters, clientSecret) || !ShopifyOAuthClient.IsValidShopDomain(shop)) return Unauthorized(); if (string.IsNullOrEmpty(expectedState) || Request.Query["state"].ToString() != expectedState) @@ -172,5 +176,5 @@ if (string.IsNullOrEmpty(expectedState) || Request.Query["state"].ToString() != // Consume the stored state so it cannot be reused, then exchange the signed code. var token = await oauthClient.ExchangeCodeForOfflineAccessTokenAsync( - shop, Request.Query["code"].ToString(), expiring: true); + clientId, clientSecret, shop, Request.Query["code"].ToString(), expiring: true); ``` diff --git a/ShopifyNet.Tests/ShopifyAuthorizationUrlTests.cs b/ShopifyNet.Tests/ShopifyAuthorizationUrlTests.cs index 2bbf3ca..894392e 100644 --- a/ShopifyNet.Tests/ShopifyAuthorizationUrlTests.cs +++ b/ShopifyNet.Tests/ShopifyAuthorizationUrlTests.cs @@ -6,15 +6,13 @@ namespace ShopifyNet.Tests; [TestClass] public class ShopifyAuthorizationUrlTests { - private readonly ShopifyOAuthClient _client = new("client+id", "secret-never-in-url"); - [DataTestMethod] [DataRow(false)] [DataRow(true)] public void BuildsEncodedAuthorizationUrl(bool online) { const string redirectUri = "https://app.example/callback?next=%2Forders&source=shopify"; - var url = _client.BuildAuthorizationUrl("test-shop.myshopify.com", "read_products,write_orders", redirectUri, "nonce+/= %", online); + var url = ShopifyOAuthClient.BuildAuthorizationUrl("client+id", "test-shop.myshopify.com", "read_products,write_orders", redirectUri, "nonce+/= %", online); var uri = new Uri(url); var query = HttpUtility.ParseQueryString(uri.Query); @@ -34,7 +32,7 @@ public void BuildsEncodedAuthorizationUrl(bool online) [TestMethod] public void DefaultsToOfflineAccess() { - var url = _client.BuildAuthorizationUrl("shop.myshopify.com", "read_products", "http://localhost:3000/callback", "nonce"); + var url = ShopifyOAuthClient.BuildAuthorizationUrl("client+id", "shop.myshopify.com", "read_products", "http://localhost:3000/callback", "nonce"); Assert.IsNull(HttpUtility.ParseQueryString(new Uri(url).Query)["grant_options[]"]); } @@ -44,7 +42,7 @@ public void DefaultsToOfflineAccess() [DataRow("https://shop.myshopify.com")] public void RejectsInvalidShop(string shop) { - Assert.ThrowsException(() => _client.BuildAuthorizationUrl(shop, "read_products", "https://app.example/callback", "nonce")); + Assert.ThrowsException(() => ShopifyOAuthClient.BuildAuthorizationUrl("client+id", shop, "read_products", "https://app.example/callback", "nonce")); } [DataTestMethod] @@ -54,23 +52,23 @@ public void RejectsInvalidShop(string shop) [DataRow("https://user:password@app.example/callback")] public void RejectsInvalidRedirectUri(string redirectUri) { - Assert.ThrowsException(() => _client.BuildAuthorizationUrl("shop.myshopify.com", "read_products", redirectUri, "nonce")); + Assert.ThrowsException(() => ShopifyOAuthClient.BuildAuthorizationUrl("client+id", "shop.myshopify.com", "read_products", redirectUri, "nonce")); } [TestMethod] public void RequiresScopesRedirectAndState() { - Assert.ThrowsException(() => _client.BuildAuthorizationUrl("shop.myshopify.com", null, "https://app.example/callback", "nonce")); - Assert.ThrowsException(() => _client.BuildAuthorizationUrl("shop.myshopify.com", " ", "https://app.example/callback", "nonce")); - Assert.ThrowsException(() => _client.BuildAuthorizationUrl("shop.myshopify.com", "read_products", null, "nonce")); - Assert.ThrowsException(() => _client.BuildAuthorizationUrl("shop.myshopify.com", "read_products", "https://app.example/callback", "")); - Assert.ThrowsException(() => _client.BuildAuthorizationUrl("shop.myshopify.com", "read_products", "https://app.example/callback", null)); + Assert.ThrowsException(() => ShopifyOAuthClient.BuildAuthorizationUrl("client+id", "shop.myshopify.com", null, "https://app.example/callback", "nonce")); + Assert.ThrowsException(() => ShopifyOAuthClient.BuildAuthorizationUrl("client+id", "shop.myshopify.com", " ", "https://app.example/callback", "nonce")); + Assert.ThrowsException(() => ShopifyOAuthClient.BuildAuthorizationUrl("client+id", "shop.myshopify.com", "read_products", null, "nonce")); + Assert.ThrowsException(() => ShopifyOAuthClient.BuildAuthorizationUrl("client+id", "shop.myshopify.com", "read_products", "https://app.example/callback", "")); + Assert.ThrowsException(() => ShopifyOAuthClient.BuildAuthorizationUrl("client+id", "shop.myshopify.com", "read_products", "https://app.example/callback", null)); } [TestMethod] public void EncodesStateContainingQueryDelimiters() { - var url = _client.BuildAuthorizationUrl("shop.myshopify.com", "read_products", "https://app.example/callback", "nonce&extra=value"); + var url = ShopifyOAuthClient.BuildAuthorizationUrl("client+id", "shop.myshopify.com", "read_products", "https://app.example/callback", "nonce&extra=value"); var query = HttpUtility.ParseQueryString(new Uri(url).Query); Assert.AreEqual("nonce&extra=value", query["state"]); Assert.IsNull(query["extra"]); diff --git a/ShopifyNet.Tests/ShopifyClientAssertionAccessTokenRequestTests.cs b/ShopifyNet.Tests/ShopifyClientAssertionAccessTokenRequestTests.cs index 5729747..7d3a4dd 100644 --- a/ShopifyNet.Tests/ShopifyClientAssertionAccessTokenRequestTests.cs +++ b/ShopifyNet.Tests/ShopifyClientAssertionAccessTokenRequestTests.cs @@ -28,6 +28,7 @@ public async Task CreatesFreshSignedAssertionForEveryTokenRequest(string algorit : keys.PrivateKeyPem; var assertionIds = new HashSet(); long issuedAt = 0; + var clientId = "client-id"; using var http = new TestHttpClient(request => { Assert.AreEqual(HttpMethod.Post, request.Method); @@ -35,7 +36,7 @@ public async Task CreatesFreshSignedAssertionForEveryTokenRequest(string algorit var form = HttpUtility.ParseQueryString(request.Content.ReadAsStringAsync().GetAwaiter().GetResult()); Assert.AreEqual(4, form.Count); Assert.AreEqual("client_credentials", form["grant_type"]); - Assert.AreEqual("client-id", form["client_id"]); + Assert.AreEqual(clientId, form["client_id"]); Assert.AreEqual("urn:ietf:params:oauth:client-assertion-type:jwt-bearer", form["client_assertion_type"]); Assert.IsNull(form["client_secret"]); var parts = form["client_assertion"].Split('.'); @@ -44,8 +45,8 @@ public async Task CreatesFreshSignedAssertionForEveryTokenRequest(string algorit Assert.AreEqual(algorithm, header.RootElement.GetProperty("alg").GetString()); Assert.AreEqual("test-key", header.RootElement.GetProperty("kid").GetString()); using var payload = JsonDocument.Parse(Base64Url.DecodeFromChars(parts[1])); - Assert.AreEqual("client-id", payload.RootElement.GetProperty("iss").GetString()); - Assert.AreEqual("client-id", payload.RootElement.GetProperty("sub").GetString()); + Assert.AreEqual(clientId, payload.RootElement.GetProperty("iss").GetString()); + Assert.AreEqual(clientId, payload.RootElement.GetProperty("sub").GetString()); Assert.AreEqual(request.RequestUri.AbsoluteUri, payload.RootElement.GetProperty("aud").GetString()); issuedAt = payload.RootElement.GetProperty("iat").GetInt64(); Assert.AreEqual(60L, payload.RootElement.GetProperty("exp").GetInt64() - issuedAt); @@ -60,11 +61,12 @@ public async Task CreatesFreshSignedAssertionForEveryTokenRequest(string algorit Content = new StringContent("""{"access_token":"test-token","scope":"read_products","expires_in":3600}""") }; }); - var client = new ShopifyOAuthClient("client-id", "unused-secret", http); + var client = new ShopifyOAuthClient(http); for (var i = 0; i < 2; i++) { + clientId = $"client-{i}"; var beforeRequest = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - var token = await client.RequestClientAssertionAccessTokenAsync("test.myshopify.com", "test-key", pem); + var token = await client.RequestClientAssertionAccessTokenAsync(clientId, "test.myshopify.com", "test-key", pem); var afterRequest = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); Assert.IsTrue(issuedAt >= beforeRequest && issuedAt <= afterRequest, "Assertion iat must be the current UTC time in Unix seconds."); @@ -78,13 +80,13 @@ public async Task CreatesFreshSignedAssertionForEveryTokenRequest(string algorit public async Task RejectsUnsupportedKeysBeforeSending() { using var http = new TestHttpClient(_ => throw new AssertFailedException("Invalid key must not be sent.")); - var client = new ShopifyOAuthClient("client-id", string.Empty, http); + var client = new ShopifyOAuthClient(http); using var wrongCurve = ECDsa.Create(ECCurve.NamedCurves.nistP384); using var smallRsa = RSA.Create(1024); await Assert.ThrowsExceptionAsync(() => client.RequestClientAssertionAccessTokenAsync( - "test.myshopify.com", "key", wrongCurve.ExportPkcs8PrivateKeyPem())); + "client-id", "test.myshopify.com", "key", wrongCurve.ExportPkcs8PrivateKeyPem())); await Assert.ThrowsExceptionAsync(() => client.RequestClientAssertionAccessTokenAsync( - "test.myshopify.com", "key", smallRsa.ExportPkcs8PrivateKeyPem())); + "client-id", "test.myshopify.com", "key", smallRsa.ExportPkcs8PrivateKeyPem())); } [DataTestMethod] @@ -94,11 +96,11 @@ public async Task RejectsPublicOnlyKeysBeforeSending(string algorithm) { using AsymmetricAlgorithm key = algorithm == "ES256" ? ECDsa.Create(ECCurve.NamedCurves.nistP256) : RSA.Create(2048); using var http = new TestHttpClient(_ => throw new AssertFailedException("Public-only key must not be sent.")); - var client = new ShopifyOAuthClient("client-id", string.Empty, http); + var client = new ShopifyOAuthClient(http); try { await client.RequestClientAssertionAccessTokenAsync( - "test.myshopify.com", "key", key.ExportSubjectPublicKeyInfoPem()); + "client-id", "test.myshopify.com", "key", key.ExportSubjectPublicKeyInfoPem()); Assert.Fail("A public-only key must fail signing."); } catch (CryptographicException) diff --git a/ShopifyNet.Tests/ShopifyClientAssertionIntegrationTests.cs b/ShopifyNet.Tests/ShopifyClientAssertionIntegrationTests.cs index 11c3023..cceff03 100644 --- a/ShopifyNet.Tests/ShopifyClientAssertionIntegrationTests.cs +++ b/ShopifyNet.Tests/ShopifyClientAssertionIntegrationTests.cs @@ -22,9 +22,9 @@ public async Task RequestClientAssertionAccessTokenAsync_ReturnsExpiringToken() var privateKeyPem = await File.ReadAllTextAsync(privateKeyPath); // Application-only authentication does not use the client secret. - var client = new ShopifyOAuthClient(clientId, clientSecret: string.Empty); + var client = new ShopifyOAuthClient(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - var token = await client.RequestClientAssertionAccessTokenAsync(shop, keyId, privateKeyPem, timeout.Token); + var token = await client.RequestClientAssertionAccessTokenAsync(clientId, shop, keyId, privateKeyPem, timeout.Token); Assert.IsNotNull(token); // Boolean assertions avoid printing the assertion, private key or returned token on failure. diff --git a/ShopifyNet.Tests/ShopifyClientAssertionKeyImportTests.cs b/ShopifyNet.Tests/ShopifyClientAssertionKeyImportTests.cs index dfe5e8c..f48f9b1 100644 --- a/ShopifyNet.Tests/ShopifyClientAssertionKeyImportTests.cs +++ b/ShopifyNet.Tests/ShopifyClientAssertionKeyImportTests.cs @@ -27,8 +27,8 @@ public async Task AcceptsRsaSizeBoundaries(int keySize) Content = new StringContent("""{"access_token":"token","scope":"read_products","expires_in":3600}""") }; }); - var client = new ShopifyOAuthClient("client", string.Empty, http); - var token = await client.RequestClientAssertionAccessTokenAsync("test.myshopify.com", "key", key.ExportPkcs8PrivateKeyPem()); + var client = new ShopifyOAuthClient(http); + var token = await client.RequestClientAssertionAccessTokenAsync("client", "test.myshopify.com", "key", key.ExportPkcs8PrivateKeyPem()); Assert.AreEqual("token", token.access_token); } @@ -95,8 +95,8 @@ public async Task RejectsMalformedPem(string pem) private static async Task RejectPem(string pem) { using var http = new TestHttpClient(_ => throw new AssertFailedException("Invalid PEM must not trigger a request.")); - var client = new ShopifyOAuthClient("client", string.Empty, http); + var client = new ShopifyOAuthClient(http); return await Assert.ThrowsExceptionAsync(() => - client.RequestClientAssertionAccessTokenAsync("test.myshopify.com", "key", pem)); + client.RequestClientAssertionAccessTokenAsync("client", "test.myshopify.com", "key", pem)); } } diff --git a/ShopifyNet.Tests/ShopifyOAuthClientIntegrationTests.cs b/ShopifyNet.Tests/ShopifyOAuthClientIntegrationTests.cs index 9f50264..2a2460a 100644 --- a/ShopifyNet.Tests/ShopifyOAuthClientIntegrationTests.cs +++ b/ShopifyNet.Tests/ShopifyOAuthClientIntegrationTests.cs @@ -12,6 +12,8 @@ public class ShopifyOAuthClientIntegrationTests { private ShopifyOAuthClient _client; private string _shop; + private string _clientId; + private string _clientSecret; [TestInitialize] public void Initialize() @@ -23,9 +25,9 @@ public void Initialize() Assert.IsTrue(Uri.CheckHostName(_shop) == UriHostNameType.Dns && _shop.EndsWith(".myshopify.com", StringComparison.OrdinalIgnoreCase), "SHOPIFYNET_SHOP_ID must be a plain store.myshopify.com hostname."); - _client = new ShopifyOAuthClient( - RequireEnvironmentVariable("SHOPIFYNET_CLIENT_ID"), - RequireEnvironmentVariable("SHOPIFYNET_CLIENT_SECRET")); + _client = new ShopifyOAuthClient(); + _clientId = RequireEnvironmentVariable("SHOPIFYNET_CLIENT_ID"); + _clientSecret = RequireEnvironmentVariable("SHOPIFYNET_CLIENT_SECRET"); } [TestMethod] @@ -33,7 +35,7 @@ public async Task MigrateToExpiringOfflineAccessTokenAsync_ReturnsExpiringToken( { var original = RequireEnvironmentVariable("SHOPIFYNET_NON_EXPIRING_ACCESS_TOKEN"); - var token = await _client.MigrateToExpiringOfflineAccessTokenAsync(_shop, original); + var token = await _client.MigrateToExpiringOfflineAccessTokenAsync(_clientId, _clientSecret, _shop, original); AssertOfflineToken(token); Assert.IsFalse(token.access_token == original, "Migration should issue a new access token."); @@ -44,7 +46,7 @@ public async Task RefreshOfflineAccessTokenAsync_ReturnsRotatedToken() { var original = RequireEnvironmentVariable("SHOPIFYNET_REFRESH_TOKEN"); - var token = await _client.RefreshOfflineAccessTokenAsync(_shop, original); + var token = await _client.RefreshOfflineAccessTokenAsync(_clientId, _clientSecret, _shop, original); AssertOfflineToken(token); // Use a boolean assertion so a failure cannot print either secret. @@ -56,7 +58,7 @@ public async Task ExchangeIdTokenForOfflineAccessTokenAsync_ReturnsExpiringToken { var idToken = RequireEnvironmentVariable("SHOPIFYNET_ID_TOKEN"); - var token = await _client.ExchangeIdTokenForOfflineAccessTokenAsync(_shop, idToken); + var token = await _client.ExchangeIdTokenForOfflineAccessTokenAsync(_clientId, _clientSecret, _shop, idToken); AssertOfflineToken(token); } @@ -66,7 +68,7 @@ public async Task ExchangeIdTokenForOnlineAccessTokenAsync_ReturnsUserToken() { var idToken = RequireEnvironmentVariable("SHOPIFYNET_ID_TOKEN"); - var token = await _client.ExchangeIdTokenForOnlineAccessTokenAsync(_shop, idToken); + var token = await _client.ExchangeIdTokenForOnlineAccessTokenAsync(_clientId, _clientSecret, _shop, idToken); AssertOnlineToken(token); } @@ -77,7 +79,7 @@ public async Task ExchangeCodeForOfflineAccessTokenAsync_ReturnsExpiringToken() // Use a fresh code from a validated callback without grant_options[]=per-user. var code = RequireEnvironmentVariable("SHOPIFYNET_OFFLINE_AUTHORIZATION_CODE"); - var token = await _client.ExchangeCodeForOfflineAccessTokenAsync(_shop, code, true); + var token = await _client.ExchangeCodeForOfflineAccessTokenAsync(_clientId, _clientSecret, _shop, code, true); AssertOfflineToken(token); } @@ -87,7 +89,7 @@ public async Task ExchangeCodeForOfflineAccessTokenAsync_ReturnsNonExpiringToken { var code = RequireEnvironmentVariable("SHOPIFYNET_NON_EXPIRING_OFFLINE_AUTHORIZATION_CODE"); - var token = await _client.ExchangeCodeForOfflineAccessTokenAsync(_shop, code, expiring: false); + var token = await _client.ExchangeCodeForOfflineAccessTokenAsync(_clientId, _clientSecret, _shop, code, expiring: false); Assert.IsNotNull(token); Assert.IsFalse(string.IsNullOrWhiteSpace(token.access_token), "Missing access token."); @@ -103,7 +105,7 @@ public async Task ExchangeCodeForOnlineAccessTokenAsync_ReturnsUserToken() // Use a separate fresh code from a validated callback with grant_options[]=per-user. var code = RequireEnvironmentVariable("SHOPIFYNET_ONLINE_AUTHORIZATION_CODE"); - var token = await _client.ExchangeCodeForOnlineAccessTokenAsync(_shop, code); + var token = await _client.ExchangeCodeForOnlineAccessTokenAsync(_clientId, _clientSecret, _shop, code); AssertOnlineToken(token); } diff --git a/ShopifyNet.Tests/ShopifyOAuthCredentialsTests.cs b/ShopifyNet.Tests/ShopifyOAuthCredentialsTests.cs new file mode 100644 index 0000000..1802c15 --- /dev/null +++ b/ShopifyNet.Tests/ShopifyOAuthCredentialsTests.cs @@ -0,0 +1,74 @@ +using System.Net; +using System.Web; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ShopifyNet.Tests; + +[TestClass] +public class ShopifyOAuthCredentialsTests +{ + [DataTestMethod] + [DataRow("migration")] + [DataRow("refresh")] + [DataRow("offline-id")] + [DataRow("online-id")] + [DataRow("offline-code")] + [DataRow("online-code")] + public async Task UsesCredentialsFromEachCall(string flow) + { + var credentials = new[] { (Id: "app-a", Secret: "secret-a"), (Id: "app-b", Secret: "secret-b") }; + var requestCount = 0; + using var http = new TestHttpClient(request => + { + var form = HttpUtility.ParseQueryString(request.Content.ReadAsStringAsync().GetAwaiter().GetResult()); + var expected = credentials[requestCount++]; + Assert.AreEqual(expected.Id, form["client_id"]); + Assert.IsTrue(expected.Secret == form["client_secret"], "Request must use this call's secret."); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"access_token":"token","expires_in":3600,"refresh_token":"refresh","refresh_token_expires_in":86400}""") + }; + }); + var client = new ShopifyOAuthClient(http); + + foreach (var (id, secret) in credentials) + { + const string shop = "test.myshopify.com"; + Task request = flow switch + { + "migration" => client.MigrateToExpiringOfflineAccessTokenAsync(id, secret, shop, "old-token"), + "refresh" => client.RefreshOfflineAccessTokenAsync(id, secret, shop, "refresh"), + "offline-id" => client.ExchangeIdTokenForOfflineAccessTokenAsync(id, secret, shop, "id-token"), + "online-id" => client.ExchangeIdTokenForOnlineAccessTokenAsync(id, secret, shop, "id-token"), + "offline-code" => client.ExchangeCodeForOfflineAccessTokenAsync(id, secret, shop, "code", expiring: true), + "online-code" => client.ExchangeCodeForOnlineAccessTokenAsync(id, secret, shop, "code"), + _ => throw new AssertFailedException("Unknown flow.") + }; + await request; + } + + Assert.AreEqual(credentials.Length, requestCount); + } + + [DataTestMethod] + [DataRow(null, "secret", "clientId")] + [DataRow("client-id", null, "clientSecret")] + public async Task RejectsMissingCredentialsBeforeSending(string clientId, string clientSecret, string parameter) + { + using var http = new TestHttpClient(_ => throw new AssertFailedException("Missing credentials must not be sent.")); + var client = new ShopifyOAuthClient(http); + var exception = await Assert.ThrowsExceptionAsync(() => + client.RefreshOfflineAccessTokenAsync(clientId, clientSecret, "test.myshopify.com", "refresh")); + Assert.AreEqual(parameter, exception.ParamName); + } + + [TestMethod] + public void StaticHelpersRejectMissingCredentials() + { + var idError = Assert.ThrowsException(() => + ShopifyOAuthClient.BuildAuthorizationUrl(null, "test.myshopify.com", "read_products", "https://app.example/callback", "nonce")); + Assert.AreEqual("clientId", idError.ParamName); + var secretError = Assert.ThrowsException(() => ShopifyOAuthClient.IsValidHmac([], null)); + Assert.AreEqual("clientSecret", secretError.ParamName); + } +} diff --git a/ShopifyNet.Tests/ShopifyOAuthValidationTests.cs b/ShopifyNet.Tests/ShopifyOAuthValidationTests.cs index e82905f..61ce57c 100644 --- a/ShopifyNet.Tests/ShopifyOAuthValidationTests.cs +++ b/ShopifyNet.Tests/ShopifyOAuthValidationTests.cs @@ -5,8 +5,6 @@ namespace ShopifyNet.Tests; [TestClass] public class ShopifyOAuthValidationTests { - private readonly ShopifyOAuthClient _client = new("client-id", "client-secret"); - private static Dictionary ValidQuery() => new() { ["timestamp"] = "1792713600", @@ -50,20 +48,20 @@ public void ValidatesShopDomain(string domain, bool valid) public async Task RejectsUntrustedDomainBeforeSendingCredentials() { var sent = false; - var client = new ShopifyOAuthClient("client-id", "client-secret", new TestHttpClient(_ => + var client = new ShopifyOAuthClient(new TestHttpClient(_ => { sent = true; return new HttpResponseMessage(System.Net.HttpStatusCode.OK); })); - await Assert.ThrowsExceptionAsync(() => client.RefreshOfflineAccessTokenAsync("evil.com", "refresh")); + await Assert.ThrowsExceptionAsync(() => client.RefreshOfflineAccessTokenAsync("client-id", "client-secret", "evil.com", "refresh")); Assert.IsFalse(sent); } [TestMethod] public void AcceptsValidHmacRegardlessOfParameterOrder() { - Assert.IsTrue(_client.IsValidHmac(ValidQuery())); - Assert.IsTrue(_client.IsValidHmac(ValidQuery().Reverse())); + Assert.IsTrue(ShopifyOAuthClient.IsValidHmac(ValidQuery(), "client-secret")); + Assert.IsTrue(ShopifyOAuthClient.IsValidHmac(ValidQuery().Reverse(), "client-secret")); } [TestMethod] @@ -71,7 +69,7 @@ public void RejectsHmacSignedOverFormEncodedValues() { var query = ValidQuery(); query["hmac"] = "27ec35edf2c692e2095a0e5f9970ff9b6ffe9acb06bbcf967bdfb1b0c500156d"; - Assert.IsFalse(_client.IsValidHmac(query)); + Assert.IsFalse(ShopifyOAuthClient.IsValidHmac(query, "client-secret")); } [DataTestMethod] @@ -83,22 +81,22 @@ public void SignsArrayParametersInOriginalOrder(string first, string second, str { new("ids[]", first), new("hmac", Sign(message)), new("ids[]", second) }; - Assert.IsTrue(_client.IsValidHmac(query)); - Assert.IsFalse(_client.IsValidHmac(query.AsEnumerable().Reverse())); + Assert.IsTrue(ShopifyOAuthClient.IsValidHmac(query, "client-secret")); + Assert.IsFalse(ShopifyOAuthClient.IsValidHmac(query.AsEnumerable().Reverse(), "client-secret")); query[0] = new("ids[]", "tampered"); - Assert.IsFalse(_client.IsValidHmac(query)); + Assert.IsFalse(ShopifyOAuthClient.IsValidHmac(query, "client-secret")); } [TestMethod] public void AcceptsSingleElementArray() { var query = new Dictionary { ["ids[]"] = "one", ["hmac"] = Sign("ids=[\"one\"]") }; - Assert.IsTrue(_client.IsValidHmac(query)); + Assert.IsTrue(ShopifyOAuthClient.IsValidHmac(query, "client-secret")); query["ids"] = "one"; - Assert.IsFalse(_client.IsValidHmac(query)); + Assert.IsFalse(ShopifyOAuthClient.IsValidHmac(query, "client-secret")); query.Remove("ids"); query["ids[]"] = null; - Assert.IsFalse(_client.IsValidHmac(query)); + Assert.IsFalse(ShopifyOAuthClient.IsValidHmac(query, "client-secret")); } [TestMethod] @@ -108,7 +106,7 @@ public void EscapesPercentWithoutEncodingOtherValueCharacters() query["code"] = "a+b %/=~*-._\u00e9"; // Independently signed with Node crypto: only % becomes %25 in this value. query["hmac"] = "7af0f2f479cf4a675fde53aaaee665bd75ae9a97b9500899431004e798b391e1"; - Assert.IsTrue(_client.IsValidHmac(query)); + Assert.IsTrue(ShopifyOAuthClient.IsValidHmac(query, "client-secret")); } [TestMethod] @@ -119,7 +117,7 @@ public void AcceptsSignedDelimitersInKeysAndValues() query["extra&="] = "+/= ~*\u00e9"; // Escape & in keys and values, and = in keys only. query["hmac"] = "6eb51503735241f316dac1d982f9d5b8ebfdebac213afc8bff79bace79a8a5e1"; - Assert.IsTrue(_client.IsValidHmac(query)); + Assert.IsTrue(ShopifyOAuthClient.IsValidHmac(query, "client-secret")); } [TestMethod] @@ -129,7 +127,7 @@ public void PreservesJsonStateWhenSigning() query["state"] = """{"ReturnUrl":null,"AccountId":"br-test-plus-mathieu.myshopify.com","UserId":null}"""; // Synthetic signature using the reported JSON shape and the test secret, not a live app secret. query["hmac"] = "5b2b32d47faae8c483a532efcd4ebedf0c736103ad9e87b298475b60b9dee442"; - Assert.IsTrue(_client.IsValidHmac(query)); + Assert.IsTrue(ShopifyOAuthClient.IsValidHmac(query, "client-secret")); } [DataTestMethod] @@ -143,7 +141,7 @@ public void RejectsTamperedParameters(string parameter) { var query = ValidQuery(); query[parameter] = "tampered"; - Assert.IsFalse(_client.IsValidHmac(query)); + Assert.IsFalse(ShopifyOAuthClient.IsValidHmac(query, "client-secret")); } [DataTestMethod] @@ -156,17 +154,17 @@ public void RejectsMalformedOrIncorrectHmac(string hmac) { var query = ValidQuery(); query["hmac"] = hmac; - Assert.IsFalse(_client.IsValidHmac(query)); + Assert.IsFalse(ShopifyOAuthClient.IsValidHmac(query, "client-secret")); } [TestMethod] public void RejectsMissingHmacAndWrongSecret() { - Assert.IsFalse(new ShopifyOAuthClient("client-id", "wrong-secret").IsValidHmac(ValidQuery())); + Assert.IsFalse(ShopifyOAuthClient.IsValidHmac(ValidQuery(), "wrong-secret")); var query = ValidQuery(); query.Remove("hmac"); - Assert.IsFalse(_client.IsValidHmac(query)); - Assert.IsFalse(_client.IsValidHmac(null)); + Assert.IsFalse(ShopifyOAuthClient.IsValidHmac(query, "client-secret")); + Assert.IsFalse(ShopifyOAuthClient.IsValidHmac(null, "client-secret")); } [DataTestMethod] @@ -175,7 +173,7 @@ public void RejectsMissingHmacAndWrongSecret() public void RejectsDuplicateParameters(string key) { var query = ValidQuery(); - Assert.IsFalse(_client.IsValidHmac(query.Append(new KeyValuePair(key, query[key])))); + Assert.IsFalse(ShopifyOAuthClient.IsValidHmac(query.Append(new KeyValuePair(key, query[key])), "client-secret")); } [TestMethod] @@ -184,10 +182,10 @@ public void RejectsAmbiguousQueryDelimiters() var query = ValidQuery(); query.Remove("host"); query["code"] = "authorization-code&host=YWRtaW4="; - Assert.IsFalse(_client.IsValidHmac(query)); + Assert.IsFalse(ShopifyOAuthClient.IsValidHmac(query, "client-secret")); query.Remove("code"); query["code=authorization-code&host"] = "YWRtaW4="; - Assert.IsFalse(_client.IsValidHmac(query)); + Assert.IsFalse(ShopifyOAuthClient.IsValidHmac(query, "client-secret")); } } diff --git a/ShopifyNet.Tests/TestHelper.cs b/ShopifyNet.Tests/TestHelper.cs index bf785f4..0ff34c5 100644 --- a/ShopifyNet.Tests/TestHelper.cs +++ b/ShopifyNet.Tests/TestHelper.cs @@ -12,8 +12,8 @@ public class TestHelper Environment.GetEnvironmentVariable("SHOPIFYNET_KEY_ID"); public static async Task GetAccessTokenAsync() { - var oauthClient = new ShopifyOAuthClient(ClientId, null); - var token = await oauthClient.RequestClientAssertionAccessTokenAsync(ShopId, KeyId, PrivateKey); + var oauthClient = new ShopifyOAuthClient(); + var token = await oauthClient.RequestClientAssertionAccessTokenAsync(ClientId, ShopId, KeyId, PrivateKey); return token.access_token; } } diff --git a/ShopifyNet.TypeGenerator/TypeGenerator.cs b/ShopifyNet.TypeGenerator/TypeGenerator.cs index 1be8841..df7b077 100644 --- a/ShopifyNet.TypeGenerator/TypeGenerator.cs +++ b/ShopifyNet.TypeGenerator/TypeGenerator.cs @@ -41,8 +41,8 @@ string csharpCode = await generator.GenerateTypesAsync(options, async query => { string shopId = GetRequiredEnvironmentVariable("SHOPIFYNET_SHOP_ID"); - var oauthClient = new ShopifyOAuthClient(GetRequiredEnvironmentVariable("SHOPIFYNET_CLIENT_ID"), null); - var token = await oauthClient.RequestClientAssertionAccessTokenAsync(shopId, + var oauthClient = new ShopifyOAuthClient(); + var token = await oauthClient.RequestClientAssertionAccessTokenAsync(GetRequiredEnvironmentVariable("SHOPIFYNET_CLIENT_ID"), shopId, GetRequiredEnvironmentVariable("SHOPIFYNET_KEY_ID"), GetRequiredEnvironmentVariable("SHOPIFYNET_PRIVATE_KEY")); var shopifyOptions = new ShopifyClientOptions(shopId, token.access_token) as IGraphQLClientOptions; diff --git a/ShopifyNet/OAuth/ShopifyOAuthClient.cs b/ShopifyNet/OAuth/ShopifyOAuthClient.cs index 82940e8..7a12a7f 100644 --- a/ShopifyNet/OAuth/ShopifyOAuthClient.cs +++ b/ShopifyNet/OAuth/ShopifyOAuthClient.cs @@ -9,6 +9,7 @@ namespace ShopifyNet; /// /// Builds Shopify OAuth authorization URLs, validates callbacks and calls the access token endpoint. +/// Credentials are supplied per operation; instances retain only the HTTP client. /// See https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens /// public class ShopifyOAuthClient @@ -46,15 +47,10 @@ public class ShopifyOAuthClient AllowAutoRedirect = false }); - private readonly string _clientId; - private readonly string _clientSecret; private readonly HttpClient _httpClient; - public ShopifyOAuthClient(string clientId, string clientSecret, HttpClient httpClient = null) + public ShopifyOAuthClient(HttpClient httpClient = null) { - _clientId = clientId ?? throw new ArgumentNullException(nameof(clientId)); - // The client secret can be null for certain flows, such as client assertion. - _clientSecret = clientSecret; _httpClient = httpClient ?? _defaultHttpClient; } @@ -63,8 +59,9 @@ public ShopifyOAuthClient(string clientId, string clientSecret, HttpClient httpC /// No request is sent. The caller must generate and store a random state nonce, then validate it in the callback. /// See https://shopify.dev/docs/apps/build/authentication-authorization/authenticate-standalone-apps /// - public string BuildAuthorizationUrl(string myShopifyDomain, string scopes, string redirectUri, string state, bool online = false) + public static string BuildAuthorizationUrl(string clientId, string myShopifyDomain, string scopes, string redirectUri, string state, bool online = false) { + ArgumentNullException.ThrowIfNull(clientId); ArgumentNullException.ThrowIfNull(myShopifyDomain); if (!IsValidShopDomain(myShopifyDomain)) throw new ArgumentException("Invalid shop domain.", nameof(myShopifyDomain)); @@ -78,7 +75,7 @@ public string BuildAuthorizationUrl(string myShopifyDomain, string scopes, strin var parameters = new Dictionary { - [CLIENT_ID_KEY] = _clientId, + [CLIENT_ID_KEY] = clientId, [SCOPE_KEY] = scopes, [REDIRECT_URI_KEY] = redirectUri, [STATE_KEY] = state, @@ -103,7 +100,7 @@ public static bool IsValidShopDomain(string myShopifyDomain) } /// - /// Verifies the OAuth callback query's hexadecimal HMAC-SHA256 using the app's client secret. + /// Verifies the OAuth callback query's hexadecimal HMAC-SHA256 using the supplied client secret. /// Supply all URL-decoded query parameters, including hmac, preserving any duplicates. /// Parameters are sorted ordinally and joined as key=value pairs, excluding hmac and signature. /// Repeated keys ending in [] represent arrays: ids[]=1 and ids[]=2 are signed as ids=["1", "2"]. @@ -112,10 +109,9 @@ public static bool IsValidShopDomain(string myShopifyDomain) /// Duplicate scalar keys, conflicting scalar/array keys, null values and invalid HMACs return false. /// This checks authenticity only: separately validate the shop, stored state nonce and any timestamp freshness policy. /// - public bool IsValidHmac(IEnumerable> queryParameters) + public static bool IsValidHmac(IEnumerable> queryParameters, string clientSecret) { - if (_clientSecret == null) - throw new ArgumentNullException(nameof(_clientSecret)); + ArgumentNullException.ThrowIfNull(clientSecret); if (queryParameters == null) return false; @@ -153,7 +149,7 @@ public bool IsValidHmac(IEnumerable> queryParameter var message = string.Join("&", parameters.Where(p => p.Key != "signature").Select(p => $"{EncodeHmacQueryComponent(p.Key, isKey: true)}={EncodeHmacQueryComponent(p.Value)}")); - var expectedHash = HMACSHA256.HashData(Encoding.UTF8.GetBytes(_clientSecret), Encoding.UTF8.GetBytes(message)); + var expectedHash = HMACSHA256.HashData(Encoding.UTF8.GetBytes(clientSecret), Encoding.UTF8.GetBytes(message)); return CryptographicOperations.FixedTimeEquals(expectedHash, suppliedHash); } @@ -172,10 +168,10 @@ private static string EncodeHmacQueryComponent(string value, bool isKey = false) /// If the response is lost, the same request can be repeated with the original non-expiring token within seven days to recover the same token. /// See https://shopify.dev/docs/apps/build/authentication-authorization/migrate-to-expiring-offline-access-tokens#migrate-existing-tokens-without-a-user-session /// - public Task MigrateToExpiringOfflineAccessTokenAsync(string myShopifyDomain, string nonExpiringAccessToken, CancellationToken cancellationToken = default) + public Task MigrateToExpiringOfflineAccessTokenAsync(string clientId, string clientSecret, string myShopifyDomain, string nonExpiringAccessToken, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(nonExpiringAccessToken); - return RequestAccessTokenAsync(myShopifyDomain, new() + return RequestAccessTokenAsync(clientId, clientSecret, myShopifyDomain, new() { [GRANT_TYPE_KEY] = TOKEN_EXCHANGE_GRANT_TYPE, [SUBJECT_TOKEN_KEY] = nonExpiringAccessToken, @@ -190,10 +186,10 @@ public Task MigrateToExpiringOfflineAccessTokenAsync( /// Shopify rotates the refresh token: the returned token (including its new refresh token) MUST be persisted and used for the next refresh. /// See https://shopify.dev/docs/apps/build/authentication-authorization/implement-token-exchange#refresh-an-expiring-offline-token /// - public Task RefreshOfflineAccessTokenAsync(string myShopifyDomain, string refreshToken, CancellationToken cancellationToken = default) + public Task RefreshOfflineAccessTokenAsync(string clientId, string clientSecret, string myShopifyDomain, string refreshToken, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(refreshToken); - return RequestAccessTokenAsync(myShopifyDomain, new() + return RequestAccessTokenAsync(clientId, clientSecret, myShopifyDomain, new() { [GRANT_TYPE_KEY] = REFRESH_TOKEN_GRANT_TYPE, [REFRESH_TOKEN_KEY] = refreshToken, @@ -205,10 +201,10 @@ public Task RefreshOfflineAccessTokenAsync(string myS /// The ID token must have been validated by the caller. /// See https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens#token-exchange-grant /// - public Task ExchangeIdTokenForOfflineAccessTokenAsync(string myShopifyDomain, string idToken, CancellationToken cancellationToken = default) + public Task ExchangeIdTokenForOfflineAccessTokenAsync(string clientId, string clientSecret, string myShopifyDomain, string idToken, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(idToken); - return RequestAccessTokenAsync(myShopifyDomain, new() + return RequestAccessTokenAsync(clientId, clientSecret, myShopifyDomain, new() { [GRANT_TYPE_KEY] = TOKEN_EXCHANGE_GRANT_TYPE, [SUBJECT_TOKEN_KEY] = idToken, @@ -223,11 +219,11 @@ public Task ExchangeIdTokenForOfflineAccessTokenAsync /// The ID token must have been validated by the caller. /// See https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens#token-exchange-grant /// - public Task ExchangeIdTokenForOnlineAccessTokenAsync(string myShopifyDomain, string idToken, CancellationToken cancellationToken = default) + public Task ExchangeIdTokenForOnlineAccessTokenAsync(string clientId, string clientSecret, string myShopifyDomain, string idToken, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(idToken); //the expiring parameter only applies to offline access tokens - return RequestAccessTokenAsync(myShopifyDomain, new() + return RequestAccessTokenAsync(clientId, clientSecret, myShopifyDomain, new() { [GRANT_TYPE_KEY] = TOKEN_EXCHANGE_GRANT_TYPE, [SUBJECT_TOKEN_KEY] = idToken, @@ -241,7 +237,7 @@ public Task ExchangeIdTokenForOnlineAccessTokenAsync(s /// The caller must validate the OAuth callback (including HMAC, state and shop) before exchanging the code. /// The code must come from an authorization request without grant_options[]=per-user. /// - public Task ExchangeCodeForOfflineAccessTokenAsync(string myShopifyDomain, string code, bool expiring, CancellationToken cancellationToken = default) + public Task ExchangeCodeForOfflineAccessTokenAsync(string clientId, string clientSecret, string myShopifyDomain, string code, bool expiring, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(code); var parameters = new Dictionary @@ -250,7 +246,7 @@ public Task ExchangeCodeForOfflineAccessTokenAsync(st }; if (expiring) parameters[EXPIRING_KEY] = EXPIRING_VALUE; - return RequestAccessTokenAsync(myShopifyDomain, parameters, true, cancellationToken); + return RequestAccessTokenAsync(clientId, clientSecret, myShopifyDomain, parameters, true, cancellationToken); } /// @@ -259,10 +255,10 @@ public Task ExchangeCodeForOfflineAccessTokenAsync(st /// The code must come from an authorization request with grant_options[]=per-user. /// See https://shopify.dev/docs/apps/build/authentication-authorization/authenticate-standalone-apps /// - public Task ExchangeCodeForOnlineAccessTokenAsync(string myShopifyDomain, string code, CancellationToken cancellationToken = default) + public Task ExchangeCodeForOnlineAccessTokenAsync(string clientId, string clientSecret, string myShopifyDomain, string code, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(code); - return RequestAccessTokenAsync(myShopifyDomain, new() + return RequestAccessTokenAsync(clientId, clientSecret, myShopifyDomain, new() { [CODE_KEY] = code, }, true, cancellationToken); @@ -270,15 +266,15 @@ public Task ExchangeCodeForOnlineAccessTokenAsync(stri /// /// Creates an ES256 or RS256 client assertion for the shop's application-only access token request. - /// Uses the configured client ID and an unencrypted P-256 or RSA PEM private key; the client secret is not used. + /// Uses the supplied client ID and an unencrypted P-256 or RSA PEM private key; the client secret is not used. /// The assertion expires after 60 seconds and contains a new jti on each call. /// Create a fresh assertion for every token request, including retries. This is not an access token. /// No HTTP request is sent. The matching public JWK must already be registered in Shopify with this key ID. /// See https://shopify.dev/docs/apps/build/authentication-authorization/application-only-access-tokens#step-4-create-a-client-assertion /// - private string CreateClientAssertion(string myShopifyDomain, string keyId, string privateKeyPem) + private static string CreateClientAssertion(string clientId, string myShopifyDomain, string keyId, string privateKeyPem) { - ArgumentException.ThrowIfNullOrWhiteSpace(_clientId); + ArgumentException.ThrowIfNullOrWhiteSpace(clientId); ArgumentNullException.ThrowIfNull(myShopifyDomain); if (!IsValidShopDomain(myShopifyDomain)) throw new ArgumentException("Invalid shop domain.", nameof(myShopifyDomain)); @@ -294,8 +290,8 @@ private string CreateClientAssertion(string myShopifyDomain, string keyId, strin var header = Base64Url.EncodeToString(JsonSerializer.SerializeToUtf8Bytes(new { alg = algorithm, kid = keyId })); var payload = Base64Url.EncodeToString(JsonSerializer.SerializeToUtf8Bytes(new { - iss = _clientId, - sub = _clientId, + iss = clientId, + sub = clientId, aud = $"https://{myShopifyDomain.ToLowerInvariant()}/admin/oauth/access_token", iat = issuedAt, exp = issuedAt + 60, @@ -368,13 +364,13 @@ private static AsymmetricAlgorithm ImportClientAssertionKey(string privateKeyPem /// See https://shopify.dev/docs/apps/build/authentication-authorization/application-only-access-tokens#step-5-request-an-access-token /// public Task RequestClientAssertionAccessTokenAsync( - string myShopifyDomain, string keyId, string privateKeyPem, CancellationToken cancellationToken = default) + string clientId, string myShopifyDomain, string keyId, string privateKeyPem, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); if (_httpClient.DefaultRequestHeaders.Authorization != null) throw new InvalidOperationException("Client assertion requests must not include an Authorization header."); - var clientAssertion = CreateClientAssertion(myShopifyDomain, keyId, privateKeyPem); - return RequestAccessTokenAsync(myShopifyDomain, new() + var clientAssertion = CreateClientAssertion(clientId, myShopifyDomain, keyId, privateKeyPem); + return RequestAccessTokenAsync(clientId, null, myShopifyDomain, new() { [GRANT_TYPE_KEY] = CLIENT_CREDENTIALS_GRANT_TYPE, [CLIENT_ASSERTION_TYPE_KEY] = JWT_BEARER_ASSERTION_TYPE, @@ -382,21 +378,22 @@ public Task RequestClientAssertionAccessToken }, false, cancellationToken); } - private async Task RequestAccessTokenAsync(string myShopifyDomain, Dictionary parameters, + private async Task RequestAccessTokenAsync(string clientId, string clientSecret, string myShopifyDomain, Dictionary parameters, bool includeClientSecret, CancellationToken cancellationToken) where T : class { + ArgumentNullException.ThrowIfNull(clientId); ArgumentNullException.ThrowIfNull(myShopifyDomain); // Credentials must only be sent to a Shopify store hostname. if (!IsValidShopDomain(myShopifyDomain)) throw new ArgumentException("Invalid shop domain.", nameof(myShopifyDomain)); - parameters[CLIENT_ID_KEY] = _clientId; + parameters[CLIENT_ID_KEY] = clientId; if (includeClientSecret) { - if (_clientSecret == null) - throw new ArgumentNullException(nameof(_clientSecret)); - parameters[CLIENT_SECRET_KEY] = _clientSecret; + if (clientSecret == null) + throw new ArgumentNullException(nameof(clientSecret)); + parameters[CLIENT_SECRET_KEY] = clientSecret; } using var request = new HttpRequestMessage(HttpMethod.Post, $"https://{myShopifyDomain}/admin/oauth/access_token") diff --git a/ShopifyNet/ShopifyNet.csproj b/ShopifyNet/ShopifyNet.csproj index 932392f..79cac51 100644 --- a/ShopifyNet/ShopifyNet.csproj +++ b/ShopifyNet/ShopifyNet.csproj @@ -5,7 +5,7 @@ https://github.com/Wish-Org/ShopifyNet https://github.com/Wish-Org/ShopifyNet .NET utilities for Shopify GraphQL - 2.20.0 + 2.21.0 shopify graphql types classes schema generator strongly typed