Hamish Fleming

Vincit qui se vincit
Neovim Arch

This is my personal blog where I write small tutorials and notes worth sharing.


I use Arch btw

Exploring Supabase Sessions Shared Between Frontend and Backend

What’s the big deal?

Let me start by explaining my setup.

  • I have a Next.js/react frontend that uses Supabase for oauth authentication, through google.
    • which is a headache and a half at the moment, with the project in between migration to using the App router
  • I have a node.js/express backend that uses Supabase for authentication and database access.
  • Foolishly, I have been accessing both under localhost:3000 and localhost:3001 respectively, so the method of maintaining a session has been as simple as storing the session in a cookie and sending it with every request.
  • As I need to have users sign in with the offline scope, I needed to authenticate directly to the back end to maintain the refresh token, then bounce between the supertokens instance, the front end and the back end to maintain the session on signin.

I really need to stop starting a blog post, solving my issue and then loosing the motivation to bother typing up any nice explanation.

Solution:

- Leveraging the supabase session token to directly pass the session in a JWT straight to the backend.
- Validating/refreshing the session on the backend on each call. This isn't an issue for my use case, as I'm not expecting a high volume of request that require a session to be maintained, but your mileage may vary.

Frontend:

So there are a few moving parts here, but functionaly it boils down to: - Set the access_token somewhere in the frontend on signin. - Create a context to manage the session, and supertokens auth state (again I have a silly setup, so the supertokens auth state is not directly linked to a user auth state) - Create an ‘Auth Manager’ util to handle all the times we need to be able to access the session token without being withing a react components context. - Overwrite/extend axios to include the session token in the header of every request.

Auth Manager:

utils/authManager.ts

class AuthManager {
    private static instance: AuthManager;
    private token: string | null = null;

    private constructor() {}

    public static getInstance(): AuthManager {
        if (!AuthManager.instance) {
            AuthManager.instance = new AuthManager();
        }
        return AuthManager.instance;
    }

    public setToken(token: string | null) {
        this.token = token;
    }

    public getToken(): string | null {
        return this.token;
    }
}

export const authManager = AuthManager.getInstance();

Axios Interceptor:

utils/axiosInstance.ts

import axios from 'axios';
import { authManager } from './authManager';

const API_BASE_URI = process.env.NEXT_PUBLIC_API_BASE_URI;

const instance = axios.create({
    baseURL: API_BASE_URI || 'http://localhost:3001/api', // Update this with your API base URL
});

instance.interceptors.request.use(config => {
    const token = authManager.getToken();
    if (token) {
        config.headers['Authorization'] = `Bearer ${token}`;
    }
    return config;
}, error => {
    return Promise.reject(error);
});

export default instance;

Context:

context/SupabaseAuthContext.tsx

import React, { createContext, useContext, useState, ReactNode, useEffect } from 'react';
import { authManager } from '../utils/authManager';

interface AuthContextType {
    token: string | null;
    setToken: (token: string | null) => void;
}

const SupabaseAuthContext = createContext<AuthContextType | undefined>(undefined);

export const SupabaseAuthProvider = ({ children }: { children: ReactNode }) => {
    const [token, setTokenState] = useState<string | null>(authManager.getToken());

    const setToken = (token: string | null) => {
        authManager.setToken(token);
        setTokenState(token);
    };

    return (
        <SupabaseAuthContext.Provider value={{ token, setToken }}>
            {children}
        </SupabaseAuthContext.Provider>
    );
};

export const useSupabaseAuth = (): AuthContextType => {
    const context = useContext(SupabaseAuthContext);
    if (!context) {
        throw new Error('useSupabaseAuth must be used within a SupabaseAuthProvider');
    }
    return context;
};

And alongside the usual changes for wrapping the app in the provider, I added a simple auth state handler to the app, to update the token when the auth state changes. pages/_app.tsx

const AuthStateHandler = () => {
    const { setToken } = useSupabaseAuth();

    useEffect(() => {
        const { data: authListener } = supabase.auth.onAuthStateChange((event, session) => {
            if (event === 'SIGNED_OUT') {
                setToken(null);
            } else if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') {
                if (session && session.access_token) {
                    setToken(session.access_token);
                }
            }
        });
        return () => {
            authListener?.unsubscribe();
        };
    }, [setToken]);

    return null;
};

While I was intially hesitant to consider this approach as all of my API classes are automatically generated, I was able to extend the axios instance without any pain to include the session token in the header of every request, and then use the auth manager to access the token when needed as swimplly as

api/client.ts

import axiosInstance from '../utils/axiosInstance';
const axios = axiosInstance;

Testing page:

And for the sake of testing, I added a simple page to test the auth manager and axios instance. pages/secret.tsx

import { useEffect, useState } from 'react';
import axios from 'axios';
import { useSupabaseAuth } from '../context/SupabaseAuthContext';

const SecretPage = () => {
    const [secretMessage, setSecretMessage] = useState('');

const getToken = () => {
  return sessionStorage.getItem('access_token') as string;
};

const token = getToken();

    useEffect(() => {
        const fetchSecret = async () => {
            try {
                const response = await axios.post(process.env.NEXT_PUBLIC_API_BASE_URI + '/api/secret', {}, {
                    headers: {
                        Authorization: token ? `Bearer ${token}` : '',
                    },
                });
                setSecretMessage(response.data.message);
            } catch (error) {
                console.error('Error fetching secret:', error);
            }
        };

        if (token) {
            fetchSecret();
        }
    }, [token]);

    return (
        <div>
            <h1>Secret Page</h1>
            <p>{secretMessage}</p>
        </div>
    );
};

export default SecretPage;

Backend:

The backend fortunately was a lot simpler than I was expecting, with the supabase session token being passed in the header of every request, I was able to validate the session on every request, and refresh the session if needed.

routes/secret.ts

import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import jwt, { JwtPayload } from 'jsonwebtoken';
import 'dotenv/config';

const router = express.Router();

// Get the secret from the environment
const hmacSecret = process.env.SUPABASE_JWT_SECRET;

// Prevent the server from starting if the secret is not set
if (!hmacSecret) {
    console.error("Please set the SUPABASE_JWT_SECRET environment variable");
    process.exit(1);
}

// Enable CORS for all origins. This is not recommended for production usage.
// Use a whitelist of allowed origins instead.
const corsOptions = {
    origin: '*',
    allowedHeaders: ['Origin', 'Content-Length', 'Content-Type', 'Authorization']
};

router.use(cors(corsOptions));

const emailCtxKey = 'email';

const authMiddleware = (hmacSecret: string) => {
    return (req: Request, res: Response, next: NextFunction) => {
        // Read the Authorization header
        const token = req.header('Authorization');
        if (!token) {
            return res.status(401).json({ error: 'Unauthorized' });
        }
	// strip the Bearer prefix
	const token1 = token.replace('Bearer ', '');
	console.log(token);

        try {
            // Validate token and extract email
            /* const decoded = jwt.verify(token, hmacSecret) as JwtPayload; */ // Unsure why this is not working...
	    const decoded = jwt.decode(token1) as JwtPayload;
	    console.log(decoded);
            const email = decoded.user_metadata.email;

            console.log(`Received request from ${email}`);

            // Save the email in the request to use later in the handler
            (req as any)[emailCtxKey] = email;

            next();
        } catch (err) {
            console.error(`Error parsing token: ${err}`);
            return res.status(401).json({ error: 'Unauthorized' });
        }
    };
};

const secretRouteHandler = () => {
    return (req: Request, res: Response) => {
        // Get the email from the request
        const email = (req as any)[emailCtxKey];

        // Return the secret message
        res.json({
            message: `Our hidden value for the user ${email}`
        });
    };
};

// The only route we have is /secret and it is protected by the authMiddleware.
router.post('/secret', authMiddleware(hmacSecret), secretRouteHandler());

export default router;

Now I’m not sure why the jwt.verify method was not working, but I was able to use jwt.decode to extract the email from the session token for testing purposes.

References:

More Like this

Using Typesense to Index Blob Storage

Typesense is an open-source search engine that provides fast and relevant search results. It is built to be simple to use and easy to scale. In this post, we will look at how to use Typesense to index and search Azure Blob Storage. I’m really ……

Say HiVno Read More

Comments....