Reverse‑proxy stack that fronts your apps with OpenID Connect SSO and group‑based access control. It’s built around nginx-proxy, an oauth2-proxy sidecar, and a small NJS (JavaScript) policy engine that lets you declare who can access what — per host and per path.
Heads‑up: The repo contains demo hostnames like
app1.domain.com
and example creds inside.env-oauth2
. Do not use those in production. Replace with your own issuer, client ID/secret, and domains.
- 🔐 SSO via OIDC using oauth2-proxy
- 👥 Group‑based authorization with a flexible policy (JSON) evaluated in NGINX via NJS
- 🧩 Works with nginx-proxy (docker-gen template) so each app can advertise its own auth policy via env vars
- 🔁 Smart auth flow: 401 →
/oauth2/start
redirect; 403 → debug‑friendly denial page - 🧪 Example apps included for quick validation
/docker
Dockerfile # Prebakes nginx.conf, template, NJS, error pages
docker-compose.yml # Core stack (nginx-proxy + docker-socket-proxy)
docker-compose.override.yml # Examples: oauth2-proxy, redis, demo apps
.env-oauth2 # Example oauth2-proxy config (replace values!)
nginx/
nginx.conf # Loads NJS; includes conf.d/*.conf
conf.d/00-oidc.conf # Declares variables + imports NJS module
njs/oidc.js # Policy merge + authz gate (NJS)
policy.json # Global fallback policy (optional)
vhost.d/default # Common vhost bits: auth subrequest locations
vhost.d/default_location # Gate hook + headers to upstream
error_pages/access_denied.html
certs/default.crt,key # Dev default cert (for HTTPS bootstrapping)
-
nginx-proxy: terminates TLS and routes by
VIRTUAL_HOST
; renders config via docker‑gen template. -
oauth2-proxy: handles OIDC login against your IdP, sets auth headers for NGINX.
-
NJS policy gate (
njs/oidc.js
): evaluates a policy from multiple sources (highest precedence first):POLICY_JSON
env on the app container (per‑host rules)PERMITTED_GROUPS
env on the app container (legacy fallback)/etc/nginx/policy.json
global file inside the proxy image- Built‑in fallback that makes everything public
-
docker-socket-proxy (in core compose): safe, read‑only access for docker‑gen.
-
redis (in override): session store for oauth2‑proxy.
-
Browser →
https://app.example.com/secure
→ nginx-proxy vhost -
Protected locations include
vhost.d/default_location
which does:auth_request /__gate
→ internal chain:/__oauth2_auth
(oauth2-proxy/oauth2/auth
) → NJS policy check
-
If unauthenticated ⇒
401
mapped to302 /oauth2/start?rd=$request_uri
-
After OIDC login, oauth2‑proxy sets headers; NJS evaluates policy rules:
- allow/deny based on path, method, group membership
-
On deny (403) you get a debug page (
/access_denied.html
) with helpful headers.
Requires Docker + Docker Compose. The examples assume you control
*.example.com
DNS pointing to your host. Replace demo domains in env vars.
-
Clone and configure
-
Copy
docker/.env-oauth2
as a starting point and replace:OAUTH2_PROXY_OIDC_ISSUER_URL
OAUTH2_PROXY_CLIENT_ID
OAUTH2_PROXY_CLIENT_SECRET
OAUTH2_PROXY_ALLOWED_REDIRECT_DOMAINS
-
(Optional) put a real cert/key in
docker/nginx/certs/
or use a TLS terminator in front.
-
-
Run the stack
cd docker docker compose -f docker-compose.yml -f docker-compose.override.yml up -d --build
-
Hit the demo apps
https://app1.example.com/
→ public root with an/admin.html
that requiresapp1_admins
https://app2.example.com/
→ requiresapp2_admins2
for/
If you prefer to bring only the proxy layer first, start with just
docker-compose.yml
and add oauth2‑proxy + apps later.
You have two ways to attach policy to an app container (the one with VIRTUAL_HOST
). Use one of them per app:
Attach an environment variable on the app container:
services:
app1:
image: nginx:alpine
environment:
VIRTUAL_HOST: app1.example.com
POLICY_JSON: >-
{"rules":[
{"path":"^/public/","public":true},
{"path":"^/admin\\.html$","methods":["GET","POST"],"allow":["app1_admins"]},
{"path":"/","allow":["app1_users"]}
]}
Rule schema (evaluated in order):
path
(regex or string prefix) — defaults to/
methods
array — optional HTTP methods filterpublic: true
— allow all, no login requiredallow: ["groupA","groupB"]
— user must be in any of the listed groups
services:
app2:
image: nginx:alpine
environment:
VIRTUAL_HOST: app2.example.com
PERMITTED_GROUPS: "app2_admins,app2_users"
This is syntactic sugar that becomes:
{"rules":[{"path":"/","allow":["app2_admins","app2_users"]}]}
Place defaults in nginx/policy.json
inside the image (baked by Dockerfile
). For example:
{
"default": { "rules": [ { "public": true } ] },
"app2.example.com": { "rules": [ { "path": "/", "allow": ["app2_admins"] } ] }
}
These live in .env-oauth2
(override with your values):
OAUTH2_PROXY_PROVIDER=oidc
OAUTH2_PROXY_OIDC_ISSUER_URL=https://<your-idp>/realms/<realm>
OAUTH2_PROXY_CLIENT_ID
/OAUTH2_PROXY_CLIENT_SECRET
OAUTH2_PROXY_OIDC_GROUPS_CLAIM=groups
(adjust if your IdP uses another claim)OAUTH2_PROXY_COOKIE_SECRET
(32+ byte base64, generate your own)OAUTH2_PROXY_SESSION_STORE_TYPE=redis
andOAUTH2_PROXY_REDIS_CONNECTION_URL=redis://redis:6379
OAUTH2_PROXY_ALLOWED_REDIRECT_DOMAINS=.example.com
Tip: Verify
/oauth2/ping
and/oauth2/auth
healthchecks are green in logs.
When access is allowed, these headers are forwarded to your app:
X-User
— from oauth2-proxy userX-Email
— email claimX-Groups
— comma‑separated groups
For troubleshooting (enabled in vhost.d/default_location
), the proxy also returns helpful X-Authz-*
headers — remove those once validated.
- TLS: The image ships with a dummy cert. Replace with a real cert or terminate TLS in front (e.g., ELB/ALB, Traefik, caddy) and forward to this stack.
- Docker socket access: The core stack uses docker‑socket‑proxy with read‑only scopes (safer than mounting
/var/run/docker.sock
directly). Thedocker-compose.broken.yml
shows an alternative wiring using a unix socket and user namespaces for reference. - Scaling: You can run multiple app containers behind one
VIRTUAL_HOST
; policies apply per host.
-
Redirect loop after login
- Check cookie domain/secure flags; make sure you’re on HTTPS and
ALLOWED_REDIRECT_DOMAINS
matches the host. - Ensure system clocks are in sync.
- Check cookie domain/secure flags; make sure you’re on HTTPS and
-
403 when user is in the right group
- Confirm the IdP actually emits the
groups
claim (or adjustOAUTH2_PROXY_OIDC_GROUPS_CLAIM
). - Verify the exact group names in the token vs your policy JSON.
- Confirm the IdP actually emits the
-
App isn’t picked up by nginx-proxy
- Container must be on the same Docker network and set
VIRTUAL_HOST
. - If using a custom external network name, export
DEFAULT_NETWORK
env for nginx‑proxy.
- Container must be on the same Docker network and set
- Extend
njs/oidc.js
to add custom matchers (e.g., header‑based rules, IP allowlists). - Replace demo domains with your own and wire real apps.
- Consider moving policy sources to a config service if you need centralized control.
This stack expects an OIDC client in Keycloak configured like below. You can either import a JSON client (recommended for dev) or click through the admin UI.
-
Create client
- Client type: OpenID Connect
- Client ID: your app ID (e.g.,
app1
) - Name: friendly name
- Always display in console: optional
-
Capability config
- Client authentication: On (confidential client)
- Authorization: Off (not needed)
- Standard flow: On (Authorization Code)
- Implicit flow: Off
- Direct access grants: Off
- Service accounts: Off
-
Login settings
- Valid redirect URIs: each app’s oauth2-proxy callback, e.g.:
https://app1.example.com/oauth2/callback
https://app2.example.com/oauth2/callback
- Web origins:
+
(or specify exact origins) - Front-channel logout: On
- Valid redirect URIs: each app’s oauth2-proxy callback, e.g.:
-
Credentials
- Client Authenticator: Client ID and Secret
- Client secret: generate a new secret and copy it into your
OAUTH2_PROXY_CLIENT_SECRET
.
-
Advanced / Attributes (defaults are fine unless you require PAR, MTLS, etc.)
- PKCE method
S256
is supported (keep default).
- PKCE method
-
Client scopes
- Default:
roles
,profile
,email
(plusweb-origins
,acr
). - Optional:
offline_access
if you plan to use refresh tokens via oauth2-proxy (not required here).
- Default:
-
Protocol mappers (group claim) Add both of the following so tokens contain the user’s groups under the same claim name — this keeps things compatible across token types:
-
Group Membership mapper
- Mapper type: Group Membership
- Token claim name:
groups
- Add to access token: On
- Add to ID token: On
- Add to userinfo: On
- Full group path: Off (unless you want
/realm/group/subgroup
)
-
Realm Role mapper
- Mapper type: User Realm Role
- Token claim name:
groups
- Multivalued: On
- Include in access/ID token & userinfo: On
-
Why two mappers? Some orgs assign access via realm roles instead of (or in addition to) groups. Mapping both into a single
groups
claim means your NGINX/NJS policy can just checkgroups
.
Match these Keycloak values with your .env-oauth2
:
OAUTH2_PROXY_PROVIDER=oidc
OAUTH2_PROXY_OIDC_ISSUER_URL=https://<KEYCLOAK_HOST>/realms/<REALM>
OAUTH2_PROXY_CLIENT_ID=<Client ID>
OAUTH2_PROXY_CLIENT_SECRET=<Client Secret>
OAUTH2_PROXY_OIDC_GROUPS_CLAIM=groups
OAUTH2_PROXY_ALLOWED_REDIRECT_DOMAINS=.example.com
(set to your domain suffix)OAUTH2_PROXY_SESSION_STORE_TYPE=redis
andOAUTH2_PROXY_REDIS_CONNECTION_URL=redis://redis:6379
- Hitting
https://app.example.com/
redirects to Keycloak login and back to your app. - Call
https://app.example.com/oauth2/userinfo
(if exposed) or decode the ID/Access token — you should seegroups: ["…"]
containing your test user’s groups/roles. - Access to paths protected by your
POLICY_JSON
works only when the user is in the allowed group(s).
- Never commit client secrets. Use secrets management or env injection.
- Prefer exact
Web Origins
over+
in production. - Keep
Standard flow
only; avoid implicit/hybrid unless absolutely required.
Add your project’s license here (e.g., MIT).