Initial commit.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
bin/
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
Executable
+12
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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, "/")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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] + "..."
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"]
|
||||
Executable
+12
@@ -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
|
||||
@@ -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
@@ -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
|
||||
Executable
+16
@@ -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
@@ -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")"
|
||||
+113
@@ -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 "$@"
|
||||
Executable
+6
@@ -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" "$@"
|
||||
Executable
+26
@@ -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 "$@"
|
||||
Executable
+32
@@ -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 "$@"
|
||||
Executable
+40
@@ -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 "$@"
|
||||
Executable
+18
@@ -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 "$@"
|
||||
Executable
+73
@@ -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 "$@"
|
||||
Executable
+13
@@ -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 "$@"
|
||||
Executable
+13
@@ -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 "$@"
|
||||
Executable
+15
@@ -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 "$@"
|
||||
Executable
+27
@@ -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 "$@"
|
||||
Executable
+107
@@ -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"
|
||||
Reference in New Issue
Block a user