Next.js with Hellō

May 18, 2022

Hellō is an OpenID Connect Provider that simplifies user registration and login.

There are lots of ways to add user login to your apps today. In the past, I’ve used tools such as AWS Cognito and Auth0. While solutions like Auth0 provide a good developer experience, Hellō offers a few advantages. When you consider the work involved in setting up OAuth applications with each social login provider you want to support, implementating authentication for a production-ready app with Auth0 takes days or weeks. Hellō shortens that time to hours.

Social Login Support

Hellō supports all social login providers, with zero configuration. For example, if I want to add social login via Google to my Auth0 app, I need to create an application in the Google developer console and link it to my Auth0 application. This process is tedious - and you’ll have to repeat it for every social login provider you want to support. Hellō completely bypasses this issue - it handles the Google OAuth flow for you entirely, along with a myriad of other social login providers.

Zero Dependencies

Hellō is exposed to your app as an OpenID Connect Provider with a simple authentication flow. You implement it via a few HTTP calls - no SDK is required.

Email/phone Verification

Hellō can return the verification status of the user in its claims. The same verification status will persist across multiple apps implemented with Hellō.

Development Flow

One of the frustrating things about existing user authentication solutions I’ve used is the difference between login flows in development versus production. E.g., in Auth0, I’ve had to use separate tenants for development and production, where the development tenant allows localhost callback URLs. Additionally, I’ve had to switch my development server to use HTTPS. Hellō cleverly solves this problem by allowing localhost callback URLs only for certain identities (i.e., your developers) - who you can control as the application owner.

With Next.js

Hellō provides a live demo app, with source code. This app runs entirely client-side - all authentication happens in JavaScript.

For many webapps, you’ll need some kind of database that stores user information. When the user log in, your app will authenticate them, then make a database call based on some unique user identifier.

I put together a demo application using Hellō with Next.js, a popular React-based web framework. I’ll walk you through the key points below - I hope you can reuse this approach, whether with Next.js or any other framework.

You can try out the demo yourself on Vercel.

Session Management

While Hellō handles authentication nicely, you still need to maintain session state for the user. I chose to use iron-session for this example. iron-session stores session data in encrypted cookies - the approach taken by frameworks like Ruby on Rails. With this approach - session state doesn’t need to be maintained server-side at all, which is great for apps that rely on statically-generated pages (like Next.js in most cases).

Design Pattern

The Hellō documentation provides a good introduction to the authentication data flow. For an application with server-side resources, the data flow is a little more complex. The general pattern is:

  • Create a nonce on the server, and bind it to the session cookie
  • Create a URL (can be on the client or server) to the Hellō consent URL, and send the client there
  • After the consent flow, Hellō will return the user to your application’s redirect URL, with an id_token in the location hash
  • The client sends the id_token back to the server. The server retrieves the nonce associated with the session, and calls the Hellō introspection API to validate the id_token
  • If the validation passes, the server updates the session with the user claims returned by Hellō.
  • This includes a sub field (subject), which is a unique user identifier. You may use this as a key in a database call, for example.

The demo app implements all of the above. At some point, I’d like to package this logic into something easily consumable - like nextjs-auth0, but for now, it’s going to be some copy/paste if you want to reuse this!

APIs

In general, you’ll want to take advantage of statically-generated pages as much as possible, making fetch calls to your APIs to populate data when necessary. To that end, let’s start with the API design.

The demo app implements four APIs: login, logout, callback, and user.

Login API

The login API builds the nonce, binds it to the session cookie, then builds the Hellō consent URL. It also binds the returnTo URL to the session cookie to faciliate redirection, e.g. if an unauthenticated user access a protected page and wishes to return there after login.

When the user navigates to the login API endpoint (/api/login) by clicking the “Login” button, they’ll be redirected to Hellō. If Hellō knows about the user already, they’ll be prompted to give access to your application. Otherwise, they can choose their preferred social login provider to continue authenticating with Hellō. The process is simple from the user’s perspective - in terms of button clicks, it’s about the same as existing authentication solutions.

const consentBaseUrl = 'https://consent.hello.coop' const defaultScopes = ['openid', 'name', 'email'] const loginRoute = async (req: NextApiRequest, res: NextApiResponse) => { const { returnTo, updateProfile } = req.query const nonce = uuidv4() req.session.nonce = nonce req.session.returnTo = Array.isArray(returnTo) ? returnTo[0] : returnTo await req.session.save() const baseUrl = new URL(config.baseUrl) const callbackUrl = new URL('/callback', baseUrl) const scope = (defaultScopes.concat( updateProfile === true.toString() ? ['profile_update'] : [])).join(' ') const consentUrl = new URL(consentBaseUrl) consentUrl.searchParams.append('client_id', config.helloClientId) consentUrl.searchParams.append('redirect_uri', callbackUrl.toString()) consentUrl.searchParams.append('scope', scope) consentUrl.searchParams.append('nonce', nonce) consentUrl.searchParams.append('response_type', 'id_token') consentUrl.searchParams.append('response_mode', 'fragment') res.redirect(consentUrl.toString()) }

It’s important to note that the user controls which data to share with your app via Hellō. Your app doesn’t know the user’s identifier as provided by Google, for example. This provides a flexible layer of indirection - if the user loses access a linked social account, they can recover their account with Hellō - and they’re not forced to re-register with your app.

Callback API

After the user completes the Hellō consent flow, they need to call your application again with the id_token returned by Hellō. The ID Token is a JSON Web Token (JWT) that has claims per OpenID Connect (see the Hellō documentation for more details). Your application needs to validate the token either by calling the Hellō introspection API, or manually performing OpenID Connect validation (using a certified library). The demo application calls the Hellō introspection API, which is straightforward and does not require authentication.

The important part of the callback API is below. It calls the Hellō introspection API, saves the data from the returned claims to the session, and performs any necessary redirection to return the user to their original destination. It does some validation to ensure that the returnTo URL is somewhere safe, i.e., on the domain controlled by the application.

const introspectUrl = new URL('/oauth/introspect', consentBaseUrl).toString() try { const { sub, name, email }: Claims = await fetch(introspectUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: `id_token=${idToken}&nonce=${nonce}&client_id=${config.helloClientId}` }).then((r) => r.json()) const user: User = { isLoggedIn: true, sub, name, email } req.session.user = user await req.session.save() const baseUrl = new URL(config.baseUrl) const dangerousReturnTo = req.session.returnTo || defaultReturnTo let safeReturnTo: URL try { safeReturnTo = new URL(dangerousReturnTo, baseUrl) if (safeReturnTo.origin === baseUrl.origin) { res.redirect(safeReturnTo.toString()) return } } catch (e) { } res.redirect(new URL(baseUrl, defaultReturnTo).toString()) } catch (error) { res.status(500).json({ message: (error as Error).message }) }

Logout API

The logout API is trivial - it just needs to destroy the session cookie.

req.session.destroy() const baseUrl = new URL(config.baseUrl) res.redirect(baseUrl.toString())

User API

The user API retrieves user information from the session and returns it as JSON. The application could make a database call here to retrieve additional information about the user.

if (req.session.user?.isLoggedIn) { res.json({ ...req.session.user, isLoggedIn: true }) } else { res.json({ isLoggedIn: false }) }

Pages

The main piece of logic used by the pages themselves is the useUser hook, which uses SWR to call the user API to retrieve user data, or else optionally redirect the user to login if they don’t have an active session.

export type User = { isLoggedIn: false } | { isLoggedIn: true, sub: string, name: string, email: string } export default function useUser({ redirect = true } = {}) { const { data: user, mutate: mutateUser } = useSWR<User>('/api/user') const { push, isReady, asPath } = useRouter() useEffect(() => { if (!user || !isReady) return if (!user.isLoggedIn && redirect) { push('/api/login?' + new URLSearchParams({ returnTo: asPath })) } }, [user, isReady, asPath]) return { user, mutateUser } }

User Profile Page

This page calls the useUser hook, and displays the user data returned by /api/user as JSON. By default, the redirect: true argument to useUser will cause the user to be redirect to /api/login?returnTo=/profile-sg if they access this page without an active session.

const { user } = useUser() return ( // ... {user && ( <pre>{JSON.stringify(user, null, 2)}</pre> )} // ... )

Example output:

{ "isLoggedIn": true, "sub": "3b25fd38-a44a-45cf-ad07-b4bc3bb0d9b2", "name": "Thomas Smith", "email": "fake@email.com" }

Callback Page

The last important part of the page we haven’t looked at yet is the callback page. Since the Hellō consent flow returns the user to the application with an id_token in the location hash, we need the page to which the user returns to watch the hash for changes. Once the id_token is retrieved, the application redirects to the /api/callback endpoint to validate the id_token and complete the login flow.

This page could return a loading indicator or similar, but for now it returns a null React component (empty content).

export default function Callback() { const { push } = useRouter() useEffect(() => { const handleHashChange = async () => { const params = new URLSearchParams(window.location.hash.substring(1)) const idToken = params.get('id_token') if (idToken) { push('/api/callback?' + new URLSearchParams({ idToken })) } } handleHashChange() window.addEventListener('hashchange', handleHashChange) return () => { window.removeEventListener('hashchange', handleHashChange) } }, []) return null }

Summary

There’s a little more setup involved in getting everything working, but we’ve gone over all the important parts. Feel free to contact me if you have any questions or suggestions.