diff --git a/cli/agos.py b/cli/agos.py index ab044f6..dc6e705 100644 --- a/cli/agos.py +++ b/cli/agos.py @@ -11,6 +11,7 @@ import base64 import hashlib import json import secrets +import socket import threading import time import uuid @@ -191,6 +192,166 @@ def _write_auth_state(payload): os.chmod(_AUTH_FILE, 0o600) +def _is_port_free(host: str, port: int) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + s.bind((host, port)) + return True + except OSError: + return False + + +def _find_callback_port(host: str, preferred_port: int, correlation_id: str) -> int: + """Return preferred_port if free, else warn and try the next 10 ports.""" + if _is_port_free(host, preferred_port): + return preferred_port + _emit_log('WARN', 'preferred_port_busy', correlation_id, + metadata={'host': host, 'port': preferred_port}) + console.print( + f'[yellow]⚠ Port {preferred_port} is already in use. ' + f'Trying nearby ports...[/yellow]' + ) + for candidate in range(preferred_port + 1, preferred_port + 11): + if _is_port_free(host, candidate): + console.print(f'[yellow] → Using port {candidate} for the login callback.[/yellow]') + _emit_log('INFO', 'fallback_port_selected', correlation_id, + metadata={'host': host, 'port': candidate}) + return candidate + raise click.ClickException( + f'Cannot find a free port near {preferred_port}. ' + f'Free up port {preferred_port} and try again.' + ) + + +_CALLBACK_SUCCESS_HTML = """ + + + + + Agos — Logged in + + + + +
+
✓
+

You're logged in to Agos

+

Authentication successful. Return to your terminal.

+ {user_block} + Open Agos → + +

This tab will close automatically in a few seconds.

+
+ +""" + +_CALLBACK_ERROR_HTML = """ + + + + + Agos — Login failed + + + +
+
✗
+

Login failed

+

Something went wrong during authentication.

+
{error_detail}
+

Return to your terminal and run agos login to try again.

+
+ +""" + + def _start_callback_server(host, port, expected_state, timeout_seconds, correlation_id): callback_event = threading.Event() callback_payload = {} @@ -206,18 +367,27 @@ def _start_callback_server(host, port, expected_state, timeout_seconds, correlat callback_payload['error_description'] = params.get('error_description', [None])[0] callback_payload['received_at'] = _utc_now_iso() callback_payload['valid_state'] = callback_payload.get('state') == expected_state - body = ( - '

Agos CLI login complete.

' - '

You can return to the terminal.

' - if callback_payload['valid_state'] and not callback_payload.get('error') - else '

Agos CLI login failed.

' - '

Return to the terminal for details.

' - ) - status = 200 if callback_payload['valid_state'] else 400 + + success = callback_payload['valid_state'] and not callback_payload.get('error') + if success: + user_block = '' # filled in post-token-exchange; safe placeholder + body = _CALLBACK_SUCCESS_HTML.format(user_block=user_block) + status = 200 + else: + error_detail = ( + callback_payload.get('error_description') + or callback_payload.get('error') + or 'Unknown error' + ) + body = _CALLBACK_ERROR_HTML.format(error_detail=error_detail) + status = 400 + + encoded = body.encode('utf-8') self.send_response(status) self.send_header('Content-Type', 'text/html; charset=utf-8') + self.send_header('Content-Length', str(len(encoded))) self.end_headers() - self.wfile.write(body.encode('utf-8')) + self.wfile.write(encoded) callback_event.set() def log_message(self, format, *args): @@ -334,8 +504,11 @@ def login(timeout_seconds, issuer, client_id, host, port): resolved_issuer = issuer or _BUNDLE_IAM_ISSUER resolved_client_id = client_id or _BUNDLE_IAM_CLIENT_ID resolved_host = host or _BUNDLE_IAM_CALLBACK_HOST - resolved_port = port or _BUNDLE_IAM_CALLBACK_PORT + preferred_port = port or _BUNDLE_IAM_CALLBACK_PORT resolved_scopes = _parse_scopes(_first_env('AGOS_CLI_IAM_SCOPES')) + + # Port availability check — find a free port before opening the browser + resolved_port = _find_callback_port(resolved_host, preferred_port, correlation_id) redirect_uri = f'http://{resolved_host}:{resolved_port}/callback' _emit_log(