Protecting an Architect Framework Application with OAuth2 or OpenID Connect Authentication

I have a number of services that I use the Architect Framework, as it's really handy for creating an event-based, multi-Lambda (HTTP) application.

One of the things I like to do to is secure my services behind OAuth2/OpenID Connect, as it's a standard way of handling authorization, and a "log in with OAuth2/OpenID Connect" is a well-supported operation across languages and technologies.

For these Architect-backed services, I wanted to have a few protected routes such as access to logs that may expose sensitive information about the calls to the services.

As I use IndieAuth as my identity layer, and we've recently made a lot of efforts to align IndieAuth with OAuth2, it's actually very straightforward to integrate with a standard OAuth2 client.

This leads to a slightly different flow than you'd use with a single service i.e. Google or GitHub login, but the gist of it should be viable for your own use.

I have taken the below code from a sample project on GitLab, as a standalone way to test this.

I'm planning on publishing this as an NPM package so it's easier to use in a generic way, across projects.

Code

Protecting resources#

Architect provides a really nicely abstracted session management setup, which allows us to set key-value data in the req.session, and means we don't need to handle how i.e. encrypt the session data, or manage session IDs.

To give us a handy way to require authentication, we can take advantage of that and store information in our session about the logged-in user, as well as add an expiry to enforce re-authentication regularly.

We can utilise the session handling in Architect to do something like this in our handler method for the incoming request:

async function handler(req) {
  const session = req.session
  const isLoggedIn = await auth.isLoggedIn(session);
  if (!isLoggedIn) {
    delete session.auth_me; // `me` is the subject of the request, i.e. https://www.jvt.me/
    delete session.auth_time;
    return {
      session,
      html: `You're not logged in`
    }
  }

  return {
    session,
    html: `Welcome back ${session.auth_me}`
  }
}

This utilises the following snippet from auth.js to validate whether the user is logged in:

const AUTH_TIME = 1000 * 60 * 60;

function authHasExpired(auth_time, now) {
  return (now - auth_time) >= AUTH_TIME;
}

async function isLoggedIn(session) {
  if (session === undefined || !session) {
    return false
  }
  if (session.auth_me === undefined || session.auth_time === undefined) {
    return false
  }
  if (authHasExpired(session.auth_time, new Date().getTime())) {
    return false;
  }

  return true;
}

Note that as well as an authentication check, we could perform additional authorization checks to validate that the user is the right one i.e. checking that they are present in a list of users that are allowed access to specific pages.

Setting up the authentication flow#

To actually start the authentication flow, we need a page to trigger this. In my case, I've got a /start?me=${profile_url} endpoint that uses IndieAuth to discover OAuth2 endpoints and then go through the OAuth2 flow as such, but you could just as easily have a /start/github that redirects to the GitHub authorization URL.

const arc = require('@architect/functions')

const auth = require('@architect/shared/auth')
const profile = require('@architect/shared/profile')
const oidc = require('openid-client')

async function handler(req) {
  // simplified for this example
  const session = req.session
  session.discovery = 'https://indieauth.jvt.me/.well-known/oauth-authorization-server';

  const issuer = await oidc.Issuer.discover(session.discovery);

  const client = await auth.createClient(issuer)
  const code_verifier = oidc.generators.codeVerifier();
  session.verifier = code_verifier;

  const code_challenge = oidc.generators.codeChallenge(code_verifier);

  const authorizationUrl = client.authorizationUrl({
    scope: 'profile',
    code_challenge,
    code_challenge_method: 'S256',
  });

  return {
    status: 302,
    session,
    headers: {
      location: authorizationUrl
    }
  }
}

Then, we have our callback URL:

const arc = require('@architect/functions')
const auth = require('@architect/shared/auth')

const oidc = require('openid-client')

async function handler(req) {
  const session = req.session

  const issuer = await oidc.Issuer.discover(session.discovery);
  const client = await auth.createClient(issuer)
  const code_verifier = session.verifier;

  const params = req.queryStringParameters
  const redirect = await auth.redirectUri()
  const tokenSet = await client.oauthCallback(redirect, params, { code_verifier });

  session.auth_time = new Date().getTime()
  session.auth_me = tokenSet.me;

  // just an example, but we'd probably want to redirect to where we were pre-auth, using something from the `req.session`
  return {
    session,
    html: `<pre>${JSON.stringify(tokenSet)}</pre>`
  }
}

Note that I'm using the me, which is returned by the IndieAuth token endpoint. Ideally we would use the Token Introspection endpoint - which may return some user information in the claims - or for an OpenID Connect solution, I would use the userinfo endpoint.

Notice that this takes advantage of a shared createClient() method, which simplifies duplication:

async function redirectUri() {
  return process.env.BASE_URL + 'callback'
}

async function createClient(issuer) {
  return new issuer.Client({
    client_id: process.env.BASE_URL,
    redirect_uris: [await redirectUri()],
    response_types: ['code'],
    token_endpoint_auth_method: 'none' // required for IndieAuth
  });
}

Written by Jamie Tanna's profile image Jamie Tanna on , and last updated on .

Content for this article is shared under the terms of the Creative Commons Attribution Non Commercial Share Alike 4.0 International, and code is shared under the Apache License 2.0.

This blog post is 100% human-written, and has nothing derived from LLMs (be it code, editorial support or prose).

Also on:

This post was filed under articles.

Interactions with this post

Interactions with this post

Below you can find the interactions that this page has had using WebMention.

Have you written a response to this post? Let me know the URL:

Do you not have a website set up with WebMention capabilities? You can use Comment Parade.