@byline/admin 2.4.0 → 2.4.1
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/abilities.js +5 -24
- package/dist/index.js +8 -30
- package/dist/lib/assert-admin-actor.js +13 -74
- package/dist/lib/create-command.js +6 -16
- package/dist/modules/admin-account/commands.js +35 -24
- package/dist/modules/admin-account/components/change-password.d.ts +8 -0
- package/dist/modules/admin-account/components/change-password.js +192 -0
- package/dist/modules/admin-account/components/change-password.module.js +8 -0
- package/dist/modules/admin-account/components/change-password_module.css +27 -0
- package/dist/modules/admin-account/components/container.d.ts +29 -0
- package/dist/modules/admin-account/components/container.js +298 -0
- package/dist/modules/admin-account/components/container.module.js +28 -0
- package/dist/modules/admin-account/components/container_module.css +106 -0
- package/dist/modules/admin-account/components/update.d.ts +8 -0
- package/dist/modules/admin-account/components/update.js +207 -0
- package/dist/modules/admin-account/components/update.module.js +8 -0
- package/dist/modules/admin-account/components/update_module.css +27 -0
- package/dist/modules/admin-account/errors.js +14 -45
- package/dist/modules/admin-account/index.js +4 -34
- package/dist/modules/admin-account/schemas.js +25 -59
- package/dist/modules/admin-account/service.js +56 -61
- package/dist/modules/admin-permissions/abilities.js +6 -24
- package/dist/modules/admin-permissions/commands.js +42 -28
- package/dist/modules/admin-permissions/components/inspector.d.ts +4 -0
- package/dist/modules/admin-permissions/components/inspector.js +284 -0
- package/dist/modules/admin-permissions/components/inspector.module.js +56 -0
- package/dist/modules/admin-permissions/components/inspector_module.css +238 -0
- package/dist/modules/admin-permissions/dto.js +3 -16
- package/dist/modules/admin-permissions/errors.js +14 -27
- package/dist/modules/admin-permissions/index.js +6 -26
- package/dist/modules/admin-permissions/repository.js +1 -8
- package/dist/modules/admin-permissions/schemas.js +33 -70
- package/dist/modules/admin-permissions/service.js +88 -92
- package/dist/modules/admin-roles/abilities.js +8 -30
- package/dist/modules/admin-roles/commands.js +89 -55
- package/dist/modules/admin-roles/components/create.d.ts +7 -0
- package/dist/modules/admin-roles/components/create.js +177 -0
- package/dist/modules/admin-roles/components/create.module.js +8 -0
- package/dist/modules/admin-roles/components/create_module.css +27 -0
- package/dist/modules/admin-roles/components/permissions.d.ts +10 -0
- package/dist/modules/admin-roles/components/permissions.js +303 -0
- package/dist/modules/admin-roles/components/permissions.module.js +44 -0
- package/dist/modules/admin-roles/components/permissions_module.css +192 -0
- package/dist/modules/admin-roles/components/update.d.ts +8 -0
- package/dist/modules/admin-roles/components/update.js +166 -0
- package/dist/modules/admin-roles/components/update.module.js +8 -0
- package/dist/modules/admin-roles/components/update_module.css +27 -0
- package/dist/modules/admin-roles/dto.js +3 -16
- package/dist/modules/admin-roles/errors.js +16 -40
- package/dist/modules/admin-roles/index.js +6 -26
- package/dist/modules/admin-roles/repository.js +1 -8
- package/dist/modules/admin-roles/schemas.js +41 -71
- package/dist/modules/admin-roles/service.js +79 -82
- package/dist/modules/admin-users/abilities.js +9 -38
- package/dist/modules/admin-users/commands.js +92 -50
- package/dist/modules/admin-users/components/create.d.ts +8 -0
- package/dist/modules/admin-users/components/create.js +268 -0
- package/dist/modules/admin-users/components/create.module.js +10 -0
- package/dist/modules/admin-users/components/create_module.css +45 -0
- package/dist/modules/admin-users/components/roles.d.ts +11 -0
- package/dist/modules/admin-users/components/roles.js +148 -0
- package/dist/modules/admin-users/components/roles.module.js +18 -0
- package/dist/modules/admin-users/components/roles_module.css +75 -0
- package/dist/modules/admin-users/components/set-password.d.ts +8 -0
- package/dist/modules/admin-users/components/set-password.js +170 -0
- package/dist/modules/admin-users/components/set-password.module.js +9 -0
- package/dist/modules/admin-users/components/set-password_module.css +31 -0
- package/dist/modules/admin-users/components/update.d.ts +8 -0
- package/dist/modules/admin-users/components/update.js +254 -0
- package/dist/modules/admin-users/components/update.module.js +9 -0
- package/dist/modules/admin-users/components/update_module.css +34 -0
- package/dist/modules/admin-users/dto.js +3 -18
- package/dist/modules/admin-users/errors.js +17 -43
- package/dist/modules/admin-users/index.js +7 -27
- package/dist/modules/admin-users/repository.js +1 -8
- package/dist/modules/admin-users/schemas.js +44 -75
- package/dist/modules/admin-users/seed-super-admin.js +9 -34
- package/dist/modules/admin-users/service.js +76 -91
- package/dist/modules/auth/components/sign-in-form.d.ts +12 -0
- package/dist/modules/auth/components/sign-in-form.js +115 -0
- package/dist/modules/auth/components/sign-in-form.module.js +12 -0
- package/dist/modules/auth/components/sign-in-form_module.css +41 -0
- package/dist/modules/auth/index.js +3 -24
- package/dist/modules/auth/jwt-session-provider.js +179 -149
- package/dist/modules/auth/password.js +11 -53
- package/dist/modules/auth/phc.js +21 -54
- package/dist/modules/auth/refresh-tokens-repository.js +1 -8
- package/dist/modules/auth/resolve-actor.js +6 -28
- package/dist/services/admin-services-context.d.ts +16 -0
- package/dist/services/admin-services-context.js +13 -0
- package/dist/services/admin-services-types.d.ts +129 -0
- package/dist/services/admin-services-types.js +1 -0
- package/dist/store.js +1 -8
- package/dist/vendor/noble-argon2/_blake.js +277 -45
- package/dist/vendor/noble-argon2/_md.js +81 -136
- package/dist/vendor/noble-argon2/_u64.js +65 -67
- package/dist/vendor/noble-argon2/argon2.js +181 -342
- package/dist/vendor/noble-argon2/blake2.js +252 -327
- package/dist/vendor/noble-argon2/utils.js +110 -490
- package/dist/vendor/noble-argon2/utils.js.LICENSE.txt +1 -0
- package/package.json +89 -10
- package/src/abilities.ts +32 -0
- package/src/declarations.d.ts +4 -0
- package/src/index.ts +39 -0
- package/src/lib/assert-admin-actor.ts +90 -0
- package/src/lib/create-command.ts +109 -0
- package/src/modules/admin-account/commands.ts +76 -0
- package/src/modules/admin-account/components/change-password.module.css +40 -0
- package/src/modules/admin-account/components/change-password.tsx +232 -0
- package/src/modules/admin-account/components/container.module.css +158 -0
- package/src/modules/admin-account/components/container.tsx +229 -0
- package/src/modules/admin-account/components/update.module.css +40 -0
- package/src/modules/admin-account/components/update.tsx +263 -0
- package/src/modules/admin-account/errors.ts +75 -0
- package/src/modules/admin-account/index.ts +60 -0
- package/src/modules/admin-account/schemas.ts +84 -0
- package/src/modules/admin-account/service.ts +92 -0
- package/src/modules/admin-permissions/abilities.ts +46 -0
- package/src/modules/admin-permissions/commands.ts +103 -0
- package/src/modules/admin-permissions/components/inspector.module.css +326 -0
- package/src/modules/admin-permissions/components/inspector.tsx +298 -0
- package/src/modules/admin-permissions/dto.ts +28 -0
- package/src/modules/admin-permissions/errors.ts +57 -0
- package/src/modules/admin-permissions/index.ts +72 -0
- package/src/modules/admin-permissions/repository.ts +49 -0
- package/src/modules/admin-permissions/schemas.ts +128 -0
- package/src/modules/admin-permissions/service.ts +137 -0
- package/src/modules/admin-roles/abilities.ts +62 -0
- package/src/modules/admin-roles/commands.ts +161 -0
- package/src/modules/admin-roles/components/create.module.css +40 -0
- package/src/modules/admin-roles/components/create.tsx +218 -0
- package/src/modules/admin-roles/components/permissions.module.css +279 -0
- package/src/modules/admin-roles/components/permissions.tsx +396 -0
- package/src/modules/admin-roles/components/update.module.css +40 -0
- package/src/modules/admin-roles/components/update.tsx +218 -0
- package/src/modules/admin-roles/dto.ts +30 -0
- package/src/modules/admin-roles/errors.ts +76 -0
- package/src/modules/admin-roles/index.ts +81 -0
- package/src/modules/admin-roles/repository.ts +96 -0
- package/src/modules/admin-roles/schemas.ts +139 -0
- package/src/modules/admin-roles/service.ts +136 -0
- package/src/modules/admin-users/abilities.ts +76 -0
- package/src/modules/admin-users/commands.ts +157 -0
- package/src/modules/admin-users/components/create.module.css +63 -0
- package/src/modules/admin-users/components/create.tsx +323 -0
- package/src/modules/admin-users/components/roles.module.css +119 -0
- package/src/modules/admin-users/components/roles.tsx +172 -0
- package/src/modules/admin-users/components/set-password.module.css +46 -0
- package/src/modules/admin-users/components/set-password.tsx +199 -0
- package/src/modules/admin-users/components/update.module.css +49 -0
- package/src/modules/admin-users/components/update.tsx +328 -0
- package/src/modules/admin-users/dto.ts +39 -0
- package/src/modules/admin-users/errors.ts +84 -0
- package/src/modules/admin-users/index.ts +91 -0
- package/src/modules/admin-users/repository.ts +161 -0
- package/src/modules/admin-users/schemas.ts +168 -0
- package/src/modules/admin-users/seed-super-admin.ts +102 -0
- package/src/modules/admin-users/service.ts +166 -0
- package/src/modules/auth/components/sign-in-form.module.css +62 -0
- package/src/modules/auth/components/sign-in-form.tsx +132 -0
- package/src/modules/auth/index.ts +31 -0
- package/src/modules/auth/jwt-session-provider.ts +301 -0
- package/src/modules/auth/password.ts +94 -0
- package/src/modules/auth/phc.ts +121 -0
- package/src/modules/auth/refresh-tokens-repository.ts +74 -0
- package/src/modules/auth/resolve-actor.ts +42 -0
- package/src/services/admin-services-context.tsx +52 -0
- package/src/services/admin-services-types.ts +177 -0
- package/src/store.ts +32 -0
- package/src/vendor/noble-argon2/LICENSE +21 -0
- package/src/vendor/noble-argon2/README.md +87 -0
- package/src/vendor/noble-argon2/_blake.ts +58 -0
- package/src/vendor/noble-argon2/_md.ts +223 -0
- package/src/vendor/noble-argon2/_u64.ts +118 -0
- package/src/vendor/noble-argon2/argon2.ts +668 -0
- package/src/vendor/noble-argon2/blake2.ts +583 -0
- package/src/vendor/noble-argon2/utils.ts +849 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SignInForm — admin sign-in card form.
|
|
3
|
+
*
|
|
4
|
+
* Override handles:
|
|
5
|
+
* .byline-sign-in-card — outer Card (responsive width)
|
|
6
|
+
* .byline-sign-in-alert — error alert spacing
|
|
7
|
+
* .byline-sign-in-form — the form element
|
|
8
|
+
* .byline-sign-in-fields — vertical stack of inputs
|
|
9
|
+
* .byline-sign-in-actions — action row (optional Home link + submit button)
|
|
10
|
+
* .byline-sign-in-home-link — left-side "Home" link (rendered when homeUrl is set)
|
|
11
|
+
* .byline-sign-in-button — the submit button (min-width)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
.card,
|
|
15
|
+
:global(.byline-sign-in-card) {
|
|
16
|
+
width: 100%;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@media (min-width: 40rem) {
|
|
20
|
+
.card,
|
|
21
|
+
:global(.byline-sign-in-card) {
|
|
22
|
+
max-width: 380px;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.alert,
|
|
27
|
+
:global(.byline-sign-in-alert) {
|
|
28
|
+
margin-top: var(--spacing-12);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.form,
|
|
32
|
+
:global(.byline-sign-in-form) {
|
|
33
|
+
padding-top: var(--spacing-8);
|
|
34
|
+
margin-bottom: var(--spacing-8);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.fields,
|
|
38
|
+
:global(.byline-sign-in-fields) {
|
|
39
|
+
display: flex;
|
|
40
|
+
flex-direction: column;
|
|
41
|
+
gap: var(--spacing-16);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.actions,
|
|
45
|
+
:global(.byline-sign-in-actions) {
|
|
46
|
+
display: flex;
|
|
47
|
+
align-items: center;
|
|
48
|
+
margin-top: var(--spacing-24);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.home-link,
|
|
52
|
+
:global(.byline-sign-in-home-link) {
|
|
53
|
+
font-size: 0.9rem;
|
|
54
|
+
text-decoration: underline;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.button,
|
|
58
|
+
:global(.byline-sign-in-button) {
|
|
59
|
+
min-width: 5rem;
|
|
60
|
+
/* Always floats right whether or not a Home link is present on the left. */
|
|
61
|
+
margin-left: auto;
|
|
62
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
5
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
7
|
+
*
|
|
8
|
+
* Copyright (c) Infonomic Company Limited
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Admin sign-in form.
|
|
13
|
+
*
|
|
14
|
+
* Client component — collects email + password, calls the `adminSignIn`
|
|
15
|
+
* server fn, and on success navigates to the caller-supplied
|
|
16
|
+
* `callbackUrl` (or `/admin`). On failure renders a generic "Invalid
|
|
17
|
+
* credentials" alert; the provider equalises timing between
|
|
18
|
+
* unknown-email and wrong-password so the UI doesn't distinguish the two.
|
|
19
|
+
*
|
|
20
|
+
* Stable override handles: `.byline-sign-in-card`, `.byline-sign-in-alert`,
|
|
21
|
+
* `.byline-sign-in-form`, `.byline-sign-in-fields`,
|
|
22
|
+
* `.byline-sign-in-actions`, `.byline-sign-in-button`,
|
|
23
|
+
* `.byline-sign-in-home-link`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { type FormEvent, useState } from 'react'
|
|
27
|
+
|
|
28
|
+
import { Alert, Button, Card, Input, LoaderEllipsis } from '@byline/ui/react'
|
|
29
|
+
import cx from 'classnames'
|
|
30
|
+
|
|
31
|
+
import { useBylineAdminServices } from '../../../services/admin-services-context.js'
|
|
32
|
+
import styles from './sign-in-form.module.css'
|
|
33
|
+
|
|
34
|
+
interface SignInFormProps {
|
|
35
|
+
/** Destination after successful sign-in. Defaults to `/admin`. */
|
|
36
|
+
callbackUrl?: string
|
|
37
|
+
/**
|
|
38
|
+
* Optional plain "Home" link rendered on the left of the action row.
|
|
39
|
+
* Typically the host's configured `serverURL` so signed-out admins can
|
|
40
|
+
* navigate back to the public site without typing the URL.
|
|
41
|
+
*/
|
|
42
|
+
homeUrl?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function SignInForm({ callbackUrl, homeUrl }: SignInFormProps) {
|
|
46
|
+
const { adminSignIn } = useBylineAdminServices()
|
|
47
|
+
const [email, setEmail] = useState('')
|
|
48
|
+
const [password, setPassword] = useState('')
|
|
49
|
+
const [pending, setPending] = useState(false)
|
|
50
|
+
const [error, setError] = useState<string | null>(null)
|
|
51
|
+
|
|
52
|
+
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
53
|
+
event.preventDefault()
|
|
54
|
+
if (pending) return
|
|
55
|
+
if (email.trim().length === 0 || password.length === 0) {
|
|
56
|
+
setError('Enter your email and password.')
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
setPending(true)
|
|
61
|
+
setError(null)
|
|
62
|
+
try {
|
|
63
|
+
await adminSignIn({ data: { email: email.trim(), password } })
|
|
64
|
+
const target = callbackUrl && callbackUrl.length > 0 ? callbackUrl : '/admin'
|
|
65
|
+
// Full-page navigation — the admin layout needs to re-run its
|
|
66
|
+
// `beforeLoad` guard against the freshly-set session cookies.
|
|
67
|
+
window.location.assign(target)
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.warn('sign-in failed', err)
|
|
70
|
+
setError('Invalid credentials.')
|
|
71
|
+
setPending(false)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<Card className={cx('byline-sign-in-card', styles.card)}>
|
|
77
|
+
<Card.Header>
|
|
78
|
+
<Card.Title>
|
|
79
|
+
<h2>Sign in</h2>
|
|
80
|
+
</Card.Title>
|
|
81
|
+
<Card.Description>Sign in to the Byline admin.</Card.Description>
|
|
82
|
+
{error && (
|
|
83
|
+
<Alert intent="danger" className={cx('byline-sign-in-alert', styles.alert)}>
|
|
84
|
+
{error}
|
|
85
|
+
</Alert>
|
|
86
|
+
)}
|
|
87
|
+
</Card.Header>
|
|
88
|
+
<Card.Content>
|
|
89
|
+
<form onSubmit={handleSubmit} noValidate className={cx('byline-sign-in-form', styles.form)}>
|
|
90
|
+
<div className={cx('byline-sign-in-fields', styles.fields)}>
|
|
91
|
+
<Input
|
|
92
|
+
label="Email"
|
|
93
|
+
id="email"
|
|
94
|
+
name="email"
|
|
95
|
+
type="email"
|
|
96
|
+
autoComplete="email"
|
|
97
|
+
required
|
|
98
|
+
value={email}
|
|
99
|
+
onChange={(event) => setEmail(event.currentTarget.value)}
|
|
100
|
+
disabled={pending}
|
|
101
|
+
/>
|
|
102
|
+
<Input
|
|
103
|
+
label="Password"
|
|
104
|
+
id="password"
|
|
105
|
+
name="password"
|
|
106
|
+
type="password"
|
|
107
|
+
autoComplete="current-password"
|
|
108
|
+
required
|
|
109
|
+
value={password}
|
|
110
|
+
onChange={(event) => setPassword(event.currentTarget.value)}
|
|
111
|
+
disabled={pending}
|
|
112
|
+
/>
|
|
113
|
+
</div>
|
|
114
|
+
<div className={cx('byline-sign-in-actions', styles.actions)}>
|
|
115
|
+
{homeUrl && (
|
|
116
|
+
<a href={homeUrl} className={cx('byline-sign-in-home-link', styles['home-link'])}>
|
|
117
|
+
Home
|
|
118
|
+
</a>
|
|
119
|
+
)}
|
|
120
|
+
<Button
|
|
121
|
+
type="submit"
|
|
122
|
+
disabled={pending}
|
|
123
|
+
className={cx('byline-sign-in-button', styles.button)}
|
|
124
|
+
>
|
|
125
|
+
{pending ? <LoaderEllipsis size={30} color="#aaaaaa" /> : <span>Sign In</span>}
|
|
126
|
+
</Button>
|
|
127
|
+
</div>
|
|
128
|
+
</form>
|
|
129
|
+
</Card.Content>
|
|
130
|
+
</Card>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* `@byline/admin/auth` — session handling for the built-in admin realm.
|
|
11
|
+
*
|
|
12
|
+
* Hosts the reference `JwtSessionProvider` and the sign-in / refresh /
|
|
13
|
+
* revoke orchestration that consumes it, along with password hashing
|
|
14
|
+
* (`hashPassword` / `verifyPassword`) and the `RefreshTokensRepository`
|
|
15
|
+
* contract the provider drives.
|
|
16
|
+
*
|
|
17
|
+
* The `SessionProvider` **interface** lives in `@byline/auth` so the
|
|
18
|
+
* pluggability contract stays narrow; this module supplies the
|
|
19
|
+
* Byline-native implementation. Third-party providers (Lucia, WorkOS,
|
|
20
|
+
* Clerk, institutional SSO) should be shipped as separate packages
|
|
21
|
+
* against `@byline/auth` rather than added here.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export { JwtSessionProvider, type JwtSessionProviderConfig } from './jwt-session-provider.js'
|
|
25
|
+
export { hashPassword, verifyPassword } from './password.js'
|
|
26
|
+
export { resolveActor } from './resolve-actor.js'
|
|
27
|
+
export type {
|
|
28
|
+
IssueRefreshTokenInput,
|
|
29
|
+
RefreshTokenRow,
|
|
30
|
+
RefreshTokensRepository,
|
|
31
|
+
} from './refresh-tokens-repository.js'
|
|
@@ -0,0 +1,301 @@
|
|
|
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 { createHash, randomBytes, randomUUID } from 'node:crypto'
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
type AccessTokenPayload,
|
|
13
|
+
type AdminAuth,
|
|
14
|
+
ERR_ACCOUNT_DISABLED,
|
|
15
|
+
ERR_INVALID_CREDENTIALS,
|
|
16
|
+
ERR_INVALID_TOKEN,
|
|
17
|
+
ERR_REVOKED_TOKEN,
|
|
18
|
+
type RefreshSessionArgs,
|
|
19
|
+
type SessionProvider,
|
|
20
|
+
type SessionProviderCapabilities,
|
|
21
|
+
type SessionTokens,
|
|
22
|
+
type SignInResult,
|
|
23
|
+
type SignInWithPasswordArgs,
|
|
24
|
+
} from '@byline/auth'
|
|
25
|
+
import { jwtVerify, SignJWT } from 'jose'
|
|
26
|
+
import { v7 as uuidv7 } from 'uuid'
|
|
27
|
+
|
|
28
|
+
import { verifyPassword } from './password.js'
|
|
29
|
+
import { resolveActor } from './resolve-actor.js'
|
|
30
|
+
import type { AdminStore } from '../../store.js'
|
|
31
|
+
|
|
32
|
+
const DEFAULT_ISSUER = 'byline'
|
|
33
|
+
const DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 15 * 60 // 15 minutes
|
|
34
|
+
const DEFAULT_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60 // 30 days
|
|
35
|
+
|
|
36
|
+
const CAPABILITIES: SessionProviderCapabilities = {
|
|
37
|
+
passwordChange: true,
|
|
38
|
+
magicLink: false,
|
|
39
|
+
sso: false,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface JwtSessionProviderConfig {
|
|
43
|
+
/**
|
|
44
|
+
* Adapter-backed admin repositories. Construct via the DB adapter's
|
|
45
|
+
* admin-store factory (e.g. `createAdminStore(db)` from
|
|
46
|
+
* `@byline/db-postgres/admin`) and pass the result in — the provider
|
|
47
|
+
* does not touch Drizzle or any other adapter-specific API directly.
|
|
48
|
+
*/
|
|
49
|
+
store: AdminStore
|
|
50
|
+
/**
|
|
51
|
+
* HMAC-SHA256 signing secret. Must be at least 32 bytes (256 bits) of
|
|
52
|
+
* entropy. Load from a secret manager — never hard-code.
|
|
53
|
+
*
|
|
54
|
+
* To switch to asymmetric signing (RS256/EdDSA), swap out this provider
|
|
55
|
+
* for a custom one backed by `jose` key objects.
|
|
56
|
+
*/
|
|
57
|
+
signingSecret: string | Uint8Array
|
|
58
|
+
/** Issuer claim (`iss`) on access tokens. Defaults to `'byline'`. */
|
|
59
|
+
issuer?: string
|
|
60
|
+
/** Access-token lifetime in seconds. Default 15 min. */
|
|
61
|
+
accessTokenTtlSeconds?: number
|
|
62
|
+
/** Refresh-token lifetime in seconds. Default 30 days. */
|
|
63
|
+
refreshTokenTtlSeconds?: number
|
|
64
|
+
/** Clock reference — override for deterministic tests. */
|
|
65
|
+
now?: () => Date
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class JwtSessionProvider implements SessionProvider {
|
|
69
|
+
public readonly capabilities = CAPABILITIES
|
|
70
|
+
|
|
71
|
+
readonly #store: AdminStore
|
|
72
|
+
readonly #signingKey: Uint8Array
|
|
73
|
+
readonly #issuer: string
|
|
74
|
+
readonly #accessTtl: number
|
|
75
|
+
readonly #refreshTtl: number
|
|
76
|
+
readonly #now: () => Date
|
|
77
|
+
|
|
78
|
+
constructor(config: JwtSessionProviderConfig) {
|
|
79
|
+
this.#store = config.store
|
|
80
|
+
this.#signingKey =
|
|
81
|
+
typeof config.signingSecret === 'string'
|
|
82
|
+
? new TextEncoder().encode(config.signingSecret)
|
|
83
|
+
: config.signingSecret
|
|
84
|
+
if (this.#signingKey.byteLength < 32) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
'JwtSessionProvider: signingSecret must carry at least 32 bytes of entropy (256 bits)'
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
this.#issuer = config.issuer ?? DEFAULT_ISSUER
|
|
90
|
+
this.#accessTtl = config.accessTokenTtlSeconds ?? DEFAULT_ACCESS_TOKEN_TTL_SECONDS
|
|
91
|
+
this.#refreshTtl = config.refreshTokenTtlSeconds ?? DEFAULT_REFRESH_TOKEN_TTL_SECONDS
|
|
92
|
+
this.#now = config.now ?? (() => new Date())
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// -----------------------------------------------------------------------
|
|
96
|
+
// SessionProvider
|
|
97
|
+
// -----------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
async signInWithPassword(args: SignInWithPasswordArgs): Promise<SignInResult> {
|
|
100
|
+
const users = this.#store.adminUsers
|
|
101
|
+
const row = await users.getByEmailForSignIn(args.email)
|
|
102
|
+
|
|
103
|
+
// Uniform error response for unknown email vs. wrong password — don't
|
|
104
|
+
// leak which one. Still do a real verify against a dummy hash so the
|
|
105
|
+
// timing is comparable; the argon2 cost dominates regardless.
|
|
106
|
+
if (!row) {
|
|
107
|
+
await verifyPassword(args.password, DUMMY_HASH_FOR_TIMING)
|
|
108
|
+
throw ERR_INVALID_CREDENTIALS({ message: 'invalid credentials' })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const ok = await verifyPassword(args.password, row.password_hash)
|
|
112
|
+
if (!ok) {
|
|
113
|
+
await users.recordLoginFailure(row.id)
|
|
114
|
+
throw ERR_INVALID_CREDENTIALS({ message: 'invalid credentials' })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!row.is_enabled) {
|
|
118
|
+
throw ERR_ACCOUNT_DISABLED({ message: 'account disabled' })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
await users.recordLoginSuccess(row.id, args.ip ?? null)
|
|
122
|
+
|
|
123
|
+
const actor = await resolveActor(this.#store, row.id)
|
|
124
|
+
// resolveActor also checks is_enabled, but we just recorded success
|
|
125
|
+
// above, so null here would indicate a race (the account was disabled
|
|
126
|
+
// between the check and the resolve). Treat as disabled.
|
|
127
|
+
if (!actor) {
|
|
128
|
+
throw ERR_ACCOUNT_DISABLED({ message: 'account disabled' })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const tokens = await this.#issueTokens({
|
|
132
|
+
adminUserId: row.id,
|
|
133
|
+
ip: args.ip ?? null,
|
|
134
|
+
userAgent: args.userAgent ?? null,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
return { ...tokens, actor }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async verifyAccessToken(token: string): Promise<{ actor: AdminAuth }> {
|
|
141
|
+
let payload: AccessTokenPayload
|
|
142
|
+
try {
|
|
143
|
+
const result = await jwtVerify<AccessTokenPayload>(token, this.#signingKey, {
|
|
144
|
+
issuer: this.#issuer,
|
|
145
|
+
})
|
|
146
|
+
payload = result.payload
|
|
147
|
+
} catch (err) {
|
|
148
|
+
throw ERR_INVALID_TOKEN({ message: 'access token verification failed', cause: err })
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (payload.typ !== 'access') {
|
|
152
|
+
throw ERR_INVALID_TOKEN({ message: 'unexpected token type' })
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const actor = await resolveActor(this.#store, payload.sub)
|
|
156
|
+
if (!actor) {
|
|
157
|
+
// The token was valid but the user is now disabled or deleted.
|
|
158
|
+
throw ERR_ACCOUNT_DISABLED({ message: 'account disabled or deleted' })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { actor }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async refreshSession(args: RefreshSessionArgs): Promise<SessionTokens> {
|
|
165
|
+
const refreshTokens = this.#store.refreshTokens
|
|
166
|
+
const hash = hashToken(args.refreshToken)
|
|
167
|
+
const row = await refreshTokens.findByHash(hash)
|
|
168
|
+
|
|
169
|
+
if (!row) {
|
|
170
|
+
throw ERR_INVALID_TOKEN({ message: 'refresh token not recognised' })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const now = this.#now()
|
|
174
|
+
|
|
175
|
+
// Already revoked?
|
|
176
|
+
if (row.revoked_at != null) {
|
|
177
|
+
if (row.rotated_to_id != null) {
|
|
178
|
+
// Rotated token replayed — the chain is compromised. Revoke every
|
|
179
|
+
// descendant so the attacker and the legitimate holder are both
|
|
180
|
+
// signed out.
|
|
181
|
+
await refreshTokens.revokeChain(row.id, now)
|
|
182
|
+
throw ERR_REVOKED_TOKEN({
|
|
183
|
+
message: 'refresh token was already rotated — chain revoked',
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
throw ERR_REVOKED_TOKEN({ message: 'refresh token has been revoked' })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (row.expires_at.getTime() <= now.getTime()) {
|
|
190
|
+
throw ERR_INVALID_TOKEN({ message: 'refresh token expired' })
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Rotate: mint a new token, mark the old one rotated_to the new id.
|
|
194
|
+
const newId = uuidv7()
|
|
195
|
+
const newRefreshPlain = generateOpaqueToken()
|
|
196
|
+
const newRefreshHash = hashToken(newRefreshPlain)
|
|
197
|
+
const refreshExpiresAt = new Date(now.getTime() + this.#refreshTtl * 1000)
|
|
198
|
+
|
|
199
|
+
await refreshTokens.issue({
|
|
200
|
+
id: newId,
|
|
201
|
+
admin_user_id: row.admin_user_id,
|
|
202
|
+
token_hash: newRefreshHash,
|
|
203
|
+
expires_at: refreshExpiresAt,
|
|
204
|
+
user_agent: args.userAgent ?? null,
|
|
205
|
+
ip: args.ip ?? null,
|
|
206
|
+
})
|
|
207
|
+
await refreshTokens.markRotated(row.id, newId, now)
|
|
208
|
+
|
|
209
|
+
const accessToken = await this.#signAccessToken(row.admin_user_id, now)
|
|
210
|
+
const accessExpiresAt = new Date(now.getTime() + this.#accessTtl * 1000)
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
accessToken,
|
|
214
|
+
refreshToken: newRefreshPlain,
|
|
215
|
+
accessTokenExpiresAt: accessExpiresAt,
|
|
216
|
+
refreshTokenExpiresAt: refreshExpiresAt,
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async revokeSession(refreshToken: string): Promise<void> {
|
|
221
|
+
const refreshTokens = this.#store.refreshTokens
|
|
222
|
+
const row = await refreshTokens.findByHash(hashToken(refreshToken))
|
|
223
|
+
if (!row) return // Idempotent — unknown tokens are a no-op.
|
|
224
|
+
await refreshTokens.revoke(row.id, this.#now())
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async resolveActor(adminUserId: string): Promise<AdminAuth | null> {
|
|
228
|
+
return resolveActor(this.#store, adminUserId)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// -----------------------------------------------------------------------
|
|
232
|
+
// Internals
|
|
233
|
+
// -----------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
async #issueTokens(input: {
|
|
236
|
+
adminUserId: string
|
|
237
|
+
ip: string | null
|
|
238
|
+
userAgent: string | null
|
|
239
|
+
}): Promise<SessionTokens> {
|
|
240
|
+
const now = this.#now()
|
|
241
|
+
const refreshTokens = this.#store.refreshTokens
|
|
242
|
+
|
|
243
|
+
const accessToken = await this.#signAccessToken(input.adminUserId, now)
|
|
244
|
+
const accessExpiresAt = new Date(now.getTime() + this.#accessTtl * 1000)
|
|
245
|
+
|
|
246
|
+
const refreshPlain = generateOpaqueToken()
|
|
247
|
+
const refreshHash = hashToken(refreshPlain)
|
|
248
|
+
const refreshExpiresAt = new Date(now.getTime() + this.#refreshTtl * 1000)
|
|
249
|
+
await refreshTokens.issue({
|
|
250
|
+
id: uuidv7(),
|
|
251
|
+
admin_user_id: input.adminUserId,
|
|
252
|
+
token_hash: refreshHash,
|
|
253
|
+
expires_at: refreshExpiresAt,
|
|
254
|
+
user_agent: input.userAgent,
|
|
255
|
+
ip: input.ip,
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
accessToken,
|
|
260
|
+
refreshToken: refreshPlain,
|
|
261
|
+
accessTokenExpiresAt: accessExpiresAt,
|
|
262
|
+
refreshTokenExpiresAt: refreshExpiresAt,
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async #signAccessToken(adminUserId: string, now: Date): Promise<string> {
|
|
267
|
+
const iat = Math.floor(now.getTime() / 1000)
|
|
268
|
+
const exp = iat + this.#accessTtl
|
|
269
|
+
return new SignJWT({ typ: 'access' })
|
|
270
|
+
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
|
271
|
+
.setSubject(adminUserId)
|
|
272
|
+
.setIssuer(this.#issuer)
|
|
273
|
+
.setIssuedAt(iat)
|
|
274
|
+
.setExpirationTime(exp)
|
|
275
|
+
.setJti(randomUUID())
|
|
276
|
+
.sign(this.#signingKey)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// Utilities
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
/** 32 bytes of randomness, base64url-encoded. ~43 chars on the wire. */
|
|
285
|
+
function generateOpaqueToken(): string {
|
|
286
|
+
return randomBytes(32).toString('base64url')
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** SHA-256 hex digest of the raw refresh-token string. */
|
|
290
|
+
function hashToken(token: string): string {
|
|
291
|
+
return createHash('sha256').update(token).digest('hex')
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* A stable argon2id hash used only to equalise sign-in timing on the
|
|
296
|
+
* unknown-email code path. The plaintext here is arbitrary — we never
|
|
297
|
+
* succeed against it. This is pre-generated at module-load time so the
|
|
298
|
+
* first sign-in call doesn't pay the generation cost.
|
|
299
|
+
*/
|
|
300
|
+
const DUMMY_HASH_FOR_TIMING =
|
|
301
|
+
'$argon2id$v=19$m=19456,t=2,p=1$c2lkZS1jaGFubmVsLW1pdGlnYXRpb24$0Hqf2vQKZqSfZZ4nJRr7K5IOjn9ngjzaQjV+yTG6iNY'
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* Password hashing — argon2id via the vendored `@noble/hashes` copy at
|
|
11
|
+
* `../../vendor/noble-argon2/`. Pure-JS, runs anywhere with a modern JS
|
|
12
|
+
* runtime (Node, Workers, Deno, Bun, browsers).
|
|
13
|
+
*
|
|
14
|
+
* Stores the full PHC string (`$argon2id$v=19$m=…$…$…`) in the
|
|
15
|
+
* `byline_admin_users.password` column. That makes the algorithm and
|
|
16
|
+
* parameters self-describing, so upgrading params later (or migrating off
|
|
17
|
+
* argon2id entirely) is a straightforward re-hash on next successful sign-in.
|
|
18
|
+
*
|
|
19
|
+
* Defaults follow OWASP 2023 guidance for argon2id: memory 19 MiB,
|
|
20
|
+
* iterations 2, parallelism 1. These are reasonable for typical server
|
|
21
|
+
* hardware; tune if sign-in latency becomes a concern under load.
|
|
22
|
+
*
|
|
23
|
+
* Note: pure-JS argon2id is meaningfully slower than the previous
|
|
24
|
+
* `@node-rs/argon2` Rust binding (~50–150 ms vs ~10 ms at these params on
|
|
25
|
+
* modern server hardware). For interactive sign-in this is fine; for
|
|
26
|
+
* high-throughput auth services consider tuning `HASH_OPTIONS` or
|
|
27
|
+
* reintroducing a native binding behind a runtime-feature check.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { argon2idAsync } from '../../vendor/noble-argon2/argon2.js'
|
|
31
|
+
import { decodeArgon2idPhc, encodeArgon2idPhc, timingSafeEqual } from './phc.js'
|
|
32
|
+
|
|
33
|
+
/** Argon2id cost parameters. Matches the prior `@node-rs/argon2` defaults. */
|
|
34
|
+
const HASH_OPTIONS = {
|
|
35
|
+
/** Memory cost in KiB (19 MiB). */
|
|
36
|
+
memoryCost: 19456,
|
|
37
|
+
/** Iterations. */
|
|
38
|
+
timeCost: 2,
|
|
39
|
+
/** Parallelism (lanes). */
|
|
40
|
+
parallelism: 1,
|
|
41
|
+
/** Derived-key length in bytes — 32 matches the prior stored hashes. */
|
|
42
|
+
hashLength: 32,
|
|
43
|
+
/** Salt length in bytes — 16 matches the prior stored hashes. */
|
|
44
|
+
saltLength: 16,
|
|
45
|
+
} as const
|
|
46
|
+
|
|
47
|
+
/** Argon2 v1.3 (RFC 9106). */
|
|
48
|
+
const ARGON2_VERSION = 0x13
|
|
49
|
+
|
|
50
|
+
function randomSalt(length: number): Uint8Array {
|
|
51
|
+
return crypto.getRandomValues(new Uint8Array(length))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Hash a plaintext password. Returns a full PHC string. */
|
|
55
|
+
export async function hashPassword(plaintext: string): Promise<string> {
|
|
56
|
+
if (plaintext.length === 0) {
|
|
57
|
+
throw new Error('hashPassword: plaintext must be non-empty')
|
|
58
|
+
}
|
|
59
|
+
const salt = randomSalt(HASH_OPTIONS.saltLength)
|
|
60
|
+
const hash = await argon2idAsync(plaintext, salt, {
|
|
61
|
+
m: HASH_OPTIONS.memoryCost,
|
|
62
|
+
t: HASH_OPTIONS.timeCost,
|
|
63
|
+
p: HASH_OPTIONS.parallelism,
|
|
64
|
+
dkLen: HASH_OPTIONS.hashLength,
|
|
65
|
+
version: ARGON2_VERSION,
|
|
66
|
+
})
|
|
67
|
+
return encodeArgon2idPhc({
|
|
68
|
+
algorithm: 'argon2id',
|
|
69
|
+
version: ARGON2_VERSION,
|
|
70
|
+
memoryCost: HASH_OPTIONS.memoryCost,
|
|
71
|
+
timeCost: HASH_OPTIONS.timeCost,
|
|
72
|
+
parallelism: HASH_OPTIONS.parallelism,
|
|
73
|
+
salt,
|
|
74
|
+
hash,
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Verify a plaintext password against a stored PHC string. Returns `false`
|
|
80
|
+
* on mismatch — never throws for a normal mismatch. Re-throws on malformed
|
|
81
|
+
* hash strings or underlying library errors so those get surfaced.
|
|
82
|
+
*/
|
|
83
|
+
export async function verifyPassword(plaintext: string, phc: string): Promise<boolean> {
|
|
84
|
+
if (plaintext.length === 0 || phc.length === 0) return false
|
|
85
|
+
const decoded = decodeArgon2idPhc(phc)
|
|
86
|
+
const candidate = await argon2idAsync(plaintext, decoded.salt, {
|
|
87
|
+
m: decoded.memoryCost,
|
|
88
|
+
t: decoded.timeCost,
|
|
89
|
+
p: decoded.parallelism,
|
|
90
|
+
dkLen: decoded.hash.length,
|
|
91
|
+
version: decoded.version,
|
|
92
|
+
})
|
|
93
|
+
return timingSafeEqual(candidate, decoded.hash)
|
|
94
|
+
}
|