@tokamakdev/plugin-local-authentication 0.1.0-beta.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -0
- package/android/LocalAuthenticationPlugin.kt +134 -0
- package/apple/LocalAuthenticationPlugin.swift +90 -0
- package/package.json +26 -0
- package/src/index.ts +28 -0
- package/tokamak-plugin.json +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# @tokamakdev/plugin-local-authentication
|
|
2
|
+
|
|
3
|
+
Device owner authentication for Tokamak applications.
|
|
4
|
+
|
|
5
|
+
Add it to your project's dependencies:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @tokamakdev/plugin-local-authentication
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { localAuthentication } from "@tokamakdev/plugin-local-authentication";
|
|
13
|
+
|
|
14
|
+
// "available" | "notEnrolled" | "unavailable"
|
|
15
|
+
const status = await localAuthentication.status("biometricsOrPasscode");
|
|
16
|
+
|
|
17
|
+
await localAuthentication.authenticate("biometricsOrPasscode", {
|
|
18
|
+
prompt: "Confirm it's you to link a new device",
|
|
19
|
+
});
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Both methods accept `biometricsOrPasscode` (Face ID, Touch ID or fingerprint,
|
|
23
|
+
falling back to the device passcode, PIN, pattern or password) or `biometrics`.
|
|
24
|
+
|
|
25
|
+
`status` reports whether the device can perform the authentication now.
|
|
26
|
+
`notEnrolled` means no biometric or passcode is set up.
|
|
27
|
+
|
|
28
|
+
`authenticate` resolves when the device owner authenticates. It rejects with
|
|
29
|
+
`NotAllowedError` when the user cancels or fails, `InvalidStateError` when the
|
|
30
|
+
authentication is not set up, and `NotSupportedError` when the device cannot
|
|
31
|
+
perform it.
|
|
32
|
+
|
|
33
|
+
`authenticate` checks the device owner when the app asks; it does not protect
|
|
34
|
+
any data. To bind a stored value to authentication, use
|
|
35
|
+
`@tokamakdev/plugin-secure-storage`.
|
|
36
|
+
|
|
37
|
+
## Platforms
|
|
38
|
+
|
|
39
|
+
- **iOS and macOS:** LocalAuthentication. On macOS, `biometricsOrPasscode`
|
|
40
|
+
accepts Touch ID or the account password.
|
|
41
|
+
- **Android:** the platform `BiometricPrompt`.
|
|
42
|
+
- **Web and Windows:** no implementation; calls throw `NotSupportedError`.
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
package com.tokamak.plugins.localauthentication
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.hardware.biometrics.BiometricManager
|
|
5
|
+
import android.hardware.biometrics.BiometricManager.Authenticators
|
|
6
|
+
import android.hardware.biometrics.BiometricPrompt
|
|
7
|
+
import android.os.CancellationSignal
|
|
8
|
+
import com.tokamak.runtime.TokamakPlugin
|
|
9
|
+
import com.tokamak.runtime.TokamakPluginError
|
|
10
|
+
import com.tokamak.runtime.TokamakPluginReply
|
|
11
|
+
import org.json.JSONObject
|
|
12
|
+
|
|
13
|
+
internal class TokamakLocalAuthenticationPlugin(
|
|
14
|
+
private val activity: Activity,
|
|
15
|
+
) : TokamakPlugin {
|
|
16
|
+
override val id = "local-authentication"
|
|
17
|
+
|
|
18
|
+
private val biometrics = activity.getSystemService(BiometricManager::class.java)
|
|
19
|
+
|
|
20
|
+
override fun call(method: String, arguments: Any?, reply: TokamakPluginReply) {
|
|
21
|
+
when (method) {
|
|
22
|
+
"status" -> respond(reply) { reply(Result.success(status(request(arguments)))) }
|
|
23
|
+
"authenticate" -> respond(reply) { authenticate(request(arguments), reply) }
|
|
24
|
+
else -> super.call(method, arguments, reply)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private fun respond(reply: TokamakPluginReply, operation: () -> Unit) {
|
|
29
|
+
runCatching(operation).onFailure { reply(Result.failure(pluginError(it))) }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private fun status(request: JSONObject): String =
|
|
33
|
+
when (biometrics.canAuthenticate(authenticators(request))) {
|
|
34
|
+
BiometricManager.BIOMETRIC_SUCCESS -> "available"
|
|
35
|
+
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> "notEnrolled"
|
|
36
|
+
else -> "unavailable"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private fun authenticate(request: JSONObject, reply: TokamakPluginReply) {
|
|
40
|
+
val authenticators = authenticators(request)
|
|
41
|
+
val prompt = request.requireString("prompt")
|
|
42
|
+
if (prompt.isEmpty()) throw typeError("prompt must be a non-empty string")
|
|
43
|
+
requireAvailable(authenticators)
|
|
44
|
+
val callback = PromptCallback(reply) { reply(Result.success(null)) }
|
|
45
|
+
val dialog =
|
|
46
|
+
BiometricPrompt.Builder(activity)
|
|
47
|
+
.setTitle(prompt)
|
|
48
|
+
.setAllowedAuthenticators(authenticators)
|
|
49
|
+
if ((authenticators and Authenticators.DEVICE_CREDENTIAL) == 0) {
|
|
50
|
+
dialog.setNegativeButton(
|
|
51
|
+
activity.getString(android.R.string.cancel),
|
|
52
|
+
activity.mainExecutor,
|
|
53
|
+
) { _, _ ->
|
|
54
|
+
reply(Result.failure(TokamakPluginError("NotAllowedError", "Authentication was cancelled")))
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
dialog.build().authenticate(CancellationSignal(), activity.mainExecutor, callback)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private fun requireAvailable(authenticators: Int) {
|
|
61
|
+
when (biometrics.canAuthenticate(authenticators)) {
|
|
62
|
+
BiometricManager.BIOMETRIC_SUCCESS -> Unit
|
|
63
|
+
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE ->
|
|
64
|
+
throw TokamakPluginError.notSupported("This device cannot perform the requested authentication")
|
|
65
|
+
else -> throw notSetUp()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private fun authenticators(request: JSONObject): Int {
|
|
70
|
+
val wireName = request.optionalString("authentication")
|
|
71
|
+
return Authentication.entries.firstOrNull { it.wireName == wireName }?.authenticators
|
|
72
|
+
?: throw typeError("authentication must be \"biometricsOrPasscode\" or \"biometrics\"")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private fun request(arguments: Any?): JSONObject =
|
|
76
|
+
arguments as? JSONObject ?: throw typeError("$id arguments must be an object")
|
|
77
|
+
|
|
78
|
+
private fun JSONObject.requireString(name: String): String =
|
|
79
|
+
optionalString(name) ?: throw typeError("$name must be a string")
|
|
80
|
+
|
|
81
|
+
private fun JSONObject.optionalString(name: String): String? =
|
|
82
|
+
when (val value = opt(name)) {
|
|
83
|
+
null, JSONObject.NULL -> null
|
|
84
|
+
is String -> value
|
|
85
|
+
else -> throw typeError("$name must be a string")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private enum class Authentication(val wireName: String, val authenticators: Int) {
|
|
89
|
+
BIOMETRICS_OR_PASSCODE(
|
|
90
|
+
"biometricsOrPasscode",
|
|
91
|
+
Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL,
|
|
92
|
+
),
|
|
93
|
+
BIOMETRICS("biometrics", Authenticators.BIOMETRIC_STRONG),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private companion object {
|
|
97
|
+
fun typeError(message: String) = TokamakPluginError("TypeError", message)
|
|
98
|
+
|
|
99
|
+
fun notSetUp() =
|
|
100
|
+
TokamakPluginError("InvalidStateError", "The requested authentication is not set up on this device")
|
|
101
|
+
|
|
102
|
+
fun pluginError(error: Throwable): TokamakPluginError =
|
|
103
|
+
error as? TokamakPluginError
|
|
104
|
+
?: TokamakPluginError("OperationError", error.message ?: error.javaClass.name)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private class PromptCallback(
|
|
109
|
+
private val reply: TokamakPluginReply,
|
|
110
|
+
private val succeeded: () -> Unit,
|
|
111
|
+
) : BiometricPrompt.AuthenticationCallback() {
|
|
112
|
+
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) = succeeded()
|
|
113
|
+
|
|
114
|
+
override fun onAuthenticationError(code: Int, message: CharSequence) =
|
|
115
|
+
reply(Result.failure(authenticationError(code, message)))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private fun authenticationError(code: Int, message: CharSequence) =
|
|
119
|
+
TokamakPluginError(
|
|
120
|
+
when (code) {
|
|
121
|
+
BiometricPrompt.BIOMETRIC_ERROR_CANCELED,
|
|
122
|
+
BiometricPrompt.BIOMETRIC_ERROR_USER_CANCELED,
|
|
123
|
+
BiometricPrompt.BIOMETRIC_ERROR_TIMEOUT,
|
|
124
|
+
BiometricPrompt.BIOMETRIC_ERROR_LOCKOUT,
|
|
125
|
+
BiometricPrompt.BIOMETRIC_ERROR_LOCKOUT_PERMANENT,
|
|
126
|
+
-> "NotAllowedError"
|
|
127
|
+
BiometricPrompt.BIOMETRIC_ERROR_NO_BIOMETRICS,
|
|
128
|
+
BiometricPrompt.BIOMETRIC_ERROR_NO_DEVICE_CREDENTIAL,
|
|
129
|
+
-> "InvalidStateError"
|
|
130
|
+
BiometricPrompt.BIOMETRIC_ERROR_HW_NOT_PRESENT -> "NotSupportedError"
|
|
131
|
+
else -> "OperationError"
|
|
132
|
+
},
|
|
133
|
+
message.toString(),
|
|
134
|
+
)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import LocalAuthentication
|
|
3
|
+
|
|
4
|
+
final class TokamakLocalAuthenticationPlugin: TokamakPlugin {
|
|
5
|
+
let id = "local-authentication"
|
|
6
|
+
|
|
7
|
+
func call(
|
|
8
|
+
method: String,
|
|
9
|
+
arguments: Any,
|
|
10
|
+
reply: @escaping TokamakPluginReply
|
|
11
|
+
) {
|
|
12
|
+
let arguments = arguments as? [String: Any] ?? [:]
|
|
13
|
+
do throws(TokamakPluginError) {
|
|
14
|
+
switch method {
|
|
15
|
+
case "status":
|
|
16
|
+
reply(.success(status(try policy(arguments))))
|
|
17
|
+
case "authenticate":
|
|
18
|
+
try authenticate(policy(arguments), prompt: prompt(arguments), reply: reply)
|
|
19
|
+
default:
|
|
20
|
+
throw .notSupported("\(id).\(method) is not supported")
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
reply(.failure(error))
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private func status(_ policy: LAPolicy) -> String {
|
|
28
|
+
var error: NSError?
|
|
29
|
+
if LAContext().canEvaluatePolicy(policy, error: &error) {
|
|
30
|
+
return "available"
|
|
31
|
+
}
|
|
32
|
+
switch (error as? LAError)?.code {
|
|
33
|
+
case .biometryNotEnrolled, .passcodeNotSet:
|
|
34
|
+
return "notEnrolled"
|
|
35
|
+
default:
|
|
36
|
+
return "unavailable"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private func authenticate(
|
|
41
|
+
_ policy: LAPolicy,
|
|
42
|
+
prompt: String,
|
|
43
|
+
reply: @escaping TokamakPluginReply
|
|
44
|
+
) {
|
|
45
|
+
let context = LAContext()
|
|
46
|
+
if policy == .deviceOwnerAuthenticationWithBiometrics {
|
|
47
|
+
// Hides the passcode fallback button, which this policy cannot use.
|
|
48
|
+
context.localizedFallbackTitle = ""
|
|
49
|
+
}
|
|
50
|
+
context.evaluatePolicy(policy, localizedReason: prompt) { success, error in
|
|
51
|
+
// A deallocated context cancels its evaluation.
|
|
52
|
+
withExtendedLifetime(context) {
|
|
53
|
+
reply(success ? .success(nil) : .failure(Self.failure(error)))
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private static func failure(_ error: Error?) -> TokamakPluginError {
|
|
59
|
+
let message = error?.localizedDescription ?? "Authentication failed"
|
|
60
|
+
switch (error as? LAError)?.code {
|
|
61
|
+
case .biometryNotEnrolled, .passcodeNotSet:
|
|
62
|
+
return TokamakPluginError(name: "InvalidStateError", message: message)
|
|
63
|
+
case .biometryNotAvailable:
|
|
64
|
+
return .notSupported(message)
|
|
65
|
+
default:
|
|
66
|
+
return TokamakPluginError(name: "NotAllowedError", message: message)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private func policy(_ arguments: [String: Any]) throws(TokamakPluginError) -> LAPolicy {
|
|
71
|
+
switch arguments["authentication"] as? String {
|
|
72
|
+
case "biometricsOrPasscode":
|
|
73
|
+
return .deviceOwnerAuthentication
|
|
74
|
+
case "biometrics":
|
|
75
|
+
return .deviceOwnerAuthenticationWithBiometrics
|
|
76
|
+
default:
|
|
77
|
+
throw TokamakPluginError(
|
|
78
|
+
name: "TypeError",
|
|
79
|
+
message: "authentication must be biometricsOrPasscode or biometrics"
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private func prompt(_ arguments: [String: Any]) throws(TokamakPluginError) -> String {
|
|
85
|
+
guard let prompt = arguments["prompt"] as? String, !prompt.isEmpty else {
|
|
86
|
+
throw TokamakPluginError(name: "TypeError", message: "prompt must be a non-empty string")
|
|
87
|
+
}
|
|
88
|
+
return prompt
|
|
89
|
+
}
|
|
90
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tokamakdev/plugin-local-authentication",
|
|
3
|
+
"version": "0.1.0-beta.45",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/mantty/tokamak.git",
|
|
7
|
+
"directory": "plugins/local-authentication"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@tokamakdev/plugin": "0.1.0-beta.45"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"android",
|
|
18
|
+
"apple",
|
|
19
|
+
"src",
|
|
20
|
+
"tokamak-plugin.json",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { FrontendPlugin } from "@tokamakdev/plugin";
|
|
2
|
+
|
|
3
|
+
/** The authentication the device owner performs. */
|
|
4
|
+
export type Authentication = "biometricsOrPasscode" | "biometrics";
|
|
5
|
+
|
|
6
|
+
/** Whether the device can perform an authentication now. */
|
|
7
|
+
export type Status = "available" | "notEnrolled" | "unavailable";
|
|
8
|
+
|
|
9
|
+
export interface AuthenticateOptions {
|
|
10
|
+
/** The reason shown in the system authentication prompt. */
|
|
11
|
+
readonly prompt: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class LocalAuthentication extends FrontendPlugin {
|
|
15
|
+
constructor() {
|
|
16
|
+
super("local-authentication");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
status(authentication: Authentication): Promise<Status> {
|
|
20
|
+
return this.call("status", { authentication });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
authenticate(authentication: Authentication, options: AuthenticateOptions): Promise<void> {
|
|
24
|
+
return this.call("authenticate", { authentication, prompt: options.prompt });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const localAuthentication = new LocalAuthentication();
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"id": "local-authentication",
|
|
4
|
+
"kind": "frontend",
|
|
5
|
+
"platforms": {
|
|
6
|
+
"macos": {
|
|
7
|
+
"class": "TokamakLocalAuthenticationPlugin",
|
|
8
|
+
"sources": ["apple/LocalAuthenticationPlugin.swift"],
|
|
9
|
+
"frameworks": ["LocalAuthentication"]
|
|
10
|
+
},
|
|
11
|
+
"ios": {
|
|
12
|
+
"class": "TokamakLocalAuthenticationPlugin",
|
|
13
|
+
"sources": ["apple/LocalAuthenticationPlugin.swift"],
|
|
14
|
+
"frameworks": ["LocalAuthentication"],
|
|
15
|
+
"plist": {
|
|
16
|
+
"NSFaceIDUsageDescription": "This app uses Face ID to confirm your identity."
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"ios-simulator": {
|
|
20
|
+
"class": "TokamakLocalAuthenticationPlugin",
|
|
21
|
+
"sources": ["apple/LocalAuthenticationPlugin.swift"],
|
|
22
|
+
"frameworks": ["LocalAuthentication"],
|
|
23
|
+
"plist": {
|
|
24
|
+
"NSFaceIDUsageDescription": "This app uses Face ID to confirm your identity."
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"android": {
|
|
28
|
+
"class": "com.tokamak.plugins.localauthentication.TokamakLocalAuthenticationPlugin",
|
|
29
|
+
"sources": ["android/LocalAuthenticationPlugin.kt"],
|
|
30
|
+
"permissions": ["android.permission.USE_BIOMETRIC"]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|