@byline/host-tanstack-start 6.7.2 → 6.9.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.
Files changed (74) hide show
  1. package/dist/admin-shell/admin-roles/container_module.css +1 -1
  2. package/dist/admin-shell/admin-roles/delete.js +1 -1
  3. package/dist/admin-shell/admin-users/container.js +181 -159
  4. package/dist/admin-shell/admin-users/container.module.js +2 -1
  5. package/dist/admin-shell/admin-users/container_module.css +5 -1
  6. package/dist/admin-shell/admin-users/delete.js +1 -1
  7. package/dist/admin-shell/chrome/admin-app-bar.d.ts +0 -7
  8. package/dist/admin-shell/collections/api.d.ts +6 -2
  9. package/dist/admin-shell/collections/api.js +109 -3
  10. package/dist/admin-shell/collections/api.test.d.ts +8 -0
  11. package/dist/admin-shell/collections/edit.js +2 -0
  12. package/dist/admin-shell/collections/preview-link.js +17 -13
  13. package/dist/admin-shell/collections/view-menu.d.ts +8 -1
  14. package/dist/admin-shell/collections/view-menu.js +124 -107
  15. package/dist/admin-shell/collections/view-menu.module.js +1 -0
  16. package/dist/admin-shell/collections/view-menu_module.css +15 -1
  17. package/dist/admin-shell/scheduled-publications/list.js +2 -2
  18. package/dist/admin-shell/singletons/preview-link.js +17 -13
  19. package/dist/admin-shell/singletons/view-menu.js +78 -63
  20. package/dist/admin-shell/singletons/view.js +2 -0
  21. package/dist/integrations/byline-admin-services.js +2 -0
  22. package/dist/integrations/passkey-admin-services.d.ts +9 -0
  23. package/dist/integrations/passkey-admin-services.js +53 -0
  24. package/dist/integrations/session-coordination.d.ts +3 -1
  25. package/dist/integrations/session-coordination.js +3 -1
  26. package/dist/integrations/session-middleware.d.ts +3 -1
  27. package/dist/integrations/session-middleware.js +87 -79
  28. package/dist/integrations/sign-in-body.d.ts +3 -0
  29. package/dist/integrations/sign-in-body.js +19 -4
  30. package/dist/integrations/sign-in-middleware.js +30 -5
  31. package/dist/server-fns/auth/passkey-admission.d.ts +24 -0
  32. package/dist/server-fns/auth/passkey-admission.js +88 -0
  33. package/dist/server-fns/auth/passkeys.d.ts +96 -0
  34. package/dist/server-fns/auth/passkeys.js +238 -0
  35. package/dist/server-fns/auth/reconcile-locale.d.ts +20 -0
  36. package/dist/server-fns/auth/reconcile-locale.js +16 -0
  37. package/dist/server-fns/auth/sign-in.js +1 -15
  38. package/dist/server-fns/collections/get.d.ts +1 -0
  39. package/dist/server-fns/collections/get.js +4 -0
  40. package/package.json +11 -11
  41. package/src/admin-shell/admin-roles/container.module.css +1 -1
  42. package/src/admin-shell/admin-roles/delete.tsx +1 -1
  43. package/src/admin-shell/admin-users/container.module.css +7 -1
  44. package/src/admin-shell/admin-users/container.tsx +22 -2
  45. package/src/admin-shell/admin-users/delete.tsx +1 -1
  46. package/src/admin-shell/chrome/admin-app-bar.tsx +2 -2
  47. package/src/admin-shell/chrome/dashboard.test.tsx +2 -2
  48. package/src/admin-shell/collections/api.test.tsx +223 -0
  49. package/src/admin-shell/collections/api.tsx +98 -3
  50. package/src/admin-shell/collections/edit.tsx +9 -1
  51. package/src/admin-shell/collections/preview-link.tsx +13 -12
  52. package/src/admin-shell/collections/view-menu.module.css +29 -3
  53. package/src/admin-shell/collections/view-menu.tsx +114 -96
  54. package/src/admin-shell/scheduled-publications/list.tsx +3 -4
  55. package/src/admin-shell/singletons/history.test.tsx +1 -1
  56. package/src/admin-shell/singletons/preview-link.tsx +13 -12
  57. package/src/admin-shell/singletons/view-menu.tsx +65 -59
  58. package/src/admin-shell/singletons/view.test.tsx +4 -4
  59. package/src/admin-shell/singletons/view.tsx +3 -1
  60. package/src/integrations/byline-admin-services.ts +3 -0
  61. package/src/integrations/passkey-admin-services.ts +85 -0
  62. package/src/integrations/session-client.test.node.ts +73 -0
  63. package/src/integrations/session-coordination.ts +3 -1
  64. package/src/integrations/session-middleware.ts +86 -72
  65. package/src/integrations/sign-in-body.test.node.ts +31 -1
  66. package/src/integrations/sign-in-body.ts +27 -3
  67. package/src/integrations/sign-in-middleware.ts +42 -9
  68. package/src/server-fns/auth/passkey-admission.test.node.ts +173 -0
  69. package/src/server-fns/auth/passkey-admission.ts +125 -0
  70. package/src/server-fns/auth/passkeys.test.node.ts +247 -0
  71. package/src/server-fns/auth/passkeys.ts +252 -0
  72. package/src/server-fns/auth/reconcile-locale.ts +40 -0
  73. package/src/server-fns/auth/sign-in.ts +1 -32
  74. package/src/server-fns/collections/get.ts +3 -0
@@ -119,3 +119,76 @@ describe('coordinated business resend', () => {
119
119
  expect(mocks.renew).toHaveBeenCalledOnce()
120
120
  })
121
121
  })
122
+
123
+ describe('cookie mutation queue', () => {
124
+ const mutation = () =>
125
+ middleware.adminSessionMutationMiddleware.options.client?.({
126
+ next: (options: any) =>
127
+ options.fetch('http://localhost/remove', { method: 'POST', body: '{}' }),
128
+ } as any)
129
+ it('releases the auth queue before renewal, then queues the single safe resend', async () => {
130
+ const order: string[] = []
131
+ vi.stubGlobal('navigator', {
132
+ locks: {
133
+ request: async (_name: string, work: () => Promise<unknown>) => {
134
+ order.push('lock')
135
+ try {
136
+ return await work()
137
+ } finally {
138
+ order.push('unlock')
139
+ }
140
+ },
141
+ },
142
+ })
143
+ const fetch = vi
144
+ .fn()
145
+ .mockImplementationOnce(async () => {
146
+ order.push('expired')
147
+ return boundary('renew')
148
+ })
149
+ .mockImplementation(async () => {
150
+ order.push('handled')
151
+ return boundary('handled')
152
+ })
153
+ vi.stubGlobal('fetch', fetch)
154
+ mocks.renew.mockImplementation(async () => {
155
+ order.push('renew')
156
+ return { sessionId: 'A' }
157
+ })
158
+ await mutation()
159
+ expect(order).toEqual([
160
+ 'lock',
161
+ 'expired',
162
+ 'unlock',
163
+ 'lock',
164
+ 'renew',
165
+ 'unlock',
166
+ 'lock',
167
+ 'handled',
168
+ 'unlock',
169
+ ])
170
+ expect(fetch).toHaveBeenCalledTimes(2)
171
+ })
172
+ it('rechecks identity after waiting for a competing auth action', async () => {
173
+ const fetch = vi.fn()
174
+ vi.stubGlobal('fetch', fetch)
175
+ const competing = coordination.coordinateAuthAction(async () => {
176
+ coordination.acceptSession('B')
177
+ })
178
+ const result = mutation()
179
+ await competing
180
+ await expect(result).rejects.toThrow('Session changed')
181
+ expect(fetch).not.toHaveBeenCalled()
182
+ })
183
+ it('does not replay a failed mutation after its handler started', async () => {
184
+ const fetch = vi
185
+ .fn()
186
+ .mockResolvedValue(
187
+ new Response('failed', { status: 500, headers: { 'x-byline-auth-boundary': 'handled' } })
188
+ )
189
+ vi.stubGlobal('fetch', fetch)
190
+ await mutation()
191
+ expect(fetch).toHaveBeenCalledOnce()
192
+ expect(mocks.renew).not.toHaveBeenCalled()
193
+ })
194
+ })
@@ -14,7 +14,9 @@ let changed = false
14
14
  let renewal: Promise<void> | undefined
15
15
  const listeners = new Set<() => void>()
16
16
  export const sessionChangedError = () =>
17
- new Error('Session changed. Acknowledge the active account before continuing.')
17
+ Object.assign(new Error('Session changed. Acknowledge the active account before continuing.'), {
18
+ code: 'ERR_SESSION_CHANGED',
19
+ })
18
20
  export function expectedSession() {
19
21
  if (typeof window === 'undefined') return null
20
22
  if (!expected) expected = window.sessionStorage.getItem(storageKey)
@@ -30,78 +30,92 @@ import { sessionRequestKind } from './session-request-kind.js'
30
30
 
31
31
  const outcomeHeader = 'x-byline-auth-boundary'
32
32
  /** The boundary owns the retry signal; handler errors cannot turn into retryable outcomes. */
33
- export const adminSessionMiddleware = createMiddleware({ type: 'function' })
34
- .client(async ({ next }) => {
35
- if (typeof window === 'undefined')
36
- return next({ sendContext: { bylineExpectedSessionId: null as string | null } })
37
- const snapshot = sessionSnapshot()
38
- requireUnchanged(snapshot)
39
- return next({
40
- sendContext: { bylineExpectedSessionId: snapshot.sessionId },
41
- fetch: async (input, init) => {
42
- // Re-serialize reusable BodyInit values (including FormData/Blob) on retry.
43
- // Never clone/tee a Request: an unread branch can buffer an entire upload.
44
- const reusable = !(input instanceof Request) && !(init?.body instanceof ReadableStream)
45
- let response = await fetch(input, init)
46
- if (response.headers.get(outcomeHeader) === 'changed') {
47
- flagSessionChanged()
48
- throw ERR_SESSION_CHANGED({ message: 'session changed' })
49
- }
50
- if (response.headers.get(outcomeHeader) === 'renew') {
51
- requireUnchanged(snapshot)
52
- await renewSingleFlight(() =>
53
- coordinateAuthAction(async () => {
54
- const result = await renewAdminSession({
55
- data: { expectedSessionId: snapshot.sessionId ?? undefined },
56
- }).catch((error) => {
57
- if (error?.code === 'ERR_SESSION_CHANGED') flagSessionChanged()
58
- throw error
33
+ function createAdminSessionMiddleware(coordinateCookies: boolean) {
34
+ return createMiddleware({ type: 'function' })
35
+ .client(async ({ next }) => {
36
+ if (typeof window === 'undefined')
37
+ return next({ sendContext: { bylineExpectedSessionId: null as string | null } })
38
+ const snapshot = sessionSnapshot()
39
+ requireUnchanged(snapshot)
40
+ return next({
41
+ sendContext: { bylineExpectedSessionId: snapshot.sessionId },
42
+ fetch: async (input, init) => {
43
+ // Re-serialize reusable BodyInit values (including FormData/Blob) on retry.
44
+ // Never clone/tee a Request: an unread branch can buffer an entire upload.
45
+ const reusable = !(input instanceof Request) && !(init?.body instanceof ReadableStream)
46
+ // Queue each cookie-mutating fetch separately. Renewal uses the same queue,
47
+ // so holding it across the complete middleware would deadlock on expiry.
48
+ const send = () =>
49
+ coordinateCookies
50
+ ? coordinateAuthAction(async () => {
51
+ requireUnchanged(snapshot)
52
+ return fetch(input, init)
53
+ })
54
+ : fetch(input, init)
55
+ let response = await send()
56
+ if (response.headers.get(outcomeHeader) === 'changed') {
57
+ flagSessionChanged()
58
+ throw ERR_SESSION_CHANGED({ message: 'session changed' })
59
+ }
60
+ if (response.headers.get(outcomeHeader) === 'renew') {
61
+ requireUnchanged(snapshot)
62
+ await renewSingleFlight(() =>
63
+ coordinateAuthAction(async () => {
64
+ const result = await renewAdminSession({
65
+ data: { expectedSessionId: snapshot.sessionId ?? undefined },
66
+ }).catch((error) => {
67
+ if (error?.code === 'ERR_SESSION_CHANGED') flagSessionChanged()
68
+ throw error
69
+ })
70
+ if (snapshot.sessionId && result.sessionId !== snapshot.sessionId) {
71
+ flagSessionChanged()
72
+ throw ERR_SESSION_CHANGED({ message: 'session changed during renewal' })
73
+ }
74
+ observeSession(result.sessionId)
59
75
  })
60
- if (snapshot.sessionId && result.sessionId !== snapshot.sessionId) {
61
- flagSessionChanged()
62
- throw ERR_SESSION_CHANGED({ message: 'session changed during renewal' })
63
- }
64
- observeSession(result.sessionId)
65
- })
66
- )
76
+ )
77
+ requireUnchanged(snapshot)
78
+ if (!reusable)
79
+ throw ERR_ACCESS_EXPIRED({ message: 'Session renewed. Please retry the operation.' })
80
+ response = await send()
81
+ if (response.headers.get(outcomeHeader) === 'changed') flagSessionChanged()
82
+ }
67
83
  requireUnchanged(snapshot)
68
- if (!reusable)
69
- throw ERR_ACCESS_EXPIRED({ message: 'Session renewed. Please retry the operation.' })
70
- response = await fetch(input, init)
71
- if (response.headers.get(outcomeHeader) === 'changed') flagSessionChanged()
72
- }
73
- requireUnchanged(snapshot)
74
- return response
75
- },
84
+ return response
85
+ },
86
+ })
87
+ })
88
+ .server(async ({ next, context, method }) => {
89
+ const kind = sessionRequestKind(getRequest())
90
+ if (!kind) throw new Error('sessionRequestMiddleware is not configured')
91
+ let auth: Awaited<ReturnType<typeof getAdminRequestContext>>
92
+ try {
93
+ auth = await getAdminRequestContext()
94
+ } catch (error) {
95
+ if (error instanceof AuthError && error.code === 'ERR_ACCESS_EXPIRED')
96
+ setResponseHeader(outcomeHeader, 'renew')
97
+ throw error
98
+ }
99
+ const expected = context.bylineExpectedSessionId
100
+ if ((kind === 'serverFn' || method !== 'GET') && (!expected || expected !== auth.sessionId)) {
101
+ setResponseHeader(outcomeHeader, 'changed')
102
+ throw ERR_SESSION_CHANGED({ message: 'session changed before operation' })
103
+ }
104
+ if (!auth.sessionId) throw ERR_UNAUTHENTICATED({ message: 'provider omitted login identity' })
105
+ // Strip the reserved signal after any handler outcome, including exceptions.
106
+ try {
107
+ const result = await next()
108
+ const response = (result as { result?: unknown }).result
109
+ if (response instanceof Response) response.headers.delete(outcomeHeader)
110
+ return result
111
+ } catch (error) {
112
+ if (error instanceof Response) error.headers.delete(outcomeHeader)
113
+ throw error
114
+ } finally {
115
+ setResponseHeader(outcomeHeader, 'handled')
116
+ }
76
117
  })
77
- })
78
- .server(async ({ next, context, method }) => {
79
- const kind = sessionRequestKind(getRequest())
80
- if (!kind) throw new Error('sessionRequestMiddleware is not configured')
81
- let auth: Awaited<ReturnType<typeof getAdminRequestContext>>
82
- try {
83
- auth = await getAdminRequestContext()
84
- } catch (error) {
85
- if (error instanceof AuthError && error.code === 'ERR_ACCESS_EXPIRED')
86
- setResponseHeader(outcomeHeader, 'renew')
87
- throw error
88
- }
89
- const expected = context.bylineExpectedSessionId
90
- if ((kind === 'serverFn' || method !== 'GET') && (!expected || expected !== auth.sessionId)) {
91
- setResponseHeader(outcomeHeader, 'changed')
92
- throw ERR_SESSION_CHANGED({ message: 'session changed before operation' })
93
- }
94
- if (!auth.sessionId) throw ERR_UNAUTHENTICATED({ message: 'provider omitted login identity' })
95
- // Strip the reserved signal after any handler outcome, including exceptions.
96
- try {
97
- const result = await next()
98
- const response = (result as { result?: unknown }).result
99
- if (response instanceof Response) response.headers.delete(outcomeHeader)
100
- return result
101
- } catch (error) {
102
- if (error instanceof Response) error.headers.delete(outcomeHeader)
103
- throw error
104
- } finally {
105
- setResponseHeader(outcomeHeader, 'handled')
106
- }
107
- })
118
+ }
119
+
120
+ export const adminSessionMiddleware = createAdminSessionMiddleware(false)
121
+ export const adminSessionMutationMiddleware = createAdminSessionMiddleware(true)
@@ -8,7 +8,14 @@
8
8
 
9
9
  import { describe, expect, it } from 'vitest'
10
10
 
11
- import { checkSignInBody, SIGN_IN_MAX_BODY_BYTES, wasSignInBodyChecked } from './sign-in-body.js'
11
+ import {
12
+ checkPasskeyBody,
13
+ checkSignInBody,
14
+ PASSKEY_MAX_BODY_BYTES,
15
+ SIGN_IN_MAX_BODY_BYTES,
16
+ wasPasskeyBodyChecked,
17
+ wasSignInBodyChecked,
18
+ } from './sign-in-body.js'
12
19
 
13
20
  function request(body: BodyInit, headers: Record<string, string> = {}) {
14
21
  return new Request('https://app.test/_serverFn/login', {
@@ -57,3 +64,26 @@ describe('sign-in body boundary', () => {
57
64
  expect((await checkSignInBody(new Request('https://app.test')))?.status).toBe(405)
58
65
  })
59
66
  })
67
+
68
+ describe('passkey envelope limits', () => {
69
+ it('allows a bounded attestation envelope but keeps password proof at 16 KiB', async () => {
70
+ const req = request('x'.repeat(20_000))
71
+ expect(await checkPasskeyBody(req)).toBeUndefined()
72
+ expect(wasPasskeyBodyChecked(req)).toBe(true)
73
+ expect(wasPasskeyBodyChecked(req, true)).toBe(false)
74
+ expect((await checkPasskeyBody(req, true))?.status).toBe(413)
75
+ })
76
+ it('rejects actual oversized chunked envelopes and never grants a body marker', async () => {
77
+ const req = request(
78
+ new ReadableStream({
79
+ start(controller) {
80
+ controller.enqueue(new Uint8Array(PASSKEY_MAX_BODY_BYTES))
81
+ controller.enqueue(new Uint8Array(1))
82
+ controller.close()
83
+ },
84
+ })
85
+ )
86
+ expect((await checkPasskeyBody(req))?.status).toBe(413)
87
+ expect(wasPasskeyBodyChecked(req)).toBe(false)
88
+ })
89
+ })
@@ -8,7 +8,9 @@
8
8
 
9
9
  /** Limit the serialized RPC envelope, not just its eventual credential fields. */
10
10
  export const SIGN_IN_MAX_BODY_BYTES = 16 * 1024
11
+ export const PASSKEY_MAX_BODY_BYTES = 64 * 1024
11
12
  const checked = new WeakSet<Request>()
13
+ const passkeyChecked = new WeakMap<Request, number>()
12
14
 
13
15
  export function wasSignInBodyChecked(request: Request): boolean {
14
16
  return checked.has(request)
@@ -16,13 +18,36 @@ export function wasSignInBodyChecked(request: Request): boolean {
16
18
 
17
19
  /** Read a bounded clone before Start parses the original. Never await tee cancellation. */
18
20
  export async function checkSignInBody(request: Request): Promise<Response | undefined> {
21
+ const rejected = await checkBody(request, SIGN_IN_MAX_BODY_BYTES)
22
+ if (!rejected) checked.add(request)
23
+ return rejected
24
+ }
25
+
26
+ export function wasPasskeyBodyChecked(request: Request, passwordProof = false): boolean {
27
+ return (
28
+ passkeyChecked.get(request) ===
29
+ (passwordProof ? SIGN_IN_MAX_BODY_BYTES : PASSKEY_MAX_BODY_BYTES)
30
+ )
31
+ }
32
+
33
+ export async function checkPasskeyBody(
34
+ request: Request,
35
+ passwordProof = false
36
+ ): Promise<Response | undefined> {
37
+ const limit = passwordProof ? SIGN_IN_MAX_BODY_BYTES : PASSKEY_MAX_BODY_BYTES
38
+ const rejected = await checkBody(request, limit)
39
+ if (!rejected) passkeyChecked.set(request, limit)
40
+ return rejected
41
+ }
42
+
43
+ async function checkBody(request: Request, limit: number): Promise<Response | undefined> {
19
44
  if (request.method !== 'POST')
20
45
  return new Response('Method not allowed', { status: 405, headers: { Allow: 'POST' } })
21
46
  if (!request.headers.get('content-type')?.toLowerCase().startsWith('application/json')) {
22
47
  return new Response('Expected JSON', { status: 415 })
23
48
  }
24
49
  const declared = request.headers.get('content-length')
25
- if (declared !== null && (!/^\d+$/.test(declared) || Number(declared) > SIGN_IN_MAX_BODY_BYTES)) {
50
+ if (declared !== null && (!/^\d+$/.test(declared) || Number(declared) > limit)) {
26
51
  return new Response('Payload too large', { status: 413 })
27
52
  }
28
53
  const reader = request.clone().body?.getReader()
@@ -37,9 +62,8 @@ export async function checkSignInBody(request: Request): Promise<Response | unde
37
62
  const { value, done } = await Promise.race([reader.read(), deadline])
38
63
  if (done) break
39
64
  size += value.byteLength
40
- if (size > SIGN_IN_MAX_BODY_BYTES) return new Response('Payload too large', { status: 413 })
65
+ if (size > limit) return new Response('Payload too large', { status: 413 })
41
66
  }
42
- checked.add(request)
43
67
  return undefined
44
68
  } catch {
45
69
  return new Response('Incomplete request body', { status: 408 })
@@ -7,18 +7,51 @@
7
7
  */
8
8
 
9
9
  import { createMiddleware } from '@tanstack/react-start'
10
+ import { setResponseHeader } from '@tanstack/react-start/server'
10
11
 
12
+ import {
13
+ passkeyEnrollment,
14
+ passkeyEnrollmentOptions,
15
+ passkeyList,
16
+ passkeyManagementContext,
17
+ passkeyPasswordProof,
18
+ passkeyProof,
19
+ passkeyProofOptions,
20
+ passkeyRemove,
21
+ passkeyRename,
22
+ passkeySignIn,
23
+ passkeySignInOptions,
24
+ } from '../server-fns/auth/passkeys.js'
11
25
  import { adminSignIn } from '../server-fns/auth/sign-in.js'
12
- import { checkSignInBody } from './sign-in-body.js'
26
+ import { checkPasskeyBody, checkSignInBody } from './sign-in-body.js'
13
27
 
14
28
  /** Register after CSRF in Start's global requestMiddleware, before RPC deserialization. */
15
29
  export const passwordSignInMiddleware = createMiddleware().server(async (ctx) => {
16
- const endpoint = new URL(adminSignIn.url, ctx.request.url).pathname.replace(/\/$/, '')
17
- if (
18
- ctx.handlerType !== 'serverFn' ||
19
- (ctx.pathname !== endpoint && !ctx.pathname.startsWith(`${endpoint}/`))
20
- )
21
- return ctx.next()
22
- const rejected = await checkSignInBody(ctx.request)
23
- return rejected ?? ctx.next()
30
+ if (ctx.handlerType !== 'serverFn') return ctx.next()
31
+ const matches = (url: string) => {
32
+ const endpoint = new URL(url, ctx.request.url).pathname.replace(/\/$/, '')
33
+ return ctx.pathname === endpoint || ctx.pathname.startsWith(`${endpoint}/`)
34
+ }
35
+ if (matches(adminSignIn.url)) return (await checkSignInBody(ctx.request)) ?? ctx.next()
36
+ const passkeys = [
37
+ passkeyManagementContext,
38
+ passkeySignInOptions,
39
+ passkeySignIn,
40
+ passkeyPasswordProof,
41
+ passkeyProofOptions,
42
+ passkeyProof,
43
+ passkeyEnrollmentOptions,
44
+ passkeyEnrollment,
45
+ passkeyList,
46
+ passkeyRename,
47
+ passkeyRemove,
48
+ ]
49
+ if (!passkeys.some((fn) => matches(fn.url))) return ctx.next()
50
+ setResponseHeader('Cache-Control', 'no-store')
51
+ const rejected = await checkPasskeyBody(ctx.request, matches(passkeyPasswordProof.url))
52
+ if (rejected) {
53
+ rejected.headers.set('Cache-Control', 'no-store')
54
+ return rejected
55
+ }
56
+ return ctx.next()
24
57
  })
@@ -0,0 +1,173 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import type {
10
+ PasskeyProvider,
11
+ PasskeyRequestProtection,
12
+ PasswordSignInProtection,
13
+ } from '@byline/auth'
14
+ import { AuthError, AuthErrorCodes } from '@byline/auth'
15
+ import { describe, expect, it, vi } from 'vitest'
16
+
17
+ import { withPasskeyAdmission } from './passkey-admission.js'
18
+
19
+ function fixture() {
20
+ const release = vi.fn()
21
+ const denial = vi.fn()
22
+ const work = vi.fn(async () => 'done')
23
+ const protection: PasskeyRequestProtection = {
24
+ resolveClientIp: vi.fn(async () => '192.0.2.1'),
25
+ limiter: {
26
+ acquire: vi.fn(async () => release),
27
+ consume: vi.fn(async () => ({ allowed: true, retryAfterSeconds: 0 })),
28
+ },
29
+ }
30
+ const provider = { recordAdmissionDenied: denial } as unknown as PasskeyProvider
31
+ const input = {
32
+ provider,
33
+ purpose: 'sign-in' as const,
34
+ request: new Request('https://cms.example.test'),
35
+ bodyChecked: true,
36
+ protection,
37
+ work,
38
+ }
39
+ return { input, release, denial, work, protection }
40
+ }
41
+
42
+ describe('passkey host admission', () => {
43
+ it.each(['middleware', 'protection', 'address', 'capacity', 'counter'] as const)(
44
+ 'fails closed before provider work for %s',
45
+ async (failure) => {
46
+ const f = fixture()
47
+ if (failure === 'middleware') f.input.bodyChecked = false
48
+ if (failure === 'protection')
49
+ f.input.protection = undefined as unknown as PasskeyRequestProtection
50
+ if (failure === 'address') vi.mocked(f.protection.resolveClientIp).mockResolvedValue(null)
51
+ if (failure === 'capacity') vi.mocked(f.protection.limiter.acquire).mockResolvedValue(null)
52
+ if (failure === 'counter')
53
+ vi.mocked(f.protection.limiter.consume).mockRejectedValue(new Error('storage secret'))
54
+ await expect(withPasskeyAdmission(f.input)).rejects.toMatchObject({
55
+ status: failure === 'capacity' ? 429 : 503,
56
+ })
57
+ expect(f.work).not.toHaveBeenCalled()
58
+ expect(f.denial).toHaveBeenCalledTimes(1)
59
+ expect(f.release).toHaveBeenCalledTimes(failure === 'counter' ? 1 : 0)
60
+ }
61
+ )
62
+ it('denies network saturation and releases capacity despite a throwing telemetry hook', async () => {
63
+ const f = fixture()
64
+ vi.mocked(f.protection.limiter.consume).mockResolvedValue({
65
+ allowed: false,
66
+ retryAfterSeconds: 17,
67
+ })
68
+ f.denial.mockImplementation(() => {
69
+ throw new Error('hook failure')
70
+ })
71
+ const error = await withPasskeyAdmission(f.input).catch((error: Response) => error)
72
+ expect(error).toBeInstanceOf(Response)
73
+ expect((error as Response).headers.get('Retry-After')).toBe('17')
74
+ expect((error as Response).headers.get('Cache-Control')).toBe('no-store')
75
+ expect(f.work).not.toHaveBeenCalled()
76
+ expect(f.release).toHaveBeenCalledTimes(1)
77
+ })
78
+ it('does not double-count provider failures as admission denials', async () => {
79
+ const f = fixture()
80
+ f.work.mockRejectedValue(new Error('verification failed'))
81
+ await expect(withPasskeyAdmission(f.input)).rejects.toThrow('verification failed')
82
+ expect(f.denial).not.toHaveBeenCalled()
83
+ expect(f.release).toHaveBeenCalledTimes(1)
84
+ })
85
+ it('runs the full password limiter using server-resolved account email and releases both leases', async () => {
86
+ const f = fixture()
87
+ const releasePassword = vi.fn()
88
+ const passwordProtection: PasswordSignInProtection = {
89
+ resolveClientIp: vi.fn(async () => '192.0.2.1'),
90
+ limiter: {
91
+ acquire: vi.fn(async () => releasePassword),
92
+ consume: vi.fn(async () => ({ allowed: true, retryAfterSeconds: 0 })),
93
+ recordResult: vi.fn(() => {
94
+ throw new Error('hook failure')
95
+ }),
96
+ },
97
+ }
98
+ const resolveActorEmail = vi.fn(async () => 'server-resolved@example.test')
99
+ const input = {
100
+ ...f.input,
101
+ purpose: 'password-proof' as const,
102
+ password: { protection: passwordProtection, resolveActorEmail },
103
+ }
104
+ await expect(withPasskeyAdmission(input)).resolves.toBe('done')
105
+ expect(passwordProtection.limiter.consume).toHaveBeenCalledWith({
106
+ email: 'server-resolved@example.test',
107
+ ip: '192.0.2.1',
108
+ })
109
+ expect(passwordProtection.limiter.recordResult).toHaveBeenCalledWith(
110
+ { email: 'server-resolved@example.test', ip: '192.0.2.1' },
111
+ 'success'
112
+ )
113
+ expect(f.protection.limiter.acquire).toHaveBeenCalledBefore(resolveActorEmail)
114
+ expect(passwordProtection.limiter.acquire).toHaveBeenCalledBefore(
115
+ passwordProtection.limiter.consume
116
+ )
117
+ expect(f.release).toHaveBeenCalledTimes(1)
118
+ expect(releasePassword).toHaveBeenCalledTimes(1)
119
+ expect(f.denial).not.toHaveBeenCalled()
120
+ vi.mocked(passwordProtection.limiter.consume).mockResolvedValue({
121
+ allowed: false,
122
+ retryAfterSeconds: 4,
123
+ })
124
+ await expect(withPasskeyAdmission(input)).rejects.toMatchObject({ status: 429 })
125
+ expect(f.work).toHaveBeenCalledTimes(1)
126
+ expect(f.release).toHaveBeenCalledTimes(2)
127
+ expect(releasePassword).toHaveBeenCalledTimes(2)
128
+ expect(f.denial).toHaveBeenCalledExactlyOnceWith('password-proof', 'network')
129
+ })
130
+ it.each([
131
+ AuthErrorCodes.INVALID_CREDENTIALS,
132
+ AuthErrorCodes.FORBIDDEN,
133
+ AuthErrorCodes.SESSION_CHANGED,
134
+ AuthErrorCodes.PASSKEY_LIMIT,
135
+ AuthErrorCodes.PASSKEY_UNAVAILABLE,
136
+ ])('accounts for password-proof errors accurately: %s', async (code) => {
137
+ const f = fixture()
138
+ const releasePassword = vi.fn()
139
+ const recordResult = vi.fn()
140
+ f.work.mockRejectedValue(new AuthError(code, { message: 'Rejected' }))
141
+ const passwordProtection: PasswordSignInProtection = {
142
+ resolveClientIp: async () => '192.0.2.1',
143
+ limiter: {
144
+ acquire: async () => releasePassword,
145
+ consume: async () => ({ allowed: true, retryAfterSeconds: 0 }),
146
+ recordResult,
147
+ },
148
+ }
149
+ await expect(
150
+ withPasskeyAdmission({
151
+ ...f.input,
152
+ purpose: 'password-proof',
153
+ password: {
154
+ protection: passwordProtection,
155
+ resolveActorEmail: async () => 'server@example.test',
156
+ },
157
+ })
158
+ ).rejects.toMatchObject({ code })
159
+ expect(recordResult).toHaveBeenCalledTimes(code === AuthErrorCodes.INVALID_CREDENTIALS ? 1 : 0)
160
+ expect(f.denial).not.toHaveBeenCalled()
161
+ expect(releasePassword).toHaveBeenCalledTimes(1)
162
+ expect(f.release).toHaveBeenCalledTimes(1)
163
+ })
164
+
165
+ it('cannot use password proof without the full password protection', async () => {
166
+ const f = fixture()
167
+ await expect(
168
+ withPasskeyAdmission({ ...f.input, purpose: 'password-proof' })
169
+ ).rejects.toMatchObject({ status: 503 })
170
+ expect(f.work).not.toHaveBeenCalled()
171
+ expect(f.release).toHaveBeenCalledTimes(1)
172
+ })
173
+ })