This commit is contained in:
Jovan Krunić
2026-10-12 15:54:21 +02:00
parent 29e9d0753b
commit d1ef5731c7
8 changed files with 485 additions and 94 deletions
+64
View File
@@ -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: {