← Commit history

v0.8.26: org register (set up once for the whole org)

John Lauer ·d45636a52d ·28d ago ·parent 28a59f9
3 files changed +167−15
.adomignore+1
@@ -1,3 +1,4 @@ # Dev/maintainer-only skills: kept in the source repo, NEVER shipped in the public # install tarball (they carry internal setup notes and customer names). skills/adom-google-connector-oauth-dev/+gateway/
bin/adom-google+162−12
@@ -23,7 +23,7 @@ See `adom-google help`. """ import sys, os, json, base64, argparse, urllib.parse, ssl, socket, hashlib, secrets, struct, time, subprocess, shutil -ADOM_GOOGLE_VERSION = "0.8.25"+ADOM_GOOGLE_VERSION = "0.8.26" if sys.argv[1:2] == ["--version"]:     print(f"adom-google {ADOM_GOOGLE_VERSION}")     sys.exit(0)@@ -238,10 +238,12 @@ Hint:   3. Configure the OAuth consent screen (User type = Internal; app name fr Hint:   4. Create an OAuth client (type = Web application); AUTO-FILL Authorized redirect URI Hint:      with the gateway `redirect_uri` (the error-prone field — never make the user type it). Hint:   5. Capture the client_id + client_secret from the final dialog.-Hint:   6. Save them:   adom-google onboard finish --org <slug> --client-id <id> --client-secret <secret>+Hint:   6. Register for the whole org:  printf '%s\n' "$SECRET" | adom-google org register --org <org> --client-id <id> Hint:   7. Authorize (full Workspace by default, no mode quiz):   adom-google auth-Hint: The secret stays on THE USER'S container (0600) and rides Adom's shared callback in RELAY-Hint: mode — Adom never holds it."""+Hint: `org register` hands the client to Adom's gateway ONCE for the whole org: it verifies the caller+Hint: is an org member (wiki login), checks the client with Google, holds the secret (broker mode), and+Hint: writes the non-secret half to <org>/org-settings so teammates need no setup. `onboard finish+Hint: --relay` keeps the old single-box mode (secret 0600 on this box only; teammates cannot use it)."""  TOKEN_URL = "https://oauth2.googleapis.com/token" AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"@@ -1667,8 +1669,8 @@ def cmd_onboard(a):     print("┌─ adom-google · onboard your org to Google Workspace ─────────────────────────┐")     print("│  One-time setup: your team gets its OWN Google OAuth client so the AI can     │")     print("│  read mail, post Chat on your behalf, build Slides/Sheets/Docs, search Drive. │")-    print("│  Your client SECRET stays on YOUR machine (0600). Adom only lends its public  │")-    print("│  callback URL — it never sees your secret.                                    │")+    print("│  Done ONCE by one person: Adom's gateway verifies you are in the org, checks  │")+    print("│  the client with Google and holds the secret, so every member just signs in. │")     print("└──────────────────────────────────────────────────────────────────────────────┘\n")     print("The AI can drive your real browser through every step below and auto-fill the")     print("fields (recommended) — or you can do it by hand. Either way, the exact values:\n")@@ -1685,17 +1687,153 @@ def cmd_onboard(a):     print("     " + cfg["console"]["create_client"])     print("     ⚠ Authorized redirect URI — paste EXACTLY (this is the gateway callback):")     print("        " + R)-    print("\n5) Copy the Client ID + Client secret it shows, then run:")-    print("     adom-google onboard finish --org <your-org-slug> \\")-    print("         --client-id <client-id> --client-secret <client-secret>")+    print("\n5) Copy the Client ID + Client secret it shows, then register it for the WHOLE org")+    print("   (secret on stdin, so it stays out of shell history):")+    print("     printf '%s\\n' \"$SECRET\" | adom-google org register --org <your-wiki-org> --client-id <client-id>")+    print("   Teammates then just run `adom-google auth`: the non-secret half is on <org>/org-settings.")     print("\n6) Authorize (full Workspace access by default):")     print("     adom-google auth          # one Allow click with your Workspace account (you have ~60 min)")     print("\n(Chat also needs a one-time Chat-app config: " + cfg["console"]["chat_config"] + ")")     print("\n" + ONBOARD_HINT)  +def _wiki_token():+    """The caller's wiki token, resolved the same way adom-wiki does: env, config, container key.+    Sent ONLY to the OAuth gateway, which uses it once to ask the wiki which orgs you belong to."""+    for k in ("ADOM_WIKI_TOKEN", "ADOMPKG_TOKEN"):+        if os.environ.get(k):+            return os.environ[k].strip()+    for path in (os.path.expanduser("~/.config/adom-wiki/config.json"),):+        try:+            t = (json.load(open(path)) or {}).get("token")+            if t:+                return t.strip()+        except Exception:+            pass+    try:+        return open("/var/run/adom/api-key").read().strip() or None+    except Exception:+        return None+++def _gateway_org_call(method, path, gw, body=None, params=None):+    tok = _wiki_token()+    if not tok:+        sys.exit("ERROR: no wiki token found (adom-wiki is not signed in on this box).\n"+                 "Hint: check with `adom-wiki whoami`; the gateway uses your wiki login to confirm org membership.")+    try:+        r = requests.request(method, gw.rstrip("/") + path, json=body, params=params, timeout=60,+                             headers={"Authorization": "Bearer " + tok})+    except requests.RequestException as e:+        sys.exit(f"ERROR: could not reach the OAuth gateway at {gw}: {e}")+    try:+        return r.status_code, r.json()+    except ValueError:+        return r.status_code, {"error": f"http_{r.status_code}", "detail": r.text[:200]}+++def _org_settings_record(org, client_id, gw, domain=None):+    """The NON-secret half, for <org>/org-settings integrations/google-oauth.json."""+    rec = {"schema": "adom.org-integration/1", "integration": ORG_INTEGRATION, "owner": org,+           "kind": "oauth-gateway-provider", "secret": False, "app": f"org-{org}",+           "client_id": client_id, "gateway_url": gw.rstrip("/"), "mode": "broker",+           "set_up_by": (_wiki_json(["whoami"]) or {}).get("data", {}).get("user", {}).get("username"),+           "note": ("Google OAuth client for adom-google. Public client_id only: the client secret is "+                    "held by the OAuth gateway, which does the token exchange, so no member's box ever "+                    "holds it. Members just run `adom-google auth`."),+           "updated": time.strftime("%Y-%m-%d")}+    if domain:+        rec["workspace_domains"] = [d.strip() for d in domain.split(",") if d.strip()]+    return rec+++def cmd_org_register(a):+    """Register THIS org's own Google OAuth client with the gateway, once, for every member."""+    org = a.org.strip().lower()+    gw = (a.gateway or ADOM_SHARED_GATEWAY).rstrip("/")+    secret = a.client_secret+    if not secret or secret == "-":+        secret = sys.stdin.readline().strip()          # keeps it out of shell history / ps+    print(f"1) Registering {org}'s Google OAuth client with the gateway ({gw}) ...")+    print("   The gateway checks: you are a member of this org (your wiki login), the client id +")+    print("   secret are real (asks Google), and its /callback is an authorized redirect URI.")+    code, d = _gateway_org_call("POST", "/apps/register", gw,+                                body={"org": org, "client_id": a.client_id, "client_secret": secret})+    secret = None+    if code != 200 or not d.get("ok"):+        msg = d.get("hint") or d.get("detail") or ""+        sys.exit(f"ERROR: gateway refused the registration: {d.get('error')} {('(' + d['google'] + ')') if d.get('google') else ''}\n"+                 + (f"Hint: {msg}\n" if msg else "")+                 + ("Hint: redirect URI to add on the OAuth client: " + d["redirect_uri"] + "\n" if d.get("redirect_uri") else ""))+    print(f"   OK: {d['app']} registered by {d['checks']['membership']}; client valid, redirect registered"+          + ("  (replaced the previous client)" if d.get("replaced") else ""))+    print(f"2) Publishing the non-secret half to {org}/org-settings so every member picks it up ...")+    rec = _org_settings_record(org, a.client_id, gw, a.domain)+    tmp = os.path.join(CFG_DIR, f".org-settings-{org}.json")+    os.makedirs(CFG_DIR, exist_ok=True)+    with open(tmp, "w") as f:+        json.dump(rec, f, indent=2)+    r = subprocess.run(["adom-wiki", "settings", "set", org, ORG_INTEGRATION, "--file", tmp, "-m",+                        f"google-oauth: {org}'s adom-google provider (secret held by the gateway)"],+                       capture_output=True, text=True, timeout=60)+    os.unlink(tmp)+    if r.returncode != 0 or "SETTINGS_WRITTEN" not in (r.stdout + r.stderr):+        out = (r.stdout + r.stderr).strip().splitlines()+        print("   WARN: could not write org settings; members would not auto-discover yet:", file=sys.stderr)+        print("   " + "\n   ".join(out[-4:]), file=sys.stderr)+        print(f"   Hint: if {org}/org-settings does not exist yet, the error above prints the one-time create command.", file=sys.stderr)+    else:+        print(f"   OK: {org}/org-settings integrations/{ORG_INTEGRATION}.json written (members only, no secret)")+    print("3) Wiring this box ...")+    global _PROVIDER+    _PROVIDER = None+    orgs, found = _discover_org_providers(only_org=org)+    if found:+        _write_org_provider(*found[0])+    else:+        _write_org_provider(org, rec)+    print(f"   OK: provider → {PROVIDER_PATH} (0600, broker mode, no secret here)")+    print(f"\n✅ {org} is set up. You and every other member of {org}: `adom-google auth` (one Allow click).")+++def cmd_org_status(a):+    org = a.org.strip().lower()+    gw = (a.gateway or ADOM_SHARED_GATEWAY).rstrip("/")+    code, d = _gateway_org_call("GET", "/apps/status", gw, params={"org": org})+    st = _wiki_json(["settings", "get", org, ORG_INTEGRATION]) or {}+    d["org_settings"] = "present" if st.get("status") == "ok" else ((st.get("hints") or [{}])[0].get("code") or "unreadable")+    if a.json:+        print(json.dumps(d)); return+    if code != 200:+        sys.exit(f"ERROR: {d.get('error')}: {d.get('hint') or d.get('detail') or ''}")+    print(f"org:            {org}  (gateway app {d.get('app')})")+    print(f"gateway:        {'registered by ' + str(d.get('registered_by')) + ' at ' + str(d.get('registered_at')) if d.get('registered') else 'NOT registered'}")+    print(f"client_id:      {d.get('client_id') or '-'}")+    print(f"org settings:   {d['org_settings']}")+    print(f"redirect_uri:   {d.get('redirect_uri')}")+++def cmd_org_unregister(a):+    org = a.org.strip().lower()+    gw = (a.gateway or ADOM_SHARED_GATEWAY).rstrip("/")+    code, d = _gateway_org_call("POST", "/apps/unregister", gw, body={"org": org})+    if code != 200:+        sys.exit(f"ERROR: {d.get('error')}: {d.get('hint') or d.get('detail') or ''}")+    print(f"OK: gateway entry for {org} {'removed' if d.get('removed') else 'was not present'}. "+          f"Members' existing tokens stop refreshing. The {org}/org-settings entry is left as is; "+          f"remove it with the wiki if the org is done with adom-google.")++ def cmd_onboard_finish(a):-    """Record the org's freshly-created OAuth client and wire RELAY mode (secret stays local)."""+    """Record the org's freshly-created OAuth client.++    DEFAULT (0.8.26+): register it with the gateway for the WHOLE org (`org register`): the+    gateway holds the secret and brokers, and the non-secret half goes on <org>/org-settings, so+    every member just runs `adom-google auth`. `--relay` keeps the old single-box mode where the+    secret stays 0600 on this box only and teammates cannot use it."""+    if not getattr(a, "relay", False):+        a.domain = getattr(a, "domain", None)+        return cmd_org_register(a)     gw = (a.gateway or ADOM_SHARED_GATEWAY).rstrip("/")     prov = {}     if os.path.exists(PROVIDER_PATH):@@ -1706,7 +1844,7 @@ def cmd_onboard_finish(a):             prov = {}     prov["gateway_url"] = gw     prov["client_id"] = a.client_id-    prov["client_secret"] = a.client_secret      # the org's OWN secret, on the org's OWN box+    prov["client_secret"] = a.client_secret or sys.stdin.readline().strip()   # the org's OWN secret, on the org's OWN box     prov["app"] = a.org     prov["broker"] = False                         # relay mode: gateway relays, we exchange locally     os.makedirs(CFG_DIR, exist_ok=True)@@ -2155,10 +2293,22 @@ def main():     y = obx.add_parser("finish")     y.add_argument("--org", required=True)     y.add_argument("--client-id", dest="client_id", required=True)-    y.add_argument("--client-secret", dest="client_secret", required=True)+    y.add_argument("--client-secret", dest="client_secret", help="omit or '-' to read from stdin")     y.add_argument("--gateway", help="override the shared callback gateway (advanced)")+    y.add_argument("--domain", help="your Google Workspace domain(s), comma separated (recorded in org settings)")+    y.add_argument("--relay", action="store_true", help="old single-box mode: keep the secret 0600 on THIS box only (teammates cannot use it)")     y.set_defaults(fn=cmd_onboard_finish) +    og = sub.add_parser("org", help="set adom-google up ONCE for a whole wiki org (gateway holds the secret; members auto-discover)")+    ogs = og.add_subparsers(dest="sub")+    z = ogs.add_parser("register", help="hand your org's Google OAuth client to the gateway + publish the non-secret half to <org>/org-settings")+    z.add_argument("--org", required=True); z.add_argument("--client-id", dest="client_id", required=True)+    z.add_argument("--client-secret", dest="client_secret", help="omit or '-' to read it from stdin (keeps it out of shell history)")+    z.add_argument("--domain", help="Google Workspace domain(s), comma separated"); z.add_argument("--gateway")+    z.set_defaults(fn=cmd_org_register)+    z = ogs.add_parser("status"); z.add_argument("--org", required=True); z.add_argument("--gateway"); z.add_argument("--json", action="store_true"); z.set_defaults(fn=cmd_org_status)+    z = ogs.add_parser("unregister"); z.add_argument("--org", required=True); z.add_argument("--gateway"); z.set_defaults(fn=cmd_org_unregister)+     g = sub.add_parser("gmail"); gs = g.add_subparsers(dest="sub")     x = gs.add_parser("attachments"); x.add_argument("message_id"); x.set_defaults(fn=cmd_gmail_attachments)     x = gs.add_parser("read"); x.add_argument("message_id"); x.add_argument("attachment_id"); x.add_argument("-o", "--out"); x.set_defaults(fn=cmd_gmail_read)
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