Files
dokkku-google-auth/functions
T
2026-07-15 07:49:11 -04:00

479 lines
14 KiB
Bash

#!/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
}