mirror of
https://github.com/opelly27/WinStudentGoalTracker.git
synced 2026-05-20 12:17:35 +00:00
auth service and API service added
This commit is contained in:
@@ -1,12 +1,15 @@
|
|||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
|
||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
|
import { authInterceptor } from './interceptors/auth.interceptor';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||||
provideRouter(routes)
|
provideRouter(routes),
|
||||||
|
provideHttpClient(withInterceptors([authInterceptor])),
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<unknown>,
|
||||||
|
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<unknown>, auth: Auth): HttpRequest<unknown> {
|
||||||
|
const token = auth.jwt ?? auth.sessionToken;
|
||||||
|
if (!token) return req;
|
||||||
|
|
||||||
|
return req.clone({
|
||||||
|
setHeaders: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Generic API response wrapper — matches C# ResponseResult<T>
|
||||||
|
export interface ResponseResult<T> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<ResponseResult<LoginResponse>> {
|
||||||
|
return this.http.post<ResponseResult<LoginResponse>>(
|
||||||
|
`${this.base}/api/Auth/Login`,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2 — select a program, receive JWT + refresh token
|
||||||
|
selectProgram(request: SelectProgramRequest): Observable<ResponseResult<SelectProgramResponse>> {
|
||||||
|
return this.http.post<ResponseResult<SelectProgramResponse>>(
|
||||||
|
`${this.base}/api/Auth/SelectProgram`,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange a refresh token for a new JWT + rotated refresh token
|
||||||
|
refreshToken(request: RefreshTokenRequest): Observable<ResponseResult<TokenRefreshResponse>> {
|
||||||
|
return this.http.post<ResponseResult<TokenRefreshResponse>>(
|
||||||
|
`${this.base}/api/Auth/RefreshToken`,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke the refresh token and log out
|
||||||
|
logout(request: RefreshTokenRequest): Observable<ResponseResult<object>> {
|
||||||
|
return this.http.post<ResponseResult<object>>(
|
||||||
|
`${this.base}/api/Auth/Logout`,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<AuthUser | null>(this.loadUser());
|
||||||
|
private readonly _sessionToken = signal<string | null>(this.loadSessionToken());
|
||||||
|
private readonly _programs = signal<UserProgramSummary[]>([]);
|
||||||
|
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<void>();
|
||||||
|
|
||||||
|
private refreshTimer: ReturnType<typeof setTimeout> | 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<ResponseResult<LoginResponse>> {
|
||||||
|
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<ResponseResult<SelectProgramResponse>> {
|
||||||
|
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<ResponseResult<TokenRefreshResponse>> {
|
||||||
|
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<TokenRefreshResponse>);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------- Logout ---------------
|
||||||
|
|
||||||
|
/** Log out: revoke the refresh token on the server, then clear local state. */
|
||||||
|
logout(): Observable<ResponseResult<object>> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: false,
|
||||||
|
apiBaseUrl: 'http://localhost:5104',
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: true,
|
||||||
|
apiBaseUrl: 'https://api.example.com',
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user