# 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