Serverless IndieAuth

Serverless IndieAuth

Or, how I set up an IndieAuth server on a static Netlify site

By

Lately, I've been enthralled by the IndieWeb movement, and in the process of rebooting my online identity I decided to make my website adhere to as many IndieWeb standards as possible. This started with simple things, like h-cards and h-entrys, which are easy to implement on a static site. But then I got thinking - the static site host I use, Netlify, has a serverless function service, so I might be able to implement the more complex standards.

I decided to start with IndieAuth, because it seemed like the easiest. IndieAuth is a protocol that fulfills a very similar purpose to OpenID. It allows users to authenticate on a web service using their domain name, with the added benefit of also being able to authorize use of standarized protocols on the user's website. The protocol is decently simple: the origin website redirects to the user's authorization URI, that authorization URI asks permission from the user, and the user is redirected back to the origin with a code. That code can be sent back to the user's website to check its validity. These codes usually expire in about 10 minutes.

This whole exchange has some state involved, such as keeping the valid codes around for 10 minutes. The first approach that came to mind was keeping state stored in a Redis cache, which would probably work but would have required a dependency on an external Redis host. I set the project aside for a few hours, and during that time I remembered that JSON web tokens exist.

With JSON web tokens, I can store the state of the exchange on someone else's state storage and not worry about it. A JSON web token is pretty much a payload and a cryptographic signature. This way, my IndieAuth server can provide a JWT with the origin server's client ID and an expiration time, and I don't have to worry about storing the code. When the origin asks to validate the JWT, I can validate the cryptographic signature. That's the essence of how my serverless IndieAuth service works.

There are two Netlify functions called authorize.js and completeAuthorization.js. This code is licensed under CC0, so you can repurpose it to work on your website, and maybe make the error handling a little more... comprehensive. Here's the code for authorize.js:

import * as jwt from 'jsonwebtoken';
import { parse } from 'querystring';
import Negotiator from 'negotiator';
import { hxapp } from '../lambda-import/hxapp';

function htmlEntities(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

export async function handler(event, context, callback) {
try {
if (event.httpMethod === 'GET') {
const { me, client_id, redirect_uri, state, response_type } = event.queryStringParameters;
if (me !== 'https://piperswe.me/' && me !== 'https://piperswe.me') {
return callback(null, {
statusCode: 400,
body: 'Invalid "me" parameter - this endpoint only serves https://piperswe.me.',
});
} else if (response_type != null && response_type !== 'id') {
return callback(null, {
statusCode: 400,
body: 'Invalid "response_type" paremeter - this endpoint only provides identification, not authorization.',
});
} else if (client_id == null) {
return callback(null, {
statusCode: 400,
body: 'Invalid "client_id" parameter - a client ID is required.',
});
} else if (redirect_uri == null) {
return callback(null, {
statusCode: 400,
body: 'Invalid "redirect_uri" parameter - a redirect URI is required.',
});
}

const hxa = await hxapp(client_id, redirect_uri);
console.log(hxa);

return callback(null, {
statusCode: 200,
body: `
<!DOCTYPE html>
<html>
<head>
<title>Authorize</title>
<link rel="stylesheet" href="/main.css" />
</head>
<body>
<div class="container">
<header>
<ul class="nav">
<li>
<a href="${htmlEntities(client_id)}">go back<small>to ${htmlEntities(client_id)}</small></a>
</li>
</ul>
<h1>Authentication</h1>
</header>
${hxa.photo ? `<img style="width: 128px; margin: auto;" src="${htmlEntities(hxa.photo)}" />`
: ''}
<p>${htmlEntities(hxa.name)} would like to verify that you are <a href="https://piperswe.me" rel="me" class="h-card">Piper McCorkle</a>.</p>
<p>${
hxa.verified
? 'The redirect URI has been verified by checking <code>&lt;link rel="redirect_uri"&gt;</code>.'
: '⚠️ The redirect URI has not been verified - <code>&lt;link rel="redirect_uri"&gt;</code> either does not exist or does not match the redirect URI.'
}</p>
<p></p>
<form action="https://piperswe.me/.netlify/functions/completeAuthorization" method="POST" id="form">
<input type="hidden" name="me" value="${htmlEntities(me)}" />
<input type="hidden" name="client_id" value="${htmlEntities(client_id)}" />
<input type="hidden" name="redirect_uri" value="${htmlEntities(redirect_uri)}" />
<input type="hidden" name="state" value="${htmlEntities(state)}" />
<label for="username">Username</label>
<input type="text" id="username" value="pmc" disabled />
<label for="password">Password</label>
<input type="password" name="password" id="password" placeholder="Enter your password then press enter." />
</form>
</div>
<script>
password.onkeydown = function(e) {
if (e.keyCode === 13) form.submit();
};
</script>
</body>
</html>
`
});
} else if (event.httpMethod === 'POST') {
const { code, redirect_uri, client_id } = parse(event.body);
let payload;
try {
payload = jwt.verify(code, process.env.JWT_SECRET);
} catch (e) {
console.log('invalid token');;
return callback(null, {
statusCode: 403,
body: 'Invalid token',
});
}
console.log('valid token');
if (redirect_uri === payload.red && client_id === payload.aud && 'https://piperswe.me/' === payload.sub) {
const negotiator = new Negotiator(event);
switch (negotiator.mediaType([ 'application/json', 'application/x-www-form-urlencoded' ])) {
case 'application/json':
console.log('json');
return callback(null, {
statusCode: 200,
body: JSON.stringify({
me: payload.sub,
}),
});
case 'application/x-www-form-urlencoded':
console.log('form');
return callback(null, {
statusCode: 200,
body: 'me=' + encodeURIComponent(payload.sub),
});
}
} else {
console.log('itrcc');
return callback(null, {
statusCode: 403,
body: 'Invalid token and redirect_uri/client_id combination',
});
}
} else {
throw '';
}
} catch (e) {
console.error(e);
return callback(null, {
statusCode: 405,
body: 'idk what you did but I don\'t like it',
});
}
}

And here's the code for completeAuthorization:

import * as jwt from 'jsonwebtoken';
import * as bcrypt from 'bcryptjs';
import { parse } from 'querystring';

export function handler(event, context, callback) {
const { me, client_id, redirect_uri, state, password } = parse(event.body);
if (me !== 'https://piperswe.me/') {
return callback(null, {
statusCode: 403,
body: 'Invalid me.',
});
}
bcrypt.compare(password, process.env.PASSWORD_HASH, (err, res) => {
if (err) {
return callback(err);
}
if (!res) {
return callback(null, {
statusCode: 403,
body: 'Incorrect password.',
});
}
const token = jwt.sign({
red: redirect_uri,
}, process.env.JWT_SECRET, {
subject: me,
audience: client_id,
expiresIn: '10 minutes',
});
return callback(null, {
statusCode: 302,
headers: {
Location: redirect_uri + '?code=' + encodeURIComponent(token) + '&state=' + encodeURIComponent(state),
},
});
})
}

And finally, here's the code for hxapp.js, which fetches h-x-app data from origin sites:

import cheerio from 'cheerio';
import fetch from 'node-fetch';

export async function hxapp(url, redirect) {
const r = await fetch(url);
const $ = cheerio.load(await r.text());
const hxappel = $('.h-x-app');
const name = hxappel.find('.p-name');
const photo = hxappel.find('.u-photo');
const verified = $('link[rel="redirect_uri"]').attr('href') === redirect;
return {
name: name.text() || url,
photo: photo.text(),
verified,
};
}

If you'd like to respond to this, I don't have WebMentions set up yet (though I might be able to do something with Netlify functions and the GitHub API...), so you'll have to comment on the Lobste.rs post.

Posted on