accounts 0.4.0 → 0.4.2
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/CHANGELOG.md +13 -0
- package/README.md +38 -7
- package/dist/cli/Provider.d.ts +12 -0
- package/dist/cli/Provider.d.ts.map +1 -0
- package/dist/cli/Provider.js +19 -0
- package/dist/cli/Provider.js.map +1 -0
- package/dist/cli/adapter.d.ts +24 -0
- package/dist/cli/adapter.d.ts.map +1 -0
- package/dist/cli/adapter.js +173 -0
- package/dist/cli/adapter.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +3 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/core/Dialog.d.ts.map +1 -1
- package/dist/core/Dialog.js +25 -1
- package/dist/core/Dialog.js.map +1 -1
- package/dist/core/IntersectionObserver.d.ts +3 -0
- package/dist/core/IntersectionObserver.d.ts.map +1 -0
- package/dist/core/IntersectionObserver.js +6 -0
- package/dist/core/IntersectionObserver.js.map +1 -0
- package/dist/core/Messenger.d.ts +14 -3
- package/dist/core/Messenger.d.ts.map +1 -1
- package/dist/core/Messenger.js +4 -4
- package/dist/core/Messenger.js.map +1 -1
- package/dist/core/Remote.d.ts +6 -3
- package/dist/core/Remote.d.ts.map +1 -1
- package/dist/core/Remote.js +3 -6
- package/dist/core/Remote.js.map +1 -1
- package/dist/core/adapters/local.d.ts.map +1 -1
- package/dist/core/adapters/local.js +2 -2
- package/dist/core/adapters/local.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/react/Remote.d.ts +21 -0
- package/dist/react/Remote.d.ts.map +1 -0
- package/dist/react/Remote.js +51 -0
- package/dist/react/Remote.js.map +1 -0
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +2 -0
- package/dist/react/index.js.map +1 -0
- package/dist/server/CliAuth.d.ts +553 -0
- package/dist/server/CliAuth.d.ts.map +1 -0
- package/dist/server/CliAuth.js +446 -0
- package/dist/server/CliAuth.js.map +1 -0
- package/dist/server/Handler.d.ts +36 -2
- package/dist/server/Handler.d.ts.map +1 -1
- package/dist/server/Handler.js +84 -0
- package/dist/server/Handler.js.map +1 -1
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -0
- package/dist/server/index.js.map +1 -1
- package/package.json +16 -54
- package/src/cli/Provider.test-d.ts +28 -0
- package/src/cli/Provider.test.ts +235 -0
- package/src/cli/Provider.ts +26 -0
- package/src/cli/adapter.ts +229 -0
- package/src/cli/index.ts +2 -0
- package/src/core/Dialog.ts +31 -1
- package/src/core/IntersectionObserver.ts +6 -0
- package/src/core/Messenger.ts +18 -8
- package/src/core/Provider.test.ts +12 -2
- package/src/core/Remote.ts +9 -10
- package/src/core/adapters/local.ts +7 -2
- package/src/index.ts +1 -0
- package/src/react/Remote.ts +94 -0
- package/src/react/index.ts +1 -0
- package/src/server/CliAuth.test-d.ts +56 -0
- package/src/server/CliAuth.test.ts +800 -0
- package/src/server/CliAuth.ts +634 -0
- package/src/server/Handler.ts +123 -1
- package/src/server/index.ts +1 -0
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
import { Address as core_Address, Base64, Hex, PublicKey } from 'ox'
|
|
2
|
+
import { KeyAuthorization as TempoKeyAuthorization, SignatureEnvelope } from 'ox/tempo'
|
|
3
|
+
import { createClient, http, type Chain, type Client, type Transport } from 'viem'
|
|
4
|
+
import type { Address } from 'viem/accounts'
|
|
5
|
+
import { verifyHash } from 'viem/actions'
|
|
6
|
+
import { tempo } from 'viem/chains'
|
|
7
|
+
import * as z from 'zod/mini'
|
|
8
|
+
|
|
9
|
+
import * as u from '../core/zod/utils.js'
|
|
10
|
+
import type { MaybePromise } from '../internal/types.js'
|
|
11
|
+
import type { Kv } from './Kv.js'
|
|
12
|
+
|
|
13
|
+
/** Supported access-key types for CLI bootstrap. */
|
|
14
|
+
export const keyType = z.union([z.literal('secp256k1'), z.literal('p256'), z.literal('webAuthn')])
|
|
15
|
+
|
|
16
|
+
/** Signed key authorization returned by the device-code flow. */
|
|
17
|
+
export const keyAuthorization = z.object({
|
|
18
|
+
address: u.address(),
|
|
19
|
+
chainId: u.bigint(),
|
|
20
|
+
expiry: z.nullish(u.number()),
|
|
21
|
+
keyId: u.address(),
|
|
22
|
+
keyType,
|
|
23
|
+
limits: z.optional(z.readonly(z.array(z.object({ token: u.address(), limit: u.bigint() })))),
|
|
24
|
+
signature: z.custom<SignatureEnvelope.SignatureEnvelopeRpc>(),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
/** Request body for `POST /auth/pkce/code`. */
|
|
28
|
+
export const createRequest = z.object({
|
|
29
|
+
account: z.optional(u.address()),
|
|
30
|
+
codeChallenge: z.string(),
|
|
31
|
+
expiry: z.optional(z.number()),
|
|
32
|
+
keyType: z.optional(keyType),
|
|
33
|
+
limits: z.optional(z.readonly(z.array(z.object({ token: u.address(), limit: u.bigint() })))),
|
|
34
|
+
pubKey: u.hex(),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
/** Response body for `POST /cli-auth/device-code`. */
|
|
38
|
+
export const createResponse = z.object({
|
|
39
|
+
code: z.string(),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
/** Request body for `POST /auth/pkce/poll/:code`. */
|
|
43
|
+
export const pollRequest = z.object({
|
|
44
|
+
codeVerifier: z.string(),
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/** Response body for `POST /auth/pkce/poll/:code`. */
|
|
48
|
+
export const pollResponse = u.oneOf([
|
|
49
|
+
z.object({
|
|
50
|
+
status: z.literal('pending'),
|
|
51
|
+
}),
|
|
52
|
+
z.object({
|
|
53
|
+
status: z.literal('authorized'),
|
|
54
|
+
accountAddress: u.address(),
|
|
55
|
+
keyAuthorization: keyAuthorization,
|
|
56
|
+
}),
|
|
57
|
+
z.object({
|
|
58
|
+
status: z.literal('expired'),
|
|
59
|
+
}),
|
|
60
|
+
])
|
|
61
|
+
|
|
62
|
+
/** Response body for `GET /auth/pkce/pending/:code`. */
|
|
63
|
+
export const pendingResponse = z.object({
|
|
64
|
+
accessKeyAddress: u.address(),
|
|
65
|
+
account: z.optional(u.address()),
|
|
66
|
+
chainId: u.bigint(),
|
|
67
|
+
code: z.string(),
|
|
68
|
+
expiry: z.number(),
|
|
69
|
+
keyType,
|
|
70
|
+
limits: z.optional(z.readonly(z.array(z.object({ token: u.address(), limit: u.bigint() })))),
|
|
71
|
+
pubKey: u.hex(),
|
|
72
|
+
status: z.literal('pending'),
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/** Request body for `POST /auth/pkce`. */
|
|
76
|
+
export const authorizeRequest = z.object({
|
|
77
|
+
accountAddress: u.address(),
|
|
78
|
+
code: z.string(),
|
|
79
|
+
keyAuthorization: keyAuthorization,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
/** Response body for `POST /cli-auth/authorize`. */
|
|
83
|
+
export const authorizeResponse = z.object({
|
|
84
|
+
status: z.literal('authorized'),
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
/** Stored device-code entry schema. */
|
|
88
|
+
export const entry = u.oneOf([
|
|
89
|
+
z.object({
|
|
90
|
+
account: z.optional(u.address()),
|
|
91
|
+
chainId: u.bigint(),
|
|
92
|
+
code: z.string(),
|
|
93
|
+
codeChallenge: z.string(),
|
|
94
|
+
createdAt: z.number(),
|
|
95
|
+
expiresAt: z.number(),
|
|
96
|
+
expiry: z.number(),
|
|
97
|
+
keyType,
|
|
98
|
+
limits: z.optional(z.readonly(z.array(z.object({ token: u.address(), limit: u.bigint() })))),
|
|
99
|
+
pubKey: u.hex(),
|
|
100
|
+
status: z.literal('pending'),
|
|
101
|
+
}),
|
|
102
|
+
z.object({
|
|
103
|
+
account: z.optional(u.address()),
|
|
104
|
+
accountAddress: u.address(),
|
|
105
|
+
authorizedAt: z.number(),
|
|
106
|
+
chainId: u.bigint(),
|
|
107
|
+
code: z.string(),
|
|
108
|
+
codeChallenge: z.string(),
|
|
109
|
+
createdAt: z.number(),
|
|
110
|
+
expiresAt: z.number(),
|
|
111
|
+
expiry: z.number(),
|
|
112
|
+
keyAuthorization,
|
|
113
|
+
keyType,
|
|
114
|
+
limits: z.optional(z.readonly(z.array(z.object({ token: u.address(), limit: u.bigint() })))),
|
|
115
|
+
pubKey: u.hex(),
|
|
116
|
+
status: z.literal('authorized'),
|
|
117
|
+
}),
|
|
118
|
+
z.object({
|
|
119
|
+
account: z.optional(u.address()),
|
|
120
|
+
accountAddress: u.address(),
|
|
121
|
+
authorizedAt: z.number(),
|
|
122
|
+
chainId: u.bigint(),
|
|
123
|
+
code: z.string(),
|
|
124
|
+
codeChallenge: z.string(),
|
|
125
|
+
consumedAt: z.number(),
|
|
126
|
+
createdAt: z.number(),
|
|
127
|
+
expiresAt: z.number(),
|
|
128
|
+
expiry: z.number(),
|
|
129
|
+
keyAuthorization,
|
|
130
|
+
keyType,
|
|
131
|
+
limits: z.optional(z.readonly(z.array(z.object({ token: u.address(), limit: u.bigint() })))),
|
|
132
|
+
pubKey: u.hex(),
|
|
133
|
+
status: z.literal('consumed'),
|
|
134
|
+
}),
|
|
135
|
+
])
|
|
136
|
+
|
|
137
|
+
/** Stored device-code entry. */
|
|
138
|
+
export type Entry = z.output<typeof entry>
|
|
139
|
+
|
|
140
|
+
/** Device-code storage contract. */
|
|
141
|
+
export type Store = {
|
|
142
|
+
/** Saves a new pending device-code entry. */
|
|
143
|
+
create: (entry: Entry.Pending) => MaybePromise<void>
|
|
144
|
+
/** Loads a device-code entry by verification code. */
|
|
145
|
+
get: (code: string) => MaybePromise<Entry | undefined>
|
|
146
|
+
/** Marks a pending device-code as authorized. */
|
|
147
|
+
authorize: (options: Store.authorize.Options) => MaybePromise<Entry.Authorized | undefined>
|
|
148
|
+
/** Consumes an authorized device-code exactly once. */
|
|
149
|
+
consume: (code: string) => MaybePromise<Entry.Authorized | undefined>
|
|
150
|
+
/** Deletes a device-code entry. */
|
|
151
|
+
delete: (code: string) => MaybePromise<void>
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export declare namespace Entry {
|
|
155
|
+
/** Pending device-code entry. */
|
|
156
|
+
export type Pending = Extract<z.output<typeof entry>, { status: 'pending' }>
|
|
157
|
+
/** Authorized device-code entry. */
|
|
158
|
+
export type Authorized = Extract<z.output<typeof entry>, { status: 'authorized' }>
|
|
159
|
+
/** Consumed device-code entry. */
|
|
160
|
+
export type Consumed = Extract<z.output<typeof entry>, { status: 'consumed' }>
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export declare namespace Store {
|
|
164
|
+
export namespace authorize {
|
|
165
|
+
export type Options = {
|
|
166
|
+
/** Root account that approved the access key. */
|
|
167
|
+
accountAddress: Address
|
|
168
|
+
/** Signed key authorization. */
|
|
169
|
+
keyAuthorization: z.output<typeof keyAuthorization>
|
|
170
|
+
/** Verification code to authorize. */
|
|
171
|
+
code: string
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export namespace kv {
|
|
176
|
+
export type Options = {
|
|
177
|
+
/** Prefix used for KV keys. @default "cli-auth" */
|
|
178
|
+
key?: string | undefined
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Error thrown when pending device-code lookup cannot return a pending request. */
|
|
184
|
+
export class PendingError extends Error {
|
|
185
|
+
status: 400 | 404
|
|
186
|
+
|
|
187
|
+
constructor(message: string, status: 400 | 404) {
|
|
188
|
+
super(message)
|
|
189
|
+
this.name = 'PendingError'
|
|
190
|
+
this.status = status
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Host validation and sanitization for requested CLI auth defaults. */
|
|
195
|
+
export type Policy = {
|
|
196
|
+
/** Validates and optionally rewrites requested policy before the entry is stored. */
|
|
197
|
+
validate: (options: Policy.validate.Options) => MaybePromise<Policy.validate.ReturnType>
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export declare namespace Policy {
|
|
201
|
+
export namespace validate {
|
|
202
|
+
export type Options = {
|
|
203
|
+
/** Requested root account restriction. */
|
|
204
|
+
account?: Address | undefined
|
|
205
|
+
/** Requested access-key expiry timestamp. Omit to let the server choose one. */
|
|
206
|
+
expiry?: number | undefined
|
|
207
|
+
/** Requested key type. */
|
|
208
|
+
keyType: z.output<typeof keyType>
|
|
209
|
+
/** Requested spending limits. */
|
|
210
|
+
limits?: readonly { token: Address; limit: bigint }[] | undefined
|
|
211
|
+
/** Requested access-key public key. */
|
|
212
|
+
pubKey: Hex.Hex
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export type ReturnType = {
|
|
216
|
+
/** Approved access-key expiry timestamp. */
|
|
217
|
+
expiry: number
|
|
218
|
+
/** Approved spending limits. */
|
|
219
|
+
limits?: readonly { token: Address; limit: bigint }[] | undefined
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Built-in device-code store helpers. */
|
|
225
|
+
export const Store = {
|
|
226
|
+
/**
|
|
227
|
+
* Creates an in-memory device-code store.
|
|
228
|
+
*
|
|
229
|
+
* Useful for tests and single-process servers.
|
|
230
|
+
*/
|
|
231
|
+
memory(): Store {
|
|
232
|
+
const entries = new Map<string, Entry>()
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
async authorize(options) {
|
|
236
|
+
const current = entries.get(options.code)
|
|
237
|
+
if (!current || current.status !== 'pending') return undefined
|
|
238
|
+
const next = {
|
|
239
|
+
...current,
|
|
240
|
+
accountAddress: options.accountAddress,
|
|
241
|
+
authorizedAt: Date.now(),
|
|
242
|
+
keyAuthorization: options.keyAuthorization,
|
|
243
|
+
status: 'authorized',
|
|
244
|
+
} satisfies Entry.Authorized
|
|
245
|
+
entries.set(options.code, next)
|
|
246
|
+
return next
|
|
247
|
+
},
|
|
248
|
+
async consume(code) {
|
|
249
|
+
const current = entries.get(code)
|
|
250
|
+
if (!current || current.status !== 'authorized') return undefined
|
|
251
|
+
entries.set(code, {
|
|
252
|
+
...current,
|
|
253
|
+
consumedAt: Date.now(),
|
|
254
|
+
status: 'consumed',
|
|
255
|
+
} satisfies Entry.Consumed)
|
|
256
|
+
return current
|
|
257
|
+
},
|
|
258
|
+
async create(entry_) {
|
|
259
|
+
entries.set(entry_.code, entry_)
|
|
260
|
+
},
|
|
261
|
+
async delete(code) {
|
|
262
|
+
entries.delete(code)
|
|
263
|
+
},
|
|
264
|
+
async get(code) {
|
|
265
|
+
return entries.get(code)
|
|
266
|
+
},
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
/**
|
|
270
|
+
* Creates a key-value backed device-code store.
|
|
271
|
+
*
|
|
272
|
+
* Stored values are encoded through the shared entry schema so they remain
|
|
273
|
+
* JSON-safe across KV implementations.
|
|
274
|
+
*/
|
|
275
|
+
kv(kv: Kv, options: Store.kv.Options = {}): Store {
|
|
276
|
+
const key = options.key ?? 'cli-auth'
|
|
277
|
+
|
|
278
|
+
function toKey(code: string) {
|
|
279
|
+
return `${key}:${code}`
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
async authorize(options) {
|
|
284
|
+
const current = await this.get(options.code)
|
|
285
|
+
if (!current || current.status !== 'pending') return undefined
|
|
286
|
+
const next = {
|
|
287
|
+
...current,
|
|
288
|
+
accountAddress: options.accountAddress,
|
|
289
|
+
authorizedAt: Date.now(),
|
|
290
|
+
keyAuthorization: options.keyAuthorization,
|
|
291
|
+
status: 'authorized',
|
|
292
|
+
} satisfies Entry.Authorized
|
|
293
|
+
await kv.set(toKey(options.code), z.encode(entry, next))
|
|
294
|
+
return next
|
|
295
|
+
},
|
|
296
|
+
async consume(code) {
|
|
297
|
+
const current = await this.get(code)
|
|
298
|
+
if (!current || current.status !== 'authorized') return undefined
|
|
299
|
+
await kv.set(
|
|
300
|
+
toKey(code),
|
|
301
|
+
z.encode(entry, {
|
|
302
|
+
...current,
|
|
303
|
+
consumedAt: Date.now(),
|
|
304
|
+
status: 'consumed',
|
|
305
|
+
} satisfies Entry.Consumed),
|
|
306
|
+
)
|
|
307
|
+
return current
|
|
308
|
+
},
|
|
309
|
+
async create(entry_) {
|
|
310
|
+
await kv.set(toKey(entry_.code), z.encode(entry, entry_))
|
|
311
|
+
},
|
|
312
|
+
async delete(code) {
|
|
313
|
+
await kv.delete(toKey(code))
|
|
314
|
+
},
|
|
315
|
+
async get(code) {
|
|
316
|
+
const value = await kv.get<z.input<typeof entry>>(toKey(code))
|
|
317
|
+
if (!value) return undefined
|
|
318
|
+
return z.decode(entry, value)
|
|
319
|
+
},
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Built-in policy helpers. */
|
|
325
|
+
export const Policy = {
|
|
326
|
+
/** Creates an allow-all policy with a default 24-hour expiry when omitted. */
|
|
327
|
+
allow(): Policy {
|
|
328
|
+
return {
|
|
329
|
+
validate({ expiry, limits }) {
|
|
330
|
+
return {
|
|
331
|
+
expiry: expiry ?? Math.floor(Date.now() / 1000) + 60 * 60 * 24,
|
|
332
|
+
...(limits ? { limits } : {}),
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
/** Returns the provided policy unchanged. */
|
|
338
|
+
from(policy: Policy): Policy {
|
|
339
|
+
return policy
|
|
340
|
+
},
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Creates and stores a new device code. */
|
|
344
|
+
export async function createDeviceCode(
|
|
345
|
+
options: createDeviceCode.Options,
|
|
346
|
+
): Promise<createDeviceCode.ReturnType> {
|
|
347
|
+
const {
|
|
348
|
+
chainId = BigInt(tempo.id),
|
|
349
|
+
now = Date.now,
|
|
350
|
+
policy = Policy.allow(),
|
|
351
|
+
random = randomBytes,
|
|
352
|
+
request,
|
|
353
|
+
store = Store.memory(),
|
|
354
|
+
ttlMs = 10 * 60 * 1_000,
|
|
355
|
+
} = options
|
|
356
|
+
const { account, codeChallenge, pubKey } = request
|
|
357
|
+
const keyType = request.keyType ?? 'secp256k1'
|
|
358
|
+
const approved = await policy.validate({
|
|
359
|
+
...(account ? { account } : {}),
|
|
360
|
+
expiry: request.expiry,
|
|
361
|
+
keyType,
|
|
362
|
+
...(request.limits ? { limits: request.limits } : {}),
|
|
363
|
+
pubKey,
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
let code: string | undefined
|
|
367
|
+
for (let i = 0; i < 10; i++) {
|
|
368
|
+
const candidate = createCode(random)
|
|
369
|
+
if (await store.get(candidate)) continue
|
|
370
|
+
code = candidate
|
|
371
|
+
break
|
|
372
|
+
}
|
|
373
|
+
if (!code) throw new Error('Unable to allocate device code.')
|
|
374
|
+
|
|
375
|
+
const createdAt = now()
|
|
376
|
+
|
|
377
|
+
await store.create({
|
|
378
|
+
...(account ? { account } : {}),
|
|
379
|
+
chainId: typeof chainId === 'bigint' ? chainId : BigInt(chainId),
|
|
380
|
+
code,
|
|
381
|
+
codeChallenge,
|
|
382
|
+
createdAt,
|
|
383
|
+
expiresAt: createdAt + ttlMs,
|
|
384
|
+
expiry: approved.expiry,
|
|
385
|
+
keyType,
|
|
386
|
+
...(approved.limits ? { limits: approved.limits } : {}),
|
|
387
|
+
pubKey,
|
|
388
|
+
status: 'pending',
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
return { code }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export declare namespace createDeviceCode {
|
|
395
|
+
export type Options = {
|
|
396
|
+
/** Chain ID embedded into the expected key authorization. @default tempo.id */
|
|
397
|
+
chainId?: bigint | number | undefined
|
|
398
|
+
/** Time source used for TTL evaluation. */
|
|
399
|
+
now?: (() => number) | undefined
|
|
400
|
+
/** Policy used to validate requested expiry and limits. */
|
|
401
|
+
policy?: Policy | undefined
|
|
402
|
+
/** Random byte generator used for verification code allocation. */
|
|
403
|
+
random?: ((size: number) => Uint8Array) | undefined
|
|
404
|
+
/** Incoming device-code creation request. */
|
|
405
|
+
request: z.output<typeof createRequest>
|
|
406
|
+
/** Device-code store. */
|
|
407
|
+
store?: Store | undefined
|
|
408
|
+
/** Pending entry TTL in milliseconds. @default 600000 */
|
|
409
|
+
ttlMs?: number | undefined
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export type ReturnType = z.output<typeof createResponse>
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Looks up a pending device code for browser approval UIs. */
|
|
416
|
+
export async function pending(options: pending.Options): Promise<pending.ReturnType> {
|
|
417
|
+
const { code, now = Date.now, store = Store.memory() } = options
|
|
418
|
+
const normalized = normalizeCode(code)
|
|
419
|
+
const current = await store.get(normalized)
|
|
420
|
+
if (!current) throw new PendingError('Unknown device code.', 404)
|
|
421
|
+
if (isExpired(current, now)) {
|
|
422
|
+
await store.delete(normalized)
|
|
423
|
+
throw new PendingError('Expired device code.', 404)
|
|
424
|
+
}
|
|
425
|
+
if (current.status !== 'pending') throw new PendingError('Device code already completed.', 400)
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
accessKeyAddress: core_Address.fromPublicKey(PublicKey.from(current.pubKey)),
|
|
429
|
+
...(current.account ? { account: current.account } : {}),
|
|
430
|
+
chainId: current.chainId,
|
|
431
|
+
code: current.code,
|
|
432
|
+
expiry: current.expiry,
|
|
433
|
+
keyType: current.keyType,
|
|
434
|
+
...(current.limits ? { limits: current.limits } : {}),
|
|
435
|
+
pubKey: current.pubKey,
|
|
436
|
+
status: 'pending',
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export declare namespace pending {
|
|
441
|
+
export type Options = {
|
|
442
|
+
/** Verification code from the route path. */
|
|
443
|
+
code: string
|
|
444
|
+
/** Time source used for TTL evaluation. */
|
|
445
|
+
now?: (() => number) | undefined
|
|
446
|
+
/** Device-code store. */
|
|
447
|
+
store?: Store | undefined
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export type ReturnType = z.output<typeof pendingResponse>
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Polls a device code with PKCE verification. */
|
|
454
|
+
export async function poll(options: poll.Options): Promise<poll.ReturnType> {
|
|
455
|
+
const { code, now = Date.now, request, store = Store.memory() } = options
|
|
456
|
+
const normalized = normalizeCode(code)
|
|
457
|
+
const current = await store.get(normalized)
|
|
458
|
+
if (!current) return { status: 'expired' }
|
|
459
|
+
if (isExpired(current, now)) {
|
|
460
|
+
await store.delete(normalized)
|
|
461
|
+
return { status: 'expired' }
|
|
462
|
+
}
|
|
463
|
+
if (!(await verifyCodeChallenge(request.codeVerifier, current.codeChallenge)))
|
|
464
|
+
throw new Error('Invalid code verifier.')
|
|
465
|
+
if (current.status === 'pending') return { status: 'pending' }
|
|
466
|
+
if (current.status === 'consumed') {
|
|
467
|
+
await store.delete(normalized)
|
|
468
|
+
return { status: 'expired' }
|
|
469
|
+
}
|
|
470
|
+
const authorized = await store.consume(normalized)
|
|
471
|
+
if (!authorized) return { status: 'expired' }
|
|
472
|
+
return {
|
|
473
|
+
accountAddress: authorized.accountAddress,
|
|
474
|
+
keyAuthorization: authorized.keyAuthorization,
|
|
475
|
+
status: 'authorized',
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export declare namespace poll {
|
|
480
|
+
export type Options = {
|
|
481
|
+
/** Verification code from the route path. */
|
|
482
|
+
code: string
|
|
483
|
+
/** Time source used for TTL evaluation. */
|
|
484
|
+
now?: (() => number) | undefined
|
|
485
|
+
/** Poll request body. */
|
|
486
|
+
request: z.output<typeof pollRequest>
|
|
487
|
+
/** Device-code store. */
|
|
488
|
+
store?: Store | undefined
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export type ReturnType = z.output<typeof pollResponse>
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Authorizes a pending device code after validating the signed key authorization. */
|
|
495
|
+
export async function authorize(options: authorize.Options): Promise<authorize.ReturnType> {
|
|
496
|
+
const {
|
|
497
|
+
chainId,
|
|
498
|
+
client = getClient({ chainId }),
|
|
499
|
+
now = Date.now,
|
|
500
|
+
request,
|
|
501
|
+
store = Store.memory(),
|
|
502
|
+
} = options
|
|
503
|
+
const code = normalizeCode(request.code)
|
|
504
|
+
const current = await store.get(code)
|
|
505
|
+
if (!current) throw new Error('Unknown device code.')
|
|
506
|
+
if (isExpired(current, now)) {
|
|
507
|
+
await store.delete(code)
|
|
508
|
+
throw new Error('Expired device code.')
|
|
509
|
+
}
|
|
510
|
+
if (current.status !== 'pending') throw new Error('Device code already completed.')
|
|
511
|
+
if (current.account && current.account.toLowerCase() !== request.accountAddress.toLowerCase())
|
|
512
|
+
throw new Error('Account does not match requested account.')
|
|
513
|
+
|
|
514
|
+
const expected = expectedKeyAuthorization(current)
|
|
515
|
+
const actual = normalizeKeyAuthorization(request.keyAuthorization)
|
|
516
|
+
|
|
517
|
+
if (actual.keyId.toLowerCase() !== expected.address.toLowerCase())
|
|
518
|
+
throw new Error('Key authorization key does not match the device-code request.')
|
|
519
|
+
if (actual.address.toLowerCase() !== expected.address.toLowerCase())
|
|
520
|
+
throw new Error('Key authorization address does not match the device-code request.')
|
|
521
|
+
if (actual.keyType !== expected.type)
|
|
522
|
+
throw new Error('Key authorization key type does not match the device-code request.')
|
|
523
|
+
if (actual.chainId !== expected.chainId)
|
|
524
|
+
throw new Error('Key authorization chain does not match the device-code request.')
|
|
525
|
+
if ((actual.expiry ?? undefined) !== (expected.expiry ?? undefined))
|
|
526
|
+
throw new Error('Key authorization expiry does not match the device-code request.')
|
|
527
|
+
if (!sameLimits(actual.limits, expected.limits))
|
|
528
|
+
throw new Error('Key authorization limits do not match the device-code request.')
|
|
529
|
+
|
|
530
|
+
const valid = await verifyHash(client as never, {
|
|
531
|
+
address: request.accountAddress,
|
|
532
|
+
hash: TempoKeyAuthorization.getSignPayload(expected),
|
|
533
|
+
signature: SignatureEnvelope.serialize(SignatureEnvelope.fromRpc(actual.signature), {
|
|
534
|
+
magic: actual.signature.type === 'webAuthn',
|
|
535
|
+
}),
|
|
536
|
+
})
|
|
537
|
+
if (!valid) throw new Error('Key authorization signature is invalid.')
|
|
538
|
+
|
|
539
|
+
const authorized = await store.authorize({
|
|
540
|
+
accountAddress: request.accountAddress,
|
|
541
|
+
code,
|
|
542
|
+
keyAuthorization: request.keyAuthorization,
|
|
543
|
+
})
|
|
544
|
+
if (!authorized) throw new Error('Unable to authorize device code.')
|
|
545
|
+
|
|
546
|
+
return { status: 'authorized' }
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export declare namespace authorize {
|
|
550
|
+
export type Options = {
|
|
551
|
+
/** Chain ID embedded into the expected key authorization. Defaults to the client chain or tempo.id. */
|
|
552
|
+
chainId?: bigint | number | undefined
|
|
553
|
+
/** Client used to verify the signed key authorization. */
|
|
554
|
+
client?: Client<Transport, Chain | undefined> | undefined
|
|
555
|
+
/** Time source used for TTL evaluation. */
|
|
556
|
+
now?: (() => number) | undefined
|
|
557
|
+
/** Authorize request body. */
|
|
558
|
+
request: z.output<typeof authorizeRequest>
|
|
559
|
+
/** Device-code store. */
|
|
560
|
+
store?: Store | undefined
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export type ReturnType = z.output<typeof authorizeResponse>
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
|
567
|
+
|
|
568
|
+
function createCode(random: (size: number) => Uint8Array) {
|
|
569
|
+
const bytes = random(8)
|
|
570
|
+
let code = ''
|
|
571
|
+
for (const byte of bytes) code += alphabet[byte % alphabet.length]
|
|
572
|
+
return code
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function normalizeCode(code: string) {
|
|
576
|
+
return code.replaceAll('-', '').toUpperCase()
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function expectedKeyAuthorization(entry: Entry.Pending) {
|
|
580
|
+
return TempoKeyAuthorization.from({
|
|
581
|
+
address: core_Address.fromPublicKey(PublicKey.from(entry.pubKey)),
|
|
582
|
+
chainId: entry.chainId,
|
|
583
|
+
expiry: entry.expiry,
|
|
584
|
+
...(entry.limits ? { limits: entry.limits } : {}),
|
|
585
|
+
type: entry.keyType,
|
|
586
|
+
})
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function getClient(options: { chainId?: bigint | number | undefined } = {}) {
|
|
590
|
+
const chainId = options.chainId
|
|
591
|
+
return createClient({
|
|
592
|
+
chain: chainId
|
|
593
|
+
? {
|
|
594
|
+
...tempo,
|
|
595
|
+
id: typeof chainId === 'bigint' ? Number(chainId) : chainId,
|
|
596
|
+
}
|
|
597
|
+
: tempo,
|
|
598
|
+
transport: http(),
|
|
599
|
+
})
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function isExpired(entry: Entry, now: () => number) {
|
|
603
|
+
return now() > entry.expiresAt
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function normalizeKeyAuthorization(value: z.output<typeof keyAuthorization>) {
|
|
607
|
+
return {
|
|
608
|
+
...value,
|
|
609
|
+
expiry: value.expiry ?? undefined,
|
|
610
|
+
limits: value.limits ?? undefined,
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function randomBytes(size: number) {
|
|
615
|
+
return crypto.getRandomValues(new Uint8Array(size))
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function sameLimits(
|
|
619
|
+
a: readonly { token: Address; limit: bigint }[] | undefined,
|
|
620
|
+
b: readonly { token: Address; limit: bigint }[] | undefined,
|
|
621
|
+
) {
|
|
622
|
+
if (!a && !b) return true
|
|
623
|
+
if (!a || !b || a.length !== b.length) return false
|
|
624
|
+
return a.every((limit, i) => {
|
|
625
|
+
const other = b[i]
|
|
626
|
+
if (!other) return false
|
|
627
|
+
return limit.token.toLowerCase() === other.token.toLowerCase() && limit.limit === other.limit
|
|
628
|
+
})
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function verifyCodeChallenge(codeVerifier: string, codeChallenge: string) {
|
|
632
|
+
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier))
|
|
633
|
+
return Base64.fromBytes(new Uint8Array(hash), { pad: false, url: true }) === codeChallenge
|
|
634
|
+
}
|