@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/LICENSE +21 -21
- package/README.md +5 -5
- package/dist/createAuth.d.ts.map +1 -1
- package/dist/migrations.js +47 -47
- package/package.json +68 -68
- package/src/apiTokenRoutes.ts +82 -82
- package/src/apiTokens.ts +62 -62
- package/src/authRoutes.ts +452 -452
- package/src/context.ts +85 -85
- package/src/createAuth.ts +58 -58
- package/src/identityConfirmation.ts +50 -50
- package/src/index.ts +17 -17
- package/src/middleware.ts +123 -123
- package/src/migrations.ts +77 -77
- package/src/module.ts +6 -6
- package/src/oidc.ts +291 -291
- package/src/oidcConfig.ts +96 -96
- package/src/oidcSettings.ts +60 -60
- package/src/oidcSettingsRoutes.ts +165 -165
- package/src/roles.ts +60 -60
- package/src/routeScope.ts +50 -50
- package/src/sessions.ts +209 -209
- package/src/setupRoutes.ts +100 -100
- package/src/signInAudit.ts +43 -43
- package/src/tables.ts +59 -59
- package/src/tokenCatalogue.ts +42 -42
- package/src/totp.ts +96 -96
- package/src/twoFactor.ts +49 -49
- package/src/userRoutes.ts +144 -144
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>
|