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..a3f1cf14 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() {
DomainSiteCloudflare
- Auth server
+ oauth2-proxyActions
diff --git a/create-a-container/models/external-domain.js b/create-a-container/models/external-domain.js
index ea24c340..62d0ff8b 100644
--- a/create-a-container/models/external-domain.js
+++ b/create-a-container/models/external-domain.js
@@ -57,9 +57,24 @@ 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: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. http://127.0.0.1:4180');
+ }
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
+ throw new Error('authServer must use http or https');
+ }
+ }
},
- comment: 'Auth server URL for auth_request (e.g., https://manager.example.com). Must implement GET /verify and GET /login?redirect='
+ 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/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..62b4aee0 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,15 @@ 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).
// `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 +92,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 +110,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..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;
@@ -237,42 +245,40 @@ 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 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;
- 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 <%= authServer %>/oauth2/auth;
+ 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";
- 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;
+ location @oauth2_signin {
+ return 302 /oauth2/sign_in?rd=$scheme://$host$request_uri;
}
<%_ } _%>
<%_ 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;
@@ -282,23 +288,24 @@ http {
}
location / {
- error_page 503 @auth_unavailable;
return 503;
}
<%_ } else { _%>
# 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;
+
+ # Capture identity from the auth subrequest and forward it to the backend.
+ 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;
+ 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:
If you're a user of this service, let the person in charge of it know.
If you're in charge of this service and authentication was enabled
by mistake, disable it. Otherwise, contact your environment administrator.
-
If you're the environment administrator, configure an auth server
- for this domain.
+
If you're the environment administrator, configure an oauth2-proxy
+ server for this domain.
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..f45ff590 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 — 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.
@@ -111,35 +111,62 @@ 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. |
+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.
-The manager application implements both endpoints and can be used as the auth server.
+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.
+
+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.
+
+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`.
+
+!!! 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**:
+
+ - **`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.
+
+!!! 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
-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
+### Sharing one sign-in across subdomains
-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/*` 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
@@ -147,19 +174,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 (Host: app.example.com)
+ 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 → app.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.
+
diff --git a/mie-opensource-landing/docs/admins/installation.md b/mie-opensource-landing/docs/admins/installation.md
index 39f802f9..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. **Auth Server URL**: `https://manager.example.org` (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 9d19e33b..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, auth server URL"
+ string authServer "nullable, oauth2-proxy process address"
}
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 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 c9b2ffd5..80505f18 100644
--- a/mie-opensource-landing/docs/developers/system-architecture.md
+++ b/mie-opensource-landing/docs/developers/system-architecture.md
@@ -144,29 +144,31 @@ 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/) 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`) 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
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 (Host: app.example.com)
+ 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 → app.example.com/oauth2/sign_in?rd=https://app.example.com/page
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.
+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.)