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..db703bca 100644
--- a/create-a-container/views/nginx-conf.ejs
+++ b/create-a-container/views/nginx-conf.ejs
@@ -33,7 +33,13 @@ http {
server_names_hash_bucket_size 128;
- # Cache zone for auth_request subrequests, keyed on credentials.
+ <%_ /*
+ Headroom for large request headers — chiefly the oauth2-proxy session
+ cookie and JWT access tokens, which can run several KB. Default is 4 8k.
+ */ -%>
+ large_client_header_buffers 8 16k;
+
+ <%_ /* Cache zone for auth_request subrequests, keyed on credentials. */ -%>
proxy_cache_path /var/cache/nginx/auth_cache levels=1:2 keys_zone=auth_cache:1m
max_size=10m inactive=5m;
@@ -41,10 +47,29 @@ http {
modsecurity_rules_file /etc/nginx/modsecurity_includes.conf;
modsecurity_transaction_id "$request_id";
- # Internal error page server on a unix socket. Named locations proxy here
- # with proxy_method GET so that NGINX's static file module will serve the
- # HTML regardless of the original request method (POST, PUT, etc.).
- # ModSecurity is disabled to prevent re-evaluation of the original request.
+ <%_ /* Modern TLS configuration */ -%>
+ ssl_protocols TLSv1.2 TLSv1.3;
+ ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
+ ssl_prefer_server_ciphers off;
+
+ <%_ /* SSL session optimization */ -%>
+ ssl_session_cache shared:SSL:10m;
+ ssl_session_timeout 10m;
+ ssl_session_tickets off;
+
+ <%_ /* Security headers (a location-level add_header would replace these). */ -%>
+ add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-XSS-Protection "1; mode=block" always;
+ add_header Alt-Svc 'h3=":443"; ma=86400' always;
+
+ <%_ /*
+ Internal error page server on a unix socket. Named locations proxy here
+ with proxy_method GET so that NGINX's static file module will serve the
+ HTML regardless of the original request method (POST, PUT, etc.).
+ ModSecurity is disabled to prevent re-evaluation of the original request.
+ */ -%>
upstream error_pages {
server unix:/run/nginx-error-pages.sock;
}
@@ -79,29 +104,11 @@ http {
server_name _;
- # SSL certificates
+ <%_ /* SSL certificates */ -%>
ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;
ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;
- # Modern TLS configuration
- ssl_protocols TLSv1.2 TLSv1.3;
- ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
- ssl_prefer_server_ciphers off;
-
- # SSL session optimization
- ssl_session_cache shared:SSL:10m;
- ssl_session_timeout 10m;
- ssl_session_tickets off;
-
- # Security headers
- add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
- add_header X-Frame-Options "SAMEORIGIN" always;
- add_header X-Content-Type-Options "nosniff" always;
- add_header X-XSS-Protection "1; mode=block" always;
- add_header Alt-Svc 'h3=":443"; ma=86400' always;
-
error_page 403 @403;
-
location @403 {
rewrite ^ /403.html break;
proxy_method GET;
@@ -110,9 +117,16 @@ http {
proxy_pass http://error_pages;
}
- <%_ if (httpServices.length === 0) { _%>
- error_page 502 @502;
+ error_page 404 @404;
+ location @404 {
+ rewrite ^ /404.html break;
+ proxy_method GET;
+ proxy_pass_request_body off;
+ proxy_set_header Content-Length "";
+ proxy_pass http://error_pages;
+ }
+ error_page 502 @502;
location @502 {
rewrite ^ /502.html break;
proxy_method GET;
@@ -121,11 +135,12 @@ http {
proxy_pass http://error_pages;
}
+ <%_ if (httpServices.length === 0) { _%>
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
- # Proxy headers
+ <%_ /* Proxy headers */ -%>
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -133,51 +148,23 @@ http {
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
- # WebSocket support
+ <%_ /* WebSocket support */ -%>
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
- # Timeouts
+ <%_ /* Timeouts */ -%>
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
- # Buffering (disable for SSE/streaming)
+ <%_ /* Buffering (disable for SSE/streaming) */ -%>
proxy_buffering off;
proxy_request_buffering off;
- # Allow large uploads
+ <%_ /* Allow large uploads */ -%>
client_max_body_size 2G;
}
<%_ } else { _%>
- error_page 403 @403;
- error_page 404 @404;
- error_page 502 @502;
-
- location @403 {
- rewrite ^ /403.html break;
- proxy_method GET;
- proxy_pass_request_body off;
- proxy_set_header Content-Length "";
- proxy_pass http://error_pages;
- }
-
- location @404 {
- rewrite ^ /404.html break;
- proxy_method GET;
- proxy_pass_request_body off;
- proxy_set_header Content-Length "";
- proxy_pass http://error_pages;
- }
-
- location @502 {
- rewrite ^ /502.html break;
- proxy_method GET;
- proxy_pass_request_body off;
- proxy_set_header Content-Length "";
- proxy_pass http://error_pages;
- }
-
return 404;
<%_ } _%>
}
@@ -195,29 +182,11 @@ http {
server_name <%= service.httpService.externalHostname %>.<%= service.httpService.externalDomain.name %>;
- # SSL certificates
+ <%_ /* SSL certificates */ -%>
ssl_certificate /etc/ssl/certs/<%= service.httpService.externalDomain.name %>.crt;
ssl_certificate_key /etc/ssl/private/<%= service.httpService.externalDomain.name %>.key;
-
- # Modern TLS configuration
- ssl_protocols TLSv1.2 TLSv1.3;
- ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
- ssl_prefer_server_ciphers off;
-
- # SSL session optimization
- ssl_session_cache shared:SSL:10m;
- ssl_session_timeout 10m;
- ssl_session_tickets off;
-
- # Security headers
- add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
- add_header X-Frame-Options "SAMEORIGIN" always;
- add_header X-Content-Type-Options "nosniff" always;
- add_header X-XSS-Protection "1; mode=block" always;
- add_header Alt-Svc 'h3=":443"; ma=86400' always;
-
- error_page 403 @403;
+ error_page 403 @403;
location @403 {
rewrite ^ /403.html break;
proxy_method GET;
@@ -227,7 +196,6 @@ http {
}
error_page 502 @502;
-
location @502 {
rewrite ^ /502.html break;
proxy_method GET;
@@ -237,42 +205,52 @@ 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 to it and pass the app's own Host
+ through, so oauth2-proxy builds redirect URIs and cookies against this
+ app's hostname. The request scheme is whatever the oauth2-proxy URL 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;
+ <%_ /* Sign-in/callback responses can set large session cookies. */ -%>
+ proxy_buffer_size 16k;
+ proxy_buffers 8 16k;
+ }
+
+ 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;
+
+ <%_ /*
+ oauth2-proxy's auth response carries the identity in headers, including
+ the access-token JWT (several KB). Enlarge the response-header buffers
+ so a big token doesn't make the auth subrequest fail (502 -> 500).
+ */ -%>
+ proxy_buffer_size 16k;
+ proxy_buffers 8 16k;
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;
+ error_page 401 = @oauth2_signin;
+ 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 */ -%>
+ error_page 503 @auth_unavailable;
location @auth_unavailable {
rewrite ^ /auth-unavailable.html break;
proxy_method GET;
@@ -282,28 +260,31 @@ http {
}
location / {
- error_page 503 @auth_unavailable;
return 503;
}
<%_ } else { _%>
- # Proxy settings
+ <%_ /* 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_groups $upstream_http_x_auth_request_groups;
+ auth_request_set $auth_email $upstream_http_x_auth_request_email;
+ auth_request_set $auth_preferred_username $upstream_http_x_auth_request_preferred_username;
+ auth_request_set $auth_token $upstream_http_x_auth_request_access_token;
+
+ proxy_set_header X-User $auth_user;
+ proxy_set_header X-Preferred-Username $auth_preferred_username;
+ proxy_set_header X-Groups $auth_groups;
+ proxy_set_header X-Email $auth_email;
+ proxy_set_header X-Access-Token $auth_token;
<%_ } _%>
proxy_pass <%= service.httpService.backendProtocol %>://<%= service.Container.ipv4Address %>:<%= service.internalPort %>;
proxy_http_version 1.1;
- # Proxy headers
+ <%_ /* Proxy headers */ -%>
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -311,28 +292,33 @@ http {
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
- # WebSocket support
+ <%_ /* WebSocket support */ -%>
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
- # Timeouts
+ <%_ /* Timeouts */ -%>
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
- # Buffering (disable for SSE/streaming)
+ <%_ /* Buffering (disable for SSE/streaming) */ -%>
proxy_buffering off;
proxy_request_buffering off;
- # Allow large uploads
+ <%_ /* Allow large uploads */ -%>
client_max_body_size 2G;
+
+ <%_ /* Allow large responses */ -%>
+ proxy_buffer_size 16k;
+ proxy_buffers 4 32k;
+ proxy_busy_buffers_size 64k;
}
<%_ } _%>
}
<%_ }) _%>
<%_ externalDomains.forEach((domain) => { _%>
- # Wildcard server for *.<%= domain.name %> - returns 404
+ <%_ /* Wildcard server for the domain - returns 404 */ -%>
server {
listen 443 ssl;
listen [::]:443 ssl;
@@ -343,29 +329,11 @@ http {
server_name *.<%= domain.name %>;
- # SSL certificates
+ <%_ /* SSL certificates */ -%>
ssl_certificate /etc/ssl/certs/<%= domain.name %>.crt;
ssl_certificate_key /etc/ssl/private/<%= domain.name %>.key;
-
- # Modern TLS configuration
- ssl_protocols TLSv1.2 TLSv1.3;
- ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
- ssl_prefer_server_ciphers off;
-
- # SSL session optimization
- ssl_session_cache shared:SSL:10m;
- ssl_session_timeout 10m;
- ssl_session_tickets off;
-
- # Security headers
- add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
- add_header X-Frame-Options "SAMEORIGIN" always;
- add_header X-Content-Type-Options "nosniff" always;
- add_header X-XSS-Protection "1; mode=block" always;
- add_header Alt-Svc 'h3=":443"; ma=86400' always;
-
- error_page 403 @403;
+ error_page 403 @403;
location @403 {
rewrite ^ /403.html break;
proxy_method GET;
@@ -375,7 +343,6 @@ http {
}
error_page 404 @404;
-
location @404 {
rewrite ^ /404.html break;
proxy_method GET;
@@ -384,11 +351,11 @@ http {
proxy_pass http://error_pages;
}
- # Return 404 for all requests
+ <%_ /* Return 404 for all requests */ -%>
return 404;
}
- # Bare domain <%= domain.name %> - proxies to docs site
+ <%_ /* Bare domain - proxies to docs site */ -%>
server {
listen 443 ssl;
listen [::]:443 ssl;
@@ -399,29 +366,11 @@ http {
server_name <%= domain.name %>;
- # SSL certificates
+ <%_ /* SSL certificates */ -%>
ssl_certificate /etc/ssl/certs/<%= domain.name %>.crt;
ssl_certificate_key /etc/ssl/private/<%= domain.name %>.key;
-
- # Modern TLS configuration
- ssl_protocols TLSv1.2 TLSv1.3;
- ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
- ssl_prefer_server_ciphers off;
-
- # SSL session optimization
- ssl_session_cache shared:SSL:10m;
- ssl_session_timeout 10m;
- ssl_session_tickets off;
-
- # Security headers
- add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
- add_header X-Frame-Options "SAMEORIGIN" always;
- add_header X-Content-Type-Options "nosniff" always;
- add_header X-XSS-Protection "1; mode=block" always;
- add_header Alt-Svc 'h3=":443"; ma=86400' always;
-
- error_page 403 @403;
+ error_page 403 @403;
location @403 {
rewrite ^ /403.html break;
proxy_method GET;
@@ -431,7 +380,6 @@ http {
}
error_page 502 @502;
-
location @502 {
rewrite ^ /502.html break;
proxy_method GET;
@@ -440,7 +388,7 @@ http {
proxy_pass http://error_pages;
}
- # Serve documentation site statically
+ <%_ /* Serve documentation site statically */ -%>
location / {
root /opt/opensource-server/mie-opensource-landing/site;
index index.html;
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..42f37213 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-Preferred-Username`, `X-Email`, `X-Groups`) forwarded to the backend — see [Adding Authentication](../../users/consuming-auth.md) for how apps consume them. 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..967723fb 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,88 @@ 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.
+
+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.
+
+### Configuring oauth2-proxy
+
+Run oauth2-proxy with a config like the one below, then:
+
+1. **Point protected domains at it.** Set the **oauth2-proxy URL** on each external domain whose services should require auth to the address from `http_address` (e.g. `http://127.0.0.1:4180`).
+2. **Enable Require auth** on the individual services you want protected.
+
+Copy this `oauth2-proxy.cfg`, fill in the four OIDC values from your identity provider plus a generated cookie secret, and run `oauth2-proxy --config=/etc/oauth2-proxy.cfg`:
+
+```toml
+# --- listen address -------------------------------------------------------
+# This is what you put in the domain's "oauth2-proxy URL".
+http_address = "127.0.0.1:4180"
+
+# --- your identity provider (fill these in) -------------------------------
+provider = "oidc"
+oidc_issuer_url = "https://idp.example.com/realms/your-realm"
+client_id = "REPLACE_ME"
+client_secret = "REPLACE_ME"
+cookie_secret = "REPLACE_ME" # 16, 24, or 32 bytes; generate: openssl rand -base64 32
+email_domains = ["*"] # which users may sign in; "*" = any email the IdP returns
+code_challenge_method = "S256" # use PKCE (recommended)
+
+# --- required for this manager's nginx integration ------------------------
+set_xauthrequest = true # return identity in X-Auth-Request-* headers
+pass_access_token = true # also expose the access token (drop if unused)
+force_https = true # browser is HTTPS even though nginx talks to us over HTTP
+cookie_secure = true
+cookie_samesite = "lax" # works with the OAuth redirect back from the IdP; avoid "strict"
+# The __Host- prefix locks the cookie to the exact host that set it (no Domain
+# attribute), so each subdomain requires its own sign-in. Requires cookie_secure.
+cookie_name = "__Host-oauth2_proxy"
+# Must cover every protected host — nginx sends an absolute post-sign-in
+# redirect, which oauth2-proxy rejects unless its domain is whitelisted.
+# A leading dot matches the apex and all subdomains.
+whitelist_domains = [".example.com"]
+
+# --- recommended ----------------------------------------------------------
+session_store_type = "redis" # keep the session cookie small (see below)
+redis_connection_url = "redis://127.0.0.1:6379"
+
+# --- optional, depending on your IdP / preference -------------------------
+skip_provider_button = true # go straight to the IdP, skip the oauth2-proxy landing page
+# insecure_oidc_allow_unverified_email = true # accept logins when the IdP marks email unverified
+```
-### Auth Server Requirements
+That's the whole setup. The rest of this section explains how it fits together and the options worth knowing about.
-The auth server URL (e.g., `https://manager.example.com`) must implement two endpoints:
+### How it works
-| 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. |
+oauth2-proxy runs as a standalone process listening on its own address (`http_address`). NGINX proxies the whole `/oauth2/*` path on each protected service straight 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.
+A single 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.
-### Identity Headers
+!!! 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.
+
+!!! note "Multiple apps, one oauth2-proxy"
+ Leave `redirect_url` unset — oauth2-proxy derives the callback per request as `https:///oauth2/callback`, so each app gets the right one. Register `https:///oauth2/callback` as a redirect URI for **each** app in your IdP, and make sure every protected host is covered by `whitelist_domains`.
-On successful authentication, the auth server can return identity headers that NGINX forwards to the backend:
+!!! warning "HTTPS scheme"
+ oauth2-proxy builds its `redirect_uri` and secure cookies from the scheme of the connection it receives, which is whatever scheme you put in the **oauth2-proxy URL**:
-| 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 |
+ - **`http://…` upstream** (most common, e.g. `http://127.0.0.1:4180`): oauth2-proxy sees a plain-HTTP connection, so the config sets `force_https = true` and `cookie_secure = true` to still emit `https://` redirect URIs and Secure cookies for HTTPS browsers.
+ - **`https://…` upstream**: terminate TLS on the oauth2-proxy listener instead (`tls_cert_file` / `tls_key_file`); oauth2-proxy then infers HTTPS from the connection and you can drop `force_https`.
-### Cookie Sharing
+!!! 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). The config above avoids this with the **Redis** session store (`session_store_type = "redis"`), which keeps only a small ticket in the cookie.
-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.
+ If you cannot run Redis, reduce what the cookie carries instead: drop `pass_access_token` if your app doesn't need the token, and request only the scopes you use. See the [session storage docs](https://oauth2-proxy.github.io/oauth2-proxy/configuration/session_storage/).
+
+See the [oauth2-proxy NGINX integration guide](https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/nginx/) for full configuration details.
+
+### Identity Headers
+
+When oauth2-proxy runs with `--set-xauthrequest`, NGINX captures its `X-Auth-Request-*` response headers and forwards the user's identity to the backend under a **stable header contract** (`X-User`, `X-Preferred-Username`, `X-Email`, `X-Groups`, and — with `--pass-access-token` — `X-Access-Token`).
+
+For the full header table and how applications consume the identity (server-side headers, verifying the access-token JWT, and the browser `/oauth2/userinfo` endpoint for static frontends), see [Adding Authentication](../../users/consuming-auth.md).
### Flow
@@ -147,19 +200,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 0b792dd5..6b567db0 100644
--- a/mie-opensource-landing/docs/developers/database-schema.md
+++ b/mie-opensource-landing/docs/developers/database-schema.md
@@ -105,7 +105,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 {
@@ -185,12 +185,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 57b99c83..cdc9f844 100644
--- a/mie-opensource-landing/docs/developers/system-architecture.md
+++ b/mie-opensource-landing/docs/developers/system-architecture.md
@@ -146,29 +146,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-Preferred-Username`, `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-Preferred-Username`, `X-Email`, `X-Groups`, `X-Access-Token`). This requires oauth2-proxy to run with `--set-xauthrequest` (and `--pass-access-token` for the token). See [Adding Authentication](../users/consuming-auth.md) for how applications consume these.
-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.)
diff --git a/mie-opensource-landing/docs/users/consuming-auth.md b/mie-opensource-landing/docs/users/consuming-auth.md
new file mode 100644
index 00000000..e905eaeb
--- /dev/null
+++ b/mie-opensource-landing/docs/users/consuming-auth.md
@@ -0,0 +1,150 @@
+# Adding Authentication
+
+You can put a sign-in wall in front of any HTTP service so only authenticated users reach it — without writing login code yourself. Requests are authenticated by [oauth2-proxy](../admins/core-concepts/external-domains.md#authentication) before they ever reach your app; your app just reads who the user is.
+
+## Turn on authentication
+
+1. Make sure the external domain your service uses has an **oauth2-proxy URL** configured. This is an admin setup — if it isn't set, ask your environment administrator (see [External Domains — Authentication](../admins/core-concepts/external-domains.md#authentication)).
+2. Enable the **Require auth** checkbox on your HTTP service when [creating or editing the container](creating-containers/web-gui.md).
+
+That's it — unauthenticated visitors are now redirected to sign in. The rest of this page is about reading the signed-in user's identity in your app, in three ways:
+
+1. **Server-side apps** — read the identity headers NGINX adds to each proxied request.
+2. **Apps that need the raw token / extra claims** — read and (optionally) verify the access token.
+3. **Static / "serverless" frontends** — call oauth2-proxy's `/oauth2/userinfo` endpoint from the browser.
+
+## Identity headers (server-side)
+
+Every authenticated request arrives at your backend with a **stable set of headers** describing the signed-in user. The names are always the same, so your app can rely on them:
+
+| Header | Description |
+|--------|-------------|
+| `X-User` | Stable, unique user id (the identity provider's `sub` claim). Treat it as an opaque key — it isn't guaranteed to be human-readable. |
+| `X-Preferred-Username` | Human-friendly username (when the user has one) |
+| `X-Email` | User's email |
+| `X-Groups` | Comma-separated list of the user's groups |
+| `X-Access-Token` | The user's access token, for calling other APIs on their behalf (see below). May be absent depending on how the domain is configured. |
+
+!!! tip "Which header to use for what"
+ Use `X-User` as the **stable key** to identify a user in your database or logs — it never changes, even if they rename themselves or change email. To **display** a name, use `X-Preferred-Username` (falling back to `X-Email`); `X-User` may be an opaque id (such as a UUID) and isn't guaranteed to be meaningful to a person.
+
+Reading them is trivial — they are ordinary request headers. Example in Node/Express:
+
+```js
+app.get('/', (req, res) => {
+ const user = {
+ id: req.get('X-User'),
+ username: req.get('X-Preferred-Username'),
+ email: req.get('X-Email'),
+ groups: (req.get('X-Groups') || '').split(',').filter(Boolean),
+ };
+ res.json({ user });
+});
+```
+
+!!! warning "Trust boundary"
+ These headers are trustworthy because every request reaches your service through the authenticating proxy, which sets them (and overwrites any a client tries to send). For this to hold, don't expose your container on a separate route that bypasses the proxy, and don't forward these headers on to untrusted third parties.
+
+## Reading and verifying the access token
+
+If you need more than the identity headers above — for example to call another API on the user's behalf, or to read custom claims — use the access token from the `X-Access-Token` header. (If that header isn't present, access-token passthrough isn't enabled for your domain; ask your administrator.)
+
+Whether that token is a **JWT** depends on your identity provider:
+
+- **Providers that issue JWT access tokens** (Keycloak, Microsoft Entra ID, Auth0, etc.) — you can decode and verify it.
+- **Providers that issue opaque tokens** (e.g. plain Google OAuth) — the token is not a JWT; treat it as a bearer string and validate it by calling the provider's introspection/userinfo endpoint instead.
+
+### Decode (no verification)
+
+A JWT is three base64url segments (`header.payload.signature`). Decoding the payload gives you the claims, but **decoding is not verification** — never make an authorization decision on a decoded-but-unverified token that came from an untrusted source.
+
+```js
+function decodeJwtPayload(token) {
+ const payload = token.split('.')[1];
+ const json = Buffer.from(payload, 'base64url').toString('utf8');
+ return JSON.parse(json);
+}
+```
+
+### Verify (recommended)
+
+Verify the signature against your IdP's public keys (JWKS) and check the standard claims (`iss`, `aud`, `exp`). Use a maintained library rather than hand-rolling this.
+
+```js
+import { createRemoteJWKSet, jwtVerify } from 'jose';
+
+// Your IdP's JWKS endpoint, e.g. from its OIDC discovery document
+// (/.well-known/openid-configuration -> "jwks_uri").
+const JWKS = createRemoteJWKSet(new URL(process.env.OIDC_JWKS_URI));
+
+async function verifyAccessToken(token) {
+ const { payload } = await jwtVerify(token, JWKS, {
+ issuer: process.env.OIDC_ISSUER, // expected "iss"
+ audience: process.env.OIDC_AUDIENCE, // expected "aud" (your client/app id)
+ });
+ return payload; // verified claims
+}
+```
+
+!!! tip
+ The values you need (`OIDC_ISSUER`, `OIDC_JWKS_URI`, `OIDC_AUDIENCE`) come from the same identity provider oauth2-proxy is configured against. Ask the administrator who set up the **oauth2-proxy URL** for the domain, or read them from the provider's discovery document at `/.well-known/openid-configuration`.
+
+!!! warning
+ `X-Access-Token` is a credential. Do not log it, return it to the browser, or send it anywhere other than the API it is intended for.
+
+## Static / "serverless" frontends: `/oauth2/userinfo`
+
+A single-page app, static site, or any frontend with **no backend of its own** cannot read the identity headers (those are added between NGINX and a backend, not visible to the browser). Instead, oauth2-proxy exposes a JSON endpoint on the app's own origin:
+
+```
+GET https://app.example.com/oauth2/userinfo
+```
+
+Because `/oauth2/*` is served on your app's own hostname, the browser sends the session cookie automatically — just include credentials:
+
+```js
+const res = await fetch('/oauth2/userinfo', { credentials: 'include' });
+if (res.status === 401) {
+ // Not signed in — send the user through sign-in, then back here.
+ window.location.href =
+ '/oauth2/sign_in?rd=' + encodeURIComponent(window.location.href);
+} else {
+ const user = await res.json();
+ // { user, email, groups, preferredUsername, additionalClaims }
+ console.log(user.preferredUsername, user.email, user.groups);
+}
+```
+
+The response is JSON with this shape:
+
+```json
+{
+ "user": "a1b2c3d4",
+ "email": "jane@example.com",
+ "groups": ["developers", "admins"],
+ "preferredUsername": "jane",
+ "additionalClaims": {}
+}
+```
+
+| Field | Description |
+|-------|-------------|
+| `user` | Stable, unique user id (the provider's `sub` claim) — treat as an opaque key; not guaranteed to be human-readable |
+| `email` | User's email |
+| `groups` | Array of group names (omitted if none) |
+| `preferredUsername` | Human-friendly username (omitted if the user doesn't have one) |
+| `additionalClaims` | Any extra identity claims configured for your domain (omitted if none) |
+
+!!! warning "`/oauth2/userinfo` is for display, not authorization"
+ This endpoint reflects the browser's own session, so it is fine for personalizing the UI (showing a name, hiding admin links). It is **not** an authorization mechanism — a static frontend cannot keep secrets, and a determined user controls their own browser. Enforce access control on the **server** side: keep sensitive data behind a Require-auth backend (which reads the trusted identity headers) or behind an API that verifies the token.
+
+### Helpful oauth2-proxy endpoints from the browser
+
+| Endpoint | Purpose |
+|----------|---------|
+| `/oauth2/userinfo` | JSON identity of the current session (`401` if not signed in) |
+| `/oauth2/sign_in?rd=` | Start sign-in, then return to `` |
+| `/oauth2/sign_out?rd=` | Clear the oauth2-proxy session cookie |
+
+!!! note
+ The `/oauth2` prefix is the default. If the administrator changed oauth2-proxy's `--proxy-prefix`, these paths change accordingly.
diff --git a/mie-opensource-landing/zensical.toml b/mie-opensource-landing/zensical.toml
index 85baa56b..e83a4f30 100644
--- a/mie-opensource-landing/zensical.toml
+++ b/mie-opensource-landing/zensical.toml
@@ -21,6 +21,7 @@ nav = [
{ "API Keys" = "users/creating-containers/api-keys.md" },
] },
{ "Monitoring Containers" = "users/monitoring-container.md" },
+ { "Adding Authentication" = "users/consuming-auth.md" },
{ "Launchpad" = [
{ "What is Launchpad" = "users/proxmox-launchpad/what-is-proxmox-launchpad.md" },
{ "Getting Started" = "users/proxmox-launchpad/getting-started.md" },