mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2025-12-11 00:36:14 +00:00
feat: store id cards
This commit is contained in:
@@ -120,6 +120,19 @@ Then start the app normally as you would
|
||||
pnpm run:android
|
||||
```
|
||||
|
||||
**This alone will not make auth work**, only the login flow.
|
||||
|
||||
If you need to test login, you have to disable live reload:
|
||||
|
||||
```sh
|
||||
pnpm ionic capacitor run android
|
||||
```
|
||||
|
||||
_**CAUTION:** a remote chrome debugging session can in some
|
||||
cases hijack the device's ADB connection. If the connection
|
||||
fails for no obvious reason, close chrome and uninstall the
|
||||
app, then try again._
|
||||
|
||||
#### iOS
|
||||
|
||||
On Android you will need to change the `custom_url_scheme` value in
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {SCIdCard, SCThingOriginType, SCThingType, SCUserConfiguration} from '@openstapps/core';
|
||||
import {from, Observable, of} from 'rxjs';
|
||||
import {from, of, Observable} from 'rxjs';
|
||||
import {AuthHelperService} from '../auth/auth-helper.service';
|
||||
import {mergeMap, filter, map, startWith} 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';
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class IdCardsProvider {
|
||||
@@ -12,18 +13,27 @@ export class IdCardsProvider {
|
||||
private authHelper: AuthHelperService,
|
||||
private config: ConfigProvider,
|
||||
private httpClient: HttpClient,
|
||||
private encryptedStorageProvider: EncryptedStorageProvider,
|
||||
) {}
|
||||
|
||||
getIdCards(): Observable<SCIdCard[]> {
|
||||
const feature = this.config.config.app.features.extern?.['idCards'];
|
||||
const auth = this.authHelper.getProvider(feature?.authProvider ?? 'default');
|
||||
const storedIdCards = from(
|
||||
this.encryptedStorageProvider.get<SCIdCard[]>('id-cards') as Promise<SCIdCard[]>,
|
||||
).pipe(filter(it => it !== undefined));
|
||||
|
||||
return auth.isAuthenticated$.pipe(
|
||||
mergeMap(isAuthenticated =>
|
||||
isAuthenticated
|
||||
? feature
|
||||
? from(auth.getValidToken()).pipe(
|
||||
mergeMap(token => this.fetchIdCards(feature.url, token.accessToken)),
|
||||
? storedIdCards.pipe(
|
||||
concatWith(
|
||||
from(auth.getValidToken()).pipe(
|
||||
mergeMap(token => this.fetchIdCards(feature.url, token.accessToken)),
|
||||
catchError(() => storedIdCards),
|
||||
),
|
||||
),
|
||||
)
|
||||
: auth.user$.pipe(
|
||||
filter(user => user !== undefined),
|
||||
@@ -31,19 +41,20 @@ export class IdCardsProvider {
|
||||
mergeMap(user => this.fetchFallbackIdCards(user)),
|
||||
startWith([]),
|
||||
)
|
||||
: // TODO: find a better solution here (async pipe stuff...)
|
||||
of([]),
|
||||
: of([]).pipe(tap(() => this.encryptedStorageProvider.delete('id-cards'))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private fetchIdCards(url: string, token: string): Observable<SCIdCard[]> {
|
||||
return this.httpClient.get<SCIdCard[]>(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
responseType: 'json',
|
||||
});
|
||||
return this.httpClient
|
||||
.get<SCIdCard[]>(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
responseType: 'json',
|
||||
})
|
||||
.pipe(tap(idCards => this.encryptedStorageProvider.set('id-cards', idCards)));
|
||||
}
|
||||
|
||||
private fetchFallbackIdCards(user: SCUserConfiguration): Observable<SCIdCard[]> {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {HttpClient} from '@angular/common/http';
|
||||
import {AuthHelperService} from '../auth/auth-helper.service';
|
||||
import {BehaviorSubject, firstValueFrom, of} from 'rxjs';
|
||||
import {SCAuthorizationProviderType} from '@openstapps/core';
|
||||
import {EncryptedStorageProvider} from '../storage/encrypted-storage.provider';
|
||||
|
||||
class FakeAuth {
|
||||
isAuthenticated$ = new BehaviorSubject(false);
|
||||
@@ -16,6 +17,7 @@ describe('IdCards', () => {
|
||||
let configProvider: ConfigProvider;
|
||||
let httpClient: HttpClient;
|
||||
let authHelper: AuthHelperService;
|
||||
let encryptedStorageProvider: EncryptedStorageProvider;
|
||||
let fakeAuth: FakeAuth;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -27,10 +29,14 @@ describe('IdCards', () => {
|
||||
fakeAuth = new FakeAuth();
|
||||
authHelper = jasmine.createSpyObj('AuthHelperService', ['getProvider']);
|
||||
authHelper.getProvider = jasmine.createSpy().and.returnValue(fakeAuth);
|
||||
encryptedStorageProvider = jasmine.createSpyObj('EncryptedStorageProvider', ['get', 'set', 'delete']);
|
||||
encryptedStorageProvider.get = jasmine.createSpy().and.resolveTo();
|
||||
encryptedStorageProvider.set = jasmine.createSpy().and.resolveTo();
|
||||
encryptedStorageProvider.delete = jasmine.createSpy().and.resolveTo();
|
||||
});
|
||||
|
||||
it('should emit undefined if not logged in', async () => {
|
||||
const provider = new IdCardsProvider(authHelper, configProvider, httpClient);
|
||||
const provider = new IdCardsProvider(authHelper, configProvider, httpClient, encryptedStorageProvider);
|
||||
expect(await firstValueFrom(provider.getIdCards())).toEqual([]);
|
||||
expect(authHelper.getProvider).toHaveBeenCalledOnceWith('fakeAuth' as SCAuthorizationProviderType);
|
||||
});
|
||||
@@ -39,7 +45,7 @@ describe('IdCards', () => {
|
||||
fakeAuth.isAuthenticated$.next(true);
|
||||
httpClient.get = jasmine.createSpy().and.returnValue(of(['abc']));
|
||||
fakeAuth.getValidToken = jasmine.createSpy().and.resolveTo({accessToken: 'fake-token'});
|
||||
const provider = new IdCardsProvider(authHelper, configProvider, httpClient);
|
||||
const provider = new IdCardsProvider(authHelper, configProvider, httpClient, encryptedStorageProvider);
|
||||
expect(await firstValueFrom(provider.getIdCards())).toEqual(['abc' as never]);
|
||||
expect(authHelper.getProvider).toHaveBeenCalledOnceWith('fakeAuth' as SCAuthorizationProviderType);
|
||||
// eslint-disable-next-line unicorn/no-null
|
||||
@@ -52,7 +58,7 @@ describe('IdCards', () => {
|
||||
});
|
||||
|
||||
it('should react to logins', async () => {
|
||||
const provider = new IdCardsProvider(authHelper, configProvider, httpClient);
|
||||
const provider = new IdCardsProvider(authHelper, configProvider, httpClient, encryptedStorageProvider);
|
||||
const observable = provider.getIdCards();
|
||||
expect(await firstValueFrom(observable)).toEqual([]);
|
||||
httpClient.get = jasmine.createSpy().and.returnValue(of(['abc']));
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {StorageProvider} from './storage.provider';
|
||||
import {Capacitor} from '@capacitor/core';
|
||||
import {SecureStoragePlugin} from 'capacitor-secure-storage-plugin';
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class EncryptedStorageProvider {
|
||||
constructor(private storageProvider: StorageProvider) {}
|
||||
|
||||
/**
|
||||
* Retrieve a large value from an encrypted storage
|
||||
* Also returns undefined if a secure context is not available (i.e. web).
|
||||
* @param key Unique identifier of the wanted resource in storage
|
||||
* @returns The value of the resource, if found
|
||||
*/
|
||||
async get<T>(key: string): Promise<T | undefined> {
|
||||
if (!Capacitor.isNativePlatform()) return undefined;
|
||||
|
||||
try {
|
||||
const jwt = JSON.parse((await SecureStoragePlugin.get({key: `stapps:key:${key}`})).value);
|
||||
const aesKey = await crypto.subtle.importKey('jwk', jwt, {name: 'AES-GCM'}, true, [
|
||||
'encrypt',
|
||||
'decrypt',
|
||||
]);
|
||||
const iv = await this.storageProvider.get<ArrayBuffer>(`encrypted:${key}:iv`);
|
||||
|
||||
const encryptedIdCards = await this.storageProvider.get<ArrayBuffer>(`encrypted:${key}`);
|
||||
const decrypted = await crypto.subtle.decrypt({name: 'AES-GCM', iv}, aesKey, encryptedIdCards);
|
||||
|
||||
const decompressionStream = new DecompressionStream('gzip');
|
||||
const writer = decompressionStream.writable.getWriter();
|
||||
writer.write(decrypted);
|
||||
writer.close();
|
||||
|
||||
const decompressed = await new Response(decompressionStream.readable).arrayBuffer();
|
||||
return JSON.parse(new TextDecoder().decode(decompressed));
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a large value in an encrypted storage
|
||||
* Does nothing if a secure context is not available (i.e. web).
|
||||
* @param key Unique identifier of the resource in storage
|
||||
* @param value The value to store
|
||||
* @returns A promise that resolves when the value is stored
|
||||
*/
|
||||
async set<T>(key: string, value: T): Promise<void> {
|
||||
if (!Capacitor.isNativePlatform()) return undefined;
|
||||
|
||||
try {
|
||||
const compressionStream = new CompressionStream('gzip');
|
||||
const writer = compressionStream.writable.getWriter();
|
||||
writer.write(new TextEncoder().encode(JSON.stringify(value)));
|
||||
writer.close();
|
||||
const encoded = await new Response(compressionStream.readable).arrayBuffer();
|
||||
|
||||
const iv = crypto.getRandomValues(new Uint8Array(16));
|
||||
const aesKey = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, [
|
||||
'encrypt',
|
||||
'decrypt',
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
SecureStoragePlugin.set({
|
||||
key: `stapps:key:${key}`,
|
||||
value: JSON.stringify(await crypto.subtle.exportKey('jwk', aesKey)),
|
||||
}),
|
||||
this.storageProvider.put(`encrypted:${key}:iv`, iv),
|
||||
]);
|
||||
|
||||
this.storageProvider.put<ArrayBuffer>(
|
||||
`encrypted:${key}`,
|
||||
await crypto.subtle.encrypt({name: 'AES-GCM', iv}, aesKey, encoded),
|
||||
);
|
||||
} catch (error) {
|
||||
alert(error);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
if (!Capacitor.isNativePlatform()) return;
|
||||
|
||||
await Promise.all([
|
||||
SecureStoragePlugin.remove({key: `stapps:key:${key}`}),
|
||||
this.storageProvider.delete(`encrypted:${key}:iv`),
|
||||
this.storageProvider.delete(`encrypted:${key}`),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user