@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.
- package/LICENSE +21 -0
- package/README.md +5 -3
- package/dist/apiTokenRoutes.d.ts +8 -0
- package/dist/apiTokenRoutes.d.ts.map +1 -0
- package/dist/apiTokenRoutes.js +67 -0
- package/dist/apiTokenRoutes.js.map +1 -0
- package/dist/apiTokens.d.ts +25 -0
- package/dist/apiTokens.d.ts.map +1 -0
- package/dist/apiTokens.js +50 -0
- package/dist/apiTokens.js.map +1 -0
- package/dist/authRoutes.d.ts +37 -0
- package/dist/authRoutes.d.ts.map +1 -0
- package/dist/authRoutes.js +403 -0
- package/dist/authRoutes.js.map +1 -0
- package/dist/context.d.ts +98 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +2 -0
- package/dist/context.js.map +1 -0
- package/dist/createAuth.d.ts +146 -0
- package/dist/createAuth.d.ts.map +1 -0
- package/dist/createAuth.js +52 -0
- package/dist/createAuth.js.map +1 -0
- package/dist/identityConfirmation.d.ts +13 -0
- package/dist/identityConfirmation.d.ts.map +1 -0
- package/dist/identityConfirmation.js +37 -0
- package/dist/identityConfirmation.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware.d.ts +41 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +100 -0
- package/dist/middleware.js.map +1 -0
- package/dist/migrations.d.ts +4 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +77 -0
- package/dist/migrations.js.map +1 -0
- package/dist/module.d.ts +8 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +5 -0
- package/dist/module.js.map +1 -0
- package/dist/oidc.d.ts +85 -0
- package/dist/oidc.d.ts.map +1 -0
- package/dist/oidc.js +213 -0
- package/dist/oidc.js.map +1 -0
- package/dist/oidcConfig.d.ts +39 -0
- package/dist/oidcConfig.d.ts.map +1 -0
- package/dist/oidcConfig.js +72 -0
- package/dist/oidcConfig.js.map +1 -0
- package/dist/oidcSettings.d.ts +15 -0
- package/dist/oidcSettings.d.ts.map +1 -0
- package/dist/oidcSettings.js +51 -0
- package/dist/oidcSettings.js.map +1 -0
- package/dist/oidcSettingsRoutes.d.ts +19 -0
- package/dist/oidcSettingsRoutes.d.ts.map +1 -0
- package/dist/oidcSettingsRoutes.js +133 -0
- package/dist/oidcSettingsRoutes.js.map +1 -0
- package/dist/roles.d.ts +25 -0
- package/dist/roles.d.ts.map +1 -0
- package/dist/roles.js +44 -0
- package/dist/roles.js.map +1 -0
- package/dist/routeScope.d.ts +34 -0
- package/dist/routeScope.d.ts.map +1 -0
- package/dist/routeScope.js +32 -0
- package/dist/routeScope.js.map +1 -0
- package/dist/sessions.d.ts +46 -0
- package/dist/sessions.d.ts.map +1 -0
- package/dist/sessions.js +159 -0
- package/dist/sessions.js.map +1 -0
- package/dist/setupRoutes.d.ts +37 -0
- package/dist/setupRoutes.d.ts.map +1 -0
- package/dist/setupRoutes.js +70 -0
- package/dist/setupRoutes.js.map +1 -0
- package/dist/signInAudit.d.ts +19 -0
- package/dist/signInAudit.d.ts.map +1 -0
- package/dist/signInAudit.js +16 -0
- package/dist/signInAudit.js.map +1 -0
- package/dist/tables.d.ts +716 -0
- package/dist/tables.d.ts.map +1 -0
- package/dist/tables.js +56 -0
- package/dist/tables.js.map +1 -0
- package/dist/tokenCatalogue.d.ts +21 -0
- package/dist/tokenCatalogue.d.ts.map +1 -0
- package/dist/tokenCatalogue.js +27 -0
- package/dist/tokenCatalogue.js.map +1 -0
- package/dist/totp.d.ts +12 -0
- package/dist/totp.d.ts.map +1 -0
- package/dist/totp.js +93 -0
- package/dist/totp.js.map +1 -0
- package/dist/twoFactor.d.ts +10 -0
- package/dist/twoFactor.d.ts.map +1 -0
- package/dist/twoFactor.js +43 -0
- package/dist/twoFactor.js.map +1 -0
- package/dist/userRoutes.d.ts +13 -0
- package/dist/userRoutes.d.ts.map +1 -0
- package/dist/userRoutes.js +135 -0
- package/dist/userRoutes.js.map +1 -0
- package/package.json +68 -6
- package/src/apiTokenRoutes.ts +82 -0
- package/src/apiTokens.ts +62 -0
- package/src/authRoutes.ts +452 -0
- package/src/context.ts +85 -0
- package/src/createAuth.ts +58 -0
- package/src/identityConfirmation.ts +50 -0
- package/src/index.ts +17 -0
- package/src/middleware.ts +123 -0
- package/src/migrations.ts +77 -0
- package/src/module.ts +6 -0
- package/src/oidc.ts +291 -0
- package/src/oidcConfig.ts +96 -0
- package/src/oidcSettings.ts +60 -0
- package/src/oidcSettingsRoutes.ts +165 -0
- package/src/roles.ts +60 -0
- package/src/routeScope.ts +50 -0
- package/src/sessions.ts +209 -0
- package/src/setupRoutes.ts +100 -0
- package/src/signInAudit.ts +43 -0
- package/src/tables.ts +59 -0
- package/src/tokenCatalogue.ts +42 -0
- package/src/totp.ts +96 -0
- package/src/twoFactor.ts +49 -0
- 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
|
+
}
|
package/src/sessions.ts
ADDED
|
@@ -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
|
+
}
|
package/src/twoFactor.ts
ADDED
|
@@ -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>
|