feat: polished login callback — port check, fallback, branded success page
Port availability check before spawning callback server. Auto-fallback to next free port if 8976 is busy. Branded dark HTML success/failure pages with auto-close, Docs/Privacy/Terms links. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
193
cli/agos.py
193
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 = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Agos — Logged in</title>
|
||||
<style>
|
||||
*, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #0f1117;
|
||||
color: #e2e8f0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}}
|
||||
.card {{
|
||||
background: #1a1d27;
|
||||
border: 1px solid #2d3148;
|
||||
border-radius: 16px;
|
||||
padding: 48px 40px 40px;
|
||||
max-width: 440px;
|
||||
width: 90%;
|
||||
text-align: center;
|
||||
box-shadow: 0 24px 64px rgba(0,0,0,0.5);
|
||||
}}
|
||||
.icon {{
|
||||
width: 64px; height: 64px;
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin: 0 auto 24px;
|
||||
font-size: 28px;
|
||||
}}
|
||||
h1 {{ font-size: 22px; font-weight: 700; color: #f1f5f9; margin-bottom: 8px; }}
|
||||
.subtitle {{ font-size: 14px; color: #94a3b8; margin-bottom: 32px; }}
|
||||
.user {{ font-size: 13px; color: #6366f1; background: #1e2035; border-radius: 8px;
|
||||
padding: 10px 16px; margin-bottom: 28px; font-weight: 500; }}
|
||||
.links {{
|
||||
display: flex; justify-content: center; gap: 20px;
|
||||
flex-wrap: wrap; margin-bottom: 28px;
|
||||
}}
|
||||
.links a {{
|
||||
font-size: 13px; color: #64748b; text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}}
|
||||
.links a:hover {{ color: #6366f1; }}
|
||||
.visit-btn {{
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
color: #fff; font-size: 14px; font-weight: 600;
|
||||
padding: 12px 28px; border-radius: 8px; text-decoration: none;
|
||||
transition: opacity 0.2s;
|
||||
}}
|
||||
.visit-btn:hover {{ opacity: 0.85; }}
|
||||
.close-note {{ font-size: 12px; color: #475569; margin-top: 20px; }}
|
||||
</style>
|
||||
<script>
|
||||
// Auto-close after 4 seconds
|
||||
setTimeout(function() {{ window.close(); }}, 4000);
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="icon">✓</div>
|
||||
<h1>You're logged in to Agos</h1>
|
||||
<p class="subtitle">Authentication successful. Return to your terminal.</p>
|
||||
{user_block}
|
||||
<a class="visit-btn" href="https://agos.armco.dev" target="_blank">Open Agos →</a>
|
||||
<div class="links" style="margin-top:20px">
|
||||
<a href="https://agos.armco.dev/docs" target="_blank">Docs</a>
|
||||
<a href="https://agos.armco.dev/privacy" target="_blank">Privacy</a>
|
||||
<a href="https://agos.armco.dev/terms" target="_blank">Terms</a>
|
||||
</div>
|
||||
<p class="close-note">This tab will close automatically in a few seconds.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
_CALLBACK_ERROR_HTML = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Agos — Login failed</title>
|
||||
<style>
|
||||
*, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #0f1117; color: #e2e8f0;
|
||||
min-height: 100vh;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}}
|
||||
.card {{
|
||||
background: #1a1d27; border: 1px solid #3f2d2d;
|
||||
border-radius: 16px; padding: 48px 40px 40px;
|
||||
max-width: 440px; width: 90%; text-align: center;
|
||||
box-shadow: 0 24px 64px rgba(0,0,0,0.5);
|
||||
}}
|
||||
.icon {{
|
||||
width: 64px; height: 64px; background: #7f1d1d;
|
||||
border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
||||
margin: 0 auto 24px; font-size: 28px;
|
||||
}}
|
||||
h1 {{ font-size: 22px; font-weight: 700; color: #fca5a5; margin-bottom: 8px; }}
|
||||
.subtitle {{ font-size: 14px; color: #94a3b8; margin-bottom: 24px; }}
|
||||
.error {{ font-size: 13px; color: #f87171; background: #1e1010;
|
||||
border-radius: 8px; padding: 10px 16px; margin-bottom: 24px; }}
|
||||
.retry-btn {{
|
||||
display: inline-block; background: #374151; color: #e2e8f0;
|
||||
font-size: 14px; font-weight: 600; padding: 12px 28px; border-radius: 8px;
|
||||
text-decoration: none;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="icon">✗</div>
|
||||
<h1>Login failed</h1>
|
||||
<p class="subtitle">Something went wrong during authentication.</p>
|
||||
<div class="error">{error_detail}</div>
|
||||
<p style="font-size:13px;color:#64748b">Return to your terminal and run <code>agos login</code> to try again.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
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 = (
|
||||
'<html><body><h2>Agos CLI login complete.</h2>'
|
||||
'<p>You can return to the terminal.</p></body></html>'
|
||||
if callback_payload['valid_state'] and not callback_payload.get('error')
|
||||
else '<html><body><h2>Agos CLI login failed.</h2>'
|
||||
'<p>Return to the terminal for details.</p></body></html>'
|
||||
)
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user