# dokku-google-auth Put Google OAuth SSO in front of any dokku app with one command. Configure Google once, then: ```bash dokku google-auth:enable my-app ``` Every request to `my-app` now requires a signed-in Google account from your organization. The app itself never touches OAuth — it just reads the signed-in user from request headers. Paths that should stay open (webhooks, API-key-protected endpoints, health checks) can be excluded per app. ## How it works ``` ┌──────────────────────── dokku host ────────────────────────┐ │ │ browser ── https ──▶ │ nginx (per-app vhost, unchanged dokku routing) │ │ │ │ │ ├─ auth_request GET /_google-auth/verify ──┐ │ │ │ 201? no: 302 to Google sign-in │ │ │ │ ▼ │ │ │ ┌────────────────────────┐ │ │ │ │ google-auth-proxy │ │ │ │ │ (one shared container, │ │ │ │ │ 127.0.0.1:2999) │ │ │ │ └────────────────────────┘ │ │ ▼ │ │ app container ◀── X-Forwarded-Email / X-Forwarded-User │ └────────────────────────────────────────────────────────────┘ ``` - **One shared Go service** (`google-auth-proxy`) runs in a single Docker container on the host, bound to `127.0.0.1`. It holds the Google client credentials and handles the entire OAuth dance. - **Per-app nginx config** is injected through dokku's standard `~dokku//nginx.conf.d/` include. It uses nginx's `auth_request` module: every request triggers a fast subrequest to the auth service, which checks an encrypted session cookie. No valid session → the browser is redirected through Google sign-in and back. - **Only one redirect URI is ever registered with Google** (the "auth host"). When a user signs in to `app-a.example.com`, Google redirects to `https:///_google-auth/callback`; the service verifies the identity, then bounces the browser to `app-a` with a single-use, 60-second, encrypted hand-off token, which becomes a session cookie scoped to `app-a`'s own domain. Adding app #47 therefore requires **zero** changes in the Google Console — apps don't even need to share a parent domain. - Sessions, OAuth state, and hand-off tokens are all AES-256-GCM encrypted and authenticated with a secret generated at configure time. Session cookies are `Secure`, `HttpOnly`, `SameSite=Lax`, and pinned to the host they were minted on. - **Per-app access lists** work because every generated location tells the service which app the request belongs to, via an `X-Google-Auth-App` header that nginx sets itself — overwriting anything a client sent. The plugin's `apps/` directory is bind-mounted into the container read-only, so the service reads an app's lists on demand instead of needing a restart. The destination app also rides through the OAuth `state` parameter, because Google's callback lands on the auth host, which may belong to a different app than the one being signed in to. ### Headers your apps receive On every authenticated request, nginx injects (and strips anything the client tried to spoof): | Header | Value | |---|---| | `X-Forwarded-Email` / `X-Auth-Request-Email` | signed-in Google email (lowercased) | | `X-Forwarded-User` / `X-Auth-Request-User` | stable Google account id (`sub` claim) | | `X-Auth-Request-Name` | display name | On **excluded** paths these headers are set to the empty string, so your app can trust that a non-empty `X-Forwarded-Email` always came from the plugin. ## How dokku plugins work (background) A dokku plugin is just a git repo of executable shell scripts. Dokku clones it into `/var/lib/dokku/plugins/available/` and: - **`plugin.toml`** — metadata (description, version). - **`install`** — runs as root at `plugin:install` / `plugin:update` time. This plugin uses it to create its data directory and `docker build` the Go auth service image (multi-stage build, so the host only needs Docker, not Go). - **`commands`** + **`subcommands/`** — dokku dispatches `dokku google-auth:enable foo` to `subcommands/enable`, passing the full command line. `commands` provides `dokku google-auth:help`. This is why the plugin must be installed under the name `google-auth`. - **Triggers** — executables named after lifecycle hooks that dokku (via [plugn](https://github.com/dokku/plugn)) calls with arguments. This plugin implements: - `nginx-pre-reload` / `core-post-deploy` — regenerate the app's `nginx.conf.d/google-auth.conf` on every deploy, so it always references the app's current upstream (whose name changes if you remap ports). - `post-delete`, `post-app-rename`, `post-app-clone` — keep plugin state in sync with app lifecycle. - **State** lives under `/var/lib/dokku/data/google-auth/`: `global/` for the shared config and `apps//` for each app's exclusions and access lists. The data root is `0700` and secrets are `0600`. `apps/` is `0711` with `0644` list files, because it is bind-mounted into the service container, which runs as an unprivileged uid and has to read those lists — the `0700` root still keeps other host users out, and `global/` is never mounted. The nginx integration relies on a stable, documented dokku feature: the generated vhost for every app contains `include /home/dokku//nginx.conf.d/*.conf;` inside the `server` block, which is exactly where this plugin drops its `location` blocks. ## Installation On the dokku host: ```bash sudo dokku plugin:install https://github.com//dokku-google-auth.git --name google-auth ``` (The `--name google-auth` matters — subcommand dispatch is keyed off the plugin directory name.) Requirements: dokku with the default **nginx** proxy (not traefik/caddy), Docker (always present on a dokku host), and nginx built with `http_auth_request_module` (true for stock Debian/Ubuntu nginx). ### 1. Create the Google OAuth client (one time, ever) 1. Go to [Google Cloud Console → APIs & Services → Credentials](https://console.cloud.google.com/apis/credentials). 2. Configure the OAuth consent screen if you haven't: **Internal** user type (recommended for a Workspace org — it automatically limits sign-in to your organization and skips app verification). 3. **Create Credentials → OAuth client ID → Web application.** 4. Add exactly one **Authorized redirect URI**: ``` https:///_google-auth/callback ``` `` is any HTTPS domain that routes to an app you will enable the plugin on. Pick your most permanent app's domain, or add a dedicated domain (e.g. `auth.example.com`) to one of your apps with `dokku domains:add auth.example.com` (plus DNS + letsencrypt as usual). All other apps piggyback on it. 5. Note the client ID and client secret. ### 2. Configure the plugin `configure` is re-runnable: every flag it takes is persisted and can be changed later by passing it again. ```bash dokku google-auth:configure \ --client-id 1234567890-abc.apps.googleusercontent.com \ --client-secret GOCSPX-xxxxxxxxxxxx \ --auth-host auth.example.com \ --allow-domain signal.org ``` This starts the shared auth service container and prints the callback URL to double-check against the Google Console. Other flags (all optional, all persisted): | Flag | Meaning | Default | |---|---|---| | `--allow-domain ` | allow any verified `*@d` account (repeatable; replaces the stored list) | — | | `--allow-email ` | allow a specific address, e.g. an outside collaborator (repeatable) | — | | `--deny-email ` | block a specific address even if the allow lists cover it (repeatable; replaces the stored list) | — | | `--clear-deny-emails` | empty the deny list | — | | `--session-ttl ` | how long a sign-in lasts (`24h`, `72h`, `30m`, …) | `24h` | | `--cookie-name ` | session cookie name | `_google_auth` | | `--port

` | host port (127.0.0.1 only) for the auth service | `2999` | | `--regenerate-cookie-secret` | rotate the session encryption key (signs everyone out) | — | These flags set the **global** lists and **replace** the list they name, which suits initial setup. For one-at-a-time changes afterwards, see the next section. ### 3. Decide who is allowed in Access lists exist in two scopes — one global default and an optional override per app. Every command takes the scope as its first argument: an app name, or `--global`. ```bash dokku google-auth:allow --global signal.org # everyone at signal.org, by default dokku google-auth:allow my-app ceo@signal.org # …but my-app is just this person dokku google-auth:deny --global former@signal.org # nobody, anywhere dokku google-auth:deny my-app bob@signal.org # bob, only on my-app ``` The rules, in the order the auth service applies them: 1. **A deny match rejects the account.** The global and per-app deny lists are *combined*, so a global denial cannot be lifted by an app. 2. **Otherwise the allow list decides**, and it is strict: an account matching nothing is rejected. There is no "allow everyone" mode, and at least one global allow entry is required. 3. **An app with its own allow entries uses only those**, ignoring the global list entirely. An app with none inherits the global list. That third rule is the useful one and the surprising one. It lets a single app be narrowed to a few people, or opened to an outside collaborator who is not in the global list at all: | | global: `signal.org` | effect | |---|---|---| | `app-a` | no entries | anyone `@signal.org` | | `app-b` | `ceo@signal.org` | **only** `ceo@signal.org` | | `app-c` | `guest@partner.com` | **only** `guest@partner.com` — not `@signal.org` | It also means the global list is a default, not a ceiling: an app can admit someone it does not cover. `google-auth:allow` warns the first time an app gains an entry, since that is the moment it stops inheriting. All eight forms: ```bash dokku google-auth:allow --global # show the global allow list dokku google-auth:allow my-app # show my-app's (or that it inherits) dokku google-auth:allow --global signal.org # a domain — any verified account there dokku google-auth:allow my-app guest@partner.com # one address dokku google-auth:unallow my-app guest@partner.com # remove either kind dokku google-auth:deny --global # show the global deny list dokku google-auth:deny my-app # show my-app's, plus the global ones dokku google-auth:undeny --global former@signal.org ``` Changes take effect on the affected user's **next request**: session cookies are re-checked against the current lists rather than trusted until they expire, so denying (or unallowing) someone with a live session ends it — and a session minted for one app is not accepted by an app whose list excludes them. To sign out everyone at once instead, use `configure --regenerate-cookie-secret`. Two guardrails: `unallow --global` refuses to remove the last global entry, since an empty global allow list locks everyone out of every app that inherits it (emptying an *app's* list is fine — it goes back to inheriting). `dokku google-auth:report` shows the global lists and each app's, and states whether an app inherits or overrides. Global lists reach the auth service in its environment, so changing one restarts the shared container. Per-app lists are read from disk on demand, so changing one takes effect within a couple of seconds with no restart and no interruption to other apps. ### 4. Protect apps ```bash dokku google-auth:enable my-app dokku google-auth:enable other-app ... ``` That's it. Visit the app in a browser — you'll be bounced through Google and back. ### 5. Exclude paths (optional, per app) ```bash # Path prefix — everything under it is open: dokku google-auth:exclude my-app /api/webhooks # Regex (prefix with re:): dokku google-auth:exclude my-app 're:^/(healthz|metrics)$' # List / remove: dokku google-auth:exclude my-app dokku google-auth:unexclude my-app /api/webhooks ``` Excluded paths proxy straight to your app with identity headers blanked — protect them yourself (API key, HMAC signature, etc.). ### Day-to-day commands ```bash dokku google-auth:report # global + per-app status, incl. both access lists dokku google-auth:report my-app # one app dokku google-auth:allow --global alice@signal.org # let someone in everywhere dokku google-auth:allow my-app alice@signal.org # …or just on one app dokku google-auth:deny --global former@signal.org # cut someone off dokku google-auth:disable my-app # turn SSO off for an app dokku google-auth:logs -t # follow auth service logs (sign-ins, denials) dokku google-auth:restart # restart the auth service ``` Users can check who they're signed in as at `https:///_google-auth/` and sign out at `https:///_google-auth/logout`. ## Things worth knowing - **HTTPS is required in practice.** Google refuses plain-HTTP redirect URIs and the session cookie is marked `Secure`. Use dokku-letsencrypt as usual. (`--insecure-allow-http` exists for local experiments only.) - **The auth host must stay enabled.** Google's callback lands on ``, so the app serving that domain must keep google-auth enabled. `google-auth:disable` warns you if you disable that app. - **Sessions are per-domain.** Signing in to `app-a` then visiting `app-b` triggers another round-trip through Google, but it's silent (already signed in) — the user just sees a quick redirect. - **In-flight POSTs across an expired session** get redirected to sign-in and are replayed as GETs — the POST body is lost. That's inherent to redirect-based SSO; keep session TTLs comfortable (e.g. `72h`) if it bites. - **Custom nginx location blocks** in your own `nginx.conf.d` files: plain `location /foo` prefix blocks will be shadowed by this plugin's catch-all regex location — use `location ^~ /foo` in your own snippets if you need them to win. - **Non-browser clients** (curl, fetch without `Accept: text/html`) get a JSON `401` instead of a redirect chain. - **Websockets** work — upgrade headers are forwarded exactly like dokku's stock nginx template. - **Plugin updates:** `sudo dokku plugin:update google-auth` rebuilds the service image and recreates the container with freshly written settings. Editing the plugin directory in place works too: the image is tagged with a fingerprint of its source, so `google-auth:restart` (and anything else that starts the service) rebuilds when the Go code has moved rather than reusing the binary that happens to be tagged. This matters because the shell half of the plugin takes effect the moment the files change while the binary does not, and a container running an older build accepts the mount, accepts `GOOGLE_AUTH_APP_CONFIG_DIR`, and ignores both. `dokku google-auth:report` names the running service's state — it asks the service itself rather than trusting Docker's metadata — and if it ever says it is ignoring per-app lists, `dokku google-auth:restart` recreates it. - **An app's own lists need its nginx config to be current**, since that is what tells the service which app a request belongs to. The plugin rewrites the config whenever you change an app's lists (and on every deploy), so this is normally invisible — but an app that has not been deployed or touched since an upgrade falls back to the global lists until then. ## Uninstall ```bash dokku google-auth:disable dokku google-auth:stop sudo dokku plugin:uninstall google-auth sudo rm -rf /var/lib/dokku/data/google-auth docker image rm dokku-google-auth:latest ``` ## Development Tasks are defined in `mise.toml` ([mise](https://mise.jdx.dev) pins Go and shellcheck): ```bash mise install # install pinned toolchain mise run test # Go unit tests (full OAuth flow against a fake Google) mise run test-nginx-conf # generate nginx config + validate with real nginx in docker mise run test-access-lists # allow/deny commands against a fake dokku layout mise run test-help # help output shapes + every subcommand documented mise run shellcheck # lint all plugin scripts mise run check # everything CI would run mise run docker-build # build the service image locally ``` Layout: ``` cmd/google-auth-proxy/ Go entrypoint internal/authproxy/ OAuth flow, session crypto, HTTP handlers (+ tests) functions shared bash for the plugin (config store, nginx generation) subcommands/ dokku google-auth:* commands install, nginx-pre-reload, core-post-deploy, post-* dokku lifecycle triggers test/ bash tests: nginx config generation, access lists, help output Dockerfile multi-stage build → static binary in a scratch image ```