← Commit history

v0.8.25: provider discovery from wiki org settings

John Lauer ·79d4b3a3d3 ·28d ago ·parent a9d27d2
3 files changed +134−12
bin/adom-google+130−9
@@ -21,9 +21,9 @@ Auth (one consent, long-lived refresh token):  See `adom-google help`. """-import sys, os, json, base64, argparse, urllib.parse, ssl, socket, hashlib, secrets, struct, time+import sys, os, json, base64, argparse, urllib.parse, ssl, socket, hashlib, secrets, struct, time, subprocess, shutil -ADOM_GOOGLE_VERSION = "0.8.24"+ADOM_GOOGLE_VERSION = "0.8.25" if sys.argv[1:2] == ["--version"]:     print(f"adom-google {ADOM_GOOGLE_VERSION}")     sys.exit(0)@@ -272,6 +272,79 @@ BUNDLED_PROVIDER = os.path.join(HERE, "provider.json") LEGACY_CREDS = os.path.join(HERE, "credentials.json") MANUAL_REDIRECT = "http://localhost"  # manual desktop-client flow: loopback, user copies the code +ORG_INTEGRATION = "google-oauth"   # file integrations/google-oauth.json on <org>/org-settings+++def _wiki_json(args, timeout=25):+    """Run `adom-wiki <args> --json`; return the envelope dict, or None on any failure."""+    if not shutil.which("adom-wiki"):+        return None+    try:+        out = subprocess.run(["adom-wiki", *args, "--json"], capture_output=True, text=True,+                             timeout=timeout).stdout+        return json.loads(out)+    except Exception:+        return None+++def _discover_org_providers(only_org=None):+    """Find the Google OAuth provider in the signed-in wiki user's ORG SETTINGS.++    Every wiki org can keep shared, NON-secret integration config on its members-only+    `<org>/org-settings` page (`adom-wiki settings`). An org that has set adom-google up stores+    `integrations/google-oauth.json` there: {client_id, gateway_url, app, mode:"broker", ...}.+    The client secret is never in it; in broker mode it lives on the OAuth gateway, which does+    the token exchange. So any member's first `adom-google auth` just works, with no extra+    package. Returns (orgs_checked, [(org, data), ...])."""+    who = _wiki_json(["whoami"])+    orgs = [o.get("name") for o in (((who or {}).get("data") or {}).get("orgs") or []) if o.get("name")]+    if only_org:+        orgs = [only_org]+    found = []+    for org in orgs:+        d = _wiki_json(["settings", "get", org, ORG_INTEGRATION])+        if not d or d.get("status") != "ok":+            continue+        data = d.get("data") or {}+        if data.get("client_secret") or data.get("secret") is True:+            continue          # never accept a secret from shared settings; it does not belong there+        if data.get("client_id") and data.get("gateway_url"):+            found.append((org, data))+    return orgs, found+++def _write_org_provider(org, data):+    """Cache an org-settings provider as provider.json (0600), tagged with its source."""+    prov = {"gateway_url": data["gateway_url"].rstrip("/"), "client_id": data["client_id"],+            "app": data.get("app") or org, "broker": (data.get("mode") or "broker") == "broker",+            "_source": f"org-settings:{org}", "_fetched": time.strftime("%Y-%m-%d")}+    if data.get("gateway_ws"):+        prov["gateway_ws"] = data["gateway_ws"]+    os.makedirs(CFG_DIR, exist_ok=True)+    fd = os.open(PROVIDER_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)+    with os.fdopen(fd, "w") as f:+        json.dump(prov, f, indent=2)+    os.chmod(PROVIDER_PATH, 0o600)+    return prov+++_DISCOVERY = {"tried": False, "candidates": [], "orgs": []}+++def _maybe_discover():+    """If NOTHING configures a provider, try the user's org settings once per process.+    An explicit provider (provider set, env, a bundled file) always wins and is never touched."""+    if _DISCOVERY["tried"] or os.environ.get("ADOM_GOOGLE_NO_DISCOVER"):+        return False+    _DISCOVERY["tried"] = True+    orgs, found = _discover_org_providers()+    _DISCOVERY["orgs"], _DISCOVERY["candidates"] = orgs, [o for o, _ in found]+    if len(found) == 1:+        _write_org_provider(*found[0])+        return True+    return False       # none, or several orgs: never guess which org a Google login belongs to++ _PROVIDER = None def _provider():     """Resolve {app, gateway_url, gateway_ws, gateway_redirect, client_id, client_secret}."""@@ -321,16 +394,19 @@ def _provider():         p["gateway_redirect"] = p["gateway_url"] + "/callback"     else:         p["gateway_redirect"] = None+        if _maybe_discover():             # wrote provider.json from org settings: resolve again+            return _provider()     _PROVIDER = p     return p   NO_PROVIDER_HINT = """ERROR: adom-google has no OAuth provider configured (gateway URL + client_id).-The open-source CLI ships without one on purpose — the provider is account-specific. Three ways:-Hint:   • Adom employee  → install the private `adom-google-adom` package (drops the Adom provider).-Hint:   • Managed (any org, ~$5/mo) → email [email protected] for a hosted OAuth gateway container.-Hint:   • Self-host (DIY) → run your own gateway (john/service-oauth) + Google OAuth client, then:-Hint:         adom-google provider set --gateway https://your-gateway.example --client-id <id>+Hint: adom-google looks for one in your wiki org's settings first (adom-wiki settings get <org> google-oauth).+Hint:   • Your org already set adom-google up (Adom does)? Run: adom-google provider discover+Hint:     If you belong to SEVERAL such orgs it will not guess: adom-google provider discover --org <org>+Hint:   • First person in your org? Set it up once for everyone: adom-google onboard --org <org>+Hint:   • Personal @gmail or any org, zero setup: adom-google connect-personal (Adom's hosted Connector)+Hint:   • Self-host: adom-google provider set --gateway https://your-gateway.example --client-id <id> Hint: Check the current provider any time with: adom-google provider show"""  # AI hint emitted whenever adom-google is unconfigured. Per ai-hints-in-clis: this is how the@@ -1284,6 +1360,15 @@ def _cmd_auth_impl(a, as_json, real_out):     if a.manual:         return _auth_manual(cfg, scopes, mode)     # Gateway flow — one static redirect, code routed back over WebSocket. Needs a provider.+    try:+        tag = json.load(open(PROVIDER_PATH)).get("_source", "") if os.path.exists(PROVIDER_PATH) else ""+    except Exception:+        tag = ""+    if tag.startswith("org-settings:") and not os.environ.get("ADOM_GOOGLE_NO_DISCOVER"):+        _o, _f = _discover_org_providers(only_org=tag.split(":", 1)[1])+        if _f:+            global _PROVIDER+            _write_org_provider(*_f[0]); _PROVIDER = None     pr = _provider()     if not pr["gateway_url"]:         sys.exit(NO_PROVIDER_HINT)@@ -1518,14 +1603,49 @@ def cmd_provider_set(a):     print("Next: ask the user safe vs full, then `adom-google auth`.")  +def cmd_provider_discover(a):+    """Fetch the provider from the user's wiki org settings and cache it (0600)."""+    orgs, found = _discover_org_providers(only_org=getattr(a, "org", None))+    res = {"orgs_checked": orgs, "found": [o for o, _ in found]}+    if len(found) == 1:+        org, data = found[0]+        prov = _write_org_provider(org, data)+        res.update(ok=True, org=org, app=prov["app"], gateway_url=prov["gateway_url"],+                   mode="broker" if prov["broker"] else "relay", provider_path=PROVIDER_PATH)+        if a.json:+            print(json.dumps(res)); return+        print(f"OK: provider from {org}/org-settings → {PROVIDER_PATH} (0600)")+        print(f"   app={prov['app']}  gateway={prov['gateway_url']}  mode={res['mode']}")+        print("Next: adom-google auth   (one Allow click)")+        return+    res["ok"] = False+    if a.json:+        print(json.dumps(res)); sys.exit(1)+    if not orgs:+        sys.exit("ERROR: could not read your wiki orgs (is adom-wiki installed and signed in? try: adom-wiki whoami).")+    if not found:+        sys.exit(f"ERROR: none of your orgs ({', '.join(orgs)}) has set adom-google up in its org settings.\n"+                 "Hint: first person in the org? adom-google onboard --org <org>\n"+                 "Hint: personal or zero-setup: adom-google connect-personal")+    sys.exit(f"ERROR: several of your orgs have adom-google set up ({', '.join(res['found'])}); pick one:\n"+             + "".join(f"  adom-google provider discover --org {o}\n" for o in res["found"]))++ def cmd_provider_show(a):     pr = _provider()     if os.path.exists(PROVIDER_PATH):       src = PROVIDER_PATH     elif os.path.exists(BASE_PROVIDER_PATH): src = BASE_PROVIDER_PATH + " (inherited from default account)"     elif os.path.exists(BUNDLED_PROVIDER):  src = BUNDLED_PROVIDER + " (bundled)"-    elif os.path.exists(LEGACY_CREDS):      src = LEGACY_CREDS + " (legacy)"+    elif os.path.exists(LEGACY_CREDS) and "clientId" in open(LEGACY_CREDS).read():+        src = LEGACY_CREDS + " (legacy)"     elif os.environ.get("OAUTH_GATEWAY_URL"): src = "environment"-    else:                                   src = "(none — run `provider set`)"+    else:                                   src = "(none: run `provider discover`, or `provider set`)"+    try:+        tag = json.load(open(PROVIDER_PATH)).get("_source", "") if os.path.exists(PROVIDER_PATH) else ""+        if tag.startswith("org-settings:"):+            src += f"  [from {tag.split(':',1)[1]}/org-settings, `provider discover` refreshes it]"+    except Exception:+        pass     print(f"app:           {pr['app']}")     print(f"gateway_url:   {pr['gateway_url'] or '(UNSET)'}")     print(f"gateway_ws:    {pr['gateway_ws'] or '(derived from gateway_url)'}")@@ -2023,6 +2143,7 @@ def main():     x.add_argument("--client-id", dest="client_id"); x.add_argument("--client-secret", dest="client_secret")     x.add_argument("--app"); x.set_defaults(fn=cmd_provider_set)     x = pvs.add_parser("show"); x.set_defaults(fn=cmd_provider_show)+    x = pvs.add_parser("discover", help="load the provider from your wiki org's settings (adom-wiki settings get <org> google-oauth)"); x.add_argument("--org", help="pick this org when you belong to several"); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_provider_discover)     s = sub.add_parser("status"); s.add_argument("--json", action="store_true", help="one JSON object with authorized:true|false (+ account, mode, scopes, gateway); exit 1 when NOT authorized"); s.set_defaults(fn=cmd_status)     s = sub.add_parser("accounts"); s.add_argument("--json", action="store_true"); s.set_defaults(fn=cmd_accounts)     s = sub.add_parser("setup"); s.set_defaults(fn=cmd_setup)
install.sh

  
page.json+4−3
@@ -94,7 +94,7 @@   "skills": [],   "hero_path": "screenshots/hero-google-v5.png",   "hero_type": "image",-  "description": "Drive your whole Google Workspace from the terminal: Gmail, Contacts, Drive, Sheets, Docs, Slides, Calendar, Tasks, Google Chat, plus a generic `api` passthrough to hit ANY Google API. Connect a personal @gmail (or another org's Google account) with ZERO setup via Adom's hosted Connector (`adom-google connect-personal`), or bring your own token-broker gateway (`provider set`): Adom staff use the private adom-google-adom provider. One-click OAuth, no client secret on your machine.",+  "description": "Drive your whole Google Workspace from the terminal: Gmail, Contacts, Drive, Sheets, Docs, Slides, Calendar, Tasks, Google Chat, plus a generic `api` passthrough to hit ANY Google API. Open source, ships with NO OAuth provider baked in: point it at your own token-broker gateway (`provider set`), buy a managed one, or: Adom staff: install the private adom-google-adom provider. One-click OAuth, no client secret on your machine. First run asks safe mode (no delete) vs full mode (incl. delete).",   "keywords": [     "adom-google",     "google-workspace",@@ -118,6 +118,8 @@     "uninstall": "./uninstall.sh"   },   "files": [+    "bin/adom-google",+    "bin/adom-gmail",     "SKILL.md",     "skills/adom-google-onboarding/SKILL.md",     "README.md",@@ -126,8 +128,7 @@     "provider.example.json",     "install.sh",     "uninstall.sh",-    "package.json",-    "VERSION"+    "package.json"   ],   "org": "adom" }\ No newline at end of file