diff --git a/api/src/Services/TokenService.cs b/api/src/Services/TokenService.cs index 9ebf761..9d19752 100644 --- a/api/src/Services/TokenService.cs +++ b/api/src/Services/TokenService.cs @@ -9,7 +9,16 @@ namespace WinStudentGoalTracker.Services; public class TokenService { private readonly IConfiguration _config; - private readonly int _tokenExpiryInSeconds = 60 * 15; // 15 minutes + + // Temporary lowered to 1 minute expiration to test front end auth management + // and see if we get any random logouts or if token refresh is working as intended. + private readonly int _tokenExpiryInSeconds = 60; // 1 minute + + // This is for the temporary non program scoped token that is granted at login + // and is only used for the selection of a program. + + // In theory we have a bug here if someone sits at the program selection screen for more + // Than 5 minutes, the program selection will fail and they will be logged out. private readonly int _sessionTokenExpiryInSeconds = 60 * 5; // 5 minutes public TokenService(IConfiguration config) diff --git a/ui/winstudentgoaltracker/src/app/shared/services/auth.ts b/ui/winstudentgoaltracker/src/app/shared/services/auth.ts index 9f59f84..c270f11 100644 --- a/ui/winstudentgoaltracker/src/app/shared/services/auth.ts +++ b/ui/winstudentgoaltracker/src/app/shared/services/auth.ts @@ -1,6 +1,6 @@ import { computed, inject, Injectable, signal } from '@angular/core'; import { Router } from '@angular/router'; -import { catchError, EMPTY, Observable, of, Subject, tap } from 'rxjs'; +import { catchError, finalize, Observable, of, shareReplay, Subject, tap } from 'rxjs'; import { AuthUser, LoginResponse, @@ -64,6 +64,7 @@ export class Auth { readonly sessionExpired$ = new Subject(); private refreshTimer: ReturnType | null = null; + private refreshInFlight$: Observable> | null = null; // --------------- Accessors --------------- @@ -138,15 +139,17 @@ export class Auth { return of({ success: false, message: 'No refresh token.' }); } - if (this._isRefreshing()) { - return EMPTY; + // If a refresh is already in flight, share the same observable + // so all callers wait for the single refresh and only one + // refresh token rotation happens on the backend. + if (this.refreshInFlight$) { + return this.refreshInFlight$; } this._isRefreshing.set(true); - return this.api.refreshToken({ refreshToken: token }).pipe( + this.refreshInFlight$ = this.api.refreshToken({ refreshToken: token }).pipe( tap((res) => { - this._isRefreshing.set(false); if (res.success && res.data) { this.storeTokens(res.data.jwt, res.data.newRefreshToken); this.scheduleRefresh(res.data.jwtExpiresIn); @@ -155,11 +158,17 @@ export class Auth { } }), catchError(() => { - this._isRefreshing.set(false); this.forceLogout(); return of({ success: false, message: 'Token refresh failed.' } as ResponseResult); }), + finalize(() => { + this._isRefreshing.set(false); + this.refreshInFlight$ = null; + }), + shareReplay(1), ); + + return this.refreshInFlight$; } // --------------- Logout --------------- @@ -238,6 +247,7 @@ export class Auth { private clearState(): void { this.clearRefreshTimer(); + this.refreshInFlight$ = null; localStorage.removeItem(STORAGE_KEYS.JWT); localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN); localStorage.removeItem(STORAGE_KEYS.SESSION_TOKEN);