At work I take care of many aspects of an application's life cycle: provisioning servers and configuring monitoring, setting up and verifying backups, automating certificate renewal, developing and maintaining the application, etc. A lot of it is a consequence of client requirements about the location of their data. Still, I'm always on the lookout for ways to eliminate most of this work for projects with no hosting restrictions.
One case that's been bothering me is our company's website, which is powered by a custom-built content management system. While hosting it on Heroku alleviates some of the burden, we still need to keep up with language and library versions. Thus, I've been thinking about alternatives.
The reason for the content management system is to allow more than developers to update the website. Any replacement would have to maintain this property.
While exploring alternatives I experimented with the combination of the Eleventy static site generator, Netlify CMS for editing and ZEIT Now for hosting.
Eleventy seems to offer a lot of flexibility without needing much configuration. Netlify CMS provides a user-friendly interface for editing content, but still integrates with git. Finally, ZEIT Now automatically issues certificates and deploys every commit.
Netlify CMS integrates nicely with the Netlify service and I would use that in production. However, I learned a more by not hosting on Netlify in my experiments.
Netlify CMS authentication backends
Netlify CMS requires read access to the repository to present the current content to editors and write access to save changes. This is solved with OAuth. A user logs in with Github and the access token is transmitted to Netlify CMS which can then commit on the user's behalf.
Github implements OAuth2 with an authorization code grant. The Netlify CMS is entirely a client side solution and cannot obtain the access token directly with this type of grant. Normally it relies on Netlify to facilitate authentication, but also supports a custom backend.
I read through the existing implementations, but did not like them. None of them have tests and many are susceptible to cross-site forgery attacks, so I decided to write my own backend (famous last words!).
"Serverless functions"
I realized I could host the authentication code directly on ZEIT Now using their "serverless functions". The concept is that if your project contains an api/
directory with one request handler per file, Now deploys the functions alongside your static content.
Up until that point I had not encountered a good use case for "serverless". An OAuth 2.0 client workflow seemed like one. It suddenly clicked.
Requesting an authorization code from Github
The login flow starts with a redirect to the Github authorization screen. This screen informs about the requested permissions defined by the scope
parameter.
const { URLSearchParams } = require('url');
const { sign } = require('jsonwebtoken');
const uuidv4 = require('uuid/v4');
module.exports = (req, res) => {
const params = {
client_id: process.env.GITHUB_CLIENT_ID,
scope: 'repo',
state: sign(
{ nonce: uuidv4() },
process.env.JWT_SECRET,
{ expiresIn: 30 }
)
};
res.writeHead(307, {
'Location': `https://github.com/login/oauth/authorize?${
new URLSearchParams(params)
}`
})
res.end();
}
The state
parameter is the one that helps us to protect against cross-site forgery attacks. In the request phase you set its value to something unique that cannot be guessed by an attacker. In the callback phase (see below), you check the state returned by the provider matches the one you sent.
Here, the unique part is the UUID. It is transported as a JSON web token signed with a symmetric secret key. Additionally the token expires after 30 seconds. That may be a bit short for the very first login as part of it requires human interaction: authorizing Netlify CMS to write to the repo. After that first time the process is fully automated and I expect Github to respond in less than 30 seconds.
Exchanging the authorization code a for an access token
If the user grants the rights to the app, they get redirected back to a predefined endpoint.
There are two query parameters to check before going forward:
code
: this is the authorization code. If there is none, someone may be trying to bypass the Github identity check.state
: this is the JSON web token sent in the first phase. Again, if that is invalid someone is trying to forge the request.
Assuming the parameters are correct, the authorization code can be exchanged for an access token.
The described callback handler could look like this:
const url = require('url');
const { URLSearchParams } = url;
const { verify } = require('jsonwebtoken');
const requestToken = require('../lib/request-token');
const { renderSuccess, renderError } = require('../lib/netlify-cms-login');
module.exports = async (req, res) => {
const queryParams = new URLSearchParams(url.parse(req.url).query);
const code = queryParams.get('code');
const state = queryParams.get('state');
const origin = process.env.ORIGIN;
if (!code) {
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(renderError(origin, 'Code parameter missing'));
return;
}
verify(state, process.env.JWT_SECRET, (error, _) => {
if (error) {
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(renderError(origin, 'Invalid state parameter'));
return;
}
});
const tokenResponse = await requestToken(code);
const { access_token, error, error_description } = tokenResponse;
if (error) {
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(renderError(origin, error));
return;
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(renderSuccess(origin, access_token));
};
The renderSuccess
and renderError
functions return responses that can be handled by Netlify CMS.
The Netlify CMS login handshake
I haven't found documentation about this part. It seems like everybody reads the Netlify CMS source code to understand how it works.
When you press the login button, Netlify CMS open the aforementioned login entry point in a new window. Then it stars listening for messages from that window. When that pop-up window has obtained an access token, it is expected to transmit it back to its parent window.
In the case of the Github workflow, the communication goes something like this:
- The pop-up window sends the message
authorizing:github
to the window that spawned it. - The original window (where Netlify CMS is running) sends back something to the effect of "Oh yeah? Do you have an access token?" (actually it echoes back the same message as far as I can tell)
- The pop-up relays Github's response in of these two forms:
// error case
authorization:github:error:{"message": "The error message"}
// success case
authorization:github:success:{"token":"access token value"}
The communication occurs via window.postMessage
. It's crucial to verify who is the sender of such a message before acting on its contents.
With that in mind here is an example of what renderSuccess
from the previous section might return:
<!doctype html><body><script>
(function() {
function recieveMessage(e) {
if (e.origin !== "https://app.domain:1234") { return }
window.opener.postMessage(
'authorization:github:success:{"token":"sometoken"}',
e.origin
)
}
window.addEventListener("message", recieveMessage, false)
window.opener.postMessage(
"authorizing:github",
"https://app.domain:1234"
)
})()
</script></body></html>
Testing
As seen above, ZEIT works with regular Node.js request handlers. One way to test a handler is to start an HTTP server, install that handler and send requests to it.
Here is for example the test for the /api/auth
endpoint.
import test from 'ava';
import http from 'http';
import listen from 'test-listen';
import fetch from 'node-fetch';
import { verify } from 'jsonwebtoken';
import { URLSearchParams } from 'url';
import auth from '../api/auth';
test('redirects to Github OAuth workflow start', async t => {
let server = http.createServer(auth);
let url = await listen(server);
let env = process.env;
process.env.GITHUB_CLIENT_ID = 'dummy-id'
process.env.JWT_SECRET = 'jwt-secret'
let response = await fetch(url, { redirect: 'manual' });
let location = response.headers.get('Location');
t.is(response.status, 307);
t.true(location.startsWith('https://github.com/login/oauth/authorize?client_id=dummy-id&scope=repo'))
verify((new URLSearchParams(location)).get('state'), 'jwt-secret', (error, _) => {
if (error) {
t.fail(`Expected a valid JSON web token to be sent as state, got ${error}`);
}
});
process.env = env;
server.close();
});
The request handler reads the secrets from the process environment. That is why I set some environment variables at the start and restore the environment after the test.
The test-listen library makes the HTTP server listen on an arbitrary unused port and returns the complete URL. That way all tests can run in parallel without port collisions.
Example source code
Read the complete source code for this experiment for more details about integrating with the Netlify CMS.