compose.gateway-sso.yml
Rama main · réplica de lectura del repositorio original.
# compose.gateway-sso.yml — put the OmniRoute dashboard behind Cerulean Authentik.
#
# WHY THIS EXISTS
# ---------------
# The gateway dashboard manages provider credentials and can read them, so it
# should not be reachable on a shared password. OmniRoute has OIDC support of its
# own, but it cannot validate an Authentik ID token: its callback strips the
# trailing slash from the configured issuer and then requires an exact match,
# and Authentik's `iss` always ends with one. `docs/gateway-sso.md` carries the
# evidence. So the dashboard is fronted by a conformant client instead, and
# OmniRoute's own `oidcEnabled` stays off.
#
# browser ──https──▶ edge (NPM, this host)
# │ gateway.studio.innotel.us (and the older
# │ gateway.olympus.innotel.us, same door, same proxy)
# ▼ http://192.168.1.71:20128
# gateway-sso (oauth2-proxy) ──▶ Authentik OIDC + group check
# │ http://127.0.0.1:20128
# ▼
# omniroute (dashboard, loopback + docker0 only)
#
# USAGE
# -----
# make gateway-sso-up # or, by hand:
# docker compose -f docker-compose.yml -f compose.gateway-sso.yml up -d gateway-sso
#
# EVERY NAME IN GATEWAY_SSO_WHITELIST_DOMAIN IS SERVED, each with its own derived
# callback — see the note at `--whitelist-domain` below. The proxy and the gateway
# must share a host, because the gateway is loopback-published and a bridge cannot
# reach that: a proxy on another machine has no door to it at all.
#
# HOST NETWORKING IS NOT OPTIONAL HERE. The gateway is published on the host's
# loopback (`127.0.0.1:20128`, plus `172.17.0.1:20128` for containers on this host),
# and Docker publishes a loopback binding to
# the loopback interface alone — a bridge container cannot reach it at all. This
# is the case `docker-compose.yml` calls "loopback-published on host", whose
# sanctioned answer is host networking; `compose.host-gateway.yml` already makes
# that trade for Studio. The same namespace then gives the proxy both the
# upstream and a LAN-facing listener, so `ports` is cleared rather than left to
# be silently discarded.
services:
gateway-sso:
# Pinned by tag, not digest: unlike the autoheal container this one holds no
# Docker socket, so a bad upstream release cannot restart anything — it can
# only fail closed behind the edge.
#
# The `-alpine` variant specifically: the default tag is distroless and has no
# shell, which makes the healthcheck below impossible to express (a `wget`
# healthcheck on it reports the container unhealthy forever — measured). A
# shell in the image is the price of a liveness signal; the container is the
# only thing listening on the door and it never runs the shell itself.
image: quay.io/oauth2-proxy/oauth2-proxy:v7.7.1-alpine
container_name: gateway-sso
restart: unless-stopped
network_mode: host
ports: !override []
# The gateway's own network is not this stack's, so there is nothing to join
# and nothing to add it to.
networks: !override []
env_file:
# Optional, because a render has to work without one: `make check-compose`
# validates this file against `.env.example` in a checkout that has no `.env`,
# and a missing env_file is an error rather than a warning. A deployment has
# one, and it is loaded exactly as before.
- path: .env
required: false
environment:
# oauth2-proxy reads OAUTH2_PROXY_* from the environment. The repo-idiomatic
# GATEWAY_* names stay in .env; this block is the only place the two
# vocabularies meet, so the credentials are never repeated in `command`
# where `docker inspect` would show them alongside everything else.
OAUTH2_PROXY_CLIENT_ID: ${GATEWAY_OIDC_CLIENT_ID}
OAUTH2_PROXY_CLIENT_SECRET: ${GATEWAY_OIDC_CLIENT_SECRET}
OAUTH2_PROXY_COOKIE_SECRET: ${GATEWAY_SSO_COOKIE_SECRET}
# The session store's address and password in one value, in the environment
# rather than in `command` for the same reason as the three above it. See
# the `--session-store-type` note further down for why this is not a cookie.
OAUTH2_PROXY_REDIS_CONNECTION_URL: redis://:${GATEWAY_SSO_REDIS_PASSWORD:-}@127.0.0.1:${GATEWAY_SSO_REDIS_PORT:-16379}/0
command:
- --provider=oidc
# Trailing slash included, because that is the issuer Authentik publishes
# in the discovery document. (This is exactly the value OmniRoute mishandles.)
- --oidc-issuer-url=${GATEWAY_OIDC_ISSUER_URL}
- --oidc-groups-claim=groups
- --scope=openid email profile
# Authentik advertises S256; oauth2-proxy leaves PKCE off unless asked, and
# it says so on every start. A public-Redirect-URI code flow should use it.
- --code-challenge-method=S256
- --upstream=${GATEWAY_SSO_UPSTREAM:-http://127.0.0.1:20128}
# THE LAN ADDRESS, NOT 0.0.0.0, and since the door moved onto the gateway's
# own default port (20128) that is load-bearing rather than tidiness: on
# this host `omniroute` already holds 127.0.0.1:20128 (host-mode callers)
# and 172.17.0.1:20128 (containers on this host, via `host.docker.internal`).
# A wildcard bind overlaps both and fails with EADDRINUSE, so the proxy
# would not start at all. Binding this host's LAN address leaves those two
# answers untouched and makes the LAN address the door — one listener, two
# roles, told apart by address rather than by port number.
- --http-address=${GATEWAY_SSO_BIND:-0.0.0.0}:${GATEWAY_SSO_PORT:-20128}
# THE LAN PATH FOR API CLIENTS, and the reason this listener is on 0.0.0.0
# rather than loopback. The gateway itself stays on 127.0.0.1, so the only
# way in from another machine is through here — which means one door, and it
# has to be the right one for each kind of caller:
#
# /v1/* inference, and FOR LAN CLIENTS ONLY. Putting an interactive
# OIDC login in front of an API would not add a check, it would
# break every client — Codex, the CLIs, a browser fetch — because
# they send a key, not a session cookie. So it is exempted here,
# and this listener's LAN address is the door for it.
#
# NOT THE PUBLIC NAME, and that distinction is load-bearing. The
# comment above used to say the bearer key authenticated this
# route. Measured on this deployment, nothing authenticates it:
# `make gateway-auth-mode` leaves the gateway with no gate of its
# own (requireLogin=false — see its docstring: the loopback
# binding is then the entire control) and this OmniRoute build
# answers 200 to /v1 with no key, a bogus key and the real one, and
# returns a completion to an unauthenticated POST. With this
# exemption, publishing the name therefore published the provider
# credentials behind it.
#
# `make gateway-edge --deny-path /v1` closes it at the edge, where
# the public name and the LAN door can be told apart (they are the
# same listener, so nothing here can). `make gateway-edge-check`
# asserts both halves. Do not remove this line to "close /v1":
# that would break the LAN clients and change nothing about the
# public name's exposure — the rule that matters is at the edge.
# /healthz liveness. Answers 200 with no body and no secrets.
# everything else, INCLUDING the dashboard, still requires the Authentik
# session and the group. The dashboard is the thing worth protecting here:
# it can read and write provider credentials.
#
# So "reachable on the LAN" and "the dashboard is not exposed" are both true,
# and neither is achieved by widening the gateway's own binding.
#
# NOT skipped, and this one is deliberate: `/api/auth/login` is the dashboard's
# OWN login. It is a no-op today — `make gateway-auth-mode` sets
# `requireLogin=false`, so the gateway does not gate itself and this route has
# nothing to hand out; there is exactly one gate, and it is Authentik. It stays
# unexempted because it costs nothing and because a future release that
# reintroduces a password check must not find it sitting outside the SSO door.
# Verified rather than assumed: with a session, `/dashboard` answers 200 and
# never reaches OmniRoute's `/login`; without one, the public name is a 302 to
# Authentik.
- --skip-auth-route=^/v1/
# THE BARE PREFIX TOO, because `^/v1/` is not the same route. oauth2-proxy
# matches the path it was given, so `^/v1/` exempts `/v1/chat/completions`
# and `/v1/models` but sends `/v1` itself to Authentik. That asymmetry is
# invisible to a client that only ever POSTs a subpath, and breaks the one
# that validates its base URL first: `OMNIROUTE_BASE_URL` is documented one
# section up as `http://192.168.1.71:20128/v1` — the bare prefix — and the
# Asterisk AI voice engine probes exactly that before accepting a config.
# It got a login page back, read the 400 as an API error, and reported its
# whole pipeline unhealthy while every runtime call to the same gateway
# worked — a failure that reads as "the model is broken".
#
# Nothing new is exposed. `GET /v1` returns the same model list as
# `/v1/models`, which is already on this list, and the public name refuses
# both at the edge (`location ^~ /v1/` + `location = /v1` -> 403), which
# scripts/gateway-edge-check.py asserts. This route only makes the
# LAN exemption cover the prefix it was always documented to cover.
# `(\?.*)?$` for the same reason as the `/api/providers` route below: the
# match is against the request URI, query string included.
- '--skip-auth-route=^/v1(\?.*)?$'
- --skip-auth-route=^/healthz$
# THE READ-ONLY DOOR FOR STUDIO'S PROVIDER SET, and it is deliberately this
# narrow. Studio owns no provider credentials and no model list of its own:
# which models a build may use is "whatever the gateway has *connected*", and
# the gateway answers that at `/api/providers` — the connections table, not
# `/v1/models`, which also advertises the anonymous no-auth providers (Felo,
# DuckDuckGo, Auggie, the Codex app-server) that are built into the gateway
# but that nobody has linked a credential for. Reading the model list alone
# cannot tell those apart, so without this route Studio either offers models
# that cannot answer or pins a default the gateway has no credentials for —
# the `503 No credentials for …` failure docs/stack.md measures.
#
# Exactly `^/api/providers$`, not `^/api/`: the rest of the management API
# (settings, connection writes, the dashboard's own routes) stays behind the
# Authentik session and the allowed group, because it can write. This one
# route is a GET of provider *names* and masked keys; it is the smallest
# thing that answers "what is connected".
#
# THE SAME LISTENER CARRIES THE PUBLIC NAME, so this is a LAN-and-public
# exemption, not a LAN-only one — oauth2-proxy cannot tell the two apart (it
# is one `--http-address`). `/v1` has the same shape and is closed at the edge
# by `make gateway-edge --deny-path /v1`; this route's public exposure is the
# model list and provider names, not inference, so it is left open for the
# dashboard to keep working. Close it at the edge the same way if that trade
# is not wanted: `make gateway-edge ARGS="--deny-path /api/providers"`.
#
# `(\?.*)?$` and not a bare `$`: oauth2-proxy matches these routes against
# the *request URI*, query string included, so `^/api/providers$` exempts the
# bare path and 302s `/api/providers?limit=5000` to Authentik — measured on
# this deployment, and exactly the kind of half-open door that reads as "the
# filter silently did nothing".
- '--skip-auth-route=^/api/providers(\?.*)?$'
# NO --redirect-url, AND THAT IS THE FEATURE. With it pinned, this proxy
# completes every login on ONE name: a request that arrives on a second name
# is sent to Authentik with the first name's `redirect_uri`, so the callback
# lands there and the second name has no session — which is why
# `gateway.olympus.innotel.us` had to be a 301 instead of a door. Left unset,
# oauth2-proxy derives the callback from the request's Host (`--reverse-proxy`
# is what makes that Host the public one), so each served name logs in on
# itself. Measured on this deployment before changing it: a probe on a spare
# port with these flags and no `--redirect-url` answered
# `redirect_uri=https://gateway.studio.innotel.us/oauth2/callback` for a
# studio Host and `…gateway.olympus.innotel.us…` for an olympus Host.
#
# A DERIVED CALLBACK IS ONLY AS GOOD AS THIS LIST — it is what turns
# "whatever Host header arrived" into a decided set, so a name left out here
# gets `redirect_uri` refused by Authentik (whose matching is strict per
# provider) rather than silently redirected somewhere unexpected.
- --whitelist-domain=${GATEWAY_SSO_WHITELIST_DOMAIN:-.innotel.us}
# The edge terminates TLS, so the session cookie is only ever sent over
# HTTPS even though this listener speaks plain HTTP.
#
# Each served name gets its OWN host-scoped `_oauth2_proxy` cookie, so the
# two names log in independently. Adding --cookie-domain=${SSO_COOKIE_DOMAIN:-.innotel.us}
# here (the line the other SSO gateways in this estate carry) makes one login
# cover every served name — trade a second login for a session cookie sent to
# every *.innotel.us host. Deliberately not the default: it widens the blast
# radius of an XSS anywhere in the domain to a dashboard that can read every
# provider credential, and neither name is worse off without it.
- --cookie-secure=true
- --cookie-samesite=lax
# Honours X-Forwarded-Proto/Host, which is how the session cookie stays
# `Secure` and the OAuth redirects come back on the public name instead of
# on the LAN address.
#
# `--trusted-ip` was tried here and REMOVED: oauth2-proxy warns on every
# request that mixing it with `--reverse-proxy` is unsafe, and it is right —
# with reverse-proxy on, the client IP is read from X-Forwarded-For, so a
# caller who can reach 20128 spoofs the trusted address and is believed.
# It hardens nothing. The real control is who can reach the port: the
# listener is on 0.0.0.0 so the edge can, which means the port should be
# firewalled to the edge's address — see docs/gateway-sso.md.
- --reverse-proxy=true
# Any verified email may pass the OIDC step; the group below is the actual
# door. OmniRoute's own allow-list cannot express this at all — it takes
# subjects and emails, so "anyone in cerulean-platform" is not a sentence it
# can say.
- --email-domain=*
- --allowed-group=${GATEWAY_SSO_ALLOWED_GROUP:-cerulean-platform}
- --skip-provider-button=true
# Authentik's OWN `email` scope mapping sets `email_verified: false` — a
# deliberate change in Authentik 2025.10, because it has no authoritative
# source for whether an address is verified. oauth2-proxy refuses such a
# token by default ("email in id_token … isn't verified"), so this flag is
# what makes the handshake finish.
#
# It is not a weak spot here: the door is `--allowed-group`, and
# `--email-domain=*` means the address grants nothing on its own. The
# alternative — a custom scope mapping asserting `email_verified: true` for
# this provider — was rejected because that asserts something Authentik
# explicitly decided it cannot know, on the platform's behalf.
- --insecure-oidc-allow-unverified-email
# SERVER-SIDE SESSIONS, AND THIS IS LOAD-BEARING. The cookie store puts the
# whole session in the browser: the email, the ID token, and every group the
# identity claims. An account in a few dozen Authentik groups — `dhunter` is
# in thirty — exceeds the 4KB a cookie can hold, so oauth2-proxy splits the
# session across several cookies and says so on every login:
#
# WARNING: Multiple cookies are required for this session as it exceeds
# the 4kb cookie limit. Please use server side session storage (eg. Redis).
#
# Those several `Set-Cookie` headers make the callback's response header
# larger than the edge's `proxy_buffer_size`, and nginx answers the request
# that would have completed the login with `502 Bad Gateway` —
# "upstream sent too big header while reading response header from upstream".
# The login fails, and it fails in the one place that looks like the gateway
# being down rather than like a cookie being large. Measured on the edge
# (proxy host #198) before this flag changed; see docs/gateway-sso.md.
#
# Moving the session out of the cookie also removes the ceiling entirely:
# it does not matter how many groups are added to it later.
- --session-store-type=redis
# That is the whole configuration here: `OAUTH2_PROXY_REDIS_CONNECTION_URL`
# in `environment` above carries the address and the password, so there is
# no second copy of either to drift from it.
# Pass the identity on to the dashboard so its logs name a person rather
# than an anonymous session.
- --set-xauthrequest=true
- --pass-user-headers=true
# Trim what a dashboard never needs to see.
- --pass-access-token=false
- --pass-authorization-header=false
- --silence-ping-logging=true
healthcheck:
# /ping is oauth2-proxy's own unauthenticated liveness route. Probing the
# dashboard instead would make this container's health depend on the
# gateway being up, which is how a healthy proxy gets restarted for
# somebody else's outage.
# Probes the BIND, not loopback: on this host 127.0.0.1:20128 answers
# `omniroute`, so a loopback probe would silently test the gateway's
# dashboard (and report unhealthy) instead of this proxy's liveness.
test: ["CMD", "wget", "-qO-", "http://${GATEWAY_SSO_BIND:-127.0.0.1}:${GATEWAY_SSO_PORT:-20128}/ping"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
# Deliberately NOT labelled `autoheal`. Autoheal acts on a failing
# healthcheck, and this container's job is to refuse traffic — a restart
# loop would turn every closed door into something that looks like a crash.
# The session store. Neither the browser nor the edge ever sees this: the proxy
# writes the session here and hands the browser a single opaque id, which is what
# keeps the callback's response header small enough for the edge to pass — the
# thing `--session-store-type=redis` above exists to fix. Sessions are the only
# thing in it, and losing them costs a re-login and nothing else, so there is no
# volume and no persistence: `--save "" --appendonly no`.
#
# Loopback only. The proxy is host-networked and reaches it at 127.0.0.1; nothing
# on the LAN can, which is the same posture as the gateway itself.
gateway-sso-sessions:
image: redis:7-alpine
container_name: gateway-sso-sessions
restart: unless-stopped
labels:
# Unlike the proxy, this one CAN be restarted usefully: it holds no state a
# restart would discard, and a wedged store fails every login.
- "autoheal=true"
environment:
GATEWAY_SSO_REDIS_PASSWORD: ${GATEWAY_SSO_REDIS_PASSWORD:-}
# The password reaches redis through this container's environment rather than
# through its command line, so `docker inspect` does not carry it. Verified
# rather than assumed: the shell inside expands it, and there is no
# `${VAR:?}` here because `make check-compose` renders every overlay against
# `.env.example` — where this is empty — and a required-variable syntax would
# turn that into a failure of the repository's own validation instead of a
# safeguard. The refusal belongs at start-up, which is what this does.
command:
- sh
- -c
- |
if [ -z "$$GATEWAY_SSO_REDIS_PASSWORD" ]; then
echo "gateway-sso-sessions: GATEWAY_SSO_REDIS_PASSWORD is empty; refusing to start a session store with no password." >&2
echo "Add one with: printf 'GATEWAY_SSO_REDIS_PASSWORD=%s\n' \$$(openssl rand -hex 32) >> .env" >&2
exit 2
fi
exec redis-server \
--requirepass "$$GATEWAY_SSO_REDIS_PASSWORD" \
--save "" \
--appendonly no \
--maxmemory-policy noeviction
# `--maxmemory-policy noeviction` on purpose: an LRU policy on a session store
# would silently log people out under memory pressure, which is worse than
# failing to write at all.
ports:
- "127.0.0.1:${GATEWAY_SSO_REDIS_PORT:-16379}:6379"
healthcheck:
# `$$` so compose passes the literal `$` through and the container's shell
# expands GATEWAY_SSO_REDIS_PASSWORD from its own environment. `-a` with a
# value from the environment rather than inlined here, so the healthcheck
# command itself carries no secret.
test: ["CMD-SHELL", "redis-cli -a \"$$GATEWAY_SSO_REDIS_PASSWORD\" --no-auth-warning ping | grep -q PONG"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s