Give identity its due: native enrollment, honest deletion, and sign-in that stays home

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 18:55:38 -04:00
parent 5a0643357f
commit 4ec8bd94d9
25 changed files with 4713 additions and 407 deletions
+348 -11
View File
@@ -12,22 +12,45 @@ turning individual services on and off, and removing an account. That is the
whole Online Accounts panel apart from one OAuth handshake.
Adding an account is the part that splits. The daemon's AddAccount takes
credentials as an ARGUMENT -- it stores them, it does not obtain them. For
password-based providers (Nextcloud, IMAP, WebDAV) that is a username and a
password, which a settings app can reasonably collect. For Google it is an OAuth
token, and the code that runs that exchange lives in libgoa-backend, which
Fedora ships without a GIR binding -- reachable from C only. Reimplementing it
would mean our own Google client credentials. So Google sign-in is handed to
GNOME's panel, and only the sign-in.
credentials as an ARGUMENT -- it stores them, it does not obtain them, and every
part of that storing happens inside goa-daemon, which does have the backend.
For password-based providers (Nextcloud, IMAP) that argument is a username and a
password, which a settings app can reasonably collect, so those are added here.
For Google it is an OAuth token, and the code that runs that exchange lives in
libgoa-backend, which Fedora ships without a GIR binding -- reachable from C
only. Reimplementing it would mean our own Google client credentials. So OAuth
sign-in is handed to GNOME's panel, and only the sign-in.
A password reaches this script on stdin and nowhere else. argv is world-readable
through /proc, so a password passed as an argument is published to every process
on the machine.
Usage:
panama-accounts list
panama-accounts list | snapshot
panama-accounts set <object-path> <service> <true|false>
panama-accounts remove <object-path>
panama-accounts add-nextcloud SERVER USERNAME (password on stdin)
panama-accounts add-imap EMAIL IMAP_HOST SMTP_HOST USERNAME (password on stdin)
"""
import json
import os
import re
import sys
import urllib.parse
class BoundaryError(RuntimeError):
"""A user-visible validation or sign-in failure."""
# A canned GOA, for contract runs: a JSON file in the shape `list` prints. With
# it set, nothing here imports gi or touches the session bus, and every change a
# verb would have made is appended to PANAMA_ACCOUNTS_LOG instead -- with the
# password replaced by its length, so a contract can prove it was read from
# stdin without a secret ever reaching a file.
FIXTURE_ENV = "PANAMA_ACCOUNTS_FIXTURE"
LOG_ENV = "PANAMA_ACCOUNTS_LOG"
# Every service GOA models. The account object carries one interface per service
# it supports, so presence of the interface is what "this account can do mail"
@@ -111,8 +134,319 @@ def find(client, path):
return None
# ── Adding a password account ────────────────────────────────────────────────
#
# The keys below are not invented. They are the keys goa-daemon writes into
# ~/.config/goa-1.0/accounts.conf, which is to say the ones each provider's
# build_object reads back -- taken from GOA 3.58's goaowncloudprovider.c and
# goaimapsmtpprovider.c, and confirmed against the accounts already on this
# machine. A key GOA does not recognize is silently ignored, so getting one
# wrong produces an account that exists and does nothing.
def read_password() -> str:
"""The password, from stdin. Never an argument, at any point in the chain."""
secret = sys.stdin.buffer.read()
# A trailing newline from a pipe is not part of the password.
if secret.endswith(b"\n"):
secret = secret[:-1]
if not secret:
raise BoundaryError("No password was provided.")
return secret.decode("utf-8", "surrogateescape")
# RFC 1123 hostname (or a dotted IPv4). These strings end up in accounts.conf
# and in URIs other processes fetch, so a shell metacharacter or a space is a
# malformed address whichever way it got here -- refuse it at the boundary.
HOSTNAME = re.compile(
r"^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?"
r"(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$")
def valid_host(text: str) -> bool:
return bool(text) and len(text) <= 253 and bool(HOSTNAME.match(text))
def nextcloud_uris(server: str) -> tuple[str, str, str]:
"""(WebDAV URI, DAV URI, host) for a Nextcloud address.
`remote.php/webdav` and `remote.php/dav` are Nextcloud's fixed layout, and
are exactly what GOA's own provider derives from the server it was given.
"""
text = (server or "").strip()
if not text:
raise BoundaryError("Enter the address of your Nextcloud server.")
if "://" not in text:
text = "https://" + text
parsed = urllib.parse.urlsplit(text)
if parsed.scheme not in ("http", "https"):
raise BoundaryError("A server address starts with https://.")
if not parsed.hostname or not valid_host(parsed.hostname):
raise BoundaryError("That is not a server address.")
# urlsplit is permissive about what follows the host; a path is fine, but
# spaces or shell punctuation anywhere in the address are not an URL that
# a Nextcloud server has.
if re.search(r"[\s;|&`$<>\"']", text):
raise BoundaryError("That is not a server address.")
base = f"{parsed.scheme}://{parsed.netloc}{parsed.path.rstrip('/')}"
return base + "/remote.php/webdav/", base + "/remote.php/dav/", parsed.hostname
def nextcloud_account(server: str, username: str) -> tuple[str, str, dict]:
"""(identity, presentation identity, details). Validation and nothing else,
so the same refusals run whether or not there is a GOA to talk to."""
identity = (username or "").strip()
if not identity:
raise BoundaryError("Enter the user name for that server.")
webdav, dav, host = nextcloud_uris(server)
return (
identity,
# What the account is shown as. A bare user name says nothing about
# which server it is on, and people keep more than one.
identity if "@" in identity else f"{identity}@{host}",
{
"Uri": webdav,
"FilesEnabled": "true",
"CalendarEnabled": "true",
"CalDavUri": dav,
"ContactsEnabled": "true",
"CardDavUri": dav,
"AcceptSslErrors": "false",
},
)
def add_nextcloud(server: str, username: str, password: str) -> str:
from gi.repository import GLib
identity, presentation, details = nextcloud_account(server, username)
return add_account("owncloud", identity, presentation,
{"password": GLib.Variant("s", password)},
details, "Nextcloud")
def imap_account(email: str, imap_host: str, smtp_host: str,
username: str) -> tuple[str, str, dict]:
address = (email or "").strip()
if "@" not in address or address.startswith("@") or address.endswith("@"):
raise BoundaryError("Enter the email address for this account.")
imap = (imap_host or "").strip()
smtp = (smtp_host or "").strip()
if not imap or not smtp:
raise BoundaryError("Enter both the incoming and outgoing server.")
if not valid_host(imap) or not valid_host(smtp):
raise BoundaryError("Mail servers are host names, like imap.example.org.")
identity = (username or "").strip() or address
# The modern defaults, and the ones every mail provider this is likely to
# meet uses: IMAP over TLS on 993, submission with STARTTLS on 587. GOA
# offers a dropdown for the other combinations; a settings page that asked
# four encryption questions to add a mailbox would be worse than one that
# gets it right and lets GNOME's panel handle the unusual server.
return (
address, address,
{
"Enabled": "true",
"EmailAddress": address,
"Name": address,
"ImapHost": imap,
"ImapUserName": identity,
"ImapUseSsl": "true",
"ImapUseTls": "false",
"ImapAcceptSslErrors": "false",
"SmtpHost": smtp,
"SmtpUseAuth": "true",
"SmtpUserName": identity,
"SmtpAuthLogin": "false",
"SmtpAuthPlain": "true",
"SmtpUseSsl": "false",
"SmtpUseTls": "true",
"SmtpAcceptSslErrors": "false",
},
)
def add_imap(email: str, imap_host: str, smtp_host: str, username: str,
password: str) -> str:
from gi.repository import GLib
identity, presentation, details = imap_account(email, imap_host, smtp_host, username)
# One password for both servers: submission almost always takes the same
# credentials as the mailbox, and asking twice for the same secret is how a
# form gets abandoned. GOA stores them under separate keys regardless.
return add_account("imap_smtp", identity, presentation,
{"imap-password": GLib.Variant("s", password),
"smtp-password": GLib.Variant("s", password)},
details, "mail")
def add_account(provider: str, identity: str, presentation: str,
credentials: dict, details: dict, label: str) -> str:
"""Hand GOA a new account, then make it prove the credentials work.
The daemon does not check what it is given -- it writes the account out and
stores the password. Without the second step a mistyped password produces an
account that exists, looks fine, and never syncs, which is the failure this
page is supposed to be able to explain.
"""
from gi.repository import GLib
client = load_client()
manager = client.get_manager()
if manager is None:
raise BoundaryError("GNOME Online Accounts is not answering.")
try:
if not manager.call_is_supported_provider_sync(provider, None):
raise BoundaryError(f"This system cannot add {label} accounts.")
path = manager.call_add_account_sync(
provider, identity, presentation,
GLib.Variant("a{sv}", credentials),
GLib.Variant("a{ss}", details), None)
except GLib.Error as failure:
raise BoundaryError(clean(failure.message)) from failure
verify(path, label)
return path
def verify(path: str, label: str) -> None:
"""Sign in once, and undo the account if that fails."""
from gi.repository import GLib
# A fresh client: the one that made the call has not necessarily seen the
# object appear yet, and this is the cheapest way to wait for it properly.
obj = find(load_client(), path)
if obj is None:
# It was added; this process simply cannot see it yet. Reporting a
# failure here would be a lie, and the account list will show it.
return
account = obj.get_account()
try:
account.set_default_timeout(60000)
account.call_ensure_credentials_sync(None)
except GLib.Error as failure:
try:
account.call_remove_sync(None)
except GLib.Error:
pass
raise BoundaryError(
f"Those {label} details were not accepted: {clean(failure.message)}"
) from failure
def clean(message: str) -> str:
"""The useful sentence of a GOA error, without the D-Bus type prefix."""
trimmed = re.sub(r"^GDBus\.Error:[^:]+:\s*", "", str(message or "")).strip()
return trimmed.splitlines()[0][:200] if trimmed else "That did not work."
# ── The canned GOA ───────────────────────────────────────────────────────────
def fixture() -> dict | None:
path = os.environ.get(FIXTURE_ENV)
if not path:
return None
try:
with open(path, encoding="utf-8") as handle:
listing = json.load(handle)
except (OSError, ValueError) as failure:
return {"accounts": [], "error": f"The accounts fixture could not be read: {failure}"}
listing.setdefault("accounts", [])
listing.setdefault("error", "")
return listing
def record(verb: str, arguments: list, password: str | None = None) -> None:
path = os.environ.get(LOG_ENV) or (os.environ.get(FIXTURE_ENV, "") + ".log")
if not path:
return
entry = {"verb": verb, "arguments": arguments}
if password is not None:
# Its length, never the thing itself: the point of the log is to prove
# the password came in on stdin, not to keep a copy of it.
entry["passwordBytes"] = len(password)
try:
with open(path, "a", encoding="utf-8") as handle:
handle.write(json.dumps(entry, separators=(",", ":")) + "\n")
except OSError:
pass
def added(operation) -> int:
"""Run an add, and answer with the fresh account list either way.
Errors come back inside that list rather than on stderr, so the page has
both facts -- what went wrong and what is there now -- from one read.
"""
error = ""
try:
operation()
except BoundaryError as failure:
error = str(failure)
try:
accounts = [describe(obj) for obj in load_client().get_accounts()]
except Exception: # noqa: BLE001 - the error above is the one worth saying
accounts = []
print(json.dumps({"accounts": accounts, "error": error}))
return 0
def replay(action: str, arguments: list, canned: dict) -> int:
"""Every verb, against the canned GOA. No bus, no gi, no account changed."""
error = ""
try:
if action in ("list", "snapshot"):
pass
elif action in ("set", "remove"):
record(action, arguments[1:])
elif action == "add-nextcloud":
if len(arguments) != 3:
raise BoundaryError("Usage: add-nextcloud SERVER USERNAME")
password = read_password()
nextcloud_account(arguments[1], arguments[2])
record(action, arguments[1:], password)
elif action == "add-imap":
if len(arguments) != 5:
raise BoundaryError("Usage: add-imap EMAIL IMAP_HOST SMTP_HOST USERNAME")
password = read_password()
imap_account(arguments[1], arguments[2], arguments[3], arguments[4])
record(action, arguments[1:], password)
else:
raise BoundaryError(f"Unknown command {action!r}.")
except BoundaryError as failure:
error = str(failure)
print(json.dumps({**canned, "error": error or canned.get("error", "")}))
return 0
def main():
action = sys.argv[1] if len(sys.argv) > 1 else "list"
arguments = sys.argv[1:]
action = arguments[0] if arguments else "list"
canned = fixture()
if canned is not None:
return replay(action, arguments, canned)
if action == "add-nextcloud":
if len(arguments) != 3:
print("usage: panama-accounts add-nextcloud SERVER USERNAME", file=sys.stderr)
return 2
return added(lambda: add_nextcloud(arguments[1], arguments[2], read_password()))
if action == "add-imap":
if len(arguments) != 5:
print("usage: panama-accounts add-imap EMAIL IMAP_HOST SMTP_HOST USERNAME",
file=sys.stderr)
return 2
return added(lambda: add_imap(arguments[1], arguments[2], arguments[3],
arguments[4], read_password()))
try:
client = load_client()
@@ -124,7 +458,9 @@ def main():
}))
return 0
if action == "list":
# "snapshot" is what every other Panama helper calls this, and what the
# service asks for; "list" is what this one has always been called.
if action in ("list", "snapshot"):
print(json.dumps({
"accounts": [describe(obj) for obj in client.get_accounts()],
"error": "",
@@ -162,7 +498,8 @@ def main():
obj.get_account().call_remove_sync(None)
return 0
print("usage: panama-accounts [list|set|remove]", file=sys.stderr)
print("usage: panama-accounts [list|snapshot|set|remove|add-nextcloud|add-imap]",
file=sys.stderr)
return 2