Skip to content

Instantly share code, notes, and snippets.

@jer-k
Last active December 29, 2024 01:54
Show Gist options
  • Save jer-k/0de56b6fa7428eef72ee1f6628f1f0e1 to your computer and use it in GitHub Desktop.
Save jer-k/0de56b6fa7428eef72ee1f6628f1f0e1 to your computer and use it in GitHub Desktop.
Setting up Zero with Ruby on Rails (session based auth) and Next.js

Reading through the Zero docs on authentication, I saw that JWT authentication was all that was supported (for now). I'm building a project using Ruby on Rails as an API and Next.js for the UI. While JWT auth is possible with Rails, most auth implementations are session based, included the built in auth solution to the latest Rails 8 release. Thus I wanted to figure out how to best set up Zero while still using the built in session based auth.

In this reply @aa, outlined how the Zero constructor can take a function for the auth param https://bsky.app/profile/aaronboodman.com/post/3ldyz5bet3s2s

This all sounds right. The auth param to Zero's constructor can be a function. When the token expires, Zero invokes the function to get a new token. It's async so you can call an endpoint or whatever.

and later

To clarify further:

  • keep the refresh token in an http-only cookie
  • in the auth js function invoke an endpoint on your server that converts refresh token into session token
  • return session token to Zero
  • later on, session token expires, zero calls auth function again, rinse repeat

Thinking through this, I didn't think we needed a refresh token, since we have the session cookie. What I ended up with was an authenticated API to generate the JWT.

With that said, I wanted to present this approach and see if anyone had any suggestions to improve it.

In Next, this idea should be able to apply to any UI framework though, I have the following setup

  • (app)/
    • layout.tsx (further referenced as server-layout to disambiguate. The layout.tsx file name is needed for Next)
  • layout.tsx
  • components/
    • client-layout.tsx
  • providers/
    • zero-client-provider.tsx

The server-layout provides an authentication layer, ensuring that the user is authenticated to the backend; if they aren't, the user is redirected to login.

The client-layout is rendered as a child of server-layout and is where we render our zero-client-provider. In this setup, we know that we'll always have an authenticated user when zero-client-provider is rendered, giving us access to our backend API.

In zero-client-provider we can set up our auth function

async function getToken(): Promise<string> {
	const response = await apiClient.get("/tokens/new");
	return response.data.token;
}

which calls the API in tokens_controller, which is an authenticated route. Note that the the authentication isn't shown in the code below as it is encapsulated in the parent class ApplicationController. So it is impossible to get a JWT without being authenticated and our UI won't make the request unless the user is authenticated. The JWT is constructed to last 7 days (could be any length) and adds in the user ID to the sub attribute as instructed in the Zero docs. In a case where the 7 days pass and the JWT expires, Zero will trigger the function again and we'll get a new token.

With all that in place, I got Zero up and running. It seems like a pretty straight forward approach to combining session based API authentication and JWT authentication for Zero.

/*
API Client for session / cookie based authentication. For Axios, `withCredentials: true` adds the
cookies with the session info
*/
import axios, { AxiosInstance, CreateAxiosDefaults, AxiosError } from "axios";
import { apiBaseUrl } from "@/lib/api-urls";
type ApiClientConfig = {
cookies?: string;
} & CreateAxiosDefaults;
const baseConfig = {
baseURL: apiBaseUrl,
withCredentials: true,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
} as const;
// Create the API client with optional cookie header
export function createApiClient(config: ApiClientConfig = {}): AxiosInstance {
const axiosConfig = {
...baseConfig,
...config,
headers: {
...baseConfig.headers,
...(config.cookies && { Cookie: config.cookies }),
...config.headers,
},
};
return axios.create(axiosConfig);
}
export const apiClient = createApiClient();
class TokensController < ApplicationController
def new
# https://zero.rocicorp.dev/docs/auth
# > When you set the auth option you must set the userID option to the same value that
# > is present in the sub field of the token.
token = JWT.encode({
exp: Time.now.to_i + 7.days.to_i,
sub: Current.user.id
}, ENV["ZERO_SECRET_KEY"],)
render json: {
token: token
}
end
end
"use client";
import { Zero } from "@rocicorp/zero";
import { ZeroProvider } from "@rocicorp/zero/react";
import { apiClient } from "@/lib/api-client";
import { schema } from "@/lib/zero/schema";
import { PropsWithChildren } from "react";
type Props = {
userId: string;
};
async function getToken(): Promise<string> {
const response = await apiClient.get("/tokens/new");
return response.data.token;
}
export default function ZeroClientProvider({
userId,
children,
}: PropsWithChildren<Props>) {
const z = new Zero({
userID: userId,
auth: () => getToken(),
server: process.env.NEXT_PUBLIC_ZERO_CACHE_PUBLIC_SERVER,
schema,
kvStore: "mem", // or "idb" for IndexedDB persistence
});
return <ZeroProvider zero={z}>{children}</ZeroProvider>;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment