From 7816f38c2951c32d3b98ddd8fbce7a0abde4b26b Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sat, 19 Sep 2026 19:10:03 -0700 Subject: [PATCH] record-compat: check every credential before reading anything A wrong tenant-administrator password failed only once the script had already enumerated every account, on a server that may not be up for long. All the identities are now checked against the session endpoint first, and a refusal names each one that failed, with the two things that usually explain it: basic authentication wants the account's name rather than its email address, and an account with two-factor or OAuth-only sign-in needs an app password. It also gives the curl line to test one on its own. The unreachable message no longer suggests --insecure for a refused connection; that hint is now only for a certificate it couldn't verify. --- tools/fork/record-compat.py | 40 +++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/tools/fork/record-compat.py b/tools/fork/record-compat.py index d4d75e5..95a1acb 100755 --- a/tools/fork/record-compat.py +++ b/tools/fork/record-compat.py @@ -78,8 +78,27 @@ class Client: fail(f'{self.name} did not authenticate: {detail}', code=1) fail(f'{self.name}: HTTP {error.code} from {url}: {detail}', code=1) except urllib.error.URLError as error: - fail(f'cannot reach {url}: {error.reason}. ' - f'A self-signed certificate needs --insecure.', code=1) + self._unreachable(error) + + def authenticates(self): + """Whether these credentials are accepted, without failing the run.""" + request = urllib.request.Request( + f'{self.server}/jmap/session', + headers={'Authorization': f'Basic {self.auth}'}) + try: + with urllib.request.urlopen(request, context=self.ctx, timeout=30) as response: + return response.status == 200 + except urllib.error.HTTPError as error: + if error.code in (401, 403): + return False + fail(f'{self.name}: HTTP {error.code} from the session endpoint', code=1) + except urllib.error.URLError as error: + self._unreachable(error) + + def _unreachable(self, error): + hint = ('. A self-signed certificate needs --insecure' + if isinstance(error.reason, ssl.SSLError) else '') + fail(f'cannot reach {self.server}: {error.reason}{hint}', code=1) def call(self, method, arguments): """One JMAP method call; returns its response object.""" @@ -204,14 +223,23 @@ def main(): args = parser.parse_args() admin = Client(args.server, args.admin, args.insecure) - session = admin._post(f'{args.server.rstrip("/")}/jmap', {'using': USING, 'methodCalls': []}) - if 'methodResponses' not in session: - fail(f'{admin.name} signed in but the server returned no JMAP session', code=1) - tenant_admins = [(Client(args.server, credentials, args.insecure), credentials.split(':', 1)[1]) for credentials in args.tenant_admin] + # Every identity first, before any work: a recording that stops on the + # last tenant administrator has wasted a pass over every account, and + # this runs against a server that may not be up for long. + refused = [client.name for client in [admin] + [c for c, _ in tenant_admins] + if not client.authenticates()] + if refused: + fail('these did not authenticate: ' + ', '.join(refused) + '.\n' + 'Basic authentication wants the account\'s name, which may not be\n' + 'its email address, and an account with two-factor or OAuth-only\n' + 'sign-in needs an app password instead of its own. Check one with:\n' + " curl -s -o /dev/null -w '%{http_code}\\n' -u 'NAME:PASSWORD' " + f'{args.server.rstrip("/")}/jmap/session', code=1) + os.makedirs(args.out, exist_ok=True) account_ids = admin.query_ids('Account') if not account_ids: