Skip to content

Commit

Permalink
Modular: Adds hostIdentity as issuer to JWTs
Browse files Browse the repository at this point in the history
  • Loading branch information
DougReeder committed Mar 19, 2024
1 parent b9363da commit 3475290
Show file tree
Hide file tree
Showing 14 changed files with 87 additions and 60 deletions.
32 changes: 17 additions & 15 deletions bin/www
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const http = require('http');
const fs = require("fs");
const path = require("path");
const {ArgumentParser} = require("argparse");
const appFactory = require('../lib/appFactory');
const {configureLogger, getLogger} = require("../lib/logger");
const S3Handler = require("../lib/routes/S3Handler");
const process = require("process");
Expand Down Expand Up @@ -33,38 +34,40 @@ try {

configureLogger(conf.logging);

conf.basePath ||= '';
if (conf.basePath && !conf.basePath.startsWith('/')) { conf.basePath = '/' + conf.basePath; }
process.env.basePath = conf.basePath;

const appFactory = require('../lib/appFactory'); // process.env.basePath must have been set previously
let basePath = conf.basePath || '';
if (basePath && !basePath.startsWith('/')) { basePath = '/' + basePath; }

let jwtSecret = process.env.JWT_SECRET || process.env.S3_SECRET_KEY;
if (!jwtSecret) {
process.env.SECRET = jwtSecret = String(Math.round(Math.random() * Number.MAX_SAFE_INTEGER))
getLogger().warning(`neither JWT_SECRET nor S3_SECRET_KEY were set in the environment. Setting it to “${jwtSecret}”`)
}

const userNameSuffix = conf.user_name_suffix || '-' + (conf.domain_name || 'Armadietto');
const hostIdentity = conf.host_identity?.trim();
if (!hostIdentity) {
getLogger().emerg(`host_identity MUST be set in the configuration file`);
process.exit(1);
}
const userNameSuffix = conf.user_name_suffix ?? '-' + hostIdentity;

if (conf.http?.port) {
start(jwtSecret, Object.assign({}, conf.http, process.env.PORT && {port: process.env.PORT}), userNameSuffix);
start( Object.assign({}, conf.http, process.env.PORT && {port: process.env.PORT}));
}

if (conf.https?.port) {
start(jwtSecret, conf.https, userNameSuffix);
start(conf.https);
}


function start(jwtSecret, network, userNameSuffix) {
function start(network) {
// If the environment variables aren't set, s3handler uses a shared public account on play.min.io,
// to which anyone in the world can read and write!
// It is not entirely compatible with S3Handler.
const s3handler = new S3Handler(process.env.S3_ENDPOINT,
process.env.S3_ACCESS_KEY, process.env.S3_SECRET_KEY, undefined,
userNameSuffix);

const app = appFactory(jwtSecret, s3handler, s3handler);
const app = appFactory({hostIdentity, jwtSecret, account: s3handler, store: s3handler, basePath});

const port = normalizePort( network?.port || '8000');
app.set('port', port);
Expand All @@ -75,9 +78,8 @@ function start(jwtSecret, network, userNameSuffix) {
}

app.locals.title = "Modular Armadietto";
app.locals.basePath = conf.basePath;
// rendering can set `locals.host` to `getHost(req)`
app.locals.host = (conf.domain_name || network?.host || '0.0.0.0') + (port ? ':' + port : '');
// Before rendering, `locals.host` should be set to `getHost(req)`
app.locals.host = (network?.host || '0.0.0.0') + (port ? ':' + port : '');
app.locals.signup = conf.allow_signup;

/** Creates HTTP server. */
Expand Down Expand Up @@ -134,7 +136,7 @@ function start(jwtSecret, network, userNameSuffix) {

/** Event listener for HTTP server "listening" event. */
function onListening() {
getLogger().notice(`Accepting remoteStorage connections: http${network.key ? 's' : ''}://${app.locals.host}/${app.locals.basePath}`);
getLogger().notice(`Accepting remoteStorage connections: http${network.key ? 's' : ''}://${app.locals.host}${basePath}/`);
}

/** Adds listeners for shutdown and serious problems */
Expand All @@ -146,7 +148,7 @@ function start(jwtSecret, network, userNameSuffix) {
function stop(signal) {
getLogger().debug(`${signal} signal received: closing HTTP server`);
server.close(() => {
getLogger().notice(`No longer accepting remoteStorage connections: http${network.key ? 's' : ''}://${app.locals.host}/${app.locals.basePath}`);
getLogger().notice(`No longer accepting remoteStorage connections: http${network.key ? 's' : ''}://${app.locals.host}${basePath}/`);
});
}

Expand Down
10 changes: 5 additions & 5 deletions lib/appFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ const errorPage = require('./util/errorPage');
const helmet = require('helmet');
const shorten = require('./util/shorten');

module.exports = function (jwtSecret, account, store) {
let basePath = process.env.basePath || '';
module.exports = function ({ hostIdentity, jwtSecret, account, store, basePath = '' }) {
if (basePath && !basePath.startsWith('/')) { basePath = '/' + basePath; }

const app = express();
app.locals.basePath = basePath;

// view engine setup
app.engine('.html', require('ejs').__express);
Expand Down Expand Up @@ -58,11 +58,11 @@ module.exports = function (jwtSecret, account, store) {
app.use(`${basePath}/.well-known`, wellKnownRouter);
app.use(`${basePath}/webfinger`, webFingerRouter);

app.use(`${basePath}/oauth`, oAuthRouter(jwtSecret));
app.use(`${basePath}/storage`, storageChecks(jwtSecret));
app.use(`${basePath}/oauth`, oAuthRouter(hostIdentity, jwtSecret));
app.use(`${basePath}/storage`, storageChecks(hostIdentity, jwtSecret));
app.use(`${basePath}/storage`, store);

// catches 404 and forwards to error handler
// catches paths not handled and returns Not Found
app.use(basePath, function (req, res, next) {
const name = req.path.slice(1);
errorPage(req, res, 404, { title: 'Not Found', message: `“${name}” doesn't exist` });
Expand Down
6 changes: 3 additions & 3 deletions lib/routes/oauth.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/* eslint-env node */
/* eslint-disable camelcase */
const express = require('express');
const router = express.Router();
const formOrQueryData = require('../middleware/formOrQueryData');
const redirectToSSL = require('../middleware/redirectToSSL');
const validUser = require('../middleware/validUser');
Expand All @@ -12,7 +11,8 @@ const qs = require('querystring');

const accessStrings = { r: 'Read', rw: 'Read/write' };

module.exports = function (jwtSecret) {
module.exports = function (hostIdentity, jwtSecret) {
const router = express.Router();
router.get('/:username',
redirectToSSL,
formOrQueryData,
Expand Down Expand Up @@ -67,7 +67,7 @@ module.exports = function (jwtSecret) {
const token = jwt.sign(
{ scopes },
jwtSecret,
{ algorithm: 'HS256', issuer: req.app.locals.host, audience: redirectOrigin, subject: username, expiresIn: '30d' }
{ algorithm: 'HS256', issuer: hostIdentity, audience: redirectOrigin, subject: username, expiresIn: '30d' }
);
getLogger().info(`created JWT for ${username} on ${redirectOrigin} w/ scope ${locals.scope}`);
const args = {
Expand Down
6 changes: 3 additions & 3 deletions lib/routes/streaming_storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ const { expressjwt: jwt } = require('express-jwt');
const { logRequest } = require('../logger');
const { getHost } = require('../util/getHost');

module.exports = function (secret) {
module.exports = function (hostIdentity, jwtSecret) {
const router = express.Router();
const ourCors = cors({ origin: true, allowedHeaders: 'Content-Type, Authorization, Content-Length, If-Match, If-None-Match, Origin, X-Requested-With', methods: 'GET, HEAD, PUT, DELETE', exposedHeaders: 'ETag', maxAge: 7200 });
const jwtCredentials = jwt({
secret,
secret: jwtSecret,
algorithms: ['HS256'],
// issuer: app.locals.host, // TODO: should be name of server (as set in config)
issuer: hostIdentity,
// audience: req.get('Origin'),
// subject: username,
maxAge: '30d',
Expand Down
4 changes: 2 additions & 2 deletions notes/S3 streaming store.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@ Configure the store by passing to the constructor the endpoint (host name, and p
* S3_ACCESS_KEY
* S3_SECRET_KEY

For AWS, you must also pass a fifth argument — a user name suffix so bucket names don't collide with other users. By default, this is a dash plus `conf.domain_name`, but you can set `conf.user_name_suffix`.
For AWS, you must also pass a fifth argument — a user name suffix so bucket names don't collide with other users. By default, this is a dash plus `conf.host_identity`, but you can set `conf.user_name_suffix` to override.

Creating an app server then resembles:

```javascript
const s3handler = new S3Handler(process.env.S3_ENDPOINT,
process.env.S3_ACCESS_KEY, process.env.S3_SECRET_KEY);
const app = require('../../lib/appFactory')(s3handler, s3handler);
const app = require('../../lib/appFactory')({account: s3handler, store: s3handler, ...});
```

Https is used if the endpoint is not localhost. If you must use http, you can include the scheme in the endpoint: `http://myhost.example.org`.
Expand Down
29 changes: 27 additions & 2 deletions notes/modular-server.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,36 @@
# Modular Server

There are two parameters for the modular server: an account module to create and validate users, and streaming storage middleware. They may be the same object (as they are for S3-compatible storage).

It's built using Express, so bespoke versions can be implemented by copying appFactory.js and adding new middleware.

There's an NPM module for almost anything worth doing in a Node.js server (albeit, not everything is production-quality).

## Configuration

Your configuration file MUST set `host_identity`.
It's typically the domain name of the host.
Changing `host_identity` will invalidate all grants of access, and make unavailable accounts stored in S3-compatible storage (unless you set `s3.user_name_suffix` to the old value).

### Modular Server Factory

The secret used to generate JWTs must have at least 32 cryptographically random ASCII characters.

The account object has methods to create users and check passwords.
The streaming storage handler is Express middleware and does the actual storage.
They may be the same object (as they are for S3-compatible storage).

S3-compatible storage is typically configured using environment variables; see the note for details.

If your server runs at a path other than root, you must pass the `basePath` argument to appFactory.

### app.set()

If you call `app.set('forceSSL', ...)` you must also call `app.set('httpsPort')` which is only used for this redirection.

### app.locals

You MUST set `app.locals.title` and `app.locals.signup` or the web pages won't render.


## Proxies

Production servers typically outsource TLS to a proxy server — nginx and Apache are both well-documented. A proxy server can also cache static content. Armadietto sets caching headers to tell caches what they can and can't cache.
Expand Down
3 changes: 1 addition & 2 deletions spec/modular/m_not_found.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@ describe('Nonexistant resource (modular)', function () {
before(async function () {
configureLogger({ log_dir: './test-log', stdout: [], log_files: ['error'] });

const app = appFactory('swordfish', {}, (_req, _res, next) => next());
const app = appFactory({ hostIdentity: 'autotest', jwtSecret: 'swordfish', account: {}, store: (_req, _res, next) => next() });
app.locals.title = 'Test Armadietto';
app.locals.basePath = '';
app.locals.host = 'localhost:xxxx';
app.locals.signup = true;
this.app = app;
Expand Down
17 changes: 11 additions & 6 deletions spec/modular/m_oauth.spec.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* eslint-env mocha, chai, node */
/* eslint-disable no-unused-expressions */

const chai = require('chai');
const expect = chai.expect;
Expand Down Expand Up @@ -29,6 +30,7 @@ describe('OAuth (modular)', function () {
}
};

this.hostIdentity = 'automated test';
this.app = express();
this.app.engine('.html', require('ejs').__express);
this.app.set('view engine', 'html');
Expand All @@ -55,7 +57,7 @@ describe('OAuth (modular)', function () {
}
}));
this.app.use(express.urlencoded({ extended: false }));
this.app.use('/oauth', oAuthRouter('swordfish'));
this.app.use('/oauth', oAuthRouter(this.hostIdentity, 'swordfish'));
this.app.set('account', mockAccount);
this.app.locals.title = 'Test Armadietto';
this.app.locals.basePath = '';
Expand All @@ -81,15 +83,15 @@ describe('OAuth (modular)', function () {
describe('without explicit read/write permissions', async function () {
it('authorizes the client to read and write', async function () {
const res = await post(this.app, '/oauth', this.auth_params);
expect(res).to.redirect;
const redirect = new URL(res.get('location'));
expect(redirect.origin).to.equal('http://example.com');
expect(redirect.pathname).to.equal('/cb');
const params = new URLSearchParams(redirect.hash.slice(1));
expect(params.get('token_type')).to.equal('bearer');
expect(params.get('state')).to.equal('the_state');
const token = params.get('access_token');
expect(token).to.match(/^eyJh/);
const { scopes } = jwt.verify(token, 'swordfish', { audience: 'http://example.com', subject: 'zebcoe' });
const { scopes } = jwt.verify(token, 'swordfish', { issuer: this.hostIdentity, audience: 'http://example.com', subject: 'zebcoe' });
expect(scopes).to.equal('the_scope:rw');
});
});
Expand All @@ -98,10 +100,11 @@ describe('OAuth (modular)', function () {
it('authorizes the client to read', async function () {
this.auth_params.scope = 'the_scope:r';
const res = await post(this.app, '/oauth', this.auth_params);
expect(res).to.redirect;
const redirect = new URL(res.get('location'));
const params = new URLSearchParams(redirect.hash.slice(1));
const token = params.get('access_token');
const { scopes } = jwt.verify(token, 'swordfish', { audience: 'http://example.com', subject: 'zebcoe' });
const { scopes } = jwt.verify(token, 'swordfish', { issuer: this.hostIdentity, audience: 'http://example.com', subject: 'zebcoe' });
expect(scopes).to.equal('the_scope:r');
});
});
Expand All @@ -110,10 +113,11 @@ describe('OAuth (modular)', function () {
it('authorizes the client to read and write', async function () {
this.auth_params.scope = 'the_scope:rw';
const res = await post(this.app, '/oauth', this.auth_params);
expect(res).to.redirect;
const redirect = new URL(res.get('location'));
const params = new URLSearchParams(redirect.hash.slice(1));
const token = params.get('access_token');
const { scopes } = jwt.verify(token, 'swordfish', { audience: 'http://example.com', subject: 'zebcoe' });
const { scopes } = jwt.verify(token, 'swordfish', { issuer: this.hostIdentity, audience: 'http://example.com', subject: 'zebcoe' });
expect(scopes).to.equal('the_scope:rw');
});
});
Expand All @@ -122,10 +126,11 @@ describe('OAuth (modular)', function () {
it('authorizes the client to read and write nonexplicit scopes', async function () {
this.auth_params.scope = 'first_scope second_scope:r third_scope:rw fourth_scope';
const res = await post(this.app, '/oauth', this.auth_params);
expect(res).to.redirect;
const redirect = new URL(res.get('location'));
const params = new URLSearchParams(redirect.hash.slice(1));
const token = params.get('access_token');
const { scopes } = jwt.verify(token, 'swordfish', { audience: 'http://example.com', subject: 'zebcoe' });
const { scopes } = jwt.verify(token, 'swordfish', { issuer: this.hostIdentity, audience: 'http://example.com', subject: 'zebcoe' });
expect(scopes).to.equal('first_scope:rw second_scope:r third_scope:rw fourth_scope:rw');
});
});
Expand Down
9 changes: 3 additions & 6 deletions spec/modular/m_root.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ describe('root page (modular)', function () {
beforeEach(function () {
configureLogger({ log_dir: './test-log', stdout: [], log_files: ['error'] });

this.app = appFactory('swordfish', {}, (_req, _res, next) => next());
this.app = appFactory({ hostIdentity: 'autotest', jwtSecret: 'swordfish', account: {}, store: (_req, _res, next) => next() });
this.app.locals.title = 'Armadietto without Signup';
this.app.locals.basePath = '';
this.app.locals.host = 'localhost:xxxx';
this.app.locals.signup = false;
});
Expand All @@ -28,9 +27,8 @@ describe('root page (modular)', function () {
beforeEach(function () {
configureLogger({ log_dir: './test-log', stdout: [], log_files: ['error'] });

this.app = appFactory('swordfish', {}, (_req, _res, next) => next());
this.app = appFactory({ hostIdentity: 'autotest', jwtSecret: 'swordfish', account: {}, store: (_req, _res, next) => next() });
this.app.locals.title = 'Armadietto with Signup';
this.app.locals.basePath = '';
this.app.locals.host = 'localhost:xxxx';
this.app.locals.signup = true;
});
Expand All @@ -43,9 +41,8 @@ describe('root page (modular)', function () {
before(async () => {
configureLogger({});

this.app = appFactory('swordfish', {}, (_req, _res, next) => next());
this.app = appFactory({ hostIdentity: 'autotest', jwtSecret: 'swordfish', account: {}, store: (_req, _res, next) => next() });
this.app.locals.title = 'Armadietto with Signup';
this.app.locals.basePath = '';
this.app.locals.host = 'localhost:xxxx';
this.app.locals.signup = true;
});
Expand Down
14 changes: 7 additions & 7 deletions spec/modular/m_signup.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ const { configureLogger } = require('../../lib/logger');
const { shouldBlockSignups, shouldAllowSignupsBasePath } = require('../signup.spec');
const core = require('../../lib/stores/core');
const appFactory = require('../../lib/appFactory');
const process = require('process');

const mockAccount = {

Expand All @@ -15,9 +14,8 @@ describe('Signup (modular)', function () {
before(async function () {
configureLogger({ log_dir: './test-log', stdout: [], log_files: ['debug'] });

const app = appFactory('swordfish', mockAccount, (_req, _res, next) => next());
const app = appFactory({ hostIdentity: 'autotest', jwtSecret: 'swordfish', account: mockAccount, store: (_req, _res, next) => next() });
app.locals.title = 'Test Armadietto';
app.locals.basePath = '';
app.locals.host = 'localhost:xxxx';
app.locals.signup = false;
this.app = app;
Expand All @@ -37,13 +35,15 @@ describe('Signup (modular)', function () {
}
};

process.env.basePath = 'basic';
delete require.cache[require.resolve('../../lib/appFactory')];
const app = require('../../lib/appFactory')('swordfish', mockAccount, (_req, _res, next) => next());
const app = require('../../lib/appFactory')({
jwtSecret: 'swordfish',
account: mockAccount,
store: (_req, _res, next) => next(),
basePath: '/basic'
});
app.set('account', this.store);
process.env.basePath = '';
app.locals.title = 'Test Armadietto';
app.locals.basePath = '/basic';
app.locals.host = 'localhost:xxxx';
app.locals.signup = true;
this.app = app;
Expand Down
3 changes: 1 addition & 2 deletions spec/modular/m_static.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@ describe('Static asset handler (modular)', function () {
before(function () {
configureLogger({ log_dir: './test-log', stdout: [], log_files: ['error'] });

const app = appFactory('swordfish', {}, (_req, _res, next) => next());
const app = appFactory({ hostIdentity: 'autotest', jwtSecret: 'swordfish', account: {}, store: (_req, _res, next) => next() });
app.locals.title = 'Test Armadietto';
app.locals.basePath = '';
app.locals.host = 'localhost:xxxx';
app.locals.signup = false;
this.app = app;
Expand Down
Loading

0 comments on commit 3475290

Please sign in to comment.