@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
@@ -0,0 +1,238 @@
1
+ import { createServerFn } from "@tanstack/react-start";
2
+ import { getRequest, setResponseHeader } from "@tanstack/react-start/server";
3
+ import { ERR_SESSION_CHANGED, ERR_UNAUTHENTICATED, isAdminAuth } from "@byline/auth";
4
+ import { clearPreviewCookie, clearSessionCookies, getAdminRequestContext, readAccessTokenCookie, readRefreshTokenCookie, setSessionCookies } from "@byline/client/server";
5
+ import { getServerConfig } from "@byline/core";
6
+ import { z } from "zod";
7
+ import { bylineCore } from "../../integrations/byline-core.js";
8
+ import { adminSessionMiddleware, adminSessionMutationMiddleware } from "../../integrations/session-middleware.js";
9
+ import { wasPasskeyBodyChecked } from "../../integrations/sign-in-body.js";
10
+ import { withPasskeyAdmission } from "./passkey-admission.js";
11
+ import { reconcileLocaleAfterSignIn } from "./reconcile-locale.js";
12
+ const session = z.object({
13
+ expectedSessionId: z.uuid()
14
+ });
15
+ const bearer = z.string().regex(/^[A-Za-z0-9_-]{43}$/);
16
+ const action = z.discriminatedUnion('purpose', [
17
+ z.object({
18
+ purpose: z.literal('enroll')
19
+ }).strict(),
20
+ z.object({
21
+ purpose: z.literal('remove'),
22
+ targetUserId: z.uuid(),
23
+ credentialRecordId: z.uuid()
24
+ }).strict()
25
+ ]);
26
+ const passkeys_name = z.string().trim().min(1).max(80);
27
+ function validate(schema) {
28
+ return (input)=>{
29
+ const parsed = schema.safeParse(input);
30
+ if (!parsed.success) throw new Response('Invalid passkey input', {
31
+ status: 400,
32
+ headers: {
33
+ 'Cache-Control': 'no-store'
34
+ }
35
+ });
36
+ return parsed.data;
37
+ };
38
+ }
39
+ function configuredProvider() {
40
+ setResponseHeader('Cache-Control', 'no-store');
41
+ const config = getServerConfig();
42
+ const provider = config.sessionProvider;
43
+ if (!config.passkeys || provider?.capabilities?.passkeys !== true || !provider.passkeys) throw new Response('Passkeys are not enabled', {
44
+ status: 404,
45
+ headers: {
46
+ 'Cache-Control': 'no-store'
47
+ }
48
+ });
49
+ return provider.passkeys;
50
+ }
51
+ async function currentSession(expectedSessionId) {
52
+ const auth = await getAdminRequestContext();
53
+ if (auth.sessionId !== expectedSessionId) throw ERR_SESSION_CHANGED({
54
+ message: 'session changed before passkey operation'
55
+ });
56
+ const accessToken = readAccessTokenCookie();
57
+ if (!accessToken) throw ERR_UNAUTHENTICATED({
58
+ message: 'access credential missing'
59
+ });
60
+ return {
61
+ accessToken,
62
+ expectedSessionId
63
+ };
64
+ }
65
+ function admitted(purpose, work, expectedSessionId) {
66
+ const provider = configuredProvider();
67
+ const config = getServerConfig();
68
+ const request = getRequest();
69
+ return withPasskeyAdmission({
70
+ provider,
71
+ purpose,
72
+ request,
73
+ bodyChecked: wasPasskeyBodyChecked(request, 'password-proof' === purpose),
74
+ protection: config.passkeyRequests,
75
+ ...'password-proof' === purpose ? {
76
+ password: {
77
+ protection: config.passwordSignIn,
78
+ resolveActorEmail: async ()=>{
79
+ const auth = await getAdminRequestContext();
80
+ if (!auth.actor || !expectedSessionId || auth.sessionId !== expectedSessionId) throw ERR_SESSION_CHANGED({
81
+ message: 'session changed before password proof'
82
+ });
83
+ const user = await bylineCore().adminStore?.adminUsers.getById(auth.actor.id);
84
+ if (!user) throw ERR_UNAUTHENTICATED({
85
+ message: 'account unavailable'
86
+ });
87
+ return user.email;
88
+ }
89
+ }
90
+ } : {},
91
+ work: ({ ip })=>work(provider, {
92
+ ip,
93
+ userAgent: request.headers.get('user-agent')?.slice(0, 512)
94
+ })
95
+ });
96
+ }
97
+ const getPasskeyCapability = createServerFn({
98
+ method: 'GET'
99
+ }).handler(async ()=>{
100
+ setResponseHeader('Cache-Control', 'no-store');
101
+ const config = getServerConfig();
102
+ return {
103
+ enabled: !!(config.passkeys && config.sessionProvider?.capabilities?.passkeys && config.sessionProvider.passkeys)
104
+ };
105
+ });
106
+ const passkeySignInOptions = createServerFn({
107
+ method: 'POST'
108
+ }).validator(validate(z.object({}).strict())).handler(async ()=>admitted('sign-in-options', (provider, metadata)=>provider.signInOptions(metadata)));
109
+ const passkeySignIn = createServerFn({
110
+ method: 'POST'
111
+ }).validator(validate(z.object({
112
+ ceremonyId: bearer,
113
+ response: z.unknown()
114
+ }).strict())).handler(async ({ data })=>admitted('sign-in', async (provider, metadata)=>{
115
+ const result = await provider.verifySignIn({
116
+ ...data,
117
+ ...metadata,
118
+ previousAccessToken: readAccessTokenCookie(),
119
+ previousRefreshToken: readRefreshTokenCookie()
120
+ });
121
+ setSessionCookies(result);
122
+ try {
123
+ await reconcileLocaleAfterSignIn(result.actor.id);
124
+ } catch {}
125
+ return {
126
+ userId: result.actor.id,
127
+ sessionId: result.sessionId
128
+ };
129
+ }));
130
+ const passkeyPasswordProof = createServerFn({
131
+ method: 'POST'
132
+ }).middleware([
133
+ adminSessionMiddleware
134
+ ]).validator(validate(session.extend({
135
+ password: z.string().min(1).max(128),
136
+ action: action
137
+ }).strict())).handler(async ({ data })=>admitted('password-proof', async (provider)=>provider.passwordProof({
138
+ ...data,
139
+ ...await currentSession(data.expectedSessionId)
140
+ }), data.expectedSessionId));
141
+ const passkeyProofOptions = createServerFn({
142
+ method: 'POST'
143
+ }).middleware([
144
+ adminSessionMiddleware
145
+ ]).validator(validate(session.extend({
146
+ action: action
147
+ }).strict())).handler(async ({ data })=>admitted('proof-options', async (provider)=>provider.proofOptions({
148
+ ...data,
149
+ ...await currentSession(data.expectedSessionId)
150
+ })));
151
+ const passkeyProof = createServerFn({
152
+ method: 'POST'
153
+ }).middleware([
154
+ adminSessionMiddleware
155
+ ]).validator(validate(session.extend({
156
+ ceremonyId: bearer,
157
+ response: z.unknown()
158
+ }).strict())).handler(async ({ data })=>admitted('proof', async (provider)=>provider.verifyProof({
159
+ ...data,
160
+ ...await currentSession(data.expectedSessionId)
161
+ })));
162
+ const passkeyEnrollmentOptions = createServerFn({
163
+ method: 'POST'
164
+ }).middleware([
165
+ adminSessionMiddleware
166
+ ]).validator(validate(session.extend({
167
+ grantId: bearer
168
+ }).strict())).handler(async ({ data })=>admitted('enrollment-options', async (provider)=>provider.enrollmentOptions({
169
+ ...data,
170
+ ...await currentSession(data.expectedSessionId)
171
+ })));
172
+ const passkeyEnrollment = createServerFn({
173
+ method: 'POST'
174
+ }).middleware([
175
+ adminSessionMiddleware
176
+ ]).validator(validate(session.extend({
177
+ ceremonyId: bearer,
178
+ response: z.unknown(),
179
+ name: passkeys_name
180
+ }).strict())).handler(async ({ data })=>admitted('enrollment', async (provider)=>provider.verifyEnrollment({
181
+ ...data,
182
+ ...await currentSession(data.expectedSessionId)
183
+ })));
184
+ const passkeyList = createServerFn({
185
+ method: 'POST'
186
+ }).middleware([
187
+ adminSessionMiddleware
188
+ ]).validator(validate(session.extend({
189
+ targetUserId: z.uuid().optional()
190
+ }).strict())).handler(async ({ data })=>admitted('list', async (provider)=>provider.listCredentials({
191
+ ...data,
192
+ ...await currentSession(data.expectedSessionId)
193
+ })));
194
+ const passkeyRename = createServerFn({
195
+ method: 'POST'
196
+ }).middleware([
197
+ adminSessionMiddleware
198
+ ]).validator(validate(session.extend({
199
+ credentialRecordId: z.uuid(),
200
+ name: passkeys_name
201
+ }).strict())).handler(async ({ data })=>admitted('rename', async (provider)=>provider.renameCredential({
202
+ ...data,
203
+ ...await currentSession(data.expectedSessionId)
204
+ })));
205
+ const passkeyRemove = createServerFn({
206
+ method: 'POST'
207
+ }).middleware([
208
+ adminSessionMutationMiddleware
209
+ ]).validator(validate(session.extend({
210
+ grantId: bearer
211
+ }).strict())).handler(async ({ data })=>admitted('remove', async (provider)=>{
212
+ const result = await provider.removeCredential({
213
+ ...data,
214
+ ...await currentSession(data.expectedSessionId)
215
+ });
216
+ if (result.signedOut) {
217
+ clearSessionCookies();
218
+ clearPreviewCookie();
219
+ }
220
+ return result;
221
+ }));
222
+ const passkeyManagementContext = createServerFn({
223
+ method: 'POST'
224
+ }).middleware([
225
+ adminSessionMiddleware
226
+ ]).validator(validate(session.strict())).handler(async ({ data })=>admitted('list', async ()=>{
227
+ await currentSession(data.expectedSessionId);
228
+ const { actor } = await getAdminRequestContext();
229
+ if (!isAdminAuth(actor)) throw ERR_UNAUTHENTICATED({
230
+ message: 'admin login required'
231
+ });
232
+ return {
233
+ id: actor.id,
234
+ isSuperAdmin: actor.isSuperAdmin,
235
+ canManagePasskeys: actor.hasAbility('admin.users.managePasskeys')
236
+ };
237
+ }));
238
+ export { getPasskeyCapability, passkeyEnrollment, passkeyEnrollmentOptions, passkeyList, passkeyManagementContext, passkeyPasswordProof, passkeyProof, passkeyProofOptions, passkeyRemove, passkeyRename, passkeySignIn, passkeySignInOptions };
@@ -0,0 +1,20 @@
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
+ * Apply the `byline_admin_lng` cookie to `admin_users.preferred_locale`
10
+ * when the two diverge after sign-in. No-op when:
11
+ *
12
+ * - The cookie is unset (the cascade falls through to the existing
13
+ * column / Accept-Language / default anyway).
14
+ * - The cookie carries a locale outside `i18n.admin.locales`
15
+ * (stale value pointing at a removed locale — let the resolver
16
+ * fall through cleanly).
17
+ * - No admin store is configured (headless tooling paths).
18
+ * - The stored value already matches the cookie.
19
+ */
20
+ export declare function reconcileLocaleAfterSignIn(adminUserId: string): Promise<void>;
@@ -0,0 +1,16 @@
1
+ import { readAdminLocaleCookie } from "../../i18n/locale-cookie.js";
2
+ import { bylineCore } from "../../integrations/byline-core.js";
3
+ async function reconcileLocaleAfterSignIn(adminUserId) {
4
+ const cookieLocale = readAdminLocaleCookie();
5
+ if (null == cookieLocale) return;
6
+ const core = bylineCore();
7
+ const locales = core.config.i18n.admin.locales;
8
+ if (!locales.includes(cookieLocale)) return;
9
+ const adminStore = core.adminStore;
10
+ if (null == adminStore) return;
11
+ const row = await adminStore.adminUsers.getById(adminUserId);
12
+ if (!row) return;
13
+ if (row.preferred_locale === cookieLocale) return;
14
+ await adminStore.adminUsers.setPreferredLocale(adminUserId, cookieLocale);
15
+ }
16
+ export { reconcileLocaleAfterSignIn };
@@ -3,10 +3,9 @@ import { getRequest, getRequestHeader } from "@tanstack/react-start/server";
3
3
  import { readAccessTokenCookie, readRefreshTokenCookie, setSessionCookies } from "@byline/client/server";
4
4
  import { getServerConfig } from "@byline/core";
5
5
  import { passwordSignInSchema } from "@byline/core/validation";
6
- import { readAdminLocaleCookie } from "../../i18n/locale-cookie.js";
7
- import { bylineCore } from "../../integrations/byline-core.js";
8
6
  import { normalizeClientIp } from "../../integrations/client-ip.js";
9
7
  import { wasSignInBodyChecked } from "../../integrations/sign-in-body.js";
8
+ import { reconcileLocaleAfterSignIn } from "./reconcile-locale.js";
10
9
  const adminSignIn = createServerFn({
11
10
  method: 'POST'
12
11
  }).validator((input)=>{
@@ -104,17 +103,4 @@ const adminSignIn = createServerFn({
104
103
  sessionId: result.sessionId
105
104
  };
106
105
  });
107
- async function reconcileLocaleAfterSignIn(adminUserId) {
108
- const cookieLocale = readAdminLocaleCookie();
109
- if (null == cookieLocale) return;
110
- const core = bylineCore();
111
- const locales = core.config.i18n.admin.locales;
112
- if (!locales.includes(cookieLocale)) return;
113
- const adminStore = core.adminStore;
114
- if (null == adminStore) return;
115
- const row = await adminStore.adminUsers.getById(adminUserId);
116
- if (!row) return;
117
- if (row.preferred_locale === cookieLocale) return;
118
- await adminStore.adminUsers.setPreferredLocale(adminUserId, cookieLocale);
119
- }
120
106
  export { adminSignIn };
package/package.json CHANGED
@@ -3,9 +3,9 @@
3
3
  "private": false,
4
4
  "type": "module",
5
5
  "license": "MPL-2.0",
6
- "version": "6.7.2",
6
+ "version": "6.8.0",
7
7
  "engines": {
8
- "node": ">=20.9.0"
8
+ "node": ">=22.22.2"
9
9
  },
10
10
  "description": "TanStack Start host adapter for Byline CMS — server fns, auth context, integration glue, admin shell, and route factories",
11
11
  "keywords": [
@@ -119,15 +119,15 @@
119
119
  }
120
120
  },
121
121
  "dependencies": {
122
- "@byline/admin": "6.7.2",
123
- "@byline/ai": "6.7.2",
124
- "@byline/analytics": "6.7.2",
125
- "@byline/analytics-agent": "6.7.2",
126
- "@byline/auth": "6.7.2",
127
- "@byline/client": "6.7.2",
128
- "@byline/core": "6.7.2",
129
- "@byline/i18n": "6.7.2",
130
- "@byline/ui": "6.7.2",
122
+ "@byline/admin": "6.8.0",
123
+ "@byline/ai": "6.8.0",
124
+ "@byline/analytics": "6.8.0",
125
+ "@byline/analytics-agent": "6.8.0",
126
+ "@byline/auth": "6.8.0",
127
+ "@byline/client": "6.8.0",
128
+ "@byline/core": "6.8.0",
129
+ "@byline/i18n": "6.8.0",
130
+ "@byline/ui": "6.8.0",
131
131
  "@dnd-kit/core": "^6.3.1",
132
132
  "@dnd-kit/sortable": "^10.0.0",
133
133
  "clsx": "^2.1.1",
@@ -17,7 +17,7 @@
17
17
  :global(.byline-role-detail-card) {
18
18
  margin-bottom: 1.5rem;
19
19
  border: var(--border-width-thin) var(--border-style-solid) var(--gray-100);
20
- border-radius: 0.125rem;
20
+ border-radius: var(--border-radius-sm);
21
21
  background-color: var(--canvas-25);
22
22
  padding: 1rem;
23
23
  }
@@ -88,7 +88,7 @@ export function DeleteRole({ role, onClose }: DeleteRoleProps) {
88
88
  <div className={cx('byline-role-delete-actions', styles.actions)}>
89
89
  <Button
90
90
  type="button"
91
- intent="secondary"
91
+ intent="noeffect"
92
92
  size="sm"
93
93
  onClick={onClose}
94
94
  disabled={pending}
@@ -18,6 +18,7 @@
18
18
  * .byline-admin-user-drawer-body — drawer content container
19
19
  * .byline-admin-user-drawer-scroll — drawer scrollable inner viewport
20
20
  * .byline-admin-user-modal — modal container sizing
21
+ * .byline-admin-user-account-link — own-account link in the Passkeys card
21
22
  * .byline-admin-user-modal-head — modal header row
22
23
  */
23
24
 
@@ -50,7 +51,7 @@
50
51
  border: var(--border-width-thin) var(--border-style-solid) var(--gray-100);
51
52
  background-color: var(--canvas-25);
52
53
  padding: 1rem;
53
- border-radius: 0.125rem;
54
+ border-radius: var(--border-radius-sm);
54
55
  }
55
56
 
56
57
  :is([data-theme="dark"], :global(.dark)) .section,
@@ -197,3 +198,8 @@
197
198
  justify-content: space-between;
198
199
  margin-bottom: 1rem;
199
200
  }
201
+
202
+ .accountLink,
203
+ :global(.byline-admin-user-account-link) {
204
+ text-decoration: underline;
205
+ }
@@ -15,6 +15,7 @@ import { useRouter } from '@tanstack/react-router'
15
15
  import { UserRoles } from '@byline/admin/admin-users/components/roles'
16
16
  import { SetPassword } from '@byline/admin/admin-users/components/set-password'
17
17
  import { UpdateUser } from '@byline/admin/admin-users/components/update'
18
+ import { PasskeyManager } from '@byline/admin/auth/components/passkey-manager'
18
19
  import { LocalDateTime } from '@byline/admin/react'
19
20
  import { useTranslation } from '@byline/i18n/react'
20
21
  import {
@@ -28,6 +29,8 @@ import {
28
29
  } from '@byline/ui/react'
29
30
  import cx from 'clsx'
30
31
 
32
+ import { getAdminRoutePath } from '../../routes/admin-path.js'
33
+ import { Link } from '../chrome/loose-router.js'
31
34
  import styles from './container.module.css'
32
35
  import { DeleteUser } from './delete.js'
33
36
  import type { AdminRoleResponse } from '../../server-fns/admin-roles/index.js'
@@ -266,9 +269,7 @@ export function AccountContainer({ user, allRoles, initialUserRoles }: AccountCo
266
269
  </p>
267
270
  </div>
268
271
  </ContainerSection>
269
- </div>
270
272
 
271
- <div className={cx('byline-admin-user-column', styles.column)}>
272
273
  <ContainerSection
273
274
  title={t('adminUsers.detail.sections.roles')}
274
275
  onEdit={openDrawer('roles')}
@@ -289,7 +290,9 @@ export function AccountContainer({ user, allRoles, initialUserRoles }: AccountCo
289
290
  {t('adminUsers.detail.editRolesButton')}
290
291
  </Button>
291
292
  </ContainerSection>
293
+ </div>
292
294
 
295
+ <div className={cx('byline-admin-user-column', styles.column)}>
293
296
  <ContainerSection
294
297
  title={t('adminUsers.detail.sections.password')}
295
298
  onEdit={openDrawer('set_password')}
@@ -303,6 +306,23 @@ export function AccountContainer({ user, allRoles, initialUserRoles }: AccountCo
303
306
  </Button>
304
307
  </ContainerSection>
305
308
 
309
+ <PasskeyManager
310
+ targetUserId={currentUser.id}
311
+ targetIsSuperAdmin={currentUser.is_super_admin}
312
+ section={ContainerSection}
313
+ ownAccountNotice={
314
+ <p className={cx('byline-admin-user-line-spaced', styles.lineSpaced)}>
315
+ {t('adminUsers.detail.passkeys.ownAccount')}{' '}
316
+ <Link
317
+ to={getAdminRoutePath('account')}
318
+ className={cx('byline-admin-user-account-link', styles.accountLink)}
319
+ >
320
+ {t('adminUsers.detail.passkeys.openAccount')}
321
+ </Link>
322
+ </p>
323
+ }
324
+ />
325
+
306
326
  <ContainerSection title={t('adminUsers.detail.sections.delete')}>
307
327
  <p className={cx('byline-admin-user-line-spaced', styles.lineSpaced)}>
308
328
  {t('adminUsers.detail.delete.intro')}
@@ -100,7 +100,7 @@ export function DeleteUser({ user, onClose }: DeleteUserProps) {
100
100
  <div className={cx('byline-admin-user-delete-actions', styles.actions)}>
101
101
  <Button
102
102
  type="button"
103
- intent="secondary"
103
+ intent="noeffect"
104
104
  size="sm"
105
105
  onClick={onClose}
106
106
  disabled={pending}
@@ -36,6 +36,7 @@ import {
36
36
  import { adminSignIn } from '../server-fns/auth/index.js'
37
37
  import { getCollectionDocumentVersion as serverGetCollectionDocumentVersion } from '../server-fns/collections/get.js'
38
38
  import { setAdminLocaleFn } from '../server-fns/i18n/index.js'
39
+ import { passkeyAdminServices } from './passkey-admin-services.js'
39
40
  import { acceptSession, coordinateAuthAction, notifySessionAction } from './session-coordination.js'
40
41
 
41
42
  /**
@@ -70,6 +71,8 @@ export const bylineAdminServices: BylineAdminServices = {
70
71
  return result
71
72
  },
72
73
 
74
+ passkeys: passkeyAdminServices,
75
+
73
76
  // Account self-service
74
77
  updateAccount,
75
78
  changeAccountPassword,
@@ -0,0 +1,85 @@
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 { PasskeyAdminServices } from '@byline/admin/services'
10
+
11
+ import {
12
+ getPasskeyCapability,
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'
25
+ import {
26
+ acceptSession,
27
+ coordinateAuthAction,
28
+ flagSessionChanged,
29
+ notifySessionAction,
30
+ requireUnchanged,
31
+ sessionChangedError,
32
+ sessionSnapshot,
33
+ } from './session-coordination.js'
34
+
35
+ export const passkeyAdminServices: PasskeyAdminServices = {
36
+ capability: () => getPasskeyCapability(),
37
+ captureLogin: (signIn = false) => {
38
+ const snapshot = sessionSnapshot()
39
+ const assertCurrent = () => {
40
+ if (!signIn) return requireUnchanged(snapshot)
41
+ // Explicit reauthentication may begin on an already flagged page, but any
42
+ // subsequent account change still invalidates this particular ceremony.
43
+ const current = sessionSnapshot()
44
+ if (current.epoch !== snapshot.epoch || current.sessionId !== snapshot.sessionId)
45
+ throw sessionChangedError()
46
+ }
47
+ assertCurrent()
48
+ return { expectedSessionId: snapshot.sessionId, assertCurrent }
49
+ },
50
+ managementContext: async (binding) => {
51
+ binding.assertCurrent()
52
+ if (!binding.expectedSessionId) throw sessionChangedError()
53
+ const actor = await passkeyManagementContext({
54
+ data: { expectedSessionId: binding.expectedSessionId },
55
+ })
56
+ binding.assertCurrent()
57
+ return actor
58
+ },
59
+ signInOptions: passkeySignInOptions,
60
+ signIn: (args, binding) =>
61
+ coordinateAuthAction(async () => {
62
+ binding.assertCurrent()
63
+ const result = await passkeySignIn(args)
64
+ acceptSession(result.sessionId)
65
+ notifySessionAction()
66
+ return result
67
+ }),
68
+ passwordProof: passkeyPasswordProof,
69
+ proofOptions: passkeyProofOptions,
70
+ proof: passkeyProof,
71
+ enrollmentOptions: passkeyEnrollmentOptions,
72
+ enrollment: passkeyEnrollment,
73
+ list: passkeyList,
74
+ rename: passkeyRename,
75
+ remove: async (args) => {
76
+ // The mutation middleware queues its fetch, including cookie application;
77
+ // it releases the queue before a pre-handler renewal can acquire it.
78
+ const result = await passkeyRemove(args)
79
+ if (result.signedOut) {
80
+ flagSessionChanged()
81
+ notifySessionAction()
82
+ }
83
+ return result
84
+ },
85
+ }
@@ -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)