Skip to content

Commit 63a478d

Browse files
CG-1904 improve expired session ux (#45)
* Improve the user experience around old stale sessions that appear to be initialized, but are actually expired. This is done by providing the new utility method Auth.ensure_request_authenticator_is_ready(). * Save computed expiration time and issued time in token files. This allows for the persistence of this information when dealing with opaque tokens. * Support non-expiring tokens.
1 parent 84c498f commit 63a478d

21 files changed

+973
-46
lines changed

docs/changelog.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
## 2.3.0 - TBD
4+
- Improve the user experience around old stale sessions that appear to be
5+
initialized, but are actually expired. This is done by providing the new
6+
utility method `Auth.ensure_request_authenticator_is_ready()`.
7+
- Save computed expiration time and issued time in token files. This allows
8+
for the persistence of this information when dealing with opaque tokens.
9+
- **Note**: Previously saved OAuth access tokens that are not JWTs with
10+
an `exp` claim that can be inspected will be considered to expire in
11+
`expires_in` seconds from the time they are loaded, since the time
12+
they were issued was not saved in the past.
13+
- Support non-expiring tokens.
14+
315
## 2.2.0 - 2025-10-02
416
- Update supported python versions.
517
Support for 3.9 dropped. Support through 3.14 added.

docs/examples/auth-client/oauth/make-oauth-authenticated-httpx-request.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
def main():
77
logging.basicConfig(level=logging.DEBUG)
88
auth_ctx = planet_auth_utils.PlanetAuthFactory.initialize_auth_client_context(auth_profile_opt="my-custom-profile")
9+
auth_ctx.ensure_request_authenticator_is_ready(allow_open_browser=True, allow_tty_prompt=True)
910
result = httpx.get(
1011
url="https://api.planet.com/basemaps/v1/mosaics",
1112
auth=auth_ctx.request_authenticator(),

docs/examples/auth-client/oauth/make-oauth-authenticated-requests-request.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
def main():
77
logging.basicConfig(level=logging.DEBUG)
88
auth_ctx = planet_auth_utils.PlanetAuthFactory.initialize_auth_client_context(auth_profile_opt="my-custom-profile")
9+
auth_ctx.ensure_request_authenticator_is_ready(allow_open_browser=True, allow_tty_prompt=True)
910
result = requests.get(
1011
url="https://api.planet.com/basemaps/v1/mosaics",
1112
auth=auth_ctx.request_authenticator(),
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import logging
2+
import planet_auth_utils
3+
4+
5+
def main():
6+
logging.basicConfig(level=logging.DEBUG)
7+
auth_ctx = planet_auth_utils.PlanetAuthFactory.initialize_auth_client_context(auth_profile_opt="my-custom-profile")
8+
9+
auth_ctx.ensure_request_authenticator_is_ready(allow_open_browser=True, allow_tty_prompt=True)
10+
11+
12+
if __name__ == "__main__":
13+
main()

src/planet_auth/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
class exists.
5757
"""
5858

59-
from .auth import Auth
59+
from .auth import Auth, AuthClientContextException
6060
from .auth_exception import AuthException
6161
from .auth_client import AuthClientConfig, AuthClient
6262
from .credential import Credential
@@ -145,6 +145,7 @@ class exists.
145145
"Auth",
146146
"AuthClient",
147147
"AuthClientConfig",
148+
"AuthClientContextException",
148149
"AuthCodeAuthClient",
149150
"AuthCodeClientConfig",
150151
"AuthCodeWithClientSecretAuthClient",

src/planet_auth/auth.py

Lines changed: 117 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,18 @@
1818
from typing import Optional, Union
1919

2020
from planet_auth.auth_client import AuthClient, AuthClientConfig
21+
from planet_auth.auth_exception import AuthException
2122
from planet_auth.credential import Credential
2223
from planet_auth.request_authenticator import CredentialRequestAuthenticator
2324
from planet_auth.storage_utils import ObjectStorageProvider
2425
from planet_auth.logging.auth_logger import getAuthLogger
2526

2627
auth_logger = getAuthLogger()
2728

28-
# class AuthClientContextException(AuthException):
29-
# def __init__(self, **kwargs):
30-
# super().__init__(**kwargs)
29+
30+
class AuthClientContextException(AuthException):
31+
def __init__(self, **kwargs):
32+
super().__init__(**kwargs)
3133

3234

3335
class Auth:
@@ -113,19 +115,129 @@ def request_authenticator_is_ready(self) -> bool:
113115
For example, simple API key clients only need an API key in their
114116
configuration. OAuth2 user clients need to have performed
115117
a user login and obtained access or refresh tokens.
118+
119+
Note: This will not detect when a credential is expired or
120+
otherwise invalid.
116121
"""
117122
return self._request_authenticator.is_initialized() or self._auth_client.can_login_unattended()
118123

119-
def login(self, **kwargs) -> Credential:
124+
def ensure_request_authenticator_is_ready(
125+
self, allow_open_browser: Optional[bool] = False, allow_tty_prompt: Optional[bool] = False
126+
) -> None:
127+
"""
128+
Do everything necessary to ensure the request authenticator is ready for use,
129+
while still biasing towards just-in-time operations and not making
130+
unnecessary network requests or prompts for user interaction.
131+
132+
This can be more complex than it sounds given the variations in the
133+
capabilities of authentication clients and possible session states.
134+
Clients may be initialized with active sessions, initialized with stale
135+
but still valid sessions, initialized with invalid or expired
136+
sessions, or completely uninitialized. The process taken to ensure
137+
client readiness with as little user disruption as possible
138+
is as follows:
139+
140+
1. If the client has been logged in and has a non-expired
141+
short-term access token, the client will be considered
142+
ready without prompting the user or probing the network.
143+
This will not require user interaction.
144+
2. If the client has not been logged in and is a type that
145+
can do so without prompting the user, the client will be
146+
considered ready without prompting the user or probing
147+
the network. This will not require user interaction.
148+
Login will be delayed until it is required.
149+
3. If the client has been logged in and has an expired
150+
short-term access token, the network will be probed to attempt
151+
a refresh of the session. This should not require user interaction.
152+
If refresh fails, the user will be prompted to perform a fresh
153+
login, requiring user interaction.
154+
4. If the client has never been logged in and is a type that
155+
requires a user interactive login, a user interactive
156+
login will be initiated.
157+
158+
There still may be conditions where we believe we are
159+
ready, but requests still ultimately fail. For example, if
160+
the auth context holds a static API key or username/password, it is
161+
assumed to be ready but the credentials could be bad. Even when ready
162+
with valid credentia, requests could fail if the service
163+
rejects the request due to its own policy configuration.
164+
165+
Parameters:
166+
allow_open_browser: specify whether login is permitted to open
167+
a browser window.
168+
allow_tty_prompt: specify whether login is permitted to request
169+
input from the terminal.
170+
"""
171+
172+
def _has_credential() -> bool:
173+
# Does not do any JIT checks
174+
return self._request_authenticator.is_initialized()
175+
176+
def _can_obtain_credentials_unattended() -> bool:
177+
# Does not do any JIT checks
178+
return self._auth_client.can_login_unattended()
179+
180+
def _is_expired() -> bool:
181+
# Does not do any JIT check
182+
new_cred = self._request_authenticator.credential(refresh_if_needed=False)
183+
if new_cred:
184+
return new_cred.is_expired()
185+
return True
186+
187+
# Case #1 above.
188+
if _has_credential() and not _is_expired():
189+
return
190+
191+
# Case #2 above.
192+
if _can_obtain_credentials_unattended():
193+
# Should we fetch one? We do not by default because the bias is towards
194+
# JIT operations and silent operations. This so programs can initialize and
195+
# not fail for auth reasons unless the credential is actually needed.
196+
return
197+
198+
# Case #3 above.
199+
if _has_credential() and _is_expired():
200+
try:
201+
# This takes care of making sure the authenticator's credential is
202+
# current with the update. No further action needed on our part.
203+
new_cred = self._request_authenticator.credential(refresh_if_needed=True)
204+
if not new_cred:
205+
raise RuntimeError("Unable to refresh credentials - Unknown error")
206+
if new_cred.is_expired():
207+
raise RuntimeError("Unable to refresh credentials - Refreshed credentials are still expired.")
208+
return
209+
except Exception as e:
210+
auth_logger.warning(
211+
msg=f"Failed to refresh expired credentials (Error: {str(e)}). Attempting interactive login."
212+
)
213+
214+
# Case #4 above.
215+
self.login(allow_open_browser=allow_open_browser, allow_tty_prompt=allow_tty_prompt)
216+
217+
def login(
218+
self, allow_open_browser: Optional[bool] = False, allow_tty_prompt: Optional[bool] = False, **kwargs
219+
) -> Credential:
120220
"""
121221
Perform a login with the configured auth client.
122222
This higher level function will ensure that the token is saved to
123223
storage if Auth context has been configured with a suitable token
124224
storage path. Otherwise, the token will be held only in memory.
125225
In all cases, the request authenticator will also be updated with
126226
the new credentials.
227+
228+
Parameters:
229+
allow_open_browser: specify whether login is permitted to open
230+
a browser window.
231+
allow_tty_prompt: specify whether login is permitted to request
232+
input from the terminal.
127233
"""
128-
new_credential = self._auth_client.login(**kwargs)
234+
new_credential = self._auth_client.login(
235+
allow_open_browser=allow_open_browser, allow_tty_prompt=allow_tty_prompt, **kwargs
236+
)
237+
if not new_credential:
238+
# AuthClient.login() is supposed to raise on failure.
239+
raise AuthClientContextException(message="Unknown login failure. No credentials and no error returned.")
240+
129241
new_credential.set_path(self._token_file_path)
130242
new_credential.set_storage_provider(self._storage_provider)
131243
new_credential.save()

src/planet_auth/credential.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import time
16+
from typing import Optional
17+
1518
from planet_auth.storage_utils import FileBackedJsonObject
1619

1720

@@ -22,7 +25,69 @@ class Credential(FileBackedJsonObject):
2225
storage provider implementation, clear-text .json files or .sops.json files
2326
with field level encryption are supported. Custom storage providers may
2427
offer different functionality.
28+
29+
Subclass Implementor Notes:
30+
The `Credential` class reserves the data fields `_iat` and `_exp` for
31+
internal use. These are used to record the time the credential was
32+
issued and the time that it expires respectively, expressed as seconds
33+
since the epoch. A None or NULL value for `_exp` indicates that
34+
the credential never expires.
35+
36+
This base class does not set these values. It is the responsibility
37+
of subclasses to set these values as appropriate. If left unset,
38+
the credential will be treated as a non-expiring credential with an
39+
indeterminate issued time.
40+
41+
Subclasses that wish to provide values should do so in their
42+
constructor and in their `set_data()` methods.
2543
"""
2644

2745
def __init__(self, data=None, file_path=None, storage_provider=None):
2846
super().__init__(data=data, file_path=file_path, storage_provider=storage_provider)
47+
48+
def expiry_time(self) -> Optional[int]:
49+
"""
50+
The time that the credential expires, expressed as seconds since the epoch.
51+
"""
52+
return self.lazy_get("_exp")
53+
54+
def issued_time(self) -> Optional[int]:
55+
"""
56+
The time that the credential was issued, expressed as seconds since the epoch.
57+
"""
58+
return self.lazy_get("_iat")
59+
60+
def is_expiring(self) -> bool:
61+
"""
62+
Return true if the credential has an expiry time.
63+
"""
64+
return self.expiry_time() is not None
65+
66+
def is_non_expiring(self) -> bool:
67+
"""
68+
Return true if the credential never expires.
69+
"""
70+
return not self.is_expiring()
71+
72+
def is_expired(self, at_time: Optional[int] = None) -> bool:
73+
"""
74+
Return true if the credential is expired at the specified time.
75+
If no time is specified, the current time is used.
76+
Non-expiring credentials will always return false.
77+
"""
78+
if self.is_non_expiring():
79+
return False
80+
81+
if at_time is None:
82+
at_time = int(time.time())
83+
84+
exp = self.expiry_time()
85+
return bool(at_time >= exp) # type: ignore[operator]
86+
87+
def is_not_expired(self, at_time: Optional[int] = None) -> bool:
88+
"""
89+
Return true if the credential is not expired at the specified time.
90+
If no time is specified, the current time is used.
91+
Non-expiring credentials will always return true.
92+
"""
93+
return not self.is_expired(at_time)

src/planet_auth/oidc/oidc_credential.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,25 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import time
1516
from typing import Optional
1617

1718
from planet_auth.credential import Credential
1819
from planet_auth.storage_utils import InvalidDataException, ObjectStorageProvider
20+
from planet_auth.oidc.token_validator import TokenValidator, InvalidArgumentException
1921

2022

2123
class FileBackedOidcCredential(Credential):
2224
"""
2325
Credential object for storing OAuth/OIDC tokens.
26+
Credential should conform to the "token" response
27+
defined in RFC 6749 for OAuth access tokens with OIDC extensions
28+
for ID tokens.
2429
"""
2530

2631
def __init__(self, data=None, credential_file=None, storage_provider: Optional[ObjectStorageProvider] = None):
2732
super().__init__(data=data, file_path=credential_file, storage_provider=storage_provider)
33+
self._augment_rfc6749_data()
2834

2935
def check_data(self, data):
3036
"""
@@ -36,6 +42,65 @@ def check_data(self, data):
3642
message="'access_token', 'id_token', or 'refresh_token' not found in file {}".format(self._file_path)
3743
)
3844

45+
def _augment_rfc6749_data(self):
46+
# RFC 6749 includes an optional "expires_in" expressing the lifespan of
47+
# the token. But without knowing when a token was issued it tells us
48+
# nothing about whether a token is actually valid.
49+
#
50+
# This function lest us augment our representation of this data to
51+
# make this credential useful when reconstructed from saved data
52+
# at a time that is distant from when the token was obtained from the
53+
# authorization server.
54+
#
55+
# Edge case - It's possible that a JWT ID token has an expiration time
56+
# that is different from the access token. It's also possible that
57+
# we have a refresh token and not any other tokens (this state could
58+
# be used for bootstrapping). We are really only tracking
59+
# access token expiration at this time.
60+
if not self._data:
61+
return
62+
63+
try:
64+
access_token_str = self.access_token()
65+
if access_token_str:
66+
(_, jwt_hazmat_body, _) = TokenValidator.hazmat_unverified_decode(access_token_str)
67+
else:
68+
jwt_hazmat_body = None
69+
except InvalidArgumentException:
70+
# Proceed as if it's not a JWT.
71+
jwt_hazmat_body = None
72+
73+
# It's possible for the combination of a transparent bearer token,
74+
# saved iat and exp values, and a expires_in value to be
75+
# over-constrained. We apply the following priority, from highest
76+
# to lowest:
77+
# - Bearer token claims
78+
# - Saved values in the credential file
79+
# - Newly calculated values
80+
# If a reasonable expiration time cannot be derived,
81+
# tokens are assumed to never expire.
82+
rfc6749_lifespan = self._data.get("expires_in", 0)
83+
if jwt_hazmat_body:
84+
_iat = jwt_hazmat_body.get("iat", self._data.get("_iat", int(time.time())))
85+
_exp = jwt_hazmat_body.get("exp", self._data.get("_exp", None))
86+
else:
87+
_iat = self._data.get("_iat", int(time.time()))
88+
_exp = self._data.get("_exp", None)
89+
90+
if _exp is None and rfc6749_lifespan > 0:
91+
_exp = _iat + rfc6749_lifespan
92+
93+
self._data["_iat"] = _iat
94+
self._data["_exp"] = _exp
95+
96+
def set_data(self, data, copy_data: bool = True):
97+
"""
98+
Set credential data for an OAuth/OIDC credential. The data structure is expected
99+
to be an RFC 6749 /token response structure.
100+
"""
101+
super().set_data(data, copy_data)
102+
self._augment_rfc6749_data()
103+
39104
def access_token(self):
40105
"""
41106
Get the current access token.

0 commit comments

Comments
 (0)