mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-10-19 07:51:52 +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 {AuthActions, AuthProvider} from './auth.provider';
|
||||||
import {AuthHelperService} from './auth-helper.service';
|
import {AuthHelperService} from './auth-helper.service';
|
||||||
|
|
||||||
@@ -38,14 +45,21 @@ describe('AuthHelperService', () => {
|
|||||||
function createService(authConfig: unknown = {default: {endpoints: {mapping: USER_MAPPING}}}) {
|
function createService(authConfig: unknown = {default: {endpoints: {mapping: USER_MAPPING}}}) {
|
||||||
configProvider.getAnyValue.and.returnValue(authConfig);
|
configProvider.getAnyValue.and.returnValue(authConfig);
|
||||||
|
|
||||||
return new AuthHelperService(
|
// AuthHelperService uses inject(), so it has to be created inside an injection context.
|
||||||
translateService as any,
|
TestBed.resetTestingModule();
|
||||||
configProvider as any,
|
TestBed.configureTestingModule({
|
||||||
storageProvider as any,
|
providers: [
|
||||||
authProvider as any,
|
AuthHelperService,
|
||||||
browser as any,
|
{provide: TranslateService, useValue: translateService},
|
||||||
alertController as any,
|
{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. */
|
/** 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;
|
endSession?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const OFFLINE_ACCESS_SCOPE = 'offline_access';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the configuration of the OIDC client.
|
* Returns the configuration of the OIDC client.
|
||||||
*/
|
*/
|
||||||
@@ -39,15 +41,40 @@ export function getClientConfig(
|
|||||||
const provider =
|
const provider =
|
||||||
authConfig.default;
|
authConfig.default;
|
||||||
|
|
||||||
|
console.log(getPlatformScopes(provider.client.scopes));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
server_host: provider.client.url,
|
server_host: provider.client.url,
|
||||||
client_id: provider.client.clientId,
|
client_id: provider.client.clientId,
|
||||||
scopes: provider.client.scopes,
|
scopes: getPlatformScopes(provider.client.scopes),
|
||||||
redirect_url: getRedirectUrl(),
|
redirect_url: getRedirectUrl(),
|
||||||
pkce: true,
|
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.
|
* Returns the AppAuth OAuth/OIDC service configuration.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import {
|
|||||||
TokenRequestHandler,
|
TokenRequestHandler,
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
} from '@openid/appauth';
|
} 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_RESPONSE_KEY = 'token_response';
|
||||||
const TOKEN_LIFETIME = 3600;
|
const TOKEN_LIFETIME = 3600;
|
||||||
@@ -58,6 +58,13 @@ describe('AuthProvider', () => {
|
|||||||
await provider.loadTokenFromStorage();
|
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 isAuthenticated = () => firstValueFrom(provider.isAuthenticated$);
|
||||||
const isLoggedIn = () => firstValueFrom(provider.isLoggedIn$);
|
const isLoggedIn = () => firstValueFrom(provider.isLoggedIn$);
|
||||||
|
|
||||||
@@ -187,6 +194,100 @@ describe('AuthProvider', () => {
|
|||||||
expect(tokenRequest).not.toHaveBeenCalled();
|
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', () => {
|
describe('when refresh fails with invalid_grant', () => {
|
||||||
const refreshError = new AppAuthError(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';
|
const TOKEN_RESPONSE_KEY = 'token_response';
|
||||||
export const INVALID_GRANT: ErrorType = 'invalid_grant';
|
export const INVALID_GRANT: ErrorType = 'invalid_grant';
|
||||||
const AUTH_EXPIRY_BUFFER = -3 * 60;
|
const AUTH_EXPIRY_BUFFER = -10 * 60;
|
||||||
|
|
||||||
export interface AuthConfig {
|
export interface AuthConfig {
|
||||||
server_host: string;
|
server_host: string;
|
||||||
@@ -98,6 +98,15 @@ export enum AuthActions {
|
|||||||
|
|
||||||
LoadUserInfoFailed =
|
LoadUserInfoFailed =
|
||||||
'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 {
|
export interface IAuthAction {
|
||||||
@@ -125,6 +134,31 @@ interface AuthorizationHandler {
|
|||||||
Promise<void>;
|
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 {
|
class NoHashQueryStringUtils extends BasicQueryStringUtils {
|
||||||
override parse(
|
override parse(
|
||||||
input: LocationLike,
|
input: LocationLike,
|
||||||
@@ -183,6 +217,13 @@ export class AuthProvider {
|
|||||||
|
|
||||||
private notifierInitialized = false;
|
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(
|
constructor(
|
||||||
private readonly platform: Platform,
|
private readonly platform: Platform,
|
||||||
private readonly configProvider: ConfigProvider,
|
private readonly configProvider: ConfigProvider,
|
||||||
@@ -353,11 +394,12 @@ export class AuthProvider {
|
|||||||
public async refreshToken():
|
public async refreshToken():
|
||||||
Promise<void> {
|
Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.requestTokenRefresh();
|
await this.refreshSession();
|
||||||
} catch (error) {
|
} catch {
|
||||||
await this.handleRefreshFailure(
|
/*
|
||||||
error,
|
* 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),
|
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({
|
this.notify({
|
||||||
action:
|
action:
|
||||||
AuthActions
|
AuthActions
|
||||||
@@ -535,35 +594,40 @@ export class AuthProvider {
|
|||||||
* expiry buffer.
|
* expiry buffer.
|
||||||
*/
|
*/
|
||||||
if (currentToken?.isValid(buffer)) {
|
if (currentToken?.isValid(buffer)) {
|
||||||
console.log(currentToken?.isValid(buffer));
|
return currentToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
return currentToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Access token is expired or will expire soon.
|
* Expired and not renewable: the session is over.
|
||||||
* We can only recover automatically if a refresh token exists.
|
|
||||||
*/
|
*/
|
||||||
if (!currentToken?.refreshToken) {
|
if (currentToken) {
|
||||||
|
await this.expireSession(
|
||||||
|
new Error('Access Token Expired Without Refresh Token'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Unable To Obtain Valid Token: No Refresh Token Available',
|
'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
|
* refreshSession() stores the newly returned token and updates
|
||||||
* and updates tokenSubject via RefreshSuccess.
|
* tokenSubject via RefreshSuccess. On invalid_grant it has already
|
||||||
|
* logged the user out.
|
||||||
*/
|
*/
|
||||||
try {
|
await this.refreshSession();
|
||||||
await this.requestTokenRefresh();
|
|
||||||
} catch (error) {
|
|
||||||
await this.handleRefreshFailure(
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshedToken =
|
const refreshedToken =
|
||||||
this.tokenSubject.value;
|
this.tokenSubject.value;
|
||||||
@@ -583,53 +647,71 @@ export class AuthProvider {
|
|||||||
return refreshedToken;
|
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(
|
private async handleRefreshFailure(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
this.notify({
|
||||||
|
action:
|
||||||
|
AuthActions.RefreshFailed,
|
||||||
|
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* AppAuth maps OAuth token endpoint errors to AppAuthError.
|
* invalid_grant means that the refresh token is no longer usable,
|
||||||
* This OAuth error means that the refresh token is no longer
|
* for example because it expired, was revoked or the refresh-token
|
||||||
* usable, for example because it expired, was revoked or
|
* chain lifetime has been reached. Log the user out.
|
||||||
* the refresh-token chain lifetime has been reached.
|
|
||||||
*
|
*
|
||||||
* Network and other temporary errors must not log the user
|
* Network and other temporary errors must not log the user out.
|
||||||
* out.
|
|
||||||
*/
|
*/
|
||||||
if (
|
if (isInvalidGrant(error)) {
|
||||||
error instanceof AppAuthError &&
|
await this.expireSession(
|
||||||
error.message === INVALID_GRANT
|
error,
|
||||||
) {
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ends the local session because it cannot be renewed anymore.
|
||||||
|
*/
|
||||||
|
private async expireSession(
|
||||||
|
error: unknown,
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.storage.removeItem(
|
await this.storage.removeItem(
|
||||||
TOKEN_RESPONSE_KEY,
|
TOKEN_RESPONSE_KEY,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
/*
|
/*
|
||||||
* Do not hide the original OAuth error if removing the
|
* Still log out in memory if removing the stored token fails.
|
||||||
* locally stored token fails.
|
|
||||||
*/
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
this.tokenSubject.next(
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.userSubject.next(
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.authenticatedSubject.next(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.loggedInSubject.next(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.notify({
|
this.notify({
|
||||||
action:
|
action:
|
||||||
AuthActions.RefreshFailed,
|
AuthActions.SessionExpired,
|
||||||
|
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
@@ -983,7 +1065,8 @@ export class AuthProvider {
|
|||||||
switch (action.action) {
|
switch (action.action) {
|
||||||
case AuthActions.SignInFailed:
|
case AuthActions.SignInFailed:
|
||||||
case AuthActions.SignOutSuccess:
|
case AuthActions.SignOutSuccess:
|
||||||
case AuthActions.SignOutFailed: {
|
case AuthActions.SignOutFailed:
|
||||||
|
case AuthActions.SessionExpired: {
|
||||||
this.tokenSubject.next(
|
this.tokenSubject.next(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -62,15 +62,34 @@ export class CapacitorRequestor implements Requestor {
|
|||||||
const response = await CapacitorHttp.request(options);
|
const response = await CapacitorHttp.request(options);
|
||||||
|
|
||||||
if (response.status < 200 || response.status >= 300) {
|
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(
|
throw new AppAuthError(
|
||||||
`HTTP ${response.status}`,
|
typeof body?.error === 'string' ? body.error : `HTTP ${response.status}`,
|
||||||
response.data,
|
body ?? response.data,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.data as T;
|
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 {
|
private getHeaders(headers: XhrSettings['headers']): HttpHeaders {
|
||||||
const result: HttpHeaders = {};
|
const result: HttpHeaders = {};
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,18 @@ import {ConfigProvider} from '../config/config.provider';
|
|||||||
import {IdCardsProvider} from './id-cards.provider';
|
import {IdCardsProvider} from './id-cards.provider';
|
||||||
import {HttpClient} from '@angular/common/http';
|
import {HttpClient} from '@angular/common/http';
|
||||||
import {AuthHelperService} from '../auth/auth-helper.service';
|
import {AuthHelperService} from '../auth/auth-helper.service';
|
||||||
|
import {AuthProvider} from '../auth/auth.provider';
|
||||||
import {BehaviorSubject, firstValueFrom, of} from 'rxjs';
|
import {BehaviorSubject, firstValueFrom, of} from 'rxjs';
|
||||||
import {EncryptedStorageProvider} from '../storage/encrypted-storage.provider';
|
import {EncryptedStorageProvider} from '../storage/encrypted-storage.provider';
|
||||||
import {TestBed} from '@angular/core/testing';
|
import {TestBed} from '@angular/core/testing';
|
||||||
import {TranslateService} from '@ngx-translate/core';
|
import {TranslateService} from '@ngx-translate/core';
|
||||||
import {StorageBackend, Requestor} from '@openid/appauth';
|
|
||||||
import {LoggerTestingModule} from 'ngx-logger/testing';
|
import {LoggerTestingModule} from 'ngx-logger/testing';
|
||||||
import {StAppsWebHttpClient} from '../data/stapps-web-http-client.provider';
|
|
||||||
import {SimpleBrowser} from '../../util/browser.factory';
|
|
||||||
|
|
||||||
class FakeAuth {
|
class FakeAuth {
|
||||||
isLoggedIn$ = new BehaviorSubject(false);
|
isLoggedIn$ = new BehaviorSubject(false);
|
||||||
|
|
||||||
|
user$ = new BehaviorSubject<unknown>(undefined);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
getValidToken() {}
|
getValidToken() {}
|
||||||
}
|
}
|
||||||
@@ -24,16 +24,14 @@ describe('IdCards', () => {
|
|||||||
let encryptedStorageProvider: EncryptedStorageProvider;
|
let encryptedStorageProvider: EncryptedStorageProvider;
|
||||||
let fakeAuth: FakeAuth;
|
let fakeAuth: FakeAuth;
|
||||||
|
|
||||||
const authHelperServiceMock = jasmine.createSpyObj('AuthHelperService', ['getProvider']);
|
const authHelperServiceMock = jasmine.createSpyObj('AuthHelperService', ['getUserFromUserInfo']);
|
||||||
const translateServiceSpy = jasmine.createSpyObj('TranslateService', ['setDefaultLang', 'use']);
|
const translateServiceSpy = jasmine.createSpyObj('TranslateService', ['setDefaultLang', 'use']);
|
||||||
let storageBackendSpy: jasmine.SpyObj<StorageBackend>;
|
|
||||||
const simpleBrowserMock = jasmine.createSpyObj('SimpleBrowser', ['open']);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
storageBackendSpy = jasmine.createSpyObj('StorageBackend', ['getItem']);
|
fakeAuth = new FakeAuth();
|
||||||
configProviderSpy = jasmine.createSpyObj('ConfigProvider', ['config', 'getAnyValue']);
|
configProviderSpy = jasmine.createSpyObj('ConfigProvider', ['config', 'getAnyValue']);
|
||||||
configProviderSpy.config = {
|
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;
|
} as never;
|
||||||
configProviderSpy.getAnyValue = () =>
|
configProviderSpy.getAnyValue = () =>
|
||||||
({
|
({
|
||||||
@@ -61,7 +59,6 @@ describe('IdCards', () => {
|
|||||||
imports: [LoggerTestingModule],
|
imports: [LoggerTestingModule],
|
||||||
providers: [
|
providers: [
|
||||||
IdCardsProvider,
|
IdCardsProvider,
|
||||||
StAppsWebHttpClient,
|
|
||||||
{
|
{
|
||||||
provide: TranslateService,
|
provide: TranslateService,
|
||||||
useValue: translateServiceSpy,
|
useValue: translateServiceSpy,
|
||||||
@@ -70,40 +67,29 @@ describe('IdCards', () => {
|
|||||||
provide: EncryptedStorageProvider,
|
provide: EncryptedStorageProvider,
|
||||||
useValue: encryptedStorageProvider,
|
useValue: encryptedStorageProvider,
|
||||||
},
|
},
|
||||||
IonicStorage,
|
|
||||||
{
|
{
|
||||||
provide: ConfigProvider,
|
provide: ConfigProvider,
|
||||||
useValue: configProviderSpy,
|
useValue: configProviderSpy,
|
||||||
},
|
},
|
||||||
Browser,
|
|
||||||
{
|
|
||||||
provide: StorageBackend,
|
|
||||||
useValue: storageBackendSpy,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
provide: AuthHelperService,
|
provide: AuthHelperService,
|
||||||
useValue: authHelperServiceMock,
|
useValue: authHelperServiceMock,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: SimpleBrowser,
|
provide: AuthProvider,
|
||||||
useValue: simpleBrowserMock,
|
useValue: fakeAuth,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: HttpClient,
|
provide: HttpClient,
|
||||||
useValue: httpClient,
|
useValue: httpClient,
|
||||||
},
|
},
|
||||||
Requestor,
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
fakeAuth = new FakeAuth();
|
|
||||||
authHelperServiceMock.getProvider = jasmine.createSpy().and.returnValue(fakeAuth);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should emit undefined if not logged in', async () => {
|
it('should emit undefined if not logged in', async () => {
|
||||||
const provider = TestBed.inject(IdCardsProvider);
|
const provider = TestBed.inject(IdCardsProvider);
|
||||||
expect(await firstValueFrom(provider.getIdCards())).toEqual([]);
|
expect(await firstValueFrom(provider.getIdCards())).toEqual([]);
|
||||||
expect(authHelperServiceMock.getProvider).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should emit network result when logged in', async () => {
|
it('should emit network result when logged in', async () => {
|
||||||
@@ -112,7 +98,6 @@ describe('IdCards', () => {
|
|||||||
fakeAuth.getValidToken = jasmine.createSpy().and.resolveTo({accessToken: 'fake-token'});
|
fakeAuth.getValidToken = jasmine.createSpy().and.resolveTo({accessToken: 'fake-token'});
|
||||||
const provider = TestBed.inject(IdCardsProvider);
|
const provider = TestBed.inject(IdCardsProvider);
|
||||||
expect(await firstValueFrom(provider.getIdCards())).toEqual(['abc' as never]);
|
expect(await firstValueFrom(provider.getIdCards())).toEqual(['abc' as never]);
|
||||||
expect(authHelperServiceMock.getProvider).toHaveBeenCalledTimes(1);
|
|
||||||
// eslint-disable-next-line unicorn/no-null
|
// eslint-disable-next-line unicorn/no-null
|
||||||
expect(httpClient.get).toHaveBeenCalledOnceWith('http://id-cards.local', {
|
expect(httpClient.get).toHaveBeenCalledOnceWith('http://id-cards.local', {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
Reference in New Issue
Block a user