|
18 | 18 | from typing import Optional, Union |
19 | 19 |
|
20 | 20 | from planet_auth.auth_client import AuthClient, AuthClientConfig |
| 21 | +from planet_auth.auth_exception import AuthException |
21 | 22 | from planet_auth.credential import Credential |
22 | 23 | from planet_auth.request_authenticator import CredentialRequestAuthenticator |
23 | 24 | from planet_auth.storage_utils import ObjectStorageProvider |
24 | 25 | from planet_auth.logging.auth_logger import getAuthLogger |
25 | 26 |
|
26 | 27 | auth_logger = getAuthLogger() |
27 | 28 |
|
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) |
31 | 33 |
|
32 | 34 |
|
33 | 35 | class Auth: |
@@ -113,19 +115,129 @@ def request_authenticator_is_ready(self) -> bool: |
113 | 115 | For example, simple API key clients only need an API key in their |
114 | 116 | configuration. OAuth2 user clients need to have performed |
115 | 117 | 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. |
116 | 121 | """ |
117 | 122 | return self._request_authenticator.is_initialized() or self._auth_client.can_login_unattended() |
118 | 123 |
|
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: |
120 | 220 | """ |
121 | 221 | Perform a login with the configured auth client. |
122 | 222 | This higher level function will ensure that the token is saved to |
123 | 223 | storage if Auth context has been configured with a suitable token |
124 | 224 | storage path. Otherwise, the token will be held only in memory. |
125 | 225 | In all cases, the request authenticator will also be updated with |
126 | 226 | 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. |
127 | 233 | """ |
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 | + |
129 | 241 | new_credential.set_path(self._token_file_path) |
130 | 242 | new_credential.set_storage_provider(self._storage_provider) |
131 | 243 | new_credential.save() |
|
0 commit comments