Skip to content

Commit

Permalink
modular server: implements OAuth
Browse files Browse the repository at this point in the history
  • Loading branch information
DougReeder committed Feb 27, 2024
1 parent e7def12 commit 3093677
Show file tree
Hide file tree
Showing 15 changed files with 417 additions and 7 deletions.
3 changes: 3 additions & 0 deletions bin/www
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ const port = normalizePort( process.env.PORT || conf.http?.port || '8000');
app.set('port', port);

app.set('forceSSL', Boolean(conf.https?.force));
if (conf.http?.port && conf.https?.port) {
app.set('httpsPort', parseInt(conf.https?.port)); // only for redirecting to HTTPS
}

// If the environment variables aren't set, storage uses a shared public account on play.min.io!
app.set('streaming store', new S3(process.env.S3_HOSTNAME,
Expand Down
3 changes: 3 additions & 0 deletions lib/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const indexRouter = require('./routes/index');
const signupRouter = require('./routes/signup');
const wellKnownRouter = require('./routes/well_known');
const webFingerRouter = require('./routes/webfinger');
const oAuthRouter = require('./routes/oauth');
const errorPage = require('./util/errorPage');
const helmet = require('helmet');
const shorten = require('./util/shorten');
Expand Down Expand Up @@ -54,6 +55,8 @@ app.use(`${basePath}/signup`, signupRouter);
app.use(`${basePath}/.well-known`, wellKnownRouter);
app.use(`${basePath}/webfinger`, webFingerRouter);

app.use(`${basePath}/oauth`, oAuthRouter);

// catches 404 and forwards to error handler
app.use(basePath, function (req, res, next) {
const name = req.path.slice(1);
Expand Down
6 changes: 3 additions & 3 deletions lib/controllers/assets.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ const assetDir = path.join(__dirname, '..', 'assets');
const { logRequest } = require('../logger');

const TYPES = {
'.css': 'text/css; charset=utf8',
'.js': 'application/javascript; charset=utf8',
'.svg': 'image/svg+xml; charset=utf8',
'.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.svg': 'image/svg+xml; charset=utf-8',
'.woff2': 'font/woff2'
};

Expand Down
2 changes: 1 addition & 1 deletion lib/controllers/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ class Controller {

const headers = {
'Content-Length': html.length,
'Content-Type': 'text/html; charset=utf8',
'Content-Type': 'text/html; charset=utf-8',
'Content-Security-Policy': "sandbox allow-scripts allow-forms allow-popups allow-same-origin; default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self'; object-src 'none'; child-src 'none'; connect-src 'none'; base-uri 'self'; frame-ancestors 'none';",
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer'
Expand Down
9 changes: 9 additions & 0 deletions lib/middleware/formOrQueryData.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/** Sets req.data to form data if form-urlencoded, otherwise to query parameters */
module.exports = function (req, res, next) {
if (req.is('application/x-www-form-urlencoded')) {
req.data = req.body;
} else {
req.data = req.query;
}
next();
};
16 changes: 16 additions & 0 deletions lib/middleware/redirectToSSL.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const isSecureRequest = require('../util/isSecureRequest');
const { getHost } = require('../util/getHost');
const { logRequest } = require('../logger');

/** redirects to HTTPS server if needed */
module.exports = function redirectToSSL (req, res, next) {
if (isSecureRequest(req) || (process.env.NODE_ENV !== 'production' && !req.app.get('forceSSL'))) {
return next();
}

const host = getHost().split(':')[0] + (req.app.get('httpsPort') ? ':' + req.app.get('httpsPort') : '');
const newUrl = 'https://' + host + req.url;

res.redirect(302, newUrl);
logRequest(req, '-', 302, 0, '-> ' + newUrl);
};
12 changes: 12 additions & 0 deletions lib/middleware/secureRequest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const isSecureRequest = require('../util/isSecureRequest');
const { logRequest } = require('../logger');

/** ensures request is secure, if required */
module.exports = function secureRequest (req, res, next) {
if (isSecureRequest(req) || (process.env.NODE_ENV !== 'production' && !req.app.get('forceSSL'))) {
return next();
}

res.status(400).end(); // TODO: add an explanatory message
logRequest(req, '-', 400, 0, 'blocked insecure');
};
13 changes: 13 additions & 0 deletions lib/middleware/validUser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const core = require('../stores/core');
const { logRequest } = require('../logger');

/** fails request if data.username doesn't pass core.isValidUsername
* TODO: have store validate username
* */
module.exports = function validUser (req, res, next) {
if (core.isValidUsername(req.data.username)) { return next(); }

res.status(400).type('text/plain').end();

logRequest(req, req.data.username, 400, 0, 'invalid user');
};
137 changes: 137 additions & 0 deletions lib/routes/oauth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/* 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');
const secureRequest = require('../middleware/secureRequest');
const { logRequest } = require('../logger');
const qs = require('querystring');

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

router.get('/:username',
redirectToSSL,
formOrQueryData,
validUser,
validOAuthRequest,
function (req, res) {
res.render('auth.html', {
title: 'Authorize',
client_host: new URL(req.query.redirect_uri).host,
client_id: req.query.client_id,
redirect_uri: req.query.redirect_uri,
response_type: req.query.response_type,
scope: req.query.scope || '',
state: req.query.state || '',
permissions: parseScope(req.query.scope || ''),
username: req.params.username,
access_strings: accessStrings
});
logRequest(req, req.params.username, 200);
});

router.post('/',
secureRequest,
formOrQueryData,
validUser,
validOAuthRequest,
async function (req, res) {
const locals = req.data;
const username = locals.username.split('@')[0];
const permissions = parseScope(locals.scope);

if (locals.deny) {
return error(req, res, 'access_denied', 'The user did not grant permission');
}

try {
await req.app.get('streaming store').authenticate({ username, password: locals.password });
const token = await req.app.get('streaming store').authorize(locals.client_id, username, permissions);
const args = {
access_token: token,
token_type: 'bearer',
...(locals.state && { state: locals.state })
};
redirect(req, res, args);
} catch (error) {
locals.title = 'Authorization Failure';
locals.client_host = new URL(locals.redirect_uri).host;
locals.error = error.message;
locals.permissions = permissions;
locals.access_strings = accessStrings;
locals.state = locals.state || '';

res.status(401).render('auth.html', locals);
}
}
);

function validOAuthRequest (req, res, next) {
if (!req.data.client_id) {
return error(req, res, 'invalid_request', 'Required parameter "client_id" is missing');
}
if (!req.data.response_type) {
return error(req, res, 'invalid_request', 'Required parameter "response_type" is missing');
}
if (!req.data.scope) {
return error(req, res, 'invalid_scope', 'Parameter "scope" is invalid');
}
if (!req.data.redirect_uri) {
return error(req, res, 'invalid_request', 'Required parameter "redirect_uri" is missing');
}
const uri = new URL(req.data.redirect_uri);
if (!uri.protocol || !uri.hostname) {
return error(req, res, 'invalid_request', 'Parameter "redirect_uri" must be a valid URL');
}

if (req.data.response_type !== 'token') {
return error(req, res, 'unsupported_response_type', 'Response type "' + req.data.response_type + '" is not supported');
}

next();
}

function error (req, res, error, error_description) {
redirect(req, res, { error, error_description },
`${req.data.username} ${error_description} ${req.data.client_id}`);
}

function redirect (req, res, args, logNote) {
const hash = qs.stringify(args);
if (req.data.redirect_uri) {
const location = req.data.redirect_uri + '#' + hash;
res.redirect(location);

if (logNote) {
logRequest(req, req.data.username || '-', 302, 0, logNote, 'warning');
} else {
logRequest(req, req.data.username || '-', 302, 0, '-> ' + req.data.redirect_uri, 'notice');
}
} else {
res.status(400).type('text/plain').send(hash);
logRequest(req, req.data.username || '-', 400, hash.length,
logNote || args?.error_description || 'no redirect_uri');
}
}

// OAuth.prototype.accessStrings = {r: 'Read', rw: 'Read/write'};
function parseScope (scope) {
const parts = scope.split(/\s+/);
const scopes = {};
let pieces;

for (let i = 0, n = parts.length; i < n; i++) {
pieces = parts[i].split(':');
pieces[0] = pieces[0].replace(/(.)\/*$/, '$1');
if (pieces[0] === 'root') pieces[0] = '/';

scopes[pieces[0]] = (pieces.length > 1)
? pieces.slice(1).join(':').split('')
: ['r', 'w'];
}
return scopes;
}

module.exports = router;
2 changes: 1 addition & 1 deletion lib/util/getHost.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ function getHost (req) {
}

function getHostBaseUrl (req) {
const scheme = (isSecureRequest(req) || req.app.get('forceSSL')) ? 'https' : 'http';
const scheme = (isSecureRequest(req) || req.app.get('forceSSL') || process.env.NODE_ENV === 'production') ? 'https' : 'http';
return scheme + '://' + getHost(req) + req.app.locals.basePath;
}

Expand Down
26 changes: 26 additions & 0 deletions spec/armadietto/a_oauth_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/* eslint-env mocha, chai, node */

const Armadietto = require('../../lib/armadietto');
const { shouldImplementOAuth } = require('../oauth.spec');

const store = {
authorize (clientId, username, permissions) {
return 'a_token';
},
authenticate (params) {
}
};

describe('OAuth (monolithic)', function () {
before(function () {
this.store = store;
this.app = new Armadietto({
bare: true,
store,
http: { },
logging: { stdout: [], log_dir: './test-log', log_files: ['debug'] }
});
});

shouldImplementOAuth();
});
2 changes: 1 addition & 1 deletion spec/armadietto/oauth_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ describe('OAuth', async () => {
};
const res = await post('/oauth', this.auth_params);
expect(res).to.have.status(401);
expect(res).to.have.header('Content-Type', 'text/html; charset=utf8');
expect(res).to.have.header('Content-Type', 'text/html; charset=utf-8');
expect(res).to.have.header('Content-Security-Policy', /sandbox.*default-src 'self'/);
expect(res).to.have.header('Referrer-Policy', 'no-referrer');
expect(res).to.have.header('X-Content-Type-Options', 'nosniff');
Expand Down
2 changes: 1 addition & 1 deletion spec/armadietto/signup_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ describe('Home w/o signup and no base path', () => {
it('returns a style sheet', async () => {
const res = await get('/assets/style.css');
expect(res).to.have.status(200);
expect(res).to.have.header('Content-Type', 'text/css; charset=utf8');
expect(res).to.have.header('Content-Type', 'text/css; charset=utf-8');
expect(res).to.have.header('X-Content-Type-Options', 'nosniff');
});

Expand Down
29 changes: 29 additions & 0 deletions spec/modular/m_oauth.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/* eslint-env mocha, chai, node */

const { configureLogger } = require('../../lib/logger');
const { shouldImplementOAuth } = require('../oauth.spec');

const mockStore = {
async authorize (_clientId, _username, _permissions) {
return 'a_token';
},
async authenticate (params) {
}
};

describe('OAuth (modular)', function () {
before(async function () {
configureLogger({ log_dir: './test-log', stdout: [], log_files: ['debug'] });

this.store = mockStore;

this.app = require('../../lib/app');
this.app.set('streaming store', this.store);
this.app.locals.title = 'Test Armadietto';
this.app.locals.basePath = '';
this.app.locals.host = 'localhost:xxxx';
this.app.locals.signup = false;
});

shouldImplementOAuth();
});
Loading

0 comments on commit 3093677

Please sign in to comment.