feat: implement core HTTP client library
- Add HTTPClient class with GET/POST support - Implement Response class with JSON parsing - Add Basic and Bearer authentication classes - Implement OAuth2 interactive provider - Add Microsoft Entra ID integration - Support URL parameters and custom headers - Include browser integration for OAuth flows 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
3
src/zerohttp/__init__.py
Normal file
3
src/zerohttp/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .auth import *
|
||||
from .http import *
|
||||
from .providers import *
|
||||
3
src/zerohttp/__main__.py
Normal file
3
src/zerohttp/__main__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
class ZeroHTTP:
|
||||
APPLICATION = "ZeroHTTP"
|
||||
VERSION = "0.0.1"
|
||||
3
src/zerohttp/auth/__init__.py
Normal file
3
src/zerohttp/auth/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .basic import BasicAuthentication
|
||||
from .bearer import BearerAuthentication
|
||||
from .interactive import InteractiveProvider
|
||||
34
src/zerohttp/auth/basic.py
Normal file
34
src/zerohttp/auth/basic.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import base64
|
||||
|
||||
class BasicAuthentication:
|
||||
def __init__(self, username: str, password: str):
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.hash = self.encode()
|
||||
|
||||
def encode(self) -> str:
|
||||
"""
|
||||
Encode the username and password as a base64 string
|
||||
"""
|
||||
# Create a string in the format "username:password"
|
||||
credentials = f"{self.username}:{self.password}"
|
||||
# Encode the string as base64
|
||||
encoded_credentials = base64.b64encode(credentials.encode("utf-8"))
|
||||
# Convert the base64 bytes to a string
|
||||
return encoded_credentials.decode("utf-8")
|
||||
|
||||
def decode(self, encoded_credentials: str) -> tuple:
|
||||
"""
|
||||
Decode the base64 string into a username and password
|
||||
"""
|
||||
# Decode the base64 string into bytes
|
||||
decoded_credentials = base64.b64decode(encoded_credentials)
|
||||
# Convert the bytes to a string
|
||||
credentials = decoded_credentials.decode("utf-8")
|
||||
# Split the string into a username and password
|
||||
username, password = credentials.split(":")
|
||||
# Return the username and password
|
||||
return username, password
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "Basic " + self.hash
|
||||
21
src/zerohttp/auth/bearer.py
Normal file
21
src/zerohttp/auth/bearer.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import base64
|
||||
|
||||
class BearerAuthentication:
|
||||
def __init__(self, token: str):
|
||||
self.token = token
|
||||
self.type = "Bearer"
|
||||
|
||||
# Convert to base64
|
||||
def encode(self) -> str:
|
||||
"""
|
||||
Encode the token as a base64 string
|
||||
"""
|
||||
# Encode the string as base64
|
||||
encoded_token = base64.b64encode(self.token.encode("utf-8"))
|
||||
# Convert the base64 bytes to a string
|
||||
self.hash = encoded_token.decode("utf-8")
|
||||
# Return the hash
|
||||
return self.hash
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "Bearer " + self.token
|
||||
123
src/zerohttp/auth/interactive.py
Normal file
123
src/zerohttp/auth/interactive.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from ..http import HTTPClient
|
||||
from ..auth import BearerAuthentication
|
||||
|
||||
import base64
|
||||
import string
|
||||
import random
|
||||
import threading
|
||||
import socketserver
|
||||
import urllib.parse as parse
|
||||
|
||||
import http.server
|
||||
from typing import Any
|
||||
|
||||
def handler_factory(state, provider):
|
||||
def create_handler(*args, **kwargs):
|
||||
return InteractiveCallbackHandler(*args, state=state, provider=provider, **kwargs)
|
||||
return create_handler
|
||||
|
||||
class InteractiveCallbackHandler(http.server.BaseHTTPRequestHandler):
|
||||
def __init__(self, request, client_address, server, state: str, provider: Any):
|
||||
self.state = state
|
||||
self.provider = provider
|
||||
super().__init__(request, client_address, server)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == '/favicon.ico':
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
query_params = parse.parse_qs(parse.urlparse(self.path).query)
|
||||
self.provider.authorization_code = query_params["code"][0]
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"<html><head><title>Authentication Successful</title></head>")
|
||||
self.wfile.write(b"<body><p>You have successfully authenticated with the provider.</p>")
|
||||
self.wfile.write(b"</body></html>")
|
||||
|
||||
self.provider.authorization_received.set()
|
||||
|
||||
class InteractiveProvider:
|
||||
def __init__(self, name: str, client_id: str, client_secret: str, token_url: str, authorization_url: str, scope: str, open: bool = True):
|
||||
self.name = name
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.redirect_uri = "http://localhost:8000/redirect"
|
||||
self.token_url = token_url
|
||||
self.authorization_url = authorization_url
|
||||
self.scope = scope
|
||||
self.authorization_code = None
|
||||
self.access_token = None
|
||||
self.server = None
|
||||
self.open = open
|
||||
self.state = self.__generate_state()
|
||||
self.authorization_received = threading.Event()
|
||||
|
||||
def start_server(self):
|
||||
handler = handler_factory(self.state, self)
|
||||
self.server = socketserver.TCPServer(("", 8000), handler)
|
||||
server_thread = threading.Thread(target=self.server.serve_forever)
|
||||
server_thread.start()
|
||||
|
||||
def stop_server(self):
|
||||
if self.server:
|
||||
def shutdown_server():
|
||||
if self.server:
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.server = None
|
||||
shutdown_thread = threading.Thread(target=shutdown_server)
|
||||
shutdown_thread.start()
|
||||
|
||||
def __generate_state(self) -> str:
|
||||
# Generate a random string
|
||||
state = ''.join(random.choices(string.ascii_uppercase + string.digits, k=16))
|
||||
# Encode the string as base64
|
||||
encoded_state = base64.b64encode(state.encode("utf-8"))
|
||||
# Convert the base64 bytes to a string
|
||||
return encoded_state.decode("utf-8")
|
||||
|
||||
def __get_authorization_code(self) -> str:
|
||||
server_thread = threading.Thread(target=self.start_server)
|
||||
server_thread.start()
|
||||
login_client = HTTPClient(base_url=self.authorization_url)
|
||||
if self.open:
|
||||
login_client.webbrowser(params=[("client_id", self.client_id), ("response_type", "code"), ("redirect_uri", self.redirect_uri), ("response_mode", "query"), ("scope", self.scope), ("state", self.__generate_state())])
|
||||
else:
|
||||
login_client.webbrowser(params=[("client_id", self.client_id), ("response_type", "code"), ("redirect_uri", self.redirect_uri), ("response_mode", "query"), ("scope", self.scope), ("state", self.__generate_state())])
|
||||
self.authorization_received.wait() # Wait for the event here
|
||||
if self.authorization_code is None:
|
||||
raise RuntimeError("Authorization code was not received")
|
||||
return self.authorization_code if self.authorization_code else ""
|
||||
|
||||
def authenticate(self) -> str:
|
||||
# Get the authorization code
|
||||
authorization_code = self.__get_authorization_code()
|
||||
# Create a client object
|
||||
client = HTTPClient(base_url="")
|
||||
client.set_header("Content-Type", "application/x-www-form-urlencoded")
|
||||
# client_id=11111111-1111-1111-1111-111111111111
|
||||
# &scope=user.read%20mail.read
|
||||
# &code=OAAABAAAAiL9Kn2Z27UubvWFPbm0gLWQJVzCTE9UkP3pSx1aXxUjq3n8b2JRLk4OxVXr...
|
||||
# &redirect_uri=http%3A%2F%2Flocalhost%2Fmyapp%2F
|
||||
# &grant_type=authorization_code
|
||||
# &client_secret=HF8Q~Krjqh4r...
|
||||
# Send the request
|
||||
data = {
|
||||
"client_id": self.client_id,
|
||||
"scope": self.scope,
|
||||
"code": authorization_code,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"grant_type": "authorization_code",
|
||||
"client_secret": self.client_secret
|
||||
}
|
||||
res = client.post(url=self.token_url, data=data)
|
||||
# Get the response
|
||||
res_json = res.json()
|
||||
self.access_token = res_json["access_token"]
|
||||
self.stop_server()
|
||||
|
||||
return self.access_token
|
||||
120
src/zerohttp/http.py
Normal file
120
src/zerohttp/http.py
Normal file
@@ -0,0 +1,120 @@
|
||||
import urllib.parse as parse
|
||||
import urllib.request as request
|
||||
import urllib.error as error
|
||||
import json as js
|
||||
import webbrowser as browser
|
||||
import http.client
|
||||
|
||||
from .__main__ import ZeroHTTP
|
||||
from typing import Any
|
||||
|
||||
class Response:
|
||||
def __init__(self, response: http.client.HTTPResponse):
|
||||
if response:
|
||||
self.text = response.read().decode("utf-8")
|
||||
else:
|
||||
self.text = ""
|
||||
|
||||
def json(self) -> dict:
|
||||
return js.loads(self.text)
|
||||
|
||||
class Param:
|
||||
def __init__(self, key: str, value: str):
|
||||
self.key = key
|
||||
self.value = value
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.key}={self.value}"
|
||||
|
||||
class HTTPClient:
|
||||
def __init__(self, auth: Any = None, proxy: str = "", base_url: str = ""):
|
||||
self.auth = auth
|
||||
self.proxy = proxy
|
||||
self.base_url = base_url if base_url else ""
|
||||
self.headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": f"{ZeroHTTP.APPLICATION}/{ZeroHTTP.VERSION}"
|
||||
}
|
||||
|
||||
def set_header(self, key: str, value: str):
|
||||
self.headers[key] = value
|
||||
|
||||
def set_headers(self, headers: dict):
|
||||
self.headers = headers
|
||||
|
||||
def get_header(self, key: str) -> str:
|
||||
return self.headers[key]
|
||||
|
||||
def get_headers(self) -> dict:
|
||||
return self.headers
|
||||
|
||||
def __send(self, method: str, url: str, data: dict = {}) -> Response:
|
||||
# Create a request object
|
||||
req = request.Request(url, method=method, headers=self.headers)
|
||||
# Add the authentication
|
||||
if self.auth:
|
||||
req.add_header("Authorization", f"{self.auth.type} {self.auth.token}")
|
||||
encoded_data: bytes | None = None
|
||||
if data:
|
||||
match self.headers["Content-Type"]:
|
||||
case "application/json":
|
||||
encoded_data = js.dumps(data).encode("utf-8")
|
||||
case "application/x-www-form-urlencoded":
|
||||
encoded_data = parse.urlencode(data).encode("utf-8")
|
||||
case _:
|
||||
encoded_data = js.dumps(data).encode("utf-8")
|
||||
try:
|
||||
response = request.urlopen(req, data=encoded_data)
|
||||
except error.HTTPError as e:
|
||||
print(e.read().decode())
|
||||
raise
|
||||
# Return the response
|
||||
return Response(response)
|
||||
|
||||
def get(self, url: str = "", params: list = []) -> Response:
|
||||
parameter = ""
|
||||
if params:
|
||||
parameter = "?"
|
||||
parameter_length = len(params)
|
||||
for param in params:
|
||||
p = Param(param[0], param[1])
|
||||
parameter += str(p)
|
||||
if params.index(param) != parameter_length - 1:
|
||||
parameter += "&"
|
||||
url = self.base_url + url + parameter
|
||||
return self.__send("GET", url)
|
||||
|
||||
def post(self, url: str = "", data: dict = {}, params: list = []) -> Response:
|
||||
parameter = ""
|
||||
if params:
|
||||
parameter = "?"
|
||||
parameter_length = len(params)
|
||||
for param in params:
|
||||
p = Param(param[0], param[1])
|
||||
parameter += str(p)
|
||||
if params.index(param) != parameter_length - 1:
|
||||
parameter += "&"
|
||||
url = self.base_url + url + parameter
|
||||
return self.__send("POST", url, data=data)
|
||||
|
||||
def link(self, url: str = "", params: list = []) -> str:
|
||||
parameter = "?"
|
||||
parameter_length = len(params)
|
||||
for param in params:
|
||||
p = Param(param[0], param[1])
|
||||
parameter += str(p)
|
||||
if params.index(param) != parameter_length - 1:
|
||||
parameter += "&"
|
||||
return self.base_url + url + parameter
|
||||
|
||||
def webbrowser(self, url: str = "", params: list = []):
|
||||
parameter = "?"
|
||||
parameter_length = len(params)
|
||||
for param in params:
|
||||
p = Param(param[0], param[1])
|
||||
parameter += str(p)
|
||||
if params.index(param) != parameter_length - 1:
|
||||
parameter += "&"
|
||||
url = self.base_url + url + parameter
|
||||
browser.open(url)
|
||||
1
src/zerohttp/providers/__init__.py
Normal file
1
src/zerohttp/providers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .entra import *
|
||||
79
src/zerohttp/providers/entra.py
Normal file
79
src/zerohttp/providers/entra.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from ..http import HTTPClient
|
||||
from ..auth import InteractiveProvider, BearerAuthentication
|
||||
|
||||
import base64
|
||||
import string
|
||||
import random
|
||||
|
||||
class Entra:
|
||||
def __init__(self, tenant: str, client: tuple, scope: str, token_url: str = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token", authorization_url: str = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize", open: bool = True):
|
||||
self.tenant = tenant
|
||||
self.client_id = client[0]
|
||||
self.client_secret = client[1]
|
||||
self.scope = scope
|
||||
self.token_url = token_url.format(tenant=self.tenant)
|
||||
self.authorization_url = authorization_url.format(tenant=self.tenant)
|
||||
self.__check_urls()
|
||||
self.provider = None
|
||||
self.client = None
|
||||
self.open = open
|
||||
|
||||
def __check_urls(self):
|
||||
if not self.token_url:
|
||||
self.token_url = f"https://login.microsoftonline.com/{self.tenant}/oauth2/v2.0/token"
|
||||
if not self.authorization_url:
|
||||
self.authorization_url = f"https://login.microsoftonline.com/{self.tenant}/oauth2/v2.0/authorize"
|
||||
|
||||
def __create_provider(self):
|
||||
self.provider = InteractiveProvider(
|
||||
name="Microsoft",
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
token_url=self.token_url,
|
||||
authorization_url=self.authorization_url,
|
||||
scope=self.scope,
|
||||
open=self.open
|
||||
)
|
||||
|
||||
def authenticate(self) -> BearerAuthentication:
|
||||
if not self.provider:
|
||||
self.__create_provider()
|
||||
if not self.provider:
|
||||
raise RuntimeError("Provider not created")
|
||||
token = self.provider.authenticate()
|
||||
if not token:
|
||||
raise RuntimeError("Authentication failed")
|
||||
return BearerAuthentication(token)
|
||||
|
||||
class EntraApp:
|
||||
def __init__(self, tenant: str, client: tuple, scope: str = "https://graph.microsoft.com/.default", token_url: str = ""):
|
||||
self.tenant = tenant
|
||||
self.client_id = client[0]
|
||||
self.client_secret = client[1]
|
||||
self.scope = scope
|
||||
self.token_url = token_url if token_url else f"https://login.microsoftonline.com/{self.tenant}/oauth2/v2.0/token"
|
||||
|
||||
def __check_urls(self):
|
||||
if not self.token_url:
|
||||
self.token_url = f"https://login.microsoftonline.com/{self.tenant}/oauth2/v2.0/token"
|
||||
|
||||
def __generate_state(self) -> str:
|
||||
# Generate a random string
|
||||
state = ''.join(random.choices(string.ascii_uppercase + string.digits, k=16))
|
||||
# Encode the string as base64
|
||||
encoded_state = base64.b64encode(state.encode("utf-8"))
|
||||
# Convert the base64 bytes to a string
|
||||
return encoded_state.decode("utf-8")
|
||||
|
||||
def authenticate(self) -> BearerAuthentication:
|
||||
client = HTTPClient(base_url=self.token_url)
|
||||
client.set_header("Content-Type", "application/x-www-form-urlencoded")
|
||||
data = {
|
||||
"client_id": self.client_id,
|
||||
"scope": self.scope,
|
||||
"client_secret": self.client_secret,
|
||||
"grant_type": "client_credentials"
|
||||
}
|
||||
response = client.post(data=data)
|
||||
token = response.json()["access_token"]
|
||||
return BearerAuthentication(token)
|
||||
Reference in New Issue
Block a user