The Angular authentication flow is the bridge between the user’s credentials and the in-memory access token. The AuthService manages the complete session lifecycle — login, token storage, automatic refresh scheduling, session restoration on page reload, and logout. Components and guards interact with AuthService through read-only computed signals, never directly with the token storage.
Complete AuthService
// ── auth.service.ts ────────────────────────────────────────────────────────
@Injectable({ providedIn: 'root' })
export class AuthService {
private http = inject(HttpClient);
private router = inject(Router);
private config = inject(APP_CONFIG);
// ── In-memory token storage (XSS-safe) ───────────────────────────────
private _accessToken = signal<string | null>(null);
private _claims = signal<JwtClaims | null>(null);
private _refreshTimer?: ReturnType<typeof setTimeout>;
// ── Public read-only state ────────────────────────────────────────────
readonly accessToken = this._accessToken.asReadonly();
readonly currentUser = this._claims.asReadonly();
readonly isLoggedIn = computed(() => this._claims() !== null);
readonly displayName = computed(() => this._claims()?.displayName ?? '');
readonly userRoles = computed(() => this._claims()?.roles ?? []);
hasRole = (role: string) => this.userRoles().includes(role);
// ── Login ─────────────────────────────────────────────────────────────
login(email: string, password: string): Observable<void> {
return this.http.post<AuthResponse>(
`${this.config.apiUrl}/api/auth/login`,
{ email, password },
{ withCredentials: true } // receive httpOnly refresh token cookie
).pipe(
tap(response => this.setSession(response)),
map(() => undefined),
);
}
// ── Logout ────────────────────────────────────────────────────────────
logout(): Observable<void> {
return this.http.post<void>(
`${this.config.apiUrl}/api/auth/logout`, {},
{ withCredentials: true }
).pipe(
finalize(() => {
this.clearSession();
this.router.navigate(['/auth/login']);
}),
catchError(() => {
this.clearSession(); // clear locally even if API call fails
this.router.navigate(['/auth/login']);
return of(undefined);
}),
);
}
// ── Silent session restore on page refresh ────────────────────────────
tryRestoreSession(): Promise<boolean> {
return firstValueFrom(
this.http.post<AuthResponse>(
`${this.config.apiUrl}/api/auth/refresh`, {},
{ withCredentials: true }
).pipe(
tap(response => this.setSession(response)),
map(() => true),
catchError(() => of(false)),
)
);
}
// ── Refresh (called by interceptor on 401) ────────────────────────────
refreshToken(): Observable<string> {
return this.http.post<AuthResponse>(
`${this.config.apiUrl}/api/auth/refresh`, {},
{ withCredentials: true }
).pipe(
tap(response => this.setSession(response)),
map(response => response.accessToken),
);
}
// ── Private helpers ───────────────────────────────────────────────────
private setSession(response: AuthResponse): void {
this._accessToken.set(response.accessToken);
this._claims.set(this.decodeJwt(response.accessToken));
this.scheduleRefresh(response.expiresIn);
}
private clearSession(): void {
this._accessToken.set(null);
this._claims.set(null);
if (this._refreshTimer) clearTimeout(this._refreshTimer);
}
private decodeJwt(token: string): JwtClaims | null {
try {
const [, payload] = token.split('.');
return JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
} catch { return null; }
}
private scheduleRefresh(expiresInSeconds: number): void {
if (this._refreshTimer) clearTimeout(this._refreshTimer);
// Refresh 60s before expiry
const refreshInMs = Math.max(0, (expiresInSeconds - 60) * 1000);
this._refreshTimer = setTimeout(() =>
this.refreshToken().subscribe(), refreshInMs);
}
}
// ── app.config.ts — APP_INITIALIZER for session restore ──────────────────
export const appConfig: ApplicationConfig = {
providers: [
{
provide: APP_INITIALIZER,
useFactory: (auth: AuthService) => () => auth.tryRestoreSession(),
deps: [AuthService],
multi: true,
},
...
],
};
APP_INITIALIZER that calls tryRestoreSession() runs before the application renders any routes. This means the user’s session is restored from the httpOnly cookie before the router activates, so route guards can correctly check isLoggedIn() and hasRole() without a race condition. Without the initializer, the guard runs with isLoggedIn() = false before the session restore completes, redirecting the user to login even when they have a valid session.scheduleRefresh() timer sets up automatic token renewal 60 seconds before expiry. This means users with open browser tabs automatically get fresh tokens without any user interaction — the session stays alive as long as the tab is open and the refresh token is valid. Set a maximum session duration by configuring the refresh token’s expiry in the API (e.g., 30 days) and requiring re-login after that period regardless of activity.decodeJwt() method decodes the JWT payload without signature verification — it simply base64-decodes the payload. This is safe because the JWT is used only for display purposes on the client (show the user’s name, determine which routes to show). The actual security is enforced by the API, which validates the JWT signature on every authenticated request. Never make security decisions in Angular based solely on decoded JWT claims — the API is the authority.Common Mistakes
Mistake 1 — Missing APP_INITIALIZER for session restore (guards fail on page refresh)
❌ Wrong — no session restore; user refreshes page; isLoggedIn() is false; guard redirects to login despite valid session.
✅ Correct — APP_INITIALIZER calls tryRestoreSession() before routing begins; session restored before guards run.
Mistake 2 — Scheduling refresh without clearing previous timer (multiple timers accumulate)
❌ Wrong — each token refresh schedules a new timer without clearing the previous one; multiple refresh calls fire.
✅ Correct — always clearTimeout(this._refreshTimer) before setting a new timer in scheduleRefresh().