@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.
Files changed (177) hide show
  1. package/dist/abilities.js +5 -24
  2. package/dist/index.js +8 -30
  3. package/dist/lib/assert-admin-actor.js +13 -74
  4. package/dist/lib/create-command.js +6 -16
  5. package/dist/modules/admin-account/commands.js +35 -24
  6. package/dist/modules/admin-account/components/change-password.d.ts +8 -0
  7. package/dist/modules/admin-account/components/change-password.js +192 -0
  8. package/dist/modules/admin-account/components/change-password.module.js +8 -0
  9. package/dist/modules/admin-account/components/change-password_module.css +27 -0
  10. package/dist/modules/admin-account/components/container.d.ts +29 -0
  11. package/dist/modules/admin-account/components/container.js +298 -0
  12. package/dist/modules/admin-account/components/container.module.js +28 -0
  13. package/dist/modules/admin-account/components/container_module.css +106 -0
  14. package/dist/modules/admin-account/components/update.d.ts +8 -0
  15. package/dist/modules/admin-account/components/update.js +207 -0
  16. package/dist/modules/admin-account/components/update.module.js +8 -0
  17. package/dist/modules/admin-account/components/update_module.css +27 -0
  18. package/dist/modules/admin-account/errors.js +14 -45
  19. package/dist/modules/admin-account/index.js +4 -34
  20. package/dist/modules/admin-account/schemas.js +25 -59
  21. package/dist/modules/admin-account/service.js +56 -61
  22. package/dist/modules/admin-permissions/abilities.js +6 -24
  23. package/dist/modules/admin-permissions/commands.js +42 -28
  24. package/dist/modules/admin-permissions/components/inspector.d.ts +4 -0
  25. package/dist/modules/admin-permissions/components/inspector.js +284 -0
  26. package/dist/modules/admin-permissions/components/inspector.module.js +56 -0
  27. package/dist/modules/admin-permissions/components/inspector_module.css +238 -0
  28. package/dist/modules/admin-permissions/dto.js +3 -16
  29. package/dist/modules/admin-permissions/errors.js +14 -27
  30. package/dist/modules/admin-permissions/index.js +6 -26
  31. package/dist/modules/admin-permissions/repository.js +1 -8
  32. package/dist/modules/admin-permissions/schemas.js +33 -70
  33. package/dist/modules/admin-permissions/service.js +88 -92
  34. package/dist/modules/admin-roles/abilities.js +8 -30
  35. package/dist/modules/admin-roles/commands.js +89 -55
  36. package/dist/modules/admin-roles/components/create.d.ts +7 -0
  37. package/dist/modules/admin-roles/components/create.js +177 -0
  38. package/dist/modules/admin-roles/components/create.module.js +8 -0
  39. package/dist/modules/admin-roles/components/create_module.css +27 -0
  40. package/dist/modules/admin-roles/components/permissions.d.ts +10 -0
  41. package/dist/modules/admin-roles/components/permissions.js +303 -0
  42. package/dist/modules/admin-roles/components/permissions.module.js +44 -0
  43. package/dist/modules/admin-roles/components/permissions_module.css +192 -0
  44. package/dist/modules/admin-roles/components/update.d.ts +8 -0
  45. package/dist/modules/admin-roles/components/update.js +166 -0
  46. package/dist/modules/admin-roles/components/update.module.js +8 -0
  47. package/dist/modules/admin-roles/components/update_module.css +27 -0
  48. package/dist/modules/admin-roles/dto.js +3 -16
  49. package/dist/modules/admin-roles/errors.js +16 -40
  50. package/dist/modules/admin-roles/index.js +6 -26
  51. package/dist/modules/admin-roles/repository.js +1 -8
  52. package/dist/modules/admin-roles/schemas.js +41 -71
  53. package/dist/modules/admin-roles/service.js +79 -82
  54. package/dist/modules/admin-users/abilities.js +9 -38
  55. package/dist/modules/admin-users/commands.js +92 -50
  56. package/dist/modules/admin-users/components/create.d.ts +8 -0
  57. package/dist/modules/admin-users/components/create.js +268 -0
  58. package/dist/modules/admin-users/components/create.module.js +10 -0
  59. package/dist/modules/admin-users/components/create_module.css +45 -0
  60. package/dist/modules/admin-users/components/roles.d.ts +11 -0
  61. package/dist/modules/admin-users/components/roles.js +148 -0
  62. package/dist/modules/admin-users/components/roles.module.js +18 -0
  63. package/dist/modules/admin-users/components/roles_module.css +75 -0
  64. package/dist/modules/admin-users/components/set-password.d.ts +8 -0
  65. package/dist/modules/admin-users/components/set-password.js +170 -0
  66. package/dist/modules/admin-users/components/set-password.module.js +9 -0
  67. package/dist/modules/admin-users/components/set-password_module.css +31 -0
  68. package/dist/modules/admin-users/components/update.d.ts +8 -0
  69. package/dist/modules/admin-users/components/update.js +254 -0
  70. package/dist/modules/admin-users/components/update.module.js +9 -0
  71. package/dist/modules/admin-users/components/update_module.css +34 -0
  72. package/dist/modules/admin-users/dto.js +3 -18
  73. package/dist/modules/admin-users/errors.js +17 -43
  74. package/dist/modules/admin-users/index.js +7 -27
  75. package/dist/modules/admin-users/repository.js +1 -8
  76. package/dist/modules/admin-users/schemas.js +44 -75
  77. package/dist/modules/admin-users/seed-super-admin.js +9 -34
  78. package/dist/modules/admin-users/service.js +76 -91
  79. package/dist/modules/auth/components/sign-in-form.d.ts +12 -0
  80. package/dist/modules/auth/components/sign-in-form.js +115 -0
  81. package/dist/modules/auth/components/sign-in-form.module.js +12 -0
  82. package/dist/modules/auth/components/sign-in-form_module.css +41 -0
  83. package/dist/modules/auth/index.js +3 -24
  84. package/dist/modules/auth/jwt-session-provider.js +179 -149
  85. package/dist/modules/auth/password.js +11 -53
  86. package/dist/modules/auth/phc.js +21 -54
  87. package/dist/modules/auth/refresh-tokens-repository.js +1 -8
  88. package/dist/modules/auth/resolve-actor.js +6 -28
  89. package/dist/services/admin-services-context.d.ts +16 -0
  90. package/dist/services/admin-services-context.js +13 -0
  91. package/dist/services/admin-services-types.d.ts +129 -0
  92. package/dist/services/admin-services-types.js +1 -0
  93. package/dist/store.js +1 -8
  94. package/dist/vendor/noble-argon2/_blake.js +277 -45
  95. package/dist/vendor/noble-argon2/_md.js +81 -136
  96. package/dist/vendor/noble-argon2/_u64.js +65 -67
  97. package/dist/vendor/noble-argon2/argon2.js +181 -342
  98. package/dist/vendor/noble-argon2/blake2.js +252 -327
  99. package/dist/vendor/noble-argon2/utils.js +110 -490
  100. package/dist/vendor/noble-argon2/utils.js.LICENSE.txt +1 -0
  101. package/package.json +89 -10
  102. package/src/abilities.ts +32 -0
  103. package/src/declarations.d.ts +4 -0
  104. package/src/index.ts +39 -0
  105. package/src/lib/assert-admin-actor.ts +90 -0
  106. package/src/lib/create-command.ts +109 -0
  107. package/src/modules/admin-account/commands.ts +76 -0
  108. package/src/modules/admin-account/components/change-password.module.css +40 -0
  109. package/src/modules/admin-account/components/change-password.tsx +232 -0
  110. package/src/modules/admin-account/components/container.module.css +158 -0
  111. package/src/modules/admin-account/components/container.tsx +229 -0
  112. package/src/modules/admin-account/components/update.module.css +40 -0
  113. package/src/modules/admin-account/components/update.tsx +263 -0
  114. package/src/modules/admin-account/errors.ts +75 -0
  115. package/src/modules/admin-account/index.ts +60 -0
  116. package/src/modules/admin-account/schemas.ts +84 -0
  117. package/src/modules/admin-account/service.ts +92 -0
  118. package/src/modules/admin-permissions/abilities.ts +46 -0
  119. package/src/modules/admin-permissions/commands.ts +103 -0
  120. package/src/modules/admin-permissions/components/inspector.module.css +326 -0
  121. package/src/modules/admin-permissions/components/inspector.tsx +298 -0
  122. package/src/modules/admin-permissions/dto.ts +28 -0
  123. package/src/modules/admin-permissions/errors.ts +57 -0
  124. package/src/modules/admin-permissions/index.ts +72 -0
  125. package/src/modules/admin-permissions/repository.ts +49 -0
  126. package/src/modules/admin-permissions/schemas.ts +128 -0
  127. package/src/modules/admin-permissions/service.ts +137 -0
  128. package/src/modules/admin-roles/abilities.ts +62 -0
  129. package/src/modules/admin-roles/commands.ts +161 -0
  130. package/src/modules/admin-roles/components/create.module.css +40 -0
  131. package/src/modules/admin-roles/components/create.tsx +218 -0
  132. package/src/modules/admin-roles/components/permissions.module.css +279 -0
  133. package/src/modules/admin-roles/components/permissions.tsx +396 -0
  134. package/src/modules/admin-roles/components/update.module.css +40 -0
  135. package/src/modules/admin-roles/components/update.tsx +218 -0
  136. package/src/modules/admin-roles/dto.ts +30 -0
  137. package/src/modules/admin-roles/errors.ts +76 -0
  138. package/src/modules/admin-roles/index.ts +81 -0
  139. package/src/modules/admin-roles/repository.ts +96 -0
  140. package/src/modules/admin-roles/schemas.ts +139 -0
  141. package/src/modules/admin-roles/service.ts +136 -0
  142. package/src/modules/admin-users/abilities.ts +76 -0
  143. package/src/modules/admin-users/commands.ts +157 -0
  144. package/src/modules/admin-users/components/create.module.css +63 -0
  145. package/src/modules/admin-users/components/create.tsx +323 -0
  146. package/src/modules/admin-users/components/roles.module.css +119 -0
  147. package/src/modules/admin-users/components/roles.tsx +172 -0
  148. package/src/modules/admin-users/components/set-password.module.css +46 -0
  149. package/src/modules/admin-users/components/set-password.tsx +199 -0
  150. package/src/modules/admin-users/components/update.module.css +49 -0
  151. package/src/modules/admin-users/components/update.tsx +328 -0
  152. package/src/modules/admin-users/dto.ts +39 -0
  153. package/src/modules/admin-users/errors.ts +84 -0
  154. package/src/modules/admin-users/index.ts +91 -0
  155. package/src/modules/admin-users/repository.ts +161 -0
  156. package/src/modules/admin-users/schemas.ts +168 -0
  157. package/src/modules/admin-users/seed-super-admin.ts +102 -0
  158. package/src/modules/admin-users/service.ts +166 -0
  159. package/src/modules/auth/components/sign-in-form.module.css +62 -0
  160. package/src/modules/auth/components/sign-in-form.tsx +132 -0
  161. package/src/modules/auth/index.ts +31 -0
  162. package/src/modules/auth/jwt-session-provider.ts +301 -0
  163. package/src/modules/auth/password.ts +94 -0
  164. package/src/modules/auth/phc.ts +121 -0
  165. package/src/modules/auth/refresh-tokens-repository.ts +74 -0
  166. package/src/modules/auth/resolve-actor.ts +42 -0
  167. package/src/services/admin-services-context.tsx +52 -0
  168. package/src/services/admin-services-types.ts +177 -0
  169. package/src/store.ts +32 -0
  170. package/src/vendor/noble-argon2/LICENSE +21 -0
  171. package/src/vendor/noble-argon2/README.md +87 -0
  172. package/src/vendor/noble-argon2/_blake.ts +58 -0
  173. package/src/vendor/noble-argon2/_md.ts +223 -0
  174. package/src/vendor/noble-argon2/_u64.ts +118 -0
  175. package/src/vendor/noble-argon2/argon2.ts +668 -0
  176. package/src/vendor/noble-argon2/blake2.ts +583 -0
  177. 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
+ }