Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,9 @@ export function ExternalDomainFormPage() {
{...register('cloudflareApiKey')}
/>
<Input
label="Auth server"
helperText="Optional URL for nginx auth_request"
label="oauth2-proxy URL"
placeholder="http://127.0.0.1:4180"
helperText="Optional. Address of an oauth2-proxy process used for nginx auth_request. nginx proxies /oauth2/* straight to it."
{...register('authServer')}
/>
{mutation.error && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function ExternalDomainsListPage() {
<TableHead>Domain</TableHead>
<TableHead>Site</TableHead>
<TableHead>Cloudflare</TableHead>
<TableHead>Auth server</TableHead>
<TableHead>oauth2-proxy</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
Expand Down
19 changes: 17 additions & 2 deletions create-a-container/models/external-domain.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 thread
runleveldev marked this conversation as resolved.
},
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',
Expand Down
61 changes: 0 additions & 61 deletions create-a-container/routers/verify.js

This file was deleted.

15 changes: 1 addition & 14 deletions create-a-container/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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
};
}
}));
Expand All @@ -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) ---
Expand All @@ -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'));
});

Expand Down
83 changes: 45 additions & 38 deletions create-a-container/views/nginx-conf.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,17 @@ http {
add_header X-XSS-Protection "1; mode=block" always;
add_header Alt-Svc 'h3=":443"; ma=86400' always;

# All error_page mappings for this server are declared here, once. Keep
# them at server scope (not inside a location) so a location-level
# error_page can never replace the inherited list — see the auth-enabled
# `location /` below, which relies on inheriting these.
error_page 403 @403;
error_page 502 @502;
<%_ if (authRequired && authServer) { _%>
error_page 401 = @oauth2_signin;
<%_ } else if (authRequired && !authServer) { _%>
error_page 503 @auth_unavailable;
<%_ } _%>

location @403 {
rewrite ^ /403.html break;
Expand All @@ -226,8 +236,6 @@ http {
proxy_pass http://error_pages;
}

error_page 502 @502;

location @502 {
rewrite ^ /502.html break;
proxy_method GET;
Expand All @@ -237,42 +245,40 @@ http {
}

<%_ if (authRequired && authServer) { _%>
# Auth subrequest — proxied to the auth server's /verify endpoint.
# Responses are cached per Cookie+Authorization pair so NGINX only
# contacts the auth server when credentials change.
location = /.oss-auth-verify {
# oauth2-proxy runs on its own address (this domain's "oauth2-proxy URL").
# We proxy the whole /oauth2/* subtree straight to it in a single hop and
# pass the app's own Host through, so oauth2-proxy builds redirect URIs and
# cookies against this app's hostname. Because it terminates these requests
# directly (no proxy in front of it from its point of view), oauth2-proxy does
# NOT need --reverse-proxy or any X-Forwarded-* headers. The request scheme is
# whatever <%= authServer %> uses; pair an http:// upstream with
# --force-https / --cookie-secure on oauth2-proxy if the browser is on HTTPS.
location /oauth2/ {
proxy_pass <%= authServer %>;
proxy_set_header Host $host;
}

location = /oauth2/auth {
internal;
resolver 127.0.0.1;
set $auth_server <%= authServer %>;
proxy_pass $auth_server/verify;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Original-Host $host;
proxy_set_header Cookie $http_cookie;
proxy_set_header Authorization $http_authorization;
proxy_pass <%= authServer %>/oauth2/auth;
proxy_set_header Host $host;
# nginx auth_request includes headers but not the request body.
proxy_set_header Content-Length "";
proxy_pass_request_body off;

proxy_cache auth_cache;
proxy_cache_key "$http_cookie$http_authorization";
proxy_cache_valid 200 5m;
proxy_cache_valid 202 5m;
proxy_cache_valid 401 30s;
}

# Capture user identity headers from the auth subrequest response.
auth_request_set $auth_user_id $upstream_http_x_user_id;
auth_request_set $auth_username $upstream_http_x_username;
auth_request_set $auth_first_name $upstream_http_x_user_first_name;
auth_request_set $auth_last_name $upstream_http_x_user_last_name;
auth_request_set $auth_email $upstream_http_x_email;
auth_request_set $auth_groups $upstream_http_x_groups;

location @login_redirect {
return 302 <%= authServer %>/login?redirect=https://$host$request_uri;
location @oauth2_signin {
return 302 /oauth2/sign_in?rd=$scheme://$host$request_uri;
}

<%_ } _%>
<%_ if (authRequired && !authServer) { _%>
# Authentication required but no auth server URL configured for this domain
# Authentication required but no oauth2-proxy URL configured for this domain
location @auth_unavailable {
rewrite ^ /auth-unavailable.html break;
proxy_method GET;
Expand All @@ -282,23 +288,24 @@ http {
}

location / {
error_page 503 @auth_unavailable;
return 503;
}
<%_ } else { _%>
# Proxy settings
location / {
<%_ if (authRequired && authServer) { _%>
auth_request /.oss-auth-verify;
error_page 401 = @login_redirect;

# Forward user identity from auth subrequest to backend
proxy_set_header X-User-ID $auth_user_id;
proxy_set_header X-Username $auth_username;
proxy_set_header X-User-First-Name $auth_first_name;
proxy_set_header X-User-Last-Name $auth_last_name;
proxy_set_header X-Email $auth_email;
proxy_set_header X-Groups $auth_groups;
auth_request /oauth2/auth;

# Capture identity from the auth subrequest and forward it to the backend.
auth_request_set $auth_user $upstream_http_x_auth_request_user;
auth_request_set $auth_email $upstream_http_x_auth_request_email;
auth_request_set $auth_groups $upstream_http_x_auth_request_groups;
auth_request_set $auth_token $upstream_http_x_auth_request_access_token;

proxy_set_header X-User $auth_user;
proxy_set_header X-Email $auth_email;
proxy_set_header X-Groups $auth_groups;
proxy_set_header X-Access-Token $auth_token;
<%_ } _%>
proxy_pass <%= service.httpService.backendProtocol %>://<%= service.Container.ipv4Address %>:<%= service.internalPort %>;
proxy_http_version 1.1;
Expand Down
8 changes: 4 additions & 4 deletions error-pages/auth-unavailable.html
Original file line number Diff line number Diff line change
Expand Up @@ -115,22 +115,22 @@
<div class="status-code">503</div>
<h1>Authentication Unavailable</h1>
<p>
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.
</p>
<div class="hostname" id="hostname"></div>
<div class="info">
<h2>What happened?</h2>
<p>
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:
</p>
<ul>
<li>If you're a <strong>user</strong> of this service, let the person in charge of it know.</li>
<li>If you're in <strong>charge of this service</strong> and authentication was enabled
by mistake, disable it. Otherwise, contact your environment administrator.</li>
<li>If you're the <strong>environment administrator</strong>, configure an auth server
for this domain.</li>
<li>If you're the <strong>environment administrator</strong>, configure an oauth2-proxy
server for this domain.</li>
</ul>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@ Users in the **ldapusers** group can SSH into any container using their cluster

Users can expose HTTP services from containers using [external domains](external-domains.md). Services are automatically configured with SSL/TLS certificates, reverse proxy routing, and DNS records.

HTTP services can optionally require authentication via the **Require auth** checkbox. When enabled, NGINX authenticates requests against the domain's [auth server](external-domains.md#authentication) before proxying. Authenticated requests include identity headers (`X-User-ID`, `X-Username`, etc.) forwarded to the backend. See [External Domains — Authentication](external-domains.md#authentication) for configuration details.
HTTP services can optionally require authentication via the **Require auth** checkbox. When enabled, NGINX authenticates requests against the domain's [oauth2-proxy server](external-domains.md#authentication) before proxying. Authenticated requests include identity headers (`X-User`, `X-Email`, `X-Groups`) forwarded to the backend. See [External Domains — Authentication](external-domains.md#authentication) for configuration details.

Loading
Loading