Last active
May 5, 2025 12:04
-
-
Save voyager5874/3045d499a9b696089aa89ee8e2119bb3 to your computer and use it in GitHub Desktop.
auth.js workaround
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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