From 9eb6c392383fb72cf09269d216c6ca98ae3e9fe1 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Wed, 24 Dec 2025 16:07:32 +0000 Subject: [PATCH 01/32] Auth Refresh Tokens Frontend Implementation --- .../app/(api)/api-proxy/[...path]/route.ts | 88 +++++++++---- front_end/src/app/(main)/accounts/actions.ts | 10 +- .../src/app/(main)/accounts/activate/route.ts | 2 +- .../src/app/(main)/accounts/reset/actions.ts | 2 +- .../accounts/social/[provider]/actions.ts | 9 +- front_end/src/middleware.ts | 60 ++++----- front_end/src/services/auth_refresh.ts | 79 ++++++++++++ front_end/src/services/auth_tokens.ts | 119 ++++++++++++++++++ front_end/src/services/session.ts | 37 ++++-- front_end/src/types/auth.ts | 14 ++- .../src/utils/core/fetch/fetch.server.ts | 86 ++++++++++--- 11 files changed, 398 insertions(+), 108 deletions(-) create mode 100644 front_end/src/services/auth_refresh.ts create mode 100644 front_end/src/services/auth_tokens.ts diff --git a/front_end/src/app/(api)/api-proxy/[...path]/route.ts b/front_end/src/app/(api)/api-proxy/[...path]/route.ts index bdba81340f..0b6d4fef9e 100644 --- a/front_end/src/app/(api)/api-proxy/[...path]/route.ts +++ b/front_end/src/app/(api)/api-proxy/[...path]/route.ts @@ -1,7 +1,15 @@ import { NextRequest, NextResponse } from "next/server"; import { getLocale } from "next-intl/server"; -import { getServerSession, getAlphaTokenSession } from "@/services/session"; +import { refreshWithSingleFlight } from "@/services/auth_refresh"; +import { + applyTokenCookiesToResponse, + AuthTokens, + getAccessToken, + getRefreshToken, + isAccessTokenExpired, +} from "@/services/auth_tokens"; +import { getAlphaTokenSession } from "@/services/session"; import { getPublicSettings } from "@/utils/public_settings.server"; export async function GET(request: NextRequest) { @@ -34,7 +42,6 @@ async function handleProxyRequest(request: NextRequest, method: string) { const includeLocale = request.headers.get("x-include-locale") !== "false"; const shouldPassAuth = passAuthHeader || PUBLIC_AUTHENTICATION_REQUIRED; - const authToken = shouldPassAuth ? await getServerSession() : null; const alphaToken = await getAlphaTokenSession(); const locale = includeLocale ? await getLocale() : "en"; @@ -58,33 +65,56 @@ async function handleProxyRequest(request: NextRequest, method: string) { "x-include-locale", ]; - const requestHeaders: HeadersInit = new Headers({ - ...Object.fromEntries( - Array.from(request.headers.entries()).filter( - ([key]) => !blocklistHeaders.includes(key.toLowerCase()) - ) - ), - }); - - requestHeaders.set("Accept", "application/json"); - requestHeaders.set("Accept-Language", locale); - - if (emptyContentType && requestHeaders.has("Content-Type")) { - requestHeaders.delete("Content-Type"); + const buildHeaders = async (accessToken?: string): Promise => { + const headers: HeadersInit = new Headers({ + ...Object.fromEntries( + Array.from(request.headers.entries()).filter( + ([key]) => !blocklistHeaders.includes(key.toLowerCase()) + ) + ), + }); + + headers.set("Accept", "application/json"); + headers.set("Accept-Language", locale); + if (emptyContentType) headers.delete("Content-Type"); + + const token = + accessToken ?? (shouldPassAuth ? await getAccessToken() : null); + if (token) headers.set("Authorization", `Bearer ${token}`); + if (alphaToken) headers.set("x-alpha-auth-token", alphaToken); + + return headers; + }; + + let refreshedTokens: AuthTokens | null = null; + + // Proactive refresh: check expiration before making request + if (shouldPassAuth && (await isAccessTokenExpired())) { + const refreshToken = await getRefreshToken(); + if (refreshToken) { + const newTokens = await refreshWithSingleFlight(refreshToken); + if (newTokens) { + refreshedTokens = newTokens; + } + } } - if (authToken) { - requestHeaders.set("Authorization", `Token ${authToken}`); - } - if (alphaToken) { - requestHeaders.set("x-alpha-auth-token", alphaToken); + let headers = await buildHeaders(refreshedTokens?.accessToken); + let response = await fetch(targetUrl, { method, headers }); + + // Fallback: retry on 401 (in case proactive check missed edge cases) + if (response.status === 401 && shouldPassAuth) { + const refreshToken = await getRefreshToken(); + if (refreshToken) { + const newTokens = await refreshWithSingleFlight(refreshToken); + if (newTokens) { + refreshedTokens = newTokens; + headers = await buildHeaders(newTokens.accessToken); + response = await fetch(targetUrl, { method, headers }); + } + } } - const response = await fetch(targetUrl, { - method, - headers: requestHeaders, - }); - const responseData = await response.blob(); const responseHeaders: HeadersInit = {}; response.headers.forEach((value, key) => { @@ -97,11 +127,17 @@ async function handleProxyRequest(request: NextRequest, method: string) { } }); - return new NextResponse(responseData, { + const nextResponse = new NextResponse(responseData, { status: response.status, statusText: response.statusText, headers: responseHeaders, }); + + if (refreshedTokens) { + applyTokenCookiesToResponse(nextResponse, refreshedTokens); + } + + return nextResponse; } // Normalize the Content-Disposition header to ensure the filename is quoted correctly diff --git a/front_end/src/app/(main)/accounts/actions.ts b/front_end/src/app/(main)/accounts/actions.ts index bcac46ee92..2556798a94 100644 --- a/front_end/src/app/(main)/accounts/actions.ts +++ b/front_end/src/app/(main)/accounts/actions.ts @@ -71,7 +71,7 @@ export default async function loginAction( }; } - await setServerSession(response.token); + await setServerSession(response); // Set user's language preference as the active locale if (response.user.language) { @@ -135,8 +135,8 @@ export async function signUpAction( const signUpActionState: SignUpActionState = { ...response }; - if (response.is_active && response.token) { - await setServerSession(response.token); + if (response.is_active && response.access_token && response.refresh_token) { + await setServerSession(response); // Set user's language preference as the active locale if (response.user?.language) { @@ -216,8 +216,8 @@ export async function simplifiedSignUpAction( try { const response = await ServerAuthApi.simplifiedSignUp(username, authToken); - if (response?.token) { - await setServerSession(response.token); + if (response) { + await setServerSession(response); } return response; } catch (err: unknown) { diff --git a/front_end/src/app/(main)/accounts/activate/route.ts b/front_end/src/app/(main)/accounts/activate/route.ts index b81de0d732..42c9d88e92 100644 --- a/front_end/src/app/(main)/accounts/activate/route.ts +++ b/front_end/src/app/(main)/accounts/activate/route.ts @@ -15,7 +15,7 @@ export async function GET(request: Request) { try { const response = await ServerAuthApi.activateAccount(userId, token); - await setServerSession(response.token); + await setServerSession(response); } catch (err) { logError(err); } diff --git a/front_end/src/app/(main)/accounts/reset/actions.ts b/front_end/src/app/(main)/accounts/reset/actions.ts index 41c275ea00..22c311906d 100644 --- a/front_end/src/app/(main)/accounts/reset/actions.ts +++ b/front_end/src/app/(main)/accounts/reset/actions.ts @@ -66,7 +66,7 @@ export async function passwordResetConfirmAction( validatedFields.data.password ); - await setServerSession(response.token); + await setServerSession(response); return { data: response, diff --git a/front_end/src/app/(main)/accounts/social/[provider]/actions.ts b/front_end/src/app/(main)/accounts/social/[provider]/actions.ts index e945c7e30c..79c2c756c2 100644 --- a/front_end/src/app/(main)/accounts/social/[provider]/actions.ts +++ b/front_end/src/app/(main)/accounts/social/[provider]/actions.ts @@ -1,7 +1,7 @@ "use server"; import ServerAuthApi from "@/services/api/auth/auth.server"; -import { setServerSession } from "@/services/session"; +import { setAuthTokens } from "@/services/auth_tokens"; import { SocialProviderType } from "@/types/auth"; import { getPublicSettings } from "@/utils/public_settings.server"; @@ -16,7 +16,10 @@ export async function exchangeSocialOauthCode( `${PUBLIC_APP_URL}/accounts/social/${provider}` ); - if (response?.token) { - await setServerSession(response.token); + if (response) { + await setAuthTokens({ + accessToken: response.access_token, + refreshToken: response.refresh_token, + }); } } diff --git a/front_end/src/middleware.ts b/front_end/src/middleware.ts index aee470bec9..87727bb83f 100644 --- a/front_end/src/middleware.ts +++ b/front_end/src/middleware.ts @@ -1,55 +1,52 @@ import { NextRequest, NextResponse } from "next/server"; import ServerAuthApi from "@/services/api/auth/auth.server"; +import { + COOKIE_NAME_ACCESS_TOKEN, + COOKIE_NAME_REFRESH_TOKEN, +} from "@/services/auth_tokens"; import { LanguageService, LOCALE_COOKIE_NAME, } from "@/services/language_service"; -import { - COOKIE_NAME_TOKEN, - getAlphaTokenSession, - getServerSession, -} from "@/services/session"; +import { getAlphaTokenSession } from "@/services/session"; import { getAlphaAccessToken } from "@/utils/alpha_access"; import { ApiError } from "@/utils/core/errors"; import { getPublicSettings } from "@/utils/public_settings.server"; +function hasAuthSession(request: NextRequest): boolean { + const accessToken = request.cookies.get(COOKIE_NAME_ACCESS_TOKEN)?.value; + const refreshToken = request.cookies.get(COOKIE_NAME_REFRESH_TOKEN)?.value; + return !!(accessToken || refreshToken); +} + export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; - - const serverSession = await getServerSession(); + const hasSession = hasAuthSession(request); const { PUBLIC_AUTHENTICATION_REQUIRED } = getPublicSettings(); // if authentication is required, check for token if (PUBLIC_AUTHENTICATION_REQUIRED) { if ( - !request.nextUrl.pathname.startsWith("/not-found/") && - !request.nextUrl.pathname.startsWith("/accounts/") && - !serverSession + !pathname.startsWith("/not-found/") && + !pathname.startsWith("/accounts/") && + !hasSession ) { // return a not found page return NextResponse.rewrite(new URL("/not-found/", request.url)); } } - let deleteCookieToken = false; + let clearAuthCookies = false; - if (serverSession) { - // Verify auth token + if (hasSession) { try { await ServerAuthApi.verifyToken(); } catch (error) { - const errorResponse = error as ApiError; - - if (errorResponse?.response?.status === 403) { - request.cookies.delete(COOKIE_NAME_TOKEN); - // A small workaround of deleting cookies. - // We need to delete cookies from request before generating response - // to let other services know we've eliminated the auth token. - // But Nextjs does not apply such cookies deletion to the response - // automatically, so we have to do it for both req and res - // https://github.com/vercel/next.js/issues/40146 - deleteCookieToken = true; + if ((error as ApiError)?.response?.status === 403) { + request.cookies.delete(COOKIE_NAME_ACCESS_TOKEN); + request.cookies.delete(COOKIE_NAME_REFRESH_TOKEN); + clearAuthCookies = true; } } @@ -72,22 +69,17 @@ export async function middleware(request: NextRequest) { const requestHeaders = new Headers(request.headers); requestHeaders.set("x-url", request.url); + const response = NextResponse.next({ request: { headers: requestHeaders } }); + const locale_in_url = request.nextUrl.searchParams.get("locale"); const locale_in_cookie = request.cookies.get(LOCALE_COOKIE_NAME)?.value; - - const response = NextResponse.next({ - request: { - headers: requestHeaders, - }, - }); - - // Handle explicit locale parameter in URL if (locale_in_url && locale_in_url !== locale_in_cookie) { LanguageService.setLocaleCookieInResponse(response, locale_in_url); } - if (deleteCookieToken) { - response.cookies.delete(COOKIE_NAME_TOKEN); + if (clearAuthCookies) { + response.cookies.delete(COOKIE_NAME_ACCESS_TOKEN); + response.cookies.delete(COOKIE_NAME_REFRESH_TOKEN); } return response; diff --git a/front_end/src/services/auth_refresh.ts b/front_end/src/services/auth_refresh.ts new file mode 100644 index 0000000000..e56132f1b1 --- /dev/null +++ b/front_end/src/services/auth_refresh.ts @@ -0,0 +1,79 @@ +import "server-only"; + +import { + getRefreshToken, + setAuthTokens, + clearAuthTokens, + AuthTokens, +} from "@/services/auth_tokens"; +import { getPublicSettings } from "@/utils/public_settings.server"; + +export type RefreshTokenResponse = { + access_token: string; + refresh_token: string; +}; + +/** + * Single-flight refresh manager. + * Concurrent requests share ONE refresh call. Cleanup after 100ms. + */ +const inFlightRefreshes = new Map>(); + +/** + * Refresh tokens with single-flight pattern. + * Can be used by both server fetcher and api-proxy. + */ +export function refreshWithSingleFlight( + refreshToken: string +): Promise { + const existing = inFlightRefreshes.get(refreshToken); + if (existing) return existing; + + const { PUBLIC_API_BASE_URL } = getPublicSettings(); + + const promise = fetch(`${PUBLIC_API_BASE_URL}/api/auth/refresh/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token: refreshToken }), + }) + .then(async (response) => { + if (!response.ok) return null; + const data: RefreshTokenResponse = await response.json(); + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + }; + }) + .catch((error) => { + console.error("Token refresh failed:", error); + return null; + }); + + inFlightRefreshes.set(refreshToken, promise); + + promise.finally(() => { + setTimeout(() => { + if (inFlightRefreshes.get(refreshToken) === promise) { + inFlightRefreshes.delete(refreshToken); + } + }, 100); + }); + + return promise; +} + +/** + * Refresh and persist tokens. Used by server-side fetcher. + */ +export async function refreshAccessToken(): Promise { + const refreshToken = await getRefreshToken(); + if (!refreshToken) return null; + + const tokens = await refreshWithSingleFlight(refreshToken); + if (tokens) { + await setAuthTokens(tokens); + } else { + await clearAuthTokens(); + } + return tokens; +} diff --git a/front_end/src/services/auth_tokens.ts b/front_end/src/services/auth_tokens.ts new file mode 100644 index 0000000000..e6d4cb0b83 --- /dev/null +++ b/front_end/src/services/auth_tokens.ts @@ -0,0 +1,119 @@ +import "server-only"; + +import { cookies } from "next/headers"; + +export const COOKIE_NAME_ACCESS_TOKEN = "metaculus_access_token"; +export const COOKIE_NAME_REFRESH_TOKEN = "metaculus_refresh_token"; + +// Token expiration times (should match backend) +export const ACCESS_TOKEN_EXPIRY_SECONDS = 15 * 60; // 15 minutes +export const REFRESH_TOKEN_EXPIRY_SECONDS = 30 * 24 * 60 * 60; // 30 days + +export type AuthTokens = { + accessToken: string; + refreshToken: string; +}; + +/** + * Set both access and refresh tokens in httpOnly cookies + */ +export async function setAuthTokens(tokens: AuthTokens): Promise { + const cookieStorage = await cookies(); + + cookieStorage.set(COOKIE_NAME_ACCESS_TOKEN, tokens.accessToken, { + httpOnly: true, + secure: true, + sameSite: "lax", + maxAge: ACCESS_TOKEN_EXPIRY_SECONDS, + path: "/", + }); + + cookieStorage.set(COOKIE_NAME_REFRESH_TOKEN, tokens.refreshToken, { + httpOnly: true, + secure: true, + sameSite: "lax", + maxAge: REFRESH_TOKEN_EXPIRY_SECONDS, + path: "/", + }); +} + +export async function getAccessToken(): Promise { + const cookieStorage = await cookies(); + return cookieStorage.get(COOKIE_NAME_ACCESS_TOKEN)?.value || null; +} + +/** + * Decode JWT payload without verification (we just need expiration time) + */ +function decodeJWTPayload(token: string): { exp?: number } | null { + try { + const [, payload] = token.split("."); + if (!payload) return null; + const decoded = Buffer.from(payload, "base64url").toString("utf-8"); + return JSON.parse(decoded); + } catch { + return null; + } +} + +/** + * Check if access token is expired or about to expire + * @param bufferSeconds - refresh if expiring within this many seconds (default: 30) + */ +export async function isAccessTokenExpired( + bufferSeconds: number = 30 +): Promise { + const token = await getAccessToken(); + if (!token) return true; + + const payload = decodeJWTPayload(token); + if (!payload?.exp) return false; // Can't determine, assume not expired + + const expiresAt = payload.exp * 1000; // Convert to milliseconds + const now = Date.now(); + const bufferMs = bufferSeconds * 1000; + + return now >= expiresAt - bufferMs; +} + +export async function getRefreshToken(): Promise { + const cookieStorage = await cookies(); + return cookieStorage.get(COOKIE_NAME_REFRESH_TOKEN)?.value || null; +} + +export async function clearAuthTokens(): Promise { + const cookieStorage = await cookies(); + cookieStorage.delete(COOKIE_NAME_ACCESS_TOKEN); + cookieStorage.delete(COOKIE_NAME_REFRESH_TOKEN); +} + +export async function hasAuthSession(): Promise { + const accessToken = await getAccessToken(); + const refreshToken = await getRefreshToken(); + return !!(accessToken || refreshToken); +} + +/** + * Apply token cookies to a NextResponse (used by api-proxy and refresh route) + */ +export function applyTokenCookiesToResponse( + response: { + cookies: { set: (name: string, value: string, options: object) => void }; + }, + tokens: AuthTokens +): void { + const opts = { + httpOnly: true, + secure: true, + sameSite: "lax" as const, + path: "/", + }; + response.cookies.set(COOKIE_NAME_ACCESS_TOKEN, tokens.accessToken, { + ...opts, + maxAge: ACCESS_TOKEN_EXPIRY_SECONDS, + }); + response.cookies.set(COOKIE_NAME_REFRESH_TOKEN, tokens.refreshToken, { + ...opts, + maxAge: REFRESH_TOKEN_EXPIRY_SECONDS, + }); +} diff --git a/front_end/src/services/session.ts b/front_end/src/services/session.ts index 047cfdf8e2..5cb3324b35 100644 --- a/front_end/src/services/session.ts +++ b/front_end/src/services/session.ts @@ -1,11 +1,20 @@ import "server-only"; import { cookies } from "next/headers"; -export const COOKIE_NAME_TOKEN = "auth_token"; +import { + setAuthTokens, + clearAuthTokens, + getAccessToken, + getRefreshToken, + hasAuthSession, + AuthTokens, +} from "@/services/auth_tokens"; +import { AuthResponse } from "@/types/auth"; + export const COOKIE_NAME_DEV_TOKEN = "alpha_token"; export const COOKIE_NAME_IMPERSONATOR_TOKEN = "impersonator_token"; -export async function setServerCookie(name: string, value: string) { +async function setServerCookie(name: string, value: string) { const cookieStorage = await cookies(); cookieStorage.set(name, value, { httpOnly: true, @@ -15,24 +24,30 @@ export async function setServerCookie(name: string, value: string) { }); } -export async function setServerSession(auth_token: string) { - return setServerCookie(COOKIE_NAME_TOKEN, auth_token); +export async function setServerSession(response: AuthResponse): Promise { + await setAuthTokens({ + accessToken: response.access_token, + refreshToken: response.refresh_token, + }); } -export async function getServerSession() { - const cookieStorage = await cookies(); - const cookie = cookieStorage.get(COOKIE_NAME_TOKEN); +export async function setServerSessionWithTokens( + tokens: AuthTokens +): Promise { + await setAuthTokens(tokens); +} - return cookie?.value || null; +export async function getServerSession(): Promise { + return getAccessToken(); } export async function getImpersonatorSession() { const cookieStorage = await cookies(); const cookie = cookieStorage.get(COOKIE_NAME_IMPERSONATOR_TOKEN); - return cookie?.value || null; } +// TODO: !!! THIS DOES NOT WORK; FIX IT !!! export async function setImpersonatorSession(token: string) { return setServerCookie(COOKIE_NAME_IMPERSONATOR_TOKEN, token); } @@ -43,14 +58,12 @@ export async function deleteImpersonatorSession() { } export async function deleteServerSession() { - const cookieStorage = await cookies(); - cookieStorage.delete(COOKIE_NAME_TOKEN); + await clearAuthTokens(); } export async function getAlphaTokenSession() { const cookieStorage = await cookies(); const cookie = cookieStorage.get(COOKIE_NAME_DEV_TOKEN); - return cookie?.value || null; } diff --git a/front_end/src/types/auth.ts b/front_end/src/types/auth.ts index e2cdc9b201..f480291599 100644 --- a/front_end/src/types/auth.ts +++ b/front_end/src/types/auth.ts @@ -5,10 +5,13 @@ export type AuthContextType = { setUser: (user: CurrentUser | null) => void; }; -export type SocialAuthResponse = { - token: string; +type AuthTokenCredentials = { + access_token: string; + refresh_token: string; }; +export type SocialAuthResponse = AuthTokenCredentials; + export type SocialProviderType = "facebook" | "google-oauth2"; export type SocialProvider = { @@ -16,12 +19,11 @@ export type SocialProvider = { auth_url: string; }; -export type AuthResponse = { - token: string; +export type AuthResponse = AuthTokenCredentials & { user: CurrentUser; }; -export type SignUpResponse = AuthResponse & { +export type SignUpResponse = AuthTokenCredentials & { + user: CurrentUser; is_active: boolean; - token: string | null; }; diff --git a/front_end/src/utils/core/fetch/fetch.server.ts b/front_end/src/utils/core/fetch/fetch.server.ts index dda55d9043..cf285afd57 100644 --- a/front_end/src/utils/core/fetch/fetch.server.ts +++ b/front_end/src/utils/core/fetch/fetch.server.ts @@ -2,12 +2,73 @@ import "server-only"; import { getLocale } from "next-intl/server"; -import { getAlphaTokenSession, getServerSession } from "@/services/session"; -import { FetchOptions, FetchConfig } from "@/types/fetch"; +import { refreshAccessToken } from "@/services/auth_refresh"; +import { + getAccessToken, + getRefreshToken, + isAccessTokenExpired, +} from "@/services/auth_tokens"; +import { getAlphaTokenSession } from "@/services/session"; +import { FetchConfig, FetchOptions } from "@/types/fetch"; import { createFetcher, defaultOptions, handleResponse } from "./fetch.shared"; import { getPublicSettings } from "../../public_settings.server"; +async function fetchWithRefresh( + url: string, + options: FetchOptions, + config: { withNextJsNotFoundRedirect: boolean; passAuthHeader: boolean } +): Promise { + const { PUBLIC_API_BASE_URL, PUBLIC_AUTHENTICATION_REQUIRED } = + getPublicSettings(); + const shouldPassAuth = + config.passAuthHeader || PUBLIC_AUTHENTICATION_REQUIRED; + + // Proactive refresh: check expiration before making request + if (shouldPassAuth && (await isAccessTokenExpired())) { + const refreshToken = await getRefreshToken(); + if (refreshToken) { + await refreshAccessToken(); + } + } + + const buildHeaders = async (accessToken?: string): Promise => { + const token = + accessToken ?? (shouldPassAuth ? await getAccessToken() : null); + const alphaToken = await getAlphaTokenSession(); + + return { + ...options, + headers: { + ...options.headers, + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(alphaToken ? { "x-alpha-auth-token": alphaToken } : {}), + }, + }; + }; + + const finalUrl = `${PUBLIC_API_BASE_URL}/api${url}`; + + let requestOptions = await buildHeaders(); + let response = await fetch(finalUrl, requestOptions); + + // Fallback: retry on 401 (in case proactive check missed edge cases) + if (response.status === 401 && shouldPassAuth) { + const refreshToken = await getRefreshToken(); + if (refreshToken) { + const newTokens = await refreshAccessToken(); + if (newTokens) { + requestOptions = await buildHeaders(newTokens.accessToken); + response = await fetch(finalUrl, requestOptions); + } + } + } + + return handleResponse(response, { + withNextJsNotFoundRedirect: config.withNextJsNotFoundRedirect, + }); +} + const serverAppFetch = async ( url: string, options: FetchOptions = {}, @@ -25,29 +86,14 @@ const serverAppFetch = async ( passAuthHeader = false; } - const { PUBLIC_API_BASE_URL, PUBLIC_AUTHENTICATION_REQUIRED } = - getPublicSettings(); + const locale = forceLocale ?? (includeLocale ? await getLocale() : "en"); - const authToken = - passAuthHeader || PUBLIC_AUTHENTICATION_REQUIRED - ? await getServerSession() - : null; - const alphaToken = await getAlphaTokenSession(); - const locale = forceLocale - ? forceLocale - : includeLocale - ? await getLocale() - : "en"; - - const finalUrl = `${PUBLIC_API_BASE_URL}/api${url}`; const finalOptions: FetchOptions = { ...defaultOptions, ...options, headers: { ...defaultOptions.headers, ...options.headers, - ...(authToken ? { Authorization: `Token ${authToken}` } : {}), - ...(alphaToken ? { "x-alpha-auth-token": alphaToken } : {}), "Accept-Language": locale, }, }; @@ -60,9 +106,9 @@ const serverAppFetch = async ( delete finalOptions.headers["Content-Type"]; } - const response = await fetch(finalUrl, finalOptions); - return await handleResponse(response, { + return fetchWithRefresh(url, finalOptions, { withNextJsNotFoundRedirect: true, + passAuthHeader, }); }; From 196a8c2747832c328223c95deb2a03246c8b3250 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Wed, 24 Dec 2025 16:12:52 +0000 Subject: [PATCH 02/32] Remove redundant auth logic from the middleware --- front_end/src/middleware.ts | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/front_end/src/middleware.ts b/front_end/src/middleware.ts index 87727bb83f..d294f2f9ff 100644 --- a/front_end/src/middleware.ts +++ b/front_end/src/middleware.ts @@ -1,6 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import ServerAuthApi from "@/services/api/auth/auth.server"; import { COOKIE_NAME_ACCESS_TOKEN, COOKIE_NAME_REFRESH_TOKEN, @@ -11,7 +10,6 @@ import { } from "@/services/language_service"; import { getAlphaTokenSession } from "@/services/session"; import { getAlphaAccessToken } from "@/utils/alpha_access"; -import { ApiError } from "@/utils/core/errors"; import { getPublicSettings } from "@/utils/public_settings.server"; function hasAuthSession(request: NextRequest): boolean { @@ -25,7 +23,8 @@ export async function middleware(request: NextRequest) { const hasSession = hasAuthSession(request); const { PUBLIC_AUTHENTICATION_REQUIRED } = getPublicSettings(); - // if authentication is required, check for token + + // If authentication is required, redirect unauthenticated users if (PUBLIC_AUTHENTICATION_REQUIRED) { if ( !pathname.startsWith("/not-found/") && @@ -37,20 +36,8 @@ export async function middleware(request: NextRequest) { } } - let clearAuthCookies = false; - + // Check restricted alpha access if (hasSession) { - try { - await ServerAuthApi.verifyToken(); - } catch (error) { - if ((error as ApiError)?.response?.status === 403) { - request.cookies.delete(COOKIE_NAME_ACCESS_TOKEN); - request.cookies.delete(COOKIE_NAME_REFRESH_TOKEN); - clearAuthCookies = true; - } - } - - // Check restricted access token const alphaAccessToken = await getAlphaAccessToken(); const alphaAuthUrl = "/alpha-auth"; @@ -77,11 +64,6 @@ export async function middleware(request: NextRequest) { LanguageService.setLocaleCookieInResponse(response, locale_in_url); } - if (clearAuthCookies) { - response.cookies.delete(COOKIE_NAME_ACCESS_TOKEN); - response.cookies.delete(COOKIE_NAME_REFRESH_TOKEN); - } - return response; } From 96800d41f35775deaa48cb61b77a328888bda07b Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Wed, 24 Dec 2025 17:49:26 +0000 Subject: [PATCH 03/32] Added todo --- front_end/src/services/auth_tokens.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/front_end/src/services/auth_tokens.ts b/front_end/src/services/auth_tokens.ts index e6d4cb0b83..e92fe9b621 100644 --- a/front_end/src/services/auth_tokens.ts +++ b/front_end/src/services/auth_tokens.ts @@ -23,6 +23,7 @@ export async function setAuthTokens(tokens: AuthTokens): Promise { cookieStorage.set(COOKIE_NAME_ACCESS_TOKEN, tokens.accessToken, { httpOnly: true, secure: true, + // TODO: confirm it's LAX sameSite: "lax", maxAge: ACCESS_TOKEN_EXPIRY_SECONDS, path: "/", From ddce2ed1a48432b1c9296e9d26e92fb0f8ec5139 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Wed, 24 Dec 2025 18:07:27 +0000 Subject: [PATCH 04/32] Small fix --- front_end/src/services/api/api_service.ts | 5 +- .../src/services/api/auth/auth.server.ts | 46 ++++++++++++------- front_end/src/services/auth_refresh.ts | 13 ++++-- front_end/src/types/fetch.ts | 3 +- .../src/utils/core/fetch/fetch.shared.ts | 5 +- 5 files changed, 46 insertions(+), 26 deletions(-) diff --git a/front_end/src/services/api/api_service.ts b/front_end/src/services/api/api_service.ts index 4f93ff348c..b840d9b8bd 100644 --- a/front_end/src/services/api/api_service.ts +++ b/front_end/src/services/api/api_service.ts @@ -14,9 +14,10 @@ export abstract class ApiService { protected post>( url: string, body: B, - options?: FetchOptions + options?: FetchOptions, + config?: FetchConfig ): Promise { - return this.fetcher.post(url, body, options); + return this.fetcher.post(url, body, options, config); } protected put( diff --git a/front_end/src/services/api/auth/auth.server.ts b/front_end/src/services/api/auth/auth.server.ts index 290409188b..9e5e17c21e 100644 --- a/front_end/src/services/api/auth/auth.server.ts +++ b/front_end/src/services/api/auth/auth.server.ts @@ -52,10 +52,12 @@ class ServerAuthApiClass extends ApiService { return this.post< SocialAuthResponse, { code: string; redirect_uri: string } - >(`/auth/social/${provider}/`, { - code, - redirect_uri, - }); + >( + `/auth/social/${provider}/`, + { code, redirect_uri }, + {}, + { passAuthHeader: false } + ); } async resendActivationEmail(login: string, redirect_url: string) { @@ -71,27 +73,37 @@ class ServerAuthApiClass extends ApiService { async signIn(login: string, password: string) { return this.post( "/auth/login/token/", - { login, password } + { login, password }, + {}, + { passAuthHeader: false } ); } async signUp(props: SignUpProps, turnstileHeaders: HeadersInit) { - return this.post("/auth/signup/", props, { - headers: turnstileHeaders, - }); + return this.post( + "/auth/signup/", + props, + { headers: turnstileHeaders }, + { passAuthHeader: false } + ); } async activateAccount(userId: string, token: string) { return this.post( "/auth/signup/activate/", - { user_id: userId, token } + { user_id: userId, token }, + {}, + { passAuthHeader: false } ); } async passwordResetRequest(login: string) { - return this.post("/auth/password-reset/", { - login, - }); + return this.post( + "/auth/password-reset/", + { login }, + {}, + { passAuthHeader: false } + ); } async passwordResetVerifyToken(user_id: number, token: string) { @@ -107,9 +119,9 @@ class ServerAuthApiClass extends ApiService { ): Promise { return this.post( `/auth/password-reset/change/?user_id=${user_id}&token=${token}`, - { - password, - } + { password }, + {}, + { passAuthHeader: false } ); } @@ -125,7 +137,9 @@ class ServerAuthApiClass extends ApiService { ): Promise { return this.post( "/auth/signup/simplified/", - { username, auth_token } + { username, auth_token }, + {}, + { passAuthHeader: false } ); } } diff --git a/front_end/src/services/auth_refresh.ts b/front_end/src/services/auth_refresh.ts index e56132f1b1..dafcc849b5 100644 --- a/front_end/src/services/auth_refresh.ts +++ b/front_end/src/services/auth_refresh.ts @@ -3,7 +3,6 @@ import "server-only"; import { getRefreshToken, setAuthTokens, - clearAuthTokens, AuthTokens, } from "@/services/auth_tokens"; import { getPublicSettings } from "@/utils/public_settings.server"; @@ -34,7 +33,7 @@ export function refreshWithSingleFlight( const promise = fetch(`${PUBLIC_API_BASE_URL}/api/auth/refresh/`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refresh_token: refreshToken }), + body: JSON.stringify({ refresh: refreshToken }), }) .then(async (response) => { if (!response.ok) return null; @@ -71,9 +70,13 @@ export async function refreshAccessToken(): Promise { const tokens = await refreshWithSingleFlight(refreshToken); if (tokens) { - await setAuthTokens(tokens); - } else { - await clearAuthTokens(); + // Try to persist new tokens, but don't fail if we can't (e.g., during SSR) + try { + await setAuthTokens(tokens); + } catch { + // Cookie modification not allowed in this context (SSR) + // Tokens will still be returned and used for this request + } } return tokens; } diff --git a/front_end/src/types/fetch.ts b/front_end/src/types/fetch.ts index cb08533225..83c1280b52 100644 --- a/front_end/src/types/fetch.ts +++ b/front_end/src/types/fetch.ts @@ -10,7 +10,8 @@ export type Fetcher = { post>( url: string, body: B, - options?: FetchOptions + options?: FetchOptions, + config?: FetchConfig ): Promise; put(url: string, body: B, options?: FetchOptions): Promise; patch(url: string, body: B, options?: FetchOptions): Promise; diff --git a/front_end/src/utils/core/fetch/fetch.shared.ts b/front_end/src/utils/core/fetch/fetch.shared.ts index fc3d041262..83a17bfccd 100644 --- a/front_end/src/utils/core/fetch/fetch.shared.ts +++ b/front_end/src/utils/core/fetch/fetch.shared.ts @@ -87,7 +87,8 @@ export function createFetcher(fetchInitializer: FetchInitializer): Fetcher { const post = >( url: string, body: B, - options: FetchOptions = {} + options: FetchOptions = {}, + config?: FetchConfig ): Promise => { const isFormData = body instanceof FormData; @@ -98,7 +99,7 @@ export function createFetcher(fetchInitializer: FetchInitializer): Fetcher { method: "POST", body: isFormData ? body : JSON.stringify(body), }, - { emptyContentType: isFormData } + { emptyContentType: isFormData, ...config } ); }; From 5016aec083bab210b8375c1efdd10b4349a1446b Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Wed, 24 Dec 2025 18:08:31 +0000 Subject: [PATCH 05/32] Backend JWT integration --- authentication/services.py | 14 ++++++++++++++ authentication/urls.py | 2 ++ authentication/views/common.py | 27 +++++++++++++-------------- metaculus_web/settings.py | 16 +++++++++++++++- poetry.lock | 33 +++++++++++++++++++++++++++++---- pyproject.toml | 1 + 6 files changed, 74 insertions(+), 19 deletions(-) diff --git a/authentication/services.py b/authentication/services.py index 18231367b2..ac68ab56e7 100644 --- a/authentication/services.py +++ b/authentication/services.py @@ -3,6 +3,8 @@ from django.core.signing import TimestampSigner from django.utils.crypto import get_random_string from rest_framework.exceptions import ValidationError +from rest_framework_simplejwt.exceptions import AuthenticationFailed +from rest_framework_simplejwt.tokens import RefreshToken from users.models import User from utils.email import send_email_with_template @@ -123,3 +125,15 @@ def send_email(self, invited_by: User, email: str): }, from_email=settings.EMAIL_HOST_USER, ) + + +def get_tokens_for_user(user): + if not user.is_active: + raise AuthenticationFailed("User is not active") + + refresh = RefreshToken.for_user(user) + + return { + "refresh_token": str(refresh), + "access_token": str(refresh.access_token), + } diff --git a/authentication/urls.py b/authentication/urls.py index 78387d9ddd..cb60610e31 100644 --- a/authentication/urls.py +++ b/authentication/urls.py @@ -1,9 +1,11 @@ from django.urls import path +from rest_framework_simplejwt.views import TokenRefreshView from .views import common, social urlpatterns = [ path("auth/login/token/", common.login_api_view), + path('auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'), path("auth/verify_token/", common.verify_token_api_view), path("auth/signup/", common.signup_api_view, name="auth-signup"), path( diff --git a/authentication/views/common.py b/authentication/views/common.py index 4d765d4c6e..b76d31ce50 100644 --- a/authentication/views/common.py +++ b/authentication/views/common.py @@ -7,7 +7,6 @@ from django.db.models import Q from django.utils import timezone from rest_framework import status, serializers -from rest_framework.authtoken.models import Token from rest_framework.decorators import api_view, permission_classes from rest_framework.exceptions import ValidationError from rest_framework.permissions import AllowAny, IsAdminUser @@ -25,6 +24,7 @@ send_password_reset_email, check_password_reset, SignupInviteService, + get_tokens_for_user, ) from projects.models import ProjectUserPermission from projects.permissions import ObjectPermission @@ -57,9 +57,9 @@ def login_api_view(request): if not user: raise ValidationError({"password": ["incorrect login / password"]}) - token, _ = Token.objects.get_or_create(user=user) + tokens = get_tokens_for_user(user) - return Response({"token": token.key, "user": UserPrivateSerializer(user).data}) + return Response({**tokens, "user": UserPrivateSerializer(user).data}) @api_view(["POST"]) @@ -115,13 +115,12 @@ def signup_api_view(request): ) is_active = user.is_active - token = None + tokens = {} if is_active: # We need to treat this as login action, so we should call `authenticate` service as well user = authenticate(login=email, password=password) - token_obj, _ = Token.objects.get_or_create(user=user) - token = token_obj.key + tokens = get_tokens_for_user(user) if not is_active: send_activation_email(user, redirect_url) @@ -132,8 +131,8 @@ def signup_api_view(request): return Response( { "is_active": is_active, - "token": token, "user": UserPrivateSerializer(user).data, + **tokens, }, status=status.HTTP_201_CREATED, ) @@ -164,14 +163,14 @@ def signup_simplified_api_view(request): last_login=timezone.now(), ) - token_obj, _ = Token.objects.get_or_create(user=user) - token = token_obj.key + # Todo: figure out better format + tokens = get_tokens_for_user(user) return Response( { "is_active": user.is_active, - "token": token, "user": UserPrivateSerializer(user).data, + **tokens, }, status=status.HTTP_201_CREATED, ) @@ -205,9 +204,9 @@ def signup_activate_api_view(request): token = serializer.validated_data["token"] user = check_and_activate_user(user_id, token) - token, _ = Token.objects.get_or_create(user=user) + tokens = get_tokens_for_user(user) - return Response({"token": token.key, "user": UserPrivateSerializer(user).data}) + return Response({**tokens, "user": UserPrivateSerializer(user).data}) @api_view(["GET"]) @@ -248,9 +247,9 @@ def password_reset_confirm_api_view(request): user.set_password(password) user.save() - token, _ = Token.objects.get_or_create(user=user) + tokens = get_tokens_for_user(user) - return Response({"token": token.key, "user": UserPrivateSerializer(user).data}) + return Response({**tokens, "user": UserPrivateSerializer(user).data}) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/metaculus_web/settings.py b/metaculus_web/settings.py index 94ae2674c2..8e1496697b 100644 --- a/metaculus_web/settings.py +++ b/metaculus_web/settings.py @@ -13,6 +13,7 @@ import os import re import sys +from datetime import timedelta from pathlib import Path import dj_database_url @@ -147,6 +148,9 @@ REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework.authentication.SessionAuthentication", + # Primary auth mechanism for web users + "rest_framework_simplejwt.authentication.JWTAuthentication", + # Auth Token: should be used for bots only! "authentication.auth.FallbackTokenAuthentication", ], "DEFAULT_PERMISSION_CLASSES": [ @@ -158,6 +162,17 @@ "MAX_LIMIT": 100, } +# Simple JWT +# https://django-rest-framework-simplejwt.readthedocs.io/ +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(seconds=45), + "REFRESH_TOKEN_LIFETIME": timedelta(days=30), + "ROTATE_REFRESH_TOKENS": True, + "BLACKLIST_AFTER_ROTATION": True, + "CHECK_REVOKE_TOKEN": True, + "REVOKE_TOKEN_CLAIM": "hash", +} + # Password validation # https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators @@ -308,7 +323,6 @@ ], } - # Setting StubBroker broker for unit tests environment # Integration tests should run as the real env if IS_TEST_ENV: diff --git a/poetry.lock b/poetry.lock index 26ec9b48bc..cc03bfdd67 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -732,11 +732,11 @@ description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["main", "dev"] -markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "sys_platform == \"win32\" or platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} [[package]] name = "cryptography" @@ -1140,6 +1140,31 @@ files = [ [package.dependencies] django = ">=4.2" +[[package]] +name = "djangorestframework-simplejwt" +version = "5.5.1" +description = "A minimal JSON Web Token authentication plugin for Django REST Framework" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469"}, + {file = "djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f"}, +] + +[package.dependencies] +django = ">=4.2" +djangorestframework = ">=3.14" +pyjwt = ">=1.7.1" + +[package.extras] +crypto = ["cryptography (>=3.3.1)"] +dev = ["Sphinx", "cryptography", "freezegun", "ipython", "pre-commit", "pytest", "pytest-cov", "pytest-django", "pytest-watch", "pytest-xdist", "python-jose (==3.3.0)", "pyupgrade", "ruff", "sphinx_rtd_theme (>=0.1.9)", "tox", "twine", "wheel", "yesqa"] +doc = ["Sphinx", "sphinx_rtd_theme (>=0.1.9)"] +lint = ["pre-commit", "pyupgrade", "ruff", "yesqa"] +python-jose = ["python-jose (==3.3.0)"] +test = ["cryptography", "freezegun", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", "tox"] + [[package]] name = "docstring-parser" version = "0.16" @@ -3927,7 +3952,7 @@ files = [ {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, ] -markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\""} +markers = {main = "sys_platform == \"win32\" or platform_system == \"Windows\""} [[package]] name = "tzlocal" @@ -4092,4 +4117,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.1" python-versions = "3.12.3" -content-hash = "c863b7ba1d3bbf8b8b0307f99e32f0d577789425db4e01167a2e9c46f693c6ff" +content-hash = "e9d471b11a6dab03004aaba3528873500b6fef6e0171126d9c36a6d484cc31c4" diff --git a/pyproject.toml b/pyproject.toml index bdf6d8558f..bf40e884e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ instructor = "^1.7.7" pytz = "^2025.2" psycopg2 = "^2.9.10" scikit-learn = "^1.7.2" +djangorestframework-simplejwt = "^5.5.1" [tool.poetry.group.dev.dependencies] pytest = "^8.2.1" From 833cd605226e39ee0553798a30eada0139a3472e Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Thu, 25 Dec 2025 13:53:19 +0000 Subject: [PATCH 06/32] Improvements --- .../app/(api)/api-proxy/[...path]/route.ts | 4 +- front_end/src/app/(main)/accounts/actions.ts | 2 +- .../accounts/social/[provider]/actions.ts | 7 +- front_end/src/middleware.ts | 54 ++++++++++++- .../src/services/api/auth/auth.server.ts | 11 +++ front_end/src/services/auth_refresh.ts | 73 +++++------------- front_end/src/services/auth_tokens.ts | 77 +++++++++++-------- front_end/src/services/session.ts | 5 +- front_end/src/types/auth.ts | 16 ++-- .../src/utils/core/fetch/fetch.server.ts | 64 +++++---------- 10 files changed, 163 insertions(+), 150 deletions(-) diff --git a/front_end/src/app/(api)/api-proxy/[...path]/route.ts b/front_end/src/app/(api)/api-proxy/[...path]/route.ts index 0b6d4fef9e..8f6ba362b7 100644 --- a/front_end/src/app/(api)/api-proxy/[...path]/route.ts +++ b/front_end/src/app/(api)/api-proxy/[...path]/route.ts @@ -99,7 +99,7 @@ async function handleProxyRequest(request: NextRequest, method: string) { } } - let headers = await buildHeaders(refreshedTokens?.accessToken); + let headers = await buildHeaders(refreshedTokens?.access); let response = await fetch(targetUrl, { method, headers }); // Fallback: retry on 401 (in case proactive check missed edge cases) @@ -109,7 +109,7 @@ async function handleProxyRequest(request: NextRequest, method: string) { const newTokens = await refreshWithSingleFlight(refreshToken); if (newTokens) { refreshedTokens = newTokens; - headers = await buildHeaders(newTokens.accessToken); + headers = await buildHeaders(newTokens.access); response = await fetch(targetUrl, { method, headers }); } } diff --git a/front_end/src/app/(main)/accounts/actions.ts b/front_end/src/app/(main)/accounts/actions.ts index 2556798a94..be657b6c8e 100644 --- a/front_end/src/app/(main)/accounts/actions.ts +++ b/front_end/src/app/(main)/accounts/actions.ts @@ -135,7 +135,7 @@ export async function signUpAction( const signUpActionState: SignUpActionState = { ...response }; - if (response.is_active && response.access_token && response.refresh_token) { + if (response.is_active && response.tokens) { await setServerSession(response); // Set user's language preference as the active locale diff --git a/front_end/src/app/(main)/accounts/social/[provider]/actions.ts b/front_end/src/app/(main)/accounts/social/[provider]/actions.ts index 79c2c756c2..faee31cdf4 100644 --- a/front_end/src/app/(main)/accounts/social/[provider]/actions.ts +++ b/front_end/src/app/(main)/accounts/social/[provider]/actions.ts @@ -16,10 +16,7 @@ export async function exchangeSocialOauthCode( `${PUBLIC_APP_URL}/accounts/social/${provider}` ); - if (response) { - await setAuthTokens({ - accessToken: response.access_token, - refreshToken: response.refresh_token, - }); + if (response?.tokens) { + await setAuthTokens(response.tokens); } } diff --git a/front_end/src/middleware.ts b/front_end/src/middleware.ts index d294f2f9ff..4570cdb40b 100644 --- a/front_end/src/middleware.ts +++ b/front_end/src/middleware.ts @@ -1,8 +1,12 @@ import { NextRequest, NextResponse } from "next/server"; +import ServerAuthApi from "@/services/api/auth/auth.server"; import { + ACCESS_TOKEN_EXPIRY_SECONDS, COOKIE_NAME_ACCESS_TOKEN, COOKIE_NAME_REFRESH_TOKEN, + isTokenExpired, + REFRESH_TOKEN_EXPIRY_SECONDS, } from "@/services/auth_tokens"; import { LanguageService, @@ -18,6 +22,49 @@ function hasAuthSession(request: NextRequest): boolean { return !!(accessToken || refreshToken); } +/** + * Refresh tokens and apply new cookies to response + */ +async function refreshTokensIfNeeded( + request: NextRequest, + response: NextResponse +): Promise { + const accessToken = request.cookies.get(COOKIE_NAME_ACCESS_TOKEN)?.value; + const refreshToken = request.cookies.get(COOKIE_NAME_REFRESH_TOKEN)?.value; + + // No refresh token = can't refresh + if (!refreshToken) return; + + // Access token still valid = no refresh needed + if (!isTokenExpired(accessToken)) return; + + let tokens; + try { + tokens = await ServerAuthApi.refreshTokens(refreshToken); + } catch (error) { + console.error("Middleware token refresh failed:", error); + return; + } + + // Set new cookies on the response + const cookieOpts = { + httpOnly: true, + secure: true, + sameSite: "lax" as const, + path: "/", + }; + + response.cookies.set(COOKIE_NAME_ACCESS_TOKEN, tokens.access, { + ...cookieOpts, + maxAge: ACCESS_TOKEN_EXPIRY_SECONDS, + }); + + response.cookies.set(COOKIE_NAME_REFRESH_TOKEN, tokens.refresh, { + ...cookieOpts, + maxAge: REFRESH_TOKEN_EXPIRY_SECONDS, + }); +} + export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; const hasSession = hasAuthSession(request); @@ -58,6 +105,11 @@ export async function middleware(request: NextRequest) { const response = NextResponse.next({ request: { headers: requestHeaders } }); + // Proactive token refresh (MUST happen in middleware to persist cookies) + if (hasSession) { + await refreshTokensIfNeeded(request, response); + } + const locale_in_url = request.nextUrl.searchParams.get("locale"); const locale_in_cookie = request.cookies.get(LOCALE_COOKIE_NAME)?.value; if (locale_in_url && locale_in_url !== locale_in_cookie) { @@ -74,7 +126,7 @@ export const config = { // Ignores prefetch requests, all media files // And embedded urls source: - "/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|questions/embed|experiments/embed|opengraph-image-|twitter-image-|.*\\..*).*)", + "/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|questions/embed|experiments/embed|opengraph-image-|twitter-image-|app-version|.*\\..*).*)", missing: [ { type: "header", key: "next-router-prefetch" }, { type: "header", key: "purpose", value: "prefetch" }, diff --git a/front_end/src/services/api/auth/auth.server.ts b/front_end/src/services/api/auth/auth.server.ts index 9e5e17c21e..a6933e60f6 100644 --- a/front_end/src/services/api/auth/auth.server.ts +++ b/front_end/src/services/api/auth/auth.server.ts @@ -1,5 +1,7 @@ import "server-only"; + import { ApiService } from "@/services/api/api_service"; +import { AuthTokens } from "@/types/auth"; import { AuthResponse, SignUpResponse, @@ -24,6 +26,15 @@ export type SignUpProps = { }; class ServerAuthApiClass extends ApiService { + async refreshTokens(refreshToken: string) { + return this.post( + "/auth/refresh/", + { refresh: refreshToken }, + {}, + { passAuthHeader: false } + ); + } + async getSocialProviders(redirect_uri: string): Promise { try { return await this.get( diff --git a/front_end/src/services/auth_refresh.ts b/front_end/src/services/auth_refresh.ts index dafcc849b5..0f1c2fb4e6 100644 --- a/front_end/src/services/auth_refresh.ts +++ b/front_end/src/services/auth_refresh.ts @@ -1,52 +1,35 @@ import "server-only"; -import { - getRefreshToken, - setAuthTokens, - AuthTokens, -} from "@/services/auth_tokens"; -import { getPublicSettings } from "@/utils/public_settings.server"; - -export type RefreshTokenResponse = { - access_token: string; - refresh_token: string; -}; +import ServerAuthApi from "@/services/api/auth/auth.server"; +import { type AuthTokens } from "@/services/auth_tokens"; /** - * Single-flight refresh manager. - * Concurrent requests share ONE refresh call. Cleanup after 100ms. + * Perform the actual token refresh API call. */ -const inFlightRefreshes = new Map>(); +async function doRefresh(refreshToken: string): Promise { + try { + return await ServerAuthApi.refreshTokens(refreshToken); + } catch (error) { + console.error("Token refresh failed:", error); + return null; + } +} /** - * Refresh tokens with single-flight pattern. - * Can be used by both server fetcher and api-proxy. + * Single-flight refresh for Route Handlers (api-proxy). + * Uses Map-based deduplication for concurrent requests. + * + * Note: SSR refresh is handled by middleware, not here. */ +const inFlightRefreshes = new Map>(); + export function refreshWithSingleFlight( refreshToken: string ): Promise { const existing = inFlightRefreshes.get(refreshToken); if (existing) return existing; - const { PUBLIC_API_BASE_URL } = getPublicSettings(); - - const promise = fetch(`${PUBLIC_API_BASE_URL}/api/auth/refresh/`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refresh: refreshToken }), - }) - .then(async (response) => { - if (!response.ok) return null; - const data: RefreshTokenResponse = await response.json(); - return { - accessToken: data.access_token, - refreshToken: data.refresh_token, - }; - }) - .catch((error) => { - console.error("Token refresh failed:", error); - return null; - }); + const promise = doRefresh(refreshToken); inFlightRefreshes.set(refreshToken, promise); @@ -60,23 +43,3 @@ export function refreshWithSingleFlight( return promise; } - -/** - * Refresh and persist tokens. Used by server-side fetcher. - */ -export async function refreshAccessToken(): Promise { - const refreshToken = await getRefreshToken(); - if (!refreshToken) return null; - - const tokens = await refreshWithSingleFlight(refreshToken); - if (tokens) { - // Try to persist new tokens, but don't fail if we can't (e.g., during SSR) - try { - await setAuthTokens(tokens); - } catch { - // Cookie modification not allowed in this context (SSR) - // Tokens will still be returned and used for this request - } - } - return tokens; -} diff --git a/front_end/src/services/auth_tokens.ts b/front_end/src/services/auth_tokens.ts index e92fe9b621..312a5384dd 100644 --- a/front_end/src/services/auth_tokens.ts +++ b/front_end/src/services/auth_tokens.ts @@ -2,17 +2,52 @@ import "server-only"; import { cookies } from "next/headers"; +import { AuthTokens } from "@/types/auth"; + +// Re-export for convenience +export type { AuthTokens } from "@/types/auth"; + +// Constants export const COOKIE_NAME_ACCESS_TOKEN = "metaculus_access_token"; export const COOKIE_NAME_REFRESH_TOKEN = "metaculus_refresh_token"; // Token expiration times (should match backend) export const ACCESS_TOKEN_EXPIRY_SECONDS = 15 * 60; // 15 minutes export const REFRESH_TOKEN_EXPIRY_SECONDS = 30 * 24 * 60 * 60; // 30 days +export const REFRESH_BUFFER_SECONDS = 30; // Refresh if expiring within 30s -export type AuthTokens = { - accessToken: string; - refreshToken: string; -}; +/** + * Decode JWT payload without verification (we just need expiration time) + */ +export function decodeJWTPayload(token: string): { exp?: number } | null { + try { + const [, payload] = token.split("."); + if (!payload) return null; + const decoded = Buffer.from(payload, "base64url").toString("utf-8"); + return JSON.parse(decoded); + } catch { + return null; + } +} + +/** + * Check if a token is expired or about to expire + */ +export function isTokenExpired( + token: string | undefined, + bufferSeconds: number = REFRESH_BUFFER_SECONDS +): boolean { + if (!token) return true; + + const payload = decodeJWTPayload(token); + if (!payload?.exp) return false; // Can't determine, assume not expired + + const expiresAt = payload.exp * 1000; + const now = Date.now(); + const bufferMs = bufferSeconds * 1000; + + return now >= expiresAt - bufferMs; +} /** * Set both access and refresh tokens in httpOnly cookies @@ -20,16 +55,15 @@ export type AuthTokens = { export async function setAuthTokens(tokens: AuthTokens): Promise { const cookieStorage = await cookies(); - cookieStorage.set(COOKIE_NAME_ACCESS_TOKEN, tokens.accessToken, { + cookieStorage.set(COOKIE_NAME_ACCESS_TOKEN, tokens.access, { httpOnly: true, secure: true, - // TODO: confirm it's LAX sameSite: "lax", maxAge: ACCESS_TOKEN_EXPIRY_SECONDS, path: "/", }); - cookieStorage.set(COOKIE_NAME_REFRESH_TOKEN, tokens.refreshToken, { + cookieStorage.set(COOKIE_NAME_REFRESH_TOKEN, tokens.refresh, { httpOnly: true, secure: true, sameSite: "lax", @@ -43,20 +77,6 @@ export async function getAccessToken(): Promise { return cookieStorage.get(COOKIE_NAME_ACCESS_TOKEN)?.value || null; } -/** - * Decode JWT payload without verification (we just need expiration time) - */ -function decodeJWTPayload(token: string): { exp?: number } | null { - try { - const [, payload] = token.split("."); - if (!payload) return null; - const decoded = Buffer.from(payload, "base64url").toString("utf-8"); - return JSON.parse(decoded); - } catch { - return null; - } -} - /** * Check if access token is expired or about to expire * @param bufferSeconds - refresh if expiring within this many seconds (default: 30) @@ -65,16 +85,7 @@ export async function isAccessTokenExpired( bufferSeconds: number = 30 ): Promise { const token = await getAccessToken(); - if (!token) return true; - - const payload = decodeJWTPayload(token); - if (!payload?.exp) return false; // Can't determine, assume not expired - - const expiresAt = payload.exp * 1000; // Convert to milliseconds - const now = Date.now(); - const bufferMs = bufferSeconds * 1000; - - return now >= expiresAt - bufferMs; + return isTokenExpired(token ?? undefined, bufferSeconds); } export async function getRefreshToken(): Promise { @@ -109,11 +120,11 @@ export function applyTokenCookiesToResponse( sameSite: "lax" as const, path: "/", }; - response.cookies.set(COOKIE_NAME_ACCESS_TOKEN, tokens.accessToken, { + response.cookies.set(COOKIE_NAME_ACCESS_TOKEN, tokens.access, { ...opts, maxAge: ACCESS_TOKEN_EXPIRY_SECONDS, }); - response.cookies.set(COOKIE_NAME_REFRESH_TOKEN, tokens.refreshToken, { + response.cookies.set(COOKIE_NAME_REFRESH_TOKEN, tokens.refresh, { ...opts, maxAge: REFRESH_TOKEN_EXPIRY_SECONDS, }); diff --git a/front_end/src/services/session.ts b/front_end/src/services/session.ts index 5cb3324b35..808599a5a4 100644 --- a/front_end/src/services/session.ts +++ b/front_end/src/services/session.ts @@ -25,10 +25,7 @@ async function setServerCookie(name: string, value: string) { } export async function setServerSession(response: AuthResponse): Promise { - await setAuthTokens({ - accessToken: response.access_token, - refreshToken: response.refresh_token, - }); + await setAuthTokens(response.tokens); } export async function setServerSessionWithTokens( diff --git a/front_end/src/types/auth.ts b/front_end/src/types/auth.ts index f480291599..a5a994c71b 100644 --- a/front_end/src/types/auth.ts +++ b/front_end/src/types/auth.ts @@ -5,12 +5,14 @@ export type AuthContextType = { setUser: (user: CurrentUser | null) => void; }; -type AuthTokenCredentials = { - access_token: string; - refresh_token: string; +export type AuthTokens = { + access: string; + refresh: string; }; -export type SocialAuthResponse = AuthTokenCredentials; +export type SocialAuthResponse = { + tokens: AuthTokens; +}; export type SocialProviderType = "facebook" | "google-oauth2"; @@ -19,11 +21,13 @@ export type SocialProvider = { auth_url: string; }; -export type AuthResponse = AuthTokenCredentials & { +export type AuthResponse = { + tokens: AuthTokens; user: CurrentUser; }; -export type SignUpResponse = AuthTokenCredentials & { +export type SignUpResponse = { + tokens: AuthTokens; user: CurrentUser; is_active: boolean; }; diff --git a/front_end/src/utils/core/fetch/fetch.server.ts b/front_end/src/utils/core/fetch/fetch.server.ts index cf285afd57..e62f7413a6 100644 --- a/front_end/src/utils/core/fetch/fetch.server.ts +++ b/front_end/src/utils/core/fetch/fetch.server.ts @@ -2,19 +2,22 @@ import "server-only"; import { getLocale } from "next-intl/server"; -import { refreshAccessToken } from "@/services/auth_refresh"; -import { - getAccessToken, - getRefreshToken, - isAccessTokenExpired, -} from "@/services/auth_tokens"; +import { getAccessToken } from "@/services/auth_tokens"; import { getAlphaTokenSession } from "@/services/session"; import { FetchConfig, FetchOptions } from "@/types/fetch"; import { createFetcher, defaultOptions, handleResponse } from "./fetch.shared"; import { getPublicSettings } from "../../public_settings.server"; -async function fetchWithRefresh( +/** + * Server-side fetch for API calls. + * + * Token refresh is handled by middleware BEFORE this runs. + * If we get a 401 here, it means the token is invalid (not just expired) + * and we should not attempt refresh during SSR (would invalidate tokens + * without being able to persist new ones). + */ +async function serverFetch( url: string, options: FetchOptions, config: { withNextJsNotFoundRedirect: boolean; passAuthHeader: boolean } @@ -24,45 +27,20 @@ async function fetchWithRefresh( const shouldPassAuth = config.passAuthHeader || PUBLIC_AUTHENTICATION_REQUIRED; - // Proactive refresh: check expiration before making request - if (shouldPassAuth && (await isAccessTokenExpired())) { - const refreshToken = await getRefreshToken(); - if (refreshToken) { - await refreshAccessToken(); - } - } - - const buildHeaders = async (accessToken?: string): Promise => { - const token = - accessToken ?? (shouldPassAuth ? await getAccessToken() : null); - const alphaToken = await getAlphaTokenSession(); + const token = shouldPassAuth ? await getAccessToken() : null; + const alphaToken = await getAlphaTokenSession(); - return { - ...options, - headers: { - ...options.headers, - ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...(alphaToken ? { "x-alpha-auth-token": alphaToken } : {}), - }, - }; + const requestOptions: FetchOptions = { + ...options, + headers: { + ...options.headers, + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(alphaToken ? { "x-alpha-auth-token": alphaToken } : {}), + }, }; const finalUrl = `${PUBLIC_API_BASE_URL}/api${url}`; - - let requestOptions = await buildHeaders(); - let response = await fetch(finalUrl, requestOptions); - - // Fallback: retry on 401 (in case proactive check missed edge cases) - if (response.status === 401 && shouldPassAuth) { - const refreshToken = await getRefreshToken(); - if (refreshToken) { - const newTokens = await refreshAccessToken(); - if (newTokens) { - requestOptions = await buildHeaders(newTokens.accessToken); - response = await fetch(finalUrl, requestOptions); - } - } - } + const response = await fetch(finalUrl, requestOptions); return handleResponse(response, { withNextJsNotFoundRedirect: config.withNextJsNotFoundRedirect, @@ -106,7 +84,7 @@ const serverAppFetch = async ( delete finalOptions.headers["Content-Type"]; } - return fetchWithRefresh(url, finalOptions, { + return serverFetch(url, finalOptions, { withNextJsNotFoundRedirect: true, passAuthHeader, }); From 498aaf9e822054213266dce5590294c2c9978b59 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Thu, 25 Dec 2025 14:12:05 +0000 Subject: [PATCH 07/32] Bump server refresh lock time --- front_end/src/services/auth_refresh.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front_end/src/services/auth_refresh.ts b/front_end/src/services/auth_refresh.ts index 0f1c2fb4e6..2435821212 100644 --- a/front_end/src/services/auth_refresh.ts +++ b/front_end/src/services/auth_refresh.ts @@ -38,7 +38,7 @@ export function refreshWithSingleFlight( if (inFlightRefreshes.get(refreshToken) === promise) { inFlightRefreshes.delete(refreshToken); } - }, 100); + }, 10_000); }); return promise; From 0246f27a1cf4d89ac7cd21834276ea493a5e88f8 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Tue, 13 Jan 2026 17:10:59 +0000 Subject: [PATCH 08/32] Bot JWT token endpoint --- users/urls.py | 5 +++++ users/views.py | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/users/urls.py b/users/urls.py index eec4d0dadd..c592fd0713 100644 --- a/users/urls.py +++ b/users/urls.py @@ -59,4 +59,9 @@ views.bot_token_api_view, name="reveal-bot-token", ), + path( + "users/me/bots//jwt/", + views.bot_jwt_api_view, + name="reveal-bot-jwt", + ), ] diff --git a/users/views.py b/users/views.py index 96094c0399..26d365767c 100644 --- a/users/views.py +++ b/users/views.py @@ -12,6 +12,7 @@ from rest_framework.request import Request from rest_framework.response import Response +from authentication.services import get_tokens_for_user from users.models import User, UserSpamActivity from users.serializers import ( UserPrivateSerializer, @@ -287,3 +288,21 @@ def bot_token_api_view(request: Request, pk: int): token, _ = Token.objects.get_or_create(user=bot) return Response({"token": token.key}) + + +@api_view(["POST"]) +def bot_jwt_api_view(request: Request, pk: int): + """ + Get JWT tokens to impersonate a bot account. + Returns access and refresh tokens for the bot. + """ + + bot = get_object_or_404(get_user_bots(request.user), pk=pk) + tokens = get_tokens_for_user(bot) + + return Response( + { + "access": tokens["access_token"], + "refresh": tokens["refresh_token"], + } + ) From e11fa90721765b9217b5bb4ad97e2371980f3d2c Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Tue, 13 Jan 2026 17:11:32 +0000 Subject: [PATCH 09/32] Bot JWT impersonation frontend --- .../app/(main)/accounts/settings/actions.tsx | 28 +++++++++++-------- front_end/src/app/(main)/layout.tsx | 6 ++-- .../services/api/profile/profile.server.ts | 5 ++++ front_end/src/services/session.ts | 28 ++++++++++++------- 4 files changed, 42 insertions(+), 25 deletions(-) diff --git a/front_end/src/app/(main)/accounts/settings/actions.tsx b/front_end/src/app/(main)/accounts/settings/actions.tsx index cccd10cc35..96d2c38f18 100644 --- a/front_end/src/app/(main)/accounts/settings/actions.tsx +++ b/front_end/src/app/(main)/accounts/settings/actions.tsx @@ -3,13 +3,14 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; +import ServerAuthApi from "@/services/api/auth/auth.server"; import ServerProfileApi from "@/services/api/profile/profile.server"; +import { getRefreshToken } from "@/services/auth_tokens"; import { deleteImpersonatorSession, - getImpersonatorSession, - getServerSession, - setImpersonatorSession, - setServerSession, + getImpersonatorRefreshToken, + setImpersonatorRefreshToken, + setServerSessionWithTokens, } from "@/services/session"; import { ApiError } from "@/utils/core/errors"; @@ -117,10 +118,13 @@ export async function getBotTokenAction(botId: number) { } export async function stopImpersonatingAction() { - const impersonatorToken = await getImpersonatorSession(); + const impersonatorRefreshToken = await getImpersonatorRefreshToken(); - if (impersonatorToken) { - await setServerSession(impersonatorToken); + if (impersonatorRefreshToken) { + const tokens = await ServerAuthApi.refreshTokens(impersonatorRefreshToken); + if (tokens) { + await setServerSessionWithTokens(tokens); + } await deleteImpersonatorSession(); } @@ -129,14 +133,14 @@ export async function stopImpersonatingAction() { export async function impersonateBotAction(botId: number) { try { - const userToken = await getServerSession(); - const { token: botToken } = await ServerProfileApi.getBotToken(botId); + const userRefreshToken = await getRefreshToken(); + const botTokens = await ServerProfileApi.getBotJwt(botId); - if (userToken) { - await setImpersonatorSession(userToken); + if (userRefreshToken) { + await setImpersonatorRefreshToken(userRefreshToken); } - await setServerSession(botToken); + await setServerSessionWithTokens(botTokens); redirect("/"); } catch (err) { diff --git a/front_end/src/app/(main)/layout.tsx b/front_end/src/app/(main)/layout.tsx index 014035924c..4a4f22e077 100644 --- a/front_end/src/app/(main)/layout.tsx +++ b/front_end/src/app/(main)/layout.tsx @@ -3,7 +3,7 @@ import "@fortawesome/fontawesome-svg-core/styles.css"; import type { Metadata } from "next"; import { defaultDescription } from "@/constants/metadata"; -import { getImpersonatorSession } from "@/services/session"; +import { getImpersonatorRefreshToken } from "@/services/session"; import { getPublicSettings } from "@/utils/public_settings.server"; import FeedbackFloat from "./(home)/components/feedback_float"; @@ -28,13 +28,13 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { - const impersonatorToken = await getImpersonatorSession(); + const impersonatorRefreshToken = await getImpersonatorRefreshToken(); return (
- {impersonatorToken && } + {impersonatorRefreshToken && }
{children}
diff --git a/front_end/src/services/api/profile/profile.server.ts b/front_end/src/services/api/profile/profile.server.ts index aae0b08cf8..d052abe5ea 100644 --- a/front_end/src/services/api/profile/profile.server.ts +++ b/front_end/src/services/api/profile/profile.server.ts @@ -1,5 +1,6 @@ import "server-only"; import { getServerSession } from "@/services/session"; +import { AuthTokens } from "@/types/auth"; import { SubscriptionEmailType } from "@/types/notifications"; import { ProfilePreferencesType } from "@/types/preferences"; import { CurrentBot, CurrentUser } from "@/types/users"; @@ -114,6 +115,10 @@ class ServerProfileApiClass extends ProfileApi { async getBotToken(botId: number) { return await this.get<{ token: string }>(`/users/me/bots/${botId}/token/`); } + + async getBotJwt(botId: number) { + return await this.post(`/users/me/bots/${botId}/jwt/`, {}); + } } const ServerProfileApi = new ServerProfileApiClass(serverFetcher); diff --git a/front_end/src/services/session.ts b/front_end/src/services/session.ts index 808599a5a4..0a61cac4e0 100644 --- a/front_end/src/services/session.ts +++ b/front_end/src/services/session.ts @@ -5,14 +5,14 @@ import { setAuthTokens, clearAuthTokens, getAccessToken, - getRefreshToken, - hasAuthSession, AuthTokens, + REFRESH_TOKEN_EXPIRY_SECONDS, } from "@/services/auth_tokens"; import { AuthResponse } from "@/types/auth"; export const COOKIE_NAME_DEV_TOKEN = "alpha_token"; -export const COOKIE_NAME_IMPERSONATOR_TOKEN = "impersonator_token"; +export const COOKIE_NAME_IMPERSONATOR_REFRESH_TOKEN = + "impersonator_refresh_token"; async function setServerCookie(name: string, value: string) { const cookieStorage = await cookies(); @@ -38,20 +38,28 @@ export async function getServerSession(): Promise { return getAccessToken(); } -export async function getImpersonatorSession() { +export async function getImpersonatorRefreshToken(): Promise { const cookieStorage = await cookies(); - const cookie = cookieStorage.get(COOKIE_NAME_IMPERSONATOR_TOKEN); + const cookie = cookieStorage.get(COOKIE_NAME_IMPERSONATOR_REFRESH_TOKEN); return cookie?.value || null; } -// TODO: !!! THIS DOES NOT WORK; FIX IT !!! -export async function setImpersonatorSession(token: string) { - return setServerCookie(COOKIE_NAME_IMPERSONATOR_TOKEN, token); +export async function setImpersonatorRefreshToken( + refreshToken: string +): Promise { + const cookieStorage = await cookies(); + cookieStorage.set(COOKIE_NAME_IMPERSONATOR_REFRESH_TOKEN, refreshToken, { + httpOnly: true, + secure: true, + sameSite: "lax", + maxAge: REFRESH_TOKEN_EXPIRY_SECONDS, + path: "/", + }); } -export async function deleteImpersonatorSession() { +export async function deleteImpersonatorSession(): Promise { const cookieStorage = await cookies(); - cookieStorage.delete(COOKIE_NAME_IMPERSONATOR_TOKEN); + cookieStorage.delete(COOKIE_NAME_IMPERSONATOR_REFRESH_TOKEN); } export async function deleteServerSession() { From 4ca5ea332fbbcf54d8f734bebbe2102a8d07d029 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Tue, 13 Jan 2026 17:27:52 +0000 Subject: [PATCH 10/32] JWT token format improvements --- authentication/services.py | 4 ++-- authentication/views/common.py | 12 ++++++------ users/views.py | 7 +------ 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/authentication/services.py b/authentication/services.py index ac68ab56e7..900483c406 100644 --- a/authentication/services.py +++ b/authentication/services.py @@ -134,6 +134,6 @@ def get_tokens_for_user(user): refresh = RefreshToken.for_user(user) return { - "refresh_token": str(refresh), - "access_token": str(refresh.access_token), + "refresh": str(refresh), + "access": str(refresh.access_token), } diff --git a/authentication/views/common.py b/authentication/views/common.py index b76d31ce50..25708aad0a 100644 --- a/authentication/views/common.py +++ b/authentication/views/common.py @@ -59,7 +59,7 @@ def login_api_view(request): tokens = get_tokens_for_user(user) - return Response({**tokens, "user": UserPrivateSerializer(user).data}) + return Response({"tokens": tokens, "user": UserPrivateSerializer(user).data}) @api_view(["POST"]) @@ -115,7 +115,7 @@ def signup_api_view(request): ) is_active = user.is_active - tokens = {} + tokens = None if is_active: # We need to treat this as login action, so we should call `authenticate` service as well @@ -132,7 +132,7 @@ def signup_api_view(request): { "is_active": is_active, "user": UserPrivateSerializer(user).data, - **tokens, + "tokens": tokens, }, status=status.HTTP_201_CREATED, ) @@ -170,7 +170,7 @@ def signup_simplified_api_view(request): { "is_active": user.is_active, "user": UserPrivateSerializer(user).data, - **tokens, + "tokens": tokens, }, status=status.HTTP_201_CREATED, ) @@ -206,7 +206,7 @@ def signup_activate_api_view(request): user = check_and_activate_user(user_id, token) tokens = get_tokens_for_user(user) - return Response({**tokens, "user": UserPrivateSerializer(user).data}) + return Response({"tokens": tokens, "user": UserPrivateSerializer(user).data}) @api_view(["GET"]) @@ -249,7 +249,7 @@ def password_reset_confirm_api_view(request): tokens = get_tokens_for_user(user) - return Response({**tokens, "user": UserPrivateSerializer(user).data}) + return Response({"tokens": tokens, "user": UserPrivateSerializer(user).data}) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/users/views.py b/users/views.py index 26d365767c..85ea4eebcb 100644 --- a/users/views.py +++ b/users/views.py @@ -300,9 +300,4 @@ def bot_jwt_api_view(request: Request, pk: int): bot = get_object_or_404(get_user_bots(request.user), pk=pk) tokens = get_tokens_for_user(bot) - return Response( - { - "access": tokens["access_token"], - "refresh": tokens["refresh_token"], - } - ) + return Response(tokens) From 9bd2292f9f20ab30771dd18e9dda54ab0437dd0a Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Tue, 13 Jan 2026 20:18:03 +0000 Subject: [PATCH 11/32] Small fix --- metaculus_web/settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/metaculus_web/settings.py b/metaculus_web/settings.py index 8e1496697b..6f9aedf401 100644 --- a/metaculus_web/settings.py +++ b/metaculus_web/settings.py @@ -165,6 +165,7 @@ # Simple JWT # https://django-rest-framework-simplejwt.readthedocs.io/ SIMPLE_JWT = { + # TODO: adjust this "ACCESS_TOKEN_LIFETIME": timedelta(seconds=45), "REFRESH_TOKEN_LIFETIME": timedelta(days=30), "ROTATE_REFRESH_TOKENS": True, From 2cee0b5a0cd84dc1dcfe46b865413fba1504d3d8 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Tue, 13 Jan 2026 20:29:58 +0000 Subject: [PATCH 12/32] Frontend refactoring --- .../app/(api)/api-proxy/[...path]/route.ts | 23 ++- .../app/(main)/accounts/settings/actions.tsx | 5 +- .../accounts/social/[provider]/actions.ts | 5 +- front_end/src/middleware.ts | 71 +++++----- front_end/src/services/auth_tokens.ts | 134 +++++++++--------- front_end/src/services/session.ts | 16 ++- .../src/utils/core/fetch/fetch.server.ts | 5 +- 7 files changed, 131 insertions(+), 128 deletions(-) diff --git a/front_end/src/app/(api)/api-proxy/[...path]/route.ts b/front_end/src/app/(api)/api-proxy/[...path]/route.ts index 8f6ba362b7..f061d5b2b5 100644 --- a/front_end/src/app/(api)/api-proxy/[...path]/route.ts +++ b/front_end/src/app/(api)/api-proxy/[...path]/route.ts @@ -3,11 +3,9 @@ import { getLocale } from "next-intl/server"; import { refreshWithSingleFlight } from "@/services/auth_refresh"; import { - applyTokenCookiesToResponse, + AuthCookieManager, AuthTokens, - getAccessToken, - getRefreshToken, - isAccessTokenExpired, + getAuthCookieManager, } from "@/services/auth_tokens"; import { getAlphaTokenSession } from "@/services/session"; import { getPublicSettings } from "@/utils/public_settings.server"; @@ -42,6 +40,7 @@ async function handleProxyRequest(request: NextRequest, method: string) { const includeLocale = request.headers.get("x-include-locale") !== "false"; const shouldPassAuth = passAuthHeader || PUBLIC_AUTHENTICATION_REQUIRED; + const authManager = await getAuthCookieManager(); const alphaToken = await getAlphaTokenSession(); const locale = includeLocale ? await getLocale() : "en"; @@ -65,7 +64,7 @@ async function handleProxyRequest(request: NextRequest, method: string) { "x-include-locale", ]; - const buildHeaders = async (accessToken?: string): Promise => { + const buildHeaders = (accessToken?: string): Headers => { const headers: HeadersInit = new Headers({ ...Object.fromEntries( Array.from(request.headers.entries()).filter( @@ -79,7 +78,7 @@ async function handleProxyRequest(request: NextRequest, method: string) { if (emptyContentType) headers.delete("Content-Type"); const token = - accessToken ?? (shouldPassAuth ? await getAccessToken() : null); + accessToken ?? (shouldPassAuth ? authManager.getAccessToken() : null); if (token) headers.set("Authorization", `Bearer ${token}`); if (alphaToken) headers.set("x-alpha-auth-token", alphaToken); @@ -89,8 +88,8 @@ async function handleProxyRequest(request: NextRequest, method: string) { let refreshedTokens: AuthTokens | null = null; // Proactive refresh: check expiration before making request - if (shouldPassAuth && (await isAccessTokenExpired())) { - const refreshToken = await getRefreshToken(); + if (shouldPassAuth && authManager.isAccessTokenExpired()) { + const refreshToken = authManager.getRefreshToken(); if (refreshToken) { const newTokens = await refreshWithSingleFlight(refreshToken); if (newTokens) { @@ -99,17 +98,17 @@ async function handleProxyRequest(request: NextRequest, method: string) { } } - let headers = await buildHeaders(refreshedTokens?.access); + let headers = buildHeaders(refreshedTokens?.access); let response = await fetch(targetUrl, { method, headers }); // Fallback: retry on 401 (in case proactive check missed edge cases) if (response.status === 401 && shouldPassAuth) { - const refreshToken = await getRefreshToken(); + const refreshToken = authManager.getRefreshToken(); if (refreshToken) { const newTokens = await refreshWithSingleFlight(refreshToken); if (newTokens) { refreshedTokens = newTokens; - headers = await buildHeaders(newTokens.access); + headers = buildHeaders(newTokens.access); response = await fetch(targetUrl, { method, headers }); } } @@ -134,7 +133,7 @@ async function handleProxyRequest(request: NextRequest, method: string) { }); if (refreshedTokens) { - applyTokenCookiesToResponse(nextResponse, refreshedTokens); + new AuthCookieManager(nextResponse.cookies).setAuthTokens(refreshedTokens); } return nextResponse; diff --git a/front_end/src/app/(main)/accounts/settings/actions.tsx b/front_end/src/app/(main)/accounts/settings/actions.tsx index 96d2c38f18..e7e7453046 100644 --- a/front_end/src/app/(main)/accounts/settings/actions.tsx +++ b/front_end/src/app/(main)/accounts/settings/actions.tsx @@ -5,7 +5,7 @@ import { redirect } from "next/navigation"; import ServerAuthApi from "@/services/api/auth/auth.server"; import ServerProfileApi from "@/services/api/profile/profile.server"; -import { getRefreshToken } from "@/services/auth_tokens"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import { deleteImpersonatorSession, getImpersonatorRefreshToken, @@ -133,7 +133,8 @@ export async function stopImpersonatingAction() { export async function impersonateBotAction(botId: number) { try { - const userRefreshToken = await getRefreshToken(); + const authManager = await getAuthCookieManager(); + const userRefreshToken = authManager.getRefreshToken(); const botTokens = await ServerProfileApi.getBotJwt(botId); if (userRefreshToken) { diff --git a/front_end/src/app/(main)/accounts/social/[provider]/actions.ts b/front_end/src/app/(main)/accounts/social/[provider]/actions.ts index faee31cdf4..d9ec06de2c 100644 --- a/front_end/src/app/(main)/accounts/social/[provider]/actions.ts +++ b/front_end/src/app/(main)/accounts/social/[provider]/actions.ts @@ -1,7 +1,7 @@ "use server"; import ServerAuthApi from "@/services/api/auth/auth.server"; -import { setAuthTokens } from "@/services/auth_tokens"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import { SocialProviderType } from "@/types/auth"; import { getPublicSettings } from "@/utils/public_settings.server"; @@ -17,6 +17,7 @@ export async function exchangeSocialOauthCode( ); if (response?.tokens) { - await setAuthTokens(response.tokens); + const authManager = await getAuthCookieManager(); + authManager.setAuthTokens(response.tokens); } } diff --git a/front_end/src/middleware.ts b/front_end/src/middleware.ts index 4570cdb40b..1cf399108c 100644 --- a/front_end/src/middleware.ts +++ b/front_end/src/middleware.ts @@ -1,13 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import ServerAuthApi from "@/services/api/auth/auth.server"; -import { - ACCESS_TOKEN_EXPIRY_SECONDS, - COOKIE_NAME_ACCESS_TOKEN, - COOKIE_NAME_REFRESH_TOKEN, - isTokenExpired, - REFRESH_TOKEN_EXPIRY_SECONDS, -} from "@/services/auth_tokens"; +import { AuthCookieManager, AuthCookieReader } from "@/services/auth_tokens"; import { LanguageService, LOCALE_COOKIE_NAME, @@ -16,58 +10,48 @@ import { getAlphaTokenSession } from "@/services/session"; import { getAlphaAccessToken } from "@/utils/alpha_access"; import { getPublicSettings } from "@/utils/public_settings.server"; -function hasAuthSession(request: NextRequest): boolean { - const accessToken = request.cookies.get(COOKIE_NAME_ACCESS_TOKEN)?.value; - const refreshToken = request.cookies.get(COOKIE_NAME_REFRESH_TOKEN)?.value; - return !!(accessToken || refreshToken); +async function verifyToken(responseAuth: AuthCookieManager): Promise { + try { + await ServerAuthApi.verifyToken(); + } catch { + // Token is invalid (user banned, token revoked, etc.) - clear all auth cookies + console.error("Token verification failed, clearing auth cookies"); + responseAuth.clearAuthTokens(); + } } /** - * Refresh tokens and apply new cookies to response + * Refresh tokens and apply new cookies to response. + * Returns true if tokens were refreshed. */ async function refreshTokensIfNeeded( - request: NextRequest, - response: NextResponse -): Promise { - const accessToken = request.cookies.get(COOKIE_NAME_ACCESS_TOKEN)?.value; - const refreshToken = request.cookies.get(COOKIE_NAME_REFRESH_TOKEN)?.value; + requestAuth: AuthCookieReader, + responseAuth: AuthCookieManager +): Promise { + const refreshToken = requestAuth.getRefreshToken(); // No refresh token = can't refresh - if (!refreshToken) return; + if (!refreshToken) return false; // Access token still valid = no refresh needed - if (!isTokenExpired(accessToken)) return; + if (!requestAuth.isAccessTokenExpired()) return false; let tokens; try { tokens = await ServerAuthApi.refreshTokens(refreshToken); } catch (error) { console.error("Middleware token refresh failed:", error); - return; + return false; } - // Set new cookies on the response - const cookieOpts = { - httpOnly: true, - secure: true, - sameSite: "lax" as const, - path: "/", - }; - - response.cookies.set(COOKIE_NAME_ACCESS_TOKEN, tokens.access, { - ...cookieOpts, - maxAge: ACCESS_TOKEN_EXPIRY_SECONDS, - }); - - response.cookies.set(COOKIE_NAME_REFRESH_TOKEN, tokens.refresh, { - ...cookieOpts, - maxAge: REFRESH_TOKEN_EXPIRY_SECONDS, - }); + responseAuth.setAuthTokens(tokens); + return true; } export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; - const hasSession = hasAuthSession(request); + const requestAuth = new AuthCookieReader(request.cookies); + const hasSession = requestAuth.hasAuthSession(); const { PUBLIC_AUTHENTICATION_REQUIRED } = getPublicSettings(); @@ -104,10 +88,19 @@ export async function middleware(request: NextRequest) { requestHeaders.set("x-url", request.url); const response = NextResponse.next({ request: { headers: requestHeaders } }); + const responseAuth = new AuthCookieManager(response.cookies); // Proactive token refresh (MUST happen in middleware to persist cookies) if (hasSession) { - await refreshTokensIfNeeded(request, response); + const tokensRefreshed = await refreshTokensIfNeeded( + requestAuth, + responseAuth + ); + // Skip verification if tokens were just refreshed (they're valid by definition) + // Only verify existing tokens to catch banned users or revoked tokens + if (!tokensRefreshed) { + await verifyToken(responseAuth); + } } const locale_in_url = request.nextUrl.searchParams.get("locale"); diff --git a/front_end/src/services/auth_tokens.ts b/front_end/src/services/auth_tokens.ts index 312a5384dd..2528baf0bf 100644 --- a/front_end/src/services/auth_tokens.ts +++ b/front_end/src/services/auth_tokens.ts @@ -16,6 +16,13 @@ export const ACCESS_TOKEN_EXPIRY_SECONDS = 15 * 60; // 15 minutes export const REFRESH_TOKEN_EXPIRY_SECONDS = 30 * 24 * 60 * 60; // 30 days export const REFRESH_BUFFER_SECONDS = 30; // Refresh if expiring within 30s +const AUTH_COOKIE_OPTIONS = { + httpOnly: true, + secure: true, + sameSite: "lax" as const, + path: "/", +}; + /** * Decode JWT payload without verification (we just need expiration time) */ @@ -49,83 +56,82 @@ export function isTokenExpired( return now >= expiresAt - bufferMs; } -/** - * Set both access and refresh tokens in httpOnly cookies - */ -export async function setAuthTokens(tokens: AuthTokens): Promise { - const cookieStorage = await cookies(); +type CookieOptions = { + httpOnly?: boolean; + secure?: boolean; + sameSite?: "lax" | "strict" | "none"; + path?: string; + maxAge?: number; +}; - cookieStorage.set(COOKIE_NAME_ACCESS_TOKEN, tokens.access, { - httpOnly: true, - secure: true, - sameSite: "lax", - maxAge: ACCESS_TOKEN_EXPIRY_SECONDS, - path: "/", - }); - - cookieStorage.set(COOKIE_NAME_REFRESH_TOKEN, tokens.refresh, { - httpOnly: true, - secure: true, - sameSite: "lax", - maxAge: REFRESH_TOKEN_EXPIRY_SECONDS, - path: "/", - }); +export interface ReadonlyCookieStorage { + get(name: string): { value: string } | undefined; } -export async function getAccessToken(): Promise { - const cookieStorage = await cookies(); - return cookieStorage.get(COOKIE_NAME_ACCESS_TOKEN)?.value || null; +export interface CookieStorage extends ReadonlyCookieStorage { + set(name: string, value: string, options?: CookieOptions): void; + delete(name: string): void; } /** - * Check if access token is expired or about to expire - * @param bufferSeconds - refresh if expiring within this many seconds (default: 30) + * Read-only manager for auth token cookies. + * Works with request.cookies (ReadonlyCookieStorage). */ -export async function isAccessTokenExpired( - bufferSeconds: number = 30 -): Promise { - const token = await getAccessToken(); - return isTokenExpired(token ?? undefined, bufferSeconds); -} +export class AuthCookieReader { + constructor(private cookieStorage: ReadonlyCookieStorage) {} -export async function getRefreshToken(): Promise { - const cookieStorage = await cookies(); - return cookieStorage.get(COOKIE_NAME_REFRESH_TOKEN)?.value || null; -} + getAccessToken(): string | null { + return this.cookieStorage.get(COOKIE_NAME_ACCESS_TOKEN)?.value || null; + } -export async function clearAuthTokens(): Promise { - const cookieStorage = await cookies(); - cookieStorage.delete(COOKIE_NAME_ACCESS_TOKEN); - cookieStorage.delete(COOKIE_NAME_REFRESH_TOKEN); + getRefreshToken(): string | null { + return this.cookieStorage.get(COOKIE_NAME_REFRESH_TOKEN)?.value || null; + } + + hasAuthSession(): boolean { + return !!(this.getAccessToken() || this.getRefreshToken()); + } + + isAccessTokenExpired( + bufferSeconds: number = REFRESH_BUFFER_SECONDS + ): boolean { + const token = this.getAccessToken(); + return isTokenExpired(token ?? undefined, bufferSeconds); + } } -export async function hasAuthSession(): Promise { - const accessToken = await getAccessToken(); - const refreshToken = await getRefreshToken(); - return !!(accessToken || refreshToken); +/** + * Full manager for auth token cookies with read/write access. + * Works with next/headers cookies() and response.cookies (CookieStorage). + */ +export class AuthCookieManager extends AuthCookieReader { + constructor(private writableCookieStorage: CookieStorage) { + super(writableCookieStorage); + } + + setAuthTokens(tokens: AuthTokens): void { + this.writableCookieStorage.set(COOKIE_NAME_ACCESS_TOKEN, tokens.access, { + ...AUTH_COOKIE_OPTIONS, + maxAge: ACCESS_TOKEN_EXPIRY_SECONDS, + }); + + this.writableCookieStorage.set(COOKIE_NAME_REFRESH_TOKEN, tokens.refresh, { + ...AUTH_COOKIE_OPTIONS, + maxAge: REFRESH_TOKEN_EXPIRY_SECONDS, + }); + } + + clearAuthTokens(): void { + this.writableCookieStorage.delete(COOKIE_NAME_ACCESS_TOKEN); + this.writableCookieStorage.delete(COOKIE_NAME_REFRESH_TOKEN); + } } /** - * Apply token cookies to a NextResponse (used by api-proxy and refresh route) + * Factory function to create an AuthCookieManager from next/headers cookies(). + * Use this in server components and server actions. */ -export function applyTokenCookiesToResponse( - response: { - cookies: { set: (name: string, value: string, options: object) => void }; - }, - tokens: AuthTokens -): void { - const opts = { - httpOnly: true, - secure: true, - sameSite: "lax" as const, - path: "/", - }; - response.cookies.set(COOKIE_NAME_ACCESS_TOKEN, tokens.access, { - ...opts, - maxAge: ACCESS_TOKEN_EXPIRY_SECONDS, - }); - response.cookies.set(COOKIE_NAME_REFRESH_TOKEN, tokens.refresh, { - ...opts, - maxAge: REFRESH_TOKEN_EXPIRY_SECONDS, - }); +export async function getAuthCookieManager(): Promise { + const cookieStorage = await cookies(); + return new AuthCookieManager(cookieStorage); } diff --git a/front_end/src/services/session.ts b/front_end/src/services/session.ts index 0a61cac4e0..736c82dad8 100644 --- a/front_end/src/services/session.ts +++ b/front_end/src/services/session.ts @@ -2,9 +2,7 @@ import "server-only"; import { cookies } from "next/headers"; import { - setAuthTokens, - clearAuthTokens, - getAccessToken, + getAuthCookieManager, AuthTokens, REFRESH_TOKEN_EXPIRY_SECONDS, } from "@/services/auth_tokens"; @@ -25,17 +23,20 @@ async function setServerCookie(name: string, value: string) { } export async function setServerSession(response: AuthResponse): Promise { - await setAuthTokens(response.tokens); + const authManager = await getAuthCookieManager(); + authManager.setAuthTokens(response.tokens); } export async function setServerSessionWithTokens( tokens: AuthTokens ): Promise { - await setAuthTokens(tokens); + const authManager = await getAuthCookieManager(); + authManager.setAuthTokens(tokens); } export async function getServerSession(): Promise { - return getAccessToken(); + const authManager = await getAuthCookieManager(); + return authManager.getAccessToken(); } export async function getImpersonatorRefreshToken(): Promise { @@ -63,7 +64,8 @@ export async function deleteImpersonatorSession(): Promise { } export async function deleteServerSession() { - await clearAuthTokens(); + const authManager = await getAuthCookieManager(); + authManager.clearAuthTokens(); } export async function getAlphaTokenSession() { diff --git a/front_end/src/utils/core/fetch/fetch.server.ts b/front_end/src/utils/core/fetch/fetch.server.ts index e62f7413a6..bc211844e3 100644 --- a/front_end/src/utils/core/fetch/fetch.server.ts +++ b/front_end/src/utils/core/fetch/fetch.server.ts @@ -2,7 +2,7 @@ import "server-only"; import { getLocale } from "next-intl/server"; -import { getAccessToken } from "@/services/auth_tokens"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import { getAlphaTokenSession } from "@/services/session"; import { FetchConfig, FetchOptions } from "@/types/fetch"; @@ -27,7 +27,8 @@ async function serverFetch( const shouldPassAuth = config.passAuthHeader || PUBLIC_AUTHENTICATION_REQUIRED; - const token = shouldPassAuth ? await getAccessToken() : null; + const authManager = await getAuthCookieManager(); + const token = shouldPassAuth ? authManager.getAccessToken() : null; const alphaToken = await getAlphaTokenSession(); const requestOptions: FetchOptions = { From e927ad07422a66bfc1c6b14114c9007a70241a3a Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Wed, 14 Jan 2026 17:50:47 +0000 Subject: [PATCH 13/32] Small refactor --- front_end/src/app/(main)/accounts/actions.ts | 20 +++---- .../src/app/(main)/accounts/activate/route.ts | 5 +- .../app/(main)/accounts/profile/actions.tsx | 6 +- .../src/app/(main)/accounts/reset/actions.ts | 5 +- .../src/app/(main)/accounts/reset/page.tsx | 6 +- .../(main)/accounts/settings/account/page.tsx | 5 +- .../app/(main)/accounts/settings/actions.tsx | 17 ++---- .../(main)/accounts/settings/bots/page.tsx | 6 +- .../app/(main)/accounts/settings/layout.tsx | 6 +- front_end/src/app/(main)/aib/2024/q3/page.tsx | 5 +- front_end/src/app/(main)/aib/2024/q4/page.tsx | 5 +- .../src/app/(main)/aib/2025/fall/page.tsx | 5 +- front_end/src/app/(main)/aib/2025/q1/page.tsx | 5 +- front_end/src/app/(main)/aib/2025/q2/page.tsx | 5 +- .../src/app/(main)/aib/2026/spring/page.tsx | 5 +- front_end/src/app/(main)/layout.tsx | 7 ++- .../services/api/profile/profile.server.ts | 6 +- front_end/src/services/auth_tokens.ts | 28 ++++++++++ front_end/src/services/session.ts | 55 ------------------- 19 files changed, 90 insertions(+), 112 deletions(-) diff --git a/front_end/src/app/(main)/accounts/actions.ts b/front_end/src/app/(main)/accounts/actions.ts index be657b6c8e..83ae29e154 100644 --- a/front_end/src/app/(main)/accounts/actions.ts +++ b/front_end/src/app/(main)/accounts/actions.ts @@ -9,12 +9,8 @@ import { z } from "zod"; import { signInSchema, SignUpSchema } from "@/app/(main)/accounts/schemas"; import ServerAuthApi from "@/services/api/auth/auth.server"; import ServerProfileApi from "@/services/api/profile/profile.server"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import { LanguageService } from "@/services/language_service"; -import { - deleteImpersonatorSession, - deleteServerSession, - setServerSession, -} from "@/services/session"; import { AuthResponse, SignUpResponse } from "@/types/auth"; import { CurrentUser } from "@/types/users"; import { ApiError } from "@/utils/core/errors"; @@ -71,7 +67,8 @@ export default async function loginAction( }; } - await setServerSession(response); + const authManager = await getAuthCookieManager(); + authManager.setAuthTokens(response.tokens); // Set user's language preference as the active locale if (response.user.language) { @@ -136,7 +133,8 @@ export async function signUpAction( const signUpActionState: SignUpActionState = { ...response }; if (response.is_active && response.tokens) { - await setServerSession(response); + const authManager = await getAuthCookieManager(); + authManager.setAuthTokens(response.tokens); // Set user's language preference as the active locale if (response.user?.language) { @@ -167,8 +165,9 @@ export async function signUpAction( } export async function LogOut() { - await deleteServerSession(); - await deleteImpersonatorSession(); + const authManager = await getAuthCookieManager(); + authManager.clearAuthTokens(); + authManager.clearImpersonatorRefreshToken(); return redirect("/"); } @@ -217,7 +216,8 @@ export async function simplifiedSignUpAction( const response = await ServerAuthApi.simplifiedSignUp(username, authToken); if (response) { - await setServerSession(response); + const authManager = await getAuthCookieManager(); + authManager.setAuthTokens(response.tokens); } return response; } catch (err: unknown) { diff --git a/front_end/src/app/(main)/accounts/activate/route.ts b/front_end/src/app/(main)/accounts/activate/route.ts index 42c9d88e92..b46c765300 100644 --- a/front_end/src/app/(main)/accounts/activate/route.ts +++ b/front_end/src/app/(main)/accounts/activate/route.ts @@ -2,7 +2,7 @@ import { redirect } from "next/navigation"; import invariant from "ts-invariant"; import ServerAuthApi from "@/services/api/auth/auth.server"; -import { setServerSession } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import { logError } from "@/utils/core/errors"; export async function GET(request: Request) { @@ -15,7 +15,8 @@ export async function GET(request: Request) { try { const response = await ServerAuthApi.activateAccount(userId, token); - await setServerSession(response); + const authManager = await getAuthCookieManager(); + authManager.setAuthTokens(response.tokens); } catch (err) { logError(err); } diff --git a/front_end/src/app/(main)/accounts/profile/actions.tsx b/front_end/src/app/(main)/accounts/profile/actions.tsx index e1cc807a74..d97a9bcb7c 100644 --- a/front_end/src/app/(main)/accounts/profile/actions.tsx +++ b/front_end/src/app/(main)/accounts/profile/actions.tsx @@ -7,8 +7,8 @@ import { updateProfileSchema, } from "@/app/(main)/accounts/schemas"; import ServerProfileApi from "@/services/api/profile/profile.server"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import { LanguageService } from "@/services/language_service"; -import { getServerSession } from "@/services/session"; import type { ErrorResponse } from "@/types/fetch"; import { CurrentUser } from "@/types/users"; import { ApiError } from "@/utils/core/errors"; @@ -129,9 +129,9 @@ export async function updateLanguagePreference( language: string, revalidate = true ) { - const serverSession = await getServerSession(); + const authManager = await getAuthCookieManager(); - if (serverSession) { + if (authManager.hasAuthSession()) { // Update the user's language preference in the database await ServerProfileApi.updateProfile({ language: language, diff --git a/front_end/src/app/(main)/accounts/reset/actions.ts b/front_end/src/app/(main)/accounts/reset/actions.ts index 22c311906d..1da1d18868 100644 --- a/front_end/src/app/(main)/accounts/reset/actions.ts +++ b/front_end/src/app/(main)/accounts/reset/actions.ts @@ -5,7 +5,7 @@ import { passwordResetRequestSchema, } from "@/app/(main)/accounts/schemas"; import ServerAuthApi from "@/services/api/auth/auth.server"; -import { setServerSession } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import { AuthResponse } from "@/types/auth"; import { ApiError } from "@/utils/core/errors"; @@ -66,7 +66,8 @@ export async function passwordResetConfirmAction( validatedFields.data.password ); - await setServerSession(response); + const authManager = await getAuthCookieManager(); + authManager.setAuthTokens(response.tokens); return { data: response, diff --git a/front_end/src/app/(main)/accounts/reset/page.tsx b/front_end/src/app/(main)/accounts/reset/page.tsx index acebe612a8..f7dd5fa1bf 100644 --- a/front_end/src/app/(main)/accounts/reset/page.tsx +++ b/front_end/src/app/(main)/accounts/reset/page.tsx @@ -3,7 +3,7 @@ import { redirect } from "next/navigation"; import PasswordReset from "@/app/(main)/accounts/reset/components/password_reset"; import { GlobalErrorContainer } from "@/components/global_error_boundary"; import ServerAuthApi from "@/services/api/auth/auth.server"; -import { getServerSession } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import { ApiError, logError } from "@/utils/core/errors"; export default async function ResetPassword(props: { @@ -13,8 +13,8 @@ export default async function ResetPassword(props: { const { user_id, token } = searchParams; - const serverSession = await getServerSession(); - if (serverSession) { + const authManager = await getAuthCookieManager(); + if (authManager.hasAuthSession()) { return redirect("/"); } diff --git a/front_end/src/app/(main)/accounts/settings/account/page.tsx b/front_end/src/app/(main)/accounts/settings/account/page.tsx index 711a89edea..2a222541b2 100644 --- a/front_end/src/app/(main)/accounts/settings/account/page.tsx +++ b/front_end/src/app/(main)/accounts/settings/account/page.tsx @@ -2,7 +2,7 @@ import invariant from "ts-invariant"; import EmailMeMyData from "@/app/(main)/accounts/settings/account/components/email_me_my_data"; import ServerProfileApi from "@/services/api/profile/profile.server"; -import { getServerSession } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import ApiAccess from "./components/api_access"; import ChangePassword from "./components/change_password"; @@ -15,7 +15,8 @@ export const metadata = { export default async function Settings() { const currentUser = await ServerProfileApi.getMyProfile(); - const token = await getServerSession(); + const authManager = await getAuthCookieManager(); + const token = authManager.getAccessToken(); invariant(currentUser); invariant(token); diff --git a/front_end/src/app/(main)/accounts/settings/actions.tsx b/front_end/src/app/(main)/accounts/settings/actions.tsx index e7e7453046..e6bc8c9ae6 100644 --- a/front_end/src/app/(main)/accounts/settings/actions.tsx +++ b/front_end/src/app/(main)/accounts/settings/actions.tsx @@ -6,12 +6,6 @@ import { redirect } from "next/navigation"; import ServerAuthApi from "@/services/api/auth/auth.server"; import ServerProfileApi from "@/services/api/profile/profile.server"; import { getAuthCookieManager } from "@/services/auth_tokens"; -import { - deleteImpersonatorSession, - getImpersonatorRefreshToken, - setImpersonatorRefreshToken, - setServerSessionWithTokens, -} from "@/services/session"; import { ApiError } from "@/utils/core/errors"; export async function changePassword(password: string, new_password: string) { @@ -118,14 +112,15 @@ export async function getBotTokenAction(botId: number) { } export async function stopImpersonatingAction() { - const impersonatorRefreshToken = await getImpersonatorRefreshToken(); + const authManager = await getAuthCookieManager(); + const impersonatorRefreshToken = authManager.getImpersonatorRefreshToken(); if (impersonatorRefreshToken) { const tokens = await ServerAuthApi.refreshTokens(impersonatorRefreshToken); if (tokens) { - await setServerSessionWithTokens(tokens); + authManager.setAuthTokens(tokens); } - await deleteImpersonatorSession(); + authManager.clearImpersonatorRefreshToken(); } redirect("/accounts/settings/bots/"); @@ -138,10 +133,10 @@ export async function impersonateBotAction(botId: number) { const botTokens = await ServerProfileApi.getBotJwt(botId); if (userRefreshToken) { - await setImpersonatorRefreshToken(userRefreshToken); + authManager.setImpersonatorRefreshToken(userRefreshToken); } - await setServerSessionWithTokens(botTokens); + authManager.setAuthTokens(botTokens); redirect("/"); } catch (err) { diff --git a/front_end/src/app/(main)/accounts/settings/bots/page.tsx b/front_end/src/app/(main)/accounts/settings/bots/page.tsx index 056a05c031..b3bc24197e 100644 --- a/front_end/src/app/(main)/accounts/settings/bots/page.tsx +++ b/front_end/src/app/(main)/accounts/settings/bots/page.tsx @@ -6,7 +6,7 @@ import BotsDisclaimer from "@/app/(main)/accounts/settings/bots/components/bots_ import BotCreateButton from "@/app/(main)/accounts/settings/bots/components/create_button"; import EmptyPlaceholder from "@/app/(main)/accounts/settings/bots/components/empty_placeholder"; import ServerProfileApi from "@/services/api/profile/profile.server"; -import { getServerSession } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import PreferencesSection from "../components/preferences_section"; @@ -15,8 +15,8 @@ export const metadata = { }; export default async function Bots() { - const token = await getServerSession(); - invariant(token); + const authManager = await getAuthCookieManager(); + invariant(authManager.hasAuthSession()); const t = await getTranslations(); const bots = await ServerProfileApi.getMyBots(); diff --git a/front_end/src/app/(main)/accounts/settings/layout.tsx b/front_end/src/app/(main)/accounts/settings/layout.tsx index 4c985696e8..b7d015e03f 100644 --- a/front_end/src/app/(main)/accounts/settings/layout.tsx +++ b/front_end/src/app/(main)/accounts/settings/layout.tsx @@ -3,7 +3,7 @@ import "@fortawesome/fontawesome-svg-core/styles.css"; import { redirect } from "next/navigation"; import ServerProfileApi from "@/services/api/profile/profile.server"; -import { getServerSession } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import SettingsHeader from "./components/settings_header"; @@ -12,10 +12,10 @@ export default async function Layout({ }: Readonly<{ children: React.ReactNode; }>) { - const token = await getServerSession(); + const authManager = await getAuthCookieManager(); const currentUser = await ServerProfileApi.getMyProfile(); - if (!token || !currentUser) return redirect("/"); + if (!authManager.hasAuthSession() || !currentUser) return redirect("/"); return (
diff --git a/front_end/src/app/(main)/aib/2024/q3/page.tsx b/front_end/src/app/(main)/aib/2024/q3/page.tsx index 694528a99c..d4b2ae0ad5 100644 --- a/front_end/src/app/(main)/aib/2024/q3/page.tsx +++ b/front_end/src/app/(main)/aib/2024/q3/page.tsx @@ -1,4 +1,4 @@ -import { getServerSession } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import AiBenchmarkingTournamentPage from "../../components/page-view-2024-q3"; @@ -9,7 +9,8 @@ export const metadata = { }; export default async function Settings() { - const token = await getServerSession(); + const authManager = await getAuthCookieManager(); + const token = authManager.getAccessToken(); return ; } diff --git a/front_end/src/app/(main)/aib/2024/q4/page.tsx b/front_end/src/app/(main)/aib/2024/q4/page.tsx index bcaf96fef2..fba3c6643a 100644 --- a/front_end/src/app/(main)/aib/2024/q4/page.tsx +++ b/front_end/src/app/(main)/aib/2024/q4/page.tsx @@ -1,4 +1,4 @@ -import { getServerSession } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import AiBenchmarkingTournamentPage from "../../components/page-view-2024-q4"; @@ -9,7 +9,8 @@ export const metadata = { }; export default async function Settings() { - const token = await getServerSession(); + const authManager = await getAuthCookieManager(); + const token = authManager.getAccessToken(); return ; } diff --git a/front_end/src/app/(main)/aib/2025/fall/page.tsx b/front_end/src/app/(main)/aib/2025/fall/page.tsx index bcfa28da37..a7bfbe890a 100644 --- a/front_end/src/app/(main)/aib/2025/fall/page.tsx +++ b/front_end/src/app/(main)/aib/2025/fall/page.tsx @@ -1,4 +1,4 @@ -import { getServerSession } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import AiBenchmarkingTournamentPage from "../../components/page-view-2025-fall"; @@ -9,7 +9,8 @@ export const metadata = { }; export default async function Settings() { - const token = await getServerSession(); + const authManager = await getAuthCookieManager(); + const token = authManager.getAccessToken(); return ; } diff --git a/front_end/src/app/(main)/aib/2025/q1/page.tsx b/front_end/src/app/(main)/aib/2025/q1/page.tsx index 3e21d736da..226ccc3ff7 100644 --- a/front_end/src/app/(main)/aib/2025/q1/page.tsx +++ b/front_end/src/app/(main)/aib/2025/q1/page.tsx @@ -1,4 +1,4 @@ -import { getServerSession } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import AiBenchmarkingTournamentPage from "../../components/page-view-2025-q1"; @@ -9,7 +9,8 @@ export const metadata = { }; export default async function Q1Page() { - const token = await getServerSession(); + const authManager = await getAuthCookieManager(); + const token = authManager.getAccessToken(); return ; } diff --git a/front_end/src/app/(main)/aib/2025/q2/page.tsx b/front_end/src/app/(main)/aib/2025/q2/page.tsx index cb6c9193a9..257dd6131e 100644 --- a/front_end/src/app/(main)/aib/2025/q2/page.tsx +++ b/front_end/src/app/(main)/aib/2025/q2/page.tsx @@ -1,4 +1,4 @@ -import { getServerSession } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import AiBenchmarkingTournamentPage from "../../components/page-view-2025-q2"; @@ -9,7 +9,8 @@ export const metadata = { }; export default async function Q2Page() { - const token = await getServerSession(); + const authManager = await getAuthCookieManager(); + const token = authManager.getAccessToken(); return ; } diff --git a/front_end/src/app/(main)/aib/2026/spring/page.tsx b/front_end/src/app/(main)/aib/2026/spring/page.tsx index 4e227c64f6..4715caada5 100644 --- a/front_end/src/app/(main)/aib/2026/spring/page.tsx +++ b/front_end/src/app/(main)/aib/2026/spring/page.tsx @@ -1,5 +1,5 @@ import ServerProfileApi from "@/services/api/profile/profile.server"; -import { getServerSession } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import AiBenchmarkingTournamentPage from "../../components/page-view"; @@ -11,7 +11,8 @@ export const metadata = { async function getPrimaryBotToken() { const user = await ServerProfileApi.getMyProfile(); - const token = await getServerSession(); + const authManager = await getAuthCookieManager(); + const token = authManager.getAccessToken(); if (!user) { return null; diff --git a/front_end/src/app/(main)/layout.tsx b/front_end/src/app/(main)/layout.tsx index 4a4f22e077..0f4db9b72c 100644 --- a/front_end/src/app/(main)/layout.tsx +++ b/front_end/src/app/(main)/layout.tsx @@ -3,7 +3,7 @@ import "@fortawesome/fontawesome-svg-core/styles.css"; import type { Metadata } from "next"; import { defaultDescription } from "@/constants/metadata"; -import { getImpersonatorRefreshToken } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import { getPublicSettings } from "@/utils/public_settings.server"; import FeedbackFloat from "./(home)/components/feedback_float"; @@ -28,13 +28,14 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { - const impersonatorRefreshToken = await getImpersonatorRefreshToken(); + const authManager = await getAuthCookieManager(); + const isImpersonating = authManager.isImpersonating(); return (
- {impersonatorRefreshToken && } + {isImpersonating && }
{children}
diff --git a/front_end/src/services/api/profile/profile.server.ts b/front_end/src/services/api/profile/profile.server.ts index d052abe5ea..d2efa519ae 100644 --- a/front_end/src/services/api/profile/profile.server.ts +++ b/front_end/src/services/api/profile/profile.server.ts @@ -1,5 +1,5 @@ import "server-only"; -import { getServerSession } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import { AuthTokens } from "@/types/auth"; import { SubscriptionEmailType } from "@/types/notifications"; import { ProfilePreferencesType } from "@/types/preferences"; @@ -36,9 +36,9 @@ class ServerProfileApiClass extends ProfileApi { // We make getMyProfile server-only, as it depends on the session // On client side, we can access user profile using `useAuth` hook async getMyProfile(): Promise { - const token = await getServerSession(); + const authManager = await getAuthCookieManager(); - if (!token) { + if (!authManager.hasAuthSession()) { return null; } diff --git a/front_end/src/services/auth_tokens.ts b/front_end/src/services/auth_tokens.ts index 2528baf0bf..4bc29f9188 100644 --- a/front_end/src/services/auth_tokens.ts +++ b/front_end/src/services/auth_tokens.ts @@ -10,6 +10,8 @@ export type { AuthTokens } from "@/types/auth"; // Constants export const COOKIE_NAME_ACCESS_TOKEN = "metaculus_access_token"; export const COOKIE_NAME_REFRESH_TOKEN = "metaculus_refresh_token"; +export const COOKIE_NAME_IMPERSONATOR_REFRESH_TOKEN = + "impersonator_refresh_token"; // Token expiration times (should match backend) export const ACCESS_TOKEN_EXPIRY_SECONDS = 15 * 60; // 15 minutes @@ -88,10 +90,21 @@ export class AuthCookieReader { return this.cookieStorage.get(COOKIE_NAME_REFRESH_TOKEN)?.value || null; } + getImpersonatorRefreshToken(): string | null { + return ( + this.cookieStorage.get(COOKIE_NAME_IMPERSONATOR_REFRESH_TOKEN)?.value || + null + ); + } + hasAuthSession(): boolean { return !!(this.getAccessToken() || this.getRefreshToken()); } + isImpersonating(): boolean { + return !!this.getImpersonatorRefreshToken(); + } + isAccessTokenExpired( bufferSeconds: number = REFRESH_BUFFER_SECONDS ): boolean { @@ -125,6 +138,21 @@ export class AuthCookieManager extends AuthCookieReader { this.writableCookieStorage.delete(COOKIE_NAME_ACCESS_TOKEN); this.writableCookieStorage.delete(COOKIE_NAME_REFRESH_TOKEN); } + + setImpersonatorRefreshToken(refreshToken: string): void { + this.writableCookieStorage.set( + COOKIE_NAME_IMPERSONATOR_REFRESH_TOKEN, + refreshToken, + { + ...AUTH_COOKIE_OPTIONS, + maxAge: REFRESH_TOKEN_EXPIRY_SECONDS, + } + ); + } + + clearImpersonatorRefreshToken(): void { + this.writableCookieStorage.delete(COOKIE_NAME_IMPERSONATOR_REFRESH_TOKEN); + } } /** diff --git a/front_end/src/services/session.ts b/front_end/src/services/session.ts index 736c82dad8..0bc98f0912 100644 --- a/front_end/src/services/session.ts +++ b/front_end/src/services/session.ts @@ -1,16 +1,7 @@ import "server-only"; import { cookies } from "next/headers"; -import { - getAuthCookieManager, - AuthTokens, - REFRESH_TOKEN_EXPIRY_SECONDS, -} from "@/services/auth_tokens"; -import { AuthResponse } from "@/types/auth"; - export const COOKIE_NAME_DEV_TOKEN = "alpha_token"; -export const COOKIE_NAME_IMPERSONATOR_REFRESH_TOKEN = - "impersonator_refresh_token"; async function setServerCookie(name: string, value: string) { const cookieStorage = await cookies(); @@ -22,52 +13,6 @@ async function setServerCookie(name: string, value: string) { }); } -export async function setServerSession(response: AuthResponse): Promise { - const authManager = await getAuthCookieManager(); - authManager.setAuthTokens(response.tokens); -} - -export async function setServerSessionWithTokens( - tokens: AuthTokens -): Promise { - const authManager = await getAuthCookieManager(); - authManager.setAuthTokens(tokens); -} - -export async function getServerSession(): Promise { - const authManager = await getAuthCookieManager(); - return authManager.getAccessToken(); -} - -export async function getImpersonatorRefreshToken(): Promise { - const cookieStorage = await cookies(); - const cookie = cookieStorage.get(COOKIE_NAME_IMPERSONATOR_REFRESH_TOKEN); - return cookie?.value || null; -} - -export async function setImpersonatorRefreshToken( - refreshToken: string -): Promise { - const cookieStorage = await cookies(); - cookieStorage.set(COOKIE_NAME_IMPERSONATOR_REFRESH_TOKEN, refreshToken, { - httpOnly: true, - secure: true, - sameSite: "lax", - maxAge: REFRESH_TOKEN_EXPIRY_SECONDS, - path: "/", - }); -} - -export async function deleteImpersonatorSession(): Promise { - const cookieStorage = await cookies(); - cookieStorage.delete(COOKIE_NAME_IMPERSONATOR_REFRESH_TOKEN); -} - -export async function deleteServerSession() { - const authManager = await getAuthCookieManager(); - authManager.clearAuthTokens(); -} - export async function getAlphaTokenSession() { const cookieStorage = await cookies(); const cookie = cookieStorage.get(COOKIE_NAME_DEV_TOKEN); From d6d80d576c4791e58a565a08770e7e0792ec1c86 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Wed, 14 Jan 2026 23:31:54 +0000 Subject: [PATCH 14/32] Added exchange legacy token endpoint --- authentication/urls.py | 2 ++ authentication/views/common.py | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/authentication/urls.py b/authentication/urls.py index cb60610e31..9e4d728059 100644 --- a/authentication/urls.py +++ b/authentication/urls.py @@ -7,6 +7,8 @@ path("auth/login/token/", common.login_api_view), path('auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'), path("auth/verify_token/", common.verify_token_api_view), + # DEPRECATED: Legacy token migration endpoint (remove after 30 days) + path("auth/exchange-legacy-token/", common.exchange_legacy_token_api_view), path("auth/signup/", common.signup_api_view, name="auth-signup"), path( "auth/signup/simplified/", diff --git a/authentication/views/common.py b/authentication/views/common.py index 25708aad0a..6258eea1a6 100644 --- a/authentication/views/common.py +++ b/authentication/views/common.py @@ -7,6 +7,7 @@ from django.db.models import Q from django.utils import timezone from rest_framework import status, serializers +from rest_framework.authtoken.models import Token from rest_framework.decorators import api_view, permission_classes from rest_framework.exceptions import ValidationError from rest_framework.permissions import AllowAny, IsAdminUser @@ -265,3 +266,28 @@ def invite_user_api_view(request): SignupInviteService().send_email(request.user, email) return Response(status=status.HTTP_204_NO_CONTENT) + + +@api_view(["POST"]) +@permission_classes([AllowAny]) +def exchange_legacy_token_api_view(request): + """ + Exchange a legacy DRF auth token for new JWT tokens. + + DEPRECATED: This endpoint exists only for backward compatibility during + the migration period. It should be removed after the grace period (30 days). + """ + token = serializers.CharField().run_validation(request.data.get("token")) + + try: + token_obj = Token.objects.get(key=token) + except Token.DoesNotExist: + raise ValidationError({"token": ["Invalid token"]}) + + user = token_obj.user + if not user.is_active: + raise ValidationError({"token": ["User account is inactive"]}) + + tokens = get_tokens_for_user(user) + + return Response({"tokens": tokens}) From 4824c3b5642040ab0d79b41eb6dd468145701354 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Wed, 14 Jan 2026 23:37:59 +0000 Subject: [PATCH 15/32] FE: added legacy token migration --- front_end/src/middleware.ts | 15 +++++- .../src/services/auth_tokens_migration.ts | 49 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 front_end/src/services/auth_tokens_migration.ts diff --git a/front_end/src/middleware.ts b/front_end/src/middleware.ts index 1cf399108c..d1c676b0b9 100644 --- a/front_end/src/middleware.ts +++ b/front_end/src/middleware.ts @@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server"; import ServerAuthApi from "@/services/api/auth/auth.server"; import { AuthCookieManager, AuthCookieReader } from "@/services/auth_tokens"; +// DEPRECATED: Remove after 30-day migration period +import { handleLegacyTokenMigration } from "@/services/auth_tokens_migration"; import { LanguageService, LOCALE_COOKIE_NAME, @@ -51,7 +53,7 @@ async function refreshTokensIfNeeded( export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; const requestAuth = new AuthCookieReader(request.cookies); - const hasSession = requestAuth.hasAuthSession(); + let hasSession = requestAuth.hasAuthSession(); const { PUBLIC_AUTHENTICATION_REQUIRED } = getPublicSettings(); @@ -90,6 +92,17 @@ export async function middleware(request: NextRequest) { const response = NextResponse.next({ request: { headers: requestHeaders } }); const responseAuth = new AuthCookieManager(response.cookies); + // DEPRECATED: Legacy token migration - remove after 30-day grace period + const wasMigrated = await handleLegacyTokenMigration( + request, + requestAuth, + responseAuth + ); + if (wasMigrated) { + // Update hasSession since we now have valid tokens + hasSession = true; + } + // Proactive token refresh (MUST happen in middleware to persist cookies) if (hasSession) { const tokensRefreshed = await refreshTokensIfNeeded( diff --git a/front_end/src/services/auth_tokens_migration.ts b/front_end/src/services/auth_tokens_migration.ts new file mode 100644 index 0000000000..4f2d50ba04 --- /dev/null +++ b/front_end/src/services/auth_tokens_migration.ts @@ -0,0 +1,49 @@ +/** + * Legacy Auth Token Migration + * + * DEPRECATED: Remove this file after 30 days from release. + */ +import "server-only"; + +import { NextRequest } from "next/server"; + +import { AuthCookieManager, AuthCookieReader } from "@/services/auth_tokens"; +import { getPublicSettings } from "@/utils/public_settings.server"; + +const LEGACY_COOKIE_NAME = "auth_token"; + +/** + * Migrate legacy auth_token cookie to new JWT tokens. + * Call this in middleware before normal auth handling. + */ +export async function handleLegacyTokenMigration( + request: NextRequest, + requestAuth: AuthCookieReader, + responseAuth: AuthCookieManager +): Promise { + if (requestAuth.hasAuthSession()) return false; + + const legacyToken = request.cookies.get(LEGACY_COOKIE_NAME)?.value; + if (!legacyToken) return false; + + const { PUBLIC_API_BASE_URL } = getPublicSettings(); + + try { + const response = await fetch( + `${PUBLIC_API_BASE_URL}/api/auth/exchange-legacy-token/`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: legacyToken }), + } + ); + + if (!response.ok) return false; + + const data = await response.json(); + responseAuth.setAuthTokens(data.tokens); + return true; + } catch { + return false; + } +} From c74c2c3a9aaf27c1214ff30eeb1cb9f6f9cf385c Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Wed, 14 Jan 2026 23:42:26 +0000 Subject: [PATCH 16/32] Fix logout action --- front_end/src/app/(main)/accounts/actions.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/front_end/src/app/(main)/accounts/actions.ts b/front_end/src/app/(main)/accounts/actions.ts index 83ae29e154..f830c458a6 100644 --- a/front_end/src/app/(main)/accounts/actions.ts +++ b/front_end/src/app/(main)/accounts/actions.ts @@ -1,7 +1,7 @@ "use server"; import { revalidatePath } from "next/cache"; -import { headers } from "next/headers"; +import { cookies, headers } from "next/headers"; import { redirect } from "next/navigation"; import { getLocale } from "next-intl/server"; import { z } from "zod"; @@ -168,6 +168,8 @@ export async function LogOut() { const authManager = await getAuthCookieManager(); authManager.clearAuthTokens(); authManager.clearImpersonatorRefreshToken(); + // DEPRECATED: Remove after 30-day migration period + (await cookies()).delete("auth_token"); return redirect("/"); } From 6b17b2bd9fa2ef5bf4a3aea340b786a04c5c099f Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Fri, 16 Jan 2026 18:28:07 +0000 Subject: [PATCH 17/32] Fixed social auth --- authentication/views/social.py | 14 +++++++------- projects/admin.py | 6 +----- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/authentication/views/social.py b/authentication/views/social.py index 8d435972c5..a833f81c27 100644 --- a/authentication/views/social.py +++ b/authentication/views/social.py @@ -4,11 +4,12 @@ from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import AllowAny from rest_framework.response import Response +from rest_framework_simplejwt.authentication import JWTAuthentication from rest_social_auth.views import SocialTokenOnlyAuthView from social_core.backends.oauth import BaseOAuth2 -from authentication.auth import FallbackTokenAuthentication -from authentication.models import ApiKey +from authentication.services import get_tokens_for_user +from users.models import User @api_view(["GET"]) @@ -45,14 +46,13 @@ def social_providers_api_view(request): class SocialCodeAuth(SocialTokenOnlyAuthView): class TokenSerializer(serializers.Serializer): - token = serializers.SerializerMethodField() + tokens = serializers.SerializerMethodField() - def get_token(self, obj): - token, created = ApiKey.objects.get_or_create(user=obj) - return token.key + def get_token(self, obj: User): + return get_tokens_for_user(obj) serializer_class = TokenSerializer - authentication_classes = (FallbackTokenAuthentication,) + authentication_classes = (JWTAuthentication,) def respond_error(self, error): response = super().respond_error(error) diff --git a/projects/admin.py b/projects/admin.py index d33e00d562..5dd5c4f072 100644 --- a/projects/admin.py +++ b/projects/admin.py @@ -6,11 +6,9 @@ from django.db.models import QuerySet, Q from django.http import HttpResponse from django.shortcuts import render, redirect, get_object_or_404 -from django.urls import path -from django.urls import reverse +from django.urls import path, reverse from django.utils.html import format_html, format_html_join from django_select2.forms import ModelSelect2MultipleWidget -from authentication.models import ApiKey from posts.models import Post from projects.models import ( @@ -561,7 +559,6 @@ def generate_50_users_password_simple(self, request, queryset: QuerySet[Project] ) user.set_password(username) user.save() - ApiKey.objects.create(user=user) data += f"{user.username}\n" for project in queryset: ProjectUserPermission.objects.create( @@ -619,7 +616,6 @@ def generate_50_users_password_complex(self, request, queryset: QuerySet[Project ) user.set_password(password) user.save() - ApiKey.objects.create(user=user) data += f"{user.username},{password}\n" for project in queryset: ProjectUserPermission.objects.create( From 392b6f9af09ba0ba82e652e01b36b7330b237fd3 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Fri, 16 Jan 2026 18:32:44 +0000 Subject: [PATCH 18/32] Fixed social auth --- authentication/views/social.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/authentication/views/social.py b/authentication/views/social.py index a833f81c27..431beb8984 100644 --- a/authentication/views/social.py +++ b/authentication/views/social.py @@ -48,7 +48,7 @@ class SocialCodeAuth(SocialTokenOnlyAuthView): class TokenSerializer(serializers.Serializer): tokens = serializers.SerializerMethodField() - def get_token(self, obj: User): + def get_tokens(self, obj: User): return get_tokens_for_user(obj) serializer_class = TokenSerializer From 48afa63b836b27f37126ebafdde8e5c6eb65e53f Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Fri, 16 Jan 2026 18:33:47 +0000 Subject: [PATCH 19/32] Small fix --- authentication/views/common.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/authentication/views/common.py b/authentication/views/common.py index d1416e211c..8c9ea6a3fd 100644 --- a/authentication/views/common.py +++ b/authentication/views/common.py @@ -303,8 +303,8 @@ def exchange_legacy_token_api_view(request): token = serializers.CharField().run_validation(request.data.get("token")) try: - token_obj = Token.objects.get(key=token) - except Token.DoesNotExist: + token_obj = ApiKey.objects.get(key=token) + except ApiKey.DoesNotExist: raise ValidationError({"token": ["Invalid token"]}) user = token_obj.user From 419593a6e42e4be2157a2634c10e053d03f45ea0 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Fri, 16 Jan 2026 19:57:37 +0000 Subject: [PATCH 20/32] Clean legacy auth token during exchange if invalid --- front_end/src/middleware.ts | 1 + front_end/src/services/auth_tokens_migration.ts | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/front_end/src/middleware.ts b/front_end/src/middleware.ts index d1c676b0b9..43f41e0e4e 100644 --- a/front_end/src/middleware.ts +++ b/front_end/src/middleware.ts @@ -95,6 +95,7 @@ export async function middleware(request: NextRequest) { // DEPRECATED: Legacy token migration - remove after 30-day grace period const wasMigrated = await handleLegacyTokenMigration( request, + response, requestAuth, responseAuth ); diff --git a/front_end/src/services/auth_tokens_migration.ts b/front_end/src/services/auth_tokens_migration.ts index 4f2d50ba04..de0993c36a 100644 --- a/front_end/src/services/auth_tokens_migration.ts +++ b/front_end/src/services/auth_tokens_migration.ts @@ -5,7 +5,7 @@ */ import "server-only"; -import { NextRequest } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { AuthCookieManager, AuthCookieReader } from "@/services/auth_tokens"; import { getPublicSettings } from "@/utils/public_settings.server"; @@ -15,9 +15,11 @@ const LEGACY_COOKIE_NAME = "auth_token"; /** * Migrate legacy auth_token cookie to new JWT tokens. * Call this in middleware before normal auth handling. + * Deletes invalid legacy tokens on 400 response. */ export async function handleLegacyTokenMigration( request: NextRequest, + response: NextResponse, requestAuth: AuthCookieReader, responseAuth: AuthCookieManager ): Promise { @@ -29,7 +31,7 @@ export async function handleLegacyTokenMigration( const { PUBLIC_API_BASE_URL } = getPublicSettings(); try { - const response = await fetch( + const apiResponse = await fetch( `${PUBLIC_API_BASE_URL}/api/auth/exchange-legacy-token/`, { method: "POST", @@ -38,9 +40,15 @@ export async function handleLegacyTokenMigration( } ); - if (!response.ok) return false; + if (apiResponse.status === 400) { + // Invalid token - clean it up + response.cookies.delete(LEGACY_COOKIE_NAME); + return false; + } - const data = await response.json(); + if (!apiResponse.ok) return false; + + const data = await apiResponse.json(); responseAuth.setAuthTokens(data.tokens); return true; } catch { From 8cfcee18513a03a0f51e2ec16fe98ab3e678dd52 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Fri, 16 Jan 2026 20:00:36 +0000 Subject: [PATCH 21/32] Updated ACCESS_TOKEN_LIFETIME --- metaculus_web/settings.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/metaculus_web/settings.py b/metaculus_web/settings.py index d1d9ef0d31..09cd30909e 100644 --- a/metaculus_web/settings.py +++ b/metaculus_web/settings.py @@ -164,8 +164,7 @@ # Simple JWT # https://django-rest-framework-simplejwt.readthedocs.io/ SIMPLE_JWT = { - # TODO: adjust this - "ACCESS_TOKEN_LIFETIME": timedelta(seconds=45), + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=15), "REFRESH_TOKEN_LIFETIME": timedelta(days=30), "ROTATE_REFRESH_TOKENS": True, "BLACKLIST_AFTER_ROTATION": True, From f60f67a90a01f7c6edce26dfffc29f9c39c596cd Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Fri, 16 Jan 2026 20:05:12 +0000 Subject: [PATCH 22/32] Small fix --- poetry.lock | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index e64212799b..2911b90032 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -790,11 +790,11 @@ description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["main", "dev"] -markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "sys_platform == \"win32\" or platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} [[package]] name = "cryptography" @@ -1198,6 +1198,31 @@ files = [ [package.dependencies] django = ">=4.2" +[[package]] +name = "djangorestframework-simplejwt" +version = "5.5.1" +description = "A minimal JSON Web Token authentication plugin for Django REST Framework" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469"}, + {file = "djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f"}, +] + +[package.dependencies] +django = ">=4.2" +djangorestframework = ">=3.14" +pyjwt = ">=1.7.1" + +[package.extras] +crypto = ["cryptography (>=3.3.1)"] +dev = ["Sphinx", "cryptography", "freezegun", "ipython", "pre-commit", "pytest", "pytest-cov", "pytest-django", "pytest-watch", "pytest-xdist", "python-jose (==3.3.0)", "pyupgrade", "ruff", "sphinx_rtd_theme (>=0.1.9)", "tox", "twine", "wheel", "yesqa"] +doc = ["Sphinx", "sphinx_rtd_theme (>=0.1.9)"] +lint = ["pre-commit", "pyupgrade", "ruff", "yesqa"] +python-jose = ["python-jose (==3.3.0)"] +test = ["cryptography", "freezegun", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", "tox"] + [[package]] name = "docstring-parser" version = "0.16" @@ -4001,7 +4026,7 @@ files = [ {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, ] -markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\""} +markers = {main = "sys_platform == \"win32\" or platform_system == \"Windows\""} [[package]] name = "tzlocal" @@ -4166,4 +4191,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.1" python-versions = "3.12.3" -content-hash = "b1c67f05c92a4fbaae07accd07dc8ce12e8c8de5451b62ef9a1a255520623ab9" +content-hash = "1da9b5b3f6a1fb609ccde72087524d510d892f6e0a80368ba754c19f24554662" From bb56580bde1695b25bda4929a9ff6dc67daaf98d Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Fri, 16 Jan 2026 20:27:26 +0000 Subject: [PATCH 23/32] Small fix --- authentication/urls.py | 2 +- tests/unit/test_auth/test_views.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/authentication/urls.py b/authentication/urls.py index 8aa6deb6da..d92d16b411 100644 --- a/authentication/urls.py +++ b/authentication/urls.py @@ -5,7 +5,7 @@ urlpatterns = [ path("auth/login/token/", common.login_api_view), - path('auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path("auth/refresh/", TokenRefreshView.as_view(), name="token_refresh"), path("auth/verify_token/", common.verify_token_api_view), # DEPRECATED: Legacy token migration endpoint (remove after 30 days) path("auth/exchange-legacy-token/", common.exchange_legacy_token_api_view), diff --git a/tests/unit/test_auth/test_views.py b/tests/unit/test_auth/test_views.py index 14ca8c6e3d..6d35c3885f 100644 --- a/tests/unit/test_auth/test_views.py +++ b/tests/unit/test_auth/test_views.py @@ -28,7 +28,7 @@ def test_signup__verify_email(self, anon_client, mocker): assert response.status_code == 201 assert response.data["is_active"] == user.is_active == False - assert not response.data["token"] + assert not response.data["tokens"] assert not user.last_login mock_send_activation_email.assert_called_once() @@ -53,7 +53,7 @@ def test_signup__do_not_verify_email(self, anon_client, mocker): assert response.status_code == 201 assert response.data["is_active"] == user.is_active == True assert user.last_login - assert response.data["token"] + assert response.data["tokens"] mock_send_activation_email.assert_not_called() @override_settings(PUBLIC_ALLOW_SIGNUP=False) @@ -100,7 +100,7 @@ def test_signup_invitation(self, anon_client): ) assert response.status_code == 201 assert response.data["is_active"] == True - assert response.data["token"] + assert response.data["tokens"] @pytest.mark.parametrize( "params,expected_langauge", From 37a453c14d43b4233b3d4a5ac2c48481f754e729 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Fri, 16 Jan 2026 20:35:14 +0000 Subject: [PATCH 24/32] Fixed types --- front_end/src/app/(api)/api-proxy/[...path]/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front_end/src/app/(api)/api-proxy/[...path]/route.ts b/front_end/src/app/(api)/api-proxy/[...path]/route.ts index f061d5b2b5..37cdca5376 100644 --- a/front_end/src/app/(api)/api-proxy/[...path]/route.ts +++ b/front_end/src/app/(api)/api-proxy/[...path]/route.ts @@ -65,7 +65,7 @@ async function handleProxyRequest(request: NextRequest, method: string) { ]; const buildHeaders = (accessToken?: string): Headers => { - const headers: HeadersInit = new Headers({ + const headers = new Headers({ ...Object.fromEntries( Array.from(request.headers.entries()).filter( ([key]) => !blocklistHeaders.includes(key.toLowerCase()) From 856c825d54a8b71783787ec8abea670c850c6000 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Mon, 19 Jan 2026 15:12:14 +0000 Subject: [PATCH 25/32] JWT: added `session_id` to the token claim --- authentication/services.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/authentication/services.py b/authentication/services.py index 900483c406..d1533f2d21 100644 --- a/authentication/services.py +++ b/authentication/services.py @@ -1,3 +1,5 @@ +import uuid + from django.conf import settings from django.contrib.auth.tokens import default_token_generator from django.core.signing import TimestampSigner @@ -132,6 +134,8 @@ def get_tokens_for_user(user): raise AuthenticationFailed("User is not active") refresh = RefreshToken.for_user(user) + # Add a session identification to isolate multiple sessions of the same user + refresh["session_id"] = str(uuid.uuid4()) return { "refresh": str(refresh), From a2ed00d8de779a79f9f4817639af3d527e6f2469 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Mon, 19 Jan 2026 15:27:56 +0000 Subject: [PATCH 26/32] JWT: added `JWT_PRIVATE_KEY` and RS256 support --- metaculus_web/settings.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/metaculus_web/settings.py b/metaculus_web/settings.py index 09cd30909e..7aad8b400a 100644 --- a/metaculus_web/settings.py +++ b/metaculus_web/settings.py @@ -19,6 +19,8 @@ import dj_database_url import django.conf.locale import sentry_sdk +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.serialization import load_pem_private_key from django.core.exceptions import DisallowedHost from dramatiq.errors import RateLimitExceeded from sentry_sdk.integrations.django import DjangoIntegration @@ -163,6 +165,31 @@ # Simple JWT # https://django-rest-framework-simplejwt.readthedocs.io/ +# Generate key with: openssl genrsa -out jwt_private.pem 2048 +# Falls back to HS256 with SECRET_KEY if JWT_PRIVATE_KEY is not set +def get_jwt_encryption_config(): + private_key_pem = os.environ.get("JWT_PRIVATE_KEY", "").replace("\\n", "\n") + + if not private_key_pem: + # Fallback to HS256 with SECRET_KEY + return {"ALGORITHM": "HS256", "SIGNING_KEY": SECRET_KEY, "VERIFYING_KEY": None} + + private_key = load_pem_private_key(private_key_pem.encode(), password=None) + public_key_pem = ( + private_key.public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode() + ) + return { + "ALGORITHM": "RS256", + "SIGNING_KEY": private_key_pem, + "VERIFYING_KEY": public_key_pem, + } + + SIMPLE_JWT = { "ACCESS_TOKEN_LIFETIME": timedelta(minutes=15), "REFRESH_TOKEN_LIFETIME": timedelta(days=30), @@ -170,6 +197,7 @@ "BLACKLIST_AFTER_ROTATION": True, "CHECK_REVOKE_TOKEN": True, "REVOKE_TOKEN_CLAIM": "hash", + **get_jwt_encryption_config(), } # Password validation From 04aef4bad67faf3ac780e32bef5068f739e6490d Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Mon, 19 Jan 2026 15:32:07 +0000 Subject: [PATCH 27/32] Small fix --- metaculus_web/settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/metaculus_web/settings.py b/metaculus_web/settings.py index 7aad8b400a..35542c7058 100644 --- a/metaculus_web/settings.py +++ b/metaculus_web/settings.py @@ -163,6 +163,7 @@ "MAX_LIMIT": 100, } + # Simple JWT # https://django-rest-framework-simplejwt.readthedocs.io/ # Generate key with: openssl genrsa -out jwt_private.pem 2048 From 8654f7654617cca7d25173e38ae1ab2c9412fe90 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Mon, 19 Jan 2026 15:41:24 +0000 Subject: [PATCH 28/32] Small fix --- metaculus_web/settings.py | 1 - 1 file changed, 1 deletion(-) diff --git a/metaculus_web/settings.py b/metaculus_web/settings.py index 35542c7058..8b95e24639 100644 --- a/metaculus_web/settings.py +++ b/metaculus_web/settings.py @@ -195,7 +195,6 @@ def get_jwt_encryption_config(): "ACCESS_TOKEN_LIFETIME": timedelta(minutes=15), "REFRESH_TOKEN_LIFETIME": timedelta(days=30), "ROTATE_REFRESH_TOKENS": True, - "BLACKLIST_AFTER_ROTATION": True, "CHECK_REVOKE_TOKEN": True, "REVOKE_TOKEN_CLAIM": "hash", **get_jwt_encryption_config(), From df04faf6b4e4355c138ae349f2be63fe3f1c7d0d Mon Sep 17 00:00:00 2001 From: Hlib Date: Mon, 19 Jan 2026 16:12:19 +0000 Subject: [PATCH 29/32] Update front_end/src/utils/core/fetch/fetch.server.ts Co-authored-by: Nikita <93587872+ncarazon@users.noreply.github.com> --- front_end/src/utils/core/fetch/fetch.server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front_end/src/utils/core/fetch/fetch.server.ts b/front_end/src/utils/core/fetch/fetch.server.ts index bc211844e3..8cd9b154a8 100644 --- a/front_end/src/utils/core/fetch/fetch.server.ts +++ b/front_end/src/utils/core/fetch/fetch.server.ts @@ -25,7 +25,7 @@ async function serverFetch( const { PUBLIC_API_BASE_URL, PUBLIC_AUTHENTICATION_REQUIRED } = getPublicSettings(); const shouldPassAuth = - config.passAuthHeader || PUBLIC_AUTHENTICATION_REQUIRED; + config.passAuthHeader ?? PUBLIC_AUTHENTICATION_REQUIRED; const authManager = await getAuthCookieManager(); const token = shouldPassAuth ? authManager.getAccessToken() : null; From a7007f478569cd1ba4c9dfc402663c1afd7cdaef Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Mon, 19 Jan 2026 16:14:39 +0000 Subject: [PATCH 30/32] Added cryptography dep --- poetry.lock | 105 +++++++++++++++++++++++++++++-------------------- pyproject.toml | 1 + 2 files changed, 63 insertions(+), 43 deletions(-) diff --git a/poetry.lock b/poetry.lock index 2911b90032..7de198ab36 100644 --- a/poetry.lock +++ b/poetry.lock @@ -798,60 +798,79 @@ markers = {main = "sys_platform == \"win32\" or platform_system == \"Windows\"", [[package]] name = "cryptography" -version = "44.0.2" +version = "46.0.3" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false -python-versions = "!=3.9.0,!=3.9.1,>=3.7" +python-versions = "!=3.9.0,!=3.9.1,>=3.8" groups = ["main"] files = [ - {file = "cryptography-44.0.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308"}, - {file = "cryptography-44.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688"}, - {file = "cryptography-44.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7"}, - {file = "cryptography-44.0.2-cp37-abi3-win32.whl", hash = "sha256:51e4de3af4ec3899d6d178a8c005226491c27c4ba84101bfb59c901e10ca9f79"}, - {file = "cryptography-44.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:c505d61b6176aaf982c5717ce04e87da5abc9a36a5b39ac03905c4aafe8de7aa"}, - {file = "cryptography-44.0.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8e0ddd63e6bf1161800592c71ac794d3fb8001f2caebe0966e77c5234fa9efc3"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23"}, - {file = "cryptography-44.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922"}, - {file = "cryptography-44.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4"}, - {file = "cryptography-44.0.2-cp39-abi3-win32.whl", hash = "sha256:3dc62975e31617badc19a906481deacdeb80b4bb454394b4098e3f2525a488c5"}, - {file = "cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:af4ff3e388f2fa7bff9f7f2b31b87d5651c45731d3e8cfa0944be43dff5cfbdb"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0529b1d5a0105dd3731fa65680b45ce49da4d8115ea76e9da77a875396727b41"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7ca25849404be2f8e4b3c59483d9d3c51298a22c1c61a0e84415104dacaf5562"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:268e4e9b177c76d569e8a145a6939eca9a5fec658c932348598818acf31ae9a5"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9eb9d22b0a5d8fd9925a7764a054dca914000607dff201a24c791ff5c799e1fa"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2bf7bf75f7df9715f810d1b038870309342bff3069c5bd8c6b96128cb158668d"}, - {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:909c97ab43a9c0c0b0ada7a1281430e4e5ec0458e6d9244c0e821bbf152f061d"}, - {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:96e7a5e9d6e71f9f4fca8eebfd603f8e86c5225bb18eb621b2c1e50b290a9471"}, - {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d1b3031093a366ac767b3feb8bcddb596671b3aaff82d4050f984da0c248b615"}, - {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:04abd71114848aa25edb28e225ab5f268096f44cf0127f3d36975bdf1bdf3390"}, - {file = "cryptography-44.0.2.tar.gz", hash = "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0"}, + {file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926"}, + {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71"}, + {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac"}, + {file = "cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"}, + {file = "cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb"}, + {file = "cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c"}, + {file = "cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3"}, + {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20"}, + {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de"}, + {file = "cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914"}, + {file = "cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db"}, + {file = "cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21"}, + {file = "cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506"}, + {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963"}, + {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4"}, + {file = "cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df"}, + {file = "cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f"}, + {file = "cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372"}, + {file = "cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32"}, + {file = "cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c"}, + {file = "cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1"}, ] [package.dependencies] -cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} +cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} [package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0) ; python_version >= \"3.8\""] +docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_version >= \"3.8\""] -pep8test = ["check-sdist ; python_version >= \"3.8\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +nox = ["nox[uv] (>=2024.4.15)"] +pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] sdist = ["build (>=1.0.0)"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==44.0.2)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test = ["certifi (>=2024)", "cryptography-vectors (==46.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] test-randomorder = ["pytest-randomly"] [[package]] @@ -4191,4 +4210,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.1" python-versions = "3.12.3" -content-hash = "1da9b5b3f6a1fb609ccde72087524d510d892f6e0a80368ba754c19f24554662" +content-hash = "f9d99cb7d959e43b2d4cfbb8322037b81c7fd0c06bb6d0415272356b34f1aff9" diff --git a/pyproject.toml b/pyproject.toml index 344d5218f6..f65a958d48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ pytz = "^2025.2" psycopg2 = "^2.9.10" scikit-learn = "^1.7.2" djangorestframework-simplejwt = "^5.5.1" +cryptography = "^46.0.3" [tool.poetry.group.dev.dependencies] pytest = "^8.2.1" From 25ee82cb6df6b017eb91d79cfb240330c086f11c Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Mon, 19 Jan 2026 16:16:09 +0000 Subject: [PATCH 31/32] Fixed types --- front_end/src/app/(main)/accounts/actions.ts | 2 +- front_end/src/types/auth.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/front_end/src/app/(main)/accounts/actions.ts b/front_end/src/app/(main)/accounts/actions.ts index f830c458a6..53e2b55d51 100644 --- a/front_end/src/app/(main)/accounts/actions.ts +++ b/front_end/src/app/(main)/accounts/actions.ts @@ -217,7 +217,7 @@ export async function simplifiedSignUpAction( try { const response = await ServerAuthApi.simplifiedSignUp(username, authToken); - if (response) { + if (response && response.tokens) { const authManager = await getAuthCookieManager(); authManager.setAuthTokens(response.tokens); } diff --git a/front_end/src/types/auth.ts b/front_end/src/types/auth.ts index a5a994c71b..03a74f5870 100644 --- a/front_end/src/types/auth.ts +++ b/front_end/src/types/auth.ts @@ -27,7 +27,7 @@ export type AuthResponse = { }; export type SignUpResponse = { - tokens: AuthTokens; + tokens: AuthTokens | null; user: CurrentUser; is_active: boolean; }; From 9c01a19fa28c4eabe2f187976370a6cb15202765 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Mon, 19 Jan 2026 16:20:59 +0000 Subject: [PATCH 32/32] Fixed types --- front_end/src/middleware.ts | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/front_end/src/middleware.ts b/front_end/src/middleware.ts index 43f41e0e4e..67bf7c6bcb 100644 --- a/front_end/src/middleware.ts +++ b/front_end/src/middleware.ts @@ -55,6 +55,24 @@ export async function middleware(request: NextRequest) { const requestAuth = new AuthCookieReader(request.cookies); let hasSession = requestAuth.hasAuthSession(); + const requestHeaders = new Headers(request.headers); + requestHeaders.set("x-url", request.url); + + const response = NextResponse.next({ request: { headers: requestHeaders } }); + const responseAuth = new AuthCookieManager(response.cookies); + + // DEPRECATED: Legacy token migration - remove after 30-day grace period + // Must run before auth checks so users with legacy tokens aren't rejected + const wasMigrated = await handleLegacyTokenMigration( + request, + response, + requestAuth, + responseAuth + ); + if (wasMigrated) { + hasSession = true; + } + const { PUBLIC_AUTHENTICATION_REQUIRED } = getPublicSettings(); // If authentication is required, redirect unauthenticated users @@ -64,7 +82,6 @@ export async function middleware(request: NextRequest) { !pathname.startsWith("/accounts/") && !hasSession ) { - // return a not found page return NextResponse.rewrite(new URL("/not-found/", request.url)); } } @@ -86,24 +103,6 @@ export async function middleware(request: NextRequest) { } } - const requestHeaders = new Headers(request.headers); - requestHeaders.set("x-url", request.url); - - const response = NextResponse.next({ request: { headers: requestHeaders } }); - const responseAuth = new AuthCookieManager(response.cookies); - - // DEPRECATED: Legacy token migration - remove after 30-day grace period - const wasMigrated = await handleLegacyTokenMigration( - request, - response, - requestAuth, - responseAuth - ); - if (wasMigrated) { - // Update hasSession since we now have valid tokens - hasSession = true; - } - // Proactive token refresh (MUST happen in middleware to persist cookies) if (hasSession) { const tokensRefreshed = await refreshTokensIfNeeded(