"""mbuser's OAuth2 access token on the pasture's Mbin, through the authorization-code flow a person would follow: an OAuth client made through the API, mbuser signed in through the login form, consent given, the code exchanged. Prints the token. Usage: mbin_token.py """ import http.client import json import os import re import socket import ssl import sys import urllib.parse HOST = "mbin.test" CA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".ca", "root.crt") REDIRECT = "https://pasture.invalid/callback" SCOPES = "read write delete subscribe block vote report user moderate" cookies = {} def request(method, path, body=None, headers=None): """One request to Mbin through Caddy on 127.0.0.1:6443, named mbin.test (SNI and Host); the CA is the pasture's own""" context = ssl.create_default_context(cafile=CA) conn = http.client.HTTPSConnection(HOST, 6443, context=context, timeout=60) conn.sock = context.wrap_socket(socket.create_connection(("127.0.0.1", 6443), timeout=60), server_hostname=HOST) # (Mbin names what it makes after the request's host: mbin.test, without the workstation's port) headers = dict(headers or {}, Host=HOST) if cookies: headers["Cookie"] = "; ".join(f"{k}={v}" for k, v in cookies.items()) conn.request(method, path, body=body, headers=headers) response = conn.getresponse() data = response.read().decode("utf-8", "replace") for name, value in response.getheaders(): if name.lower() == "set-cookie": key, _, rest = value.partition("=") cookies[key.strip()] = rest.split(";")[0] return response.status, dict((k.lower(), v) for k, v in response.getheaders()), data def follow(path): """GETs path and follows redirects on Mbin, returning the last answer and where a redirect away from Mbin pointed""" for _ in range(10): status, headers, data = request("GET", path) location = headers.get("location") if status not in (301, 302, 303, 307, 308) or not location: return status, data, None if location.startswith(REDIRECT): return status, data, location parsed = urllib.parse.urlsplit(location) path = parsed.path + ("?" + parsed.query if parsed.query else "") raise SystemExit("too many redirects") def csrf(html): match = re.search(r'name="_csrf_token"\s+value="([^"]+)"', html) if not match: raise SystemExit("no CSRF token in the page") return match.group(1) def main(password, saved): # one client for every run (Mbin limits how many are made) client = json.load(open(saved)) if os.path.exists(saved) else None if client is None: status, _, data = request("POST", "/api/client", json.dumps({ "name": "pasture", "contactEmail": "pasture@mbin.test", "description": "the pasture's scenarios", "public": False, "redirectUris": [REDIRECT], "grants": ["authorization_code", "refresh_token"], "scopes": SCOPES.split() }), {"Content-Type": "application/json"}) if status >= 300: raise SystemExit(f"client: {status} {data[:300]}") client = json.loads(data) json.dump({"identifier": client["identifier"], "secret": client["secret"]}, open(saved, "w")) status, _, page = request("GET", "/login") form = urllib.parse.urlencode({"email": "mbuser", "password": password, "_csrf_token": csrf(page)}) status, headers, _ = request("POST", "/login", form, {"Content-Type": "application/x-www-form-urlencoded"}) if status not in (302, 303) or "/login" in headers.get("location", ""): raise SystemExit(f"login refused: {status} {headers.get('location')}") query = urllib.parse.urlencode({"response_type": "code", "client_id": client["identifier"], "redirect_uri": REDIRECT, "scope": SCOPES, "state": "pasture"}) status, page, done = follow("/authorize?" + query) if done is None: consent = "/consent?" + query form = urllib.parse.urlencode({"consent": "yes", "_csrf_token": csrf(page)}) status, headers, _ = request("POST", consent, form, {"Content-Type": "application/x-www-form-urlencoded"}) location = headers.get("location", "") parsed = urllib.parse.urlsplit(location) status, page, done = follow(parsed.path + "?" + parsed.query) if done is None: raise SystemExit(f"no code: {status} {page[:300]}") code = urllib.parse.parse_qs(urllib.parse.urlsplit(done).query)["code"][0] form = urllib.parse.urlencode({"grant_type": "authorization_code", "client_id": client["identifier"], "client_secret": client["secret"], "redirect_uri": REDIRECT, "code": code}) status, _, data = request("POST", "/token", form, {"Content-Type": "application/x-www-form-urlencoded"}) if status >= 300: raise SystemExit(f"token: {status} {data[:300]}") print(json.loads(data)["access_token"]) main(sys.argv[1], sys.argv[2])