@tokamakdev/plugin-local-authentication 0.1.0-beta.100

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 ADDED
@@ -0,0 +1,43 @@
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, `NotSupportedError` when the device cannot
31
+ perform it, and `NeedsUIError` while the app is off screen on iOS or Android,
32
+ where the system cannot show its prompt.
33
+
34
+ `authenticate` checks the device owner when the app asks; it does not protect
35
+ any data. To bind a stored value to authentication, use
36
+ `@tokamakdev/plugin-secure-storage`.
37
+
38
+ ## Platforms
39
+
40
+ - **iOS and macOS:** LocalAuthentication. On macOS, `biometricsOrPasscode`
41
+ accepts Touch ID or the account password.
42
+ - **Android:** the platform `BiometricPrompt`.
43
+ - **Web and Windows:** no implementation; calls throw `NotSupportedError`.
@@ -0,0 +1,3 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <uses-permission android:name="android.permission.USE_BIOMETRIC" />
3
+ </manifest>
@@ -0,0 +1,59 @@
1
+ package com.tokamak.plugins.localauthentication
2
+
3
+ import android.hardware.biometrics.BiometricManager
4
+ import android.hardware.biometrics.BiometricManager.Authenticators
5
+ import com.tokamak.runtime.TokamakHost
6
+ import com.tokamak.runtime.TokamakPlugin
7
+ import com.tokamak.runtime.TokamakPluginError
8
+ import com.tokamak.runtime.TokamakPluginReply
9
+ import com.tokamak.runtime.authenticateOwner
10
+ import com.tokamak.runtime.optionalString
11
+ import com.tokamak.runtime.requireObject
12
+ import com.tokamak.runtime.requireOwnerAuthentication
13
+ import com.tokamak.runtime.requireString
14
+ import org.json.JSONObject
15
+
16
+ class TokamakLocalAuthenticationPlugin(
17
+ private val host: TokamakHost,
18
+ ) : TokamakPlugin {
19
+ override val id = "local-authentication"
20
+
21
+ private val biometrics = host.context.getSystemService(BiometricManager::class.java)
22
+
23
+ override fun call(method: String, arguments: Any?, reply: TokamakPluginReply) {
24
+ when (method) {
25
+ "status" -> reply(Result.success(status(requireObject(arguments))))
26
+ "authenticate" -> authenticate(requireObject(arguments), reply)
27
+ else -> super.call(method, arguments, reply)
28
+ }
29
+ }
30
+
31
+ private fun status(request: JSONObject): String =
32
+ when (biometrics.canAuthenticate(authenticators(request))) {
33
+ BiometricManager.BIOMETRIC_SUCCESS -> "available"
34
+ BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> "notEnrolled"
35
+ else -> "unavailable"
36
+ }
37
+
38
+ private fun authenticate(request: JSONObject, reply: TokamakPluginReply) {
39
+ val authenticators = authenticators(request)
40
+ val prompt = request.requireString("prompt")
41
+ if (prompt.isEmpty()) throw TokamakPluginError.typeError("prompt must be a non-empty string")
42
+ host.requireOwnerAuthentication(authenticators)
43
+ host.authenticateOwner(prompt, authenticators, null, reply) { reply(Result.success(null)) }
44
+ }
45
+
46
+ private fun authenticators(request: JSONObject): Int {
47
+ val wireName = request.optionalString("authentication")
48
+ return Authentication.entries.firstOrNull { it.wireName == wireName }?.authenticators
49
+ ?: throw TokamakPluginError.typeError("authentication must be \"biometricsOrPasscode\" or \"biometrics\"")
50
+ }
51
+
52
+ private enum class Authentication(val wireName: String, val authenticators: Int) {
53
+ BIOMETRICS_OR_PASSCODE(
54
+ "biometricsOrPasscode",
55
+ Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL,
56
+ ),
57
+ BIOMETRICS("biometrics", Authenticators.BIOMETRIC_STRONG),
58
+ }
59
+ }
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>NSFaceIDUsageDescription</key>
6
+ <string>This app uses Face ID to confirm your identity.</string>
7
+ </dict>
8
+ </plist>
@@ -0,0 +1,103 @@
1
+ import Foundation
2
+ import LocalAuthentication
3
+
4
+ final class TokamakLocalAuthenticationPlugin: TokamakPlugin {
5
+ let id = "local-authentication"
6
+
7
+ private let host: TokamakHost
8
+
9
+ init(host: TokamakHost) {
10
+ self.host = host
11
+ }
12
+
13
+ func call(
14
+ method: String,
15
+ arguments: Any,
16
+ reply: @escaping TokamakPluginReply
17
+ ) {
18
+ let arguments = arguments as? [String: Any] ?? [:]
19
+ do throws(TokamakPluginError) {
20
+ switch method {
21
+ case "status":
22
+ reply(.success(status(try policy(arguments))))
23
+ case "authenticate":
24
+ try authenticate(policy(arguments), prompt: prompt(arguments), reply: reply)
25
+ default:
26
+ throw .notSupported("\(id).\(method) is not supported")
27
+ }
28
+ } catch {
29
+ reply(.failure(error))
30
+ }
31
+ }
32
+
33
+ private func status(_ policy: LAPolicy) -> String {
34
+ var error: NSError?
35
+ if LAContext().canEvaluatePolicy(policy, error: &error) {
36
+ return "available"
37
+ }
38
+ switch (error as? LAError)?.code {
39
+ case .biometryNotEnrolled, .passcodeNotSet:
40
+ return "notEnrolled"
41
+ default:
42
+ return "unavailable"
43
+ }
44
+ }
45
+
46
+ /// Shows the system prompt, which needs the app able to show UI, unless the
47
+ /// authentication fails without one.
48
+ private func authenticate(
49
+ _ policy: LAPolicy,
50
+ prompt: String,
51
+ reply: @escaping TokamakPluginReply
52
+ ) throws(TokamakPluginError) {
53
+ let context = LAContext()
54
+ var error: NSError?
55
+ guard context.canEvaluatePolicy(policy, error: &error) else {
56
+ throw Self.failure(error)
57
+ }
58
+ try host.requireUI()
59
+ if policy == .deviceOwnerAuthenticationWithBiometrics {
60
+ // Hides the passcode fallback button, which this policy cannot use.
61
+ context.localizedFallbackTitle = ""
62
+ }
63
+ context.evaluatePolicy(policy, localizedReason: prompt) { success, error in
64
+ // A deallocated context cancels its evaluation.
65
+ withExtendedLifetime(context) {
66
+ reply(success ? .success(nil) : .failure(Self.failure(error)))
67
+ }
68
+ }
69
+ }
70
+
71
+ private static func failure(_ error: Error?) -> TokamakPluginError {
72
+ let message = error?.localizedDescription ?? "Authentication failed"
73
+ switch (error as? LAError)?.code {
74
+ case .biometryNotEnrolled, .passcodeNotSet:
75
+ return TokamakPluginError(name: "InvalidStateError", message: message)
76
+ case .biometryNotAvailable:
77
+ return .notSupported(message)
78
+ default:
79
+ return TokamakPluginError(name: "NotAllowedError", message: message)
80
+ }
81
+ }
82
+
83
+ private func policy(_ arguments: [String: Any]) throws(TokamakPluginError) -> LAPolicy {
84
+ switch arguments["authentication"] as? String {
85
+ case "biometricsOrPasscode":
86
+ return .deviceOwnerAuthentication
87
+ case "biometrics":
88
+ return .deviceOwnerAuthenticationWithBiometrics
89
+ default:
90
+ throw TokamakPluginError(
91
+ name: "TypeError",
92
+ message: "authentication must be biometricsOrPasscode or biometrics"
93
+ )
94
+ }
95
+ }
96
+
97
+ private func prompt(_ arguments: [String: Any]) throws(TokamakPluginError) -> String {
98
+ guard let prompt = arguments["prompt"] as? String, !prompt.isEmpty else {
99
+ throw TokamakPluginError(name: "TypeError", message: "prompt must be a non-empty string")
100
+ }
101
+ return prompt
102
+ }
103
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@tokamakdev/plugin-local-authentication",
3
+ "version": "0.1.0-beta.100",
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
+ "peerDependencies": {
14
+ "@tokamakdev/plugin": "0.1.0-beta.100"
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 { Plugin } 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 Plugin {
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,20 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "id": "local-authentication",
4
+ "platforms": {
5
+ "macos": {
6
+ "class": "TokamakLocalAuthenticationPlugin",
7
+ "sources": ["apple/LocalAuthenticationPlugin.swift"]
8
+ },
9
+ "ios": {
10
+ "class": "TokamakLocalAuthenticationPlugin",
11
+ "sources": ["apple/LocalAuthenticationPlugin.swift"],
12
+ "plist": "apple/Info.plist"
13
+ },
14
+ "android": {
15
+ "class": "com.tokamak.plugins.localauthentication.TokamakLocalAuthenticationPlugin",
16
+ "sources": ["android/LocalAuthenticationPlugin.kt"],
17
+ "manifest": "android/AndroidManifest.xml"
18
+ }
19
+ }
20
+ }