feat: init and fork some code from angular-auth-oidc-client
Some checks are pending
Build, Lint & Test Lib / Built, Lint and Test Library (push) Waiting to run
Build, Lint & Test Lib / Angular latest (push) Blocked by required conditions
Build, Lint & Test Lib / Angular latest & Schematics Job (push) Blocked by required conditions
Build, Lint & Test Lib / Angular latest Standalone & Schematics Job (push) Blocked by required conditions
Build, Lint & Test Lib / Angular 16 & RxJs 6 (push) Blocked by required conditions
Build, Lint & Test Lib / Angular V16 (push) Blocked by required conditions
Docs / Build and Deploy Docs Job (push) Waiting to run
Docs / Close Pull Request Job (push) Waiting to run

This commit is contained in:
2025-01-18 01:05:00 +08:00
parent 276d9fbda8
commit 983254164b
201 changed files with 35689 additions and 0 deletions

View File

@@ -0,0 +1,356 @@
import { TestBed, waitForAsync } from '@angular/core/testing';
import { of } from 'rxjs';
import { skip } from 'rxjs/operators';
import { mockAbstractProvider, mockProvider } from '../../test/auto-mock';
import { LoggerService } from '../logging/logger.service';
import { OidcSecurityService } from '../oidc.security.service';
import { PublicEventsService } from '../public-events/public-events.service';
import { AbstractSecurityStorage } from '../storage/abstract-security-storage';
import { DefaultSessionStorageService } from '../storage/default-sessionstorage.service';
import { StoragePersistenceService } from '../storage/storage-persistence.service';
import { PlatformProvider } from '../utils/platform-provider/platform.provider';
import { CheckSessionService } from './check-session.service';
import { IFrameService } from './existing-iframe.service';
describe('CheckSessionService', () => {
let checkSessionService: CheckSessionService;
let loggerService: LoggerService;
let iFrameService: IFrameService;
let storagePersistenceService: StoragePersistenceService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
CheckSessionService,
OidcSecurityService,
IFrameService,
PublicEventsService,
mockProvider(StoragePersistenceService),
mockProvider(LoggerService),
mockProvider(PlatformProvider),
mockAbstractProvider(
AbstractSecurityStorage,
DefaultSessionStorageService
),
],
});
});
beforeEach(() => {
checkSessionService = TestBed.inject(CheckSessionService);
loggerService = TestBed.inject(LoggerService);
iFrameService = TestBed.inject(IFrameService);
storagePersistenceService = TestBed.inject(StoragePersistenceService);
});
afterEach(() => {
const iFrameIdwhichshouldneverexist = window.document.getElementById(
'idwhichshouldneverexist'
);
if (iFrameIdwhichshouldneverexist) {
iFrameIdwhichshouldneverexist.parentNode?.removeChild(
iFrameIdwhichshouldneverexist
);
}
const myiFrameForCheckSession = window.document.getElementById(
'myiFrameForCheckSession'
);
if (myiFrameForCheckSession) {
myiFrameForCheckSession.parentNode?.removeChild(myiFrameForCheckSession);
}
});
it('should create', () => {
expect(checkSessionService).toBeTruthy();
});
it('getOrCreateIframe calls iFrameService.addIFrameToWindowBody if no Iframe exists', () => {
spyOn(iFrameService, 'addIFrameToWindowBody').and.callThrough();
const result = (checkSessionService as any).getOrCreateIframe({
configId: 'configId1',
});
expect(result).toBeTruthy();
expect(iFrameService.addIFrameToWindowBody).toHaveBeenCalled();
});
it('getOrCreateIframe returns true if document found on window.document', () => {
iFrameService.addIFrameToWindowBody('myiFrameForCheckSession', {
configId: 'configId1',
});
const result = (checkSessionService as any).getOrCreateIframe();
expect(result).toBeDefined();
});
it('init appends iframe on body with correct values', () => {
expect((checkSessionService as any).sessionIframe).toBeFalsy();
spyOn<any>(loggerService, 'logDebug').and.callFake(() => undefined);
(checkSessionService as any).init();
const iframe = (checkSessionService as any).getOrCreateIframe({
configId: 'configId1',
});
expect(iframe).toBeTruthy();
expect(iframe.id).toBe('myiFrameForCheckSession');
expect(iframe.style.display).toBe('none');
const iFrame = document.getElementById('myiFrameForCheckSession');
expect(iFrame).toBeDefined();
});
it('log warning if authWellKnownEndpoints.check_session_iframe is not existing', () => {
const spyLogWarning = spyOn<any>(loggerService, 'logWarning');
const config = { configId: 'configId1' };
spyOn<any>(loggerService, 'logDebug').and.callFake(() => undefined);
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', config)
.and.returnValue({ checkSessionIframe: undefined });
(checkSessionService as any).init(config);
expect(spyLogWarning).toHaveBeenCalledOnceWith(config, jasmine.any(String));
});
it('start() calls pollserversession() with clientId if no scheduledheartbeat is set', () => {
const spy = spyOn<any>(checkSessionService, 'pollServerSession');
const config = { clientId: 'clientId', configId: 'configId1' };
checkSessionService.start(config);
expect(spy).toHaveBeenCalledOnceWith('clientId', config);
});
it('start() does not call pollServerSession() if scheduledHeartBeatRunning is set', () => {
const config = { configId: 'configId1' };
const spy = spyOn<any>(checkSessionService, 'pollServerSession');
(checkSessionService as any).scheduledHeartBeatRunning = (): void =>
undefined;
checkSessionService.start(config);
expect(spy).not.toHaveBeenCalled();
});
it('stopCheckingSession sets heartbeat to null', () => {
(checkSessionService as any).scheduledHeartBeatRunning = setTimeout(
() => undefined,
999
);
checkSessionService.stop();
const heartBeat = (checkSessionService as any).scheduledHeartBeatRunning;
expect(heartBeat).toBeNull();
});
it('stopCheckingSession does nothing if scheduledHeartBeatRunning is not set', () => {
(checkSessionService as any).scheduledHeartBeatRunning = null;
const spy = spyOn<any>(checkSessionService, 'clearScheduledHeartBeat');
checkSessionService.stop();
expect(spy).not.toHaveBeenCalledOnceWith();
});
describe('serverStateChanged', () => {
it('returns false if startCheckSession is not configured', () => {
const config = { startCheckSession: false, configId: 'configId1' };
const result = checkSessionService.serverStateChanged(config);
expect(result).toBeFalsy();
});
it('returns false if checkSessionReceived is false', () => {
(checkSessionService as any).checkSessionReceived = false;
const config = { startCheckSession: true, configId: 'configId1' };
const result = checkSessionService.serverStateChanged(config);
expect(result).toBeFalse();
});
it('returns true if startCheckSession is configured and checkSessionReceived is true', () => {
(checkSessionService as any).checkSessionReceived = true;
const config = { startCheckSession: true, configId: 'configId1' };
const result = checkSessionService.serverStateChanged(config);
expect(result).toBeTrue();
});
});
describe('pollServerSession', () => {
beforeEach(() => {
spyOn<any>(checkSessionService, 'init').and.returnValue(of(undefined));
});
it('increases outstandingMessages', () => {
spyOn<any>(checkSessionService, 'getExistingIframe').and.returnValue({
contentWindow: { postMessage: () => undefined },
});
const authWellKnownEndpoints = {
checkSessionIframe: 'https://some-testing-url.com',
};
const config = { configId: 'configId1' };
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', config)
.and.returnValue(authWellKnownEndpoints)
.withArgs('session_state', config)
.and.returnValue('session_state');
spyOn(loggerService, 'logDebug').and.callFake(() => undefined);
(checkSessionService as any).pollServerSession('clientId', config);
expect((checkSessionService as any).outstandingMessages).toBe(1);
});
it('logs warning if iframe does not exist', () => {
spyOn<any>(checkSessionService, 'getExistingIframe').and.returnValue(
null
);
const authWellKnownEndpoints = {
checkSessionIframe: 'https://some-testing-url.com',
};
const config = { configId: 'configId1' };
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', config)
.and.returnValue(authWellKnownEndpoints);
const spyLogWarning = spyOn(loggerService, 'logWarning').and.callFake(
() => undefined
);
spyOn(loggerService, 'logDebug').and.callFake(() => undefined);
(checkSessionService as any).pollServerSession('clientId', config);
expect(spyLogWarning).toHaveBeenCalledOnceWith(
config,
jasmine.any(String)
);
});
it('logs warning if clientId is not set', () => {
spyOn<any>(checkSessionService, 'getExistingIframe').and.returnValue({});
const authWellKnownEndpoints = {
checkSessionIframe: 'https://some-testing-url.com',
};
const config = { configId: 'configId1' };
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', config)
.and.returnValue(authWellKnownEndpoints);
const spyLogWarning = spyOn(loggerService, 'logWarning').and.callFake(
() => undefined
);
spyOn(loggerService, 'logDebug').and.callFake(() => undefined);
(checkSessionService as any).pollServerSession('', config);
expect(spyLogWarning).toHaveBeenCalledOnceWith(
config,
jasmine.any(String)
);
});
it('logs debug if session_state is not set', () => {
spyOn<any>(checkSessionService, 'getExistingIframe').and.returnValue({});
const authWellKnownEndpoints = {
checkSessionIframe: 'https://some-testing-url.com',
};
const config = { configId: 'configId1' };
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', config)
.and.returnValue(authWellKnownEndpoints)
.withArgs('session_state', config)
.and.returnValue(null);
const spyLogDebug = spyOn(loggerService, 'logDebug').and.callFake(
() => undefined
);
(checkSessionService as any).pollServerSession('clientId', config);
expect(spyLogDebug).toHaveBeenCalledTimes(2);
});
it('logs debug if session_state is set but authWellKnownEndpoints are not set', () => {
spyOn<any>(checkSessionService, 'getExistingIframe').and.returnValue({});
const authWellKnownEndpoints = null;
const config = { configId: 'configId1' };
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', config)
.and.returnValue(authWellKnownEndpoints)
.withArgs('session_state', config)
.and.returnValue('some_session_state');
const spyLogDebug = spyOn(loggerService, 'logDebug').and.callFake(
() => undefined
);
(checkSessionService as any).pollServerSession('clientId', config);
expect(spyLogDebug).toHaveBeenCalledTimes(2);
});
});
describe('init', () => {
it('returns falsy observable when lastIframerefresh and iframeRefreshInterval are bigger than now', waitForAsync(() => {
const serviceAsAny = checkSessionService as any;
const dateNow = new Date();
const lastRefresh = dateNow.setMinutes(dateNow.getMinutes() + 30);
serviceAsAny.lastIFrameRefresh = lastRefresh;
serviceAsAny.iframeRefreshInterval = lastRefresh;
serviceAsAny.init().subscribe((result: any) => {
expect(result).toBeUndefined();
});
}));
});
describe('isCheckSessionConfigured', () => {
it('returns true if startCheckSession on config is true', () => {
const config = { configId: 'configId1', startCheckSession: true };
const result = checkSessionService.isCheckSessionConfigured(config);
expect(result).toBe(true);
});
it('returns true if startCheckSession on config is true', () => {
const config = { configId: 'configId1', startCheckSession: false };
const result = checkSessionService.isCheckSessionConfigured(config);
expect(result).toBe(false);
});
});
describe('checkSessionChanged$', () => {
it('emits when internal event is thrown', waitForAsync(() => {
checkSessionService.checkSessionChanged$
.pipe(skip(1))
.subscribe((result) => {
expect(result).toBe(true);
});
const serviceAsAny = checkSessionService as any;
serviceAsAny.checkSessionChangedInternal$.next(true);
}));
it('emits false initially', waitForAsync(() => {
checkSessionService.checkSessionChanged$.subscribe((result) => {
expect(result).toBe(false);
});
}));
it('emits false then true when emitted', waitForAsync(() => {
const expectedResultsInOrder = [false, true];
let counter = 0;
checkSessionService.checkSessionChanged$.subscribe((result) => {
expect(result).toBe(expectedResultsInOrder[counter]);
counter++;
});
(checkSessionService as any).checkSessionChangedInternal$.next(true);
}));
});
});

View File

@@ -0,0 +1,319 @@
import { DOCUMENT } from '../dom';
import { Injectable, NgZone, OnDestroy, inject } from 'injection-js';
import { BehaviorSubject, Observable, of } from 'rxjs';
import { take } from 'rxjs/operators';
import { OpenIdConfiguration } from '../config/openid-configuration';
import { LoggerService } from '../logging/logger.service';
import { EventTypes } from '../public-events/event-types';
import { PublicEventsService } from '../public-events/public-events.service';
import { StoragePersistenceService } from '../storage/storage-persistence.service';
import { IFrameService } from './existing-iframe.service';
const IFRAME_FOR_CHECK_SESSION_IDENTIFIER = 'myiFrameForCheckSession';
// http://openid.net/specs/openid-connect-session-1_0-ID4.html
@Injectable()
export class CheckSessionService implements OnDestroy {
private readonly loggerService = inject(LoggerService);
private readonly storagePersistenceService = inject(
StoragePersistenceService
);
private readonly iFrameService = inject(IFrameService);
private readonly eventService = inject(PublicEventsService);
private readonly zone = inject(NgZone);
private readonly document = inject(DOCUMENT);
private checkSessionReceived = false;
private scheduledHeartBeatRunning: number | null = null;
private lastIFrameRefresh = 0;
private outstandingMessages = 0;
private readonly heartBeatInterval = 3000;
private readonly iframeRefreshInterval = 60000;
private readonly checkSessionChangedInternal$ = new BehaviorSubject<boolean>(
false
);
private iframeMessageEventListener?: (
this: Window,
ev: MessageEvent<any>
) => any;
get checkSessionChanged$(): Observable<boolean> {
return this.checkSessionChangedInternal$.asObservable();
}
ngOnDestroy(): void {
this.stop();
const windowAsDefaultView = this.document.defaultView;
if (windowAsDefaultView && this.iframeMessageEventListener) {
windowAsDefaultView.removeEventListener(
'message',
this.iframeMessageEventListener,
false
);
}
}
isCheckSessionConfigured(configuration: OpenIdConfiguration): boolean {
const { startCheckSession } = configuration;
return Boolean(startCheckSession);
}
start(configuration: OpenIdConfiguration): void {
if (!!this.scheduledHeartBeatRunning) {
return;
}
const { clientId } = configuration;
this.pollServerSession(clientId, configuration);
}
stop(): void {
if (!this.scheduledHeartBeatRunning) {
return;
}
this.clearScheduledHeartBeat();
this.checkSessionReceived = false;
}
serverStateChanged(configuration: OpenIdConfiguration): boolean {
const { startCheckSession } = configuration;
return Boolean(startCheckSession) && this.checkSessionReceived;
}
getExistingIframe(): HTMLIFrameElement | null {
return this.iFrameService.getExistingIFrame(
IFRAME_FOR_CHECK_SESSION_IDENTIFIER
);
}
private init(configuration: OpenIdConfiguration): Observable<void> {
if (this.lastIFrameRefresh + this.iframeRefreshInterval > Date.now()) {
return of();
}
const authWellKnownEndPoints = this.storagePersistenceService.read(
'authWellKnownEndPoints',
configuration
);
if (!authWellKnownEndPoints) {
this.loggerService.logWarning(
configuration,
'CheckSession - init check session: authWellKnownEndpoints is undefined. Returning.'
);
return of();
}
const existingIframe = this.getOrCreateIframe(configuration);
// https://www.w3.org/TR/2000/REC-DOM-Level-2-Events-20001113/events.html#Events-EventTarget-addEventListener
// If multiple identical EventListeners are registered on the same EventTarget with the same parameters the duplicate instances are discarded. They do not cause the EventListener to be called twice and since they are discarded they do not need to be removed with the removeEventListener method.
// this is done even if iframe exists for HMR to work, since iframe exists on service init
this.bindMessageEventToIframe(configuration);
const checkSessionIframe = authWellKnownEndPoints.checkSessionIframe;
const contentWindow = existingIframe.contentWindow;
if (!checkSessionIframe) {
this.loggerService.logWarning(
configuration,
'CheckSession - init check session: checkSessionIframe is not configured to run'
);
return of();
}
if (!contentWindow) {
this.loggerService.logWarning(
configuration,
'CheckSession - init check session: IFrame contentWindow does not exist'
);
} else {
contentWindow.location.replace(checkSessionIframe);
}
return new Observable((observer) => {
existingIframe.onload = (): void => {
this.lastIFrameRefresh = Date.now();
observer.next();
observer.complete();
};
});
}
private pollServerSession(
clientId: string | undefined,
configuration: OpenIdConfiguration
): void {
this.outstandingMessages = 0;
const pollServerSessionRecur = (): void => {
this.init(configuration)
.pipe(take(1))
.subscribe(() => {
const existingIframe = this.getExistingIframe();
if (existingIframe && clientId) {
this.loggerService.logDebug(
configuration,
`CheckSession - clientId : '${clientId}' - existingIframe: '${existingIframe}'`
);
const sessionState = this.storagePersistenceService.read(
'session_state',
configuration
);
const authWellKnownEndPoints = this.storagePersistenceService.read(
'authWellKnownEndPoints',
configuration
);
const contentWindow = existingIframe.contentWindow;
if (
sessionState &&
authWellKnownEndPoints?.checkSessionIframe &&
contentWindow
) {
const iframeOrigin = new URL(
authWellKnownEndPoints.checkSessionIframe
)?.origin;
this.outstandingMessages++;
contentWindow.postMessage(
clientId + ' ' + sessionState,
iframeOrigin
);
} else {
this.loggerService.logDebug(
configuration,
`CheckSession - session_state is '${sessionState}' - AuthWellKnownEndPoints is '${JSON.stringify(
authWellKnownEndPoints,
null,
2
)}'`
);
this.checkSessionChangedInternal$.next(true);
}
} else {
this.loggerService.logWarning(
configuration,
`CheckSession - OidcSecurityCheckSession pollServerSession checkSession IFrame does not exist:
clientId : '${clientId}' - existingIframe: '${existingIframe}'`
);
}
// after sending three messages with no response, fail.
if (this.outstandingMessages > 3) {
this.loggerService.logError(
configuration,
`CheckSession - OidcSecurityCheckSession not receiving check session response messages.
Outstanding messages: '${this.outstandingMessages}'. Server unreachable?`
);
}
this.zone.runOutsideAngular(() => {
this.scheduledHeartBeatRunning =
this.document?.defaultView?.setTimeout(
() => this.zone.run(pollServerSessionRecur),
this.heartBeatInterval
) ?? null;
});
});
};
pollServerSessionRecur();
}
private clearScheduledHeartBeat(): void {
if (this.scheduledHeartBeatRunning !== null) {
clearTimeout(this.scheduledHeartBeatRunning);
this.scheduledHeartBeatRunning = null;
}
}
private messageHandler(configuration: OpenIdConfiguration, e: any): void {
const existingIFrame = this.getExistingIframe();
const authWellKnownEndPoints = this.storagePersistenceService.read(
'authWellKnownEndPoints',
configuration
);
const startsWith = !!authWellKnownEndPoints?.checkSessionIframe?.startsWith(
e.origin
);
this.outstandingMessages = 0;
if (
existingIFrame &&
startsWith &&
e.source === existingIFrame.contentWindow
) {
if (e.data === 'error') {
this.loggerService.logWarning(
configuration,
'CheckSession - error from check session messageHandler'
);
} else if (e.data === 'changed') {
this.loggerService.logDebug(
configuration,
`CheckSession - ${e} from check session messageHandler`
);
this.checkSessionReceived = true;
this.eventService.fireEvent(EventTypes.CheckSessionReceived, e.data);
this.checkSessionChangedInternal$.next(true);
} else {
this.eventService.fireEvent(EventTypes.CheckSessionReceived, e.data);
this.loggerService.logDebug(
configuration,
`CheckSession - ${e.data} from check session messageHandler`
);
}
}
}
private bindMessageEventToIframe(configuration: OpenIdConfiguration): void {
this.iframeMessageEventListener = this.messageHandler.bind(
this,
configuration
);
const defaultView = this.document.defaultView;
if (defaultView) {
defaultView.addEventListener(
'message',
this.iframeMessageEventListener,
false
);
}
}
private getOrCreateIframe(
configuration: OpenIdConfiguration
): HTMLIFrameElement {
return (
this.getExistingIframe() ||
this.iFrameService.addIFrameToWindowBody(
IFRAME_FOR_CHECK_SESSION_IDENTIFIER,
configuration
)
);
}
}

View File

@@ -0,0 +1,75 @@
import { DOCUMENT } from '../../dom';
import { inject, Injectable } from 'injection-js';
import { OpenIdConfiguration } from '../config/openid-configuration';
import { LoggerService } from '../logging/logger.service';
@Injectable()
export class IFrameService {
private readonly document = inject(DOCUMENT);
private readonly loggerService = inject(LoggerService);
getExistingIFrame(identifier: string): HTMLIFrameElement | null {
const iFrameOnParent = this.getIFrameFromParentWindow(identifier);
if (this.isIFrameElement(iFrameOnParent)) {
return iFrameOnParent;
}
const iFrameOnSelf = this.getIFrameFromWindow(identifier);
if (this.isIFrameElement(iFrameOnSelf)) {
return iFrameOnSelf;
}
return null;
}
addIFrameToWindowBody(
identifier: string,
config: OpenIdConfiguration
): HTMLIFrameElement {
const sessionIframe = this.document.createElement('iframe');
sessionIframe.id = identifier;
sessionIframe.title = identifier;
this.loggerService.logDebug(config, sessionIframe);
sessionIframe.style.display = 'none';
this.document.body.appendChild(sessionIframe);
return sessionIframe;
}
private getIFrameFromParentWindow(
identifier: string
): HTMLIFrameElement | null {
try {
const iFrameElement =
this.document.defaultView?.parent.document.getElementById(identifier);
if (this.isIFrameElement(iFrameElement)) {
return iFrameElement;
}
return null;
} catch (e) {
return null;
}
}
private getIFrameFromWindow(identifier: string): HTMLIFrameElement | null {
const iFrameElement = this.document.getElementById(identifier);
if (this.isIFrameElement(iFrameElement)) {
return iFrameElement;
}
return null;
}
private isIFrameElement(
element: HTMLElement | null | undefined
): element is HTMLIFrameElement {
return !!element && element instanceof HTMLIFrameElement;
}
}

View File

@@ -0,0 +1,67 @@
import { TestBed, waitForAsync } from '@angular/core/testing';
import { of } from 'rxjs';
import { mockProvider } from '../../test/auto-mock';
import { LoggerService } from '../logging/logger.service';
import { UrlService } from '../utils/url/url.service';
import { RefreshSessionIframeService } from './refresh-session-iframe.service';
import { SilentRenewService } from './silent-renew.service';
describe('RefreshSessionIframeService ', () => {
let refreshSessionIframeService: RefreshSessionIframeService;
let urlService: UrlService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
RefreshSessionIframeService,
mockProvider(SilentRenewService),
mockProvider(LoggerService),
mockProvider(UrlService),
],
});
});
beforeEach(() => {
refreshSessionIframeService = TestBed.inject(RefreshSessionIframeService);
urlService = TestBed.inject(UrlService);
});
it('should create', () => {
expect(refreshSessionIframeService).toBeTruthy();
});
describe('refreshSessionWithIframe', () => {
it('calls sendAuthorizeRequestUsingSilentRenew with created url', waitForAsync(() => {
spyOn(urlService, 'getRefreshSessionSilentRenewUrl').and.returnValue(
of('a-url')
);
const sendAuthorizeRequestUsingSilentRenewSpy = spyOn(
refreshSessionIframeService as any,
'sendAuthorizeRequestUsingSilentRenew'
).and.returnValue(of(null));
const allConfigs = [{ configId: 'configId1' }];
refreshSessionIframeService
.refreshSessionWithIframe(allConfigs[0], allConfigs)
.subscribe(() => {
expect(
sendAuthorizeRequestUsingSilentRenewSpy
).toHaveBeenCalledOnceWith('a-url', allConfigs[0], allConfigs);
});
}));
});
describe('initSilentRenewRequest', () => {
it('dispatches customevent to window object', waitForAsync(() => {
const dispatchEventSpy = spyOn(window, 'dispatchEvent');
(refreshSessionIframeService as any).initSilentRenewRequest();
expect(dispatchEventSpy).toHaveBeenCalledOnceWith(
new CustomEvent('oidc-silent-renew-init', {
detail: jasmine.any(Number),
})
);
}));
});
});

View File

@@ -0,0 +1,106 @@
import { DOCUMENT } from '../dom';
import { Injectable, RendererFactory2, inject } from 'injection-js';
import { Observable } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { OpenIdConfiguration } from '../config/openid-configuration';
import { LoggerService } from '../logging/logger.service';
import { UrlService } from '../utils/url/url.service';
import { SilentRenewService } from './silent-renew.service';
@Injectable()
export class RefreshSessionIframeService {
private readonly renderer = inject(RendererFactory2).createRenderer(
null,
null
);
private readonly loggerService = inject(LoggerService);
private readonly urlService = inject(UrlService);
private readonly silentRenewService = inject(SilentRenewService);
private readonly document = inject(DOCUMENT);
refreshSessionWithIframe(
config: OpenIdConfiguration,
allConfigs: OpenIdConfiguration[],
customParams?: { [key: string]: string | number | boolean }
): Observable<boolean> {
this.loggerService.logDebug(
config,
'BEGIN refresh session Authorize Iframe renew'
);
return this.urlService
.getRefreshSessionSilentRenewUrl(config, customParams)
.pipe(
switchMap((url) => {
return this.sendAuthorizeRequestUsingSilentRenew(
url,
config,
allConfigs
);
})
);
}
private sendAuthorizeRequestUsingSilentRenew(
url: string | null,
config: OpenIdConfiguration,
allConfigs: OpenIdConfiguration[]
): Observable<boolean> {
const sessionIframe = this.silentRenewService.getOrCreateIframe(config);
this.initSilentRenewRequest(config, allConfigs);
this.loggerService.logDebug(
config,
`sendAuthorizeRequestUsingSilentRenew for URL: ${url}`
);
return new Observable((observer) => {
const onLoadHandler = (): void => {
sessionIframe.removeEventListener('load', onLoadHandler);
this.loggerService.logDebug(
config,
'removed event listener from IFrame'
);
observer.next(true);
observer.complete();
};
sessionIframe.addEventListener('load', onLoadHandler);
sessionIframe.contentWindow?.location.replace(url ?? '');
});
}
private initSilentRenewRequest(
config: OpenIdConfiguration,
allConfigs: OpenIdConfiguration[]
): void {
const instanceId = Math.random();
const initDestroyHandler = this.renderer.listen(
'window',
'oidc-silent-renew-init',
(e: CustomEvent) => {
if (e.detail !== instanceId) {
initDestroyHandler();
renewDestroyHandler();
}
}
);
const renewDestroyHandler = this.renderer.listen(
'window',
'oidc-silent-renew-message',
(e) =>
this.silentRenewService.silentRenewEventHandler(e, config, allConfigs)
);
this.document.defaultView?.dispatchEvent(
new CustomEvent('oidc-silent-renew-init', {
detail: instanceId,
})
);
}
}

View File

@@ -0,0 +1,377 @@
import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing';
import { Observable, of, throwError } from 'rxjs';
import { mockProvider } from '../../test/auto-mock';
import { AuthStateService } from '../auth-state/auth-state.service';
import { ImplicitFlowCallbackService } from '../callback/implicit-flow-callback.service';
import { IntervalService } from '../callback/interval.service';
import { CallbackContext } from '../flows/callback-context';
import { FlowsDataService } from '../flows/flows-data.service';
import { FlowsService } from '../flows/flows.service';
import { ResetAuthDataService } from '../flows/reset-auth-data.service';
import { LoggerService } from '../logging/logger.service';
import { FlowHelper } from '../utils/flowHelper/flow-helper.service';
import { ValidationResult } from '../validation/validation-result';
import { IFrameService } from './existing-iframe.service';
import { SilentRenewService } from './silent-renew.service';
describe('SilentRenewService ', () => {
let silentRenewService: SilentRenewService;
let flowHelper: FlowHelper;
let implicitFlowCallbackService: ImplicitFlowCallbackService;
let iFrameService: IFrameService;
let flowsDataService: FlowsDataService;
let loggerService: LoggerService;
let flowsService: FlowsService;
let authStateService: AuthStateService;
let resetAuthDataService: ResetAuthDataService;
let intervalService: IntervalService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
SilentRenewService,
IFrameService,
mockProvider(FlowsService),
mockProvider(ResetAuthDataService),
mockProvider(FlowsDataService),
mockProvider(AuthStateService),
mockProvider(LoggerService),
mockProvider(ImplicitFlowCallbackService),
mockProvider(IntervalService),
FlowHelper,
],
});
});
beforeEach(() => {
silentRenewService = TestBed.inject(SilentRenewService);
iFrameService = TestBed.inject(IFrameService);
flowHelper = TestBed.inject(FlowHelper);
implicitFlowCallbackService = TestBed.inject(ImplicitFlowCallbackService);
flowsDataService = TestBed.inject(FlowsDataService);
flowsService = TestBed.inject(FlowsService);
loggerService = TestBed.inject(LoggerService);
authStateService = TestBed.inject(AuthStateService);
resetAuthDataService = TestBed.inject(ResetAuthDataService);
intervalService = TestBed.inject(IntervalService);
});
it('should create', () => {
expect(silentRenewService).toBeTruthy();
});
describe('refreshSessionWithIFrameCompleted', () => {
it('is of type observable', () => {
expect(silentRenewService.refreshSessionWithIFrameCompleted$).toEqual(
jasmine.any(Observable)
);
});
});
describe('isSilentRenewConfigured', () => {
it('returns true if refreshToken is configured false and silentRenew is configured true', () => {
const config = { useRefreshToken: false, silentRenew: true };
const result = silentRenewService.isSilentRenewConfigured(config);
expect(result).toBe(true);
});
it('returns false if refreshToken is configured true and silentRenew is configured true', () => {
const config = { useRefreshToken: true, silentRenew: true };
const result = silentRenewService.isSilentRenewConfigured(config);
expect(result).toBe(false);
});
it('returns false if refreshToken is configured false and silentRenew is configured false', () => {
const config = { useRefreshToken: false, silentRenew: false };
const result = silentRenewService.isSilentRenewConfigured(config);
expect(result).toBe(false);
});
});
describe('getOrCreateIframe', () => {
it('returns iframe if iframe is truthy', () => {
spyOn(silentRenewService as any, 'getExistingIframe').and.returnValue({
name: 'anything',
});
const result = silentRenewService.getOrCreateIframe({
configId: 'configId1',
});
expect(result).toEqual({ name: 'anything' } as HTMLIFrameElement);
});
it('adds iframe to body if existing iframe is falsy', () => {
const config = { configId: 'configId1' };
spyOn(silentRenewService as any, 'getExistingIframe').and.returnValue(
null
);
const spy = spyOn(iFrameService, 'addIFrameToWindowBody').and.returnValue(
{ name: 'anything' } as HTMLIFrameElement
);
const result = silentRenewService.getOrCreateIframe(config);
expect(result).toEqual({ name: 'anything' } as HTMLIFrameElement);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledOnceWith('myiFrameForSilentRenew', config);
});
});
describe('codeFlowCallbackSilentRenewIframe', () => {
it('calls processSilentRenewCodeFlowCallback with correct arguments', waitForAsync(() => {
const config = { configId: 'configId1' };
const allConfigs = [config];
const spy = spyOn(
flowsService,
'processSilentRenewCodeFlowCallback'
).and.returnValue(of({} as CallbackContext));
const expectedContext = {
code: 'some-code',
refreshToken: '',
state: 'some-state',
sessionState: 'some-session-state',
authResult: null,
isRenewProcess: true,
jwtKeys: null,
validationResult: null,
existingIdToken: null,
} as CallbackContext;
const url = 'url-part-1';
const urlParts =
'code=some-code&state=some-state&session_state=some-session-state';
silentRenewService
.codeFlowCallbackSilentRenewIframe([url, urlParts], config, allConfigs)
.subscribe(() => {
expect(spy).toHaveBeenCalledOnceWith(
expectedContext,
config,
allConfigs
);
});
}));
it('throws error if url has error param and resets everything on error', waitForAsync(() => {
const config = { configId: 'configId1' };
const allConfigs = [config];
const spy = spyOn(
flowsService,
'processSilentRenewCodeFlowCallback'
).and.returnValue(of({} as CallbackContext));
const authStateServiceSpy = spyOn(
authStateService,
'updateAndPublishAuthState'
);
const resetAuthorizationDataSpy = spyOn(
resetAuthDataService,
'resetAuthorizationData'
);
const setNonceSpy = spyOn(flowsDataService, 'setNonce');
const stopPeriodicTokenCheckSpy = spyOn(
intervalService,
'stopPeriodicTokenCheck'
);
const url = 'url-part-1';
const urlParts = 'error=some_error';
silentRenewService
.codeFlowCallbackSilentRenewIframe([url, urlParts], config, allConfigs)
.subscribe({
error: (error) => {
expect(error).toEqual(new Error('some_error'));
expect(spy).not.toHaveBeenCalled();
expect(authStateServiceSpy).toHaveBeenCalledOnceWith({
isAuthenticated: false,
validationResult: ValidationResult.LoginRequired,
isRenewProcess: true,
});
expect(resetAuthorizationDataSpy).toHaveBeenCalledOnceWith(
config,
allConfigs
);
expect(setNonceSpy).toHaveBeenCalledOnceWith('', config);
expect(stopPeriodicTokenCheckSpy).toHaveBeenCalledTimes(1);
},
});
}));
});
describe('silentRenewEventHandler', () => {
it('returns if no details is given', fakeAsync(() => {
const isCurrentFlowCodeFlowSpy = spyOn(
flowHelper,
'isCurrentFlowCodeFlow'
).and.returnValue(false);
spyOn(
implicitFlowCallbackService,
'authenticatedImplicitFlowCallback'
).and.returnValue(of({} as CallbackContext));
const eventData = { detail: null } as CustomEvent;
const allConfigs = [{ configId: 'configId1' }];
silentRenewService.silentRenewEventHandler(
eventData,
allConfigs[0],
allConfigs
);
tick(1000);
expect(isCurrentFlowCodeFlowSpy).not.toHaveBeenCalled();
}));
it('calls authorizedImplicitFlowCallback if current flow is not code flow', fakeAsync(() => {
const isCurrentFlowCodeFlowSpy = spyOn(
flowHelper,
'isCurrentFlowCodeFlow'
).and.returnValue(false);
const authorizedImplicitFlowCallbackSpy = spyOn(
implicitFlowCallbackService,
'authenticatedImplicitFlowCallback'
).and.returnValue(of({} as CallbackContext));
const eventData = { detail: 'detail' } as CustomEvent;
const allConfigs = [{ configId: 'configId1' }];
silentRenewService.silentRenewEventHandler(
eventData,
allConfigs[0],
allConfigs
);
tick(1000);
expect(isCurrentFlowCodeFlowSpy).toHaveBeenCalled();
expect(authorizedImplicitFlowCallbackSpy).toHaveBeenCalledOnceWith(
allConfigs[0],
allConfigs,
'detail'
);
}));
it('calls codeFlowCallbackSilentRenewIframe if current flow is code flow', fakeAsync(() => {
spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(true);
const codeFlowCallbackSilentRenewIframe = spyOn(
silentRenewService,
'codeFlowCallbackSilentRenewIframe'
).and.returnValue(of({} as CallbackContext));
const eventData = { detail: 'detail?detail2' } as CustomEvent;
const allConfigs = [{ configId: 'configId1' }];
silentRenewService.silentRenewEventHandler(
eventData,
allConfigs[0],
allConfigs
);
tick(1000);
expect(codeFlowCallbackSilentRenewIframe).toHaveBeenCalledOnceWith(
['detail', 'detail2'],
allConfigs[0],
allConfigs
);
}));
it('calls authorizedImplicitFlowCallback if current flow is not code flow', fakeAsync(() => {
spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(true);
const codeFlowCallbackSilentRenewIframe = spyOn(
silentRenewService,
'codeFlowCallbackSilentRenewIframe'
).and.returnValue(of({} as CallbackContext));
const eventData = { detail: 'detail?detail2' } as CustomEvent;
const allConfigs = [{ configId: 'configId1' }];
silentRenewService.silentRenewEventHandler(
eventData,
allConfigs[0],
allConfigs
);
tick(1000);
expect(codeFlowCallbackSilentRenewIframe).toHaveBeenCalledOnceWith(
['detail', 'detail2'],
allConfigs[0],
allConfigs
);
}));
it('calls next on refreshSessionWithIFrameCompleted with callbackcontext', fakeAsync(() => {
spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(true);
spyOn(
silentRenewService,
'codeFlowCallbackSilentRenewIframe'
).and.returnValue(
of({ refreshToken: 'callbackContext' } as CallbackContext)
);
const eventData = { detail: 'detail?detail2' } as CustomEvent;
const allConfigs = [{ configId: 'configId1' }];
silentRenewService.refreshSessionWithIFrameCompleted$.subscribe(
(result) => {
expect(result).toEqual({
refreshToken: 'callbackContext',
} as CallbackContext);
}
);
silentRenewService.silentRenewEventHandler(
eventData,
allConfigs[0],
allConfigs
);
tick(1000);
}));
it('loggs and calls flowsDataService.resetSilentRenewRunning in case of an error', fakeAsync(() => {
spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(true);
spyOn(
silentRenewService,
'codeFlowCallbackSilentRenewIframe'
).and.returnValue(throwError(() => new Error('ERROR')));
const resetSilentRenewRunningSpy = spyOn(
flowsDataService,
'resetSilentRenewRunning'
);
const logErrorSpy = spyOn(loggerService, 'logError');
const allConfigs = [{ configId: 'configId1' }];
const eventData = { detail: 'detail?detail2' } as CustomEvent;
silentRenewService.silentRenewEventHandler(
eventData,
allConfigs[0],
allConfigs
);
tick(1000);
expect(resetSilentRenewRunningSpy).toHaveBeenCalledTimes(1);
expect(logErrorSpy).toHaveBeenCalledTimes(1);
}));
it('calls next on refreshSessionWithIFrameCompleted with null in case of error', fakeAsync(() => {
spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(true);
spyOn(
silentRenewService,
'codeFlowCallbackSilentRenewIframe'
).and.returnValue(throwError(() => new Error('ERROR')));
const eventData = { detail: 'detail?detail2' } as CustomEvent;
const allConfigs = [{ configId: 'configId1' }];
silentRenewService.refreshSessionWithIFrameCompleted$.subscribe(
(result) => {
expect(result).toBeNull();
}
);
silentRenewService.silentRenewEventHandler(
eventData,
allConfigs[0],
allConfigs
);
tick(1000);
}));
});
});

View File

@@ -0,0 +1,168 @@
import { HttpParams } from '@ngify/http';
import { inject, Injectable } from 'injection-js';
import { Observable, Subject, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { AuthStateService } from '../auth-state/auth-state.service';
import { ImplicitFlowCallbackService } from '../callback/implicit-flow-callback.service';
import { IntervalService } from '../callback/interval.service';
import { OpenIdConfiguration } from '../config/openid-configuration';
import { CallbackContext } from '../flows/callback-context';
import { FlowsDataService } from '../flows/flows-data.service';
import { FlowsService } from '../flows/flows.service';
import { ResetAuthDataService } from '../flows/reset-auth-data.service';
import { LoggerService } from '../logging/logger.service';
import { FlowHelper } from '../utils/flowHelper/flow-helper.service';
import { ValidationResult } from '../validation/validation-result';
import { IFrameService } from './existing-iframe.service';
const IFRAME_FOR_SILENT_RENEW_IDENTIFIER = 'myiFrameForSilentRenew';
@Injectable()
export class SilentRenewService {
private readonly refreshSessionWithIFrameCompletedInternal$ =
new Subject<CallbackContext | null>();
get refreshSessionWithIFrameCompleted$(): Observable<CallbackContext | null> {
return this.refreshSessionWithIFrameCompletedInternal$.asObservable();
}
private readonly loggerService = inject(LoggerService);
private readonly iFrameService = inject(IFrameService);
private readonly flowsService = inject(FlowsService);
private readonly resetAuthDataService = inject(ResetAuthDataService);
private readonly flowsDataService = inject(FlowsDataService);
private readonly authStateService = inject(AuthStateService);
private readonly flowHelper = inject(FlowHelper);
private readonly implicitFlowCallbackService = inject(
ImplicitFlowCallbackService
);
private readonly intervalService = inject(IntervalService);
getOrCreateIframe(config: OpenIdConfiguration): HTMLIFrameElement {
const existingIframe = this.getExistingIframe();
if (!existingIframe) {
return this.iFrameService.addIFrameToWindowBody(
IFRAME_FOR_SILENT_RENEW_IDENTIFIER,
config
);
}
return existingIframe;
}
isSilentRenewConfigured(configuration: OpenIdConfiguration): boolean {
const { useRefreshToken, silentRenew } = configuration;
return !useRefreshToken && Boolean(silentRenew);
}
codeFlowCallbackSilentRenewIframe(
urlParts: string[],
config: OpenIdConfiguration,
allConfigs: OpenIdConfiguration[]
): Observable<CallbackContext> {
const params = new HttpParams({
fromString: urlParts[1],
});
const errorParam = params.get('error');
if (errorParam) {
this.authStateService.updateAndPublishAuthState({
isAuthenticated: false,
validationResult: ValidationResult.LoginRequired,
isRenewProcess: true,
});
this.resetAuthDataService.resetAuthorizationData(config, allConfigs);
this.flowsDataService.setNonce('', config);
this.intervalService.stopPeriodicTokenCheck();
return throwError(() => new Error(errorParam));
}
const code = params.get('code') ?? '';
const state = params.get('state') ?? '';
const sessionState = params.get('session_state');
const callbackContext: CallbackContext = {
code,
refreshToken: '',
state,
sessionState,
authResult: null,
isRenewProcess: true,
jwtKeys: null,
validationResult: null,
existingIdToken: null,
};
return this.flowsService
.processSilentRenewCodeFlowCallback(callbackContext, config, allConfigs)
.pipe(
catchError((error) => {
this.intervalService.stopPeriodicTokenCheck();
this.resetAuthDataService.resetAuthorizationData(config, allConfigs);
return throwError(() => new Error(error));
})
);
}
silentRenewEventHandler(
e: CustomEvent,
config: OpenIdConfiguration,
allConfigs: OpenIdConfiguration[]
): void {
this.loggerService.logDebug(config, 'silentRenewEventHandler');
if (!e.detail) {
return;
}
let callback$: Observable<CallbackContext>;
const isCodeFlow = this.flowHelper.isCurrentFlowCodeFlow(config);
if (isCodeFlow) {
const urlParts = e.detail.toString().split('?');
callback$ = this.codeFlowCallbackSilentRenewIframe(
urlParts,
config,
allConfigs
);
} else {
callback$ =
this.implicitFlowCallbackService.authenticatedImplicitFlowCallback(
config,
allConfigs,
e.detail
);
}
callback$.subscribe({
next: (callbackContext) => {
this.refreshSessionWithIFrameCompletedInternal$.next(callbackContext);
this.flowsDataService.resetSilentRenewRunning(config);
},
error: (err: unknown) => {
this.loggerService.logError(config, 'Error: ' + err);
this.refreshSessionWithIFrameCompletedInternal$.next(null);
this.flowsDataService.resetSilentRenewRunning(config);
},
});
}
private getExistingIframe(): HTMLIFrameElement | null {
return this.iFrameService.getExistingIFrame(
IFRAME_FOR_SILENT_RENEW_IDENTIFIER
);
}
}