diff --git a/example/capacitor/README.md b/example/capacitor/README.md
new file mode 100644
index 00000000..174a9951
--- /dev/null
+++ b/example/capacitor/README.md
@@ -0,0 +1,35 @@
+# Ionic Capacitor Example App
+
+Example app of ionic app using capacitor.
+
+## Project config
+
+We need the following redirect uris to work:
+
+```
+http://localhost:8100/reauth-redirect
+http://localhost:8100/oauth-redirect
+com.authgear.exampleapp.capacitor://host/path
+https://localhost
+capacitor://localhost
+```
+
+## Run the app
+
+Before running the app, build the latest sdk at project root.
+
+```sh
+npm i
+npm run build
+```
+
+Then, in this directory, run the exmaple app.
+
+```sh
+cd example/capacitor
+npm i
+# Run it in ios device
+npm run run-ios
+# OR, run it in android device
+npm run run-android
+```
diff --git a/example/capacitor/ios/App/App/Info.plist b/example/capacitor/ios/App/App/Info.plist
index 6a6ff2eb..60e98093 100644
--- a/example/capacitor/ios/App/App/Info.plist
+++ b/example/capacitor/ios/App/App/Info.plist
@@ -47,5 +47,7 @@
ITSAppUsesNonExemptEncryption
+ NSFaceIDUsageDescription
+ Use Face ID to authenticate
diff --git a/example/capacitor/package-lock.json b/example/capacitor/package-lock.json
index b3eacd1b..612d3961 100644
--- a/example/capacitor/package-lock.json
+++ b/example/capacitor/package-lock.json
@@ -51,11 +51,11 @@
}
},
"../../packages/authgear-capacitor": {
- "name": "authgear-sdk-capacitor",
- "version": "2.2.0",
+ "name": "@authgear/capacitor",
+ "version": "2.3.2",
"license": "Apache-2.0",
"devDependencies": {
- "@authgear/core": "2.2.0",
+ "@authgear/core": "2.3.2",
"@capacitor/android": "^5.0.0",
"@capacitor/core": "^5.0.0",
"@capacitor/ios": "^5.0.0"
@@ -66,10 +66,10 @@
},
"../../packages/authgear-web": {
"name": "@authgear/web",
- "version": "2.2.0",
+ "version": "2.3.2",
"license": "Apache-2.0",
"devDependencies": {
- "@authgear/core": "2.2.0",
+ "@authgear/core": "2.3.2",
"core-js-pure": "3.22.7"
}
},
diff --git a/example/capacitor/package.json b/example/capacitor/package.json
index 3293ef59..c988b12f 100644
--- a/example/capacitor/package.json
+++ b/example/capacitor/package.json
@@ -8,7 +8,9 @@
"build": "tsc && vite build",
"preview": "vite preview",
"test.unit": "vitest",
- "lint": "eslint"
+ "lint": "eslint",
+ "run-ios": "cap run ios",
+ "run-android": "cap run android"
},
"dependencies": {
"@authgear/capacitor": "../../packages/authgear-capacitor",
diff --git a/example/capacitor/src/pages/Home.tsx b/example/capacitor/src/pages/Home.tsx
index 9a42be93..a507694e 100644
--- a/example/capacitor/src/pages/Home.tsx
+++ b/example/capacitor/src/pages/Home.tsx
@@ -41,6 +41,10 @@ import authgearCapacitor, {
CancelError as CapacitorCancelError,
ColorScheme,
Page as CapacitorPage,
+ BiometricOptions,
+ BiometricAccessConstraintIOS,
+ BiometricLAPolicy,
+ BiometricAccessConstraintAndroid,
} from "@authgear/capacitor";
import {
readClientID,
@@ -59,6 +63,22 @@ const REDIRECT_URI_WEB_AUTHENTICATE = "http://localhost:8100/oauth-redirect";
const REDIRECT_URI_WEB_REAUTH = "http://localhost:8100/reauth-redirect";
const REDIRECT_URI_CAPACITOR = "com.authgear.exampleapp.capacitor://host/path";
+const biometricOptions: BiometricOptions = {
+ ios: {
+ localizedReason: "Use biometric to authenticate",
+ constraint: BiometricAccessConstraintIOS.BiometryCurrentSet,
+ policy: BiometricLAPolicy.deviceOwnerAuthenticationWithBiometrics,
+ },
+ android: {
+ title: "Biometric Authentication",
+ subtitle: "Biometric authentication",
+ description: "Use biometric to authenticate",
+ negativeButtonText: "Cancel",
+ constraint: [BiometricAccessConstraintAndroid.BiometricStrong],
+ invalidatedByBiometricEnrollment: true,
+ },
+};
+
function isPlatformWeb(): boolean {
return Capacitor.getPlatform() === "web";
}
@@ -83,6 +103,7 @@ function AuthgearDemo() {
const [isSSOEnabled, setIsSSOEnabled] = useState(() => {
return readIsSSOEnabled();
});
+ const [biometricEnabled, setBiometricEnabled] = useState(false);
const [sessionState, setSessionState] = useState(() => {
if (isPlatformWeb()) {
@@ -105,6 +126,20 @@ function AuthgearDemo() {
return d;
}, [setSessionState]);
+ const updateBiometricState = useCallback(async () => {
+ if (isPlatformWeb()) {
+ return;
+ }
+
+ try {
+ await authgearCapacitor.checkBiometricSupported(biometricOptions);
+ const enabled = await authgearCapacitor.isBiometricEnabled();
+ setBiometricEnabled(enabled);
+ } catch (e) {
+ console.error(e);
+ }
+ }, []);
+
const showError = useCallback((e: any) => {
const json = JSON.parse(JSON.stringify(e));
json["constructor.name"] = e?.constructor?.name;
@@ -129,6 +164,7 @@ function AuthgearDemo() {
}, []);
const postConfigure = useCallback(async () => {
+ await updateBiometricState();
const sessionState = isPlatformWeb()
? authgearWeb.sessionState
: authgearCapacitor.sessionState;
@@ -144,7 +180,7 @@ function AuthgearDemo() {
}
setInitialized(true);
- }, []);
+ }, [updateBiometricState]);
const configure = useCallback(async () => {
setLoading(true);
@@ -207,8 +243,48 @@ function AuthgearDemo() {
showError(e);
} finally {
setLoading(false);
+ await updateBiometricState();
+ }
+ }, [colorScheme, page, showError, showUserInfo, updateBiometricState]);
+
+ const enableBiometric = useCallback(async () => {
+ setLoading(true);
+ try {
+ await authgearCapacitor.enableBiometric(biometricOptions);
+ } catch (e: unknown) {
+ showError(e);
+ } finally {
+ setLoading(false);
+ await updateBiometricState();
+ }
+ }, [showError, updateBiometricState]);
+
+ const authenticateBiometric = useCallback(async () => {
+ setLoading(true);
+ try {
+ const { userInfo } = await authgearCapacitor.authenticateBiometric(
+ biometricOptions
+ );
+ showUserInfo(userInfo);
+ } catch (e: unknown) {
+ showError(e);
+ } finally {
+ setLoading(false);
+ await updateBiometricState();
+ }
+ }, [showError, showUserInfo, updateBiometricState]);
+
+ const disableBiometric = useCallback(async () => {
+ setLoading(true);
+ try {
+ await authgearCapacitor.disableBiometric();
+ } catch (e: unknown) {
+ showError(e);
+ } finally {
+ setLoading(false);
+ await updateBiometricState();
}
- }, [colorScheme, page, showError, showUserInfo]);
+ }, [showError, updateBiometricState]);
const showAuthTime = useCallback(() => {
if (isPlatformWeb()) {
@@ -224,7 +300,7 @@ function AuthgearDemo() {
}
}, []);
- const reauthenticate = useCallback(async () => {
+ const reauthenticateWebOnly = useCallback(async () => {
setLoading(true);
try {
if (isPlatformWeb()) {
@@ -260,6 +336,45 @@ function AuthgearDemo() {
}
}, [showError, colorScheme, showAuthTime]);
+ const reauthenticate = useCallback(async () => {
+ setLoading(true);
+ try {
+ if (isPlatformWeb()) {
+ await authgearWeb.refreshIDToken();
+ if (!authgearWeb.canReauthenticate()) {
+ throw new Error(
+ "canReauthenticate() returns false for the current user"
+ );
+ }
+
+ authgearWeb.startReauthentication({
+ redirectURI: REDIRECT_URI_WEB_REAUTH,
+ });
+ } else {
+ await authgearCapacitor.refreshIDToken();
+ if (!authgearCapacitor.canReauthenticate()) {
+ throw new Error(
+ "canReauthenticate() returns false for the current user"
+ );
+ }
+
+ await authgearCapacitor.reauthenticate(
+ {
+ redirectURI: REDIRECT_URI_CAPACITOR,
+ colorScheme:
+ colorScheme === "" ? undefined : (colorScheme as ColorScheme),
+ },
+ biometricOptions
+ );
+ showAuthTime();
+ }
+ } catch (e) {
+ showError(e);
+ } finally {
+ setLoading(false);
+ }
+ }, [showError, colorScheme, showAuthTime]);
+
const openSettings = useCallback(async () => {
if (isPlatformWeb()) {
authgearWeb.open(WebPage.Settings);
@@ -409,6 +524,46 @@ function AuthgearDemo() {
[reauthenticate]
);
+ const onClickReauthenticateWebOnly = useCallback(
+ (e: MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ reauthenticateWebOnly();
+ },
+ [reauthenticateWebOnly]
+ );
+
+ const onClickEnableBiometric = useCallback(
+ (e: MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ enableBiometric();
+ },
+ [enableBiometric]
+ );
+
+ const onClickAuthenticateBiometric = useCallback(
+ (e: MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ authenticateBiometric();
+ },
+ [authenticateBiometric]
+ );
+
+ const onClickDisableBiometric = useCallback(
+ (e: MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ disableBiometric();
+ },
+ [disableBiometric]
+ );
+
const onClickOpenSettings = useCallback(
(e: MouseEvent) => {
e.preventDefault();
@@ -525,10 +680,40 @@ function AuthgearDemo() {
Re-authenticate
+
+ Re-authenticate (biometric or web)
+
+ {isPlatformWeb() ? null : (
+
+ Enable biometric
+
+ )}
+
+ Disable biometric
+
+
+ Authenticate with biometric
+
= Build.VERSION_CODES.M) {
@@ -158,6 +185,252 @@ JSONObject getDeviceInfo(Context ctx) throws Exception {
return rootMap;
}
+ int checkBiometricSupported(Context ctx, int flags) throws Exception {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ throw this.makeBiometricMinimumAPILevelException();
+ }
+
+ BiometricManager manager = BiometricManager.from(ctx);
+ int result = manager.canAuthenticate(flags);
+
+ if (result == BiometricManager.BIOMETRIC_SUCCESS) {
+ // Further test if the key pair generator can be initialized.
+ // https://issuetracker.google.com/issues/147374428#comment9
+ try {
+ this.createKeyPairGenerator(this.makeGenerateKeyPairSpec("__test__", flags, true));
+ } catch (Exception e) {
+ // This branch is reachable only when there is a weak face and no strong fingerprints.
+ // So we treat this situation as BIOMETRIC_ERROR_NONE_ENROLLED.
+ result = BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED;
+ // fallthrough
+ }
+ }
+
+ return result;
+ }
+
+ void createBiometricPrivateKey(AppCompatActivity activity, BiometricOptions options, BiometricCallback callback) throws Exception {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ throw this.makeBiometricMinimumAPILevelException();
+ }
+
+ BiometricPrompt.PromptInfo promptInfo = this.buildPromptInfo(options);
+ KeyGenParameterSpec spec = this.makeGenerateKeyPairSpec(
+ options.alias,
+ this.authenticatorTypesToKeyProperties(options.flags),
+ options.invalidatedByBiometricEnrollment
+ );
+ KeyPair keyPair = this.createKeyPair(spec);
+ this.signBiometricJWT(
+ activity,
+ keyPair,
+ options.kid,
+ options.payload,
+ promptInfo,
+ callback
+ );
+ }
+
+ void signWithBiometricPrivateKey(AppCompatActivity activity, BiometricOptions options, BiometricCallback callback) throws Exception {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ throw this.makeBiometricMinimumAPILevelException();
+ }
+
+ BiometricPrompt.PromptInfo promptInfo = this.buildPromptInfo(options);
+ KeyPair keyPair = this.getPrivateKey(options.alias);
+ this.signBiometricJWT(
+ activity,
+ keyPair,
+ options.kid,
+ options.payload,
+ promptInfo,
+ callback
+ );
+ }
+
+ void removeBiometricPrivateKey(String alias) throws Exception {
+ KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore");
+ keyStore.load(null);
+ keyStore.deleteEntry(alias);
+ }
+
+ @RequiresApi(Build.VERSION_CODES.M)
+ private KeyGenParameterSpec makeGenerateKeyPairSpec(String alias, int flags, boolean invalidatedByBiometricEnrollment) {
+ KeyGenParameterSpec.Builder builder = new KeyGenParameterSpec.Builder(
+ alias, KeyProperties.PURPOSE_SIGN | KeyProperties.PURPOSE_VERIFY
+ );
+ builder.setKeySize(2048);
+ builder.setDigests(KeyProperties.DIGEST_SHA256);
+ builder.setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1);
+ builder.setUserAuthenticationRequired(true);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ builder.setUserAuthenticationParameters(
+ 0,
+ flags
+ );
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ builder.setInvalidatedByBiometricEnrollment(invalidatedByBiometricEnrollment);
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ // Samsung Android 12 treats setUnlockedDeviceRequired in a different way.
+ // If setUnlockedDeviceRequired is true, then the device must be unlocked
+ // with the same level of security requirement.
+ // Otherwise, UserNotAuthenticatedException will be thrown when a cryptographic operation is initialized.
+ //
+ // The steps to reproduce the bug
+ //
+ // - Restart the device
+ // - Unlock the device with credentials
+ // - Create a Signature with a PrivateKey with setUnlockedDeviceRequired(true)
+ // - Call Signature.initSign, UserNotAuthenticatedException will be thrown.
+ // builder.setUnlockedDeviceRequired(true);
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ // User confirmation is not needed because the BiometricPrompt itself is a kind of confirmation.
+ // builder.setUserConfirmationRequired(true)
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ // User presence requires a physical button which is not our intended use case.
+ // builder.setUserPresenceRequired(true)
+ }
+
+ return builder.build();
+ }
+
+ @RequiresApi(Build.VERSION_CODES.M)
+ private KeyPair createKeyPair(KeyGenParameterSpec spec) throws Exception {
+ return this.createKeyPairGenerator(spec).generateKeyPair();
+ }
+
+ @RequiresApi(Build.VERSION_CODES.M)
+ private KeyPairGenerator createKeyPairGenerator(KeyGenParameterSpec spec) throws Exception {
+ KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, "AndroidKeyStore");
+ keyPairGenerator.initialize(spec);
+ return keyPairGenerator;
+ }
+
+ private KeyPair getPrivateKey(String alias) throws Exception {
+ KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore");
+ keyStore.load(null);
+ KeyStore.Entry entry = keyStore.getEntry(alias, null);
+ if (entry instanceof KeyStore.PrivateKeyEntry) {
+ KeyStore.PrivateKeyEntry privateKeyEntry = (KeyStore.PrivateKeyEntry) entry;
+ return new KeyPair(privateKeyEntry.getCertificate().getPublicKey(), privateKeyEntry.getPrivateKey());
+ }
+ throw new KeyNotFoundException();
+ }
+
+ private BiometricPrompt.PromptInfo buildPromptInfo(
+ BiometricOptions options
+ ) {
+ BiometricPrompt.PromptInfo.Builder builder = new BiometricPrompt.PromptInfo.Builder();
+ builder.setTitle(options.title);
+ builder.setSubtitle(options.subtitle);
+ builder.setDescription(options.description);
+ builder.setAllowedAuthenticators(options.flags);
+ if ((options.flags & BiometricManager.Authenticators.DEVICE_CREDENTIAL) == 0) {
+ builder.setNegativeButtonText(options.negativeButtonText);
+ }
+ return builder.build();
+ }
+
+ private int authenticatorTypesToKeyProperties(int flags) {
+ int out = 0;
+ if ((flags & BiometricManager.Authenticators.BIOMETRIC_STRONG) != 0) {
+ out |= KeyProperties.AUTH_BIOMETRIC_STRONG;
+ }
+ if ((flags & BiometricManager.Authenticators.DEVICE_CREDENTIAL) != 0) {
+ out |= KeyProperties.AUTH_DEVICE_CREDENTIAL;
+ }
+ return out;
+ }
+
+ private void signBiometricJWT(AppCompatActivity activity, KeyPair keyPair, String kid, JSONObject payload, BiometricPrompt.PromptInfo promptInfo, BiometricCallback callback) throws Exception {
+ JSONObject jwk = this.makeJWK(keyPair, kid);
+ JSONObject header = this.makeBiometricJWTHeader(jwk);
+ Signature lockedSignature = this.makeSignature(keyPair.getPrivate());
+ BiometricPrompt.CryptoObject cryptoObject = new BiometricPrompt.CryptoObject(lockedSignature);
+ BiometricPrompt prompt = new BiometricPrompt(activity, new BiometricPrompt.AuthenticationCallback() {
+ @Override
+ public void onAuthenticationError(int errorCode, @NonNull CharSequence errString) {
+ callback.onAuthenticationError(errorCode, errString);
+ }
+
+ @Override
+ public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) {
+ Signature signature = result.getCryptoObject().getSignature();
+ try {
+ String jwt = Authgear.this.signJWT(signature, header, payload);
+ callback.onSuccess(jwt);
+ } catch (SignatureException e) {
+ callback.onException(e);
+ }
+ }
+
+ @Override
+ public void onAuthenticationFailed() {
+ // This callback will be invoked EVERY time the recognition failed.
+ // So while the prompt is still opened, this callback can be called repetitively.
+ // Finally, either onAuthenticationError or onAuthenticationSucceeded will be called.
+ // So this callback is not important to the developer.
+ }
+ });
+ Handler handler = new Handler(Looper.getMainLooper());
+ handler.post(() -> prompt.authenticate(promptInfo, cryptoObject));
+ }
+
+ private JSONObject makeJWK(KeyPair keyPair, String kid) throws JSONException {
+ JSONObject jwk = new JSONObject();
+ jwk.put("kid", kid);
+ PublicKey publicKey = keyPair.getPublic();
+ RSAPublicKey rsaPublicKey = (RSAPublicKey) publicKey;
+ jwk.put("alg", "RS256");
+ jwk.put("kty", "RSA");
+ jwk.put("n", this.base64URLEncode(rsaPublicKey.getModulus().toByteArray()));
+ jwk.put("e", this.base64URLEncode(rsaPublicKey.getPublicExponent().toByteArray()));
+ return jwk;
+ }
+
+ private JSONObject makeBiometricJWTHeader(JSONObject jwk) throws JSONException {
+ JSONObject header = new JSONObject();
+ header.put("typ", "vnd.authgear.biometric-request");
+ header.put("kid", jwk.getString("kid"));
+ header.put("alg", jwk.getString("alg"));
+ header.put("jwk", jwk);
+ return header;
+ }
+
+ private Signature makeSignature(PrivateKey privateKey) throws Exception {
+ Signature signature = Signature.getInstance("SHA256withRSA");
+ signature.initSign(privateKey);
+ return signature;
+ }
+
+ private String signJWT(Signature signature, JSONObject header, JSONObject payload) throws SignatureException {
+ String headerJSON = header.toString();
+ String payloadJSON = payload.toString();
+ String headerString = this.base64URLEncode(headerJSON.getBytes(UTF8));
+ String payloadString = this.base64URLEncode(payloadJSON.getBytes(UTF8));
+ String strToSign = headerString + "." + payloadString;
+ signature.update(strToSign.getBytes(UTF8));
+ byte[] sig = signature.sign();
+ return strToSign + "." + this.base64URLEncode(sig);
+ }
+
+ private String base64URLEncode(byte[] bytes) {
+ return Base64.encodeToString(bytes, Base64.NO_WRAP | Base64.URL_SAFE | Base64.NO_PADDING);
+ }
+
+ private Exception makeBiometricMinimumAPILevelException() {
+ return new Exception("Biometric authentication requires at least API Level 23");
+ }
+
private SharedPreferences getSharedPreferences(Context ctx) throws Exception {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
MasterKey masterKey = new MasterKey.Builder(ctx)
diff --git a/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/AuthgearPlugin.java b/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/AuthgearPlugin.java
index ea84892b..fc3a12ee 100644
--- a/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/AuthgearPlugin.java
+++ b/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/AuthgearPlugin.java
@@ -6,15 +6,22 @@
import android.net.Uri;
import androidx.activity.result.ActivityResult;
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.biometric.BiometricManager;
+import androidx.biometric.BiometricPrompt;
import com.getcapacitor.JSArray;
import com.getcapacitor.JSObject;
+import com.getcapacitor.JSValue;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.ActivityCallback;
import com.getcapacitor.annotation.CapacitorPlugin;
+import org.json.JSONArray;
+import org.json.JSONException;
import org.json.JSONObject;
@CapacitorPlugin(name = "Authgear")
@@ -112,6 +119,14 @@ public void getDeviceInfo(PluginCall call) {
}
}
+ @PluginMethod
+ public void generateUUID(PluginCall call) {
+ String uuid = this.implementation.generateUUID();
+ JSObject ret = new JSObject();
+ ret.put("uuid", uuid);
+ call.resolve(ret);
+ }
+
@PluginMethod
public void openAuthorizeURL(PluginCall call) {
String urlString = call.getString("url");
@@ -160,6 +175,229 @@ private void handleOpenURL(PluginCall call, ActivityResult activityResult) {
}
}
+ @PluginMethod
+ public void checkBiometricSupported(PluginCall call) {
+ JSObject android = call.getObject("android");
+ JSONArray constraint = this.jsObjectGetArray(android, "constraint");
+ int flags = this.constraintToFlag(constraint);
+
+ Context ctx = this.getContext();
+ try {
+ int result = this.implementation.checkBiometricSupported(ctx, flags);
+ if (result == BiometricManager.BIOMETRIC_SUCCESS) {
+ call.resolve();
+ } else {
+ String resultString = this.resultToString(result);
+ call.reject(resultString, resultString);
+ }
+ } catch (Exception e) {
+ this.reject(call, e);
+ }
+ }
+
+ @PluginMethod
+ public void createBiometricPrivateKey(PluginCall call) {
+ AppCompatActivity activity = this.getActivity();
+
+ JSObject payload = call.getObject("payload");
+ String kid = call.getString("kid");
+ String alias = "com.authgear.keys.biometric." + kid;
+ JSObject android = call.getObject("android");
+ JSONArray constraint = this.jsObjectGetArray(android, "constraint");
+ boolean invalidatedByBiometricEnrollment = android.getBool("invalidatedByBiometricEnrollment");
+ int flags = this.constraintToFlag(constraint);
+ String title = android.getString("title");
+ String subtitle = android.getString("subtitle");
+ String description = android.getString("description");
+ String negativeButtonText = android.getString("negativeButtonText");
+
+ BiometricOptions options = new BiometricOptions();
+ options.payload = payload;
+ options.kid = kid;
+ options.alias = alias;
+ options.flags = flags;
+ options.invalidatedByBiometricEnrollment = invalidatedByBiometricEnrollment;
+ options.title = title;
+ options.subtitle = subtitle;
+ options.description = description;
+ options.negativeButtonText = negativeButtonText;
+
+ try {
+ this.implementation.createBiometricPrivateKey(
+ activity,
+ options,
+ new BiometricCallback() {
+ @Override
+ public void onSuccess(String jwt) {
+ JSObject obj = new JSObject();
+ obj.put("jwt", jwt);
+ call.resolve(obj);
+ }
+
+ @Override
+ public void onAuthenticationError(int errorCode, @NonNull CharSequence errString) {
+ call.reject(errString.toString(), AuthgearPlugin.this.errorCodeToString(errorCode));
+ }
+
+ @Override
+ public void onException(Exception e) {
+ AuthgearPlugin.this.reject(call, e);
+ }
+ }
+ );
+ } catch (Exception e) {
+ this.reject(call, e);
+ }
+ }
+
+ @PluginMethod
+ public void signWithBiometricPrivateKey(PluginCall call) {
+ AppCompatActivity activity = this.getActivity();
+
+ JSObject payload = call.getObject("payload");
+ String kid = call.getString("kid");
+ String alias = "com.authgear.keys.biometric." + kid;
+ JSObject android = call.getObject("android");
+ JSONArray constraint = this.jsObjectGetArray(android, "constraint");
+ boolean invalidatedByBiometricEnrollment = android.getBool("invalidatedByBiometricEnrollment");
+ int flags = this.constraintToFlag(constraint);
+ String title = android.getString("title");
+ String subtitle = android.getString("subtitle");
+ String description = android.getString("description");
+ String negativeButtonText = android.getString("negativeButtonText");
+
+ BiometricOptions options = new BiometricOptions();
+ options.payload = payload;
+ options.kid = kid;
+ options.alias = alias;
+ options.flags = flags;
+ options.invalidatedByBiometricEnrollment = invalidatedByBiometricEnrollment;
+ options.title = title;
+ options.subtitle = subtitle;
+ options.description = description;
+ options.negativeButtonText = negativeButtonText;
+
+ try {
+ this.implementation.signWithBiometricPrivateKey(
+ activity,
+ options,
+ new BiometricCallback() {
+ @Override
+ public void onSuccess(String jwt) {
+ JSObject obj = new JSObject();
+ obj.put("jwt", jwt);
+ call.resolve(obj);
+ }
+
+ @Override
+ public void onAuthenticationError(int errorCode, @NonNull CharSequence errString) {
+ call.reject(errString.toString(), AuthgearPlugin.this.errorCodeToString(errorCode));
+ }
+
+ @Override
+ public void onException(Exception e) {
+ AuthgearPlugin.this.reject(call, e);
+ }
+ }
+ );
+ } catch (Exception e) {
+ this.reject(call, e);
+ }
+ }
+
+ @PluginMethod
+ public void removeBiometricPrivateKey(PluginCall call) {
+ String kid = call.getString("kid");
+ String alias = "com.authgear.keys.biometric." + kid;
+
+ try {
+ this.implementation.removeBiometricPrivateKey(alias);
+ call.resolve();
+ } catch (Exception e) {
+ this.reject(call, e);
+ }
+ }
+
+ private JSONArray jsObjectGetArray(JSObject obj, String key) {
+ try {
+ return obj.getJSONArray(key);
+ } catch (JSONException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private int constraintToFlag(JSONArray constraint) {
+ try {
+ int flag = 0;
+ for (int i = 0; i < constraint.length(); i++) {
+ String c = constraint.getString(i);
+ if (c.equals("BIOMETRIC_STRONG")) {
+ flag |= BiometricManager.Authenticators.BIOMETRIC_STRONG;
+ }
+ if (c.equals("DEVICE_CREDENTIAL")) {
+ flag |= BiometricManager.Authenticators.DEVICE_CREDENTIAL;
+ }
+ }
+ return flag;
+ } catch (JSONException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private String resultToString(int result) {
+ switch (result) {
+ case BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE:
+ return "BIOMETRIC_ERROR_HW_UNAVAILABLE";
+ case BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED:
+ return "BIOMETRIC_ERROR_NONE_ENROLLED";
+ case BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE:
+ return "BIOMETRIC_ERROR_NO_HARDWARE";
+ case BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED:
+ return "BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED";
+ case BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED:
+ return "BIOMETRIC_ERROR_UNSUPPORTED";
+ case BiometricManager.BIOMETRIC_STATUS_UNKNOWN:
+ return "BIOMETRIC_STATUS_UNKNOWN";
+ default:
+ return "BIOMETRIC_ERROR_UNKNOWN";
+ }
+ }
+
+ private String errorCodeToString(int errorCode) {
+ switch (errorCode) {
+ case BiometricPrompt.ERROR_CANCELED:
+ return "ERROR_CANCELED";
+ case BiometricPrompt.ERROR_HW_NOT_PRESENT:
+ return "ERROR_HW_NOT_PRESENT";
+ case BiometricPrompt.ERROR_HW_UNAVAILABLE:
+ return "ERROR_HW_UNAVAILABLE";
+ case BiometricPrompt.ERROR_LOCKOUT:
+ return "ERROR_LOCKOUT";
+ case BiometricPrompt.ERROR_LOCKOUT_PERMANENT:
+ return "ERROR_LOCKOUT_PERMANENT";
+ case BiometricPrompt.ERROR_NEGATIVE_BUTTON:
+ return "ERROR_NEGATIVE_BUTTON";
+ case BiometricPrompt.ERROR_NO_BIOMETRICS:
+ return "ERROR_NO_BIOMETRICS";
+ case BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL:
+ return "ERROR_NO_DEVICE_CREDENTIAL";
+ case BiometricPrompt.ERROR_NO_SPACE:
+ return "ERROR_NO_SPACE";
+ case BiometricPrompt.ERROR_SECURITY_UPDATE_REQUIRED:
+ return "ERROR_SECURITY_UPDATE_REQUIRED";
+ case BiometricPrompt.ERROR_TIMEOUT:
+ return "ERROR_TIMEOUT";
+ case BiometricPrompt.ERROR_UNABLE_TO_PROCESS:
+ return "ERROR_UNABLE_TO_PROCESS";
+ case BiometricPrompt.ERROR_USER_CANCELED:
+ return "ERROR_USER_CANCELED";
+ case BiometricPrompt.ERROR_VENDOR:
+ return "ERROR_VENDOR";
+ default:
+ return "ERROR_UNKNOWN";
+ }
+ }
+
private void reject(PluginCall call, Exception e) {
call.reject(e.getMessage(), e.getClass().getName(), e);
}
diff --git a/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/BiometricCallback.java b/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/BiometricCallback.java
new file mode 100644
index 00000000..0a9a1ddd
--- /dev/null
+++ b/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/BiometricCallback.java
@@ -0,0 +1,9 @@
+package com.authgear.capacitor;
+
+import androidx.annotation.NonNull;
+
+interface BiometricCallback {
+ void onSuccess(String jwt);
+ void onAuthenticationError(int errorCode, @NonNull CharSequence errString);
+ void onException(Exception e);
+}
diff --git a/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/BiometricOptions.java b/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/BiometricOptions.java
new file mode 100644
index 00000000..21bfeb03
--- /dev/null
+++ b/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/BiometricOptions.java
@@ -0,0 +1,17 @@
+package com.authgear.capacitor;
+
+import com.getcapacitor.JSObject;
+
+class BiometricOptions {
+ JSObject payload;
+ String kid;
+ String alias;
+ int flags;
+ boolean invalidatedByBiometricEnrollment;
+ String title;
+ String subtitle;
+ String description;
+ String negativeButtonText;
+
+ BiometricOptions() {}
+}
diff --git a/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/KeyNotFoundException.java b/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/KeyNotFoundException.java
new file mode 100644
index 00000000..70ca22af
--- /dev/null
+++ b/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/KeyNotFoundException.java
@@ -0,0 +1,3 @@
+package com.authgear.capacitor;
+
+public class KeyNotFoundException extends Exception {}
diff --git a/packages/authgear-capacitor/ios/Plugin.xcodeproj/project.pbxproj b/packages/authgear-capacitor/ios/Plugin.xcodeproj/project.pbxproj
index fcad5d86..b38f2bce 100644
--- a/packages/authgear-capacitor/ios/Plugin.xcodeproj/project.pbxproj
+++ b/packages/authgear-capacitor/ios/Plugin.xcodeproj/project.pbxproj
@@ -15,6 +15,8 @@
50ADFFA82020EE4F00D50D53 /* AuthgearPlugin.m in Sources */ = {isa = PBXBuildFile; fileRef = 50ADFFA72020EE4F00D50D53 /* AuthgearPlugin.m */; };
50E1A94820377CB70090CE1A /* AuthgearPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E1A94720377CB70090CE1A /* AuthgearPlugin.swift */; };
7774CCB12B302A7F007420F2 /* AuthgearPluginImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7774CCB02B302A7F007420F2 /* AuthgearPluginImpl.swift */; };
+ 77F8B4232B4FDFB700A6F088 /* Asn1IntegerConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77F8B4222B4FDFB700A6F088 /* Asn1IntegerConversion.swift */; };
+ 77F8B4252B4FE08700A6F088 /* ASN1DERParsing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77F8B4242B4FE08700A6F088 /* ASN1DERParsing.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -39,6 +41,8 @@
50E1A94720377CB70090CE1A /* AuthgearPlugin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthgearPlugin.swift; sourceTree = ""; };
5E23F77F099397094342571A /* Pods-Plugin.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Plugin.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Plugin/Pods-Plugin.debug.xcconfig"; sourceTree = ""; };
7774CCB02B302A7F007420F2 /* AuthgearPluginImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthgearPluginImpl.swift; sourceTree = ""; };
+ 77F8B4222B4FDFB700A6F088 /* Asn1IntegerConversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Asn1IntegerConversion.swift; sourceTree = ""; };
+ 77F8B4242B4FE08700A6F088 /* ASN1DERParsing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ASN1DERParsing.swift; sourceTree = ""; };
91781294A431A2A7CC6EB714 /* Pods-Plugin.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Plugin.release.xcconfig"; path = "Pods/Target Support Files/Pods-Plugin/Pods-Plugin.release.xcconfig"; sourceTree = ""; };
96ED1B6440D6672E406C8D19 /* Pods-PluginTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PluginTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-PluginTests/Pods-PluginTests.debug.xcconfig"; sourceTree = ""; };
F65BB2953ECE002E1EF3E424 /* Pods-PluginTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PluginTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-PluginTests/Pods-PluginTests.release.xcconfig"; sourceTree = ""; };
@@ -95,6 +99,8 @@
50ADFFA72020EE4F00D50D53 /* AuthgearPlugin.m */,
7774CCB02B302A7F007420F2 /* AuthgearPluginImpl.swift */,
50ADFF8C201F53D600D50D53 /* Info.plist */,
+ 77F8B4222B4FDFB700A6F088 /* Asn1IntegerConversion.swift */,
+ 77F8B4242B4FE08700A6F088 /* ASN1DERParsing.swift */,
);
path = Plugin;
sourceTree = "";
@@ -303,7 +309,9 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ 77F8B4252B4FE08700A6F088 /* ASN1DERParsing.swift in Sources */,
50E1A94820377CB70090CE1A /* AuthgearPlugin.swift in Sources */,
+ 77F8B4232B4FDFB700A6F088 /* Asn1IntegerConversion.swift in Sources */,
50ADFFA82020EE4F00D50D53 /* AuthgearPlugin.m in Sources */,
7774CCB12B302A7F007420F2 /* AuthgearPluginImpl.swift in Sources */,
);
diff --git a/packages/authgear-capacitor/ios/Plugin/ASN1DERParsing.swift b/packages/authgear-capacitor/ios/Plugin/ASN1DERParsing.swift
new file mode 100644
index 00000000..ca6c187c
--- /dev/null
+++ b/packages/authgear-capacitor/ios/Plugin/ASN1DERParsing.swift
@@ -0,0 +1,230 @@
+// swiftformat:disable all
+
+// Copied from https://github.com/airsidemobile/JOSESwift/blob/2.4.0/JOSESwift/Sources/ASN1DERParsing.swift
+
+//
+// ASN1DERParsing.swift
+// JOSESwift
+//
+// Created by Daniel Egger on 06.02.18.
+//
+// ---------------------------------------------------------------------------
+// Copyright 2019 Airside Mobile Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+// ---------------------------------------------------------------------------
+//
+
+import Foundation
+
+internal enum ASN1DERParsingError: Error {
+ case incorrectTypeTag(actualTag: UInt8, expectedTag: UInt8)
+ case incorrectLengthFieldLength
+ case incorrectValueLength
+ case incorrectTLVLength
+}
+
+/// Possible ASN.1 types.
+/// See [here](https://msdn.microsoft.com/en-us/library/windows/desktop/bb648640(v=vs.85).aspx)
+/// for more information.
+internal enum ASN1Type {
+ case sequence
+ case integer
+
+ var tag: UInt8 {
+ switch self {
+ case .sequence:
+ return 0x30
+ case .integer:
+ return 0x02
+ }
+ }
+}
+
+internal struct TLVTriplet {
+ let tag: UInt8
+ let length: [UInt8]
+ let value: [UInt8]
+}
+
+// MARK: Array Extension for Parsing
+// Inspired by: https://github.com/henrinormak/Heimdall/blob/master/Heimdall/Heimdall.swift
+
+internal extension Array where Element == UInt8 {
+
+ /// Reads the value of the specified ASN.1 type from the front of the bytes array.
+ /// The bytes array is expected to be a DER encoding of an ASN.1 type.
+ /// The specified type's TLV triplet is expected to be at the front of the bytes array.
+ /// The bytes array may contain trailing bytes after the TLV triplet that are ignored during parsing.
+ ///
+ /// - Parameter type: The ASN.1 type to read.
+ /// More information about the expected DER encoding of the specified ASN.1 type can be found
+ /// [here](https://msdn.microsoft.com/en-us/library/windows/desktop/bb648640(v=vs.85).aspx).
+ /// - Returns: The value of the specified ASN.1 type. More formally, the value field of the type's TLV triplet.
+ /// - Throws: An `ASN1DERParsingError` indicating any parsing errors.
+ func read(_ type: ASN1Type) throws -> [UInt8] {
+ let triplet = try self.nextTLVTriplet()
+
+ guard triplet.tag == type.tag else {
+ throw ASN1DERParsingError.incorrectTypeTag(actualTag: triplet.tag, expectedTag: type.tag)
+ }
+
+ return triplet.value
+ }
+
+ /// Removes the specified ASN.1 type from the bytes array.
+ /// The bytes array is expected to be a DER encoding of a ASN.1 type.
+ /// The specified type's TLV triplet is expected to be at the front of the bytes array.
+ ///
+ /// - Parameter type: The ASN.1 type to be removed from the bytes array.
+ /// More information about the expected DER encoding of the specified ASN.1 type can be found
+ /// [here](https://msdn.microsoft.com/en-us/library/windows/desktop/bb648640(v=vs.85).aspx).
+ /// - Returns: The remaining bytes of the bytes array that may contain further ASN.1 types.
+ /// - Throws: An `ASN1DERParsingError` indicating any parsing errors.
+ func skip(_ type: ASN1Type) throws -> [UInt8] {
+ let triplet = try self.nextTLVTriplet()
+
+ guard triplet.tag == type.tag else {
+ throw ASN1DERParsingError.incorrectTypeTag(actualTag: triplet.tag, expectedTag: type.tag)
+ }
+
+ // TLV triplet = 1 tag byte + some length bytes + some value bytes
+ let skippedTripletLength = (1 + triplet.length.count + triplet.value.count)
+
+ return Array(self.dropFirst(skippedTripletLength))
+ }
+
+ /// Reads a TLV (tag, length, value) triplet of a DER encoded ASN.1 type from the bytes array.
+ /// More information on the DER Transfer Syntax encoding ASN.1 types can be found
+ /// [here](https://msdn.microsoft.com/en-us/library/windows/desktop/bb540801(v=vs.85).aspx).
+ ///
+ /// - Returns: A triplet containing the ASN.1 type's tag, length, and value field.
+ func nextTLVTriplet() throws -> TLVTriplet {
+ var pointer = 0
+
+ // DER encoding of an ASN.1 type: [ TAG | LENGTH | VALUE ].
+
+ // At least the tag and one length byte must be present.
+ guard self.count >= 2 else {
+ throw ASN1DERParsingError.incorrectTLVLength
+ }
+
+ let tag = readTag(from: self, pointer: &pointer)
+
+ let lengthField = try readLengthField(from: self, pointer: &pointer)
+
+ let valueFieldLength = try length(encodedBy: lengthField)
+
+ let valueField = try readValueField(ofLength: valueFieldLength, from: self, pointer: &pointer)
+
+ return TLVTriplet(tag: tag, length: lengthField, value: valueField)
+ }
+
+}
+
+// MARK: Freestanding Helper Functions
+
+private func readTag(from encodedTriplet: [UInt8], pointer: inout Int) -> UInt8 {
+ let tag = encodedTriplet[pointer]
+
+ // ---------------------------------------- //
+ // tag length field value field //
+ // [ 0xN | ............ | ........... ] //
+ // ^ //
+ // | //
+ // pointer //
+ // ---------------------------------------- //
+
+ pointer.advance()
+
+ return tag
+}
+
+private func readLengthField(from encodedTriplet: [UInt8], pointer: inout Int) throws -> [UInt8] {
+ if encodedTriplet[pointer] < 128 {
+ let lengthField = [ encodedTriplet[pointer] ]
+ pointer.advance()
+
+ return lengthField
+ }
+
+ // -------------------------------------------------- //
+ // tag length field value field //
+ // [ ... | 0x8N 0x00 0x01 ... 0xN | ........... ] //
+ // ^ | | //
+ // | -------v------- //
+ // | lengthFieldCount //
+ // | //
+ // pointer //
+ // -------------------------------------------------- //
+
+ let lengthFieldCount = Int(encodedTriplet[pointer] - 128)
+
+ // Ensure we have enough bytes left.
+ guard (pointer + lengthFieldCount) < encodedTriplet.count else {
+ throw ASN1DERParsingError.incorrectLengthFieldLength
+ }
+
+ let lengthField = Array(encodedTriplet[pointer...(pointer + lengthFieldCount)])
+
+ pointer.advance()
+ pointer.advance(by: lengthFieldCount)
+
+ return lengthField
+}
+
+private func readValueField(ofLength length: Int, from encodedTriplet: [UInt8], pointer: inout Int) throws -> [UInt8] {
+ let endPointer = (pointer + length)
+
+ // --------------------------------------------------------------- //
+ // tag length field value field //
+ // [ ... | ............ | 0x01 0x02 0x03 0x04 ... 0xN ] //
+ // ^ ^ //
+ // | | //
+ // pointer endPointer //
+ // --------------------------------------------------------------- //
+
+ // Ensure we have enough bytes left.
+ guard endPointer <= encodedTriplet.count else {
+ throw ASN1DERParsingError.incorrectValueLength
+ }
+
+ return Array(encodedTriplet[pointer.. Int {
+ // If the value field contains < 128 bytes, the length field requires only one byte (00000010 = length two).
+ // If the value field contains >= 128 bytes, the highest bit of the length field is 1 and the remaining bits
+ // identify the number of bytes needed to encode the length (10000010 - 10000000 = 10 = two length bytes follow).
+
+ // Length is directly encoded by the only byte in the length field.
+ if lengthField.count == 1 {
+ return Int(lengthField[0])
+ }
+
+ // Length is encoded by all but the first byte in the length field.
+ // The first byte in the length field encodes the number of remaining bytes used to encode the length.
+ var length: UInt64 = 0
+ for byte in lengthField.dropFirst() {
+ length = (length << 8)
+ length += UInt64(byte)
+ }
+
+ return Int(length)
+}
+
+private extension Int {
+ mutating func advance(by n: Int = 1) {
+ self = self.advanced(by: n)
+ }
+}
diff --git a/packages/authgear-capacitor/ios/Plugin/Asn1IntegerConversion.swift b/packages/authgear-capacitor/ios/Plugin/Asn1IntegerConversion.swift
new file mode 100644
index 00000000..951b66ba
--- /dev/null
+++ b/packages/authgear-capacitor/ios/Plugin/Asn1IntegerConversion.swift
@@ -0,0 +1,40 @@
+// swiftforamt:disable all
+
+import Foundation
+
+// Copied from https://github.com/airsidemobile/JOSESwift/blob/2.4.0/JOSESwift/Sources/CryptoImplementation/EC.swift#L229
+enum Asn1IntegerConversion {
+ static func toRaw(_ data: Data, of fixedLength: Int) -> Data {
+ let varLength = data.count
+ if varLength > fixedLength + 1 {
+ fatalError("ASN.1 integer is \(varLength) bytes long when it should be < \(fixedLength + 1).")
+ }
+ if varLength == fixedLength + 1 {
+ assert(data.first == 0)
+ return data.dropFirst()
+ }
+ if varLength == fixedLength {
+ return data
+ }
+ if varLength < fixedLength {
+ // pad to fixed length using 0x00 bytes
+ return Data(count: fixedLength - varLength) + data
+ }
+ fatalError("Unable to parse ASN.1 integer. This should be unreachable.")
+ }
+
+ static func fromRaw(_ data: Data) -> Data {
+ assert(!data.isEmpty)
+ let msb: UInt8 = 0b1000_0000
+ // drop all leading zero bytes
+ let varlen = data.drop { $0 == 0 }
+ guard let firstNonZero = varlen.first else {
+ // all bytes were zero so the encoded value is zero
+ return Data(count: 1)
+ }
+ if (firstNonZero & msb) == msb {
+ return Data(count: 1) + varlen
+ }
+ return varlen
+ }
+}
diff --git a/packages/authgear-capacitor/ios/Plugin/AuthgearPlugin.m b/packages/authgear-capacitor/ios/Plugin/AuthgearPlugin.m
index c20d4d5e..d46e4f63 100644
--- a/packages/authgear-capacitor/ios/Plugin/AuthgearPlugin.m
+++ b/packages/authgear-capacitor/ios/Plugin/AuthgearPlugin.m
@@ -10,6 +10,11 @@
CAP_PLUGIN_METHOD(randomBytes, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(sha256String, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(getDeviceInfo, CAPPluginReturnPromise);
+ CAP_PLUGIN_METHOD(generateUUID, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(openAuthorizeURL, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(openURL, CAPPluginReturnPromise);
+ CAP_PLUGIN_METHOD(checkBiometricSupported, CAPPluginReturnPromise);
+ CAP_PLUGIN_METHOD(createBiometricPrivateKey, CAPPluginReturnPromise);
+ CAP_PLUGIN_METHOD(signWithBiometricPrivateKey, CAPPluginReturnPromise);
+ CAP_PLUGIN_METHOD(removeBiometricPrivateKey, CAPPluginReturnPromise);
)
diff --git a/packages/authgear-capacitor/ios/Plugin/AuthgearPlugin.swift b/packages/authgear-capacitor/ios/Plugin/AuthgearPlugin.swift
index be1bd4f8..a70aab10 100644
--- a/packages/authgear-capacitor/ios/Plugin/AuthgearPlugin.swift
+++ b/packages/authgear-capacitor/ios/Plugin/AuthgearPlugin.swift
@@ -1,4 +1,5 @@
import Foundation
+import LocalAuthentication
import Capacitor
@objc(AuthgearPlugin)
@@ -70,12 +71,19 @@ public class AuthgearPlugin: CAPPlugin {
}
@objc func getDeviceInfo(_ call: CAPPluginCall) {
- let deviceInfo = self.impl.getDeviceInfo();
+ let deviceInfo = self.impl.getDeviceInfo()
call.resolve([
"deviceInfo": deviceInfo
])
}
+ @objc func generateUUID(_ call: CAPPluginCall) {
+ let uuid = self.impl.generateUUID()
+ call.resolve([
+ "uuid": uuid
+ ])
+ }
+
@objc func openAuthorizeURL(_ call: CAPPluginCall) {
let url = URL(string: call.getString("url")!)!
let callbackURL = URL(string: call.getString("callbackURL")!)!
@@ -108,4 +116,87 @@ public class AuthgearPlugin: CAPPlugin {
}
}
}
+
+ @objc func checkBiometricSupported(_ call: CAPPluginCall) {
+ DispatchQueue.main.async {
+ do {
+ try self.impl.checkBiometricSupported()
+ call.resolve()
+ } catch {
+ error.reject(call)
+ }
+ }
+ }
+
+ @objc func createBiometricPrivateKey(_ call: CAPPluginCall) {
+ let kid = call.getString("kid")!
+ let payload = call.getObject("payload")!
+ let ios = call.getObject("ios")!
+ let constraint = ios["constraint"] as! String
+ let localizedReason = ios["localizedReason"] as! String
+ let tag = "com.authgear.keys.biometric.\(kid)"
+ let policy = LAPolicy.deviceOwnerAuthenticationWithBiometrics
+
+ DispatchQueue.main.async {
+ self.impl.createBiometricPrivateKey(
+ policy: policy,
+ localizedReason: localizedReason,
+ constraint: constraint,
+ kid: kid,
+ tag: tag,
+ payload: payload
+ ) { (jwt, error) in
+ if let error = error {
+ error.reject(call)
+ }
+ if let jwt = jwt {
+ call.resolve([
+ "jwt": jwt
+ ])
+ }
+ }
+ }
+ }
+
+ @objc func signWithBiometricPrivateKey(_ call: CAPPluginCall) {
+ let kid = call.getString("kid")!
+ let payload = call.getObject("payload")!
+ let ios = call.getObject("ios")!
+ let policyString = ios["policy"] as! String
+ let localizedReason = ios["localizedReason"] as! String
+ let tag = "com.authgear.keys.biometric.\(kid)"
+
+ DispatchQueue.main.async {
+ self.impl.signWithBiometricPrivateKey(
+ policyString: policyString,
+ localizedReason: localizedReason,
+ kid: kid,
+ tag: tag,
+ payload: payload
+ ) { (jwt, error) in
+ if let error = error {
+ error.reject(call)
+ }
+ if let jwt = jwt {
+ call.resolve([
+ "jwt": jwt
+ ])
+ }
+ }
+ }
+ }
+
+ @objc func removeBiometricPrivateKey(_ call: CAPPluginCall) {
+ let kid = call.getString("kid")!
+ let tag = "com.authgear.keys.biometric.\(kid)"
+
+ DispatchQueue.main.async {
+ do {
+ try self.impl.removeBiometricPrivateKey(tag: tag)
+ call.resolve()
+ } catch {
+ error.reject(call)
+ }
+ }
+ }
}
diff --git a/packages/authgear-capacitor/ios/Plugin/AuthgearPluginImpl.swift b/packages/authgear-capacitor/ios/Plugin/AuthgearPluginImpl.swift
index 169952a3..b0f8da14 100644
--- a/packages/authgear-capacitor/ios/Plugin/AuthgearPluginImpl.swift
+++ b/packages/authgear-capacitor/ios/Plugin/AuthgearPluginImpl.swift
@@ -2,6 +2,7 @@ import Foundation
import CommonCrypto
import UIKit
import AuthenticationServices
+import LocalAuthentication
import Capacitor
@objc class AuthgearPluginImpl: NSObject, ASWebAuthenticationPresentationContextProviding {
@@ -177,6 +178,11 @@ import Capacitor
]
}
+ @objc func generateUUID() -> String {
+ let uuid = NSUUID()
+ return uuid.uuidString
+ }
+
@objc func openAuthorizeURL(window: UIWindow, url: URL, callbackURL: URL, prefersEphemeralWebBrowserSession: Bool, completion: @escaping (String?, Error?) -> Void) {
if #available(iOS 12.0, *) {
let scheme = callbackURL.scheme
@@ -192,7 +198,7 @@ import Capacitor
if isCancel {
completion(nil, NSError.makeCancel(error: error))
} else {
- completion(nil, NSError.makeError(message: "openAuthorizeURL failed", code: nil, error: error))
+ completion(nil, NSError.makeUnrecoverableAuthgearError(message: "openAuthorizeURL failed", error: error))
}
}
if let redirectURI = redirectURI {
@@ -206,7 +212,7 @@ import Capacitor
}
asWebSession!.start()
} else {
- completion(nil, NSError.makeError(message: "SDK supports only iOS 12.0 or newer", code: nil, error: nil))
+ completion(nil, NSError.makeUnrecoverableAuthgearError(message: "SDK supports only iOS 12.0 or newer", error: nil))
}
}
@@ -225,7 +231,7 @@ import Capacitor
if isCancel {
completion(nil)
} else {
- completion(NSError.makeError(message: "openURL failed", code: nil, error: error))
+ completion(NSError.makeUnrecoverableAuthgearError(message: "openURL failed", error: error))
}
} else {
completion(nil)
@@ -238,7 +244,89 @@ import Capacitor
}
asWebSession!.start()
} else {
- completion(NSError.makeError(message: "SDK supports only iOS 12.0 or newer", code: nil, error: nil))
+ completion(NSError.makeUnrecoverableAuthgearError(message: "SDK supports only iOS 12.0 or newer", error: nil))
+ }
+ }
+
+ @objc func checkBiometricSupported() throws {
+ if #available(iOS 11.3, *) {
+ let policy = LAPolicy.deviceOwnerAuthenticationWithBiometrics
+ let laContext = self.makeLAContext(policy: policy)
+ var error: NSError?
+ laContext.canEvaluatePolicy(policy, error: &error)
+ if let error = error {
+ throw error
+ }
+ } else {
+ throw NSError.makeUnrecoverableAuthgearError(message: "Biometric authentication requires at least iOS 11.3", error: nil)
+ }
+ }
+
+ @objc func createBiometricPrivateKey(
+ policy: LAPolicy,
+ localizedReason: String,
+ constraint: String,
+ kid: String,
+ tag: String,
+ payload: [String: Any],
+ completion: @escaping (String?, Error?) -> Void
+ ) {
+ let ctx = makeLAContext(policy: policy)
+ ctx.evaluatePolicy(policy, localizedReason: localizedReason) { ok, error in
+ if let error = error {
+ completion(nil, error)
+ return
+ }
+
+ do {
+ let privateKey = try self.generateAndAddBiometricPrivateKey(constraint: constraint, tag: tag, laContext: ctx)
+ let jwt = try self.signBiometricJWT(privateKey: privateKey, kid: kid, payload: payload)
+ completion(jwt, nil)
+ return
+ } catch {
+ completion(nil, error)
+ return
+ }
+ }
+ }
+
+ @objc func signWithBiometricPrivateKey(
+ policyString: String,
+ localizedReason: String,
+ kid: String,
+ tag: String,
+ payload: [String: Any],
+ completion: @escaping (String?, Error?) -> Void
+ ) {
+ let policy = LAPolicy.from(string: policyString)!
+ let ctx = makeLAContext(policy: policy)
+ ctx.evaluatePolicy(policy, localizedReason: localizedReason) { ok, error in
+ if let error = error {
+ completion(nil, error)
+ return
+ }
+
+ do {
+ let privateKey = try self.getBiometricPrivateKey(tag: tag, laContext: ctx)
+ let jwt = try self.signBiometricJWT(privateKey: privateKey, kid: kid, payload: payload)
+ completion(jwt, nil)
+ return
+ } catch {
+ completion(nil, error)
+ return
+ }
+ }
+ }
+
+ @objc func removeBiometricPrivateKey(tag: String) throws {
+ let query: NSDictionary = [
+ kSecClass: kSecClassKey,
+ // Do not specify the key type because it can be either RSA or EC.
+ kSecAttrApplicationTag: tag
+ ]
+ let status = SecItemDelete(query)
+ guard status == errSecSuccess || status == errSecItemNotFound else {
+ throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
}
}
@@ -246,6 +334,204 @@ import Capacitor
let window = self.asWebAuthenticationSessionHandles[session]!
return window
}
+
+ private func makeLAContext(policy: LAPolicy) -> LAContext {
+ let ctx = LAContext()
+ if policy == LAPolicy.deviceOwnerAuthenticationWithBiometrics {
+ ctx.localizedFallbackTitle = "";
+ }
+ return ctx
+ }
+
+ private func generateAndAddBiometricPrivateKey(constraint: String, tag: String, laContext: LAContext) throws -> SecKey {
+ var cfError: Unmanaged?
+
+ var flags: SecAccessControlCreateFlags = []
+ // https://developer.apple.com/documentation/security/certificate_key_and_trust_services/keys/protecting_keys_with_the_secure_enclave
+ flags.insert(.privateKeyUsage)
+
+ #if targetEnvironment(simulator)
+ // On Xcode 15.2, iPhone 15 iOS 17.2, using any of these flags will result in
+ // NSOSStatusErrorDomain code=-25293 message="Key generation failed"
+ #else
+ switch constraint {
+ case "biometryAny":
+ flags.insert(.biometryAny)
+ case "biometryCurrentSet":
+ flags.insert(.biometryCurrentSet)
+ case "userPresence":
+ flags.insert(.userPresence)
+ default:
+ break
+ }
+ #endif
+
+ guard let accessControl = SecAccessControlCreateWithFlags(
+ kCFAllocatorDefault,
+ kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
+ flags,
+ &cfError
+ ) else {
+ throw cfError!.takeRetainedValue() as Error
+ }
+
+ let attributes: NSDictionary = [
+ kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom,
+ kSecAttrKeySizeInBits: 256,
+ kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
+ kSecPrivateKeyAttrs: [
+ kSecClass: kSecClassKey,
+ kSecAttrIsPermanent: true,
+ kSecAttrApplicationTag: tag,
+ kSecAttrAccessControl: accessControl,
+ kSecUseAuthenticationContext: laContext
+ ]
+ ]
+
+ guard let privateKey = SecKeyCreateRandomKey(attributes, &cfError) else {
+ throw cfError!.takeRetainedValue() as Error
+ }
+
+ return privateKey
+ }
+
+ private func getBiometricPrivateKey(tag: String, laContext: LAContext) throws -> SecKey {
+ let query: NSDictionary = [
+ kSecClass: kSecClassKey,
+ kSecMatchLimit: kSecMatchLimitOne,
+ // Do not specify the key type because it can be either RSA or EC.
+ kSecAttrApplicationTag: tag,
+ kSecUseAuthenticationContext: laContext,
+ kSecReturnRef: true
+ ]
+
+ var item: CFTypeRef?
+ let status = SecItemCopyMatching(query, &item)
+
+ guard status == errSecSuccess else {
+ throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
+ }
+
+ let privateKey = item as! SecKey
+ return privateKey
+ }
+
+ private func signBiometricJWT(privateKey: SecKey, kid: String, payload: [String: Any]) throws -> String {
+ let jwk = try self.getJWKFromPrivateKey(privateKey: privateKey, kid: kid)
+ let header = [
+ "typ": "vnd.authgear.biometric-request",
+ "kid": jwk["kid"],
+ "alg": jwk["alg"],
+ "jwk": jwk
+ ]
+ let jwt = try self.signJWT(privateKey: privateKey, header: header as [String: Any], payload: payload)
+ return jwt
+ }
+
+ private func getJWKFromPrivateKey(privateKey: SecKey, kid: String) throws -> [String: Any] {
+ var cfError: Unmanaged?
+
+ let publicKey = SecKeyCopyPublicKey(privateKey)!
+ guard let cfData = SecKeyCopyExternalRepresentation(publicKey, &cfError) else {
+ throw cfError!.takeRetainedValue() as Error
+ }
+
+ let data = cfData as Data
+
+ switch KeyType.from(privateKey)! {
+ case .rsa:
+ return getJWKFromRSA(kid: kid, data: data)
+ case .ec:
+ return try getJWKFromEC(kid: kid, data: data)
+ }
+ }
+
+ private func getJWKFromRSA(kid: String, data: Data) -> [String: Any] {
+ let n = data.subdata(in: Range(NSRange(location: data.count > 269 ? 9 : 8, length: 256))!)
+ let e = data.subdata(in: Range(NSRange(location: data.count - 3, length: 3))!)
+
+ return [
+ "kid": kid,
+ "kty": "RSA",
+ "alg": "RS256",
+ "n": n.base64urlEncodedString(),
+ "e": e.base64urlEncodedString(),
+ ]
+ }
+
+ private func getJWKFromEC(kid: String, data: Data) throws -> [String: Any] {
+ var publicKeyBytes = [UInt8](data)
+
+ guard publicKeyBytes.removeFirst() == 0x04 else {
+ throw NSError.makeUnrecoverableAuthgearError(message: "unexpected ec public key format", error: nil)
+ }
+
+ let coordinateOctetLength = 32
+
+ let xBytes = publicKeyBytes[0.. String {
+ let headerJSON = try JSONSerialization.data(withJSONObject: header)
+ let payloadJSON = try JSONSerialization.data(withJSONObject: payload)
+ let headerString = headerJSON.base64urlEncodedString()
+ let payloadString = payloadJSON.base64urlEncodedString()
+ let stringToSign = "\(headerString).\(payloadString)"
+ let dataToSign = stringToSign.data(using: .utf8)!
+ let signature = try self.signData(privateKey: privateKey, data: dataToSign)
+ let signatureString = signature.base64urlEncodedString()
+ return "\(stringToSign).\(signatureString)"
+ }
+
+ private func signData(privateKey: SecKey, data: Data) throws -> Data {
+ switch KeyType.from(privateKey)! {
+ case .rsa:
+ return try signRSA(privateKey: privateKey, data: data)
+ case .ec:
+ return try signEC(privateKey: privateKey, data: data)
+ }
+ }
+
+ private func signRSA(privateKey: SecKey, data: Data) throws -> Data {
+ var cfError: Unmanaged?
+ guard let signature = SecKeyCreateSignature(privateKey, .rsaSignatureMessagePKCS1v15SHA256, data as CFData, &cfError) else {
+ throw cfError!.takeRetainedValue() as Error
+ }
+ return signature as Data
+ }
+
+ private func signEC(privateKey: SecKey, data: Data) throws -> Data {
+ var cfError: Unmanaged?
+ guard let signature = SecKeyCreateSignature(privateKey, .ecdsaSignatureMessageX962SHA256, data as CFData, &cfError) else {
+ throw cfError!.takeRetainedValue() as Error
+ }
+
+ // Convert the signature to correct format
+ // See https://github.com/airsidemobile/JOSESwift/blob/2.4.0/JOSESwift/Sources/CryptoImplementation/EC.swift#L208
+ let coordinateOctetLength = 32
+
+ let ecSignatureTLV = [UInt8](signature as Data)
+ let ecSignature = try ecSignatureTLV.read(.sequence)
+ let varlenR = try Data(ecSignature.read(.integer))
+ let varlenS = try Data(ecSignature.skip(.integer).read(.integer))
+ let fixlenR = Asn1IntegerConversion.toRaw(varlenR, of: coordinateOctetLength)
+ let fixlenS = Asn1IntegerConversion.toRaw(varlenS, of: coordinateOctetLength)
+
+ let fixedSignature = (fixlenR + fixlenS)
+ return fixedSignature
+ }
}
private extension UIUserInterfaceIdiom {
@@ -272,13 +558,10 @@ private extension UIUserInterfaceIdiom {
extension NSError {
static let AuthgearDomain = "Authgear"
- static func makeError(message: String, code: String?, error: Error?) -> NSError {
+ static func makeUnrecoverableAuthgearError(message: String, error: Error?) -> NSError {
var userInfo: [String: Any] = [
NSLocalizedDescriptionKey: message
]
- if let code = code {
- userInfo["code"] = code
- }
if let error = error {
userInfo[NSUnderlyingErrorKey] = error
}
@@ -286,31 +569,37 @@ extension NSError {
}
static func makeCancel(error: Error?) -> NSError {
- return makeError(message: "CANCEL", code: "CANCEL", error: error)
+ var userInfo: [String: Any] = [
+ NSLocalizedDescriptionKey: "CANCEL"
+ ]
+ userInfo["code"] = "CANCEL"
+ if let error = error {
+ userInfo[NSUnderlyingErrorKey] = error
+ }
+ return NSError(domain: AuthgearDomain, code: 0, userInfo: userInfo)
}
- var capacitorCode: String? {
+ var capacitorMessage: String {
get {
- return self.userInfo["code"] as? String
+ return self.localizedDescription
}
}
- var capacitorUnderlyingError: Error? {
+ var capacitorCode: String? {
get {
- return self.userInfo[NSUnderlyingErrorKey] as? Error
+ return self.userInfo["code"] as? String
}
}
- var capacitorMessage: String {
+ var capacitorUnderlyingError: Error? {
get {
- return self.localizedDescription
+ return self.userInfo[NSUnderlyingErrorKey] as? Error
}
}
var capacitorData: [String: Any]? {
get {
- let underlying = self.capacitorUnderlyingError
- if let underlying = underlying {
+ if let underlying = self.capacitorUnderlyingError {
let nsError = underlying as NSError
let domain = nsError.domain
let code = nsError.code
@@ -322,8 +611,19 @@ extension NSError {
"userInfo": userInfo
]
]
+ } else {
+ let nsError = self
+ let domain = nsError.domain
+ let code = nsError.code
+ let userInfo = nsError.userInfo
+ return [
+ "cause": [
+ "domain": domain,
+ "code": code,
+ "userInfo": userInfo
+ ]
+ ]
}
- return nil
}
}
}
@@ -338,3 +638,47 @@ extension Error {
call.reject(message, code, underlyingError, data)
}
}
+
+private enum KeyType {
+ case rsa
+ case ec
+
+ static func from(_ privateKey: SecKey) -> KeyType? {
+ guard let attributes = SecKeyCopyAttributes(privateKey) as? [CFString: Any],
+ let keyType = attributes[kSecAttrKeyType] as? String else {
+ return nil
+ }
+
+ if (keyType == (kSecAttrKeyTypeECSECPrimeRandom as String)) {
+ return .ec
+ }
+
+ if (keyType == (kSecAttrKeyTypeRSA as String)) {
+ return .rsa
+ }
+
+ return nil
+ }
+}
+
+private extension Data {
+ func base64urlEncodedString() -> String {
+ base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+ }
+}
+
+private extension LAPolicy {
+ static func from(string: String) -> LAPolicy? {
+ switch string {
+ case "deviceOwnerAuthenticationWithBiometrics":
+ return .deviceOwnerAuthenticationWithBiometrics
+ case "deviceOwnerAuthentication":
+ return .deviceOwnerAuthentication
+ default:
+ return nil
+ }
+ }
+}
diff --git a/packages/authgear-capacitor/src/error.ts b/packages/authgear-capacitor/src/error.ts
index a7fc1978..ba6108d1 100644
--- a/packages/authgear-capacitor/src/error.ts
+++ b/packages/authgear-capacitor/src/error.ts
@@ -1,11 +1,52 @@
import { AuthgearError, CancelError } from "@authgear/core";
-type _ErrorIdentificationFunction = (e: unknown) => boolean;
+// iOS LocalAuthentication
+const kLAErrorDomain = "com.apple.LocalAuthentication";
+// const kLAErrorAuthenticationFailed = -1;
+const kLAErrorUserCancel = -2;
+// const kLAErrorUserFallback = -3;
+// const kLAErrorSystemCancel = -4;
+const kLAErrorPasscodeNotSet = -5;
+// const kLAErrorAppCancel = -9;
+// const kLAErrorInvalidContext = -10;
+// const kLAErrorWatchNotAvailable = -11;
+// const kLAErrorNotInteractive = -1004;
+const kLAErrorBiometryNotAvailable = -6;
+const kLAErrorBiometryNotEnrolled = -7;
+const kLAErrorBiometryLockout = -8;
-const _errorMappings: [_ErrorIdentificationFunction, typeof AuthgearError][] = [
- [_isCancel, CancelError],
-];
+// iOS Keychain
+const NSOSStatusErrorDomain = "NSOSStatusErrorDomain";
+const errSecUserCanceled = -128;
+//const errSecAuthFailed = -25293;
+const errSecItemNotFound = -25300;
+
+// Android BiometricManager.canAuthenticate
+const BIOMETRIC_ERROR_HW_UNAVAILABLE = "BIOMETRIC_ERROR_HW_UNAVAILABLE";
+const BIOMETRIC_ERROR_NONE_ENROLLED = "BIOMETRIC_ERROR_NONE_ENROLLED";
+const BIOMETRIC_ERROR_NO_HARDWARE = "BIOMETRIC_ERROR_NO_HARDWARE";
+const BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED =
+ "BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED";
+const BIOMETRIC_ERROR_UNSUPPORTED = "BIOMETRIC_ERROR_UNSUPPORTED";
+// const BIOMETRIC_STATUS_UNKNOWN = "BIOMETRIC_STATUS_UNKNOWN";
+// Android BiometricPrompt
+const ERROR_CANCELED = "ERROR_CANCELED";
+const ERROR_HW_NOT_PRESENT = "ERROR_HW_NOT_PRESENT";
+const ERROR_HW_UNAVAILABLE = "ERROR_HW_UNAVAILABLE";
+const ERROR_LOCKOUT = "ERROR_LOCKOUT";
+const ERROR_LOCKOUT_PERMANENT = "ERROR_LOCKOUT_PERMANENT";
+const ERROR_NEGATIVE_BUTTON = "ERROR_NEGATIVE_BUTTON";
+const ERROR_NO_BIOMETRICS = "ERROR_NO_BIOMETRICS";
+const ERROR_NO_DEVICE_CREDENTIAL = "ERROR_NO_DEVICE_CREDENTIAL";
+// const ERROR_NO_SPACE = "ERROR_NO_SPACE";
+const ERROR_SECURITY_UPDATE_REQUIRED = "ERROR_SECURITY_UPDATE_REQUIRED";
+// const ERROR_TIMEOUT = "ERROR_TIMEOUT";
+// const ERROR_UNABLE_TO_PROCESS = "ERROR_UNABLE_TO_PROCESS";
+const ERROR_USER_CANCELED = "ERROR_USER_CANCELED";
+// const ERROR_VENDOR = "ERROR_VENDOR";
+
+// on iOS
// {
// "errorMessage": "CANCEL",
// "message": "CANCEL",
@@ -18,13 +59,28 @@ const _errorMappings: [_ErrorIdentificationFunction, typeof AuthgearError][] = [
// },
// "code": "CANCEL"
// }
+//
+// on Android
+// {
+// "message": "CANCEL",
+// "code": "CANCEL",
+// "data": undefined
+// }
+
+/**
+ * @internal
+ */
export interface PlatformError {
message: string;
- errorMessage: string;
code: string;
- data?: {
- cause?: {
- // iOS
+}
+
+/**
+ * @internal
+ */
+export interface PlatformErrorIOSWithCause extends PlatformError {
+ data: {
+ cause: {
domain: string;
code: number;
userInfo?: unknown;
@@ -32,23 +88,83 @@ export interface PlatformError {
};
}
+/**
+ * @internal
+ */
export function isPlatformError(e: unknown): e is PlatformError {
+ return typeof e === "object" && e != null && "message" in e && "code" in e;
+}
+
+/**
+ * @internal
+ */
+export function isPlatformErrorIOS(e: unknown): e is PlatformErrorIOSWithCause {
return (
- typeof e === "object" &&
- e != null &&
- "message" in e &&
- "errorMessage" in e &&
- "code" in e
+ isPlatformError(e) &&
+ "data" in e &&
+ typeof (e as any).data === "object" &&
+ (e as any).data != null &&
+ "cause" in (e as any).data
);
}
-export function _isCancel(e: unknown): boolean {
- if (isPlatformError(e)) {
- return e.code === "CANCEL";
- }
- return false;
-}
+/**
+ * BiometricPrivateKeyNotFoundError means the biometric has changed so that
+ * the private key has been invalidated.
+ *
+ * @public
+ */
+export class BiometricPrivateKeyNotFoundError extends AuthgearError {}
+
+/**
+ * BiometricNotSupportedOrPermissionDeniedError means this device does not support biometric,
+ * or the user has denied the usage of biometric.
+ *
+ * @public
+ */
+export class BiometricNotSupportedOrPermissionDeniedError extends AuthgearError {}
+
+/**
+ * BiometricNoPasscodeError means the device does not have a passcode.
+ * You should prompt the user to setup a password for their device.
+ *
+ * @public
+ */
+export class BiometricNoPasscodeError extends AuthgearError {}
+
+/**
+ * BiometricNoEnrollmentError means the user has not setup biometric.
+ * You should prompt the user to do so.
+ *
+ * @public
+ */
+export class BiometricNoEnrollmentError extends AuthgearError {}
+
+/**
+ * BiometricLockoutError means the biometric is locked due to too many failed attempts.
+ *
+ * @public
+ */
+export class BiometricLockoutError extends AuthgearError {}
+
+type _ErrorIdentificationFunction = (e: unknown) => boolean;
+
+const _errorMappings: [_ErrorIdentificationFunction, typeof AuthgearError][] = [
+ [_isBiometricPrivateKeyNotFoundError, BiometricPrivateKeyNotFoundError],
+ [_isBiometricCancel, CancelError],
+ [
+ _isBiometricNotSupportedOrPermissionDeniedError,
+ BiometricNotSupportedOrPermissionDeniedError,
+ ],
+ [_isBiometricNoEnrollmentError, BiometricNoEnrollmentError],
+ [_isBiometricNoPasscodeError, BiometricNoPasscodeError],
+ [_isBiometricLockoutError, BiometricLockoutError],
+ [_isCancel, CancelError],
+];
+/**
+ * @internal
+ */
export function _wrapError(e: unknown): unknown {
for (const [f, cls] of _errorMappings) {
if (f(e)) {
@@ -61,3 +177,126 @@ export function _wrapError(e: unknown): unknown {
err.underlyingError = e;
return err;
}
+
+export function _isCancel(e: unknown): boolean {
+ if (isPlatformError(e)) {
+ return e.code === "CANCEL";
+ }
+ return false;
+}
+
+/**
+ * @internal
+ */
+export function _isBiometricPrivateKeyNotFoundError(e: unknown): boolean {
+ if (isPlatformErrorIOS(e)) {
+ return (
+ e.data.cause.domain === NSOSStatusErrorDomain &&
+ e.data.cause.code === errSecItemNotFound
+ );
+ }
+ if (isPlatformError(e)) {
+ return (
+ e.code === "android.security.keystore.KeyPermanentlyInvalidatedException"
+ );
+ }
+ return false;
+}
+
+/**
+ * @internal
+ */
+export function _isBiometricCancel(e: unknown): boolean {
+ if (isPlatformErrorIOS(e)) {
+ return (
+ (e.data.cause.domain === kLAErrorDomain &&
+ e.data.cause.code === kLAErrorUserCancel) ||
+ (e.data.cause.domain === NSOSStatusErrorDomain &&
+ e.data.cause.code === errSecUserCanceled)
+ );
+ }
+ if (isPlatformError(e)) {
+ return (
+ e.code === ERROR_CANCELED ||
+ e.code === ERROR_NEGATIVE_BUTTON ||
+ e.code === ERROR_USER_CANCELED
+ );
+ }
+ return false;
+}
+
+/**
+ * @internal
+ */
+export function _isBiometricNotSupportedOrPermissionDeniedError(
+ e: unknown
+): boolean {
+ if (isPlatformErrorIOS(e)) {
+ return (
+ e.data.cause.domain === kLAErrorDomain &&
+ e.data.cause.code === kLAErrorBiometryNotAvailable
+ );
+ }
+ if (isPlatformError(e)) {
+ return (
+ e.code === BIOMETRIC_ERROR_HW_UNAVAILABLE ||
+ e.code === BIOMETRIC_ERROR_NO_HARDWARE ||
+ e.code === BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED ||
+ e.code === BIOMETRIC_ERROR_UNSUPPORTED ||
+ e.code === ERROR_HW_NOT_PRESENT ||
+ e.code === ERROR_HW_UNAVAILABLE ||
+ e.code === ERROR_SECURITY_UPDATE_REQUIRED
+ );
+ }
+ return false;
+}
+
+/**
+ * @internal
+ */
+export function _isBiometricNoEnrollmentError(e: unknown): boolean {
+ if (isPlatformErrorIOS(e)) {
+ return (
+ e.data.cause.domain === kLAErrorDomain &&
+ e.data.cause.code === kLAErrorBiometryNotEnrolled
+ );
+ }
+ if (isPlatformError(e)) {
+ return (
+ e.code === BIOMETRIC_ERROR_NONE_ENROLLED || e.code === ERROR_NO_BIOMETRICS
+ );
+ }
+ return false;
+}
+
+/**
+ * @internal
+ */
+export function _isBiometricNoPasscodeError(e: unknown): boolean {
+ if (isPlatformErrorIOS(e)) {
+ return (
+ e.data.cause.domain === kLAErrorDomain &&
+ e.data.cause.code === kLAErrorPasscodeNotSet
+ );
+ }
+ if (isPlatformError(e)) {
+ return e.code === ERROR_NO_DEVICE_CREDENTIAL;
+ }
+ return false;
+}
+
+/**
+ * @internal
+ */
+export function _isBiometricLockoutError(e: unknown): boolean {
+ if (isPlatformErrorIOS(e)) {
+ return (
+ e.data.cause.domain === kLAErrorDomain &&
+ e.data.cause.code === kLAErrorBiometryLockout
+ );
+ }
+ if (isPlatformError(e)) {
+ return e.code === ERROR_LOCKOUT || e.code === ERROR_LOCKOUT_PERMANENT;
+ }
+ return false;
+}
diff --git a/packages/authgear-capacitor/src/index.ts b/packages/authgear-capacitor/src/index.ts
index 99009988..193c6788 100644
--- a/packages/authgear-capacitor/src/index.ts
+++ b/packages/authgear-capacitor/src/index.ts
@@ -4,6 +4,7 @@ import {
type TokenStorage,
type UserInfo,
AuthgearError,
+ OAuthError,
SessionState,
SessionStateChangeReason,
Page,
@@ -16,7 +17,16 @@ import {
} from "@authgear/core";
import { PersistentContainerStorage, PersistentTokenStorage } from "./storage";
import { generateCodeVerifier, computeCodeChallenge } from "./pkce";
-import { getDeviceInfo, openAuthorizeURL, openURL } from "./plugin";
+import {
+ generateUUID,
+ getDeviceInfo,
+ openAuthorizeURL,
+ openURL,
+ createBiometricPrivateKey,
+ checkBiometricSupported,
+ removeBiometricPrivateKey,
+ signWithBiometricPrivateKey,
+} from "./plugin";
import {
type CapacitorContainerDelegate,
type AuthenticateOptions,
@@ -24,12 +34,21 @@ import {
type ReauthenticateOptions,
type ReauthenticateResult,
type SettingOptions,
+ type BiometricOptions,
} from "./types";
+import { BiometricPrivateKeyNotFoundError } from "./error";
import { Capacitor } from "@capacitor/core";
export * from "@authgear/core";
export * from "./types";
export * from "./storage";
+export {
+ BiometricPrivateKeyNotFoundError,
+ BiometricNotSupportedOrPermissionDeniedError,
+ BiometricNoPasscodeError,
+ BiometricNoEnrollmentError,
+ BiometricLockoutError,
+} from "./error";
function getPlatform(): string {
const platform = Capacitor.getPlatform();
@@ -87,6 +106,14 @@ async function getXDeviceInfo(): Promise {
}
/**
+ * CapacitorContainer is the entrypoint of the SDK.
+ * An instance of a container allows the user to authenticate, reauthenticate, etc.
+ *
+ * Every container has a name.
+ * The default name of a container is `default`.
+ * If your app supports multi login sessions, you can use multiple containers with different names.
+ * You are responsible for managing the list of names in this case.
+ *
* @public
*/
export class CapacitorContainer {
@@ -376,7 +403,7 @@ export class CapacitorContainer {
x_device_info: xDeviceInfo,
}
);
- // await this.disableBiometric();
+ await this.disableBiometric();
return result;
}
@@ -388,13 +415,14 @@ export class CapacitorContainer {
* @public
*/
async reauthenticate(
- options: ReauthenticateOptions
+ options: ReauthenticateOptions,
+ biometricOptions?: BiometricOptions
): Promise {
// Use biometric to reauthenticate if the developer instructs us to do so.
- // const biometricEnabled = await this.isBiometricEnabled();
- // if (biometricEnabled && biometricOptions != null) {
- // return this.authenticateBiometric(biometricOptions);
- // }
+ const biometricEnabled = await this.isBiometricEnabled();
+ if (biometricEnabled && biometricOptions != null) {
+ return this.authenticateBiometric(biometricOptions);
+ }
const platform = getPlatform();
@@ -548,6 +576,127 @@ export class CapacitorContainer {
],
});
}
+
+ /**
+ * @public
+ */
+ // eslint-disable-next-line class-methods-use-this
+ async checkBiometricSupported(options: BiometricOptions): Promise {
+ await checkBiometricSupported(options);
+ }
+
+ /**
+ * @public
+ */
+ async isBiometricEnabled(): Promise {
+ const keyID = await this.storage.getBiometricKeyID(this.name);
+ return keyID != null;
+ }
+
+ async disableBiometric(): Promise {
+ const keyID = await this.storage.getBiometricKeyID(this.name);
+ if (keyID != null) {
+ await removeBiometricPrivateKey(keyID);
+ await this.storage.delBiometricKeyID(this.name);
+ }
+ }
+
+ async enableBiometric(options: BiometricOptions): Promise {
+ const clientID = this.clientID;
+ if (clientID == null) {
+ throw new AuthgearError("missing client ID");
+ }
+ await this.refreshAccessTokenIfNeeded();
+ const accessToken = this.accessToken;
+ if (accessToken == null) {
+ throw new AuthgearError("enableBiometric requires authenticated user");
+ }
+
+ const kid = await generateUUID();
+ const deviceInfo = await getDeviceInfo();
+ const { token } = await this.baseContainer.apiClient.oauthChallenge(
+ "biometric_request"
+ );
+ const now = Math.floor(+new Date() / 1000);
+ const payload = {
+ iat: now,
+ exp: now + 300,
+ challenge: token,
+ action: "setup",
+ device_info: deviceInfo,
+ };
+ const jwt = await createBiometricPrivateKey({
+ ...options,
+ kid,
+ payload,
+ });
+ await this.baseContainer.apiClient._setupBiometricRequest({
+ access_token: accessToken,
+ client_id: clientID,
+ jwt,
+ });
+ await this.storage.setBiometricKeyID(this.name, kid);
+ }
+
+ async authenticateBiometric(
+ options: BiometricOptions
+ ): Promise {
+ const kid = await this.storage.getBiometricKeyID(this.name);
+ if (kid == null) {
+ throw new AuthgearError("biometric key ID not found");
+ }
+ const clientID = this.clientID;
+ if (clientID == null) {
+ throw new AuthgearError("missing client ID");
+ }
+ const deviceInfo = await getDeviceInfo();
+ const { token } = await this.baseContainer.apiClient.oauthChallenge(
+ "biometric_request"
+ );
+ const now = Math.floor(+new Date() / 1000);
+ const payload = {
+ iat: now,
+ exp: now + 300,
+ challenge: token,
+ action: "authenticate",
+ device_info: deviceInfo,
+ };
+
+ try {
+ const jwt = await signWithBiometricPrivateKey({
+ ...options,
+ kid,
+ payload,
+ });
+ const tokenResponse =
+ await this.baseContainer.apiClient._oidcTokenRequest({
+ grant_type: "urn:authgear:params:oauth:grant-type:biometric-request",
+ client_id: clientID,
+ jwt,
+ });
+
+ const userInfo = await this.baseContainer.apiClient._oidcUserInfoRequest(
+ tokenResponse.access_token
+ );
+ await this.baseContainer._persistTokenResponse(
+ tokenResponse,
+ SessionStateChangeReason.Authenticated
+ );
+ return { userInfo };
+ } catch (e: unknown) {
+ if (e instanceof BiometricPrivateKeyNotFoundError) {
+ await this.disableBiometric();
+ }
+ if (
+ e instanceof OAuthError &&
+ e.error === "invalid_grant" &&
+ e.error_description === "InvalidCredentials"
+ ) {
+ await this.disableBiometric();
+ }
+ throw e;
+ }
+ }
}
/**
diff --git a/packages/authgear-capacitor/src/plugin.ts b/packages/authgear-capacitor/src/plugin.ts
index 48db76f0..4361973c 100644
--- a/packages/authgear-capacitor/src/plugin.ts
+++ b/packages/authgear-capacitor/src/plugin.ts
@@ -1,4 +1,5 @@
import { registerPlugin } from "@capacitor/core";
+import { BiometricPrivateKeyOptions, BiometricOptions } from "./types";
import { _wrapError } from "./error";
export interface AuthgearPlugin {
@@ -8,12 +9,21 @@ export interface AuthgearPlugin {
randomBytes(options: { length: number }): Promise<{ bytes: number[] }>;
sha256String(options: { input: string }): Promise<{ bytes: number[] }>;
getDeviceInfo(): Promise<{ deviceInfo: unknown }>;
+ generateUUID(): Promise<{ uuid: string }>;
openAuthorizeURL(options: {
url: string;
callbackURL: string;
prefersEphemeralWebBrowserSession: boolean;
}): Promise<{ redirectURI: string }>;
openURL(options: { url: string }): Promise;
+ checkBiometricSupported(options: BiometricOptions): Promise;
+ createBiometricPrivateKey(
+ options: BiometricPrivateKeyOptions
+ ): Promise<{ jwt: string }>;
+ signWithBiometricPrivateKey(
+ options: BiometricPrivateKeyOptions
+ ): Promise<{ jwt: string }>;
+ removeBiometricPrivateKey(options: { kid: string }): Promise;
}
const Authgear = registerPlugin("Authgear", {});
@@ -73,6 +83,15 @@ export async function getDeviceInfo(): Promise {
}
}
+export async function generateUUID(): Promise {
+ try {
+ const { uuid } = await Authgear.generateUUID();
+ return uuid;
+ } catch (e: unknown) {
+ throw _wrapError(e);
+ }
+}
+
export async function openAuthorizeURL(options: {
url: string;
callbackURL: string;
@@ -93,3 +112,43 @@ export async function openURL(options: { url: string }): Promise {
throw _wrapError(e);
}
}
+
+export async function checkBiometricSupported(
+ options: BiometricOptions
+): Promise {
+ try {
+ await Authgear.checkBiometricSupported(options);
+ } catch (e: unknown) {
+ throw _wrapError(e);
+ }
+}
+
+export async function createBiometricPrivateKey(
+ options: BiometricPrivateKeyOptions
+): Promise {
+ try {
+ const { jwt } = await Authgear.createBiometricPrivateKey(options);
+ return jwt;
+ } catch (e: unknown) {
+ throw _wrapError(e);
+ }
+}
+
+export async function signWithBiometricPrivateKey(
+ options: BiometricPrivateKeyOptions
+): Promise {
+ try {
+ const { jwt } = await Authgear.signWithBiometricPrivateKey(options);
+ return jwt;
+ } catch (e: unknown) {
+ throw _wrapError(e);
+ }
+}
+
+export async function removeBiometricPrivateKey(kid: string): Promise {
+ try {
+ await Authgear.removeBiometricPrivateKey({ kid });
+ } catch (e: unknown) {
+ throw _wrapError(e);
+ }
+}
diff --git a/packages/authgear-capacitor/src/storage.ts b/packages/authgear-capacitor/src/storage.ts
index 0e802e48..09720691 100644
--- a/packages/authgear-capacitor/src/storage.ts
+++ b/packages/authgear-capacitor/src/storage.ts
@@ -23,6 +23,12 @@ class _PlatformStorageDriver implements _StorageDriver {
}
/**
+ * PersistentTokenStorage stores the refresh token in a persistent storage.
+ * When the app launches again next time, the refresh token is loaded from the persistent storage.
+ * The user is considered authenticated as long as the refresh token is found.
+ * However, the validity of the refresh token is not guaranteed.
+ * You must call fetchUserInfo to ensure the refresh token is still valid.
+ *
* @public
*/
export class PersistentTokenStorage implements TokenStorage {
diff --git a/packages/authgear-capacitor/src/types.ts b/packages/authgear-capacitor/src/types.ts
index 10cf4a4e..61dd26cb 100644
--- a/packages/authgear-capacitor/src/types.ts
+++ b/packages/authgear-capacitor/src/types.ts
@@ -151,3 +151,166 @@ export interface SettingOptions {
*/
colorScheme?: ColorScheme;
}
+
+/**
+ * BiometricLAPolicy configures iOS specific behavior.
+ * It must be consistent with BiometricAccessConstraintIOS.
+ *
+ * @public
+ */
+export enum BiometricLAPolicy {
+ /**
+ * The biometric prompt only prompts for biometric. No fallback to device passcode.
+ *
+ * See https://developer.apple.com/documentation/localauthentication/lapolicy/deviceownerauthenticationwithbiometrics
+ *
+ * @public
+ */
+ deviceOwnerAuthenticationWithBiometrics = "deviceOwnerAuthenticationWithBiometrics",
+ /**
+ * The biometric prompt prompts for biometric first, and then fallback to device passcode.
+ *
+ * See https://developer.apple.com/documentation/localauthentication/lapolicy/deviceownerauthentication
+ *
+ * @public
+ */
+ deviceOwnerAuthentication = "deviceOwnerAuthentication",
+}
+
+/**
+ * BiometricAccessConstraintIOS configures iOS specific behavior.
+ * It must be consistent with BiometricLAPolicy.
+ *
+ * @public
+ */
+export enum BiometricAccessConstraintIOS {
+ /**
+ * The user does not need to set up biometric again when a new finger or face is added or removed.
+ *
+ * See https://developer.apple.com/documentation/security/secaccesscontrolcreateflags/2937191-biometryany
+ *
+ * @public
+ */
+ BiometricAny = "biometryAny",
+ /**
+ * The user needs to set up biometric again when a new finger or face is added or removed.
+ *
+ * See https://developer.apple.com/documentation/security/secaccesscontrolcreateflags/2937192-biometrycurrentset
+ *
+ * @public
+ */
+ BiometryCurrentSet = "biometryCurrentSet",
+ /**
+ * The user can either use biometric or device code to authenticate.
+ *
+ * See https://developer.apple.com/documentation/security/secaccesscontrolcreateflags/1392879-userpresence
+ *
+ * @public
+ */
+ UserPresence = "userPresence",
+}
+
+/**
+ * iOS specific options for biometric authentication.
+ *
+ * @public
+ */
+export interface BiometricOptionsIOS {
+ /**
+ * See https://developer.apple.com/documentation/localauthentication/lacontext/1514176-evaluatepolicy#parameters
+ *
+ * @public
+ */
+ localizedReason: string;
+ constraint: BiometricAccessConstraintIOS;
+ policy: BiometricLAPolicy;
+}
+
+/**
+ * See https://developer.android.com/reference/androidx/biometric/BiometricManager.Authenticators
+ *
+ * @public
+ */
+export enum BiometricAccessConstraintAndroid {
+ /**
+ * The user can use Class 3 biometric to authenticate.
+ *
+ * See https://developer.android.com/reference/androidx/biometric/BiometricManager.Authenticators#BIOMETRIC_STRONG()
+ *
+ * @public
+ */
+ BiometricStrong = "BIOMETRIC_STRONG",
+ /**
+ * The user can either use biometric or device code to authenticate.
+ *
+ * See https://developer.android.com/reference/androidx/biometric/BiometricManager.Authenticators#DEVICE_CREDENTIAL()
+ *
+ * @public
+ */
+ DeviceCredential = "DEVICE_CREDENTIAL",
+}
+
+/**
+ * Android specific options for biometric authentication.
+ *
+ * @public
+ */
+export interface BiometricOptionsAndroid {
+ /**
+ * See https://developer.android.com/reference/androidx/biometric/BiometricPrompt.PromptInfo#getTitle()
+ *
+ * @public
+ */
+ title: string;
+ /**
+ * See https://developer.android.com/reference/androidx/biometric/BiometricPrompt.PromptInfo#getSubtitle()
+ *
+ * @public
+ */
+ subtitle: string;
+ /**
+ * See https://developer.android.com/reference/androidx/biometric/BiometricPrompt.PromptInfo#getDescription()
+ *
+ * @public
+ */
+ description: string;
+ /**
+ * https://developer.android.com/reference/androidx/biometric/BiometricPrompt.PromptInfo#getNegativeButtonText()
+ *
+ * @public
+ */
+ negativeButtonText: string;
+ constraint: BiometricAccessConstraintAndroid[];
+ /**
+ * The user needs to set up biometric again when a new biometric is enrolled or all enrolled biometrics are removed.
+ *
+ * See https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec#isInvalidatedByBiometricEnrollment()
+ *
+ * @public
+ */
+ invalidatedByBiometricEnrollment: boolean;
+}
+
+/**
+ * BiometricOptions is options for biometric authentication.
+ *
+ * @public
+ */
+export interface BiometricOptions {
+ ios: BiometricOptionsIOS;
+ android: BiometricOptionsAndroid;
+}
+
+/**
+ * @internal
+ */
+export interface BiometricPrivateKeyOptions extends BiometricOptions {
+ kid: string;
+ payload: {
+ iat: number;
+ exp: number;
+ challenge: string;
+ action: string;
+ device_info: unknown;
+ };
+}
diff --git a/packages/authgear-core/src/storage.ts b/packages/authgear-core/src/storage.ts
index 829f67f8..4aa0e2e8 100644
--- a/packages/authgear-core/src/storage.ts
+++ b/packages/authgear-core/src/storage.ts
@@ -83,6 +83,13 @@ export class _MemoryStorageDriver implements _StorageDriver {
}
/**
+ * TransientTokenStorage stores the refresh token in memory.
+ * The refresh token is forgotten as soon as the user quits the app, or
+ * the app was killed by the system.
+ * When the app launches again next time, no refresh token is found.
+ * The user is considered unauthenticated.
+ * This implies the user needs to authenticate over again on every app launch.
+ *
* @public
*/
export class TransientTokenStorage implements TokenStorage {
diff --git a/packages/authgear-core/src/types.ts b/packages/authgear-core/src/types.ts
index b53e94a8..05aa569a 100644
--- a/packages/authgear-core/src/types.ts
+++ b/packages/authgear-core/src/types.ts
@@ -1,4 +1,8 @@
/**
+ * UserInfo is the result of fetchUserInfo.
+ * It contains `sub` which is the User ID,
+ * as well OIDC standard claims like `email`.
+ *
* @public
*/
export interface UserInfo {
@@ -38,6 +42,9 @@ export interface UserInfo {
}
/**
+ * ColorScheme represents the color scheme supported by Authgear.
+ * A colorscheme is either light or dark. Authgear supports both by default.
+ *
* @public
*/
export enum ColorScheme {
@@ -186,6 +193,10 @@ export interface _AnonymousUserPromotionCodeResponse {
}
/**
+ * TokenStorage is an interface controlling when refresh tokens are stored.
+ * Normally you do not need to implement this interface.
+ * You can use one of those implementations provided by the SDK.
+ *
* @public
*/
export interface TokenStorage {
@@ -221,6 +232,8 @@ export interface _StorageDriver {
}
/**
+ * Options for the constructor of a Container.
+ *
* @public
*/
export interface ContainerOptions {
diff --git a/packages/authgear-react-native/src/error.ts b/packages/authgear-react-native/src/error.ts
index c73dd9e3..cf138ba8 100644
--- a/packages/authgear-react-native/src/error.ts
+++ b/packages/authgear-react-native/src/error.ts
@@ -11,50 +11,50 @@ import { AuthgearError, CancelError } from "@authgear/core";
// }
// iOS LocalAuthentication
-export const kLAErrorDomain = "com.apple.LocalAuthentication";
-// export const kLAErrorAuthenticationFailed = "-1";
-export const kLAErrorUserCancel = "-2";
-// export const kLAErrorUserFallback = "-3";
-// export const kLAErrorSystemCancel = "-4";
-export const kLAErrorPasscodeNotSet = "-5";
-// export const kLAErrorAppCancel = "-9";
-// export const kLAErrorInvalidContext = "-10";
-// export const kLAErrorWatchNotAvailable = "-11";
-// export const kLAErrorNotInteractive = "-1004";
-export const kLAErrorBiometryNotAvailable = "-6";
-export const kLAErrorBiometryNotEnrolled = "-7";
-export const kLAErrorBiometryLockout = "-8";
+const kLAErrorDomain = "com.apple.LocalAuthentication";
+// const kLAErrorAuthenticationFailed = "-1";
+const kLAErrorUserCancel = "-2";
+// const kLAErrorUserFallback = "-3";
+// const kLAErrorSystemCancel = "-4";
+const kLAErrorPasscodeNotSet = "-5";
+// const kLAErrorAppCancel = "-9";
+// const kLAErrorInvalidContext = "-10";
+// const kLAErrorWatchNotAvailable = "-11";
+// const kLAErrorNotInteractive = "-1004";
+const kLAErrorBiometryNotAvailable = "-6";
+const kLAErrorBiometryNotEnrolled = "-7";
+const kLAErrorBiometryLockout = "-8";
// iOS Keychain
-export const NSOSStatusErrorDomain = "NSOSStatusErrorDomain";
-export const errSecUserCanceled = "-128";
-export const errSecAuthFailed = "-25293";
-export const errSecItemNotFound = "-25300";
+const NSOSStatusErrorDomain = "NSOSStatusErrorDomain";
+const errSecUserCanceled = "-128";
+// const errSecAuthFailed = "-25293";
+const errSecItemNotFound = "-25300";
// Android BiometricManager.canAuthenticate
-export const BIOMETRIC_ERROR_HW_UNAVAILABLE = "BIOMETRIC_ERROR_HW_UNAVAILABLE";
-export const BIOMETRIC_ERROR_NONE_ENROLLED = "BIOMETRIC_ERROR_NONE_ENROLLED";
-export const BIOMETRIC_ERROR_NO_HARDWARE = "BIOMETRIC_ERROR_NO_HARDWARE";
-export const BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED =
+const BIOMETRIC_ERROR_HW_UNAVAILABLE = "BIOMETRIC_ERROR_HW_UNAVAILABLE";
+const BIOMETRIC_ERROR_NONE_ENROLLED = "BIOMETRIC_ERROR_NONE_ENROLLED";
+const BIOMETRIC_ERROR_NO_HARDWARE = "BIOMETRIC_ERROR_NO_HARDWARE";
+const BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED =
"BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED";
-export const BIOMETRIC_ERROR_UNSUPPORTED = "BIOMETRIC_ERROR_UNSUPPORTED";
-// export const BIOMETRIC_STATUS_UNKNOWN = "BIOMETRIC_STATUS_UNKNOWN";
+const BIOMETRIC_ERROR_UNSUPPORTED = "BIOMETRIC_ERROR_UNSUPPORTED";
+// const BIOMETRIC_STATUS_UNKNOWN = "BIOMETRIC_STATUS_UNKNOWN";
// Android BiometricPrompt
-export const ERROR_CANCELED = "ERROR_CANCELED";
-export const ERROR_HW_NOT_PRESENT = "ERROR_HW_NOT_PRESENT";
-export const ERROR_HW_UNAVAILABLE = "ERROR_HW_UNAVAILABLE";
-export const ERROR_LOCKOUT = "ERROR_LOCKOUT";
-export const ERROR_LOCKOUT_PERMANENT = "ERROR_LOCKOUT_PERMANENT";
-export const ERROR_NEGATIVE_BUTTON = "ERROR_NEGATIVE_BUTTON";
-export const ERROR_NO_BIOMETRICS = "ERROR_NO_BIOMETRICS";
-export const ERROR_NO_DEVICE_CREDENTIAL = "ERROR_NO_DEVICE_CREDENTIAL";
-// export const ERROR_NO_SPACE = "ERROR_NO_SPACE";
-export const ERROR_SECURITY_UPDATE_REQUIRED = "ERROR_SECURITY_UPDATE_REQUIRED";
-// export const ERROR_TIMEOUT = "ERROR_TIMEOUT";
-// export const ERROR_UNABLE_TO_PROCESS = "ERROR_UNABLE_TO_PROCESS";
-export const ERROR_USER_CANCELED = "ERROR_USER_CANCELED";
-// export const ERROR_VENDOR = "ERROR_VENDOR";
+const ERROR_CANCELED = "ERROR_CANCELED";
+const ERROR_HW_NOT_PRESENT = "ERROR_HW_NOT_PRESENT";
+const ERROR_HW_UNAVAILABLE = "ERROR_HW_UNAVAILABLE";
+const ERROR_LOCKOUT = "ERROR_LOCKOUT";
+const ERROR_LOCKOUT_PERMANENT = "ERROR_LOCKOUT_PERMANENT";
+const ERROR_NEGATIVE_BUTTON = "ERROR_NEGATIVE_BUTTON";
+const ERROR_NO_BIOMETRICS = "ERROR_NO_BIOMETRICS";
+const ERROR_NO_DEVICE_CREDENTIAL = "ERROR_NO_DEVICE_CREDENTIAL";
+// const ERROR_NO_SPACE = "ERROR_NO_SPACE";
+const ERROR_SECURITY_UPDATE_REQUIRED = "ERROR_SECURITY_UPDATE_REQUIRED";
+// const ERROR_TIMEOUT = "ERROR_TIMEOUT";
+// const ERROR_UNABLE_TO_PROCESS = "ERROR_UNABLE_TO_PROCESS";
+const ERROR_USER_CANCELED = "ERROR_USER_CANCELED";
+// const ERROR_VENDOR = "ERROR_VENDOR";
export interface PlatformErrorIOS {
code: string;
diff --git a/packages/authgear-react-native/src/index.ts b/packages/authgear-react-native/src/index.ts
index 7f7063ce..6d465173 100644
--- a/packages/authgear-react-native/src/index.ts
+++ b/packages/authgear-react-native/src/index.ts
@@ -97,7 +97,13 @@ async function getXDeviceInfo(): Promise {
}
/**
- * React Native Container.
+ * ReactNativeContainer is the entrypoint of the SDK.
+ * An instance of a container allows the user to authenticate, reauthenticate, etc.
+ *
+ * Every container has a name.
+ * The default name of a container is `default`.
+ * If your app supports multi login sessions, you can use multiple containers with different names.
+ * You are responsible for managing the list of names in this case.
*
* @public
*/
diff --git a/packages/authgear-react-native/src/storage.ts b/packages/authgear-react-native/src/storage.ts
index 5aff1daa..e4a0b615 100644
--- a/packages/authgear-react-native/src/storage.ts
+++ b/packages/authgear-react-native/src/storage.ts
@@ -27,6 +27,12 @@ class _PlatformStorageDriver implements _StorageDriver {
}
/**
+ * PersistentTokenStorage stores the refresh token in a persistent storage.
+ * When the app launches again next time, the refresh token is loaded from the persistent storage.
+ * The user is considered authenticated as long as the refresh token is found.
+ * However, the validity of the refresh token is not guaranteed.
+ * You must call fetchUserInfo to ensure the refresh token is still valid.
+ *
* @public
*/
export class PersistentTokenStorage implements TokenStorage {
diff --git a/packages/authgear-react-native/src/types.ts b/packages/authgear-react-native/src/types.ts
index 4081dea8..93234079 100644
--- a/packages/authgear-react-native/src/types.ts
+++ b/packages/authgear-react-native/src/types.ts
@@ -201,52 +201,147 @@ export interface SettingOptions {
}
/**
+ * BiometricLAPolicy configures iOS specific behavior.
+ * It must be consistent with BiometricAccessConstraintIOS.
+ *
* @public
*/
export enum BiometricLAPolicy {
+ /**
+ * The biometric prompt only prompts for biometric. No fallback to device passcode.
+ *
+ * See https://developer.apple.com/documentation/localauthentication/lapolicy/deviceownerauthenticationwithbiometrics
+ *
+ * @public
+ */
deviceOwnerAuthenticationWithBiometrics = "deviceOwnerAuthenticationWithBiometrics",
+ /**
+ * The biometric prompt prompts for biometric first, and then fallback to device passcode.
+ *
+ * See https://developer.apple.com/documentation/localauthentication/lapolicy/deviceownerauthentication
+ *
+ * @public
+ */
deviceOwnerAuthentication = "deviceOwnerAuthentication",
}
/**
+ * BiometricAccessConstraintIOS configures iOS specific behavior.
+ * It must be consistent with BiometricLAPolicy.
+ *
* @public
*/
export enum BiometricAccessConstraintIOS {
+ /**
+ * The user does not need to set up biometric again when a new finger or face is added or removed.
+ *
+ * See https://developer.apple.com/documentation/security/secaccesscontrolcreateflags/2937191-biometryany
+ *
+ * @public
+ */
BiometricAny = "biometryAny",
+ /**
+ * The user needs to set up biometric again when a new finger or face is added or removed.
+ *
+ * See https://developer.apple.com/documentation/security/secaccesscontrolcreateflags/2937192-biometrycurrentset
+ *
+ * @public
+ */
BiometryCurrentSet = "biometryCurrentSet",
+ /**
+ * The user can either use biometric or device code to authenticate.
+ *
+ * See https://developer.apple.com/documentation/security/secaccesscontrolcreateflags/1392879-userpresence
+ *
+ * @public
+ */
UserPresence = "userPresence",
}
/**
+ * iOS specific options for biometric authentication.
+ *
* @public
*/
export interface BiometricOptionsIOS {
+ /**
+ * See https://developer.apple.com/documentation/localauthentication/lacontext/1514176-evaluatepolicy#parameters
+ *
+ * @public
+ */
localizedReason: string;
constraint: BiometricAccessConstraintIOS;
policy: BiometricLAPolicy;
}
/**
+ * See https://developer.android.com/reference/androidx/biometric/BiometricManager.Authenticators
+ *
* @public
*/
export enum BiometricAccessConstraintAndroid {
+ /**
+ * The user can use Class 3 biometric to authenticate.
+ *
+ * See https://developer.android.com/reference/androidx/biometric/BiometricManager.Authenticators#BIOMETRIC_STRONG()
+ *
+ * @public
+ */
BiometricStrong = "BIOMETRIC_STRONG",
+ /**
+ * The user can either use biometric or device code to authenticate.
+ *
+ * See https://developer.android.com/reference/androidx/biometric/BiometricManager.Authenticators#DEVICE_CREDENTIAL()
+ *
+ * @public
+ */
DeviceCredential = "DEVICE_CREDENTIAL",
}
/**
+ * Android specific options for biometric authentication.
+ *
* @public
*/
export interface BiometricOptionsAndroid {
+ /**
+ * See https://developer.android.com/reference/androidx/biometric/BiometricPrompt.PromptInfo#getTitle()
+ *
+ * @public
+ */
title: string;
+ /**
+ * See https://developer.android.com/reference/androidx/biometric/BiometricPrompt.PromptInfo#getSubtitle()
+ *
+ * @public
+ */
subtitle: string;
+ /**
+ * See https://developer.android.com/reference/androidx/biometric/BiometricPrompt.PromptInfo#getDescription()
+ *
+ * @public
+ */
description: string;
+ /**
+ * https://developer.android.com/reference/androidx/biometric/BiometricPrompt.PromptInfo#getNegativeButtonText()
+ *
+ * @public
+ */
negativeButtonText: string;
constraint: BiometricAccessConstraintAndroid[];
+ /**
+ * The user needs to set up biometric again when a new biometric is enrolled or all enrolled biometrics are removed.
+ *
+ * See https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec#isInvalidatedByBiometricEnrollment()
+ *
+ * @public
+ */
invalidatedByBiometricEnrollment: boolean;
}
/**
+ * BiometricOptions is options for biometric authentication.
+ *
* @public
*/
export interface BiometricOptions {
@@ -255,7 +350,7 @@ export interface BiometricOptions {
}
/**
- * @public
+ * @internal
*/
export interface BiometricPrivateKeyOptions extends BiometricOptions {
kid: string;
diff --git a/packages/authgear-web/src/container.ts b/packages/authgear-web/src/container.ts
index b9c9053a..d0ded7af 100644
--- a/packages/authgear-web/src/container.ts
+++ b/packages/authgear-web/src/container.ts
@@ -66,7 +66,13 @@ export interface ConfigureOptions {
}
/**
- * Web Container
+ * WebContainer is the entrypoint of the SDK.
+ * An instance of a container allows the user to authenticate, reauthenticate, etc.
+ *
+ * Every container has a name.
+ * The default name of a container is `default`.
+ * If your app supports multi login sessions, you can use multiple containers with different names.
+ * You are responsible for managing the list of names in this case.
*
* @public
*/
diff --git a/packages/authgear-web/src/storage.ts b/packages/authgear-web/src/storage.ts
index 8d8fdff6..71f8f10b 100644
--- a/packages/authgear-web/src/storage.ts
+++ b/packages/authgear-web/src/storage.ts
@@ -20,7 +20,13 @@ const _localStorageStorageDriver: _StorageDriver = {
};
/**
- * @internal
+ * PersistentTokenStorage stores the refresh token in a persistent storage.
+ * When the app launches again next time, the refresh token is loaded from the persistent storage.
+ * The user is considered authenticated as long as the refresh token is found.
+ * However, the validity of the refresh token is not guaranteed.
+ * You must call fetchUserInfo to ensure the refresh token is still valid.
+ *
+ * @public
*/
export class PersistentTokenStorage implements TokenStorage {
private keyMaker: _KeyMaker;
diff --git a/typedoc/capacitor_index.md b/typedoc/capacitor_index.md
index 1a482ae8..3e0de660 100644
--- a/typedoc/capacitor_index.md
+++ b/typedoc/capacitor_index.md
@@ -9,6 +9,6 @@ See all the available methods in [CapacitorContainer Reference](./classes/Capaci
:::tip Just getting started?
-Check out our [Get Started guide](https://docs.authgear.com/get-started/capacitor) to see usage.
+Check out our [Get Started guide](https://docs.authgear.com/get-started/native-mobile-app/ionic-sdk) to see usage.
:::
diff --git a/typedoc/index.md b/typedoc/index.md
index 79a8c130..8555a9c6 100644
--- a/typedoc/index.md
+++ b/typedoc/index.md
@@ -14,3 +14,8 @@ Read the [Introduction](web/) to get started.
[@authgear/react-native](https://www.npmjs.com/package/@authgear/react-native) is the SDK you want to use if your application is written in React Native.
Read the [Introduction](react-native/) to get started.
+
+## Capacitor SDK
+
+[@authgear/capacitor](https://www.npmjs.com/package/@authgear/capacitor) is the SDK you want to use if your application is written in Ionic with Capacitor.
+Read the [Introduction](capacitor/) to get started.