@better-forge/auth 0.1.0 → 0.1.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/src/totp.ts CHANGED
@@ -1,96 +1,96 @@
1
- import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
2
-
3
- const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
4
- const PERIOD_SECONDS = 30
5
- const DIGITS = 6
6
-
7
- function encodeBase32(input: Buffer): string {
8
- let bits = 0
9
- let value = 0
10
- let output = ''
11
- for (const byte of input) {
12
- value = (value << 8) | byte
13
- bits += 8
14
- while (bits >= 5) {
15
- output += ALPHABET[(value >>> (bits - 5)) & 31]
16
- bits -= 5
17
- }
18
- }
19
- if (bits > 0) output += ALPHABET[(value << (5 - bits)) & 31]
20
- return output
21
- }
22
-
23
- function decodeBase32(input: string): Buffer {
24
- let bits = 0
25
- let value = 0
26
- const bytes: number[] = []
27
- for (const character of input.toUpperCase().replace(/=|\s|-/g, '')) {
28
- const index = ALPHABET.indexOf(character)
29
- if (index < 0) throw new Error('Invalid base32 secret')
30
- value = (value << 5) | index
31
- bits += 5
32
- if (bits >= 8) {
33
- bytes.push((value >>> (bits - 8)) & 255)
34
- bits -= 8
35
- }
36
- }
37
- return Buffer.from(bytes)
38
- }
39
-
40
- function codeAt(secret: string, counter: number): string {
41
- const buffer = Buffer.alloc(8)
42
- buffer.writeBigUInt64BE(BigInt(counter))
43
- const digest = createHmac('sha1', decodeBase32(secret)).update(buffer).digest()
44
- const offset = digest[digest.length - 1]! & 0x0f
45
- const binary = ((digest[offset]! & 0x7f) << 24)
46
- | ((digest[offset + 1]! & 0xff) << 16)
47
- | ((digest[offset + 2]! & 0xff) << 8)
48
- | (digest[offset + 3]! & 0xff)
49
- return String(binary % (10 ** DIGITS)).padStart(DIGITS, '0')
50
- }
51
-
52
- function safeEqual(left: string, right: string): boolean {
53
- const a = Buffer.from(left)
54
- const b = Buffer.from(right)
55
- return a.length === b.length && timingSafeEqual(a, b)
56
- }
57
-
58
- export function generateTotpSecret(): string {
59
- return encodeBase32(randomBytes(20))
60
- }
61
-
62
- export function verifyTotp(secret: string, input: string, now = Date.now()): boolean {
63
- const normalized = input.replace(/\s/g, '')
64
- const counter = Math.floor(now / 1000 / PERIOD_SECONDS)
65
- return [-1, 0, 1].some((offset) => safeEqual(codeAt(secret, counter + offset), normalized))
66
- }
67
-
68
- export function generateTotpCode(secret: string, now = Date.now()): string {
69
- return codeAt(secret, Math.floor(now / 1000 / PERIOD_SECONDS))
70
- }
71
-
72
- /** The `otpauth://` URI an authenticator app scans; `issuer` is the name it shows next to the account. */
73
- export function totpUri(secret: string, email: string, issuer: string): string {
74
- const label = `${issuer}:${email}`
75
- return `otpauth://totp/${encodeURIComponent(label)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=${DIGITS}&period=${PERIOD_SECONDS}`
76
- }
77
-
78
- export function generateRecoveryCodes(): string[] {
79
- return Array.from({ length: 8 }, () => {
80
- const value = randomBytes(10).toString('hex').toUpperCase()
81
- return value.match(/.{1,5}/g)!.join('-')
82
- })
83
- }
84
-
85
- export function hashRecoveryCode(code: string): string {
86
- return createHash('sha256').update(code.replace(/\s|-/g, '').toUpperCase()).digest('hex')
87
- }
88
-
89
- export function consumeRecoveryCode(storedJson: string | null, input: string): { valid: boolean; remaining: string[] } {
90
- let stored: string[]
91
- try { stored = JSON.parse(storedJson ?? '[]') as string[] } catch { return { valid: false, remaining: [] } }
92
- const candidate = hashRecoveryCode(input)
93
- const index = stored.findIndex((hash) => safeEqual(hash, candidate))
94
- if (index < 0) return { valid: false, remaining: stored }
95
- return { valid: true, remaining: stored.filter((_, itemIndex) => itemIndex !== index) }
96
- }
1
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
2
+
3
+ const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
4
+ const PERIOD_SECONDS = 30
5
+ const DIGITS = 6
6
+
7
+ function encodeBase32(input: Buffer): string {
8
+ let bits = 0
9
+ let value = 0
10
+ let output = ''
11
+ for (const byte of input) {
12
+ value = (value << 8) | byte
13
+ bits += 8
14
+ while (bits >= 5) {
15
+ output += ALPHABET[(value >>> (bits - 5)) & 31]
16
+ bits -= 5
17
+ }
18
+ }
19
+ if (bits > 0) output += ALPHABET[(value << (5 - bits)) & 31]
20
+ return output
21
+ }
22
+
23
+ function decodeBase32(input: string): Buffer {
24
+ let bits = 0
25
+ let value = 0
26
+ const bytes: number[] = []
27
+ for (const character of input.toUpperCase().replace(/=|\s|-/g, '')) {
28
+ const index = ALPHABET.indexOf(character)
29
+ if (index < 0) throw new Error('Invalid base32 secret')
30
+ value = (value << 5) | index
31
+ bits += 5
32
+ if (bits >= 8) {
33
+ bytes.push((value >>> (bits - 8)) & 255)
34
+ bits -= 8
35
+ }
36
+ }
37
+ return Buffer.from(bytes)
38
+ }
39
+
40
+ function codeAt(secret: string, counter: number): string {
41
+ const buffer = Buffer.alloc(8)
42
+ buffer.writeBigUInt64BE(BigInt(counter))
43
+ const digest = createHmac('sha1', decodeBase32(secret)).update(buffer).digest()
44
+ const offset = digest[digest.length - 1]! & 0x0f
45
+ const binary = ((digest[offset]! & 0x7f) << 24)
46
+ | ((digest[offset + 1]! & 0xff) << 16)
47
+ | ((digest[offset + 2]! & 0xff) << 8)
48
+ | (digest[offset + 3]! & 0xff)
49
+ return String(binary % (10 ** DIGITS)).padStart(DIGITS, '0')
50
+ }
51
+
52
+ function safeEqual(left: string, right: string): boolean {
53
+ const a = Buffer.from(left)
54
+ const b = Buffer.from(right)
55
+ return a.length === b.length && timingSafeEqual(a, b)
56
+ }
57
+
58
+ export function generateTotpSecret(): string {
59
+ return encodeBase32(randomBytes(20))
60
+ }
61
+
62
+ export function verifyTotp(secret: string, input: string, now = Date.now()): boolean {
63
+ const normalized = input.replace(/\s/g, '')
64
+ const counter = Math.floor(now / 1000 / PERIOD_SECONDS)
65
+ return [-1, 0, 1].some((offset) => safeEqual(codeAt(secret, counter + offset), normalized))
66
+ }
67
+
68
+ export function generateTotpCode(secret: string, now = Date.now()): string {
69
+ return codeAt(secret, Math.floor(now / 1000 / PERIOD_SECONDS))
70
+ }
71
+
72
+ /** The `otpauth://` URI an authenticator app scans; `issuer` is the name it shows next to the account. */
73
+ export function totpUri(secret: string, email: string, issuer: string): string {
74
+ const label = `${issuer}:${email}`
75
+ return `otpauth://totp/${encodeURIComponent(label)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=${DIGITS}&period=${PERIOD_SECONDS}`
76
+ }
77
+
78
+ export function generateRecoveryCodes(): string[] {
79
+ return Array.from({ length: 8 }, () => {
80
+ const value = randomBytes(10).toString('hex').toUpperCase()
81
+ return value.match(/.{1,5}/g)!.join('-')
82
+ })
83
+ }
84
+
85
+ export function hashRecoveryCode(code: string): string {
86
+ return createHash('sha256').update(code.replace(/\s|-/g, '').toUpperCase()).digest('hex')
87
+ }
88
+
89
+ export function consumeRecoveryCode(storedJson: string | null, input: string): { valid: boolean; remaining: string[] } {
90
+ let stored: string[]
91
+ try { stored = JSON.parse(storedJson ?? '[]') as string[] } catch { return { valid: false, remaining: [] } }
92
+ const candidate = hashRecoveryCode(input)
93
+ const index = stored.findIndex((hash) => safeEqual(hash, candidate))
94
+ if (index < 0) return { valid: false, remaining: stored }
95
+ return { valid: true, remaining: stored.filter((_, itemIndex) => itemIndex !== index) }
96
+ }
package/src/twoFactor.ts CHANGED
@@ -1,49 +1,49 @@
1
- import { and, eq } from 'drizzle-orm'
2
- import { withImmediateTransaction } from '@better-forge/db'
3
- import { consumeRecoveryCode, verifyTotp } from './totp.js'
4
- import { authSessions, users } from './tables.js'
5
- import type { AuthOptions } from './context.js'
6
-
7
- type TwoFactorUser = Pick<typeof users.$inferSelect, 'id' | 'totpEnabled' | 'totpSecret' | 'totpRecoveryCodes'>
8
-
9
- export function createTwoFactor(ctx: AuthOptions) {
10
- const { db, sqlite, secretBox } = ctx
11
-
12
- /**
13
- * Verify a TOTP or consume a recovery code. Recovery-code persistence uses a
14
- * compare-and-swap update so the same code cannot win in concurrent requests.
15
- */
16
- async function verifySecondFactor(user: TwoFactorUser, code: string): Promise<boolean> {
17
- if (!user.totpEnabled || !user.totpSecret) return false
18
- if (verifyTotp(secretBox.decrypt(user.totpSecret), code)) return true
19
-
20
- const storedCodes = user.totpRecoveryCodes
21
- const recovery = consumeRecoveryCode(storedCodes, code)
22
- if (!storedCodes || !recovery.valid) return false
23
-
24
- const updated = await db.update(users)
25
- .set({ totpRecoveryCodes: JSON.stringify(recovery.remaining) })
26
- .where(and(eq(users.id, user.id), eq(users.totpRecoveryCodes, storedCodes)))
27
- .returning({ id: users.id })
28
- return updated.length === 1
29
- }
30
-
31
- /** Clear 2FA credentials and revoke every active session for the user. */
32
- async function resetTwoFactor(userId: number): Promise<boolean> {
33
- return withImmediateTransaction(sqlite, async () => {
34
- const updated = await db.update(users).set({
35
- totpSecret: null,
36
- totpEnabled: 0,
37
- totpRecoveryCodes: null,
38
- }).where(and(eq(users.id, userId), eq(users.totpEnabled, 1)))
39
- .returning({ id: users.id })
40
- if (updated.length !== 1) return false
41
- await db.delete(authSessions).where(eq(authSessions.userId, userId))
42
- return true
43
- })
44
- }
45
-
46
- return { verifySecondFactor, resetTwoFactor }
47
- }
48
-
49
- export type TwoFactor = ReturnType<typeof createTwoFactor>
1
+ import { and, eq } from 'drizzle-orm'
2
+ import { withImmediateTransaction } from '@better-forge/db'
3
+ import { consumeRecoveryCode, verifyTotp } from './totp.js'
4
+ import { authSessions, users } from './tables.js'
5
+ import type { AuthOptions } from './context.js'
6
+
7
+ type TwoFactorUser = Pick<typeof users.$inferSelect, 'id' | 'totpEnabled' | 'totpSecret' | 'totpRecoveryCodes'>
8
+
9
+ export function createTwoFactor(ctx: AuthOptions) {
10
+ const { db, sqlite, secretBox } = ctx
11
+
12
+ /**
13
+ * Verify a TOTP or consume a recovery code. Recovery-code persistence uses a
14
+ * compare-and-swap update so the same code cannot win in concurrent requests.
15
+ */
16
+ async function verifySecondFactor(user: TwoFactorUser, code: string): Promise<boolean> {
17
+ if (!user.totpEnabled || !user.totpSecret) return false
18
+ if (verifyTotp(secretBox.decrypt(user.totpSecret), code)) return true
19
+
20
+ const storedCodes = user.totpRecoveryCodes
21
+ const recovery = consumeRecoveryCode(storedCodes, code)
22
+ if (!storedCodes || !recovery.valid) return false
23
+
24
+ const updated = await db.update(users)
25
+ .set({ totpRecoveryCodes: JSON.stringify(recovery.remaining) })
26
+ .where(and(eq(users.id, user.id), eq(users.totpRecoveryCodes, storedCodes)))
27
+ .returning({ id: users.id })
28
+ return updated.length === 1
29
+ }
30
+
31
+ /** Clear 2FA credentials and revoke every active session for the user. */
32
+ async function resetTwoFactor(userId: number): Promise<boolean> {
33
+ return withImmediateTransaction(sqlite, async () => {
34
+ const updated = await db.update(users).set({
35
+ totpSecret: null,
36
+ totpEnabled: 0,
37
+ totpRecoveryCodes: null,
38
+ }).where(and(eq(users.id, userId), eq(users.totpEnabled, 1)))
39
+ .returning({ id: users.id })
40
+ if (updated.length !== 1) return false
41
+ await db.delete(authSessions).where(eq(authSessions.userId, userId))
42
+ return true
43
+ })
44
+ }
45
+
46
+ return { verifySecondFactor, resetTwoFactor }
47
+ }
48
+
49
+ export type TwoFactor = ReturnType<typeof createTwoFactor>
package/src/userRoutes.ts CHANGED
@@ -1,144 +1,144 @@
1
- import type { FastifyInstance } from 'fastify'
2
- import bcrypt from 'bcryptjs'
3
- import { randomInt } from 'node:crypto'
4
- import { eq } from 'drizzle-orm'
5
- import { users } from './tables.js'
6
- import { requestIdentity } from './middleware.js'
7
- import type { AuthOptions } from './context.js'
8
- import type { Sessions } from './sessions.js'
9
- import type { TwoFactor } from './twoFactor.js'
10
- import type { IdentityConfirmation } from './identityConfirmation.js'
11
-
12
- function generateTempPassword(): string {
13
- const chars = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789'
14
- let result = ''
15
- for (let i = 0; i < 12; i++) result += chars[randomInt(chars.length)]
16
- return result
17
- }
18
-
19
- export function createUserRoutes(ctx: AuthOptions, parts: { sessions: Sessions; twoFactor: TwoFactor; identityConfirmation: IdentityConfirmation }) {
20
- const { db, roles, audit } = ctx
21
- const { revokeUserSessions } = parts.sessions
22
- const { resetTwoFactor } = parts.twoFactor
23
- const { confirmIdentity } = parts.identityConfirmation
24
-
25
- // These routes act on accounts and sign-ins, so a hijacked session alone must not be enough.
26
- const sensitive = { config: { rateLimit: ctx.rateLimits.sensitiveAction } }
27
-
28
- async function userRoutes(app: FastifyInstance) {
29
- app.get('/', async () => {
30
- const all = await db.select({
31
- id: users.id,
32
- email: users.email,
33
- role: users.role,
34
- mustChangePassword: users.mustChangePassword,
35
- twoFactorEnabled: users.totpEnabled,
36
- oidcIssuer: users.oidcIssuer,
37
- createdAt: users.createdAt,
38
- }).from(users)
39
- return all.map(({ mustChangePassword, oidcIssuer, ...u }) => ({
40
- ...u,
41
- role: roles.normalize(u.role),
42
- ssoLinked: !!oidcIssuer,
43
- // An SSO sign-in revokes the temporary password, so the flag alone is accurate.
44
- pendingTemporaryPassword: !!mustChangePassword,
45
- }))
46
- })
47
-
48
- app.post<{ Body: { email: string; role?: string; currentPassword?: string } }>('/', sensitive, async (req, reply) => {
49
- const { email, role = roles.defaultForNewUsers } = req.body
50
- if (!email) return reply.code(400).send({ error: 'Email is required' })
51
- if (!roles.isValid(role)) return reply.code(400).send({ error: 'Invalid role' })
52
- const actor = requestIdentity(req)
53
- if (!await confirmIdentity(actor, req.body.currentPassword, reply)) return reply
54
- const existing = await db.select().from(users).where(eq(users.email, email))
55
- if (existing.length > 0) return reply.code(409).send({ error: 'User with this email already exists' })
56
- const temporaryPassword = generateTempPassword()
57
- const hash = await bcrypt.hash(temporaryPassword, 10)
58
- const result = await db.insert(users).values({
59
- email, passwordHash: hash, role, mustChangePassword: 1, createdAt: Date.now(),
60
- }).returning({ id: users.id, email: users.email, role: users.role, createdAt: users.createdAt })
61
- audit.write(audit.actor(actor), 'create', 'user', result[0]!.id, email,
62
- audit.snapshot({ email, role }))
63
- return { ...result[0], temporaryPassword }
64
- })
65
-
66
- app.patch<{ Params: { id: string }; Body: { role: string; currentPassword?: string } }>('/:id/role', sensitive, async (req, reply) => {
67
- const id = Number(req.params.id)
68
- const jwt = requestIdentity(req)
69
- if (jwt.userId === id) return reply.code(400).send({ error: 'Cannot change your own role' })
70
- if (!roles.isValid(req.body.role)) {
71
- return reply.code(400).send({ error: 'Invalid role' })
72
- }
73
- if (!await confirmIdentity(jwt, req.body.currentPassword, reply)) return reply
74
- const existing = (await db.select().from(users).where(eq(users.id, id)))[0]
75
- const result = await db.update(users).set({ role: req.body.role }).where(eq(users.id, id))
76
- .returning({ id: users.id, role: users.role })
77
- if (!result.length) return reply.code(404).send({ error: 'User not found' })
78
- if (existing) {
79
- await revokeUserSessions(id)
80
- const actor = requestIdentity(req)
81
- audit.write(audit.actor(actor), 'update', 'user', id, existing.email,
82
- { role: { from: existing.role, to: req.body.role } })
83
- }
84
- return result[0]
85
- })
86
-
87
- app.post<{ Params: { id: string }; Body: { currentPassword?: string } }>('/:id/reset-password', sensitive, async (req, reply) => {
88
- const id = Number(req.params.id)
89
- const actor = requestIdentity(req)
90
- if (!await confirmIdentity(actor, req.body?.currentPassword, reply)) return reply
91
- const user = (await db.select().from(users).where(eq(users.id, id)))[0]
92
- if (!user) return reply.code(404).send({ error: 'User not found' })
93
- const temporaryPassword = generateTempPassword()
94
- const hash = await bcrypt.hash(temporaryPassword, 10)
95
- await db.update(users).set({
96
- passwordHash: hash,
97
- mustChangePassword: 1,
98
- }).where(eq(users.id, id))
99
- await revokeUserSessions(id)
100
- audit.write(audit.actor(actor), 'update', 'user', id, user.email,
101
- { action: 'password_reset' })
102
- return { temporaryPassword }
103
- })
104
-
105
- app.post<{ Params: { id: string }; Body: { currentPassword: string } }>('/:id/reset-2fa', sensitive, async (req, reply) => {
106
- const id = Number(req.params.id)
107
- const actor = requestIdentity(req)
108
- if (!Number.isInteger(id)) return reply.code(400).send({ error: 'Invalid user ID' })
109
- if (actor.userId === id) {
110
- return reply.code(400).send({ error: 'Use account settings to disable your own two-factor authentication' })
111
- }
112
-
113
- if (!await confirmIdentity(actor, req.body?.currentPassword, reply)) return reply
114
-
115
- const target = (await db.select().from(users).where(eq(users.id, id)))[0]
116
- if (!target) return reply.code(404).send({ error: 'User not found' })
117
- if (!target.totpEnabled) return reply.code(409).send({ error: 'Two-factor authentication is not enabled for this user' })
118
- if (!await resetTwoFactor(id)) return reply.code(409).send({ error: 'Two-factor authentication is already disabled' })
119
-
120
- await audit.write(
121
- audit.actor(actor),
122
- 'update', 'user-security', id, target.email,
123
- { twoFactorReset: { from: true, to: false }, method: 'admin_recovery' },
124
- )
125
- return { twoFactorEnabled: false }
126
- })
127
-
128
- app.delete<{ Params: { id: string } }>('/:id', async (req, reply) => {
129
- const id = Number(req.params.id)
130
- const actor = requestIdentity(req)
131
- if (actor.userId === id) return reply.code(400).send({ error: 'Cannot delete your own account' })
132
- const existing = (await db.select().from(users).where(eq(users.id, id)))[0]
133
- await revokeUserSessions(id)
134
- await db.delete(users).where(eq(users.id, id))
135
- if (existing) {
136
- audit.write(audit.actor(actor), 'delete', 'user', id, existing.email,
137
- audit.snapshot({ email: existing.email, role: existing.role }))
138
- }
139
- return { success: true }
140
- })
141
- }
142
-
143
- return { userRoutes }
144
- }
1
+ import type { FastifyInstance } from 'fastify'
2
+ import bcrypt from 'bcryptjs'
3
+ import { randomInt } from 'node:crypto'
4
+ import { eq } from 'drizzle-orm'
5
+ import { users } from './tables.js'
6
+ import { requestIdentity } from './middleware.js'
7
+ import type { AuthOptions } from './context.js'
8
+ import type { Sessions } from './sessions.js'
9
+ import type { TwoFactor } from './twoFactor.js'
10
+ import type { IdentityConfirmation } from './identityConfirmation.js'
11
+
12
+ function generateTempPassword(): string {
13
+ const chars = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789'
14
+ let result = ''
15
+ for (let i = 0; i < 12; i++) result += chars[randomInt(chars.length)]
16
+ return result
17
+ }
18
+
19
+ export function createUserRoutes(ctx: AuthOptions, parts: { sessions: Sessions; twoFactor: TwoFactor; identityConfirmation: IdentityConfirmation }) {
20
+ const { db, roles, audit } = ctx
21
+ const { revokeUserSessions } = parts.sessions
22
+ const { resetTwoFactor } = parts.twoFactor
23
+ const { confirmIdentity } = parts.identityConfirmation
24
+
25
+ // These routes act on accounts and sign-ins, so a hijacked session alone must not be enough.
26
+ const sensitive = { config: { rateLimit: ctx.rateLimits.sensitiveAction } }
27
+
28
+ async function userRoutes(app: FastifyInstance) {
29
+ app.get('/', async () => {
30
+ const all = await db.select({
31
+ id: users.id,
32
+ email: users.email,
33
+ role: users.role,
34
+ mustChangePassword: users.mustChangePassword,
35
+ twoFactorEnabled: users.totpEnabled,
36
+ oidcIssuer: users.oidcIssuer,
37
+ createdAt: users.createdAt,
38
+ }).from(users)
39
+ return all.map(({ mustChangePassword, oidcIssuer, ...u }) => ({
40
+ ...u,
41
+ role: roles.normalize(u.role),
42
+ ssoLinked: !!oidcIssuer,
43
+ // An SSO sign-in revokes the temporary password, so the flag alone is accurate.
44
+ pendingTemporaryPassword: !!mustChangePassword,
45
+ }))
46
+ })
47
+
48
+ app.post<{ Body: { email: string; role?: string; currentPassword?: string } }>('/', sensitive, async (req, reply) => {
49
+ const { email, role = roles.defaultForNewUsers } = req.body
50
+ if (!email) return reply.code(400).send({ error: 'Email is required' })
51
+ if (!roles.isValid(role)) return reply.code(400).send({ error: 'Invalid role' })
52
+ const actor = requestIdentity(req)
53
+ if (!await confirmIdentity(actor, req.body.currentPassword, reply)) return reply
54
+ const existing = await db.select().from(users).where(eq(users.email, email))
55
+ if (existing.length > 0) return reply.code(409).send({ error: 'User with this email already exists' })
56
+ const temporaryPassword = generateTempPassword()
57
+ const hash = await bcrypt.hash(temporaryPassword, 10)
58
+ const result = await db.insert(users).values({
59
+ email, passwordHash: hash, role, mustChangePassword: 1, createdAt: Date.now(),
60
+ }).returning({ id: users.id, email: users.email, role: users.role, createdAt: users.createdAt })
61
+ audit.write(audit.actor(actor), 'create', 'user', result[0]!.id, email,
62
+ audit.snapshot({ email, role }))
63
+ return { ...result[0], temporaryPassword }
64
+ })
65
+
66
+ app.patch<{ Params: { id: string }; Body: { role: string; currentPassword?: string } }>('/:id/role', sensitive, async (req, reply) => {
67
+ const id = Number(req.params.id)
68
+ const jwt = requestIdentity(req)
69
+ if (jwt.userId === id) return reply.code(400).send({ error: 'Cannot change your own role' })
70
+ if (!roles.isValid(req.body.role)) {
71
+ return reply.code(400).send({ error: 'Invalid role' })
72
+ }
73
+ if (!await confirmIdentity(jwt, req.body.currentPassword, reply)) return reply
74
+ const existing = (await db.select().from(users).where(eq(users.id, id)))[0]
75
+ const result = await db.update(users).set({ role: req.body.role }).where(eq(users.id, id))
76
+ .returning({ id: users.id, role: users.role })
77
+ if (!result.length) return reply.code(404).send({ error: 'User not found' })
78
+ if (existing) {
79
+ await revokeUserSessions(id)
80
+ const actor = requestIdentity(req)
81
+ audit.write(audit.actor(actor), 'update', 'user', id, existing.email,
82
+ { role: { from: existing.role, to: req.body.role } })
83
+ }
84
+ return result[0]
85
+ })
86
+
87
+ app.post<{ Params: { id: string }; Body: { currentPassword?: string } }>('/:id/reset-password', sensitive, async (req, reply) => {
88
+ const id = Number(req.params.id)
89
+ const actor = requestIdentity(req)
90
+ if (!await confirmIdentity(actor, req.body?.currentPassword, reply)) return reply
91
+ const user = (await db.select().from(users).where(eq(users.id, id)))[0]
92
+ if (!user) return reply.code(404).send({ error: 'User not found' })
93
+ const temporaryPassword = generateTempPassword()
94
+ const hash = await bcrypt.hash(temporaryPassword, 10)
95
+ await db.update(users).set({
96
+ passwordHash: hash,
97
+ mustChangePassword: 1,
98
+ }).where(eq(users.id, id))
99
+ await revokeUserSessions(id)
100
+ audit.write(audit.actor(actor), 'update', 'user', id, user.email,
101
+ { action: 'password_reset' })
102
+ return { temporaryPassword }
103
+ })
104
+
105
+ app.post<{ Params: { id: string }; Body: { currentPassword: string } }>('/:id/reset-2fa', sensitive, async (req, reply) => {
106
+ const id = Number(req.params.id)
107
+ const actor = requestIdentity(req)
108
+ if (!Number.isInteger(id)) return reply.code(400).send({ error: 'Invalid user ID' })
109
+ if (actor.userId === id) {
110
+ return reply.code(400).send({ error: 'Use account settings to disable your own two-factor authentication' })
111
+ }
112
+
113
+ if (!await confirmIdentity(actor, req.body?.currentPassword, reply)) return reply
114
+
115
+ const target = (await db.select().from(users).where(eq(users.id, id)))[0]
116
+ if (!target) return reply.code(404).send({ error: 'User not found' })
117
+ if (!target.totpEnabled) return reply.code(409).send({ error: 'Two-factor authentication is not enabled for this user' })
118
+ if (!await resetTwoFactor(id)) return reply.code(409).send({ error: 'Two-factor authentication is already disabled' })
119
+
120
+ await audit.write(
121
+ audit.actor(actor),
122
+ 'update', 'user-security', id, target.email,
123
+ { twoFactorReset: { from: true, to: false }, method: 'admin_recovery' },
124
+ )
125
+ return { twoFactorEnabled: false }
126
+ })
127
+
128
+ app.delete<{ Params: { id: string } }>('/:id', async (req, reply) => {
129
+ const id = Number(req.params.id)
130
+ const actor = requestIdentity(req)
131
+ if (actor.userId === id) return reply.code(400).send({ error: 'Cannot delete your own account' })
132
+ const existing = (await db.select().from(users).where(eq(users.id, id)))[0]
133
+ await revokeUserSessions(id)
134
+ await db.delete(users).where(eq(users.id, id))
135
+ if (existing) {
136
+ audit.write(audit.actor(actor), 'delete', 'user', id, existing.email,
137
+ audit.snapshot({ email: existing.email, role: existing.role }))
138
+ }
139
+ return { success: true }
140
+ })
141
+ }
142
+
143
+ return { userRoutes }
144
+ }