@byline/admin 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 (86) hide show
  1. package/dist/modules/admin-account/components/change-password.js +1 -1
  2. package/dist/modules/admin-account/components/container.js +18 -3
  3. package/dist/modules/admin-account/components/container.module.js +2 -0
  4. package/dist/modules/admin-account/components/container_module.css +7 -0
  5. package/dist/modules/admin-account/components/preferences.js +1 -1
  6. package/dist/modules/admin-account/components/update.js +1 -1
  7. package/dist/modules/admin-roles/components/create.js +1 -1
  8. package/dist/modules/admin-roles/components/permissions.js +1 -1
  9. package/dist/modules/admin-roles/components/update.js +1 -1
  10. package/dist/modules/admin-users/abilities.d.ts +1 -0
  11. package/dist/modules/admin-users/abilities.js +8 -1
  12. package/dist/modules/admin-users/components/create.js +1 -1
  13. package/dist/modules/admin-users/components/roles.js +1 -1
  14. package/dist/modules/admin-users/components/set-password.js +1 -1
  15. package/dist/modules/admin-users/components/update.js +1 -1
  16. package/dist/modules/auth/auth-maintenance-repository.d.ts +31 -0
  17. package/dist/modules/auth/auth-maintenance-repository.js +1 -0
  18. package/dist/modules/auth/auth-maintenance.d.ts +11 -0
  19. package/dist/modules/auth/auth-maintenance.js +39 -0
  20. package/dist/modules/auth/auth-maintenance.test.node.d.ts +1 -0
  21. package/dist/modules/auth/components/passkey-browser.d.ts +14 -0
  22. package/dist/modules/auth/components/passkey-browser.js +118 -0
  23. package/dist/modules/auth/components/passkey-browser.test.node.d.ts +1 -0
  24. package/dist/modules/auth/components/passkey-manager.d.ts +26 -0
  25. package/dist/modules/auth/components/passkey-manager.js +406 -0
  26. package/dist/modules/auth/components/passkey-manager.module.js +22 -0
  27. package/dist/modules/auth/components/passkey-manager_module.css +104 -0
  28. package/dist/modules/auth/components/sign-in-form.js +62 -3
  29. package/dist/modules/auth/components/sign-in-form.module.js +5 -1
  30. package/dist/modules/auth/components/sign-in-form_module.css +40 -0
  31. package/dist/modules/auth/index.d.ts +5 -0
  32. package/dist/modules/auth/index.js +3 -0
  33. package/dist/modules/auth/jwt-session-provider.d.ts +12 -4
  34. package/dist/modules/auth/jwt-session-provider.js +135 -94
  35. package/dist/modules/auth/native-passkey-provider.d.ts +72 -0
  36. package/dist/modules/auth/native-passkey-provider.js +618 -0
  37. package/dist/modules/auth/passkey-rate-limiter.d.ts +14 -0
  38. package/dist/modules/auth/passkey-rate-limiter.js +60 -0
  39. package/dist/modules/auth/passkey-rate-limiter.test.node.d.ts +8 -0
  40. package/dist/modules/auth/passkey-storage-validation.d.ts +14 -0
  41. package/dist/modules/auth/passkey-storage-validation.js +59 -0
  42. package/dist/modules/auth/passkeys-repository.d.ts +96 -0
  43. package/dist/modules/auth/passkeys-repository.js +1 -0
  44. package/dist/modules/auth/refresh-tokens-repository.d.ts +1 -1
  45. package/dist/modules/auth/sign-in-rate-limiter.d.ts +2 -0
  46. package/dist/modules/auth/sign-in-rate-limiter.js +1 -1
  47. package/dist/services/admin-services-context.d.ts +1 -1
  48. package/dist/services/admin-services-types.d.ts +60 -0
  49. package/dist/store.d.ts +4 -0
  50. package/package.json +16 -9
  51. package/src/modules/admin-account/components/change-password.tsx +1 -1
  52. package/src/modules/admin-account/components/container.module.css +9 -0
  53. package/src/modules/admin-account/components/container.tsx +12 -3
  54. package/src/modules/admin-account/components/preferences.tsx +1 -1
  55. package/src/modules/admin-account/components/update.tsx +1 -1
  56. package/src/modules/admin-roles/components/create.tsx +1 -1
  57. package/src/modules/admin-roles/components/permissions.tsx +1 -1
  58. package/src/modules/admin-roles/components/update.tsx +1 -1
  59. package/src/modules/admin-users/abilities.ts +7 -0
  60. package/src/modules/admin-users/components/create.tsx +1 -1
  61. package/src/modules/admin-users/components/roles.tsx +1 -1
  62. package/src/modules/admin-users/components/set-password.tsx +1 -1
  63. package/src/modules/admin-users/components/update.tsx +1 -1
  64. package/src/modules/auth/auth-maintenance-repository.ts +26 -0
  65. package/src/modules/auth/auth-maintenance.test.node.ts +110 -0
  66. package/src/modules/auth/auth-maintenance.ts +62 -0
  67. package/src/modules/auth/components/passkey-browser.test.node.ts +188 -0
  68. package/src/modules/auth/components/passkey-browser.ts +139 -0
  69. package/src/modules/auth/components/passkey-manager.module.css +100 -0
  70. package/src/modules/auth/components/passkey-manager.test.tsx +189 -0
  71. package/src/modules/auth/components/passkey-manager.tsx +386 -0
  72. package/src/modules/auth/components/sign-in-form.module.css +37 -0
  73. package/src/modules/auth/components/sign-in-form.test.tsx +92 -0
  74. package/src/modules/auth/components/sign-in-form.tsx +65 -4
  75. package/src/modules/auth/index.ts +15 -0
  76. package/src/modules/auth/jwt-session-provider.ts +116 -44
  77. package/src/modules/auth/native-passkey-provider.ts +710 -0
  78. package/src/modules/auth/passkey-rate-limiter.test.node.ts +83 -0
  79. package/src/modules/auth/passkey-rate-limiter.ts +76 -0
  80. package/src/modules/auth/passkey-storage-validation.ts +108 -0
  81. package/src/modules/auth/passkeys-repository.ts +97 -0
  82. package/src/modules/auth/refresh-tokens-repository.ts +1 -1
  83. package/src/modules/auth/sign-in-rate-limiter.ts +1 -1
  84. package/src/services/admin-services-context.tsx +2 -0
  85. package/src/services/admin-services-types.ts +60 -0
  86. package/src/store.ts +4 -0
@@ -0,0 +1,92 @@
1
+ import { act, type ReactNode } from 'react'
2
+
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
5
+
6
+ const mocks = vi.hoisted(() => ({
7
+ enabled: true,
8
+ signIn: vi.fn(),
9
+ passkey: vi.fn(),
10
+ services: {} as any,
11
+ }))
12
+ vi.mock('../../../services/admin-services-context.js', () => ({
13
+ useBylineAdminServices: () => mocks.services,
14
+ }))
15
+ vi.mock('@byline/i18n/react', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
16
+ vi.mock('@byline/core', () => ({ getAdminConfig: () => ({ routes: { admin: '/admin' } }) }))
17
+ vi.mock('./passkey-browser.js', () => ({
18
+ signInWithPasskey: mocks.passkey,
19
+ passkeyErrorKey: () => 'passkeys.errors.cancelled',
20
+ }))
21
+ vi.mock('@byline/ui/react', () => {
22
+ const Box = ({ children }: { children: ReactNode }) => <div>{children}</div>
23
+ return {
24
+ Card: Object.assign(Box, {
25
+ Header: Box,
26
+ Title: Box,
27
+ Description: Box,
28
+ Content: Box,
29
+ Footer: Box,
30
+ }),
31
+ Alert: ({ children }: { children: ReactNode }) => <div role="alert">{children}</div>,
32
+ Button: ({ children, disabled, onClick, type }: any) => (
33
+ <button type={type} disabled={disabled} onClick={onClick}>
34
+ {children}
35
+ </button>
36
+ ),
37
+ Input: ({ label, ...rest }: any) => <input aria-label={label} {...rest} />,
38
+ InputPassword: ({
39
+ label,
40
+ showPasswordLabel: _show,
41
+ hidePasswordLabel: _hide,
42
+ ...rest
43
+ }: any) => <input type="password" aria-label={label} {...rest} />,
44
+ LoaderEllipsis: () => null,
45
+ PasskeyIcon: () => null,
46
+ }
47
+ })
48
+
49
+ import { SignInForm } from './sign-in-form.js'
50
+
51
+ let root: Root
52
+ let container: HTMLDivElement
53
+ beforeEach(() => {
54
+ vi.resetAllMocks()
55
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
56
+ mocks.services = {
57
+ adminSignIn: mocks.signIn,
58
+ passkeys: { capability: async () => ({ enabled: mocks.enabled }) },
59
+ }
60
+ mocks.enabled = true
61
+ container = document.createElement('div')
62
+ document.body.appendChild(container)
63
+ root = createRoot(container)
64
+ })
65
+ afterEach(async () => {
66
+ await act(async () => root.unmount())
67
+ container.remove()
68
+ vi.unstubAllGlobals()
69
+ })
70
+ it('retains password sign-in when passkeys are disabled or the host has no passkey services', async () => {
71
+ mocks.enabled = false
72
+ await act(async () => root.render(<SignInForm />))
73
+ expect(container.querySelector('input[type="password"]')).not.toBeNull()
74
+ expect(container.textContent).not.toContain('passkeys.signIn')
75
+ delete mocks.services.passkeys
76
+ await act(async () => root.render(<SignInForm />))
77
+ expect(container.querySelector('form')).not.toBeNull()
78
+ })
79
+ it('keeps password choice visible and restores controls after passkey cancellation', async () => {
80
+ mocks.passkey.mockRejectedValue(new Error('cancelled'))
81
+ await act(async () => root.render(<SignInForm />))
82
+ const button = [...container.querySelectorAll('button')].find(
83
+ (button) => button.textContent === 'passkeys.signIn'
84
+ )!
85
+ expect(button).toBeTruthy()
86
+ expect(container.querySelector('input[type="password"]')).not.toBeNull()
87
+ await act(async () => button.click())
88
+ expect(container.querySelector('[role="alert"]')?.textContent).toBe('passkeys.errors.cancelled')
89
+ expect(button.disabled).toBe(false)
90
+ expect(mocks.passkey).toHaveBeenCalledOnce()
91
+ expect(mocks.signIn).not.toHaveBeenCalled()
92
+ })
@@ -20,18 +20,28 @@
20
20
  * Stable override handles: `.byline-sign-in-card`, `.byline-sign-in-alert`,
21
21
  * `.byline-sign-in-form`, `.byline-sign-in-fields`,
22
22
  * `.byline-sign-in-actions`, `.byline-sign-in-button`,
23
- * `.byline-sign-in-home-link`.
23
+ * `.byline-sign-in-home-link`, `.byline-sign-in-alternate`,
24
+ * `.byline-sign-in-divider`, `.byline-passkey-sign-in-button`.
24
25
  */
25
26
 
26
- import { type FormEvent, useState } from 'react'
27
+ import { type FormEvent, useEffect, useRef, useState } from 'react'
27
28
 
28
29
  import { getAdminConfig } from '@byline/core'
29
30
  import { useTranslation } from '@byline/i18n/react'
30
- import { Alert, Button, Card, Input, InputPassword, LoaderEllipsis } from '@byline/ui/react'
31
+ import {
32
+ Alert,
33
+ Button,
34
+ Card,
35
+ Input,
36
+ InputPassword,
37
+ LoaderEllipsis,
38
+ PasskeyIcon,
39
+ } from '@byline/ui/react'
31
40
  import cx from 'clsx'
32
41
 
33
42
  import { useBylineAdminServices } from '../../../services/admin-services-context.js'
34
43
  import { normalizeRootRelativeRedirect, resolveSignInFormRedirect } from '../safe-redirect.js'
44
+ import { passkeyErrorKey, signInWithPasskey } from './passkey-browser.js'
35
45
  import styles from './sign-in-form.module.css'
36
46
 
37
47
  export interface SignInFormProps {
@@ -46,13 +56,47 @@ export interface SignInFormProps {
46
56
  }
47
57
 
48
58
  export function SignInForm({ redirectTo, homeUrl }: SignInFormProps) {
49
- const { adminSignIn } = useBylineAdminServices()
59
+ const { adminSignIn, passkeys } = useBylineAdminServices()
50
60
  const { t } = useTranslation('byline-admin')
51
61
  const [email, setEmail] = useState('')
52
62
  const [password, setPassword] = useState('')
53
63
  const [pending, setPending] = useState(false)
54
64
  const [error, setError] = useState<string | null>(null)
55
65
 
66
+ const mounted = useRef(false)
67
+ const [passkeysEnabled, setPasskeysEnabled] = useState(false)
68
+ useEffect(() => {
69
+ let active = true
70
+ mounted.current = true
71
+ void passkeys
72
+ ?.capability()
73
+ .then(({ enabled }) => {
74
+ if (active) setPasskeysEnabled(enabled)
75
+ })
76
+ .catch(() => {})
77
+ return () => {
78
+ active = false
79
+ mounted.current = false
80
+ }
81
+ }, [passkeys])
82
+
83
+ async function handlePasskey() {
84
+ if (!passkeys || pending) return
85
+ setPending(true)
86
+ setError(null)
87
+ try {
88
+ await signInWithPasskey(passkeys, () => mounted.current)
89
+ } catch (error) {
90
+ if (mounted.current) {
91
+ setError(t(passkeyErrorKey(error)))
92
+ setPending(false)
93
+ }
94
+ return
95
+ }
96
+ const destination = resolveSignInFormRedirect(redirectTo, getAdminConfig().routes.admin)
97
+ window.location.assign(normalizeRootRelativeRedirect(destination) ?? '/')
98
+ }
99
+
56
100
  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
57
101
  event.preventDefault()
58
102
  if (pending) return
@@ -144,6 +188,23 @@ export function SignInForm({ redirectTo, homeUrl }: SignInFormProps) {
144
188
  </div>
145
189
  </form>
146
190
  </Card.Content>
191
+ {passkeysEnabled && (
192
+ <Card.Footer className={cx('byline-sign-in-alternate', styles.alternate)}>
193
+ <div className={cx('byline-sign-in-divider', styles.divider)}>
194
+ <span>{t('auth.signIn.alternate')}</span>
195
+ </div>
196
+ <Button
197
+ type="button"
198
+ variant="outlined"
199
+ disabled={pending}
200
+ onClick={handlePasskey}
201
+ className={cx('byline-passkey-sign-in-button', styles['passkey-button'])}
202
+ >
203
+ <PasskeyIcon width="20px" height="20px" />
204
+ <span>{t('passkeys.signIn')}</span>
205
+ </Button>
206
+ </Card.Footer>
207
+ )}
147
208
  </Card>
148
209
  )
149
210
  }
@@ -21,11 +21,19 @@
21
21
  * against `@byline/auth` rather than added here.
22
22
  */
23
23
 
24
+ export { createAuthMaintenanceTasks } from './auth-maintenance.js'
24
25
  export {
25
26
  JwtSessionProvider,
26
27
  type JwtSessionProviderConfig,
27
28
  type NativeSessionEvent,
28
29
  } from './jwt-session-provider.js'
30
+ export { createPasskeyRequestLimiter } from './passkey-rate-limiter.js'
31
+ export {
32
+ assertPasskeyAttempt,
33
+ assertPasskeyBytes,
34
+ assertPasskeyClaim,
35
+ assertPasskeyTokenHash,
36
+ } from './passkey-storage-validation.js'
29
37
  export { hashPassword, verifyPassword } from './password.js'
30
38
  export { resolveActor } from './resolve-actor.js'
31
39
  export {
@@ -35,7 +43,14 @@ export {
35
43
  type SignInRateLimitStore,
36
44
  type SignInSecurityEvent,
37
45
  } from './sign-in-rate-limiter.js'
46
+ export type { AuthMaintenanceRepository } from './auth-maintenance-repository.js'
38
47
  export type { LoginSessionRow, LoginSessionsRepository } from './login-sessions-repository.js'
48
+ export type {
49
+ PasskeyAttemptRow,
50
+ PasskeyClaimInput,
51
+ PasskeyCredentialRow,
52
+ PasskeysRepository,
53
+ } from './passkeys-repository.js'
39
54
  export type {
40
55
  IssueRefreshTokenInput,
41
56
  RefreshTokenRow,
@@ -17,6 +17,8 @@ import {
17
17
  ERR_INVALID_TOKEN,
18
18
  ERR_REVOKED_TOKEN,
19
19
  ERR_SESSION_CHANGED,
20
+ type PasskeyProvider,
21
+ type PasskeySession,
20
22
  type RefreshSessionArgs,
21
23
  type RevokeSessionArgs,
22
24
  type SessionProvider,
@@ -25,13 +27,16 @@ import {
25
27
  type SignInResult,
26
28
  type SignInWithPasswordArgs,
27
29
  } from '@byline/auth'
30
+ import type { PasskeyConfig } from '@byline/core'
28
31
  import { passwordSignInSchema } from '@byline/core/validation'
29
32
  import { compactVerify, jwtVerify, SignJWT } from 'jose'
30
33
  import { v7 as uuidv7 } from 'uuid'
31
34
 
35
+ import { NativePasskeyProvider } from './native-passkey-provider.js'
32
36
  import { verifyPassword } from './password.js'
33
37
  import { resolveActor, resolveActorFromUser } from './resolve-actor.js'
34
38
  import type { AdminStore } from '../../store.js'
39
+ import type { AdminUserWithPasswordRow } from '../admin-users/repository.js'
35
40
 
36
41
  const DEFAULT_ISSUER = 'byline'
37
42
  const DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 15 * 60 // 15 minutes
@@ -43,12 +48,39 @@ const CAPABILITIES: SessionProviderCapabilities = {
43
48
  sso: false,
44
49
  }
45
50
 
46
- export interface NativeSessionEvent {
47
- type: 'refresh_attempted' | 'refresh_completed' | 'refresh_contested'
48
- }
51
+ export type NativeSessionEvent =
52
+ | { type: 'refresh_attempted' | 'refresh_completed' | 'refresh_contested' }
53
+ | {
54
+ type:
55
+ | 'passkey_attempted'
56
+ | 'passkey_completed'
57
+ | 'passkey_failed'
58
+ | 'passkey_admission_denied'
59
+ purpose:
60
+ | 'sign-in-options'
61
+ | 'sign-in'
62
+ | 'password-proof'
63
+ | 'proof-options'
64
+ | 'proof'
65
+ | 'enrollment-options'
66
+ | 'enrollment'
67
+ | 'list'
68
+ | 'rename'
69
+ | 'remove'
70
+ reason?:
71
+ | 'invalid-proof'
72
+ | 'stale-authority'
73
+ | 'forbidden'
74
+ | 'limit'
75
+ | 'unavailable'
76
+ | 'capacity'
77
+ | 'network'
78
+ }
49
79
 
50
80
  export interface JwtSessionProviderConfig {
51
- /** Best-effort, token-free renewal telemetry. Errors never change authentication outcomes. */
81
+ /** Optional explicit WebAuthn trust configuration; disabled when absent. */
82
+ passkeys?: PasskeyConfig
83
+ /** Best-effort, token-free authentication telemetry. Errors never change authentication outcomes. */
52
84
  onEvent?: (event: NativeSessionEvent) => void
53
85
  /**
54
86
  * Adapter-backed admin repositories. Construct via the DB adapter's
@@ -76,7 +108,8 @@ export interface JwtSessionProviderConfig {
76
108
  }
77
109
 
78
110
  export class JwtSessionProvider implements SessionProvider {
79
- public readonly capabilities = CAPABILITIES
111
+ public readonly capabilities: SessionProviderCapabilities
112
+ public readonly passkeys?: PasskeyProvider
80
113
 
81
114
  readonly #onEvent?: (event: NativeSessionEvent) => void
82
115
  readonly #store: AdminStore
@@ -102,6 +135,16 @@ export class JwtSessionProvider implements SessionProvider {
102
135
  this.#accessTtl = config.accessTokenTtlSeconds ?? DEFAULT_ACCESS_TOKEN_TTL_SECONDS
103
136
  this.#refreshTtl = config.refreshTokenTtlSeconds ?? DEFAULT_REFRESH_TOKEN_TTL_SECONDS
104
137
  this.#now = config.now ?? (() => new Date())
138
+ this.capabilities = config.passkeys ? { ...CAPABILITIES, passkeys: true } : CAPABILITIES
139
+ if (config.passkeys) {
140
+ this.passkeys = new NativePasskeyProvider(config.passkeys, {
141
+ store: this.#store,
142
+ now: this.#now,
143
+ authenticate: (session, store) => this.#authenticatePasskeySession(session, store),
144
+ signIn: (snapshot, args, recheck) => this.#completeSignIn(snapshot, args, recheck),
145
+ emit: (event) => this.#emitEvent(event),
146
+ })
147
+ }
105
148
  }
106
149
 
107
150
  // -----------------------------------------------------------------------
@@ -131,45 +174,19 @@ export class JwtSessionProvider implements SessionProvider {
131
174
  throw ERR_ACCOUNT_DISABLED({ message: 'account disabled' })
132
175
  }
133
176
 
134
- const previous = await this.#observedLogins(args)
135
- const withLocks = <T>(work: (store: AdminStore) => Promise<T>) =>
136
- previous.length
137
- ? this.#store.withSessionLocks(
138
- [row.id, ...previous.map((login) => login.admin_user_id)],
139
- work
140
- )
141
- : this.#store.withSessionLock(row.id, (store) => work(store))
142
- return withLocks(async (store) => {
143
- const current = await store.adminUsers.getByIdForSignIn(row.id)
144
- if (!current?.is_enabled) throw ERR_ACCOUNT_DISABLED({ message: 'account disabled' })
145
- // Password verification is deliberately outside the lock. Revalidate
146
- // both the credential and generation before issuing under that lock.
147
- if (
148
- current.password_hash !== row.password_hash ||
149
- current.session_version !== row.session_version
150
- ) {
177
+ return this.#completeSignIn(row, args, async (_store, current) => {
178
+ if (current.password_hash !== row.password_hash) {
151
179
  throw ERR_INVALID_CREDENTIALS({ message: 'credentials changed during sign-in' })
152
180
  }
153
- for (const observed of previous) {
154
- const login = await store.loginSessions.findById(observed.id)
155
- if (login && login.admin_user_id === observed.admin_user_id) {
156
- await store.loginSessions.revoke(login.id, this.#now())
157
- }
158
- }
159
- await store.adminUsers.recordLoginSuccess(row.id, args.ip ?? null)
160
- const actor = await resolveActorFromUser(store, current)
161
- if (!actor) throw ERR_ACCOUNT_DISABLED({ message: 'account disabled' })
162
- const tokens = await this.#issueTokens(store, {
163
- adminUserId: row.id,
164
- sessionVersion: current.session_version,
165
- ip: args.ip ?? null,
166
- userAgent: args.userAgent ?? null,
167
- })
168
- return { ...tokens, actor }
169
181
  })
170
182
  }
171
183
 
172
184
  async verifyAccessToken(token: string): Promise<{ actor: AdminAuth; sessionId: string }> {
185
+ const { actor, sessionId } = await this.#verifyAccessTokenAtStore(token, this.#store)
186
+ return { actor, sessionId }
187
+ }
188
+
189
+ async #verifyAccessTokenAtStore(token: string, store: AdminStore) {
173
190
  let payload: AccessTokenPayload
174
191
  try {
175
192
  const result = await jwtVerify<AccessTokenPayload>(token, this.#signingKey, {
@@ -196,14 +213,14 @@ export class JwtSessionProvider implements SessionProvider {
196
213
  throw ERR_INVALID_TOKEN({ message: 'unexpected token type' })
197
214
  }
198
215
 
199
- const current = await this.#store.adminUsers.getByIdForSignIn(payload.sub)
216
+ const current = await store.adminUsers.getByIdForSignIn(payload.sub)
200
217
  if (!current?.is_enabled) {
201
218
  throw ERR_ACCOUNT_DISABLED({ message: 'account disabled or deleted' })
202
219
  }
203
220
  if (current.session_version !== payload.sv) {
204
221
  throw ERR_REVOKED_TOKEN({ message: 'account session generation changed' })
205
222
  }
206
- const login = await this.#store.loginSessions.findById(payload.sid)
223
+ const login = await store.loginSessions.findById(payload.sid)
207
224
  if (
208
225
  !login ||
209
226
  login.admin_user_id !== payload.sub ||
@@ -213,9 +230,9 @@ export class JwtSessionProvider implements SessionProvider {
213
230
  ) {
214
231
  throw ERR_REVOKED_TOKEN({ message: 'login revoked or expired' })
215
232
  }
216
- const actor = await resolveActorFromUser(this.#store, current)
233
+ const actor = await resolveActorFromUser(store, current)
217
234
  if (!actor) throw ERR_ACCOUNT_DISABLED({ message: 'account disabled or deleted' })
218
- return { actor, sessionId: payload.sid }
235
+ return { actor, sessionId: payload.sid, user: current }
219
236
  }
220
237
 
221
238
  async refreshSession(args: RefreshSessionArgs): Promise<SessionTokens> {
@@ -339,14 +356,69 @@ export class JwtSessionProvider implements SessionProvider {
339
356
  // Internals
340
357
  // -----------------------------------------------------------------------
341
358
 
342
- #emit(type: NativeSessionEvent['type']) {
359
+ #emit(type: 'refresh_attempted' | 'refresh_completed' | 'refresh_contested') {
360
+ this.#emitEvent({ type })
361
+ }
362
+
363
+ #emitEvent(event: NativeSessionEvent): void {
343
364
  try {
344
- this.#onEvent?.({ type })
365
+ void Promise.resolve(this.#onEvent?.(event)).catch(() => {})
345
366
  } catch {
346
367
  /* Telemetry is not an authentication dependency. */
347
368
  }
348
369
  }
349
370
 
371
+ async #authenticatePasskeySession(session: PasskeySession, store = this.#store) {
372
+ const result = await this.#verifyAccessTokenAtStore(session.accessToken, store)
373
+ if (result.sessionId !== session.expectedSessionId)
374
+ throw ERR_SESSION_CHANGED({ message: 'session changed' })
375
+ return result
376
+ }
377
+
378
+ async #completeSignIn(
379
+ snapshot: AdminUserWithPasswordRow,
380
+ args: Pick<
381
+ SignInWithPasswordArgs,
382
+ 'previousAccessToken' | 'previousRefreshToken' | 'ip' | 'userAgent'
383
+ >,
384
+ recheck: (store: AdminStore, current: AdminUserWithPasswordRow) => Promise<void>
385
+ ): Promise<SignInResult> {
386
+ const previous = await this.#observedLogins(args)
387
+ const withLocks = <T>(work: (store: AdminStore) => Promise<T>) =>
388
+ previous.length
389
+ ? this.#store.withSessionLocks(
390
+ [snapshot.id, ...previous.map((login) => login.admin_user_id)],
391
+ work
392
+ )
393
+ : this.#store.withSessionLock(snapshot.id, (store) => work(store))
394
+ return withLocks(async (store) => {
395
+ const current = await store.adminUsers.getByIdForSignIn(snapshot.id)
396
+ if (!current?.is_enabled) throw ERR_ACCOUNT_DISABLED({ message: 'account disabled' })
397
+ // Proof verification is outside the lock. Revalidate the account generation
398
+ // and the method-specific credential snapshot before issuing under this lock.
399
+ if (current.session_version !== snapshot.session_version) {
400
+ throw ERR_INVALID_CREDENTIALS({ message: 'credentials changed during sign-in' })
401
+ }
402
+ await recheck(store, current)
403
+ for (const observed of previous) {
404
+ const login = await store.loginSessions.findById(observed.id)
405
+ if (login && login.admin_user_id === observed.admin_user_id) {
406
+ await store.loginSessions.revoke(login.id, this.#now())
407
+ }
408
+ }
409
+ await store.adminUsers.recordLoginSuccess(snapshot.id, args.ip ?? null)
410
+ const actor = await resolveActorFromUser(store, current)
411
+ if (!actor) throw ERR_ACCOUNT_DISABLED({ message: 'account disabled' })
412
+ const tokens = await this.#issueTokens(store, {
413
+ adminUserId: snapshot.id,
414
+ sessionVersion: current.session_version,
415
+ ip: args.ip ?? null,
416
+ userAgent: args.userAgent ?? null,
417
+ })
418
+ return { ...tokens, actor }
419
+ })
420
+ }
421
+
350
422
  async #observedLogins(
351
423
  args: Pick<SignInWithPasswordArgs, 'previousAccessToken' | 'previousRefreshToken'>
352
424
  ) {