diff --git a/ui/winstudentgoaltracker/src/app/app.config.ts b/ui/winstudentgoaltracker/src/app/app.config.ts index d953f4c..b7cb093 100644 --- a/ui/winstudentgoaltracker/src/app/app.config.ts +++ b/ui/winstudentgoaltracker/src/app/app.config.ts @@ -1,12 +1,15 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; +import { authInterceptor } from './interceptors/auth.interceptor'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideZoneChangeDetection({ eventCoalescing: true }), - provideRouter(routes) + provideRouter(routes), + provideHttpClient(withInterceptors([authInterceptor])), ] }; diff --git a/ui/winstudentgoaltracker/src/app/interceptors/auth.interceptor.ts b/ui/winstudentgoaltracker/src/app/interceptors/auth.interceptor.ts new file mode 100644 index 0000000..3506fd9 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/interceptors/auth.interceptor.ts @@ -0,0 +1,48 @@ +import { HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { catchError, switchMap, throwError } from 'rxjs'; +import { Auth } from '../services/auth'; + +/** + * Functional HTTP interceptor that: + * 1. Attaches the current JWT (or session token during phase 1) to outgoing requests. + * 2. On a 401 response, attempts a single token refresh then retries the original request. + * 3. If the refresh also fails, forces logout. + */ +export const authInterceptor: HttpInterceptorFn = ( + req: HttpRequest, + next: HttpHandlerFn, +) => { + const auth = inject(Auth); + + // Skip token attachment for the refresh-token endpoint to avoid circular 401 loops. + const isRefreshRequest = req.url.includes('/Auth/RefreshToken'); + const cloned = isRefreshRequest ? req : attachToken(req, auth); + + return next(cloned).pipe( + catchError((error) => { + if (error.status === 401 && !isRefreshRequest && auth.refreshToken) { + return auth.doRefresh().pipe( + switchMap((res) => { + if (res.success) { + return next(attachToken(req, auth)); + } + return throwError(() => error); + }), + catchError(() => throwError(() => error)), + ); + } + + return throwError(() => error); + }), + ); +}; + +function attachToken(req: HttpRequest, auth: Auth): HttpRequest { + const token = auth.jwt ?? auth.sessionToken; + if (!token) return req; + + return req.clone({ + setHeaders: { Authorization: `Bearer ${token}` }, + }); +} diff --git a/ui/winstudentgoaltracker/src/app/models/auth.models.ts b/ui/winstudentgoaltracker/src/app/models/auth.models.ts new file mode 100644 index 0000000..549fc81 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/models/auth.models.ts @@ -0,0 +1,62 @@ +// Generic API response wrapper — matches C# ResponseResult +export interface ResponseResult { + success: boolean; + message: string; + data?: T; +} + +// Phase 1: Login +export interface LoginRequest { + email: string; + password: string; +} + +export interface LoginResponse { + sessionToken: string; + programs: UserProgramSummary[]; +} + +export interface UserProgramSummary { + programId: string; + programName: string; + role: string; + roleDisplayName: string; + isPrimary: boolean; +} + +// Phase 2: Select Program +export interface SelectProgramRequest { + programId: string; +} + +export interface SelectProgramResponse { + userId: string; + email: string; + programName: string; + jwt: string; + refreshToken: string; + role: string; + roleDisplayName: string; + jwtExpiresIn: number; +} + +// Token Refresh +export interface RefreshTokenRequest { + refreshToken: string; +} + +export interface TokenRefreshResponse { + jwt: string; + newRefreshToken: string; + jwtExpiresIn: number; +} + +// Auth state exposed by the Auth service +export interface AuthUser { + userId: string; + email: string; + programId: string; + programName: string; + role: string; + roleDisplayName: string; +} diff --git a/ui/winstudentgoaltracker/src/app/services/api.spec.ts b/ui/winstudentgoaltracker/src/app/services/api.spec.ts new file mode 100644 index 0000000..5824fb1 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/services/api.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { Api } from './api'; + +describe('Api', () => { + let service: Api; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(Api); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/ui/winstudentgoaltracker/src/app/services/api.ts b/ui/winstudentgoaltracker/src/app/services/api.ts new file mode 100644 index 0000000..85a2189 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/services/api.ts @@ -0,0 +1,53 @@ +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { environment } from '../../environments/environment'; +import { + LoginRequest, + LoginResponse, + RefreshTokenRequest, + ResponseResult, + SelectProgramRequest, + SelectProgramResponse, + TokenRefreshResponse, +} from '../models/auth.models'; + +@Injectable({ + providedIn: 'root', +}) +export class Api { + private readonly http = inject(HttpClient); + private readonly base = environment.apiBaseUrl; + + // Phase 1 — verify credentials, receive session token + program list + login(request: LoginRequest): Observable> { + return this.http.post>( + `${this.base}/api/Auth/Login`, + request, + ); + } + + // Phase 2 — select a program, receive JWT + refresh token + selectProgram(request: SelectProgramRequest): Observable> { + return this.http.post>( + `${this.base}/api/Auth/SelectProgram`, + request, + ); + } + + // Exchange a refresh token for a new JWT + rotated refresh token + refreshToken(request: RefreshTokenRequest): Observable> { + return this.http.post>( + `${this.base}/api/Auth/RefreshToken`, + request, + ); + } + + // Revoke the refresh token and log out + logout(request: RefreshTokenRequest): Observable> { + return this.http.post>( + `${this.base}/api/Auth/Logout`, + request, + ); + } +} diff --git a/ui/winstudentgoaltracker/src/app/services/auth.spec.ts b/ui/winstudentgoaltracker/src/app/services/auth.spec.ts new file mode 100644 index 0000000..3a04d76 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/services/auth.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { Auth } from './auth'; + +describe('Auth', () => { + let service: Auth; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(Auth); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/ui/winstudentgoaltracker/src/app/services/auth.ts b/ui/winstudentgoaltracker/src/app/services/auth.ts new file mode 100644 index 0000000..b2c3b5d --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/services/auth.ts @@ -0,0 +1,265 @@ +import { computed, inject, Injectable, signal } from '@angular/core'; +import { Router } from '@angular/router'; +import { catchError, EMPTY, Observable, of, Subject, tap } from 'rxjs'; +import { + AuthUser, + LoginResponse, + ResponseResult, + SelectProgramResponse, + TokenRefreshResponse, + UserProgramSummary, +} from '../models/auth.models'; +import { Api } from './api'; + +const STORAGE_KEYS = { + JWT: 'auth_jwt', + REFRESH_TOKEN: 'auth_refresh_token', + SESSION_TOKEN: 'auth_session_token', + USER: 'auth_user', +} as const; + +// Refresh the JWT this many seconds before it actually expires. +const REFRESH_BUFFER_SECONDS = 60; + +@Injectable({ + providedIn: 'root', +}) +export class Auth { + private readonly api = inject(Api); + private readonly router = inject(Router); + + // --------------- Reactive state (signals) --------------- + + private readonly _user = signal(this.loadUser()); + private readonly _sessionToken = signal(this.loadSessionToken()); + private readonly _programs = signal([]); + private readonly _isRefreshing = signal(false); + + /** The currently authenticated user (null when logged out). */ + readonly user = this._user.asReadonly(); + + /** True when the user has completed both login phases and holds a valid JWT. */ + readonly isAuthenticated = computed(() => this._user() !== null && !!this.jwt); + + /** True while login phase 1 has succeeded and the user is choosing a program. */ + readonly isSelectingProgram = computed(() => this._sessionToken() !== null); + + /** Programs returned by phase 1 for the user to choose from. */ + readonly programs = this._programs.asReadonly(); + + /** Emits when a token refresh fails and the user is forced to re-login. */ + readonly sessionExpired$ = new Subject(); + + private refreshTimer: ReturnType | null = null; + + // --------------- Accessors --------------- + + /** Current JWT (access token). */ + get jwt(): string | null { + return localStorage.getItem(STORAGE_KEYS.JWT); + } + + /** Current session token (phase 1 only). */ + get sessionToken(): string | null { + return localStorage.getItem(STORAGE_KEYS.SESSION_TOKEN); + } + + /** Current refresh token. */ + get refreshToken(): string | null { + return localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN); + } + + /** Whether a token refresh is currently in flight. */ + get isRefreshing(): boolean { + return this._isRefreshing(); + } + + // --------------- Phase 1: Login --------------- + + /** + * Verify credentials. On success the response contains a short-lived + * session token and the list of programs the user belongs to. + * + * After calling this, present the program list and call `selectProgram()`. + */ + login(email: string, password: string): Observable> { + return this.api.login({ email, password }).pipe( + tap((res) => { + if (res.success && res.data) { + localStorage.setItem(STORAGE_KEYS.SESSION_TOKEN, res.data.sessionToken); + this._sessionToken.set(res.data.sessionToken); + this._programs.set(res.data.programs); + } + }), + ); + } + + // --------------- Phase 2: Select Program --------------- + + /** + * Complete login by choosing a program. On success the service stores + * the JWT + refresh token, starts the proactive refresh timer, + * and populates the `user` signal. + */ + selectProgram(programId: string): Observable> { + return this.api.selectProgram({ programId }).pipe( + tap((res) => { + if (res.success && res.data) { + this.handleFullAuth(res.data); + } + }), + ); + } + + // --------------- Token Refresh --------------- + + /** + * Manually trigger a token refresh. Normally you don't need this — + * the proactive timer and the 401 interceptor handle it automatically. + * + * Returns the API response so callers can react to failures. + */ + doRefresh(): Observable> { + const token = this.refreshToken; + if (!token) { + this.forceLogout(); + return of({ success: false, message: 'No refresh token.' }); + } + + if (this._isRefreshing()) { + return EMPTY; + } + + this._isRefreshing.set(true); + + return 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); + } else { + this.forceLogout(); + } + }), + catchError(() => { + this._isRefreshing.set(false); + this.forceLogout(); + return of({ success: false, message: 'Token refresh failed.' } as ResponseResult); + }), + ); + } + + // --------------- Logout --------------- + + /** Log out: revoke the refresh token on the server, then clear local state. */ + logout(): Observable> { + const token = this.refreshToken; + this.clearState(); + + if (!token) { + return of({ success: true, message: 'Logged out.' }); + } + + return this.api.logout({ refreshToken: token }).pipe( + catchError(() => of({ success: true, message: 'Logged out locally.' })), + ); + } + + // --------------- Internals --------------- + + /** + * Called by the 401 interceptor when a refresh fails irrecoverably. + * Clears all auth state and redirects to login. + */ + forceLogout(): void { + this.clearState(); + this.sessionExpired$.next(); + this.router.navigateByUrl('/login'); + } + + private handleFullAuth(data: SelectProgramResponse): void { + const user: AuthUser = { + userId: data.userId, + email: data.email, + programId: data.jwt ? this.extractClaim(data.jwt, 'program_id') ?? '' : '', + programName: data.programName, + role: data.role, + roleDisplayName: data.roleDisplayName, + }; + + // Persist + this.storeTokens(data.jwt, data.refreshToken); + localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(user)); + + // Clear phase-1 artefacts + localStorage.removeItem(STORAGE_KEYS.SESSION_TOKEN); + this._sessionToken.set(null); + this._programs.set([]); + + // Update reactive state + this._user.set(user); + + // Start proactive refresh + this.scheduleRefresh(data.jwtExpiresIn); + } + + private storeTokens(jwt: string, refreshToken: string): void { + localStorage.setItem(STORAGE_KEYS.JWT, jwt); + localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken); + } + + private scheduleRefresh(expiresInSeconds: number): void { + this.clearRefreshTimer(); + const delayMs = Math.max((expiresInSeconds - REFRESH_BUFFER_SECONDS) * 1000, 0); + this.refreshTimer = setTimeout(() => { + this.doRefresh().subscribe(); + }, delayMs); + } + + private clearRefreshTimer(): void { + if (this.refreshTimer !== null) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + } + + private clearState(): void { + this.clearRefreshTimer(); + localStorage.removeItem(STORAGE_KEYS.JWT); + localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN); + localStorage.removeItem(STORAGE_KEYS.SESSION_TOKEN); + localStorage.removeItem(STORAGE_KEYS.USER); + this._user.set(null); + this._sessionToken.set(null); + this._programs.set([]); + this._isRefreshing.set(false); + } + + private loadUser(): AuthUser | null { + try { + const raw = localStorage.getItem(STORAGE_KEYS.USER); + return raw ? (JSON.parse(raw) as AuthUser) : null; + } catch { + return null; + } + } + + private loadSessionToken(): string | null { + return localStorage.getItem(STORAGE_KEYS.SESSION_TOKEN); + } + + /** + * Decode a single claim from a JWT without pulling in a library. + * Returns null if the claim is missing or the token is malformed. + */ + private extractClaim(jwt: string, claim: string): string | null { + try { + const payload = jwt.split('.')[1]; + const decoded = JSON.parse(atob(payload)); + return decoded[claim] ?? null; + } catch { + return null; + } + } +} diff --git a/ui/winstudentgoaltracker/src/environments/environment.development.ts b/ui/winstudentgoaltracker/src/environments/environment.development.ts new file mode 100644 index 0000000..58d4a99 --- /dev/null +++ b/ui/winstudentgoaltracker/src/environments/environment.development.ts @@ -0,0 +1,4 @@ +export const environment = { + production: false, + apiBaseUrl: 'http://localhost:5104', +}; diff --git a/ui/winstudentgoaltracker/src/environments/environment.ts b/ui/winstudentgoaltracker/src/environments/environment.ts new file mode 100644 index 0000000..3ffb8c7 --- /dev/null +++ b/ui/winstudentgoaltracker/src/environments/environment.ts @@ -0,0 +1,4 @@ +export const environment = { + production: true, + apiBaseUrl: 'https://api.example.com', +};