Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});


});
126 changes: 104 additions & 22 deletions client/wfprev-war/src/main/angular/src/app/services/token.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,37 +47,119 @@ export class TokenService {
this.checkForToken(undefined, lazyAuthenticate, allowLocalExpiredToken);
}

public async checkForToken(redirectUri?: string, lazyAuth = false, allowLocalExpiredToken = false): Promise<void> {
public async checkForToken(
redirectUri?: string,
lazyAuth = false,
allowLocalExpiredToken = false
): Promise<void> {
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<void> {
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);
Expand Down
Loading