Files
2026-08-06 22:52:15 -04:00

370 lines
18 KiB
Markdown

# 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/<app>/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://<auth-host>/_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/<name>` 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/<name>`** — 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/<app>/` 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/<app>/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/<you>/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://<auth-host>/_google-auth/callback
```
`<auth-host>` 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 <app> 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 <d>` | allow any verified `*@d` account (repeatable; replaces the stored list) | — |
| `--allow-email <e>` | allow a specific address, e.g. an outside collaborator (repeatable) | — |
| `--deny-email <e>` | 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 <dur>` | how long a sign-in lasts (`24h`, `72h`, `30m`, …) | `24h` |
| `--cookie-name <n>` | session cookie name | `_google_auth` |
| `--port <p>` | 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://<any-app>/_google-auth/`
and sign out at `https://<any-app>/_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
`<auth-host>`, 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 <each-app>
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
```