Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
9eb6c39
Auth Refresh Tokens Frontend Implementation
hlbmtc Dec 24, 2025
196a8c2
Remove redundant auth logic from the middleware
hlbmtc Dec 24, 2025
96800d4
Added todo
hlbmtc Dec 24, 2025
ddce2ed
Small fix
hlbmtc Dec 24, 2025
5016aec
Backend JWT integration
hlbmtc Dec 24, 2025
833cd60
Improvements
hlbmtc Dec 25, 2025
498aaf9
Bump server refresh lock time
hlbmtc Dec 25, 2025
b4e3d8f
Merge branch 'main' into feat/frontend-auth-refresh-tokens
hlbmtc Jan 13, 2026
0246f27
Bot JWT token endpoint
hlbmtc Jan 13, 2026
e11fa90
Bot JWT impersonation frontend
hlbmtc Jan 13, 2026
4ca5ea3
JWT token format improvements
hlbmtc Jan 13, 2026
9bd2292
Small fix
hlbmtc Jan 13, 2026
2cee0b5
Frontend refactoring
hlbmtc Jan 13, 2026
e927ad0
Small refactor
hlbmtc Jan 14, 2026
d6d80d5
Added exchange legacy token endpoint
hlbmtc Jan 14, 2026
4824c3b
FE: added legacy token migration
hlbmtc Jan 14, 2026
c74c2c3
Fix logout action
hlbmtc Jan 14, 2026
73b2d35
Merge branch 'main' into feat/frontend-auth-refresh-tokens
hlbmtc Jan 16, 2026
6b17b2b
Fixed social auth
hlbmtc Jan 16, 2026
392b6f9
Fixed social auth
hlbmtc Jan 16, 2026
48afa63
Small fix
hlbmtc Jan 16, 2026
419593a
Clean legacy auth token during exchange if invalid
hlbmtc Jan 16, 2026
8cfcee1
Updated ACCESS_TOKEN_LIFETIME
hlbmtc Jan 16, 2026
da25625
Merge remote-tracking branch 'origin/feat/frontend-auth-refresh-token…
hlbmtc Jan 16, 2026
f60f67a
Small fix
hlbmtc Jan 16, 2026
bb56580
Small fix
hlbmtc Jan 16, 2026
37a453c
Fixed types
hlbmtc Jan 16, 2026
856c825
JWT: added `session_id` to the token claim
hlbmtc Jan 19, 2026
a2ed00d
JWT: added `JWT_PRIVATE_KEY` and RS256 support
hlbmtc Jan 19, 2026
04aef4b
Small fix
hlbmtc Jan 19, 2026
80545dd
Merge branch 'main' into feat/frontend-auth-refresh-tokens
hlbmtc Jan 19, 2026
8654f76
Small fix
hlbmtc Jan 19, 2026
7a196d5
Merge remote-tracking branch 'origin/feat/frontend-auth-refresh-token…
hlbmtc Jan 19, 2026
df04faf
Update front_end/src/utils/core/fetch/fetch.server.ts
hlbmtc Jan 19, 2026
a7007f4
Added cryptography dep
hlbmtc Jan 19, 2026
25ee82c
Fixed types
hlbmtc Jan 19, 2026
9c01a19
Fixed types
hlbmtc Jan 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions authentication/services.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import uuid

from django.conf import settings
from django.contrib.auth.tokens import default_token_generator
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
Expand Down Expand Up @@ -123,3 +127,17 @@ 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)
# Add a session identification to isolate multiple sessions of the same user
refresh["session_id"] = str(uuid.uuid4())

return {
"refresh": str(refresh),
"access": str(refresh.access_token),
}
4 changes: 4 additions & 0 deletions authentication/urls.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
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),
# 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/",
Expand Down
50 changes: 37 additions & 13 deletions authentication/views/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
send_password_reset_email,
check_password_reset,
SignupInviteService,
get_tokens_for_user,
)
from projects.models import ProjectUserPermission
from projects.permissions import ObjectPermission
Expand Down Expand Up @@ -62,9 +63,9 @@ def login_api_view(request):
if not user:
raise ValidationError({"password": ["incorrect login / password"]})

token, _ = ApiKey.objects.get_or_create(user=user)
tokens = get_tokens_for_user(user)

return Response({"token": token.key, "user": UserPrivateSerializer(user).data})
return Response({"tokens": tokens, "user": UserPrivateSerializer(user).data})


@api_view(["POST"])
Expand Down Expand Up @@ -120,13 +121,12 @@ def signup_api_view(request):
)

is_active = user.is_active
token = None
tokens = None

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, _ = ApiKey.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)
Expand All @@ -137,8 +137,8 @@ def signup_api_view(request):
return Response(
{
"is_active": is_active,
"token": token,
"user": UserPrivateSerializer(user).data,
"tokens": tokens,
},
status=status.HTTP_201_CREATED,
)
Expand Down Expand Up @@ -169,14 +169,13 @@ def signup_simplified_api_view(request):
last_login=timezone.now(),
)

token_obj, _ = ApiKey.objects.get_or_create(user=user)
token = token_obj.key
tokens = get_tokens_for_user(user)

return Response(
{
"is_active": user.is_active,
"token": token,
"user": UserPrivateSerializer(user).data,
"tokens": tokens,
},
status=status.HTTP_201_CREATED,
)
Expand Down Expand Up @@ -210,9 +209,9 @@ def signup_activate_api_view(request):
token = serializer.validated_data["token"]

user = check_and_activate_user(user_id, token)
token, _ = ApiKey.objects.get_or_create(user=user)
tokens = get_tokens_for_user(user)

return Response({"token": token.key, "user": UserPrivateSerializer(user).data})
return Response({"tokens": tokens, "user": UserPrivateSerializer(user).data})


@api_view(["GET"])
Expand Down Expand Up @@ -253,9 +252,9 @@ def password_reset_confirm_api_view(request):
user.set_password(password)
user.save()

token, _ = ApiKey.objects.get_or_create(user=user)
tokens = get_tokens_for_user(user)

return Response({"token": token.key, "user": UserPrivateSerializer(user).data})
return Response({"tokens": tokens, "user": UserPrivateSerializer(user).data})

return Response(status=status.HTTP_204_NO_CONTENT)

Expand Down Expand Up @@ -290,3 +289,28 @@ def api_key_rotate_api_view(request):
api_key = ApiKey.objects.create(user=request.user)

return Response({"key": api_key.key}, status=status.HTTP_201_CREATED)


@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 = ApiKey.objects.get(key=token)
except ApiKey.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})
14 changes: 7 additions & 7 deletions authentication/views/social.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -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_tokens(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)
Expand Down
87 changes: 61 additions & 26 deletions front_end/src/app/(api)/api-proxy/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
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 {
AuthCookieManager,
AuthTokens,
getAuthCookieManager,
} from "@/services/auth_tokens";
import { getAlphaTokenSession } from "@/services/session";
import { getPublicSettings } from "@/utils/public_settings.server";

export async function GET(request: NextRequest) {
Expand Down Expand Up @@ -34,7 +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 authToken = shouldPassAuth ? await getServerSession() : null;
const authManager = await getAuthCookieManager();
const alphaToken = await getAlphaTokenSession();
const locale = includeLocale ? await getLocale() : "en";

Expand All @@ -58,33 +64,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 = (accessToken?: string): Headers => {
const headers = 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 ? authManager.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 && authManager.isAccessTokenExpired()) {
const refreshToken = authManager.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 = 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 = authManager.getRefreshToken();
if (refreshToken) {
const newTokens = await refreshWithSingleFlight(refreshToken);
if (newTokens) {
refreshedTokens = newTokens;
headers = buildHeaders(newTokens.access);
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) => {
Expand All @@ -97,11 +126,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) {
new AuthCookieManager(nextResponse.cookies).setAuthTokens(refreshedTokens);
}

return nextResponse;
}

// Normalize the Content-Disposition header to ensure the filename is quoted correctly
Expand Down
28 changes: 15 additions & 13 deletions front_end/src/app/(main)/accounts/actions.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
"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";

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";
Expand Down Expand Up @@ -71,7 +67,8 @@ export default async function loginAction(
};
}

await setServerSession(response.token);
const authManager = await getAuthCookieManager();
authManager.setAuthTokens(response.tokens);

// Set user's language preference as the active locale
if (response.user.language) {
Expand Down Expand Up @@ -135,8 +132,9 @@ export async function signUpAction(

const signUpActionState: SignUpActionState = { ...response };

if (response.is_active && response.token) {
await setServerSession(response.token);
if (response.is_active && response.tokens) {
const authManager = await getAuthCookieManager();
authManager.setAuthTokens(response.tokens);

// Set user's language preference as the active locale
if (response.user?.language) {
Expand Down Expand Up @@ -167,8 +165,11 @@ export async function signUpAction(
}

export async function LogOut() {
await deleteServerSession();
await deleteImpersonatorSession();
const authManager = await getAuthCookieManager();
authManager.clearAuthTokens();
authManager.clearImpersonatorRefreshToken();
// DEPRECATED: Remove after 30-day migration period
(await cookies()).delete("auth_token");
return redirect("/");
}

Expand Down Expand Up @@ -216,8 +217,9 @@ export async function simplifiedSignUpAction(
try {
const response = await ServerAuthApi.simplifiedSignUp(username, authToken);

if (response?.token) {
await setServerSession(response.token);
if (response && response.tokens) {
const authManager = await getAuthCookieManager();
authManager.setAuthTokens(response.tokens);
}
return response;
} catch (err: unknown) {
Expand Down
Loading