accounts 0.16.2 → 0.17.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/CHANGELOG.md +33 -0
- package/README.md +3 -4
- package/dist/cli/Provider.d.ts +1 -1
- package/dist/cli/Provider.d.ts.map +1 -1
- package/dist/cli/Provider.js +3 -3
- package/dist/cli/Provider.js.map +1 -1
- package/dist/cli/adapter.d.ts +13 -8
- package/dist/cli/adapter.d.ts.map +1 -1
- package/dist/cli/adapter.js +92 -303
- package/dist/cli/adapter.js.map +1 -1
- package/dist/core/adapters/deviceCode/deviceCode.d.ts +59 -0
- package/dist/core/adapters/deviceCode/deviceCode.d.ts.map +1 -0
- package/dist/core/adapters/deviceCode/deviceCode.js +93 -0
- package/dist/core/adapters/deviceCode/deviceCode.js.map +1 -0
- package/dist/core/adapters/deviceCode/index.d.ts +3 -0
- package/dist/core/adapters/deviceCode/index.d.ts.map +1 -0
- package/dist/core/adapters/deviceCode/index.js +3 -0
- package/dist/core/adapters/deviceCode/index.js.map +1 -0
- package/dist/core/adapters/deviceCode/tempoWallet.d.ts +9 -0
- package/dist/core/adapters/deviceCode/tempoWallet.d.ts.map +1 -0
- package/dist/core/adapters/deviceCode/tempoWallet.js +11 -0
- package/dist/core/adapters/deviceCode/tempoWallet.js.map +1 -0
- package/dist/core/adapters/internal/fromRequest.d.ts +10 -0
- package/dist/core/adapters/internal/fromRequest.d.ts.map +1 -1
- package/dist/core/adapters/internal/fromRequest.js +4 -2
- package/dist/core/adapters/internal/fromRequest.js.map +1 -1
- package/dist/server/CliAuth.d.ts +69 -1
- package/dist/server/CliAuth.d.ts.map +1 -1
- package/dist/server/CliAuth.js +6 -1
- package/dist/server/CliAuth.js.map +1 -1
- package/dist/server/Handler.d.ts +2 -1
- package/dist/server/Handler.d.ts.map +1 -1
- package/dist/server/Handler.js +2 -1
- 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/dist/server/internal/handlers/codeAuth.d.ts +1 -0
- package/dist/server/internal/handlers/codeAuth.d.ts.map +1 -1
- package/dist/server/internal/handlers/codeAuth.js +1 -0
- package/dist/server/internal/handlers/codeAuth.js.map +1 -1
- package/dist/server/internal/handlers/deviceCode.d.ts +60 -0
- package/dist/server/internal/handlers/deviceCode.d.ts.map +1 -0
- package/dist/server/internal/handlers/deviceCode.js +218 -0
- package/dist/server/internal/handlers/deviceCode.js.map +1 -0
- package/package.json +7 -1
- package/src/cli/Provider.localnet.test.ts +414 -470
- package/src/cli/Provider.test-d.ts +4 -3
- package/src/cli/Provider.ts +6 -6
- package/src/cli/adapter.ts +111 -380
- package/src/core/adapters/deviceCode/deviceCode.localnet.test.ts +226 -0
- package/src/core/adapters/deviceCode/deviceCode.test-d.ts +19 -0
- package/src/core/adapters/deviceCode/deviceCode.ts +157 -0
- package/src/core/adapters/deviceCode/index.ts +2 -0
- package/src/core/adapters/deviceCode/tempoWallet.ts +17 -0
- package/src/core/adapters/internal/fromRequest.ts +115 -88
- package/src/server/CliAuth.test.ts +29 -18
- package/src/server/CliAuth.ts +10 -2
- package/src/server/Handler.test-d.ts +19 -33
- package/src/server/Handler.ts +2 -1
- package/src/server/index.ts +1 -0
- package/src/server/internal/handlers/codeAuth.ts +1 -0
- package/src/server/internal/handlers/deviceCode.test.ts +140 -0
- package/src/server/internal/handlers/deviceCode.ts +323 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Base64 } from 'ox'
|
|
1
|
+
import { Base64, Hex } from 'ox'
|
|
2
2
|
import { KeyAuthorization } from 'ox/tempo'
|
|
3
3
|
import { createClient, custom, encodeErrorResult, encodeFunctionResult } from 'viem'
|
|
4
4
|
import { Abis, Account as TempoAccount } from 'viem/tempo'
|
|
@@ -23,6 +23,22 @@ const limits = [
|
|
|
23
23
|
},
|
|
24
24
|
] as const
|
|
25
25
|
|
|
26
|
+
describe('keyAuthorization', () => {
|
|
27
|
+
test('behavior: preserves spending limit periods', () => {
|
|
28
|
+
const authorization = z.decode(CliAuth.keyAuthorization, {
|
|
29
|
+
address: accessKey.address,
|
|
30
|
+
chainId: '0xa5bf',
|
|
31
|
+
expiry: Hex.fromNumber(expiry),
|
|
32
|
+
keyId: accessKey.address,
|
|
33
|
+
keyType: accessKey.keyType,
|
|
34
|
+
limits: [{ limit: '0x3e8', period: Hex.fromNumber(86_400), token }],
|
|
35
|
+
signature: { r: '0x1', s: '0x2', type: 'secp256k1', yParity: '0x0' },
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
expect(authorization.limits).toEqual([{ limit, period: 86_400, token }])
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
26
42
|
function createMissingAccessKeyClient() {
|
|
27
43
|
return createClient({
|
|
28
44
|
chain,
|
|
@@ -744,23 +760,18 @@ Received 2 bytes.]`)
|
|
|
744
760
|
limits: Array.from({ length: 11 }, () => item),
|
|
745
761
|
})
|
|
746
762
|
|
|
747
|
-
expect(result).
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
}
|
|
760
|
-
]],
|
|
761
|
-
"success": false,
|
|
762
|
-
}
|
|
763
|
-
`)
|
|
763
|
+
expect(result.success).toBe(false)
|
|
764
|
+
if (result.success) return
|
|
765
|
+
expect(result.error.issues).toEqual([
|
|
766
|
+
{
|
|
767
|
+
code: 'too_big',
|
|
768
|
+
inclusive: true,
|
|
769
|
+
maximum: 10,
|
|
770
|
+
message: 'Invalid input',
|
|
771
|
+
origin: 'array',
|
|
772
|
+
path: ['limits'],
|
|
773
|
+
},
|
|
774
|
+
])
|
|
764
775
|
})
|
|
765
776
|
|
|
766
777
|
test('behavior: rejects malformed limit tokens', async () => {
|
package/src/server/CliAuth.ts
CHANGED
|
@@ -12,7 +12,11 @@ import type { MaybePromise } from '../internal/types.js'
|
|
|
12
12
|
import type { Kv } from './Kv.js'
|
|
13
13
|
|
|
14
14
|
const maxLimits = 10
|
|
15
|
-
const limit = z.object({
|
|
15
|
+
const limit = z.object({
|
|
16
|
+
token: u.address(),
|
|
17
|
+
limit: u.bigint(),
|
|
18
|
+
period: z.optional(u.number()),
|
|
19
|
+
})
|
|
16
20
|
const limits = z.readonly(z.array(limit).check(z.maxLength(maxLimits)))
|
|
17
21
|
const showDeposit = z.optional(
|
|
18
22
|
z.union([
|
|
@@ -249,7 +253,10 @@ export const entry = u.oneOf([
|
|
|
249
253
|
}),
|
|
250
254
|
])
|
|
251
255
|
|
|
252
|
-
/**
|
|
256
|
+
/**
|
|
257
|
+
* Shared CLI auth helper with pre-bound defaults and cached clients.
|
|
258
|
+
* @deprecated Use `Handler.deviceCode` with the Wata device-code transport.
|
|
259
|
+
*/
|
|
253
260
|
export type CliAuth = {
|
|
254
261
|
/** Creates and stores a new device code. */
|
|
255
262
|
createDeviceCode: (options: createDeviceCode.Parameters) => Promise<createDeviceCode.ReturnType>
|
|
@@ -622,6 +629,7 @@ export const RateLimit = {
|
|
|
622
629
|
* Instantiates a CLI auth helper with shared defaults and cached clients.
|
|
623
630
|
*
|
|
624
631
|
*
|
|
632
|
+
* @deprecated Use `Handler.deviceCode` with the Wata device-code transport.
|
|
625
633
|
* @param {from.Options} options - Shared CLI auth defaults.
|
|
626
634
|
* @returns {CliAuth} CLI auth helper.
|
|
627
635
|
*
|
|
@@ -1,42 +1,28 @@
|
|
|
1
1
|
import { hc } from 'hono/client'
|
|
2
2
|
import type { ExtractSchema } from 'hono/types'
|
|
3
|
-
import { http, type Chain, type Transport } from 'viem'
|
|
4
|
-
import { tempo, tempoModerato } from 'viem/tempo/chains'
|
|
5
3
|
import { describe, expectTypeOf, test } from 'vp/test'
|
|
4
|
+
import { Store } from 'wata/host'
|
|
6
5
|
|
|
7
|
-
import * as CliAuth from './CliAuth.js'
|
|
8
6
|
import * as Handler from './Handler.js'
|
|
9
7
|
|
|
10
|
-
describe('
|
|
11
|
-
test('
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
test('accepts derived clients from chains/transports', () => {
|
|
19
|
-
void Handler.codeAuth({
|
|
20
|
-
chains: [tempo, tempoModerato],
|
|
21
|
-
transports: {
|
|
22
|
-
[tempo.id]: http('https://rpc.tempo.xyz'),
|
|
23
|
-
[tempoModerato.id]: http('https://rpc.moderato.tempo.xyz'),
|
|
8
|
+
describe('deviceCode', () => {
|
|
9
|
+
test('accepts host deployment options', () => {
|
|
10
|
+
const handler = Handler.deviceCode({
|
|
11
|
+
baseUrl: (request) => new URL(request.url).origin,
|
|
12
|
+
html: {
|
|
13
|
+
render: () => new Response('verify'),
|
|
24
14
|
},
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
void Handler.codeAuth({
|
|
33
|
-
rateLimit: false,
|
|
34
|
-
})
|
|
35
|
-
void Handler.codeAuth({
|
|
36
|
-
rateLimitKey(request) {
|
|
37
|
-
return request.headers.get('x-forwarded-for') ?? 'unknown'
|
|
15
|
+
store: Store.memory(),
|
|
16
|
+
validate(options) {
|
|
17
|
+
expectTypeOf(options.record).toEqualTypeOf<import('wata/host').DeviceCode.PendingRecord>()
|
|
18
|
+
expectTypeOf(options.request.id).toEqualTypeOf<string | number>()
|
|
19
|
+
expectTypeOf(options.result).toBeUnknown()
|
|
20
|
+
expectTypeOf(options.userCode).toBeString()
|
|
21
|
+
return undefined
|
|
38
22
|
},
|
|
39
23
|
})
|
|
24
|
+
|
|
25
|
+
expectTypeOf(handler).toMatchTypeOf<Handler.Handler>()
|
|
40
26
|
})
|
|
41
27
|
})
|
|
42
28
|
|
|
@@ -125,11 +111,11 @@ describe('compose', () => {
|
|
|
125
111
|
})
|
|
126
112
|
|
|
127
113
|
test('handlers without a route schema (legacy `Handler`) compose without errors', () => {
|
|
128
|
-
// `Handler.
|
|
114
|
+
// `Handler.from` returns plain `Handler` (default `Hono`), no route
|
|
129
115
|
// schema. Mixing it with typed handlers must not break inference for the
|
|
130
116
|
// typed siblings.
|
|
131
|
-
const
|
|
132
|
-
const composed = Handler.compose([
|
|
117
|
+
const plain = Handler.from() as unknown as Handler.Handler
|
|
118
|
+
const composed = Handler.compose([plain, makeAlpha()], { path: '/api' })
|
|
133
119
|
const client = hc<typeof composed>('http://localhost')
|
|
134
120
|
|
|
135
121
|
expectTypeOf(client.api.alpha.ping.$post).toBeFunction()
|
package/src/server/Handler.ts
CHANGED
|
@@ -7,6 +7,7 @@ import * as RequestListener from './internal/requestListener.js'
|
|
|
7
7
|
export { auth } from './internal/handlers/auth.js'
|
|
8
8
|
export { type SessionRequest } from './internal/handlers/session.js'
|
|
9
9
|
export { codeAuth } from './internal/handlers/codeAuth.js'
|
|
10
|
+
export { deviceCode } from './internal/handlers/deviceCode.js'
|
|
10
11
|
export { exchange } from './internal/handlers/exchange.js'
|
|
11
12
|
export { oidcProvider } from './internal/handlers/oidcProvider.js'
|
|
12
13
|
export { relay } from './internal/handlers/relay.js'
|
|
@@ -51,7 +52,7 @@ export type ComposedSchema<subs extends readonly unknown[], path extends string>
|
|
|
51
52
|
* import { hc } from 'hono/client'
|
|
52
53
|
*
|
|
53
54
|
* const app = Handler.compose(
|
|
54
|
-
* [Handler.exchange(), Handler.
|
|
55
|
+
* [Handler.exchange(), Handler.relay()],
|
|
55
56
|
* { path: '/api' },
|
|
56
57
|
* )
|
|
57
58
|
* type App = typeof app
|
package/src/server/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { type Handler, from } from '../../Handler.js'
|
|
|
15
15
|
* - `POST /auth/pkce/poll/:code`
|
|
16
16
|
* - `POST /auth/pkce`
|
|
17
17
|
*
|
|
18
|
+
* @deprecated Use `Handler.deviceCode` with the Wata device-code transport.
|
|
18
19
|
* @param {codeAuth.Options} options - Options.
|
|
19
20
|
* @returns {Handler} Request handler.
|
|
20
21
|
*/
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { Base64, Bytes, Hash } from 'ox'
|
|
2
|
+
import { describe, expect, test, vi } from 'vp/test'
|
|
3
|
+
|
|
4
|
+
import { deviceCode } from './deviceCode.js'
|
|
5
|
+
|
|
6
|
+
const verifier = 'test-device-code-verifier-0123456789'
|
|
7
|
+
|
|
8
|
+
function createHandler(validate = vi.fn(() => undefined)) {
|
|
9
|
+
return {
|
|
10
|
+
handler: deviceCode({
|
|
11
|
+
html: { render: () => new Response('verify') },
|
|
12
|
+
pollingInterval: 1,
|
|
13
|
+
validate,
|
|
14
|
+
}),
|
|
15
|
+
validate,
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function register(handler: ReturnType<typeof deviceCode>) {
|
|
20
|
+
const response = await handler.fetch(
|
|
21
|
+
new Request('https://wallet.example.com/auth/device/register', {
|
|
22
|
+
body: JSON.stringify({
|
|
23
|
+
code_challenge: Base64.fromBytes(Hash.sha256(Bytes.fromString(verifier), { as: 'Bytes' }), {
|
|
24
|
+
pad: false,
|
|
25
|
+
url: true,
|
|
26
|
+
}),
|
|
27
|
+
code_challenge_method: 'S256',
|
|
28
|
+
message: {
|
|
29
|
+
payload: [{ id: 1, jsonrpc: '2.0', method: 'wallet_connect', params: [] }],
|
|
30
|
+
type: 'rpc-requests',
|
|
31
|
+
},
|
|
32
|
+
meta: { name: 'Test CLI' },
|
|
33
|
+
}),
|
|
34
|
+
headers: { 'content-type': 'application/json' },
|
|
35
|
+
method: 'POST',
|
|
36
|
+
}),
|
|
37
|
+
)
|
|
38
|
+
return (await response.json()) as { device_code: string; user_code: string }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function verify(
|
|
42
|
+
handler: ReturnType<typeof deviceCode>,
|
|
43
|
+
userCode: string,
|
|
44
|
+
response:
|
|
45
|
+
| { error: { code: number; data?: unknown; message: string }; id: number }
|
|
46
|
+
| {
|
|
47
|
+
id: number
|
|
48
|
+
result: unknown
|
|
49
|
+
},
|
|
50
|
+
) {
|
|
51
|
+
return await handler.fetch(
|
|
52
|
+
new Request('https://wallet.example.com/auth/device/verify', {
|
|
53
|
+
body: JSON.stringify({ action: 'approve', results: [response], user_code: userCode }),
|
|
54
|
+
headers: { 'content-type': 'application/json' },
|
|
55
|
+
method: 'POST',
|
|
56
|
+
}),
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function token(handler: ReturnType<typeof deviceCode>, code: string) {
|
|
61
|
+
const response = await handler.fetch(
|
|
62
|
+
new Request('https://wallet.example.com/auth/device/token', {
|
|
63
|
+
body: JSON.stringify({
|
|
64
|
+
code_verifier: verifier,
|
|
65
|
+
device_code: code,
|
|
66
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
67
|
+
}),
|
|
68
|
+
headers: { 'content-type': 'application/json' },
|
|
69
|
+
method: 'POST',
|
|
70
|
+
}),
|
|
71
|
+
)
|
|
72
|
+
return { body: await response.json(), status: response.status }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('deviceCode', () => {
|
|
76
|
+
test('default: validates approved results before relaying them', async () => {
|
|
77
|
+
const { handler, validate } = createHandler()
|
|
78
|
+
const registered = await register(handler)
|
|
79
|
+
|
|
80
|
+
const approved = await verify(handler, registered.user_code, { id: 1, result: '0x1' })
|
|
81
|
+
|
|
82
|
+
expect(approved.status).toMatchInlineSnapshot(`200`)
|
|
83
|
+
expect(validate).toHaveBeenCalledWith(
|
|
84
|
+
expect.objectContaining({
|
|
85
|
+
request: expect.objectContaining({ id: 1, method: 'wallet_connect' }),
|
|
86
|
+
result: '0x1',
|
|
87
|
+
userCode: registered.user_code,
|
|
88
|
+
}),
|
|
89
|
+
)
|
|
90
|
+
await expect(token(handler, registered.device_code)).resolves.toMatchInlineSnapshot(`
|
|
91
|
+
{
|
|
92
|
+
"body": {
|
|
93
|
+
"payload": [
|
|
94
|
+
{
|
|
95
|
+
"id": 1,
|
|
96
|
+
"jsonrpc": "2.0",
|
|
97
|
+
"result": "0x1",
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
"type": "rpc-responses",
|
|
101
|
+
},
|
|
102
|
+
"status": 200,
|
|
103
|
+
}
|
|
104
|
+
`)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('behavior: relays submitted RPC errors', async () => {
|
|
108
|
+
const { handler, validate } = createHandler()
|
|
109
|
+
const registered = await register(handler)
|
|
110
|
+
|
|
111
|
+
const approved = await verify(handler, registered.user_code, {
|
|
112
|
+
error: { code: 4001, data: { reason: 'cancelled' }, message: 'Request rejected.' },
|
|
113
|
+
id: 1,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
expect(approved.status).toMatchInlineSnapshot(`200`)
|
|
117
|
+
expect(validate).not.toHaveBeenCalled()
|
|
118
|
+
await expect(token(handler, registered.device_code)).resolves.toMatchInlineSnapshot(`
|
|
119
|
+
{
|
|
120
|
+
"body": {
|
|
121
|
+
"payload": [
|
|
122
|
+
{
|
|
123
|
+
"error": {
|
|
124
|
+
"code": 4001,
|
|
125
|
+
"data": {
|
|
126
|
+
"reason": "cancelled",
|
|
127
|
+
},
|
|
128
|
+
"message": "Request rejected.",
|
|
129
|
+
},
|
|
130
|
+
"id": 1,
|
|
131
|
+
"jsonrpc": "2.0",
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
"type": "rpc-responses",
|
|
135
|
+
},
|
|
136
|
+
"status": 200,
|
|
137
|
+
}
|
|
138
|
+
`)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { type DeviceCode, Store, Wata, deviceCode as core_deviceCode } from 'wata/host'
|
|
2
|
+
import * as z from 'zod/mini'
|
|
3
|
+
|
|
4
|
+
import { type Handler, from } from '../../Handler.js'
|
|
5
|
+
|
|
6
|
+
const maxVerifyBodyBytes = 65_536
|
|
7
|
+
|
|
8
|
+
const verifyRequest = z.object({
|
|
9
|
+
action: z.union([z.literal('approve'), z.literal('deny')]),
|
|
10
|
+
results: z.optional(
|
|
11
|
+
z.array(
|
|
12
|
+
z.union([
|
|
13
|
+
z.object({
|
|
14
|
+
error: z.object({
|
|
15
|
+
code: z.number(),
|
|
16
|
+
data: z.optional(z.json()),
|
|
17
|
+
message: z.string(),
|
|
18
|
+
}),
|
|
19
|
+
id: z.union([z.string(), z.number()]),
|
|
20
|
+
}),
|
|
21
|
+
z.object({ id: z.union([z.string(), z.number()]), result: z.json() }),
|
|
22
|
+
]),
|
|
23
|
+
),
|
|
24
|
+
),
|
|
25
|
+
user_code: z.string(),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
type PendingRequest = {
|
|
29
|
+
context?: { account?: string | undefined; chainId?: number | undefined } | undefined
|
|
30
|
+
id: string | number
|
|
31
|
+
method: string
|
|
32
|
+
params?: unknown
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Instantiates a Wata device-code handler for wallet RPC requests.
|
|
37
|
+
*
|
|
38
|
+
* The handler implements the OAuth 2.0 Device Authorization Grant endpoints,
|
|
39
|
+
* accepts browser-submitted RPC results, and returns them to the polling
|
|
40
|
+
* consumer. The consuming host is responsible for deciding which methods
|
|
41
|
+
* its approval UI supports.
|
|
42
|
+
*
|
|
43
|
+
* @param options - Options.
|
|
44
|
+
* @returns Request handler.
|
|
45
|
+
*/
|
|
46
|
+
export function deviceCode(options: deviceCode.Options): Handler {
|
|
47
|
+
const {
|
|
48
|
+
baseUrl,
|
|
49
|
+
expiresIn,
|
|
50
|
+
fetch,
|
|
51
|
+
html,
|
|
52
|
+
maxBodyBytes = maxVerifyBodyBytes,
|
|
53
|
+
path = '/auth/device',
|
|
54
|
+
pollingInterval,
|
|
55
|
+
store = Store.memory(),
|
|
56
|
+
validate,
|
|
57
|
+
...rest
|
|
58
|
+
} = options
|
|
59
|
+
|
|
60
|
+
const router = from(rest)
|
|
61
|
+
router.all(`${path}/*`, async (c) => {
|
|
62
|
+
const request = c.req.raw
|
|
63
|
+
const origin = typeof baseUrl === 'function' ? baseUrl(request) : baseUrl
|
|
64
|
+
const results = new Map<
|
|
65
|
+
string | number,
|
|
66
|
+
{ result: unknown } | { error: { code: number; data?: unknown; message: string } }
|
|
67
|
+
>()
|
|
68
|
+
let settle: ((error?: Error) => void) | undefined
|
|
69
|
+
const responded = new Promise<void>((resolve, reject) => {
|
|
70
|
+
settle = (error) => (error ? reject(error) : resolve())
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const wata = Wata.create({
|
|
74
|
+
transports: [
|
|
75
|
+
core_deviceCode({
|
|
76
|
+
...(origin ? { baseUrl: origin } : {}),
|
|
77
|
+
...(expiresIn !== undefined ? { expiresIn } : {}),
|
|
78
|
+
...(fetch !== undefined ? { fetch } : {}),
|
|
79
|
+
html: {
|
|
80
|
+
async authenticate({ actions, request }) {
|
|
81
|
+
const body = await readVerifyBody(request, maxBodyBytes)
|
|
82
|
+
if (!body.ok) return body.response
|
|
83
|
+
const { action, results: submitted, user_code } = body.value
|
|
84
|
+
|
|
85
|
+
const userCode = normalizeUserCode(user_code)
|
|
86
|
+
const record = await actions.get(userCode)
|
|
87
|
+
if (!record)
|
|
88
|
+
return Response.json(
|
|
89
|
+
{ error: 'unknown_code', error_description: 'Unknown or expired device code.' },
|
|
90
|
+
{ status: 404 },
|
|
91
|
+
)
|
|
92
|
+
if (record.status !== 'pending')
|
|
93
|
+
return Response.json(
|
|
94
|
+
{ error: 'not_pending', error_description: 'Device code already completed.' },
|
|
95
|
+
{ status: 409 },
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if (action === 'deny') {
|
|
99
|
+
await actions.deny(userCode)
|
|
100
|
+
return Response.json({ status: 'denied' })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const requests = pendingRequests(record)
|
|
104
|
+
const [pending] = requests
|
|
105
|
+
if (requests.length !== 1 || !pending)
|
|
106
|
+
return Response.json(
|
|
107
|
+
{
|
|
108
|
+
error: 'invalid_request',
|
|
109
|
+
error_description: 'Expected one pending request.',
|
|
110
|
+
},
|
|
111
|
+
{ status: 400 },
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
const response = submitted?.find((entry) => entry.id === pending.id)
|
|
115
|
+
if (!response)
|
|
116
|
+
return Response.json(
|
|
117
|
+
{
|
|
118
|
+
error: 'invalid_request',
|
|
119
|
+
error_description: 'Missing response for pending request.',
|
|
120
|
+
},
|
|
121
|
+
{ status: 400 },
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if ('result' in response) {
|
|
125
|
+
const validation = await validate({
|
|
126
|
+
record,
|
|
127
|
+
request: pending,
|
|
128
|
+
result: response.result,
|
|
129
|
+
userCode,
|
|
130
|
+
})
|
|
131
|
+
if (validation) return validation
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
results.set(pending.id, response)
|
|
135
|
+
await actions.approve(userCode)
|
|
136
|
+
await responded
|
|
137
|
+
return Response.json({ status: 'approved' })
|
|
138
|
+
},
|
|
139
|
+
render: html.render,
|
|
140
|
+
},
|
|
141
|
+
path,
|
|
142
|
+
...(pollingInterval !== undefined ? { pollingInterval } : {}),
|
|
143
|
+
store: normalizeStore(store),
|
|
144
|
+
}),
|
|
145
|
+
],
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const session = wata.start()
|
|
149
|
+
session.onRequest(async (event) => {
|
|
150
|
+
const response = results.get(event.id)
|
|
151
|
+
try {
|
|
152
|
+
if (!response)
|
|
153
|
+
await event.reject({ code: -32603, message: 'No result supplied for request.' })
|
|
154
|
+
else if ('error' in response) await event.reject(response.error)
|
|
155
|
+
else await event.respond(response.result)
|
|
156
|
+
settle?.()
|
|
157
|
+
} catch (error) {
|
|
158
|
+
settle?.(error as Error)
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
return await normalizeRegisterResponse(await wata.fetch(request), request, path)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
return router
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export declare namespace deviceCode {
|
|
169
|
+
/** Options for {@link deviceCode}. */
|
|
170
|
+
export type Options = from.Options & {
|
|
171
|
+
/** Public host origin or a request-based origin resolver. */
|
|
172
|
+
baseUrl?: string | ((request: Request) => string) | undefined
|
|
173
|
+
/** Authorization intent lifetime in seconds. @default 600 */
|
|
174
|
+
expiresIn?: number | undefined
|
|
175
|
+
/** Discovery fetch implementation. */
|
|
176
|
+
fetch?: typeof globalThis.fetch | undefined
|
|
177
|
+
/** Approval page hooks. */
|
|
178
|
+
html: {
|
|
179
|
+
/** Renders or redirects the approval page. */
|
|
180
|
+
render: DeviceCode.html.Hooks['render']
|
|
181
|
+
}
|
|
182
|
+
/** Maximum approval request body size in bytes. @default 65536 */
|
|
183
|
+
maxBodyBytes?: number | undefined
|
|
184
|
+
/** Device-code endpoint path. @default "/auth/device" */
|
|
185
|
+
path?: string | undefined
|
|
186
|
+
/** Suggested polling interval in milliseconds. */
|
|
187
|
+
pollingInterval?: number | undefined
|
|
188
|
+
/** Device-code persistence. @default in-memory */
|
|
189
|
+
store?: Store.Store | undefined
|
|
190
|
+
/** Host policy applied before an approved result is relayed to the consumer. */
|
|
191
|
+
validate: (options: {
|
|
192
|
+
/** Pending device-code record. */
|
|
193
|
+
record: DeviceCode.PendingRecord
|
|
194
|
+
/** Pending JSON-RPC request matched to the submitted result. */
|
|
195
|
+
request: PendingRequest
|
|
196
|
+
/** Browser-submitted result. */
|
|
197
|
+
result: unknown
|
|
198
|
+
/** Normalized raw user code. */
|
|
199
|
+
userCode: string
|
|
200
|
+
}) => Promise<Response | undefined> | Response | undefined
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function pendingRequests(record: DeviceCode.PendingRecord): PendingRequest[] {
|
|
205
|
+
if (record.message.type !== 'rpc-requests') return []
|
|
206
|
+
return record.message.payload.filter(
|
|
207
|
+
(message) => typeof message === 'object' && message !== null && 'id' in message,
|
|
208
|
+
) as unknown as PendingRequest[]
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function normalizeUserCode(value: string): string {
|
|
212
|
+
return value.replace(/[\s-]/g, '').toUpperCase()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function normalizeStore(store: Store.Store): Store.Store {
|
|
216
|
+
function key(value: string) {
|
|
217
|
+
if (!value.startsWith('user:')) return value
|
|
218
|
+
return `user:${normalizeUserCode(value.slice('user:'.length))}`
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return Store.from({
|
|
222
|
+
delete: (value) => store.delete(key(value)),
|
|
223
|
+
get: <value = unknown>(name: string) => store.get<value>(key(name)),
|
|
224
|
+
set: (name, value, options) => store.set(key(name), value, options),
|
|
225
|
+
...(store.take
|
|
226
|
+
? { take: <value = unknown>(name: string) => store.take!<value>(key(name)) }
|
|
227
|
+
: {}),
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function normalizeRegisterResponse(response: Response, request: Request, path: string) {
|
|
232
|
+
if (!response.ok || new URL(request.url).pathname !== `${path}/register`) return response
|
|
233
|
+
|
|
234
|
+
const body = (await response.clone().json()) as {
|
|
235
|
+
user_code?: unknown
|
|
236
|
+
verification_uri_complete?: unknown
|
|
237
|
+
}
|
|
238
|
+
if (typeof body.user_code !== 'string') return response
|
|
239
|
+
|
|
240
|
+
const userCode = normalizeUserCode(body.user_code)
|
|
241
|
+
const verificationUriComplete =
|
|
242
|
+
typeof body.verification_uri_complete === 'string'
|
|
243
|
+
? (() => {
|
|
244
|
+
const url = new URL(body.verification_uri_complete)
|
|
245
|
+
url.searchParams.set('user_code', userCode)
|
|
246
|
+
return url.toString()
|
|
247
|
+
})()
|
|
248
|
+
: body.verification_uri_complete
|
|
249
|
+
const headers = new Headers(response.headers)
|
|
250
|
+
headers.delete('content-length')
|
|
251
|
+
return Response.json(
|
|
252
|
+
{
|
|
253
|
+
...body,
|
|
254
|
+
user_code: userCode,
|
|
255
|
+
...(verificationUriComplete ? { verification_uri_complete: verificationUriComplete } : {}),
|
|
256
|
+
},
|
|
257
|
+
{ headers, status: response.status },
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function readVerifyBody(
|
|
262
|
+
request: Request,
|
|
263
|
+
maxBodyBytes: number,
|
|
264
|
+
): Promise<
|
|
265
|
+
{ ok: true; value: z.output<typeof verifyRequest> } | { ok: false; response: Response }
|
|
266
|
+
> {
|
|
267
|
+
const invalid = (description: string) => ({
|
|
268
|
+
ok: false as const,
|
|
269
|
+
response: Response.json(
|
|
270
|
+
{ error: 'invalid_request', error_description: description },
|
|
271
|
+
{ status: 400 },
|
|
272
|
+
),
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
let json: unknown
|
|
276
|
+
try {
|
|
277
|
+
const body = await readBody(request, maxBodyBytes)
|
|
278
|
+
if (!body.ok) return invalid('Request body is too large.')
|
|
279
|
+
json = JSON.parse(body.text)
|
|
280
|
+
} catch {
|
|
281
|
+
return invalid('Expected a JSON body.')
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const parsed = z.safeParse(verifyRequest, json)
|
|
285
|
+
if (!parsed.success) return invalid('Malformed verify request.')
|
|
286
|
+
return { ok: true, value: parsed.data }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function readBody(
|
|
290
|
+
request: Request,
|
|
291
|
+
maxBodyBytes: number,
|
|
292
|
+
): Promise<{ ok: true; text: string } | { ok: false }> {
|
|
293
|
+
const length = request.headers.get('content-length')
|
|
294
|
+
if (length && Number(length) > maxBodyBytes) return { ok: false }
|
|
295
|
+
if (!request.body) return { ok: true, text: '' }
|
|
296
|
+
|
|
297
|
+
const reader = request.body.getReader()
|
|
298
|
+
const chunks: Uint8Array[] = []
|
|
299
|
+
let size = 0
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
while (true) {
|
|
303
|
+
const { done, value } = await reader.read()
|
|
304
|
+
if (done) break
|
|
305
|
+
size += value.byteLength
|
|
306
|
+
if (size > maxBodyBytes) {
|
|
307
|
+
await reader.cancel().catch(() => undefined)
|
|
308
|
+
return { ok: false }
|
|
309
|
+
}
|
|
310
|
+
chunks.push(value)
|
|
311
|
+
}
|
|
312
|
+
} finally {
|
|
313
|
+
reader.releaseLock()
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const bytes = new Uint8Array(size)
|
|
317
|
+
let offset = 0
|
|
318
|
+
for (const chunk of chunks) {
|
|
319
|
+
bytes.set(chunk, offset)
|
|
320
|
+
offset += chunk.byteLength
|
|
321
|
+
}
|
|
322
|
+
return { ok: true, text: new TextDecoder().decode(bytes) }
|
|
323
|
+
}
|