feat(mobile): add U2F support for Android devices (#2311)
* feat(mobile): add U2F support for Android devices * chore: fix specs
This commit is contained in:
BIN
.yarn/cache/@standardnotes-domain-core-npm-1.12.0-c4ef3cb073-852f15e481.zip
vendored
Normal file
BIN
.yarn/cache/@standardnotes-domain-core-npm-1.12.0-c4ef3cb073-852f15e481.zip
vendored
Normal file
Binary file not shown.
@@ -37,7 +37,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@standardnotes/common": "^1.46.6",
|
||||
"@standardnotes/domain-core": "^1.11.3",
|
||||
"@standardnotes/domain-core": "^1.12.0",
|
||||
"@standardnotes/encryption": "workspace:*",
|
||||
"@standardnotes/models": "workspace:*",
|
||||
"@standardnotes/responses": "workspace:*",
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@standardnotes/common": "^1.46.6",
|
||||
"@standardnotes/domain-core": "^1.11.3",
|
||||
"@standardnotes/domain-core": "^1.12.0",
|
||||
"@standardnotes/security": "^1.7.6",
|
||||
"reflect-metadata": "^0.1.13"
|
||||
},
|
||||
|
||||
@@ -178,6 +178,7 @@ dependencies {
|
||||
implementation 'androidx.appcompat:appcompat:1.1.0-rc01'
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.0.0")
|
||||
implementation 'de.undercouch:gradle-download-task:5.0.2'
|
||||
implementation 'com.google.android.gms:play-services-fido:20.0.1'
|
||||
|
||||
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}")
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.standardnotes;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentSender;
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
import com.facebook.react.bridge.Arguments;
|
||||
import com.facebook.react.bridge.NativeModule;
|
||||
import com.facebook.react.bridge.ReactApplicationContext;
|
||||
import com.facebook.react.bridge.ActivityEventListener;
|
||||
import com.facebook.react.bridge.BaseActivityEventListener;
|
||||
import com.facebook.react.bridge.ReactContext;
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule;
|
||||
import com.facebook.react.bridge.ReactMethod;
|
||||
import com.facebook.react.bridge.Promise;
|
||||
import com.facebook.react.bridge.WritableMap;
|
||||
import java.util.Map;
|
||||
import java.util.HashMap;
|
||||
import java.util.ArrayList;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
|
||||
import com.google.android.gms.fido.fido2.Fido2ApiClient;
|
||||
import com.google.android.gms.fido.fido2.api.common.AuthenticatorErrorResponse;
|
||||
import com.google.android.gms.fido.fido2.api.common.AuthenticatorAssertionResponse;
|
||||
import com.google.android.gms.fido.fido2.api.common.AuthenticationExtensionsClientOutputs;
|
||||
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredential;
|
||||
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialType;
|
||||
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialDescriptor;
|
||||
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialRequestOptions;
|
||||
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialRequestOptions;
|
||||
import com.google.android.gms.fido.Fido;
|
||||
import com.google.android.gms.tasks.OnFailureListener;
|
||||
import com.google.android.gms.tasks.OnSuccessListener;
|
||||
import com.google.android.gms.tasks.Task;
|
||||
|
||||
public class Fido2ApiModule extends ReactContextBaseJavaModule {
|
||||
private final Fido2ApiClient fido2ApiClient;
|
||||
private final ReactApplicationContext reactContext;
|
||||
private static final int SIGN_REQUEST_CODE = 111;
|
||||
|
||||
private static final String LOGS_TAG = "Fido2ApiModule";
|
||||
private static final String RP_ID = "app.standardnotes.com";
|
||||
|
||||
private Promise signInPromise;
|
||||
|
||||
private final ActivityEventListener activityEventListener = new BaseActivityEventListener() {
|
||||
@Override
|
||||
public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent intent) {
|
||||
super.onActivityResult(activity, requestCode, resultCode, intent);
|
||||
|
||||
if (requestCode == SIGN_REQUEST_CODE) {
|
||||
if (signInPromise != null) {
|
||||
if (resultCode == Activity.RESULT_CANCELED) {
|
||||
Log.e(LOGS_TAG, "FIDO sign in cancelled");
|
||||
|
||||
signInPromise.reject("FIDO sign in cancelled");
|
||||
} else if (resultCode == Activity.RESULT_OK) {
|
||||
if (intent.hasExtra(Fido.FIDO2_KEY_ERROR_EXTRA)) {
|
||||
AuthenticatorErrorResponse authenticatorErrorResponse =
|
||||
AuthenticatorErrorResponse.deserializeFromBytes(intent.getByteArrayExtra(Fido.FIDO2_KEY_ERROR_EXTRA));
|
||||
Log.e(LOGS_TAG, "Fido Error: " + authenticatorErrorResponse.getErrorMessage());
|
||||
|
||||
signInPromise.reject(authenticatorErrorResponse.getErrorMessage());
|
||||
} else if (intent.hasExtra(Fido.FIDO2_KEY_CREDENTIAL_EXTRA)) {
|
||||
PublicKeyCredential publicKeyCredential =
|
||||
PublicKeyCredential.deserializeFromBytes(
|
||||
intent.getByteArrayExtra(Fido.FIDO2_KEY_CREDENTIAL_EXTRA));
|
||||
AuthenticatorAssertionResponse signedData =
|
||||
(AuthenticatorAssertionResponse) publicKeyCredential.getResponse();
|
||||
|
||||
WritableMap signInResult = Arguments.createMap();
|
||||
signInResult.putString("id", Base64.encodeToString(signedData.getKeyHandle(), Base64.URL_SAFE));
|
||||
signInResult.putString("rawId", Base64.encodeToString(signedData.getKeyHandle(), Base64.URL_SAFE));
|
||||
|
||||
byte[] extensionOutputsBytes = null;
|
||||
AuthenticationExtensionsClientOutputs extensionOutputs = publicKeyCredential.getClientExtensionResults();
|
||||
if (extensionOutputs != null) {
|
||||
extensionOutputsBytes = extensionOutputs.serializeToBytes();
|
||||
if (extensionOutputsBytes != null) {
|
||||
signInResult.putString("clientExtensionResults", Base64.encodeToString(extensionOutputsBytes, Base64.URL_SAFE));
|
||||
}
|
||||
}
|
||||
|
||||
WritableMap response = Arguments.createMap();
|
||||
response.putString("clientDataJSON", Base64.encodeToString(signedData.getClientDataJSON(), Base64.URL_SAFE));
|
||||
response.putString("authenticatorData", Base64.encodeToString(signedData.getAuthenticatorData(), Base64.URL_SAFE));
|
||||
response.putString("signature", Base64.encodeToString(signedData.getSignature(), Base64.URL_SAFE));
|
||||
byte[] userHandle = signedData.getUserHandle();
|
||||
if (userHandle != null) {
|
||||
response.putString("userHandle", Base64.encodeToString(userHandle, Base64.URL_SAFE));
|
||||
}
|
||||
signInResult.putMap("response", response);
|
||||
|
||||
signInPromise.resolve(signInResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
signInPromise = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Fido2ApiModule(ReactApplicationContext context) {
|
||||
super(context);
|
||||
|
||||
fido2ApiClient = Fido.getFido2ApiClient(context);
|
||||
context.addActivityEventListener(activityEventListener);
|
||||
|
||||
this.reactContext = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Fido2ApiModule";
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void promptForU2FAuthentication(String authenticationOptionsJSONString, Promise promise) throws JSONException {
|
||||
signInPromise = promise;
|
||||
|
||||
JSONObject authenticationOptions = new JSONObject(authenticationOptionsJSONString);
|
||||
|
||||
ArrayList<PublicKeyCredentialDescriptor> allowedKeys = new ArrayList<PublicKeyCredentialDescriptor>();
|
||||
|
||||
JSONArray allowedCredentials = authenticationOptions.getJSONArray("allowCredentials");
|
||||
for (int i = 0, size = allowedCredentials.length(); i < size; i++) {
|
||||
JSONObject allowedCredential = allowedCredentials.getJSONObject(i);
|
||||
allowedKeys.add(
|
||||
new PublicKeyCredentialDescriptor(
|
||||
PublicKeyCredentialType.PUBLIC_KEY.toString(),
|
||||
this.convertBase64URLStringToBytes(allowedCredential.getString("id")),
|
||||
null
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
String challenge = authenticationOptions.getString("challenge");
|
||||
Double timeout = authenticationOptions.getDouble("timeout") / 1000d;
|
||||
|
||||
PublicKeyCredentialRequestOptions.Builder optionsBuilder = new PublicKeyCredentialRequestOptions
|
||||
.Builder()
|
||||
.setRpId(RP_ID)
|
||||
.setAllowList(allowedKeys)
|
||||
.setChallenge(this.convertBase64URLStringToBytes(challenge))
|
||||
.setTimeoutSeconds(timeout);
|
||||
|
||||
PublicKeyCredentialRequestOptions options = optionsBuilder.build();
|
||||
|
||||
Task result = this.fido2ApiClient.getSignPendingIntent(options);
|
||||
|
||||
final Activity activity = this.reactContext.getCurrentActivity();
|
||||
|
||||
result.addOnSuccessListener(
|
||||
new OnSuccessListener<PendingIntent>() {
|
||||
@Override
|
||||
public void onSuccess(PendingIntent fido2PendingIntent) {
|
||||
if (fido2PendingIntent == null) {
|
||||
Log.e(LOGS_TAG, "No pending FIDO intent returned");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
activity.startIntentSenderForResult(
|
||||
fido2PendingIntent.getIntentSender(),
|
||||
SIGN_REQUEST_CODE,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
);
|
||||
} catch (IntentSender.SendIntentException exception) {
|
||||
Log.e(LOGS_TAG, "Error starting FIDO intent: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
result.addOnFailureListener(
|
||||
new OnFailureListener() {
|
||||
@Override
|
||||
public void onFailure(Exception e) {
|
||||
Log.e(LOGS_TAG, "Error getting FIDO intent: " + e.getMessage());
|
||||
signInPromise.reject(e.getMessage());
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private byte[] convertBase64URLStringToBytes(String base64URLString) {
|
||||
String base64String = base64URLString.replace('-', '+').replace('_', '/');
|
||||
int padding = (4 - (base64String.length() % 4)) % 4;
|
||||
for (int i = 0; i < padding; i++) {
|
||||
base64String += '=';
|
||||
}
|
||||
|
||||
return Base64.decode(base64String, Base64.DEFAULT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.standardnotes;
|
||||
|
||||
import com.facebook.react.ReactPackage;
|
||||
import com.facebook.react.bridge.NativeModule;
|
||||
import com.facebook.react.bridge.ReactApplicationContext;
|
||||
import com.facebook.react.uimanager.ViewManager;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public class Fido2ApiPackage implements ReactPackage {
|
||||
@Override
|
||||
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
|
||||
List<NativeModule> modules = new ArrayList<>();
|
||||
|
||||
modules.add(new Fido2ApiModule(reactContext));
|
||||
|
||||
return modules;
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@ public class MainApplication extends Application implements ReactApplication {
|
||||
@SuppressWarnings("UnnecessaryLocalVariable")
|
||||
List<ReactPackage> packages = new PackageList(this).getPackages();
|
||||
|
||||
packages.add(new Fido2ApiPackage());
|
||||
|
||||
return packages;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,4 +43,7 @@ newArchEnabled=false
|
||||
hermesEnabled=true
|
||||
|
||||
# Set AsyncStorage limit
|
||||
AsyncStorage_db_size_in_MB=50
|
||||
AsyncStorage_db_size_in_MB=50
|
||||
|
||||
# The URL of the server
|
||||
host=https://app.standardnotes.com
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
AppStateStatus,
|
||||
ColorSchemeName,
|
||||
Linking,
|
||||
NativeModules,
|
||||
PermissionsAndroid,
|
||||
Platform,
|
||||
StatusBar,
|
||||
@@ -71,6 +72,26 @@ export class MobileDevice implements MobileDeviceInterface {
|
||||
private colorSchemeService?: ColorSchemeObserverService,
|
||||
) {}
|
||||
|
||||
async authenticateWithU2F(authenticationOptionsJSONString: string): Promise<Record<string, unknown> | null> {
|
||||
const { Fido2ApiModule } = NativeModules
|
||||
|
||||
if (!Fido2ApiModule) {
|
||||
this.consoleLog('Fido2ApiModule is not available')
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await Fido2ApiModule.promptForU2FAuthentication(authenticationOptionsJSONString)
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
this.consoleLog(`Fido2ApiModule.authenticateWithU2F error: ${(error as Error).message}`)
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
purchaseSubscriptionIAP(plan: AppleIAPProductId): Promise<AppleIAPReceipt | undefined> {
|
||||
return PurchaseManager.getInstance().purchase(plan)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"dependencies": {
|
||||
"@standardnotes/api": "workspace:^",
|
||||
"@standardnotes/common": "^1.46.4",
|
||||
"@standardnotes/domain-core": "^1.11.3",
|
||||
"@standardnotes/domain-core": "^1.12.0",
|
||||
"@standardnotes/encryption": "workspace:^",
|
||||
"@standardnotes/files": "workspace:^",
|
||||
"@standardnotes/models": "workspace:^",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Environment, Platform, RawKeychainValue } from '@standardnotes/models'
|
||||
|
||||
import { AppleIAPProductId } from './../Subscription/AppleIAPProductId'
|
||||
import { DeviceInterface } from './DeviceInterface'
|
||||
import { Environment, Platform, RawKeychainValue } from '@standardnotes/models'
|
||||
import { AppleIAPReceipt } from '../Subscription/AppleIAPReceipt'
|
||||
|
||||
export interface MobileDeviceInterface extends DeviceInterface {
|
||||
@@ -25,4 +26,5 @@ export interface MobileDeviceInterface extends DeviceInterface {
|
||||
getAppState(): Promise<'active' | 'background' | 'inactive' | 'unknown' | 'extension'>
|
||||
getColorScheme(): Promise<'light' | 'dark' | null | undefined>
|
||||
purchaseSubscriptionIAP(plan: AppleIAPProductId): Promise<AppleIAPReceipt | undefined>
|
||||
authenticateWithU2F(authenticationOptionsJSONString: string): Promise<Record<string, unknown> | null>
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ import { ListRevisions } from '@Lib/Domain/UseCase/ListRevisions/ListRevisions'
|
||||
import { GetRevision } from '@Lib/Domain/UseCase/GetRevision/GetRevision'
|
||||
import { DeleteRevision } from '@Lib/Domain/UseCase/DeleteRevision/DeleteRevision'
|
||||
import { GetAuthenticatorAuthenticationResponse } from '@Lib/Domain/UseCase/GetAuthenticatorAuthenticationResponse/GetAuthenticatorAuthenticationResponse'
|
||||
import { GetAuthenticatorAuthenticationOptions } from '@Lib/Domain/UseCase/GetAuthenticatorAuthenticationOptions/GetAuthenticatorAuthenticationOptions'
|
||||
|
||||
/** How often to automatically sync, in milliseconds */
|
||||
const DEFAULT_AUTO_SYNC_INTERVAL = 30_000
|
||||
@@ -182,6 +183,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
private declare _addAuthenticator: AddAuthenticator
|
||||
private declare _listAuthenticators: ListAuthenticators
|
||||
private declare _deleteAuthenticator: DeleteAuthenticator
|
||||
private declare _getAuthenticatorAuthenticationOptions: GetAuthenticatorAuthenticationOptions
|
||||
private declare _getAuthenticatorAuthenticationResponse: GetAuthenticatorAuthenticationResponse
|
||||
private declare _listRevisions: ListRevisions
|
||||
private declare _getRevision: GetRevision
|
||||
@@ -284,6 +286,10 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
return this._deleteAuthenticator
|
||||
}
|
||||
|
||||
get getAuthenticatorAuthenticationOptions(): GetAuthenticatorAuthenticationOptions {
|
||||
return this._getAuthenticatorAuthenticationOptions
|
||||
}
|
||||
|
||||
get getAuthenticatorAuthenticationResponse(): GetAuthenticatorAuthenticationResponse {
|
||||
return this._getAuthenticatorAuthenticationResponse
|
||||
}
|
||||
@@ -1819,8 +1825,10 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
|
||||
this._deleteAuthenticator = new DeleteAuthenticator(this.authenticatorManager)
|
||||
|
||||
this._getAuthenticatorAuthenticationOptions = new GetAuthenticatorAuthenticationOptions(this.authenticatorManager)
|
||||
|
||||
this._getAuthenticatorAuthenticationResponse = new GetAuthenticatorAuthenticationResponse(
|
||||
this.authenticatorManager,
|
||||
this._getAuthenticatorAuthenticationOptions,
|
||||
this.options.u2fAuthenticatorVerificationPromptFunction,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { AuthenticatorClientInterface } from '@standardnotes/services'
|
||||
|
||||
import { GetAuthenticatorAuthenticationOptions } from './GetAuthenticatorAuthenticationOptions'
|
||||
|
||||
describe('GetAuthenticatorAuthenticationOptions', () => {
|
||||
let authenticatorClient: AuthenticatorClientInterface
|
||||
|
||||
const createUseCase = () => new GetAuthenticatorAuthenticationOptions(authenticatorClient)
|
||||
|
||||
beforeEach(() => {
|
||||
authenticatorClient = {} as jest.Mocked<AuthenticatorClientInterface>
|
||||
authenticatorClient.generateAuthenticationOptions = jest.fn().mockResolvedValue({ foo: 'bar' })
|
||||
})
|
||||
|
||||
it('should return an error if username is not provided', async () => {
|
||||
const result = await createUseCase().execute({
|
||||
username: '',
|
||||
})
|
||||
|
||||
expect(result.isFailed()).toBe(true)
|
||||
expect(result.getError()).toBe('Could not generate authenticator authentication options: Username cannot be empty')
|
||||
})
|
||||
|
||||
it('should return an error if authenticator client fails to generate authentication options', async () => {
|
||||
authenticatorClient.generateAuthenticationOptions = jest.fn().mockResolvedValue(null)
|
||||
|
||||
const result = await createUseCase().execute({
|
||||
username: 'test@test.te',
|
||||
})
|
||||
|
||||
expect(result.isFailed()).toBe(true)
|
||||
expect(result.getError()).toBe('Could not generate authenticator authentication options')
|
||||
})
|
||||
|
||||
it('should return ok if authenticator client succeeds to generate authenticator response', async () => {
|
||||
const result = await createUseCase().execute({
|
||||
username: 'test@test.te',
|
||||
})
|
||||
|
||||
expect(result.isFailed()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { AuthenticatorClientInterface } from '@standardnotes/services'
|
||||
import { Result, UseCaseInterface, Username } from '@standardnotes/domain-core'
|
||||
|
||||
import { GetAuthenticatorAuthenticationOptionsDTO } from './GetAuthenticatorAuthenticationOptionsDTO'
|
||||
|
||||
export class GetAuthenticatorAuthenticationOptions implements UseCaseInterface<Record<string, unknown>> {
|
||||
constructor(private authenticatorClient: AuthenticatorClientInterface) {}
|
||||
|
||||
async execute(dto: GetAuthenticatorAuthenticationOptionsDTO): Promise<Result<Record<string, unknown>>> {
|
||||
const usernameOrError = Username.create(dto.username)
|
||||
if (usernameOrError.isFailed()) {
|
||||
return Result.fail(`Could not generate authenticator authentication options: ${usernameOrError.getError()}`)
|
||||
}
|
||||
const username = usernameOrError.getValue()
|
||||
|
||||
const authenticationOptions = await this.authenticatorClient.generateAuthenticationOptions(username)
|
||||
if (authenticationOptions === null) {
|
||||
return Result.fail('Could not generate authenticator authentication options')
|
||||
}
|
||||
|
||||
return Result.ok(authenticationOptions)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface GetAuthenticatorAuthenticationOptionsDTO {
|
||||
username: string
|
||||
}
|
||||
@@ -1,40 +1,31 @@
|
||||
import { AuthenticatorClientInterface } from '@standardnotes/services'
|
||||
|
||||
import { GetAuthenticatorAuthenticationResponse } from './GetAuthenticatorAuthenticationResponse'
|
||||
import { GetAuthenticatorAuthenticationOptions } from '../GetAuthenticatorAuthenticationOptions/GetAuthenticatorAuthenticationOptions'
|
||||
import { Result } from '@standardnotes/domain-core'
|
||||
|
||||
describe('GetAuthenticatorAuthenticationResponse', () => {
|
||||
let authenticatorClient: AuthenticatorClientInterface
|
||||
let getAuthenticatorAuthenticationOptions: GetAuthenticatorAuthenticationOptions
|
||||
let authenticatorVerificationPromptFunction: (
|
||||
authenticationOptions: Record<string, unknown>,
|
||||
) => Promise<Record<string, unknown>>
|
||||
|
||||
const createUseCase = () => new GetAuthenticatorAuthenticationResponse(authenticatorClient, authenticatorVerificationPromptFunction)
|
||||
const createUseCase = () => new GetAuthenticatorAuthenticationResponse(getAuthenticatorAuthenticationOptions, authenticatorVerificationPromptFunction)
|
||||
|
||||
beforeEach(() => {
|
||||
authenticatorClient = {} as jest.Mocked<AuthenticatorClientInterface>
|
||||
authenticatorClient.generateAuthenticationOptions = jest.fn().mockResolvedValue({ foo: 'bar' })
|
||||
getAuthenticatorAuthenticationOptions = {} as jest.Mocked<GetAuthenticatorAuthenticationOptions>
|
||||
getAuthenticatorAuthenticationOptions.execute = jest.fn().mockResolvedValue(Result.ok({ foo: 'bar' }))
|
||||
|
||||
authenticatorVerificationPromptFunction = jest.fn()
|
||||
})
|
||||
|
||||
it('should return an error if username is not provided', async () => {
|
||||
const result = await createUseCase().execute({
|
||||
username: '',
|
||||
})
|
||||
|
||||
expect(result.isFailed()).toBe(true)
|
||||
expect(result.getError()).toBe('Could not generate authenticator authentication options: Username cannot be empty')
|
||||
})
|
||||
|
||||
it('should return an error if authenticator client fails to generate authentication options', async () => {
|
||||
authenticatorClient.generateAuthenticationOptions = jest.fn().mockResolvedValue(null)
|
||||
it('should return an error if it fails to generate authentication options', async () => {
|
||||
getAuthenticatorAuthenticationOptions.execute = jest.fn().mockReturnValue(Result.fail('error'))
|
||||
|
||||
const result = await createUseCase().execute({
|
||||
username: 'test@test.te',
|
||||
})
|
||||
|
||||
expect(result.isFailed()).toBe(true)
|
||||
expect(result.getError()).toBe('Could not generate authenticator authentication options')
|
||||
expect(result.getError()).toBe('error')
|
||||
})
|
||||
|
||||
it('should return an error if authenticator verification prompt function fails', async () => {
|
||||
@@ -57,7 +48,7 @@ describe('GetAuthenticatorAuthenticationResponse', () => {
|
||||
})
|
||||
|
||||
it('should return error if authenticatorVerificationPromptFunction is not provided', async () => {
|
||||
const result = await new GetAuthenticatorAuthenticationResponse(authenticatorClient).execute({
|
||||
const result = await new GetAuthenticatorAuthenticationResponse(getAuthenticatorAuthenticationOptions).execute({
|
||||
username: 'test@test.te',
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { AuthenticatorClientInterface } from '@standardnotes/services'
|
||||
import { Result, UseCaseInterface, Username } from '@standardnotes/domain-core'
|
||||
import { Result, UseCaseInterface } from '@standardnotes/domain-core'
|
||||
import { GetAuthenticatorAuthenticationResponseDTO } from './GetAuthenticatorAuthenticationResponseDTO'
|
||||
import { GetAuthenticatorAuthenticationOptions } from '../GetAuthenticatorAuthenticationOptions/GetAuthenticatorAuthenticationOptions'
|
||||
|
||||
export class GetAuthenticatorAuthenticationResponse implements UseCaseInterface<Record<string, unknown>> {
|
||||
constructor(
|
||||
private authenticatorClient: AuthenticatorClientInterface,
|
||||
private getAuthenticatorAuthenticationOptions: GetAuthenticatorAuthenticationOptions,
|
||||
private authenticatorVerificationPromptFunction?: (
|
||||
authenticationOptions: Record<string, unknown>,
|
||||
) => Promise<Record<string, unknown>>,
|
||||
@@ -17,16 +17,13 @@ export class GetAuthenticatorAuthenticationResponse implements UseCaseInterface<
|
||||
)
|
||||
}
|
||||
|
||||
const usernameOrError = Username.create(dto.username)
|
||||
if (usernameOrError.isFailed()) {
|
||||
return Result.fail(`Could not generate authenticator authentication options: ${usernameOrError.getError()}`)
|
||||
}
|
||||
const username = usernameOrError.getValue()
|
||||
|
||||
const authenticationOptions = await this.authenticatorClient.generateAuthenticationOptions(username)
|
||||
if (authenticationOptions === null) {
|
||||
return Result.fail('Could not generate authenticator authentication options')
|
||||
const authenticationOptionsOrError = await this.getAuthenticatorAuthenticationOptions.execute({
|
||||
username: dto.username,
|
||||
})
|
||||
if (authenticationOptionsOrError.isFailed()) {
|
||||
return Result.fail(authenticationOptionsOrError.getError())
|
||||
}
|
||||
const authenticationOptions = authenticationOptionsOrError.getValue()
|
||||
|
||||
let authenticatorResponse
|
||||
try {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { GetAuthenticatorAuthenticationResponse } from './GetAuthenticatorAuthen
|
||||
import { ListRevisions } from './ListRevisions/ListRevisions'
|
||||
import { GetRevision } from './GetRevision/GetRevision'
|
||||
import { DeleteRevision } from './DeleteRevision/DeleteRevision'
|
||||
import { GetAuthenticatorAuthenticationOptions } from './GetAuthenticatorAuthenticationOptions/GetAuthenticatorAuthenticationOptions'
|
||||
|
||||
export interface UseCaseContainerInterface {
|
||||
get signInWithRecoveryCodes(): SignInWithRecoveryCodes
|
||||
@@ -15,6 +16,7 @@ export interface UseCaseContainerInterface {
|
||||
get listAuthenticators(): ListAuthenticators
|
||||
get deleteAuthenticator(): DeleteAuthenticator
|
||||
get getAuthenticatorAuthenticationResponse(): GetAuthenticatorAuthenticationResponse
|
||||
get getAuthenticatorAuthenticationOptions(): GetAuthenticatorAuthenticationOptions
|
||||
get listRevisions(): ListRevisions
|
||||
get getRevision(): GetRevision
|
||||
get deleteRevision(): DeleteRevision
|
||||
|
||||
@@ -6,6 +6,8 @@ export * from './UseCase/DeleteAuthenticator/DeleteAuthenticator'
|
||||
export * from './UseCase/DeleteAuthenticator/DeleteAuthenticatorDTO'
|
||||
export * from './UseCase/DeleteRevision/DeleteRevision'
|
||||
export * from './UseCase/DeleteRevision/DeleteRevisionDTO'
|
||||
export * from './UseCase/GetAuthenticatorAuthenticationOptions/GetAuthenticatorAuthenticationOptions'
|
||||
export * from './UseCase/GetAuthenticatorAuthenticationOptions/GetAuthenticatorAuthenticationOptionsDTO'
|
||||
export * from './UseCase/GetAuthenticatorAuthenticationResponse/GetAuthenticatorAuthenticationResponse'
|
||||
export * from './UseCase/GetAuthenticatorAuthenticationResponse/GetAuthenticatorAuthenticationResponseDTO'
|
||||
export * from './UseCase/GetRecoveryCodes/GetRecoveryCodes'
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"@babel/preset-env": "*",
|
||||
"@standardnotes/api": "workspace:*",
|
||||
"@standardnotes/common": "^1.46.6",
|
||||
"@standardnotes/domain-core": "^1.11.3",
|
||||
"@standardnotes/domain-core": "^1.12.0",
|
||||
"@standardnotes/domain-events": "^2.108.1",
|
||||
"@standardnotes/encryption": "workspace:*",
|
||||
"@standardnotes/features": "workspace:*",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
import { WebApplicationInterface } from '@standardnotes/snjs/dist/@types'
|
||||
import { WebApplicationInterface } from '@standardnotes/snjs'
|
||||
import { jsonTestData, htmlTestData } from './testData'
|
||||
import { GoogleKeepConverter } from './GoogleKeepConverter'
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { Username } from '@standardnotes/snjs'
|
||||
import { ChallengePrompt } from '@standardnotes/services'
|
||||
import { RefObject, useState } from 'react'
|
||||
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { isAndroid } from '@/Utils'
|
||||
|
||||
import Button from '../Button/Button'
|
||||
import Icon from '../Icon/Icon'
|
||||
|
||||
import { InputValue } from './InputValue'
|
||||
import U2FPromptIframeContainer from './U2FPromptIframeContainer'
|
||||
|
||||
@@ -18,7 +23,7 @@ const U2FPrompt = ({ application, onValueChange, prompt, buttonRef, contextData
|
||||
const [authenticatorResponse, setAuthenticatorResponse] = useState<Record<string, unknown> | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
if (!application.isFullU2FClient) {
|
||||
if (!application.isFullU2FClient && !isAndroid()) {
|
||||
return (
|
||||
<U2FPromptIframeContainer
|
||||
contextData={contextData}
|
||||
@@ -28,48 +33,71 @@ const U2FPrompt = ({ application, onValueChange, prompt, buttonRef, contextData
|
||||
}}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<div className="min-w-76">
|
||||
{error && <div className="text-red-500">{error}</div>}
|
||||
<Button
|
||||
primary
|
||||
fullWidth
|
||||
colorStyle={authenticatorResponse ? 'success' : 'info'}
|
||||
onClick={async () => {
|
||||
const usernameOrError = Username.create((contextData as { username: string }).username)
|
||||
if (usernameOrError.isFailed()) {
|
||||
setError(usernameOrError.getError())
|
||||
return
|
||||
}
|
||||
const username = usernameOrError.getValue()
|
||||
|
||||
let authenticatorResponse: Record<string, unknown> | null = null
|
||||
if (isAndroid()) {
|
||||
const authenticatorOptionsOrError = await application.getAuthenticatorAuthenticationOptions.execute({
|
||||
username: username.value,
|
||||
})
|
||||
if (authenticatorOptionsOrError.isFailed()) {
|
||||
setError(authenticatorOptionsOrError.getError())
|
||||
return
|
||||
}
|
||||
const authenticatorOptions = authenticatorOptionsOrError.getValue()
|
||||
|
||||
authenticatorResponse = await application
|
||||
.mobileDevice()
|
||||
.authenticateWithU2F(JSON.stringify(authenticatorOptions))
|
||||
} else {
|
||||
const authenticatorResponseOrError = await application.getAuthenticatorAuthenticationResponse.execute({
|
||||
username: username.value,
|
||||
})
|
||||
|
||||
if (authenticatorResponseOrError.isFailed()) {
|
||||
setError(authenticatorResponseOrError.getError())
|
||||
return
|
||||
}
|
||||
|
||||
authenticatorResponse = authenticatorResponseOrError.getValue()
|
||||
}
|
||||
|
||||
if (authenticatorResponse === null) {
|
||||
setError('Failed to obtain device response')
|
||||
return
|
||||
}
|
||||
|
||||
setAuthenticatorResponse(authenticatorResponse)
|
||||
onValueChange(authenticatorResponse, prompt)
|
||||
}}
|
||||
ref={buttonRef}
|
||||
>
|
||||
{authenticatorResponse ? (
|
||||
<span className="flex items-center justify-center gap-3">
|
||||
<Icon type="check-circle" />
|
||||
Obtained Device Response
|
||||
</span>
|
||||
) : (
|
||||
'Authenticate Device'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-76">
|
||||
{error && <div className="text-red-500">{error}</div>}
|
||||
<Button
|
||||
primary
|
||||
fullWidth
|
||||
colorStyle={authenticatorResponse ? 'success' : 'info'}
|
||||
onClick={async () => {
|
||||
if (!contextData || contextData.username === undefined) {
|
||||
setError('No username provided')
|
||||
return
|
||||
}
|
||||
|
||||
const authenticatorResponseOrError = await application.getAuthenticatorAuthenticationResponse.execute({
|
||||
username: contextData.username as string,
|
||||
})
|
||||
|
||||
if (authenticatorResponseOrError.isFailed()) {
|
||||
setError(authenticatorResponseOrError.getError())
|
||||
return
|
||||
}
|
||||
|
||||
const authenticatorResponse = authenticatorResponseOrError.getValue()
|
||||
|
||||
setAuthenticatorResponse(authenticatorResponse)
|
||||
onValueChange(authenticatorResponse, prompt)
|
||||
}}
|
||||
ref={buttonRef}
|
||||
>
|
||||
{authenticatorResponse ? (
|
||||
<span className="flex items-center justify-center gap-3">
|
||||
<Icon type="check-circle" />
|
||||
Obtained Device Response
|
||||
</span>
|
||||
) : (
|
||||
'Authenticate Device'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default U2FPrompt
|
||||
|
||||
17
yarn.lock
17
yarn.lock
@@ -4780,7 +4780,7 @@ __metadata:
|
||||
resolution: "@standardnotes/api@workspace:packages/api"
|
||||
dependencies:
|
||||
"@standardnotes/common": ^1.46.6
|
||||
"@standardnotes/domain-core": ^1.11.3
|
||||
"@standardnotes/domain-core": ^1.12.0
|
||||
"@standardnotes/encryption": "workspace:*"
|
||||
"@standardnotes/models": "workspace:*"
|
||||
"@standardnotes/responses": "workspace:*"
|
||||
@@ -5009,6 +5009,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@standardnotes/domain-core@npm:^1.12.0":
|
||||
version: 1.12.0
|
||||
resolution: "@standardnotes/domain-core@npm:1.12.0"
|
||||
dependencies:
|
||||
uuid: ^9.0.0
|
||||
checksum: 852f15e481546fac621d503ea1cbdf9b2cb3e343d0c4cbe2fea7acc0f751a122d64479b55fa18e032e51d277a56621b9401d6f2d01756eb877eeb3572108e2a6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@standardnotes/domain-events@npm:^2.108.1":
|
||||
version: 2.108.1
|
||||
resolution: "@standardnotes/domain-events@npm:2.108.1"
|
||||
@@ -5086,7 +5095,7 @@ __metadata:
|
||||
resolution: "@standardnotes/features@workspace:packages/features"
|
||||
dependencies:
|
||||
"@standardnotes/common": ^1.46.6
|
||||
"@standardnotes/domain-core": ^1.11.3
|
||||
"@standardnotes/domain-core": ^1.12.0
|
||||
"@standardnotes/security": ^1.7.6
|
||||
"@types/jest": ^29.2.3
|
||||
"@typescript-eslint/eslint-plugin": "*"
|
||||
@@ -5397,7 +5406,7 @@ __metadata:
|
||||
dependencies:
|
||||
"@standardnotes/api": "workspace:^"
|
||||
"@standardnotes/common": ^1.46.4
|
||||
"@standardnotes/domain-core": ^1.11.3
|
||||
"@standardnotes/domain-core": ^1.12.0
|
||||
"@standardnotes/encryption": "workspace:^"
|
||||
"@standardnotes/files": "workspace:^"
|
||||
"@standardnotes/models": "workspace:^"
|
||||
@@ -5495,7 +5504,7 @@ __metadata:
|
||||
"@babel/preset-env": "*"
|
||||
"@standardnotes/api": "workspace:*"
|
||||
"@standardnotes/common": ^1.46.6
|
||||
"@standardnotes/domain-core": ^1.11.3
|
||||
"@standardnotes/domain-core": ^1.12.0
|
||||
"@standardnotes/domain-events": ^2.108.1
|
||||
"@standardnotes/encryption": "workspace:*"
|
||||
"@standardnotes/features": "workspace:*"
|
||||
|
||||
Reference in New Issue
Block a user