@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.
- 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/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/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/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
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { AdminAuth, ERR_SESSION_CHANGED } from '@byline/auth'
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
const mocks = vi.hoisted(() => ({
|
|
5
|
+
config: {} as any,
|
|
6
|
+
request: null as unknown as Request,
|
|
7
|
+
auth: vi.fn(),
|
|
8
|
+
user: vi.fn(),
|
|
9
|
+
setCookies: vi.fn(),
|
|
10
|
+
clearCookies: vi.fn(),
|
|
11
|
+
clearPreview: vi.fn(),
|
|
12
|
+
header: vi.fn(),
|
|
13
|
+
release: vi.fn(),
|
|
14
|
+
consume: vi.fn(),
|
|
15
|
+
acquire: vi.fn(),
|
|
16
|
+
passwordConsume: vi.fn(),
|
|
17
|
+
passwordRelease: vi.fn(),
|
|
18
|
+
}))
|
|
19
|
+
vi.mock('@tanstack/react-start', () => ({
|
|
20
|
+
createServerFn: () => {
|
|
21
|
+
const result: any = {
|
|
22
|
+
middleware: () => result,
|
|
23
|
+
validator: (validate: any) => {
|
|
24
|
+
result.validate = validate
|
|
25
|
+
return result
|
|
26
|
+
},
|
|
27
|
+
handler: (handler: any) => {
|
|
28
|
+
result.handler = handler
|
|
29
|
+
return result
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
return result
|
|
33
|
+
},
|
|
34
|
+
}))
|
|
35
|
+
vi.mock('@tanstack/react-start/server', () => ({
|
|
36
|
+
getRequest: () => mocks.request,
|
|
37
|
+
getRequestHeader: () => undefined,
|
|
38
|
+
setResponseHeader: mocks.header,
|
|
39
|
+
}))
|
|
40
|
+
vi.mock('@byline/core', () => ({ getServerConfig: () => mocks.config }))
|
|
41
|
+
vi.mock('@byline/client/server', () => ({
|
|
42
|
+
getAdminRequestContext: mocks.auth,
|
|
43
|
+
readAccessTokenCookie: () => 'server-access',
|
|
44
|
+
readRefreshTokenCookie: () => 'server-refresh',
|
|
45
|
+
setSessionCookies: mocks.setCookies,
|
|
46
|
+
clearSessionCookies: mocks.clearCookies,
|
|
47
|
+
clearPreviewCookie: mocks.clearPreview,
|
|
48
|
+
}))
|
|
49
|
+
vi.mock('../../integrations/session-middleware.js', () => ({
|
|
50
|
+
adminSessionMiddleware: {},
|
|
51
|
+
adminSessionMutationMiddleware: {},
|
|
52
|
+
}))
|
|
53
|
+
vi.mock('../../integrations/byline-core.js', () => ({
|
|
54
|
+
bylineCore: () => ({ adminStore: { adminUsers: { getById: mocks.user } } }),
|
|
55
|
+
}))
|
|
56
|
+
vi.mock('../../i18n/locale-cookie.js', () => ({ readAdminLocaleCookie: () => null }))
|
|
57
|
+
|
|
58
|
+
import { checkPasskeyBody } from '../../integrations/sign-in-body.js'
|
|
59
|
+
import * as endpoints from './passkeys.js'
|
|
60
|
+
|
|
61
|
+
const sid = '00000000-0000-4000-8000-000000000001'
|
|
62
|
+
const userId = '00000000-0000-4000-8000-000000000002'
|
|
63
|
+
const bearer = 'a'.repeat(43)
|
|
64
|
+
const methods = [
|
|
65
|
+
'signInOptions',
|
|
66
|
+
'verifySignIn',
|
|
67
|
+
'passwordProof',
|
|
68
|
+
'proofOptions',
|
|
69
|
+
'verifyProof',
|
|
70
|
+
'enrollmentOptions',
|
|
71
|
+
'verifyEnrollment',
|
|
72
|
+
'listCredentials',
|
|
73
|
+
'renameCredential',
|
|
74
|
+
'removeCredential',
|
|
75
|
+
]
|
|
76
|
+
let provider: Record<string, ReturnType<typeof vi.fn>>
|
|
77
|
+
const invoke = (endpoint: unknown, data: unknown = {}) => {
|
|
78
|
+
const fn = endpoint as {
|
|
79
|
+
validate?: (data: unknown) => unknown
|
|
80
|
+
handler: (args: any) => Promise<any>
|
|
81
|
+
}
|
|
82
|
+
return fn.handler({ data: fn.validate ? fn.validate(data) : data })
|
|
83
|
+
}
|
|
84
|
+
async function request(password = false) {
|
|
85
|
+
mocks.request = new Request('https://app.test/passkeys', {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: { 'content-type': 'application/json' },
|
|
88
|
+
body: '{}',
|
|
89
|
+
})
|
|
90
|
+
await checkPasskeyBody(mocks.request, password)
|
|
91
|
+
}
|
|
92
|
+
beforeEach(async () => {
|
|
93
|
+
vi.resetAllMocks()
|
|
94
|
+
provider = Object.fromEntries(methods.map((name) => [name, vi.fn().mockResolvedValue({})]))
|
|
95
|
+
mocks.auth.mockResolvedValue({
|
|
96
|
+
actor: new AdminAuth({ id: userId, abilities: [] }),
|
|
97
|
+
sessionId: sid,
|
|
98
|
+
})
|
|
99
|
+
mocks.user.mockResolvedValue({ id: userId, email: 'actor@example.test' })
|
|
100
|
+
mocks.acquire.mockResolvedValue(mocks.release)
|
|
101
|
+
mocks.consume.mockResolvedValue({ allowed: true, retryAfterSeconds: 0 })
|
|
102
|
+
mocks.passwordConsume.mockResolvedValue({ allowed: true, retryAfterSeconds: 0 })
|
|
103
|
+
mocks.config = {
|
|
104
|
+
passkeys: {},
|
|
105
|
+
sessionProvider: { capabilities: { passkeys: true }, passkeys: provider },
|
|
106
|
+
passkeyRequests: {
|
|
107
|
+
resolveClientIp: () => '192.0.2.1',
|
|
108
|
+
limiter: { acquire: mocks.acquire, consume: mocks.consume },
|
|
109
|
+
},
|
|
110
|
+
passwordSignIn: {
|
|
111
|
+
resolveClientIp: () => '192.0.2.1',
|
|
112
|
+
limiter: { acquire: async () => mocks.passwordRelease, consume: mocks.passwordConsume },
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
await request()
|
|
116
|
+
})
|
|
117
|
+
describe('passkey host endpoints', () => {
|
|
118
|
+
it('discovers only an enabled capability without starting a ceremony', async () => {
|
|
119
|
+
expect(await invoke(endpoints.getPasskeyCapability)).toEqual({ enabled: true })
|
|
120
|
+
delete mocks.config.passkeys
|
|
121
|
+
expect(await invoke(endpoints.getPasskeyCapability)).toEqual({ enabled: false })
|
|
122
|
+
expect(provider.signInOptions).not.toHaveBeenCalled()
|
|
123
|
+
})
|
|
124
|
+
it('keeps tokens server-side and writes cookies only after verified issuance', async () => {
|
|
125
|
+
const result = {
|
|
126
|
+
actor: { id: userId },
|
|
127
|
+
sessionId: sid,
|
|
128
|
+
accessToken: 'secret',
|
|
129
|
+
refreshToken: 'secret',
|
|
130
|
+
}
|
|
131
|
+
provider.verifySignIn.mockResolvedValue(result)
|
|
132
|
+
const response = { raw: 'untrusted' }
|
|
133
|
+
expect(await invoke(endpoints.passkeySignIn, { ceremonyId: bearer, response })).toEqual({
|
|
134
|
+
userId,
|
|
135
|
+
sessionId: sid,
|
|
136
|
+
})
|
|
137
|
+
expect(provider.verifySignIn).toHaveBeenCalledWith(
|
|
138
|
+
expect.objectContaining({
|
|
139
|
+
previousAccessToken: 'server-access',
|
|
140
|
+
previousRefreshToken: 'server-refresh',
|
|
141
|
+
response,
|
|
142
|
+
})
|
|
143
|
+
)
|
|
144
|
+
expect(mocks.setCookies).toHaveBeenCalledWith(result)
|
|
145
|
+
expect(mocks.header).toHaveBeenCalledWith('Cache-Control', 'no-store')
|
|
146
|
+
provider.verifySignIn.mockRejectedValue(new Error('uncertain commit'))
|
|
147
|
+
await expect(invoke(endpoints.passkeySignIn, { ceremonyId: bearer, response })).rejects.toThrow(
|
|
148
|
+
'uncertain commit'
|
|
149
|
+
)
|
|
150
|
+
expect(mocks.setCookies).toHaveBeenCalledTimes(1)
|
|
151
|
+
})
|
|
152
|
+
it.each([undefined, false, true, 'false'])(
|
|
153
|
+
'forwards crossOrigin %s without rewriting signed response bytes',
|
|
154
|
+
async (crossOrigin) => {
|
|
155
|
+
const response = {
|
|
156
|
+
response: {
|
|
157
|
+
clientDataJSON: Buffer.from(JSON.stringify({ crossOrigin })).toString('base64url'),
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
await invoke(endpoints.passkeyProof, { expectedSessionId: sid, ceremonyId: bearer, response })
|
|
161
|
+
expect(provider.verifyProof.mock.calls[0][0].response).toBe(response)
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
it('applies both limiters with server-resolved email for password proof', async () => {
|
|
165
|
+
await request(true)
|
|
166
|
+
await invoke(endpoints.passkeyPasswordProof, {
|
|
167
|
+
expectedSessionId: sid,
|
|
168
|
+
password: 'legacy',
|
|
169
|
+
action: { purpose: 'enroll' },
|
|
170
|
+
})
|
|
171
|
+
expect(mocks.passwordConsume).toHaveBeenCalledWith({
|
|
172
|
+
email: 'actor@example.test',
|
|
173
|
+
ip: '192.0.2.1',
|
|
174
|
+
})
|
|
175
|
+
expect(mocks.consume).toHaveBeenCalledOnce()
|
|
176
|
+
expect(mocks.passwordRelease).toHaveBeenCalledOnce()
|
|
177
|
+
expect(provider.passwordProof).toHaveBeenCalledWith({
|
|
178
|
+
expectedSessionId: sid,
|
|
179
|
+
accessToken: 'server-access',
|
|
180
|
+
password: 'legacy',
|
|
181
|
+
action: { purpose: 'enroll' },
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
it('rejects a substituted expected login before proof or password lookup', async () => {
|
|
185
|
+
await request(true)
|
|
186
|
+
await expect(
|
|
187
|
+
invoke(endpoints.passkeyPasswordProof, {
|
|
188
|
+
expectedSessionId: userId,
|
|
189
|
+
password: 'legacy',
|
|
190
|
+
action: { purpose: 'enroll' },
|
|
191
|
+
})
|
|
192
|
+
).rejects.toMatchObject({ code: 'ERR_SESSION_CHANGED' })
|
|
193
|
+
expect(mocks.user).not.toHaveBeenCalled()
|
|
194
|
+
expect(provider.passwordProof).not.toHaveBeenCalled()
|
|
195
|
+
})
|
|
196
|
+
it('does not let a client supply an email or access credential', () => {
|
|
197
|
+
expect(() =>
|
|
198
|
+
invoke(endpoints.passkeyPasswordProof, {
|
|
199
|
+
expectedSessionId: sid,
|
|
200
|
+
password: 'legacy',
|
|
201
|
+
action: { purpose: 'enroll' },
|
|
202
|
+
email: 'other@example.test',
|
|
203
|
+
})
|
|
204
|
+
).toThrow()
|
|
205
|
+
expect(() =>
|
|
206
|
+
invoke(endpoints.passkeyList, { expectedSessionId: sid, accessToken: 'client-token' })
|
|
207
|
+
).toThrow()
|
|
208
|
+
})
|
|
209
|
+
it.each([false, true])(
|
|
210
|
+
'clears cookies only when removal signs out the actor: %s',
|
|
211
|
+
async (signedOut) => {
|
|
212
|
+
provider.removeCredential.mockResolvedValue({ signedOut })
|
|
213
|
+
expect(
|
|
214
|
+
await invoke(endpoints.passkeyRemove, { expectedSessionId: sid, grantId: bearer })
|
|
215
|
+
).toEqual({ signedOut })
|
|
216
|
+
expect(mocks.clearCookies).toHaveBeenCalledTimes(signedOut ? 1 : 0)
|
|
217
|
+
expect(mocks.clearPreview).toHaveBeenCalledTimes(signedOut ? 1 : 0)
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
it('does not clear cookies or retry an ambiguous removal', async () => {
|
|
221
|
+
provider.removeCredential.mockRejectedValue(new Error('connection lost'))
|
|
222
|
+
await expect(
|
|
223
|
+
invoke(endpoints.passkeyRemove, { expectedSessionId: sid, grantId: bearer })
|
|
224
|
+
).rejects.toThrow('connection lost')
|
|
225
|
+
expect(provider.removeCredential).toHaveBeenCalledOnce()
|
|
226
|
+
expect(mocks.clearCookies).not.toHaveBeenCalled()
|
|
227
|
+
})
|
|
228
|
+
it.each(['middleware', 'protection', 'capability', 'capacity'])(
|
|
229
|
+
'fails closed before provider work without %s',
|
|
230
|
+
async (failure) => {
|
|
231
|
+
if (failure === 'middleware') mocks.request = new Request('https://app.test')
|
|
232
|
+
if (failure === 'protection') delete mocks.config.passkeyRequests
|
|
233
|
+
if (failure === 'capability') mocks.config.sessionProvider.capabilities.passkeys = false
|
|
234
|
+
if (failure === 'capacity') mocks.acquire.mockResolvedValue(null)
|
|
235
|
+
const error = await invoke(endpoints.passkeySignInOptions).catch((error) => error)
|
|
236
|
+
expect(error.status).toBe(failure === 'capability' ? 404 : failure === 'capacity' ? 429 : 503)
|
|
237
|
+
expect(provider.signInOptions).not.toHaveBeenCalled()
|
|
238
|
+
}
|
|
239
|
+
)
|
|
240
|
+
it('never retries provider session-change errors', async () => {
|
|
241
|
+
provider.verifyProof.mockRejectedValue(ERR_SESSION_CHANGED({ message: 'authority changed' }))
|
|
242
|
+
await expect(
|
|
243
|
+
invoke(endpoints.passkeyProof, { expectedSessionId: sid, ceremonyId: bearer, response: {} })
|
|
244
|
+
).rejects.toMatchObject({ code: 'ERR_SESSION_CHANGED' })
|
|
245
|
+
expect(provider.verifyProof).toHaveBeenCalledOnce()
|
|
246
|
+
})
|
|
247
|
+
})
|
|
@@ -0,0 +1,252 @@
|
|
|
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 { createServerFn } from '@tanstack/react-start'
|
|
10
|
+
import { getRequest, setResponseHeader } from '@tanstack/react-start/server'
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
ERR_SESSION_CHANGED,
|
|
14
|
+
ERR_UNAUTHENTICATED,
|
|
15
|
+
isAdminAuth,
|
|
16
|
+
type PasskeyOperation,
|
|
17
|
+
type PasskeyProvider,
|
|
18
|
+
} from '@byline/auth'
|
|
19
|
+
import {
|
|
20
|
+
clearPreviewCookie,
|
|
21
|
+
clearSessionCookies,
|
|
22
|
+
getAdminRequestContext,
|
|
23
|
+
readAccessTokenCookie,
|
|
24
|
+
readRefreshTokenCookie,
|
|
25
|
+
setSessionCookies,
|
|
26
|
+
} from '@byline/client/server'
|
|
27
|
+
import { getServerConfig } from '@byline/core'
|
|
28
|
+
import { z } from 'zod'
|
|
29
|
+
|
|
30
|
+
import { bylineCore } from '../../integrations/byline-core.js'
|
|
31
|
+
import {
|
|
32
|
+
adminSessionMiddleware,
|
|
33
|
+
adminSessionMutationMiddleware,
|
|
34
|
+
} from '../../integrations/session-middleware.js'
|
|
35
|
+
import { wasPasskeyBodyChecked } from '../../integrations/sign-in-body.js'
|
|
36
|
+
import { withPasskeyAdmission } from './passkey-admission.js'
|
|
37
|
+
import { reconcileLocaleAfterSignIn } from './reconcile-locale.js'
|
|
38
|
+
|
|
39
|
+
const session = z.object({ expectedSessionId: z.uuid() })
|
|
40
|
+
const bearer = z.string().regex(/^[A-Za-z0-9_-]{43}$/)
|
|
41
|
+
const action = z.discriminatedUnion('purpose', [
|
|
42
|
+
z.object({ purpose: z.literal('enroll') }).strict(),
|
|
43
|
+
z
|
|
44
|
+
.object({ purpose: z.literal('remove'), targetUserId: z.uuid(), credentialRecordId: z.uuid() })
|
|
45
|
+
.strict(),
|
|
46
|
+
])
|
|
47
|
+
const name = z.string().trim().min(1).max(80)
|
|
48
|
+
function validate<T>(schema: z.ZodType<T>) {
|
|
49
|
+
return (input: unknown): T => {
|
|
50
|
+
const parsed = schema.safeParse(input)
|
|
51
|
+
if (!parsed.success)
|
|
52
|
+
throw new Response('Invalid passkey input', {
|
|
53
|
+
status: 400,
|
|
54
|
+
headers: { 'Cache-Control': 'no-store' },
|
|
55
|
+
})
|
|
56
|
+
return parsed.data
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function configuredProvider(): PasskeyProvider {
|
|
61
|
+
setResponseHeader('Cache-Control', 'no-store')
|
|
62
|
+
const config = getServerConfig()
|
|
63
|
+
const provider = config.sessionProvider
|
|
64
|
+
if (!config.passkeys || provider?.capabilities?.passkeys !== true || !provider.passkeys)
|
|
65
|
+
throw new Response('Passkeys are not enabled', {
|
|
66
|
+
status: 404,
|
|
67
|
+
headers: { 'Cache-Control': 'no-store' },
|
|
68
|
+
})
|
|
69
|
+
return provider.passkeys
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function currentSession(expectedSessionId: string) {
|
|
73
|
+
const auth = await getAdminRequestContext()
|
|
74
|
+
if (auth.sessionId !== expectedSessionId)
|
|
75
|
+
throw ERR_SESSION_CHANGED({ message: 'session changed before passkey operation' })
|
|
76
|
+
const accessToken = readAccessTokenCookie()
|
|
77
|
+
if (!accessToken) throw ERR_UNAUTHENTICATED({ message: 'access credential missing' })
|
|
78
|
+
return { accessToken, expectedSessionId }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function admitted<T>(
|
|
82
|
+
purpose: PasskeyOperation,
|
|
83
|
+
work: (provider: PasskeyProvider, metadata: { ip: string; userAgent?: string }) => Promise<T>,
|
|
84
|
+
expectedSessionId?: string
|
|
85
|
+
) {
|
|
86
|
+
const provider = configuredProvider()
|
|
87
|
+
const config = getServerConfig()
|
|
88
|
+
const request = getRequest()
|
|
89
|
+
return withPasskeyAdmission({
|
|
90
|
+
provider,
|
|
91
|
+
purpose,
|
|
92
|
+
request,
|
|
93
|
+
bodyChecked: wasPasskeyBodyChecked(request, purpose === 'password-proof'),
|
|
94
|
+
protection: config.passkeyRequests,
|
|
95
|
+
...(purpose === 'password-proof'
|
|
96
|
+
? {
|
|
97
|
+
password: {
|
|
98
|
+
protection: config.passwordSignIn,
|
|
99
|
+
resolveActorEmail: async () => {
|
|
100
|
+
const auth = await getAdminRequestContext()
|
|
101
|
+
if (!auth.actor || !expectedSessionId || auth.sessionId !== expectedSessionId)
|
|
102
|
+
throw ERR_SESSION_CHANGED({ message: 'session changed before password proof' })
|
|
103
|
+
const user = await bylineCore().adminStore?.adminUsers.getById(auth.actor.id)
|
|
104
|
+
if (!user) throw ERR_UNAUTHENTICATED({ message: 'account unavailable' })
|
|
105
|
+
return user.email
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
: {}),
|
|
110
|
+
work: ({ ip }) =>
|
|
111
|
+
work(provider, { ip, userAgent: request.headers.get('user-agent')?.slice(0, 512) }),
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Cosmetic capability discovery creates no ceremony and reads no account data. */
|
|
116
|
+
export const getPasskeyCapability = createServerFn({ method: 'GET' }).handler(async () => {
|
|
117
|
+
setResponseHeader('Cache-Control', 'no-store')
|
|
118
|
+
const config = getServerConfig()
|
|
119
|
+
return {
|
|
120
|
+
enabled: !!(
|
|
121
|
+
config.passkeys &&
|
|
122
|
+
config.sessionProvider?.capabilities?.passkeys &&
|
|
123
|
+
config.sessionProvider.passkeys
|
|
124
|
+
),
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
export const passkeySignInOptions = createServerFn({ method: 'POST' })
|
|
129
|
+
.validator(validate(z.object({}).strict()))
|
|
130
|
+
.handler(async () =>
|
|
131
|
+
admitted('sign-in-options', (provider, metadata) => provider.signInOptions(metadata))
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
export const passkeySignIn = createServerFn({ method: 'POST' })
|
|
135
|
+
.validator(validate(z.object({ ceremonyId: bearer, response: z.unknown() }).strict()))
|
|
136
|
+
.handler(async ({ data }) =>
|
|
137
|
+
admitted('sign-in', async (provider, metadata) => {
|
|
138
|
+
const result = await provider.verifySignIn({
|
|
139
|
+
...data,
|
|
140
|
+
...metadata,
|
|
141
|
+
previousAccessToken: readAccessTokenCookie(),
|
|
142
|
+
previousRefreshToken: readRefreshTokenCookie(),
|
|
143
|
+
})
|
|
144
|
+
setSessionCookies(result)
|
|
145
|
+
try {
|
|
146
|
+
await reconcileLocaleAfterSignIn(result.actor.id)
|
|
147
|
+
} catch {
|
|
148
|
+
/* Locale is best effort. */
|
|
149
|
+
}
|
|
150
|
+
return { userId: result.actor.id, sessionId: result.sessionId }
|
|
151
|
+
})
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
export const passkeyPasswordProof = createServerFn({ method: 'POST' })
|
|
155
|
+
.middleware([adminSessionMiddleware])
|
|
156
|
+
.validator(validate(session.extend({ password: z.string().min(1).max(128), action }).strict()))
|
|
157
|
+
.handler(async ({ data }) =>
|
|
158
|
+
admitted(
|
|
159
|
+
'password-proof',
|
|
160
|
+
async (provider) =>
|
|
161
|
+
provider.passwordProof({ ...data, ...(await currentSession(data.expectedSessionId)) }),
|
|
162
|
+
data.expectedSessionId
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
export const passkeyProofOptions = createServerFn({ method: 'POST' })
|
|
167
|
+
.middleware([adminSessionMiddleware])
|
|
168
|
+
.validator(validate(session.extend({ action }).strict()))
|
|
169
|
+
.handler(async ({ data }) =>
|
|
170
|
+
admitted('proof-options', async (provider) =>
|
|
171
|
+
provider.proofOptions({ ...data, ...(await currentSession(data.expectedSessionId)) })
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
export const passkeyProof = createServerFn({ method: 'POST' })
|
|
176
|
+
.middleware([adminSessionMiddleware])
|
|
177
|
+
.validator(validate(session.extend({ ceremonyId: bearer, response: z.unknown() }).strict()))
|
|
178
|
+
.handler(async ({ data }) =>
|
|
179
|
+
admitted('proof', async (provider) =>
|
|
180
|
+
provider.verifyProof({ ...data, ...(await currentSession(data.expectedSessionId)) })
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
export const passkeyEnrollmentOptions = createServerFn({ method: 'POST' })
|
|
185
|
+
.middleware([adminSessionMiddleware])
|
|
186
|
+
.validator(validate(session.extend({ grantId: bearer }).strict()))
|
|
187
|
+
.handler(async ({ data }) =>
|
|
188
|
+
admitted('enrollment-options', async (provider) =>
|
|
189
|
+
provider.enrollmentOptions({ ...data, ...(await currentSession(data.expectedSessionId)) })
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
export const passkeyEnrollment = createServerFn({ method: 'POST' })
|
|
194
|
+
.middleware([adminSessionMiddleware])
|
|
195
|
+
.validator(validate(session.extend({ ceremonyId: bearer, response: z.unknown(), name }).strict()))
|
|
196
|
+
.handler(async ({ data }) =>
|
|
197
|
+
admitted('enrollment', async (provider) =>
|
|
198
|
+
provider.verifyEnrollment({ ...data, ...(await currentSession(data.expectedSessionId)) })
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
export const passkeyList = createServerFn({ method: 'POST' })
|
|
203
|
+
.middleware([adminSessionMiddleware])
|
|
204
|
+
.validator(validate(session.extend({ targetUserId: z.uuid().optional() }).strict()))
|
|
205
|
+
.handler(async ({ data }) =>
|
|
206
|
+
admitted('list', async (provider) =>
|
|
207
|
+
provider.listCredentials({ ...data, ...(await currentSession(data.expectedSessionId)) })
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
export const passkeyRename = createServerFn({ method: 'POST' })
|
|
212
|
+
.middleware([adminSessionMiddleware])
|
|
213
|
+
.validator(validate(session.extend({ credentialRecordId: z.uuid(), name }).strict()))
|
|
214
|
+
.handler(async ({ data }) =>
|
|
215
|
+
admitted('rename', async (provider) =>
|
|
216
|
+
provider.renameCredential({ ...data, ...(await currentSession(data.expectedSessionId)) })
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
export const passkeyRemove = createServerFn({ method: 'POST' })
|
|
221
|
+
.middleware([adminSessionMutationMiddleware])
|
|
222
|
+
.validator(validate(session.extend({ grantId: bearer }).strict()))
|
|
223
|
+
.handler(async ({ data }) =>
|
|
224
|
+
admitted('remove', async (provider) => {
|
|
225
|
+
const result = await provider.removeCredential({
|
|
226
|
+
...data,
|
|
227
|
+
...(await currentSession(data.expectedSessionId)),
|
|
228
|
+
})
|
|
229
|
+
if (result.signedOut) {
|
|
230
|
+
clearSessionCookies()
|
|
231
|
+
clearPreviewCookie()
|
|
232
|
+
}
|
|
233
|
+
return result
|
|
234
|
+
})
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
/** Cosmetic permissions for the manager; every mutation still rechecks authority. */
|
|
238
|
+
export const passkeyManagementContext = createServerFn({ method: 'POST' })
|
|
239
|
+
.middleware([adminSessionMiddleware])
|
|
240
|
+
.validator(validate(session.strict()))
|
|
241
|
+
.handler(async ({ data }) =>
|
|
242
|
+
admitted('list', async () => {
|
|
243
|
+
await currentSession(data.expectedSessionId)
|
|
244
|
+
const { actor } = await getAdminRequestContext()
|
|
245
|
+
if (!isAdminAuth(actor)) throw ERR_UNAUTHENTICATED({ message: 'admin login required' })
|
|
246
|
+
return {
|
|
247
|
+
id: actor.id,
|
|
248
|
+
isSuperAdmin: actor.isSuperAdmin,
|
|
249
|
+
canManagePasskeys: actor.hasAbility('admin.users.managePasskeys'),
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
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 { readAdminLocaleCookie } from '../../i18n/locale-cookie.js'
|
|
10
|
+
import { bylineCore } from '../../integrations/byline-core.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Apply the `byline_admin_lng` cookie to `admin_users.preferred_locale`
|
|
14
|
+
* when the two diverge after sign-in. No-op when:
|
|
15
|
+
*
|
|
16
|
+
* - The cookie is unset (the cascade falls through to the existing
|
|
17
|
+
* column / Accept-Language / default anyway).
|
|
18
|
+
* - The cookie carries a locale outside `i18n.admin.locales`
|
|
19
|
+
* (stale value pointing at a removed locale — let the resolver
|
|
20
|
+
* fall through cleanly).
|
|
21
|
+
* - No admin store is configured (headless tooling paths).
|
|
22
|
+
* - The stored value already matches the cookie.
|
|
23
|
+
*/
|
|
24
|
+
export async function reconcileLocaleAfterSignIn(adminUserId: string): Promise<void> {
|
|
25
|
+
const cookieLocale = readAdminLocaleCookie()
|
|
26
|
+
if (cookieLocale == null) return
|
|
27
|
+
|
|
28
|
+
const core = bylineCore()
|
|
29
|
+
const locales = core.config.i18n.admin.locales
|
|
30
|
+
if (!locales.includes(cookieLocale)) return
|
|
31
|
+
|
|
32
|
+
const adminStore = core.adminStore
|
|
33
|
+
if (adminStore == null) return
|
|
34
|
+
|
|
35
|
+
const row = await adminStore.adminUsers.getById(adminUserId)
|
|
36
|
+
if (!row) return
|
|
37
|
+
if (row.preferred_locale === cookieLocale) return
|
|
38
|
+
|
|
39
|
+
await adminStore.adminUsers.setPreferredLocale(adminUserId, cookieLocale)
|
|
40
|
+
}
|
|
@@ -29,10 +29,9 @@ import {
|
|
|
29
29
|
import { getServerConfig } from '@byline/core'
|
|
30
30
|
import { passwordSignInSchema } from '@byline/core/validation'
|
|
31
31
|
|
|
32
|
-
import { readAdminLocaleCookie } from '../../i18n/locale-cookie.js'
|
|
33
|
-
import { bylineCore } from '../../integrations/byline-core.js'
|
|
34
32
|
import { normalizeClientIp } from '../../integrations/client-ip.js'
|
|
35
33
|
import { wasSignInBodyChecked } from '../../integrations/sign-in-body.js'
|
|
34
|
+
import { reconcileLocaleAfterSignIn } from './reconcile-locale.js'
|
|
36
35
|
|
|
37
36
|
export interface SignInInput {
|
|
38
37
|
email: string
|
|
@@ -137,33 +136,3 @@ export const adminSignIn = createServerFn({ method: 'POST' })
|
|
|
137
136
|
|
|
138
137
|
return { userId: result.actor.id, sessionId: result.sessionId }
|
|
139
138
|
})
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Apply the `byline_admin_lng` cookie to `admin_users.preferred_locale`
|
|
143
|
-
* when the two diverge after sign-in. No-op when:
|
|
144
|
-
*
|
|
145
|
-
* - The cookie is unset (the cascade falls through to the existing
|
|
146
|
-
* column / Accept-Language / default anyway).
|
|
147
|
-
* - The cookie carries a locale outside `i18n.admin.locales`
|
|
148
|
-
* (stale value pointing at a removed locale — let the resolver
|
|
149
|
-
* fall through cleanly).
|
|
150
|
-
* - No admin store is configured (headless tooling paths).
|
|
151
|
-
* - The stored value already matches the cookie.
|
|
152
|
-
*/
|
|
153
|
-
async function reconcileLocaleAfterSignIn(adminUserId: string): Promise<void> {
|
|
154
|
-
const cookieLocale = readAdminLocaleCookie()
|
|
155
|
-
if (cookieLocale == null) return
|
|
156
|
-
|
|
157
|
-
const core = bylineCore()
|
|
158
|
-
const locales = core.config.i18n.admin.locales
|
|
159
|
-
if (!locales.includes(cookieLocale)) return
|
|
160
|
-
|
|
161
|
-
const adminStore = core.adminStore
|
|
162
|
-
if (adminStore == null) return
|
|
163
|
-
|
|
164
|
-
const row = await adminStore.adminUsers.getById(adminUserId)
|
|
165
|
-
if (!row) return
|
|
166
|
-
if (row.preferred_locale === cookieLocale) return
|
|
167
|
-
|
|
168
|
-
await adminStore.adminUsers.setPreferredLocale(adminUserId, cookieLocale)
|
|
169
|
-
}
|