@localauth/cli 0.3.0 → 0.4.0
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 +1 -0
- package/cli/ask.ts +57 -0
- package/cli/cli.ts +8 -1
- package/cli/run.ts +2 -2
- package/package.json +6 -2
- package/shared/protocol.ts +3 -1
- package/shared/validation.ts +1 -0
- package/skills/localauth/SKILL.md +9 -0
package/README.md
CHANGED
|
@@ -61,6 +61,7 @@ localauth show <id> # one login with its output (--json for scri
|
|
|
61
61
|
localauth status # account and service this machine is connected to
|
|
62
62
|
localauth verify # match a number on the page to trust end-to-end encryption
|
|
63
63
|
localauth logout # disconnect this machine
|
|
64
|
+
localauth approve --title <text> [--description <text>] [--timeout <minutes>]
|
|
64
65
|
```
|
|
65
66
|
|
|
66
67
|
`localauth finish` matches the address to the waiting login by port and OAuth state, so a user
|
package/cli/ask.ts
CHANGED
|
@@ -159,3 +159,60 @@ export async function ask(connection: Connection, options: AskOptions, say: (tex
|
|
|
159
159
|
await run.close()
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
|
+
|
|
163
|
+
export interface ApproveOptions { title: string; description?: string; timeout: number }
|
|
164
|
+
export function parseApproveArgs(rest: string[]): ApproveOptions {
|
|
165
|
+
let options: ApproveOptions = { title: '', timeout: 15 }
|
|
166
|
+
for (let i = 0; i < rest.length; i++) {
|
|
167
|
+
let value = rest[i + 1]
|
|
168
|
+
if (value === undefined) throw new Error(`${rest[i]} needs a value.`)
|
|
169
|
+
if (rest[i] === '--title') options.title = value
|
|
170
|
+
else if (rest[i] === '--description') options.description = value
|
|
171
|
+
else if (rest[i] === '--timeout') {
|
|
172
|
+
options.timeout = Number(value)
|
|
173
|
+
if (!Number.isFinite(options.timeout) || options.timeout <= 0 || options.timeout > 60) throw new Error('--timeout must be greater than 0 and at most 60.')
|
|
174
|
+
} else throw new Error(`Unknown localauth approve option "${rest[i]}".`)
|
|
175
|
+
i++
|
|
176
|
+
}
|
|
177
|
+
if (!options.title) throw new Error('localauth approve needs --title.')
|
|
178
|
+
return options
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Only an answer sealed with the pinned account key can approve; anything else that arrives can only decline. */
|
|
182
|
+
export async function approve(connection: Connection, options: ApproveOptions, say: (text: string) => void): Promise<number> {
|
|
183
|
+
if (!connection.accountPublicKey) throw new Error('localauth approve needs end-to-end encryption: add a passkey on your account, then run localauth verify.')
|
|
184
|
+
let expiresAt = Date.now() + Math.ceil(options.timeout * 60_000)
|
|
185
|
+
let request: AskRequest = { title: options.title, description: options.description, fields: [{ name: 'APPROVE', label: 'Approve', secret: false, required: true }] }
|
|
186
|
+
let data: RunData = { authenticatedMessages: true, kind: 'ask', approval: true, title: options.title, ask: request, expiresAt, command: '', flows: [], output: '', exitCode: null, attempt: 1, retryUntil: null }
|
|
187
|
+
if (!isAskRequest(data.ask)) throw new Error('Invalid approval request.')
|
|
188
|
+
let run = await createRun(connection, [], say, { retry: false, link: false }, data)
|
|
189
|
+
let stop = new AbortController(), interrupted = 0
|
|
190
|
+
let listeners = Object.entries({ SIGINT: 130, SIGTERM: 143, SIGHUP: 129 }).map(([signal, code]) => {
|
|
191
|
+
let listener = () => { interrupted = code; stop.abort() }
|
|
192
|
+
process.on(signal, listener); return { signal, listener }
|
|
193
|
+
})
|
|
194
|
+
try {
|
|
195
|
+
await run.publish()
|
|
196
|
+
let decided: boolean | null = null
|
|
197
|
+
while (!stop.signal.aborted && Date.now() < expiresAt && decided === null) {
|
|
198
|
+
let signal = AbortSignal.any([stop.signal, AbortSignal.timeout(Math.max(1, expiresAt - Date.now()))])
|
|
199
|
+
try {
|
|
200
|
+
for (let message of await run.takeMessages(signal)) {
|
|
201
|
+
if (message.kind === 'decline') { decided = false; break }
|
|
202
|
+
if (message.kind !== 'answer' || typeof message.data !== 'string' || !message.data.startsWith('a1.')) continue
|
|
203
|
+
let value = await run.openMessage<AnswerData>('answer', message.data)
|
|
204
|
+
if (isAnswer(value) && !value.declined && answerFields(value, request)?.APPROVE === 'yes') { decided = true; break }
|
|
205
|
+
}
|
|
206
|
+
if (decided === null) await sleep(Math.min(1500, Math.max(1, expiresAt - Date.now())), undefined, { signal })
|
|
207
|
+
} catch (error) { if (!signal.aborted) throw error }
|
|
208
|
+
}
|
|
209
|
+
data.exitCode = decided ? 0 : 1
|
|
210
|
+
await run.publish(AbortSignal.timeout(3000)).catch(() => {})
|
|
211
|
+
if (interrupted) return interrupted
|
|
212
|
+
say(decided ? 'localauth: approved.' : decided === false ? 'localauth: the owner declined the request.' : 'localauth: the request timed out.')
|
|
213
|
+
return decided ? 0 : 1
|
|
214
|
+
} finally {
|
|
215
|
+
for (let { signal, listener } of listeners) process.off(signal, listener)
|
|
216
|
+
await run.close()
|
|
217
|
+
}
|
|
218
|
+
}
|
package/cli/cli.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ask, parseAskArgs } from './ask.ts'
|
|
1
|
+
import { ask, parseAskArgs, approve, parseApproveArgs } from './ask.ts'
|
|
2
2
|
import * as os from 'node:os'
|
|
3
3
|
import { createTerminal } from 'remix/terminal'
|
|
4
4
|
|
|
@@ -32,6 +32,10 @@ Usage:
|
|
|
32
32
|
LD_*, NODE_OPTIONS, BROWSER, LOCALAUTH_* or other loader variables
|
|
33
33
|
Title/labels: max 120; description: max 2000 (plain text + links)
|
|
34
34
|
Requests expire in 15 minutes (max 60); decline/timeout exits 1
|
|
35
|
+
localauth approve --title <text> [--description <text>] [--timeout <minutes>]
|
|
36
|
+
Wait for the owner to allow something. They answer from their
|
|
37
|
+
signed-in list with a fresh passkey check; no link is made
|
|
38
|
+
Needs end-to-end encryption; exits 0 if approved, 1 otherwise
|
|
35
39
|
localauth finish [<callback-url>] Hand a pasted http://localhost:... address to the waiting command
|
|
36
40
|
(reads it from stdin when omitted, keeping it out of ps)
|
|
37
41
|
localauth list [--json] This machine's logins and requests
|
|
@@ -78,6 +82,9 @@ export async function main(args: string[]): Promise<number> {
|
|
|
78
82
|
case 'ask':
|
|
79
83
|
return await ask(requireConnection(), parseAskArgs(rest), say)
|
|
80
84
|
|
|
85
|
+
case 'approve':
|
|
86
|
+
return await approve(requireConnection(), parseApproveArgs(rest), say)
|
|
87
|
+
|
|
81
88
|
case 'browser':
|
|
82
89
|
return await reportBrowserUrls(rest, say)
|
|
83
90
|
|
package/cli/run.ts
CHANGED
|
@@ -159,7 +159,7 @@ export async function createRun(connection: Connection | null, argv: string[], s
|
|
|
159
159
|
snapshot.output = redactor.redact(snapshot.output)
|
|
160
160
|
for (let flow of snapshot.flows) flow.note = redactor.redact(flow.note)
|
|
161
161
|
let sealed = await encodeRun(connection!, id, snapshot, signal)
|
|
162
|
-
let reply = await service('PUT', `/api/v1/machine/runs/${id}`, { state: runState(snapshot), data: sealed, kind: data.kind ?? 'login', ...(data.kind === 'ask' ? { expiresAt: data.expiresAt } : {}), ...(link ? { linkTokenHash: link.tokenHash, linkData: await sealLink(link.key, 'run', id, snapshot) } : {}) }, signal).catch((error) => {
|
|
162
|
+
let reply = await service('PUT', `/api/v1/machine/runs/${id}`, { state: runState(snapshot), data: sealed, kind: data.kind ?? 'login', ...(data.approval ? { approval: true } : {}), ...(data.kind === 'ask' ? { expiresAt: data.expiresAt } : {}), ...(link ? { linkTokenHash: link.tokenHash, linkData: await sealLink(link.key, 'run', id, snapshot) } : {}) }, signal).catch((error) => {
|
|
163
163
|
if (!offline) say(`localauth: ${(error as Error).message}`)
|
|
164
164
|
offline = true
|
|
165
165
|
return null
|
|
@@ -253,7 +253,7 @@ export async function createRun(connection: Connection | null, argv: string[], s
|
|
|
253
253
|
if (reply.status === 404) throw new Error('This request expired or was dismissed.')
|
|
254
254
|
return reply.status === 200 ? reply.data.data : []
|
|
255
255
|
},
|
|
256
|
-
async openMessage<T>(kind: MessageKind, value: unknown) {
|
|
256
|
+
async openMessage<T>(kind: Exclude<MessageKind, 'decline'>, value: unknown) {
|
|
257
257
|
return openMessage<T>({ privateKey: key!, publicKey: connection!.publicKey, accountPublicKey: connection?.accountPublicKey, linkKey: link?.key }, kind, id, value)
|
|
258
258
|
},
|
|
259
259
|
async takeRetry(signal: AbortSignal) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@localauth/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Finish CLI logins on a headless server from your phone, with optional passkey-based end-to-end encryption",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"build": "node scripts/build-client.ts",
|
|
41
41
|
"deploy": "wrangler deploy",
|
|
42
42
|
"test": "NODE_ENV=test remix test",
|
|
43
|
-
"typecheck": "tsc --noEmit",
|
|
43
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p app/landing-demo",
|
|
44
44
|
"db:generate": "drizzle-kit generate",
|
|
45
45
|
"db:migrate:local": "wrangler d1 migrations apply localauth --local",
|
|
46
46
|
"db:migrate": "wrangler d1 migrations apply localauth --remote",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"node": ">=24.3.0"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
+
"@simplewebauthn/server": "13.3.3",
|
|
55
56
|
"remix": "^3.0.0"
|
|
56
57
|
},
|
|
57
58
|
"devDependencies": {
|
|
@@ -62,6 +63,8 @@
|
|
|
62
63
|
"@remix-run/ui": "0.12.1",
|
|
63
64
|
"@simplewebauthn/browser": "^13.3.0",
|
|
64
65
|
"@types/node": "^26.6.5",
|
|
66
|
+
"@types/react": "18.3.12",
|
|
67
|
+
"@types/react-dom": "18.3.1",
|
|
65
68
|
"auth": "1.7.12",
|
|
66
69
|
"better-auth": "1.7.12",
|
|
67
70
|
"drizzle-kit": "^0.31.11",
|
|
@@ -70,6 +73,7 @@
|
|
|
70
73
|
"jose": "^6.2.12",
|
|
71
74
|
"patch-package": "8.0.1",
|
|
72
75
|
"playwright-core": "^1.64.1",
|
|
76
|
+
"preact": "10.25.4",
|
|
73
77
|
"typescript": "^7.0.2",
|
|
74
78
|
"wrangler": "^4.151.0"
|
|
75
79
|
}
|
package/shared/protocol.ts
CHANGED
|
@@ -21,7 +21,7 @@ export interface Flow {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/** Login details: plaintext in the owner API by default, opaque in end-to-end mode. */
|
|
24
|
-
export type MessageKind = 'callback' | 'input' | 'answer'
|
|
24
|
+
export type MessageKind = 'callback' | 'input' | 'answer' | 'decline'
|
|
25
25
|
export interface InputData { attempt: number; text: string; messageId: string }
|
|
26
26
|
export interface AskField { name: string; label: string; secret: boolean; required: boolean }
|
|
27
27
|
export interface AskRequest { title?: string; description?: string; fields: AskField[] }
|
|
@@ -33,6 +33,8 @@ export type AnswerData = { declined: true; messageId: string }
|
|
|
33
33
|
export interface RunData {
|
|
34
34
|
authenticatedMessages?: boolean
|
|
35
35
|
kind?: 'login' | 'ask'
|
|
36
|
+
/** Answered only with a fresh passkey check, never by typing or through a link. */
|
|
37
|
+
approval?: boolean
|
|
36
38
|
inputEnabled?: boolean
|
|
37
39
|
secret?: boolean
|
|
38
40
|
ask?: AskRequest
|
package/shared/validation.ts
CHANGED
|
@@ -10,6 +10,7 @@ export function isRunData(value: unknown): value is RunData {
|
|
|
10
10
|
return !!v && typeof v.title === 'string' && typeof v.command === 'string' && typeof v.output === 'string'
|
|
11
11
|
&& (v.authenticatedMessages === undefined || typeof v.authenticatedMessages === 'boolean')
|
|
12
12
|
&& (v.kind === undefined || ['login', 'ask'].includes(v.kind))
|
|
13
|
+
&& (v.approval === undefined || (v.approval === true && v.kind === 'ask'))
|
|
13
14
|
&& (v.inputEnabled === undefined || typeof v.inputEnabled === 'boolean')
|
|
14
15
|
&& (v.secret === undefined || typeof v.secret === 'boolean')
|
|
15
16
|
&& (v.ask === undefined || (v.kind === 'ask' && isAskRequest(v.ask)))
|
|
@@ -185,3 +185,12 @@ nothing to paste, the command finishes on its own once the user approves.
|
|
|
185
185
|
title on the page (Kargo, Codex, …).
|
|
186
186
|
- If the user gives up, stop the background `localauth run` process so the command stops listening.
|
|
187
187
|
- `localauth help` lists every command.
|
|
188
|
+
|
|
189
|
+
## Asking the owner to allow something
|
|
190
|
+
|
|
191
|
+
```sh
|
|
192
|
+
localauth approve --title "Allow access to the production database" --description "Why it is needed"
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
It exits 0 if approved and 1 if declined or timed out. The owner answers from their signed-in list with a new passkey check, so there is no link to send them: tell them to open localauth.dev. It needs end-to-end encryption on the account.
|
|
196
|
+
|