@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/middleware.ts CHANGED
@@ -1,123 +1,123 @@
1
- import type { FastifyReply, FastifyRequest } from 'fastify'
2
- import { requiredScope, tokenAllows } from '@better-forge/contracts'
3
- import { API_TOKEN_NOT_ACCEPTED, type AuthIdentity, type Sessions } from './sessions.js'
4
- import type { ApiTokens } from './apiTokens.js'
5
-
6
- declare module 'fastify' {
7
- interface FastifyContextConfig {
8
- allowPendingPasswordChange?: boolean
9
- /** Routes that accept an API token. Everything else answers 403 to one, so tokens never reach account security. */
10
- allowApiToken?: boolean
11
- /**
12
- * The part of the admin API a route belongs to (a key of TOKEN_RESOURCES, or `vault`). A token needs `<part>:read`
13
- * for GET and `<part>:write` for anything else. `custom` means the handler checks the token itself. A route a
14
- * token may reach but that names no part is refused: tokens fail closed.
15
- */
16
- tokenScope?: string
17
- }
18
- }
19
-
20
- export const PASSWORD_CHANGE_REQUIRED = 'PASSWORD_CHANGE_REQUIRED'
21
-
22
- /**
23
- * Route config for the few routes a user with a pending forced password change may still call
24
- * (session lookup, change-password, logout). Every other authenticated route answers 403 until
25
- * the password is changed, so the requirement is enforced server-side, not only by the admin UI.
26
- */
27
- export const ALLOW_PENDING_PASSWORD_CHANGE = { allowPendingPasswordChange: true } as const
28
-
29
- function existingIdentity(req: FastifyRequest): AuthIdentity | null {
30
- const value = req.user as Partial<AuthIdentity> | undefined
31
- return value?.sessionId && typeof value.userId === 'number' ? value as AuthIdentity : null
32
- }
33
-
34
- /**
35
- * The identity attached by requireAuth/requireRole. Only call from handlers behind one of them.
36
- */
37
- export function requestIdentity(req: FastifyRequest): AuthIdentity {
38
- const value = req.user as Partial<AuthIdentity> | undefined
39
- if (typeof value?.userId !== 'number') throw new Error('requestIdentity called on an unauthenticated request')
40
- return value as AuthIdentity
41
- }
42
-
43
- function passwordChangeBlocks(req: FastifyRequest, identity: AuthIdentity): boolean {
44
- if (!identity.mustChangePassword) return false
45
- return req.routeOptions.config?.allowPendingPasswordChange !== true
46
- }
47
-
48
- function sendPasswordChangeRequired(reply: FastifyReply) {
49
- return reply.code(403).send({ error: 'Password change required', code: PASSWORD_CHANGE_REQUIRED })
50
- }
51
-
52
- export function createMiddleware(sessions: Sessions, apiTokens: ApiTokens) {
53
- const { authenticateRequest, verifyCsrf } = sessions
54
- const { consumeApiTokenBudget } = apiTokens
55
-
56
- /**
57
- * Authenticates the request and checks its CSRF token. Answers 401 (with `unauthorized` as the body) or 403
58
- * itself and returns null when either fails, or when the user still has to replace a temporary password.
59
- * A hook that gets null must return the reply.
60
- */
61
- async function authenticateOrReject(
62
- req: FastifyRequest,
63
- reply: FastifyReply,
64
- unauthorized: Record<string, string> = { error: 'Unauthorized' },
65
- ): Promise<AuthIdentity | null> {
66
- const allowApiToken = req.routeOptions.config?.allowApiToken === true
67
- const existing = existingIdentity(req)
68
- let identity: AuthIdentity
69
- try {
70
- identity = existing ?? await authenticateRequest(req, { allowApiToken })
71
- if (identity.apiToken && !allowApiToken) throw new Error(API_TOKEN_NOT_ACCEPTED)
72
- await verifyCsrf(req, identity)
73
- } catch (error) {
74
- const message = error instanceof Error ? error.message : ''
75
- if (message === 'Invalid CSRF token') reply.code(403).send({ error: 'Invalid CSRF token' })
76
- else if (message === API_TOKEN_NOT_ACCEPTED) reply.code(403).send({ error: 'API tokens cannot call this endpoint' })
77
- else reply.code(401).send(unauthorized)
78
- return null
79
- }
80
- // Only a request that just authenticated spends budget; the later hooks of the same request reuse the identity.
81
- if (identity.apiToken && !existing && !consumeApiTokenBudget(identity.apiToken.id)) {
82
- reply.code(429).header('retry-after', '60').send({ error: 'Too many requests' })
83
- return null
84
- }
85
- if (identity.apiToken) {
86
- const resource = req.routeOptions.config?.tokenScope
87
- if (resource !== 'custom') {
88
- const required = resource ? requiredScope(resource, req.method) : null
89
- if (!required || !tokenAllows(identity.apiToken.scopes, required)) {
90
- reply.code(403).send({ error: required ? `This token does not have the "${required}" permission` : 'API tokens cannot call this endpoint' })
91
- return null
92
- }
93
- }
94
- }
95
- if (passwordChangeBlocks(req, identity)) {
96
- sendPasswordChangeRequired(reply)
97
- return null
98
- }
99
- return identity
100
- }
101
-
102
- async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
103
- if (!await authenticateOrReject(req, reply)) return reply
104
- }
105
-
106
- function requireRole(...allowed: string[]) {
107
- const hook = async (req: FastifyRequest, reply: FastifyReply) => {
108
- const identity = await authenticateOrReject(req, reply)
109
- if (!identity) return reply
110
- const { role } = identity
111
- if (role !== 'admin' && !allowed.includes(role)) {
112
- return reply.code(403).send({ error: 'Forbidden' })
113
- }
114
- }
115
- // Readable by the route-table snapshot test; the hook itself never looks at it.
116
- return Object.assign(hook, { allowedRoles: allowed, requiresAuth: true })
117
- }
118
-
119
- // Readable by the route-table snapshot test, like `allowedRoles`; the hook itself never looks at it.
120
- return { authenticateOrReject, requireAuth: Object.assign(requireAuth, { requiresAuth: true }), requireRole }
121
- }
122
-
123
- export type Middleware = ReturnType<typeof createMiddleware>
1
+ import type { FastifyReply, FastifyRequest } from 'fastify'
2
+ import { requiredScope, tokenAllows } from '@better-forge/contracts'
3
+ import { API_TOKEN_NOT_ACCEPTED, type AuthIdentity, type Sessions } from './sessions.js'
4
+ import type { ApiTokens } from './apiTokens.js'
5
+
6
+ declare module 'fastify' {
7
+ interface FastifyContextConfig {
8
+ allowPendingPasswordChange?: boolean
9
+ /** Routes that accept an API token. Everything else answers 403 to one, so tokens never reach account security. */
10
+ allowApiToken?: boolean
11
+ /**
12
+ * The part of the admin API a route belongs to (a key of TOKEN_RESOURCES, or `vault`). A token needs `<part>:read`
13
+ * for GET and `<part>:write` for anything else. `custom` means the handler checks the token itself. A route a
14
+ * token may reach but that names no part is refused: tokens fail closed.
15
+ */
16
+ tokenScope?: string
17
+ }
18
+ }
19
+
20
+ export const PASSWORD_CHANGE_REQUIRED = 'PASSWORD_CHANGE_REQUIRED'
21
+
22
+ /**
23
+ * Route config for the few routes a user with a pending forced password change may still call
24
+ * (session lookup, change-password, logout). Every other authenticated route answers 403 until
25
+ * the password is changed, so the requirement is enforced server-side, not only by the admin UI.
26
+ */
27
+ export const ALLOW_PENDING_PASSWORD_CHANGE = { allowPendingPasswordChange: true } as const
28
+
29
+ function existingIdentity(req: FastifyRequest): AuthIdentity | null {
30
+ const value = req.user as Partial<AuthIdentity> | undefined
31
+ return value?.sessionId && typeof value.userId === 'number' ? value as AuthIdentity : null
32
+ }
33
+
34
+ /**
35
+ * The identity attached by requireAuth/requireRole. Only call from handlers behind one of them.
36
+ */
37
+ export function requestIdentity(req: FastifyRequest): AuthIdentity {
38
+ const value = req.user as Partial<AuthIdentity> | undefined
39
+ if (typeof value?.userId !== 'number') throw new Error('requestIdentity called on an unauthenticated request')
40
+ return value as AuthIdentity
41
+ }
42
+
43
+ function passwordChangeBlocks(req: FastifyRequest, identity: AuthIdentity): boolean {
44
+ if (!identity.mustChangePassword) return false
45
+ return req.routeOptions.config?.allowPendingPasswordChange !== true
46
+ }
47
+
48
+ function sendPasswordChangeRequired(reply: FastifyReply) {
49
+ return reply.code(403).send({ error: 'Password change required', code: PASSWORD_CHANGE_REQUIRED })
50
+ }
51
+
52
+ export function createMiddleware(sessions: Sessions, apiTokens: ApiTokens) {
53
+ const { authenticateRequest, verifyCsrf } = sessions
54
+ const { consumeApiTokenBudget } = apiTokens
55
+
56
+ /**
57
+ * Authenticates the request and checks its CSRF token. Answers 401 (with `unauthorized` as the body) or 403
58
+ * itself and returns null when either fails, or when the user still has to replace a temporary password.
59
+ * A hook that gets null must return the reply.
60
+ */
61
+ async function authenticateOrReject(
62
+ req: FastifyRequest,
63
+ reply: FastifyReply,
64
+ unauthorized: Record<string, string> = { error: 'Unauthorized' },
65
+ ): Promise<AuthIdentity | null> {
66
+ const allowApiToken = req.routeOptions.config?.allowApiToken === true
67
+ const existing = existingIdentity(req)
68
+ let identity: AuthIdentity
69
+ try {
70
+ identity = existing ?? await authenticateRequest(req, { allowApiToken })
71
+ if (identity.apiToken && !allowApiToken) throw new Error(API_TOKEN_NOT_ACCEPTED)
72
+ await verifyCsrf(req, identity)
73
+ } catch (error) {
74
+ const message = error instanceof Error ? error.message : ''
75
+ if (message === 'Invalid CSRF token') reply.code(403).send({ error: 'Invalid CSRF token' })
76
+ else if (message === API_TOKEN_NOT_ACCEPTED) reply.code(403).send({ error: 'API tokens cannot call this endpoint' })
77
+ else reply.code(401).send(unauthorized)
78
+ return null
79
+ }
80
+ // Only a request that just authenticated spends budget; the later hooks of the same request reuse the identity.
81
+ if (identity.apiToken && !existing && !consumeApiTokenBudget(identity.apiToken.id)) {
82
+ reply.code(429).header('retry-after', '60').send({ error: 'Too many requests' })
83
+ return null
84
+ }
85
+ if (identity.apiToken) {
86
+ const resource = req.routeOptions.config?.tokenScope
87
+ if (resource !== 'custom') {
88
+ const required = resource ? requiredScope(resource, req.method) : null
89
+ if (!required || !tokenAllows(identity.apiToken.scopes, required)) {
90
+ reply.code(403).send({ error: required ? `This token does not have the "${required}" permission` : 'API tokens cannot call this endpoint' })
91
+ return null
92
+ }
93
+ }
94
+ }
95
+ if (passwordChangeBlocks(req, identity)) {
96
+ sendPasswordChangeRequired(reply)
97
+ return null
98
+ }
99
+ return identity
100
+ }
101
+
102
+ async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
103
+ if (!await authenticateOrReject(req, reply)) return reply
104
+ }
105
+
106
+ function requireRole(...allowed: string[]) {
107
+ const hook = async (req: FastifyRequest, reply: FastifyReply) => {
108
+ const identity = await authenticateOrReject(req, reply)
109
+ if (!identity) return reply
110
+ const { role } = identity
111
+ if (role !== 'admin' && !allowed.includes(role)) {
112
+ return reply.code(403).send({ error: 'Forbidden' })
113
+ }
114
+ }
115
+ // Readable by the route-table snapshot test; the hook itself never looks at it.
116
+ return Object.assign(hook, { allowedRoles: allowed, requiresAuth: true })
117
+ }
118
+
119
+ // Readable by the route-table snapshot test, like `allowedRoles`; the hook itself never looks at it.
120
+ return { authenticateOrReject, requireAuth: Object.assign(requireAuth, { requiresAuth: true }), requireRole }
121
+ }
122
+
123
+ export type Middleware = ReturnType<typeof createMiddleware>
package/src/migrations.ts CHANGED
@@ -1,77 +1,77 @@
1
- import { randomBytes } from 'node:crypto'
2
- import bcrypt from 'bcryptjs'
3
- import type { MigrationStep } from '@better-forge/db'
4
-
5
- /** Users, sign-in sessions, SSO settings and API tokens. Steps run in this order. */
6
- export const authMigrations: readonly MigrationStep[] = [
7
- { kind: 'exec', sql: `
8
- CREATE TABLE IF NOT EXISTS users (
9
- id INTEGER PRIMARY KEY AUTOINCREMENT,
10
- email TEXT NOT NULL UNIQUE,
11
- password_hash TEXT NOT NULL,
12
- role TEXT NOT NULL DEFAULT 'admin',
13
- created_at INTEGER NOT NULL
14
- );
15
- ` },
16
- { kind: 'exec', sql: `
17
- CREATE TABLE IF NOT EXISTS oidc_settings (
18
- id INTEGER PRIMARY KEY,
19
- enabled INTEGER NOT NULL DEFAULT 0,
20
- issuer TEXT NOT NULL DEFAULT '',
21
- client_id TEXT NOT NULL DEFAULT '',
22
- client_secret TEXT NOT NULL DEFAULT '',
23
- scopes TEXT NOT NULL DEFAULT '',
24
- redirect_uri TEXT NOT NULL DEFAULT '',
25
- button_label TEXT NOT NULL DEFAULT '',
26
- allow_unverified_email INTEGER NOT NULL DEFAULT 0,
27
- disable_password_login INTEGER NOT NULL DEFAULT 0,
28
- updated_at INTEGER NOT NULL
29
- );
30
- ` },
31
- { kind: 'exec', sql: `
32
- CREATE TABLE IF NOT EXISTS auth_sessions (
33
- id TEXT PRIMARY KEY,
34
- user_id INTEGER NOT NULL,
35
- csrf_token_hash TEXT NOT NULL,
36
- created_at INTEGER NOT NULL,
37
- last_seen_at INTEGER NOT NULL,
38
- expires_at INTEGER NOT NULL,
39
- auth_method TEXT NOT NULL DEFAULT 'password',
40
- verified_at INTEGER,
41
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
42
- );
43
- CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions(user_id);
44
- CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions(expires_at);
45
- ` },
46
- { kind: 'exec', sql: `
47
- CREATE TABLE IF NOT EXISTS api_tokens (
48
- id INTEGER PRIMARY KEY AUTOINCREMENT,
49
- name TEXT NOT NULL,
50
- token_hash TEXT NOT NULL UNIQUE,
51
- prefix TEXT NOT NULL,
52
- created_at INTEGER NOT NULL,
53
- expires_at INTEGER,
54
- last_used_at INTEGER,
55
- scopes TEXT NOT NULL
56
- );
57
- ` },
58
- { kind: 'column', sql: `ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0`, desc: 'users.must_change_password' },
59
- { kind: 'column', sql: `ALTER TABLE users ADD COLUMN totp_secret TEXT`, desc: 'users.totp_secret' },
60
- { kind: 'column', sql: `ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0`, desc: 'users.totp_enabled' },
61
- { kind: 'column', sql: `ALTER TABLE users ADD COLUMN totp_recovery_codes TEXT`, desc: 'users.totp_recovery_codes' },
62
- { kind: 'column', sql: `ALTER TABLE auth_sessions ADD COLUMN auth_method TEXT NOT NULL DEFAULT 'password'`, desc: 'auth_sessions.auth_method' },
63
- { kind: 'column', sql: `ALTER TABLE users ADD COLUMN oidc_issuer TEXT`, desc: 'users.oidc_issuer' },
64
- { kind: 'column', sql: `ALTER TABLE users ADD COLUMN oidc_subject TEXT`, desc: 'users.oidc_subject' },
65
- { kind: 'column', sql: `ALTER TABLE auth_sessions ADD COLUMN verified_at INTEGER`, desc: 'auth_sessions.verified_at' },
66
- // One IdP identity can be bound to one user only. Needs the columns added above.
67
- { kind: 'exec', sql: `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc_identity ON users(oidc_issuer, oidc_subject)` },
68
- /**
69
- * Accounts linked to SSO before a sign-in started revoking the temporary password may still have a working one
70
- * that their administrator knows. Linking only happens on an SSO sign-in, so revoke it the same way.
71
- */
72
- { kind: 'data', name: 'sso-users-temporary-password-revoked-v1', run(sqlite) {
73
- const pending = sqlite.prepare('SELECT id FROM users WHERE must_change_password = 1 AND oidc_subject IS NOT NULL').all() as Array<{ id: number }>
74
- const revoke = sqlite.prepare('UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?')
75
- for (const { id } of pending) revoke.run(bcrypt.hashSync(randomBytes(32).toString('base64url'), 10), id)
76
- } },
77
- ]
1
+ import { randomBytes } from 'node:crypto'
2
+ import bcrypt from 'bcryptjs'
3
+ import type { MigrationStep } from '@better-forge/db'
4
+
5
+ /** Users, sign-in sessions, SSO settings and API tokens. Steps run in this order. */
6
+ export const authMigrations: readonly MigrationStep[] = [
7
+ { kind: 'exec', sql: `
8
+ CREATE TABLE IF NOT EXISTS users (
9
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
10
+ email TEXT NOT NULL UNIQUE,
11
+ password_hash TEXT NOT NULL,
12
+ role TEXT NOT NULL DEFAULT 'admin',
13
+ created_at INTEGER NOT NULL
14
+ );
15
+ ` },
16
+ { kind: 'exec', sql: `
17
+ CREATE TABLE IF NOT EXISTS oidc_settings (
18
+ id INTEGER PRIMARY KEY,
19
+ enabled INTEGER NOT NULL DEFAULT 0,
20
+ issuer TEXT NOT NULL DEFAULT '',
21
+ client_id TEXT NOT NULL DEFAULT '',
22
+ client_secret TEXT NOT NULL DEFAULT '',
23
+ scopes TEXT NOT NULL DEFAULT '',
24
+ redirect_uri TEXT NOT NULL DEFAULT '',
25
+ button_label TEXT NOT NULL DEFAULT '',
26
+ allow_unverified_email INTEGER NOT NULL DEFAULT 0,
27
+ disable_password_login INTEGER NOT NULL DEFAULT 0,
28
+ updated_at INTEGER NOT NULL
29
+ );
30
+ ` },
31
+ { kind: 'exec', sql: `
32
+ CREATE TABLE IF NOT EXISTS auth_sessions (
33
+ id TEXT PRIMARY KEY,
34
+ user_id INTEGER NOT NULL,
35
+ csrf_token_hash TEXT NOT NULL,
36
+ created_at INTEGER NOT NULL,
37
+ last_seen_at INTEGER NOT NULL,
38
+ expires_at INTEGER NOT NULL,
39
+ auth_method TEXT NOT NULL DEFAULT 'password',
40
+ verified_at INTEGER,
41
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
42
+ );
43
+ CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions(user_id);
44
+ CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions(expires_at);
45
+ ` },
46
+ { kind: 'exec', sql: `
47
+ CREATE TABLE IF NOT EXISTS api_tokens (
48
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
49
+ name TEXT NOT NULL,
50
+ token_hash TEXT NOT NULL UNIQUE,
51
+ prefix TEXT NOT NULL,
52
+ created_at INTEGER NOT NULL,
53
+ expires_at INTEGER,
54
+ last_used_at INTEGER,
55
+ scopes TEXT NOT NULL
56
+ );
57
+ ` },
58
+ { kind: 'column', sql: `ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0`, desc: 'users.must_change_password' },
59
+ { kind: 'column', sql: `ALTER TABLE users ADD COLUMN totp_secret TEXT`, desc: 'users.totp_secret' },
60
+ { kind: 'column', sql: `ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0`, desc: 'users.totp_enabled' },
61
+ { kind: 'column', sql: `ALTER TABLE users ADD COLUMN totp_recovery_codes TEXT`, desc: 'users.totp_recovery_codes' },
62
+ { kind: 'column', sql: `ALTER TABLE auth_sessions ADD COLUMN auth_method TEXT NOT NULL DEFAULT 'password'`, desc: 'auth_sessions.auth_method' },
63
+ { kind: 'column', sql: `ALTER TABLE users ADD COLUMN oidc_issuer TEXT`, desc: 'users.oidc_issuer' },
64
+ { kind: 'column', sql: `ALTER TABLE users ADD COLUMN oidc_subject TEXT`, desc: 'users.oidc_subject' },
65
+ { kind: 'column', sql: `ALTER TABLE auth_sessions ADD COLUMN verified_at INTEGER`, desc: 'auth_sessions.verified_at' },
66
+ // One IdP identity can be bound to one user only. Needs the columns added above.
67
+ { kind: 'exec', sql: `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc_identity ON users(oidc_issuer, oidc_subject)` },
68
+ /**
69
+ * Accounts linked to SSO before a sign-in started revoking the temporary password may still have a working one
70
+ * that their administrator knows. Linking only happens on an SSO sign-in, so revoke it the same way.
71
+ */
72
+ { kind: 'data', name: 'sso-users-temporary-password-revoked-v1', run(sqlite) {
73
+ const pending = sqlite.prepare('SELECT id FROM users WHERE must_change_password = 1 AND oidc_subject IS NOT NULL').all() as Array<{ id: number }>
74
+ const revoke = sqlite.prepare('UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?')
75
+ for (const { id } of pending) revoke.run(bcrypt.hashSync(randomBytes(32).toString('base64url'), 10), id)
76
+ } },
77
+ ]
package/src/module.ts CHANGED
@@ -1,6 +1,6 @@
1
- import type { DbModule } from '@better-forge/db'
2
- import * as tables from './tables.js'
3
- import { authMigrations } from './migrations.js'
4
-
5
- /** The tables of accounts, sessions, API tokens and SSO settings, with their migrations, for the application's list of database modules. */
6
- export const authDbModule = { id: 'auth', tables, migrations: authMigrations } satisfies DbModule
1
+ import type { DbModule } from '@better-forge/db'
2
+ import * as tables from './tables.js'
3
+ import { authMigrations } from './migrations.js'
4
+
5
+ /** The tables of accounts, sessions, API tokens and SSO settings, with their migrations, for the application's list of database modules. */
6
+ export const authDbModule = { id: 'auth', tables, migrations: authMigrations } satisfies DbModule