@better-forge/auth 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +5 -3
  3. package/dist/apiTokenRoutes.d.ts +8 -0
  4. package/dist/apiTokenRoutes.d.ts.map +1 -0
  5. package/dist/apiTokenRoutes.js +67 -0
  6. package/dist/apiTokenRoutes.js.map +1 -0
  7. package/dist/apiTokens.d.ts +25 -0
  8. package/dist/apiTokens.d.ts.map +1 -0
  9. package/dist/apiTokens.js +50 -0
  10. package/dist/apiTokens.js.map +1 -0
  11. package/dist/authRoutes.d.ts +37 -0
  12. package/dist/authRoutes.d.ts.map +1 -0
  13. package/dist/authRoutes.js +403 -0
  14. package/dist/authRoutes.js.map +1 -0
  15. package/dist/context.d.ts +98 -0
  16. package/dist/context.d.ts.map +1 -0
  17. package/dist/context.js +2 -0
  18. package/dist/context.js.map +1 -0
  19. package/dist/createAuth.d.ts +146 -0
  20. package/dist/createAuth.d.ts.map +1 -0
  21. package/dist/createAuth.js +52 -0
  22. package/dist/createAuth.js.map +1 -0
  23. package/dist/identityConfirmation.d.ts +13 -0
  24. package/dist/identityConfirmation.d.ts.map +1 -0
  25. package/dist/identityConfirmation.js +37 -0
  26. package/dist/identityConfirmation.js.map +1 -0
  27. package/dist/index.d.ts +18 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +14 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/middleware.d.ts +41 -0
  32. package/dist/middleware.d.ts.map +1 -0
  33. package/dist/middleware.js +100 -0
  34. package/dist/middleware.js.map +1 -0
  35. package/dist/migrations.d.ts +4 -0
  36. package/dist/migrations.d.ts.map +1 -0
  37. package/dist/migrations.js +77 -0
  38. package/dist/migrations.js.map +1 -0
  39. package/dist/module.d.ts +8 -0
  40. package/dist/module.d.ts.map +1 -0
  41. package/dist/module.js +5 -0
  42. package/dist/module.js.map +1 -0
  43. package/dist/oidc.d.ts +85 -0
  44. package/dist/oidc.d.ts.map +1 -0
  45. package/dist/oidc.js +213 -0
  46. package/dist/oidc.js.map +1 -0
  47. package/dist/oidcConfig.d.ts +39 -0
  48. package/dist/oidcConfig.d.ts.map +1 -0
  49. package/dist/oidcConfig.js +72 -0
  50. package/dist/oidcConfig.js.map +1 -0
  51. package/dist/oidcSettings.d.ts +15 -0
  52. package/dist/oidcSettings.d.ts.map +1 -0
  53. package/dist/oidcSettings.js +51 -0
  54. package/dist/oidcSettings.js.map +1 -0
  55. package/dist/oidcSettingsRoutes.d.ts +19 -0
  56. package/dist/oidcSettingsRoutes.d.ts.map +1 -0
  57. package/dist/oidcSettingsRoutes.js +133 -0
  58. package/dist/oidcSettingsRoutes.js.map +1 -0
  59. package/dist/roles.d.ts +25 -0
  60. package/dist/roles.d.ts.map +1 -0
  61. package/dist/roles.js +44 -0
  62. package/dist/roles.js.map +1 -0
  63. package/dist/routeScope.d.ts +34 -0
  64. package/dist/routeScope.d.ts.map +1 -0
  65. package/dist/routeScope.js +32 -0
  66. package/dist/routeScope.js.map +1 -0
  67. package/dist/sessions.d.ts +46 -0
  68. package/dist/sessions.d.ts.map +1 -0
  69. package/dist/sessions.js +159 -0
  70. package/dist/sessions.js.map +1 -0
  71. package/dist/setupRoutes.d.ts +37 -0
  72. package/dist/setupRoutes.d.ts.map +1 -0
  73. package/dist/setupRoutes.js +70 -0
  74. package/dist/setupRoutes.js.map +1 -0
  75. package/dist/signInAudit.d.ts +19 -0
  76. package/dist/signInAudit.d.ts.map +1 -0
  77. package/dist/signInAudit.js +16 -0
  78. package/dist/signInAudit.js.map +1 -0
  79. package/dist/tables.d.ts +716 -0
  80. package/dist/tables.d.ts.map +1 -0
  81. package/dist/tables.js +56 -0
  82. package/dist/tables.js.map +1 -0
  83. package/dist/tokenCatalogue.d.ts +21 -0
  84. package/dist/tokenCatalogue.d.ts.map +1 -0
  85. package/dist/tokenCatalogue.js +27 -0
  86. package/dist/tokenCatalogue.js.map +1 -0
  87. package/dist/totp.d.ts +12 -0
  88. package/dist/totp.d.ts.map +1 -0
  89. package/dist/totp.js +93 -0
  90. package/dist/totp.js.map +1 -0
  91. package/dist/twoFactor.d.ts +10 -0
  92. package/dist/twoFactor.d.ts.map +1 -0
  93. package/dist/twoFactor.js +43 -0
  94. package/dist/twoFactor.js.map +1 -0
  95. package/dist/userRoutes.d.ts +13 -0
  96. package/dist/userRoutes.d.ts.map +1 -0
  97. package/dist/userRoutes.js +135 -0
  98. package/dist/userRoutes.js.map +1 -0
  99. package/package.json +68 -6
  100. package/src/apiTokenRoutes.ts +82 -0
  101. package/src/apiTokens.ts +62 -0
  102. package/src/authRoutes.ts +452 -0
  103. package/src/context.ts +85 -0
  104. package/src/createAuth.ts +58 -0
  105. package/src/identityConfirmation.ts +50 -0
  106. package/src/index.ts +17 -0
  107. package/src/middleware.ts +123 -0
  108. package/src/migrations.ts +77 -0
  109. package/src/module.ts +6 -0
  110. package/src/oidc.ts +291 -0
  111. package/src/oidcConfig.ts +96 -0
  112. package/src/oidcSettings.ts +60 -0
  113. package/src/oidcSettingsRoutes.ts +165 -0
  114. package/src/roles.ts +60 -0
  115. package/src/routeScope.ts +50 -0
  116. package/src/sessions.ts +209 -0
  117. package/src/setupRoutes.ts +100 -0
  118. package/src/signInAudit.ts +43 -0
  119. package/src/tables.ts +59 -0
  120. package/src/tokenCatalogue.ts +42 -0
  121. package/src/totp.ts +96 -0
  122. package/src/twoFactor.ts +49 -0
  123. package/src/userRoutes.ts +144 -0
@@ -0,0 +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
+ }