diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cf7f5b..0ac3dd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/README.md b/README.md index e7b4d8d..f3d3e26 100644 --- a/README.md +++ b/README.md @@ -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) | @@ -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. diff --git a/shopify_app/__init__.py b/shopify_app/__init__.py index 823f802..a59831a 100644 --- a/shopify_app/__init__.py +++ b/shopify_app/__init__.py @@ -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: """ @@ -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: @@ -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, ) @@ -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: """ @@ -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: @@ -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, ) diff --git a/shopify_app/_version.py b/shopify_app/_version.py index 38c5661..d2a34c5 100644 --- a/shopify_app/_version.py +++ b/shopify_app/_version.py @@ -2,4 +2,4 @@ from __future__ import annotations -__version__ = "0.1.2" +__version__ = "0.1.3" diff --git a/shopify_app/exchange/refresh_token.py b/shopify_app/exchange/refresh_token.py index 7264609..d62258b 100644 --- a/shopify_app/exchange/refresh_token.py +++ b/shopify_app/exchange/refresh_token.py @@ -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. @@ -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, diff --git a/shopify_app/exchange/token_exchange.py b/shopify_app/exchange/token_exchange.py index 397f163..f8c85bc 100644 --- a/shopify_app/exchange/token_exchange.py +++ b/shopify_app/exchange/token_exchange.py @@ -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: """ @@ -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: @@ -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 @@ -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 @@ -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 @@ -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" @@ -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" @@ -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: """ @@ -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: @@ -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 @@ -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 diff --git a/shopify_app/types.py b/shopify_app/types.py index c4097dc..783e8c8 100644 --- a/shopify_app/types.py +++ b/shopify_app/types.py @@ -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]