Skip to content

Commit 0ddf9bc

Browse files
authored
WFPREV-797 Extra scope check for WFPREV_CREATOR (#981)
1 parent 2fe950d commit 0ddf9bc

File tree

2 files changed

+335
-22
lines changed

2 files changed

+335
-22
lines changed

client/wfprev-war/src/main/angular/src/app/services/token.service.spec.ts

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,4 +406,235 @@ describe('TokenService', () => {
406406
expect(service.getIdir()).toBe('');
407407
});
408408
});
409+
410+
describe('hasAllScopesFromHash', () => {
411+
const call = (hash: string, required: string[]) =>
412+
(service as any).hasAllScopesFromHash(hash, required);
413+
414+
afterEach(() => {
415+
// clean up hash after each test
416+
window.history.pushState({}, '', '/');
417+
});
418+
419+
it('returns true when all explicit scopes are present (space encoded as %20)', () => {
420+
const hash = '#access_token=t&scope=FOO%20BAR%20BAZ';
421+
expect(call(hash, ['FOO', 'BAR'])).toBeTrue();
422+
});
423+
424+
it('returns true when scopes are + separated (IdPs may encode spaces as +)', () => {
425+
const hash = '#access_token=t&scope=FOO+BAR+BAZ';
426+
expect(call(hash, ['FOO', 'BAR'])).toBeTrue();
427+
});
428+
429+
it('supports wildcard prefix (WFDM.*) when at least one WFDM scope is present', () => {
430+
const hash = '#access_token=t&scope=WFDM.CREATE_FILE%20WFPREV.GET_TOPLEVEL';
431+
expect(call(hash, ['WFDM.*'])).toBeTrue();
432+
});
433+
434+
it('fails wildcard check when no scope matches the prefix', () => {
435+
const hash = '#access_token=t&scope=WFPREV.GET_TOPLEVEL';
436+
expect(call(hash, ['WFDM.*'])).toBeFalse();
437+
});
438+
439+
it('returns false if an explicit required scope is missing', () => {
440+
const hash = '#access_token=t&scope=FOO%20BAR';
441+
expect(call(hash, ['FOO', 'MISSING'])).toBeFalse();
442+
});
443+
444+
it('treats empty required list as false', () => {
445+
const hash = '#access_token=t&scope=ANY';
446+
expect(call(hash, [])).toBeFalse();
447+
});
448+
});
449+
450+
describe('checkForToken — scope gate on access_token hash', () => {
451+
const makeCfg = (authScopes: string[]) => ({
452+
application: {
453+
lazyAuthenticate: true,
454+
enableLocalStorageToken: false,
455+
allowLocalExpiredToken: false,
456+
baseUrl: 'http://test.com',
457+
localStorageTokenKey: 'test-oauth'
458+
},
459+
webade: {
460+
oauth2Url: 'http://oauth.test',
461+
clientId: 'test-client',
462+
authScopes,
463+
enableCheckToken: false
464+
}
465+
});
466+
467+
afterEach(() => {
468+
window.history.pushState({}, '', '/');
469+
});
470+
471+
it('redirects to error page when required scopes are missing', async () => {
472+
window.history.pushState({}, '', '/');
473+
474+
const cfg = makeCfg(['WFPREV.GET_TOPLEVEL', 'WFDM.*']);
475+
(mockAppConfigService.getConfig as jasmine.Spy).and.returnValue(cfg);
476+
477+
const service = new TokenService(mockInjector, mockAppConfigService, mockSnackbarService, mockRouter);
478+
window.history.pushState({}, '', '/#access_token=tok&scope=WFPREV.GET_TOPLEVEL');
479+
const parseSpy = spyOn(service as any, 'parseToken');
480+
481+
await service.checkForToken();
482+
expect(mockRouter.navigate).toHaveBeenCalledWith(['/' + ResourcesRoutes.ERROR_PAGE]);
483+
expect(parseSpy).not.toHaveBeenCalled();
484+
});
485+
486+
it('parses token when all required scopes are present (including wildcard match)', async () => {
487+
window.history.pushState({}, '', '/');
488+
489+
const cfg = makeCfg(['WFPREV.GET_TOPLEVEL', 'WFDM.*']);
490+
(mockAppConfigService.getConfig as jasmine.Spy).and.returnValue(cfg);
491+
492+
const svc = new TokenService(mockInjector, mockAppConfigService, mockSnackbarService, mockRouter);
493+
494+
window.history.pushState(
495+
{}, '',
496+
'/#access_token=tok&scope=WFPREV.GET_TOPLEVEL%20WFDM.UPDATE_FILE'
497+
);
498+
499+
const parseSpy = spyOn(svc as any, 'parseToken');
500+
501+
await svc.checkForToken();
502+
503+
expect(parseSpy).toHaveBeenCalledWith(globalThis.location.hash);
504+
expect(mockRouter.navigate).not.toHaveBeenCalled();
505+
});
506+
507+
it('accepts "+"-separated scopes as space (IdP encoding quirk)', async () => {
508+
window.history.pushState({}, '', '/');
509+
510+
const cfg = makeCfg(['FOO', 'BAR']);
511+
(mockAppConfigService.getConfig as jasmine.Spy).and.returnValue(cfg);
512+
513+
const svc = new TokenService(mockInjector, mockAppConfigService, mockSnackbarService, mockRouter);
514+
515+
window.history.pushState({}, '', '/#access_token=tok&scope=FOO+BAR');
516+
517+
const parseSpy = spyOn(svc as any, 'parseToken');
518+
519+
await svc.checkForToken();
520+
521+
expect(parseSpy).toHaveBeenCalled();
522+
expect(mockRouter.navigate).not.toHaveBeenCalled();
523+
});
524+
});
525+
526+
describe('checkForToken — offline local storage branch', () => {
527+
const makeJwt = (expSecondsFromNow: number) => {
528+
const now = Math.floor(Date.now() / 1000);
529+
const payload = { exp: now + expSecondsFromNow };
530+
const b64 = btoa(JSON.stringify(payload));
531+
return `h.${b64}.s`;
532+
};
533+
534+
const setNavigatorOnline = (val: boolean) => {
535+
Object.defineProperty(navigator, 'onLine', { value: val, configurable: true });
536+
};
537+
538+
afterEach(() => {
539+
window.history.pushState({}, '', '/');
540+
setNavigatorOnline(true);
541+
localStorage.clear();
542+
});
543+
544+
it('offline: expired local token ⇒ clearLocalToken + initLogin are called', async () => {
545+
const cfg = {
546+
application: {
547+
lazyAuthenticate: true,
548+
enableLocalStorageToken: false,
549+
allowLocalExpiredToken: false,
550+
baseUrl: 'http://test.com',
551+
localStorageTokenKey: 'test-oauth',
552+
},
553+
webade: { oauth2Url: 'http://oauth.test', clientId: 'test-client', authScopes: [], enableCheckToken: false }
554+
};
555+
(mockAppConfigService.getConfig as jasmine.Spy).and.returnValue(cfg);
556+
557+
const svc = new TokenService(mockInjector, mockAppConfigService, mockSnackbarService, mockRouter);
558+
559+
(svc as any).useLocalStore = true;
560+
561+
// seed an EXPIRED token in localStorage (exp = now - 60s)
562+
const expiredJwt = makeJwt(-60);
563+
localStorage.setItem('test-oauth', JSON.stringify({ access_token: expiredJwt, expires_in: 3600 }));
564+
565+
// go offline so we enter the offline/local branch
566+
setNavigatorOnline(false);
567+
568+
const clearSpy = spyOn(svc as any, 'clearLocalToken').and.callThrough();
569+
const initLoginSpy = spyOn(svc as any, 'initLogin').and.callThrough();
570+
571+
await svc.checkForToken('http://redir', true, false);
572+
573+
expect(clearSpy).toHaveBeenCalled();
574+
expect(initLoginSpy).toHaveBeenCalledWith('http://redir');
575+
});
576+
577+
it('offline: non-expired local token ⇒ does NOT clear or login', async () => {
578+
const cfg = {
579+
application: {
580+
lazyAuthenticate: true,
581+
enableLocalStorageToken: false,
582+
allowLocalExpiredToken: false,
583+
baseUrl: 'http://test.com',
584+
localStorageTokenKey: 'test-oauth',
585+
},
586+
webade: { oauth2Url: 'http://oauth.test', clientId: 'test-client', authScopes: [], enableCheckToken: false }
587+
};
588+
(mockAppConfigService.getConfig as jasmine.Spy).and.returnValue(cfg);
589+
590+
const svc = new TokenService(mockInjector, mockAppConfigService, mockSnackbarService, mockRouter);
591+
(svc as any).useLocalStore = true;
592+
593+
// seed a VALID token (exp = now + 1h)
594+
const validJwt = makeJwt(3600);
595+
localStorage.setItem('test-oauth', JSON.stringify({ access_token: validJwt, expires_in: 3600 }));
596+
597+
setNavigatorOnline(false);
598+
599+
const clearSpy = spyOn(svc as any, 'clearLocalToken');
600+
const initLoginSpy = spyOn(svc as any, 'initLogin');
601+
602+
await svc.checkForToken(undefined, true, false);
603+
604+
expect(clearSpy).not.toHaveBeenCalled();
605+
expect(initLoginSpy).not.toHaveBeenCalled();
606+
});
607+
608+
it('offline: expired but allowed (allowLocalExpiredToken=true) ⇒ no clear/login', async () => {
609+
const cfg = {
610+
application: {
611+
lazyAuthenticate: true,
612+
enableLocalStorageToken: false,
613+
allowLocalExpiredToken: false,
614+
baseUrl: 'http://test.com',
615+
localStorageTokenKey: 'test-oauth',
616+
},
617+
webade: { oauth2Url: 'http://oauth.test', clientId: 'test-client', authScopes: [], enableCheckToken: false }
618+
};
619+
(mockAppConfigService.getConfig as jasmine.Spy).and.returnValue(cfg);
620+
621+
const svc = new TokenService(mockInjector, mockAppConfigService, mockSnackbarService, mockRouter);
622+
(svc as any).useLocalStore = true;
623+
624+
const expiredJwt = makeJwt(-60);
625+
localStorage.setItem('test-oauth', JSON.stringify({ access_token: expiredJwt, expires_in: 3600 }));
626+
627+
setNavigatorOnline(false);
628+
629+
const clearSpy = spyOn(svc as any, 'clearLocalToken');
630+
const initLoginSpy = spyOn(svc as any, 'initLogin');
631+
632+
await svc.checkForToken(undefined, true, true);
633+
634+
expect(clearSpy).not.toHaveBeenCalled();
635+
expect(initLoginSpy).not.toHaveBeenCalled();
636+
});
637+
});
638+
639+
409640
});

client/wfprev-war/src/main/angular/src/app/services/token.service.ts

Lines changed: 104 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -47,37 +47,119 @@ export class TokenService {
4747
this.checkForToken(undefined, lazyAuthenticate, allowLocalExpiredToken);
4848
}
4949

50-
public async checkForToken(redirectUri?: string, lazyAuth = false, allowLocalExpiredToken = false): Promise<void> {
50+
public async checkForToken(
51+
redirectUri?: string,
52+
lazyAuth = false,
53+
allowLocalExpiredToken = false
54+
): Promise<void> {
5155
const hash = globalThis.location?.hash;
56+
const authScopes = this.appConfigService?.getConfig()?.webade?.authScopes as unknown as string[] || [];
5257

53-
if (hash?.includes('access_token')) {
58+
// 1) URL contained an OAuth error → redirect to error page
59+
if (this.hasAuthErrorInHash(hash)) {
60+
this.router.navigate(['/' + ResourcesRoutes.ERROR_PAGE]);
61+
return;
62+
}
63+
64+
// 2) URL has access token → validate scopes then parse
65+
if (this.hasAccessTokenFromHash(hash)) {
66+
if (!this.hasAllScopesFromHash(hash, authScopes)) {
67+
this.router.navigate(['/' + ResourcesRoutes.ERROR_PAGE]);
68+
return;
69+
}
5470
this.parseToken(hash);
55-
} else if (this.useLocalStore && !navigator.onLine) {
56-
let tokenStore = localStorage.getItem(this.LOCAL_STORAGE_KEY);
57-
58-
if (tokenStore) {
59-
try {
60-
await this.initAuthFromSession();
61-
} catch (err) {
62-
this.tokenDetails = undefined;
63-
localStorage.removeItem(this.LOCAL_STORAGE_KEY);
64-
this.initIDIRLogin(redirectUri);
65-
}
66-
} else {
67-
this.initIDIRLogin(redirectUri);
71+
return;
72+
}
73+
74+
// 3) Offline path using local storage
75+
if (this.shouldUseOfflineLocal()) {
76+
const tokenStore = localStorage.getItem(this.LOCAL_STORAGE_KEY);
77+
78+
if (!tokenStore) {
79+
this.initLogin(redirectUri);
80+
return;
6881
}
6982

70-
if (!allowLocalExpiredToken && this.isTokenExpired(this.tokenDetails)) {
71-
localStorage.removeItem(this.LOCAL_STORAGE_KEY);
72-
this.initIDIRLogin(redirectUri);
83+
await this.tryInitFromSessionOrLogin(redirectUri);
84+
85+
if (this.isLocalExpired(!allowLocalExpiredToken)) {
86+
this.clearLocalToken();
87+
this.initLogin(redirectUri);
7388
}
74-
} else if (hash?.includes('error')) {
75-
this.router.navigate(['/' + ResourcesRoutes.ERROR_PAGE]);
76-
} else if (!lazyAuth) {
77-
this.initIDIRLogin(redirectUri);
89+
return;
90+
}
91+
92+
// 4) Default: if not lazy, start login
93+
if (!lazyAuth) {
94+
this.initLogin(redirectUri);
95+
}
96+
}
97+
98+
private hasAccessTokenFromHash(hash?: string): boolean {
99+
return !!hash && hash.includes('access_token');
100+
}
101+
102+
private hasAuthErrorInHash(hash?: string): boolean {
103+
return !!hash && hash.includes('error');
104+
}
105+
106+
private shouldUseOfflineLocal(): boolean {
107+
return this.useLocalStore && !navigator.onLine;
108+
}
109+
110+
private initLogin(redirectUri?: string): void {
111+
this.initIDIRLogin(redirectUri);
112+
}
113+
114+
private clearLocalToken(): void {
115+
this.tokenDetails = undefined;
116+
localStorage.removeItem(this.LOCAL_STORAGE_KEY);
117+
}
118+
119+
private async tryInitFromSessionOrLogin(redirectUri?: string): Promise<void> {
120+
try {
121+
await this.initAuthFromSession();
122+
} catch {
123+
this.clearLocalToken();
124+
this.initLogin(redirectUri);
78125
}
79126
}
80127

128+
private isLocalExpired(disallowExpired: boolean): boolean {
129+
return disallowExpired && this.isTokenExpired(this.tokenDetails);
130+
}
131+
132+
private hasAllScopesFromHash(hash: string | undefined, required: string[] = []): boolean {
133+
if (!hash || required.length === 0) return false;
134+
135+
const params = new URLSearchParams(hash.replace(/^#/, ''));
136+
let scopeParam = params.get('scope') || '';
137+
138+
if (!scopeParam) return false;
139+
140+
scopeParam = scopeParam.replaceAll(/\+/g, ' ');
141+
142+
const grantedList = scopeParam.split(/\s+/).filter(Boolean);
143+
const grantedSet = new Set(grantedList);
144+
145+
const missing: string[] = [];
146+
147+
for (const req of required) {
148+
// handle wildcard pattern like "WFDM.*"
149+
if (req.endsWith('.*')) {
150+
const prefix = req.slice(0, -1);
151+
const hasAny = grantedList.some(g => g.startsWith(prefix));
152+
if (!hasAny) missing.push(req);
153+
} else if (!grantedSet.has(req)) missing.push(req);
154+
}
155+
156+
if (missing.length > 0) {
157+
return false;
158+
}
159+
return true;
160+
}
161+
162+
81163
public isTokenExpired(token: any): boolean {
82164
if (token?.exp) {
83165
const expiryDate = moment.unix(token.exp);

0 commit comments

Comments
 (0)