arctic-lights
Version:
Drop-In replacement for NextAuth v5, built with arctic for faster performance
259 lines (258 loc) • 11.8 kB
JavaScript
import { generateCodeVerifier, generateState, GitHub, Google, MicrosoftEntraId } from "arctic";
import { redirect } from "next/navigation";
import { cookies, headers } from "next/headers";
import { createJWT, validateJWT } from "oslo/jwt";
import { TimeSpan } from "oslo";
const getArcticProvider = (provider, redirectUrl) => {
redirectUrl = redirectUrl + provider.type;
switch (provider.type) {
case 'google':
return new Google(provider.options.clientId, provider.options.clientSecret, redirectUrl);
case 'entra-id':
return new MicrosoftEntraId(provider.options.tenantId, provider.options.clientId, provider.options.clientSecret, redirectUrl);
case 'azure-ad':
return new MicrosoftEntraId(provider.options.tenantId, provider.options.clientId, provider.options.clientSecret, redirectUrl);
case 'github':
return new GitHub(provider.options.clientId, provider.options.clientSecret, { redirectURI: redirectUrl, enterpriseDomain: provider.options.enterpriseDomain });
default:
console.error('Unknown provider type:', provider.type);
return null;
}
};
const getRedirectUrl = (url) => {
return getHost(url) + '/api/auth/callback/';
};
const getHost = (url) => {
let hostUrl;
if (process.env.ARCTICLIGHTS_URL)
hostUrl = process.env.ARCTICLIGHTS_URL;
else if (process.env.NEXTAUTH_URL)
hostUrl = process.env.NEXTAUTH_URL;
if (hostUrl) {
if (hostUrl.match(/https?:\/\/.*/g) === null)
console.error('ArcticLights: redirectUrl is invalid. It must be a valid URL');
else
return hostUrl.endsWith('/') ? hostUrl.substring(0, hostUrl.length - 1) : hostUrl;
}
return url.split('://')[0] + '://' + url.split('://')[1].split('/')[0];
};
const getProviders = async (options) => {
if (options.providers instanceof Promise)
return await options.providers;
return options.providers;
};
const findProvider = async (options, providerType) => {
return (await getProviders(options)).find(provider => (provider === null || provider === void 0 ? void 0 : provider.type) === providerType) || null;
};
const getSignInPage = (options, host) => {
var _a;
const page = ((_a = options.pages) === null || _a === void 0 ? void 0 : _a.signIn) || 'signin';
return host + (page.startsWith('/') ? page : ('/' + page));
};
const signInWithProvider = async (options, provider, url) => {
const host = getHost(url);
const params = new URLSearchParams(url.split('?')[1]);
const state = generateState();
const codeVerifier = generateCodeVerifier();
cookies().set('arctic_state', state, {
secure: process.env.NODE_ENV === 'production',
path: '/',
httpOnly: true,
maxAge: 60 * 10,
});
if (provider.pkce)
cookies().set('arctic_code_verifier', codeVerifier, {
secure: process.env.NODE_ENV === 'production',
path: '/',
httpOnly: true,
maxAge: 60 * 10,
});
let callbackURL = decodeURIComponent(params.get('callback') || '/');
if (!callbackURL.includes('://'))
callbackURL = host + (callbackURL.startsWith('/') ? callbackURL : ('/' + callbackURL));
cookies().set('arctic_callback', encodeURI(callbackURL), {
secure: process.env.NODE_ENV === 'production',
path: '/',
httpOnly: true,
maxAge: 60 * 10,
});
const arcticProvider = getArcticProvider(provider, getRedirectUrl(url));
if (arcticProvider === null)
return Response.redirect(getSignInPage(options, host) + '?error=provider_not_found');
if (provider.pkce)
redirect((await arcticProvider.createAuthorizationURL(state, codeVerifier, {
scopes: provider.scopes
})).toString());
else
redirect((await arcticProvider.createAuthorizationURL(state, {
scopes: provider.scopes
})).toString());
};
const handleCallback = async (options, provider, url) => {
var _a, _b, _c, _d, _e, _f;
const host = getHost(url);
const params = new URLSearchParams(url.split('?')[1]);
const code = params.get('code');
const state = params.get('state');
const storedState = (_a = cookies().get('arctic_state')) === null || _a === void 0 ? void 0 : _a.value;
const codeVerifier = provider.pkce ? (_b = cookies().get('arctic_code_verifier')) === null || _b === void 0 ? void 0 : _b.value : undefined;
const callbackURL = ((_c = cookies().get('arctic_callback')) === null || _c === void 0 ? void 0 : _c.value) || host;
cookies().set('arctic_state', '', { maxAge: 0 });
cookies().set('arctic_callback', '', { maxAge: 0 });
if (provider.pkce)
cookies().set('arctic_code_verifier', '', { maxAge: 0 });
if (!code || !state || !storedState || (provider.pkce && !codeVerifier) || state !== storedState)
return Response.redirect(getSignInPage(options, host) + '?error=signin_failed');
try {
const arcticProvider = getArcticProvider(provider, getRedirectUrl(url));
if (arcticProvider === null)
return Response.redirect(getSignInPage(options, host) + '?error=provider_not_found');
const tokens = await arcticProvider.validateAuthorizationCode(code, provider.pkce ? codeVerifier : undefined);
const user = await provider.getUser(tokens.accessToken);
if (user === undefined) {
console.error('ArcticLights: Unable to get user data');
return Response.redirect(getSignInPage(options, host) + '?error=signin_failed');
}
const sessionToken = await createSessionToken(options, provider.type, user, tokens);
if (!sessionToken)
return Response.redirect(getSignInPage(options, host) + '?error=session_creation_failed');
cookies().set(((_e = (_d = options.session) === null || _d === void 0 ? void 0 : _d.accepted) === null || _e === void 0 ? void 0 : _e.cookie) || 'arctic_session', sessionToken, {
secure: process.env.NODE_ENV === 'production',
path: '/',
httpOnly: true,
maxAge: ((_f = options.session) === null || _f === void 0 ? void 0 : _f.maxAge) || 60 * 60 * 24 * 7,
});
return Response.redirect(decodeURI(callbackURL));
}
catch (e) {
console.error('ArcticLights: Unable to sign in:', e);
return Response.redirect(getSignInPage(options, host) + '?error=signin_failed');
}
};
const listProviders = (providers) => {
return new Response(JSON.stringify(providers.map(provider => {
return {
type: provider.type,
style: provider.style
};
})), { status: 200 });
};
const getSecret = (options) => {
const secret = options.secret || process.env.ARCTICLIGHTS_SECRET || process.env.NEXTAUTH_SECRET;
if (secret === undefined) {
console.error('ArcticLights: secret is missing. Set secret in options or ARCTICLIGHTS_SECRET in environment variables');
return;
}
return new TextEncoder().encode(secret);
};
const createSessionToken = async (options, provider, user, tokens) => {
var _a, _b;
const secret = getSecret(options);
if (!secret)
return;
let token = {
provider,
user: {
email: user.email,
name: user.name,
image: user.image
}
};
if ((_a = options.callbacks) === null || _a === void 0 ? void 0 : _a.jwt)
token = await options.callbacks.jwt({ token, account: tokens, profile: user });
try {
return await createJWT('HS256', secret, token, { expiresIn: new TimeSpan(((_b = options.session) === null || _b === void 0 ? void 0 : _b.maxAge) || 60 * 60 * 24 * 7, 's') });
}
catch (e) {
console.error('ArcticLights: Unable to create session token:', e);
}
};
const signOutSession = async (url) => {
const params = new URLSearchParams(url.split('?')[1]);
let callbackURL = decodeURIComponent(params.get('callback') || '/');
const host = getHost(url);
if (!callbackURL.includes('://'))
callbackURL = host + (callbackURL.startsWith('/') ? callbackURL : ('/' + callbackURL));
cookies().set('arctic_session', '', { maxAge: 0 });
return Response.redirect(callbackURL);
};
const getToken = async (options) => {
var _a, _b;
const accepted = ((_a = options.session) === null || _a === void 0 ? void 0 : _a.accepted) || { cookie: 'arctic_session' };
if (accepted.cookie) {
const cookie = (_b = cookies().get(accepted.cookie)) === null || _b === void 0 ? void 0 : _b.value;
if (cookie)
return { token: cookie, type: 'cookie' };
}
if (accepted.header) {
const header = headers().get(accepted.header);
if (header)
return { token: header.substring(6), type: 'header' }; // Remove Token prefix
}
};
const ArcticLights = (options) => {
return {
handlers: {
GET: async (req, { params }) => {
const path = params.arcticlights || params.nextauth;
const host = getHost(req.url);
if (path === undefined || path.length == 0)
return Response.redirect(getSignInPage(options, host));
if (path.length == 1 && path[0] === 'providers')
return listProviders((await getProviders(options)).filter(provider => provider !== null));
if (path.length == 1 && path[0] === 'signout')
return signOutSession(req.url);
if (path.length == 2) {
const provider = await findProvider(options, path[1]);
if (provider === null)
return Response.redirect(getSignInPage(options, host) + '?error=provider_not_found');
if (path[0] === 'signin')
return signInWithProvider(options, provider, req.url);
if (path[0] === 'callback')
return handleCallback(options, provider, req.url);
}
return Response.redirect(getSignInPage(options, host));
},
POST: async () => { return new Response(JSON.stringify({ error: 'Bad request' }), { status: 400 }); }
},
auth: async () => {
var _a;
const token = await getToken(options);
const secret = getSecret(options);
if (!secret || !token)
return null;
try {
const jwt = await validateJWT('HS256', secret, token.token);
const payload = jwt.payload;
const session = {
expires: jwt.expiresAt,
provider: payload.provider,
user: payload.user
};
try {
if ((_a = options.callbacks) === null || _a === void 0 ? void 0 : _a.session)
return await options.callbacks.session({ session, token: payload, user: payload.user });
}
catch (e) {
console.error('ArcticLights: Unable to run session callback:', e);
}
return session;
}
catch (e) {
return null;
}
},
getProviders: async () => {
return (await getProviders(options)).filter(provider => provider !== null).map(provider => {
return {
type: provider.type,
style: provider.style
};
});
},
generateToken: async (session) => {
return await createSessionToken(options, session.provider, session.user);
},
};
};
export default ArcticLights;