This commit is contained in:
Jovan Krunić
2026-09-23 17:57:29 +02:00
parent 2989dda68b
commit 7002d8861f
3 changed files with 346 additions and 37 deletions
@@ -0,0 +1,187 @@
import {
AuthorizationServiceConfiguration,
TokenResponse,
} from '@openid/appauth';
import {firstValueFrom} from 'rxjs';
import {
AuthActions,
AuthProvider,
} from './auth.provider';
describe('AuthProvider', () => {
let provider: AuthProvider;
beforeEach(() => {
const platform = {
is: () => false,
} as any;
const configProvider = {} as any;
provider = new AuthProvider(
platform,
configProvider,
);
(provider as any).authConfigValue = {
server_host: 'https://idp.example.org',
client_id: 'test-client',
redirect_url: 'https://app.example.org/callback',
scopes: 'openid profile',
pkce: true,
};
(provider as any).localConfiguration =
new AuthorizationServiceConfiguration({
authorization_endpoint:
'https://idp.example.org/authorize',
token_endpoint:
'https://idp.example.org/token',
revocation_endpoint:
'https://idp.example.org/revoke',
});
});
it('refreshes the token when the access token is expired', async () => {
const expiredToken = new TokenResponse({
access_token: 'expired-access-token',
refresh_token: 'valid-refresh-token',
token_type: 'bearer',
});
spyOn(
expiredToken,
'isValid',
).and.returnValue(false);
(provider as any).tokenSubject.next(
expiredToken,
);
const refreshedToken = new TokenResponse({
access_token: 'new-access-token',
refresh_token: 'new-refresh-token',
token_type: 'bearer',
expires_in: '3600',
});
spyOn(
refreshedToken,
'isValid',
).and.returnValue(true);
const performTokenRequest =
spyOn(
(provider as any).tokenHandler,
'performTokenRequest',
).and.resolveTo(
refreshedToken,
);
spyOn(
(provider as any).storage,
'setItem',
).and.resolveTo();
const token =
await provider.getValidToken();
expect(
performTokenRequest,
).toHaveBeenCalled();
expect(
token.accessToken,
).toBe(
'new-access-token',
);
expect(
token.refreshToken,
).toBe(
'new-refresh-token',
);
});
it('fails when the refresh token is expired', async () => {
const expiredToken = new TokenResponse({
access_token: 'expired-access-token',
refresh_token: 'expired-refresh-token',
token_type: 'bearer',
});
spyOn(
expiredToken,
'isValid',
).and.returnValue(false);
(provider as any).tokenSubject.next(
expiredToken,
);
spyOn(
(provider as any).tokenHandler,
'performTokenRequest',
).and.returnValue(
Promise.reject(
new Error('invalid_grant'),
),
);
await expectAsync(
provider.getValidToken(),
).toBeRejectedWithError(
'invalid_grant',
);
});
it('changes isLoggedIn to false when the refresh token fails', async () => {
const expiredToken = new TokenResponse({
access_token: 'expired-access-token',
refresh_token: 'expired-refresh-token',
token_type: 'bearer',
});
spyOn(
expiredToken,
'isValid',
).and.returnValue(false);
(provider as any).notify({
action:
AuthActions.LoadTokenFromStorageSuccess,
tokenResponse:
expiredToken,
});
expect(
await firstValueFrom(
provider.isLoggedIn$,
),
).toBeTrue();
spyOn(
(provider as any).tokenHandler,
'performTokenRequest',
).and.returnValue(
Promise.reject(
new Error('invalid_grant'),
),
);
await expectAsync(
provider.getValidToken(),
).toBeRejectedWithError(
'invalid_grant',
);
expect(
await firstValueFrom(
provider.isLoggedIn$,
),
).toBeFalse();
});
});
@@ -554,7 +554,33 @@ export class AuthProvider {
* requestTokenRefresh() stores the newly returned token
* and updates tokenSubject via RefreshSuccess.
*/
await this.requestTokenRefresh();
try {
await this.requestTokenRefresh();
} catch (error) {
/*
* An invalid_grant response means that the refresh token
* is no longer usable, for example because it expired or
* was revoked.
*
* Network errors must not log the user out.
*/
if (
error instanceof Error &&
error.message.includes(
'invalid_grant',
)
) {
this.authenticatedSubject.next(
false,
);
this.loggedInSubject.next(
false,
);
}
throw error;
}
const refreshedToken =
this.tokenSubject.value;
@@ -875,7 +901,6 @@ export class AuthProvider {
.completeAuthorizationRequestIfPossible();
}
private async internalEndSessionCallback():
Promise<void> {
await this.closeBrowser();
@@ -1,70 +1,167 @@
import {ConfigProvider} from '../config/config.provider';
import {IdCardsProvider} from './id-cards.provider';
import {HttpClient} from '@angular/common/http';
import {AuthHelperService} from '../auth/auth-helper.service';
import {BehaviorSubject, firstValueFrom, of} from 'rxjs';
import {AuthHelperService} from '../auth/auth-helper.service';
import {AuthProvider} from '../auth/auth.provider';
import {ConfigProvider} from '../config/config.provider';
import {EncryptedStorageProvider} from '../storage/encrypted-storage.provider';
import {IdCardsProvider} from './id-cards.provider';
class FakeAuth {
isLoggedIn$ = new BehaviorSubject(false);
// eslint-disable-next-line @typescript-eslint/no-empty-function
getValidToken() {}
getValidToken = jasmine.createSpy('getValidToken');
}
describe('IdCards', () => {
let authHelper: AuthHelperService;
let configProvider: ConfigProvider;
let httpClient: HttpClient;
let authHelper: AuthHelperService;
let authProvider: AuthProvider;
let encryptedStorageProvider: EncryptedStorageProvider;
let fakeAuth: FakeAuth;
beforeEach(() => {
configProvider = jasmine.createSpyObj('ConfigProvider', ['config']);
authHelper = jasmine.createSpyObj(
'AuthHelperService',
['getUserFromUserInfo'],
);
configProvider = jasmine.createSpyObj(
'ConfigProvider',
['config'],
);
configProvider.config = {
app: {features: {extern: {idCards: {url: 'http://id-cards.local', authProvider: 'fakeAuth'}}}},
app: {
features: {
extern: {
idCards: {
url: 'http://id-cards.local',
authProvider: 'fakeAuth',
},
},
},
},
} as never;
httpClient = jasmine.createSpyObj('HttpClient', ['get']);
httpClient = jasmine.createSpyObj(
'HttpClient',
['get'],
);
fakeAuth = new FakeAuth();
authHelper = jasmine.createSpyObj('AuthHelperService', ['getProvider']);
authHelper.getProvider = jasmine.createSpy().and.returnValue(fakeAuth);
encryptedStorageProvider = jasmine.createSpyObj('EncryptedStorageProvider', ['get', 'set', 'delete']);
encryptedStorageProvider.get = jasmine.createSpy().and.resolveTo();
encryptedStorageProvider.set = jasmine.createSpy().and.resolveTo();
encryptedStorageProvider.delete = jasmine.createSpy().and.resolveTo();
authProvider =
fakeAuth as unknown as AuthProvider;
encryptedStorageProvider =
jasmine.createSpyObj(
'EncryptedStorageProvider',
['get', 'set', 'delete'],
);
encryptedStorageProvider.get =
jasmine.createSpy().and.resolveTo();
encryptedStorageProvider.set =
jasmine.createSpy().and.resolveTo();
encryptedStorageProvider.delete =
jasmine.createSpy().and.resolveTo();
});
it('should emit undefined if not logged in', async () => {
const provider = new IdCardsProvider(authHelper, configProvider, httpClient, encryptedStorageProvider);
expect(await firstValueFrom(provider.getIdCards())).toEqual([]);
expect(authHelper.getProvider).toHaveBeenCalledTimes(1);
const provider = new IdCardsProvider(
authHelper,
authProvider,
configProvider,
httpClient,
encryptedStorageProvider,
);
expect(
await firstValueFrom(
provider.getIdCards(),
),
).toEqual([]);
});
it('should emit network result when logged in', async () => {
fakeAuth.isLoggedIn$.next(true);
httpClient.get = jasmine.createSpy().and.returnValue(of(['abc']));
fakeAuth.getValidToken = jasmine.createSpy().and.resolveTo({accessToken: 'fake-token'});
const provider = new IdCardsProvider(authHelper, configProvider, httpClient, encryptedStorageProvider);
expect(await firstValueFrom(provider.getIdCards())).toEqual(['abc' as never]);
expect(authHelper.getProvider).toHaveBeenCalledTimes(1);
// eslint-disable-next-line unicorn/no-null
expect(httpClient.get).toHaveBeenCalledOnceWith('http://id-cards.local', {
headers: {
Authorization: 'Bearer fake-token',
},
responseType: 'json',
httpClient.get =
jasmine.createSpy().and.returnValue(
of(['abc']),
);
fakeAuth.getValidToken.and.resolveTo({
accessToken: 'fake-token',
});
const provider = new IdCardsProvider(
authHelper,
authProvider,
configProvider,
httpClient,
encryptedStorageProvider,
);
expect(
await firstValueFrom(
provider.getIdCards(),
),
).toEqual([
'abc' as never,
]);
// eslint-disable-next-line unicorn/no-null
expect(httpClient.get)
.toHaveBeenCalledOnceWith(
'http://id-cards.local',
{
headers: {
Authorization:
'Bearer fake-token',
},
responseType: 'json',
},
);
});
it('should react to logins', async () => {
const provider = new IdCardsProvider(authHelper, configProvider, httpClient, encryptedStorageProvider);
const observable = provider.getIdCards();
expect(await firstValueFrom(observable)).toEqual([]);
httpClient.get = jasmine.createSpy().and.returnValue(of(['abc']));
fakeAuth.getValidToken = jasmine.createSpy().and.resolveTo({accessToken: 'fake-token'});
const provider = new IdCardsProvider(
authHelper,
authProvider,
configProvider,
httpClient,
encryptedStorageProvider,
);
const observable =
provider.getIdCards();
expect(
await firstValueFrom(observable),
).toEqual([]);
httpClient.get =
jasmine.createSpy().and.returnValue(
of(['abc']),
);
fakeAuth.getValidToken.and.resolveTo({
accessToken: 'fake-token',
});
fakeAuth.isLoggedIn$.next(true);
// this is counter-intuitive, but because we unsubscribed above the first value
// will now contain the network result.
expect(await firstValueFrom(observable)).toEqual(['abc' as never]);
expect(
await firstValueFrom(observable),
).toEqual([
'abc' as never,
]);
});
});