Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.3]

- Add optional `expiring` parameter to `exchange_using_token_exchange`. Defaults to `True`. Pass `False` to request a non-expiring token (no `refresh_token` or `refresh_token_expires`). If `False`, `refresh_token` and `refresh_token_expires` will be `None` in result.

## [0.1.2]

- Redact sensitive information in `log` and `http_logs`
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,9 +358,9 @@ You will need to write the database code to get and save access tokens. The pack
| `access_mode` | str | Access mode: "online" or "offline" |
| `token` | str | The access token |
| `scope` | str | Granted scopes |
| `refresh_token` | str | Token used to refresh the access token |
| `expires` | str | ISO 8601 datetime when access token expires |
| `refresh_token_expires` | str | ISO 8601 datetime when refresh token expires |
| `refresh_token` | str or None | Token used to refresh the access token. `None` for non-expiring tokens. |
| `expires` | str or None | ISO 8601 datetime when access token expires. `None` for non-expiring tokens. |
| `refresh_token_expires` | str or None | ISO 8601 datetime when refresh token expires. `None` for non-expiring tokens. |
| `user_id` | str | A unique identifier for the user |
| `user` | AccessUser | User details (online mode only, `None` for offline) |

Expand Down Expand Up @@ -400,6 +400,7 @@ If there is no access token in the database, use token exchange to get one:
Note:

- `exchange_using_token_exchange` receives `result.new_id_token_response` from the verify function. This allows Shopify to automatically retry this request if the id token has become stale.
- Pass `expiring=False` to request a non-expiring token (no `refresh_token` or `refresh_token_expires`). Defaults to `True`.
- If using online access tokens, use the `user_id` provided by the `result`.
- If your app has need to access the admin API outside of requests from App Home, Admin UI Extensions or POS UI Extensions you should also exchange and save an offline token.

Expand Down
6 changes: 6 additions & 0 deletions shopify_app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ def exchange_using_token_exchange(
access_mode: str,
id_token: Optional[Union[IdTokenDetails, dict]] = None,
invalid_token_response: Optional[Union[Res, dict]] = None,
expiring: bool = True,
http_client: Optional[httpx.Client] = None,
) -> TokenExchangeResult:
"""
Expand All @@ -256,6 +257,7 @@ def exchange_using_token_exchange(
access_mode (str): Either "online" or "offline"
id_token (IdTokenDetails | dict): IdTokenDetails or dict with exchangeable, token, and claims
invalid_token_response (Res | dict): Pre-built response to return if token is invalid (or None)
expiring (bool): Whether the token should expire (default True). Set to False for non-expiring tokens.
http_client: Optional HTTP client for testing (undocumented)

Returns:
Expand All @@ -266,6 +268,7 @@ def exchange_using_token_exchange(
self.config,
id_token=id_token,
invalid_token_response=invalid_token_response,
expiring=expiring,
http_client=http_client,
)

Expand Down Expand Up @@ -354,6 +357,7 @@ async def exchange_using_token_exchange_async(
access_mode: str,
id_token: Optional[Union[IdTokenDetails, dict]] = None,
invalid_token_response: Optional[Union[Res, dict]] = None,
expiring: bool = True,
http_client: Optional[httpx.AsyncClient] = None,
) -> TokenExchangeResult:
"""
Expand All @@ -365,6 +369,7 @@ async def exchange_using_token_exchange_async(
access_mode (str): Either "online" or "offline"
id_token (IdTokenDetails | dict): IdTokenDetails or dict with exchangeable, token, and claims
invalid_token_response (Res | dict): Pre-built response to return if token is invalid (or None)
expiring (bool): Whether the token should expire (default True). Set to False for non-expiring tokens.
http_client: Optional async HTTP client for testing (httpx.AsyncClient)

Returns:
Expand All @@ -375,6 +380,7 @@ async def exchange_using_token_exchange_async(
self.config,
id_token=id_token,
invalid_token_response=invalid_token_response,
expiring=expiring,
http_client=http_client,
)

Expand Down
2 changes: 1 addition & 1 deletion shopify_app/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

from __future__ import annotations

__version__ = "0.1.2"
__version__ = "0.1.3"
17 changes: 16 additions & 1 deletion shopify_app/exchange/refresh_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ def refresh_access_token(


def _validate_refresh_token(
refresh_token: str,
refresh_token: Optional[str],
shop: Optional[str] = None,
) -> Tuple[bool, Optional[TokenExchangeResult]]:
"""Validate refresh_token parameter.
Expand All @@ -204,6 +204,21 @@ def _validate_refresh_token(
Returns:
tuple: (is_valid: bool, error_response: TokenExchangeResult or None)
"""
if refresh_token is None:
return (
False,
TokenExchangeResult(
ok=False,
shop=None,
access_token=None,
log=Log(
code="configuration_error",
detail="Non-expiring access tokens cannot be refreshed.",
),
http_logs=[],
response=Res(status=500, body="", headers={}),
),
)
if not refresh_token:
return (
False,
Expand Down
59 changes: 48 additions & 11 deletions shopify_app/exchange/token_exchange.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def token_exchange(
app_config: AppConfig,
id_token: Optional[Union[IdTokenDetails, dict]] = None,
invalid_token_response: Optional[Union[Res, dict]] = None,
expiring: bool = True,
http_client: Optional[httpx.Client] = None,
) -> TokenExchangeResult:
"""
Expand Down Expand Up @@ -80,6 +81,19 @@ def token_exchange(
raise RuntimeError("_validate_access_mode returned invalid but no error")
return error

if not expiring and access_mode == "online":
return TokenExchangeResult(
ok=False,
shop=None,
access_token=None,
log=Log(
code="configuration_error",
detail="The expiring parameter is only applicable to offline access tokens. Online tokens always expire.",
),
http_logs=[],
response=Res(status=500, body="", headers={}),
)

is_valid, error = _validate_id_token(id_token)
if not is_valid:
if error is None:
Expand All @@ -104,7 +118,7 @@ def token_exchange(

# Build request
token_endpoint, request_body, request_headers, req_obj = _build_request(
client_id, client_secret, jwt_string, access_mode, shop_url
client_id, client_secret, jwt_string, access_mode, shop_url, expiring
)

# Make the request with retry logic for 429 responses
Expand Down Expand Up @@ -148,7 +162,10 @@ def token_exchange(
Literal["online", "offline"], access_mode
)
return _handle_success_response(
response_data, shop_name, access_mode_literal, http_logs
response_data,
shop_name,
access_mode_literal,
http_logs,
)

# Handle 429 rate limit with retry helper
Expand Down Expand Up @@ -329,7 +346,9 @@ def _validate_id_token(
return (True, None)


def _build_request(client_id, client_secret, jwt_string, access_mode, shop_url):
def _build_request(
client_id, client_secret, jwt_string, access_mode, shop_url, expiring: bool = True
):
"""Build token exchange request components."""
import json

Expand All @@ -344,7 +363,7 @@ def _build_request(client_id, client_secret, jwt_string, access_mode, shop_url):
"subject_token": jwt_string,
"subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
"requested_token_type": requested_token_type,
"expiring": 1,
"expiring": 1 if expiring else 0,
}

token_endpoint = f"{shop_url}/admin/oauth/access_token"
Expand Down Expand Up @@ -487,15 +506,16 @@ def _handle_success_response(
) -> TokenExchangeResult:
"""Handle successful token exchange response."""
access_token = response_data.get("access_token", "")
expires_in = response_data.get("expires_in")
scope = response_data.get("scope", "")
refresh_token = response_data.get("refresh_token", "")
refresh_token_expires_in = response_data.get("refresh_token_expires_in")
associated_user = response_data.get("associated_user")
associated_user_scope = response_data.get("associated_user_scope", "")

# Calculate expiration timestamps
# If expires_in is None, the token doesn't expire
expires_in = response_data.get("expires_in")
refresh_token = response_data.get("refresh_token")
refresh_token_expires_in = response_data.get("refresh_token_expires_in")

# Calculate expiration timestamps from response data.
# If expires_in is absent (e.g. non-expiring offline tokens), expires will be None.
expires = (
(datetime.now(timezone.utc) + timedelta(seconds=expires_in)).strftime(
"%Y-%m-%dT%H:%M:%SZ"
Expand Down Expand Up @@ -653,6 +673,7 @@ async def token_exchange_async(
app_config: AppConfig,
id_token: Optional[Union[IdTokenDetails, dict]] = None,
invalid_token_response: Optional[Union[Res, dict]] = None,
expiring: bool = True,
http_client: Optional[httpx.AsyncClient] = None,
) -> TokenExchangeResult:
"""
Expand Down Expand Up @@ -692,6 +713,19 @@ async def token_exchange_async(
raise RuntimeError("_validate_access_mode returned invalid but no error")
return error

if not expiring and access_mode == "online":
return TokenExchangeResult(
ok=False,
shop=None,
access_token=None,
log=Log(
code="configuration_error",
detail="The expiring parameter is only applicable to offline access tokens. Online tokens always expire.",
),
http_logs=[],
response=Res(status=500, body="", headers={}),
)

is_valid, error = _validate_id_token(id_token)
if not is_valid:
if error is None:
Expand All @@ -716,7 +750,7 @@ async def token_exchange_async(

# Build request
token_endpoint, request_body, request_headers, req_obj = _build_request(
client_id, client_secret, jwt_string, access_mode, shop_url
client_id, client_secret, jwt_string, access_mode, shop_url, expiring
)

# Make the request with retry logic for 429 responses
Expand Down Expand Up @@ -760,7 +794,10 @@ async def token_exchange_async(
Literal["online", "offline"], access_mode
)
return _handle_success_response(
response_data, shop_name, access_mode_literal, http_logs
response_data,
shop_name,
access_mode_literal,
http_logs,
)

# Handle 429 rate limit with retry helper
Expand Down
2 changes: 1 addition & 1 deletion shopify_app/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ class TokenExchangeAccessToken:
expires: Optional[str]
scope: str
access_mode: Literal["online", "offline"]
refresh_token: str
refresh_token: Optional[str]
refresh_token_expires: Optional[str]
user: Optional[User]

Expand Down
Loading