feat: harden IAM config — hardcode bundle constants, simplify login UX

IAM issuer, client ID, and loopback redirect URI are now bundle constants.
Users no longer configure IAM — `agos login` works with zero setup.
`agos config` now only exposes AGOS_CLI_API_URL (optional, for local dev).
Removed --issuer / --client-id / --host / --port from login help output.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
2026-08-21 18:45:09 +05:30
parent 6a7fc4e5ef
commit 334e0f0d23
2 changed files with 60 additions and 84 deletions

View File

@@ -64,20 +64,22 @@ _AUTH_DIR = Path.home() / '.agos'
_AUTH_FILE = _AUTH_DIR / 'auth.json'
_CONFIG_FILE = _AUTH_DIR / 'config.json'
# Keys the CLI recognizes and their descriptions
# ── Hardcoded bundle constants ─────────────────────────────────────────────────
# IAM and client details are fixed for the CLI bundle — users never configure
# these. The loopback redirect URI is the RFC 8252 standard for native/CLI apps.
_BUNDLE_IAM_ISSUER = 'https://iam.armco.dev'
_BUNDLE_IAM_CLIENT_ID = 'client_60250edc6189418ab3d7ee4d1577c0e7'
_BUNDLE_IAM_CALLBACK_HOST = '127.0.0.1'
_BUNDLE_IAM_CALLBACK_PORT = 8976
_BUNDLE_IAM_REDIRECT_URI = f'http://{_BUNDLE_IAM_CALLBACK_HOST}:{_BUNDLE_IAM_CALLBACK_PORT}/callback'
_BUNDLE_API_URL_PROD = 'https://agos.armco.dev'
_BUNDLE_API_URL_LOCAL = 'http://localhost:2000'
# ──────────────────────────────────────────────────────────────────────────────
# Keys the user can configure (IAM is not user-configurable — it's baked in)
_CONFIG_SCHEMA: dict[str, dict] = {
'AGOS_CLI_IAM_ISSUER': {
'description': 'Armco IAM issuer URL (e.g. https://iam.armco.dev)',
'required': True,
'env_aliases': ['VITE_IAM_ISSUER', 'IAM_ISSUER'],
},
'AGOS_CLI_IAM_CLIENT_ID': {
'description': 'OAuth client ID for the native/CLI app',
'required': True,
'env_aliases': ['VITE_IAM_DESKTOP_CLIENT_ID', 'VITE_IAM_CLIENT_ID'],
},
'AGOS_CLI_API_URL': {
'description': 'AGOS API base URL (default: http://localhost:2000)',
'description': f'AGOS API base URL (prod: {_BUNDLE_API_URL_PROD} | local dev: {_BUNDLE_API_URL_LOCAL})',
'required': False,
'env_aliases': ['AGOS_API_URL', 'VITE_API_URL'],
},
@@ -111,6 +113,11 @@ def _config_effective_value(key: str) -> str | None:
return None
def _resolve_api_url_from_config() -> str:
"""Return the API URL: stored config > env alias > prod default."""
return _config_effective_value('AGOS_CLI_API_URL') or _BUNDLE_API_URL_PROD
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
@@ -307,21 +314,6 @@ def cli():
@cli.command()
@click.option('--issuer', default=None, help='Armco IAM issuer URL')
@click.option('--client-id', default=None, help='IAM OAuth client ID for the CLI/native app')
@click.option(
'--host',
default='127.0.0.1',
show_default=True,
help='Loopback host for the local callback listener',
)
@click.option(
'--port',
default=8976,
show_default=True,
type=int,
help='Loopback port for the local callback listener',
)
@click.option(
'--timeout',
'timeout_seconds',
@@ -330,32 +322,21 @@ def cli():
type=int,
help='Seconds to wait for the browser login callback',
)
@click.option('--scopes', default=None, help='Space or comma separated OAuth scopes')
def login(issuer, client_id, host, port, timeout_seconds, scopes):
"""Login with Armco IAM for Agos CLI."""
# Hidden dev-only overrides — not shown in help
@click.option('--issuer', default=None, hidden=True)
@click.option('--client-id', default=None, hidden=True)
@click.option('--host', default=None, hidden=True)
@click.option('--port', default=None, type=int, hidden=True)
def login(timeout_seconds, issuer, client_id, host, port):
"""Login to Agos via Armco IAM (opens browser for authentication)."""
correlation_id = f'cli_login_{uuid.uuid4().hex[:12]}'
resolved_issuer = issuer or _config_effective_value('AGOS_CLI_IAM_ISSUER') or _first_env('VITE_IAM_ISSUER', 'IAM_ISSUER')
resolved_client_id = client_id or _config_effective_value('AGOS_CLI_IAM_CLIENT_ID') or _first_env('VITE_IAM_CLIENT_ID')
resolved_scopes = _parse_scopes(scopes or _first_env('AGOS_CLI_IAM_SCOPES'))
redirect_uri = f'http://{host}:{port}/callback'
if not resolved_issuer:
raise click.ClickException(
'Missing IAM issuer. Run `agos config` to set it up, '
'or set AGOS_CLI_IAM_ISSUER / VITE_IAM_ISSUER, or pass --issuer.'
)
if not resolved_client_id:
raise click.ClickException(
'Missing CLI IAM client ID. Run `agos config` to set it up, '
'or set AGOS_CLI_IAM_CLIENT_ID / VITE_IAM_DESKTOP_CLIENT_ID, or pass --client-id.'
)
if os.getenv('AGOS_CLI_IAM_CLIENT_ID', '').strip() == '' and os.getenv('VITE_IAM_CLIENT_ID', '').strip():
console.print(
'[yellow]Using VITE_IAM_CLIENT_ID for CLI login. '
'Ensure IAM allows the loopback redirect URI for this client.[/yellow]'
)
# Bundle constants — users never need to configure these
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
resolved_scopes = _parse_scopes(_first_env('AGOS_CLI_IAM_SCOPES'))
redirect_uri = f'http://{resolved_host}:{resolved_port}/callback'
_emit_log(
'INFO',
@@ -376,8 +357,8 @@ def login(issuer, client_id, host, port, timeout_seconds, scopes):
code_verifier = _pkce_verifier()
code_challenge = _pkce_challenge(code_verifier)
callback_event, callback_payload, thread = _start_callback_server(
host=host,
port=port,
host=resolved_host,
port=resolved_port,
expected_state=state,
timeout_seconds=timeout_seconds,
correlation_id=correlation_id,
@@ -474,8 +455,9 @@ def login(issuer, client_id, host, port, timeout_seconds, scopes):
@click.option('--list', 'list_all', is_flag=True, default=False, help='Show current stored config.')
@click.option('--unset', default=None, metavar='KEY', help='Remove a stored config key.')
def config_command(assignment, list_all, unset):
"""Configure CLI settings (IAM issuer, client ID, API URL, etc.).
"""Configure CLI settings (API URL, etc.).
IAM auth details are bundled — you do not need to configure them.
Run without arguments for interactive setup.
Pass KEY=VALUE pairs to set specific values.
Use --list to view current config or --unset KEY to remove a value.
@@ -483,8 +465,7 @@ def config_command(assignment, list_all, unset):
\b
Examples:
agos config
agos config AGOS_CLI_IAM_ISSUER=https://iam.armco.dev
agos config AGOS_CLI_IAM_CLIENT_ID=client_abc123
agos config AGOS_CLI_API_URL=https://agos.armco.dev
agos config --list
agos config --unset AGOS_CLI_API_URL
"""
@@ -529,7 +510,7 @@ def config_command(assignment, list_all, unset):
for pair in assignment:
if '=' not in pair:
raise click.ClickException(
f'Invalid format: `{pair}`. Use KEY=VALUE (e.g. AGOS_CLI_IAM_ISSUER=https://iam.armco.dev)'
f'Invalid format: `{pair}`. Use KEY=VALUE (e.g. AGOS_CLI_API_URL=https://agos.armco.dev)'
)
key, _, value = pair.partition('=')
key = key.strip()
@@ -541,22 +522,22 @@ def config_command(assignment, list_all, unset):
console.print(f'[green]Set[/green] {key} = {cfg[key]}')
return
# Interactive mode — check required keys and prompt for any that are missing
console.print('[bold blue]Agos CLI Configuration Setup[/bold blue]')
# Interactive mode
console.print('[bold blue]Agos CLI Configuration[/bold blue]')
console.print(
f'[dim]Settings are saved to {_CONFIG_FILE}[/dim]\n'
'[dim]Press Enter to keep an existing value.[/dim]\n'
f'[dim]Settings saved to {_CONFIG_FILE}[/dim]\n'
f'[dim]IAM auth is pre-configured (issuer: {_BUNDLE_IAM_ISSUER})[/dim]\n'
'[dim]Press Enter to keep an existing value, or clear it to reset to default.[/dim]\n'
)
changed = False
for key, meta in _CONFIG_SCHEMA.items():
effective = _config_effective_value(key)
stored = cfg.get(key, '')
required_label = '[red]*required[/red]' if meta['required'] else '[dim]optional[/dim]'
required_label = '[dim]optional[/dim]'
prompt_default = stored or ''
# Show current effective source if not stored directly
if effective and not stored:
console.print(
f'[dim] {key} is currently satisfied by an environment variable/alias: {effective}[/dim]'
f'[dim] {key} is currently set via environment: {effective}[/dim]'
)
display_default = f' [dim](current: {stored})[/dim]' if stored else ''
console.print(f'\n[cyan]{key}[/cyan] {required_label}{display_default}')
@@ -567,7 +548,6 @@ def config_command(assignment, list_all, unset):
cfg[key] = new_value
changed = True
elif not new_value and stored:
# User cleared the value
del cfg[key]
changed = True
@@ -577,18 +557,10 @@ def config_command(assignment, list_all, unset):
else:
console.print('\n[dim]No changes.[/dim]')
# Final summary
console.print()
all_ok = all(
_config_effective_value(k)
for k, m in _CONFIG_SCHEMA.items()
if m['required']
)
if all_ok:
console.print('[bold green]✓ All required settings are configured. Run `agos login` to authenticate.[/bold green]')
else:
missing = [k for k, m in _CONFIG_SCHEMA.items() if m['required'] and not _config_effective_value(k)]
console.print(f'[yellow]⚠ Still missing required keys: {", ".join(missing)}[/yellow]')
api_url = _resolve_api_url_from_config()
console.print(f'[bold green]✓ Ready. API target: {api_url}[/bold green]')
console.print('[dim]Run `agos login` to authenticate.[/dim]')
@cli.group()

View File

@@ -28,11 +28,14 @@ _MANUAL: Dict[str, Dict[str, object]] = {
],
},
'login': {
'summary': 'Authenticate the CLI with Armco IAM and cache tokens in `~/.agos/auth.json`.',
'summary': (
'Authenticate the CLI with Armco IAM via a browser-based PKCE flow. '
'IAM provider details are pre-configured — just run `agos login`. '
'Tokens are cached in ~/.agos/auth.json.'
),
'examples': [
'agos login',
'agos login --issuer https://iam.armco.dev --client-id client_abc123',
'agos login --port 8976 --timeout 240',
'agos login --timeout 240',
],
},
'whoami': {
@@ -57,20 +60,21 @@ _MANUAL: Dict[str, Dict[str, object]] = {
},
'config': {
'summary': (
'Configure CLI settings (IAM issuer, client ID, API URL) stored in ~/.agos/config.json. '
'Configure CLI settings stored in ~/.agos/config.json. '
'IAM auth details are pre-configured in the bundle — users only need to '
'set AGOS_CLI_API_URL if targeting a non-default API endpoint. '
'Run without arguments for an interactive guided setup. '
'Pass KEY=VALUE pairs to set individual keys non-interactively.'
),
'examples': [
'agos config',
'agos config AGOS_CLI_IAM_ISSUER=https://iam.armco.dev',
'agos config AGOS_CLI_IAM_CLIENT_ID=client_abc123',
'agos config AGOS_CLI_API_URL=https://agos.armco.dev',
'agos config --list',
'agos config --unset AGOS_CLI_API_URL',
],
'notes': [
'Config is stored at ~/.agos/config.json (mode 0600).',
'Values set here override .env files but are overridden by explicit --flags.',
'IAM issuer and client ID are hardcoded in the bundle — do not set them here.',
'After configuring, run `agos login` to authenticate.',
],
},