diff --git a/client/wfprev-war/src/main/angular/src/app/services/token.service.spec.ts b/client/wfprev-war/src/main/angular/src/app/services/token.service.spec.ts index b3c4f783b..f7adf2e38 100644 --- a/client/wfprev-war/src/main/angular/src/app/services/token.service.spec.ts +++ b/client/wfprev-war/src/main/angular/src/app/services/token.service.spec.ts @@ -406,4 +406,235 @@ describe('TokenService', () => { expect(service.getIdir()).toBe(''); }); }); + + describe('hasAllScopesFromHash', () => { + const call = (hash: string, required: string[]) => + (service as any).hasAllScopesFromHash(hash, required); + + afterEach(() => { + // clean up hash after each test + window.history.pushState({}, '', '/'); + }); + + it('returns true when all explicit scopes are present (space encoded as %20)', () => { + const hash = '#access_token=t&scope=FOO%20BAR%20BAZ'; + expect(call(hash, ['FOO', 'BAR'])).toBeTrue(); + }); + + it('returns true when scopes are + separated (IdPs may encode spaces as +)', () => { + const hash = '#access_token=t&scope=FOO+BAR+BAZ'; + expect(call(hash, ['FOO', 'BAR'])).toBeTrue(); + }); + + it('supports wildcard prefix (WFDM.*) when at least one WFDM scope is present', () => { + const hash = '#access_token=t&scope=WFDM.CREATE_FILE%20WFPREV.GET_TOPLEVEL'; + expect(call(hash, ['WFDM.*'])).toBeTrue(); + }); + + it('fails wildcard check when no scope matches the prefix', () => { + const hash = '#access_token=t&scope=WFPREV.GET_TOPLEVEL'; + expect(call(hash, ['WFDM.*'])).toBeFalse(); + }); + + it('returns false if an explicit required scope is missing', () => { + const hash = '#access_token=t&scope=FOO%20BAR'; + expect(call(hash, ['FOO', 'MISSING'])).toBeFalse(); + }); + + it('treats empty required list as false', () => { + const hash = '#access_token=t&scope=ANY'; + expect(call(hash, [])).toBeFalse(); + }); +}); + +describe('checkForToken — scope gate on access_token hash', () => { + const makeCfg = (authScopes: string[]) => ({ + application: { + lazyAuthenticate: true, + enableLocalStorageToken: false, + allowLocalExpiredToken: false, + baseUrl: 'http://test.com', + localStorageTokenKey: 'test-oauth' + }, + webade: { + oauth2Url: 'http://oauth.test', + clientId: 'test-client', + authScopes, + enableCheckToken: false + } + }); + + afterEach(() => { + window.history.pushState({}, '', '/'); + }); + + it('redirects to error page when required scopes are missing', async () => { + window.history.pushState({}, '', '/'); + + const cfg = makeCfg(['WFPREV.GET_TOPLEVEL', 'WFDM.*']); + (mockAppConfigService.getConfig as jasmine.Spy).and.returnValue(cfg); + + const service = new TokenService(mockInjector, mockAppConfigService, mockSnackbarService, mockRouter); + window.history.pushState({}, '', '/#access_token=tok&scope=WFPREV.GET_TOPLEVEL'); + const parseSpy = spyOn(service as any, 'parseToken'); + + await service.checkForToken(); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/' + ResourcesRoutes.ERROR_PAGE]); + expect(parseSpy).not.toHaveBeenCalled(); + }); + + it('parses token when all required scopes are present (including wildcard match)', async () => { + window.history.pushState({}, '', '/'); + + const cfg = makeCfg(['WFPREV.GET_TOPLEVEL', 'WFDM.*']); + (mockAppConfigService.getConfig as jasmine.Spy).and.returnValue(cfg); + + const svc = new TokenService(mockInjector, mockAppConfigService, mockSnackbarService, mockRouter); + + window.history.pushState( + {}, '', + '/#access_token=tok&scope=WFPREV.GET_TOPLEVEL%20WFDM.UPDATE_FILE' + ); + + const parseSpy = spyOn(svc as any, 'parseToken'); + + await svc.checkForToken(); + + expect(parseSpy).toHaveBeenCalledWith(globalThis.location.hash); + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + + it('accepts "+"-separated scopes as space (IdP encoding quirk)', async () => { + window.history.pushState({}, '', '/'); + + const cfg = makeCfg(['FOO', 'BAR']); + (mockAppConfigService.getConfig as jasmine.Spy).and.returnValue(cfg); + + const svc = new TokenService(mockInjector, mockAppConfigService, mockSnackbarService, mockRouter); + + window.history.pushState({}, '', '/#access_token=tok&scope=FOO+BAR'); + + const parseSpy = spyOn(svc as any, 'parseToken'); + + await svc.checkForToken(); + + expect(parseSpy).toHaveBeenCalled(); + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); +}); + +describe('checkForToken — offline local storage branch', () => { + const makeJwt = (expSecondsFromNow: number) => { + const now = Math.floor(Date.now() / 1000); + const payload = { exp: now + expSecondsFromNow }; + const b64 = btoa(JSON.stringify(payload)); + return `h.${b64}.s`; + }; + + const setNavigatorOnline = (val: boolean) => { + Object.defineProperty(navigator, 'onLine', { value: val, configurable: true }); + }; + + afterEach(() => { + window.history.pushState({}, '', '/'); + setNavigatorOnline(true); + localStorage.clear(); + }); + + it('offline: expired local token ⇒ clearLocalToken + initLogin are called', async () => { + const cfg = { + application: { + lazyAuthenticate: true, + enableLocalStorageToken: false, + allowLocalExpiredToken: false, + baseUrl: 'http://test.com', + localStorageTokenKey: 'test-oauth', + }, + webade: { oauth2Url: 'http://oauth.test', clientId: 'test-client', authScopes: [], enableCheckToken: false } + }; + (mockAppConfigService.getConfig as jasmine.Spy).and.returnValue(cfg); + + const svc = new TokenService(mockInjector, mockAppConfigService, mockSnackbarService, mockRouter); + + (svc as any).useLocalStore = true; + + // seed an EXPIRED token in localStorage (exp = now - 60s) + const expiredJwt = makeJwt(-60); + localStorage.setItem('test-oauth', JSON.stringify({ access_token: expiredJwt, expires_in: 3600 })); + + // go offline so we enter the offline/local branch + setNavigatorOnline(false); + + const clearSpy = spyOn(svc as any, 'clearLocalToken').and.callThrough(); + const initLoginSpy = spyOn(svc as any, 'initLogin').and.callThrough(); + + await svc.checkForToken('http://redir', true, false); + + expect(clearSpy).toHaveBeenCalled(); + expect(initLoginSpy).toHaveBeenCalledWith('http://redir'); + }); + + it('offline: non-expired local token ⇒ does NOT clear or login', async () => { + const cfg = { + application: { + lazyAuthenticate: true, + enableLocalStorageToken: false, + allowLocalExpiredToken: false, + baseUrl: 'http://test.com', + localStorageTokenKey: 'test-oauth', + }, + webade: { oauth2Url: 'http://oauth.test', clientId: 'test-client', authScopes: [], enableCheckToken: false } + }; + (mockAppConfigService.getConfig as jasmine.Spy).and.returnValue(cfg); + + const svc = new TokenService(mockInjector, mockAppConfigService, mockSnackbarService, mockRouter); + (svc as any).useLocalStore = true; + + // seed a VALID token (exp = now + 1h) + const validJwt = makeJwt(3600); + localStorage.setItem('test-oauth', JSON.stringify({ access_token: validJwt, expires_in: 3600 })); + + setNavigatorOnline(false); + + const clearSpy = spyOn(svc as any, 'clearLocalToken'); + const initLoginSpy = spyOn(svc as any, 'initLogin'); + + await svc.checkForToken(undefined, true, false); + + expect(clearSpy).not.toHaveBeenCalled(); + expect(initLoginSpy).not.toHaveBeenCalled(); + }); + + it('offline: expired but allowed (allowLocalExpiredToken=true) ⇒ no clear/login', async () => { + const cfg = { + application: { + lazyAuthenticate: true, + enableLocalStorageToken: false, + allowLocalExpiredToken: false, + baseUrl: 'http://test.com', + localStorageTokenKey: 'test-oauth', + }, + webade: { oauth2Url: 'http://oauth.test', clientId: 'test-client', authScopes: [], enableCheckToken: false } + }; + (mockAppConfigService.getConfig as jasmine.Spy).and.returnValue(cfg); + + const svc = new TokenService(mockInjector, mockAppConfigService, mockSnackbarService, mockRouter); + (svc as any).useLocalStore = true; + + const expiredJwt = makeJwt(-60); + localStorage.setItem('test-oauth', JSON.stringify({ access_token: expiredJwt, expires_in: 3600 })); + + setNavigatorOnline(false); + + const clearSpy = spyOn(svc as any, 'clearLocalToken'); + const initLoginSpy = spyOn(svc as any, 'initLogin'); + + await svc.checkForToken(undefined, true, true); + + expect(clearSpy).not.toHaveBeenCalled(); + expect(initLoginSpy).not.toHaveBeenCalled(); + }); +}); + + }); diff --git a/client/wfprev-war/src/main/angular/src/app/services/token.service.ts b/client/wfprev-war/src/main/angular/src/app/services/token.service.ts index e385e7908..6a40eda4d 100644 --- a/client/wfprev-war/src/main/angular/src/app/services/token.service.ts +++ b/client/wfprev-war/src/main/angular/src/app/services/token.service.ts @@ -47,37 +47,119 @@ export class TokenService { this.checkForToken(undefined, lazyAuthenticate, allowLocalExpiredToken); } - public async checkForToken(redirectUri?: string, lazyAuth = false, allowLocalExpiredToken = false): Promise { + public async checkForToken( + redirectUri?: string, + lazyAuth = false, + allowLocalExpiredToken = false + ): Promise { const hash = globalThis.location?.hash; + const authScopes = this.appConfigService?.getConfig()?.webade?.authScopes as unknown as string[] || []; - if (hash?.includes('access_token')) { + // 1) URL contained an OAuth error → redirect to error page + if (this.hasAuthErrorInHash(hash)) { + this.router.navigate(['/' + ResourcesRoutes.ERROR_PAGE]); + return; + } + + // 2) URL has access token → validate scopes then parse + if (this.hasAccessTokenFromHash(hash)) { + if (!this.hasAllScopesFromHash(hash, authScopes)) { + this.router.navigate(['/' + ResourcesRoutes.ERROR_PAGE]); + return; + } this.parseToken(hash); - } else if (this.useLocalStore && !navigator.onLine) { - let tokenStore = localStorage.getItem(this.LOCAL_STORAGE_KEY); - - if (tokenStore) { - try { - await this.initAuthFromSession(); - } catch (err) { - this.tokenDetails = undefined; - localStorage.removeItem(this.LOCAL_STORAGE_KEY); - this.initIDIRLogin(redirectUri); - } - } else { - this.initIDIRLogin(redirectUri); + return; + } + + // 3) Offline path using local storage + if (this.shouldUseOfflineLocal()) { + const tokenStore = localStorage.getItem(this.LOCAL_STORAGE_KEY); + + if (!tokenStore) { + this.initLogin(redirectUri); + return; } - if (!allowLocalExpiredToken && this.isTokenExpired(this.tokenDetails)) { - localStorage.removeItem(this.LOCAL_STORAGE_KEY); - this.initIDIRLogin(redirectUri); + await this.tryInitFromSessionOrLogin(redirectUri); + + if (this.isLocalExpired(!allowLocalExpiredToken)) { + this.clearLocalToken(); + this.initLogin(redirectUri); } - } else if (hash?.includes('error')) { - this.router.navigate(['/' + ResourcesRoutes.ERROR_PAGE]); - } else if (!lazyAuth) { - this.initIDIRLogin(redirectUri); + return; + } + + // 4) Default: if not lazy, start login + if (!lazyAuth) { + this.initLogin(redirectUri); + } + } + + private hasAccessTokenFromHash(hash?: string): boolean { + return !!hash && hash.includes('access_token'); + } + + private hasAuthErrorInHash(hash?: string): boolean { + return !!hash && hash.includes('error'); + } + + private shouldUseOfflineLocal(): boolean { + return this.useLocalStore && !navigator.onLine; + } + + private initLogin(redirectUri?: string): void { + this.initIDIRLogin(redirectUri); + } + + private clearLocalToken(): void { + this.tokenDetails = undefined; + localStorage.removeItem(this.LOCAL_STORAGE_KEY); + } + + private async tryInitFromSessionOrLogin(redirectUri?: string): Promise { + try { + await this.initAuthFromSession(); + } catch { + this.clearLocalToken(); + this.initLogin(redirectUri); } } + private isLocalExpired(disallowExpired: boolean): boolean { + return disallowExpired && this.isTokenExpired(this.tokenDetails); + } + + private hasAllScopesFromHash(hash: string | undefined, required: string[] = []): boolean { + if (!hash || required.length === 0) return false; + + const params = new URLSearchParams(hash.replace(/^#/, '')); + let scopeParam = params.get('scope') || ''; + + if (!scopeParam) return false; + + scopeParam = scopeParam.replaceAll(/\+/g, ' '); + + const grantedList = scopeParam.split(/\s+/).filter(Boolean); + const grantedSet = new Set(grantedList); + + const missing: string[] = []; + + for (const req of required) { + // handle wildcard pattern like "WFDM.*" + if (req.endsWith('.*')) { + const prefix = req.slice(0, -1); + const hasAny = grantedList.some(g => g.startsWith(prefix)); + if (!hasAny) missing.push(req); + } else if (!grantedSet.has(req)) missing.push(req); + } + + if (missing.length > 0) { + return false; + } + return true; + } + + public isTokenExpired(token: any): boolean { if (token?.exp) { const expiryDate = moment.unix(token.exp);