@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/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
|