mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-10-13 04:52:47 +00:00
WIP
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (C) 2026 StApps
|
||||
* This program is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the Free
|
||||
* Software Foundation, version 3.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/*
|
||||
* tsconfig.spec.json maps every `@capacitor/*` import to this folder, so this
|
||||
* file replaces `@capacitor/core` in all tests – including the imports made by
|
||||
* real Capacitor plugins (registerPlugin, WebPlugin, CapacitorHttp, …).
|
||||
*
|
||||
* Therefore everything is re-exported from the real package (imported via a
|
||||
* relative path, which bypasses the path mapping) and only `Capacitor` is
|
||||
* replaced by a controllable mock. The local export shadows the one from
|
||||
* `export *`.
|
||||
*/
|
||||
export * from '../../node_modules/@capacitor/core';
|
||||
|
||||
type Platform = 'web' | 'ios' | 'android';
|
||||
|
||||
export class CapacitorMock {
|
||||
private platform: Platform = 'web';
|
||||
|
||||
getPlatform(): Platform {
|
||||
return this.platform;
|
||||
}
|
||||
|
||||
isNativePlatform(): boolean {
|
||||
return this.platform !== 'web';
|
||||
}
|
||||
|
||||
isPluginAvailable(_name: string): boolean {
|
||||
return this.isNativePlatform();
|
||||
}
|
||||
|
||||
convertFileSrc(filePath: string): string {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test helper: simulate running on the given platform.
|
||||
*/
|
||||
setPlatform(platform: Platform): void {
|
||||
this.platform = platform;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test helper: back to the default (web), call this in afterEach.
|
||||
*/
|
||||
reset(): void {
|
||||
this.platform = 'web';
|
||||
}
|
||||
}
|
||||
|
||||
export const Capacitor = new CapacitorMock();
|
||||
@@ -1,3 +1,10 @@
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {AlertController} from '@ionic/angular/standalone';
|
||||
import {TranslateService} from '@ngx-translate/core';
|
||||
|
||||
import {SimpleBrowser} from '../../util/browser.factory';
|
||||
import {ConfigProvider} from '../config/config.provider';
|
||||
import {StorageProvider} from '../storage/storage.provider';
|
||||
import {AuthActions, AuthProvider} from './auth.provider';
|
||||
import {AuthHelperService} from './auth-helper.service';
|
||||
|
||||
@@ -38,14 +45,21 @@ describe('AuthHelperService', () => {
|
||||
function createService(authConfig: unknown = {default: {endpoints: {mapping: USER_MAPPING}}}) {
|
||||
configProvider.getAnyValue.and.returnValue(authConfig);
|
||||
|
||||
return new AuthHelperService(
|
||||
translateService as any,
|
||||
configProvider as any,
|
||||
storageProvider as any,
|
||||
authProvider as any,
|
||||
browser as any,
|
||||
alertController as any,
|
||||
);
|
||||
// AuthHelperService uses inject(), so it has to be created inside an injection context.
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
AuthHelperService,
|
||||
{provide: TranslateService, useValue: translateService},
|
||||
{provide: ConfigProvider, useValue: configProvider},
|
||||
{provide: StorageProvider, useValue: storageProvider},
|
||||
{provide: AuthProvider, useValue: authProvider},
|
||||
{provide: SimpleBrowser, useValue: browser},
|
||||
{provide: AlertController, useValue: alertController},
|
||||
],
|
||||
});
|
||||
|
||||
return TestBed.inject(AuthHelperService);
|
||||
}
|
||||
|
||||
/** Options passed to the most recent AlertController.create() call. */
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (C) 2026 StApps
|
||||
* This program is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the Free
|
||||
* Software Foundation, version 3.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Capacitor} from '@capacitor/core';
|
||||
import {SCAuthorizationProvider} from '@openstapps/core';
|
||||
|
||||
import type {CapacitorMock} from '../../../../__mocks__/@capacitor/core';
|
||||
import {getClientConfig, getPlatformScopes} from './auth.provider.methods';
|
||||
|
||||
const capacitor = Capacitor as unknown as CapacitorMock;
|
||||
|
||||
const authConfig = {
|
||||
default: {
|
||||
client: {
|
||||
clientId: 'client-id',
|
||||
scopes: 'openid profile email offline_access',
|
||||
url: 'https://idp.example.com',
|
||||
},
|
||||
endpoints: {
|
||||
authorization: 'https://idp.example.com/authorize',
|
||||
token: 'https://idp.example.com/token',
|
||||
userinfo: 'https://idp.example.com/userinfo',
|
||||
mapping: {},
|
||||
},
|
||||
} as unknown as SCAuthorizationProvider,
|
||||
};
|
||||
|
||||
describe('auth.provider.methods', () => {
|
||||
afterEach(() => {
|
||||
capacitor.reset();
|
||||
});
|
||||
|
||||
describe('getClientConfig', () => {
|
||||
it('requests offline_access on iOS', () => {
|
||||
capacitor.setPlatform('ios');
|
||||
|
||||
expect(getClientConfig(authConfig).scopes).toBe('openid profile email offline_access');
|
||||
});
|
||||
|
||||
it('requests offline_access on Android', () => {
|
||||
capacitor.setPlatform('android');
|
||||
|
||||
expect(getClientConfig(authConfig).scopes).toBe('openid profile email offline_access');
|
||||
});
|
||||
|
||||
it('does not request offline_access in the browser', () => {
|
||||
capacitor.setPlatform('web');
|
||||
|
||||
expect(getClientConfig(authConfig).scopes).toBe('openid profile email');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlatformScopes', () => {
|
||||
it('leaves scopes without offline_access unchanged in the browser', () => {
|
||||
expect(getPlatformScopes('openid profile')).toBe('openid profile');
|
||||
});
|
||||
|
||||
it('normalizes extra whitespace', () => {
|
||||
expect(getPlatformScopes(' openid offline_access profile ')).toBe('openid profile');
|
||||
});
|
||||
|
||||
it('still accepts an explicit platform flag', () => {
|
||||
expect(getPlatformScopes('openid offline_access', true)).toBe('openid offline_access');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,8 @@ export interface OidcEndpoints {
|
||||
endSession?: string;
|
||||
}
|
||||
|
||||
export const OFFLINE_ACCESS_SCOPE = 'offline_access';
|
||||
|
||||
/**
|
||||
* Returns the configuration of the OIDC client.
|
||||
*/
|
||||
@@ -39,15 +41,40 @@ export function getClientConfig(
|
||||
const provider =
|
||||
authConfig.default;
|
||||
|
||||
console.log(getPlatformScopes(provider.client.scopes));
|
||||
|
||||
return {
|
||||
server_host: provider.client.url,
|
||||
client_id: provider.client.clientId,
|
||||
scopes: provider.client.scopes,
|
||||
scopes: getPlatformScopes(provider.client.scopes),
|
||||
redirect_url: getRedirectUrl(),
|
||||
pkce: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the scopes to request on the current platform.
|
||||
*
|
||||
* `offline_access` (long-lived refresh tokens) is only requested in the
|
||||
* native apps, where tokens live in the Keychain / Keystore. In the browser,
|
||||
* tokens live in web storage and are reachable by any XSS, so the session is
|
||||
* limited to the access token lifetime instead.
|
||||
*/
|
||||
export function getPlatformScopes(
|
||||
scopes: string,
|
||||
isNative: boolean = Capacitor.isNativePlatform(),
|
||||
): string {
|
||||
const scopeList = scopes
|
||||
.split(/\s+/)
|
||||
.filter(scope => scope.length > 0);
|
||||
|
||||
return (
|
||||
isNative
|
||||
? scopeList
|
||||
: scopeList.filter(scope => scope !== OFFLINE_ACCESS_SCOPE)
|
||||
).join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the AppAuth OAuth/OIDC service configuration.
|
||||
*/
|
||||
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
TokenRequestHandler,
|
||||
TokenResponse,
|
||||
} from '@openid/appauth';
|
||||
import {BehaviorSubject, firstValueFrom} from 'rxjs';
|
||||
import {BehaviorSubject, firstValueFrom, skip} from 'rxjs';
|
||||
|
||||
import {AuthProvider, IAuthAction, INVALID_GRANT} from './auth.provider';
|
||||
import {AuthActions, AuthProvider, IAuthAction, INVALID_GRANT, isInvalidGrant} from './auth.provider';
|
||||
|
||||
const TOKEN_RESPONSE_KEY = 'token_response';
|
||||
const TOKEN_LIFETIME = 3600;
|
||||
@@ -58,6 +58,13 @@ describe('AuthProvider', () => {
|
||||
await provider.loadTokenFromStorage();
|
||||
}
|
||||
|
||||
/** Records every auth event emitted after this call (skips the replayed last one). */
|
||||
function recordEvents(): AuthActions[] {
|
||||
const events: AuthActions[] = [];
|
||||
provider.events$.pipe(skip(1)).subscribe(event => events.push(event.action));
|
||||
return events;
|
||||
}
|
||||
|
||||
const isAuthenticated = () => firstValueFrom(provider.isAuthenticated$);
|
||||
const isLoggedIn = () => firstValueFrom(provider.isLoggedIn$);
|
||||
|
||||
@@ -187,6 +194,100 @@ describe('AuthProvider', () => {
|
||||
expect(tokenRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends only one refresh request for parallel callers', async () => {
|
||||
await givenLoadedToken(makeToken({remainingSeconds: -60}));
|
||||
tokenRequest.and.resolveTo(makeToken({accessToken: 'new-access-token'}));
|
||||
|
||||
const tokens = await Promise.all([
|
||||
provider.getValidToken(),
|
||||
provider.getValidToken(),
|
||||
provider.getValidToken(),
|
||||
]);
|
||||
|
||||
expect(tokenRequest).toHaveBeenCalledTimes(1);
|
||||
expect(tokens.map(token => token.accessToken)).toEqual([
|
||||
'new-access-token',
|
||||
'new-access-token',
|
||||
'new-access-token',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refreshes again after a previous refresh has finished', async () => {
|
||||
await givenLoadedToken(makeToken({remainingSeconds: -60}));
|
||||
tokenRequest.and.resolveTo(makeToken({accessToken: 'first', remainingSeconds: -60}));
|
||||
|
||||
// The first refresh returns an already expired token, so the call fails ...
|
||||
await expectAsync(provider.getValidToken()).toBeRejected();
|
||||
|
||||
// ... and the next call must start a new request instead of reusing the old one.
|
||||
tokenRequest.and.resolveTo(makeToken({accessToken: 'second'}));
|
||||
expect((await provider.getValidToken()).accessToken).toBe('second');
|
||||
expect(tokenRequest).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe('without a refresh token (browser)', () => {
|
||||
it('uses the token within the buffer instead of failing early', async () => {
|
||||
await givenLoadedToken(makeToken({accessToken: 'current-access-token', remainingSeconds: 60, refreshToken: null}));
|
||||
|
||||
const token = await provider.getValidToken();
|
||||
|
||||
expect(token.accessToken).toBe('current-access-token');
|
||||
expect(tokenRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs the user out when the token expires while the app is open', async () => {
|
||||
await givenLoadedToken(makeToken({remainingSeconds: 60, refreshToken: null}));
|
||||
expect(await isLoggedIn()).toBeTrue();
|
||||
const events = recordEvents();
|
||||
|
||||
jasmine.clock().tick(2 * 60 * 1000);
|
||||
|
||||
await expectAsync(provider.getValidToken()).toBeRejectedWithError(/No Refresh Token Available/);
|
||||
expect(await isLoggedIn()).toBeFalse();
|
||||
expect(internals.tokenSubject.value).toBeUndefined();
|
||||
expect(storageRemoveItem).toHaveBeenCalledOnceWith(TOKEN_RESPONSE_KEY);
|
||||
expect(events).toContain(AuthActions.SessionExpired);
|
||||
expect(tokenRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not restore an expired token from storage', async () => {
|
||||
await givenLoadedToken(makeToken({remainingSeconds: -60, refreshToken: null}));
|
||||
|
||||
expect(await isLoggedIn()).toBeFalse();
|
||||
expect(internals.tokenSubject.value).toBeUndefined();
|
||||
expect(storageRemoveItem).toHaveBeenCalledOnceWith(TOKEN_RESPONSE_KEY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when refresh fails with invalid_grant from the native requestor', () => {
|
||||
// CapacitorRequestor rejects non-2xx responses itself and keeps the OAuth body in extras.
|
||||
const refreshError = new AppAuthError('HTTP 400', {error: INVALID_GRANT});
|
||||
|
||||
it('logs the user out', async () => {
|
||||
await givenLoadedToken(makeToken({remainingSeconds: -60}));
|
||||
const events = recordEvents();
|
||||
tokenRequest.and.rejectWith(refreshError);
|
||||
|
||||
await expectAsync(provider.getValidToken()).toBeRejectedWith(refreshError);
|
||||
|
||||
expect(await isLoggedIn()).toBeFalse();
|
||||
expect(storageRemoveItem).toHaveBeenCalledOnceWith(TOKEN_RESPONSE_KEY);
|
||||
expect(events).toEqual([AuthActions.RefreshFailed, AuthActions.SessionExpired]);
|
||||
});
|
||||
|
||||
it('logs the user out only once for parallel callers', async () => {
|
||||
await givenLoadedToken(makeToken({remainingSeconds: -60}));
|
||||
const events = recordEvents();
|
||||
tokenRequest.and.rejectWith(refreshError);
|
||||
|
||||
const results = await Promise.allSettled([provider.getValidToken(), provider.getValidToken()]);
|
||||
|
||||
expect(results.map(result => result.status)).toEqual(['rejected', 'rejected']);
|
||||
expect(tokenRequest).toHaveBeenCalledTimes(1);
|
||||
expect(events.filter(event => event === AuthActions.SessionExpired).length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when refresh fails with invalid_grant', () => {
|
||||
const refreshError = new AppAuthError(INVALID_GRANT);
|
||||
|
||||
@@ -236,4 +337,24 @@ describe('AuthProvider', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInvalidGrant', () => {
|
||||
it('detects invalid_grant as error message', () => {
|
||||
expect(isInvalidGrant(new AppAuthError(INVALID_GRANT))).toBeTrue();
|
||||
});
|
||||
|
||||
it('detects invalid_grant in the response body', () => {
|
||||
expect(isInvalidGrant(new AppAuthError('HTTP 400', {error: INVALID_GRANT}))).toBeTrue();
|
||||
});
|
||||
|
||||
it('ignores other OAuth errors', () => {
|
||||
expect(isInvalidGrant(new AppAuthError('HTTP 400', {error: 'invalid_request'}))).toBeFalse();
|
||||
});
|
||||
|
||||
it('ignores network and non-AppAuth errors', () => {
|
||||
expect(isInvalidGrant(new AppAuthError('HTTP 503'))).toBeFalse();
|
||||
expect(isInvalidGrant(new Error(INVALID_GRANT))).toBeFalse();
|
||||
expect(isInvalidGrant(undefined)).toBeFalse();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ import {storageFactory} from './factories/storage.factory';
|
||||
|
||||
const TOKEN_RESPONSE_KEY = 'token_response';
|
||||
export const INVALID_GRANT: ErrorType = 'invalid_grant';
|
||||
const AUTH_EXPIRY_BUFFER = -3 * 60;
|
||||
const AUTH_EXPIRY_BUFFER = -10 * 60;
|
||||
|
||||
export interface AuthConfig {
|
||||
server_host: string;
|
||||
@@ -98,6 +98,15 @@ export enum AuthActions {
|
||||
|
||||
LoadUserInfoFailed =
|
||||
'LoadUserInfoFailed',
|
||||
|
||||
/**
|
||||
* The session can no longer be renewed: the access token expired without a
|
||||
* refresh token (browser), or the IdP rejected the refresh token with
|
||||
* invalid_grant (e.g. refresh token chain lifetime reached).
|
||||
* The user is logged out locally.
|
||||
*/
|
||||
SessionExpired =
|
||||
'SessionExpired',
|
||||
}
|
||||
|
||||
export interface IAuthAction {
|
||||
@@ -125,6 +134,31 @@ interface AuthorizationHandler {
|
||||
Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the token endpoint rejected the grant (RFC 6749, 5.2).
|
||||
*
|
||||
* AppAuth's BaseTokenRequestHandler only produces
|
||||
* AppAuthError('invalid_grant') when the requestor resolves with the error
|
||||
* body. Our requestors reject non-2xx responses themselves, so the OAuth error
|
||||
* may also be found in the error's extras (the parsed response body).
|
||||
*/
|
||||
export function isInvalidGrant(
|
||||
error: unknown,
|
||||
): boolean {
|
||||
if (!(error instanceof AppAuthError)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (error.message === INVALID_GRANT) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const extras =
|
||||
error.extras as {error?: unknown} | undefined;
|
||||
|
||||
return extras?.error === INVALID_GRANT;
|
||||
}
|
||||
|
||||
class NoHashQueryStringUtils extends BasicQueryStringUtils {
|
||||
override parse(
|
||||
input: LocationLike,
|
||||
@@ -183,6 +217,13 @@ export class AuthProvider {
|
||||
|
||||
private notifierInitialized = false;
|
||||
|
||||
/**
|
||||
* Refresh request currently in progress. Parallel callers share it, so the
|
||||
* same refresh token is never sent twice (with refresh token rotation the
|
||||
* second request would fail with invalid_grant and log the user out).
|
||||
*/
|
||||
private refreshInFlight?: Promise<void>;
|
||||
|
||||
constructor(
|
||||
private readonly platform: Platform,
|
||||
private readonly configProvider: ConfigProvider,
|
||||
@@ -353,11 +394,12 @@ export class AuthProvider {
|
||||
public async refreshToken():
|
||||
Promise<void> {
|
||||
try {
|
||||
await this.requestTokenRefresh();
|
||||
} catch (error) {
|
||||
await this.handleRefreshFailure(
|
||||
error,
|
||||
);
|
||||
await this.refreshSession();
|
||||
} catch {
|
||||
/*
|
||||
* Already handled by refreshSession(): the session is expired on
|
||||
* invalid_grant and kept on temporary errors.
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,6 +547,23 @@ export class AuthProvider {
|
||||
JSON.parse(stored),
|
||||
);
|
||||
|
||||
/*
|
||||
* Expired and not renewable (browser, no refresh token):
|
||||
* this session is over, do not show the user as logged in.
|
||||
*/
|
||||
if (
|
||||
!token.isValid(0) &&
|
||||
!token.refreshToken
|
||||
) {
|
||||
await this.storage.removeItem(
|
||||
TOKEN_RESPONSE_KEY,
|
||||
);
|
||||
|
||||
throw new Error(
|
||||
'Stored Token Expired',
|
||||
);
|
||||
}
|
||||
|
||||
this.notify({
|
||||
action:
|
||||
AuthActions
|
||||
@@ -535,35 +594,40 @@ export class AuthProvider {
|
||||
* expiry buffer.
|
||||
*/
|
||||
if (currentToken?.isValid(buffer)) {
|
||||
console.log(currentToken?.isValid(buffer));
|
||||
return currentToken;
|
||||
}
|
||||
|
||||
/*
|
||||
* Access token is expired or will expire soon.
|
||||
* We can only recover automatically if a refresh token exists.
|
||||
*/
|
||||
if (!currentToken?.refreshToken) {
|
||||
/*
|
||||
* Without a refresh token (browser) nothing can be renewed early,
|
||||
* so the buffer is pointless: use the token until it really expires.
|
||||
*/
|
||||
if (currentToken?.isValid(0)) {
|
||||
return currentToken;
|
||||
}
|
||||
|
||||
/*
|
||||
* Expired and not renewable: the session is over.
|
||||
*/
|
||||
if (currentToken) {
|
||||
await this.expireSession(
|
||||
new Error('Access Token Expired Without Refresh Token'),
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Unable To Obtain Valid Token: No Refresh Token Available',
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Refresh immediately.
|
||||
* Access token is expired or will expire soon: refresh.
|
||||
*
|
||||
* requestTokenRefresh() stores the newly returned token
|
||||
* and updates tokenSubject via RefreshSuccess.
|
||||
* refreshSession() stores the newly returned token and updates
|
||||
* tokenSubject via RefreshSuccess. On invalid_grant it has already
|
||||
* logged the user out.
|
||||
*/
|
||||
try {
|
||||
await this.requestTokenRefresh();
|
||||
} catch (error) {
|
||||
await this.handleRefreshFailure(
|
||||
error,
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
await this.refreshSession();
|
||||
|
||||
const refreshedToken =
|
||||
this.tokenSubject.value;
|
||||
@@ -583,53 +647,71 @@ export class AuthProvider {
|
||||
return refreshedToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the token once, shared by all parallel callers.
|
||||
* Rejects with the original error after it has been handled.
|
||||
*/
|
||||
private refreshSession(): Promise<void> {
|
||||
this.refreshInFlight ??= (async () => {
|
||||
try {
|
||||
await this.requestTokenRefresh();
|
||||
} catch (error) {
|
||||
await this.handleRefreshFailure(
|
||||
error,
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
})().finally(() => {
|
||||
this.refreshInFlight = undefined;
|
||||
});
|
||||
|
||||
return this.refreshInFlight;
|
||||
}
|
||||
|
||||
private async handleRefreshFailure(
|
||||
error: unknown,
|
||||
): Promise<void> {
|
||||
this.notify({
|
||||
action:
|
||||
AuthActions.RefreshFailed,
|
||||
|
||||
error,
|
||||
});
|
||||
|
||||
/*
|
||||
* AppAuth maps OAuth token endpoint errors to AppAuthError.
|
||||
* This OAuth error means that the refresh token is no longer
|
||||
* usable, for example because it expired, was revoked or
|
||||
* the refresh-token chain lifetime has been reached.
|
||||
* invalid_grant means that the refresh token is no longer usable,
|
||||
* for example because it expired, was revoked or the refresh-token
|
||||
* chain lifetime has been reached. Log the user out.
|
||||
*
|
||||
* Network and other temporary errors must not log the user
|
||||
* out.
|
||||
* Network and other temporary errors must not log the user out.
|
||||
*/
|
||||
if (
|
||||
error instanceof AppAuthError &&
|
||||
error.message === INVALID_GRANT
|
||||
) {
|
||||
try {
|
||||
await this.storage.removeItem(
|
||||
TOKEN_RESPONSE_KEY,
|
||||
);
|
||||
} catch {
|
||||
/*
|
||||
* Do not hide the original OAuth error if removing the
|
||||
* locally stored token fails.
|
||||
*/
|
||||
}
|
||||
|
||||
this.tokenSubject.next(
|
||||
undefined,
|
||||
if (isInvalidGrant(error)) {
|
||||
await this.expireSession(
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.userSubject.next(
|
||||
undefined,
|
||||
);
|
||||
|
||||
this.authenticatedSubject.next(
|
||||
false,
|
||||
);
|
||||
|
||||
this.loggedInSubject.next(
|
||||
false,
|
||||
/**
|
||||
* Ends the local session because it cannot be renewed anymore.
|
||||
*/
|
||||
private async expireSession(
|
||||
error: unknown,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.storage.removeItem(
|
||||
TOKEN_RESPONSE_KEY,
|
||||
);
|
||||
} catch {
|
||||
/*
|
||||
* Still log out in memory if removing the stored token fails.
|
||||
*/
|
||||
}
|
||||
|
||||
this.notify({
|
||||
action:
|
||||
AuthActions.RefreshFailed,
|
||||
AuthActions.SessionExpired,
|
||||
|
||||
error,
|
||||
});
|
||||
@@ -983,7 +1065,8 @@ export class AuthProvider {
|
||||
switch (action.action) {
|
||||
case AuthActions.SignInFailed:
|
||||
case AuthActions.SignOutSuccess:
|
||||
case AuthActions.SignOutFailed: {
|
||||
case AuthActions.SignOutFailed:
|
||||
case AuthActions.SessionExpired: {
|
||||
this.tokenSubject.next(
|
||||
undefined,
|
||||
);
|
||||
|
||||
@@ -62,15 +62,34 @@ export class CapacitorRequestor implements Requestor {
|
||||
const response = await CapacitorHttp.request(options);
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
const body = this.parseBody(response.data);
|
||||
|
||||
/*
|
||||
* Use the OAuth error code (e.g. 'invalid_grant') as message when the
|
||||
* server sent one (RFC 6749, 5.2), so callers can tell a rejected
|
||||
* refresh token apart from temporary failures.
|
||||
*/
|
||||
throw new AppAuthError(
|
||||
`HTTP ${response.status}`,
|
||||
response.data,
|
||||
typeof body?.error === 'string' ? body.error : `HTTP ${response.status}`,
|
||||
body ?? response.data,
|
||||
);
|
||||
}
|
||||
|
||||
return response.data as T;
|
||||
}
|
||||
|
||||
private parseBody(data: unknown): Record<string, unknown> | undefined {
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return data !== null && typeof data === 'object' ? (data as Record<string, unknown>) : undefined;
|
||||
}
|
||||
|
||||
private getHeaders(headers: XhrSettings['headers']): HttpHeaders {
|
||||
const result: HttpHeaders = {};
|
||||
|
||||
|
||||
@@ -2,18 +2,18 @@ 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 {AuthProvider} from '../auth/auth.provider';
|
||||
import {BehaviorSubject, firstValueFrom, of} from 'rxjs';
|
||||
import {EncryptedStorageProvider} from '../storage/encrypted-storage.provider';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {TranslateService} from '@ngx-translate/core';
|
||||
import {StorageBackend, Requestor} from '@openid/appauth';
|
||||
import {LoggerTestingModule} from 'ngx-logger/testing';
|
||||
import {StAppsWebHttpClient} from '../data/stapps-web-http-client.provider';
|
||||
import {SimpleBrowser} from '../../util/browser.factory';
|
||||
|
||||
class FakeAuth {
|
||||
isLoggedIn$ = new BehaviorSubject(false);
|
||||
|
||||
user$ = new BehaviorSubject<unknown>(undefined);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
getValidToken() {}
|
||||
}
|
||||
@@ -24,16 +24,14 @@ describe('IdCards', () => {
|
||||
let encryptedStorageProvider: EncryptedStorageProvider;
|
||||
let fakeAuth: FakeAuth;
|
||||
|
||||
const authHelperServiceMock = jasmine.createSpyObj('AuthHelperService', ['getProvider']);
|
||||
const authHelperServiceMock = jasmine.createSpyObj('AuthHelperService', ['getUserFromUserInfo']);
|
||||
const translateServiceSpy = jasmine.createSpyObj('TranslateService', ['setDefaultLang', 'use']);
|
||||
let storageBackendSpy: jasmine.SpyObj<StorageBackend>;
|
||||
const simpleBrowserMock = jasmine.createSpyObj('SimpleBrowser', ['open']);
|
||||
|
||||
beforeEach(() => {
|
||||
storageBackendSpy = jasmine.createSpyObj('StorageBackend', ['getItem']);
|
||||
fakeAuth = new FakeAuth();
|
||||
configProviderSpy = jasmine.createSpyObj('ConfigProvider', ['config', 'getAnyValue']);
|
||||
configProviderSpy.config = {
|
||||
app: {features: {extern: {idCards: {url: 'http://id-cards.local', authProvider: 'fakeAuth'}}}},
|
||||
app: {features: {extern: {idCards: {url: 'http://id-cards.local', authProvider: 'default'}}}},
|
||||
} as never;
|
||||
configProviderSpy.getAnyValue = () =>
|
||||
({
|
||||
@@ -61,7 +59,6 @@ describe('IdCards', () => {
|
||||
imports: [LoggerTestingModule],
|
||||
providers: [
|
||||
IdCardsProvider,
|
||||
StAppsWebHttpClient,
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: translateServiceSpy,
|
||||
@@ -70,40 +67,29 @@ describe('IdCards', () => {
|
||||
provide: EncryptedStorageProvider,
|
||||
useValue: encryptedStorageProvider,
|
||||
},
|
||||
IonicStorage,
|
||||
{
|
||||
provide: ConfigProvider,
|
||||
useValue: configProviderSpy,
|
||||
},
|
||||
Browser,
|
||||
{
|
||||
provide: StorageBackend,
|
||||
useValue: storageBackendSpy,
|
||||
},
|
||||
{
|
||||
provide: AuthHelperService,
|
||||
useValue: authHelperServiceMock,
|
||||
},
|
||||
{
|
||||
provide: SimpleBrowser,
|
||||
useValue: simpleBrowserMock,
|
||||
provide: AuthProvider,
|
||||
useValue: fakeAuth,
|
||||
},
|
||||
{
|
||||
provide: HttpClient,
|
||||
useValue: httpClient,
|
||||
},
|
||||
Requestor,
|
||||
],
|
||||
});
|
||||
|
||||
fakeAuth = new FakeAuth();
|
||||
authHelperServiceMock.getProvider = jasmine.createSpy().and.returnValue(fakeAuth);
|
||||
});
|
||||
|
||||
it('should emit undefined if not logged in', async () => {
|
||||
const provider = TestBed.inject(IdCardsProvider);
|
||||
expect(await firstValueFrom(provider.getIdCards())).toEqual([]);
|
||||
expect(authHelperServiceMock.getProvider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should emit network result when logged in', async () => {
|
||||
@@ -112,7 +98,6 @@ describe('IdCards', () => {
|
||||
fakeAuth.getValidToken = jasmine.createSpy().and.resolveTo({accessToken: 'fake-token'});
|
||||
const provider = TestBed.inject(IdCardsProvider);
|
||||
expect(await firstValueFrom(provider.getIdCards())).toEqual(['abc' as never]);
|
||||
expect(authHelperServiceMock.getProvider).toHaveBeenCalledTimes(1);
|
||||
// eslint-disable-next-line unicorn/no-null
|
||||
expect(httpClient.get).toHaveBeenCalledOnceWith('http://id-cards.local', {
|
||||
headers: {
|
||||
|
||||
Reference in New Issue
Block a user