@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.
- package/dist/admin-shell/admin-roles/container_module.css +1 -1
- package/dist/admin-shell/admin-roles/delete.js +1 -1
- package/dist/admin-shell/admin-users/container.js +181 -159
- package/dist/admin-shell/admin-users/container.module.js +2 -1
- package/dist/admin-shell/admin-users/container_module.css +5 -1
- package/dist/admin-shell/admin-users/delete.js +1 -1
- package/dist/admin-shell/chrome/admin-app-bar.d.ts +0 -7
- package/dist/admin-shell/collections/api.d.ts +6 -2
- package/dist/admin-shell/collections/api.js +109 -3
- package/dist/admin-shell/collections/api.test.d.ts +8 -0
- package/dist/admin-shell/collections/edit.js +2 -0
- package/dist/admin-shell/collections/preview-link.js +17 -13
- package/dist/admin-shell/collections/view-menu.d.ts +8 -1
- package/dist/admin-shell/collections/view-menu.js +124 -107
- package/dist/admin-shell/collections/view-menu.module.js +1 -0
- package/dist/admin-shell/collections/view-menu_module.css +15 -1
- package/dist/admin-shell/scheduled-publications/list.js +2 -2
- package/dist/admin-shell/singletons/preview-link.js +17 -13
- package/dist/admin-shell/singletons/view-menu.js +78 -63
- package/dist/admin-shell/singletons/view.js +2 -0
- package/dist/integrations/byline-admin-services.js +2 -0
- package/dist/integrations/passkey-admin-services.d.ts +9 -0
- package/dist/integrations/passkey-admin-services.js +53 -0
- package/dist/integrations/session-coordination.d.ts +3 -1
- package/dist/integrations/session-coordination.js +3 -1
- package/dist/integrations/session-middleware.d.ts +3 -1
- package/dist/integrations/session-middleware.js +87 -79
- package/dist/integrations/sign-in-body.d.ts +3 -0
- package/dist/integrations/sign-in-body.js +19 -4
- package/dist/integrations/sign-in-middleware.js +30 -5
- package/dist/server-fns/auth/passkey-admission.d.ts +24 -0
- package/dist/server-fns/auth/passkey-admission.js +88 -0
- package/dist/server-fns/auth/passkeys.d.ts +96 -0
- package/dist/server-fns/auth/passkeys.js +238 -0
- package/dist/server-fns/auth/reconcile-locale.d.ts +20 -0
- package/dist/server-fns/auth/reconcile-locale.js +16 -0
- package/dist/server-fns/auth/sign-in.js +1 -15
- package/dist/server-fns/collections/get.d.ts +1 -0
- package/dist/server-fns/collections/get.js +4 -0
- package/package.json +11 -11
- package/src/admin-shell/admin-roles/container.module.css +1 -1
- package/src/admin-shell/admin-roles/delete.tsx +1 -1
- package/src/admin-shell/admin-users/container.module.css +7 -1
- package/src/admin-shell/admin-users/container.tsx +22 -2
- package/src/admin-shell/admin-users/delete.tsx +1 -1
- package/src/admin-shell/chrome/admin-app-bar.tsx +2 -2
- package/src/admin-shell/chrome/dashboard.test.tsx +2 -2
- package/src/admin-shell/collections/api.test.tsx +223 -0
- package/src/admin-shell/collections/api.tsx +98 -3
- package/src/admin-shell/collections/edit.tsx +9 -1
- package/src/admin-shell/collections/preview-link.tsx +13 -12
- package/src/admin-shell/collections/view-menu.module.css +29 -3
- package/src/admin-shell/collections/view-menu.tsx +114 -96
- package/src/admin-shell/scheduled-publications/list.tsx +3 -4
- package/src/admin-shell/singletons/history.test.tsx +1 -1
- package/src/admin-shell/singletons/preview-link.tsx +13 -12
- package/src/admin-shell/singletons/view-menu.tsx +65 -59
- package/src/admin-shell/singletons/view.test.tsx +4 -4
- package/src/admin-shell/singletons/view.tsx +3 -1
- package/src/integrations/byline-admin-services.ts +3 -0
- package/src/integrations/passkey-admin-services.ts +85 -0
- package/src/integrations/session-client.test.node.ts +73 -0
- package/src/integrations/session-coordination.ts +3 -1
- package/src/integrations/session-middleware.ts +86 -72
- package/src/integrations/sign-in-body.test.node.ts +31 -1
- package/src/integrations/sign-in-body.ts +27 -3
- package/src/integrations/sign-in-middleware.ts +42 -9
- package/src/server-fns/auth/passkey-admission.test.node.ts +173 -0
- package/src/server-fns/auth/passkey-admission.ts +125 -0
- package/src/server-fns/auth/passkeys.test.node.ts +247 -0
- package/src/server-fns/auth/passkeys.ts +252 -0
- package/src/server-fns/auth/reconcile-locale.ts +40 -0
- package/src/server-fns/auth/sign-in.ts +1 -32
- 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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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 {
|
|
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) >
|
|
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 >
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
ctx.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
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
|
+
})
|