fix: user gets logged out when their token expires

Closes #230
This commit is contained in:
Thea Schöbl
2024-11-28 18:04:08 +00:00
committed by Jovan Krunić
parent 07e5c80223
commit 580ebee362
6 changed files with 67 additions and 64 deletions

View File

@@ -86,6 +86,8 @@ export abstract class AuthService implements IAuthService {
private _authenticatedSubject = new BehaviorSubject<boolean>(false); private _authenticatedSubject = new BehaviorSubject<boolean>(false);
private _loggedInSubject = new BehaviorSubject<boolean>(false);
private _initComplete = new BehaviorSubject<boolean>(false); private _initComplete = new BehaviorSubject<boolean>(false);
protected tokenHandler: TokenRequestHandler; protected tokenHandler: TokenRequestHandler;
@@ -133,6 +135,13 @@ export abstract class AuthService implements IAuthService {
return this._authenticatedSubject.asObservable(); return this._authenticatedSubject.asObservable();
} }
/**
* Similar to isAuthenticated$, but will also return true if the token is expired
*/
get isLoggedIn$(): Observable<boolean> {
return this._loggedInSubject.asObservable();
}
get initComplete$(): Observable<boolean> { get initComplete$(): Observable<boolean> {
return this._initComplete.asObservable(); return this._initComplete.asObservable();
} }
@@ -183,19 +192,20 @@ export abstract class AuthService implements IAuthService {
protected notifyActionListers(action: IAuthAction) { protected notifyActionListers(action: IAuthAction) {
/* eslint-disable unicorn/no-useless-undefined */ /* eslint-disable unicorn/no-useless-undefined */
switch (action.action) { switch (action.action) {
case AuthActions.RefreshFailed:
case AuthActions.SignInFailed: case AuthActions.SignInFailed:
case AuthActions.SignOutSuccess: case AuthActions.SignOutSuccess:
case AuthActions.SignOutFailed: { case AuthActions.SignOutFailed: {
this._tokenSubject.next(undefined); this._tokenSubject.next(undefined);
this._userSubject.next(undefined); this._userSubject.next(undefined);
this._authenticatedSubject.next(false); this._authenticatedSubject.next(false);
this._loggedInSubject.next(false);
break; break;
} }
case AuthActions.LoadTokenFromStorageFailed: { case AuthActions.LoadTokenFromStorageFailed: {
this._tokenSubject.next(undefined); this._tokenSubject.next(undefined);
this._userSubject.next(undefined); this._userSubject.next(undefined);
this._authenticatedSubject.next(false); this._authenticatedSubject.next(false);
this._loggedInSubject.next(false);
this._initComplete.next(true); this._initComplete.next(true);
break; break;
} }
@@ -203,11 +213,13 @@ export abstract class AuthService implements IAuthService {
case AuthActions.RefreshSuccess: { case AuthActions.RefreshSuccess: {
this._tokenSubject.next(action.tokenResponse); this._tokenSubject.next(action.tokenResponse);
this._authenticatedSubject.next(true); this._authenticatedSubject.next(true);
this._loggedInSubject.next(true);
break; break;
} }
case AuthActions.LoadTokenFromStorageSuccess: { case AuthActions.LoadTokenFromStorageSuccess: {
this._tokenSubject.next(action.tokenResponse); this._tokenSubject.next(action.tokenResponse);
this._authenticatedSubject.next((action.tokenResponse as TokenResponse).isValid(0)); this._authenticatedSubject.next((action.tokenResponse as TokenResponse).isValid(0));
this._loggedInSubject.next(true);
this._initComplete.next(true); this._initComplete.next(true);
break; break;
} }
@@ -442,7 +454,6 @@ export abstract class AuthService implements IAuthService {
public async refreshToken() { public async refreshToken() {
await this.requestTokenRefresh().catch(error => { await this.requestTokenRefresh().catch(error => {
this.storage.removeItem(TOKEN_RESPONSE_KEY);
this.notifyActionListers(AuthActionBuilder.RefreshFailed(error)); this.notifyActionListers(AuthActionBuilder.RefreshFailed(error));
}); });
} }

View File

@@ -81,6 +81,8 @@ export class PAIAAuthService {
private _authenticatedSubject = new BehaviorSubject<boolean>(false); private _authenticatedSubject = new BehaviorSubject<boolean>(false);
private _loggedIn = new BehaviorSubject<boolean>(false);
private _initComplete = new BehaviorSubject<boolean>(false); private _initComplete = new BehaviorSubject<boolean>(false);
protected tokenHandler: PAIATokenRequestHandler; protected tokenHandler: PAIATokenRequestHandler;
@@ -118,6 +120,13 @@ export class PAIAAuthService {
return this._authenticatedSubject.asObservable(); return this._authenticatedSubject.asObservable();
} }
/**
* Similar to isAuthenticated$, but will also return true if the token is expired
*/
get isLoggedIn$(): Observable<boolean> {
return this._loggedIn.asObservable();
}
get initComplete$(): Observable<boolean> { get initComplete$(): Observable<boolean> {
return this._initComplete.asObservable(); return this._initComplete.asObservable();
} }
@@ -170,23 +179,27 @@ export class PAIAAuthService {
this._tokenSubject.next(undefined); this._tokenSubject.next(undefined);
this._userSubject.next(undefined); this._userSubject.next(undefined);
this._authenticatedSubject.next(false); this._authenticatedSubject.next(false);
this._loggedIn.next(false);
break; break;
} }
case AuthActions.LoadTokenFromStorageFailed: { case AuthActions.LoadTokenFromStorageFailed: {
this._tokenSubject.next(undefined); this._tokenSubject.next(undefined);
this._userSubject.next(undefined); this._userSubject.next(undefined);
this._authenticatedSubject.next(false); this._authenticatedSubject.next(false);
this._loggedIn.next(false);
this._initComplete.next(true); this._initComplete.next(true);
break; break;
} }
case AuthActions.SignInSuccess: { case AuthActions.SignInSuccess: {
this._tokenSubject.next(action.tokenResponse); this._tokenSubject.next(action.tokenResponse);
this._authenticatedSubject.next(true); this._authenticatedSubject.next(true);
this._loggedIn.next(true);
break; break;
} }
case AuthActions.LoadTokenFromStorageSuccess: { case AuthActions.LoadTokenFromStorageSuccess: {
this._tokenSubject.next(action.tokenResponse); this._tokenSubject.next(action.tokenResponse);
this._authenticatedSubject.next((action.tokenResponse as TokenResponse).isValid(0)); this._authenticatedSubject.next((action.tokenResponse as TokenResponse).isValid(0));
this._loggedIn.next(true);
this._initComplete.next(true); this._initComplete.next(true);
break; break;
} }

View File

@@ -23,9 +23,9 @@ export class IdCardsProvider {
this.encryptedStorageProvider.get<SCIdCard[]>('id-cards') as Promise<SCIdCard[]>, this.encryptedStorageProvider.get<SCIdCard[]>('id-cards') as Promise<SCIdCard[]>,
).pipe(filter(it => it !== undefined)); ).pipe(filter(it => it !== undefined));
return auth.isAuthenticated$.pipe( return auth.isLoggedIn$.pipe(
mergeMap(isAuthenticated => mergeMap(isLoggedIn =>
isAuthenticated isLoggedIn
? feature ? feature
? storedIdCards.pipe( ? storedIdCards.pipe(
concatWith( concatWith(

View File

@@ -7,7 +7,7 @@ import {SCAuthorizationProviderType} from '@openstapps/core';
import {EncryptedStorageProvider} from '../storage/encrypted-storage.provider'; import {EncryptedStorageProvider} from '../storage/encrypted-storage.provider';
class FakeAuth { class FakeAuth {
isAuthenticated$ = new BehaviorSubject(false); isLoggedIn$ = new BehaviorSubject(false);
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
getValidToken() {} getValidToken() {}
@@ -42,7 +42,7 @@ describe('IdCards', () => {
}); });
it('should emit network result when logged in', async () => { it('should emit network result when logged in', async () => {
fakeAuth.isAuthenticated$.next(true); fakeAuth.isLoggedIn$.next(true);
httpClient.get = jasmine.createSpy().and.returnValue(of(['abc'])); httpClient.get = jasmine.createSpy().and.returnValue(of(['abc']));
fakeAuth.getValidToken = jasmine.createSpy().and.resolveTo({accessToken: 'fake-token'}); fakeAuth.getValidToken = jasmine.createSpy().and.resolveTo({accessToken: 'fake-token'});
const provider = new IdCardsProvider(authHelper, configProvider, httpClient, encryptedStorageProvider); const provider = new IdCardsProvider(authHelper, configProvider, httpClient, encryptedStorageProvider);
@@ -63,7 +63,7 @@ describe('IdCards', () => {
expect(await firstValueFrom(observable)).toEqual([]); expect(await firstValueFrom(observable)).toEqual([]);
httpClient.get = jasmine.createSpy().and.returnValue(of(['abc'])); httpClient.get = jasmine.createSpy().and.returnValue(of(['abc']));
fakeAuth.getValidToken = jasmine.createSpy().and.resolveTo({accessToken: 'fake-token'}); fakeAuth.getValidToken = jasmine.createSpy().and.resolveTo({accessToken: 'fake-token'});
fakeAuth.isAuthenticated$.next(true); fakeAuth.isLoggedIn$.next(true);
// this is counter-intuitive, but because we unsubscribed above the first value // this is counter-intuitive, but because we unsubscribed above the first value
// will now contain the network result. // will now contain the network result.
expect(await firstValueFrom(observable)).toEqual(['abc' as never]); expect(await firstValueFrom(observable)).toEqual(['abc' as never]);

View File

@@ -6,74 +6,56 @@
* *
* This program is distributed in the hope that it will be useful, but WITHOUT * This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for * FITNESS FOR A PARTICULAr purpose. see the gnu general public license for
* more details. * more details.
* *
* You should have received a copy of the GNU General Public License along with * you should have received a copy of the gnu general public license along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. if not, see <https://www.gnu.org/licenses/>.
*/ */
import {Component, DestroyRef, inject, Input, OnInit} from '@angular/core'; import {Component, inject, input} from '@angular/core';
import {SCSection} from '../../../../config/profile-page-sections'; import {SCSection} from '../../../../config/profile-page-sections';
import {AuthHelperService} from '../../auth/auth-helper.service'; import {AuthHelperService} from '../../auth/auth-helper.service';
import {Observable} from 'rxjs'; import {mergeMap, of} from 'rxjs';
import {SCAuthorizationProviderType} from '@openstapps/core';
import Swiper from 'swiper'; import Swiper from 'swiper';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {toObservable, toSignal} from '@angular/core/rxjs-interop';
@Component({ @Component({
selector: 'stapps-profile-page-section', selector: 'stapps-profile-page-section',
templateUrl: 'profile-page-section.html', templateUrl: 'profile-page-section.html',
styleUrls: ['profile-page-section.scss'], styleUrls: ['profile-page-section.scss'],
}) })
export class ProfilePageSectionComponent implements OnInit { export class ProfilePageSectionComponent {
@Input() item: SCSection; item = input.required<SCSection>();
@Input() minSlideWidth = 110; minSlideWidth = input(110);
isLoggedIn: boolean; authHelper = inject(AuthHelperService);
loggedIn = toSignal(
toObservable(this.item).pipe(
mergeMap(item =>
item.authProvider ? this.authHelper.getProvider(item.authProvider).isLoggedIn$ : of(false),
),
),
);
isEnd = false; isEnd = false;
isBeginning = true; isBeginning = true;
slidesPerView: number; slidesPerView?: number;
slidesFillScreen = false; slidesFillScreen = false;
data: {
[key in SCAuthorizationProviderType]: {loggedIn$: Observable<boolean>};
} = {
default: {
loggedIn$: this.authHelper.getProvider('default').isAuthenticated$,
},
paia: {
loggedIn$: this.authHelper.getProvider('paia').isAuthenticated$,
},
};
destroy$ = inject(DestroyRef);
constructor(private authHelper: AuthHelperService) {}
ngOnInit() {
if (this.item.authProvider) {
this.data[this.item.authProvider].loggedIn$
.pipe(takeUntilDestroyed(this.destroy$))
.subscribe(loggedIn => {
this.isLoggedIn = loggedIn;
});
}
}
activeIndexChange(swiper: Swiper) { activeIndexChange(swiper: Swiper) {
this.isBeginning = swiper.isBeginning; this.isBeginning = swiper.isBeginning;
this.isEnd = swiper.isEnd; this.isEnd = swiper.isEnd;
this.slidesFillScreen = this.slidesPerView >= swiper.slides.length; this.slidesFillScreen = this.slidesPerView! >= swiper.slides.length;
} }
resizeSwiper(resizeEvent: ResizeObserverEntry, swiper: Swiper) { resizeSwiper(resizeEvent: ResizeObserverEntry, swiper: Swiper) {
const slidesPerView = const slidesPerView =
Math.floor((resizeEvent.contentRect.width - this.minSlideWidth / 2) / this.minSlideWidth) + 0.5; Math.floor((resizeEvent.contentRect.width - this.minSlideWidth() / 2) / this.minSlideWidth()) + 0.5;
if (slidesPerView > 1 && slidesPerView !== this.slidesPerView) { if (slidesPerView > 1 && slidesPerView !== this.slidesPerView) {
this.slidesPerView = slidesPerView; this.slidesPerView = slidesPerView;
@@ -84,16 +66,13 @@ export class ProfilePageSectionComponent implements OnInit {
} }
async toggleLogIn() { async toggleLogIn() {
if (!this.item.authProvider) return; const providerType = this.item().authProvider;
await (this.isLoggedIn ? this.signOut(this.item.authProvider) : this.signIn(this.item.authProvider)); if (!providerType) return;
} if (this.loggedIn()) {
await this.authHelper.getProvider(providerType).signOut();
async signIn(providerType: SCAuthorizationProviderType) { await this.authHelper.endBrowserSession(providerType);
await this.authHelper.getProvider(providerType).signIn(); } else {
} await this.authHelper.getProvider(providerType).signIn();
}
async signOut(providerType: SCAuthorizationProviderType) {
await this.authHelper.getProvider(providerType).signOut();
await this.authHelper.endBrowserSession(providerType);
} }
} }

View File

@@ -13,24 +13,24 @@
~ this program. If not, see <https://www.gnu.org/licenses/>. ~ this program. If not, see <https://www.gnu.org/licenses/>.
--> -->
<stapps-section [title]="'name' | translateSimple: item"> <stapps-section [title]="'name' | translateSimple: item()">
@if (item.authProvider) { @if (item().authProvider) {
<ion-button slot="button-end" fill="clear" color="dark" (click)="toggleLogIn()"> <ion-button slot="button-end" fill="clear" color="dark" (click)="toggleLogIn()">
@if (isLoggedIn) { @if (loggedIn()) {
<ion-icon slot="end" name="logout"></ion-icon> <ion-icon slot="end" name="logout"></ion-icon>
} @else { } @else {
<ion-icon slot="end" name="login"></ion-icon> <ion-icon slot="end" name="login"></ion-icon>
} }
<ion-label>{{ 'profile.buttons.default.log_' + (isLoggedIn ? 'out' : 'in') | translate }}</ion-label> <ion-label>{{ 'profile.buttons.default.log_' + (loggedIn() ? 'out' : 'in') | translate }}</ion-label>
</ion-button> </ion-button>
} }
<simple-swiper> <simple-swiper>
@for (link of item.links; track link) { @for (link of item().links; track link) {
<ion-item <ion-item
lines="none" lines="none"
[routerLink]="link.link" [routerLink]="link.link"
[disabled]="link.needsAuth && !isLoggedIn" [disabled]="link.needsAuth && !loggedIn()"
[detail]="false" [detail]="false"
> >
<div> <div>