Search code examples
angularasp.net-corecookiesjwtidentityserver4

Storing JWT token into HttpOnly cookies


I read a few articles that local storage is not the preferred way to store JWT tokens, because it's not meant to be used for session storage, because you can access it easily through JavaScript code which might lead to XSS itself if there is a vulnerable third-party library or something.

Summarized from these articles, the correct way is to use HttpOnly cookies instead of local storage for session/sensitive information.

Question 1

I found a service for the cookies just like the one I'm currently using for local storage. What is unclear to me is expires=Thu, 1 Jan 1990 12:00:00 UTC; path=/;`. Does it really have to expire at some point? I just need to store my JWT and refresh tokens. The whole information is in there.

import { Injectable } from '@angular/core';

/**
 * Handles all business logic relating to setting and getting local storage items.
 */
@Injectable({
  providedIn: 'root'
})
export class LocalStorageService {
  setItem(key: string, value: any): void {
    localStorage.setItem(key, JSON.stringify(value));
  }

  getItem<T>(key: string): T | null {
    const item: string | null = localStorage.getItem(key);
    return item !== null ? (JSON.parse(item) as T) : null;
  }

  removeItem(key: string): void {
    localStorage.removeItem(key);
  }
}
import { Inject, Injectable } from '@angular/core';

@Injectable({
    providedIn: 'root',
  })
export class AppCookieService {
    private cookieStore = {};

    constructor() {
        this.parseCookies(document.cookie);
    }

    public parseCookies(cookies = document.cookie) {
        this.cookieStore = {};
        if (!!cookies === false) { return; }
        const cookiesArr = cookies.split(';');
        for (const cookie of cookiesArr) {
            const cookieArr = cookie.split('=');
            this.cookieStore[cookieArr[0].trim()] = cookieArr[1];
        }
    }

    get(key: string) {
        this.parseCookies();
        return !!this.cookieStore[key] ? this.cookieStore[key] : null;
    }

    remove(key: string) {
      document.cookie = `${key} = ; expires=Thu, 1 jan 1990 12:00:00 UTC; path=/`;
    }

    set(key: string, value: string) {
        document.cookie = key + '=' + (value || '');
    }
}

Question 2

Look at the log out function signOut(). Isn't it a better practice to revoke the JWT token in the backend (additional subscription to the backend)?

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import { map, Observable, of } from 'rxjs';

import { JwtHelperService } from '@auth0/angular-jwt';
import { environment } from '@env';
import { LocalStorageService } from '@core/services';
import { AuthResponse, User } from '@core/types';

@Injectable({
  providedIn: 'root'
})
export class AuthService {
  private readonly ACTION_URL = `${environment.apiUrl}/Accounts/token`;

  private jwtHelperService: JwtHelperService;

  get userInfo(): User | null {
    const accessToken = this.getAccessToken();
    return accessToken ? this.jwtHelperService.decodeToken(accessToken) : null;
  }

  constructor(
    private httpClient: HttpClient,
    private router: Router,
    private localStorageService: LocalStorageService
  ) {
    this.jwtHelperService = new JwtHelperService();
  }

  signIn(credentials: { username: string; password: string }): Observable<AuthResponse> {
    return this.httpClient.post<AuthResponse>(`${this.ACTION_URL}/create`, credentials).pipe(
      map((response: AuthResponse) => {
        this.setUser(response);
        return response;
      })
    );
  }

  refreshToken(): Observable<AuthResponse | null> {
    const refreshToken = this.getRefreshToken();
    if (!refreshToken) {
      this.clearUser();
      return of(null);
    }

    return this.httpClient.post<AuthResponse>(`${this.ACTION_URL}/refresh`, { refreshToken }).pipe(
      map((response) => {
        this.setUser(response);
        return response;
      })
    );
  }

  signOut(): void {
    this.clearUser();
    this.router.navigate(['/auth']);
  }

  getAccessToken(): string | null {
    return this.localStorageService.getItem('accessToken');
  }

  getRefreshToken(): string | null {
    return this.localStorageService.getItem('refreshToken');
  }

  hasAccessTokenExpired(token: string): boolean {
    return this.jwtHelperService.isTokenExpired(token);
  }

  isSignedIn(): boolean {
    return this.getAccessToken() ? true : false;
  }

  private setUser(response: AuthResponse): void {
    this.localStorageService.setItem('accessToken', response.accessToken);
    this.localStorageService.setItem('refreshToken', response.refreshToken);
  }

  private clearUser() {
    this.localStorageService.removeItem('accessToken');
    this.localStorageService.removeItem('refreshToken');
  }
}

Question 3

My backend is ASP.NET Core 5 and I'm using IdentityServer4. I'm not sure if I have to make the backend validate the cookies or how does it work?

services.AddIdentityServer(options =>
{
    options.Events.RaiseErrorEvents = true;
    options.Events.RaiseInformationEvents = true;
    options.Events.RaiseFailureEvents = true;
    options.Events.RaiseSuccessEvents = true;
    
    options.EmitStaticAudienceClaim = true;
})
    .AddDeveloperSigningCredential()
    .AddInMemoryIdentityResources(Configuration.GetIdentityResources())
    .AddInMemoryApiScopes(Configuration.GetApiScopes(configuration))
    .AddInMemoryApiResources(Configuration.GetApiResources(configuration))
    .AddInMemoryClients(Configuration.GetClients(configuration))
    .AddCustomUserStore();

services.AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
    .AddIdentityServerAuthentication(options =>
    {
        options.Authority = configuration["AuthConfiguration:ClientUrl"];
        options.RequireHttpsMetadata = false;
        options.RoleClaimType = "role";
        
        options.ApiName = configuration["AuthConfiguration:ApiName"];
        options.SupportedTokens = SupportedTokens.Jwt;
        options.JwtValidationClockSkew = TimeSpan.FromTicks(TimeSpan.TicksPerMinute);
    });

Solution

    1. You want your backend to set HttpOnly cookie with refresh token. So you'll have a POST endpoint where you post your user credentials and this endpoint returns refresh token in HttpOnly cookie and accessToken can be returned in request body as a regular JSON property. Here's example of how to set a cookie in response:

          var cookieOptions = new CookieOptions
          {
              HttpOnly = true,
              Expires = DateTime.UtcNow.AddDays(7),
              SameSite = SameSiteMode.None,
              Secure = true
          };
          Response.Cookies.Append("refreshToken", token, cookieOptions);
      
    2. Once you have HttpCookie with refresh token you can pass it to a dedicated API endpoint to rotate access token. This endpoint can actually also rotate refresh token as a security best practice. Here's how you can check if you have an HttpCookie in your request:

          var refreshToken = Request.Cookies["refreshToken"];
          if (string.IsNullOrEmpty(refreshToken))
          {
              return BadRequest(new { Message = "Invalid token" });
          }
      
    3. Your access token should be short lived, for example, 15-20 minutes. It means that you want to proactively rotate it shortly before it expires to make sure authenticated user won't get logged out. You can use setInterval function in JavaScript to build this refresh functionality.

    4. Your refresh token can live longer, but it shouldn't be non-expiring. Also, it's really good idea to rotate refresh token on access token refresh as mentioned in point 2.

    5. Your access token doesn't need to be stored anywhere like local/session storage or cookie. You can simply keep it in some SPA service which lives as long as single page isn't reloaded. If it's reloaded by a user you just rotate tokens during initial load (remember HttpOnly cookie is stuck to your domain and is available to your browser as a resource) and once you have an access token you can put it to authorization header for each backend request.

    6. You'll need to persist issued refreshed tokens somewhere (relational database or key-value store) to be able to verify them, keep track on expiration and revoke if needed.