Skip to content

Commit a888af3

Browse files
committed
feat: enhance role management and token handling in case detail and token inspector components
1 parent f96b361 commit a888af3

4 files changed

Lines changed: 100 additions & 5 deletions

File tree

sample-app/spa/src/app/components/case-detail/case-detail.component.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,22 @@ export class CaseDetailComponent implements OnInit {
3131
) {}
3232

3333
ngOnInit(): void {
34+
// ID token roles render immediately so the read-only banner is
35+
// accurate before the access token round-trip completes.
3436
this.roles = this.authService.getRoles();
3537
this.canDownload = this.authService.canDownloadEvidence();
3638

39+
// Entra emits the `roles` claim on the access token by default —
40+
// upgrade the role list and download capability once it arrives.
41+
this.authService.getEffectiveRoles$().subscribe((roles) => {
42+
if (roles.length > 0) {
43+
this.roles = roles;
44+
}
45+
});
46+
this.authService.canDownloadEvidence$().subscribe((canDownload) => {
47+
this.canDownload = canDownload;
48+
});
49+
3750
const id = this.route.snapshot.paramMap.get('id');
3851
if (id) {
3952
this.evidenceService.getCaseById(id).subscribe({

sample-app/spa/src/app/components/token-inspector/token-inspector.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ <h3 class="ontario-h3">Access token</h3>
2929
{{ showAccessToken ? 'Hide' : 'Show' }} raw value
3030
</button>
3131
<button type="button" class="ontario-button ontario-button--tertiary"
32-
(click)="refreshAccessToken()" [disabled]="loadingAccessToken">
32+
(click)="refreshAccessToken(true)" [disabled]="loadingAccessToken">
3333
{{ loadingAccessToken ? 'Refreshing…' : 'Refresh' }}
3434
</button>
3535
</div>

sample-app/spa/src/app/components/token-inspector/token-inspector.component.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,25 @@ export class TokenInspectorComponent implements OnInit {
2929
this.refreshAccessToken();
3030
}
3131

32-
refreshAccessToken(): void {
32+
refreshAccessToken(forceRefresh = false): void {
3333
this.loadingAccessToken = true;
34-
this.authService.getAccessToken().subscribe((token) => {
34+
if (forceRefresh) {
35+
this.status = 'Requesting a fresh access token from Entra ID…';
36+
}
37+
const previousToken = this.accessToken;
38+
this.authService.getAccessToken(forceRefresh).subscribe((token) => {
3539
this.accessToken = token;
3640
this.loadingAccessToken = false;
3741
if (!token) {
3842
this.status =
3943
'Could not silently acquire an access token. Sign out and sign back in if this persists.';
44+
return;
45+
}
46+
if (forceRefresh) {
47+
this.status =
48+
token === previousToken
49+
? 'Entra returned the same access token (still valid in the cache).'
50+
: 'Access token refreshed.';
4051
}
4152
});
4253
}

sample-app/spa/src/app/services/auth.service.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
SilentRequest,
77
} from '@azure/msal-browser';
88
import { from, Observable, of } from 'rxjs';
9-
import { catchError, map } from 'rxjs/operators';
9+
import { catchError, map, shareReplay } from 'rxjs/operators';
1010
import { loginRequest } from '../auth-config';
1111

1212
/**
@@ -49,6 +49,29 @@ export class AuthService {
4949
return Array.isArray(roles) ? roles.map((r) => String(r)) : [];
5050
}
5151

52+
/**
53+
* Effective app roles for the user. Entra ID emits the `roles` claim
54+
* on the API access token by default, and only on the ID token when
55+
* the app registration explicitly opts in via optional claims. We
56+
* therefore prefer the access token as the source of truth and fall
57+
* back to the ID token claims when the access token is unavailable.
58+
*/
59+
getEffectiveRoles$(): Observable<string[]> {
60+
return this.getAccessToken().pipe(
61+
map((token) => {
62+
if (token) {
63+
const claims = decodeJwtPayload(token);
64+
const roles = claims?.['roles'];
65+
if (Array.isArray(roles)) {
66+
return roles.map((r) => String(r));
67+
}
68+
}
69+
return this.getRoles();
70+
}),
71+
shareReplay({ bufferSize: 1, refCount: true }),
72+
);
73+
}
74+
5275
hasAnyRole(...roles: string[]): boolean {
5376
if (roles.length === 0) {
5477
return false;
@@ -62,18 +85,36 @@ export class AuthService {
6285
return this.hasAnyRole(ROLE_CASE_READER, ROLE_CASE_ADMIN);
6386
}
6487

88+
/**
89+
* Async variant of {@link canDownloadEvidence} that consults the
90+
* access token roles. Use this in components — the synchronous
91+
* helper only sees the ID token claims, which Entra often omits.
92+
*/
93+
canDownloadEvidence$(): Observable<boolean> {
94+
return this.getEffectiveRoles$().pipe(
95+
map(
96+
(roles) =>
97+
roles.includes(ROLE_CASE_READER) || roles.includes(ROLE_CASE_ADMIN),
98+
),
99+
);
100+
}
101+
65102
/**
66103
* Acquire the current API access token silently from the MSAL cache,
67104
* refreshing via the hidden iframe / refresh token when needed.
105+
*
106+
* @param forceRefresh When true, bypass the MSAL token cache and
107+
* request a freshly minted access token from Entra ID.
68108
*/
69-
getAccessToken(): Observable<string | null> {
109+
getAccessToken(forceRefresh = false): Observable<string | null> {
70110
const account = this.getActiveAccount();
71111
if (!account) {
72112
return of(null);
73113
}
74114
const request: SilentRequest = {
75115
scopes: loginRequest.scopes,
76116
account,
117+
forceRefresh,
77118
};
78119
return from(this.msalService.instance.acquireTokenSilent(request)).pipe(
79120
map((res: AuthenticationResult) => res.accessToken),
@@ -123,3 +164,33 @@ export class AuthService {
123164
}
124165
}
125166
}
167+
168+
/**
169+
* Decode the payload of a JWT (header.payload.signature) without
170+
* verifying the signature. Used purely for reading non-sensitive
171+
* claims (e.g. `roles`) on the client. The signature is validated
172+
* server-side by the API.
173+
*/
174+
function decodeJwtPayload(token: string): Record<string, unknown> | null {
175+
const parts = token.split('.');
176+
if (parts.length < 2) {
177+
return null;
178+
}
179+
try {
180+
const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
181+
const padded = base64.padEnd(
182+
base64.length + ((4 - (base64.length % 4)) % 4),
183+
'=',
184+
);
185+
const json = decodeURIComponent(
186+
atob(padded)
187+
.split('')
188+
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
189+
.join(''),
190+
);
191+
return JSON.parse(json) as Record<string, unknown>;
192+
} catch (err) {
193+
console.warn('Failed to decode JWT payload', err);
194+
return null;
195+
}
196+
}

0 commit comments

Comments
 (0)