Path: blob/main/singlestoredb/auth.py
469 views
#!/usr/bin/env python1import datetime2from typing import Any3from typing import List4from typing import Optional5from typing import Union67import jwt8910# Credential types11PASSWORD = 'password'12JWT = 'jwt'13BROWSER_SSO = 'browser_sso'1415# Single Sign-On URL16SSO_URL = 'https://portal.singlestore.com/engine-sso'171819class JSONWebToken(object):20"""Container for JWT information."""2122def __init__(23self, token: str, expires: datetime.datetime,24email: str, username: str, url: str = SSO_URL,25clusters: Optional[Union[str, List[str]]] = None,26databases: Optional[Union[str, List[str]]] = None,27timeout: int = 60,28):29self.token = token30self.expires = expires31self.email = email32self.username = username33self.model_version_number = 13435# Attributes needed for refreshing tokens36self.url = url37self.clusters = clusters38self.databases = databases39self.timeout = timeout4041@classmethod42def from_token(cls, token: bytes, verify_signature: bool = False) -> 'JSONWebToken':43"""Validate the contents of the JWT."""44info = jwt.decode(token, options={'verify_signature': verify_signature})4546if not info.get('sub', None) and not info.get('username', None):47raise ValueError("Missing 'sub' and 'username' in claims")48if not info.get('email', None):49raise ValueError("Missing 'email' in claims")50if not info.get('exp', None):51raise ValueError("Missing 'exp' in claims")52try:53expires = datetime.datetime.fromtimestamp(info['exp'], datetime.timezone.utc)54except Exception as exc:55raise ValueError("Invalid 'exp' in claims: {}".format(str(exc)))5657username = info.get('username', info.get('sub', None))58email = info['email']5960return cls(token.decode('utf-8'), expires=expires, email=email, username=username)6162def __str__(self) -> str:63return self.token6465def __repr__(self) -> str:66return repr(self.token)6768@property69def is_expired(self) -> bool:70"""Determine if the token has expired."""71return self.expires >= datetime.datetime.now()7273def refresh(self, force: bool = False) -> bool:74"""75Refresh the token as needed.7677Parameters78----------79force : bool, optional80Should a new token be generated even if the existing81one has not expired yet?8283Returns84-------85bool : Indicating whether the token was refreshed or not8687"""88if force or self.is_expired:89out = get_jwt(90self.email, url=self.url, clusters=self.clusters,91databases=self.databases, timeout=self.timeout,92)93self.token = out.token94self.expires = out.expires95return True96return False979899def _listify(s: Optional[Union[str, List[str]]]) -> Optional[str]:100"""Return a list of strings in a comma-separated string."""101if s is None:102return None103if not isinstance(s, str):104return ','.join(s)105return s106107108def get_jwt(109email: str, url: str = SSO_URL,110clusters: Optional[Union[str, List[str]]] = None,111databases: Optional[Union[str, List[str]]] = None,112timeout: int = 60, browser: Optional[Union[str, List[str]]] = None,113) -> JSONWebToken:114"""115Retrieve a JWT token from the SingleStoreDB single-sign-on URL.116117Parameters118----------119email : str120EMail of the database user121url : str, optional122The URL of the single-sign-on token generator123clusters : str or list[str], optional124The name of the cluster being connected to125databases : str or list[str], optional126The name of the database being connected to127timeout : int, optional128Number of seconds to wait before timing out the authentication request129browser : str or list[str], optional130Browser to use instead of the default. This value can be any of the131names specified in Python's `webbrowser` module. This includes132'google-chrome', 'chrome', 'chromium', 'chromium-browser', 'firefox',133etc. Note that at the time of this writing, Safari was not134compatible. If a list of names is specified, each one tried until135a working browser is located.136137Returns138-------139JSONWebToken140141"""142import platform143import webbrowser144import time145import threading146import urllib147from http.server import BaseHTTPRequestHandler, HTTPServer148149from .config import get_option150151token = []152error = []153154class AuthServer(BaseHTTPRequestHandler):155156def log_message(self, format: str, *args: Any) -> None:157return158159def do_POST(self) -> None:160content_len = int(self.headers.get('Content-Length', 0))161post_body = self.rfile.read(content_len)162163try:164out = JSONWebToken.from_token(post_body)165except Exception as exc:166self.send_response(400, exc.args[0])167self.send_header('Content-Type', 'text/plain')168self.end_headers()169error.append(exc)170return171172token.append(out)173174self.send_response(204)175self.send_header('Access-Control-Allow-Origin', '*')176self.send_header('Content-Type', 'text/plain')177self.end_headers()178179server = None180181try:182server = HTTPServer(('127.0.0.1', 0), AuthServer)183threading.Thread(target=server.serve_forever).start()184185host = server.server_address[0]186if isinstance(host, bytes):187host = host.decode('utf-8')188189query = urllib.parse.urlencode({190k: v for k, v in dict(191email=email,192returnTo=f'http://{host}:{server.server_address[1]}',193db=_listify(databases),194cluster=_listify(clusters),195).items() if v is not None196})197198if browser is None:199browser = get_option('sso_browser')200201# On Mac, always specify a list of browsers to check because Safari202# is not compatible.203if browser is None and platform.platform().lower().startswith('mac'):204browser = [205'chrome', 'google-chrome', 'chromium',206'chromium-browser', 'firefox',207]208209if browser and isinstance(browser, str):210browser = [browser]211212if browser:213exc: Optional[Exception] = None214for item in browser:215try:216webbrowser.get(item).open(f'{url}?{query}')217break218except webbrowser.Error as wexc:219exc = wexc220pass221if exc is not None:222raise RuntimeError(223'Could not find compatible web browser for accessing JWT',224)225else:226webbrowser.open(f'{url}?{query}')227228for i in range(timeout * 2):229if error:230raise error[0]231if token:232out = token[0]233out.url = url234out.clusters = clusters235out.databases = databases236out.timeout = timeout237return out238time.sleep(0.5)239240finally:241if server is not None:242server.shutdown()243244raise RuntimeError('Timeout waiting for token')245246247