Search code examples
c#reactjsfirebaseauthenticationtoken

Firebase Token Expired and 401 Unauthorized on ASP.NET API Call


I'm working on a project with a React frontend using Firebase authentication and a C# backend API. The frontend retrieves and sends the Firebase ID token for authentication, but I'm encountering a 401 Unauthorized error with the message "Firebase ID token expired" on an authenticated request.

Frontend Code (AuthContext.js):

  • makeAuthenticatedRequest function handles authenticated API calls and attempts to refresh the token on failure.
  • getFreshToken manages token refreshing with a rate-limit of 5 minutes
 // AuthContext.js
  const getFreshToken = async (force = false) => {
    if (!user) return null;

    const now = Date.now();
    if (force || now - lastTokenRefresh > 5 * 60 * 1000) {
      try {
        const token = await user.getIdToken(true);
        setLastTokenRefresh(now);
        return token;
      } catch (error) {
        console.error('Error refreshing token:', error);
        return null;
      }
    }

    return user.getIdToken();
  };

  const makeAuthenticatedRequest = async (url, options = {}, retryCount = 0) => {
    if (!user) {
      throw new Error('User not authenticated');
    }

    try {
      const token = await getFreshToken(retryCount > 0);
      if (!token) throw new Error('Failed to get authentication token');

      const response = await fetch(url, {
        ...options,
        headers: {
          ...options.headers,
          'Authorization': `Bearer ${token}`,
          'Accept': 'application/json',
          'Content-Type': 'application/json'
        }
      });

      if (!response.ok) {
        const errorData = await response.json();
        
        if (errorData.message?.includes('expired') && retryCount < 2) {
          await new Promise(resolve => setTimeout(resolve, 1000));
          return makeAuthenticatedRequest(url, options, retryCount + 1);
        }
        
        throw new Error(JSON.stringify(errorData));
      }

      return await response.json();
    } catch (error) {
      console.error('API request failed:', error);
      throw error;
    }
  };

  useEffect(() => {
    const checkUserRole = async () => {
      if (user) {
        try {
          const response = await makeAuthenticatedRequest('http://localhost:5067/api/auth/protected');
          setIsAdmin(response.user.isAdmin);
        } catch (error) {
          console.error('Error checking user role:', error);
          setIsAdmin(false);
        }
      } else {
        setIsAdmin(false);
      }
    };

    checkUserRole();
  }, [user]);

  useEffect(() => {
    if (!user) return;

    const refreshInterval = setInterval(async () => {
      await getFreshToken(true);
    }, 10 * 60 * 1000);

    return () => clearInterval(refreshInterval);
  }, [user]);

Backend Code (AuthController.cs): The TestProtected endpoint verifies the token and checks user permissions.

// AuthController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using FirebaseAdmin.Auth;
using UniLostAndFound.API.Services;
using UniLostAndFound.API.Models;

namespace UniLostAndFound.API.Controllers;

[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
    private readonly ILogger<AuthController> _logger;
    private readonly FirestoreService _firestoreService;

    public AuthController(ILogger<AuthController> logger, FirestoreService firestoreService)
    {
        _logger = logger;
        _firestoreService = firestoreService;
    }

    [HttpGet("test")]
    public ActionResult TestPublic()
    {
        return Ok(new { message = "Public endpoint working!" });
    }

    [HttpGet("protected")]
    public async Task<ActionResult> TestProtected()
    {
        try
        {
            string authHeader = Request.Headers["Authorization"].ToString();
            _logger.LogInformation($"Auth Header received: {authHeader.Length} characters");

            if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer "))
            {
                return Unauthorized(new { message = "No Bearer token found" });
            }

            string idToken = authHeader.Substring("Bearer ".Length);

            try
            {
                var firebaseToken = await FirebaseAuth.DefaultInstance.VerifyIdTokenAsync(idToken);
                string email = firebaseToken.Claims.GetValueOrDefault("email", "").ToString();
                string name = firebaseToken.Claims.GetValueOrDefault("name", "").ToString();

                // Check if email is allowed
                if (!await _firestoreService.IsAllowedEmail(email))
                {
                    return Unauthorized(new { message = "Email domain not allowed" });
                }

                // Check if user is admin
                bool isAdmin = await _firestoreService.IsAdminEmail(email);

                return Ok(new 
                { 
                    message = "Authentication successful",
                    user = new
                    {
                        uid = firebaseToken.Uid,
                        email = email,
                        name = name,
                        isAdmin = isAdmin
                    }
                });
            }
            catch (FirebaseAuthException ex)
            {
                _logger.LogWarning($"Token verification failed: {ex.Message}");
                return Unauthorized(new { message = ex.Message });
            }
            catch (Exception ex)
            {
                _logger.LogError($"Unexpected error during token verification: {ex.Message}");
                return StatusCode(500, new { message = "Internal server error during authentication" });
            }
        }
        catch (Exception ex)
        {
            _logger.LogError($"Unexpected error: {ex.Message}");
            return StatusCode(500, new { message = "Internal server error" });
        }
    }
} 

Program.cs

// Update the JWT Bearer configuration
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        var projectId = "unilostandfound";
        options.Authority = $"https://securetoken.google.com/{projectId}";
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = $"https://securetoken.google.com/{projectId}",
            ValidateAudience = true,
            ValidAudience = projectId,
            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromMinutes(5)
        };
    });

Issues Encountered

  • 401 Unauthorized: When making authenticated requests from the frontend, I frequently receive a 401 error.
  • Token Expiration: The error message indicates that the Firebase ID token has expired. I thought the getFreshToken() function in my AuthContext.js would handle refreshing, but it seems to fail occasionally.

What I've Tried

  • Implementing a token refresh mechanism that attempts to get a new ID token if the current one is expired.
  • Retrying the API request once if a 401 error is encountered.

Honestly, I am pretty lost right now, I cannot pinpoint exactly what causing the token to expired. Why are the tokens expiring so quickly? Did I do the token handling wrong, if so, what is the best solution? Lastly, did I miss anything from my backend? Thank you.


Solution

  • Fixed It! At first I thought it was a mismatch in server-client time but it wasn't.

    What I did was I fixed my token refresh.

    I set up an interval to refresh the user's token every 30 minutes. This ensures that the token remains valid during active sessions. Here’s the code I used:

          useEffect(() => {
            if (!user) return;
            // Force token refresh every 30 minutes
            const refreshInterval = setInterval(async () => {
              try {
                await user.getIdToken(true);
                console.log("[Auth] Token refreshed by interval");
              } catch (error) {
                console.error("[Auth] Token refresh failed:", error);
              }
            }, 30 * 60 * 1000); // 30 minutes
        
            return () => clearInterval(refreshInterval);
          }, [user]);