Add local authserver to provide /verify-credentials route

This is used for apps which are using OpenID to login but still need to
be able to verify the users password or app password
This commit is contained in:
Johannes Zellner
2026-04-02 19:00:59 +02:00
parent b2ca6206cc
commit dab9bcb9db
8 changed files with 175 additions and 2 deletions

3
box.js
View File

@@ -4,6 +4,7 @@ import constants from './src/constants.js';
import fs from 'node:fs';
import ldapServer from './src/ldapserver.js';
import net from 'node:net';
import authServer from './src/authserver.js';
import oidcServer from './src/oidcserver.js';
import paths from './src/paths.js';
import proxyAuth from './src/proxyauth.js';
@@ -72,6 +73,7 @@ process.on('SIGINT', async function () {
await directoryServer.stop();
await ldapServer.stop();
await oidcServer.stop();
await authServer.stop();
setTimeout(() => {
log('Shutdown complete');
@@ -87,6 +89,7 @@ process.on('SIGTERM', async function () {
await directoryServer.stop();
await ldapServer.stop();
await oidcServer.stop();
await authServer.stop();
setTimeout(() => {
log('Shutdown complete');

View File

@@ -113,7 +113,7 @@ $ip6tables -t filter -A CLOUDRON -p udp --sport 547 --dport 546 -j ACCEPT
ipxtables -t filter -A CLOUDRON -p udp --sport 53 -j ACCEPT
# for ldap,dockerproxy server (ipv4 only) to accept connections from apps. for connecting to addons and mail container ports, docker already has rules
$iptables -t filter -A CLOUDRON -p tcp -s 172.18.0.0/16 -d 172.18.0.1 -m multiport --dports 3002,3003 -j ACCEPT
$iptables -t filter -A CLOUDRON -p tcp -s 172.18.0.0/16 -d 172.18.0.1 -m multiport --dports 3002,3003,3006 -j ACCEPT
$iptables -t filter -A CLOUDRON -p udp -s 172.18.0.0/16 --dport 53 -j ACCEPT # dns responses from docker (127.0.0.11)
ipxtables -t filter -A CLOUDRON -i lo -j ACCEPT # required for localhost connections (mysql)

81
src/authserver.js Normal file
View File

@@ -0,0 +1,81 @@
import assert from 'node:assert';
import apps from './apps.js';
import BoxError from './boxerror.js';
import constants from './constants.js';
import express from 'express';
import http from 'node:http';
import logger from './logger.js';
import middleware from './middleware/index.js';
import safe from '@cloudron/safetydance';
import users from './users.js';
import util from 'node:util';
import { HttpError, HttpSuccess } from '@cloudron/connect-lastmile';
const { trace, log } = logger('authserver');
let gHttpServer = null;
async function verifyPost(req, res, next) {
if (!req.body || typeof req.body !== 'object') return next(new HttpError(400, 'Body must be a JSON object'));
const { identifier, password } = req.body;
if (typeof identifier !== 'string' || identifier.length === 0) return next(new HttpError(400, 'identifier must be a non-empty string'));
if (typeof password !== 'string' || password.length === 0) return next(new HttpError(400, 'password must be a non-empty string'));
trace(`verifyPost: attempt for ${identifier}`);
let verifyFunc;
if (identifier.startsWith('uid-')) verifyFunc = users.verifyWithId;
else if (identifier.includes('@')) verifyFunc = users.verifyWithEmail;
else verifyFunc = users.verifyWithUsername;
const [, connectingApp] = await safe(apps.getByIpAddress(req.socket.remoteAddress));
const appId = connectingApp ? connectingApp.id : '';
// Internal loopback-only password check; skipTotpCheck matches LDAP bind behavior for automated callers.
const [verifyError, user] = await safe(verifyFunc(identifier, password, appId, { skipTotpCheck: true }));
if (verifyError && verifyError.reason === BoxError.INVALID_CREDENTIALS) return next(new HttpError(401, verifyError.message));
if (verifyError && verifyError.reason === BoxError.NOT_FOUND) return next(new HttpError(401, 'Username and password does not match'));
if (verifyError) return next(new HttpError(500, verifyError));
if (!user) return next(new HttpError(401, 'Username and password does not match'));
next(new HttpSuccess(200, {
id: user.id,
username: user.username,
email: user.email,
displayName: user.displayName
}));
}
async function start() {
assert(gHttpServer === null, 'Auth server already started');
const app = express();
app.enable('trust proxy');
const json = express.json({ strict: true, limit: '2mb' });
app.post('/verify-credentials', json, verifyPost);
app.use(middleware.lastMile());
gHttpServer = http.createServer(app);
// In production the auth HTTP API is only reachable on the docker bridge IP. Tests run on the
// host and connect via 127.0.0.1, and app IP checks rely on that remote address.
const bindHost = constants.TEST ? '127.0.0.1' : constants.DOCKER_IPv4_GATEWAY;
log(`start: listening on ${bindHost}:${constants.AUTH_PORT}`);
await util.promisify(gHttpServer.listen.bind(gHttpServer))(constants.AUTH_PORT, bindHost);
}
async function stop() {
if (!gHttpServer) return;
await util.promisify(gHttpServer.close.bind(gHttpServer))();
gHttpServer = null;
}
export default {
start,
stop,
};

View File

@@ -30,6 +30,7 @@ export default {
DOCKER_PROXY_PORT: 3003,
USER_DIRECTORY_LDAPS_PORT: 3004, // user directory LDAP with TLS rerouting in iptables, public port is 636
OIDC_PORT: 3005,
AUTH_PORT: 3006,
TURN_PORT: 3478, // tcp and udp
TURN_TLS_PORT: 5349, // tcp and udp
TURN_UDP_PORT_START: 50000,

View File

@@ -178,7 +178,7 @@ async function start() {
});
log(`start: listening on 172.18.0.1:${constants.DOCKER_PROXY_PORT}`);
await util.promisify(gHttpServer.listen.bind(gHttpServer))(constants.DOCKER_PROXY_PORT, '172.18.0.1');
await util.promisify(gHttpServer.listen.bind(gHttpServer))(constants.DOCKER_PROXY_PORT, constants.DOCKER_IPv4_GATEWAY);
}
async function stop() {

View File

@@ -12,6 +12,7 @@ import dockerProxy from './dockerproxy.js';
import fs from 'node:fs';
import infra from './infra_version.js';
import locks from './locks.js';
import authServer from './authserver.js';
import oidcServer from './oidcserver.js';
import paths from './paths.js';
import reverseProxy from './reverseproxy.js';
@@ -170,6 +171,7 @@ async function onActivated(restoreOptions) {
await startInfra(restoreOptions);
await cron.startJobs();
await dockerProxy.start(); // this relies on the 'cloudron' docker network interface to be available
await authServer.start(); // this relies on the 'cloudron' docker network interface to be available
// disable responding to api calls via IP to not leak domain info. this is carefully placed as the last item, so it buys
// the UI some time to query the dashboard domain in the restore code path
@@ -185,6 +187,7 @@ async function onDeactivated() {
await cron.stopJobs();
await dockerProxy.stop();
await oidcServer.stop();
await authServer.stop();
}
async function uninitialize() {

View File

@@ -8,6 +8,7 @@ import assert from 'node:assert/strict';
import mailer from '../../mailer.js';
import nock from 'nock';
import oidcClients from '../../oidcclients.js';
import authServer from '../../authserver.js';
import oidcServer from '../../oidcserver.js';
import server from '../../server.js';
import settings from '../../settings.js';
@@ -103,6 +104,7 @@ async function setupServer() {
await database._clear();
await appstore._setApiServerOrigin(mockApiServerOrigin);
await oidcServer.stop();
await authServer.stop();
await server.start();
log('Set up server complete');
}
@@ -172,6 +174,7 @@ async function cleanup() {
log('Cleaning up');
await server.stop();
await oidcServer.stop();
await authServer.stop();
if (!nock.isActive()) nock.activate();
log('Cleaned up');
}

View File

@@ -0,0 +1,82 @@
import { describe, it, before, after } from 'mocha';
import appPasswords from '../apppasswords.js';
import apps from '../apps.js';
import authServer from '../authserver.js';
import constants from '../constants.js';
import common from './common.js';
import assert from 'node:assert/strict';
import superagent from '@cloudron/superagent';
const authUrl = `http://127.0.0.1:${constants.AUTH_PORT}`;
function assertVerifyCredentialsProfile(body, expected) {
assert.equal(body.id, expected.id);
assert.equal(body.username, expected.username);
assert.equal(body.email, expected.email);
assert.equal(body.displayName, expected.displayName);
}
describe('authserver HTTP', function () {
const { setup, cleanup, admin, app } = common;
before(async function () {
await setup();
await apps.update(app.id, { containerIp: '127.0.0.1' });
await authServer.start();
});
after(async function () {
await authServer.stop();
await cleanup();
});
it('returns 200 with account password from localhost', async function () {
const response = await superagent.post(`${authUrl}/verify-credentials`)
.send({ identifier: admin.username, password: admin.password })
.ok(() => true);
assert.equal(response.status, 200);
assertVerifyCredentialsProfile(response.body, admin);
});
it('returns 200 with app password when containerIp matches peer', async function () {
const { id, password } = await appPasswords.add(admin.id, app.id, 'authserver-test', null);
const response = await superagent.post(`${authUrl}/verify-credentials`)
.send({ identifier: admin.username, password })
.ok(() => true);
await appPasswords.del(id);
assert.equal(response.status, 200);
assertVerifyCredentialsProfile(response.body, admin);
});
it('returns 401 for app password when containerIp does not match peer', async function () {
const { id, password } = await appPasswords.add(admin.id, app.id, 'authserver-test-ip', null);
await apps.update(app.id, { containerIp: '172.18.88.88' });
const response = await superagent.post(`${authUrl}/verify-credentials`)
.send({ identifier: admin.username, password })
.ok(() => true);
await apps.update(app.id, { containerIp: '127.0.0.1' });
await appPasswords.del(id);
assert.equal(response.status, 401);
});
it('returns 200 with account password when containerIp does not match peer', async function () {
await apps.update(app.id, { containerIp: '172.18.88.88' });
const response = await superagent.post(`${authUrl}/verify-credentials`)
.send({ identifier: admin.username, password: admin.password })
.ok(() => true);
await apps.update(app.id, { containerIp: '127.0.0.1' });
assert.equal(response.status, 200);
assertVerifyCredentialsProfile(response.body, admin);
});
});