← Commit history

v0.3.0 — provider model: open-source CLI ships NO OAuth provider (no Adom gateway/client in the package); add provider set/show; Adom infra moves to private adom-google-adom package

John Lauer ·1d56775ac9 ·4mo ago ·parent ab01794
6 files changed +176−57
README.md+13−6
@@ -59,15 +59,18 @@ source for the gateway: [`john/service-oauth`](https://wiki.adom.inc/pages/servi  ## Getting your own auth — three ways -The **code** is open source. The thing that's account-specific is the OAuth app + gateway.-Pick your path:+The **code** is open source and ships with **no OAuth provider baked in** — it has no idea+which gateway or Google client to use until you give it one. That "provider" (gateway URL ++client_id) is the only account-specific piece. Pick your path:  ### 1. You're at Adom → just use it ```bash-adompkg install adom-google-adom-google setup     # asks: safe mode (no delete) or full mode?-adom-google auth      # one Allow click — done+adompkg install adom-google-adom   # pulls the CLI + drops the Adom provider config+adom-google setup                  # asks: safe mode (no delete) or full mode?+adom-google auth                   # one Allow click — done ```+(`adom-google-adom` is a tiny private, Adom-only package that writes the Adom provider; the+public `adom-google` package contains none of Adom's infrastructure.)  ### 2. Any other company / team → let Adom host your OAuth (easiest) 💵 Email **[email protected]**. We spin up a dedicated OAuth gateway container for your org@@ -82,7 +85,11 @@ Fork this repo and stand up your own:   with the Gmail/Drive/Sheets/Docs/Slides/Calendar/Tasks/People/Chat APIs enabled. - The open-source OAuth gateway ([`john/service-oauth`](https://wiki.adom.inc/pages/service-oauth))   on a small container, with your client's secret in `creds/<app>.json`.-- Point `OAUTH_GATEWAY_URL` + `bin/credentials.json` (client_id) at yours.+- Point the CLI at **your** gateway + client (writes `~/.config/adom-google/provider.json`, 0600):+  ```bash+  adom-google provider set --gateway https://your-gateway.example --client-id <your-client-id>+  adom-google provider show     # verify what's resolved (env > provider.json > bundled)+  ```  **Enabling the APIs** — in the Cloud Console go to **APIs & Services → Library**, search each one, and click **Enable**. You want all nine:
SKILL.md+15−11
@@ -94,19 +94,22 @@ Adom OAuth Gateway gives one static callback for all containers **and** acts as confidential-client **token broker**, so the client secret never reaches user containers:  1. `adom-google auth` builds the Google consent URL with-   `redirect_uri = https://oauth-9mycwxij7tif.adom.cloud/callback` and a random `state`,+   `redirect_uri = <your gateway>/callback` (from the resolved provider) and a random `state`,    and opens a WebSocket to the Gateway registering `{state, app:"adom-google", exchange:true}`.-2. The user approves in their browser (their own @adom.inc Workspace account).+2. The user approves in their browser (their own Google Workspace account). 3. Google redirects to the Gateway `/callback`; the Gateway **does the code→token exchange    itself** (using the secret it alone holds) and pushes `{type:"tokens", …}` over the WS. 4. The container stores only the **refresh token** `0600` — no client secret. Later refreshes    go through the gateway's `POST /refresh`, so the secret stays on the gateway forever. -`bin/credentials.json` ships **only the public client_id** (it appears in every consent URL-anyway) — **no secret in the package.** The secret lives in the gateway's `creds/adom-google.json`-(0600, gitignored). The backing GCP project (Internal/adom.inc consent) enables whichever APIs-the user needs (People, Gmail, Drive, Sheets, Docs, Slides, Calendar, Tasks) and registers the-gateway callback as an authorized redirect URI.+**The package ships NO provider** — no gateway URL, no client_id, nothing Adom-specific. The+provider (`{gateway_url, client_id, app}`) is resolved at runtime, later wins: bundled+`provider.json` → legacy `credentials.json` → `~/.config/adom-google/provider.json` → env+(`OAUTH_GATEWAY_URL`, `ADOM_GOOGLE_CLIENT_ID/SECRET/APP`). With none configured, every command+prints the NO_PROVIDER hint. Adom employees install the private **`adom-google-adom`** package,+which writes the Adom provider (gateway + Internal client_id) to `~/.config/adom-google/provider.json`;+self-hosters run `adom-google provider set --gateway <url> --client-id <id>`. The client secret is+never in any package — in broker mode it lives only on the gateway's `creds/<app>.json` (0600).  If the Gateway is down, `auth --manual` uses a desktop-client loopback paste flow (that one exchanges locally and does need a client secret via `init`).@@ -122,10 +125,11 @@ non-adom.inc accounts at consent. Two options to surface to them:    they then get the full adom-google capability set, same as the Adom team. 2. **Self-host (DIY):** fork the repo; create their own Google Cloud project + OAuth client    (Internal to their Workspace), enable the APIs they need, run the open-source OAuth gateway-   (`john/service-oauth`) with their client secret in `creds/<app>.json`, and point-   `OAUTH_GATEWAY_URL` + `bin/credentials.json` (client_id) at theirs. Google **Chat** also-   needs a one-time Chat-app configuration (name/avatar/description) in the Cloud console's-   Chat API → Configuration tab, or `spaces.*` calls 404 with "Chat app not found".+   (`john/service-oauth`) with their client secret in `creds/<app>.json`, then point the CLI at+   theirs: `adom-google provider set --gateway https://their-gateway --client-id <their-id>`+   (writes `~/.config/adom-google/provider.json`). Google **Chat** also needs a one-time+   Chat-app configuration (name/avatar/description) in the Cloud console's Chat API →+   Configuration tab, or `spaces.*` calls 404 with "Chat app not found".  ## Commands 
WIKI-PUBLISH.md+1−1
@@ -29,7 +29,7 @@ adom-wiki-publish verify adom-google ## Bundle info  - **Type:** app-- **Wiki page:** https://git-wiki-ktqxite5iglh.adom.cloud/pages/adom-google+- **Wiki page:** https://wiki.adom.inc/pages/adom-google - **Install:** `adompkg install adom-google` - **Source:** this repository (commit, push, then sync to wiki) 
bin/adom-google+139−37
@@ -71,13 +71,69 @@ AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" GMAIL_API = "https://gmail.googleapis.com/gmail/v1/users/me" PEOPLE_API = "https://people.googleapis.com/v1" -# --- Adom OAuth Gateway (one static redirect for every container; see gallia oauth skill) ----GATEWAY_URL = os.environ.get("OAUTH_GATEWAY_URL", "https://oauth-9mycwxij7tif.adom.cloud")-GATEWAY_WS = os.environ.get("OAUTH_GATEWAY_WS", "wss://oauth-9mycwxij7tif.adom.cloud")-GATEWAY_REDIRECT = GATEWAY_URL.rstrip("/") + "/callback"-# Manual (desktop-client) flow redirect — loopback, user copies code from the URL bar.-MANUAL_REDIRECT = "http://localhost"-APP = "adom-google"   # broker app id — the gateway holds this app's client secret+# --- OAuth PROVIDER (gateway URL + client) — deliberately NOT hardcoded -----------------------+# This open-source CLI ships with ZERO provider baked in. The provider (which OAuth gateway to+# talk to + which Google client_id) is resolved at runtime so the public code carries no+# Adom-specific infrastructure. Resolution, later wins:+#   1. bundled provider.json next to the script  (a private *provider* package may ship this)+#   2. legacy credentials.json next to the script (old {clientId,...}; back-compat only)+#   3. ~/.config/adom-google/provider.json        (what `provider set` / the employee pkg writes)+#   4. env: OAUTH_GATEWAY_URL, OAUTH_GATEWAY_WS, ADOM_GOOGLE_CLIENT_ID/SECRET/APP+# Adom employees get the Adom provider via the private `adom-google-adom` package; everyone else+# self-hosts a gateway or buys a managed one ([email protected]). See README.+DEFAULT_APP = "adom-google"+PROVIDER_PATH = os.path.join(CFG_DIR, "provider.json")+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++_PROVIDER = None+def _provider():+    """Resolve {app, gateway_url, gateway_ws, gateway_redirect, client_id, client_secret}."""+    global _PROVIDER+    if _PROVIDER is not None:+        return _PROVIDER+    p = {"app": DEFAULT_APP, "gateway_url": None, "gateway_ws": None,+         "client_id": None, "client_secret": None}+    def merge(path, keymap):+        if not os.path.exists(path):+            return+        try:+            with open(path) as f:+                d = json.load(f)+        except Exception:+            return+        for src, dst in keymap.items():+            if d.get(src):+                p[dst] = d[src]+    full = {"gateway_url": "gateway_url", "gateway_ws": "gateway_ws", "client_id": "client_id",+            "client_secret": "client_secret", "app": "app"}+    merge(BUNDLED_PROVIDER, full)+    merge(LEGACY_CREDS, {"clientId": "client_id", "clientSecret": "client_secret", "app": "app"})+    merge(PROVIDER_PATH, full)+    e = os.environ+    p["gateway_url"]    = e.get("OAUTH_GATEWAY_URL",      p["gateway_url"])+    p["gateway_ws"]     = e.get("OAUTH_GATEWAY_WS",       p["gateway_ws"])+    p["client_id"]      = e.get("ADOM_GOOGLE_CLIENT_ID",  p["client_id"])+    p["client_secret"]  = e.get("ADOM_GOOGLE_CLIENT_SECRET", p["client_secret"])+    p["app"]            = e.get("ADOM_GOOGLE_APP",        p["app"])+    if p["gateway_url"]:+        p["gateway_url"] = p["gateway_url"].rstrip("/")+        p["gateway_ws"] = (p["gateway_ws"] or "wss://" + p["gateway_url"].split("://", 1)[-1]).rstrip("/")+        p["gateway_redirect"] = p["gateway_url"] + "/callback"+    else:+        p["gateway_redirect"] = None+    _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: 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 # CLI forces the installing AI to ASK THE USER the safe/full choice at the moment of setup,@@ -131,24 +187,15 @@ def _save(cfg):     os.chmod(CFG, 0o600)  -def _bundled_client():-    """The shared Adom OAuth client (ships with the skill). Used by the gateway flow so-    users don't each create a Google Cloud project. Returns (client_id, client_secret)."""-    path = os.path.join(HERE, "credentials.json")-    if not os.path.exists(path):-        return None, None-    with open(path) as f:-        c = json.load(f)-    return c.get("clientId"), c.get("clientSecret")-- def _client_creds(cfg):-    """client_id (public) + client_secret. In BROKER mode the secret is None — it lives-    only on the gateway; the manual/local flow still uses a real secret."""-    cid = cfg.get("client_id") or _bundled_client()[0]-    csec = cfg.get("client_secret") or _bundled_client()[1]+    """client_id (public) + client_secret, from the per-user config or the resolved provider.+    In BROKER mode the secret is None — it lives only on the gateway; the manual/local flow+    still uses a real secret (via `init` or a self-host provider)."""+    pr = _provider()+    cid = cfg.get("client_id") or pr["client_id"]+    csec = cfg.get("client_secret") or pr.get("client_secret")     if not cid:-        sys.exit("No OAuth client_id available (need bundled credentials.json or `init`).")+        sys.exit(NO_PROVIDER_HINT)     return cid, csec  @@ -159,8 +206,11 @@ def _access_token(cfg):         return cfg["access_token"]     if cfg.get("broker"):         # Confidential-client broker: the gateway holds the secret and does the refresh.-        r = requests.post(GATEWAY_URL.rstrip("/") + "/refresh",-                          json={"app": cfg.get("app", APP), "refresh_token": cfg["refresh_token"]},+        pr = _provider()+        if not pr["gateway_url"]:+            sys.exit(NO_PROVIDER_HINT)+        r = requests.post(pr["gateway_url"] + "/refresh",+                          json={"app": cfg.get("app", pr["app"]), "refresh_token": cfg["refresh_token"]},                           timeout=30)         if r.status_code != 200:             sys.exit(f"Gateway token refresh failed ({r.status_code}). Re-run `adom-google auth`.")@@ -580,7 +630,7 @@ def _store_broker_tokens(cfg, tokens, scopes, mode):     cfg["scopes"] = scopes     cfg["mode"] = mode     cfg["broker"] = True-    cfg["app"] = APP+    cfg["app"] = _provider()["app"]     cfg.pop("client_secret", None)   # broker mode: never keep a secret locally     cfg.pop("_migrated_from", None)     cfg.pop("_pending_mode", None)@@ -593,33 +643,36 @@ def cmd_auth(a):     scopes = _scopes_for(mode)     if a.manual:         return _auth_manual(cfg, scopes, mode)-    # Gateway flow (the Adom way) — one static redirect, code routed back over WebSocket.+    # Gateway flow — one static redirect, code routed back over WebSocket. Needs a provider.+    pr = _provider()+    if not pr["gateway_url"]:+        sys.exit(NO_PROVIDER_HINT)     cid, _ = _client_creds(cfg)     state = secrets.token_urlsafe(24)     params = {-        "client_id": cid, "redirect_uri": GATEWAY_REDIRECT, "response_type": "code",+        "client_id": cid, "redirect_uri": pr["gateway_redirect"], "response_type": "code",         "scope": " ".join(scopes), "access_type": "offline", "prompt": "consent",         "state": state,     }     auth_link = AUTH_URL + "?" + urllib.parse.urlencode(params)-    print(f"Adom OAuth Gateway flow — {mode.upper()} mode"+    print(f"OAuth Gateway flow — {mode.upper()} mode"           + ("  (full Drive/Sheets/Docs/Slides + delete)" if mode == "full"              else "  (safe: only files this app makes; no 'delete all')") + ".\n")-    print("1) Open this URL and approve (use your @adom.inc Workspace account):\n")+    print("1) Open this URL and approve with your Google Workspace account:\n")     print("   " + auth_link + "\n")     print("2) Waiting for the gateway… (up to 10 min)")     try:         # Broker mode: the gateway exchanges the code and returns tokens (the client         # secret stays on the gateway). Falls back to relayed code if it can't broker.         msg = _minimal_ws_roundtrip(-            GATEWAY_WS,+            pr["gateway_ws"],             send_msgs=[{"type": "register", "state": state, "provider": "google",-                        "app": APP, "exchange": True}],+                        "app": pr["app"], "exchange": True}],             want_types=("tokens", "callback"),         )     except Exception as e:         sys.exit(f"\nGateway flow failed: {e}\n"-                 f"Is the OAuth Gateway up? ({GATEWAY_URL})\n"+                 f"Is the OAuth Gateway up? ({pr['gateway_url']})\n"                  f"Fallback: run `adom-google auth --manual`.")     if msg.get("state") != state:         sys.exit("State mismatch from gateway — aborting.")@@ -628,7 +681,7 @@ def cmd_auth(a):         print(f"\n✅ Authorized via gateway broker ({mode} mode). Token stored (0600); "               "client secret never touched this container.")     else:-        _exchange_code(cfg, msg["code"], GATEWAY_REDIRECT, scopes, mode)+        _exchange_code(cfg, msg["code"], pr["gateway_redirect"], scopes, mode)         print(f"\n✅ Authorized via gateway ({mode} mode). Refresh token stored (0600).")     print("   Scopes: " + ", ".join(s.split("/")[-1] for s in scopes)) @@ -636,7 +689,7 @@ def cmd_auth(a): def _auth_manual(cfg, scopes, mode):     cid, _ = _client_creds(cfg)     if not cfg.get("client_id"):-        print("Manual flow uses the bundled Adom OAuth client, but its redirect must allow")+        print("Manual flow uses the configured OAuth client, but its redirect must allow")         print(f"`{MANUAL_REDIRECT}`. If you hit redirect_uri_mismatch, run:")         print("   adom-google init <your_desktop_client_id> <your_desktop_client_secret>\n")     cfg["_pending_mode"] = mode       # so auth-code knows which scope profile was requested@@ -669,6 +722,46 @@ def cmd_init(a):     print(f"Saved client credentials to {CFG}. Next: `adom-google auth --manual`")  +def cmd_provider_set(a):+    """Write the OAuth provider (gateway + client) to ~/.config/adom-google/provider.json.+    This is what the private `adom-google-adom` package drops for Adom employees; self-hosters+    point it at their own gateway + client."""+    prov = {}+    if os.path.exists(PROVIDER_PATH):+        try:+            with open(PROVIDER_PATH) as f:+                prov = json.load(f)+        except Exception:+            prov = {}+    if a.gateway:       prov["gateway_url"] = a.gateway.rstrip("/")+    if a.ws:            prov["gateway_ws"] = a.ws+    if a.client_id:     prov["client_id"] = a.client_id+    if a.client_secret: prov["client_secret"] = a.client_secret+    if a.app:           prov["app"] = a.app+    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)+    print(f"Saved provider → {PROVIDER_PATH} (0600).")+    print("Next: ask the user safe vs full, then `adom-google auth`.")+++def cmd_provider_show(a):+    pr = _provider()+    if os.path.exists(PROVIDER_PATH):       src = PROVIDER_PATH+    elif os.path.exists(BUNDLED_PROVIDER):  src = BUNDLED_PROVIDER + " (bundled)"+    elif os.path.exists(LEGACY_CREDS):      src = LEGACY_CREDS + " (legacy)"+    elif os.environ.get("OAUTH_GATEWAY_URL"): src = "environment"+    else:                                   src = "(none — run `provider set`)"+    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)'}")+    print(f"client_id:     {(pr['client_id'][:22] + '…') if pr['client_id'] else '(UNSET)'}")+    print(f"client_secret: {'present' if pr.get('client_secret') else '(none — broker mode)'}")+    print(f"source:        {src}")++ def cmd_setup(a):     # Canonical first-run entry point. Always prints the ask-the-user hint (even mid-config),     # so the installing AI is reminded to ask the safe/full question before authorizing.@@ -680,9 +773,12 @@ def cmd_status(a):     if not cfg or not cfg.get("refresh_token"):         print(SETUP_HINT)         return-    cid = cfg.get("client_id") or _bundled_client()[0] or "(none)"+    pr = _provider()+    cid = cfg.get("client_id") or pr["client_id"] or "(none)"+    src = "config" if cfg.get("client_id") else ("provider" if pr["client_id"] else "UNSET")     print(f"Config:      {CFG}")-    print(f"OAuth client: {cid[:18]}… ({'manual/explicit' if cfg.get('client_id') else 'bundled Adom client'})")+    print(f"Provider:    gateway={pr['gateway_url'] or '(UNSET — run `provider set`)'}  app={pr['app']}")+    print(f"OAuth client: {(cid[:18] + '…') if cid != '(none)' else cid} (from {src})")     print(f"Refresh token: {'present' if cfg.get('refresh_token') else 'MISSING — run auth'}")     print(f"Mode:         {cfg.get('mode', '(unset)')}  (safe = no 'delete all'; full = broad)")     print(f"Auth:         {'broker (secret on gateway, none here)' if cfg.get('broker') else 'local (client secret in config)'}")@@ -737,6 +833,12 @@ def main():     s = sub.add_parser("auth"); s.add_argument("--manual", action="store_true"); s.add_argument("--full", action="store_true", help="request the FULL (broad, incl. delete-all) scope set; default is SAFE"); s.set_defaults(fn=cmd_auth)     s = sub.add_parser("auth-code"); s.add_argument("code"); s.set_defaults(fn=cmd_auth_code)     s = sub.add_parser("init"); s.add_argument("client_id"); s.add_argument("client_secret"); s.set_defaults(fn=cmd_init)+    pv = sub.add_parser("provider"); pvs = pv.add_subparsers(dest="sub")+    x = pvs.add_parser("set")+    x.add_argument("--gateway"); x.add_argument("--ws")+    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)     s = sub.add_parser("status"); s.set_defaults(fn=cmd_status)     s = sub.add_parser("setup"); s.set_defaults(fn=cmd_setup) 
package.json+2−2
@@ -1,8 +1,8 @@ {   "slug": "adom-google",-  "version": "0.2.1",+  "version": "0.3.0",   "type": "app",-  "description": "Drive your whole Google Workspace from the terminal \u2014 Gmail, Contacts, Drive, Sheets, Docs, Slides, Calendar, Tasks, Google Chat, plus a generic `api` passthrough to hit ANY Google API. One-click OAuth via the Adom token-broker gateway (no client secret on your machine). On first run it asks each user to choose safe mode (no delete) or full mode (incl. delete).",+  "description": "Drive your whole Google Workspace from the terminal \u2014 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 \u2014 Adom staff \u2014 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).",   "install": {     "binary_name": "adom-google"   },
provider.example.jsonadded+6
@@ -0,0 +1,6 @@+{+  "_comment": "adom-google ships with NO provider. To self-host, copy this to ~/.config/adom-google/provider.json (or run `adom-google provider set`). It points the CLI at YOUR OAuth gateway + Google client. No client secret goes here — in broker mode the secret lives only on the gateway.",+  "gateway_url": "https://your-oauth-gateway.example.com",+  "client_id": "YOUR_GOOGLE_OAUTH_CLIENT_ID.apps.googleusercontent.com",+  "app": "adom-google"+}