Skip to content

Instantly share code, notes, and snippets.

@voyager5874
Last active May 5, 2025 12:04
Show Gist options
  • Save voyager5874/3045d499a9b696089aa89ee8e2119bb3 to your computer and use it in GitHub Desktop.
Save voyager5874/3045d499a9b696089aa89ee8e2119bb3 to your computer and use it in GitHub Desktop.
auth.js workaround
//proposed workaround for token rotation problem
//https://github.com/nextauthjs/next-auth/issues/7558#issuecomment-2705548712
export const authConfig: NextAuthConfig = {
pages: {
signIn: '/auth/sign-in',
},
callbacks: {
async signIn(data) {
return !!data?.account?.access_token || !!data?.account?.accessToken || !!data?.user?.accessToken;
},
authorized({ auth, request: { nextUrl, cookies, url } }) {
console.log(
`[🗿NEXT-AUTH]:${new Date().toLocaleTimeString()} / authorized / - next address is: ${nextUrl.pathname}, checking authorization...`,
);
let isAuthWithApp = !!auth?.appAccessToken;
const lastUsedRefreshToken = cookies.get(LAST_USED_REFRESH_TOKEN_COOKIE)?.value;
const skip = lastUsedRefreshToken === auth?.appRefreshToken;
console.log(
`${skip ? '[🚳NEXT-AUTH] / authorized / - refresh token is already used, skipping refresh' : 'refresh token is not used yet'}`,
);
const forceRefresh = nextUrl.searchParams.has(FORCE_REFRESH_PARAM);
const expiring =
process.env.NODE_ENV === 'development'
? isTokenExpiring(auth?.appAccessToken, 1, TEST_MAX_IAT_AGO - 2 > 0 ? TEST_MAX_IAT_AGO - 2 : 0)
: isTokenExpiring(auth?.appAccessToken, 2);
if (!skip && auth?.appAccessToken && (expiring || forceRefresh)) {
const searchParams = new URLSearchParams(nextUrl.search);
searchParams.delete(FORCE_REFRESH_PARAM);
const returnUrl = nextUrl.pathname + (searchParams.toString() ? `?${searchParams.toString()}` : '');
const refreshUrl = `/api/tokens/tokens-refresh-redirect?${RETURN_TO_PARAM}=${encodeURIComponent(returnUrl)}`;
console.log(
`[🕗NEXT-AUTH]:${new Date().toLocaleTimeString()} / authorized / - access token is expiring, redirecting to ${refreshUrl} route handler...`,
);
const response = NextResponse.redirect(new URL(refreshUrl, url));
auth?.appRefreshToken &&
response.cookies.set(LAST_USED_REFRESH_TOKEN_COOKIE, auth.appRefreshToken, {
maxAge: 60,
path: '/',
httpOnly: true,
sameSite: 'strict',
});
return response;
}
const toAuth = nextUrl.pathname.startsWith('/auth');
const toLogin = nextUrl.pathname.startsWith('/auth/sign-in');
const isDev = process.env.NODE_ENV === 'development';
const toRootPage = nextUrl.pathname === '/';
if (isDev && toRootPage) {
return true;
}
if (auth?.error) {
return NextResponse.redirect(new URL('/auth/sign-out', nextUrl));
}
if (!isAuthWithApp && !toAuth) {
return NextResponse.redirect(new URL('/auth/sign-in', nextUrl));
}
if (toLogin && isAuthWithApp) {
return NextResponse.redirect(new URL('/forums', nextUrl));
}
return isAuthWithApp;
},
async jwt({ token, user, account, trigger, session, profile }) {
console.log(
`[🗿NEXT-AUTH]:${new Date().toLocaleTimeString()} / jwt / trigger: ${trigger} ${account?.provider ? '/ ' + account.provider : ''}`);
if (account && trigger === 'signIn') {
const zitadelSignIn = account.provider.toLowerCase().includes('zitadel');
const credentialSignIn = account.provider.toLowerCase().includes('credentials');
if (zitadelSignIn) {
const { access_token: idpAccessToken } = account;
if (!idpAccessToken) throw new TypeError('Missing idp access token');
const appAuth = await exchangeIdpToken(idpAccessToken);
if (!appAuth) {
throw new TypeError('failed idp token exchange');
}
const decoded = decodeJWT(appAuth.accessToken);
const expires = new Date(decoded.exp).toUTCString();
return {
appAccessToken: appAuth.accessToken,
appRefreshToken: appAuth.refreshToken,
appUser: appAuth.user,
expires,
exp: decoded.exp,
expiresAt: decoded.exp,
};
}
if (credentialSignIn) {
if (Object.keys(user).length === 0) {
return token;
}
const { accessToken, refreshToken, name, user: userObjString, email, roles } = user;
const appUser = JSON.parse(userObjString);
const decoded = accessToken ? decodeJWT(accessToken) : null;
const expDate = decoded ? getDateFromEpoch(decoded.exp) : null;
return {
appUser,
appAccessToken: accessToken,
appRefreshToken: refreshToken,
exp: decoded?.exp,
expires: expDate,
expiresAt: decoded?.exp,
};
}
}
if (trigger === 'update') {
return {
...token,
...session,
};
}
return { ...token };
},
async session({ session, token, trigger, newSession, user }) {
console.log(`[🗿NEXT-AUTH]:${new Date().toLocaleTimeString()} / session / trigger:${trigger}`, {
session,
token,
user,
newSession,
});
token?.appUser && (session.appUser = token?.appUser);
let isAccessTokenFromJwtNewer = isFirstTokenNewer(token?.appAccessToken, session?.appAccessToken);
if (typeof session?.appAccessToken !== 'string' || isAccessTokenFromJwtNewer) {
session.appAccessToken = token?.appAccessToken;
}
let isRefreshTokenFromJwtNewer = isFirstTokenNewer(token?.appRefreshToken, session?.appRefreshToken);
if (typeof session?.appRefreshToken !== 'string' || isRefreshTokenFromJwtNewer) {
session.appRefreshToken = token?.appRefreshToken;
}
if (typeof session?.expiresAt !== 'number' || token?.expiresAt > session?.expiresAt) {
session.expiresAt = token?.expiresAt;
session.expires = token?.expires;
}
if (token?.error === 'Unauthorized') {
return {
user: null,
appUser: undefined,
appAccessToken: undefined,
appRefreshToken: undefined,
expiresAt: 0,
expires: new Date(),
error: token.error,
};
}
return session;
},
},
providers: [],
} satisfies NextAuthConfig;
interface FetcherError extends Error {
response?: Response;
path?: string;
data?: any;
status?: number;
statusText?: Response['statusText'];
digest?: string;
}
export const isFetcherError = (error: unknown): error is FetcherError =>
typeof error === 'object' && error !== null && 'statusText' in error && !!error.statusText;
export interface FetcherConfig extends RequestInit {
body?: any;
params?: Record<string, string | number>;
next?: NextFetchRequestConfig;
auth?: Session | null;
router?: ReturnType<typeof useRouter>;
sessionUpdater?: (data: Partial<Session>) => Promise<Session | null>;
context?: 'server-action' | 'ssr' | 'client';
}
export class Fetcher {
private readonly baseURL: string;
private readonly defaultOptions: RequestInit;
private readonly mutex: Mutex;
private mutexRelease: MutexInterface['release'] | undefined;
private session: AppAuthData | null;
constructor(baseURL: string = '', defaultOptions: RequestInit = {}, mutexInstance?: Mutex) {
this.baseURL = baseURL;
this.defaultOptions = defaultOptions;
this.session = null;
this.mutex = mutexInstance ? mutexInstance : new Mutex();
}
async request<T = any>(url: string, config: FetcherConfig = {}): Promise<{ data: T | null }> {
let requestStartTime = null;
if (this.mutex.isLocked()) {
console.log(`[Fetcher] ${url} waiting for mutex unlock...`);
requestStartTime = Date.now();
}
await this.mutex.waitForUnlock();
if (requestStartTime) {
const unlockedIn = Date.now() - requestStartTime;
console.log(`[Fetcher] mutex was locked for ${unlockedIn} ms, proceeding...`);
requestStartTime = null;
}
const isDev = process.env.NODE_ENV === 'development';
const isServerside = typeof window === 'undefined';
const canSetHttpOnlyCookie = config?.context === 'server-action';
if (config?.auth && config?.auth?.appAccessToken && config?.auth?.appRefreshToken && config?.auth?.appUser) {
if (!this.session?.accessToken || isFirstTokenNewer(config.auth.appAccessToken, this.session?.accessToken)) {
this.session = {
accessToken: config?.auth?.appAccessToken,
refreshToken: config?.auth?.appRefreshToken,
user: config?.auth?.appUser,
};
}
}
let finalUrl = this.baseURL ? `${this.baseURL}${url}` : url;
if (url.startsWith('http')) {
finalUrl = url;
}
if (isDev) {
finalUrl = 'http://localhost:3000' + '/api/proxy/dev' + url;
}
if (config.params) {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(config.params)) {
searchParams.append(key, String(value));
}
const separator = finalUrl.includes('?') ? '&' : '?';
finalUrl += `${separator}${searchParams.toString()}`;
}
const mergedHeaders: HeadersInit = {
...(this.defaultOptions.headers || {}),
...(config.headers || {}),
};
let body = config?.body;
const headers = new Headers(mergedHeaders);
if (!headers.has('Authorization') && this.session?.accessToken) {
headers.set('Authorization', `Bearer ${this.session?.accessToken}`);
}
if (!headers.has('Authorization') && !config?.auth && !this.session?.accessToken) {
await this.getAuthSession();
if (this.session?.accessToken) {
headers.set('Authorization', `Bearer ${this.session?.accessToken}`);
}
}
if (config?.body && typeof config.body !== 'string' && !(config.body instanceof FormData)) {
body = JSON.stringify(config.body);
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
}
if (config?.body && typeof config.body !== 'string') {
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'text/plain');
}
}
const options: RequestInit = {
...this.defaultOptions,
...omit(config, ['auth', 'context', 'sessionUpdater']),
headers,
method: config.method ? config.method.toUpperCase() : 'GET',
cache: config.cache || 'no-store',
next: config?.next,
};
if (body) {
options.body = body;
}
try {
let response = await fetch(finalUrl, options);
isDev &&
console.log('[Fetcher] debug', {
response,
hasUpdater: config?.sessionUpdater !== undefined,
config,
finalUrl,
options,
status: response.status,
body: config?.body,
isServerside,
bearerIat: getTokenIatOrExpTime(this.session?.accessToken, 'iat'),
configAuthIat: getTokenIatOrExpTime(config?.auth?.appAccessToken, 'iat'),
});
if (response.status === 204 || response.headers.get('Content-Length') === '0') {
return { data: null };
}
console.log(`[🚀Fetcher] path: ${finalUrl}, status code:${response.status}`);
if (response.status === 401 && headers.has('Authorization') && config?.context !== 'ssr') {
console.log('[🚨Fetcher] 401 code - will attempt to refresh');
if (!this.mutex.isLocked()) {
this.mutexRelease = await this.mutex.acquire();
console.log(`[🔒Fetcher] Locked mutex and trying to refresh tokens...`);
await this.getAuthSession();
console.log(`[🌀Fetcher] retrying ${finalUrl} with refreshed access token...`, { response });
response = await fetch(finalUrl, { ...options, ...makeAuthHeaderForFetch(this.session?.accessToken) });
console.log(`[🚀Fetcher] status code:${response.status}, ${response.ok ? 'success' : 'failed'}`);
if (response.status === 204 || response.headers.get('Content-Length') === '0') {
return { data: null };
}
}
}
const contentType = response.headers.get('Content-Type');
let data: any;
if (contentType?.includes('application/json')) {
data = await response.json();
} else if (contentType?.includes('text/')) {
data = await response.text();
} else {
data = await response.blob();
}
if (!response.ok) {
throw createErrorObject(response, data);
}
return { data };
} catch (error) {
if (isFetcherError(error) && error.status === 401) {
// redirect('/auth/sign-out');
console.log(`[🚳Fetcher] 401 error, signing out (refreshing session failed or was impossible to perform)`);
if (isServerside && config?.context !== 'ssr') {
await signOut();
}
if (!isServerside) {
await reactSignOut();
}
}
if (error instanceof Error) {
throw error;
}
throw new Error('[🚳Fetcher] Сетевая ошибка');
} finally {
if (this.mutexRelease) {
console.log('[🔓Fetcher] unlocking mutex...');
this.mutexRelease();
this.mutexRelease = undefined;
}
}
}
protected async getAuthSession() {
console.log('[🚀Fetcher] calling getAuthSession...');
const res = await sfGetSessionViaAuthServerSideMethod();
if (
res &&
res.appAccessToken &&
res.appRefreshToken &&
(isFirstTokenNewer(res.appAccessToken, this.session?.accessToken) || !this.session?.accessToken)
) {
this.session = {
...this.session,
accessToken: res.appAccessToken,
refreshToken: res.appRefreshToken,
user: res.appUser,
};
}
return res;
}
get<T = any>(url: string, config: FetcherConfig = {}): Promise<{ data: T | null }> {
return this.request<T>(url, { ...config, method: 'GET' });
}
post<T = any>(url: string, body?: any, config: FetcherConfig = {}): Promise<{ data: T | null }> {
return this.request<T>(url, { ...config, method: 'POST', body });
}
put<T = any>(url: string, body?: any, config: FetcherConfig = {}): Promise<{ data: T | null }> {
return this.request<T>(url, { ...config, method: 'PUT', body });
}
delete<T = any>(url: string, config: FetcherConfig = {}): Promise<{ data: T | null }> {
return this.request<T>(url, { ...config, method: 'DELETE' });
}
patch<T = any>(url: string, body?: any, config: FetcherConfig = {}): Promise<{ data: T | null }> {
return this.request<T>(url, { ...config, method: 'PATCH', body });
}
}
function createErrorObject<T>(response: Response, data?: T): FetcherError {
const error: FetcherError = new Error(`Ошибка ${response.status}: ${response.statusText}`);
error.response = response;
error.data = data ? data : {};
error.status = response.status;
error.statusText = response.statusText;
error.path = response.url;
return error;
}
export const mutex = new Mutex();
export const roscApiFetcher = new Fetcher(ROSC_API_BASE_URL, {}, mutex);
export async function GET(request: NextRequest) {
const { sessionAccessTokenIat, bearerIat, referrer, returnTo, allCookies, requestUrl } =
await getRequestInfo(request);
return handleSessionUpdate(request);
}
export async function POST(req: NextRequest) {
const { referrer, requestUrl, allCookies, bearer, returnTo } = await getRequestInfo(req);
return handleSessionUpdate(req);
}
async function handleSessionUpdate(req: NextRequest) {
const { session, requestUrl, accessToken, refreshToken, tokenUser, userObjString, returnTo } =
await getRequestInfo(req);
if (!accessToken || !refreshToken) {
return NextResponse.redirect(new URL('/auth/sign-in', requestUrl));
}
try {
const refreshedTokens = await getRefreshedAppTokens(refreshToken);
if (!refreshedTokens || typeof refreshedTokens === 'string') {
return NextResponse.redirect(new URL('/auth/sign-in', requestUrl));
}
if (!refreshedTokens || typeof refreshedTokens === 'string') {
return NextResponse.redirect(new URL('/auth/sign-in', requestUrl));
}
const res = await signIn('credentials', {
accessToken: refreshedTokens.accessToken,
refreshToken: refreshedTokens.refreshToken,
user: userObjString,
redirect: false,
});
const response = NextResponse.redirect(new URL(returnTo, requestUrl));
refreshToken &&
response.cookies.set(LAST_USED_REFRESH_TOKEN_COOKIE, refreshToken, {
maxAge: 60,
path: '/',
httpOnly: true,
sameSite: 'strict',
});
return response;
} catch (error) {
console.error('Error refreshing tokens:', error);
return NextResponse.redirect(new URL('/auth/sign-out', requestUrl));
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment