Files
dokkku-google-auth/README.md
T

313 lines
14 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.
### 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 config +
one directory per enabled app). Secrets are `0600`.
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 **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
`--allow-domain` and `--allow-email` together form the **allowlist**, and at
least one entry is required. Any account matching neither is rejected — there
is no "allow everyone" mode. So to limit access to a specific list of people,
use only addresses and no domain:
```bash
dokku google-auth:allow greyson@signal.org alice@signal.org
```
The **deny list** is checked first and wins over both allow rules, which is
how you cut off one person without narrowing the whole domain:
```bash
dokku google-auth:allow signal.org # everyone at signal.org…
dokku google-auth:deny former@signal.org # …except this account
```
These four commands each change one entry at a time and restart the auth
service for you:
```bash
dokku google-auth:allow # show the allow list
dokku google-auth:allow signal.org # a domain (any verified account there)
dokku google-auth:allow guest@partner.com # one address
dokku google-auth:unallow guest@partner.com # remove either kind
dokku google-auth:deny # show the deny list
dokku google-auth:deny former@signal.org
dokku google-auth:undeny 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. To
sign out everyone at once instead, use
`configure --regenerate-cookie-secret`.
`unallow` refuses to remove the last allow entry, since an empty allowlist
locks everyone out of every enabled app. `dokku google-auth:report` shows
both lists as they currently stand.
### 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 alice@signal.org # let someone in
dokku google-auth:deny 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 restarts the container automatically.
## 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 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/nginx-conf-test.sh bash integration test for the generated nginx config
Dockerfile multi-stage build → static binary in a scratch image
```