@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,50 @@
1
+ import type { FastifyInstance } from 'fastify'
2
+
3
+ /**
4
+ * Marks every route registered in `scope` as belonging to a part of the API, which is what an API token is checked
5
+ * against (`<part>:read` for GET, `<part>:write` otherwise). A route without a part is refused to every token.
6
+ */
7
+ export function belongsTo(scope: FastifyInstance, part: string): void {
8
+ scope.addHook('onRoute', (route) => { route.config = { ...route.config, tokenScope: part } })
9
+ }
10
+
11
+ /** Lets (or, with `false`, stops) the routes registered in `scope` accept an API token. Routes accept none by default. */
12
+ export function acceptApiTokens(scope: FastifyInstance, accept = true): void {
13
+ scope.addHook('onRoute', (route) => { route.config = { ...route.config, allowApiToken: accept } })
14
+ }
15
+
16
+ type RoutePlugin = (scope: FastifyInstance) => Promise<void> | void
17
+ type Guard = (...args: never[]) => unknown
18
+
19
+ /** A group of routes that share a guard and, usually, a part of the API tokens are checked against. */
20
+ export interface AdminRouteGroup {
21
+ /** The part of the API the routes belong to. Omit it for a group whose routes no token reaches. */
22
+ part?: string
23
+ /** The group refuses API tokens even though the rest of the API accepts them. */
24
+ noTokens?: boolean
25
+ /** Runs before every route of the group (the role the group needs). */
26
+ guard: Guard
27
+ /** The route plugins of the group with the prefix each is mounted at. */
28
+ routes: ReadonlyArray<readonly [plugin: RoutePlugin, options: { prefix: string }]>
29
+ }
30
+
31
+ /**
32
+ * Registers the admin API: the scope accepts API tokens and needs a signed-in caller (`authenticate`), then every group
33
+ * gets its own scope with its part, its guard and its routes. A token is checked against the part of the group a route
34
+ * is in; a group with `noTokens` is for what a leaked token must never reach.
35
+ */
36
+ export async function registerAdminApi(
37
+ adminApp: FastifyInstance,
38
+ options: { authenticate: Guard; groups: readonly AdminRouteGroup[] },
39
+ ): Promise<void> {
40
+ acceptApiTokens(adminApp)
41
+ adminApp.addHook('preHandler', options.authenticate as never)
42
+ for (const group of options.groups) {
43
+ await adminApp.register(async (scope) => {
44
+ if (group.noTokens) acceptApiTokens(scope, false)
45
+ if (group.part !== undefined) belongsTo(scope, group.part)
46
+ scope.addHook('preHandler', group.guard as never)
47
+ for (const [plugin, routeOptions] of group.routes) await scope.register(plugin, routeOptions)
48
+ })
49
+ }
50
+ }
@@ -0,0 +1,209 @@
1
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
2
+ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
3
+ import { and, eq, lt, ne } from 'drizzle-orm'
4
+ import { authSessions, users } from './tables.js'
5
+ import type { AuthOptions } from './context.js'
6
+ import type { ApiTokens } from './apiTokens.js'
7
+
8
+ const SESSION_SECONDS = 12 * 60 * 60
9
+
10
+ export type AuthMethod = 'password' | 'oidc'
11
+
12
+ export interface AuthIdentity {
13
+ userId: number
14
+ email: string
15
+ role: string
16
+ sessionId: string
17
+ mustChangePassword: boolean
18
+ twoFactorEnabled: boolean
19
+ authMethod: AuthMethod
20
+ /**
21
+ * Set when the request is authenticated by an API token instead of a session. A token belongs to no user: `userId`
22
+ * is 0, `email` is `token: <name>` (it names the token in the audit log) and `sessionId` is a synthetic
23
+ * `api-token:<id>`. A token has no role: `role` is a placeholder
24
+ * that lets it through the role checks, and what it may do is decided by `scopes`, checked for every request in
25
+ * authenticateOrReject.
26
+ */
27
+ apiToken?: { id: number; name: string; scopes: string[] }
28
+ }
29
+
30
+ export interface AuthenticateOptions {
31
+ /** API tokens are only accepted on routes that opt in; everywhere else they are refused. */
32
+ allowApiToken?: boolean
33
+ }
34
+
35
+ export const API_TOKEN_NOT_ACCEPTED = 'API token not accepted here'
36
+
37
+ interface SessionClaims {
38
+ userId: number
39
+ email: string
40
+ role: string
41
+ sessionId: string
42
+ }
43
+
44
+ export function createSessions(ctx: AuthOptions, apiTokens: ApiTokens) {
45
+ const { db, roles } = ctx
46
+ const hooks = ctx.hooks ?? {}
47
+ const SESSION_COOKIE = ctx.cookies.session
48
+ const CSRF_COOKIE = ctx.cookies.csrf
49
+ const { findUsableApiToken, isApiToken, parseScopes } = apiTokens
50
+
51
+ function cookieOptions(httpOnly: boolean) {
52
+ return {
53
+ path: '/',
54
+ httpOnly,
55
+ secure: ctx.secureCookies(),
56
+ sameSite: 'strict' as const,
57
+ maxAge: SESSION_SECONDS,
58
+ }
59
+ }
60
+
61
+ function hash(value: string): string {
62
+ return createHash('sha256').update(value).digest('hex')
63
+ }
64
+
65
+ function equalHash(left: string, right: string): boolean {
66
+ const a = Buffer.from(left)
67
+ const b = Buffer.from(right)
68
+ return a.length === b.length && timingSafeEqual(a, b)
69
+ }
70
+
71
+ async function createAuthSession(
72
+ app: FastifyInstance,
73
+ reply: FastifyReply,
74
+ user: { id: number; email: string; role: string; mustChangePassword: number; totpEnabled: number },
75
+ authMethod: AuthMethod = 'password',
76
+ ): Promise<AuthIdentity> {
77
+ const now = Date.now()
78
+ const sessionId = randomUUID()
79
+ const csrfToken = randomBytes(32).toString('base64url')
80
+ const expiresAt = now + SESSION_SECONDS * 1000
81
+ await db.delete(authSessions).where(lt(authSessions.expiresAt, now))
82
+ await db.insert(authSessions).values({
83
+ id: sessionId,
84
+ userId: user.id,
85
+ csrfTokenHash: hash(csrfToken),
86
+ createdAt: now,
87
+ lastSeenAt: now,
88
+ expiresAt,
89
+ authMethod,
90
+ verifiedAt: now,
91
+ })
92
+
93
+ const role = roles.normalize(user.role)
94
+ const token = app.jwt.sign({ userId: user.id, email: user.email, role, sessionId })
95
+ reply.setCookie(SESSION_COOKIE, token, cookieOptions(true))
96
+ reply.setCookie(CSRF_COOKIE, csrfToken, cookieOptions(false))
97
+ return {
98
+ userId: user.id,
99
+ email: user.email,
100
+ role,
101
+ sessionId,
102
+ mustChangePassword: mustChangePassword(user, authMethod),
103
+ twoFactorEnabled: !!user.totpEnabled,
104
+ authMethod,
105
+ }
106
+ }
107
+
108
+ /**
109
+ * A password session must replace a pending temporary password, so the one the administrator handed out stops
110
+ * working. An SSO session never has to: the SSO sign-in makes the temporary password unusable instead.
111
+ */
112
+ function mustChangePassword(user: { mustChangePassword: number }, authMethod: string): boolean {
113
+ return !!user.mustChangePassword && authMethod !== 'oidc'
114
+ }
115
+
116
+ function clearAuthCookies(reply: FastifyReply): void {
117
+ const base = { path: '/', secure: ctx.secureCookies(), sameSite: 'strict' as const }
118
+ reply.clearCookie(SESSION_COOKIE, { ...base, httpOnly: true })
119
+ reply.clearCookie(CSRF_COOKIE, { ...base, httpOnly: false })
120
+ }
121
+
122
+ function requestToken(req: FastifyRequest): string | null {
123
+ const authorization = req.headers.authorization
124
+ if (authorization?.startsWith('Bearer ')) return authorization.slice(7)
125
+ return req.cookies[SESSION_COOKIE] ?? null
126
+ }
127
+
128
+ async function authenticateApiToken(req: FastifyRequest, token: string): Promise<AuthIdentity> {
129
+ const found = await findUsableApiToken(token)
130
+ if (!found) throw new Error('Invalid API token')
131
+ const identity: AuthIdentity = {
132
+ userId: 0,
133
+ email: `token: ${found.name}`,
134
+ role: 'admin',
135
+ sessionId: `api-token:${found.id}`,
136
+ mustChangePassword: false,
137
+ twoFactorEnabled: false,
138
+ authMethod: 'password',
139
+ apiToken: { id: found.id, name: found.name, scopes: parseScopes(found.scopes) },
140
+ }
141
+ req.user = identity
142
+ return identity
143
+ }
144
+
145
+ async function authenticateRequest(req: FastifyRequest, options: AuthenticateOptions = {}): Promise<AuthIdentity> {
146
+ const token = requestToken(req)
147
+ if (!token) throw new Error('Missing session')
148
+ if (isApiToken(token)) {
149
+ if (!options.allowApiToken) throw new Error(API_TOKEN_NOT_ACCEPTED)
150
+ return authenticateApiToken(req, token)
151
+ }
152
+ const claims = req.server.jwt.verify<SessionClaims>(token)
153
+ if (!claims.sessionId || !Number.isInteger(claims.userId)) throw new Error('Invalid session')
154
+
155
+ const now = Date.now()
156
+ const session = (await db.select().from(authSessions).where(eq(authSessions.id, claims.sessionId)))[0]
157
+ if (!session || session.userId !== claims.userId || session.expiresAt <= now) {
158
+ if (session) await db.delete(authSessions).where(eq(authSessions.id, session.id))
159
+ throw new Error('Expired session')
160
+ }
161
+ const user = (await db.select().from(users).where(eq(users.id, claims.userId)))[0]
162
+ if (!user) throw new Error('Unknown user')
163
+
164
+ if (now - session.lastSeenAt >= 60_000) {
165
+ await db.update(authSessions).set({ lastSeenAt: now }).where(eq(authSessions.id, session.id))
166
+ }
167
+ const identity: AuthIdentity = {
168
+ userId: user.id,
169
+ email: user.email,
170
+ role: roles.normalize(user.role),
171
+ sessionId: session.id,
172
+ mustChangePassword: mustChangePassword(user, session.authMethod),
173
+ twoFactorEnabled: !!user.totpEnabled,
174
+ authMethod: session.authMethod === 'oidc' ? 'oidc' : 'password',
175
+ }
176
+ req.user = identity
177
+ return identity
178
+ }
179
+
180
+ async function verifyCsrf(req: FastifyRequest, identity: AuthIdentity): Promise<void> {
181
+ if (req.headers.authorization?.startsWith('Bearer ')) return
182
+ if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return
183
+ const cookie = req.cookies[CSRF_COOKIE]
184
+ const header = req.headers['x-csrf-token']
185
+ if (!cookie || typeof header !== 'string' || !equalHash(hash(cookie), hash(header))) {
186
+ throw new Error('Invalid CSRF token')
187
+ }
188
+ const session = (await db.select({ csrfTokenHash: authSessions.csrfTokenHash })
189
+ .from(authSessions).where(eq(authSessions.id, identity.sessionId)))[0]
190
+ if (!session || !equalHash(session.csrfTokenHash, hash(header))) throw new Error('Invalid CSRF token')
191
+ }
192
+
193
+ async function revokeSession(sessionId: string): Promise<void> {
194
+ await db.delete(authSessions).where(eq(authSessions.id, sessionId))
195
+ hooks.onSessionsRevoked?.({ sessionId })
196
+ }
197
+
198
+ async function revokeUserSessions(userId: number, exceptSessionId?: string): Promise<void> {
199
+ const condition = exceptSessionId
200
+ ? and(eq(authSessions.userId, userId), ne(authSessions.id, exceptSessionId))
201
+ : eq(authSessions.userId, userId)
202
+ await db.delete(authSessions).where(condition)
203
+ hooks.onSessionsRevoked?.({ userId, exceptSessionId })
204
+ }
205
+
206
+ return { createAuthSession, clearAuthCookies, authenticateRequest, verifyCsrf, revokeSession, revokeUserSessions }
207
+ }
208
+
209
+ export type Sessions = ReturnType<typeof createSessions>
@@ -0,0 +1,100 @@
1
+ import type { FastifyInstance, FastifyReply } from 'fastify'
2
+ import bcrypt from 'bcryptjs'
3
+ import { eq } from 'drizzle-orm'
4
+ import type { SqliteDatabase } from '@better-forge/db'
5
+ import { users } from './tables.js'
6
+
7
+ export interface SetupRoutesOptions<Identity extends { sessionId?: string }> {
8
+ db: SqliteDatabase
9
+ /** Runs `work` so that all of it is written, or none of it. */
10
+ withTransaction: <T>(work: () => Promise<T>) => Promise<T>
11
+ isSetupComplete: () => boolean
12
+ /** Records that setup is done. Called as the last step inside the transaction: if it throws, the administrator is not kept. */
13
+ writeSetupComplete: () => void
14
+ /** Takes the record back, when the transaction fails to commit after it was written. */
15
+ removeSetupMarker: () => void
16
+ /** Opens the database and brings its tables up to date; called once the request has been checked. */
17
+ prepareDatabase: () => void
18
+ /** Signs the new administrator in. */
19
+ createSession: (app: FastifyInstance, reply: FastifyReply, user: typeof users.$inferSelect) => Promise<Identity>
20
+ /** Rate limit of `POST /complete` (the config the rate limit plugin understands). */
21
+ rateLimit: Record<string, unknown>
22
+ /** Writes whatever else the application needs on first run, inside the same transaction as the administrator. */
23
+ onSetup?: () => Promise<void>
24
+ /** Starts the work that runs only once the application is set up. */
25
+ startBackgroundServices?: () => void
26
+ /** Lets setup run although accounts already exist, for an installation that is filled with accounts before its first run. Checked on each request. */
27
+ allowExistingUsers?: () => boolean
28
+ }
29
+
30
+ /**
31
+ * The first-run routes: whether the application still needs setup, and the one request that creates the administrator
32
+ * and finishes it. Setup is refused once it is done, and only one attempt runs at a time.
33
+ */
34
+ export function createSetupRoutes<Identity extends { sessionId?: string }>(options: SetupRoutesOptions<Identity>) {
35
+ const { db } = options
36
+ let inProgress = false
37
+
38
+ return async function setupRoutes(app: FastifyInstance, overrides: { startBackgroundServices?: () => void } = {}) {
39
+ app.get('/status', async () => ({ needsSetup: !options.isSetupComplete() }))
40
+
41
+ app.post<{ Body: { email: string; password: string } }>('/complete', {
42
+ config: { rateLimit: options.rateLimit },
43
+ }, async (req, reply) => {
44
+ if (options.isSetupComplete()) {
45
+ return reply.code(409).send({ error: 'Setup already completed' })
46
+ }
47
+
48
+ const { email, password } = req.body ?? {}
49
+ if (typeof email !== 'string' || typeof password !== 'string' || !email || !password) {
50
+ return reply.code(400).send({ error: 'Email and password are required' })
51
+ }
52
+ if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
53
+ return reply.code(400).send({ error: 'A valid email address is required' })
54
+ }
55
+ if (password.length < 8 || password.length > 128) {
56
+ return reply.code(400).send({ error: 'Password must be between 8 and 128 characters' })
57
+ }
58
+
59
+ if (inProgress) {
60
+ return reply.code(409).send({ error: 'Setup is already in progress' })
61
+ }
62
+
63
+ inProgress = true
64
+ try {
65
+ // Initialize DB and run migrations now that we have a confirmed DB type
66
+ options.prepareDatabase()
67
+
68
+ const hash = await bcrypt.hash(password, 10)
69
+ let setupMarkerWritten = false
70
+ const user = await options.withTransaction(async () => {
71
+ // The marker can be missing while the data is not (a lost volume, a restore). Accounts that already
72
+ // exist mean setup was done: never create or overwrite an administrator over them.
73
+ if (!options.allowExistingUsers?.() && (await db.select({ id: users.id }).from(users).limit(1)).length > 0) return null
74
+
75
+ await db.insert(users).values({ email, passwordHash: hash, role: 'admin', createdAt: Date.now() })
76
+
77
+ await options.onSetup?.()
78
+
79
+ const admin = (await db.select().from(users).where(eq(users.email, email)))[0]!
80
+ // Last step: if writing the marker fails the admin insert rolls back with it.
81
+ options.writeSetupComplete()
82
+ setupMarkerWritten = true
83
+ return admin
84
+ }).catch((error: unknown) => {
85
+ // COMMIT itself failed after the marker was written — undo it so setup can be retried.
86
+ if (setupMarkerWritten) options.removeSetupMarker()
87
+ throw error
88
+ })
89
+ if (!user) return reply.code(409).send({ error: 'Setup already completed' })
90
+ const startServices = overrides.startBackgroundServices ?? options.startBackgroundServices
91
+ startServices?.()
92
+ const identity = await options.createSession(app, reply, user)
93
+ const { sessionId: _sessionId, ...safeIdentity } = identity
94
+ return safeIdentity
95
+ } finally {
96
+ inProgress = false
97
+ }
98
+ })
99
+ }
100
+ }
@@ -0,0 +1,43 @@
1
+ import type { AuthOptions } from './context.js'
2
+
3
+ /** How the user tried to sign in, as recorded in `diff.method` of 'sign_in' audit entries. */
4
+ export type SignInMethod = 'password' | 'sso'
5
+
6
+ /** Why a password, or the authentication-code step of any sign-in, was refused. SSO refusals use the OIDC codes. */
7
+ export type CredentialDenialCode =
8
+ | 'password_login_disabled'
9
+ | 'no_matching_account'
10
+ | 'wrong_password'
11
+ | 'two_factor_expired'
12
+ | 'invalid_two_factor_code'
13
+
14
+ export interface SignInDenial {
15
+ code: string
16
+ reason: string
17
+ email?: string | undefined
18
+ }
19
+
20
+ export function createSignInAudit(ctx: AuthOptions) {
21
+ const { audit } = ctx
22
+
23
+ /** Records a successful sign-in; `twoFactor` says whether an authentication code was part of it. */
24
+ async function auditSignIn(user: { id: number; email: string }, method: SignInMethod, twoFactor: boolean): Promise<void> {
25
+ await audit.write({ userId: user.id, userEmail: user.email }, 'allow', 'sign_in', user.id, user.email, { method, twoFactor })
26
+ }
27
+
28
+ /**
29
+ * Records a refused sign-in. Nobody is signed in, so the actor is user 0 with the email that was entered or that
30
+ * the provider asserted, when there was one. The user only ever sees a generic message; this holds the reason.
31
+ */
32
+ async function auditSignInDenial(method: SignInMethod, denial: SignInDenial, extra: Record<string, unknown> = {}): Promise<void> {
33
+ await audit.write(
34
+ { userId: 0, userEmail: denial.email ?? `unknown (${method === 'sso' ? 'SSO' : 'password'})` },
35
+ 'deny', 'sign_in', null, denial.email ?? (method === 'sso' ? 'SSO sign-in' : 'Password sign-in'),
36
+ { method, code: denial.code, reason: denial.reason, ...extra },
37
+ )
38
+ }
39
+
40
+ return { auditSignIn, auditSignInDenial }
41
+ }
42
+
43
+ export type SignInAudit = ReturnType<typeof createSignInAudit>
package/src/tables.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
2
+
3
+ export const users = sqliteTable('users', {
4
+ id: integer('id').primaryKey({ autoIncrement: true }),
5
+ email: text('email').notNull().unique(),
6
+ passwordHash: text('password_hash').notNull(),
7
+ role: text('role').notNull().default('admin'),
8
+ createdAt: integer('created_at').notNull(),
9
+ // Additive columns (added via ALTER TABLE, must stay at end for sqlite-proxy position mapping)
10
+ mustChangePassword: integer('must_change_password').notNull().default(0),
11
+ totpSecret: text('totp_secret'),
12
+ totpEnabled: integer('totp_enabled').notNull().default(0),
13
+ totpRecoveryCodes: text('totp_recovery_codes'),
14
+ // SSO identity bound on the first OIDC sign-in; later sign-ins match on it instead of the email.
15
+ oidcIssuer: text('oidc_issuer'),
16
+ oidcSubject: text('oidc_subject'),
17
+ })
18
+
19
+ export const authSessions = sqliteTable('auth_sessions', {
20
+ id: text('id').primaryKey(),
21
+ userId: integer('user_id').notNull(),
22
+ csrfTokenHash: text('csrf_token_hash').notNull(),
23
+ createdAt: integer('created_at').notNull(),
24
+ lastSeenAt: integer('last_seen_at').notNull(),
25
+ expiresAt: integer('expires_at').notNull(),
26
+ // 'password' or 'oidc'. An SSO session never asks for the temporary password to be changed.
27
+ authMethod: text('auth_method').notNull().default('password'),
28
+ // When the user last proved who they are in this session: the sign-in, or a later SSO confirmation.
29
+ verifiedAt: integer('verified_at'),
30
+ })
31
+
32
+ /** Long-lived bearer credentials for scripts and CI. Only the SHA-256 of the token is stored; revoking deletes the row. */
33
+ export const apiTokens = sqliteTable('api_tokens', {
34
+ id: integer('id').primaryKey({ autoIncrement: true }),
35
+ name: text('name').notNull(),
36
+ tokenHash: text('token_hash').notNull().unique(),
37
+ /** Leading characters of the token, so it can be recognised in the list. */
38
+ prefix: text('prefix').notNull(),
39
+ createdAt: integer('created_at').notNull(),
40
+ expiresAt: integer('expires_at'),
41
+ lastUsedAt: integer('last_used_at'),
42
+ /** JSON list of what the token may do, e.g. `["notes:write","tags:read"]` (the permissions are defined by the application). */
43
+ scopes: text('scopes').notNull(),
44
+ })
45
+
46
+ export const oidcSettings = sqliteTable('oidc_settings', {
47
+ id: integer('id').primaryKey(),
48
+ enabled: integer('enabled').notNull().default(0),
49
+ issuer: text('issuer').notNull().default(''),
50
+ clientId: text('client_id').notNull().default(''),
51
+ /** Stored encrypted by the application's secret box; empty when no secret is set. */
52
+ clientSecret: text('client_secret').notNull().default(''),
53
+ scopes: text('scopes').notNull().default(''),
54
+ redirectUri: text('redirect_uri').notNull().default(''),
55
+ buttonLabel: text('button_label').notNull().default(''),
56
+ allowUnverifiedEmail: integer('allow_unverified_email').notNull().default(0),
57
+ disablePasswordLogin: integer('disable_password_login').notNull().default(0),
58
+ updatedAt: integer('updated_at').notNull(),
59
+ })
@@ -0,0 +1,42 @@
1
+ import type { TokenPreset, TokenResource } from '@better-forge/contracts'
2
+ import { VAULT_USE_SCOPE, normalizeScopes } from '@better-forge/contracts'
3
+
4
+ export interface TokenCatalogue {
5
+ /** Every permission a token can hold, in the order they are listed: `<part>:read`, `<part>:write`, then the extra ones. */
6
+ readonly scopes: readonly string[]
7
+ readonly resources: readonly TokenResource[]
8
+ readonly presets: readonly TokenPreset[]
9
+ /** A clean list of known permissions (reading added wherever writing is granted), or null if one is unknown or none is given. */
10
+ normalize(input: unknown): string[] | null
11
+ /** The permissions stored with a token. Anything that is not a known permission is dropped. */
12
+ parse(stored: string): string[]
13
+ }
14
+
15
+ /**
16
+ * The permissions an API token can be given. A part is a group of routes (`belongsTo(scope, part)` ties routes to
17
+ * it); `extraScopes` are single permissions that belong to no part, such as the one to use vault secrets.
18
+ */
19
+ export function createTokenCatalogue(options: {
20
+ resources: readonly TokenResource[]
21
+ extraScopes?: readonly string[]
22
+ presets?: (scopes: readonly string[]) => readonly TokenPreset[]
23
+ }): TokenCatalogue {
24
+ const scopes = [
25
+ ...options.resources.flatMap((resource) => resource.levels.map((level) => `${resource.key}:${level}`)),
26
+ ...(options.extraScopes ?? [VAULT_USE_SCOPE]),
27
+ ]
28
+ return {
29
+ scopes,
30
+ resources: options.resources,
31
+ presets: options.presets?.(scopes) ?? [],
32
+ normalize: (input) => normalizeScopes(input, scopes),
33
+ parse(stored) {
34
+ try {
35
+ const parsed: unknown = JSON.parse(stored)
36
+ return Array.isArray(parsed) ? parsed.filter((scope): scope is string => typeof scope === 'string' && scopes.includes(scope)) : []
37
+ } catch {
38
+ return []
39
+ }
40
+ },
41
+ }
42
+ }
package/src/totp.ts ADDED
@@ -0,0 +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
+ }
@@ -0,0 +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>