From 75c0db0d23ea39719c2152da1fab68530152deaa Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 16 Jun 2026 10:44:58 -0400 Subject: [PATCH 01/10] Replace builtin proxy auth with oauth2-proxy Rather than expecting manager-specific semantics for the nginx auth_request flow, use oauth2-proxy as the expected forward-auth API. Administrators run and configure their own oauth2-proxy server; the manager no longer provides forward-auth itself. Template (views/nginx-conf.ejs): - Add /oauth2/ and internal /oauth2/auth locations that proxy to the configured oauth2-proxy upstream (the domain's authServer). - Use `auth_request /oauth2/auth` with `error_page 401 = @oauth2_signin`, redirecting (302) to the same-host /oauth2/sign_in path. - Capture oauth2-proxy's X-Auth-Request-* response headers but forward them to the backend under the stable X-User / X-Email / X-Groups / X-Access-Token contract. Manager forward-auth removal: - Delete routers/verify.js and its mount + SPA catch-all exception. - Scope the manager session cookie to its own host (drop the cross-subdomain cookie sharing that only existed for auth_request). The authServer field now points at the oauth2-proxy upstream (e.g. http://127.0.0.1:4180); model comment, form/list UI, and docs updated accordingly. Refs #348 --- .../ExternalDomainFormPage.tsx | 5 +- .../ExternalDomainsListPage.tsx | 2 +- create-a-container/models/external-domain.js | 2 +- create-a-container/routers/verify.js | 61 ------------- create-a-container/server.js | 19 ++-- create-a-container/views/nginx-conf.ejs | 90 ++++++++++++------- error-pages/auth-unavailable.html | 8 +- .../docs/admins/core-concepts/containers.md | 2 +- .../admins/core-concepts/external-domains.md | 59 ++++++------ .../docs/admins/installation.md | 2 +- .../docs/developers/database-schema.md | 6 +- .../docs/developers/system-architecture.md | 16 ++-- 12 files changed, 114 insertions(+), 158 deletions(-) delete mode 100644 create-a-container/routers/verify.js diff --git a/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx b/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx index df44ae3e..f83c74df 100644 --- a/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx +++ b/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx @@ -166,8 +166,9 @@ export function ExternalDomainFormPage() { {...register('cloudflareApiKey')} /> {mutation.error && ( diff --git a/create-a-container/client/src/pages/external-domains/ExternalDomainsListPage.tsx b/create-a-container/client/src/pages/external-domains/ExternalDomainsListPage.tsx index 36b58321..8b97d794 100644 --- a/create-a-container/client/src/pages/external-domains/ExternalDomainsListPage.tsx +++ b/create-a-container/client/src/pages/external-domains/ExternalDomainsListPage.tsx @@ -67,7 +67,7 @@ export function ExternalDomainsListPage() { Domain Site Cloudflare - Auth server + oauth2-proxy Actions diff --git a/create-a-container/models/external-domain.js b/create-a-container/models/external-domain.js index ea24c340..47fa52ba 100644 --- a/create-a-container/models/external-domain.js +++ b/create-a-container/models/external-domain.js @@ -59,7 +59,7 @@ module.exports = (sequelize) => { validate: { isUrl: true }, - comment: 'Auth server URL for auth_request (e.g., https://manager.example.com). Must implement GET /verify and GET /login?redirect=' + comment: 'oauth2-proxy upstream URL for nginx auth_request (e.g., http://127.0.0.1:4180). The administrator must run an oauth2-proxy server configured for this domain.' } }, { tableName: 'ExternalDomains', diff --git a/create-a-container/routers/verify.js b/create-a-container/routers/verify.js deleted file mode 100644 index 4775da88..00000000 --- a/create-a-container/routers/verify.js +++ /dev/null @@ -1,61 +0,0 @@ -const express = require('express'); -const { ApiKey, User, Group } = require('../models'); -const { extractKeyPrefix } = require('../utils/apikey'); - -const router = express.Router(); - -function setUserHeaders(res, user, groups) { - res.set('X-User-ID', String(user.uidNumber)); - res.set('X-Username', user.uid); - res.set('X-User-First-Name', user.givenName); - res.set('X-User-Last-Name', user.sn); - res.set('X-Email', user.mail); - res.set('X-Groups', groups.map((g) => g.cn).join(',')); -} - -// GET /verify — lightweight auth check for nginx auth_request subrequests. -// Returns 200 with user identity headers if authenticated, 401 otherwise. -router.get('/', async (req, res) => { - if (req.session && req.session.user) { - const user = await User.findOne({ - where: { uid: req.session.user }, - include: [{ model: Group, as: 'groups' }], - }); - if (user) { - setUserHeaders(res, user, user.groups || []); - return res.status(200).send(); - } - } - - const authHeader = req.get('Authorization'); - if (authHeader && authHeader.startsWith('Bearer ')) { - const apiKey = authHeader.substring(7); - if (apiKey) { - const keyPrefix = extractKeyPrefix(apiKey); - const apiKeys = await ApiKey.findAll({ - where: { keyPrefix }, - include: [{ - model: User, - as: 'user', - include: [{ model: Group, as: 'groups' }], - }], - }); - for (const storedKey of apiKeys) { - const isValid = await storedKey.validateKey(apiKey); - if (isValid) { - storedKey.recordUsage().catch((err) => { - console.error('Failed to update API key last used timestamp:', err); - }); - if (storedKey.user) { - setUserHeaders(res, storedKey.user, storedKey.user.groups || []); - } - return res.status(200).send(); - } - } - } - } - - return res.status(401).send(); -}); - -module.exports = router; diff --git a/create-a-container/server.js b/create-a-container/server.js index 650384b3..73726e01 100644 --- a/create-a-container/server.js +++ b/create-a-container/server.js @@ -8,7 +8,6 @@ const SequelizeStore = require('express-session-sequelize')(session.Store); const path = require('path'); const RateLimit = require('express-rate-limit'); const crypto = require('crypto'); -const net = require('net'); const swaggerUi = require('swagger-ui-express'); const YAML = require('yamljs'); const { sequelize, SessionSecret } = require('./models'); @@ -58,25 +57,19 @@ async function main() { store: sessionStore, resave: false, saveUninitialized: false, - // Dynamic cookie: drop the host part and set domain to the parent domain - // (e.g., manager.example.com → .example.com) so the session cookie is - // shared across sibling subdomains for nginx auth_request. - // For IP addresses (IPv4/IPv6) and single-label hosts like "localhost", - // omit the domain attribute so the browser scopes the cookie to the - // exact host (RFC 6265 forbids domain attributes on IP literals). + // The manager's session cookie only needs to be valid for the manager + // host itself — forward-auth for other subdomains is handled by an + // external oauth2-proxy server, which manages its own cookies. We leave + // the cookie scoped to the exact host (no `domain` attribute). // `secure` is derived from the request protocol (honoring `trust proxy` // and X-Forwarded-Proto from nginx) rather than NODE_ENV, so the flag // tracks the actual transport — set on HTTPS, omitted on plain HTTP // bootstrap/dev access. cookie: function(req) { - const hostname = req.hostname || ''; - const parts = hostname.split('.'); - const shouldDropHost = !net.isIP(hostname) && parts.length > 2; return { secure: req.secure, maxAge: 24 * 60 * 60 * 1000, // 24 hours sameSite: 'lax', - domain: shouldDropHost ? `.${parts.slice(1).join('.')}` : hostname }; } })); @@ -103,10 +96,8 @@ async function main() { // --- Mount Routers --- const apiV1Router = require('./routers/api/v1'); const templatesRouter = require('./routers/templates'); - const verifyRouter = require('./routers/verify'); app.use('/api/v1', apiV1Router); - app.use('/verify', verifyRouter); // nginx auth_request subrequest endpoint app.use('/', templatesRouter); // serves /sites/:siteId/nginx and /sites/:siteId/dnsmasq/:file // --- API Documentation (Swagger UI) --- @@ -123,7 +114,7 @@ async function main() { // --- SPA: serve compiled React app for everything else --- const clientDist = path.join(__dirname, 'client', 'dist'); app.use(express.static(clientDist)); - app.get(/^\/(?!api(\/|$)|verify(\/|$)|sites\/[^/]+\/(nginx$|dnsmasq\/)).*$/, (req, res) => { + app.get(/^\/(?!api(\/|$)|sites\/[^/]+\/(nginx$|dnsmasq\/)).*$/, (req, res) => { res.sendFile(path.join(clientDist, 'index.html')); }); diff --git a/create-a-container/views/nginx-conf.ejs b/create-a-container/views/nginx-conf.ejs index a638e4fe..d5bdbec9 100644 --- a/create-a-container/views/nginx-conf.ejs +++ b/create-a-container/views/nginx-conf.ejs @@ -237,42 +237,64 @@ http { } <%_ if (authRequired && authServer) { _%> - # Auth subrequest — proxied to the auth server's /verify endpoint. - # Responses are cached per Cookie+Authorization pair so NGINX only - # contacts the auth server when credentials change. - location = /.oss-auth-verify { + # --- oauth2-proxy integration ------------------------------------------- + # The administrator runs an oauth2-proxy server (configured separately) and + # sets this domain's "oauth2-proxy URL" to its upstream address. NGINX + # authenticates each request against oauth2-proxy's /oauth2/auth endpoint + # using the auth_request module. + # See https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/nginx/ + set $oauth2_proxy <%= authServer %>; + + # Sign-in, callback, and other oauth2-proxy endpoints. + location /oauth2/ { + resolver 127.0.0.1; + proxy_pass $oauth2_proxy; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Scheme $scheme; + proxy_set_header X-Auth-Request-Redirect $scheme://$host$request_uri; + } + + # Internal auth check. oauth2-proxy returns 202 (authenticated) or 401. + # Responses are cached per Cookie+Authorization pair so NGINX only contacts + # oauth2-proxy when the credentials change. + location = /oauth2/auth { internal; resolver 127.0.0.1; - set $auth_server <%= authServer %>; - proxy_pass $auth_server/verify; - proxy_pass_request_body off; - proxy_set_header Content-Length ""; - proxy_set_header X-Original-URI $request_uri; - proxy_set_header X-Original-Host $host; - proxy_set_header Cookie $http_cookie; - proxy_set_header Authorization $http_authorization; + proxy_pass $oauth2_proxy; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Scheme $scheme; + # nginx auth_request includes headers but not the request body. + proxy_set_header Content-Length ""; + proxy_pass_request_body off; proxy_cache auth_cache; proxy_cache_key "$http_cookie$http_authorization"; - proxy_cache_valid 200 5m; + proxy_cache_valid 202 5m; proxy_cache_valid 401 30s; } - # Capture user identity headers from the auth subrequest response. - auth_request_set $auth_user_id $upstream_http_x_user_id; - auth_request_set $auth_username $upstream_http_x_username; - auth_request_set $auth_first_name $upstream_http_x_user_first_name; - auth_request_set $auth_last_name $upstream_http_x_user_last_name; - auth_request_set $auth_email $upstream_http_x_email; - auth_request_set $auth_groups $upstream_http_x_groups; - - location @login_redirect { - return 302 <%= authServer %>/login?redirect=https://$host$request_uri; + # Named location that issues a proper 302 redirect to oauth2-proxy's + # sign-in page when auth_request returns 401. The sign-in page is served + # on this same host via the `location /oauth2/` proxy above, so redirect + # to the local path rather than the (internal) upstream address. + location @oauth2_signin { + return 302 /oauth2/sign_in?rd=$scheme://$host$request_uri; } + # Capture identity from the auth_request response. Requires oauth2-proxy to + # run with --set-xauthrequest (and --pass-access-token for the access token). + # oauth2-proxy returns these as X-Auth-Request-* headers; we forward them to + # the backend under the stable X-User / X-Email contract (see below). + auth_request_set $auth_user $upstream_http_x_auth_request_user; + auth_request_set $auth_email $upstream_http_x_auth_request_email; + auth_request_set $auth_groups $upstream_http_x_auth_request_groups; + auth_request_set $auth_token $upstream_http_x_auth_request_access_token; + <%_ } _%> <%_ if (authRequired && !authServer) { _%> - # Authentication required but no auth server URL configured for this domain + # Authentication required but no oauth2-proxy URL configured for this domain location @auth_unavailable { rewrite ^ /auth-unavailable.html break; proxy_method GET; @@ -289,16 +311,16 @@ http { # Proxy settings location / { <%_ if (authRequired && authServer) { _%> - auth_request /.oss-auth-verify; - error_page 401 = @login_redirect; - - # Forward user identity from auth subrequest to backend - proxy_set_header X-User-ID $auth_user_id; - proxy_set_header X-Username $auth_username; - proxy_set_header X-User-First-Name $auth_first_name; - proxy_set_header X-User-Last-Name $auth_last_name; - proxy_set_header X-Email $auth_email; - proxy_set_header X-Groups $auth_groups; + auth_request /oauth2/auth; + error_page 401 = @oauth2_signin; + + # Forward identity to the backend under a stable header contract. + # (oauth2-proxy's own X-Auth-Request-* names are mapped here so the + # backend contract stays the same regardless of the auth provider.) + proxy_set_header X-User $auth_user; + proxy_set_header X-Email $auth_email; + proxy_set_header X-Groups $auth_groups; + proxy_set_header X-Access-Token $auth_token; <%_ } _%> proxy_pass <%= service.httpService.backendProtocol %>://<%= service.Container.ipv4Address %>:<%= service.internalPort %>; proxy_http_version 1.1; diff --git a/error-pages/auth-unavailable.html b/error-pages/auth-unavailable.html index d9592442..aa379cd3 100644 --- a/error-pages/auth-unavailable.html +++ b/error-pages/auth-unavailable.html @@ -115,22 +115,22 @@
503

Authentication Unavailable

- This service requires authentication, but no authentication server has been + This service requires authentication, but no oauth2-proxy server has been configured for this domain.

What happened?

- This service has authentication enabled, but no authentication server + This service has authentication enabled, but no oauth2-proxy server has been configured for the domain. To resolve this:

diff --git a/mie-opensource-landing/docs/admins/core-concepts/containers.md b/mie-opensource-landing/docs/admins/core-concepts/containers.md index 851b8488..04eceec8 100644 --- a/mie-opensource-landing/docs/admins/core-concepts/containers.md +++ b/mie-opensource-landing/docs/admins/core-concepts/containers.md @@ -20,5 +20,5 @@ Users in the **ldapusers** group can SSH into any container using their cluster Users can expose HTTP services from containers using [external domains](external-domains.md). Services are automatically configured with SSL/TLS certificates, reverse proxy routing, and DNS records. -HTTP services can optionally require authentication via the **Require auth** checkbox. When enabled, NGINX authenticates requests against the domain's [auth server](external-domains.md#authentication) before proxying. Authenticated requests include identity headers (`X-User-ID`, `X-Username`, etc.) forwarded to the backend. See [External Domains — Authentication](external-domains.md#authentication) for configuration details. +HTTP services can optionally require authentication via the **Require auth** checkbox. When enabled, NGINX authenticates requests against the domain's [oauth2-proxy server](external-domains.md#authentication) before proxying. Authenticated requests include identity headers (`X-User`, `X-Email`, `X-Groups`) forwarded to the backend. See [External Domains — Authentication](external-domains.md#authentication) for configuration details. diff --git a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md index 256d753a..61eb2ebc 100644 --- a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md +++ b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md @@ -18,7 +18,7 @@ External domains expose container HTTP services to the internet. Domains are glo | **ACME Directory** | CA endpoint, Let's Encrypt Production/Staging (currently unused) | | **Cloudflare API Email** | Cloudflare account email, sent as the `X-Auth-Email` header — optional unless using Cross-Site DNS | | **Cloudflare API Key** | Cloudflare **User API Token**, sent as `Authorization: Bearer `. Despite the field name, this is *not* the legacy Global API Key. Optional unless using Cross-Site DNS. | -| **Auth Server URL** | Optional — URL of an authentication server for NGINX `auth_request`. See [Authentication](#authentication) | +| **oauth2-proxy URL** | Optional — upstream URL of an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) server for NGINX `auth_request`. See [Authentication](#authentication) | !!! tip Use Let's Encrypt **Staging** for testing — it has higher rate limits. Switch to **Production** once verified. @@ -111,35 +111,36 @@ When creating a container service, users select an external domain and specify a ## Authentication -HTTP services can require authentication via NGINX's [`auth_request`](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module. When a service has **Require auth** enabled, NGINX sends a subrequest to the domain's auth server before proxying each request. Unauthenticated users are redirected to the auth server's login page. +HTTP services can require authentication via NGINX's [`auth_request`](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module, delegated to an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) server that you run and configure. When a service has **Require auth** enabled, NGINX authenticates each request against oauth2-proxy's `/oauth2/auth` endpoint before proxying. Unauthenticated users are redirected to oauth2-proxy's sign-in page. -### Auth Server Requirements +The manager does **not** provide authentication itself — you must deploy and configure a valid oauth2-proxy server (with your chosen OIDC/OAuth2 provider) and point the domain's **oauth2-proxy URL** at it. -The auth server URL (e.g., `https://manager.example.com`) must implement two endpoints: +### Configuring oauth2-proxy -| Endpoint | Behavior | -|----------|----------| -| `GET /verify` | Return `2xx` if the user is authenticated, `401` otherwise. May return identity headers (see below). | -| `GET /login?redirect=` | Login page that redirects to `` after successful authentication. | +Set the domain's **oauth2-proxy URL** to the upstream address of your oauth2-proxy instance (e.g. `http://127.0.0.1:4180`). NGINX proxies the `/oauth2/` paths on each authenticated service's host to this upstream, so the OAuth2 endpoints are served on the same hostname as the application. -The manager application implements both endpoints and can be used as the auth server. +Run oauth2-proxy with at least: + +- `--reverse-proxy=true` — required when behind NGINX. +- `--set-xauthrequest=true` — so identity is returned in `X-Auth-Request-*` response headers (forwarded to the backend). +- `--pass-access-token=true` *(optional)* — to forward the access token as `X-Access-Token`. + +See the [oauth2-proxy NGINX integration guide](https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/nginx/) for full configuration details. ### Identity Headers -On successful authentication, the auth server can return identity headers that NGINX forwards to the backend: +When oauth2-proxy runs with `--set-xauthrequest`, NGINX captures its `X-Auth-Request-*` response headers and forwards them to the backend under a **stable header contract** (so the backend sees the same names regardless of the auth provider): -| Header | Description | -|--------|-------------| -| `X-User-ID` | Numeric user ID | -| `X-Username` | Username | -| `X-User-First-Name` | First name | -| `X-User-Last-Name` | Last name | -| `X-Email` | Email address | -| `X-Groups` | Comma-separated group names | +| Header forwarded to backend | Source (oauth2-proxy response) | +|-----------------------------|--------------------------------| +| `X-User` | `X-Auth-Request-User` | +| `X-Email` | `X-Auth-Request-Email` | +| `X-Groups` | `X-Auth-Request-Groups` | +| `X-Access-Token` | `X-Auth-Request-Access-Token` (with `--pass-access-token`) | -### Cookie Sharing +### Cookie Domain -The auth server must be on a subdomain of the external domain (e.g., `manager.example.com` for domain `example.com`). The manager sets its session cookie on the parent domain (`.example.com`) so sibling subdomains share the cookie for `auth_request` subrequests. +Because oauth2-proxy is served on the same hostname as each application (via the `/oauth2/` proxy), its session cookie is scoped to that host by default. To share a single sign-in session across multiple subdomains of the external domain, configure oauth2-proxy with a parent `--cookie-domain` (e.g. `.example.com`) and a `--whitelist-domain` for the redirect targets. ### Flow @@ -147,19 +148,21 @@ The auth server must be on a subdomain of the external domain (e.g., `manager.ex sequenceDiagram participant Client participant NGINX - participant AuthServer as Auth Server + participant OAuth2Proxy as oauth2-proxy participant Backend Client->>NGINX: GET app.example.com/page - NGINX->>AuthServer: GET /verify (subrequest) - alt Authenticated - AuthServer-->>NGINX: 200 + identity headers - NGINX->>Backend: Proxied request + X-User-* headers + NGINX->>OAuth2Proxy: auth_request → GET /oauth2/auth (subrequest) + alt 202 (authenticated) + OAuth2Proxy-->>NGINX: 202 + X-Auth-Request-* headers + NGINX->>Backend: Proxied request + identity headers Backend-->>NGINX: Response NGINX-->>Client: Response - else Not authenticated - AuthServer-->>NGINX: 401 - NGINX-->>Client: 302 → auth server /login?redirect=... + else 401 (unauthenticated) + OAuth2Proxy-->>NGINX: 401 + NGINX-->>Client: 302 → /oauth2/sign_in?rd=original_url end ``` +If **Require auth** is enabled but no **oauth2-proxy URL** is configured on the domain, NGINX serves a 503 "Authentication Unavailable" page. + diff --git a/mie-opensource-landing/docs/admins/installation.md b/mie-opensource-landing/docs/admins/installation.md index 39f802f9..bbb499c9 100644 --- a/mie-opensource-landing/docs/admins/installation.md +++ b/mie-opensource-landing/docs/admins/installation.md @@ -104,7 +104,7 @@ Further reading: [External Domains](core-concepts/external-domains.md). 2. **Default Site**: `First Site` 3. **ACME Email** and **ACME Directory** are currently unused. 4. **Cloudflare API Email** and **Key** are optional unless you are planning to use Cross-Site DNS. - 5. **Auth Server URL**: `https://manager.example.org` (see [Authentication](core-concepts/external-domains.md#authentication)). + 5. **oauth2-proxy URL**: optional — the upstream address of an oauth2-proxy server (e.g. `http://127.0.0.1:4180`) if you want to require authentication for services on this domain (see [Authentication](core-concepts/external-domains.md#authentication)). 3. Select "Create External Domain". 4. Refer to [SSL Certificate Provisioning](core-concepts/external-domains.md#ssl-certificate-provisioning) to configure an HTTPS certificate. diff --git a/mie-opensource-landing/docs/developers/database-schema.md b/mie-opensource-landing/docs/developers/database-schema.md index 9d19e33b..62d21374 100644 --- a/mie-opensource-landing/docs/developers/database-schema.md +++ b/mie-opensource-landing/docs/developers/database-schema.md @@ -103,7 +103,7 @@ erDiagram string cloudflareApiEmail string cloudflareApiKey int siteId FK "nullable, default site" - string authServer "nullable, auth server URL" + string authServer "nullable, oauth2-proxy upstream URL" } Jobs { @@ -183,12 +183,12 @@ LXC container on a Proxmox node. Unique composite index on `(nodeId, containerId ### Service (STI) Base model with `type` discriminator (`http`, `transport`, `dns`). Belongs to Container. -- **HTTPService**: `(externalHostname, externalDomainId)` unique. Belongs to ExternalDomain. `backendProtocol` controls `proxy_pass` scheme (`http` or `https`). `authRequired` enables NGINX `auth_request` — requires the domain's `authServer` to be configured. +- **HTTPService**: `(externalHostname, externalDomainId)` unique. Belongs to ExternalDomain. `backendProtocol` controls `proxy_pass` scheme (`http` or `https`). `authRequired` enables NGINX `auth_request` against the domain's oauth2-proxy — requires the domain's `authServer` to be configured. - **TransportService**: `(protocol, externalPort)` unique. `findNextAvailablePort()` static method. - **DnsService**: SRV records with `serviceName`. ### ExternalDomain -Manages public domains for HTTP service exposure. `siteId` is nullable — when set, indicates the "default site" whose DNS is assumed pre-configured (e.g., wildcard A record). Global resource available to all sites. Has many HTTPServices. Cloudflare credentials used for both ACME DNS-01 challenges and cross-site A record management. `authServer` is an optional URL pointing to an authentication server that implements the NGINX `auth_request` protocol (see [External Domains](../admins/core-concepts/external-domains.md#authentication)). +Manages public domains for HTTP service exposure. `siteId` is nullable — when set, indicates the "default site" whose DNS is assumed pre-configured (e.g., wildcard A record). Global resource available to all sites. Has many HTTPServices. Cloudflare credentials used for both ACME DNS-01 challenges and cross-site A record management. `authServer` is an optional URL pointing to an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) upstream used for NGINX `auth_request` (see [External Domains](../admins/core-concepts/external-domains.md#authentication)). ## User Management Models diff --git a/mie-opensource-landing/docs/developers/system-architecture.md b/mie-opensource-landing/docs/developers/system-architecture.md index c9b2ffd5..c1f066e4 100644 --- a/mie-opensource-landing/docs/developers/system-architecture.md +++ b/mie-opensource-landing/docs/developers/system-architecture.md @@ -144,29 +144,29 @@ sequenceDiagram ### Authenticated HTTP Services -When `authRequired` is enabled on an HTTP service, NGINX uses the [`auth_request`](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module to authenticate requests before proxying. The domain's `authServer` must be configured (see [External Domains](../admins/core-concepts/external-domains.md#authentication)). +When `authRequired` is enabled on an HTTP service, NGINX uses the [`auth_request`](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module to authenticate requests against an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) server before proxying. The domain's `authServer` must point to the oauth2-proxy upstream (see [External Domains](../admins/core-concepts/external-domains.md#authentication)). The manager itself no longer provides forward-auth — administrators run and configure oauth2-proxy. ```mermaid sequenceDiagram participant Client participant NGINX - participant AuthServer as Auth Server + participant OAuth2Proxy as oauth2-proxy participant Container Client->>NGINX: GET app.example.com/page - NGINX->>AuthServer: Subrequest: GET /verify - alt 2xx (authenticated) - AuthServer-->>NGINX: 200 + X-User-* headers + NGINX->>OAuth2Proxy: Subrequest: GET /oauth2/auth + alt 202 (authenticated) + OAuth2Proxy-->>NGINX: 202 + X-Auth-Request-* headers NGINX->>Container: Proxied request + identity headers Container-->>NGINX: Response NGINX-->>Client: Response else 401 (unauthenticated) - AuthServer-->>NGINX: 401 - NGINX-->>Client: 302 → /login?redirect=original_url + OAuth2Proxy-->>NGINX: 401 + NGINX-->>Client: 302 → /oauth2/sign_in?rd=original_url end ``` -NGINX captures identity headers from the auth server subrequest (`X-User-ID`, `X-Username`, `X-User-First-Name`, `X-User-Last-Name`, `X-Email`, `X-Groups`) and forwards them to the backend container via `proxy_set_header`. +NGINX captures identity headers from the oauth2-proxy subrequest (`X-Auth-Request-User`, `X-Auth-Request-Email`, `X-Auth-Request-Groups`, and the access token) and forwards them to the backend container via `proxy_set_header` under a stable contract (`X-User`, `X-Email`, `X-Groups`, `X-Access-Token`). This requires oauth2-proxy to run with `--set-xauthrequest` (and `--pass-access-token` for the token). If `authRequired` is enabled but no `authServer` is configured on the domain, NGINX serves a 503 error page. From 331dbc5110fa371767946a21e8926e3d66a29b80 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 16 Jun 2026 11:16:22 -0400 Subject: [PATCH 02/10] oauth2-proxy auth_request: support central auth host on the same LB The auth server (oauth2-proxy) is expected to be a routable host on the same load balancer (e.g. https://oauth2-proxy.example.com) that many apps delegate to, rather than a per-app loopback upstream. When NGINX proxies the /oauth2/auth subrequest to that host over the load balancer, it now pins `Host` to the auth host's own name (parsed from authServer via `new URL().host`) instead of `$host`. Sending `$host` (the app's hostname) would make the proxied request re-match the app's own server block and loop through auth_request indefinitely; pinning Host routes it to the oauth2-proxy server block. Other changes: - @oauth2_signin now 302s the browser to the auth host's /oauth2/sign_in?rd=; X-Auth-Request-Redirect uses the absolute $scheme://$host$request_uri (multi-domain form) so one oauth2-proxy serves many app hosts. - proxy_ssl_server_name on so SNI matches the pinned Host on the HTTPS hop; drop the now-unused resolver (proxy_pass target is a literal). - Guard against a malformed authServer (unparseable URL) by falling back to the 503 "auth unavailable" page instead of emitting a broken proxy_pass. - authServer now documents/represents the public oauth2-proxy URL; updated model comment, form helper/placeholder, and docs (incl. --cookie-domain/--whitelist-domain guidance, the separate oauth2-proxy server-block requirement, and a loop-protection note). Refs #348 --- .../ExternalDomainFormPage.tsx | 4 +- create-a-container/models/external-domain.js | 2 +- create-a-container/views/nginx-conf.ejs | 75 +++++++++++-------- .../admins/core-concepts/external-domains.md | 25 +++++-- .../docs/admins/installation.md | 2 +- .../docs/developers/database-schema.md | 4 +- .../docs/developers/system-architecture.md | 12 +-- 7 files changed, 75 insertions(+), 49 deletions(-) diff --git a/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx b/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx index f83c74df..6c26b6a0 100644 --- a/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx +++ b/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx @@ -167,8 +167,8 @@ export function ExternalDomainFormPage() { /> {mutation.error && ( diff --git a/create-a-container/models/external-domain.js b/create-a-container/models/external-domain.js index 47fa52ba..681b057c 100644 --- a/create-a-container/models/external-domain.js +++ b/create-a-container/models/external-domain.js @@ -59,7 +59,7 @@ module.exports = (sequelize) => { validate: { isUrl: true }, - comment: 'oauth2-proxy upstream URL for nginx auth_request (e.g., http://127.0.0.1:4180). The administrator must run an oauth2-proxy server configured for this domain.' + comment: 'Public oauth2-proxy URL for nginx auth_request (e.g., https://oauth2-proxy.example.com). A routable host on the same load balancer that proxies to an administrator-run oauth2-proxy; protected services delegate auth to it.' } }, { tableName: 'ExternalDomains', diff --git a/create-a-container/views/nginx-conf.ejs b/create-a-container/views/nginx-conf.ejs index d5bdbec9..8e8cb768 100644 --- a/create-a-container/views/nginx-conf.ejs +++ b/create-a-container/views/nginx-conf.ejs @@ -185,6 +185,20 @@ http { <%_ httpServices.forEach((service, index) => { _%> <%_ const authRequired = service.httpService.authRequired; _%> <%_ const authServer = service.httpService.externalDomain.authServer; _%> + <%_ + // The auth server (oauth2-proxy) is a routable host on this same load + // balancer (e.g. https://oauth2-proxy.example.com). When NGINX proxies the + // /oauth2/* paths to it, it must send Host = the auth host's own name — NOT + // $host — otherwise the proxied request would re-match THIS app's server + // block and loop through auth_request. Parse the host out of authServer so + // we can pin the upstream Host header. Falls back to disabling auth if the + // URL is unparseable. + let authHost = null; + if (authRequired && authServer) { + try { authHost = new URL(authServer).host; } catch (e) { authHost = null; } + } + const authEnabled = authRequired && authServer && authHost; + _%> server { listen 443 ssl; listen [::]:443 ssl; @@ -236,38 +250,37 @@ http { proxy_pass http://error_pages; } - <%_ if (authRequired && authServer) { _%> + <%_ if (authEnabled) { _%> # --- oauth2-proxy integration ------------------------------------------- - # The administrator runs an oauth2-proxy server (configured separately) and - # sets this domain's "oauth2-proxy URL" to its upstream address. NGINX - # authenticates each request against oauth2-proxy's /oauth2/auth endpoint - # using the auth_request module. + # The administrator runs a single oauth2-proxy, published as a routable host + # on this same load balancer (the domain's "oauth2-proxy URL", e.g. + # https://oauth2-proxy.example.com — configured as its own external-domain + # service that proxies to the oauth2-proxy process). Every authenticated app + # on the load balancer delegates to it via auth_request. + # + # IMPORTANT: the subrequest below proxies to the auth host over the load + # balancer, so it MUST send `Host: <%= authHost %>` (the auth host's own + # name) and NOT `$host`. With `$host`, the proxied request would re-match + # THIS app's server block and loop through auth_request indefinitely. # See https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/nginx/ - set $oauth2_proxy <%= authServer %>; - - # Sign-in, callback, and other oauth2-proxy endpoints. - location /oauth2/ { - resolver 127.0.0.1; - proxy_pass $oauth2_proxy; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Scheme $scheme; - proxy_set_header X-Auth-Request-Redirect $scheme://$host$request_uri; - } # Internal auth check. oauth2-proxy returns 202 (authenticated) or 401. # Responses are cached per Cookie+Authorization pair so NGINX only contacts # oauth2-proxy when the credentials change. location = /oauth2/auth { internal; - resolver 127.0.0.1; - proxy_pass $oauth2_proxy; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Scheme $scheme; + proxy_pass <%= authServer %>/oauth2/auth; + # Pin Host to the auth host (loop guard — see note above). + proxy_set_header Host <%= authHost %>; + proxy_ssl_server_name on; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Scheme $scheme; + # Preserve the ORIGINAL app URL so oauth2-proxy returns the user here + # after sign-in (absolute form is required for the multi-domain setup). + proxy_set_header X-Auth-Request-Redirect $scheme://$host$request_uri; # nginx auth_request includes headers but not the request body. - proxy_set_header Content-Length ""; - proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_pass_request_body off; proxy_cache auth_cache; proxy_cache_key "$http_cookie$http_authorization"; @@ -275,12 +288,12 @@ http { proxy_cache_valid 401 30s; } - # Named location that issues a proper 302 redirect to oauth2-proxy's - # sign-in page when auth_request returns 401. The sign-in page is served - # on this same host via the `location /oauth2/` proxy above, so redirect - # to the local path rather than the (internal) upstream address. + # On 401, send the browser to oauth2-proxy's sign-in page on the auth host. + # The browser authenticates there, the session cookie is set on the shared + # parent domain (oauth2-proxy --cookie-domain=.), and `rd` brings the + # user back to this app afterward. location @oauth2_signin { - return 302 /oauth2/sign_in?rd=$scheme://$host$request_uri; + return 302 <%= authServer %>/oauth2/sign_in?rd=$scheme://$host$request_uri; } # Capture identity from the auth_request response. Requires oauth2-proxy to @@ -293,8 +306,8 @@ http { auth_request_set $auth_token $upstream_http_x_auth_request_access_token; <%_ } _%> - <%_ if (authRequired && !authServer) { _%> - # Authentication required but no oauth2-proxy URL configured for this domain + <%_ if (authRequired && !authEnabled) { _%> + # Authentication required but no (valid) oauth2-proxy URL configured for this domain location @auth_unavailable { rewrite ^ /auth-unavailable.html break; proxy_method GET; @@ -310,7 +323,7 @@ http { <%_ } else { _%> # Proxy settings location / { - <%_ if (authRequired && authServer) { _%> + <%_ if (authEnabled) { _%> auth_request /oauth2/auth; error_page 401 = @oauth2_signin; diff --git a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md index 61eb2ebc..8041788b 100644 --- a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md +++ b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md @@ -18,7 +18,7 @@ External domains expose container HTTP services to the internet. Domains are glo | **ACME Directory** | CA endpoint, Let's Encrypt Production/Staging (currently unused) | | **Cloudflare API Email** | Cloudflare account email, sent as the `X-Auth-Email` header — optional unless using Cross-Site DNS | | **Cloudflare API Key** | Cloudflare **User API Token**, sent as `Authorization: Bearer `. Despite the field name, this is *not* the legacy Global API Key. Optional unless using Cross-Site DNS. | -| **oauth2-proxy URL** | Optional — upstream URL of an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) server for NGINX `auth_request`. See [Authentication](#authentication) | +| **oauth2-proxy URL** | Optional — public URL of an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) server (a routable host on this load balancer, e.g. `https://oauth2-proxy.example.com`) for NGINX `auth_request`. See [Authentication](#authentication) | !!! tip Use Let's Encrypt **Staging** for testing — it has higher rate limits. Switch to **Production** once verified. @@ -117,16 +117,27 @@ The manager does **not** provide authentication itself — you must deploy and c ### Configuring oauth2-proxy -Set the domain's **oauth2-proxy URL** to the upstream address of your oauth2-proxy instance (e.g. `http://127.0.0.1:4180`). NGINX proxies the `/oauth2/` paths on each authenticated service's host to this upstream, so the OAuth2 endpoints are served on the same hostname as the application. +A **single** oauth2-proxy instance can authenticate every service on the load balancer. Publish it as its own routable host (e.g. `oauth2-proxy.example.com`) and set the domain's **oauth2-proxy URL** to that public URL (e.g. `https://oauth2-proxy.example.com`). + +1. **Expose oauth2-proxy as its own service.** Create an HTTP service (e.g. hostname `oauth2-proxy` on `example.com`) that proxies to your oauth2-proxy process, and leave **Require auth disabled** on it — it *is* the auth server, so it must never require auth itself (doing so would make it call `auth_request` against itself and loop). +2. **Point protected domains at it.** Set the **oauth2-proxy URL** on each external domain whose services should require auth to `https://oauth2-proxy.example.com`. +3. **Enable Require auth** on the individual services you want protected. + +When a protected service (e.g. `app.example.com`) receives a request, NGINX issues an `auth_request` subrequest to `https://oauth2-proxy.example.com/oauth2/auth`. On `401`, the browser is redirected to `https://oauth2-proxy.example.com/oauth2/sign_in?rd=`; after sign-in the user is sent back to the originating service. Run oauth2-proxy with at least: - `--reverse-proxy=true` — required when behind NGINX. - `--set-xauthrequest=true` — so identity is returned in `X-Auth-Request-*` response headers (forwarded to the backend). +- `--cookie-domain=.example.com` — so the session cookie is shared across all sibling subdomains (required for one oauth2-proxy to serve multiple hosts). +- `--whitelist-domain=.example.com` — so post-sign-in redirects back to your app hosts are allowed. - `--pass-access-token=true` *(optional)* — to forward the access token as `X-Access-Token`. See the [oauth2-proxy NGINX integration guide](https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/nginx/) for full configuration details. +!!! warning "Loop protection" + When NGINX proxies the auth subrequest to `oauth2-proxy.example.com` over the same load balancer, it sends `Host: oauth2-proxy.example.com` (the auth host's own name) — **not** the app's host. This is what makes the request land on the oauth2-proxy server block instead of re-matching the app's block and looping through `auth_request`. The generated config does this automatically; just make sure the **oauth2-proxy URL** is the auth server's own public hostname and that **Require auth is off** on the oauth2-proxy service. + ### Identity Headers When oauth2-proxy runs with `--set-xauthrequest`, NGINX captures its `X-Auth-Request-*` response headers and forwards them to the backend under a **stable header contract** (so the backend sees the same names regardless of the auth provider): @@ -140,7 +151,7 @@ When oauth2-proxy runs with `--set-xauthrequest`, NGINX captures its `X-Auth-Req ### Cookie Domain -Because oauth2-proxy is served on the same hostname as each application (via the `/oauth2/` proxy), its session cookie is scoped to that host by default. To share a single sign-in session across multiple subdomains of the external domain, configure oauth2-proxy with a parent `--cookie-domain` (e.g. `.example.com`) and a `--whitelist-domain` for the redirect targets. +A single oauth2-proxy serving multiple hosts must set its session cookie on the shared parent domain so every app subdomain can present it on the `auth_request` subrequest. Configure oauth2-proxy with `--cookie-domain=.example.com` and `--whitelist-domain=.example.com`. The auth server and all protected services must therefore be subdomains of the same parent domain. ### Flow @@ -148,11 +159,11 @@ Because oauth2-proxy is served on the same hostname as each application (via the sequenceDiagram participant Client participant NGINX - participant OAuth2Proxy as oauth2-proxy + participant OAuth2Proxy as oauth2-proxy.example.com participant Backend Client->>NGINX: GET app.example.com/page - NGINX->>OAuth2Proxy: auth_request → GET /oauth2/auth (subrequest) + NGINX->>OAuth2Proxy: auth_request → GET /oauth2/auth (Host: oauth2-proxy.example.com) alt 202 (authenticated) OAuth2Proxy-->>NGINX: 202 + X-Auth-Request-* headers NGINX->>Backend: Proxied request + identity headers @@ -160,9 +171,9 @@ sequenceDiagram NGINX-->>Client: Response else 401 (unauthenticated) OAuth2Proxy-->>NGINX: 401 - NGINX-->>Client: 302 → /oauth2/sign_in?rd=original_url + NGINX-->>Client: 302 → https://oauth2-proxy.example.com/oauth2/sign_in?rd=https://app.example.com/page end ``` -If **Require auth** is enabled but no **oauth2-proxy URL** is configured on the domain, NGINX serves a 503 "Authentication Unavailable" page. +If **Require auth** is enabled but no (valid) **oauth2-proxy URL** is configured on the domain, NGINX serves a 503 "Authentication Unavailable" page. diff --git a/mie-opensource-landing/docs/admins/installation.md b/mie-opensource-landing/docs/admins/installation.md index bbb499c9..588460d7 100644 --- a/mie-opensource-landing/docs/admins/installation.md +++ b/mie-opensource-landing/docs/admins/installation.md @@ -104,7 +104,7 @@ Further reading: [External Domains](core-concepts/external-domains.md). 2. **Default Site**: `First Site` 3. **ACME Email** and **ACME Directory** are currently unused. 4. **Cloudflare API Email** and **Key** are optional unless you are planning to use Cross-Site DNS. - 5. **oauth2-proxy URL**: optional — the upstream address of an oauth2-proxy server (e.g. `http://127.0.0.1:4180`) if you want to require authentication for services on this domain (see [Authentication](core-concepts/external-domains.md#authentication)). + 5. **oauth2-proxy URL**: optional — the public URL of an oauth2-proxy server (a routable host on this load balancer, e.g. `https://oauth2-proxy.example.org`) if you want to require authentication for services on this domain (see [Authentication](core-concepts/external-domains.md#authentication)). 3. Select "Create External Domain". 4. Refer to [SSL Certificate Provisioning](core-concepts/external-domains.md#ssl-certificate-provisioning) to configure an HTTPS certificate. diff --git a/mie-opensource-landing/docs/developers/database-schema.md b/mie-opensource-landing/docs/developers/database-schema.md index 62d21374..7f8135d1 100644 --- a/mie-opensource-landing/docs/developers/database-schema.md +++ b/mie-opensource-landing/docs/developers/database-schema.md @@ -103,7 +103,7 @@ erDiagram string cloudflareApiEmail string cloudflareApiKey int siteId FK "nullable, default site" - string authServer "nullable, oauth2-proxy upstream URL" + string authServer "nullable, public oauth2-proxy URL" } Jobs { @@ -188,7 +188,7 @@ Base model with `type` discriminator (`http`, `transport`, `dns`). Belongs to Co - **DnsService**: SRV records with `serviceName`. ### ExternalDomain -Manages public domains for HTTP service exposure. `siteId` is nullable — when set, indicates the "default site" whose DNS is assumed pre-configured (e.g., wildcard A record). Global resource available to all sites. Has many HTTPServices. Cloudflare credentials used for both ACME DNS-01 challenges and cross-site A record management. `authServer` is an optional URL pointing to an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) upstream used for NGINX `auth_request` (see [External Domains](../admins/core-concepts/external-domains.md#authentication)). +Manages public domains for HTTP service exposure. `siteId` is nullable — when set, indicates the "default site" whose DNS is assumed pre-configured (e.g., wildcard A record). Global resource available to all sites. Has many HTTPServices. Cloudflare credentials used for both ACME DNS-01 challenges and cross-site A record management. `authServer` is an optional public URL of an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) host (a routable host on the same load balancer) used for NGINX `auth_request` (see [External Domains](../admins/core-concepts/external-domains.md#authentication)). ## User Management Models diff --git a/mie-opensource-landing/docs/developers/system-architecture.md b/mie-opensource-landing/docs/developers/system-architecture.md index c1f066e4..1a584c9e 100644 --- a/mie-opensource-landing/docs/developers/system-architecture.md +++ b/mie-opensource-landing/docs/developers/system-architecture.md @@ -144,17 +144,19 @@ sequenceDiagram ### Authenticated HTTP Services -When `authRequired` is enabled on an HTTP service, NGINX uses the [`auth_request`](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module to authenticate requests against an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) server before proxying. The domain's `authServer` must point to the oauth2-proxy upstream (see [External Domains](../admins/core-concepts/external-domains.md#authentication)). The manager itself no longer provides forward-auth — administrators run and configure oauth2-proxy. +When `authRequired` is enabled on an HTTP service, NGINX uses the [`auth_request`](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module to authenticate requests against an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) server before proxying. The domain's `authServer` is the oauth2-proxy server's public URL — a routable host on the same load balancer, e.g. `https://oauth2-proxy.example.com` (see [External Domains](../admins/core-concepts/external-domains.md#authentication)). The manager itself no longer provides forward-auth — administrators run and configure oauth2-proxy. + +The auth subrequest is proxied to that host **with `Host` pinned to the auth host's own name** (`new URL(authServer).host`), not the app's `$host`. Because the subrequest travels back over the load balancer, using `$host` would re-match the app's own `server` block and loop through `auth_request`; pinning `Host` makes it land on the oauth2-proxy `server` block instead. The redirect target (`X-Auth-Request-Redirect` / `rd=`) uses the absolute `$scheme://$host$request_uri` so one oauth2-proxy can serve many app hosts. ```mermaid sequenceDiagram participant Client participant NGINX - participant OAuth2Proxy as oauth2-proxy + participant OAuth2Proxy as oauth2-proxy.example.com participant Container Client->>NGINX: GET app.example.com/page - NGINX->>OAuth2Proxy: Subrequest: GET /oauth2/auth + NGINX->>OAuth2Proxy: Subrequest: GET /oauth2/auth (Host: oauth2-proxy.example.com) alt 202 (authenticated) OAuth2Proxy-->>NGINX: 202 + X-Auth-Request-* headers NGINX->>Container: Proxied request + identity headers @@ -162,11 +164,11 @@ sequenceDiagram NGINX-->>Client: Response else 401 (unauthenticated) OAuth2Proxy-->>NGINX: 401 - NGINX-->>Client: 302 → /oauth2/sign_in?rd=original_url + NGINX-->>Client: 302 → https://oauth2-proxy.example.com/oauth2/sign_in?rd=https://app.example.com/page end ``` NGINX captures identity headers from the oauth2-proxy subrequest (`X-Auth-Request-User`, `X-Auth-Request-Email`, `X-Auth-Request-Groups`, and the access token) and forwards them to the backend container via `proxy_set_header` under a stable contract (`X-User`, `X-Email`, `X-Groups`, `X-Access-Token`). This requires oauth2-proxy to run with `--set-xauthrequest` (and `--pass-access-token` for the token). -If `authRequired` is enabled but no `authServer` is configured on the domain, NGINX serves a 503 error page. +If `authRequired` is enabled but no (valid) `authServer` is configured on the domain, NGINX serves a 503 error page. From 4d64ad62cb2b2a609c2d5057547079a03834c53d Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 16 Jun 2026 11:30:08 -0400 Subject: [PATCH 03/10] Address PR review: validate authServer in API, use $proxy_host - Move authServer URL validation to the model (API layer): replace the permissive `isUrl: true` (which accepts scheme-less hosts like "oauth2-proxy.example.com") with a custom validator requiring an absolute http(s) URL. A malformed value is now rejected on create/ update with a 422 and never reaches the nginx template. - Drop the template-side `new URL()` parsing / authHost / authEnabled. The auth block again gates on `authRequired && authServer`. - Use `proxy_set_header Host $proxy_host;` (nginx's default, taken from the proxy_pass URL) instead of injecting a parsed host. This keeps the loop guard (Host = the oauth2-proxy host, never $host) without the template re-parsing the URL. Refs #348 --- create-a-container/models/external-domain.js | 17 ++++++++- create-a-container/views/nginx-conf.ejs | 35 +++++++------------ .../admins/core-concepts/external-domains.md | 2 +- .../docs/developers/system-architecture.md | 2 +- 4 files changed, 30 insertions(+), 26 deletions(-) diff --git a/create-a-container/models/external-domain.js b/create-a-container/models/external-domain.js index 681b057c..aeeeca0e 100644 --- a/create-a-container/models/external-domain.js +++ b/create-a-container/models/external-domain.js @@ -57,7 +57,22 @@ module.exports = (sequelize) => { type: DataTypes.STRING, allowNull: true, validate: { - isUrl: true + // Must be an absolute http(s) URL — it is interpolated directly into + // nginx `proxy_pass`, which requires a scheme. Reject scheme-less hosts + // (e.g. "oauth2-proxy.example.com") and non-http schemes here so a bad + // value can never reach the generated config. + isHttpUrl(value) { + if (value === null || value === undefined || value === '') return; + let url; + try { + url = new URL(value); + } catch (e) { + throw new Error('authServer must be an absolute URL, e.g. https://oauth2-proxy.example.com'); + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('authServer must use http or https'); + } + } }, comment: 'Public oauth2-proxy URL for nginx auth_request (e.g., https://oauth2-proxy.example.com). A routable host on the same load balancer that proxies to an administrator-run oauth2-proxy; protected services delegate auth to it.' } diff --git a/create-a-container/views/nginx-conf.ejs b/create-a-container/views/nginx-conf.ejs index 8e8cb768..d369b905 100644 --- a/create-a-container/views/nginx-conf.ejs +++ b/create-a-container/views/nginx-conf.ejs @@ -185,20 +185,6 @@ http { <%_ httpServices.forEach((service, index) => { _%> <%_ const authRequired = service.httpService.authRequired; _%> <%_ const authServer = service.httpService.externalDomain.authServer; _%> - <%_ - // The auth server (oauth2-proxy) is a routable host on this same load - // balancer (e.g. https://oauth2-proxy.example.com). When NGINX proxies the - // /oauth2/* paths to it, it must send Host = the auth host's own name — NOT - // $host — otherwise the proxied request would re-match THIS app's server - // block and loop through auth_request. Parse the host out of authServer so - // we can pin the upstream Host header. Falls back to disabling auth if the - // URL is unparseable. - let authHost = null; - if (authRequired && authServer) { - try { authHost = new URL(authServer).host; } catch (e) { authHost = null; } - } - const authEnabled = authRequired && authServer && authHost; - _%> server { listen 443 ssl; listen [::]:443 ssl; @@ -250,7 +236,7 @@ http { proxy_pass http://error_pages; } - <%_ if (authEnabled) { _%> + <%_ if (authRequired && authServer) { _%> # --- oauth2-proxy integration ------------------------------------------- # The administrator runs a single oauth2-proxy, published as a routable host # on this same load balancer (the domain's "oauth2-proxy URL", e.g. @@ -259,9 +245,11 @@ http { # on the load balancer delegates to it via auth_request. # # IMPORTANT: the subrequest below proxies to the auth host over the load - # balancer, so it MUST send `Host: <%= authHost %>` (the auth host's own - # name) and NOT `$host`. With `$host`, the proxied request would re-match - # THIS app's server block and loop through auth_request indefinitely. + # balancer, so it MUST send the auth host's own name as `Host` and NOT + # `$host`. With `$host`, the proxied request would re-match THIS app's server + # block and loop through auth_request indefinitely. We use `$proxy_host` + # (nginx's default Host, taken from the proxy_pass URL) so the Host always + # matches the auth host without re-parsing the URL ourselves. # See https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/nginx/ # Internal auth check. oauth2-proxy returns 202 (authenticated) or 401. @@ -270,8 +258,9 @@ http { location = /oauth2/auth { internal; proxy_pass <%= authServer %>/oauth2/auth; - # Pin Host to the auth host (loop guard — see note above). - proxy_set_header Host <%= authHost %>; + # Pin Host to the auth host (loop guard — see note above). $proxy_host is + # the host from the proxy_pass URL, i.e. the oauth2-proxy host, never $host. + proxy_set_header Host $proxy_host; proxy_ssl_server_name on; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Scheme $scheme; @@ -306,8 +295,8 @@ http { auth_request_set $auth_token $upstream_http_x_auth_request_access_token; <%_ } _%> - <%_ if (authRequired && !authEnabled) { _%> - # Authentication required but no (valid) oauth2-proxy URL configured for this domain + <%_ if (authRequired && !authServer) { _%> + # Authentication required but no oauth2-proxy URL configured for this domain location @auth_unavailable { rewrite ^ /auth-unavailable.html break; proxy_method GET; @@ -323,7 +312,7 @@ http { <%_ } else { _%> # Proxy settings location / { - <%_ if (authEnabled) { _%> + <%_ if (authRequired && authServer) { _%> auth_request /oauth2/auth; error_page 401 = @oauth2_signin; diff --git a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md index 8041788b..e751770d 100644 --- a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md +++ b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md @@ -175,5 +175,5 @@ sequenceDiagram end ``` -If **Require auth** is enabled but no (valid) **oauth2-proxy URL** is configured on the domain, NGINX serves a 503 "Authentication Unavailable" page. +If **Require auth** is enabled but no **oauth2-proxy URL** is configured on the domain, NGINX serves a 503 "Authentication Unavailable" page. diff --git a/mie-opensource-landing/docs/developers/system-architecture.md b/mie-opensource-landing/docs/developers/system-architecture.md index 1a584c9e..aa38918d 100644 --- a/mie-opensource-landing/docs/developers/system-architecture.md +++ b/mie-opensource-landing/docs/developers/system-architecture.md @@ -170,5 +170,5 @@ sequenceDiagram NGINX captures identity headers from the oauth2-proxy subrequest (`X-Auth-Request-User`, `X-Auth-Request-Email`, `X-Auth-Request-Groups`, and the access token) and forwards them to the backend container via `proxy_set_header` under a stable contract (`X-User`, `X-Email`, `X-Groups`, `X-Access-Token`). This requires oauth2-proxy to run with `--set-xauthrequest` (and `--pass-access-token` for the token). -If `authRequired` is enabled but no (valid) `authServer` is configured on the domain, NGINX serves a 503 error page. +If `authRequired` is enabled but no `authServer` is configured on the domain, NGINX serves a 503 error page. (A malformed `authServer` cannot reach the template — it is rejected by the API on write.) From 553325f52e849f668b9f82d1fa256b30c42ba574 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 16 Jun 2026 11:52:15 -0400 Subject: [PATCH 04/10] cleanup --- .../ExternalDomainFormPage.tsx | 2 +- create-a-container/models/external-domain.js | 2 +- create-a-container/server.js | 4 --- create-a-container/views/nginx-conf.ejs | 35 +------------------ 4 files changed, 3 insertions(+), 40 deletions(-) diff --git a/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx b/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx index 6c26b6a0..e6031452 100644 --- a/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx +++ b/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx @@ -168,7 +168,7 @@ export function ExternalDomainFormPage() { {mutation.error && ( diff --git a/create-a-container/models/external-domain.js b/create-a-container/models/external-domain.js index aeeeca0e..2f5ded01 100644 --- a/create-a-container/models/external-domain.js +++ b/create-a-container/models/external-domain.js @@ -74,7 +74,7 @@ module.exports = (sequelize) => { } } }, - comment: 'Public oauth2-proxy URL for nginx auth_request (e.g., https://oauth2-proxy.example.com). A routable host on the same load balancer that proxies to an administrator-run oauth2-proxy; protected services delegate auth to it.' + comment: 'oauth2-proxy URL for nginx auth_request (e.g., https://oauth2-proxy.example.com)' } }, { tableName: 'ExternalDomains', diff --git a/create-a-container/server.js b/create-a-container/server.js index 73726e01..62b4aee0 100644 --- a/create-a-container/server.js +++ b/create-a-container/server.js @@ -57,10 +57,6 @@ async function main() { store: sessionStore, resave: false, saveUninitialized: false, - // The manager's session cookie only needs to be valid for the manager - // host itself — forward-auth for other subdomains is handled by an - // external oauth2-proxy server, which manages its own cookies. We leave - // the cookie scoped to the exact host (no `domain` attribute). // `secure` is derived from the request protocol (honoring `trust proxy` // and X-Forwarded-Proto from nginx) rather than NODE_ENV, so the flag // tracks the actual transport — set on HTTPS, omitted on plain HTTP diff --git a/create-a-container/views/nginx-conf.ejs b/create-a-container/views/nginx-conf.ejs index d369b905..5e75c77d 100644 --- a/create-a-container/views/nginx-conf.ejs +++ b/create-a-container/views/nginx-conf.ejs @@ -237,37 +237,14 @@ http { } <%_ if (authRequired && authServer) { _%> - # --- oauth2-proxy integration ------------------------------------------- - # The administrator runs a single oauth2-proxy, published as a routable host - # on this same load balancer (the domain's "oauth2-proxy URL", e.g. - # https://oauth2-proxy.example.com — configured as its own external-domain - # service that proxies to the oauth2-proxy process). Every authenticated app - # on the load balancer delegates to it via auth_request. - # - # IMPORTANT: the subrequest below proxies to the auth host over the load - # balancer, so it MUST send the auth host's own name as `Host` and NOT - # `$host`. With `$host`, the proxied request would re-match THIS app's server - # block and loop through auth_request indefinitely. We use `$proxy_host` - # (nginx's default Host, taken from the proxy_pass URL) so the Host always - # matches the auth host without re-parsing the URL ourselves. - # See https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/nginx/ - - # Internal auth check. oauth2-proxy returns 202 (authenticated) or 401. - # Responses are cached per Cookie+Authorization pair so NGINX only contacts - # oauth2-proxy when the credentials change. location = /oauth2/auth { internal; proxy_pass <%= authServer %>/oauth2/auth; - # Pin Host to the auth host (loop guard — see note above). $proxy_host is - # the host from the proxy_pass URL, i.e. the oauth2-proxy host, never $host. proxy_set_header Host $proxy_host; proxy_ssl_server_name on; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Scheme $scheme; - # Preserve the ORIGINAL app URL so oauth2-proxy returns the user here - # after sign-in (absolute form is required for the multi-domain setup). proxy_set_header X-Auth-Request-Redirect $scheme://$host$request_uri; - # nginx auth_request includes headers but not the request body. proxy_set_header Content-Length ""; proxy_pass_request_body off; @@ -277,18 +254,10 @@ http { proxy_cache_valid 401 30s; } - # On 401, send the browser to oauth2-proxy's sign-in page on the auth host. - # The browser authenticates there, the session cookie is set on the shared - # parent domain (oauth2-proxy --cookie-domain=.), and `rd` brings the - # user back to this app afterward. location @oauth2_signin { return 302 <%= authServer %>/oauth2/sign_in?rd=$scheme://$host$request_uri; } - # Capture identity from the auth_request response. Requires oauth2-proxy to - # run with --set-xauthrequest (and --pass-access-token for the access token). - # oauth2-proxy returns these as X-Auth-Request-* headers; we forward them to - # the backend under the stable X-User / X-Email contract (see below). auth_request_set $auth_user $upstream_http_x_auth_request_user; auth_request_set $auth_email $upstream_http_x_auth_request_email; auth_request_set $auth_groups $upstream_http_x_auth_request_groups; @@ -316,9 +285,7 @@ http { auth_request /oauth2/auth; error_page 401 = @oauth2_signin; - # Forward identity to the backend under a stable header contract. - # (oauth2-proxy's own X-Auth-Request-* names are mapped here so the - # backend contract stays the same regardless of the auth provider.) + # Forward user identity from auth subrequest to backend proxy_set_header X-User $auth_user; proxy_set_header X-Email $auth_email; proxy_set_header X-Groups $auth_groups; From 254dbfd81c94ba994bb2d97196a51e6b61e482ce Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 16 Jun 2026 12:08:25 -0400 Subject: [PATCH 05/10] Add X-Forwarded-Uri to the oauth2-proxy auth subrequest oauth2-proxy's nginx auth_request integration expects X-Forwarded-Uri on the /oauth2/auth subrequest so it can reconstruct the original request URI for redirect/upstream context. Matches the upstream example config. Refs #348 --- create-a-container/views/nginx-conf.ejs | 1 + 1 file changed, 1 insertion(+) diff --git a/create-a-container/views/nginx-conf.ejs b/create-a-container/views/nginx-conf.ejs index 5e75c77d..ca3d3385 100644 --- a/create-a-container/views/nginx-conf.ejs +++ b/create-a-container/views/nginx-conf.ejs @@ -244,6 +244,7 @@ http { proxy_ssl_server_name on; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Scheme $scheme; + proxy_set_header X-Forwarded-Uri $request_uri; proxy_set_header X-Auth-Request-Redirect $scheme://$host$request_uri; proxy_set_header Content-Length ""; proxy_pass_request_body off; From db2f1f6ab92cefa1f3ee1272e22062ac58ea6d06 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 16 Jun 2026 17:07:44 -0400 Subject: [PATCH 06/10] Proxy /oauth2/* to oauth2-proxy in a single hop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run oauth2-proxy as a standalone process on its own address (authServer, e.g. http://127.0.0.1:4180) and proxy the whole /oauth2/* subtree — plus the auth_request check at /oauth2/auth — straight to it, passing the app's own Host through. oauth2-proxy then terminates these requests directly, so it builds redirect URIs / cookies against the correct app hostname and needs no --reverse-proxy, --real-ip-from, or X-Forwarded-* headers. This removes the previous two-hop design (app -> routable oauth2-proxy vhost -> process), which let the proxy's own server block clobber X-Forwarded-Host and produced redirect_uris on the proxy's hostname. It also drops the Host-pinning loop guard and proxy_ssl_server_name, which are no longer needed. Admins who want oauth2-proxy behind the same load-balancer IP can expose its port via an L4 (stream{}) passthrough. Request scheme follows the authServer protocol; pair an http:// upstream with --force-https / --cookie-secure for HTTPS browsers. Updates the authServer validator/comment, the form helper, and the docs accordingly. Refs #348 --- .../ExternalDomainFormPage.tsx | 4 +- create-a-container/models/external-domain.js | 8 ++-- create-a-container/views/nginx-conf.ejs | 27 ++++++++----- .../admins/core-concepts/external-domains.md | 38 +++++++++++-------- .../docs/admins/installation.md | 2 +- .../docs/developers/database-schema.md | 4 +- .../docs/developers/system-architecture.md | 10 ++--- 7 files changed, 54 insertions(+), 39 deletions(-) diff --git a/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx b/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx index e6031452..a3f1cf14 100644 --- a/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx +++ b/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx @@ -167,8 +167,8 @@ export function ExternalDomainFormPage() { /> {mutation.error && ( diff --git a/create-a-container/models/external-domain.js b/create-a-container/models/external-domain.js index 2f5ded01..62d0ff8b 100644 --- a/create-a-container/models/external-domain.js +++ b/create-a-container/models/external-domain.js @@ -59,22 +59,22 @@ module.exports = (sequelize) => { validate: { // Must be an absolute http(s) URL — it is interpolated directly into // nginx `proxy_pass`, which requires a scheme. Reject scheme-less hosts - // (e.g. "oauth2-proxy.example.com") and non-http schemes here so a bad - // value can never reach the generated config. + // (e.g. "oauth2-proxy:4180") and non-http schemes here so a bad value + // can never reach the generated config. isHttpUrl(value) { if (value === null || value === undefined || value === '') return; let url; try { url = new URL(value); } catch (e) { - throw new Error('authServer must be an absolute URL, e.g. https://oauth2-proxy.example.com'); + throw new Error('authServer must be an absolute URL, e.g. http://127.0.0.1:4180'); } if (url.protocol !== 'http:' && url.protocol !== 'https:') { throw new Error('authServer must use http or https'); } } }, - comment: 'oauth2-proxy URL for nginx auth_request (e.g., https://oauth2-proxy.example.com)' + comment: "Address of the oauth2-proxy process for nginx auth_request, e.g. http://127.0.0.1:4180. nginx proxies /oauth2/* straight to it in a single hop; do not point this at a path-prefixed URL." } }, { tableName: 'ExternalDomains', diff --git a/create-a-container/views/nginx-conf.ejs b/create-a-container/views/nginx-conf.ejs index ca3d3385..66803561 100644 --- a/create-a-container/views/nginx-conf.ejs +++ b/create-a-container/views/nginx-conf.ejs @@ -237,17 +237,26 @@ http { } <%_ if (authRequired && authServer) { _%> + # oauth2-proxy runs on its own address (this domain's "oauth2-proxy URL"). + # We proxy the whole /oauth2/* subtree straight to it in a single hop and + # pass the app's own Host through, so oauth2-proxy builds redirect URIs and + # cookies against this app's hostname. Because it terminates these requests + # directly (no proxy in front of it from its point of view), oauth2-proxy does + # NOT need --reverse-proxy or any X-Forwarded-* headers. The request scheme is + # whatever <%= authServer %> uses; pair an http:// upstream with + # --force-https / --cookie-secure on oauth2-proxy if the browser is on HTTPS. + location /oauth2/ { + proxy_pass <%= authServer %>; + proxy_set_header Host $host; + } + location = /oauth2/auth { internal; proxy_pass <%= authServer %>/oauth2/auth; - proxy_set_header Host $proxy_host; - proxy_ssl_server_name on; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Scheme $scheme; - proxy_set_header X-Forwarded-Uri $request_uri; - proxy_set_header X-Auth-Request-Redirect $scheme://$host$request_uri; - proxy_set_header Content-Length ""; - proxy_pass_request_body off; + proxy_set_header Host $host; + # nginx auth_request includes headers but not the request body. + proxy_set_header Content-Length ""; + proxy_pass_request_body off; proxy_cache auth_cache; proxy_cache_key "$http_cookie$http_authorization"; @@ -256,7 +265,7 @@ http { } location @oauth2_signin { - return 302 <%= authServer %>/oauth2/sign_in?rd=$scheme://$host$request_uri; + return 302 /oauth2/sign_in?rd=$scheme://$host$request_uri; } auth_request_set $auth_user $upstream_http_x_auth_request_user; diff --git a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md index e751770d..ba7f358a 100644 --- a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md +++ b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md @@ -18,7 +18,7 @@ External domains expose container HTTP services to the internet. Domains are glo | **ACME Directory** | CA endpoint, Let's Encrypt Production/Staging (currently unused) | | **Cloudflare API Email** | Cloudflare account email, sent as the `X-Auth-Email` header — optional unless using Cross-Site DNS | | **Cloudflare API Key** | Cloudflare **User API Token**, sent as `Authorization: Bearer `. Despite the field name, this is *not* the legacy Global API Key. Optional unless using Cross-Site DNS. | -| **oauth2-proxy URL** | Optional — public URL of an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) server (a routable host on this load balancer, e.g. `https://oauth2-proxy.example.com`) for NGINX `auth_request`. See [Authentication](#authentication) | +| **oauth2-proxy URL** | Optional — address of an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) process (e.g. `http://127.0.0.1:4180`) that NGINX proxies `/oauth2/*` to for `auth_request`. See [Authentication](#authentication) | !!! tip Use Let's Encrypt **Staging** for testing — it has higher rate limits. Switch to **Production** once verified. @@ -117,26 +117,31 @@ The manager does **not** provide authentication itself — you must deploy and c ### Configuring oauth2-proxy -A **single** oauth2-proxy instance can authenticate every service on the load balancer. Publish it as its own routable host (e.g. `oauth2-proxy.example.com`) and set the domain's **oauth2-proxy URL** to that public URL (e.g. `https://oauth2-proxy.example.com`). +Run oauth2-proxy as a standalone process listening on its own address, and set the domain's **oauth2-proxy URL** to that address (e.g. `http://127.0.0.1:4180`). NGINX proxies the whole `/oauth2/*` path on each protected service straight to that address in a **single hop**, so to the browser the OAuth2 endpoints appear under the app's own hostname (`app.example.com/oauth2/...`) while actually being served by oauth2-proxy. -1. **Expose oauth2-proxy as its own service.** Create an HTTP service (e.g. hostname `oauth2-proxy` on `example.com`) that proxies to your oauth2-proxy process, and leave **Require auth disabled** on it — it *is* the auth server, so it must never require auth itself (doing so would make it call `auth_request` against itself and loop). -2. **Point protected domains at it.** Set the **oauth2-proxy URL** on each external domain whose services should require auth to `https://oauth2-proxy.example.com`. +1. **Run oauth2-proxy** on a fixed address reachable from the NGINX host (loopback if co-located, e.g. `http://127.0.0.1:4180`, or a private host/port). +2. **Point protected domains at it.** Set the **oauth2-proxy URL** on each external domain whose services should require auth. 3. **Enable Require auth** on the individual services you want protected. -When a protected service (e.g. `app.example.com`) receives a request, NGINX issues an `auth_request` subrequest to `https://oauth2-proxy.example.com/oauth2/auth`. On `401`, the browser is redirected to `https://oauth2-proxy.example.com/oauth2/sign_in?rd=`; after sign-in the user is sent back to the originating service. +A single oauth2-proxy instance can serve many services this way — they all proxy `/oauth2/*` to the same address. Because NGINX passes each app's own `Host` through, oauth2-proxy builds redirect URIs and cookies against the correct app hostname without any extra configuration. + +!!! note "Putting oauth2-proxy behind the same load balancer" + oauth2-proxy does **not** need to be on the NGINX host. If you want it to live behind the same load-balancer IP, expose its port with an L4 (TCP) passthrough — e.g. a **transport service**, which NGINX serves via its `stream {}` block — and point the **oauth2-proxy URL** at that address. The `/oauth2/*` traffic still reaches oauth2-proxy in a single hop, so none of the header/`--reverse-proxy` handling below is required. Run oauth2-proxy with at least: -- `--reverse-proxy=true` — required when behind NGINX. - `--set-xauthrequest=true` — so identity is returned in `X-Auth-Request-*` response headers (forwarded to the backend). -- `--cookie-domain=.example.com` — so the session cookie is shared across all sibling subdomains (required for one oauth2-proxy to serve multiple hosts). -- `--whitelist-domain=.example.com` — so post-sign-in redirects back to your app hosts are allowed. - `--pass-access-token=true` *(optional)* — to forward the access token as `X-Access-Token`. -See the [oauth2-proxy NGINX integration guide](https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/nginx/) for full configuration details. +Because NGINX proxies straight to oauth2-proxy (nothing sits in front of it from its point of view), you do **not** need `--reverse-proxy`, `--real-ip-from`, or any `X-Forwarded-*` headers. + +!!! warning "HTTPS scheme" + oauth2-proxy builds its `redirect_uri` and secure cookies from the scheme of the connection it receives. The scheme is whatever you put in the **oauth2-proxy URL**: -!!! warning "Loop protection" - When NGINX proxies the auth subrequest to `oauth2-proxy.example.com` over the same load balancer, it sends `Host: oauth2-proxy.example.com` (the auth host's own name) — **not** the app's host. This is what makes the request land on the oauth2-proxy server block instead of re-matching the app's block and looping through `auth_request`. The generated config does this automatically; just make sure the **oauth2-proxy URL** is the auth server's own public hostname and that **Require auth is off** on the oauth2-proxy service. + - **`http://…` upstream** (most common, e.g. `http://127.0.0.1:4180`): oauth2-proxy sees a plain-HTTP connection. Run it with `--force-https=true` and `--cookie-secure=true` so it still emits `https://` redirect URIs and secure cookies for HTTPS browsers. + - **`https://…` upstream**: terminate TLS on the oauth2-proxy listener; oauth2-proxy infers HTTPS from the connection directly. + +See the [oauth2-proxy NGINX integration guide](https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/nginx/) for full configuration details. ### Identity Headers @@ -149,9 +154,9 @@ When oauth2-proxy runs with `--set-xauthrequest`, NGINX captures its `X-Auth-Req | `X-Groups` | `X-Auth-Request-Groups` | | `X-Access-Token` | `X-Auth-Request-Access-Token` (with `--pass-access-token`) | -### Cookie Domain +### Sharing one sign-in across subdomains -A single oauth2-proxy serving multiple hosts must set its session cookie on the shared parent domain so every app subdomain can present it on the `auth_request` subrequest. Configure oauth2-proxy with `--cookie-domain=.example.com` and `--whitelist-domain=.example.com`. The auth server and all protected services must therefore be subdomains of the same parent domain. +Because `/oauth2/*` is served on each app's own hostname, the oauth2-proxy cookie is scoped to that host by default. To share a single sign-in across multiple subdomains, run oauth2-proxy with `--cookie-domain=.example.com` and `--whitelist-domain=.example.com`, and make the protected services subdomains of the same parent domain. ### Flow @@ -159,11 +164,11 @@ A single oauth2-proxy serving multiple hosts must set its session cookie on the sequenceDiagram participant Client participant NGINX - participant OAuth2Proxy as oauth2-proxy.example.com + participant OAuth2Proxy as oauth2-proxy participant Backend Client->>NGINX: GET app.example.com/page - NGINX->>OAuth2Proxy: auth_request → GET /oauth2/auth (Host: oauth2-proxy.example.com) + NGINX->>OAuth2Proxy: auth_request → GET /oauth2/auth (Host: app.example.com) alt 202 (authenticated) OAuth2Proxy-->>NGINX: 202 + X-Auth-Request-* headers NGINX->>Backend: Proxied request + identity headers @@ -171,7 +176,8 @@ sequenceDiagram NGINX-->>Client: Response else 401 (unauthenticated) OAuth2Proxy-->>NGINX: 401 - NGINX-->>Client: 302 → https://oauth2-proxy.example.com/oauth2/sign_in?rd=https://app.example.com/page + NGINX-->>Client: 302 → app.example.com/oauth2/sign_in?rd=https://app.example.com/page + Note over Client,OAuth2Proxy: /oauth2/* is proxied to oauth2-proxy in one hop end ``` diff --git a/mie-opensource-landing/docs/admins/installation.md b/mie-opensource-landing/docs/admins/installation.md index 588460d7..0e518900 100644 --- a/mie-opensource-landing/docs/admins/installation.md +++ b/mie-opensource-landing/docs/admins/installation.md @@ -104,7 +104,7 @@ Further reading: [External Domains](core-concepts/external-domains.md). 2. **Default Site**: `First Site` 3. **ACME Email** and **ACME Directory** are currently unused. 4. **Cloudflare API Email** and **Key** are optional unless you are planning to use Cross-Site DNS. - 5. **oauth2-proxy URL**: optional — the public URL of an oauth2-proxy server (a routable host on this load balancer, e.g. `https://oauth2-proxy.example.org`) if you want to require authentication for services on this domain (see [Authentication](core-concepts/external-domains.md#authentication)). + 5. **oauth2-proxy URL**: optional — the address of an oauth2-proxy process (e.g. `http://127.0.0.1:4180`) if you want to require authentication for services on this domain (see [Authentication](core-concepts/external-domains.md#authentication)). 3. Select "Create External Domain". 4. Refer to [SSL Certificate Provisioning](core-concepts/external-domains.md#ssl-certificate-provisioning) to configure an HTTPS certificate. diff --git a/mie-opensource-landing/docs/developers/database-schema.md b/mie-opensource-landing/docs/developers/database-schema.md index 7f8135d1..5436ed7f 100644 --- a/mie-opensource-landing/docs/developers/database-schema.md +++ b/mie-opensource-landing/docs/developers/database-schema.md @@ -103,7 +103,7 @@ erDiagram string cloudflareApiEmail string cloudflareApiKey int siteId FK "nullable, default site" - string authServer "nullable, public oauth2-proxy URL" + string authServer "nullable, oauth2-proxy process address" } Jobs { @@ -188,7 +188,7 @@ Base model with `type` discriminator (`http`, `transport`, `dns`). Belongs to Co - **DnsService**: SRV records with `serviceName`. ### ExternalDomain -Manages public domains for HTTP service exposure. `siteId` is nullable — when set, indicates the "default site" whose DNS is assumed pre-configured (e.g., wildcard A record). Global resource available to all sites. Has many HTTPServices. Cloudflare credentials used for both ACME DNS-01 challenges and cross-site A record management. `authServer` is an optional public URL of an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) host (a routable host on the same load balancer) used for NGINX `auth_request` (see [External Domains](../admins/core-concepts/external-domains.md#authentication)). +Manages public domains for HTTP service exposure. `siteId` is nullable — when set, indicates the "default site" whose DNS is assumed pre-configured (e.g., wildcard A record). Global resource available to all sites. Has many HTTPServices. Cloudflare credentials used for both ACME DNS-01 challenges and cross-site A record management. `authServer` is an optional address of an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) process (e.g. `http://127.0.0.1:4180`) that NGINX proxies `/oauth2/*` to for `auth_request` (see [External Domains](../admins/core-concepts/external-domains.md#authentication)). ## User Management Models diff --git a/mie-opensource-landing/docs/developers/system-architecture.md b/mie-opensource-landing/docs/developers/system-architecture.md index aa38918d..a0897fef 100644 --- a/mie-opensource-landing/docs/developers/system-architecture.md +++ b/mie-opensource-landing/docs/developers/system-architecture.md @@ -144,19 +144,19 @@ sequenceDiagram ### Authenticated HTTP Services -When `authRequired` is enabled on an HTTP service, NGINX uses the [`auth_request`](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module to authenticate requests against an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) server before proxying. The domain's `authServer` is the oauth2-proxy server's public URL — a routable host on the same load balancer, e.g. `https://oauth2-proxy.example.com` (see [External Domains](../admins/core-concepts/external-domains.md#authentication)). The manager itself no longer provides forward-auth — administrators run and configure oauth2-proxy. +When `authRequired` is enabled on an HTTP service, NGINX uses the [`auth_request`](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module to authenticate requests against an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) process before proxying. The domain's `authServer` is the address of that process, e.g. `http://127.0.0.1:4180` (see [External Domains](../admins/core-concepts/external-domains.md#authentication)). The manager itself no longer provides forward-auth — administrators run and configure oauth2-proxy. -The auth subrequest is proxied to that host **with `Host` pinned to the auth host's own name** (`new URL(authServer).host`), not the app's `$host`. Because the subrequest travels back over the load balancer, using `$host` would re-match the app's own `server` block and loop through `auth_request`; pinning `Host` makes it land on the oauth2-proxy `server` block instead. The redirect target (`X-Auth-Request-Redirect` / `rd=`) uses the absolute `$scheme://$host$request_uri` so one oauth2-proxy can serve many app hosts. +NGINX proxies the whole `/oauth2/*` subtree (and the `auth_request` check at `/oauth2/auth`) straight to `authServer` in a **single hop**, passing the app's own `Host` through. Because oauth2-proxy terminates these requests directly, it builds redirect URIs and cookies against the correct app hostname and needs no `--reverse-proxy` / `X-Forwarded-*` handling. If the admin wants oauth2-proxy behind the same load-balancer IP, they expose its port via an L4 (`stream {}`) passthrough; it is never fronted by a generated `server` block, so there is no second hop to clobber headers. ```mermaid sequenceDiagram participant Client participant NGINX - participant OAuth2Proxy as oauth2-proxy.example.com + participant OAuth2Proxy as oauth2-proxy participant Container Client->>NGINX: GET app.example.com/page - NGINX->>OAuth2Proxy: Subrequest: GET /oauth2/auth (Host: oauth2-proxy.example.com) + NGINX->>OAuth2Proxy: Subrequest: GET /oauth2/auth (Host: app.example.com) alt 202 (authenticated) OAuth2Proxy-->>NGINX: 202 + X-Auth-Request-* headers NGINX->>Container: Proxied request + identity headers @@ -164,7 +164,7 @@ sequenceDiagram NGINX-->>Client: Response else 401 (unauthenticated) OAuth2Proxy-->>NGINX: 401 - NGINX-->>Client: 302 → https://oauth2-proxy.example.com/oauth2/sign_in?rd=https://app.example.com/page + NGINX-->>Client: 302 → app.example.com/oauth2/sign_in?rd=https://app.example.com/page end ``` From db80fa9ca8d25a0bfb23f209ed54617b39397a2c Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 16 Jun 2026 17:20:02 -0400 Subject: [PATCH 07/10] Fix @502 on protected domains; document redis session store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the auth_request_set directives from server scope into `location /` (alongside auth_request). At server scope they were evaluated for the error_page named locations (@502/@403) too, which have no auth subrequest — breaking those internal redirects, so a signed-in user hitting a backend 502 got nginx's default page instead of @502. This matches the upstream oauth2-proxy nginx example, which keeps auth_request_set inside location /. Also document --session-store-type=redis as the fix for oversized _oauth2_proxy cookies (the default cookie store packs all tokens into the cookie and can exceed NGINX's header buffers). Refs #348 --- create-a-container/views/nginx-conf.ejs | 16 ++++++++++------ .../admins/core-concepts/external-domains.md | 12 ++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/create-a-container/views/nginx-conf.ejs b/create-a-container/views/nginx-conf.ejs index 66803561..684dd719 100644 --- a/create-a-container/views/nginx-conf.ejs +++ b/create-a-container/views/nginx-conf.ejs @@ -268,11 +268,6 @@ http { return 302 /oauth2/sign_in?rd=$scheme://$host$request_uri; } - auth_request_set $auth_user $upstream_http_x_auth_request_user; - auth_request_set $auth_email $upstream_http_x_auth_request_email; - auth_request_set $auth_groups $upstream_http_x_auth_request_groups; - auth_request_set $auth_token $upstream_http_x_auth_request_access_token; - <%_ } _%> <%_ if (authRequired && !authServer) { _%> # Authentication required but no oauth2-proxy URL configured for this domain @@ -295,7 +290,16 @@ http { auth_request /oauth2/auth; error_page 401 = @oauth2_signin; - # Forward user identity from auth subrequest to backend + # Capture identity from the auth subrequest and forward it to the backend. + # These auth_request_set directives must live here (alongside auth_request), + # not at server scope — otherwise nginx evaluates them for error_page + # locations like @502 that have no auth subrequest, which breaks those + # internal redirects. + auth_request_set $auth_user $upstream_http_x_auth_request_user; + auth_request_set $auth_email $upstream_http_x_auth_request_email; + auth_request_set $auth_groups $upstream_http_x_auth_request_groups; + auth_request_set $auth_token $upstream_http_x_auth_request_access_token; + proxy_set_header X-User $auth_user; proxy_set_header X-Email $auth_email; proxy_set_header X-Groups $auth_groups; diff --git a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md index ba7f358a..b5707d57 100644 --- a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md +++ b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md @@ -143,6 +143,18 @@ Because NGINX proxies straight to oauth2-proxy (nothing sits in front of it from See the [oauth2-proxy NGINX integration guide](https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/nginx/) for full configuration details. +!!! warning "Large session cookies" + With the default **cookie** session store, oauth2-proxy packs the entire encrypted session (access, refresh, and ID tokens plus claims) into the `_oauth2_proxy` cookie, sent on every request. This easily exceeds 4 KB and can trip NGINX header-buffer limits (e.g. a `502` on the `/oauth2/callback` response). + + Use the **Redis** session store so only a small ticket is stored in the cookie: + + ``` + --session-store-type=redis + --redis-connection-url=redis://:6379 + ``` + + If you cannot run Redis, reduce what the cookie carries instead: drop `--pass-access-token` / `--set-authorization-header` if you do not need the token downstream, and request only the scopes you use. See the [session storage docs](https://oauth2-proxy.github.io/oauth2-proxy/configuration/session_storage/). + ### Identity Headers When oauth2-proxy runs with `--set-xauthrequest`, NGINX captures its `X-Auth-Request-*` response headers and forwards them to the backend under a **stable header contract** (so the backend sees the same names regardless of the auth provider): From b656c23fe6b5bf45872ffd011e2427abacdca9c6 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 16 Jun 2026 17:34:16 -0400 Subject: [PATCH 08/10] Re-declare error_page in protected location / so @502/@403 work Root cause of the default 502 page on auth-enabled domains: a location-level `error_page` REPLACES (does not merge with) the server-level error_page list. Adding `error_page 401 = @oauth2_signin` inside `location /` therefore dropped the server-level `error_page 403 @403; error_page 502 @502;` for that location, so a signed-in user hitting a down backend got nginx's built-in 502 page. Re-declare error_page 403/502 inside the auth-enabled location /. Verified with a local nginx repro (auth subrequest 202 + dead backend): without the re-declaration nginx serves the default 502; with it, the custom @502 page renders. Also corrects the auth_request_set comment from the previous commit (the server-scope placement was not the cause; the error_page override was). Refs #348 --- create-a-container/views/nginx-conf.ejs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/create-a-container/views/nginx-conf.ejs b/create-a-container/views/nginx-conf.ejs index 684dd719..a0322206 100644 --- a/create-a-container/views/nginx-conf.ejs +++ b/create-a-container/views/nginx-conf.ejs @@ -289,12 +289,14 @@ http { <%_ if (authRequired && authServer) { _%> auth_request /oauth2/auth; error_page 401 = @oauth2_signin; + # A location-level error_page replaces (does not merge with) the + # server-level error_page list, so the @403/@502 handlers defined above + # must be re-declared here or this location would fall back to nginx's + # default error pages. + error_page 403 @403; + error_page 502 @502; # Capture identity from the auth subrequest and forward it to the backend. - # These auth_request_set directives must live here (alongside auth_request), - # not at server scope — otherwise nginx evaluates them for error_page - # locations like @502 that have no auth subrequest, which breaks those - # internal redirects. auth_request_set $auth_user $upstream_http_x_auth_request_user; auth_request_set $auth_email $upstream_http_x_auth_request_email; auth_request_set $auth_groups $upstream_http_x_auth_request_groups; From cde5cd882f45baee72995bc1e1a5ddc570f0716a Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 16 Jun 2026 17:37:30 -0400 Subject: [PATCH 09/10] Consolidate error_page to a single server-scope block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declare every error_page mapping once, at server scope, and remove all location-level error_page directives. Previously the protected location / re-declared error_page 401/403/502 (because a location-level error_page replaces, rather than merges with, the inherited list) and the no-URL location / declared error_page 503 — three separate places per server. Moving error_page 401 = @oauth2_signin and error_page 503 = @auth_unavailable to server scope keeps them working (verified with a local nginx repro: unauth -> sign-in redirect, auth + dead backend -> custom @502, no-URL -> @auth_unavailable) while leaving exactly one error_page block per server and nothing to re-declare. Refs #348 --- create-a-container/views/nginx-conf.ejs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/create-a-container/views/nginx-conf.ejs b/create-a-container/views/nginx-conf.ejs index a0322206..c25ead58 100644 --- a/create-a-container/views/nginx-conf.ejs +++ b/create-a-container/views/nginx-conf.ejs @@ -216,7 +216,17 @@ http { add_header X-XSS-Protection "1; mode=block" always; add_header Alt-Svc 'h3=":443"; ma=86400' always; + # All error_page mappings for this server are declared here, once. Keep + # them at server scope (not inside a location) so a location-level + # error_page can never replace the inherited list — see the auth-enabled + # `location /` below, which relies on inheriting these. error_page 403 @403; + error_page 502 @502; + <%_ if (authRequired && authServer) { _%> + error_page 401 = @oauth2_signin; + <%_ } else if (authRequired && !authServer) { _%> + error_page 503 @auth_unavailable; + <%_ } _%> location @403 { rewrite ^ /403.html break; @@ -226,8 +236,6 @@ http { proxy_pass http://error_pages; } - error_page 502 @502; - location @502 { rewrite ^ /502.html break; proxy_method GET; @@ -280,7 +288,6 @@ http { } location / { - error_page 503 @auth_unavailable; return 503; } <%_ } else { _%> @@ -288,13 +295,6 @@ http { location / { <%_ if (authRequired && authServer) { _%> auth_request /oauth2/auth; - error_page 401 = @oauth2_signin; - # A location-level error_page replaces (does not merge with) the - # server-level error_page list, so the @403/@502 handlers defined above - # must be re-declared here or this location would fall back to nginx's - # default error pages. - error_page 403 @403; - error_page 502 @502; # Capture identity from the auth subrequest and forward it to the backend. auth_request_set $auth_user $upstream_http_x_auth_request_user; From d08239986b53677b140c0d60b8d1832d1fc5eee3 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 16 Jun 2026 17:46:08 -0400 Subject: [PATCH 10/10] docs: drop stale oauth2-proxy reverse-proxy framing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that NGINX sends requests directly to oauth2-proxy, the docs no longer need to contrast with the old reverse-proxy/multi-hop setup. Remove the "single hop", "no second hop to clobber headers", and "you do not need --reverse-proxy / --real-ip-from / X-Forwarded-*" language, which is only meaningful to someone who knew the previous arrangement. The sections now describe the direct setup positively. (General "nginx is the reverse proxy for containers" references are unchanged — that architecture is unchanged.) Refs #348 --- .../docs/admins/core-concepts/external-domains.md | 7 ++----- .../docs/developers/system-architecture.md | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md index b5707d57..f45ff590 100644 --- a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md +++ b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md @@ -117,7 +117,7 @@ The manager does **not** provide authentication itself — you must deploy and c ### Configuring oauth2-proxy -Run oauth2-proxy as a standalone process listening on its own address, and set the domain's **oauth2-proxy URL** to that address (e.g. `http://127.0.0.1:4180`). NGINX proxies the whole `/oauth2/*` path on each protected service straight to that address in a **single hop**, so to the browser the OAuth2 endpoints appear under the app's own hostname (`app.example.com/oauth2/...`) while actually being served by oauth2-proxy. +Run oauth2-proxy as a standalone process listening on its own address, and set the domain's **oauth2-proxy URL** to that address (e.g. `http://127.0.0.1:4180`). NGINX proxies the whole `/oauth2/*` path on each protected service to that address, so to the browser the OAuth2 endpoints appear under the app's own hostname (`app.example.com/oauth2/...`) while actually being served by oauth2-proxy. 1. **Run oauth2-proxy** on a fixed address reachable from the NGINX host (loopback if co-located, e.g. `http://127.0.0.1:4180`, or a private host/port). 2. **Point protected domains at it.** Set the **oauth2-proxy URL** on each external domain whose services should require auth. @@ -126,15 +126,13 @@ Run oauth2-proxy as a standalone process listening on its own address, and set t A single oauth2-proxy instance can serve many services this way — they all proxy `/oauth2/*` to the same address. Because NGINX passes each app's own `Host` through, oauth2-proxy builds redirect URIs and cookies against the correct app hostname without any extra configuration. !!! note "Putting oauth2-proxy behind the same load balancer" - oauth2-proxy does **not** need to be on the NGINX host. If you want it to live behind the same load-balancer IP, expose its port with an L4 (TCP) passthrough — e.g. a **transport service**, which NGINX serves via its `stream {}` block — and point the **oauth2-proxy URL** at that address. The `/oauth2/*` traffic still reaches oauth2-proxy in a single hop, so none of the header/`--reverse-proxy` handling below is required. + oauth2-proxy does **not** need to be on the NGINX host. If you want it to live behind the same load-balancer IP, expose its port with an L4 (TCP) passthrough — e.g. a **transport service**, which NGINX serves via its `stream {}` block — and point the **oauth2-proxy URL** at that address. Run oauth2-proxy with at least: - `--set-xauthrequest=true` — so identity is returned in `X-Auth-Request-*` response headers (forwarded to the backend). - `--pass-access-token=true` *(optional)* — to forward the access token as `X-Access-Token`. -Because NGINX proxies straight to oauth2-proxy (nothing sits in front of it from its point of view), you do **not** need `--reverse-proxy`, `--real-ip-from`, or any `X-Forwarded-*` headers. - !!! warning "HTTPS scheme" oauth2-proxy builds its `redirect_uri` and secure cookies from the scheme of the connection it receives. The scheme is whatever you put in the **oauth2-proxy URL**: @@ -189,7 +187,6 @@ sequenceDiagram else 401 (unauthenticated) OAuth2Proxy-->>NGINX: 401 NGINX-->>Client: 302 → app.example.com/oauth2/sign_in?rd=https://app.example.com/page - Note over Client,OAuth2Proxy: /oauth2/* is proxied to oauth2-proxy in one hop end ``` diff --git a/mie-opensource-landing/docs/developers/system-architecture.md b/mie-opensource-landing/docs/developers/system-architecture.md index a0897fef..80505f18 100644 --- a/mie-opensource-landing/docs/developers/system-architecture.md +++ b/mie-opensource-landing/docs/developers/system-architecture.md @@ -146,7 +146,7 @@ sequenceDiagram When `authRequired` is enabled on an HTTP service, NGINX uses the [`auth_request`](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module to authenticate requests against an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) process before proxying. The domain's `authServer` is the address of that process, e.g. `http://127.0.0.1:4180` (see [External Domains](../admins/core-concepts/external-domains.md#authentication)). The manager itself no longer provides forward-auth — administrators run and configure oauth2-proxy. -NGINX proxies the whole `/oauth2/*` subtree (and the `auth_request` check at `/oauth2/auth`) straight to `authServer` in a **single hop**, passing the app's own `Host` through. Because oauth2-proxy terminates these requests directly, it builds redirect URIs and cookies against the correct app hostname and needs no `--reverse-proxy` / `X-Forwarded-*` handling. If the admin wants oauth2-proxy behind the same load-balancer IP, they expose its port via an L4 (`stream {}`) passthrough; it is never fronted by a generated `server` block, so there is no second hop to clobber headers. +NGINX proxies the whole `/oauth2/*` subtree (and the `auth_request` check at `/oauth2/auth`) to `authServer`, passing the app's own `Host` through. oauth2-proxy terminates those requests itself and builds redirect URIs and cookies against the correct app hostname. If the admin wants oauth2-proxy behind the same load-balancer IP, they expose its port via an L4 (`stream {}`) passthrough. ```mermaid sequenceDiagram