import { startRegistration, startAuthentication, browserSupportsWebAuthn, WebAuthnAbortService, } from '@simplewebauthn/browser' import { Capacitor, registerPlugin } from '@capacitor/core' export interface RegisteredKey { credentialId: string nickname: string createdAt: number transports: string[] deviceType: string | null lastUsedAt?: number | null enrolledBy?: number | null } // Native Android bridge (android/app/src/main/java/com/logship/mobile/WebAuthnPlugin.java). // Default mode "key": YubiKit runs the ceremony inside the app (own "hold the key" dialog, NFC // reader mode + USB) and signs for the WEB origin, so no Credential Manager sheet, no Play // services and no asset links are involved. Mode "system" = Android Credential Manager fallback. // Both methods take and return the same JSON shapes as @simplewebauthn/browser. export interface NativeKeyCapabilities { available: boolean nfc?: boolean nfcEnabled?: boolean usb?: boolean system?: boolean yubikit?: boolean error?: string } interface NativeCeremonyOptions { requestJson: string mode?: 'key' | 'system' origin?: string title?: string message?: string detectedMessage?: string touchMessage?: string retryMessage?: string nfcOffMessage?: string cancelLabel?: string } interface NativeWebAuthnPlugin { isAvailable(): Promise create(options: NativeCeremonyOptions): Promise<{ responseJson: string; transport?: string }> get(options: NativeCeremonyOptions): Promise<{ responseJson: string; transport?: string }> cancel(): Promise } const NativeWebAuthn = registerPlugin('WebAuthn') const isNative = () => typeof window !== 'undefined' && Capacitor.isNativePlatform() // The origin the in-app ceremony signs for — must be WEBAUTHN_ORIGIN on the server (the app build // carries it as public.apiBase, default https://app.logship.de). const DEFAULT_WEB_ORIGIN = 'https://app.logship.de' // Normalise plugin failures to the DOMException names the web flow already handles. function nativeError(e: any): Error { const msg = String(e?.message ?? e ?? '') const code = String(e?.code ?? '') const err: any = new Error(msg || 'Security key operation failed') if (code === 'NotAllowedError' || /cancel/i.test(msg)) err.name = 'NotAllowedError' else if (code === 'InvalidStateError') err.name = 'InvalidStateError' else if (code) err.name = code return err } const nativeCreate = async (optionsJSON: any, opts: Omit) => { try { const r = await NativeWebAuthn.create({ ...opts, requestJson: JSON.stringify(optionsJSON) }) return JSON.parse(r.responseJson) } catch (e) { throw nativeError(e) } } const nativeGet = async (optionsJSON: any, opts: Omit) => { try { const r = await NativeWebAuthn.get({ ...opts, requestJson: JSON.stringify(optionsJSON) }) return JSON.parse(r.responseJson) } catch (e) { throw nativeError(e) } } // Strip our extra fields before handing the options to the authenticator. const pickOptions = (res: any) => { const { challengeToken, credentialCount, needsUserName, targetUser, ...options } = res ?? {} return options } export const useWebAuthn = () => { let nativeAvailable: boolean | null = null const capabilities = ref(null) const { t } = useTranslation() const config = useRuntimeConfig() // Texts for the native "hold the key" dialog + the origin the in-app ceremony signs for. const nativeOpts = (create: boolean): Omit => { const tr: any = t.value || {} const apiBase = String((config.public as any)?.apiBase || '').replace(/\/+$/, '') return { mode: 'key', origin: apiBase || DEFAULT_WEB_ORIGIN, title: create ? (tr.security_key_enrol_dialog_title || 'Enrol security key') : (tr.security_key_dialog_title || 'Security key'), message: tr.security_key_dialog_message || 'Hold your YubiKey flat against the back of the phone, or plug it into the USB port.', detectedMessage: tr.security_key_dialog_detected || 'Key detected – keep holding it still…', touchMessage: tr.security_key_dialog_touch || 'Key connected – touch the gold contact on the key.', retryMessage: tr.security_key_dialog_retry || 'Connection lost – hold the key to the phone again and keep it still.', nfcOffMessage: tr.security_key_dialog_nfc_off || 'NFC is switched off on this phone. Enable it in the settings or plug the key in via USB.', cancelLabel: tr.cancel || 'Cancel', } } /** Synchronous capability check (web). The native plugin is probed by isAvailable(). */ const isSupported = (): boolean => { if (typeof window === 'undefined') return false if (isNative()) return nativeAvailable !== false try { return browserSupportsWebAuthn() } catch { return false } } /** Async capability check incl. the native plugin — use this on mount. */ const isAvailable = async (): Promise => { if (typeof window === 'undefined') return false if (isNative()) { try { const r = await NativeWebAuthn.isAvailable() capabilities.value = r nativeAvailable = r?.available !== false } catch (e: any) { // The plugin is compiled into every app build — a rejection here means it failed to load. // Keep the button visible so the tap surfaces the reason instead of silently vanishing. capabilities.value = { available: true, error: 'WebAuthn plugin unavailable: ' + String(e?.message ?? e) } nativeAvailable = true } return nativeAvailable } return isSupported() } /** Abort a running in-app ceremony (page leaves while the dialog is open). */ const cancelNative = async () => { if (!isNative()) return try { await NativeWebAuthn.cancel() } catch {} } const createCredential = async (optionsJSON: any) => { if (isNative()) return nativeCreate(optionsJSON, nativeOpts(true)) try { WebAuthnAbortService.cancelCeremony() } catch {} return startRegistration({ optionsJSON }) } const getAssertion = async (optionsJSON: any) => { if (isNative()) { if (capabilities.value?.error && capabilities.value?.yubikit === false) { const err: any = new Error(capabilities.value.error) err.name = 'NotSupportedError' throw err } return nativeGet(optionsJSON, nativeOpts(false)) } // After a user-cancelled prompt, the lib's internal AbortController can still hold the // previous signal. Without resetting it, calling startAuthentication again silently no-ops // in some browsers and the user has to reload the page. try { WebAuthnAbortService.cancelCeremony() } catch {} return startAuthentication({ optionsJSON }) } /** * Enrol a key. Without `forUserId` → the logged-in user's own key (profile page). With it → * admin enrolment for that employee through /api/admin/users/[id]/webauthn/*. */ const register = async (nickname: string, opts: { forUserId?: number } = {}) => { const base = opts.forUserId ? `/api/admin/users/${opts.forUserId}/webauthn` : '/api/idempiere-auth/webauthn' const res: any = await $fetch(`${base}/register-options`, { method: 'POST', headers: useRequestHeaders(['cookie']), }) const attestation = await createCredential(pickOptions(res)) return await $fetch(`${base}/register-verify`, { method: 'POST', headers: useRequestHeaders(['cookie']), body: { response: attestation, nickname, challengeToken: res?.challengeToken }, }) } /** * Tap-only login. Resolves `{ needsUserName: true }` when the server wants a username first * (global allow-list disabled / too large); pass `userName` on the next call. */ const loginWithKey = async (opts: { userName?: string; mobileWorker?: boolean } = {}): Promise => { const res: any = await $fetch('/api/idempiere-auth/webauthn/login-options', { method: 'POST', headers: useRequestHeaders(['cookie']), body: { userName: opts.userName || undefined }, }) if (res?.needsUserName) return { needsUserName: true } const assertion = await getAssertion(pickOptions(res)) const isPwa = typeof window !== 'undefined' && (window.matchMedia('(display-mode: standalone)').matches || !!(navigator as any)['standalone']) return await $fetch('/api/idempiere-auth/webauthn/login-verify', { method: 'POST', headers: useRequestHeaders(['cookie']), body: { response: assertion, challengeToken: res?.challengeToken, pwa: isPwa, mobileWorker: !!opts.mobileWorker, }, }) } const listKeys = async (forUserId?: number): Promise<{ keys: RegisteredKey[]; hasPasswordStored: boolean }> => { const url = forUserId ? `/api/admin/users/${forUserId}/webauthn` : '/api/idempiere-auth/webauthn/list' return await $fetch(url, { headers: useRequestHeaders(['cookie']) }) } const deleteKey = async (credentialId: string, forUserId?: number) => { const url = forUserId ? `/api/admin/users/${forUserId}/webauthn/destroy` : '/api/idempiere-auth/webauthn/delete' return await $fetch(url, { method: 'DELETE', headers: useRequestHeaders(['cookie']), body: { credentialId }, }) } return { isSupported, isAvailable, capabilities, cancelNative, register, loginWithKey, listKeys, deleteKey } }