@better-forge/auth 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/roles.ts CHANGED
@@ -1,60 +1,60 @@
1
- /** The role that may do everything; it exists in every application. */
2
- export const ADMIN_ROLE = 'admin'
3
-
4
- export interface RoleRegistry {
5
- /**
6
- * Adds the application's roles to `admin`. `fallback` is what a stored value that is not a role normalises to,
7
- * and `defaultForNewUsers` is the role an account gets when none is asked for.
8
- */
9
- register(roles: readonly string[], options: { fallback: string; defaultForNewUsers: string }): void
10
- /** Normalizes a stored role value, including the legacy JSON-array format. */
11
- normalize(raw: string): string
12
- isValid(role: string): boolean
13
- /** Every role, `admin` first, in the order they were registered. */
14
- list(): readonly string[]
15
- readonly defaultForNewUsers: string
16
- /**
17
- * Called by createAuth: after this, registering throws, so the set of roles cannot change under a running app.
18
- * It also throws when the application never registered its roles, because the fallback would then be `admin`.
19
- */
20
- freeze(): void
21
- }
22
-
23
- export function createRoleRegistry(): RoleRegistry {
24
- const roles: string[] = [ADMIN_ROLE]
25
- let fallback: string = ADMIN_ROLE
26
- let defaultForNewUsers: string = ADMIN_ROLE
27
- let frozen = false
28
- let registered = false
29
-
30
- const isValid = (role: string) => roles.includes(role)
31
-
32
- return {
33
- register(added, options) {
34
- if (frozen) throw new Error('Roles cannot be registered after the auth module was created')
35
- for (const role of added) if (!roles.includes(role)) roles.push(role)
36
- if (!roles.includes(options.fallback) || !roles.includes(options.defaultForNewUsers)) {
37
- throw new Error('The fallback and the default role for new users must be registered roles')
38
- }
39
- fallback = options.fallback
40
- defaultForNewUsers = options.defaultForNewUsers
41
- registered = true
42
- },
43
- normalize(raw) {
44
- try {
45
- const parsed: unknown = JSON.parse(raw)
46
- const role = Array.isArray(parsed) ? parsed[0] : raw
47
- return typeof role === 'string' && isValid(role) ? role : fallback
48
- } catch {
49
- return isValid(raw) ? raw : fallback
50
- }
51
- },
52
- isValid,
53
- list: () => roles,
54
- get defaultForNewUsers() { return defaultForNewUsers },
55
- freeze() {
56
- if (!registered) throw new Error('Register the application roles before creating the auth module: without them every unknown role would be an administrator')
57
- frozen = true
58
- },
59
- }
60
- }
1
+ /** The role that may do everything; it exists in every application. */
2
+ export const ADMIN_ROLE = 'admin'
3
+
4
+ export interface RoleRegistry {
5
+ /**
6
+ * Adds the application's roles to `admin`. `fallback` is what a stored value that is not a role normalises to,
7
+ * and `defaultForNewUsers` is the role an account gets when none is asked for.
8
+ */
9
+ register(roles: readonly string[], options: { fallback: string; defaultForNewUsers: string }): void
10
+ /** Normalizes a stored role value, including the legacy JSON-array format. */
11
+ normalize(raw: string): string
12
+ isValid(role: string): boolean
13
+ /** Every role, `admin` first, in the order they were registered. */
14
+ list(): readonly string[]
15
+ readonly defaultForNewUsers: string
16
+ /**
17
+ * Called by createAuth: after this, registering throws, so the set of roles cannot change under a running app.
18
+ * It also throws when the application never registered its roles, because the fallback would then be `admin`.
19
+ */
20
+ freeze(): void
21
+ }
22
+
23
+ export function createRoleRegistry(): RoleRegistry {
24
+ const roles: string[] = [ADMIN_ROLE]
25
+ let fallback: string = ADMIN_ROLE
26
+ let defaultForNewUsers: string = ADMIN_ROLE
27
+ let frozen = false
28
+ let registered = false
29
+
30
+ const isValid = (role: string) => roles.includes(role)
31
+
32
+ return {
33
+ register(added, options) {
34
+ if (frozen) throw new Error('Roles cannot be registered after the auth module was created')
35
+ for (const role of added) if (!roles.includes(role)) roles.push(role)
36
+ if (!roles.includes(options.fallback) || !roles.includes(options.defaultForNewUsers)) {
37
+ throw new Error('The fallback and the default role for new users must be registered roles')
38
+ }
39
+ fallback = options.fallback
40
+ defaultForNewUsers = options.defaultForNewUsers
41
+ registered = true
42
+ },
43
+ normalize(raw) {
44
+ try {
45
+ const parsed: unknown = JSON.parse(raw)
46
+ const role = Array.isArray(parsed) ? parsed[0] : raw
47
+ return typeof role === 'string' && isValid(role) ? role : fallback
48
+ } catch {
49
+ return isValid(raw) ? raw : fallback
50
+ }
51
+ },
52
+ isValid,
53
+ list: () => roles,
54
+ get defaultForNewUsers() { return defaultForNewUsers },
55
+ freeze() {
56
+ if (!registered) throw new Error('Register the application roles before creating the auth module: without them every unknown role would be an administrator')
57
+ frozen = true
58
+ },
59
+ }
60
+ }
package/src/routeScope.ts CHANGED
@@ -1,50 +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
- }
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 CHANGED
@@ -1,209 +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>
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>