@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
package/src/context.ts ADDED
@@ -0,0 +1,85 @@
1
+ import type { DatabaseSync } from 'node:sqlite'
2
+ // Loaded for the request and reply properties they add (`cookies`, `jwt`, `user`, route `rateLimit` config).
3
+ import type {} from '@fastify/cookie'
4
+ import type {} from '@fastify/jwt'
5
+ import type {} from '@fastify/rate-limit'
6
+ import type { Audit } from '@better-forge/audit'
7
+ import type { SqliteDatabase } from '@better-forge/db'
8
+ import type { RoleRegistry } from './roles.js'
9
+ import type { TokenCatalogue } from './tokenCatalogue.js'
10
+
11
+ /** Which sessions were ended, so the application can close what they had open (for example live connections). */
12
+ export type SessionsRevoked =
13
+ | { sessionId: string }
14
+ | { userId: number; exceptSessionId?: string | undefined }
15
+
16
+ export interface UnknownSsoUser {
17
+ email: string
18
+ subject: string
19
+ issuer: string
20
+ }
21
+
22
+ export type SignInOutcome = { role: string } | { error: string } | 'two-factor'
23
+
24
+ export interface AuthHooks {
25
+ /** Called after sessions were deleted, whatever the reason (sign-out, password reset, role change, ...). */
26
+ onSessionsRevoked?: (match: SessionsRevoked) => void
27
+ /**
28
+ * An SSO sign-in succeeded at the provider but no account matches. Return the role of an account to create for
29
+ * this person, or null to refuse. Only called when the provider's identity is verified; without this hook nobody
30
+ * is created.
31
+ */
32
+ resolveUnknownSsoUser?: (user: UnknownSsoUser) => Promise<{ role: string } | null>
33
+ /**
34
+ * Where a sign-in goes next, for one that started with a `returnTo` target the application listed in
35
+ * `returnTargets`, or for a role that belongs somewhere else than `urls.home`. Return undefined for the default.
36
+ */
37
+ signInRedirect?: (flow: { returnTo?: string | undefined }, outcome: SignInOutcome) => string | undefined
38
+ }
39
+
40
+ /** A `@fastify/rate-limit` route configuration. Passed through untouched, so a group id can be shared with other routes. */
41
+ export interface RateLimitConfig {
42
+ groupId?: string
43
+ max: number
44
+ timeWindow: string | number
45
+ }
46
+
47
+ /**
48
+ * Everything the auth module needs from the application. All of it is passed in: the module reads no globals, so
49
+ * two applications (or two tests) can each have their own.
50
+ */
51
+ export interface AuthOptions {
52
+ /** Database handle and the raw connection (for transactions). Both are used lazily, so they may be opened after this is created. */
53
+ db: SqliteDatabase
54
+ sqlite: DatabaseSync
55
+ audit: Pick<Audit, 'write' | 'actor' | 'diffObjects' | 'snapshot'>
56
+ roles: RoleRegistry
57
+ tokens: TokenCatalogue
58
+ /** Encrypts and decrypts secrets the module stores (the SSO client secret, authenticator secrets). */
59
+ secretBox: { encrypt(plain: string): string; decrypt(stored: string): string }
60
+ cookies: { session: string; csrf: string; oidcFlow: string; twoFactorChallenge: string }
61
+ /** Prefix that tells an API token from a session token in a Bearer header. */
62
+ apiTokenPrefix: string
63
+ /** Name an authenticator app shows next to the account. */
64
+ totpIssuer: string
65
+ /** Where the sign-in routes are mounted. The cookie paths and the SSO callback derive from it. */
66
+ basePath: string
67
+ urls: {
68
+ /** Where a failed sign-in sends the browser, e.g. `/admin/login`. */
69
+ signIn: string
70
+ /** Where a finished sign-in goes by default. */
71
+ home: string
72
+ /** The page that closes the SSO confirmation popup. */
73
+ ssoConfirm: string
74
+ /** The page that shows the result of an SSO test. */
75
+ ssoTest: string
76
+ }
77
+ /** The address the application is reached at, from the environment; '' when unknown. */
78
+ publicUrl: (env: NodeJS.ProcessEnv) => string
79
+ /** Whether cookies are marked Secure. Evaluated on every response. */
80
+ secureCookies: () => boolean
81
+ rateLimits: { signIn: RateLimitConfig; sensitiveAction: RateLimitConfig }
82
+ /** `returnTo` values a sign-in may start with; the application's `signInRedirect` hook turns them into a destination. */
83
+ returnTargets?: readonly string[]
84
+ hooks?: AuthHooks
85
+ }
@@ -0,0 +1,58 @@
1
+ import type { AuthOptions } from './context.js'
2
+ import { createApiTokenRoutes } from './apiTokenRoutes.js'
3
+ import { createApiTokens } from './apiTokens.js'
4
+ import { createIdentityConfirmation } from './identityConfirmation.js'
5
+ import { createMiddleware } from './middleware.js'
6
+ import { createOidc } from './oidc.js'
7
+ import { createOidcConfigResolver } from './oidcConfig.js'
8
+ import { createOidcSettings } from './oidcSettings.js'
9
+ import { createOidcSettingsRoutes } from './oidcSettingsRoutes.js'
10
+ import { createSessions } from './sessions.js'
11
+ import { createSignInAudit } from './signInAudit.js'
12
+ import { createSignInRoutes } from './authRoutes.js'
13
+ import { createTwoFactor } from './twoFactor.js'
14
+ import { createUserRoutes } from './userRoutes.js'
15
+
16
+ /**
17
+ * Accounts, sessions, two-factor sign-in, single sign-on, API tokens and the routes that manage them. Everything is
18
+ * built from `options` and kept in the returned closures; nothing is shared between two calls.
19
+ */
20
+ export function createAuth(options: AuthOptions) {
21
+ options.roles.freeze()
22
+
23
+ const apiTokens = createApiTokens(options)
24
+ const sessions = createSessions(options, apiTokens)
25
+ const middleware = createMiddleware(sessions, apiTokens)
26
+ const identityConfirmation = createIdentityConfirmation(options)
27
+ const twoFactor = createTwoFactor(options)
28
+ const signInAudit = createSignInAudit(options)
29
+ const oidcConfig = createOidcConfigResolver({ publicUrl: options.publicUrl, callbackPath: `${options.basePath}/oidc/callback` })
30
+ const oidcSettings = createOidcSettings(options, oidcConfig)
31
+ const oidc = createOidc(options, signInAudit)
32
+
33
+ const signIn = createSignInRoutes(options, { sessions, middleware, twoFactor, identityConfirmation, signInAudit, oidcSettings, oidc })
34
+ const users = createUserRoutes(options, { sessions, twoFactor, identityConfirmation })
35
+ const oidcSettingsRoutes = createOidcSettingsRoutes(options, { identityConfirmation, oidc, oidcSettings, resolver: oidcConfig, startOidcFlow: signIn.startOidcFlow })
36
+ const tokenRoutes = createApiTokenRoutes(options, apiTokens, identityConfirmation)
37
+
38
+ return {
39
+ sessions,
40
+ middleware,
41
+ identityConfirmation,
42
+ twoFactor,
43
+ signInAudit,
44
+ oidcConfig,
45
+ oidcSettings,
46
+ oidc,
47
+ apiTokens,
48
+ /** Plain plugins (not wrapped for encapsulation), so the hooks of the scope they are registered in apply to them. */
49
+ routes: {
50
+ signIn: signIn.authRoutes,
51
+ users: users.userRoutes,
52
+ oidcSettings: oidcSettingsRoutes.oidcSettingsRoutes,
53
+ apiTokens: tokenRoutes.apiTokenRoutes,
54
+ },
55
+ }
56
+ }
57
+
58
+ export type Auth = ReturnType<typeof createAuth>
@@ -0,0 +1,50 @@
1
+ import bcrypt from 'bcryptjs'
2
+ import { and, eq, gt } from 'drizzle-orm'
3
+ import type { FastifyReply } from 'fastify'
4
+ import { authSessions, users } from './tables.js'
5
+ import type { AuthOptions } from './context.js'
6
+ import type { AuthIdentity } from './sessions.js'
7
+
8
+ /** Error code telling the admin UI to confirm the user through the identity provider, then retry. */
9
+ export const SSO_CONFIRMATION_REQUIRED = 'SSO_CONFIRMATION_REQUIRED'
10
+
11
+ /** How long an SSO sign-in or SSO confirmation covers sensitive actions in that session. */
12
+ export const SSO_CONFIRMATION_WINDOW_MS = 10 * 60 * 1000
13
+
14
+ export function createIdentityConfirmation(ctx: AuthOptions) {
15
+ const { db } = ctx
16
+
17
+ /**
18
+ * Sensitive actions are confirmed the way the session signed in: a password session with the current
19
+ * password, an SSO session with a recent sign-in at the identity provider (the sign-in itself, or a
20
+ * confirmation popup started from the UI). Returns true, or sends the refusal and returns false.
21
+ */
22
+ async function confirmIdentity(identity: AuthIdentity, currentPassword: unknown, reply: FastifyReply): Promise<boolean> {
23
+ if (identity.authMethod === 'oidc') {
24
+ const session = (await db.select().from(authSessions).where(eq(authSessions.id, identity.sessionId)))[0]
25
+ const verifiedAt = session?.verifiedAt ?? session?.createdAt ?? 0
26
+ if (Date.now() - verifiedAt <= SSO_CONFIRMATION_WINDOW_MS) return true
27
+ await reply.code(403).send({ error: 'Confirm your identity with single sign-on to continue', code: SSO_CONFIRMATION_REQUIRED })
28
+ return false
29
+ }
30
+ const user = (await db.select().from(users).where(eq(users.id, identity.userId)))[0]
31
+ if (user && typeof currentPassword === 'string' && currentPassword && await bcrypt.compare(currentPassword, user.passwordHash)) return true
32
+ await reply.code(400).send({ error: 'Current password is incorrect' })
33
+ return false
34
+ }
35
+
36
+ /** Records a successful SSO confirmation on a live SSO session of that user. False when there is no such session. */
37
+ async function markSessionVerified(sessionId: string, userId: number): Promise<boolean> {
38
+ const updated = await db.update(authSessions).set({ verifiedAt: Date.now() }).where(and(
39
+ eq(authSessions.id, sessionId),
40
+ eq(authSessions.userId, userId),
41
+ eq(authSessions.authMethod, 'oidc'),
42
+ gt(authSessions.expiresAt, Date.now()),
43
+ )).returning({ id: authSessions.id })
44
+ return updated.length > 0
45
+ }
46
+
47
+ return { confirmIdentity, markSessionVerified }
48
+ }
49
+
50
+ export type IdentityConfirmation = ReturnType<typeof createIdentityConfirmation>
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ export { createAuth, type Auth } from './createAuth.js'
2
+ export { authDbModule } from './module.js'
3
+ export { apiTokens, authSessions, oidcSettings, users } from './tables.js'
4
+ export { acceptApiTokens, belongsTo, registerAdminApi, type AdminRouteGroup } from './routeScope.js'
5
+ export { createSetupRoutes, type SetupRoutesOptions } from './setupRoutes.js'
6
+ export { ADMIN_ROLE, createRoleRegistry, type RoleRegistry } from './roles.js'
7
+ export { createTokenCatalogue, type TokenCatalogue } from './tokenCatalogue.js'
8
+ export type { AuthHooks, AuthOptions, RateLimitConfig, SessionsRevoked, SignInOutcome, UnknownSsoUser } from './context.js'
9
+ export { ALLOW_PENDING_PASSWORD_CHANGE, PASSWORD_CHANGE_REQUIRED, requestIdentity } from './middleware.js'
10
+ export { API_TOKEN_NOT_ACCEPTED, type AuthenticateOptions, type AuthIdentity, type AuthMethod } from './sessions.js'
11
+ export { SSO_CONFIRMATION_REQUIRED, SSO_CONFIRMATION_WINDOW_MS } from './identityConfirmation.js'
12
+ export { checkEmailVerified, OIDC_CONFIRMATION_MAX_AGE_SECONDS } from './oidc.js'
13
+ export type { OidcDecision, OidcDenial, OidcDenialCode, OidcFlowState, OidcTestResult, SsoProvisioning } from './oidc.js'
14
+ export type { OidcSettingsRow } from './oidcSettings.js'
15
+ export { createOidcConfigResolver, DEFAULT_OIDC_BUTTON_LABEL, DEFAULT_OIDC_SCOPES, isHttpUrl, passwordLoginEnabled, type OidcConfig, type OidcSettingsInput } from './oidcConfig.js'
16
+ export { consumeRecoveryCode, generateRecoveryCodes, generateTotpCode, generateTotpSecret, hashRecoveryCode, totpUri, verifyTotp } from './totp.js'
17
+ export type { CredentialDenialCode, SignInDenial, SignInMethod } from './signInAudit.js'
@@ -0,0 +1,123 @@
1
+ import type { FastifyReply, FastifyRequest } from 'fastify'
2
+ import { requiredScope, tokenAllows } from '@better-forge/contracts'
3
+ import { API_TOKEN_NOT_ACCEPTED, type AuthIdentity, type Sessions } from './sessions.js'
4
+ import type { ApiTokens } from './apiTokens.js'
5
+
6
+ declare module 'fastify' {
7
+ interface FastifyContextConfig {
8
+ allowPendingPasswordChange?: boolean
9
+ /** Routes that accept an API token. Everything else answers 403 to one, so tokens never reach account security. */
10
+ allowApiToken?: boolean
11
+ /**
12
+ * The part of the admin API a route belongs to (a key of TOKEN_RESOURCES, or `vault`). A token needs `<part>:read`
13
+ * for GET and `<part>:write` for anything else. `custom` means the handler checks the token itself. A route a
14
+ * token may reach but that names no part is refused: tokens fail closed.
15
+ */
16
+ tokenScope?: string
17
+ }
18
+ }
19
+
20
+ export const PASSWORD_CHANGE_REQUIRED = 'PASSWORD_CHANGE_REQUIRED'
21
+
22
+ /**
23
+ * Route config for the few routes a user with a pending forced password change may still call
24
+ * (session lookup, change-password, logout). Every other authenticated route answers 403 until
25
+ * the password is changed, so the requirement is enforced server-side, not only by the admin UI.
26
+ */
27
+ export const ALLOW_PENDING_PASSWORD_CHANGE = { allowPendingPasswordChange: true } as const
28
+
29
+ function existingIdentity(req: FastifyRequest): AuthIdentity | null {
30
+ const value = req.user as Partial<AuthIdentity> | undefined
31
+ return value?.sessionId && typeof value.userId === 'number' ? value as AuthIdentity : null
32
+ }
33
+
34
+ /**
35
+ * The identity attached by requireAuth/requireRole. Only call from handlers behind one of them.
36
+ */
37
+ export function requestIdentity(req: FastifyRequest): AuthIdentity {
38
+ const value = req.user as Partial<AuthIdentity> | undefined
39
+ if (typeof value?.userId !== 'number') throw new Error('requestIdentity called on an unauthenticated request')
40
+ return value as AuthIdentity
41
+ }
42
+
43
+ function passwordChangeBlocks(req: FastifyRequest, identity: AuthIdentity): boolean {
44
+ if (!identity.mustChangePassword) return false
45
+ return req.routeOptions.config?.allowPendingPasswordChange !== true
46
+ }
47
+
48
+ function sendPasswordChangeRequired(reply: FastifyReply) {
49
+ return reply.code(403).send({ error: 'Password change required', code: PASSWORD_CHANGE_REQUIRED })
50
+ }
51
+
52
+ export function createMiddleware(sessions: Sessions, apiTokens: ApiTokens) {
53
+ const { authenticateRequest, verifyCsrf } = sessions
54
+ const { consumeApiTokenBudget } = apiTokens
55
+
56
+ /**
57
+ * Authenticates the request and checks its CSRF token. Answers 401 (with `unauthorized` as the body) or 403
58
+ * itself and returns null when either fails, or when the user still has to replace a temporary password.
59
+ * A hook that gets null must return the reply.
60
+ */
61
+ async function authenticateOrReject(
62
+ req: FastifyRequest,
63
+ reply: FastifyReply,
64
+ unauthorized: Record<string, string> = { error: 'Unauthorized' },
65
+ ): Promise<AuthIdentity | null> {
66
+ const allowApiToken = req.routeOptions.config?.allowApiToken === true
67
+ const existing = existingIdentity(req)
68
+ let identity: AuthIdentity
69
+ try {
70
+ identity = existing ?? await authenticateRequest(req, { allowApiToken })
71
+ if (identity.apiToken && !allowApiToken) throw new Error(API_TOKEN_NOT_ACCEPTED)
72
+ await verifyCsrf(req, identity)
73
+ } catch (error) {
74
+ const message = error instanceof Error ? error.message : ''
75
+ if (message === 'Invalid CSRF token') reply.code(403).send({ error: 'Invalid CSRF token' })
76
+ else if (message === API_TOKEN_NOT_ACCEPTED) reply.code(403).send({ error: 'API tokens cannot call this endpoint' })
77
+ else reply.code(401).send(unauthorized)
78
+ return null
79
+ }
80
+ // Only a request that just authenticated spends budget; the later hooks of the same request reuse the identity.
81
+ if (identity.apiToken && !existing && !consumeApiTokenBudget(identity.apiToken.id)) {
82
+ reply.code(429).header('retry-after', '60').send({ error: 'Too many requests' })
83
+ return null
84
+ }
85
+ if (identity.apiToken) {
86
+ const resource = req.routeOptions.config?.tokenScope
87
+ if (resource !== 'custom') {
88
+ const required = resource ? requiredScope(resource, req.method) : null
89
+ if (!required || !tokenAllows(identity.apiToken.scopes, required)) {
90
+ reply.code(403).send({ error: required ? `This token does not have the "${required}" permission` : 'API tokens cannot call this endpoint' })
91
+ return null
92
+ }
93
+ }
94
+ }
95
+ if (passwordChangeBlocks(req, identity)) {
96
+ sendPasswordChangeRequired(reply)
97
+ return null
98
+ }
99
+ return identity
100
+ }
101
+
102
+ async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
103
+ if (!await authenticateOrReject(req, reply)) return reply
104
+ }
105
+
106
+ function requireRole(...allowed: string[]) {
107
+ const hook = async (req: FastifyRequest, reply: FastifyReply) => {
108
+ const identity = await authenticateOrReject(req, reply)
109
+ if (!identity) return reply
110
+ const { role } = identity
111
+ if (role !== 'admin' && !allowed.includes(role)) {
112
+ return reply.code(403).send({ error: 'Forbidden' })
113
+ }
114
+ }
115
+ // Readable by the route-table snapshot test; the hook itself never looks at it.
116
+ return Object.assign(hook, { allowedRoles: allowed, requiresAuth: true })
117
+ }
118
+
119
+ // Readable by the route-table snapshot test, like `allowedRoles`; the hook itself never looks at it.
120
+ return { authenticateOrReject, requireAuth: Object.assign(requireAuth, { requiresAuth: true }), requireRole }
121
+ }
122
+
123
+ export type Middleware = ReturnType<typeof createMiddleware>
@@ -0,0 +1,77 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import bcrypt from 'bcryptjs'
3
+ import type { MigrationStep } from '@better-forge/db'
4
+
5
+ /** Users, sign-in sessions, SSO settings and API tokens. Steps run in this order. */
6
+ export const authMigrations: readonly MigrationStep[] = [
7
+ { kind: 'exec', sql: `
8
+ CREATE TABLE IF NOT EXISTS users (
9
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
10
+ email TEXT NOT NULL UNIQUE,
11
+ password_hash TEXT NOT NULL,
12
+ role TEXT NOT NULL DEFAULT 'admin',
13
+ created_at INTEGER NOT NULL
14
+ );
15
+ ` },
16
+ { kind: 'exec', sql: `
17
+ CREATE TABLE IF NOT EXISTS oidc_settings (
18
+ id INTEGER PRIMARY KEY,
19
+ enabled INTEGER NOT NULL DEFAULT 0,
20
+ issuer TEXT NOT NULL DEFAULT '',
21
+ client_id TEXT NOT NULL DEFAULT '',
22
+ client_secret TEXT NOT NULL DEFAULT '',
23
+ scopes TEXT NOT NULL DEFAULT '',
24
+ redirect_uri TEXT NOT NULL DEFAULT '',
25
+ button_label TEXT NOT NULL DEFAULT '',
26
+ allow_unverified_email INTEGER NOT NULL DEFAULT 0,
27
+ disable_password_login INTEGER NOT NULL DEFAULT 0,
28
+ updated_at INTEGER NOT NULL
29
+ );
30
+ ` },
31
+ { kind: 'exec', sql: `
32
+ CREATE TABLE IF NOT EXISTS auth_sessions (
33
+ id TEXT PRIMARY KEY,
34
+ user_id INTEGER NOT NULL,
35
+ csrf_token_hash TEXT NOT NULL,
36
+ created_at INTEGER NOT NULL,
37
+ last_seen_at INTEGER NOT NULL,
38
+ expires_at INTEGER NOT NULL,
39
+ auth_method TEXT NOT NULL DEFAULT 'password',
40
+ verified_at INTEGER,
41
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
42
+ );
43
+ CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions(user_id);
44
+ CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions(expires_at);
45
+ ` },
46
+ { kind: 'exec', sql: `
47
+ CREATE TABLE IF NOT EXISTS api_tokens (
48
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
49
+ name TEXT NOT NULL,
50
+ token_hash TEXT NOT NULL UNIQUE,
51
+ prefix TEXT NOT NULL,
52
+ created_at INTEGER NOT NULL,
53
+ expires_at INTEGER,
54
+ last_used_at INTEGER,
55
+ scopes TEXT NOT NULL
56
+ );
57
+ ` },
58
+ { kind: 'column', sql: `ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0`, desc: 'users.must_change_password' },
59
+ { kind: 'column', sql: `ALTER TABLE users ADD COLUMN totp_secret TEXT`, desc: 'users.totp_secret' },
60
+ { kind: 'column', sql: `ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0`, desc: 'users.totp_enabled' },
61
+ { kind: 'column', sql: `ALTER TABLE users ADD COLUMN totp_recovery_codes TEXT`, desc: 'users.totp_recovery_codes' },
62
+ { kind: 'column', sql: `ALTER TABLE auth_sessions ADD COLUMN auth_method TEXT NOT NULL DEFAULT 'password'`, desc: 'auth_sessions.auth_method' },
63
+ { kind: 'column', sql: `ALTER TABLE users ADD COLUMN oidc_issuer TEXT`, desc: 'users.oidc_issuer' },
64
+ { kind: 'column', sql: `ALTER TABLE users ADD COLUMN oidc_subject TEXT`, desc: 'users.oidc_subject' },
65
+ { kind: 'column', sql: `ALTER TABLE auth_sessions ADD COLUMN verified_at INTEGER`, desc: 'auth_sessions.verified_at' },
66
+ // One IdP identity can be bound to one user only. Needs the columns added above.
67
+ { kind: 'exec', sql: `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc_identity ON users(oidc_issuer, oidc_subject)` },
68
+ /**
69
+ * Accounts linked to SSO before a sign-in started revoking the temporary password may still have a working one
70
+ * that their administrator knows. Linking only happens on an SSO sign-in, so revoke it the same way.
71
+ */
72
+ { kind: 'data', name: 'sso-users-temporary-password-revoked-v1', run(sqlite) {
73
+ const pending = sqlite.prepare('SELECT id FROM users WHERE must_change_password = 1 AND oidc_subject IS NOT NULL').all() as Array<{ id: number }>
74
+ const revoke = sqlite.prepare('UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?')
75
+ for (const { id } of pending) revoke.run(bcrypt.hashSync(randomBytes(32).toString('base64url'), 10), id)
76
+ } },
77
+ ]
package/src/module.ts ADDED
@@ -0,0 +1,6 @@
1
+ import type { DbModule } from '@better-forge/db'
2
+ import * as tables from './tables.js'
3
+ import { authMigrations } from './migrations.js'
4
+
5
+ /** The tables of accounts, sessions, API tokens and SSO settings, with their migrations, for the application's list of database modules. */
6
+ export const authDbModule = { id: 'auth', tables, migrations: authMigrations } satisfies DbModule