66 SilentRequest ,
77} from '@azure/msal-browser' ;
88import { from , Observable , of } from 'rxjs' ;
9- import { catchError , map } from 'rxjs/operators' ;
9+ import { catchError , map , shareReplay } from 'rxjs/operators' ;
1010import { 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