mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 12:19:06 +00:00
fix: sso refresh token race condition (#405)
With NextAuth, there is a race condition of the current implementation of refreshToken using multiple tab. Because getSession() is broadcasted (or triggered by another component, window focus or such), we may ask for the jwt() to be refreshed at the same time. The problem is the first time will go correctly, while all others calls will be rejected as they are using a revoked token. This redis lock is per-user, and will use redis lock as a source of truth.
This commit is contained in:
@@ -4,7 +4,7 @@ import { SessionAutoRefresh } from "./SessionAutoRefresh";
|
||||
|
||||
export default function SessionProvider({ children }) {
|
||||
return (
|
||||
<SessionProviderNextAuth refetchInterval={60} refetchOnWindowFocus={true}>
|
||||
<SessionProviderNextAuth>
|
||||
<SessionAutoRefresh>{children}</SessionAutoRefresh>
|
||||
</SessionProviderNextAuth>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
// import { kv } from "@vercel/kv";
|
||||
import Redlock, { ResourceLockedError } from "redlock";
|
||||
import { AuthOptions } from "next-auth";
|
||||
import AuthentikProvider from "next-auth/providers/authentik";
|
||||
import { JWT } from "next-auth/jwt";
|
||||
import { JWTWithAccessToken, CustomSession } from "./types";
|
||||
import Redis from "ioredis";
|
||||
|
||||
const PRETIMEOUT = 60; // seconds before token expires to refresh it
|
||||
const DEFAULT_REDIS_KEY_TIMEOUT = 60 * 60 * 24 * 30; // 30 days (refresh token expires in 30 days)
|
||||
const kv = new Redis(process.env.KV_URL || "", {
|
||||
tls: {},
|
||||
});
|
||||
const redlock = new Redlock([kv], {});
|
||||
|
||||
redlock.on("error", (error) => {
|
||||
if (error instanceof ResourceLockedError) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Log all other errors.
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
export const authOptions: AuthOptions = {
|
||||
providers: [
|
||||
@@ -28,13 +45,19 @@ export const authOptions: AuthOptions = {
|
||||
// called only on first login
|
||||
// XXX account.expires_in used in example is not defined for authentik backend, but expires_at is
|
||||
const expiresAt = (account.expires_at as number) - PRETIMEOUT;
|
||||
|
||||
return {
|
||||
const jwtToken = {
|
||||
...extendedToken,
|
||||
accessToken: account.access_token,
|
||||
accessTokenExpires: expiresAt * 1000,
|
||||
refreshToken: account.refresh_token,
|
||||
};
|
||||
kv.set(
|
||||
`token:${jwtToken.sub}`,
|
||||
JSON.stringify(jwtToken),
|
||||
"EX",
|
||||
DEFAULT_REDIS_KEY_TIMEOUT,
|
||||
);
|
||||
return jwtToken;
|
||||
}
|
||||
|
||||
if (Date.now() < extendedToken.accessTokenExpires) {
|
||||
@@ -42,7 +65,7 @@ export const authOptions: AuthOptions = {
|
||||
}
|
||||
|
||||
// access token has expired, try to update it
|
||||
return await refreshAccessToken(token);
|
||||
return await redisLockedrefreshAccessToken(token);
|
||||
},
|
||||
async session({ session, token }) {
|
||||
const extendedToken = token as JWTWithAccessToken;
|
||||
@@ -60,7 +83,35 @@ export const authOptions: AuthOptions = {
|
||||
},
|
||||
};
|
||||
|
||||
async function refreshAccessToken(token: JWT) {
|
||||
async function redisLockedrefreshAccessToken(token: JWT) {
|
||||
return await redlock.using(
|
||||
[token.sub as string, "jwt-refresh"],
|
||||
5000,
|
||||
async () => {
|
||||
const redisToken = await kv.get(`token:${token.sub}`);
|
||||
const currentToken = JSON.parse(
|
||||
redisToken as string,
|
||||
) as JWTWithAccessToken;
|
||||
|
||||
// if there is multiple requests for the same token, it may already have been refreshed
|
||||
if (Date.now() < currentToken.accessTokenExpires) {
|
||||
return currentToken;
|
||||
}
|
||||
|
||||
// now really do the request
|
||||
const newToken = await refreshAccessToken(currentToken);
|
||||
await kv.set(
|
||||
`token:${currentToken.sub}`,
|
||||
JSON.stringify(newToken),
|
||||
"EX",
|
||||
DEFAULT_REDIS_KEY_TIMEOUT,
|
||||
);
|
||||
return newToken;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshAccessToken(token: JWT): Promise<JWTWithAccessToken> {
|
||||
try {
|
||||
const url = `${process.env.AUTHENTIK_REFRESH_TOKEN_URL}`;
|
||||
|
||||
@@ -79,9 +130,15 @@ async function refreshAccessToken(token: JWT) {
|
||||
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
new Date().toISOString(),
|
||||
"Failed to refresh access token. Response status:",
|
||||
response.status,
|
||||
);
|
||||
const responseBody = await response.text();
|
||||
console.error(new Date().toISOString(), "Response body:", responseBody);
|
||||
throw new Error(`Failed to refresh access token: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const refreshedTokens = await response.json();
|
||||
return {
|
||||
...token,
|
||||
@@ -92,10 +149,9 @@ async function refreshAccessToken(token: JWT) {
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error refreshing access token", error);
|
||||
|
||||
return {
|
||||
...token,
|
||||
error: "RefreshAccessTokenError",
|
||||
};
|
||||
} as JWTWithAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,52 +56,3 @@ export default withAuth(
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
|
||||
// import { getFiefAuthMiddleware } from "./app/lib/fief";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
import { getConfig } from "./app/lib/edgeConfig";
|
||||
import { authOptions } from "./app/api/auth/[...nextauth]/route";
|
||||
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const config = await getConfig();
|
||||
|
||||
console.log(
|
||||
"---------------------------------------------------------------",
|
||||
);
|
||||
console.log(
|
||||
"middleware",
|
||||
"request.nextUrl.pathname",
|
||||
request.nextUrl.pathname,
|
||||
);
|
||||
console.log("middleware", "config", config);
|
||||
|
||||
if (
|
||||
request.nextUrl.pathname.match(
|
||||
"^/((?!api|_next/static|_next/image|favicon.ico).*)",
|
||||
)
|
||||
) {
|
||||
// Feature-flag protedted paths
|
||||
if (
|
||||
(!config.features.browse &&
|
||||
request.nextUrl.pathname.startsWith("/browse")) ||
|
||||
(!config.features.rooms && request.nextUrl.pathname.startsWith("/rooms"))
|
||||
) {
|
||||
console.log("!! redirecting to", request.nextUrl.origin);
|
||||
return NextResponse.redirect(request.nextUrl.origin);
|
||||
}
|
||||
|
||||
if (config.features.requireLogin) {
|
||||
const fiefMiddleware = await getFiefAuthMiddleware(request.nextUrl);
|
||||
const fiefResponse = await fiefMiddleware(request);
|
||||
return fiefResponse;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
**/
|
||||
|
||||
@@ -11,17 +11,17 @@
|
||||
"openapi": "openapi-ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/icons": "2.1.1",
|
||||
"@chakra-ui/form-control": "2.2.0",
|
||||
"@chakra-ui/icon": "3.2.0",
|
||||
"@chakra-ui/system": "2.6.2",
|
||||
"@chakra-ui/menu": "^2.2.1",
|
||||
"@chakra-ui/next-js": "^2.2.0",
|
||||
"@chakra-ui/icons": "2.1.1",
|
||||
"@chakra-ui/layout": "^2.3.1",
|
||||
"@chakra-ui/media-query": "^3.3.0",
|
||||
"@chakra-ui/spinner": "^2.1.0",
|
||||
"@chakra-ui/form-control": "2.2.0",
|
||||
"@chakra-ui/menu": "^2.2.1",
|
||||
"@chakra-ui/next-js": "^2.2.0",
|
||||
"@chakra-ui/react": "^2.8.2",
|
||||
"@chakra-ui/react-types": "^2.0.6",
|
||||
"@chakra-ui/spinner": "^2.1.0",
|
||||
"@chakra-ui/system": "2.6.2",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
||||
@@ -29,6 +29,7 @@
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@sentry/nextjs": "^7.77.0",
|
||||
"@vercel/edge-config": "^0.4.1",
|
||||
"@vercel/kv": "^2.0.0",
|
||||
"@whereby.com/browser-sdk": "^3.3.4",
|
||||
"autoprefixer": "10.4.20",
|
||||
"axios": "^1.6.2",
|
||||
@@ -37,6 +38,7 @@
|
||||
"eslint-config-next": "^14.2.7",
|
||||
"fontawesome": "^5.6.3",
|
||||
"framer-motion": "^10.16.16",
|
||||
"ioredis": "^5.4.1",
|
||||
"jest-worker": "^29.6.2",
|
||||
"next": "^14.2.7",
|
||||
"next-auth": "^4.24.7",
|
||||
@@ -49,6 +51,7 @@
|
||||
"react-markdown": "^9.0.0",
|
||||
"react-qr-code": "^2.0.12",
|
||||
"react-select-search": "^4.1.7",
|
||||
"redlock": "^5.0.0-beta.2",
|
||||
"sass": "^1.63.6",
|
||||
"simple-peer": "^9.11.1",
|
||||
"superagent": "^8.0.9",
|
||||
@@ -64,6 +67,7 @@
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "^0.48.0",
|
||||
"@types/react": "18.2.20",
|
||||
"prettier": "^3.0.0"
|
||||
"prettier": "^3.0.0",
|
||||
"vercel": "^37.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
1425
www/yarn.lock
1425
www/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user