This commit is contained in:
Jovan Krunić
2026-09-23 17:17:22 +02:00
parent fca79249f6
commit c756eaa29e
3 changed files with 270 additions and 62 deletions
@@ -24,9 +24,11 @@ import {
AuthorizationResponse, AuthorizationResponse,
AuthorizationServiceConfiguration, AuthorizationServiceConfiguration,
BaseTokenRequestHandler, BaseTokenRequestHandler,
BasicQueryStringUtils,
DefaultCrypto, DefaultCrypto,
GRANT_TYPE_AUTHORIZATION_CODE, GRANT_TYPE_AUTHORIZATION_CODE,
GRANT_TYPE_REFRESH_TOKEN, GRANT_TYPE_REFRESH_TOKEN,
LocationLike,
RedirectRequestHandler, RedirectRequestHandler,
Requestor, Requestor,
RevokeTokenRequest, RevokeTokenRequest,
@@ -53,7 +55,7 @@ import {requestorFactory} from './factories/requestor.factory';
import {storageFactory} from './factories/storage.factory'; import {storageFactory} from './factories/storage.factory';
const TOKEN_RESPONSE_KEY = 'token_response'; const TOKEN_RESPONSE_KEY = 'token_response';
const AUTH_EXPIRY_BUFFER = -10 * 60; const AUTH_EXPIRY_BUFFER = -5 * 60;
export interface AuthConfig { export interface AuthConfig {
server_host: string; server_host: string;
@@ -120,6 +122,18 @@ interface AuthorizationHandler {
Promise<void>; Promise<void>;
} }
class NoHashQueryStringUtils extends BasicQueryStringUtils {
override parse(
input: LocationLike,
_useHash?: boolean,
): StringMap {
return super.parse(
input,
false,
);
}
}
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
@@ -183,6 +197,7 @@ export class AuthService {
) )
: new RedirectRequestHandler( : new RedirectRequestHandler(
this.storage, this.storage,
new NoHashQueryStringUtils(),
); );
this.tokenHandler = this.tokenHandler =
@@ -524,34 +539,50 @@ export class AuthService {
const currentToken = const currentToken =
this.tokenSubject.value; this.tokenSubject.value;
if ( /*
currentToken?.isValid(buffer) * Access token still valid for longer than the configured
) { * expiry buffer.
*/
if (currentToken?.isValid(buffer)) {
return currentToken; return currentToken;
} }
/*
* Access token is expired or will expire soon.
* We can only recover automatically if a refresh token exists.
*/
if (!currentToken?.refreshToken) { if (!currentToken?.refreshToken) {
throw new Error( throw new Error(
'Unable To Obtain Valid Token', 'Unable To Obtain Valid Token: No Refresh Token Available',
); );
} }
await this.refreshToken(); /*
* Refresh immediately.
*
* requestTokenRefresh() stores the newly returned token
* and updates tokenSubject via RefreshSuccess.
*/
await this.requestTokenRefresh();
const refreshedToken = const refreshedToken =
this.tokenSubject.value; this.tokenSubject.value;
if ( if (!refreshedToken) {
refreshedToken?.isValid(buffer) throw new Error(
) { 'Unable To Obtain Valid Token: Refresh Returned No Token',
return refreshedToken; );
} }
if (!refreshedToken.isValid(buffer)) {
throw new Error( throw new Error(
'Unable To Obtain Valid Token', 'Unable To Obtain Valid Token: Refreshed Token Is Already Too Close To Expiry',
); );
} }
return refreshedToken;
}
private setupAuthorizationNotifier(): private setupAuthorizationNotifier():
void { void {
if (this.notifierInitialized) { if (this.notifierInitialized) {
@@ -1,12 +1,25 @@
import {Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
import {SCIdCard, SCThingOriginType, SCThingType, SCUserConfiguration} from '@openstapps/core'; import {
SCIdCard,
SCThingOriginType,
SCThingType,
SCUserConfiguration,
} from '@openstapps/core';
import {from, of, Observable} from 'rxjs'; import {from, of, Observable} from 'rxjs';
import {AuthHelperService} from '../auth/auth-helper.service'; import {AuthHelperService} from '../auth/auth-helper.service';
import {mergeMap, concatWith, filter, map, startWith, catchError, tap} from 'rxjs/operators'; import {
mergeMap,
concatWith,
filter,
map,
startWith,
catchError,
tap,
} from 'rxjs/operators';
import {ConfigProvider} from '../config/config.provider'; import {ConfigProvider} from '../config/config.provider';
import {HttpClient} from '@angular/common/http'; import {HttpClient} from '@angular/common/http';
import {EncryptedStorageProvider} from '../storage/encrypted-storage.provider'; import {EncryptedStorageProvider} from '../storage/encrypted-storage.provider';
import {AuthService} from "../auth/auth.service"; import {AuthService} from '../auth/auth.service';
@Injectable({providedIn: 'root'}) @Injectable({providedIn: 'root'})
export class IdCardsProvider { export class IdCardsProvider {
@@ -19,35 +32,136 @@ export class IdCardsProvider {
) {} ) {}
getIdCards(): Observable<SCIdCard[]> { getIdCards(): Observable<SCIdCard[]> {
const feature = this.config.config.app.features.extern?.['idCards']; const feature =
this.config.config.app.features.extern?.['idCards'];
console.log(
'[IdCardsProvider] idCards feature:',
feature,
);
const storedIdCards = from( const storedIdCards = from(
this.encryptedStorageProvider.get<SCIdCard[]>('id-cards') as Promise<SCIdCard[]>, this.encryptedStorageProvider.get<SCIdCard[]>(
).pipe(filter(it => it !== undefined)); 'id-cards',
) as Promise<SCIdCard[]>,
).pipe(
tap(idCards => {
console.log(
'[IdCardsProvider] stored ID cards:',
idCards,
);
}),
filter(it => it !== undefined),
);
return this.authService.isLoggedIn$.pipe( return this.authService.isLoggedIn$.pipe(
tap(isLoggedIn => {
console.log(
'[IdCardsProvider] isLoggedIn:',
isLoggedIn,
);
}),
mergeMap(isLoggedIn => mergeMap(isLoggedIn =>
isLoggedIn isLoggedIn
? feature ? feature
? storedIdCards.pipe( ? storedIdCards.pipe(
concatWith( concatWith(
from(this.authService.getValidToken()).pipe( from(
mergeMap(token => this.fetchIdCards(feature.url, token.accessToken)), this.authService.getValidToken(),
catchError(() => storedIdCards), ).pipe(
tap(() => {
console.log(
'[IdCardsProvider] valid token available',
);
}),
mergeMap(token =>
this.fetchIdCards(
feature.url,
token.accessToken,
),
),
catchError(error => {
console.error(
'[IdCardsProvider] ID card request failed:',
error,
);
return storedIdCards;
}),
), ),
), ),
) )
: this.authService.user$.pipe( : this.authService.user$.pipe(
filter(user => user !== undefined), tap(user => {
map(userInfo => this.authHelper.getUserFromUserInfo(userInfo as object)), console.log(
mergeMap(user => this.fetchFallbackIdCards(user)), '[IdCardsProvider] user:',
user,
);
}),
filter(
user =>
user !== undefined,
),
map(userInfo => {
const user =
this.authHelper.getUserFromUserInfo(
userInfo as object,
);
console.log(
'[IdCardsProvider] mapped user:',
user,
);
return user;
}),
mergeMap(user =>
this.fetchFallbackIdCards(
user,
),
),
startWith([]), startWith([]),
) )
: of([]).pipe(tap({next: () => this.encryptedStorageProvider.delete('id-cards')})), : of([]).pipe(
tap({
next: () => {
console.log(
'[IdCardsProvider] not logged in - deleting stored ID cards',
);
void this.encryptedStorageProvider.delete(
'id-cards',
);
},
}),
), ),
),
tap(idCards => {
console.log(
'[IdCardsProvider] resulting ID cards:',
idCards,
);
}),
); );
} }
private fetchIdCards(url: string, token: string): Observable<SCIdCard[]> { private fetchIdCards(
url: string,
token: string,
): Observable<SCIdCard[]> {
console.log(
'[IdCardsProvider] fetching ID cards from:',
url,
);
return this.httpClient return this.httpClient
.get<SCIdCard[]>(url, { .get<SCIdCard[]>(url, {
headers: { headers: {
@@ -55,18 +169,79 @@ export class IdCardsProvider {
}, },
responseType: 'json', responseType: 'json',
}) })
.pipe(tap({next: idCards => this.encryptedStorageProvider.set('id-cards', idCards)})); .pipe(
tap({
next: idCards => {
console.log(
'[IdCardsProvider] fetched ID cards:',
idCards,
);
void this.encryptedStorageProvider.set(
'id-cards',
idCards,
);
},
error: error => {
console.error(
'[IdCardsProvider] fetchIdCards failed:',
error,
);
},
}),
);
} }
private fetchFallbackIdCards(user: SCUserConfiguration): Observable<SCIdCard[]> { private fetchFallbackIdCards(
return this.httpClient.get('/assets/examples/student-id.sample.svg', {responseType: 'text'}).pipe( user: SCUserConfiguration,
): Observable<SCIdCard[]> {
console.log(
'[IdCardsProvider] creating fallback ID card for user:',
user,
);
return this.httpClient
.get(
'/assets/examples/student-id.sample.svg',
{
responseType: 'text',
},
)
.pipe(
tap({
next: () => {
console.log(
'[IdCardsProvider] fallback SVG loaded',
);
},
error: error => {
console.error(
'[IdCardsProvider] loading fallback SVG failed:',
error,
);
},
}),
map(svg => { map(svg => {
let result = svg; let result = svg;
for (const key in user) { for (const key in user) {
result = result.replaceAll(`{{${key}}}`, (user as unknown as Record<string, string>)[key]); result = result.replaceAll(
`{{${key}}}`,
(
user as unknown as Record<
string,
string
>
)[key],
);
} }
return `data:image/svg+xml;utf8,${encodeURIComponent(result)}`; return `data:image/svg+xml;utf8,${encodeURIComponent(result)}`;
}), }),
map(image => [ map(image => [
{ {
name: 'Student ID', name: 'Student ID',
@@ -76,7 +251,8 @@ export class IdCardsProvider {
origin: { origin: {
name: 'Sample Origin', name: 'Sample Origin',
type: SCThingOriginType.Remote, type: SCThingOriginType.Remote,
indexed: new Date().toISOString(), indexed:
new Date().toISOString(),
}, },
}, },
]), ]),
@@ -17,7 +17,7 @@ import {AuthHelperService} from '../../auth/auth-helper.service';
import {ActivatedRoute} from '@angular/router'; import {ActivatedRoute} from '@angular/router';
import {ScheduleProvider} from '../../calendar/schedule.provider'; import {ScheduleProvider} from '../../calendar/schedule.provider';
import {profilePageSections} from '../../../../config/profile-page-sections'; import {profilePageSections} from '../../../../config/profile-page-sections';
import {AuthService} from "../../auth/auth.service"; import {AuthService} from '../../auth/auth.service';
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
@@ -36,7 +36,13 @@ export class ProfilePageComponent {
async signIn() { async signIn() {
const originPath = this.activatedRoute.snapshot.queryParamMap.get('origin_path'); const originPath = this.activatedRoute.snapshot.queryParamMap.get('origin_path');
await (originPath ? this.authHelper.setOriginPath(originPath) : this.authHelper.deleteOriginPath());
await (
originPath
? this.authHelper.setOriginPath(originPath)
: this.authHelper.deleteOriginPath()
);
await this.authService.signIn(); await this.authService.signIn();
} }
@@ -45,11 +51,6 @@ export class ProfilePageComponent {
} }
ionViewWillEnter() { ionViewWillEnter() {
this.authService void this.authService.loadUserInfo();
.getValidToken()
.then(() => void this.authService.loadUserInfo())
.catch(() => {
// noop
});
} }
} }