2026-08-06 22:52:15 -04:00
2026-07-15 07:49:11 -04:00
2026-08-06 22:52:15 -04:00
2026-08-06 22:52:15 -04:00
2026-08-06 22:52:15 -04:00
2026-07-15 07:49:11 -04:00
2026-07-15 07:49:11 -04:00
2026-08-06 13:53:24 -04:00
2026-07-15 07:49:11 -04:00
2026-07-15 07:49:11 -04:00
2026-08-06 22:52:15 -04:00
2026-07-15 07:49:11 -04:00
2026-08-06 15:55:56 -04:00
2026-08-06 13:53:24 -04:00
2026-07-15 07:49:11 -04:00
2026-07-15 07:49:11 -04:00
2026-07-15 07:49:11 -04:00
2026-07-15 07:49:11 -04:00
2026-07-15 07:49:11 -04:00
2026-08-06 22:52:15 -04:00

dokku-google-auth

Put Google OAuth SSO in front of any dokku app with one command. Configure Google once, then:

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) 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:

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.

  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.

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.

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:

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

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)

# 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

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

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 pins Go and shellcheck):

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
S
Description
No description provided
Readme
99 KiB
Languages
Shell 55.4%
Go 44.2%
Dockerfile 0.4%