Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions TOUCH_ID_TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Touch ID Local Testing Guide

## Quick Start

Test Touch ID authentication locally without installing globally:

```bash
cd /Users/USER/Documents/cli

# Test read-only command (no Touch ID)
HEROKU_TOUCH_ID_ENABLED=true ./bin/run.js config -a your-app

# Test mutating command (Touch ID required)
HEROKU_TOUCH_ID_ENABLED=true ./bin/run.js config:set TEST_KEY=test_value -a your-app
```

## What You'll See

### Read-Only Commands (GET/HEAD)
```bash
HEROKU_TOUCH_ID_ENABLED=true ./bin/run.js apps:info my-app
```
- No Touch ID prompt
- Command executes immediately

### Mutating Commands (POST/PUT/PATCH/DELETE)
```bash
HEROKU_TOUCH_ID_ENABLED=true ./bin/run.js config:set KEY=value -a my-app
```
- Console shows: `🔐 Touch ID authentication required for PATCH request`
- Native macOS dialog appears with "Authenticate to allow Heroku CLI PATCH request"
- Use Touch ID fingerprint (or password as fallback)
- Console shows: `Touch ID authenticated... ✓`
- Command executes

## Testing Methods

### Method 1: Environment Variable (Recommended)
```bash
export HEROKU_TOUCH_ID_ENABLED=true
./bin/run.js config:set KEY=value -a your-app
./bin/run.js apps:create my-test-app
```

### Method 2: Per-Command
```bash
HEROKU_TOUCH_ID_ENABLED=true ./bin/run.js addons:create heroku-postgresql -a app
```

### Method 3: ht Command
```bash
# ht has Touch ID enabled by default
./bin/ht config:set KEY=value -a your-app
```

## Debug Mode

See what's happening behind the scenes:

```bash
DEBUG_TOUCH_ID=1 HEROKU_TOUCH_ID_ENABLED=true ./bin/run.js config:set KEY=val -a app
```

Output:
```
[Touch ID Debug] Init hook running, Touch ID enabled
[Touch ID Debug] Found heroku getter, wrapping it
[Touch ID Debug] Wrapping API client
[Touch ID Debug] Method: PATCH, URL: /apps/app/config-vars, Requires auth: true
🔐 Touch ID authentication required for PATCH request
Touch ID authenticated... ✓
```

## Test Cases

### Should NOT Require Touch ID
- `heroku config -a app`
- `heroku apps:info app`
- `heroku logs -a app`
- `heroku releases -a app`

### SHOULD Require Touch ID
- `heroku config:set KEY=value -a app`
- `heroku apps:create new-app`
- `heroku addons:create service -a app`
- `heroku ps:scale web=2 -a app`
- `heroku maintenance:on -a app`

## Disabling Touch ID

Even with `HEROKU_TOUCH_ID_ENABLED`, you can disable:

```bash
HEROKU_DISABLE_TOUCH_ID=true ./bin/run.js config:set KEY=val -a app
```

## Platform Behavior

### macOS with Touch ID
- Native dialog with fingerprint icon
- Use Touch ID sensor or password

### macOS without Touch ID
- Falls back to password-only authentication
- Still uses secure LocalAuthentication framework

### Linux/Windows
- Touch ID checks automatically skipped
- Shows warning: "Touch ID not available on this device"
- Command proceeds normally

## Troubleshooting

### "Touch ID not available"
- Check if your Mac has Touch ID hardware
- Verify Touch ID is enabled in System Preferences
- Try: `swift scripts/touch-id-auth.swift "Test"` directly

### Authentication dialog doesn't appear
- Verify `HEROKU_TOUCH_ID_ENABLED=true` is set
- Check debug output with `DEBUG_TOUCH_ID=1`
- Ensure you're running a mutating command (not GET)

### Build required
If you modified source files:
```bash
npm run build
```

## Production Notes

- `ht` command is for testing only
- Production usage: set `HEROKU_TOUCH_ID_ENABLED=true` in shell profile
- Or create an alias: `alias ht='HEROKU_TOUCH_ID_ENABLED=true heroku'`
- Touch ID has no effect on regular `heroku` command unless explicitly enabled
41 changes: 41 additions & 0 deletions bin/ht
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/usr/bin/env -S node --no-deprecation

// ht - Heroku CLI with Touch ID authentication
// This is an alternate entry point that enables Touch ID for non-GET requests

// Enable Touch ID by default for this command
process.env.HEROKU_TOUCH_ID_ENABLED = 'true'

import {execute, settings} from '@oclif/core'

// Enable performance tracking when oclif:perf is specified in DEBUG
if (process.env.DEBUG?.includes('oclif:perf') || process.env.DEBUG === 'oclif:*' || process.env.DEBUG === '*') {
settings.performanceEnabled = true
}

process.env.HEROKU_UPDATE_INSTRUCTIONS = process.env.HEROKU_UPDATE_INSTRUCTIONS || 'update with: "npm update -g heroku"'

const now = new Date()
const cliStartTime = now.getTime()

const {getTelemetryDisabledReason, isTelemetryEnabled, telemetryDebug} = await import('../dist/lib/analytics-telemetry/telemetry-utils.js')
const enableTelemetry = isTelemetryEnabled()

if (enableTelemetry) {
telemetryDebug('Telemetry enabled: setting up handlers (beforeExit, SIGINT, SIGTERM)')
// Dynamically import telemetry modules
const {setupTelemetryHandlers} = await import('../dist/lib/analytics-telemetry/worker-client.js')
const {computeDuration} = await import('../dist/lib/analytics-telemetry/telemetry-utils.js')

// Setup all telemetry handlers (beforeExit, SIGINT, SIGTERM)
setupTelemetryHandlers({
cliStartTime,
computeDuration,
enableTelemetry,
})
} else {
const reason = getTelemetryDisabledReason()
telemetryDebug('Telemetry disabled (%s): skipping telemetry handler setup', reason)
}

await execute({dir: import.meta.url})
11 changes: 8 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
"description": "CLI to interact with Heroku",
"version": "11.5.0",
"author": "Heroku",
"bin": "./bin/run.js",
"bin": {
"heroku": "./bin/run.js",
"ht": "./bin/ht"
},
"bugs": "https://github.com/heroku/cli/issues",
"dependencies": {
"@heroku-cli/command": "12.4.2",
Expand Down Expand Up @@ -183,13 +186,15 @@
"init": [
"./dist/hooks/init/version",
"./dist/hooks/init/terms-of-service",
"./dist/hooks/init/setup-otel-telemetry"
"./dist/hooks/init/setup-otel-telemetry",
"./dist/hooks/init/touch-id"
],
"postrun": [
"./dist/hooks/postrun/send-otel-telemetry"
],
"prerun": [
"./dist/hooks/prerun/collect-and-send-herokulytics"
"./dist/hooks/prerun/collect-and-send-herokulytics",
"./dist/hooks/prerun/touch-id"
],
"preupdate": [
"./dist/hooks/preupdate/check-npm-auth"
Expand Down
42 changes: 42 additions & 0 deletions scripts/touch-id-auth.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env swift

import Foundation
import LocalAuthentication

let context = LAContext()
var error: NSError?

// Check if biometric authentication is available
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
if let error = error {
print("UNAVAILABLE:\(error.localizedDescription)")
} else {
print("UNAVAILABLE:Touch ID not available")
}
exit(1)
}

// Get the reason from command line arguments
let reason = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "Authenticate to continue"

// Create a semaphore to wait for async result
let semaphore = DispatchSemaphore(value: 0)
var authResult = false

// Attempt biometric authentication
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, authError in
authResult = success
semaphore.signal()
}

// Wait for authentication to complete
semaphore.wait()

// Print result
if authResult {
print("SUCCESS")
exit(0)
} else {
print("FAILED:Authentication failed or cancelled")
exit(1)
}
61 changes: 61 additions & 0 deletions src/hooks/init/touch-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {Hook} from '@oclif/core'
import {wrapAPIClientWithTouchId} from '../../lib/biometric/api-client-wrapper'

Check failure on line 2 in src/hooks/init/touch-id.ts

View workflow job for this annotation

GitHub Actions / test (20.x, macos-latest)

Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean '../../lib/biometric/api-client-wrapper.js'?

Check failure on line 2 in src/hooks/init/touch-id.ts

View workflow job for this annotation

GitHub Actions / test (20.x, ubuntu-latest)

Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean '../../lib/biometric/api-client-wrapper.js'?

Check failure on line 2 in src/hooks/init/touch-id.ts

View workflow job for this annotation

GitHub Actions / integration (22.x, ubuntu-latest)

Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean '../../lib/biometric/api-client-wrapper.js'?

Check failure on line 2 in src/hooks/init/touch-id.ts

View workflow job for this annotation

GitHub Actions / test (22.x, ubuntu-latest)

Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean '../../lib/biometric/api-client-wrapper.js'?

Check failure on line 2 in src/hooks/init/touch-id.ts

View workflow job for this annotation

GitHub Actions / integration (20.x, ubuntu-latest)

Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean '../../lib/biometric/api-client-wrapper.js'?

Check failure on line 2 in src/hooks/init/touch-id.ts

View workflow job for this annotation

GitHub Actions / lint

Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean '../../lib/biometric/api-client-wrapper.js'?

Check failure on line 2 in src/hooks/init/touch-id.ts

View workflow job for this annotation

GitHub Actions / acceptance (22.x, ubuntu-latest)

Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean '../../lib/biometric/api-client-wrapper.js'?

const hook: Hook<'init'> = async function (opts) {
// Check if Touch ID is explicitly enabled (via `ht` command or env var)
const touchIdEnabled = process.env.HEROKU_TOUCH_ID_ENABLED === 'true' || process.env.HEROKU_TOUCH_ID_ENABLED === '1'

// Check if Touch ID is explicitly disabled
const touchIdDisabled = process.env.HEROKU_DISABLE_TOUCH_ID === '1' || process.env.HEROKU_DISABLE_TOUCH_ID === 'true'

if (touchIdDisabled || !touchIdEnabled) {
return
}

// Debug logging
if (process.env.DEBUG_TOUCH_ID) {
console.error('[Touch ID Debug] Init hook running, Touch ID enabled')
}

// Wrap the Command base class to intercept heroku client getter
try {
const {Command} = require('@heroku-cli/command')

// Store the original getter
const descriptor = Object.getOwnPropertyDescriptor(Command.prototype, 'heroku')
if (descriptor && descriptor.get) {
const originalGetter = descriptor.get

if (process.env.DEBUG_TOUCH_ID) {
console.error('[Touch ID Debug] Found heroku getter, wrapping it')
}

// Replace with wrapped version
Object.defineProperty(Command.prototype, 'heroku', {
get(this: any) {
const client = originalGetter.call(this)
// Wrap only once
if (client && !(client as any).__TOUCH_ID_WRAPPED) {
if (process.env.DEBUG_TOUCH_ID) {
console.error('[Touch ID Debug] Wrapping API client')
}

wrapAPIClientWithTouchId(client);
(client as any).__TOUCH_ID_WRAPPED = true
}

return client
},
configurable: true,
enumerable: true,
})
} else if (process.env.DEBUG_TOUCH_ID) {
console.error('[Touch ID Debug] Could not find heroku getter descriptor')
}
} catch (error) {
// Silently fail if we can't wrap - better than breaking the CLI
console.warn('Failed to enable Touch ID:', error)
}
}

export default hook
9 changes: 9 additions & 0 deletions src/hooks/prerun/touch-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {Hook} from '@oclif/core'

// This hook is kept for future use but currently does nothing
// The Touch ID wrapping is handled in the init hook
const hook: Hook<'prerun'> = async function (opts) {
// No-op - Touch ID wrapping happens in init hook
}

export default hook
Loading
Loading