All of my site’s logins are handled via Supabase and Supabase generates JWTs with a CMS claim for Directus users. How can I use the JWT to login into Directus bypassing Directus’s account creation screens? In order words, when the JWT has the CMS claim Directus should just let the user in and make a hidden Directus account for them. I’ve tried for several hour to implement this, can someone please provide guidance on how to do this?
After about 12 hours of trying I finally got it working. BTW, you need to verify the JWT signature, I didn’t include that piece.
/**
* Directus SSO Extension - Supabase JWT Token Exchange
* Uses SESSION MODE (directus_session_token) for admin UI compatibility
*
* This extension provides two endpoints:
* 1. POST /exchange - Exchanges Supabase JWT for Directus refresh token
* 2. GET /login - Converts refresh token to session token and sets cookie
*/
import { nanoid } from 'nanoid';
export default (router, { services, database, logger, getSchema }) => {
const { AuthenticationService, UsersService } = services;
// Login endpoint - sets session token cookie and redirects
router.get('/login', async (req, res) => {
try {
const { refresh_token } = req.query;
if (!refresh_token) {
logger.error('[SSO Login] Missing refresh token');
return res.status(400).send('Missing refresh token');
}
logger.info('[SSO Login] Validating refresh token');
// Validate session exists
const sessions = await database('directus_sessions')
.where('token', refresh_token)
.where('expires', '>', new Date())
.select('*');
if (!sessions || sessions.length === 0) {
logger.error('[SSO Login] Invalid or expired session');
return res.status(400).send('Invalid or expired session');
}
logger.info('[SSO Login] Valid session found');
// Get schema
const schema = await getSchema();
// Use AuthenticationService to refresh in SESSION mode
const authenticationService = new AuthenticationService({ schema });
const authResult = await authenticationService.refresh(refresh_token, {
session: true // <-- KEY: Enable session mode
});
logger.info('[SSO Login] Session token generated, length:', authResult.accessToken.length);
// Set SESSION token cookie (not refresh token!)
const sessionCookieName = process.env.SESSION_COOKIE_NAME || 'directus_session_token';
res.cookie(sessionCookieName, authResult.accessToken, {
httpOnly: true,
secure: process.env.SESSION_COOKIE_SECURE === 'true',
sameSite: process.env.SESSION_COOKIE_SAME_SITE || 'lax',
maxAge: authResult.expires,
path: '/'
});
logger.info('[SSO Login] Session cookie set, redirecting to /admin');
return res.redirect('/admin');
} catch (error) {
logger.error('[SSO Login] Error:', error.message);
logger.error('[SSO Login] Stack:', error.stack);
return res.status(500).send('Login failed: ' + error.message);
}
});
// Token exchange endpoint
router.post('/exchange', async (req, res) => {
try {
const { token } = req.body;
if (!token) {
return res.status(400).json({
error: 'Missing JWT token in request body'
});
}
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error('Invalid JWT format');
}
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
logger.info('[SSO Exchange] Processing token for user:', payload.email);
// Check authorization
const isSystemAdmin = payload.role === 'admin' || payload.user_role === 'admin';
const isBlogEditor = payload.blog_editor === true;
const hasAccess = isSystemAdmin || isBlogEditor;
if (!hasAccess) {
logger.warn('[SSO Exchange] Access denied for user:', payload.email);
return res.status(403).json({
error: 'Not authorized for content management'
});
}
const schema = await getSchema();
const usersService = new UsersService({ schema, accountability: { admin: true } });
// Get or create user
let user;
try {
const users = await usersService.readByQuery({
filter: { external_identifier: { _eq: payload.sub } },
limit: 1
});
user = users[0];
} catch (error) {
logger.debug('[SSO Exchange] User lookup error:', error.message);
}
if (!user) {
logger.info('[SSO Exchange] Creating new user:', payload.email);
const userId = await usersService.createOne({
external_identifier: payload.sub,
email: payload.email,
first_name: payload.email.split('@')[0],
role: isSystemAdmin ? 'e5fabd1f-f805-4d96-9ddf-55d6db1bbc15' : 'ba24d01f-0d7a-4d8b-a215-10dd75ec1b5a',
status: 'active',
provider: 'supabase'
});
user = await usersService.readOne(userId);
logger.info('[SSO Exchange] User created:', user.id);
} else {
logger.info('[SSO Exchange] Existing user found:', user.id);
}
// Create refresh token for session generation
const refreshToken = nanoid(64);
const refreshExpires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await database('directus_sessions').insert({
token: refreshToken,
user: user.id,
expires: refreshExpires,
ip: req.ip,
user_agent: req.get('user-agent'),
share: null,
origin: req.get('origin')
});
logger.info('[SSO Exchange] Session created');
// Return login URL
const loginUrl = `/directus-extension-supabase-token-exchange/login?refresh_token=${refreshToken}`;
return res.json({
success: true,
message: 'Authentication successful',
redirect_url: `https://cms.lowpan.com${loginUrl}`
});
} catch (error) {
logger.error('[SSO Exchange] Error:', error.message);
logger.error('[SSO Exchange] Stack:', error.stack);
return res.status(500).json({
error: 'Authentication failed: ' + error.message
});
}
});
};
Hi @Jon_Smirl, welcome to the forum and thank you very much for sharing that you got this solved, by us marking this as answered, hopefully it can help someone else with the same question.