@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.
- package/dist/modules/admin-account/components/change-password.js +1 -1
- package/dist/modules/admin-account/components/container.js +18 -3
- package/dist/modules/admin-account/components/container.module.js +2 -0
- package/dist/modules/admin-account/components/container_module.css +7 -0
- package/dist/modules/admin-account/components/preferences.js +1 -1
- package/dist/modules/admin-account/components/update.js +1 -1
- package/dist/modules/admin-roles/components/create.js +1 -1
- package/dist/modules/admin-roles/components/permissions.js +1 -1
- package/dist/modules/admin-roles/components/update.js +1 -1
- package/dist/modules/admin-users/abilities.d.ts +1 -0
- package/dist/modules/admin-users/abilities.js +8 -1
- package/dist/modules/admin-users/components/create.js +1 -1
- package/dist/modules/admin-users/components/roles.js +1 -1
- package/dist/modules/admin-users/components/set-password.js +1 -1
- package/dist/modules/admin-users/components/update.js +1 -1
- package/dist/modules/auth/auth-maintenance-repository.d.ts +31 -0
- package/dist/modules/auth/auth-maintenance-repository.js +1 -0
- package/dist/modules/auth/auth-maintenance.d.ts +11 -0
- package/dist/modules/auth/auth-maintenance.js +39 -0
- package/dist/modules/auth/auth-maintenance.test.node.d.ts +1 -0
- package/dist/modules/auth/components/passkey-browser.d.ts +14 -0
- package/dist/modules/auth/components/passkey-browser.js +118 -0
- package/dist/modules/auth/components/passkey-browser.test.node.d.ts +1 -0
- package/dist/modules/auth/components/passkey-manager.d.ts +26 -0
- package/dist/modules/auth/components/passkey-manager.js +406 -0
- package/dist/modules/auth/components/passkey-manager.module.js +22 -0
- package/dist/modules/auth/components/passkey-manager_module.css +104 -0
- package/dist/modules/auth/components/sign-in-form.js +62 -3
- package/dist/modules/auth/components/sign-in-form.module.js +5 -1
- package/dist/modules/auth/components/sign-in-form_module.css +40 -0
- package/dist/modules/auth/index.d.ts +5 -0
- package/dist/modules/auth/index.js +3 -0
- package/dist/modules/auth/jwt-session-provider.d.ts +12 -4
- package/dist/modules/auth/jwt-session-provider.js +135 -94
- package/dist/modules/auth/native-passkey-provider.d.ts +72 -0
- package/dist/modules/auth/native-passkey-provider.js +618 -0
- package/dist/modules/auth/passkey-rate-limiter.d.ts +14 -0
- package/dist/modules/auth/passkey-rate-limiter.js +60 -0
- package/dist/modules/auth/passkey-rate-limiter.test.node.d.ts +8 -0
- package/dist/modules/auth/passkey-storage-validation.d.ts +14 -0
- package/dist/modules/auth/passkey-storage-validation.js +59 -0
- package/dist/modules/auth/passkeys-repository.d.ts +96 -0
- package/dist/modules/auth/passkeys-repository.js +1 -0
- package/dist/modules/auth/refresh-tokens-repository.d.ts +1 -1
- package/dist/modules/auth/sign-in-rate-limiter.d.ts +2 -0
- package/dist/modules/auth/sign-in-rate-limiter.js +1 -1
- package/dist/services/admin-services-context.d.ts +1 -1
- package/dist/services/admin-services-types.d.ts +60 -0
- package/dist/store.d.ts +4 -0
- package/package.json +16 -9
- package/src/modules/admin-account/components/change-password.tsx +1 -1
- package/src/modules/admin-account/components/container.module.css +9 -0
- package/src/modules/admin-account/components/container.tsx +12 -3
- package/src/modules/admin-account/components/preferences.tsx +1 -1
- package/src/modules/admin-account/components/update.tsx +1 -1
- package/src/modules/admin-roles/components/create.tsx +1 -1
- package/src/modules/admin-roles/components/permissions.tsx +1 -1
- package/src/modules/admin-roles/components/update.tsx +1 -1
- package/src/modules/admin-users/abilities.ts +7 -0
- package/src/modules/admin-users/components/create.tsx +1 -1
- package/src/modules/admin-users/components/roles.tsx +1 -1
- package/src/modules/admin-users/components/set-password.tsx +1 -1
- package/src/modules/admin-users/components/update.tsx +1 -1
- package/src/modules/auth/auth-maintenance-repository.ts +26 -0
- package/src/modules/auth/auth-maintenance.test.node.ts +110 -0
- package/src/modules/auth/auth-maintenance.ts +62 -0
- package/src/modules/auth/components/passkey-browser.test.node.ts +188 -0
- package/src/modules/auth/components/passkey-browser.ts +139 -0
- package/src/modules/auth/components/passkey-manager.module.css +100 -0
- package/src/modules/auth/components/passkey-manager.test.tsx +189 -0
- package/src/modules/auth/components/passkey-manager.tsx +386 -0
- package/src/modules/auth/components/sign-in-form.module.css +37 -0
- package/src/modules/auth/components/sign-in-form.test.tsx +92 -0
- package/src/modules/auth/components/sign-in-form.tsx +65 -4
- package/src/modules/auth/index.ts +15 -0
- package/src/modules/auth/jwt-session-provider.ts +116 -44
- package/src/modules/auth/native-passkey-provider.ts +710 -0
- package/src/modules/auth/passkey-rate-limiter.test.node.ts +83 -0
- package/src/modules/auth/passkey-rate-limiter.ts +76 -0
- package/src/modules/auth/passkey-storage-validation.ts +108 -0
- package/src/modules/auth/passkeys-repository.ts +97 -0
- package/src/modules/auth/refresh-tokens-repository.ts +1 -1
- package/src/modules/auth/sign-in-rate-limiter.ts +1 -1
- package/src/services/admin-services-context.tsx +2 -0
- package/src/services/admin-services-types.ts +60 -0
- 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 {
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
135
|
-
|
|
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
|
|
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
|
|
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(
|
|
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:
|
|
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?.(
|
|
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
|
) {
|