(self, bundle: dict)
| 1187 | return endpoint |
| 1188 | |
| 1189 | def _refresh(self, bundle: dict) -> dict: |
| 1190 | refresh_token = bundle.get("refresh_token") |
| 1191 | if not isinstance(refresh_token, str) or not refresh_token: |
| 1192 | raise SandboxError( |
| 1193 | f"OIDC token for gateway '{self._cluster_name}' has no " |
| 1194 | f"refresh token. Re-authenticate with: openshell gateway " |
| 1195 | f"login" |
| 1196 | ) |
| 1197 | token_endpoint = self._discover_token_endpoint(bundle) |
| 1198 | client_id = bundle.get("client_id", "openshell-cli") |
| 1199 | |
| 1200 | # RFC 6749 §6: refresh_token grant. Form-encoded POST with |
| 1201 | # grant_type, refresh_token, and (for a public client) client_id. |
| 1202 | # No Authorization header (token_endpoint_auth_method="none"). |
| 1203 | try: |
| 1204 | resp = self._http.post( |
| 1205 | token_endpoint, |
| 1206 | data={ |
| 1207 | "grant_type": "refresh_token", |
| 1208 | "refresh_token": refresh_token, |
| 1209 | "client_id": client_id, |
| 1210 | }, |
| 1211 | ) |
| 1212 | except httpx.HTTPError as e: |
| 1213 | raise SandboxError( |
| 1214 | f"OIDC token refresh failed for gateway " |
| 1215 | f"'{self._cluster_name}': {type(e).__name__}: {e}. " |
| 1216 | f"Re-authenticate with: openshell gateway login" |
| 1217 | ) from e |
| 1218 | if resp.status_code != 200: |
| 1219 | # Include the IdP's error body for diagnostics — RFC 6749 |
| 1220 | # mandates a JSON body like {"error":"invalid_grant", ...} |
| 1221 | # on failure, which is the most useful signal to surface. |
| 1222 | error_code = None |
| 1223 | with contextlib.suppress(Exception): |
| 1224 | body = resp.json() |
| 1225 | if isinstance(body, dict): |
| 1226 | error_code = body.get("error") |
| 1227 | detail = "" |
| 1228 | with contextlib.suppress(Exception): |
| 1229 | detail = f": {resp.text[:200]}" |
| 1230 | message = ( |
| 1231 | f"OIDC token refresh failed for gateway " |
| 1232 | f"'{self._cluster_name}': HTTP {resp.status_code}" |
| 1233 | f"{detail}. Re-authenticate with: openshell gateway " |
| 1234 | f"login" |
| 1235 | ) |
| 1236 | # `invalid_grant` specifically means the refresh_token was |
| 1237 | # rejected — distinguished from transport/5xx errors so the |
| 1238 | # caller can retry once with a peer-rotated bundle (a lost |
| 1239 | # cross-process rotation race) before surfacing the failure. |
| 1240 | if error_code == "invalid_grant": |
| 1241 | raise _InvalidGrantError(message) |
| 1242 | raise SandboxError(message) |
| 1243 | try: |
| 1244 | token = resp.json() |
| 1245 | except ValueError as e: |
| 1246 | raise SandboxError( |
no test coverage detected