View on GitHub

Enhance Workshop

This is the Enhance Workshop

Module Index

Module 8: Sessions and Simple Authentication

Outline

Sessions

We are missing a few tools to finish our CRUDL routes from the last module. We need a way to maintain a persistent state between the request and responses back and forth between the browser and the server. Sessions give us that. They are the best way to add authentication and to close the loop on form validation from the server.

When you visit a website, by default, it doesn’t remember anything about you - it’s like starting a new conversation every time you go to the website or even loading a new page on the same website you are already visiting. HTTP is a stateless protocol. What if you want the website to remember something about you? That’s where sessions come in. Sessions are a way for a website to remember things about you, like if you’re logged in or what’s in your shopping cart.

Enhance implements sessions with HttpOnly cookies. HttpOnly cookies are the best way to ensure that your session data is safe and secure.

Cookies

A cookie is a small piece of data that a website stores on a user’s computer. This data is then sent back to the website with every subsequent request. This allows the website to remember things like user preferences or login status. At the end of the day, a Cookie is an HTTP request header, and writing a cookie is accomplished with the HTTP response header set-cookie.

By default, cookies are sent back and forth between the browser and the server in plain text, making them vulnerable to theft by hackers. To help mitigate this risk, you can set the “HttpOnly” flag on a cookie. This flag tells the browser that the cookie should only be sent back to the server via HTTP requests and will not be accessible to client-side scripting such as JavaScript. In addition to HttpOnly, you can set the “Secure” flag on a cookie. This flag tells the browser that the cookie will only be sent over secure connections (i.e. HTTPS). The “Secure” flag helps to prevent the cookie from being intercepted by a hacker who may be listening in on an unsecured connection.

When combined, HttpOnly and secure cookies provide a powerful defense against session hijacking. By keeping session data away from client-side scripts and encrypting it during transit, you can protect your users from a wide range of security threats. By taking the time to implement these security measures correctly, you’ll be able to rest assured that your users’ data is well-protected.

Enhance Sessions

Enhance has session functionality built-in using set-cookie behind the scenes. That code is open source, has been audited by thousands, and has more affordances for better security.

Implementing a basic login flow on top of session

Getting back to the portfolio we are building one of the things that we need sessions for is authentication. We want any guests to be able to see the portfolio, résumé, and link tree pages, but we don’t want them to be able to create new links.

To restrict the CRUDL routes we will use sessions. For this we will build a simple single player authentication.

Lets build a login page

<!-- /app/pages/login.html -->
<enhance-page-container>
  <main>
        <enhance-form action="/login" method="post" >
            <enhance-text-input
              label="Password"
              id="password"
              name="password"
              type="password"
            ></enhance-text-input>
              <enhance-submit-button><span slot="label">Log in</span></enhance-submit-button>
        </enhance-form>
  </main>
</enhance-page-container>

Now we need to add the API route for this to post to.

// /app/api/login.mjs
export async function post (req) {
 let authorized = req.body.password === process.env.SECRET_PASSWORD
 return {
   location: '/',
   session: { authorized }
 }
}

This log in process uses a password stored in an environment variable. In a later module we will talk about how to set environment variables for production. But for local development we can use a .env file too do it.

Create a .env file and paste the following in it.

SECRET_PASSWORD="secret"

Make sure that this file is in your .gitignore so that it will not be checked into GitHub.

Now you are ready to log in.

To be able to log out we add a post route for /logout

// /app/api/logout.mjs
export async function post (req) {
 return {
   location: '/',
   session: {}
 }
}


// For local dev it is convenient to be able to logout using a get route
export async function get () {
  const env = process.env.ARC_ENV
  if (env !== 'staging' && env !== 'production') {
    return {
      session: {},
      location: '/'
    }
  } else {
    return {
      code: 404
    }
  }
}

Notice that in production there is only a POST route for logout. For debugging it is nice to be able to use a GET route.

The previous code will clear the session entirely. If there are possibly values in the session that you want maintained you can just clear the login state as follows.

// /app/api/logout.mjs
export async function post (req) {
 const {authorized:removeAuthorized, ...newSession} = req.session
 return {
   location: '/',
   session: newSession
 }
}

Using Session state to protect routes

Now we have a session that will persist with each request for that user unless cleared by the server. With this we can verify the authentication status for any other pages. The req.session.authorized property can be checked in any API route. If we have a protected route that only the the owner should see we check the status and redirect to the login page if not authorized.

// /app/api/protected.mjs
export async function get (req) {
 let authorized = !!(req.session.authorized)
 if (!authorized) {
   return { location: '/login' }
 }
}

Some pages may not be fully restricted but just have mixed content for an authenticated user. In this case you don’t have to redirect if not authorized. Instead we will pass the authorized property to the page.

// /app/api/mixed-content.mjs
export async function get (req) {
 let authorized = !!(req.session.authorized)
 return {
   json: { authorized }
 }
}

And in the page we can use that authorized property to control what content is shown.

// /app/pages/mixed-content.mjs
export default function mixed({html, state}){
return html`
${state.store.authorized ? `<form method=POST action=/logout><button>logout</button></form>` : ''}
<main>Hello</main>
`
}

In the case of a POST request we usually want to respond with a “Not Authorized” status code instead of redirecting.

// /app/api/protected.mjs
export async function post (req) {
 let authorized = !!(req.session.authorized)
 if (!authorized) return { status: 401 }
 return { json: data }
}

We could add these few lines of code to all our CRUDL routes which would not be too bad. But there is a better way. Enhance has an affordance for lightweight middleware. Lets use that to add auth checks to our protected routes.

Authentication Middleware

In an API handler we can export an array of handlers instead of one function.

We can rewrite the handler as shown below to share the authentication logic.

// /app/api/links.mjs
import { getLinks, upsertLink, validate } from '../models/links.mjs'
import { checkAuth } from '../lib/check-auth.mjs'

export const get = [checkAuth,listLinks]
export const post = [checkAuth,postLinks]

async function listLinks (req) {
  const links = await getLinks()
  if (req.session.problems) {
    let { problems, link, ...session } = req.session
    return {
      session,
      json: { problems, links, link }
    }
  }

  return {
    json: { links }
  }
}

async function postLinks (req) {
  const session = req.session
  // Validate
  let { problems, link } = await validate.create(req)
  if (problems) {
    return {
      session: { ...session, problems, link },
      json: { problems, link },
      location: '/links'
    }
  }

  // eslint-disable-next-line no-unused-vars
  let { problems: removedProblems, link: removed, ...newSession } = session
  try {
    const result = await upsertLink(link)
    return {
      session: newSession,
      json: { link: result },
      location: '/links'
    }
  }
  catch (err) {
    return {
      session: { ...newSession, error: err.message },
      json: { error: err.message },
      location: '/links'
    }
  }
}

For more info check out the Enhance Docs

We can write a check-auth.mjs middleware function and add it to our protected routes.

Copy and paste the middleware below into /app/lib/check-auth.mjs.

export async function checkAuth(req) {
  const session = req.session
  const authorized = session?.authorized ? session?.authorized : false

  if (!authorized){
    if (req.method === 'GET') {
      return {
        location: '/login'
      }
    }
    return {
      status: 401
    }
  }
}

Now do the same for the other two protected routes at /app/api/links/$id.mjs and /app/api/links/$id/delete.mjs.

Finally our protected routes are actually protected.

Auth Status in HTML pages

The Authentication check used here protects an unauthorized user from ever seeing a page. In some cases you will want users to be able to access a page either way with UI changes depending on their auth status. This may be an avatar in the header or a log out button only shown if they are authenticated. For that we need to pass the authorized property from the session to the page using the store.

We can do that in each of the API routes by grabbing authenticated from the session and returning it in the json so that it is available in the state.store.

This is actually tricky to do with middleware because if we want to add something to the response we need to hang it on the request and pull it off at the end of the middleware chain.

If an early middleware returns something it will send the response immediately and short circuit any other middleware.

Another problem with using the API for this is that if our page does not need an API we will have to add it just to pass the authorized status.

So the solution for this ends up being something we have already seen. We can use the head.mjs to get the req.session.authorized and put it in the state.store.authorized to make it available in every page.

Lets add a logout button to our nav-bar using this approach.

Add the following code in the head.mjs (only the top of the file is show).

// code removed ...
export default function Head(state) {
  const { req, store } = state
  const { path, session } = req

  if (store.authorized === undefined) {
    store.authorized = session.authorized || false
  }
  if (store.path === undefined) {
    store.path = path
  }
  // code removed ...

Now we can go back to our /app/elements/nav-bar.mjs and add the log out button.

  <ul class='mis-auto flex gap0 list-none text-1 uppercase tracking1 font-semibold'>
    <li><a href='/'>Home</a></li>
    <li><a href='/resume'>Résumé</a></li>
    <li><a href='/linktree'>Links</a></li>
    ${state.store?.authorized ?
    `<li><form method="POST" action="/logout"> <button>Log Out</button></form></li>` : ''}
  </ul>

Now we have pretty bullet proof authentication for our portfolio site. This would need some more features for a site with many authorized users.

We have extensive examples to follow for all of the most common types of authentication that you can find in our Auth Series.

In the next module we will add the finishing touches on our CRUDL routes using session to handle validation problems.