Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ coverage.xml
report.xml
**/.local
CHANGELOG_UNRELEASED.md
.idea/
.hermes/
83 changes: 80 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,21 @@ echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.profile
ewc login
```

Keycloak authentication is **mandatory** for all EWC CLI commands (except `ewc version`). The login command:

1. Opens a browser window for Keycloak OIDC authentication (auth code + PKCE) → an **ephemeral** access token (not stored).
2. Uses that token to authenticate to **OpenBao** (JWT/OIDC auth method) → an ephemeral OpenBao client token.
3. Reads secrets from OpenBao:
- Kubernetes kubeconfig → saved to `~/.ewccli/kubeconfigs/<profile>.yaml`
- OpenStack application credentials (id + secret) → saved to the profile.
4. Saves the downstream credentials to your profile. **No Keycloak/OpenBao tokens are persisted.**

When a downstream credential expires (OpenStack 401/403), the command tells you to re-login:

```
Your OpenStack credentials have expired. Please run: ewc login --profile <profile_name>
```

IMPORTANT:

- EWC CLI uses the following order of importance:
Expand All @@ -192,15 +207,77 @@ All your profiles are saved under `~/.ewccli/profiles`

You can manually add profiles in the same file and the ewccli can use them already.

Info required for a profile:
Info stored in a profile (no OIDC tokens):
```
[my-profile]
federee = EUMETSAT or ECMWF
region = WAW3-1
tenant_name = eumetsat-ewc-communityhub
application_credential_id =
application_credential_secret =
application_credential_id =
application_credential_secret =
ssh_public_key_path =
ssh_private_key_path =
kubeconfig_path = /home/user/.ewccli/kubeconfigs/my-profile.yaml
```

### Multi-tenancy with `--profile`

The `--profile` flag lets you manage multiple tenancy profiles. Each profile stores its own federee, region, tenant name, SSH keys, application credentials, and kubeconfig path.

```bash
ewc login --profile my-profile
```

If `--profile` is omitted, the default profile named `default` is used.

When you log in with a profile that already exists, the CLI skips the federee/region/tenant_name prompts and simply refreshes your downstream credentials from OpenBao. When the profile does not exist, you will be prompted to choose a federee and region interactively.

If you do not provide `--profile` during login but supply `--federee`, `--region`, and `--tenant-name`, the profile name is auto-generated as `federee-region-tenant_name` (e.g. `eumetsat-ecis-r1-demo-user-eu`).

All commands that access cloud resources accept `--profile`:

```bash
ewc infra list --profile my-profile
ewc hub deploy ITEM --profile my-profile
```

### Headless / SSH sessions

If you're on a headless machine or SSH session, use `--no-browser` to print the login URL instead of opening a browser:

```bash
ewc login --no-browser
```

You can also combine with other flags:

```bash
ewc login --profile my-profile --no-browser
```

**Configuration:**

The Keycloak and OpenBao settings can be overridden via environment variables:

| Variable | Default | Description |
|---|---|---|
| `EWC_CLI_KEYCLOAK_URL` | `https://iam.europeanweather.cloud` | Keycloak server URL |
| `EWC_CLI_KEYCLOAK_REALM` | `ewc-login-broker` | Keycloak realm |
| `EWC_CLI_KEYCLOAK_CLIENT_ID` | `ewccli` | OIDC client ID |
| `EWC_CLI_KEYCLOAK_SCOPE` | `openid profile email` | OIDC scopes |
| `EWC_CLI_OIDC_CALLBACK_TIMEOUT` | `300` | Callback wait timeout (seconds) |
| `EWC_CLI_OIDC_CALLBACK_PORT` | `11325` | Loopback port for the OIDC callback server |
| `EWC_CLI_OPENBAO_URL` | `https://secrets-val.internal.eumetsat.europeanweather.cloud` | OpenBao API base URL |
| `EWC_CLI_OPENBAO_OIDC_ROLE` | `default` | OpenBao OIDC auth role name |
| `EWC_CLI_OPENBAO_KV_MOUNT` | `secret` | KV2 engine mount path |
| `EWC_CLI_OPENBAO_NAMESPACE` | `openbao-users` | OpenBao namespace (sent as `X-Vault-Namespace`) |

**Credential expiry:**

The CLI does **not** store Keycloak or OpenBao tokens. Instead it stores the downstream credentials (OpenStack application credentials and kubeconfig) retrieved from OpenBao. When those credentials expire or are revoked, cloud operations will fail with a 401/403 and the CLI will prompt you to re-login:

```
Your OpenStack credentials have expired. Please run: ewc login --profile <profile_name>
```

## List Items in the catalog
Expand Down
Empty file.
147 changes: 147 additions & 0 deletions ewccli/backends/keycloak/callback_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""Lightweight HTTP server to receive the OIDC authorization code callback."""

import time
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Optional
from urllib.parse import urlparse, parse_qs


_SUCCESS_HTML = (
b"<html><body style='font-family:sans-serif;text-align:center;padding:50px'>"
b"<h2>&#9989; Authentication successful!</h2>"
b"<p>You can close this browser tab and return to your terminal.</p>"
b"</body></html>"
)

_ERROR_HTML = (
b"<html><body style='font-family:sans-serif;text-align:center;padding:50px'>"
b"<h2>&#10060; Authentication failed</h2>"
b"<p>State mismatch or error. Please try again.</p>"
b"</body></html>"
)


class CallbackServer:
"""HTTP server that listens for the OIDC redirect callback on localhost.

Usage:
server = CallbackServer(expected_state="...")
server.start()
# ... open browser to auth URL ...
result = server.wait_for_callback(timeout=300)
server.stop()
"""

def __init__(self, expected_state: str, port: int = 0):
self._expected_state = expected_state
self._result: Optional[tuple[str, str]] = None
self._error: Optional[str] = None
self._httpd: Optional[HTTPServer] = None
self._thread: Optional[threading.Thread] = None
self._requested_port = port
self.port: int = 0

def start(self) -> None:
"""Start the server on a loopback port."""
handler = self._make_handler()
self._httpd = HTTPServer(("127.0.0.1", self._requested_port), handler)
self.port = self._httpd.server_address[1]
self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True)
self._thread.start()

def stop(self) -> None:
"""Shut down the server."""
if self._httpd:
self._httpd.shutdown()
self._httpd.server_close()
if self._thread:
self._thread.join(timeout=2)

def wait_for_callback(self, timeout: float = 300) -> Optional[tuple[str, str]]:
"""Block until the callback is received or timeout.

Returns (code, state) on success, or None on timeout/error.

Uses a polling loop instead of a blocking thread.join() so that
SIGINT (Ctrl+C) can interrupt the wait on the main thread.
"""
if self._thread is None:
return None

deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if self._result is not None or self._error is not None:
break
if not self._thread.is_alive():
break
time.sleep(0.2)

return self._result

@property
def error(self) -> Optional[str]:
"""Return error description if one occurred."""
return self._error

@property
def redirect_uri(self) -> str:
"""The redirect_uri to pass to the authorization endpoint."""
return f"http://127.0.0.1:{self.port}/callback"

def _make_handler(self):
"""Create a request handler class bound to this server instance."""

expected_state = self._expected_state
outer = self # closure over the CallbackServer instance

class _Handler(BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802
parsed = urlparse(self.path)
if parsed.path != "/callback":
self.send_response(404)
self.end_headers()
return

params = parse_qs(parsed.query)
code = params.get("code", [None])[0]
state = params.get("state", [None])[0]

if state != expected_state:
outer._error = "State mismatch"
self.send_response(400)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(_ERROR_HTML)
threading.Thread(
target=outer._httpd.shutdown, daemon=True
).start()
return

if code is None:
error = params.get("error", ["unknown"])[0]
outer._error = f"Authorization error: {error}"
self.send_response(400)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(_ERROR_HTML)
threading.Thread(
target=outer._httpd.shutdown, daemon=True
).start()
return

outer._result = (code, state)
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(_SUCCESS_HTML)
# Shut down the server in a separate thread so this handler
# can finish sending the response first.
threading.Thread(
target=outer._httpd.shutdown, daemon=True
).start()

def log_message(self, format, *args): # noqa: A002
pass # silence stderr logging

return _Handler
Loading
Loading