Skip to content

Commit d36ce53

Browse files
committed
Add API for migrating non expiring to expiring offline tokens
1 parent 483f52f commit d36ce53

File tree

4 files changed

+183
-0
lines changed

4 files changed

+183
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Note: For changes to the API, see https://shopify.dev/changelog?filter=api
44
## Unreleased
55

66
- Add support for expiring offline access tokens with refresh tokens. See [OAuth documentation](docs/usage/oauth.md#expiring-offline-access-tokens) for details.
7+
- Add `ShopifyAPI::Auth::TokenExchange.migrate_to_expiring_token` method to migrate existing non-expiring offline tokens to expiring tokens. See [migration documentation](docs/usage/oauth.md#migrating-non-expiring-tokens-to-expiring-tokens) for details.
78

89
### 15.0.0
910

docs/usage/oauth.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ For more information on authenticating a Shopify app please see the [Types of Au
1616
- [Expiring Offline Access Tokens](#expiring-offline-access-tokens)
1717
- [Enabling Expiring Offline Access Tokens](#enabling-expiring-offline-access-tokens)
1818
- [Refreshing Access Tokens](#refreshing-access-tokens)
19+
- [Migrating Non-Expiring Tokens to Expiring Tokens](#migrating-non-expiring-tokens-to-expiring-tokens)
1920
- [Using OAuth Session to make authenticated API calls](#using-oauth-session-to-make-authenticated-api-calls)
2021

2122
## Session Persistence
@@ -387,6 +388,51 @@ if session.refresh_token_expired?
387388
end
388389
```
389390

391+
### Migrating Non-Expiring Tokens to Expiring Tokens
392+
393+
If you have existing non-expiring offline access tokens and want to migrate them to expiring tokens, you can use the `ShopifyAPI::Auth::TokenExchange.migrate_to_expiring_token` method. This performs a token exchange that converts your non-expiring offline token into an expiring one with a refresh token.
394+
395+
> [!WARNING]
396+
> This is a **one-time, irreversible migration** per shop. Once you migrate a shop's token to an expiring token, you cannot convert it back to a non-expiring token. The shop would need to reinstall your app with `expiring_offline_access_tokens: false` in your Context configuration to obtain a new non-expiring token.
397+
398+
#### Input
399+
| Parameter | Type | Required? | Notes |
400+
| -------------- | -------- | :-------: | ----------------------------------------------------------------------------------------------- |
401+
| `non_expiring_offline_session` | `ShopifyAPI::Auth::Session` | Yes | A Session object containing the non-expiring offline access token to migrate. |
402+
403+
#### Output
404+
This method returns a new `ShopifyAPI::Auth::Session` object with an expiring access token and refresh token. Your app should store this new session to replace the non-expiring one.
405+
406+
#### Example
407+
```ruby
408+
def migrate_shop_to_expiring_offline_token(shop)
409+
# Retrieve the existing non-expiring session
410+
old_session = MyApp::SessionRepository.retrieve_shop_session_by_shopify_domain(shop)
411+
412+
# Migrate to expiring token
413+
new_session = ShopifyAPI::Auth::TokenExchange.migrate_to_expiring_token(
414+
non_expiring_offline_session: old_session
415+
)
416+
417+
# Store the new expiring session, replacing the old one
418+
MyApp::SessionRepository.store_shop_session(new_session)
419+
end
420+
```
421+
422+
#### Migration Strategy
423+
When migrating your app to use expiring tokens, follow this order:
424+
425+
1. **Update your database schema** to add `expires_at` (timestamp), `refresh_token` (string) and `refresh_token_expires` (timestamp) columns to your session storage
426+
2. **Implement refresh logic** in your app to handle token expiration using `ShopifyAPI::Auth::RefreshToken.refresh_access_token`
427+
3. **Enable expiring tokens in your Context setup** so new installations will request and receive expiring tokens:
428+
```ruby
429+
ShopifyAPI::Context.setup(
430+
expiring_offline_access_tokens: true,
431+
# ... other config
432+
)
433+
```
434+
4. **Migrate existing non-expiring tokens** for shops that have already installed your app using the migration method above
435+
390436
## Using OAuth Session to make authenticated API calls
391437
Once your OAuth flow is complete, and you have persisted your `Session` object, you may use that `Session` object to make authenticated API calls.
392438

lib/shopify_api/auth/token_exchange.rb

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,50 @@ def exchange_token(shop:, session_token:, requested_token_type:)
7878
access_token_response: Oauth::AccessTokenResponse.from_hash(session_params),
7979
)
8080
end
81+
82+
sig do
83+
params(
84+
non_expiring_offline_session: ShopifyAPI::Auth::Session,
85+
).returns(ShopifyAPI::Auth::Session)
86+
end
87+
def migrate_to_expiring_token(non_expiring_offline_session:)
88+
unless ShopifyAPI::Context.setup?
89+
raise ShopifyAPI::Errors::ContextNotSetupError,
90+
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
91+
end
92+
93+
body = {
94+
client_id: ShopifyAPI::Context.api_key,
95+
client_secret: ShopifyAPI::Context.api_secret_key,
96+
grant_type: TOKEN_EXCHANGE_GRANT_TYPE,
97+
subject_token: non_expiring_offline_session.access_token,
98+
subject_token_type: RequestedTokenType::OFFLINE_ACCESS_TOKEN.serialize,
99+
requested_token_type: RequestedTokenType::OFFLINE_ACCESS_TOKEN.serialize,
100+
expiring: "1",
101+
}
102+
103+
client = Clients::HttpClient.new(session: non_expiring_offline_session, base_path: "/admin/oauth")
104+
response = begin
105+
client.request(
106+
Clients::HttpRequest.new(
107+
http_method: :post,
108+
path: "access_token",
109+
body: body,
110+
body_type: "application/json",
111+
),
112+
)
113+
rescue ShopifyAPI::Errors::HttpResponseError => error
114+
ShopifyAPI::Context.logger.debug("Failed to migrate to expiring offline token: #{error.message}")
115+
raise error
116+
end
117+
118+
session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h
119+
120+
Session.from(
121+
shop: non_expiring_offline_session.shop,
122+
access_token_response: Oauth::AccessTokenResponse.from_hash(session_params),
123+
)
124+
end
81125
end
82126
end
83127
end

test/auth/token_exchange_test.rb

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,98 @@ def test_exchange_token_online_token
228228

229229
assert_equal(expected_session, session)
230230
end
231+
232+
def test_migrate_to_expiring_token_context_not_setup
233+
modify_context(api_key: "", api_secret_key: "", host: "")
234+
non_expiring_session = ShopifyAPI::Auth::Session.new(
235+
shop: @shop,
236+
access_token: "old-offline-token-123",
237+
)
238+
239+
assert_raises(ShopifyAPI::Errors::ContextNotSetupError) do
240+
ShopifyAPI::Auth::TokenExchange.migrate_to_expiring_token(
241+
non_expiring_offline_session: non_expiring_session,
242+
)
243+
end
244+
end
245+
246+
def test_migrate_to_expiring_token_success
247+
non_expiring_token = "old-offline-token-123"
248+
non_expiring_session = ShopifyAPI::Auth::Session.new(
249+
shop: @shop,
250+
access_token: non_expiring_token,
251+
)
252+
migration_request = {
253+
client_id: ShopifyAPI::Context.api_key,
254+
client_secret: ShopifyAPI::Context.api_secret_key,
255+
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
256+
subject_token: non_expiring_token,
257+
subject_token_type: "urn:shopify:params:oauth:token-type:offline-access-token",
258+
requested_token_type: "urn:shopify:params:oauth:token-type:offline-access-token",
259+
expiring: "1",
260+
}
261+
262+
stub_request(:post, "https://#{@shop}/admin/oauth/access_token")
263+
.with(
264+
body: migration_request,
265+
headers: { "Content-Type" => "application/json" },
266+
)
267+
.to_return(body: @expiring_offline_token_response.to_json, headers: { content_type: "application/json" })
268+
269+
expected_session = ShopifyAPI::Auth::Session.new(
270+
id: "offline_#{@shop}",
271+
shop: @shop,
272+
access_token: @expiring_offline_token_response[:access_token],
273+
scope: @expiring_offline_token_response[:scope],
274+
is_online: false,
275+
expires: @stubbed_time_now + @expiring_offline_token_response[:expires_in].to_i,
276+
shopify_session_id: @expiring_offline_token_response[:session],
277+
refresh_token: @expiring_offline_token_response[:refresh_token],
278+
refresh_token_expires: @stubbed_time_now + @expiring_offline_token_response[:refresh_token_expires_in].to_i,
279+
)
280+
281+
session = Time.stub(:now, @stubbed_time_now) do
282+
ShopifyAPI::Auth::TokenExchange.migrate_to_expiring_token(
283+
non_expiring_offline_session: non_expiring_session,
284+
)
285+
end
286+
287+
assert_equal(expected_session, session)
288+
end
289+
290+
def test_migrate_to_expiring_token_http_error
291+
non_expiring_token = "old-offline-token-123"
292+
non_expiring_session = ShopifyAPI::Auth::Session.new(
293+
shop: @shop,
294+
access_token: non_expiring_token,
295+
)
296+
migration_request = {
297+
client_id: ShopifyAPI::Context.api_key,
298+
client_secret: ShopifyAPI::Context.api_secret_key,
299+
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
300+
subject_token: non_expiring_token,
301+
subject_token_type: "urn:shopify:params:oauth:token-type:offline-access-token",
302+
requested_token_type: "urn:shopify:params:oauth:token-type:offline-access-token",
303+
expiring: "1",
304+
}
305+
306+
stub_request(:post, "https://#{@shop}/admin/oauth/access_token")
307+
.with(
308+
body: migration_request,
309+
headers: { "Content-Type" => "application/json" },
310+
)
311+
.to_return(
312+
status: 400,
313+
body: { error: "invalid_subject_token" }.to_json,
314+
headers: { content_type: "application/json" },
315+
)
316+
317+
assert_raises(ShopifyAPI::Errors::HttpResponseError) do
318+
ShopifyAPI::Auth::TokenExchange.migrate_to_expiring_token(
319+
non_expiring_offline_session: non_expiring_session,
320+
)
321+
end
322+
end
231323
end
232324
end
233325
end

0 commit comments

Comments
 (0)