|
| 1 | +import base64 |
| 2 | +import hashlib |
| 3 | +from http.server import BaseHTTPRequestHandler, HTTPServer |
| 4 | +import json |
| 5 | +import secrets |
| 6 | +import urllib.parse |
| 7 | +import webbrowser |
| 8 | + |
1 | 9 | import click |
| 10 | +import requests |
| 11 | + |
2 | 12 |
|
3 | 13 | from centml.sdk import auth |
4 | 14 | from centml.sdk.config import settings |
5 | 15 |
|
6 | 16 |
|
| 17 | +CLIENT_ID = settings.CENTML_WORKOS_CLIENT_ID |
| 18 | +SERVER_HOST = "127.0.0.1" |
| 19 | +SERVER_PORT = 57983 |
| 20 | +REDIRECT_URI = f"http://{SERVER_HOST}:{SERVER_PORT}/callback" |
| 21 | +AUTHORIZE_URL = "https://auth.centml.com/user_management/authorize" |
| 22 | +AUTHENTICATE_URL = "https://auth.centml.com/user_management/authenticate" |
| 23 | +PROVIDER = "authkit" |
| 24 | + |
| 25 | + |
| 26 | +def generate_pkce_pair(): |
| 27 | + verifier = secrets.token_urlsafe(64) |
| 28 | + challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=") |
| 29 | + return verifier, challenge |
| 30 | + |
| 31 | + |
| 32 | +def build_auth_url(client_id, redirect_uri, challenge): |
| 33 | + params = { |
| 34 | + "response_type": "code", |
| 35 | + "client_id": client_id, |
| 36 | + "redirect_uri": redirect_uri, |
| 37 | + "code_challenge": challenge, |
| 38 | + "code_challenge_method": "S256", |
| 39 | + "provider": PROVIDER, |
| 40 | + } |
| 41 | + return f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params)}" |
| 42 | + |
| 43 | + |
| 44 | +class OAuthHandler(BaseHTTPRequestHandler): |
| 45 | + def do_GET(self): |
| 46 | + query = urllib.parse.urlparse(self.path).query |
| 47 | + params = urllib.parse.parse_qs(query) |
| 48 | + self.server.auth_code = params.get("code", [None])[0] |
| 49 | + |
| 50 | + self.send_response(200) |
| 51 | + self.send_header("Content-type", "text/html") |
| 52 | + self.end_headers() |
| 53 | + self.wfile.write( |
| 54 | + """ |
| 55 | + <html> |
| 56 | + <body> |
| 57 | + <h1>Succesfully logged into CentML CLI</h1> |
| 58 | + <p>You can now close this tab and continue in the CLI.</p> |
| 59 | + </body> |
| 60 | + </html> |
| 61 | + """.encode( |
| 62 | + "utf-8" |
| 63 | + ) |
| 64 | + ) |
| 65 | + |
| 66 | + def log_message(self, format, *args): |
| 67 | + # Override this to suppress logging |
| 68 | + pass |
| 69 | + |
| 70 | + |
| 71 | +def get_auth_code(): |
| 72 | + server = HTTPServer((SERVER_HOST, SERVER_PORT), OAuthHandler) |
| 73 | + server.handle_request() |
| 74 | + return server.auth_code |
| 75 | + |
| 76 | + |
| 77 | +def exchange_code_for_token(code, code_verifier): |
| 78 | + data = { |
| 79 | + "grant_type": "authorization_code", |
| 80 | + "client_id": CLIENT_ID, |
| 81 | + "code": code, |
| 82 | + "redirect_uri": REDIRECT_URI, |
| 83 | + "code_verifier": code_verifier, |
| 84 | + } |
| 85 | + headers = {"Content-Type": "application/x-www-form-urlencoded"} |
| 86 | + response = requests.post(AUTHENTICATE_URL, data=data, headers=headers, timeout=3) |
| 87 | + response.raise_for_status() |
| 88 | + return response.json() |
| 89 | + |
| 90 | + |
7 | 91 | @click.command(help="Login to CentML") |
8 | 92 | @click.argument("token_file", required=False) |
9 | 93 | def login(token_file): |
10 | 94 | if token_file: |
11 | 95 | auth.store_centml_cred(token_file) |
12 | 96 |
|
13 | | - if auth.load_centml_cred(): |
14 | | - click.echo(f"Authenticating with credentials from {settings.CENTML_CRED_FILE_PATH}\n") |
15 | | - click.echo("Login successful") |
| 97 | + cred = auth.load_centml_cred() |
| 98 | + if cred is not None and auth.refresh_centml_token(cred.get("refresh_token")): |
| 99 | + click.echo("Authenticating with stored credentials...\n") |
| 100 | + click.echo("✅ Login successful") |
16 | 101 | else: |
17 | | - click.echo("Login with CentML authentication token") |
18 | | - click.echo("Usage: centml login TOKEN_FILE\n") |
19 | | - choice = click.confirm("Do you want to download the token?") |
| 102 | + click.echo("Logging into CentML...") |
20 | 103 |
|
| 104 | + choice = click.confirm("Do you want to log in with your browser now?", default=True) |
21 | 105 | if choice: |
22 | | - click.launch(f"{settings.CENTML_WEB_URL}?isCliAuthenticated=true") |
| 106 | + try: |
| 107 | + # PKCE Flow |
| 108 | + code_verifier, code_challenge = generate_pkce_pair() |
| 109 | + auth_url = build_auth_url(CLIENT_ID, REDIRECT_URI, code_challenge) |
| 110 | + click.echo("A browser window will open for you to authenticate.") |
| 111 | + click.echo("If it doesn't open automatically, you can copy and paste this URL:") |
| 112 | + click.echo(f" {auth_url}\n") |
| 113 | + webbrowser.open(auth_url) |
| 114 | + click.echo("Waiting for authentication...") |
| 115 | + |
| 116 | + code = get_auth_code() |
| 117 | + response_dict = exchange_code_for_token(code, code_verifier) |
| 118 | + # If there is an error, we should remove the credentials and the user needs to sign in again. |
| 119 | + if "error" in response_dict: |
| 120 | + click.echo("Login failed. Please try again.") |
| 121 | + else: |
| 122 | + cred = { |
| 123 | + key: response_dict[key] for key in ("access_token", "refresh_token") if key in response_dict |
| 124 | + } |
| 125 | + with open(settings.CENTML_CRED_FILE_PATH, "w") as f: |
| 126 | + json.dump(cred, f) |
| 127 | + click.echo("✅ Login successful") |
| 128 | + except Exception as e: |
| 129 | + click.echo(f"Login failed: {e}") |
23 | 130 | else: |
24 | 131 | click.echo("Login unsuccessful") |
25 | 132 |
|
|
0 commit comments