@byline/host-tanstack-start 6.7.2 → 6.8.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 (43) 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/integrations/byline-admin-services.js +2 -0
  8. package/dist/integrations/passkey-admin-services.d.ts +9 -0
  9. package/dist/integrations/passkey-admin-services.js +53 -0
  10. package/dist/integrations/session-coordination.d.ts +3 -1
  11. package/dist/integrations/session-coordination.js +3 -1
  12. package/dist/integrations/session-middleware.d.ts +3 -1
  13. package/dist/integrations/session-middleware.js +87 -79
  14. package/dist/integrations/sign-in-body.d.ts +3 -0
  15. package/dist/integrations/sign-in-body.js +19 -4
  16. package/dist/integrations/sign-in-middleware.js +30 -5
  17. package/dist/server-fns/auth/passkey-admission.d.ts +24 -0
  18. package/dist/server-fns/auth/passkey-admission.js +88 -0
  19. package/dist/server-fns/auth/passkeys.d.ts +96 -0
  20. package/dist/server-fns/auth/passkeys.js +238 -0
  21. package/dist/server-fns/auth/reconcile-locale.d.ts +20 -0
  22. package/dist/server-fns/auth/reconcile-locale.js +16 -0
  23. package/dist/server-fns/auth/sign-in.js +1 -15
  24. package/package.json +11 -11
  25. package/src/admin-shell/admin-roles/container.module.css +1 -1
  26. package/src/admin-shell/admin-roles/delete.tsx +1 -1
  27. package/src/admin-shell/admin-users/container.module.css +7 -1
  28. package/src/admin-shell/admin-users/container.tsx +22 -2
  29. package/src/admin-shell/admin-users/delete.tsx +1 -1
  30. package/src/integrations/byline-admin-services.ts +3 -0
  31. package/src/integrations/passkey-admin-services.ts +85 -0
  32. package/src/integrations/session-client.test.node.ts +73 -0
  33. package/src/integrations/session-coordination.ts +3 -1
  34. package/src/integrations/session-middleware.ts +86 -72
  35. package/src/integrations/sign-in-body.test.node.ts +31 -1
  36. package/src/integrations/sign-in-body.ts +27 -3
  37. package/src/integrations/sign-in-middleware.ts +42 -9
  38. package/src/server-fns/auth/passkey-admission.test.node.ts +173 -0
  39. package/src/server-fns/auth/passkey-admission.ts +125 -0
  40. package/src/server-fns/auth/passkeys.test.node.ts +247 -0
  41. package/src/server-fns/auth/passkeys.ts +252 -0
  42. package/src/server-fns/auth/reconcile-locale.ts +40 -0
  43. package/src/server-fns/auth/sign-in.ts +1 -32
@@ -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
+ })
@@ -0,0 +1,125 @@
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
+ PasskeyAdmissionReason,
11
+ PasskeyOperation,
12
+ PasskeyProvider,
13
+ PasskeyRequestProtection,
14
+ PasswordSignInProtection,
15
+ } from '@byline/auth'
16
+ import { AuthError, AuthErrorCodes } from '@byline/auth'
17
+
18
+ import { normalizeClientIp } from '../../integrations/client-ip.js'
19
+
20
+ /** Host boundary shared by the passkey POST handlers. Body and CSRF guards run first. */
21
+ export async function withPasskeyAdmission<T>(input: {
22
+ provider: PasskeyProvider
23
+ purpose: PasskeyOperation
24
+ request: Request
25
+ bodyChecked: boolean
26
+ protection: PasskeyRequestProtection | undefined
27
+ /** Required only for password proof; lookup must authenticate the expected login. */
28
+ password?: {
29
+ protection: PasswordSignInProtection | undefined
30
+ resolveActorEmail(): Promise<string>
31
+ }
32
+ work(metadata: { ip: string }): Promise<T>
33
+ }): Promise<T> {
34
+ const recordDenial = (reason: PasskeyAdmissionReason) => {
35
+ try {
36
+ void Promise.resolve(input.provider.recordAdmissionDenied?.(input.purpose, reason)).catch(
37
+ () => {}
38
+ )
39
+ } catch {
40
+ /* Observability cannot change admission or strand capacity. */
41
+ }
42
+ }
43
+ const denied = (reason: PasskeyAdmissionReason, status: number, retry?: number): never => {
44
+ recordDenial(reason)
45
+ throw new Response(
46
+ status === 429
47
+ ? 'Too many authentication requests'
48
+ : 'Authentication temporarily unavailable',
49
+ {
50
+ status,
51
+ headers: {
52
+ 'Cache-Control': 'no-store',
53
+ ...(retry ? { 'Retry-After': String(retry) } : {}),
54
+ },
55
+ }
56
+ )
57
+ }
58
+ const available = async <R>(work: () => Promise<R>): Promise<R> => {
59
+ try {
60
+ return await work()
61
+ } catch {
62
+ return denied('unavailable', 503)
63
+ }
64
+ }
65
+ const protection = input.protection
66
+ if (!input.bodyChecked || !protection) return denied('unavailable', 503)
67
+ const ip = await available(async () =>
68
+ normalizeClientIp(await protection.resolveClientIp(input.request))
69
+ )
70
+ if (!ip) return denied('unavailable', 503)
71
+ const release = await available(() => protection.limiter.acquire())
72
+ if (!release) return denied('capacity', 429, 1)
73
+ try {
74
+ const admission = await available(() => protection.limiter.consume({ ip }))
75
+ if (!admission.allowed) return denied('network', 429, admission.retryAfterSeconds)
76
+ if (input.purpose === 'password-proof' && !input.password) return denied('unavailable', 503)
77
+ if (!input.password) return await input.work({ ip })
78
+ const password = input.password
79
+ const passwordProtection = password.protection
80
+ if (!passwordProtection) return denied('unavailable', 503)
81
+ // Resolve the account from the authenticated expected login, never from submitted email.
82
+ let email: string
83
+ try {
84
+ email = await password.resolveActorEmail()
85
+ } catch (error) {
86
+ recordDenial('unavailable')
87
+ throw error
88
+ }
89
+ const passwordIp = await available(async () =>
90
+ normalizeClientIp(await passwordProtection.resolveClientIp(input.request))
91
+ )
92
+ if (!passwordIp) return denied('unavailable', 503)
93
+ const releasePassword = await available(() => passwordProtection.limiter.acquire())
94
+ if (!releasePassword) return denied('capacity', 429, 1)
95
+ try {
96
+ const passwordAdmission = await available(() =>
97
+ passwordProtection.limiter.consume({ email, ip: passwordIp })
98
+ )
99
+ if (!passwordAdmission.allowed)
100
+ return denied('network', 429, passwordAdmission.retryAfterSeconds)
101
+ const record = (outcome: 'success' | 'failure') => {
102
+ try {
103
+ void Promise.resolve(
104
+ passwordProtection.limiter.recordResult?.({ email, ip: passwordIp }, outcome)
105
+ ).catch(() => {})
106
+ } catch {
107
+ /* Best effort. */
108
+ }
109
+ }
110
+ try {
111
+ const result = await input.work({ ip })
112
+ record('success')
113
+ return result
114
+ } catch (error) {
115
+ if (error instanceof AuthError && error.code === AuthErrorCodes.INVALID_CREDENTIALS)
116
+ record('failure')
117
+ throw error
118
+ }
119
+ } finally {
120
+ releasePassword()
121
+ }
122
+ } finally {
123
+ release()
124
+ }
125
+ }