370 lines
18 KiB
Markdown
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
|
|
```
|