localauthentication

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 30, 2026 License: BSD-3-Clause Imports: 4 Imported by: 0

README

localauthentication

CI Go Reference Coverage

Ask macOS to authenticate the person at the keyboard — Touch ID, or the login password — from pure Go with CGO_ENABLED=0.

It binds LAContext through go-macos/objc (Objective-C message sends over ebitengine/purego), so it links with no cgo and shells out to nothing.

import "github.com/go-macos/localauthentication"

// Is there a sensor, and of what kind? (No prompt.)
kind, err := localauthentication.Biometry()          // "Touch ID" / "none"

// Could this be evaluated right now? (No prompt, no side effect.)
err = localauthentication.Available(localauthentication.PolicyBiometrics)

// Ask. THIS PROMPTS, and it blocks until they answer.
err = localauthentication.Evaluate(ctx,
    localauthentication.PolicyOwner, "unlock this document")

What it is

A biometric check is not something a program implements. The fingerprint never leaves the Secure Enclave; nothing in your process, or in the kernel, ever sees it. What LocalAuthentication returns is an attestation — the system saying the device owner was present and identified themselves. This package asks for that attestation and reports faithfully what came back.

It is therefore a gate on convenience, not a cryptographic control: anything that can patch your process can skip it. Data that must actually stay secret belongs in the Keychain behind a SecAccessControl — see Relationship with go-macos/keychain.

Policies

Constant LAPolicy Behaviour
PolicyBiometrics LAPolicyDeviceOwnerAuthenticationWithBiometrics (1) Touch ID and nothing else. Unavailable, unenrolled or locked-out biometry fails rather than falling back. "Use Password…" ends the evaluation with ErrUserFallback.
PolicyOwner LAPolicyDeviceOwnerAuthentication (2) Touch ID or the account password. If biometry cannot be used the password is asked for straight away, and "Use Password…" switches the same dialog over instead of ending it.

The values are 1 and 2 — read out of LAPublicDefines.h in the macOS SDK, not from memory. (0 and 1 is the pair people misremember; there is no LAPolicy 0, and passing one is not a mistake the framework forgives — see below.)

Any other policy value is rejected with ErrInvalidPolicy before the framework is reached.

A bool would be a lie

Evaluate returns an error, because "it did not succeed" covers a dozen situations a caller must treat differently. Every failure is an *Error carrying the raw LAError code and the system's own localised message, and it unwraps to a sentinel, so errors.Is answers the question you actually have while errors.As keeps the detail for a log.

Sentinel LAError What it means for the caller
ErrAuthenticationFailed -1 Wrong finger or wrong password. They are present and trying; another attempt is reasonable.
ErrUserCancel -2 They pressed Cancel. A decision. Do not re-prompt.
ErrUserFallback -3 They pressed "Use Password…" under PolicyBiometrics, which ends the evaluation. Offer your own route, or re-evaluate under PolicyOwner.
ErrSystemCancel -4 The system interrupted — another app came forward, the screen locked. Nobody decided anything.
ErrPasscodeNotSet -5 No account password, so PolicyOwner has nothing to fall back to.
ErrBiometryNotAvailable -6 No usable sensor. Stop offering the option this session.
ErrBiometryNotEnrolled -7 A sensor, but no enrolled finger. Worth saying — they can fix it in System Settings.
ErrBiometryLockout -8 Five failures; the sensor is locked until the account password is entered. Retrying biometrics is pointless — re-evaluate PolicyOwner, which unlocks it as a side effect.
ErrAppCancel -9 Your own cancellation (a cancelled context).
ErrInvalidContext -10 The LAContext was already invalidated.
ErrCompanionNotAvailable -11 No paired Apple Watch nearby.
ErrBiometryNotPaired -12 Biometry lives on a removable accessory that was never paired.
ErrBiometryDisconnected -13 That accessory is paired but not connected.
ErrInvalidDimensions -14 Invalid embedded-UI dimensions. Not reachable through this package.
ErrNotInteractive -1004 UI was required but forbidden — you passed WithoutInteraction().

Plus, before the framework is reached: ErrEmptyReason, ErrInvalidPolicy, ErrUnavailable, ErrUnsupported, and ErrNotBundled (attached to a failure, never on its own).

An LAError this package does not recognise unwraps to nothing. The raw Code and Message are then the only truth there is, and inventing a category for it would be a guess.

The hard parts, honestly

The reply is an Objective-C block — and that worked

-evaluatePolicy:localizedReason:reply: is asynchronous and takes a block, not a function pointer. It was worth checking whether purego could carry that before designing around it: it can. go-macos/objc exposes NewBlock, and the darwin backend passes a real block whose Go closure hands the result back over a channel. Nothing is faked, nothing is polled, and there is no fallback API here because none was needed.

Three lifetimes have to be right, and each is a crash or a leak if it is not: the block (released from inside the handler via its own self pointer, which the block ABI passes as the callback's first parameter), the context (retained for the evaluation, because Apple cancels an evaluation whose context is deallocated), and the reason string (retained rather than trusted to an autorelease pool that may not exist on the calling thread).

Evaluate is nonetheless synchronous: the answer decides what happens next, so the caller wants to wait for it. Apple documents the reply as arriving on a private framework queue, so no run loop of yours has to be turning.

⚠ Do not call Evaluate on a GUI program's blocked main thread. Call it from a goroutine and post the result back with objc.DispatchMain.

Cancelling the context invalidates the LAContext, which dismisses the sheet; Evaluate then returns ctx.Err(), and the late reply is absorbed by a buffered channel so the framework's queue is never stuck on a send nobody is receiving.

It wants an .app bundle

LocalAuthentication identifies the asking program by its bundle: the prompt reads «AppName» is trying to <your reason>. A bare go build binary has no bundle identifier, and evaluation from one is unreliable.

Build a real bundle with go-macos/appbundle and run from inside it. When a failure arrives and this process is not bundled, the *Error says so: its Unbundled field is set, it unwraps to ErrNotBundled, and Error() names appbundle. The note is attached only to setup failures — never to ErrUserCancel, ErrUserFallback, ErrAuthenticationFailed or ErrSystemCancel, because those prove the dialog appeared and the identity plainly worked.

Two calls into this framework kill your process

Neither returns an error. Both raise NSInvalidArgumentException, and an Objective-C exception unwinding through a purego frame is not a Go panic — there is no recover() for it, the process aborts:

  • an empty localizedReason to evaluatePolicy:;
  • a policy the framework does not know to canEvaluatePolicy: — which is how ErrInvalidPolicy came to exist here. It was found by a test that passed 9999 and took the whole test binary down with Error Domain=com.apple.LocalAuthentication Code=-1001 "Unknown policy: '9999'".

Both are guarded in the portable layer, before any message is sent.

biometryType is empty until you ask something else

-[LAContext biometryType] is only populated after canEvaluatePolicy: has been called on that same context. Read straight after init it reports none on a Mac with a perfectly good Touch ID sensor, and nothing tells you why. Biometry() makes that call first and discards its answer.

Testing: no test may raise a prompt

The portable logic is covered on every platform through an injected seam that replaces LAContext entirely.

The darwin tests go further: they message a real LAContext, resolve real selectors, and run a real Objective-C block end to end. None of them can raise a prompt, and that rests on two guarantees out of Apple's own headers rather than on hope:

  • an invalidated context "can not be used for policy evaluation and an attempt to do so will fail with LAErrorInvalidContext" — there is nothing left to draw a dialog with;
  • interactionNotAllowed makes an evaluation "fail with LAErrorNotInteractive instead of displaying the authentication UI" — exposed as WithoutInteraction(), which is both the safety belt and a legitimate probe.

Machine-dependent answers are asserted on their shape — "the framework answered with an error we recognise" — never on the hardware the test happens to run on, so a CI runner with no Touch ID and a laptop with one both pass for the right reason.

The one test that really prompts is opt-in:

CGO_ENABLED=0 go test ./...                              # 100% coverage, never prompts
LOCALAUTH_LIVE_PROMPT=1 go test -run TestLivePrompt -v   # asks you for real

Relationship with go-macos/keychain

Deliberately, none in code. The two solve different halves and the boundary is worth stating.

go-macos/keychain can already store an item behind a SecAccessControl with kSecAccessControlUserPresence, and reading such an item prompts for Touch ID by itself — the Keychain asks LocalAuthentication on your behalf and nothing in this package is involved:

keychain.Set("my-app", "alice", secret, keychain.WithAccessControl(keychain.UserPresence))
secret, err := keychain.Get("my-app", "alice")   // prompts; enforced by the Keychain

That is the stronger arrangement, because the secret is genuinely unreadable until the attestation is made, rather than merely being guarded by a check that code could skip. If you have a secret, use that.

This package is for the other case: gating an action that has no secret behind it — reopening a document already decrypted in memory, revealing a field, confirming a destructive step.

There is one real seam between the two, and it is not implemented: LAContext can be passed to a Keychain query as kSecUseAuthenticationContext, so one successful evaluation authorises a subsequent read without a second prompt. That means exposing a live LAContext across a package boundary and agreeing on its lifetime; it belongs in whichever package owns the query, and it should be designed rather than fallen into.

How the PDF reader will use it

go-pdfkit's reader wants biometric unlock for a protected document. It does not need to change for this package to exist — this is the shape it will take:

// At startup, once: decide whether to draw the button at all.
canUseTouchID := localauthentication.Available(localauthentication.PolicyOwner) == nil

// When the person opens a protected document, from a goroutine — never from
// the blocked main thread.
go func() {
    err := localauthentication.Evaluate(ctx, localauthentication.PolicyOwner,
        "unlock this document")
    objc.DispatchMain(func() {
        switch {
        case err == nil:
            reveal(doc)
        case errors.Is(err, localauthentication.ErrUserCancel):
            // They said no. Leave the document closed, say nothing.
        case errors.Is(err, localauthentication.ErrBiometryLockout):
            askForPassphrase("Touch ID is locked; enter your document password")
        case errors.Is(err, localauthentication.ErrNotBundled):
            log.Printf("not running from an .app bundle: %v", err)
            askForPassphrase("")
        default:
            askForPassphrase("")
        }
    })
}()

The document's own passphrase stays the real protection; this only decides whether the reader has to ask for it again. If the reader ever wants to keep a passphrase, that belongs in go-macos/keychain with WithAccessControl(UserPresence), not here.

The reader must be a real .app — which it already is, built with go-macos/appbundle.

Platforms

Darwin only. Every exported symbol is defined on every platform so consumers cross-compile; off darwin each entry point returns ErrUnsupported. CI builds the darwin backend for amd64 and arm64 and the stub for six 64-bit targets.

License

BSD-3-Clause. See LICENSE.

Documentation

Overview

Package localauthentication asks macOS to authenticate the person at the keyboard — Touch ID, or the login password — from pure Go with CGO_ENABLED=0.

It binds LAContext from the LocalAuthentication framework through github.com/go-macos/objc (Objective-C message sends over github.com/ebitengine/purego), so it links with no cgo.

What this is, and what it is not

A biometric check is NOT something a program implements. The fingerprint never leaves the Secure Enclave; nothing in this process — or in the kernel — ever sees it. What LocalAuthentication returns is an ATTESTATION: the system says that the device owner was present and identified themselves. This package is a way to ask for that attestation and to report faithfully what came back. It cannot verify anything itself, and it can be lied to by anything that can patch this process, so it is a gate on convenience, not a cryptographic control. Data that must actually stay secret is protected by storing the key in the Keychain behind a SecAccessControl — see "Should this package know about the Keychain?" below.

Using it

// Is there any biometric hardware, and of what kind?
kind, err := localauthentication.Biometry()   // TouchID, FaceID, OpticID, None

// Could this policy be evaluated right now? (No prompt; no side effect.)
err := localauthentication.Available(localauthentication.PolicyBiometrics)

// Ask. THIS PROMPTS. It blocks until the person answers.
err := localauthentication.Evaluate(ctx,
	localauthentication.PolicyOwner, "unlock this document")
switch {
case err == nil:                                        // authenticated
case errors.Is(err, localauthentication.ErrUserCancel):  // they said no
case errors.Is(err, localauthentication.ErrBiometryLockout): // too many tries
default:                                                // see the table in the README
}

A bool would be a lie

Evaluate returns an error, not a boolean, because "it did not succeed" covers a dozen situations a caller must treat differently. The person pressing Cancel (ErrUserCancel) is a decision to respect; the person pressing "Use Password…" (ErrUserFallback) is a request to offer another route; a finger that did not match (ErrAuthenticationFailed) invites another try; a Mac with no Touch ID at all (ErrBiometryNotAvailable) means never offering the option again this session; and five failures in a row (ErrBiometryLockout) means the sensor is now locked until the account password is entered, so retrying is pointless and only annoys. Collapsing all of that into false would make each of those the same, and every caller would then guess.

Every failure is a *Error carrying the raw LAError code and the system's own localised message, and it unwraps to one of the sentinels above, so errors.Is answers the question a caller actually has while errors.As keeps the detail for a log.

It needs an .app bundle

LocalAuthentication identifies the asking program by its bundle: the prompt reads "«AppName» is trying to <your reason>". A bare executable built by go build has no bundle and no bundle identifier, and evaluation from one is unreliable — it may be refused outright, and where it is not, the dialog has no name to show. Build a real bundle with github.com/go-macos/appbundle and run from inside it. When a failure arrives and this process is NOT bundled, the *Error says so (its Unbundled field is set, it unwraps to ErrNotBundled, and Error() names appbundle), so the cause is stated rather than guessed at.

This package cannot fix that for you: a bundle identity is decided when the program is packaged, not when it runs.

The reply is a block, and this API is synchronous

-[LAContext evaluatePolicy:localizedReason:reply:] is asynchronous and takes an Objective-C BLOCK, not a function pointer. That IS practicable here: github.com/go-macos/objc exposes NewBlock, and the darwin backend passes a real block whose Go closure hands the result back over a channel. Nothing is faked and nothing is polled.

Evaluate then BLOCKS until the reply arrives, because a synchronous call is what every caller of this actually wants — the answer decides what happens next. Apple documents that the reply block runs "on a private queue internal to the framework", so no run loop of yours has to be turning for it to fire.

⚠ Do not call Evaluate from the main thread of a GUI program while that
thread is blocked. AppKit needs the main thread; a program that blocks it
waiting for an answer can deny the system the thread it needs to show the
sheet. Call it from a goroutine and post the result back
(objc.DispatchMain).

Pass a context: cancelling it invalidates the LAContext, which Apple documents as terminating any evaluation in progress. Evaluate then returns ctx.Err() — the reply still fires afterwards (with LAErrorAppCancel) and the backend releases the block and the context there, so nothing leaks.

Should this package know about the Keychain?

Deliberately, it does not. github.com/go-macos/keychain can already store an item behind a SecAccessControl with kSecAccessControlUserPresence, and READING such an item prompts for Touch ID by itself — the Keychain asks LocalAuthentication on the caller's behalf, and nothing in this package is involved. That is the stronger arrangement, because the secret is genuinely unreadable until the attestation is made, instead of being readable by anything that skips a check.

This package is for the other case: gating an action that has no secret behind it — reopening a document that is already decrypted in memory, revealing a field, confirming a destructive step.

There IS one real seam between the two, and it is not implemented here: LAContext can be handed to a Keychain query as kSecUseAuthenticationContext, so one successful evaluation authorises a subsequent read without a second prompt. Wiring that up means exposing a live LAContext across a package boundary and agreeing on its lifetime; it belongs in whichever package owns the query, and it should be designed rather than fallen into. Until then the two packages stay independent, and a caller that wants a gate AND a secret uses keychain's WithAccessControl(UserPresence) — one prompt, enforced by the Keychain.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrUnsupported is what every entry point answers away from macOS.
	// The exported surface exists on every platform so consumers
	// cross-compile.
	ErrUnsupported = errors.New("localauthentication: unsupported on this platform (macOS only)")
	// ErrUnavailable means the LocalAuthentication framework, the LAContext
	// class, or a context object could not be obtained. A message to a nil
	// object returns zero IN SILENCE, so every acquisition here is checked and
	// this is what a nil one is reported as.
	ErrUnavailable = errors.New("localauthentication: the LocalAuthentication framework is not available")
	// ErrEmptyReason is returned by [Evaluate] when reason is "". This is a
	// guard, not a preference: -[LAContext evaluatePolicy:localizedReason:reply:]
	// raises NSInvalidArgumentException for an empty reason, and an
	// Objective-C exception crossing a purego frame terminates the process —
	// there is no recover() for it.
	ErrEmptyReason = errors.New("localauthentication: a localized reason is required")
	// ErrInvalidPolicy is returned when the policy is not one this package
	// defines.
	//
	// This one was found the hard way, by a test. LAContext does NOT return an
	// error for a policy it does not know: -canEvaluatePolicy:error: RAISES
	// NSInvalidArgumentException ("Unknown policy: '9999'"), and an Objective-C
	// exception unwinding through a purego frame is not recoverable — the
	// process aborts, with an NSException backtrace and no Go panic to catch.
	// So the policy is checked here, before the framework is reached, exactly
	// as the empty reason is.
	ErrInvalidPolicy = errors.New("localauthentication: unknown policy")
	// ErrNotBundled reports that this process is not running from an .app
	// bundle, so LocalAuthentication has no application identity to show in
	// its prompt. A [*Error] unwraps to this in ADDITION to its own cause when
	// the failure is one a missing bundle could explain. Build a bundle with
	// github.com/go-macos/appbundle.
	ErrNotBundled = errors.New("localauthentication: this process is not an .app bundle (see github.com/go-macos/appbundle)")
)

Errors this package raises before it ever reaches the framework.

View Source
var (
	// ErrAuthenticationFailed (-1): the credential was wrong — a finger that
	// did not match, or a bad password. The person is present and trying;
	// offering another attempt is reasonable.
	ErrAuthenticationFailed = errors.New("localauthentication: authentication failed")
	// ErrUserCancel (-2): they pressed Cancel. A decision, not a fault. Do not
	// re-prompt.
	ErrUserCancel = errors.New("localauthentication: cancelled by the user")
	// ErrUserFallback (-3): they pressed "Use Password…" under
	// [PolicyBiometrics], which ENDS the evaluation. Offer your own route, or
	// re-evaluate under [PolicyOwner] so the system asks for the password
	// itself. It does not arise under PolicyOwner.
	ErrUserFallback = errors.New("localauthentication: the user asked to fall back to a password")
	// ErrSystemCancel (-4): the system interrupted the dialog — another app
	// came forward, the screen locked. Nobody decided anything; retrying when
	// the program is frontmost again is fine.
	ErrSystemCancel = errors.New("localauthentication: cancelled by the system")
	// ErrPasscodeNotSet (-5): the account has no password set, so
	// [PolicyOwner] has nothing to fall back to.
	ErrPasscodeNotSet = errors.New("localauthentication: no device passcode is set")
	// ErrBiometryNotAvailable (-6): this Mac has no usable biometric sensor,
	// or this process may not use it. Stop offering the option.
	ErrBiometryNotAvailable = errors.New("localauthentication: biometry is not available")
	// ErrBiometryNotEnrolled (-7): there is a sensor but no fingerprint is
	// enrolled. Worth telling the person, because they can fix it in System
	// Settings.
	ErrBiometryNotEnrolled = errors.New("localauthentication: biometry has no enrolled identity")
	// ErrBiometryLockout (-8): five failures in a row; the sensor is locked
	// until the account password is entered. Retrying [PolicyBiometrics] is
	// pointless — re-evaluate [PolicyOwner], which asks for the password and
	// unlocks the sensor as a side effect.
	ErrBiometryLockout = errors.New("localauthentication: biometry is locked out after too many failed attempts")
	// ErrAppCancel (-9): the program itself cancelled — this is what a
	// cancelled context produces, after [Evaluate] has already returned
	// ctx.Err().
	ErrAppCancel = errors.New("localauthentication: cancelled by the application")
	// ErrInvalidContext (-10): the LAContext was already invalidated. This
	// package builds a fresh one per call, so it should not occur.
	ErrInvalidContext = errors.New("localauthentication: the authentication context is invalid")
	// ErrCompanionNotAvailable (-11): no paired Apple Watch nearby. Only for
	// the companion policies, which this package does not expose.
	ErrCompanionNotAvailable = errors.New("localauthentication: no companion device is available")
	// ErrBiometryNotPaired (-12): biometry is provided by a removable
	// accessory (a Magic Keyboard with Touch ID) that has never been paired.
	ErrBiometryNotPaired = errors.New("localauthentication: the biometric accessory is not paired")
	// ErrBiometryDisconnected (-13): that accessory is paired but not
	// connected right now.
	ErrBiometryDisconnected = errors.New("localauthentication: the biometric accessory is disconnected")
	// ErrInvalidDimensions (-14): the embedded authentication view was given
	// invalid dimensions. Not reachable through this package.
	ErrInvalidDimensions = errors.New("localauthentication: invalid embedded UI dimensions")
	// ErrNotInteractive (-1004): UI was required but forbidden, because
	// [WithoutInteraction] was used. This is the outcome that lets the test
	// suite drive a real evaluation end to end without ever showing a prompt.
	ErrNotInteractive = errors.New("localauthentication: interaction is not allowed")
)

The LAError codes, as sentinels. Each is what a *Error unwraps to, so a caller writes errors.Is(err, ErrUserCancel) rather than comparing integers. The codes are LAError's, read from LAPublicDefines.h in the macOS SDK.

Functions

func Available

func Available(policy Policy) error

Available reports whether policy could be evaluated right now, returning nil if it could and a *Error saying why not if it could not — no enrolled finger (ErrBiometryNotEnrolled), a locked-out sensor (ErrBiometryLockout), no password set (ErrPasscodeNotSet).

It shows no UI and has no side effect, so it is the right call for deciding whether to draw an "Unlock with Touch ID" button at all. Its answer is a snapshot: consume it now rather than caching it, because a finger can be enrolled, or a sensor locked out, between this call and the next.

A policy this package does not define is rejected with ErrInvalidPolicy before the framework is reached — LAContext throws for one, and that throw kills the process.

Apple warns that calling it from inside an evaluation's reply block can deadlock. This package never does, and neither should you.

func Evaluate

func Evaluate(ctx context.Context, policy Policy, reason string, opts ...Option) error

Evaluate asks the person at the keyboard to authenticate under policy, and BLOCKS until they answer. It returns nil when the system attests that they did, and otherwise a *Error that unwraps to the sentinel for what happened — see the package comment for why that is not a boolean.

reason is shown to the person, completing the sentence «AppName» is trying to <reason>. Write it as a verb phrase ("unlock this document"), lower case, no trailing full stop. It must not be empty: the framework raises NSInvalidArgumentException for an empty reason and that exception kills the process, so ErrEmptyReason is returned before the framework is reached.

Cancelling ctx invalidates the context, which terminates the evaluation and dismisses the dialog; Evaluate then returns ctx.Err(). A ctx already cancelled on entry means no prompt is ever shown.

A policy this package does not define is rejected with ErrInvalidPolicy before the framework is reached, for the same reason an empty reason is: the framework's answer to either is an Objective-C exception, not an error.

⚠ It prompts, and it blocks. Do not call it on a GUI program's main thread — see the package comment.

func Gate

func Gate(ctx context.Context, policy Policy, reason string, opts ...Option) error

Gate is the convenience an app reaches for when biometric unlock is a CONVENIENCE, not a security boundary: it runs Evaluate for policy and reason when the policy can be evaluated, and returns nil WITHOUT prompting when it cannot — no enrolled biometrics and no passcode, an unbundled process, or a non-darwin build. So a person who simply has no way to satisfy the check is never locked out of their own app; the gate just isn't there for them.

It returns nil when the person authenticated (or there was nothing to authenticate against), and a *Error only when the policy WAS evaluable and they failed or cancelled. A caller that must ENFORCE presence — refuse to proceed when biometrics are absent — should call Available and Evaluate itself and treat an unavailable policy as a hard stop.

Like Evaluate it prompts and blocks: do not call it on a GUI program's main thread.

Types

type BiometryType

type BiometryType int

BiometryType is an LABiometryType: which biometric sensor this Mac has, if any. The values are a bit set in LAPublicDefines.h, but Apple's own property reports exactly one of them.

const (
	// BiometryNone means there is no biometric sensor, or none this process
	// may use.
	BiometryNone BiometryType = 0
	// BiometryTouchID is a fingerprint sensor — the only kind a Mac has today.
	BiometryTouchID BiometryType = 1
	// BiometryFaceID exists in LABiometryType and is declared available on
	// macOS 10.15+, but no Mac ships it. Handle it; do not expect it.
	BiometryFaceID BiometryType = 2
	// BiometryOpticID is Apple Vision Pro's iris sensor. Declared on macOS 14+
	// for source compatibility; no Mac has one.
	BiometryOpticID BiometryType = 4
)

The biometry kinds LABiometryType defines.

func Biometry

func Biometry() (BiometryType, error)

Biometry reports which biometric sensor this Mac has: BiometryTouchID, or BiometryNone on a Mac without one (or where this process may not use it).

The trap it works around: -[LAContext biometryType] is only populated AFTER -canEvaluatePolicy: has been called on that same context. Read straight after init it reports None on a machine with a perfectly good Touch ID sensor, and nothing tells you why. So this calls CanEvaluate first and discards its answer — the sensor's existence is a different question from whether it can be used right now, which is Available's.

It shows no UI.

func (BiometryType) String

func (b BiometryType) String() string

String names the sensor.

type Error

type Error struct {
	// Op is the failing operation: "available" or "evaluate".
	Op string
	// Code is the raw LAError code (negative), or 0 when the framework
	// reported a failure without an NSError.
	Code int
	// Message is -[NSError localizedDescription], in the user's language. It
	// may be empty.
	Message string
	// Unbundled records that this process is not an .app bundle AND that the
	// failure is one a missing bundle identity could explain. When it is set
	// the error also unwraps to [ErrNotBundled].
	Unbundled bool
}

Error is a failure reported by LocalAuthentication. It keeps the raw LAError code and the system's own localised message — the sentence the user would have seen — and unwraps to the matching sentinel so errors.Is works.

func (*Error) Error

func (e *Error) Error() string

Error implements the error interface.

func (*Error) Unwrap

func (e *Error) Unwrap() []error

Unwrap returns the sentinel for this error's LAError code and, when the process is unbundled and that could explain the failure, ErrNotBundled as well — so errors.Is finds either. An unrecognised code unwraps to nothing: the raw Code is then the only truth there is, and inventing a category for it would be a guess.

type Option

type Option func(*options)

Option customises the dialog, or suppresses it. Options are applied left to right and affect only the call they are passed to — each call builds its own LAContext.

func WithCancelTitle

func WithCancelTitle(title string) Option

WithCancelTitle renames the dialog's cancel button, whose default title is "Cancel". An empty title restores the default rather than hiding the button: a dialog the person cannot dismiss is not something this package will build.

func WithFallbackTitle

func WithFallbackTitle(title string) Option

WithFallbackTitle renames the dialog's fallback button, whose default title is "Use Password…". Passing "" HIDES the button, which is the way to offer biometrics with no escape hatch of the system's own; under PolicyBiometrics pressing it yields ErrUserFallback, so hiding it removes an outcome the caller would otherwise have to handle.

func WithoutInteraction

func WithoutInteraction() Option

WithoutInteraction sets -[LAContext setInteractionNotAllowed:], so the evaluation fails with ErrNotInteractive INSTEAD of showing any UI.

It is not a way to authenticate silently — nothing can — it is a way to exercise the full path (context, block, reply, error mapping) against the real framework with a guarantee that no prompt appears. That is how this package's own darwin tests run on a developer's laptop and on CI without ever putting a Touch ID sheet in front of anyone. It is also a legitimate probe in production: it distinguishes "the system would ask" from "the system would refuse before asking".

type Policy

type Policy int

Policy is an LAPolicy: what the system should accept as proof.

const (
	// PolicyBiometrics is LAPolicyDeviceOwnerAuthenticationWithBiometrics:
	// Touch ID and nothing else. If biometry is unavailable, unenrolled or
	// locked out, evaluation FAILS rather than falling back — which is the
	// point of asking for it. The dialog's "Use Password…" button ends the
	// evaluation with [ErrUserFallback], leaving the caller to decide what to
	// offer instead.
	PolicyBiometrics Policy = 1
	// PolicyOwner is LAPolicyDeviceOwnerAuthentication: Touch ID OR the
	// account password. If biometry cannot be used the password is asked for
	// straight away, and "Use Password…" switches the same dialog over rather
	// than ending it — so [ErrUserFallback] does not arise. This is the right
	// choice for unlocking something the owner must always be able to reach.
	PolicyOwner Policy = 2
)

The two policies that make sense on macOS. The values are LAPolicy's, read from LAPublicDefines.h in the macOS SDK.

(Note for anyone checking against a half-remembered constant: these are 1 and 2, not 0 and 1. There is no LAPolicy 0.)

func (Policy) String

func (p Policy) String() string

String names the policy.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL