Initial commit.

This commit is contained in:
Greyson Parrelli
2026-07-15 07:49:11 -04:00
commit a3f0f8a1be
37 changed files with 2825 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
bin/
+4
View File
@@ -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
+14
View File
@@ -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"]
+259
View File
@@ -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/<app>/nginx.conf.d/` include. It uses nginx's `auth_request`
module: every request triggers a fast subrequest to the auth service, which
checks an encrypted session cookie. No valid session → the browser is
redirected through Google sign-in and back.
- **Only one redirect URI is ever registered with Google** (the "auth
host"). When a user signs in to `app-a.example.com`, Google redirects to
`https://<auth-host>/_google-auth/callback`; the service verifies the
identity, then bounces the browser to `app-a` with a single-use, 60-second,
encrypted hand-off token, which becomes a session cookie scoped to
`app-a`'s own domain. Adding app #47 therefore requires **zero** changes in
the Google Console — apps don't even need to share a parent domain.
- Sessions, OAuth state, and hand-off tokens are all AES-256-GCM encrypted
and authenticated with a secret generated at configure time. Session
cookies are `Secure`, `HttpOnly`, `SameSite=Lax`, and pinned to the host
they were minted on.
### Headers your apps receive
On every authenticated request, nginx injects (and strips anything the
client tried to spoof):
| Header | Value |
|---|---|
| `X-Forwarded-Email` / `X-Auth-Request-Email` | signed-in Google email (lowercased) |
| `X-Forwarded-User` / `X-Auth-Request-User` | stable Google account id (`sub` claim) |
| `X-Auth-Request-Name` | display name |
On **excluded** paths these headers are set to the empty string, so your app
can trust that a non-empty `X-Forwarded-Email` always came from the plugin.
## How dokku plugins work (background)
A dokku plugin is just a git repo of executable shell scripts. Dokku clones
it into `/var/lib/dokku/plugins/available/<name>` and:
- **`plugin.toml`** — metadata (description, version).
- **`install`** — runs as root at `plugin:install` / `plugin:update` time.
This plugin uses it to create its data directory and `docker build` the Go
auth service image (multi-stage build, so the host only needs Docker, not
Go).
- **`commands`** + **`subcommands/<name>`** — dokku dispatches
`dokku google-auth:enable foo` to `subcommands/enable`, passing the full
command line. `commands` provides `dokku google-auth:help`. This is why the
plugin must be installed under the name `google-auth`.
- **Triggers** — executables named after lifecycle hooks that dokku (via
[plugn](https://github.com/dokku/plugn)) calls with arguments. This plugin
implements:
- `nginx-pre-reload` / `core-post-deploy` — regenerate the app's
`nginx.conf.d/google-auth.conf` on every deploy, so it always references
the app's current upstream (whose name changes if you remap ports).
- `post-delete`, `post-app-rename`, `post-app-clone` — keep plugin state in
sync with app lifecycle.
- **State** lives under `/var/lib/dokku/data/google-auth/` (global config +
one directory per enabled app). Secrets are `0600`.
The nginx integration relies on a stable, documented dokku feature: the
generated vhost for every app contains
`include /home/dokku/<app>/nginx.conf.d/*.conf;` inside the `server` block,
which is exactly where this plugin drops its `location` blocks.
## Installation
On the dokku host:
```bash
sudo dokku plugin:install https://github.com/<you>/dokku-google-auth.git --name google-auth
```
(The `--name google-auth` matters — subcommand dispatch is keyed off the
plugin directory name.)
Requirements: dokku with the default **nginx** proxy (not traefik/caddy),
Docker (always present on a dokku host), and nginx built with
`http_auth_request_module` (true for stock Debian/Ubuntu nginx).
### 1. Create the Google OAuth client (one time, ever)
1. Go to [Google Cloud Console → APIs & Services → Credentials](https://console.cloud.google.com/apis/credentials).
2. Configure the OAuth consent screen if you haven't: **Internal** user type
(recommended for a Workspace org — it automatically limits sign-in to your
organization and skips app verification).
3. **Create Credentials → OAuth client ID → Web application.**
4. Add exactly one **Authorized redirect URI**:
```
https://<auth-host>/_google-auth/callback
```
`<auth-host>` is any HTTPS domain that routes to an app you will enable
the plugin on. Pick your most permanent app's domain, or add a dedicated
domain (e.g. `auth.example.com`) to one of your apps with
`dokku domains:add <app> auth.example.com` (plus DNS + letsencrypt as
usual). All other apps piggyback on it.
5. Note the client ID and client secret.
### 2. Configure the plugin (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 <d>` | allow any verified `*@d` account (repeatable; replaces the stored list) | — |
| `--allow-email <e>` | allow a specific address, e.g. an outside collaborator (repeatable) | — |
| `--session-ttl <dur>` | how long a sign-in lasts (`24h`, `72h`, `30m`, …) | `24h` |
| `--cookie-name <n>` | session cookie name | `_google_auth` |
| `--port <p>` | host port (127.0.0.1 only) for the auth service | `2999` |
| `--regenerate-cookie-secret` | rotate the session encryption key (signs everyone out) | — |
### 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://<any-app>/_google-auth/`
and sign out at `https://<any-app>/_google-auth/logout`.
## Things worth knowing
- **HTTPS is required in practice.** Google refuses plain-HTTP redirect URIs
and the session cookie is marked `Secure`. Use dokku-letsencrypt as usual.
(`--insecure-allow-http` exists for local experiments only.)
- **The auth host must stay enabled.** Google's callback lands on
`<auth-host>`, so the app serving that domain must keep google-auth
enabled. `google-auth:disable` warns you if you disable that app.
- **Sessions are per-domain.** Signing in to `app-a` then visiting `app-b`
triggers another round-trip through Google, but it's silent (already
signed in) — the user just sees a quick redirect.
- **In-flight POSTs across an expired session** get redirected to sign-in and
are replayed as GETs — the POST body is lost. That's inherent to
redirect-based SSO; keep session TTLs comfortable (e.g. `72h`) if it bites.
- **Custom nginx location blocks** in your own `nginx.conf.d` files: plain
`location /foo` prefix blocks will be shadowed by this plugin's catch-all
regex location — use `location ^~ /foo` in your own snippets if you need
them to win.
- **Non-browser clients** (curl, fetch without `Accept: text/html`) get a
JSON `401` instead of a redirect chain.
- **Websockets** work — upgrade headers are forwarded exactly like dokku's
stock nginx template.
- **Plugin updates:** `sudo dokku plugin:update google-auth` rebuilds the
service image and restarts the container automatically.
## Uninstall
```bash
dokku google-auth:disable <each-app>
dokku google-auth:stop
sudo dokku plugin:uninstall google-auth
sudo rm -rf /var/lib/dokku/data/google-auth
docker image rm dokku-google-auth:latest
```
## Development
Tasks are defined in `mise.toml` ([mise](https://mise.jdx.dev) pins Go and
shellcheck):
```bash
mise install # install pinned toolchain
mise run test # Go unit tests (full OAuth flow against a fake Google)
mise run test-nginx-conf # generate nginx config + validate with real nginx in docker
mise run shellcheck # lint all plugin scripts
mise run check # everything CI would run
mise run docker-build # build the service image locally
```
Layout:
```
cmd/google-auth-proxy/ Go entrypoint
internal/authproxy/ OAuth flow, session crypto, HTTP handlers (+ tests)
functions shared bash for the plugin (config store, nginx generation)
subcommands/ dokku google-auth:* commands
install, nginx-pre-reload, core-post-deploy, post-* dokku lifecycle triggers
test/nginx-conf-test.sh bash integration test for the generated nginx config
Dockerfile multi-stage build → static binary in a scratch image
```
+79
View File
@@ -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
}
Executable
+37
View File
@@ -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 <<help_content
google-auth:configure [options], Configure the shared Google OAuth service (run once)
google-auth:enable <app>, Require Google sign-in for all requests to <app>
google-auth:disable <app>, Remove Google sign-in from <app>
google-auth:exclude <app> <pattern...>, Exempt path prefixes (/path) or regexes (re:^/x) from sign-in
google-auth:unexclude <app> <pattern...>, 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
+12
View File
@@ -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
+478
View File
@@ -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 <<EOF
proxy_pass http://${UPSTREAM};
proxy_http_version 1.1;
proxy_read_timeout ${TIMEOUT};
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection \$http_connection;
proxy_set_header Host \$http_host;
proxy_set_header X-Forwarded-For \$remote_addr;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Request-Start \$msec;
EOF
}
fn-ga-generate-conf() {
declare APP="$1"
local upstream timeout port
upstream="$(fn-ga-upstream-name "$APP")" || return 1
timeout="$(fn-ga-proxy-read-timeout "$APP")"
port="$(fn-ga-global-get port "$GOOGLE_AUTH_DEFAULT_PORT")"
cat <<EOF
# Managed by the dokku google-auth plugin — do not edit by hand.
# Regenerated on every deploy and by google-auth:* commands.
location = ${GOOGLE_AUTH_ROUTE_PREFIX}/verify {
internal;
proxy_pass http://127.0.0.1:${port};
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Host \$host;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-For \$remote_addr;
}
location ^~ ${GOOGLE_AUTH_ROUTE_PREFIX}/ {
proxy_pass http://127.0.0.1:${port};
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-For \$remote_addr;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_set_header X-Auth-Request-Redirect "";
}
location @google_auth_signin {
rewrite ^ ${GOOGLE_AUTH_ROUTE_PREFIX}/start break;
proxy_pass http://127.0.0.1:${port};
proxy_set_header Host \$host;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-For \$remote_addr;
proxy_set_header X-Auth-Request-Redirect \$request_uri;
}
EOF
local pattern
while IFS= read -r pattern; do
[[ -z "$pattern" ]] && continue
echo ""
if [[ "$pattern" == re:* ]]; then
echo "# google-auth: path excluded from SSO (${pattern})"
echo "location ~ ${pattern#re:} {"
else
echo "# google-auth: path excluded from SSO (${pattern})"
echo "location ^~ ${pattern} {"
fi
fn-ga-proxy-directives "$upstream" "$timeout"
cat <<'EOF'
# Strip identity headers so clients cannot spoof them on open paths.
proxy_set_header X-Forwarded-User "";
proxy_set_header X-Forwarded-Email "";
proxy_set_header X-Auth-Request-User "";
proxy_set_header X-Auth-Request-Email "";
proxy_set_header X-Auth-Request-Name "";
}
EOF
done < <(fn-ga-excludes "$APP")
cat <<EOF
# Everything else requires a Google session.
location ~ ^/ {
auth_request ${GOOGLE_AUTH_ROUTE_PREFIX}/verify;
auth_request_set \$google_auth_user \$upstream_http_x_auth_request_user;
auth_request_set \$google_auth_email \$upstream_http_x_auth_request_email;
auth_request_set \$google_auth_name \$upstream_http_x_auth_request_name;
error_page 401 = @google_auth_signin;
$(fn-ga-proxy-directives "$upstream" "$timeout")
proxy_set_header X-Forwarded-User \$google_auth_user;
proxy_set_header X-Forwarded-Email \$google_auth_email;
proxy_set_header X-Auth-Request-User \$google_auth_user;
proxy_set_header X-Auth-Request-Email \$google_auth_email;
proxy_set_header X-Auth-Request-Name \$google_auth_name;
}
EOF
}
fn-ga-conf-path() {
declare APP="$1"
echo "$DOKKU_ROOT/$APP/nginx.conf.d/google-auth.conf"
}
# Regenerates (or removes) the app's conf file without touching nginx.
# Prints one of: changed, unchanged, skipped.
fn-ga-write-conf() {
declare APP="$1"
local conf dir tmp
conf="$(fn-ga-conf-path "$APP")"
dir="$(dirname "$conf")"
if ! fn-google-auth-app-enabled "$APP"; then
if [[ -f "$conf" ]]; then
rm -f "$conf"
echo changed
else
echo unchanged
fi
return 0
fi
tmp="$(mktemp)"
if ! fn-ga-generate-conf "$APP" >"$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" <<EOF
GOOGLE_AUTH_CLIENT_ID=$(fn-ga-global-get client-id)
GOOGLE_AUTH_CLIENT_SECRET=$(fn-ga-global-get client-secret)
GOOGLE_AUTH_COOKIE_SECRET=$(fn-ga-global-get cookie-secret)
GOOGLE_AUTH_AUTH_HOST=$(fn-ga-global-get auth-host)
GOOGLE_AUTH_ALLOWED_DOMAINS=$domains
GOOGLE_AUTH_ALLOWED_EMAILS=$emails
GOOGLE_AUTH_COOKIE_NAME=$(fn-ga-global-get cookie-name _google_auth)
GOOGLE_AUTH_SESSION_TTL=$(fn-ga-global-get session-ttl 24h)
GOOGLE_AUTH_ALLOW_INSECURE=$(fn-ga-global-get allow-insecure false)
GOOGLE_AUTH_LISTEN=:2999
EOF
}
fn-ga-service-start() {
fn-ga-configured || dokku_log_fail "google-auth is not fully configured; run: dokku google-auth:configure"
fn-ga-image-exists || fn-ga-build-image
fn-ga-write-env-file
local port
port="$(fn-ga-global-get port "$GOOGLE_AUTH_DEFAULT_PORT")"
docker container rm -f "$GOOGLE_AUTH_SERVICE_NAME" >/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
}
+3
View File
@@ -0,0 +1,3 @@
module dokku-google-auth
go 1.24
Executable
+32
View File
@@ -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
+37
View File
@@ -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"`
}
+138
View File
@@ -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, "/")
}
+80
View File
@@ -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")
}
}
+71
View File
@@ -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)
}
+57
View File
@@ -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")
}
}
+385
View File
@@ -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
}
+149
View File
@@ -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] + "..."
}
+34
View File
@@ -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
}
+375
View File
@@ -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 <b>"+html.EscapeString(email)+"</b>, 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 <b>`+html.EscapeString(requestHost(r))+`</b>.
<p><a href="/">Sign in again</a></p>`)
}
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 <b>`+html.EscapeString(requestHost(r))+`</b> as <b>`+html.EscapeString(sess.Email)+`</b>`+
` (`+html.EscapeString(sess.Name)+`).`+
`<p>Session expires `+time.Unix(sess.Exp, 0).UTC().Format(time.RFC1123)+`.</p>`+
`<p><a href="`+RoutePrefix+`/logout">Sign out</a></p>`)
return
}
s.htmlPage(w, http.StatusOK, "Not signed in",
`Not signed in on <b>`+html.EscapeString(requestHost(r))+`</b>.
<p><a href="`+RoutePrefix+`/start?rd=/">Sign in with Google</a></p>`)
}
// --- 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, `<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>%s</title>
<style>
body{font-family:system-ui,sans-serif;max-width:36rem;margin:15vh auto 0;padding:0 1rem;color:#222;line-height:1.5}
h1{font-size:1.3rem} a{color:#1a73e8}
</style></head>
<body><h1>%s</h1><p>%s</p></body></html>`, html.EscapeString(title), html.EscapeString(title), body)
}
+39
View File
@@ -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"]
+12
View File
@@ -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
+4
View File
@@ -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]
Executable
+16
View File
@@ -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
+16
View File
@@ -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
Executable
+10
View File
@@ -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")"
Vendored Executable
+113
View File
@@ -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 <app>"
fn-ga-warn-if-auth-host-unrouted
}
cmd-google-auth-configure "$@"
+6
View File
@@ -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" "$@"
+26
View File
@@ -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 <app>"
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 "$@"
+32
View File
@@ -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 <app>"
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 "$@"
+40
View File
@@ -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 <app> <pattern...>"
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 "$@"
+18
View File
@@ -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 "$@"
+73
View File
@@ -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 '<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 "$@"
+13
View File
@@ -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 "$@"
+13
View File
@@ -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 "$@"
+15
View File
@@ -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 "$@"
+27
View File
@@ -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 <app> <pattern...>"
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 "$@"
+107
View File
@@ -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" <<EOF
events {}
http {
upstream myapp-5000 {
server 127.0.0.1:65000;
}
server {
listen 8080;
server_name myapp.example.com;
location / {
proxy_pass http://myapp-5000;
}
include /work/google-auth.conf;
}
}
EOF
if docker run --rm -v "$WORK:/work:ro" nginx:alpine nginx -t -c /work/nginx-test.conf; then
echo "ok: real nginx accepted the generated config"
else
fail "nginx -t rejected the generated config"
fi
else
echo "skip: docker unavailable, skipped real nginx validation"
fi
echo "ALL NGINX CONF TESTS PASSED"