diff --git a/src/Apps/W1/Shopify/App/.docs-updated b/src/Apps/W1/Shopify/App/.docs-updated index 51bbc28924..e8cafa2be0 100644 --- a/src/Apps/W1/Shopify/App/.docs-updated +++ b/src/Apps/W1/Shopify/App/.docs-updated @@ -1,4 +1,4 @@ # Documentation last updated -commit: 343aa21d1e5737d250aabf05cd9233d15c411cdc -date: 2026-04-08 +commit: ca6ec0d1b28e22419d9f83f4101ae83b74067c29 +date: 2026-07-11 scope: full diff --git a/src/Apps/W1/Shopify/App/CLAUDE.md b/src/Apps/W1/Shopify/App/CLAUDE.md index 55f6fca0d5..0efa31993a 100644 --- a/src/Apps/W1/Shopify/App/CLAUDE.md +++ b/src/Apps/W1/Shopify/App/CLAUDE.md @@ -19,6 +19,10 @@ All Shopify API communication goes through GraphQL. The `ShpfyCommunicationMgt.C *Updated: 2026-03-24 -- GraphQL resource file refactoring* +Authentication uses Shopify **expiring offline access tokens**. On install the connector requests an expiring token (`expiring=1`) and stores it -- with its `Token Expires At` and 90-day `Refresh Token Expires At` -- on `Shpfy Registered Store New`, keyed by store URL (access and refresh tokens live in IsolatedStorage as `SecretText`). `ShpfyAuthenticationMgt.EnsureValidAccessToken`, called from `ShpfyCommunicationMgt` before every request, transparently refreshes a near-expiry token and performs the one-time (irreversible) migration of legacy non-expiring tokens via token exchange. A recurring backstop job (`Shpfy Token Refresh`) keeps tokens and their refresh tokens alive for idle shops. + +*Updated: 2026-07-11 -- Expiring offline access token support (slice 637954)* + Mapping strategies are interface-driven throughout. Customer mapping (`ICustomerMapping`) selects between by-email/phone, by-bill-to, or default-customer strategies. Company mapping (`ICompanyMapping`) can match by email/phone or tax ID. Stock calculation uses `IStockAvailable` and `IStockCalculation` interfaces. Product status on creation, removal actions for blocked items, county resolution, and customer name formatting are all interface-backed enums. The Shop record's enum fields (e.g., `"Customer Mapping Type"`, `"Stock Calculation"`, `"Status for Created Products"`) select which implementation to use at runtime. Sync is incremental via the Synchronization Info table (`ShpfySynchronizationInfo.Table.al`), which stores the last sync timestamp per shop and sync type. An empty/zero date falls back to a sentinel value of `2004-01-01` (see `GetEmptySyncTime()`). Products use hash-based change detection -- the Product table stores `"Image Hash"`, `"Tags Hash"`, and `"Description Html Hash"` fields, computed via a custom hash algorithm in `ShpfyHash`, to avoid unnecessary API calls when nothing has actually changed. @@ -62,6 +66,7 @@ Records link to BC entities via SystemId (GUID), not Code/No. For example, `Shpf - The Shop table is the god object -- nearly every configuration setting lives there, with over 100 fields controlling sync directions, mapping strategies, account mappings, plan-based feature flags, webhook config, and more. The `"Advanced Shopify Plan"` field gates features requiring Plus/Advanced plans (currently staff members). B2B features are now unconditionally available on all plans. - All API calls go through GraphQL, never REST. Queries are `.graphql` resource files in `.resources/graphql/{Area}/`, loaded via `NavApp.GetResourceAsText()` and dispatched through the `ShpfyGraphQLType` enum. +- Authentication uses expiring offline access tokens: the connector requests `expiring=1` on install, refreshes the 1-hour access token before expiry (rotating the 90-day refresh token), and migrates legacy non-expiring tokens on first use. `EnsureValidAccessToken` (from `ShpfyCommunicationMgt.GetAccessToken`) handles this on demand; the recurring `Shpfy Token Refresh` job is the backstop. If the refresh token lapses (90 days idle), the merchant must reconnect the shop from the Shop Card. Migration is one-time and irreversible per shop. - Products use hash-based change detection (`"Image Hash"`, `"Tags Hash"`, `"Description Html Hash"`) via a custom hash algorithm to skip unnecessary API calls when nothing has changed. - Records link to BC entities via SystemId (GUID), not Code/No. -- FlowFields like `"Item No."` display the human-readable values via CalcFormula lookup. Renumbering BC items does not break Shopify links. - Orders store every monetary amount in dual currency: shop currency fields (`"Total Amount"`, `"VAT Amount"`) and presentment/customer-facing currency fields (`"Presentment Total Amount"`, `"Presentment VAT Amount"`). The `"Currency Handling"` setting on Shop controls which is used for BC documents. diff --git a/src/Apps/W1/Shopify/App/docs/business-logic.md b/src/Apps/W1/Shopify/App/docs/business-logic.md index 5942d3a3b9..d69ac4d82a 100644 --- a/src/Apps/W1/Shopify/App/docs/business-logic.md +++ b/src/Apps/W1/Shopify/App/docs/business-logic.md @@ -2,6 +2,22 @@ This document covers the major processing flows in the Shopify Connector, focusing on decision points, non-obvious behavior, and what can go wrong. +## Authentication and token lifecycle + +The connector authenticates to Shopify with **expiring offline access tokens** (public apps must migrate to these by 2027-01-01). An access token lives for 1 hour; a refresh token lives for 90 days and is rotated on each refresh. Tokens are held on `Shpfy Registered Store New` (keyed by store URL) -- the access and refresh tokens as `SecretText` in IsolatedStorage, with `Token Expires At` and `Refresh Token Expires At` as fields. + +`ShpfyCommunicationMgt.GetAccessToken` calls `ShpfyAuthenticationMgt.EnsureValidAccessToken` before every API request, so the following happens transparently in any session, interactive or background: + +- **Fast path** -- a valid expiring token is used as-is. +- **Refresh** -- when the access token is within a 5-minute buffer of expiry, `RefreshAccessToken` exchanges the refresh token (`grant_type=refresh_token`) for a new access + refresh token. Transient failures are retried with the *same* refresh token (Shopify returns the same response for ~1 hour); a 401 or a lapsed refresh token is terminal and surfaces a "reconnect the store" error. +- **Migration** -- a legacy non-expiring token (no refresh token stored) is migrated once via token exchange. This is best-effort: if it fails the existing token is kept (it still works until the deadline); on success Shopify revokes the old token, so the exchange is irreversible per shop. Because a legacy store has no refresh token, the fast-path check never short-circuits it, so migration is throttled by a `Last Migration Attempt` timestamp (retried at most once per hour) to avoid re-attempting a failing migration on every API call in a sync loop. + +Refresh and migration are serialized with a table lock plus a double-checked re-read, because Shopify allows only one refreshable token per app and store -- this keeps concurrent sessions (or multiple BC companies sharing a shop) from thrashing the token. As a safety net, an unexpected 401 from a normal API call triggers a single forced refresh and retry. + +The `Shpfy Token Refresh` job (a recurring Job Queue entry scheduled the first time an enabled Shopify Shop Card is opened) is the proactive backstop. It iterates enabled shops and runs a per-shop worker so one shop's failure does not abort the run, keeping access tokens and 90-day refresh tokens alive for shops that are otherwise idle. When a refresh token has fully lapsed, the Shop Card shows a reconnect notification. + +*Updated: 2026-07-11 -- Expiring offline access token support (slice 637954)* + ## Product synchronization Product sync is bi-directional, controlled by the Shop's `"Sync Item"` setting (To Shopify, From Shopify, or disabled). The two directions have fundamentally different architectures. diff --git a/src/Apps/W1/Shopify/App/docs/data-model.md b/src/Apps/W1/Shopify/App/docs/data-model.md index 4ac3e57900..b95002766b 100644 --- a/src/Apps/W1/Shopify/App/docs/data-model.md +++ b/src/Apps/W1/Shopify/App/docs/data-model.md @@ -14,12 +14,17 @@ The Shop table includes plan-based feature flags. The `"Advanced Shopify Plan"` Shop Location maps Shopify fulfillment locations to BC warehouse locations. Each mapping includes a stock calculation enum that determines how to compute available stock for that pairing. +Registered Store (`Shpfy Registered Store New`) holds the OAuth state for each connected shop, keyed by store URL (not Shop Code): the requested and actual scopes, plus expiring-token metadata (`Token Expires At`, `Refresh Token Expires At`, and `Last Migration Attempt` for migration throttling). The access and refresh tokens themselves are not table fields -- they are stored as `SecretText` in IsolatedStorage (module scope), keyed by the record's SystemId (the refresh token via `SetEncrypted`). This is a per-app+store credential, so multiple BC companies connected to the same shop each keep their own copy. See business-logic.md for the token lifecycle. + +*Updated: 2026-07-11 -- Expiring offline access token metadata (slice 637954)* + ```mermaid erDiagram SHOP ||--o{ SYNCHRONIZATION_INFO : tracks SHOP ||--o{ SHOP_LOCATION : maps SHOP ||--o{ PRODUCT : contains SHOP ||--o{ ORDER_HEADER : contains + SHOP ||..|| REGISTERED_STORE : "OAuth tokens (by URL)" ``` The Shop's `GetEmptySyncTime()` returns `2004-01-01` as a sentinel for "never synced" -- not `0DT`. This date is far enough in the past to import all data on first sync but avoids edge cases with zero-date handling in AL. The `SetLastSyncTime()` method stores `CurrentDateTime` after a successful sync. When the next sync runs, it passes this timestamp to Shopify's `updated_at` filter to fetch only changed records. diff --git a/src/Apps/W1/Shopify/App/docs/patterns.md b/src/Apps/W1/Shopify/App/docs/patterns.md index 42edc27ae7..3aff0760c0 100644 --- a/src/Apps/W1/Shopify/App/docs/patterns.md +++ b/src/Apps/W1/Shopify/App/docs/patterns.md @@ -159,6 +159,14 @@ CustomerNo := ICustomerMapping.DoMapping(...); This pattern repeats for stock calculation, product status, removal actions, customer name formatting, county resolution, company mapping, return/refund processing, metafield types, metafield owners, bulk operations, and document link handlers. Once you understand one instance, you understand them all. +## Job Queue dispatcher and per-shop worker + +Background work that must run per shop with error isolation uses a two-codeunit split. The scheduled `Shpfy Token Refresh` codeunit has `TableNo = "Job Queue Entry"` and iterates the enabled shops. For each shop it calls `Codeunit.Run` on a separate worker, `Shpfy Token Refresh Shop`, which has `TableNo = "Shpfy Shop"`. Running the worker via `Codeunit.Run` gives each shop its own transaction, so a failure for one shop is caught and logged without aborting the whole run. + +The `TableNo` on the dispatcher is not optional: the Job Queue dispatcher passes the `Job Queue Entry` record to the codeunit it runs, so a Job-Queue-scheduled codeunit must use `TableNo = "Job Queue Entry"` (or none). Giving it the record it actually processes (e.g. `"Shpfy Shop"`) fails at runtime with "Record(472) is not compatible". Keep the dispatcher on `"Job Queue Entry"` and delegate record-specific work to a worker codeunit. + +*Updated: 2026-07-11 -- Token refresh backstop (slice 637954)* + ## Legacy patterns ### Config Template Header (removed in v25) diff --git a/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al index 84a0070bc3..63c3ee520c 100644 --- a/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al +++ b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al @@ -212,6 +212,7 @@ codeunit 30103 "Shpfy Communication Mgt." HttpRequestMessage: HttpRequestMessage; HttpResponseMessage: HttpResponseMessage; RetryCounter: Integer; + Sent: Boolean; begin FeatureTelemetry.LogUptake('0000HUV', 'Shopify', Enum::"Feature Uptake Status"::Used); if CheckOutgoingRequest then @@ -230,9 +231,17 @@ codeunit 30103 "Shpfy Communication Mgt." Sleep(Wait); end; - if HttpClient.Send(HttpRequestMessage, HttpResponseMessage) then begin + Sent := HttpClient.Send(HttpRequestMessage, HttpResponseMessage); + if Sent then begin + if HandleUnauthorizedResponse(HttpResponseMessage) then begin + Clear(HttpClient); + Clear(HttpRequestMessage); + Clear(HttpResponseMessage); + CreateHttpRequestMessage(Url, Method, Request, HttpRequestMessage); + Sent := HttpClient.Send(HttpRequestMessage, HttpResponseMessage); + end; Clear(RetryCounter); - while (not HttpResponseMessage.IsBlockedByEnvironment) and (EvaluateResponse(HttpResponseMessage)) and (RetryCounter < MaxRetries) do begin + while Sent and (not HttpResponseMessage.IsBlockedByEnvironment) and (EvaluateResponse(HttpResponseMessage)) and (RetryCounter < MaxRetries) do begin RetryCounter += 1; Sleep(1000); LogShopifyRequest(Url, Method, Request, HttpResponseMessage, Response, RetryCounter); @@ -240,7 +249,7 @@ codeunit 30103 "Shpfy Communication Mgt." Clear(HttpRequestMessage); Clear(HttpResponseMessage); CreateHttpRequestMessage(Url, Method, Request, HttpRequestMessage); - HttpClient.Send(HttpRequestMessage, HttpResponseMessage); + Sent := HttpClient.Send(HttpRequestMessage, HttpResponseMessage); end; end; if GetContent(HttpResponseMessage, Response) then; @@ -374,6 +383,7 @@ codeunit 30103 "Shpfy Communication Mgt." NoAccessTokenErr: label 'No Access token for the store "%1".\Please request an access token for this store.', Comment = '%1 = Store'; ChangedScopeErr: Label 'The application scope is changed, please request a new access token for the store "%1".', Comment = '%1 = Store'; begin + AuthenticationMgt.EnsureValidAccessToken(Store); if RegisteredStoreNew.Get(Store) then if RegisteredStoreNew."Requested Scope" = AuthenticationMgt.GetScope() then begin AccessToken := RegisteredStoreNew.GetAccessToken(); @@ -528,6 +538,21 @@ codeunit 30103 "Shpfy Communication Mgt." end; end; + local procedure HandleUnauthorizedResponse(HttpResponseMessage: HttpResponseMessage): Boolean + var + AuthenticationMgt: Codeunit "Shpfy Authentication Mgt."; + Store: Text; + begin + // An expiring offline token may have been retired unexpectedly. Force a single refresh + // (or migration) so the request can be retried with a fresh token. + if HttpResponseMessage.HttpStatusCode() <> 401 then + exit(false); + Store := Shop.GetStoreName(); + if Store = '' then + exit(false); + exit(AuthenticationMgt.ForceTokenRefresh(Store)); + end; + /// /// Description for SetShop. /// diff --git a/src/Apps/W1/Shopify/App/src/Base/Pages/ShpfyShopCard.Page.al b/src/Apps/W1/Shopify/App/src/Base/Pages/ShpfyShopCard.Page.al index 9d574d9cdd..5a76f31345 100644 --- a/src/Apps/W1/Shopify/App/src/Base/Pages/ShpfyShopCard.Page.al +++ b/src/Apps/W1/Shopify/App/src/Base/Pages/ShpfyShopCard.Page.al @@ -1225,6 +1225,8 @@ page 30101 "Shpfy Shop Card" ApiVersion: Text; ApiVersionExpiryDate: Date; ScopeChangeConfirmLbl: Label 'The access scope of shop %1 for the Shopify connector has changed. Do you want to request a new access token?', Comment = '%1 - Shop Code'; + RefreshTokenExpiredNotificationLbl: Label 'The connection to Shopify shop %1 has expired. Reconnect the shop to continue synchronizing.', Comment = '%1 - Shop Code'; + ReconnectActionLbl: Label 'Reconnect'; ConnectionSuccessfulMsg: Label 'Connection successful.'; ConnectionAndWebhooksSuccessfulMsg: Label 'Connection successful and auto synchronize orders is configured correctly.'; OrderCreatedWebhookNotConfiguredMsg: Label 'Connection successful, but auto synchronize orders is misconfigured. Reactivate Auto Sync Orders setting.'; @@ -1235,6 +1237,8 @@ page 30101 "Shpfy Shop Card" AuthenticationMgt: Codeunit "Shpfy Authentication Mgt."; CommunicationMgt: Codeunit "Shpfy Communication Mgt."; ShopReview: Codeunit "Shpfy Shop Review"; + TokenRefresh: Codeunit "Shpfy Token Refresh"; + RefreshTokenExpiredNotification: Notification; ApiVersionExpiryDateTime: DateTime; begin FeatureTelemetry.LogUptake('0000HUU', 'Shopify', Enum::"Feature Uptake Status"::Discovered); @@ -1244,6 +1248,11 @@ page 30101 "Shpfy Shop Card" ApiVersionExpiryDate := DT2Date(ApiVersionExpiryDateTime); Rec.CheckApiVersionExpiryDate(ApiVersion, ApiVersionExpiryDateTime); + // Ensure the recurring token-refresh backstop exists. Scheduled here (not the API path or + // install/upgrade) so enqueuing never runs inside a caller's business transaction; an admin + // can disable it by setting the Job Queue Entry On Hold. + TokenRefresh.ScheduleRefreshJob(); + if AuthenticationMgt.CheckScopeChange(Rec) then if Confirm(StrSubstNo(ScopeChangeConfirmLbl, Rec.Code)) then begin Rec.RequestAccessToken(); @@ -1257,6 +1266,13 @@ page 30101 "Shpfy Shop Card" Rec.UpdateFulfillmentService(); #endif ShopReview.MaybeShowReviewReminder(Rec.GetStoreName()); + + if AuthenticationMgt.IsRefreshTokenExpired(Rec.GetStoreName()) then begin + RefreshTokenExpiredNotification.Message(StrSubstNo(RefreshTokenExpiredNotificationLbl, Rec.Code)); + RefreshTokenExpiredNotification.SetData('ShopCode', Rec.Code); + RefreshTokenExpiredNotification.AddAction(ReconnectActionLbl, Codeunit::"Shpfy Authentication Mgt.", 'ReconnectFromNotification'); + RefreshTokenExpiredNotification.Send(); + end; end; end; diff --git a/src/Apps/W1/Shopify/App/src/Base/docs/CLAUDE.md b/src/Apps/W1/Shopify/App/src/Base/docs/CLAUDE.md index c725bde0bb..e675c43002 100644 --- a/src/Apps/W1/Shopify/App/src/Base/docs/CLAUDE.md +++ b/src/Apps/W1/Shopify/App/src/Base/docs/CLAUDE.md @@ -21,6 +21,18 @@ GraphQL queries. `Shpfy Communication Events` publishes internal events for every API interaction (`OnClientSend`, `OnClientPost`, `OnClientGet`, `OnGetContent`, `OnGetAccessToken`) -- tests use these to mock responses. +`GetAccessToken` ensures a valid expiring offline access token before each +request via `ShpfyAuthenticationMgt.EnsureValidAccessToken` (refresh a +near-expiry token or migrate a legacy non-expiring one, on demand), and +`ExecuteWebRequest` forces a single token refresh and retry on an +unexpected 401. The `Shpfy Token Refresh` dispatcher plus +`Shpfy Token Refresh Shop` worker are a recurring backstop that keeps +tokens and their 90-day refresh tokens alive; `Shpfy Installer` and +`Shpfy Upgrade Mgt.` register that Job Queue entry. See the app-level +business-logic.md for the full token lifecycle. + +*Updated: 2026-07-11 -- Expiring offline access token support (slice 637954)* + `Shpfy Background Syncs` orchestrates all sync operations via Job Queue, splitting between background-allowed and foreground-only shops. @@ -61,5 +73,6 @@ syncs companies and catalog prices regardless of plan. - The `Shpfy Cue` table uses FlowFields for role center counts: unmapped customers/products/companies, unprocessed orders/shipments, sync errors. - Empty sync time sentinel is `2004-01-01` (`GetEmptySyncTime()`), not `0DT`. +- Authentication uses expiring offline access tokens: `EnsureValidAccessToken` (from `GetAccessToken`) refreshes before expiry and migrates legacy non-expiring tokens; the `Shpfy Token Refresh` dispatcher + `Shpfy Token Refresh Shop` worker are the proactive backstop, registered as a Job Queue entry on install/upgrade. A lapsed 90-day refresh token requires reconnecting the shop from the Shop Card. - Three page extensions embed Shopify Activities into standard role centers. - `ShpfyConnectorGuide` and `ShpfyInitialImport` provide first-time setup. diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al index 6bf47168d1..ee912f72d3 100644 --- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al +++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al @@ -31,6 +31,21 @@ codeunit 30199 "Shpfy Authentication Mgt." EnableHttpRequestActionLbl: Label 'Allow HTTP requests'; InvalidShopUrlErr: Label 'The URL must refer to the internal shop location at myshopify.com. It must not be the public URL that customers use, such as myshop.com.'; NotSupportedOnPremErr: Label 'Shopify connector is only supported in SaaS environments.'; + RefreshTokenExpiredErr: Label 'The Shopify access token for store "%1" has expired and could not be refreshed automatically. Open the Shopify Shop card and reconnect the store to continue.', Comment = '%1 = Store'; + TokenRefreshTransientErr: Label 'The Shopify access token for store "%1" could not be refreshed because of a temporary problem contacting Shopify. Please try again later.', Comment = '%1 = Store'; + ReconnectActionLbl: Label 'Reconnect'; + StoreDimensionTok: Label 'Store', Locked = true; + TokenExchangeGrantTypeTok: Label 'urn:ietf:params:oauth:grant-type:token-exchange', Locked = true; + RefreshTokenGrantTypeTok: Label 'refresh_token', Locked = true; + OfflineAccessTokenTypeTok: Label 'urn:shopify:params:oauth:token-type:offline-access-token', Locked = true; + TokenMigratedTxt: Label 'Migrated Shopify store to an expiring offline access token.', Locked = true; + TokenMigrationFailedTxt: Label 'Failed to migrate Shopify store to an expiring offline access token. The existing token is kept.', Locked = true; + TokenRefreshedTxt: Label 'Refreshed the Shopify expiring offline access token.', Locked = true; + TokenRefreshTransientTxt: Label 'A transient error occurred while refreshing the Shopify access token. The existing token is still valid and will be retried later.', Locked = true; + TokenRefreshExpiredTxt: Label 'The Shopify refresh token has expired. The store must be reconnected.', Locked = true; + ReconnectRequiredTitleLbl: Label 'Shopify reconnection required'; + ReconnectDetailedLbl: Label 'The stored access token could not be refreshed automatically. This usually means the 90-day refresh window lapsed or access was revoked in Shopify. Choose Reconnect to sign in to Shopify again and restore synchronization.'; + TokenExchangeFailedErr: Label 'Could not obtain an access token from Shopify for store "%1". Please try connecting the store again.', Comment = '%1 = Store'; [NonDebuggable] local procedure GetClientId(): Text @@ -110,25 +125,39 @@ codeunit 30199 "Shpfy Authentication Mgt." [NonDebuggable] local procedure GetToken(Store: Text; AuthorizationCode: SecretText) var - JsonHelper: Codeunit "Shpfy Json Helper"; - Body: Text; + RequestBody: JsonObject; + Credentials: Dictionary of [Text, SecretText]; + ResponseBody: Text; + StatusCode: Integer; + begin + RequestBody.Add('client_id', GetClientId()); + RequestBody.Add('client_secret', ''); + RequestBody.Add('code', ''); + RequestBody.Add('expiring', 1); + Credentials.Add('$.client_secret', GetClientSecret()); + Credentials.Add('$.code', AuthorizationCode); + + StatusCode := ExecuteTokenRequest(Store, RequestBody, Credentials, ResponseBody); + if not IsSuccessStatusCode(StatusCode) then + Error(TokenExchangeFailedErr, Store); + if not ResponseHasAccessToken(ResponseBody) then + Error(TokenExchangeFailedErr, Store); + + SaveInstalledToken(Store, ResponseBody); + end; + + [NonDebuggable] + local procedure ExecuteTokenRequest(Store: Text; RequestBody: JsonObject; Credentials: Dictionary of [Text, SecretText]; var ResponseBody: Text): Integer + var SecretBody: SecretText; Url: Text; HttpClient: HttpClient; RequestHeaders: HttpHeaders; RequestHttpContent: HttpContent; HttpResponseMessage: HttpResponseMessage; - JObject: JsonObject; - RequestBody: JsonObject; - Credentials: Dictionary of [Text, SecretText]; AccessTokenURLTxt: Label 'https://%1/admin/oauth/access_token', Comment = '%1 = Store', Locked = true; HttpRequestBlockedErrorInfo: ErrorInfo; begin - RequestBody.Add('client_id', GetClientId()); - RequestBody.Add('client_secret', ''); - RequestBody.Add('code', ''); - Credentials.Add('$.client_secret', GetClientSecret()); - Credentials.Add('$.code', AuthorizationCode); RequestBody.WriteWithSecretsTo(Credentials, SecretBody); Url := StrSubstNo(AccessTokenURLTxt, Store); @@ -147,15 +176,15 @@ codeunit 30199 "Shpfy Authentication Mgt." HttpRequestBlockedErrorInfo.AddAction(EnableHttpRequestActionLbl, Codeunit::"Shpfy Authentication Mgt.", 'EnableHttpRequestForShopifyConnector'); Error(HttpRequestBlockedErrorInfo); end else - exit; + exit(0); - Clear(Body); - HttpResponseMessage.Content().ReadAs(Body); - JObject.ReadFrom(Body); - SaveStoreInfo(Store, JsonHelper.GetValueAsText(JObject.AsToken(), 'scope'), JsonHelper.GetValueAsText(JObject.AsToken(), 'access_token')); + Clear(ResponseBody); + HttpResponseMessage.Content().ReadAs(ResponseBody); + exit(HttpResponseMessage.HttpStatusCode()); end; - local procedure SaveStoreInfo(Store: Text; ActualScope: Text; AccessToken: SecretText) + [NonDebuggable] + local procedure SaveInstalledToken(Store: Text; ResponseBody: Text) var RegisteredStoreNew: Record "Shpfy Registered Store New"; begin @@ -166,9 +195,292 @@ codeunit 30199 "Shpfy Authentication Mgt." RegisteredStoreNew.Insert(); end; RegisteredStoreNew."Requested Scope" := GetScope(); - RegisteredStoreNew."Actual Scope" := CopyStr(ActualScope, 1, MaxStrLen(RegisteredStoreNew."Actual Scope")); RegisteredStoreNew.Modify(); + SaveTokenResponse(RegisteredStoreNew, ResponseBody); + end; + + [NonDebuggable] + local procedure SaveTokenResponse(var RegisteredStoreNew: Record "Shpfy Registered Store New"; ResponseBody: Text) + var + JsonHelper: Codeunit "Shpfy Json Helper"; + JObject: JsonObject; + JToken: JsonToken; + AccessToken: SecretText; + RefreshToken: SecretText; + AccessTokenText: Text; + RefreshTokenText: Text; + ActualScope: Text; + ExpiresInSeconds: BigInteger; + RefreshExpiresInSeconds: BigInteger; + begin + if not JObject.ReadFrom(ResponseBody) then + exit; + JToken := JObject.AsToken(); + + AccessTokenText := JsonHelper.GetValueAsText(JToken, 'access_token'); + if AccessTokenText = '' then + exit; + + ActualScope := JsonHelper.GetValueAsText(JToken, 'scope'); + if ActualScope <> '' then + RegisteredStoreNew."Actual Scope" := CopyStr(ActualScope, 1, MaxStrLen(RegisteredStoreNew."Actual Scope")); + + ExpiresInSeconds := JsonHelper.GetValueAsBigInteger(JToken, 'expires_in'); + if ExpiresInSeconds > 0 then + RegisteredStoreNew."Token Expires At" := AddSeconds(CurrentDateTime(), ExpiresInSeconds) + else + RegisteredStoreNew."Token Expires At" := 0DT; + + RefreshExpiresInSeconds := JsonHelper.GetValueAsBigInteger(JToken, 'refresh_token_expires_in'); + if RefreshExpiresInSeconds > 0 then + RegisteredStoreNew."Refresh Token Expires At" := AddSeconds(CurrentDateTime(), RefreshExpiresInSeconds) + else + RegisteredStoreNew."Refresh Token Expires At" := 0DT; + + RegisteredStoreNew.Modify(); + + AccessToken := AccessTokenText; RegisteredStoreNew.SetAccessToken(AccessToken); + + RefreshTokenText := JsonHelper.GetValueAsText(JToken, 'refresh_token'); + if RefreshTokenText <> '' then begin + RefreshToken := RefreshTokenText; + RegisteredStoreNew.SetRefreshToken(RefreshToken); + end; + end; + + /// + /// Ensures the store has a valid, non-expired offline access token before use: legacy + /// non-expiring tokens are migrated on first use, expiring tokens refreshed near expiry. + /// Uses double-checked locking; the row lock is held across the token request so concurrent + /// sessions cannot retire each other's refresh token. The lock is per-company. + /// + /// The store URL. + internal procedure EnsureValidAccessToken(Store: Text) + var + RegisteredStoreNew: Record "Shpfy Registered Store New"; + begin + Store := Store.ToLower(); + if not RegisteredStoreNew.Get(Store) then + exit; + + if RegisteredStoreNew.HasRefreshToken() then begin + if not TokenNeedsRefresh(RegisteredStoreNew) then + exit; + end else + if not ShouldAttemptMigration(RegisteredStoreNew) then + exit; + + RegisteredStoreNew.LockTable(); + if not RegisteredStoreNew.Get(Store) then + exit; + + if RegisteredStoreNew.HasRefreshToken() then begin + if TokenNeedsRefresh(RegisteredStoreNew) then + RefreshAccessToken(Store, RegisteredStoreNew); + end else + TryMigrate(Store, RegisteredStoreNew); + + Commit(); + end; + + /// + /// Forces a token refresh (or migration) on an unexpected 401, throttled to at most once per + /// minute per store. Returns true if a refresh was attempted (caller may retry), false if throttled. + /// + /// The store URL. + internal procedure ForceTokenRefresh(Store: Text): Boolean + var + RegisteredStoreNew: Record "Shpfy Registered Store New"; + begin + Store := Store.ToLower(); + if not RegisteredStoreNew.Get(Store) then + exit(false); + + if not ShouldForceRefresh(RegisteredStoreNew) then + exit(false); + + RegisteredStoreNew.LockTable(); + if not RegisteredStoreNew.Get(Store) then + exit(false); + if not ShouldForceRefresh(RegisteredStoreNew) then + exit(false); + + RegisteredStoreNew."Last Force Refresh At" := CurrentDateTime(); + RegisteredStoreNew.Modify(); + // Persist the cooldown before the refresh so a terminal-failure rollback can't discard it. + Commit(); + + if RegisteredStoreNew.HasRefreshToken() then + RefreshAccessToken(Store, RegisteredStoreNew) + else + TryMigrate(Store, RegisteredStoreNew); + + Commit(); + exit(true); + end; + + local procedure ShouldForceRefresh(RegisteredStoreNew: Record "Shpfy Registered Store New"): Boolean + begin + if RegisteredStoreNew."Last Force Refresh At" = 0DT then + exit(true); + exit(CurrentDateTime() - RegisteredStoreNew."Last Force Refresh At" >= GetForceRefreshCooldown()); + end; + + local procedure GetForceRefreshCooldown(): Duration + begin + exit(60 * 1000); // Force a reactive refresh for a store at most once per minute. + end; + + local procedure TryMigrate(Store: Text; var RegisteredStoreNew: Record "Shpfy Registered Store New") + begin + // Throttle migration attempts: a legacy store has no refresh token, so without this guard + // every API call in a sync loop would re-attempt a failing migration (lock + HTTP + commit). + if not ShouldAttemptMigration(RegisteredStoreNew) then + exit; + RegisteredStoreNew."Last Migration Attempt" := CurrentDateTime(); + RegisteredStoreNew.Modify(); + if not RegisteredStoreNew.GetAccessToken().IsEmpty() then + MigrateToExpiringToken(Store, RegisteredStoreNew); + end; + + local procedure ShouldAttemptMigration(RegisteredStoreNew: Record "Shpfy Registered Store New"): Boolean + begin + if RegisteredStoreNew."Last Migration Attempt" = 0DT then + exit(true); + exit(CurrentDateTime() - RegisteredStoreNew."Last Migration Attempt" >= GetMigrationCooldown()); + end; + + local procedure GetMigrationCooldown(): Duration + begin + exit(60 * 60 * 1000); // Retry a failed migration at most once per hour. + end; + + [NonDebuggable] + local procedure MigrateToExpiringToken(Store: Text; var RegisteredStoreNew: Record "Shpfy Registered Store New") + var + RequestBody: JsonObject; + Credentials: Dictionary of [Text, SecretText]; + ResponseBody: Text; + StatusCode: Integer; + begin + RequestBody.Add('client_id', GetClientId()); + RequestBody.Add('client_secret', ''); + RequestBody.Add('grant_type', TokenExchangeGrantTypeTok); + RequestBody.Add('subject_token', ''); + RequestBody.Add('subject_token_type', OfflineAccessTokenTypeTok); + RequestBody.Add('requested_token_type', OfflineAccessTokenTypeTok); + RequestBody.Add('expiring', 1); + Credentials.Add('$.client_secret', GetClientSecret()); + Credentials.Add('$.subject_token', RegisteredStoreNew.GetAccessToken()); + + StatusCode := ExecuteTokenRequest(Store, RequestBody, Credentials, ResponseBody); + + // Migration is best-effort: the non-expiring token still works until January 1, 2027, + // so a transient failure must not break the connector. On success the old token is revoked. + if IsSuccessStatusCode(StatusCode) and ResponseHasAccessToken(ResponseBody) then begin + SaveTokenResponse(RegisteredStoreNew, ResponseBody); + Session.LogMessage('0000UIW', TokenMigratedTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok); + end else + Session.LogMessage('0000UIX', TokenMigrationFailedTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok); + end; + + [NonDebuggable] + local procedure RefreshAccessToken(Store: Text; var RegisteredStoreNew: Record "Shpfy Registered Store New") + var + RequestBody: JsonObject; + Credentials: Dictionary of [Text, SecretText]; + ResponseBody: Text; + StatusCode: Integer; + Attempt: Integer; + MaxAttempts: Integer; + begin + if RefreshTokenExpired(RegisteredStoreNew) then begin + Session.LogMessage('0000UIY', TokenRefreshExpiredTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok); + Error(CreateReconnectErrorInfo(Store)); + end; + + MaxAttempts := 3; + for Attempt := 1 to MaxAttempts do begin + Clear(RequestBody); + Clear(Credentials); + RequestBody.Add('client_id', GetClientId()); + RequestBody.Add('client_secret', ''); + RequestBody.Add('grant_type', RefreshTokenGrantTypeTok); + RequestBody.Add('refresh_token', ''); + Credentials.Add('$.client_secret', GetClientSecret()); + // Retry uses the SAME refresh token: Shopify returns the same response for up to 1 hour. + Credentials.Add('$.refresh_token', RegisteredStoreNew.GetRefreshToken()); + + StatusCode := ExecuteTokenRequest(Store, RequestBody, Credentials, ResponseBody); + + if IsSuccessStatusCode(StatusCode) and ResponseHasAccessToken(ResponseBody) then begin + SaveTokenResponse(RegisteredStoreNew, ResponseBody); + Session.LogMessage('0000UIZ', TokenRefreshedTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok); + exit; + end; + + // A 401 with an inactive refresh token is terminal: the merchant must reconnect. + if StatusCode = 401 then begin + Session.LogMessage('0000UJ0', TokenRefreshExpiredTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok); + Error(CreateReconnectErrorInfo(Store)); + end; + + Sleep(1000 * Attempt); + end; + + // Transient failures only (refresh token still valid, no 401): temporary Shopify/network + // problem, not a reconnect. Error only if the current token is already expired. + if TokenExpired(RegisteredStoreNew) then + Error(TokenRefreshTransientErr, Store); + Session.LogMessage('0000UJ1', TokenRefreshTransientTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok); + end; + + local procedure TokenNeedsRefresh(RegisteredStoreNew: Record "Shpfy Registered Store New"): Boolean + begin + // A non-expiring token (no expiry recorded) never needs refreshing. + if RegisteredStoreNew."Token Expires At" = 0DT then + exit(false); + exit(CurrentDateTime() + GetRefreshBufferMs() >= RegisteredStoreNew."Token Expires At"); + end; + + local procedure TokenExpired(RegisteredStoreNew: Record "Shpfy Registered Store New"): Boolean + begin + exit((RegisteredStoreNew."Token Expires At" <> 0DT) and (CurrentDateTime() >= RegisteredStoreNew."Token Expires At")); + end; + + local procedure RefreshTokenExpired(RegisteredStoreNew: Record "Shpfy Registered Store New"): Boolean + begin + exit((RegisteredStoreNew."Refresh Token Expires At" <> 0DT) and (CurrentDateTime() >= RegisteredStoreNew."Refresh Token Expires At")); + end; + + local procedure GetRefreshBufferMs(): Duration + begin + exit(5 * 60 * 1000); // Refresh 5 minutes before the access token expires. + end; + + local procedure AddSeconds(StartDateTime: DateTime; Seconds: BigInteger): DateTime + var + Lifetime: Duration; + begin + Lifetime := Seconds * 1000; + exit(StartDateTime + Lifetime); + end; + + local procedure IsSuccessStatusCode(StatusCode: Integer): Boolean + begin + exit((StatusCode >= 200) and (StatusCode < 300)); + end; + + [NonDebuggable] + local procedure ResponseHasAccessToken(ResponseBody: Text): Boolean + var + JsonHelper: Codeunit "Shpfy Json Helper"; + JObject: JsonObject; + begin + if not JObject.ReadFrom(ResponseBody) then + exit(false); + exit(JsonHelper.GetValueAsText(JObject.AsToken(), 'access_token') <> ''); end; [NonDebuggable] @@ -181,6 +493,68 @@ codeunit 30199 "Shpfy Authentication Mgt." exit(not RegisteredStoreNew.GetAccessToken().IsEmpty()); end; + /// + /// Returns whether the store uses an expiring offline token whose refresh token has expired, + /// meaning the merchant must reconnect the store before the connector can be used again. + /// + /// The store URL. + internal procedure IsRefreshTokenExpired(Store: Text): Boolean + var + RegisteredStoreNew: Record "Shpfy Registered Store New"; + begin + Store := Store.ToLower(); + if not RegisteredStoreNew.Get(Store) then + exit(false); + if not RegisteredStoreNew.HasRefreshToken() then + exit(false); + exit(RefreshTokenExpired(RegisteredStoreNew)); + end; + + internal procedure ReconnectFromNotification(ReconnectNotification: Notification) + var + Shop: Record "Shpfy Shop"; + ShopCode: Code[20]; + begin + if Evaluate(ShopCode, ReconnectNotification.GetData('ShopCode')) then + if Shop.Get(ShopCode) then begin + Shop.RequestAccessToken(); + Shop.GetShopSettings(); + Shop.Modify(); + end; + end; + + local procedure CreateReconnectErrorInfo(Store: Text) ReconnectError: ErrorInfo + begin + ReconnectError.DataClassification := ReconnectError.DataClassification::CustomerContent; + ReconnectError.ErrorType := ReconnectError.ErrorType::Client; + ReconnectError.Verbosity := ReconnectError.Verbosity::Error; + ReconnectError.Title := ReconnectRequiredTitleLbl; + ReconnectError.Message := StrSubstNo(RefreshTokenExpiredErr, Store); + ReconnectError.DetailedMessage := ReconnectDetailedLbl; + ReconnectError.CustomDimensions.Add(StoreDimensionTok, Store); + ReconnectError.AddAction(ReconnectActionLbl, Codeunit::"Shpfy Authentication Mgt.", 'ReconnectFromError'); + end; + + internal procedure ReconnectFromError(ErrorInfo: ErrorInfo) + var + Shop: Record "Shpfy Shop"; + Store: Text; + begin + if not ErrorInfo.CustomDimensions.ContainsKey(StoreDimensionTok) then + exit; + Store := ErrorInfo.CustomDimensions.Get(StoreDimensionTok).ToLower(); + Shop.SetRange(Enabled, true); + if Shop.FindSet() then + repeat + if Shop.GetStoreName() = Store then begin + Shop.RequestAccessToken(); + Shop.GetShopSettings(); + Shop.Modify(); + exit; + end; + until Shop.Next() = 0; + end; + internal procedure AssertValidShopUrl(ShopUrl: Text) var URI: Codeunit Uri; diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al new file mode 100644 index 0000000000..792fa89063 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al @@ -0,0 +1,103 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace Microsoft.Integration.Shopify; + +using System.Threading; + +/// +/// Codeunit Shpfy Token Refresh (ID 30431). +/// Proactive backstop that keeps expiring offline access tokens (and their 90-day refresh tokens) +/// alive for shops that are otherwise idle, and completes the one-time migration of legacy +/// non-expiring tokens. It complements the on-demand refresh performed before every API call. +/// Scheduled as a recurring Job Queue Entry; each shop is processed in its own transaction (via the +/// "Shpfy Token Refresh Shop" worker) so a single failing shop does not abort the whole run. +/// +codeunit 30431 "Shpfy Token Refresh" +{ + Access = Internal; + TableNo = "Job Queue Entry"; + + trigger OnRun() + begin + RefreshAllShops(); + end; + + var + CategoryTok: Label 'Shopify Integration', Locked = true; + TokenRefreshJobFailedTxt: Label 'The scheduled Shopify token refresh failed for a store.', Locked = true; + ScheduleFailedTxt: Label 'Failed to schedule the Shopify token refresh job.', Locked = true; + JobQueueCategoryLbl: Label 'SHPFYAUTH', Locked = true; + JobDescriptionTxt: Label 'Shopify: refresh expiring access tokens'; + + local procedure RefreshAllShops() + var + Shop: Record "Shpfy Shop"; + begin + Shop.SetRange(Enabled, true); + Shop.SetLoadFields("Shopify URL"); + if Shop.FindSet() then + repeat + // Each shop runs in its own transaction (Codeunit.Run) so one failure does not abort the run. + if not Codeunit.Run(Codeunit::"Shpfy Token Refresh Shop", Shop) then + LogRefreshFailure(Shop.Code, GetLastErrorText()); + until Shop.Next() = 0; + end; + + local procedure LogRefreshFailure(ShopCode: Code[20]; ErrorText: Text) + var + Dimensions: Dictionary of [Text, Text]; + begin + // Keep the message generic; the error detail (which may carry customer content) goes into a + // custom dimension and the whole event is classified accordingly. + Dimensions.Add('Category', CategoryTok); + Dimensions.Add('ShopCode', ShopCode); + Dimensions.Add('ErrorText', ErrorText); + Session.LogMessage('0000UIV', TokenRefreshJobFailedTxt, Verbosity::Warning, DataClassification::CustomerContent, TelemetryScope::ExtensionPublisher, Dimensions); + end; + + local procedure LogScheduleFailure(ErrorText: Text) + var + Dimensions: Dictionary of [Text, Text]; + begin + Dimensions.Add('Category', CategoryTok); + Dimensions.Add('ErrorText', ErrorText); + Session.LogMessage('0000UJ6', ScheduleFailedTxt, Verbosity::Warning, DataClassification::CustomerContent, TelemetryScope::ExtensionPublisher, Dimensions); + end; + + /// + /// Creates the recurring backstop Job Queue Entry unless one already exists. Called from the + /// Shop Card, not install/upgrade (where enqueuing, which implicitly commits, is disallowed). + /// The existing-entry check lets an admin disable the backstop by setting the entry On Hold. + /// + internal procedure ScheduleRefreshJob() + var + JobQueueEntry: Record "Job Queue Entry"; + begin + JobQueueEntry.SetRange("Object Type to Run", JobQueueEntry."Object Type to Run"::Codeunit); + JobQueueEntry.SetRange("Object ID to Run", Codeunit::"Shpfy Token Refresh"); + if not JobQueueEntry.IsEmpty() then + exit; + + Clear(JobQueueEntry); + JobQueueEntry.Init(); + JobQueueEntry."Object Type to Run" := JobQueueEntry."Object Type to Run"::Codeunit; + JobQueueEntry."Object ID to Run" := Codeunit::"Shpfy Token Refresh"; + JobQueueEntry."Recurring Job" := true; + JobQueueEntry."Run on Mondays" := true; + JobQueueEntry."Run on Tuesdays" := true; + JobQueueEntry."Run on Wednesdays" := true; + JobQueueEntry."Run on Thursdays" := true; + JobQueueEntry."Run on Fridays" := true; + JobQueueEntry."Run on Saturdays" := true; + JobQueueEntry."Run on Sundays" := true; + JobQueueEntry."No. of Minutes between Runs" := 720; + JobQueueEntry."No. of Attempts to Run" := 5; + JobQueueEntry.Description := CopyStr(JobDescriptionTxt, 1, MaxStrLen(JobQueueEntry.Description)); + JobQueueEntry."Job Queue Category Code" := JobQueueCategoryLbl; + if not Codeunit.Run(Codeunit::"Job Queue - Enqueue", JobQueueEntry) then + LogScheduleFailure(GetLastErrorText()); + end; +} diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefreshShop.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefreshShop.Codeunit.al new file mode 100644 index 0000000000..7d9325282a --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefreshShop.Codeunit.al @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace Microsoft.Integration.Shopify; + +/// +/// Codeunit Shpfy Token Refresh Shop (ID 30432). +/// Per-shop worker for the "Shpfy Token Refresh" backstop. Run via Codeunit.Run so that a failure +/// for one shop is isolated in its own transaction and does not abort the whole backstop run. +/// +codeunit 30432 "Shpfy Token Refresh Shop" +{ + Access = Internal; + TableNo = "Shpfy Shop"; + + trigger OnRun() + var + AuthenticationMgt: Codeunit "Shpfy Authentication Mgt."; + Store: Text; + begin + Store := Rec.GetStoreName(); + if Store <> '' then + AuthenticationMgt.EnsureValidAccessToken(Store); + end; +} diff --git a/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al b/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al index 2339618a1f..a1b4ec19c1 100644 --- a/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al +++ b/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al @@ -41,6 +41,26 @@ table 30138 "Shpfy Registered Store New" Caption = 'Review Completed'; DataClassification = SystemMetadata; } + field(6; "Token Expires At"; DateTime) + { + Caption = 'Token Expires At'; + DataClassification = SystemMetadata; + } + field(7; "Refresh Token Expires At"; DateTime) + { + Caption = 'Refresh Token Expires At'; + DataClassification = SystemMetadata; + } + field(8; "Last Migration Attempt"; DateTime) + { + Caption = 'Last Migration Attempt'; + DataClassification = SystemMetadata; + } + field(9; "Last Force Refresh At"; DateTime) + { + Caption = 'Last Force Refresh At'; + DataClassification = SystemMetadata; + } } keys { @@ -52,11 +72,34 @@ table 30138 "Shpfy Registered Store New" internal procedure SetAccessToken(AccessToken: SecretText) begin - IsolatedStorage.Set('AccessToken(' + Rec.SystemId + ')', AccessToken, DataScope::Module); + // Encrypt at rest when encryption is configured; fall back to unencrypted storage otherwise + // (e.g. on-prem without an encryption key), matching the base app OAuth token pattern. + if EncryptionEnabled() then + IsolatedStorage.SetEncrypted('AccessToken(' + Rec.SystemId + ')', AccessToken, DataScope::Module) + else + IsolatedStorage.Set('AccessToken(' + Rec.SystemId + ')', AccessToken, DataScope::Module); end; internal procedure GetAccessToken() Result: SecretText begin if not IsolatedStorage.Get('AccessToken(' + Rec.SystemId + ')', DataScope::Module, Result) then; end; + + internal procedure SetRefreshToken(RefreshToken: SecretText) + begin + if EncryptionEnabled() then + IsolatedStorage.SetEncrypted('RefreshToken(' + Rec.SystemId + ')', RefreshToken, DataScope::Module) + else + IsolatedStorage.Set('RefreshToken(' + Rec.SystemId + ')', RefreshToken, DataScope::Module); + end; + + internal procedure GetRefreshToken() Result: SecretText + begin + if not IsolatedStorage.Get('RefreshToken(' + Rec.SystemId + ')', DataScope::Module, Result) then; + end; + + internal procedure HasRefreshToken(): Boolean + begin + exit(not GetRefreshToken().IsEmpty()); + end; } diff --git a/src/Apps/W1/Shopify/App/src/PermissionSets/ShpfyObjects.PermissionSet.al b/src/Apps/W1/Shopify/App/src/PermissionSets/ShpfyObjects.PermissionSet.al index 65c48a0472..90878df7c8 100644 --- a/src/Apps/W1/Shopify/App/src/PermissionSets/ShpfyObjects.PermissionSet.al +++ b/src/Apps/W1/Shopify/App/src/PermissionSets/ShpfyObjects.PermissionSet.al @@ -268,6 +268,8 @@ permissionset 30104 "Shpfy - Objects" codeunit "Shpfy Tax Registration No." = X, codeunit "Shpfy ToArchivedProduct" = X, codeunit "Shpfy ToDraftProduct" = X, + codeunit "Shpfy Token Refresh" = X, + codeunit "Shpfy Token Refresh Shop" = X, codeunit "Shpfy Translation API" = X, codeunit "Shpfy Translation Mgt." = X, codeunit "Shpfy Transactions" = X, diff --git a/src/Apps/W1/Shopify/Test/Integration/ShpfyTokenRefreshTest.Codeunit.al b/src/Apps/W1/Shopify/Test/Integration/ShpfyTokenRefreshTest.Codeunit.al new file mode 100644 index 0000000000..983a047fd5 --- /dev/null +++ b/src/Apps/W1/Shopify/Test/Integration/ShpfyTokenRefreshTest.Codeunit.al @@ -0,0 +1,119 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace Microsoft.Integration.Shopify.Test; + +using Microsoft.Integration.Shopify; +using System.TestLibraries.Utilities; + +codeunit 139627 "Shpfy Token Refresh Test" +{ + Subtype = Test; + TestType = IntegrationTest; + TestPermissions = Disabled; + TestHttpRequestPolicy = BlockOutboundRequests; + + var + LibraryAssert: Codeunit "Library Assert"; + Any: Codeunit Any; + StoreTok: Label 'shpfytokentest.myshopify.com', Locked = true; + + [Test] + procedure RefreshTokenRoundTrips() + var + RegisteredStoreNew: Record "Shpfy Registered Store New"; + begin + // [SCENARIO] A refresh token stored for a registered store can be retrieved. + // [GIVEN] A registered store with a stored refresh token. + SetupStore(RegisteredStoreNew, true, DaysFromNow(1)); + + // [THEN] The refresh token is present. + LibraryAssert.IsTrue(RegisteredStoreNew.HasRefreshToken(), 'Refresh token should be stored.'); + LibraryAssert.IsFalse(RegisteredStoreNew.GetRefreshToken().IsEmpty(), 'Refresh token should not be empty.'); + end; + + [Test] + procedure IsRefreshTokenExpiredFalseWhenNoRefreshToken() + var + RegisteredStoreNew: Record "Shpfy Registered Store New"; + AuthenticationMgt: Codeunit "Shpfy Authentication Mgt."; + begin + // [SCENARIO] A legacy non-expiring token (no refresh token) is never reported as expired. + // [GIVEN] A registered store without a refresh token, even with a past refresh expiry. + SetupStore(RegisteredStoreNew, false, DaysFromNow(-1)); + + // [THEN] The refresh token is not considered expired. + LibraryAssert.IsFalse(AuthenticationMgt.IsRefreshTokenExpired(StoreTok), 'A store without a refresh token must not be reported as expired.'); + end; + + [Test] + procedure IsRefreshTokenExpiredTrueWhenExpired() + var + RegisteredStoreNew: Record "Shpfy Registered Store New"; + AuthenticationMgt: Codeunit "Shpfy Authentication Mgt."; + begin + // [SCENARIO] An expiring token whose refresh token lifetime has passed is reported as expired. + // [GIVEN] A registered store with a refresh token and a refresh expiry in the past. + SetupStore(RegisteredStoreNew, true, DaysFromNow(-1)); + + // [THEN] The refresh token is considered expired. + LibraryAssert.IsTrue(AuthenticationMgt.IsRefreshTokenExpired(StoreTok), 'An expired refresh token must be reported as expired.'); + end; + + [Test] + procedure IsRefreshTokenExpiredFalseWhenValid() + var + RegisteredStoreNew: Record "Shpfy Registered Store New"; + AuthenticationMgt: Codeunit "Shpfy Authentication Mgt."; + begin + // [SCENARIO] An expiring token whose refresh token is still within its lifetime is not expired. + // [GIVEN] A registered store with a refresh token and a refresh expiry in the future. + SetupStore(RegisteredStoreNew, true, DaysFromNow(30)); + + // [THEN] The refresh token is not considered expired. + LibraryAssert.IsFalse(AuthenticationMgt.IsRefreshTokenExpired(StoreTok), 'A valid refresh token must not be reported as expired.'); + end; + + [Test] + procedure IsRefreshTokenExpiredFalseWhenExpiryUnknown() + var + RegisteredStoreNew: Record "Shpfy Registered Store New"; + AuthenticationMgt: Codeunit "Shpfy Authentication Mgt."; + begin + // [SCENARIO] A refresh token with no recorded expiry is not reported as expired. + // [GIVEN] A registered store with a refresh token but a blank refresh expiry. + SetupStore(RegisteredStoreNew, true, 0DT); + + // [THEN] The refresh token is not considered expired. + LibraryAssert.IsFalse(AuthenticationMgt.IsRefreshTokenExpired(StoreTok), 'A refresh token with no recorded expiry must not be reported as expired.'); + end; + + local procedure SetupStore(var RegisteredStoreNew: Record "Shpfy Registered Store New"; SetRefresh: Boolean; RefreshExpiresAt: DateTime) + var + RefreshToken: SecretText; + begin + if RegisteredStoreNew.Get(StoreTok) then + RegisteredStoreNew.Delete(); + RegisteredStoreNew.Init(); + RegisteredStoreNew.Store := CopyStr(StoreTok, 1, MaxStrLen(RegisteredStoreNew.Store)); + RegisteredStoreNew."Refresh Token Expires At" := RefreshExpiresAt; + RegisteredStoreNew.Insert(); + if SetRefresh then begin + RefreshToken := Any.AlphanumericText(20); + RegisteredStoreNew.SetRefreshToken(RefreshToken); + end; + end; + + local procedure DaysFromNow(Days: Integer): DateTime + var + Milliseconds: BigInteger; + Offset: Duration; + begin + Milliseconds := Days; + Milliseconds := Milliseconds * 24 * 60 * 60 * 1000; + Offset := Milliseconds; + exit(CurrentDateTime() + Offset); + end; +}