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() { 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..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:

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" },