├── .husky └── pre-commit ├── .gitignore ├── .prettierrc ├── index.d.ts ├── .github └── workflows │ └── test.yml ├── index.js ├── binding.gyp ├── package.json ├── test └── module.spec.js ├── README.md └── auth.mm /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | node_modules 3 | .DS_Store 4 | bin -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true, 6 | "printWidth": 100, 7 | "endOfLine": "lf" 8 | } 9 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for node-mac-auth 2 | // Project: node-mac-auth 3 | 4 | declare module 'node-mac-auth' { 5 | export function canPromptTouchID(): boolean; 6 | 7 | export function promptTouchID(options: { 8 | reason: string; 9 | reuseDuration?: number; 10 | }): Promise; 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | build: 16 | runs-on: macos-latest 17 | steps: 18 | - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # tag: v4.1.1 19 | - name: Setup Node.js 20 | uses: actions/setup-node@b39b52d1213e96004bfcb1c61a8a6fa8ab84f3e8 # tag: v4.0.1 21 | with: 22 | node-version: lts/-1 23 | - name: Install Dependencies 24 | run: npm ci 25 | - name: Run Tests 26 | run: npm test 27 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const auth = require('bindings')('auth.node') 2 | 3 | function promptTouchID(options) { 4 | if (!options || typeof options !== 'object') { 5 | throw new Error('Options object is required.') 6 | } 7 | 8 | if (!options.hasOwnProperty('reason') || typeof options.reason !== 'string') { 9 | throw new Error('Reason parameter must be a string.') 10 | } 11 | 12 | if (options.hasOwnProperty('reuseDuration') && typeof options.reuseDuration !== 'number') { 13 | throw new TypeError('reuseDuration parameter must be a number.') 14 | } 15 | 16 | return auth.promptTouchID.call(this, options) 17 | } 18 | 19 | module.exports = { 20 | canPromptTouchID: auth.canPromptTouchID, 21 | promptTouchID, 22 | } 23 | -------------------------------------------------------------------------------- /binding.gyp: -------------------------------------------------------------------------------- 1 | { 2 | "targets": [{ 3 | "target_name": "auth", 4 | "sources": [ ], 5 | "conditions": [ 6 | ['OS=="mac"', { 7 | "sources": [ 8 | "auth.mm", 9 | ], 10 | }] 11 | ], 12 | 'include_dirs': [ 13 | " { 5 | describe('canPromptTouchID()', () => { 6 | it('should not throw', () => { 7 | expect(() => { 8 | canPromptTouchID() 9 | }).to.not.throw() 10 | }) 11 | 12 | it('should return a boolean', () => { 13 | const canPrompt = canPromptTouchID() 14 | expect(canPrompt).to.be.a('boolean') 15 | }) 16 | }) 17 | 18 | describe('promptTouchID()', () => { 19 | it('should throw if no options object is passed', () => { 20 | expect(() => { 21 | promptTouchID() 22 | }).to.throw(/Options object is required./) 23 | }) 24 | 25 | it('should throw if no reason is passed', () => { 26 | expect(() => { 27 | promptTouchID({}) 28 | }).to.throw(/Reason parameter must be a string./) 29 | }) 30 | 31 | it('should throw if reason is not a string', () => { 32 | expect(() => { 33 | promptTouchID({ reason: 1 }) 34 | }).to.throw(/Reason parameter must be a string./) 35 | }) 36 | 37 | it('should throw if reuseDuration is not a number', () => { 38 | expect(() => { 39 | promptTouchID({ reason: 'i want to', reuseDuration: 'not-a-number' }) 40 | }).to.throw(/reuseDuration parameter must be a number./) 41 | }) 42 | 43 | it('should not throw if no reuseDuration is passed', () => { 44 | expect(() => { 45 | promptTouchID({ reason: 'i want to' }, () => {}) 46 | }).to.not.throw() 47 | 48 | // Quit after test passes, or it will hang on system prompt. 49 | process.exit(0) 50 | }) 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![MIT license](https://img.shields.io/badge/License-MIT-blue.svg)](https://lbesson.mit-license.org/) 2 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) [![GitHub release](https://img.shields.io/github/release/codebytere/node-mac-auth.svg)](https://GitHub.com/codebytere/node-mac-auth/releases/) [![Actions Status](https://github.com/codebytere/node-mac-auth/workflows/Test/badge.svg)](https://github.com/codebytere/node-mac-auth/actions) 3 | 4 | # node-mac-auth 5 | 6 | A native node module that allows you to query and handle native macOS biometric authentication. 7 | 8 | This module will have no effect unless there's an app bundle to own it: without one the API will simply appear not to run as a corollary of the way macOS handles native UI APIs. 9 | 10 | **Nota Bene:** This module does not nor is it intended to perform process privilege escalation, e.g. allow you to authenticate as an admin user. 11 | 12 | ## API 13 | 14 | ### `canPromptTouchID()` 15 | 16 | Returns `Boolean` - whether or not this device has the ability to use Touch ID. 17 | 18 | ```js 19 | const { canPromptTouchID } = require('node-mac-auth') 20 | 21 | const canPrompt = canPromptTouchID() 22 | console.log(`I ${canPrompt ? 'can' : 'cannot'} prompt for TouchID!`) 23 | ``` 24 | 25 | **NOTE:** This API will return `false` on macOS systems older than Sierra 10.12.2. 26 | 27 | ### `promptTouchID(options)` 28 | 29 | * `options` Object 30 | * `reason` String - The reason you are asking for Touch ID authentication. 31 | * `reuseDuration` Number (optional) - The duration for which Touch ID authentication reuse is allowable, in seconds. 32 | 33 | Returns `Promise` - resolves when Touch ID authenticates successfully. 34 | 35 | ```js 36 | const { promptTouchID } = require('node-mac-auth') 37 | 38 | promptTouchID({ reason: 'To get consent for a Security-Gated Thing' }).then(() => { 39 | console.log('You have successfully authenticated with Touch ID!') 40 | }).catch(err => { 41 | console.log('TouchID failed because: ', err) 42 | }) 43 | ``` 44 | 45 | ## Trying It Out 46 | 47 | To see this module in action: 48 | 49 | ```sh 50 | $ git clone https://github.com/electron/electron-quick-start 51 | $ cd electron-quick-start 52 | $ npm install 53 | $ npm install node-mac-auth 54 | ``` 55 | 56 | then open `main.js` inside `electron-quick-start` and add: 57 | 58 | ```js 59 | const { canPromptTouchID, promptTouchID } = require('node-mac-auth') 60 | ``` 61 | 62 | to the top at line 4, and 63 | 64 | ```js 65 | const canPrompt = canPromptTouchID() 66 | console.log(`I ${canPrompt ? 'can' : 'cannot'} prompt for TouchID!`) 67 | 68 | promptTouchID({ reason: 'To get consent for a Security-Gated Thing' }).then(() => { 69 | console.log('You have successfully authenticated with Touch ID!') 70 | }).catch(err => { 71 | console.log('TouchID failed because: ', err) 72 | }) 73 | ``` 74 | 75 | Inside the `createWindow` function beginning at line 9. Enjoy! 76 | -------------------------------------------------------------------------------- /auth.mm: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #import 4 | 5 | // No-op value to pass into function parameter for ThreadSafeFunction 6 | Napi::Value NoOp(const Napi::CallbackInfo &info) { 7 | return info.Env().Undefined(); 8 | } 9 | 10 | // Whether the system allows prompting for Touch ID authentication. 11 | Napi::Boolean CanPromptTouchID(const Napi::CallbackInfo &info) { 12 | Napi::Env env = info.Env(); 13 | bool can_evaluate = false; 14 | 15 | if (@available(macOS 10.12.2, *)) { 16 | LAContext *context = [[LAContext alloc] init]; 17 | if ([context 18 | canEvaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics 19 | error:nil]) { 20 | if (@available(macOS 10.13.2, *)) { 21 | can_evaluate = [context biometryType] == LABiometryTypeTouchID; 22 | } else { 23 | can_evaluate = true; 24 | } 25 | } 26 | } 27 | return Napi::Boolean::New(env, can_evaluate); 28 | } 29 | 30 | // Prompt the user for authentication with Touch ID. 31 | Napi::Promise PromptTouchID(const Napi::CallbackInfo &info) { 32 | Napi::Env env = info.Env(); 33 | Napi::Object data = info[0].As(); 34 | 35 | std::string reason = ""; 36 | if (data.Has("reason")) 37 | reason = data.Get("reason").As().Utf8Value(); 38 | 39 | int reuse_duration = 0; 40 | if (data.Has("reuseDuration")) 41 | reuse_duration = data.Get("reuseDuration").As().Int32Value(); 42 | 43 | Napi::Promise::Deferred deferred = Napi::Promise::Deferred::New(env); 44 | Napi::ThreadSafeFunction ts_fn = Napi::ThreadSafeFunction::New( 45 | env, Napi::Function::New(env, NoOp), "authCallback", 0, 1); 46 | 47 | LAContext *context = [[LAContext alloc] init]; 48 | 49 | // The app-provided reason for requesting authentication 50 | NSString *request_reason = [NSString stringWithUTF8String:reason.c_str()]; 51 | 52 | // Authenticate with biometry. 53 | LAPolicy policy = LAPolicyDeviceOwnerAuthenticationWithBiometrics; 54 | 55 | // Optionally set the duration for which Touch ID authentication reuse is 56 | // allowable 57 | if (reuse_duration > 0) 58 | [context setTouchIDAuthenticationAllowableReuseDuration:reuse_duration]; 59 | 60 | __block Napi::ThreadSafeFunction tsfn = ts_fn; 61 | [context 62 | evaluatePolicy:policy 63 | localizedReason:request_reason 64 | reply:^(BOOL success, NSError *error) { 65 | // Promise resolution callback 66 | auto resolve_cb = [=](Napi::Env env, Napi::Function noop_cb) { 67 | deferred.Resolve(env.Null()); 68 | }; 69 | 70 | // Promise rejection callback 71 | auto reject_cb = [=](Napi::Env env, Napi::Function noop_cb, 72 | const char *error) { 73 | deferred.Reject(Napi::String::New(env, error)); 74 | }; 75 | 76 | if (error) { 77 | const char *err_str = 78 | [error.localizedDescription UTF8String]; 79 | tsfn.BlockingCall(err_str, reject_cb); 80 | } else { 81 | tsfn.BlockingCall(resolve_cb); 82 | }; 83 | tsfn.Release(); 84 | }]; 85 | 86 | return deferred.Promise(); 87 | } 88 | 89 | // Initializes all functions exposed to JS 90 | Napi::Object Init(Napi::Env env, Napi::Object exports) { 91 | exports.Set(Napi::String::New(env, "canPromptTouchID"), 92 | Napi::Function::New(env, CanPromptTouchID)); 93 | exports.Set(Napi::String::New(env, "promptTouchID"), 94 | Napi::Function::New(env, PromptTouchID)); 95 | 96 | return exports; 97 | } 98 | 99 | NODE_API_MODULE(permissions, Init) --------------------------------------------------------------------------------