From a3f0f8a1bee7ae1a5b06841dcfd662301e7bb525 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Wed, 15 Jul 2026 07:49:11 -0400 Subject: [PATCH] Initial commit. --- .gitignore | 1 + .shellcheckrc | 4 + Dockerfile | 14 + README.md | 259 ++++++++++++++++ cmd/google-auth-proxy/main.go | 79 +++++ commands | 37 +++ core-post-deploy | 12 + functions | 478 ++++++++++++++++++++++++++++++ go.mod | 3 + install | 32 ++ internal/authproxy/claims.go | 37 +++ internal/authproxy/config.go | 138 +++++++++ internal/authproxy/config_test.go | 80 +++++ internal/authproxy/crypto.go | 71 +++++ internal/authproxy/crypto_test.go | 57 ++++ internal/authproxy/flow_test.go | 385 ++++++++++++++++++++++++ internal/authproxy/google.go | 149 ++++++++++ internal/authproxy/nonce.go | 34 +++ internal/authproxy/server.go | 375 +++++++++++++++++++++++ mise.toml | 39 +++ nginx-pre-reload | 12 + plugin.toml | 4 + post-app-clone | 16 + post-app-rename | 16 + post-delete | 10 + subcommands/configure | 113 +++++++ subcommands/default | 6 + subcommands/disable | 26 ++ subcommands/enable | 32 ++ subcommands/exclude | 40 +++ subcommands/logs | 18 ++ subcommands/report | 73 +++++ subcommands/restart | 13 + subcommands/start | 13 + subcommands/stop | 15 + subcommands/unexclude | 27 ++ test/nginx-conf-test.sh | 107 +++++++ 37 files changed, 2825 insertions(+) create mode 100644 .gitignore create mode 100644 .shellcheckrc create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 cmd/google-auth-proxy/main.go create mode 100755 commands create mode 100755 core-post-deploy create mode 100644 functions create mode 100644 go.mod create mode 100755 install create mode 100644 internal/authproxy/claims.go create mode 100644 internal/authproxy/config.go create mode 100644 internal/authproxy/config_test.go create mode 100644 internal/authproxy/crypto.go create mode 100644 internal/authproxy/crypto_test.go create mode 100644 internal/authproxy/flow_test.go create mode 100644 internal/authproxy/google.go create mode 100644 internal/authproxy/nonce.go create mode 100644 internal/authproxy/server.go create mode 100644 mise.toml create mode 100755 nginx-pre-reload create mode 100644 plugin.toml create mode 100755 post-app-clone create mode 100755 post-app-rename create mode 100755 post-delete create mode 100755 subcommands/configure create mode 100755 subcommands/default create mode 100755 subcommands/disable create mode 100755 subcommands/enable create mode 100755 subcommands/exclude create mode 100755 subcommands/logs create mode 100755 subcommands/report create mode 100755 subcommands/restart create mode 100755 subcommands/start create mode 100755 subcommands/stop create mode 100755 subcommands/unexclude create mode 100755 test/nginx-conf-test.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e660fd9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +bin/ diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000..7f1b247 --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,4 @@ +# SC2034: `declare desc=...` is the dokku plugin convention for documenting +# subcommands; dokku reads it externally. +# SC2016: tests intentionally grep for literal nginx `$variables`. +disable=SC2034,SC2016 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..83682ec --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.24-alpine AS build +WORKDIR /src +COPY go.mod ./ +COPY cmd ./cmd +COPY internal ./internal +RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/google-auth-proxy ./cmd/google-auth-proxy + +FROM scratch +COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=build /out/google-auth-proxy /google-auth-proxy +USER 65532:65532 +EXPOSE 2999 +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s CMD ["/google-auth-proxy", "-healthcheck"] +ENTRYPOINT ["/google-auth-proxy"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..4782055 --- /dev/null +++ b/README.md @@ -0,0 +1,259 @@ +# 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. + +### 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 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//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 (one time) + +```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) | — | +| `--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) | — | + +### 3. 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. + +### 4. 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 +dokku google-auth:report my-app # one app +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 restarts the container automatically. + +## 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 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 +``` diff --git a/cmd/google-auth-proxy/main.go b/cmd/google-auth-proxy/main.go new file mode 100644 index 0000000..cb62f8f --- /dev/null +++ b/cmd/google-auth-proxy/main.go @@ -0,0 +1,79 @@ +// Command google-auth-proxy is a small Google OAuth SSO service designed to +// sit behind nginx's auth_request module. The dokku google-auth plugin runs +// one instance of it per host and points every protected app's nginx config +// at it. +package main + +import ( + "context" + "errors" + "flag" + "log" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "dokku-google-auth/internal/authproxy" +) + +func main() { + healthcheck := flag.Bool("healthcheck", false, "probe the locally running server and exit 0 if healthy") + flag.Parse() + + if *healthcheck { + os.Exit(runHealthcheck()) + } + + cfg, err := authproxy.ConfigFromEnv() + if err != nil { + log.Fatalf("configuration error: %v", err) + } + + server, err := authproxy.New(cfg) + if err != nil { + log.Fatalf("startup error: %v", err) + } + + httpServer := &http.Server{ + Addr: cfg.ListenAddr, + Handler: server.Routes(), + ReadHeaderTimeout: 10 * time.Second, + } + + shutdown := make(chan os.Signal, 1) + signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-shutdown + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = httpServer.Shutdown(ctx) + }() + + log.Printf("google-auth-proxy listening on %s (auth host: %s)", cfg.ListenAddr, cfg.AuthHost) + if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("server error: %v", err) + } +} + +func runHealthcheck() int { + addr := os.Getenv("GOOGLE_AUTH_LISTEN") + if addr == "" { + addr = ":2999" + } + if strings.HasPrefix(addr, ":") { + addr = "127.0.0.1" + addr + } + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get("http://" + addr + "/_google-auth/healthz") + if err != nil { + return 1 + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return 1 + } + return 0 +} diff --git a/commands b/commands new file mode 100755 index 0000000..bbe0269 --- /dev/null +++ b/commands @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x + +case "$1" in + help | google-auth:help) + help_content() { + cat <, Require Google sign-in for all requests to + google-auth:disable , Remove Google sign-in from + google-auth:exclude , Exempt path prefixes (/path) or regexes (re:^/x) from sign-in + google-auth:unexclude , Remove previously excluded patterns + google-auth:report [app], Show global and per-app google-auth status + google-auth:start, Start the shared auth service container + google-auth:stop, Stop the shared auth service container + google-auth:restart, Restart the shared auth service container (picks up config changes) + google-auth:logs [--tail|-t], Show logs from the shared auth service container +help_content + } + + if [[ "$1" == "google-auth:help" ]]; then + echo -e 'Usage: dokku google-auth[:COMMAND]' + echo '' + echo 'Put Google OAuth SSO in front of dokku apps.' + echo '' + echo 'Commands:' + help_content | sort | column -c2 -t -s, + else + help_content + fi + ;; + + *) + exit "${DOKKU_NOT_IMPLEMENTED_EXIT:-10}" + ;; +esac diff --git a/core-post-deploy b/core-post-deploy new file mode 100755 index 0000000..d8347e8 --- /dev/null +++ b/core-post-deploy @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Trigger: runs after every successful deploy, before nginx-vhosts rebuilds +# the proxy config (plugins run in name order; google-auth < nginx-vhosts). +# Belt-and-suspenders companion to nginx-pre-reload. +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x +source "$(dirname "${BASH_SOURCE[0]}")/functions" + +APP="${1:-}" +[[ -z "$APP" ]] && exit 0 +fn-google-auth-app-enabled "$APP" || exit 0 +fn-ga-write-conf "$APP" >/dev/null || true diff --git a/functions b/functions new file mode 100644 index 0000000..9feda4d --- /dev/null +++ b/functions @@ -0,0 +1,478 @@ +#!/usr/bin/env bash +# Shared functions for the google-auth dokku plugin. +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x + +GOOGLE_AUTH_PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +export DOKKU_ROOT=${DOKKU_ROOT:-/home/dokku} +export DOKKU_LIB_ROOT=${DOKKU_LIB_ROOT:-/var/lib/dokku} +export PLUGIN_CORE_AVAILABLE_PATH=${PLUGIN_CORE_AVAILABLE_PATH:-$DOKKU_LIB_ROOT/core-plugins/available} + +if [[ -f "$PLUGIN_CORE_AVAILABLE_PATH/common/functions" ]]; then + # shellcheck disable=SC1091 + source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions" +fi + +GOOGLE_AUTH_DATA_ROOT="$DOKKU_LIB_ROOT/data/google-auth" +GOOGLE_AUTH_SERVICE_NAME="dokku-google-auth" +GOOGLE_AUTH_IMAGE="dokku-google-auth:latest" +GOOGLE_AUTH_DEFAULT_PORT="2999" +GOOGLE_AUTH_ROUTE_PREFIX="/_google-auth" + +# Fallbacks so the plugin can be exercised outside a dokku host (tests, dev). +if ! declare -f dokku_log_info1 >/dev/null 2>&1; then + dokku_log_info1() { echo "-----> $*"; } + dokku_log_info2() { echo "=====> $*"; } + dokku_log_verbose() { echo " $*"; } + dokku_log_warn() { echo " ! $*" 1>&2; } + dokku_log_fail() { + echo " ! $*" 1>&2 + exit 1 + } + verify_app_name() { + [[ -n "$1" && -d "$DOKKU_ROOT/$1" ]] || dokku_log_fail "App $1 does not exist" + } +fi + +# --- key/value storage (one file per key under the plugin data dir) --- + +fn-ga-global-get() { + declare KEY="$1" DEFAULT="${2:-}" + local file="$GOOGLE_AUTH_DATA_ROOT/global/$KEY" + if [[ -s "$file" ]]; then + head -n1 "$file" + else + printf '%s' "$DEFAULT" + fi +} + +fn-ga-global-set() { + declare KEY="$1" VALUE="$2" + mkdir -p "$GOOGLE_AUTH_DATA_ROOT/global" + printf '%s\n' "$VALUE" >"$GOOGLE_AUTH_DATA_ROOT/global/$KEY" + chmod 600 "$GOOGLE_AUTH_DATA_ROOT/global/$KEY" +} + +# Multi-value keys store one entry per line. +fn-ga-global-get-list() { + declare KEY="$1" + cat "$GOOGLE_AUTH_DATA_ROOT/global/$KEY" 2>/dev/null || true +} + +fn-ga-global-set-list() { + declare KEY="$1" + shift + mkdir -p "$GOOGLE_AUTH_DATA_ROOT/global" + local file="$GOOGLE_AUTH_DATA_ROOT/global/$KEY" + : >"$file" + local entry + for entry in "$@"; do + printf '%s\n' "$entry" >>"$file" + done + chmod 600 "$file" +} + +fn-ga-app-dir() { + declare APP="$1" + echo "$GOOGLE_AUTH_DATA_ROOT/apps/$APP" +} + +fn-google-auth-app-enabled() { + declare APP="$1" + [[ -f "$(fn-ga-app-dir "$APP")/enabled" ]] +} + +fn-ga-app-set-enabled() { + declare APP="$1" ENABLED="$2" + local dir + dir="$(fn-ga-app-dir "$APP")" + if [[ "$ENABLED" == "true" ]]; then + mkdir -p "$dir" + touch "$dir/enabled" + else + rm -f "$dir/enabled" + fi +} + +fn-ga-excludes() { + declare APP="$1" + cat "$(fn-ga-app-dir "$APP")/excludes" 2>/dev/null || true +} + +fn-ga-enabled-apps() { + local dir + for dir in "$GOOGLE_AUTH_DATA_ROOT/apps"/*/; do + [[ -d "$dir" ]] || continue + local app + app="$(basename "$dir")" + fn-google-auth-app-enabled "$app" && echo "$app" + done + return 0 +} + +fn-ga-configured() { + [[ -n "$(fn-ga-global-get client-id)" ]] || return 1 + [[ -n "$(fn-ga-global-get client-secret)" ]] || return 1 + [[ -n "$(fn-ga-global-get auth-host)" ]] || return 1 + [[ -n "$(fn-ga-global-get cookie-secret)" ]] || return 1 + [[ -s "$GOOGLE_AUTH_DATA_ROOT/global/allowed-domains" || -s "$GOOGLE_AUTH_DATA_ROOT/global/allowed-emails" ]] || return 1 +} + +# --- exclusion patterns --- + +# Patterns are either a path prefix ("/api/webhooks") or a regex ("re:^/v[0-9]+/public/"). +fn-ga-validate-pattern() { + declare PATTERN="$1" + [[ -n "$PATTERN" ]] || return 1 + # Guard against nginx config injection. + if printf '%s' "$PATTERN" | grep -qE '[;{}"'"'"'[:space:]]'; then + return 1 + fi + if [[ "$PATTERN" == re:* ]]; then + [[ -n "${PATTERN#re:}" ]] || return 1 + else + [[ "$PATTERN" == /* ]] || return 1 + fi +} + +fn-ga-exclude-add() { + declare APP="$1" PATTERN="$2" + local dir file + dir="$(fn-ga-app-dir "$APP")" + file="$dir/excludes" + mkdir -p "$dir" + touch "$file" + grep -qxF "$PATTERN" "$file" || printf '%s\n' "$PATTERN" >>"$file" +} + +fn-ga-exclude-remove() { + declare APP="$1" PATTERN="$2" + local file tmp + file="$(fn-ga-app-dir "$APP")/excludes" + [[ -f "$file" ]] || return 0 + tmp="$(mktemp)" + grep -vxF "$PATTERN" "$file" >"$tmp" || true + cat "$tmp" >"$file" + rm -f "$tmp" +} + +# --- nginx config generation --- + +# The app's upstream block is created by dokku's own nginx template; we reuse +# it by name so excluded and protected locations proxy to the same place. +fn-ga-upstream-name() { + declare APP="$1" + local nginx_conf="$DOKKU_ROOT/$APP/nginx.conf" + [[ -f "$nginx_conf" ]] || return 1 + local name + name="$(awk '$1 == "upstream" {print $2; exit}' "$nginx_conf")" + [[ -n "$name" ]] || return 1 + printf '%s' "$name" +} + +fn-ga-proxy-read-timeout() { + declare APP="$1" + local t="" file + for file in "$DOKKU_LIB_ROOT/config/nginx/$APP/proxy-read-timeout" \ + "$DOKKU_LIB_ROOT/config/nginx/--global/proxy-read-timeout"; do + if [[ -s "$file" ]]; then + t="$(head -n1 "$file")" + break + fi + done + printf '%s' "${t:-60s}" +} + +# Mirrors the proxy directives from dokku's default nginx template so +# requests routed through our locations behave like stock dokku routing. +fn-ga-proxy-directives() { + declare UPSTREAM="$1" TIMEOUT="$2" + cat <"$tmp" 2>/dev/null; then + rm -f "$tmp" + echo skipped + return 0 + fi + mkdir -p "$dir" + if [[ -f "$conf" ]] && cmp -s "$tmp" "$conf"; then + rm -f "$tmp" + echo unchanged + return 0 + fi + cat "$tmp" >"$conf" + rm -f "$tmp" + echo changed +} + +# --- nginx validate/reload (via dokku core helpers when available) --- + +fn-ga-source-nginx-functions() { + if [[ -f "$PLUGIN_CORE_AVAILABLE_PATH/nginx-vhosts/functions" ]]; then + # shellcheck disable=SC1091 + source "$PLUGIN_CORE_AVAILABLE_PATH/nginx-vhosts/functions" 2>/dev/null || true + fi +} + +fn-ga-nginx-validate() { + fn-ga-source-nginx-functions + if declare -f validate_nginx >/dev/null 2>&1; then + (validate_nginx) >/dev/null 2>&1 + return $? + fi + return 0 +} + +fn-ga-nginx-reload() { + fn-ga-source-nginx-functions + if declare -f restart_nginx >/dev/null 2>&1; then + restart_nginx >/dev/null 2>&1 || dokku_log_warn "nginx reload reported an error; check 'nginx -t'" + else + dokku_log_warn "could not reload nginx automatically; run: sudo systemctl reload nginx" + fi +} + +# Write conf for one app, validate nginx, roll back on failure, reload. +fn-ga-apply() { + declare APP="$1" + local conf backup="" had_file=false status + conf="$(fn-ga-conf-path "$APP")" + if [[ -f "$conf" ]]; then + backup="$(mktemp)" + cat "$conf" >"$backup" + had_file=true + fi + + status="$(fn-ga-write-conf "$APP")" + case "$status" in + skipped) + dokku_log_warn "$APP has no generated nginx config yet (not deployed?). google-auth config will be added on the next deploy." + ;; + changed) + if ! fn-ga-nginx-validate; then + if [[ "$had_file" == "true" ]]; then + cat "$backup" >"$conf" + else + rm -f "$conf" + fi + [[ -n "$backup" ]] && rm -f "$backup" + dokku_log_fail "nginx rejected the generated config for $APP; change reverted (check exclude patterns)" + fi + fn-ga-nginx-reload + ;; + esac + [[ -n "$backup" ]] && rm -f "$backup" + return 0 +} + +# Regenerate confs for every enabled app (e.g. after the service port changes). +fn-ga-apply-all() { + local app any_changed=false status + while IFS= read -r app; do + [[ -z "$app" ]] && continue + status="$(fn-ga-write-conf "$app")" + [[ "$status" == "changed" ]] && any_changed=true + done < <(fn-ga-enabled-apps) + if [[ "$any_changed" == "true" ]]; then + if fn-ga-nginx-validate; then + fn-ga-nginx-reload + else + dokku_log_warn "nginx validation failed after regenerating google-auth configs; run 'nginx -t' to inspect" + fi + fi + return 0 +} + +# --- auth service container management --- + +fn-ga-image-exists() { + docker image inspect "$GOOGLE_AUTH_IMAGE" >/dev/null 2>&1 +} + +fn-ga-build-image() { + command -v docker >/dev/null 2>&1 || dokku_log_fail "docker is required to build the google-auth service image" + dokku_log_info1 "Building $GOOGLE_AUTH_IMAGE (first build downloads the Go toolchain image; this can take a few minutes)" + docker image build -t "$GOOGLE_AUTH_IMAGE" "$GOOGLE_AUTH_PLUGIN_DIR" +} + +fn-ga-service-running() { + [[ "$(docker container inspect -f '{{.State.Running}}' "$GOOGLE_AUTH_SERVICE_NAME" 2>/dev/null)" == "true" ]] +} + +fn-ga-write-env-file() { + local envfile="$GOOGLE_AUTH_DATA_ROOT/service.env" + local domains emails + domains="$(fn-ga-global-get-list allowed-domains | paste -sd, -)" + emails="$(fn-ga-global-get-list allowed-emails | paste -sd, -)" + mkdir -p "$GOOGLE_AUTH_DATA_ROOT" + umask 077 + cat >"$envfile" </dev/null 2>&1 || true + docker container run -d \ + --name "$GOOGLE_AUTH_SERVICE_NAME" \ + --restart=unless-stopped \ + -p "127.0.0.1:${port}:2999" \ + --env-file "$GOOGLE_AUTH_DATA_ROOT/service.env" \ + "$GOOGLE_AUTH_IMAGE" >/dev/null + dokku_log_info1 "google-auth service running on 127.0.0.1:${port}" +} + +fn-ga-service-stop() { + docker container rm -f "$GOOGLE_AUTH_SERVICE_NAME" >/dev/null 2>&1 || true +} + +# Warn when the configured auth host is not served by any enabled app, +# because Google's callback would then land on nothing. +fn-ga-warn-if-auth-host-unrouted() { + local auth_host app vhost_file + auth_host="$(fn-ga-global-get auth-host)" + [[ -n "$auth_host" ]] || return 0 + while IFS= read -r app; do + [[ -z "$app" ]] && continue + vhost_file="$DOKKU_ROOT/$app/VHOST" + [[ -f "$vhost_file" ]] && grep -qxF "$auth_host" "$vhost_file" && return 0 + done < <(fn-ga-enabled-apps) + dokku_log_warn "auth host '$auth_host' is not a domain of any google-auth-enabled app." + dokku_log_warn "Google's OAuth callback (https://$auth_host$GOOGLE_AUTH_ROUTE_PREFIX/callback) must route to an enabled app." + return 0 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0fd286a --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module dokku-google-auth + +go 1.24 diff --git a/install b/install new file mode 100755 index 0000000..4fbd5ca --- /dev/null +++ b/install @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Runs (as root) on `dokku plugin:install` and `dokku plugin:update`. +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x + +PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DOKKU_LIB_ROOT=${DOKKU_LIB_ROOT:-/var/lib/dokku} +DATA_ROOT="$DOKKU_LIB_ROOT/data/google-auth" + +mkdir -p "$DATA_ROOT/global" "$DATA_ROOT/apps" +chown -R dokku:dokku "$DATA_ROOT" 2>/dev/null || true +chmod 700 "$DATA_ROOT" + +if command -v docker >/dev/null 2>&1; then + echo "-----> Building dokku-google-auth service image (first build can take a few minutes)" + docker image build -t dokku-google-auth:latest "$PLUGIN_DIR" + # Pick up the new image if the service is already running. + if [[ "$(docker container inspect -f '{{.State.Running}}' dokku-google-auth 2>/dev/null)" == "true" ]]; then + echo "-----> Restarting google-auth service with the new image" + port="$(head -n1 "$DATA_ROOT/global/port" 2>/dev/null || true)" + port="${port:-2999}" + docker container rm -f dokku-google-auth >/dev/null 2>&1 || true + docker container run -d \ + --name dokku-google-auth \ + --restart=unless-stopped \ + -p "127.0.0.1:${port}:2999" \ + --env-file "$DATA_ROOT/service.env" \ + dokku-google-auth:latest >/dev/null + fi +else + echo " ! docker not found; the google-auth service image was not built" 1>&2 +fi diff --git a/internal/authproxy/claims.go b/internal/authproxy/claims.go new file mode 100644 index 0000000..015e1b4 --- /dev/null +++ b/internal/authproxy/claims.go @@ -0,0 +1,37 @@ +package authproxy + +// stateClaims rides through Google's OAuth flow as the `state` parameter. +// It remembers which app host the user was visiting and where to send them +// after sign-in. +type stateClaims struct { + Host string `json:"h"` // app host the user was visiting + RD string `json:"r"` // relative path to return to + Proto string `json:"p"` // http or https + Nonce string `json:"n"` + Exp int64 `json:"e"` // unix seconds +} + +// handoffClaims is the short-lived token the callback (on the auth host) +// hands to the destination app host so it can mint a session cookie on its +// own domain. +type handoffClaims struct { + Email string `json:"em"` + User string `json:"u"` // Google account id (sub) + Name string `json:"na"` + Host string `json:"h"` + RD string `json:"r"` + Proto string `json:"p"` + Nonce string `json:"n"` + Exp int64 `json:"e"` +} + +// sessionClaims is the content of the session cookie. Cookies are host-only +// (no Domain attribute) and additionally pinned to the host they were minted +// for. +type sessionClaims struct { + Email string `json:"em"` + User string `json:"u"` + Name string `json:"na"` + Host string `json:"h"` + Exp int64 `json:"e"` +} diff --git a/internal/authproxy/config.go b/internal/authproxy/config.go new file mode 100644 index 0000000..b5b0aa6 --- /dev/null +++ b/internal/authproxy/config.go @@ -0,0 +1,138 @@ +package authproxy + +import ( + "fmt" + "os" + "strings" + "time" +) + +const ( + googleAuthorizeURL = "https://accounts.google.com/o/oauth2/v2/auth" + googleTokenURL = "https://oauth2.googleapis.com/token" + + // RoutePrefix is the URL namespace the proxy owns on every protected + // host. It must match the locations the plugin writes into nginx. + RoutePrefix = "/_google-auth" +) + +// Config holds everything the auth service needs. It is normally populated +// from GOOGLE_AUTH_* environment variables written by the dokku plugin. +type Config struct { + // ClientID / ClientSecret identify the Google OAuth client. + ClientID string + ClientSecret string + + // CookieSecret is the key material used to encrypt and authenticate + // sessions, state, and hand-off tokens. Must be at least 32 characters. + CookieSecret string + + // AuthHost is the one hostname whose /_google-auth/callback is + // registered with Google as the redirect URI. It must be a domain that + // routes to this service through nginx (i.e. a domain of any app that + // has google-auth enabled). + AuthHost string + + // AllowedDomains / AllowedEmails control who may sign in. An email is + // accepted if its domain is in AllowedDomains or the full address is in + // AllowedEmails. + AllowedDomains []string + AllowedEmails []string + + CookieName string + SessionTTL time.Duration + ListenAddr string + + // AllowInsecure permits plain-HTTP flows (no Secure cookie flag, http + // redirect URI). Only for local testing; Google requires HTTPS redirect + // URIs in production anyway. + AllowInsecure bool + + // AuthorizeURL / TokenURL are overridable for tests. + AuthorizeURL string + TokenURL string +} + +// ConfigFromEnv builds a Config from GOOGLE_AUTH_* environment variables and +// validates it. +func ConfigFromEnv() (Config, error) { + cfg := Config{ + ClientID: os.Getenv("GOOGLE_AUTH_CLIENT_ID"), + ClientSecret: os.Getenv("GOOGLE_AUTH_CLIENT_SECRET"), + CookieSecret: os.Getenv("GOOGLE_AUTH_COOKIE_SECRET"), + AuthHost: normalizeHost(os.Getenv("GOOGLE_AUTH_AUTH_HOST")), + CookieName: envOr("GOOGLE_AUTH_COOKIE_NAME", "_google_auth"), + ListenAddr: envOr("GOOGLE_AUTH_LISTEN", ":2999"), + AuthorizeURL: envOr("GOOGLE_AUTH_AUTHORIZE_URL", googleAuthorizeURL), + TokenURL: envOr("GOOGLE_AUTH_TOKEN_URL", googleTokenURL), + AllowInsecure: os.Getenv("GOOGLE_AUTH_ALLOW_INSECURE") == "true", + } + + ttlRaw := envOr("GOOGLE_AUTH_SESSION_TTL", "24h") + ttl, err := time.ParseDuration(ttlRaw) + if err != nil || ttl <= 0 { + return cfg, fmt.Errorf("GOOGLE_AUTH_SESSION_TTL %q is not a valid positive duration", ttlRaw) + } + cfg.SessionTTL = ttl + + for _, d := range splitList(os.Getenv("GOOGLE_AUTH_ALLOWED_DOMAINS")) { + cfg.AllowedDomains = append(cfg.AllowedDomains, strings.TrimPrefix(d, "@")) + } + cfg.AllowedEmails = splitList(os.Getenv("GOOGLE_AUTH_ALLOWED_EMAILS")) + + return cfg, cfg.validate() +} + +func (c Config) validate() error { + if c.ClientID == "" { + return fmt.Errorf("GOOGLE_AUTH_CLIENT_ID is required") + } + if c.ClientSecret == "" { + return fmt.Errorf("GOOGLE_AUTH_CLIENT_SECRET is required") + } + if len(c.CookieSecret) < 32 { + return fmt.Errorf("GOOGLE_AUTH_COOKIE_SECRET must be at least 32 characters") + } + if c.AuthHost == "" { + return fmt.Errorf("GOOGLE_AUTH_AUTH_HOST is required") + } + if strings.ContainsAny(c.AuthHost, "/:") { + return fmt.Errorf("GOOGLE_AUTH_AUTH_HOST must be a bare hostname, got %q", c.AuthHost) + } + if len(c.AllowedDomains) == 0 && len(c.AllowedEmails) == 0 { + return fmt.Errorf("at least one of GOOGLE_AUTH_ALLOWED_DOMAINS or GOOGLE_AUTH_ALLOWED_EMAILS must be set") + } + if c.CookieName == "" { + return fmt.Errorf("GOOGLE_AUTH_COOKIE_NAME must not be empty") + } + return nil +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +// splitList splits a comma- or whitespace-separated list, lowercasing and +// trimming each entry. +func splitList(raw string) []string { + var out []string + for _, part := range strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ' ' || r == '\t' || r == '\n' + }) { + part = strings.ToLower(strings.TrimSpace(part)) + if part != "" { + out = append(out, part) + } + } + return out +} + +func normalizeHost(raw string) string { + h := strings.ToLower(strings.TrimSpace(raw)) + h = strings.TrimPrefix(h, "https://") + h = strings.TrimPrefix(h, "http://") + return strings.TrimSuffix(h, "/") +} diff --git a/internal/authproxy/config_test.go b/internal/authproxy/config_test.go new file mode 100644 index 0000000..5a0d326 --- /dev/null +++ b/internal/authproxy/config_test.go @@ -0,0 +1,80 @@ +package authproxy + +import ( + "strings" + "testing" + "time" +) + +func validConfig() Config { + return Config{ + ClientID: "cid", + ClientSecret: "sec", + CookieSecret: strings.Repeat("x", 32), + AuthHost: "auth.example.com", + AllowedDomains: []string{"signal.org"}, + CookieName: "_google_auth", + SessionTTL: time.Hour, + } +} + +func TestConfigValidate(t *testing.T) { + if err := validConfig().validate(); err != nil { + t.Fatalf("valid config rejected: %v", err) + } + + mutations := map[string]func(*Config){ + "missing client id": func(c *Config) { c.ClientID = "" }, + "missing client secret": func(c *Config) { c.ClientSecret = "" }, + "short cookie secret": func(c *Config) { c.CookieSecret = "short" }, + "missing auth host": func(c *Config) { c.AuthHost = "" }, + "auth host with scheme": func(c *Config) { c.AuthHost = "https://auth.example.com" }, + "no allow rules": func(c *Config) { c.AllowedDomains = nil; c.AllowedEmails = nil }, + } + for name, mutate := range mutations { + cfg := validConfig() + mutate(&cfg) + if err := cfg.validate(); err == nil { + t.Errorf("%s: expected validation error", name) + } + } +} + +func TestConfigFromEnv(t *testing.T) { + t.Setenv("GOOGLE_AUTH_CLIENT_ID", "cid") + t.Setenv("GOOGLE_AUTH_CLIENT_SECRET", "sec") + t.Setenv("GOOGLE_AUTH_COOKIE_SECRET", strings.Repeat("x", 32)) + t.Setenv("GOOGLE_AUTH_AUTH_HOST", "HTTPS://Auth.Example.com/") + t.Setenv("GOOGLE_AUTH_ALLOWED_DOMAINS", "Signal.org, @example.com") + t.Setenv("GOOGLE_AUTH_ALLOWED_EMAILS", "Guest@Partner.com") + t.Setenv("GOOGLE_AUTH_SESSION_TTL", "48h") + + cfg, err := ConfigFromEnv() + if err != nil { + t.Fatal(err) + } + if cfg.AuthHost != "auth.example.com" { + t.Errorf("AuthHost = %q", cfg.AuthHost) + } + if len(cfg.AllowedDomains) != 2 || cfg.AllowedDomains[0] != "signal.org" || cfg.AllowedDomains[1] != "example.com" { + t.Errorf("AllowedDomains = %v", cfg.AllowedDomains) + } + if len(cfg.AllowedEmails) != 1 || cfg.AllowedEmails[0] != "guest@partner.com" { + t.Errorf("AllowedEmails = %v", cfg.AllowedEmails) + } + if cfg.SessionTTL != 48*time.Hour { + t.Errorf("SessionTTL = %v", cfg.SessionTTL) + } +} + +func TestConfigFromEnvRejectsBadTTL(t *testing.T) { + t.Setenv("GOOGLE_AUTH_CLIENT_ID", "cid") + t.Setenv("GOOGLE_AUTH_CLIENT_SECRET", "sec") + t.Setenv("GOOGLE_AUTH_COOKIE_SECRET", strings.Repeat("x", 32)) + t.Setenv("GOOGLE_AUTH_AUTH_HOST", "auth.example.com") + t.Setenv("GOOGLE_AUTH_ALLOWED_DOMAINS", "signal.org") + t.Setenv("GOOGLE_AUTH_SESSION_TTL", "2 fortnights") + if _, err := ConfigFromEnv(); err == nil { + t.Fatal("expected error for bad TTL") + } +} diff --git a/internal/authproxy/crypto.go b/internal/authproxy/crypto.go new file mode 100644 index 0000000..6f925ae --- /dev/null +++ b/internal/authproxy/crypto.go @@ -0,0 +1,71 @@ +package authproxy + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" +) + +// box seals and opens small JSON payloads with AES-256-GCM. The purpose +// string is bound in as additional authenticated data so a token minted for +// one use (e.g. OAuth state) can never be replayed as another (e.g. a +// session cookie). +type box struct { + aead cipher.AEAD +} + +func newBox(secret string) (*box, error) { + key := sha256.Sum256([]byte(secret)) + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + return &box{aead: aead}, nil +} + +func (b *box) seal(purpose string, v any) (string, error) { + plain, err := json.Marshal(v) + if err != nil { + return "", err + } + nonce := make([]byte, b.aead.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + out := b.aead.Seal(nonce, nonce, plain, []byte(purpose)) + return base64.RawURLEncoding.EncodeToString(out), nil +} + +func (b *box) open(purpose, token string, v any) error { + raw, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return fmt.Errorf("malformed token: %w", err) + } + ns := b.aead.NonceSize() + if len(raw) <= ns { + return fmt.Errorf("malformed token: too short") + } + plain, err := b.aead.Open(nil, raw[:ns], raw[ns:], []byte(purpose)) + if err != nil { + return fmt.Errorf("token failed authentication: %w", err) + } + return json.Unmarshal(plain, v) +} + +func randToken() string { + buf := make([]byte, 16) + if _, err := io.ReadFull(rand.Reader, buf); err != nil { + panic(err) // crypto/rand failure is unrecoverable + } + return hex.EncodeToString(buf) +} diff --git a/internal/authproxy/crypto_test.go b/internal/authproxy/crypto_test.go new file mode 100644 index 0000000..3fc8756 --- /dev/null +++ b/internal/authproxy/crypto_test.go @@ -0,0 +1,57 @@ +package authproxy + +import ( + "encoding/base64" + "strings" + "testing" +) + +func TestBoxRoundtrip(t *testing.T) { + b, err := newBox(strings.Repeat("s", 32)) + if err != nil { + t.Fatal(err) + } + in := stateClaims{Host: "app.example.com", RD: "/x?a=1&b=2", Proto: "https", Nonce: "n", Exp: 123} + tok, err := b.seal("state", in) + if err != nil { + t.Fatal(err) + } + var out stateClaims + if err := b.open("state", tok, &out); err != nil { + t.Fatal(err) + } + if out != in { + t.Fatalf("roundtrip mismatch: %+v != %+v", out, in) + } +} + +func TestBoxRejectsWrongPurpose(t *testing.T) { + b, _ := newBox(strings.Repeat("s", 32)) + tok, _ := b.seal("state", stateClaims{Host: "a"}) + var out stateClaims + if err := b.open("session", tok, &out); err == nil { + t.Fatal("expected purpose mismatch to fail") + } +} + +func TestBoxRejectsTampering(t *testing.T) { + b, _ := newBox(strings.Repeat("s", 32)) + tok, _ := b.seal("state", stateClaims{Host: "a"}) + raw, _ := base64.RawURLEncoding.DecodeString(tok) + raw[len(raw)-1] ^= 0x01 + tampered := base64.RawURLEncoding.EncodeToString(raw) + var out stateClaims + if err := b.open("state", tampered, &out); err == nil { + t.Fatal("expected tampered token to fail") + } +} + +func TestBoxRejectsWrongKey(t *testing.T) { + b1, _ := newBox(strings.Repeat("a", 32)) + b2, _ := newBox(strings.Repeat("b", 32)) + tok, _ := b1.seal("state", stateClaims{Host: "a"}) + var out stateClaims + if err := b2.open("state", tok, &out); err == nil { + t.Fatal("expected wrong key to fail") + } +} diff --git a/internal/authproxy/flow_test.go b/internal/authproxy/flow_test.go new file mode 100644 index 0000000..728092e --- /dev/null +++ b/internal/authproxy/flow_test.go @@ -0,0 +1,385 @@ +package authproxy + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +const ( + testClientID = "test-client-id.apps.googleusercontent.com" + appHost = "myapp.example.com" + authHost = "auth.example.com" +) + +// fakeGoogle stands in for Google's token endpoint. The returned id_token +// carries the given claims; signature contents don't matter because the +// token arrives over a direct TLS channel in production. +func fakeGoogle(t *testing.T, claims map[string]any) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Errorf("parse form: %v", err) + } + if r.FormValue("grant_type") != "authorization_code" { + t.Errorf("unexpected grant_type %q", r.FormValue("grant_type")) + } + if r.FormValue("code") != "good-code" { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"error":"invalid_grant"}`) + return + } + payload, _ := json.Marshal(claims) + idt := b64(`{"alg":"RS256","typ":"JWT"}`) + "." + + base64.RawURLEncoding.EncodeToString(payload) + "." + b64("sig") + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"access_token":"at","id_token":%q}`, idt) + })) +} + +func b64(s string) string { + return base64.RawURLEncoding.EncodeToString([]byte(s)) +} + +func goodClaims() map[string]any { + return map[string]any{ + "iss": "https://accounts.google.com", + "aud": testClientID, + "sub": "1234567890", + "exp": time.Now().Add(time.Hour).Unix(), + "email": "Greyson@Signal.org", + "email_verified": true, + "hd": "signal.org", + "name": "Greyson", + } +} + +func newTestServer(t *testing.T, tokenURL string) *Server { + t.Helper() + cfg := Config{ + ClientID: testClientID, + ClientSecret: "test-secret", + CookieSecret: strings.Repeat("k", 32), + AuthHost: authHost, + AllowedDomains: []string{"signal.org"}, + CookieName: "_google_auth", + SessionTTL: time.Hour, + ListenAddr: ":0", + AuthorizeURL: "https://accounts.google.com/o/oauth2/v2/auth", + TokenURL: tokenURL, + } + if err := cfg.validate(); err != nil { + t.Fatalf("test config invalid: %v", err) + } + s, err := New(cfg) + if err != nil { + t.Fatal(err) + } + return s +} + +func do(h http.Handler, r *http.Request) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +// TestFullFlow walks the whole journey: unauthenticated verify, start, +// callback on the auth host, finish on the app host, authenticated verify. +func TestFullFlow(t *testing.T) { + google := fakeGoogle(t, goodClaims()) + defer google.Close() + s := newTestServer(t, google.URL) + h := s.Routes() + + // 1. verify without a cookie → 401 + r := httptest.NewRequest("GET", "http://"+appHost+RoutePrefix+"/verify", nil) + if w := do(h, r); w.Code != http.StatusUnauthorized { + t.Fatalf("verify without cookie: got %d, want 401", w.Code) + } + + // 2. start (as nginx would proxy it) → 302 to Google + origURI := "/secret/page?a=1&b=2" + r = httptest.NewRequest("GET", "http://"+appHost+RoutePrefix+"/start", nil) + r.Header.Set("Accept", "text/html,application/xhtml+xml") + r.Header.Set("X-Auth-Request-Redirect", origURI) + r.Header.Set("X-Forwarded-Proto", "https") + w := do(h, r) + if w.Code != http.StatusFound { + t.Fatalf("start: got %d, want 302", w.Code) + } + loc, err := url.Parse(w.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + if loc.Host != "accounts.google.com" { + t.Fatalf("start redirected to %s, want accounts.google.com", loc.Host) + } + if got := loc.Query().Get("client_id"); got != testClientID { + t.Fatalf("client_id = %q", got) + } + if got := loc.Query().Get("redirect_uri"); got != "https://"+authHost+RoutePrefix+"/callback" { + t.Fatalf("redirect_uri = %q", got) + } + if got := loc.Query().Get("hd"); got != "signal.org" { + t.Fatalf("hd = %q", got) + } + state := loc.Query().Get("state") + if state == "" { + t.Fatal("no state in Google redirect") + } + + // 3. callback on the auth host → 302 to finish on the app host + r = httptest.NewRequest("GET", + "http://"+authHost+RoutePrefix+"/callback?code=good-code&state="+url.QueryEscape(state), nil) + w = do(h, r) + if w.Code != http.StatusFound { + t.Fatalf("callback: got %d, want 302; body: %s", w.Code, w.Body.String()) + } + finishURL, err := url.Parse(w.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + if finishURL.Scheme != "https" || finishURL.Host != appHost || finishURL.Path != RoutePrefix+"/finish" { + t.Fatalf("callback redirected to %s", finishURL) + } + + // 4. finish on the app host → session cookie + redirect to original URI + r = httptest.NewRequest("GET", finishURL.String(), nil) + w = do(h, r) + if w.Code != http.StatusFound { + t.Fatalf("finish: got %d, want 302; body: %s", w.Code, w.Body.String()) + } + if got := w.Header().Get("Location"); got != origURI { + t.Fatalf("finish redirected to %q, want %q", got, origURI) + } + cookies := w.Result().Cookies() + if len(cookies) != 1 || cookies[0].Name != "_google_auth" { + t.Fatalf("expected one session cookie, got %v", cookies) + } + sessionCookie := cookies[0] + if !sessionCookie.Secure || !sessionCookie.HttpOnly { + t.Fatalf("session cookie should be Secure+HttpOnly: %+v", sessionCookie) + } + + // 5. verify with the cookie → 200 with identity headers + r = httptest.NewRequest("GET", "http://"+appHost+RoutePrefix+"/verify", nil) + r.AddCookie(sessionCookie) + w = do(h, r) + if w.Code != http.StatusOK { + t.Fatalf("verify with cookie: got %d, want 200", w.Code) + } + if got := w.Header().Get("X-Auth-Request-Email"); got != "greyson@signal.org" { + t.Fatalf("X-Auth-Request-Email = %q", got) + } + if got := w.Header().Get("X-Auth-Request-User"); got != "1234567890" { + t.Fatalf("X-Auth-Request-User = %q", got) + } + + // 6. the same cookie must NOT work on a different host + r = httptest.NewRequest("GET", "http://other.example.com"+RoutePrefix+"/verify", nil) + r.AddCookie(sessionCookie) + if w := do(h, r); w.Code != http.StatusUnauthorized { + t.Fatalf("verify on wrong host: got %d, want 401", w.Code) + } + + // 7. replaying the finish token must fail (single-use nonce) + r = httptest.NewRequest("GET", finishURL.String(), nil) + if w := do(h, r); w.Code != http.StatusForbidden { + t.Fatalf("finish replay: got %d, want 403", w.Code) + } + + // 8. logout clears the cookie + r = httptest.NewRequest("GET", "http://"+appHost+RoutePrefix+"/logout", nil) + r.AddCookie(sessionCookie) + w = do(h, r) + found := false + for _, c := range w.Result().Cookies() { + if c.Name == "_google_auth" && c.MaxAge < 0 { + found = true + } + } + if !found { + t.Fatal("logout did not clear the session cookie") + } +} + +func TestCallbackRejectsDisallowedDomain(t *testing.T) { + claims := goodClaims() + claims["email"] = "intruder@evil.com" + claims["hd"] = "evil.com" + google := fakeGoogle(t, claims) + defer google.Close() + s := newTestServer(t, google.URL) + h := s.Routes() + + state := mustState(t, s, appHost, "/") + r := httptest.NewRequest("GET", + "http://"+authHost+RoutePrefix+"/callback?code=good-code&state="+url.QueryEscape(state), nil) + w := do(h, r) + if w.Code != http.StatusForbidden { + t.Fatalf("got %d, want 403", w.Code) + } + if !strings.Contains(w.Body.String(), "not allowed") { + t.Fatalf("body should explain denial: %s", w.Body.String()) + } +} + +func TestCallbackRejectsWrongAudience(t *testing.T) { + claims := goodClaims() + claims["aud"] = "someone-else" + google := fakeGoogle(t, claims) + defer google.Close() + s := newTestServer(t, google.URL) + + state := mustState(t, s, appHost, "/") + r := httptest.NewRequest("GET", + "http://"+authHost+RoutePrefix+"/callback?code=good-code&state="+url.QueryEscape(state), nil) + if w := do(s.Routes(), r); w.Code != http.StatusForbidden { + t.Fatalf("got %d, want 403", w.Code) + } +} + +func TestCallbackRejectsUnverifiedEmail(t *testing.T) { + claims := goodClaims() + claims["email_verified"] = false + google := fakeGoogle(t, claims) + defer google.Close() + s := newTestServer(t, google.URL) + + state := mustState(t, s, appHost, "/") + r := httptest.NewRequest("GET", + "http://"+authHost+RoutePrefix+"/callback?code=good-code&state="+url.QueryEscape(state), nil) + if w := do(s.Routes(), r); w.Code != http.StatusForbidden { + t.Fatalf("got %d, want 403", w.Code) + } +} + +func TestCallbackOnlyServedOnAuthHost(t *testing.T) { + s := newTestServer(t, "http://unused.invalid") + r := httptest.NewRequest("GET", "http://"+appHost+RoutePrefix+"/callback?code=x&state=y", nil) + if w := do(s.Routes(), r); w.Code != http.StatusNotFound { + t.Fatalf("got %d, want 404", w.Code) + } +} + +func TestCallbackRejectsExpiredState(t *testing.T) { + s := newTestServer(t, "http://unused.invalid") + st := stateClaims{Host: appHost, RD: "/", Proto: "https", Nonce: "n", + Exp: time.Now().Add(-time.Minute).Unix()} + tok, err := s.box.seal("state", st) + if err != nil { + t.Fatal(err) + } + r := httptest.NewRequest("GET", + "http://"+authHost+RoutePrefix+"/callback?code=good-code&state="+url.QueryEscape(tok), nil) + if w := do(s.Routes(), r); w.Code != http.StatusForbidden { + t.Fatalf("got %d, want 403", w.Code) + } +} + +func TestFinishRejectsWrongHost(t *testing.T) { + s := newTestServer(t, "http://unused.invalid") + hand := handoffClaims{Email: "a@signal.org", User: "1", Host: appHost, RD: "/", + Proto: "https", Nonce: randToken(), Exp: time.Now().Add(time.Minute).Unix()} + tok, _ := s.box.seal("handoff", hand) + r := httptest.NewRequest("GET", + "http://other.example.com"+RoutePrefix+"/finish?token="+url.QueryEscape(tok), nil) + if w := do(s.Routes(), r); w.Code != http.StatusForbidden { + t.Fatalf("got %d, want 403", w.Code) + } +} + +func TestStartReturnsJSON401ForNonBrowsers(t *testing.T) { + s := newTestServer(t, "http://unused.invalid") + r := httptest.NewRequest("GET", "http://"+appHost+RoutePrefix+"/start", nil) + r.Header.Set("Accept", "application/json") + w := do(s.Routes(), r) + if w.Code != http.StatusUnauthorized { + t.Fatalf("got %d, want 401", w.Code) + } + if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") { + t.Fatalf("content type = %q", ct) + } +} + +func TestExpiredSessionRejected(t *testing.T) { + s := newTestServer(t, "http://unused.invalid") + sess := sessionClaims{Email: "a@signal.org", User: "1", Host: appHost, + Exp: time.Now().Add(-time.Minute).Unix()} + val, _ := s.box.seal("session", sess) + r := httptest.NewRequest("GET", "http://"+appHost+RoutePrefix+"/verify", nil) + r.AddCookie(&http.Cookie{Name: "_google_auth", Value: val}) + if w := do(s.Routes(), r); w.Code != http.StatusUnauthorized { + t.Fatalf("got %d, want 401", w.Code) + } +} + +func TestSanitizeRedirect(t *testing.T) { + cases := map[string]string{ + "": "/", + "/ok": "/ok", + "/ok?a=1&b=2": "/ok?a=1&b=2", + "//evil.com/x": "/", + "https://evil.com": "/", + "/x\r\nSet-Cookie: p": "/", + "\\evil": "/", + RoutePrefix + "/start": "/", // avoid redirect loops into our own routes + "relative/no/lead/slash": "/", + "/deep/path/./is/fine": "/deep/path/./is/fine", + } + for in, want := range cases { + if got := sanitizeRedirect(in); got != want { + t.Errorf("sanitizeRedirect(%q) = %q, want %q", in, got, want) + } + } +} + +func TestEmailAllowed(t *testing.T) { + s := newTestServer(t, "http://unused.invalid") + s.cfg.AllowedEmails = []string{"guest@partner.com"} + cases := map[string]bool{ + "greyson@signal.org": true, + "GREYSON@SIGNAL.ORG": true, + "guest@partner.com": true, + "other@partner.com": false, + "evil@notsignal.org": false, + "greyson@signal.org.evil.c": false, + "signal.org": false, + } + for email, want := range cases { + if got := s.emailAllowed(email); got != want { + t.Errorf("emailAllowed(%q) = %v, want %v", email, got, want) + } + } +} + +func TestFlexClaims(t *testing.T) { + var tok idToken + payload := `{"aud":["a","b"],"email_verified":"true","iss":"accounts.google.com","exp":99}` + if err := json.Unmarshal([]byte(payload), &tok); err != nil { + t.Fatal(err) + } + if !tok.Aud.contains("b") || !bool(tok.EmailVerified) { + t.Fatalf("flex claims parsed wrong: %+v", tok) + } +} + +// mustState mints a valid state token the way handleStart would. +func mustState(t *testing.T, s *Server, host, rd string) string { + t.Helper() + tok, err := s.box.seal("state", stateClaims{ + Host: host, RD: rd, Proto: "https", Nonce: randToken(), + Exp: time.Now().Add(10 * time.Minute).Unix(), + }) + if err != nil { + t.Fatal(err) + } + return tok +} diff --git a/internal/authproxy/google.go b/internal/authproxy/google.go new file mode 100644 index 0000000..36904af --- /dev/null +++ b/internal/authproxy/google.go @@ -0,0 +1,149 @@ +package authproxy + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + "time" +) + +// idToken holds the claims we care about from Google's OpenID Connect +// id_token. The token arrives directly from Google's token endpoint over +// TLS, so per the OIDC spec its signature does not need separate +// verification; we still validate issuer, audience, and expiry. +type idToken struct { + Iss string `json:"iss"` + Sub string `json:"sub"` + Aud flexAud `json:"aud"` + Exp int64 `json:"exp"` + Email string `json:"email"` + EmailVerified flexBool `json:"email_verified"` + Hd string `json:"hd"` + Name string `json:"name"` +} + +func (s *Server) exchangeCode(ctx context.Context, code string) (*idToken, error) { + if code == "" { + return nil, fmt.Errorf("missing code parameter") + } + form := url.Values{ + "code": {code}, + "client_id": {s.cfg.ClientID}, + "client_secret": {s.cfg.ClientSecret}, + "redirect_uri": {s.redirectURI()}, + "grant_type": {"authorization_code"}, + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.TokenURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("token endpoint: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("token endpoint read: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, truncate(string(body), 200)) + } + + var tr struct { + IDToken string `json:"id_token"` + } + if err := json.Unmarshal(body, &tr); err != nil { + return nil, fmt.Errorf("token endpoint response: %w", err) + } + if tr.IDToken == "" { + return nil, fmt.Errorf("token endpoint response missing id_token") + } + return parseIDToken(tr.IDToken) +} + +func parseIDToken(raw string) (*idToken, error) { + parts := strings.Split(raw, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("id_token is not a JWT") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("id_token payload: %w", err) + } + var tok idToken + if err := json.Unmarshal(payload, &tok); err != nil { + return nil, fmt.Errorf("id_token claims: %w", err) + } + return &tok, nil +} + +func (s *Server) validateIDToken(t *idToken) error { + if t.Iss != "https://accounts.google.com" && t.Iss != "accounts.google.com" { + return fmt.Errorf("unexpected issuer %q", t.Iss) + } + if !t.Aud.contains(s.cfg.ClientID) { + return fmt.Errorf("audience mismatch") + } + if time.Now().Unix() > t.Exp { + return fmt.Errorf("token expired") + } + if t.Email == "" { + return fmt.Errorf("no email claim") + } + if !bool(t.EmailVerified) { + return fmt.Errorf("email %s is not verified", t.Email) + } + return nil +} + +// flexAud accepts the JWT aud claim as either a string or an array. +type flexAud []string + +func (a *flexAud) UnmarshalJSON(b []byte) error { + var single string + if err := json.Unmarshal(b, &single); err == nil { + *a = flexAud{single} + return nil + } + var many []string + if err := json.Unmarshal(b, &many); err != nil { + return err + } + *a = flexAud(many) + return nil +} + +func (a flexAud) contains(v string) bool { + return slices.Contains(a, v) +} + +// flexBool accepts true, "true", false, or "false" — Google has historically +// been inconsistent about the email_verified type. +type flexBool bool + +func (b *flexBool) UnmarshalJSON(data []byte) error { + s := strings.Trim(string(data), `"`) + v, err := strconv.ParseBool(s) + if err != nil { + return fmt.Errorf("invalid bool value %s", data) + } + *b = flexBool(v) + return nil +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/internal/authproxy/nonce.go b/internal/authproxy/nonce.go new file mode 100644 index 0000000..a96a590 --- /dev/null +++ b/internal/authproxy/nonce.go @@ -0,0 +1,34 @@ +package authproxy + +import ( + "sync" + "time" +) + +// nonceCache makes hand-off tokens single-use. Entries expire alongside the +// token they guard, so the map stays tiny (hand-off tokens live 60 seconds). +type nonceCache struct { + mu sync.Mutex + seen map[string]int64 +} + +func newNonceCache() *nonceCache { + return &nonceCache{seen: make(map[string]int64)} +} + +// use records the nonce and reports whether this was its first use. +func (c *nonceCache) use(nonce string, exp int64) bool { + c.mu.Lock() + defer c.mu.Unlock() + now := time.Now().Unix() + for k, v := range c.seen { + if v < now { + delete(c.seen, k) + } + } + if _, dup := c.seen[nonce]; dup { + return false + } + c.seen[nonce] = exp + return true +} diff --git a/internal/authproxy/server.go b/internal/authproxy/server.go new file mode 100644 index 0000000..63159c1 --- /dev/null +++ b/internal/authproxy/server.go @@ -0,0 +1,375 @@ +package authproxy + +import ( + "fmt" + "html" + "log" + "net" + "net/http" + "net/url" + "slices" + "strings" + "time" +) + +// Server implements the auth endpoints nginx talks to: +// +// GET /_google-auth/verify auth_request subrequest: 200 if signed in, 401 otherwise +// GET /_google-auth/start begin the OAuth flow (redirects to Google) +// GET /_google-auth/callback Google redirect URI (only served on AuthHost) +// GET /_google-auth/finish mint the session cookie on the destination app host +// GET /_google-auth/logout clear the session cookie +// GET /_google-auth/healthz liveness probe +// GET /_google-auth/ human-readable status page +type Server struct { + cfg Config + box *box + nonces *nonceCache + client *http.Client +} + +func New(cfg Config) (*Server, error) { + b, err := newBox(cfg.CookieSecret) + if err != nil { + return nil, err + } + return &Server{ + cfg: cfg, + box: b, + nonces: newNonceCache(), + client: &http.Client{Timeout: 15 * time.Second}, + }, nil +} + +func (s *Server) Routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc(RoutePrefix+"/verify", s.handleVerify) + mux.HandleFunc(RoutePrefix+"/start", s.handleStart) + mux.HandleFunc(RoutePrefix+"/callback", s.handleCallback) + mux.HandleFunc(RoutePrefix+"/finish", s.handleFinish) + mux.HandleFunc(RoutePrefix+"/logout", s.handleLogout) + mux.HandleFunc(RoutePrefix+"/healthz", s.handleHealthz) + mux.HandleFunc(RoutePrefix+"/", s.handleStatus) + mux.HandleFunc("/", s.handleStatus) + return mux +} + +// handleVerify is the nginx auth_request target. Response headers become +// available to nginx as $upstream_http_* variables. +func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) { + sess, ok := s.sessionFromRequest(r) + if !ok { + w.WriteHeader(http.StatusUnauthorized) + return + } + h := w.Header() + h.Set("X-Auth-Request-Email", headerSafe(sess.Email)) + h.Set("X-Auth-Request-User", headerSafe(sess.User)) + h.Set("X-Auth-Request-Name", headerSafe(sess.Name)) + h.Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) +} + +// handleStart begins the OAuth flow. nginx proxies unauthenticated requests +// here (via the @google_auth_signin named location) with the original URI in +// the X-Auth-Request-Redirect header. +func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) { + rd := r.Header.Get("X-Auth-Request-Redirect") + if rd == "" { + rd = r.URL.Query().Get("rd") + } + rd = sanitizeRedirect(rd) + + // Non-browser clients (API calls, curl) get a clean 401 instead of a + // redirect chain they can't follow meaningfully. + if !strings.Contains(r.Header.Get("Accept"), "text/html") { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, `{"error":"authentication required"}`) + return + } + + host := requestHost(r) + if host == "" { + s.htmlError(w, http.StatusBadRequest, "Missing Host header.") + return + } + + st := stateClaims{ + Host: host, + RD: rd, + Proto: s.proto(r), + Nonce: randToken(), + Exp: time.Now().Add(10 * time.Minute).Unix(), + } + token, err := s.box.seal("state", st) + if err != nil { + s.htmlError(w, http.StatusInternalServerError, "Could not start sign-in.") + return + } + + q := url.Values{} + q.Set("client_id", s.cfg.ClientID) + q.Set("redirect_uri", s.redirectURI()) + q.Set("response_type", "code") + q.Set("scope", "openid email profile") + q.Set("state", token) + if len(s.cfg.AllowedDomains) == 1 { + // UX hint only; real enforcement happens in emailAllowed. + q.Set("hd", s.cfg.AllowedDomains[0]) + } + http.Redirect(w, r, s.cfg.AuthorizeURL+"?"+q.Encode(), http.StatusFound) +} + +// handleCallback is Google's redirect target. It only ever runs on AuthHost, +// exchanges the code, authorizes the email, and bounces the browser back to +// the originating app host with a short-lived hand-off token. +func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) { + if requestHost(r) != s.cfg.AuthHost { + s.htmlError(w, http.StatusNotFound, "This host does not serve the OAuth callback.") + return + } + q := r.URL.Query() + if errCode := q.Get("error"); errCode != "" { + s.htmlError(w, http.StatusForbidden, "Google sign-in failed: "+html.EscapeString(errCode)) + return + } + + var st stateClaims + if err := s.box.open("state", q.Get("state"), &st); err != nil { + s.htmlError(w, http.StatusBadRequest, "Invalid sign-in state. Go back to the app and try again.") + return + } + if expired(st.Exp) { + s.htmlError(w, http.StatusForbidden, "This sign-in attempt expired. Go back to the app and try again.") + return + } + + idTok, err := s.exchangeCode(r.Context(), q.Get("code")) + if err != nil { + log.Printf("callback: code exchange failed: %v", err) + s.htmlError(w, http.StatusBadGateway, "Could not complete sign-in with Google. Try again.") + return + } + if err := s.validateIDToken(idTok); err != nil { + log.Printf("callback: id_token rejected: %v", err) + s.htmlError(w, http.StatusForbidden, "Google returned an invalid identity token.") + return + } + email := strings.ToLower(idTok.Email) + if !s.emailAllowed(email) { + log.Printf("callback: denied %s (not in allowed domains/emails) for host %s", email, st.Host) + s.htmlError(w, http.StatusForbidden, + "You are signed in to Google as "+html.EscapeString(email)+", but that account is not allowed to access this app.") + return + } + + hand := handoffClaims{ + Email: email, + User: idTok.Sub, + Name: idTok.Name, + Host: st.Host, + RD: st.RD, + Proto: st.Proto, + Nonce: randToken(), + Exp: time.Now().Add(60 * time.Second).Unix(), + } + token, err := s.box.seal("handoff", hand) + if err != nil { + s.htmlError(w, http.StatusInternalServerError, "Could not complete sign-in.") + return + } + dest := fmt.Sprintf("%s://%s%s/finish?token=%s", st.Proto, st.Host, RoutePrefix, url.QueryEscape(token)) + http.Redirect(w, r, dest, http.StatusFound) +} + +// handleFinish runs on the destination app host and turns a hand-off token +// into a host-scoped session cookie. +func (s *Server) handleFinish(w http.ResponseWriter, r *http.Request) { + var hand handoffClaims + if err := s.box.open("handoff", r.URL.Query().Get("token"), &hand); err != nil { + s.htmlError(w, http.StatusForbidden, "Invalid sign-in token. Go back to the app and try again.") + return + } + if expired(hand.Exp) { + s.htmlError(w, http.StatusForbidden, "This sign-in token expired. Go back to the app and try again.") + return + } + if hand.Host != requestHost(r) { + s.htmlError(w, http.StatusForbidden, "This sign-in token was issued for a different host.") + return + } + if !s.nonces.use(hand.Nonce, hand.Exp) { + s.htmlError(w, http.StatusForbidden, "This sign-in token was already used.") + return + } + + sess := sessionClaims{ + Email: hand.Email, + User: hand.User, + Name: hand.Name, + Host: hand.Host, + Exp: time.Now().Add(s.cfg.SessionTTL).Unix(), + } + value, err := s.box.seal("session", sess) + if err != nil { + s.htmlError(w, http.StatusInternalServerError, "Could not create session.") + return + } + http.SetCookie(w, &http.Cookie{ + Name: s.cfg.CookieName, + Value: value, + Path: "/", + MaxAge: int(s.cfg.SessionTTL.Seconds()), + HttpOnly: true, + Secure: hand.Proto == "https", + SameSite: http.SameSiteLaxMode, + }) + log.Printf("signed in %s on %s", sess.Email, sess.Host) + http.Redirect(w, r, sanitizeRedirect(hand.RD), http.StatusFound) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: s.cfg.CookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: s.proto(r) == "https", + SameSite: http.SameSiteLaxMode, + }) + if rd := sanitizeRedirect(r.URL.Query().Get("rd")); rd != "/" { + http.Redirect(w, r, rd, http.StatusFound) + return + } + s.htmlPage(w, http.StatusOK, "Signed out", + `You have been signed out of `+html.EscapeString(requestHost(r))+`. +

Sign in again

`) +} + +func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "ok") +} + +// handleStatus is a small human-readable page for debugging. +func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { + if sess, ok := s.sessionFromRequest(r); ok { + s.htmlPage(w, http.StatusOK, "Signed in", + `Signed in to `+html.EscapeString(requestHost(r))+` as `+html.EscapeString(sess.Email)+``+ + ` (`+html.EscapeString(sess.Name)+`).`+ + `

Session expires `+time.Unix(sess.Exp, 0).UTC().Format(time.RFC1123)+`.

`+ + `

Sign out

`) + return + } + s.htmlPage(w, http.StatusOK, "Not signed in", + `Not signed in on `+html.EscapeString(requestHost(r))+`. +

Sign in with Google

`) +} + +// --- helpers --- + +func (s *Server) sessionFromRequest(r *http.Request) (*sessionClaims, bool) { + c, err := r.Cookie(s.cfg.CookieName) + if err != nil || c.Value == "" { + return nil, false + } + var sess sessionClaims + if err := s.box.open("session", c.Value, &sess); err != nil { + return nil, false + } + if expired(sess.Exp) { + return nil, false + } + if sess.Host != requestHost(r) { + return nil, false + } + return &sess, true +} + +func (s *Server) emailAllowed(email string) bool { + email = strings.ToLower(email) + if slices.Contains(s.cfg.AllowedEmails, email) { + return true + } + at := strings.LastIndex(email, "@") + if at < 0 { + return false + } + return slices.Contains(s.cfg.AllowedDomains, email[at+1:]) +} + +func (s *Server) redirectURI() string { + scheme := "https" + if s.cfg.AllowInsecure { + scheme = "http" + } + return scheme + "://" + s.cfg.AuthHost + RoutePrefix + "/callback" +} + +// proto reports the effective client-facing scheme. Unless insecure mode is +// on, everything is treated as https so cookies always carry Secure. +func (s *Server) proto(r *http.Request) string { + if s.cfg.AllowInsecure && r.Header.Get("X-Forwarded-Proto") == "http" { + return "http" + } + return "https" +} + +func requestHost(r *http.Request) string { + host := r.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + return strings.ToLower(host) +} + +// sanitizeRedirect only permits same-host relative paths, preventing open +// redirects. Anything suspicious collapses to "/". +func sanitizeRedirect(rd string) string { + if rd == "" || !strings.HasPrefix(rd, "/") || strings.HasPrefix(rd, "//") { + return "/" + } + if strings.ContainsAny(rd, "\\\r\n") { + return "/" + } + if strings.HasPrefix(rd, RoutePrefix) { + return "/" + } + return rd +} + +func expired(unixSeconds int64) bool { + return time.Now().Unix() > unixSeconds +} + +// headerSafe strips characters that are not safe in an HTTP header value. +func headerSafe(s string) string { + return strings.Map(func(r rune) rune { + if r < 32 || r == 127 { + return -1 + } + return r + }, s) +} + +func (s *Server) htmlError(w http.ResponseWriter, status int, body string) { + s.htmlPage(w, status, http.StatusText(status), body) +} + +func (s *Server) htmlPage(w http.ResponseWriter, status int, title, body string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(status) + fmt.Fprintf(w, ` + +%s + +

%s

%s

`, html.EscapeString(title), html.EscapeString(title), body) +} diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..99eecfd --- /dev/null +++ b/mise.toml @@ -0,0 +1,39 @@ +[tools] +go = "1.24" +shellcheck = "0.10.0" + +[tasks.build] +description = "Build the auth proxy binary" +run = "go build -o bin/google-auth-proxy ./cmd/google-auth-proxy" + +[tasks.test] +description = "Run Go unit tests" +run = "go test ./..." + +[tasks.vet] +description = "Run go vet" +run = "go vet ./..." + +[tasks.fmt] +description = "Format Go code" +run = "gofmt -w ." + +[tasks.fmt-check] +description = "Fail if any Go file is unformatted" +run = 'test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)' + +[tasks.shellcheck] +description = "Lint all plugin shell scripts" +run = "shellcheck -x functions commands install nginx-pre-reload core-post-deploy post-delete post-app-rename post-app-clone subcommands/* test/nginx-conf-test.sh" + +[tasks.test-nginx-conf] +description = "Generate an nginx config from a fake app and validate it with real nginx (docker)" +run = "test/nginx-conf-test.sh" + +[tasks.docker-build] +description = "Build the auth service docker image" +run = "docker build -t dokku-google-auth:latest ." + +[tasks.check] +description = "Run everything CI would run" +depends = ["fmt-check", "vet", "test", "shellcheck", "test-nginx-conf"] diff --git a/nginx-pre-reload b/nginx-pre-reload new file mode 100755 index 0000000..1eff8a4 --- /dev/null +++ b/nginx-pre-reload @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Trigger: runs before dokku validates + reloads nginx for an app. +# Regenerates our per-app conf so it always references the current upstream +# (the upstream name changes if the app's port mapping changes). +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x +source "$(dirname "${BASH_SOURCE[0]}")/functions" + +APP="${1:-}" +[[ -z "$APP" ]] && exit 0 +fn-google-auth-app-enabled "$APP" || exit 0 +fn-ga-write-conf "$APP" >/dev/null || true diff --git a/plugin.toml b/plugin.toml new file mode 100644 index 0000000..73a7fb6 --- /dev/null +++ b/plugin.toml @@ -0,0 +1,4 @@ +[plugin] +description = "Put Google OAuth SSO in front of dokku apps, with per-path exclusions" +version = "0.1.0" +[plugin.config] diff --git a/post-app-clone b/post-app-clone new file mode 100755 index 0000000..93dca44 --- /dev/null +++ b/post-app-clone @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Trigger: runs after `dokku apps:clone OLD NEW`. +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x +source "$(dirname "${BASH_SOURCE[0]}")/functions" + +OLD_APP="${1:-}" +NEW_APP="${2:-}" +[[ -z "$OLD_APP" || -z "$NEW_APP" ]] && exit 0 + +if [[ -d "$(fn-ga-app-dir "$OLD_APP")" ]]; then + rm -rf "$(fn-ga-app-dir "$NEW_APP")" + cp -r "$(fn-ga-app-dir "$OLD_APP")" "$(fn-ga-app-dir "$NEW_APP")" +fi +fn-google-auth-app-enabled "$NEW_APP" || exit 0 +fn-ga-write-conf "$NEW_APP" >/dev/null || true diff --git a/post-app-rename b/post-app-rename new file mode 100755 index 0000000..ea44302 --- /dev/null +++ b/post-app-rename @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Trigger: runs after `dokku apps:rename OLD NEW`. +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x +source "$(dirname "${BASH_SOURCE[0]}")/functions" + +OLD_APP="${1:-}" +NEW_APP="${2:-}" +[[ -z "$OLD_APP" || -z "$NEW_APP" ]] && exit 0 + +if [[ -d "$(fn-ga-app-dir "$OLD_APP")" ]]; then + rm -rf "$(fn-ga-app-dir "$NEW_APP")" + mv "$(fn-ga-app-dir "$OLD_APP")" "$(fn-ga-app-dir "$NEW_APP")" +fi +fn-google-auth-app-enabled "$NEW_APP" || exit 0 +fn-ga-write-conf "$NEW_APP" >/dev/null || true diff --git a/post-delete b/post-delete new file mode 100755 index 0000000..9897e11 --- /dev/null +++ b/post-delete @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Trigger: runs when an app is destroyed. The app's nginx.conf.d directory is +# removed by dokku core; we only clean up our own state. +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x +source "$(dirname "${BASH_SOURCE[0]}")/functions" + +APP="${1:-}" +[[ -z "$APP" ]] && exit 0 +rm -rf "$(fn-ga-app-dir "$APP")" diff --git a/subcommands/configure b/subcommands/configure new file mode 100755 index 0000000..30974b9 --- /dev/null +++ b/subcommands/configure @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x +source "$(dirname "$(dirname "${BASH_SOURCE[0]}")")/functions" + +cmd-google-auth-configure() { + declare desc="configure the shared Google OAuth service" + local cmd="google-auth:configure" + [[ "$1" == "$cmd" ]] && shift 1 + + local domains=() emails=() domains_given=false emails_given=false + local old_port + old_port="$(fn-ga-global-get port "$GOOGLE_AUTH_DEFAULT_PORT")" + + while [[ $# -gt 0 ]]; do + case "$1" in + --client-id) + [[ -n "${2:-}" ]] || dokku_log_fail "--client-id requires a value" + fn-ga-global-set client-id "$2" + shift 2 + ;; + --client-secret) + [[ -n "${2:-}" ]] || dokku_log_fail "--client-secret requires a value" + fn-ga-global-set client-secret "$2" + shift 2 + ;; + --auth-host) + [[ -n "${2:-}" ]] || dokku_log_fail "--auth-host requires a value" + local host="${2#https://}" + host="${host#http://}" + host="${host%/}" + fn-ga-global-set auth-host "${host,,}" + shift 2 + ;; + --allow-domain) + [[ -n "${2:-}" ]] || dokku_log_fail "--allow-domain requires a value" + domains_given=true + domains+=("${2,,}") + shift 2 + ;; + --allow-email) + [[ -n "${2:-}" ]] || dokku_log_fail "--allow-email requires a value" + emails_given=true + emails+=("${2,,}") + shift 2 + ;; + --session-ttl) + [[ "${2:-}" =~ ^[0-9]+(h|m|s)$ ]] || dokku_log_fail "--session-ttl must look like 24h, 30m, or 3600s" + fn-ga-global-set session-ttl "$2" + shift 2 + ;; + --cookie-name) + [[ -n "${2:-}" ]] || dokku_log_fail "--cookie-name requires a value" + fn-ga-global-set cookie-name "$2" + shift 2 + ;; + --port) + [[ "${2:-}" =~ ^[0-9]+$ ]] || dokku_log_fail "--port must be a number" + fn-ga-global-set port "$2" + shift 2 + ;; + --insecure-allow-http) + # For local/dev testing only; Google requires https redirect URIs. + fn-ga-global-set allow-insecure true + shift 1 + ;; + --regenerate-cookie-secret) + fn-ga-global-set cookie-secret "$(head -c32 /dev/urandom | od -An -tx1 | tr -d ' \n')" + dokku_log_info1 "cookie secret regenerated; all existing sessions are now invalid" + shift 1 + ;; + *) + dokku_log_fail "unknown flag: $1 (see: dokku google-auth:help)" + ;; + esac + done + + # Replace the allow lists only when new values were passed. + [[ "$domains_given" == "true" ]] && fn-ga-global-set-list allowed-domains "${domains[@]}" + [[ "$emails_given" == "true" ]] && fn-ga-global-set-list allowed-emails "${emails[@]}" + + # First run: generate the cookie secret automatically. + if [[ -z "$(fn-ga-global-get cookie-secret)" ]]; then + fn-ga-global-set cookie-secret "$(head -c32 /dev/urandom | od -An -tx1 | tr -d ' \n')" + fi + + if ! fn-ga-configured; then + dokku_log_warn "configuration incomplete — required: --client-id, --client-secret, --auth-host, and at least one --allow-domain or --allow-email" + dokku_log_warn "settings so far are saved; run google-auth:configure again with the missing flags" + return 0 + fi + + fn-ga-service-start + + local new_port + new_port="$(fn-ga-global-get port "$GOOGLE_AUTH_DEFAULT_PORT")" + if [[ "$new_port" != "$old_port" ]]; then + dokku_log_info1 "service port changed; regenerating nginx config for enabled apps" + fn-ga-apply-all + fi + + local auth_host + auth_host="$(fn-ga-global-get auth-host)" + dokku_log_info2 "google-auth configured" + dokku_log_verbose "Register this redirect URI in the Google Cloud Console for your OAuth client:" + dokku_log_verbose "" + dokku_log_verbose " https://${auth_host}${GOOGLE_AUTH_ROUTE_PREFIX}/callback" + dokku_log_verbose "" + dokku_log_verbose "Then protect apps with: dokku google-auth:enable " + fn-ga-warn-if-auth-host-unrouted +} + +cmd-google-auth-configure "$@" diff --git a/subcommands/default b/subcommands/default new file mode 100755 index 0000000..d02512e --- /dev/null +++ b/subcommands/default @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x + +# `dokku google-auth` with no subcommand shows the report. +exec "$(dirname "${BASH_SOURCE[0]}")/report" "$@" diff --git a/subcommands/disable b/subcommands/disable new file mode 100755 index 0000000..b255974 --- /dev/null +++ b/subcommands/disable @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x +source "$(dirname "$(dirname "${BASH_SOURCE[0]}")")/functions" + +cmd-google-auth-disable() { + declare desc="remove Google sign-in from an app" + local cmd="google-auth:disable" + [[ "$1" == "$cmd" ]] && shift 1 + declare APP="$1" + + [[ -n "$APP" ]] || dokku_log_fail "usage: dokku google-auth:disable " + verify_app_name "$APP" + + fn-ga-app-set-enabled "$APP" false + fn-ga-apply "$APP" + dokku_log_info2 "Google auth disabled for $APP" + + local auth_host + auth_host="$(fn-ga-global-get auth-host)" + if [[ -n "$auth_host" && -f "$DOKKU_ROOT/$APP/VHOST" ]] && grep -qxF "$auth_host" "$DOKKU_ROOT/$APP/VHOST"; then + dokku_log_warn "$APP served the auth host '$auth_host'; sign-in for other apps will break until another enabled app serves it" + fi +} + +cmd-google-auth-disable "$@" diff --git a/subcommands/enable b/subcommands/enable new file mode 100755 index 0000000..c0f7009 --- /dev/null +++ b/subcommands/enable @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x +source "$(dirname "$(dirname "${BASH_SOURCE[0]}")")/functions" + +cmd-google-auth-enable() { + declare desc="require Google sign-in for all requests to an app" + local cmd="google-auth:enable" + [[ "$1" == "$cmd" ]] && shift 1 + declare APP="$1" + + [[ -n "$APP" ]] || dokku_log_fail "usage: dokku google-auth:enable " + verify_app_name "$APP" + fn-ga-configured || dokku_log_fail "google-auth is not configured yet; run: dokku google-auth:configure" + + if ! fn-ga-service-running; then + dokku_log_info1 "auth service is not running; starting it" + fn-ga-service-start + fi + + fn-ga-app-set-enabled "$APP" true + fn-ga-apply "$APP" + + dokku_log_info2 "Google auth enabled for $APP" + dokku_log_verbose "Authenticated requests reach the app with these headers:" + dokku_log_verbose " X-Forwarded-Email / X-Auth-Request-Email — signed-in Google email" + dokku_log_verbose " X-Forwarded-User / X-Auth-Request-User — stable Google account id" + dokku_log_verbose " X-Auth-Request-Name — display name" + fn-ga-warn-if-auth-host-unrouted +} + +cmd-google-auth-enable "$@" diff --git a/subcommands/exclude b/subcommands/exclude new file mode 100755 index 0000000..4dc36f9 --- /dev/null +++ b/subcommands/exclude @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x +source "$(dirname "$(dirname "${BASH_SOURCE[0]}")")/functions" + +cmd-google-auth-exclude() { + declare desc="exempt paths from Google sign-in (prefix: /path, regex: re:^/pattern)" + local cmd="google-auth:exclude" + [[ "$1" == "$cmd" ]] && shift 1 + declare APP="$1" + shift 1 || true + + [[ -n "$APP" ]] || dokku_log_fail "usage: dokku google-auth:exclude " + verify_app_name "$APP" + + if [[ $# -eq 0 ]]; then + dokku_log_info2 "excluded paths for $APP" + local pattern + while IFS= read -r pattern; do + [[ -n "$pattern" ]] && dokku_log_verbose "$pattern" + done < <(fn-ga-excludes "$APP") + return 0 + fi + + local pattern + for pattern in "$@"; do + fn-ga-validate-pattern "$pattern" || + dokku_log_fail "invalid pattern '$pattern' — use a path prefix like /api/webhooks or a regex like re:^/v[0-9]+/public/ (no spaces, quotes, or ;{})" + fn-ga-exclude-add "$APP" "$pattern" + dokku_log_info1 "excluded: $pattern" + done + + if fn-google-auth-app-enabled "$APP"; then + fn-ga-apply "$APP" + else + dokku_log_verbose "patterns saved; they take effect when google-auth is enabled for $APP" + fi +} + +cmd-google-auth-exclude "$@" diff --git a/subcommands/logs b/subcommands/logs new file mode 100755 index 0000000..ce9d838 --- /dev/null +++ b/subcommands/logs @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x +source "$(dirname "$(dirname "${BASH_SOURCE[0]}")")/functions" + +cmd-google-auth-logs() { + declare desc="show logs from the shared auth service container" + local cmd="google-auth:logs" + [[ "$1" == "$cmd" ]] && shift 1 + + local args=(--tail 100) + if [[ "${1:-}" == "--tail" || "${1:-}" == "-t" ]]; then + args=(--tail 100 --follow) + fi + docker container logs "${args[@]}" "$GOOGLE_AUTH_SERVICE_NAME" +} + +cmd-google-auth-logs "$@" diff --git a/subcommands/report b/subcommands/report new file mode 100755 index 0000000..e2d8fdb --- /dev/null +++ b/subcommands/report @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x +source "$(dirname "$(dirname "${BASH_SOURCE[0]}")")/functions" + +fn-ga-report-app() { + declare APP="$1" + dokku_log_info2 "$APP google-auth information" + if fn-google-auth-app-enabled "$APP"; then + dokku_log_verbose "Enabled: true" + local pattern found=false + while IFS= read -r pattern; do + [[ -z "$pattern" ]] && continue + if [[ "$found" == "false" ]]; then + dokku_log_verbose "Excluded: $pattern" + found=true + else + dokku_log_verbose " $pattern" + fi + done < <(fn-ga-excludes "$APP") + [[ "$found" == "false" ]] && dokku_log_verbose "Excluded: (none)" + if [[ -f "$(fn-ga-conf-path "$APP")" ]]; then + dokku_log_verbose "Nginx: $(fn-ga-conf-path "$APP")" + else + dokku_log_verbose "Nginx: (config pending; will be written on next deploy)" + fi + else + dokku_log_verbose "Enabled: false" + fi +} + +cmd-google-auth-report() { + declare desc="show global and per-app google-auth status" + local cmd="google-auth:report" + [[ "$1" == "$cmd" ]] && shift 1 + declare APP="${1:-}" + + if [[ -n "$APP" ]]; then + verify_app_name "$APP" + fn-ga-report-app "$APP" + return 0 + fi + + dokku_log_info2 "google-auth global information" + if fn-ga-configured; then + dokku_log_verbose "Configured: true" + else + dokku_log_verbose "Configured: false (run dokku google-auth:configure)" + fi + local client_id + client_id="$(fn-ga-global-get client-id)" + dokku_log_verbose "Client id: ${client_id:-(unset)}" + dokku_log_verbose "Client secret: $([[ -n "$(fn-ga-global-get client-secret)" ]] && echo '(set)' || echo '(unset)')" + dokku_log_verbose "Auth host: $(fn-ga-global-get auth-host '(unset)')" + dokku_log_verbose "Callback URL: https://$(fn-ga-global-get auth-host '')${GOOGLE_AUTH_ROUTE_PREFIX}/callback" + dokku_log_verbose "Allowed domains: $(fn-ga-global-get-list allowed-domains | paste -sd' ' -)" + dokku_log_verbose "Allowed emails: $(fn-ga-global-get-list allowed-emails | paste -sd' ' -)" + dokku_log_verbose "Session TTL: $(fn-ga-global-get session-ttl 24h)" + dokku_log_verbose "Service port: 127.0.0.1:$(fn-ga-global-get port "$GOOGLE_AUTH_DEFAULT_PORT")" + if fn-ga-service-running; then + dokku_log_verbose "Service: running" + else + dokku_log_verbose "Service: not running" + fi + + local app + while IFS= read -r app; do + [[ -z "$app" ]] && continue + fn-ga-report-app "$app" + done < <(fn-ga-enabled-apps) +} + +cmd-google-auth-report "$@" diff --git a/subcommands/restart b/subcommands/restart new file mode 100755 index 0000000..9463314 --- /dev/null +++ b/subcommands/restart @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x +source "$(dirname "$(dirname "${BASH_SOURCE[0]}")")/functions" + +cmd-google-auth-restart() { + declare desc="restart the shared auth service container" + local cmd="google-auth:restart" + [[ "$1" == "$cmd" ]] && shift 1 + fn-ga-service-start +} + +cmd-google-auth-restart "$@" diff --git a/subcommands/start b/subcommands/start new file mode 100755 index 0000000..f39f9d1 --- /dev/null +++ b/subcommands/start @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x +source "$(dirname "$(dirname "${BASH_SOURCE[0]}")")/functions" + +cmd-google-auth-start() { + declare desc="start the shared auth service container" + local cmd="google-auth:start" + [[ "$1" == "$cmd" ]] && shift 1 + fn-ga-service-start +} + +cmd-google-auth-start "$@" diff --git a/subcommands/stop b/subcommands/stop new file mode 100755 index 0000000..b7fd304 --- /dev/null +++ b/subcommands/stop @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x +source "$(dirname "$(dirname "${BASH_SOURCE[0]}")")/functions" + +cmd-google-auth-stop() { + declare desc="stop the shared auth service container" + local cmd="google-auth:stop" + [[ "$1" == "$cmd" ]] && shift 1 + fn-ga-service-stop + dokku_log_info1 "google-auth service stopped" + dokku_log_warn "apps with google-auth enabled will return 502/redirect errors until it is started again" +} + +cmd-google-auth-stop "$@" diff --git a/subcommands/unexclude b/subcommands/unexclude new file mode 100755 index 0000000..9a48d3c --- /dev/null +++ b/subcommands/unexclude @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x +source "$(dirname "$(dirname "${BASH_SOURCE[0]}")")/functions" + +cmd-google-auth-unexclude() { + declare desc="remove previously excluded patterns" + local cmd="google-auth:unexclude" + [[ "$1" == "$cmd" ]] && shift 1 + declare APP="$1" + shift 1 || true + + [[ -n "$APP" && $# -gt 0 ]] || dokku_log_fail "usage: dokku google-auth:unexclude " + verify_app_name "$APP" + + local pattern + for pattern in "$@"; do + fn-ga-exclude-remove "$APP" "$pattern" + dokku_log_info1 "removed exclusion: $pattern" + done + + if fn-google-auth-app-enabled "$APP"; then + fn-ga-apply "$APP" + fi +} + +cmd-google-auth-unexclude "$@" diff --git a/test/nginx-conf-test.sh b/test/nginx-conf-test.sh new file mode 100755 index 0000000..00788f1 --- /dev/null +++ b/test/nginx-conf-test.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Exercises the plugin's nginx config generation against a fake dokku layout, +# asserts the important directives are present, and (if docker is available) +# validates the result with a real nginx binary. +set -eo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# Fake dokku host layout. +export DOKKU_ROOT="$WORK/dokku-root" +export DOKKU_LIB_ROOT="$WORK/dokku-lib" +export PLUGIN_CORE_AVAILABLE_PATH="$WORK/nonexistent" # force built-in fallbacks +APP="myapp" +mkdir -p "$DOKKU_ROOT/$APP" "$DOKKU_LIB_ROOT/data/google-auth/global" + +# A minimal nginx.conf as dokku's template would generate it. +cat >"$DOKKU_ROOT/$APP/nginx.conf" <<'EOF' +upstream myapp-5000 { + server 172.17.0.3:5000; +} +server { + listen 80; + server_name myapp.example.com; +} +EOF + +# shellcheck disable=SC1091 +source "$ROOT/functions" + +fail() { + echo "FAIL: $*" 1>&2 + exit 1 +} + +# --- pattern validation --- +fn-ga-validate-pattern "/api/webhooks" || fail "prefix pattern should be valid" +fn-ga-validate-pattern "re:^/v[0-9]+/public/" || fail "regex pattern should be valid" +fn-ga-validate-pattern "api/webhooks" && fail "pattern without leading / should be invalid" +fn-ga-validate-pattern "/x; }" && fail "pattern with injection chars should be invalid" +fn-ga-validate-pattern '/x{2}' && fail "pattern with braces should be invalid" +fn-ga-validate-pattern "" && fail "empty pattern should be invalid" +echo "ok: pattern validation" + +# --- conf generation --- +fn-ga-app-set-enabled "$APP" true +fn-ga-exclude-add "$APP" "/api/webhooks" +fn-ga-exclude-add "$APP" "re:^/healthz$" + +CONF="$WORK/google-auth.conf" +fn-ga-generate-conf "$APP" >"$CONF" || fail "conf generation failed" + +grep -q 'proxy_pass http://myapp-5000;' "$CONF" || fail "conf should proxy to the app upstream" +grep -q 'auth_request /_google-auth/verify;' "$CONF" || fail "conf should gate with auth_request" +grep -q 'location ^~ /api/webhooks {' "$CONF" || fail "conf should contain prefix exclusion" +grep -q 'location ~ ^/healthz$ {' "$CONF" || fail "conf should contain regex exclusion" +grep -q 'error_page 401 = @google_auth_signin;' "$CONF" || fail "conf should redirect 401s to signin" +grep -q 'proxy_set_header X-Forwarded-Email \$google_auth_email;' "$CONF" || fail "conf should forward the email header" +grep -q 'proxy_set_header X-Forwarded-Email "";' "$CONF" || fail "excluded paths should strip identity headers" +echo "ok: conf contents" + +# --- write/remove behavior --- +[[ "$(fn-ga-write-conf "$APP")" == "changed" ]] || fail "first write should report changed" +[[ "$(fn-ga-write-conf "$APP")" == "unchanged" ]] || fail "second write should report unchanged" +[[ -f "$DOKKU_ROOT/$APP/nginx.conf.d/google-auth.conf" ]] || fail "conf file should exist" +fn-ga-app-set-enabled "$APP" false +[[ "$(fn-ga-write-conf "$APP")" == "changed" ]] || fail "disable should remove the conf" +[[ ! -f "$DOKKU_ROOT/$APP/nginx.conf.d/google-auth.conf" ]] || fail "conf file should be gone" +fn-ga-app-set-enabled "$APP" true +fn-ga-write-conf "$APP" >/dev/null +echo "ok: write/remove behavior" + +# --- undeployed app is skipped --- +mkdir -p "$DOKKU_ROOT/fresh-app" +fn-ga-app-set-enabled "fresh-app" true +[[ "$(fn-ga-write-conf "fresh-app")" == "skipped" ]] || fail "app without nginx.conf should be skipped" +echo "ok: undeployed app skipped" + +# --- validate with real nginx if docker is around --- +if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + cat >"$WORK/nginx-test.conf" <