Analysis Date: 2026-03-05
Application: TMI-UX Angular Application
Scope: Network-accessible routes and endpoints only
The TMI-UX Angular application implements a multi-layered authorization system with the following characteristics:
- Authentication Layer: JWT-based authentication with OAuth2/SAML flows
- Role-Based Access Control (RBAC): Admin and Security Reviewer roles
- Resource-Level Authorization: Owner/Writer/Reader permissions for threat models
- Defense-in-Depth: Route guards, resolvers, HTTP interceptors, and component-level checks
Critical Finding: Authorization is primarily enforced on the frontend only with minimal backend validation enforcement visible. All authorization decisions rely on JWT token claims and cached user profiles, which creates potential bypass opportunities if the backend does not perform equivalent validation.
Location: /app/repos/tmi-ux/src/app/auth/guards/auth.guard.ts
Purpose: Protects routes requiring user authentication
Implementation:
export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService);
const router = inject(Router);
const logger = inject(LoggerService);
// Defense-in-depth: Synchronous token expiry check (lines 28-32)
authService.validateAndUpdateAuthState();
return authService.isAuthenticated$.pipe(
take(1),
map(isAuthenticated => {
if (isAuthenticated) {
return true;
} else {
void router.navigate(['/login'], {
queryParams: {
returnUrl: state.url,
reason: 'session_expired',
},
});
return false;
}
}),
);
};Key Characteristics:
- Validation Method: Checks
isAuthenticated$BehaviorSubject from AuthService - Token Validation: Synchronous token expiry check via
validateAndUpdateAuthState()(line 32) - On Failure: Redirects to
/loginwithreturnUrlandreason=session_expired - Critical Lines: 32 (token validation), 34-51 (observable check)
Weaknesses:
- Relies on client-side token expiry validation
- BehaviorSubject state can become stale if token expires while page is active
- No server-side validation at guard level (relies on HTTP interceptor)
Location: /app/repos/tmi-ux/src/app/auth/guards/admin.guard.ts
Purpose: Protects routes requiring administrator privileges
Implementation:
export const adminGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
const logger = inject(LoggerService);
return authService.refreshUserProfile().pipe(
map(userProfile => {
if (userProfile.is_admin) {
logger.info('Admin access granted');
return true;
} else {
logger.warn('Admin access denied: User is not an administrator');
void router.navigate([authService.getLandingPage()], {
queryParams: {
error: 'admin_required',
},
});
return false;
}
}),
catchError(error => {
logger.error('Failed to verify admin status', error);
void router.navigate([authService.getLandingPage()], {
queryParams: {
error: 'admin_check_failed',
},
});
return of(false);
}),
);
};Key Characteristics:
- Validation Method: Fresh API call to
GET /users/meviarefreshUserProfile()(line 26) - Role Check: Verifies
is_adminfield from server response (line 28) - On Failure: Redirects to landing page with
error=admin_requiredorerror=admin_check_failed - Critical Lines: 26 (API call), 28 (role check)
Strengths:
- Performs fresh server-side validation on every route activation
- Does not rely on cached JWT claims
- Default deny on error
Weaknesses:
- Additional network round-trip on every admin route navigation
- Race condition: User could navigate to admin route, then admin status revoked server-side before API call completes
Location: /app/repos/tmi-ux/src/app/auth/guards/reviewer.guard.ts
Purpose: Protects routes requiring security reviewer privileges
Implementation:
export const reviewerGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
const logger = inject(LoggerService);
return authService.refreshUserProfile().pipe(
map(userProfile => {
if (userProfile.is_security_reviewer) {
logger.info('Security reviewer access granted');
return true;
} else {
logger.warn('Security reviewer access denied: User is not a security reviewer');
void router.navigate([authService.getLandingPage()]);
return false;
}
}),
catchError(error => {
logger.error('Failed to verify security reviewer status', error);
void router.navigate([authService.getLandingPage()]);
return of(false);
}),
);
};Key Characteristics:
- Validation Method: Fresh API call to
GET /users/meviarefreshUserProfile()(line 26) - Role Check: Verifies
is_security_reviewerfield from server response (line 28) - On Failure: Redirects to landing page
- Critical Lines: 26 (API call), 28 (role check)
Identical to adminGuard in structure, different only in role checked
Location: /app/repos/tmi-ux/src/app/auth/guards/home.guard.ts
Purpose: Redirects authenticated users away from home page to their landing page
Implementation:
export const homeGuard: CanActivateFn = (_route, _state) => {
const authService = inject(AuthService);
const router = inject(Router);
const logger = inject(LoggerService);
// If user is authenticated, redirect to their role-based landing page
if (authService.isAuthenticated) {
logger.debugComponent('HomeGuard', 'User is authenticated, redirecting to landing page');
void router.navigate([authService.getLandingPage()]);
return false;
}
// Allow access to home page for unauthenticated users
return true;
};Key Characteristics:
- Validation Method: Checks
authService.isAuthenticatedsynchronous property (line 13) - On Success: Redirects to role-based landing page
- Critical Lines: 13 (authentication check), 15 (redirect)
Weakness:
- Uses synchronous
isAuthenticatedproperty instead of observable - No token expiry validation at this guard level
| Route Pattern | Guards Applied | Protected Resource |
|---|---|---|
/ |
homeGuard |
Home page (redirects if authenticated) |
/login |
None | Login page (public) |
/oauth2/callback |
None | OAuth callback (public) |
/unauthorized |
None | Unauthorized page (public) |
/about |
None | About page (public) |
/tos |
None | Terms of Service (public) |
/privacy |
None | Privacy Policy (public) |
/dashboard |
authGuard |
Threat model dashboard |
/admin |
authGuard |
Admin parent route |
/admin/* (all children) |
authGuard, adminGuard |
Admin section (users, groups, quotas, webhooks, addons, settings, surveys) |
/tm/* |
authGuard |
Threat model pages |
/intake/* |
authGuard |
Survey intake (respondent experience) |
/triage/* |
authGuard, reviewerGuard |
Triage section (security reviewer) |
Critical Finding: The /tm route applies authGuard to the parent but resource-level authorization is delegated to the resolver and component level.
| Route Pattern | Resolver | Notes |
|---|---|---|
/tm/:id |
threatModelResolver |
Threat model editor |
/tm/:id/threat/:threatId |
threatModelResolver |
Threat detail page |
/tm/:id/note/:noteId |
threatModelResolver |
Note detail page |
/tm/:id/dfd/:dfdId |
threatModelResolver |
Data flow diagram editor |
All threat model routes inherit authGuard from parent and use threatModelResolver for resource-level authorization.
Location: Applied via canActivate array in route configuration
Decision Type: Role-based (admin, reviewer) and authentication state
Enforcement: Client-side Angular router navigation prevention
Location: /app/repos/tmi-ux/src/app/pages/tm/resolvers/threat-model.resolver.ts
Decision Type: Resource-level authorization (threat model access)
Implementation:
export const threatModelResolver: ResolveFn<ThreatModel | null> = (
route,
state,
): Observable<ThreatModel | null> => {
const threatModelService = inject(ThreatModelService);
const authorizationService = inject(ThreatModelAuthorizationService);
const logger = inject(LoggerService);
const router = inject(Router);
const threatModelId = route.paramMap.get('id');
// Load threat model with forced refresh (line 56)
return threatModelService.getThreatModelById(threatModelId, forceRefresh).pipe(
tap(threatModel => {
if (threatModel) {
const userPermission = authorizationService.getCurrentUserPermission();
logger.info('User permission determined', {
threatModelId: threatModel.id,
permission: userPermission,
});
}
}),
catchError((error: unknown) => {
const httpError = error as { status?: number };
if (httpError.status === 403) {
// Navigate to dashboard with access_denied error (lines 79-84)
void router.navigate(['/dashboard'], {
queryParams: {
error: 'access_denied',
threat_model_id: threatModelId,
},
});
} else if (httpError.status === 401) {
// Navigate to dashboard with auth_required error (lines 92-97)
}
return EMPTY; // Prevents route activation
}),
);
};Key Points:
- Resolver fetches threat model from backend via
ThreatModelService.getThreatModelById()(line 56) - Backend returns 403 if user lacks permission → resolver redirects to dashboard
- Backend returns 401 if authentication fails → JWT interceptor attempts token refresh
- Resolver sets authorization state in
ThreatModelAuthorizationService(done inside service method) - Critical Lines: 56 (API call), 73-106 (error handling)
Enforcement: Combination of backend API response and client-side navigation prevention
Location: /app/repos/tmi-ux/src/app/auth/interceptors/jwt.interceptor.ts
Registered in: /app/repos/tmi-ux/src/app/app.config.ts (lines 345-355)
Interceptor Order:
JwtInterceptor- Adds Authorization header (line 347)HttpLoggingInterceptor- Logs requests/responses (line 352)
JWT Interceptor Implementation:
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
// Only add token to requests to our API that are not public endpoints (line 49)
if (this.isApiRequest(request.url) && !this.isPublicEndpoint(request.url)) {
return this.authService.getValidToken().pipe(
switchMap(token => {
const tokenizedRequest = request.clone({
setHeaders: {
Authorization: `Bearer ${token.token}`,
},
});
return next.handle(tokenizedRequest).pipe();
}),
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
return this.handleUnauthorizedErrorWithRefresh(request, next);
}
return this.handleError(error, request);
}),
);
}
// For public endpoints or non-API requests, just pass through
return next.handle(request).pipe(
catchError((error: HttpErrorResponse) => this.handleError(error, request))
);
}Public Endpoints (no auth required, lines 25-34):
//version/oauth2/callback/oauth2/providers/oauth2/refresh/oauth2/authorize/*/oauth2/token/*/saml/providers
401 Handling (lines 164-231):
- On 401 response, checks if request already has
IS_AUTH_RETRYcontext flag - If not retried yet, calls
authService.forceRefreshToken()to get new token - Clones request with new token and sets
IS_AUTH_RETRYflag to prevent infinite loops - Retries request with new token
- If retry also fails with 401, propagates error without logout
Critical Lines:
- 49 (public endpoint check)
- 51 (token retrieval)
- 61-65 (Authorization header injection)
- 105 (401 error handling)
- 200 (forced token refresh)
- 205 (retry with IS_AUTH_RETRY context)
Enforcement: Automatic token attachment and refresh, but does not validate user roles or permissions - relies entirely on backend to reject unauthorized requests
Example: /app/repos/tmi-ux/src/app/pages/tm/tm-edit.component.ts
Authorization Properties:
canEdit = false;
canManagePermissions = false;Subscription to Authorization Service (lines 624-633):
this.authorizationService.canEdit$.subscribe(canEdit => {
this.canEdit = canEdit;
});
this.authorizationService.canManagePermissions$.subscribe(canManage => {
this.canManagePermissions = canManage;
});Usage in Methods (examples):
// Line 903 - Delete threat
if (!this.threatModel || !this.canEdit) return;
// Line 942 - Delete document
if (!this.threatModel || !this.canEdit) return;
// Line 1255 - Edit threat
if (!this.canEdit) {
// Show read-only message
}
// Line 1407 - Edit note
if (!this.canEdit) {
// Show read-only message
}Enforcement: UI-level only - disables buttons, shows read-only messages, returns early from methods. Does not prevent API calls if bypassed.
JWT Payload Interface (lines 56-72 of auth.service.ts):
interface JwtPayload {
sub?: string; // Provider-assigned user ID (maps to provider_id)
email?: string;
name?: string; // Display name (maps to display_name)
iat?: number; // Issued at (timestamp)
exp?: number; // Expiration (timestamp)
idp?: string; // OAuth provider (e.g., "google", "github")
aud?: string; // Audience
iss?: string; // Issuer
groups?: string[]; // User group memberships
providers?: Array<{
provider: string;
is_primary: boolean;
}>;
tmi_is_administrator?: boolean; // Admin role flag
tmi_is_security_reviewer?: boolean; // Reviewer role flag
}Key Claims:
tmi_is_administrator- Admin role (used by adminGuard)tmi_is_security_reviewer- Security reviewer role (used by reviewerGuard)groups- Group memberships (used by threat model authorization)exp- Token expiration (validated client-side)
Admin Role Check (lines 264-266):
get isAdmin(): boolean {
return this.userProfile?.is_admin === true;
}Security Reviewer Role Check (lines 268-270):
get isSecurityReviewer(): boolean {
return this.userProfile?.is_security_reviewer === true;
}Groups Extraction (lines 234-248):
get userGroups(): string[] {
const token = this.getStoredToken();
if (!token) {
return [];
}
try {
const payload = token.token.split('.')[1];
const decodedPayload = JSON.parse(atob(payload)) as JwtPayload;
return decodedPayload.groups || [];
} catch (error) {
this.logger.warn('Could not decode token to get groups', error);
return [];
}
}Critical: All role checks rely on userProfile which is populated from:
- JWT token claims (lines 1376-1377 in
extractUserProfileFromToken) - Fresh API call to
/meendpoint (lines 1391-1485 inrefreshUserProfile)
User Profile Storage:
- Location: localStorage (key:
user_profile) - Encryption: AES-GCM encrypted using JWT access token as key material (lines 1601-1614)
- Decryption: Requires valid access token to decrypt (lines 1621-1661)
Profile Refresh Flow:
refreshUserProfile(): Observable<UserProfile> {
return this.http.get<UserMeResponse>(`${environment.apiUrl}/me`).pipe(
map(response => {
// Transform API response to UserProfile format
const serverProfile: UserProfile = {
provider: response.provider,
provider_id: response.provider_id,
display_name: response.name,
email: response.email,
groups: response.groups ?? null,
is_admin: response.is_admin,
is_security_reviewer: response.is_security_reviewer,
};
// Merge with JWT-derived profile (lines 1432-1447)
const mergedProfile = {
// Identity fields: always use JWT values (authoritative source)
provider: currentProfile.provider,
provider_id: currentProfile.provider_id,
// Server-verified fields: prefer server values
is_admin: serverProfile.is_admin,
is_security_reviewer: serverProfile.is_security_reviewer,
};
return mergedProfile;
}),
tap(profile => {
// Update cached profile
this.userProfileSubject.next(profile);
void this.storeUserProfile(profile);
}),
);
}Critical Lines:
- 1393 (GET /me API call)
- 1444 (is_admin from server)
- 1445-1446 (is_security_reviewer from server)
- 1473 (update BehaviorSubject)
- 1474 (update localStorage)
Refresh Triggers:
- After successful login (line 1113)
- On admin guard activation (line 26 of admin.guard.ts)
- On reviewer guard activation (line 26 of reviewer.guard.ts)
Staleness Risk:
- Profile cached in BehaviorSubject and localStorage
- Only refreshed when explicitly called (guards) or after login
- Between refreshes, uses stale cached values
Location: /app/repos/tmi-ux/src/app/pages/tm/services/threat-model-authorization.service.ts
Permission Model:
- Owner: Full control (read, write, manage permissions, delete)
- Writer: Can edit threat model content
- Reader: Read-only access
Authorization State Management:
private _authorizationSubject = new BehaviorSubject<Authorization[] | null>(null);
private _currentOwner: User | null = null;
private _currentThreatModelId: string | null = null;Public Observables:
get currentUserPermission$(): Observable<'reader' | 'writer' | 'owner' | null>
get canEdit$(): Observable<boolean>
get canManagePermissions$(): Observable<boolean>Method: calculateUserPermission() (lines 172-221)
Algorithm Flow:
-
Extract Current User Identity (lines 175-183)
- Get
providerfrom JWTidpclaim - Get
provider_idfrom JWTsubclaim - Get
emailfrom JWT - Get
groupsfrom JWT
- Get
-
Check Owner Match (lines 186-188)
- Call
_checkOwnerMatch()to compare current user against threat model owner field - If match, return
'owner'immediately
- Call
-
Check Authorization List (lines 191-200)
- If not owner, check authorization entries array
- If no entries, return
null(no access)
-
Find Highest Permission (lines 204-209)
- Call
_findHighestPermission()to scan authorization entries - Return highest matching permission level
- Call
Critical Lines:
- 175-178 (identity extraction)
- 186 (owner check)
- 204 (authorization list scan)
Method: _checkOwnerMatch() (lines 227-295)
Matching Algorithm:
-
Primary Match:
(provider, provider_id)composite key (lines 252-253)const providerMatches = this._currentOwner.provider === currentUserProvider; const providerIdMatches = this._currentOwner.provider_id === currentUserProviderId;
-
Fallback Match: Email comparison if primary fails (lines 256-260)
const emailFallbackMatches = !providerIdMatches && !!this._currentOwner.email && !!currentUserEmail && this._currentOwner.provider_id === currentUserEmail;
Note: This fallback handles a backend bug where
owner.provider_idsometimes contains email instead of OAuth provider ID (line 279) -
Return:
trueif either primary or fallback matches (line 294)
Critical Lines:
- 252-253 (primary match)
- 256-260 (email fallback)
- 278-286 (email fallback warning log)
Method: _findHighestPermission() (lines 301-329)
Permission Ranking:
const ROLE_RANK: Record<string, number> = {
reader: 1,
writer: 2,
owner: 3
};Matching Algorithm:
- Iterate Authorization Entries (line 310)
- For Each Entry:
- Call
_matchAuthorizationEntry()to check match (lines 311-316) - If matched, compare rank with current highest (lines 320-323)
- Update highest if this entry ranks higher
- Early Return: If
ownerpermission found, return immediately (line 325)
- Call
Critical Lines:
- 307 (role ranking)
- 311 (entry matching)
- 320-322 (rank comparison)
- 325 (early return on owner)
Method: _matchAuthorizationEntry() (lines 335-375)
Entry Types:
-
User Principal (
principal_type: 'user', lines 341-353)- Match on
(provider, provider_id)composite key - Return entry's
roleif matched
- Match on
-
Group Principal (
principal_type: 'group', lines 354-372)- Special Case:
provider_id.toLowerCase() === 'everyone'matches all users (lines 355-361) - Group Membership: Check if current user's groups include entry's
provider_id(lines 364-370)
- Special Case:
Critical Lines:
- 342 (user principal provider match)
- 355 (everyone group special case)
- 364 (group membership check)
Set via: ThreatModelService.getThreatModelById() → calls authorizationService.setAuthorization()
Backend Response Structure (inferred):
interface ThreatModel {
id: string;
owner: User; // Principal-based user object
authorization: Authorization[] | null;
// ... other fields
}
interface User {
provider: string; // OAuth provider (e.g., "google")
provider_id: string; // Provider-assigned ID
email?: string;
// ... other fields
}
interface Authorization {
principal_type: 'user' | 'group';
provider: string;
provider_id: string;
role: 'reader' | 'writer' | 'owner';
}Critical: Authorization data comes from backend API response, but permission calculation happens entirely client-side. No subsequent server-side validation of permissions before operations.
Vulnerability: Component-level authorization checks (canEdit, canManagePermissions) are purely UI-level and easily bypassed.
Bypass Method:
- Open browser DevTools
- Set
component.canEdit = truein console - UI becomes editable
- API calls execute without frontend prevention
Impact: HIGH if backend does not independently validate permissions
Example Locations:
/app/repos/tmi-ux/src/app/pages/tm/tm-edit.component.tslines 903, 942, 989, 1255, 1407, etc.
Evidence:
// Line 903 - Delete threat
if (!this.threatModel || !this.canEdit) return;
// ⚠️ Early return only - if bypassed, API call proceedsRecommendation: Backend MUST validate all write operations against authorization rules. Frontend checks are for UX only.
Vulnerability: User roles cached in localStorage and BehaviorSubject, only refreshed on specific triggers.
Staleness Windows:
- After Login: Profile refreshed (line 1113 of auth.service.ts)
- Admin Route: Profile refreshed by adminGuard (line 26 of admin.guard.ts)
- Reviewer Route: Profile refreshed by reviewerGuard (line 26 of reviewer.guard.ts)
- Between Windows: Uses cached profile from localStorage
Bypass Scenario:
- User granted admin role at T=0
- User logs in at T=1, profile cached with
is_admin: true - Admin role revoked server-side at T=2
- User navigates to
/dashboardat T=3 (no admin guard, no refresh) - User navigates to non-admin route at T=4 (no refresh)
- User still has
is_admin: truein cached profile until next admin guard activation or re-login
Impact: MEDIUM - User retains stale admin status in UI but backend should reject admin operations
Affected Routes:
/dashboard- UsesauthGuardonly (no profile refresh)/tm/*- UsesauthGuardonly (no profile refresh for role checks)/intake/*- UsesauthGuardonly (no profile refresh)
Recommendation:
- Implement periodic profile refresh (e.g., every 5 minutes)
- OR add
refreshUserProfile()call toauthGuard - Backend MUST validate roles on every admin operation regardless of frontend state
Vulnerability: Guards execute asynchronously - user could navigate faster than guard resolution
Scenario 1: Token Expiry During Guard Execution
- User initiates navigation to
/adminat T=0 authGuardchecks token validity at T=1 (valid, exp=T+10s)adminGuardstarts profile refresh API call at T=2- Token expires at T=10
- Profile refresh API completes at T=11 with 401 (expired token)
- JWT interceptor attempts token refresh (may fail if refresh token also expired)
Mitigation in Code:
authGuardperforms synchronous token expiry check before consulting observable (line 32)- JWT interceptor retries with refreshed token on 401 (lines 164-231)
- Token validity guard monitors visibility changes and timer drift (token-validity-guard.service.ts)
Scenario 2: Role Revocation During Guard Execution
- User initiates navigation to
/adminat T=0 authGuardpasses at T=1 (authenticated)- Admin role revoked server-side at T=2
adminGuardAPI call completes at T=3 (still has admin role from before revocation)- User gains access despite revocation
Impact: LOW - Very narrow time window, backend should still enforce
Vulnerability: ThreatModelAuthorizationService calculates permissions client-side but does not validate before every API call.
Example: Delete Threat
ThreatModelService.deleteThreat() (lines 993-999):
deleteThreat(threatModelId: string, threatId: string): Observable<boolean> {
return this.apiService.delete(`threat_models/${threatModelId}/threats/${threatId}`).pipe(
map(() => true),
tap(() => {
const cached = this._cachedThreatModels.get(threatModelId);
if (cached?.threats) {
// Update cache
}
}),
);
}No authorization check before API call! Only component checks canEdit (line 903 of tm-edit.component.ts)
Bypass Method:
- Inject
ThreatModelServicevia DevTools - Call
threatModelService.deleteThreat('tm-123', 'threat-456') - API request executes without frontend permission check
Impact: CRITICAL if backend does not validate threat model write permissions
Affected Operations:
deleteThreat()(line 993)deleteDocument()(line 1061)deleteDiagram()(line 1324)deleteNote()(line 1599)deleteAsset()(line 1715)updateThreatModel(),updateThreat(),createThreat(), etc. (similar pattern throughout)
Recommendation: Backend MUST check threat model authorization on EVERY write operation.
Vulnerability: JWT tokens validated client-side only, no signature verification in browser.
Client-Side JWT Handling:
- Token stored encrypted in localStorage (lines 2005-2014 of auth.service.ts)
- Token decrypted using browser fingerprint as key (lines 1980-2000)
- Token payload decoded with
atob()andJSON.parse()(lines 202, 222, 242, etc.)
No Client-Side Signature Verification:
// Line 202 - Extract provider ID
const payload = token.token.split('.')[1];
const decodedPayload = JSON.parse(atob(payload)) as JwtPayload;
return decodedPayload.sub || '';Bypass Scenario:
- Attacker obtains valid JWT token
- Decodes payload (base64)
- Modifies
tmi_is_administrator: true - Re-encodes payload
- Creates new token with forged payload (invalid signature but frontend doesn't verify)
- Injects forged token into localStorage
Frontend Impact:
authService.isAdminreturnstrue(line 265)adminGuardcallsrefreshUserProfile()which makes API call with forged token- Backend MUST reject forged token based on invalid signature
Mitigation:
- Frontend encryption (AES-GCM) prevents casual localStorage inspection but NOT forgery by determined attacker
- Frontend DOES NOT and CANNOT verify JWT signatures (requires private key)
- Backend MUST verify JWT signature on every request
Impact: LOW if backend properly validates JWT signatures, CRITICAL if backend trusts token claims without validation
Vulnerability: Cross-tab logout relies on localStorage events which can be blocked or delayed
Implementation (lines 1883-1890 of auth.service.ts):
// Broadcast logout to other browser tabs
try {
localStorage.setItem('auth_logout_broadcast', Date.now().toString());
localStorage.removeItem('auth_logout_broadcast');
} catch {
// Ignore storage errors (e.g., private browsing mode)
}Listening Side (lines 156-164 of token-validity-guard.service.ts):
this.storageEventHandler = (event: StorageEvent) => {
if (event.key === 'auth_logout_broadcast') {
this.logger.info('Received logout broadcast from another tab');
this.ngZone.run(() => {
this.handleCrossTabLogout();
});
return;
}
};Bypass Scenario:
- User opens Tab A and Tab B
- User logs out in Tab A
- Tab A broadcasts logout via localStorage
- Tab B's storage event listener is blocked by browser (private browsing mode, storage quota exceeded, etc.)
- Tab B remains "authenticated" with stale state
Mitigation in Code:
- Tab B's token will eventually expire (validated on visibility change, heartbeat, or next API call)
- Token validity guard monitors visibility changes (lines 97-108)
Impact: LOW - Narrow window before token expiry or next validation
Audit Result: All authenticated routes have appropriate guards
Protected Routes:
/dashboard→authGuard✓/admin/*→authGuard+adminGuard✓/tm/*→authGuard+threatModelResolver✓/intake/*→authGuard✓/triage/*→authGuard+reviewerGuard✓
Public Routes (intentionally unguarded):
/,/login,/oauth2/callback,/unauthorized,/about,/tos,/privacy✓
No bypass opportunities identified in route guard coverage.
Client-Side Validation: isTokenValid() (lines 291-300 of auth.service.ts)
private isTokenValid(token?: JwtToken | null): boolean {
const tokenToCheck = token || this.getStoredToken();
if (!tokenToCheck) {
return false;
}
const now = new Date();
return tokenToCheck.expiresAt > now;
}Triggers:
authGuardcallsvalidateAndUpdateAuthState()before every route activation (line 32 of auth.guard.ts)TokenValidityGuardServicevalidates on:- Visibility change (tab becomes visible, line 102)
- Timer drift detection (heartbeat exceeds expected interval, line 135)
- Storage event (cross-tab logout, line 170)
Critical: Client-side clock manipulation could bypass expiry validation, but backend will still reject expired tokens
Automatic Refresh: getValidToken() (lines 354-385 of auth.service.ts)
Refresh Triggers:
- Token expires within 15 minutes (line 345)
- 401 response from API (JWT interceptor, line 200)
Refresh Process:
- Check if token needs refresh (line 395)
- Call
refreshToken()→POST /oauth2/refreshwithrefresh_token(lines 1755-1762) - Backend returns new
access_tokenandrefresh_token(line 1765) - Store new token (line 415, 1807)
- Update BehaviorSubject (line 1532)
Refresh Token Rotation:
- Backend SHOULD return new refresh token (line 1796)
- If same refresh token returned, warning logged (line 1797)
Forced Refresh on 401:
- JWT interceptor detects 401 (line 105)
- Calls
forceRefreshToken()(line 200) - Deduplicates concurrent refresh requests via
refreshInProgress$observable (lines 1831-1856)
Critical Lines:
- 345 (15-minute refresh window)
- 1755-1762 (refresh API call)
- 200 (forced refresh on 401)
- 1831-1856 (deduplication)
Token Storage:
- Location: localStorage (key:
auth_token) - Encryption: AES-GCM with browser fingerprint as key (lines 2005-2014)
- Expiry: Stored as
expiresAtDate object (line 299)
Expiry Detection:
-
Proactive Checks:
authGuardbefore route activation (line 32)- Visibility change handler (line 102 of token-validity-guard.service.ts)
- Heartbeat drift detection (line 135)
-
Reactive Checks:
- 401 response from API triggers forced refresh (line 200 of jwt.interceptor.ts)
Expiry Actions:
validateAndUpdateAuthState()detects expired token (line 324 of auth.service.ts)- Clears auth data via
clearAuthData()(line 329) - Updates
isAuthenticatedSubjecttofalse(line 1865) authGuardredirects to/loginwithreturnUrl(lines 42-47)
Critical Lines:
- 324 (expiry detection)
- 329 (clear auth data)
- 1865 (update auth state)
[User] → [Login Page]
↓
[OAuth Provider] → [TMI Backend /oauth2/authorize]
↓
[TMI Backend] ← [Provider Callback]
↓
[TMI Backend] → [JWT Token Generation]
↓
[Frontend /oauth2/callback] ← [access_token, refresh_token]
↓
[AuthService.handleOAuthCallback()]
↓
[Extract user profile from JWT] → tmi_is_administrator, tmi_is_security_reviewer
↓
[Store token (encrypted)] → localStorage['auth_token']
↓
[Store profile (encrypted)] → localStorage['user_profile']
↓
[Update BehaviorSubjects] → isAuthenticated$ = true, userProfile$ = {...}
↓
[Fetch fresh profile] → GET /me
↓
[Merge JWT + server profile] → Update cached profile with is_admin, is_security_reviewer
↓
[Navigate to landing page] → /dashboard (reviewer) or /admin (admin) or /intake (user)
[User] → Navigate to /admin/users
↓
[Router] → authGuard
↓
[authGuard.validateAndUpdateAuthState()] → Check token expiry
↓
[authGuard checks isAuthenticated$]
├─ false → Redirect to /login?returnUrl=/admin/users
└─ true → Allow navigation
↓
[Router] → adminGuard
↓
[adminGuard.refreshUserProfile()] → GET /me
↓
[Backend validates JWT signature] → Returns user profile
↓
[Check userProfile.is_admin]
├─ false → Redirect to /dashboard?error=admin_required
└─ true → Allow navigation
↓
[Component loads]
[User] → Navigate to /tm/tm-123
↓
[Router] → authGuard
↓
[authGuard.validateAndUpdateAuthState()] → Check token expiry
↓
[authGuard checks isAuthenticated$]
├─ false → Redirect to /login?returnUrl=/tm/tm-123
└─ true → Allow navigation
↓
[Router] → threatModelResolver
↓
[threatModelResolver] → ThreatModelService.getThreatModelById('tm-123')
↓
[ThreatModelService] → GET /threat_models/tm-123 (with Authorization: Bearer <token>)
↓
[Backend validates JWT and checks authorization]
├─ 401 → JWT interceptor → Forced token refresh → Retry
├─ 403 → Resolver redirects to /dashboard?error=access_denied
└─ 200 → Return threat model with owner + authorization array
↓
[ThreatModelService.setAuthorization()] → Updates ThreatModelAuthorizationService
↓
[ThreatModelAuthorizationService.calculateUserPermission()]
↓
[Check owner field] → (provider, provider_id) match?
├─ Match → permission = 'owner'
└─ No match → Check authorization array
↓
[Scan authorization entries]
├─ User principal match → permission = entry.role
├─ Group 'everyone' → permission = entry.role
├─ Group membership match → permission = entry.role
└─ No match → permission = null
↓
[Update canEdit$, canManagePermissions$ observables]
↓
[Component subscribes to observables]
↓
[Component enables/disables UI based on permissions]
[Component] → ThreatModelService.deleteThreat('tm-123', 'threat-456')
↓
[ThreatModelService] → ApiService.delete('/threat_models/tm-123/threats/threat-456')
↓
[HTTP Request] → JWT Interceptor
↓
[JwtInterceptor.intercept()]
↓
[Check if public endpoint]
├─ Yes → Pass through without token
└─ No → authService.getValidToken()
↓
[Check token expiry]
├─ Expired → refreshToken() → POST /oauth2/refresh
└─ Valid → Return token
↓
[Clone request + add header] → Authorization: Bearer <token>
↓
[Send request to backend]
↓
[Backend validates JWT signature]
↓
[Backend checks threat model authorization]
├─ User not authorized → 403 Forbidden
├─ Token invalid → 401 Unauthorized → JWT interceptor forced refresh → Retry
└─ Authorized → 200 OK → Delete threat
↓
[Response] → ThreatModelService
↓
[Update cache] → Remove threat from _cachedThreatModels
↓
[Return success] → Component
| # | Weakness | Severity | Location | Mitigation |
|---|---|---|---|---|
| 1 | Frontend-only authorization checks | HIGH | Component methods (tm-edit.component.ts lines 903, 942, etc.) | Backend MUST validate all write operations |
| 2 | Stale role information | MEDIUM | AuthService user profile caching | Add periodic profile refresh or refresh on authGuard |
| 3 | No service-level authorization checks | CRITICAL | ThreatModelService CRUD methods (lines 993, 1061, etc.) | Backend MUST validate permissions on every operation |
| 4 | Race conditions in guard execution | LOW | Guard async resolution | Backend enforcement is primary defense |
| 5 | Client-side JWT decoding without signature verification | LOW | AuthService (lines 202, 222, etc.) | Backend MUST verify JWT signatures |
| 6 | Cross-tab logout synchronization gaps | LOW | Token validity guard (line 158) | Token expiry provides fallback defense |
| 7 | Authorization calculation entirely client-side | HIGH | ThreatModelAuthorizationService.calculateUserPermission() | Backend MUST recalculate permissions on every request |
1. Component-Level Authorization Bypass
- Method: Modify
canEditorcanManagePermissionsin browser console - Access Gained: UI becomes editable, API calls execute
- Success Depends On: Whether backend validates permissions
- Test: Set
component.canEdit = truein DevTools, attempt delete operation
2. Service Method Direct Invocation
- Method: Inject
ThreatModelServicein console, call CRUD methods directly - Access Gained: Bypass component-level checks entirely
- Success Depends On: Whether backend validates permissions
- Test:
const service = ng.probe(document.querySelector('app-root')).injector.get('ThreatModelService'); service.deleteThreat('tm-123', 'threat-456').subscribe(console.log);
3. Authorization State Manipulation
- Method: Modify
ThreatModelAuthorizationService._authorizationSubjectto grant owner permission - Access Gained: UI enables owner-level operations
- Success Depends On: Whether backend validates permissions
- Test:
const authzService = ng.probe(document.querySelector('app-root')).injector.get('ThreatModelAuthorizationService'); authzService._authorizationSubject.next([ {principal_type: 'user', provider: 'google', provider_id: 'attacker-id', role: 'owner'} ]);
4. Stale Admin Status Exploitation
- Method: User granted admin, logs in, admin revoked, continues using cached admin status
- Access Gained: Admin UI remains accessible until next admin guard activation
- Success Depends On: Whether backend validates admin role on operations
- Window: Between admin revocation and next navigation to admin route
5. JWT Token Forgery
- Method: Modify JWT payload to set
tmi_is_administrator: true, inject into localStorage - Access Gained: Frontend treats user as admin
- Success Depends On: Whether backend validates JWT signature (should fail)
- Likelihood: LOW if backend properly validates signatures
6. Race Condition in Token Refresh
- Method: Rapidly navigate during token expiry window
- Access Gained: Brief access to route before token validation completes
- Window: Milliseconds between navigation start and guard resolution
- Likelihood: Very low, requires precise timing
7. Cross-Tab Logout Evasion
- Method: Block localStorage events to prevent cross-tab logout synchronization
- Access Gained: Remain "authenticated" in one tab after logging out in another
- Window: Until token expiry or next validation
- Mitigation: Token expiry eventually enforces logout
-
Backend Authorization Enforcement
- Every API endpoint MUST validate user permissions independently
- Do NOT trust frontend authorization state
- Implement middleware/interceptors for consistent enforcement
-
Service-Level Authorization Checks
- Add authorization validation in ThreatModelService before API calls
- Example:
deleteThreat(threatModelId: string, threatId: string): Observable<boolean> { if (!this.authorizationService.canEdit()) { return throwError(() => new Error('Insufficient permissions')); } return this.apiService.delete(`threat_models/${threatModelId}/threats/${threatId}`); }
-
Periodic Profile Refresh
- Refresh user profile every 5 minutes to prevent stale role information
- OR add profile refresh to
authGuardfor all protected routes
-
Authorization State Validation
- Add backend API call to validate current user's permission on threat model before sensitive operations
- Example: Add
GET /threat_models/:id/permissions/meendpoint
-
JWT Token Binding
- Consider binding JWT tokens to browser fingerprint or device ID
- Prevents token theft and reuse from different contexts
-
Authorization Audit Logging
- Log all authorization decisions (grants and denials) server-side
- Include user identity, resource, operation, timestamp
-
Token Refresh Limits
- Implement refresh token rotation with max refresh count
- Expire refresh tokens after extended inactivity
The TMI-UX Angular application implements a comprehensive authorization architecture with multiple layers of defense:
- Route Guards: Prevent navigation to protected routes
- Role-Based Access Control: Admin and Security Reviewer roles
- Resource-Level Authorization: Owner/Writer/Reader permissions for threat models
- Token Management: JWT with automatic refresh and expiry handling
However, the primary weakness is reliance on frontend enforcement:
- Component-level checks can be bypassed via DevTools
- Service methods lack authorization validation before API calls
- Authorization calculations happen client-side without backend re-validation
The security posture depends entirely on backend API enforcement. If the backend properly validates:
- JWT signatures
- User roles (admin, reviewer)
- Resource permissions (threat model authorization)
Then the frontend authorization system provides excellent defense-in-depth and user experience but NOT primary security.
If the backend trusts frontend state or fails to validate permissions, the application is vulnerable to complete authorization bypass.
| Component | File Path |
|---|---|
| Guards | |
| authGuard | /app/repos/tmi-ux/src/app/auth/guards/auth.guard.ts |
| adminGuard | /app/repos/tmi-ux/src/app/auth/guards/admin.guard.ts |
| reviewerGuard | /app/repos/tmi-ux/src/app/auth/guards/reviewer.guard.ts |
| homeGuard | /app/repos/tmi-ux/src/app/auth/guards/home.guard.ts |
| Services | |
| AuthService | /app/repos/tmi-ux/src/app/auth/services/auth.service.ts |
| ThreatModelAuthorizationService | /app/repos/tmi-ux/src/app/pages/tm/services/threat-model-authorization.service.ts |
| ThreatModelService | /app/repos/tmi-ux/src/app/pages/tm/services/threat-model.service.ts |
| TokenValidityGuardService | /app/repos/tmi-ux/src/app/auth/services/token-validity-guard.service.ts |
| Interceptors | |
| JwtInterceptor | /app/repos/tmi-ux/src/app/auth/interceptors/jwt.interceptor.ts |
| HttpLoggingInterceptor | /app/repos/tmi-ux/src/app/core/interceptors/http-logging.interceptor.ts |
| SecurityHeadersInterceptor | /app/repos/tmi-ux/src/app/core/interceptors/security-headers.interceptor.ts |
| Resolvers | |
| threatModelResolver | /app/repos/tmi-ux/src/app/pages/tm/resolvers/threat-model.resolver.ts |
| Routes | |
| App Routes | /app/repos/tmi-ux/src/app/app.routes.ts |
| Threat Model Routes | /app/repos/tmi-ux/src/app/pages/tm/tm.routes.ts |
| Triage Routes | /app/repos/tmi-ux/src/app/pages/triage/triage.routes.ts |
| Survey Routes | /app/repos/tmi-ux/src/app/pages/surveys/surveys.routes.ts |
| Dashboard Routes | /app/repos/tmi-ux/src/app/pages/dashboard/dashboard.routes.ts |
| Configuration | |
| App Config | /app/repos/tmi-ux/src/app/app.config.ts |
File: /app/repos/tmi-ux/src/app/auth/services/auth.service.ts
Lines: 56-72
File: /app/repos/tmi-ux/src/app/auth/guards/admin.guard.ts
Lines: 26-39
File: /app/repos/tmi-ux/src/app/pages/tm/services/threat-model-authorization.service.ts
Lines: 172-221
File: /app/repos/tmi-ux/src/app/auth/interceptors/jwt.interceptor.ts
Lines: 164-231
File: /app/repos/tmi-ux/src/app/pages/tm/tm-edit.component.ts
Lines: 624-633, 903, 942, 989, 1255, 1407
End of Report