Mbin 1.10.1 runs in the pasture (its image, a messenger worker, a RabbitMQ
of its own, its API limits raised), and peers/mbin_token.py gets mbuser's
token through the authorization-code flow. scenarios/mbin.sh: 24 checks and
one known gap, magazines both ways, titled threads, a Note to a magazine as
a microblog post, comments, favourites and upvotes both ways, a moderator's
lock, unlock and removal, the unfollow and statistics.
What it showed:
- Mbin sends a magazine's threads to its subscribers as the author's Create,
the magazine as its audience, never announced. A post whose group is
followed here and lives on the post's own server is now kept as if
announced; the same from another server is not.
- A moderator's lock is a bare Lock (and Undo{Lock}): LockHandler takes it
from the post's own server only.
- Mbin takes private messages only as ChatMessage and its actors say
nothing about it; PrivaPub never decides by a server's software, so this
stays open as G-0008 for the owner.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
102 lines
4.6 KiB
Python
102 lines
4.6 KiB
Python
"""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 <password> <file keeping the client>"""
|
|
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])
|