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,
AuthorizationServiceConfiguration,
BaseTokenRequestHandler,
BasicQueryStringUtils,
DefaultCrypto,
GRANT_TYPE_AUTHORIZATION_CODE,
GRANT_TYPE_REFRESH_TOKEN,
LocationLike,
RedirectRequestHandler,
Requestor,
RevokeTokenRequest,
@@ -53,7 +55,7 @@ import {requestorFactory} from './factories/requestor.factory';
import {storageFactory} from './factories/storage.factory';
const TOKEN_RESPONSE_KEY = 'token_response';
const AUTH_EXPIRY_BUFFER = -10 * 60;
const AUTH_EXPIRY_BUFFER = -5 * 60;
export interface AuthConfig {
server_host: string;
@@ -120,6 +122,18 @@ interface AuthorizationHandler {
Promise<void>;
}
class NoHashQueryStringUtils extends BasicQueryStringUtils {
override parse(
input: LocationLike,
_useHash?: boolean,
): StringMap {
return super.parse(
input,
false,
);
}
}
@Injectable({
providedIn: 'root',
})
@@ -183,6 +197,7 @@ export class AuthService {
)
: new RedirectRequestHandler(
this.storage,
new NoHashQueryStringUtils(),
);
this.tokenHandler =
@@ -524,32 +539,48 @@ export class AuthService {
const currentToken =
this.tokenSubject.value;
if (
currentToken?.isValid(buffer)
) {
/*
* Access token still valid for longer than the configured
* expiry buffer.
*/
if (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) {
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 =
this.tokenSubject.value;
if (
refreshedToken?.isValid(buffer)
) {
return refreshedToken;
if (!refreshedToken) {
throw new Error(
'Unable To Obtain Valid Token: Refresh Returned No Token',
);
}
throw new Error(
'Unable To Obtain Valid Token',
);
if (!refreshedToken.isValid(buffer)) {
throw new Error(
'Unable To Obtain Valid Token: Refreshed Token Is Already Too Close To Expiry',
);
}
return refreshedToken;
}
private setupAuthorizationNotifier():
@@ -1,12 +1,25 @@
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 {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 {HttpClient} from '@angular/common/http';
import {EncryptedStorageProvider} from '../storage/encrypted-storage.provider';
import {AuthService} from "../auth/auth.service";
import {AuthService} from '../auth/auth.service';
@Injectable({providedIn: 'root'})
export class IdCardsProvider {
@@ -19,35 +32,136 @@ export class IdCardsProvider {
) {}
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(
this.encryptedStorageProvider.get<SCIdCard[]>('id-cards') as Promise<SCIdCard[]>,
).pipe(filter(it => it !== undefined));
this.encryptedStorageProvider.get<SCIdCard[]>(
'id-cards',
) as Promise<SCIdCard[]>,
).pipe(
tap(idCards => {
console.log(
'[IdCardsProvider] stored ID cards:',
idCards,
);
}),
filter(it => it !== undefined),
);
return this.authService.isLoggedIn$.pipe(
tap(isLoggedIn => {
console.log(
'[IdCardsProvider] isLoggedIn:',
isLoggedIn,
);
}),
mergeMap(isLoggedIn =>
isLoggedIn
? feature
? storedIdCards.pipe(
concatWith(
from(this.authService.getValidToken()).pipe(
mergeMap(token => this.fetchIdCards(feature.url, token.accessToken)),
catchError(() => storedIdCards),
concatWith(
from(
this.authService.getValidToken(),
).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(
filter(user => user !== undefined),
map(userInfo => this.authHelper.getUserFromUserInfo(userInfo as object)),
mergeMap(user => this.fetchFallbackIdCards(user)),
startWith([]),
)
: of([]).pipe(tap({next: () => this.encryptedStorageProvider.delete('id-cards')})),
tap(user => {
console.log(
'[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([]),
)
: 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
.get<SCIdCard[]>(url, {
headers: {
@@ -55,31 +169,93 @@ export class IdCardsProvider {
},
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[]> {
return this.httpClient.get('/assets/examples/student-id.sample.svg', {responseType: 'text'}).pipe(
map(svg => {
let result = svg;
for (const key in user) {
result = result.replaceAll(`{{${key}}}`, (user as unknown as Record<string, string>)[key]);
}
return `data:image/svg+xml;utf8,${encodeURIComponent(result)}`;
}),
map(image => [
{
name: 'Student ID',
image,
type: SCThingType.IdCard,
uid: '1234',
origin: {
name: 'Sample Origin',
type: SCThingOriginType.Remote,
indexed: new Date().toISOString(),
},
},
]),
private fetchFallbackIdCards(
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 => {
let result = svg;
for (const key in user) {
result = result.replaceAll(
`{{${key}}}`,
(
user as unknown as Record<
string,
string
>
)[key],
);
}
return `data:image/svg+xml;utf8,${encodeURIComponent(result)}`;
}),
map(image => [
{
name: 'Student ID',
image,
type: SCThingType.IdCard,
uid: '1234',
origin: {
name: 'Sample Origin',
type: SCThingOriginType.Remote,
indexed:
new Date().toISOString(),
},
},
]),
);
}
}
@@ -17,7 +17,7 @@ import {AuthHelperService} from '../../auth/auth-helper.service';
import {ActivatedRoute} from '@angular/router';
import {ScheduleProvider} from '../../calendar/schedule.provider';
import {profilePageSections} from '../../../../config/profile-page-sections';
import {AuthService} from "../../auth/auth.service";
import {AuthService} from '../../auth/auth.service';
@Component({
selector: 'app-home',
@@ -36,7 +36,13 @@ export class ProfilePageComponent {
async signIn() {
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();
}
@@ -45,11 +51,6 @@ export class ProfilePageComponent {
}
ionViewWillEnter() {
this.authService
.getValidToken()
.then(() => void this.authService.loadUserInfo())
.catch(() => {
// noop
});
void this.authService.loadUserInfo();
}
}