mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-09 11:12:52 +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
|
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
|
#### iOS
|
||||||
|
|
||||||
On Android you will need to change the `custom_url_scheme` value in
|
On Android you will need to change the `custom_url_scheme` value in
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
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, Observable, of} from 'rxjs';
|
import {from, of, Observable} from 'rxjs';
|
||||||
import {AuthHelperService} from '../auth/auth-helper.service';
|
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 {ConfigProvider} from '../config/config.provider';
|
||||||
import {HttpClient} from '@angular/common/http';
|
import {HttpClient} from '@angular/common/http';
|
||||||
|
import {EncryptedStorageProvider} from '../storage/encrypted-storage.provider';
|
||||||
|
|
||||||
@Injectable({providedIn: 'root'})
|
@Injectable({providedIn: 'root'})
|
||||||
export class IdCardsProvider {
|
export class IdCardsProvider {
|
||||||
@@ -12,18 +13,27 @@ export class IdCardsProvider {
|
|||||||
private authHelper: AuthHelperService,
|
private authHelper: AuthHelperService,
|
||||||
private config: ConfigProvider,
|
private config: ConfigProvider,
|
||||||
private httpClient: HttpClient,
|
private httpClient: HttpClient,
|
||||||
|
private encryptedStorageProvider: EncryptedStorageProvider,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
getIdCards(): Observable<SCIdCard[]> {
|
getIdCards(): Observable<SCIdCard[]> {
|
||||||
const feature = this.config.config.app.features.extern?.['idCards'];
|
const feature = this.config.config.app.features.extern?.['idCards'];
|
||||||
const auth = this.authHelper.getProvider(feature?.authProvider ?? 'default');
|
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(
|
return auth.isAuthenticated$.pipe(
|
||||||
mergeMap(isAuthenticated =>
|
mergeMap(isAuthenticated =>
|
||||||
isAuthenticated
|
isAuthenticated
|
||||||
? feature
|
? feature
|
||||||
? from(auth.getValidToken()).pipe(
|
? storedIdCards.pipe(
|
||||||
mergeMap(token => this.fetchIdCards(feature.url, token.accessToken)),
|
concatWith(
|
||||||
|
from(auth.getValidToken()).pipe(
|
||||||
|
mergeMap(token => this.fetchIdCards(feature.url, token.accessToken)),
|
||||||
|
catchError(() => storedIdCards),
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: auth.user$.pipe(
|
: auth.user$.pipe(
|
||||||
filter(user => user !== undefined),
|
filter(user => user !== undefined),
|
||||||
@@ -31,19 +41,20 @@ export class IdCardsProvider {
|
|||||||
mergeMap(user => this.fetchFallbackIdCards(user)),
|
mergeMap(user => this.fetchFallbackIdCards(user)),
|
||||||
startWith([]),
|
startWith([]),
|
||||||
)
|
)
|
||||||
: // TODO: find a better solution here (async pipe stuff...)
|
: of([]).pipe(tap(() => this.encryptedStorageProvider.delete('id-cards'))),
|
||||||
of([]),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetchIdCards(url: string, token: string): Observable<SCIdCard[]> {
|
private fetchIdCards(url: string, token: string): Observable<SCIdCard[]> {
|
||||||
return this.httpClient.get<SCIdCard[]>(url, {
|
return this.httpClient
|
||||||
headers: {
|
.get<SCIdCard[]>(url, {
|
||||||
Authorization: `Bearer ${token}`,
|
headers: {
|
||||||
},
|
Authorization: `Bearer ${token}`,
|
||||||
responseType: 'json',
|
},
|
||||||
});
|
responseType: 'json',
|
||||||
|
})
|
||||||
|
.pipe(tap(idCards => this.encryptedStorageProvider.set('id-cards', idCards)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetchFallbackIdCards(user: SCUserConfiguration): Observable<SCIdCard[]> {
|
private fetchFallbackIdCards(user: SCUserConfiguration): Observable<SCIdCard[]> {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {HttpClient} from '@angular/common/http';
|
|||||||
import {AuthHelperService} from '../auth/auth-helper.service';
|
import {AuthHelperService} from '../auth/auth-helper.service';
|
||||||
import {BehaviorSubject, firstValueFrom, of} from 'rxjs';
|
import {BehaviorSubject, firstValueFrom, of} from 'rxjs';
|
||||||
import {SCAuthorizationProviderType} from '@openstapps/core';
|
import {SCAuthorizationProviderType} from '@openstapps/core';
|
||||||
|
import {EncryptedStorageProvider} from '../storage/encrypted-storage.provider';
|
||||||
|
|
||||||
class FakeAuth {
|
class FakeAuth {
|
||||||
isAuthenticated$ = new BehaviorSubject(false);
|
isAuthenticated$ = new BehaviorSubject(false);
|
||||||
@@ -16,6 +17,7 @@ describe('IdCards', () => {
|
|||||||
let configProvider: ConfigProvider;
|
let configProvider: ConfigProvider;
|
||||||
let httpClient: HttpClient;
|
let httpClient: HttpClient;
|
||||||
let authHelper: AuthHelperService;
|
let authHelper: AuthHelperService;
|
||||||
|
let encryptedStorageProvider: EncryptedStorageProvider;
|
||||||
let fakeAuth: FakeAuth;
|
let fakeAuth: FakeAuth;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -27,10 +29,14 @@ describe('IdCards', () => {
|
|||||||
fakeAuth = new FakeAuth();
|
fakeAuth = new FakeAuth();
|
||||||
authHelper = jasmine.createSpyObj('AuthHelperService', ['getProvider']);
|
authHelper = jasmine.createSpyObj('AuthHelperService', ['getProvider']);
|
||||||
authHelper.getProvider = jasmine.createSpy().and.returnValue(fakeAuth);
|
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 () => {
|
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(await firstValueFrom(provider.getIdCards())).toEqual([]);
|
||||||
expect(authHelper.getProvider).toHaveBeenCalledOnceWith('fakeAuth' as SCAuthorizationProviderType);
|
expect(authHelper.getProvider).toHaveBeenCalledOnceWith('fakeAuth' as SCAuthorizationProviderType);
|
||||||
});
|
});
|
||||||
@@ -39,7 +45,7 @@ describe('IdCards', () => {
|
|||||||
fakeAuth.isAuthenticated$.next(true);
|
fakeAuth.isAuthenticated$.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);
|
const provider = new IdCardsProvider(authHelper, configProvider, httpClient, encryptedStorageProvider);
|
||||||
expect(await firstValueFrom(provider.getIdCards())).toEqual(['abc' as never]);
|
expect(await firstValueFrom(provider.getIdCards())).toEqual(['abc' as never]);
|
||||||
expect(authHelper.getProvider).toHaveBeenCalledOnceWith('fakeAuth' as SCAuthorizationProviderType);
|
expect(authHelper.getProvider).toHaveBeenCalledOnceWith('fakeAuth' as SCAuthorizationProviderType);
|
||||||
// eslint-disable-next-line unicorn/no-null
|
// eslint-disable-next-line unicorn/no-null
|
||||||
@@ -52,7 +58,7 @@ describe('IdCards', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should react to logins', async () => {
|
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();
|
const observable = provider.getIdCards();
|
||||||
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']));
|
||||||
|
|||||||
@@ -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