mbkauthe 5.1.1 → 5.2.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/README.md CHANGED
@@ -10,14 +10,14 @@
10
10
  <img height="64px" src="./public/logo.png" alt="MBKAuthe" />
11
11
  </p>
12
12
 
13
- **MBKAuthe** is an open source authentication package for Node.js, Express, and PostgreSQL. It handles login, session validation, role/app access checks, optional TOTP 2FA, OAuth login, API token authentication, and multi-session management.
13
+ **MBKAuthe** is an open source authentication package for Node.js and Express, backed by PostgreSQL or SQLite. It handles login, session validation, role/app access checks, optional TOTP 2FA, OAuth login, API token authentication, and multi-session management.
14
14
 
15
15
  > **Note:** MBKAuthe is intentionally focused on authentication and session validation. The broader user, permission, and dashboard management system is a separate MBKTech product named **MBKCore**(closed source for now).
16
16
 
17
17
  ## Features
18
18
 
19
19
  - Express middleware for session validation and role checks
20
- - PostgreSQL-backed user, session, 2FA, trusted-device, and API-token storage
20
+ - PostgreSQL or SQLite storage for users, sessions, 2FA, trusted devices, and API tokens
21
21
  - Secure password authentication with PBKDF2
22
22
  - Optional TOTP 2FA with trusted devices
23
23
  - GitHub App and Google OAuth login flows
@@ -46,17 +46,24 @@ Copy-Item .env.example .env
46
46
 
47
47
  See the [configuration guide](docs/guides/configuration.md) for `mbkautheVar`, `mbkauthShared`, OAuth settings, session settings, and deployment flags.
48
48
 
49
- 3. Create database tables.
49
+ 3. Choose a database backend.
50
50
 
51
- Run [docs/schema/db.sql](docs/schema/db.sql) against PostgreSQL, or use the package script:
51
+ MBKAuthe supports two backends, selected with `DB_TYPE` in `mbkautheVar`:
52
+
53
+ - **PostgreSQL** (default) - set `LOGIN_DB` to a connection string. Recommended for production and multi-instance deployments.
54
+ - **SQLite** - set `DB_TYPE` to `sqlite` and `SQLITE_PATH` to a file path (created if missing). No database server required - convenient for development, tests, and small single-instance deployments. Uses `better-sqlite3` with WAL mode; expect `-wal`/`-shm` side files next to the database file. See the [SQLite backend notes](docs/guides/database.md#sqlite-backend-notes) in the database guide.
55
+
56
+ 4. Create database tables.
52
57
 
53
58
  ```bash
54
59
  npm run create-tables
55
60
  ```
56
61
 
62
+ The script applies [docs/schema/db.sql](docs/schema/db.sql) (PostgreSQL) or [docs/schema/db.sqlite.sql](docs/schema/db.sqlite.sql) (SQLite) to the configured backend. You can also run the matching SQL file yourself.
63
+
57
64
  The schema includes a default SuperAdmin user (`support` / `12345678`). Change that password immediately. See the [database guide](docs/guides/database.md).
58
65
 
59
- 4. Mount MBKAuthe in Express.
66
+ 5. Mount MBKAuthe in Express.
60
67
 
61
68
  ```javascript
62
69
  import express from "express";
@@ -93,7 +100,8 @@ app.listen(3000);
93
100
  - `strictValidateSession` - require cookie session authentication only.
94
101
  - `strictValidateSessionAndRole` - strict cookie session plus role check.
95
102
  - `authenticate(token)` - protect server-to-server routes with a static bearer token.
96
- - `dblogin` - access the configured PostgreSQL pool.
103
+ - `dblogin` - access the configured database pool (`pg.Pool` or the SQLite adapter, per `DB_TYPE`).
104
+ - `dbType` - the active backend: `"postgres"` or `"sqlite"`.
97
105
 
98
106
  See the [API reference](docs/reference/api.md) for endpoints, middleware, examples, security notes, and rate limits.
99
107
 
@@ -149,6 +157,7 @@ Development-only diagnostics are mounted when `process.env.env === "dev"`:
149
157
  - Set an appropriate `COOKIE_EXPIRE_TIME`
150
158
  - Store secrets in environment variables
151
159
  - Configure OAuth credentials only when the matching provider is enabled
160
+ - If using the SQLite backend, put `SQLITE_PATH` on persistent disk (not ephemeral/serverless storage) and back up the database together with its `-wal`/`-shm` side files
152
161
 
153
162
  Vercel deployments can use shared OAuth credentials through `mbkauthShared`.
154
163
 
package/docs/README.md CHANGED
@@ -27,7 +27,8 @@ This directory is organized by how the documentation is used:
27
27
 
28
28
  ## Assets
29
29
 
30
- - [Database schema SQL](schema/db.sql)
30
+ - [Database schema SQL - PostgreSQL](schema/db.sql)
31
+ - [Database schema SQL - SQLite](schema/db.sqlite.sql)
31
32
  - [Authentication flow Mermaid source](diagrams/auth-flows.mmd)
32
33
  - [Authentication process Mermaid source](diagrams/auth-processes.mmd)
33
34
  - [Rendered diagram images](images/)
@@ -39,12 +39,25 @@ This document describes the environment variables MBKAuth expects and keeps brie
39
39
  - Example: `"DOMAIN":"localhost"`
40
40
  - Required: Yes
41
41
 
42
+ - DB_TYPE
43
+ - Description: Which database backend to use.
44
+ - Values: `postgres` (default) / `sqlite`
45
+ - Example: `"DB_TYPE":"sqlite"`
46
+ - Required: No (defaults to `postgres`)
47
+
42
48
  - LOGIN_DB
43
49
  - Description: PostgreSQL connection string for auth (must start with `postgresql://` or `postgres://`).
44
50
  - Example: `"LOGIN_DB":"postgresql://user:pass@localhost:5432/mbkauth"`
45
- - Required: Yes
51
+ - Required: Yes, when `DB_TYPE` is `postgres` (the default)
46
52
  - Create free postgres db: https://neon.com/
47
53
 
54
+ - SQLITE_PATH
55
+ - Description: Path to the SQLite database file (created if missing). Uses `better-sqlite3` under the hood.
56
+ - Example: `"SQLITE_PATH":"./data/mbkauthe.sqlite"`
57
+ - Required: Yes, when `DB_TYPE` is `sqlite`
58
+ - Default: `./mbkauthe.sqlite`
59
+ - Run `npm run create-tables` after setting this to create the schema (loads `docs/schema/db.sqlite.sql` instead of the Postgres `db.sql`).
60
+
48
61
  - MBKAUTH_TWO_FA_ENABLE
49
62
  - Description: Enable Two-Factor Authentication.
50
63
  - Values: `true` / `false` / `f`
@@ -123,6 +136,14 @@ Production (short):
123
136
  mbkautheVar={"APP_NAME":"mbkauthe","Main_SECRET_TOKEN":"prod-token","SESSION_SECRET_KEY":"prod-secret","IS_DEPLOYED":"true","DOMAIN":"yourdomain.com","LOGIN_DB":"postgresql://dbuser:secure@db:5432/mbkauth_prod","MBKAUTH_TWO_FA_ENABLE":"true"}
124
137
  ```
125
138
 
139
+ SQLite (no external database required):
140
+
141
+ ```env
142
+ mbkautheVar={"APP_NAME":"mbkauthe","Main_SECRET_TOKEN":"dev-token","SESSION_SECRET_KEY":"dev-secret","IS_DEPLOYED":"false","DOMAIN":"localhost","DB_TYPE":"sqlite","SQLITE_PATH":"./data/mbkauthe.sqlite","MBKAUTH_TWO_FA_ENABLE":"false"}
143
+ ```
144
+
145
+ Then run `npm run create-tables` to create `./data/mbkauthe.sqlite` with the schema in `docs/schema/db.sqlite.sql`.
146
+
126
147
  ---
127
148
 
128
149
  ## Rules & best practices
@@ -2,7 +2,7 @@
2
2
 
3
3
  [Back to docs index](../README.md) | [Back to project README](../../README.md)
4
4
 
5
- **Executable DDL lives only in [`docs/schema/db.sql`](../schema/db.sql).** This file explains what that script creates and how the app uses it. Run the script against Postgres when bootstrapping or aligning a database (for example `psql $DATABASE_URL -f docs/schema/db.sql`). The app can also apply it via `lib/createTable.js`, which reads `docs/schema/db.sql`.
5
+ **Executable DDL lives only in [`docs/schema/db.sql`](../schema/db.sql) (Postgres) and [`docs/schema/db.sqlite.sql`](../schema/db.sqlite.sql) (SQLite).** This file explains what those scripts create and how the app uses them. Run the Postgres script when bootstrapping or aligning a database (for example `psql $DATABASE_URL -f docs/schema/db.sql`). The app can also apply the right script for the configured backend via `lib/createTable.js`.
6
6
 
7
7
  ---
8
8
 
@@ -91,3 +91,34 @@ const encryptedPassword = hashPassword("your-password", "newusername");
91
91
  ```
92
92
 
93
93
  Replace usernames, roles (`SuperAdmin`, `NormalUser`, `Guest`, `member`), and flags (`Active`, `HaveMailAccount`) to match your needs.
94
+
95
+ ---
96
+
97
+ ## SQLite backend notes
98
+
99
+ When `DB_TYPE` is `sqlite` (see the [configuration guide](configuration.md)), the app uses one `better-sqlite3` connection wrapped by `lib/db/sqlitePool.js`. The schema comes from [`docs/schema/db.sqlite.sql`](../schema/db.sqlite.sql) instead of `db.sql`.
100
+
101
+ - The database runs in WAL journal mode with foreign keys enabled. Expect `-wal` and `-shm` side files next to the database file; do not delete them while the app is running, and include them when copying a live database (or checkpoint first with `PRAGMA wal_checkpoint(TRUNCATE)`).
102
+ - Concurrent requests are safe: an internal FIFO mutex serializes statements so a plain query can never run inside another request's open transaction.
103
+
104
+ ### Warning: inside a transaction, only use `txRepo`
105
+
106
+ The mutex is not re-entrant. `pool.query()` waits for the mutex, and an open transaction holds it — so a `pool.query()` call from inside a `withTransaction` callback waits on the transaction it is part of and **deadlocks the request** (it hangs with no error). This includes indirect calls through a repository bound to the pool.
107
+
108
+ ```javascript
109
+ await authRepo.withTransaction(async (txRepo) => {
110
+ // Correct: txRepo wraps the client that holds the lock.
111
+ await txRepo.insertAppSession(username, expiresAt, meta);
112
+
113
+ // WRONG - deadlocks: authRepo is bound to the pool, which
114
+ // waits for the lock this transaction is holding.
115
+ // await authRepo.getUserProfileByUsername(username);
116
+
117
+ // WRONG for the same reason:
118
+ // await pool.query('SELECT 1');
119
+ });
120
+ ```
121
+
122
+ Rule of thumb: inside the `withTransaction` callback, only touch the `txRepo` parameter — never `authRepo`, the pool, or anything else that reaches the pool. Queries against the pool are fine again once the callback returns.
123
+
124
+ (The Postgres backend has the same rule with a different failure mode: a `pool.query()` inside a transaction runs on a different pooled connection, silently outside the transaction.)
@@ -98,7 +98,7 @@ When a user logs in, MBKAuthe creates a session and sets the following cookies:
98
98
  ### Session Lifetime
99
99
 
100
100
  - Default: 2 days (configurable via `COOKIE_EXPIRE_TIME`)
101
- - Application sessions are stored in the `Sessions` table in PostgreSQL
101
+ - Application sessions are stored in the `Sessions` table in the configured database (PostgreSQL or SQLite, per `DB_TYPE`)
102
102
  - Sessions persist across subdomains in production
103
103
 
104
104
  ---
@@ -0,0 +1,155 @@
1
+ -- SQLite schema for mbkauthe.
2
+ --
3
+ -- This mirrors the auth-relevant subset of docs/schema/db.sql (Postgres).
4
+ -- Tables belonging to the wider MBKTech platform rather than mbkauthe
5
+ -- itself (todos, notifications, categories, etc.) are intentionally not
6
+ -- included here — add those separately in your own app schema if needed.
7
+ --
8
+ -- Notes on the Postgres -> SQLite translation:
9
+ -- * "integer GENERATED ... AS IDENTITY" -> INTEGER PRIMARY KEY AUTOINCREMENT
10
+ -- * "boolean" -> INTEGER (0/1)
11
+ -- * "jsonb"/"json" -> TEXT (store JSON as text)
12
+ -- * "timestamp with time zone" -> TEXT (CURRENT_TIMESTAMP format)
13
+ -- * "uuid DEFAULT gen_random_uuid()" -> TEXT DEFAULT (uuid v4 expression)
14
+ -- * GIN indexes / COMMENT ON TABLE -> omitted (no SQLite equivalent)
15
+
16
+ PRAGMA foreign_keys = ON;
17
+
18
+ -- Table: Users
19
+ CREATE TABLE IF NOT EXISTS "Users" (
20
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
21
+ "UserName" TEXT UNIQUE,
22
+ "Password" TEXT DEFAULT '12345670',
23
+ "Active" INTEGER DEFAULT 0,
24
+ "Role" TEXT DEFAULT 'NormalUser',
25
+ "HaveMailAccount" INTEGER DEFAULT 0,
26
+ "AllowedApps" TEXT DEFAULT '["Portal", "mbkauthe"]',
27
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
28
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
29
+ last_login TEXT,
30
+ "PasswordEnc" TEXT,
31
+ "FullName" TEXT,
32
+ "UserId" TEXT UNIQUE CHECK ("UserId" IS NULL OR length("UserId") = 9),
33
+ email TEXT DEFAULT 'support@mbktech.org',
34
+ "Image" TEXT DEFAULT 'https://portal.mbktech.org/icon.svg',
35
+ "Bio" TEXT DEFAULT 'I am ....',
36
+ "SocialAccounts" TEXT DEFAULT '{}',
37
+ "Positions" TEXT DEFAULT '{"Not_Permanent": "Member Is Not Permanent"}'
38
+ );
39
+ CREATE INDEX IF NOT EXISTS idx_users_active ON "Users" ("Active");
40
+ CREATE INDEX IF NOT EXISTS idx_users_email ON "Users" (email);
41
+ CREATE INDEX IF NOT EXISTS idx_users_last_login ON "Users" (last_login);
42
+ CREATE INDEX IF NOT EXISTS idx_users_role ON "Users" ("Role");
43
+ CREATE INDEX IF NOT EXISTS idx_users_username ON "Users" ("UserName");
44
+ CREATE INDEX IF NOT EXISTS idx_users_userid ON "Users" ("UserId");
45
+
46
+ -- Table: ApiTokens
47
+ CREATE TABLE IF NOT EXISTS "ApiTokens" (
48
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
49
+ "UserName" TEXT NOT NULL REFERENCES "Users"("UserName") ON DELETE CASCADE,
50
+ "Name" TEXT NOT NULL CHECK (length(trim("Name")) > 0),
51
+ "TokenHash" TEXT NOT NULL UNIQUE,
52
+ "Prefix" TEXT NOT NULL,
53
+ "Permissions" TEXT DEFAULT '{"scope": "read-only", "allowedApps": null}' NOT NULL,
54
+ "LastUsed" TEXT,
55
+ "CreatedAt" TEXT DEFAULT CURRENT_TIMESTAMP,
56
+ "ExpiresAt" TEXT,
57
+ CHECK ("ExpiresAt" IS NULL OR "ExpiresAt" > "CreatedAt")
58
+ );
59
+ CREATE INDEX IF NOT EXISTS idx_apitokens_expires ON "ApiTokens" ("ExpiresAt") WHERE "ExpiresAt" IS NOT NULL;
60
+ CREATE INDEX IF NOT EXISTS idx_apitokens_username_created ON "ApiTokens" ("UserName", "CreatedAt" DESC);
61
+
62
+ -- Table: PasswordResets
63
+ CREATE TABLE IF NOT EXISTS "PasswordResets" (
64
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
65
+ "UserName" TEXT NOT NULL REFERENCES "Users"("UserName") ON DELETE CASCADE,
66
+ "resetToken" TEXT,
67
+ "resetTokenExpires" TEXT,
68
+ "resetAttempts" INTEGER DEFAULT 0,
69
+ "lastResetAttempt" TEXT,
70
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
71
+ );
72
+ CREATE INDEX IF NOT EXISTS idx_password_resets_token ON "PasswordResets" ("resetToken");
73
+
74
+ -- Table: Sessions (mbkauthe's own app-session table — distinct from the
75
+ -- express-session store table below, which is just "session")
76
+ CREATE TABLE IF NOT EXISTS "Sessions" (
77
+ id TEXT PRIMARY KEY DEFAULT (
78
+ lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' ||
79
+ substr(lower(hex(randomblob(2))), 2) || '-' ||
80
+ substr('89ab', (abs(random()) % 4) + 1, 1) || substr(lower(hex(randomblob(2))), 2) || '-' ||
81
+ lower(hex(randomblob(6)))
82
+ ),
83
+ "UserName" TEXT NOT NULL REFERENCES "Users"("UserName") ON DELETE CASCADE,
84
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
85
+ expires_at TEXT,
86
+ meta TEXT
87
+ );
88
+ CREATE INDEX IF NOT EXISTS idx_sessions_expires ON "Sessions" (expires_at) WHERE expires_at IS NOT NULL;
89
+ CREATE INDEX IF NOT EXISTS idx_sessions_user_created ON "Sessions" ("UserName", created_at);
90
+ CREATE INDEX IF NOT EXISTS idx_sessions_username_expires ON "Sessions" ("UserName", expires_at);
91
+
92
+ -- Table: TrustedDevices
93
+ CREATE TABLE IF NOT EXISTS "TrustedDevices" (
94
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
95
+ "UserName" TEXT NOT NULL REFERENCES "Users"("UserName") ON DELETE CASCADE,
96
+ "DeviceToken" TEXT NOT NULL UNIQUE,
97
+ "DeviceName" TEXT,
98
+ "UserAgent" TEXT,
99
+ "IpAddress" TEXT,
100
+ "CreatedAt" TEXT DEFAULT CURRENT_TIMESTAMP,
101
+ "ExpiresAt" TEXT NOT NULL,
102
+ "LastUsed" TEXT DEFAULT CURRENT_TIMESTAMP
103
+ );
104
+ CREATE INDEX IF NOT EXISTS idx_trusted_devices_expires ON "TrustedDevices" ("ExpiresAt");
105
+ CREATE INDEX IF NOT EXISTS idx_trusted_devices_username_expires ON "TrustedDevices" ("UserName", "ExpiresAt");
106
+ CREATE INDEX IF NOT EXISTS idx_trusted_devices_token_user_expires ON "TrustedDevices" ("DeviceToken", "UserName", "ExpiresAt");
107
+
108
+ -- Table: TwoFA
109
+ CREATE TABLE IF NOT EXISTS "TwoFA" (
110
+ "UserName" TEXT NOT NULL PRIMARY KEY REFERENCES "Users"("UserName") ON DELETE CASCADE,
111
+ "TwoFAStatus" INTEGER DEFAULT 0 NOT NULL,
112
+ "TwoFASecret" TEXT
113
+ );
114
+ CREATE INDEX IF NOT EXISTS idx_twofa_username_status ON "TwoFA" ("UserName", "TwoFAStatus");
115
+
116
+ -- Table: session (express-session store; see lib/session/SqliteSessionStore.js)
117
+ CREATE TABLE IF NOT EXISTS "session" (
118
+ sid TEXT PRIMARY KEY,
119
+ sess TEXT NOT NULL,
120
+ expire TEXT NOT NULL,
121
+ username TEXT REFERENCES "Users"("UserName") ON DELETE CASCADE,
122
+ last_activity TEXT DEFAULT CURRENT_TIMESTAMP
123
+ );
124
+ CREATE INDEX IF NOT EXISTS idx_session_expire ON "session" (expire);
125
+
126
+ -- Table: user_github
127
+ CREATE TABLE IF NOT EXISTS user_github (
128
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
129
+ user_name TEXT REFERENCES "Users"("UserName") ON DELETE CASCADE,
130
+ github_id TEXT UNIQUE,
131
+ github_username TEXT,
132
+ access_token TEXT,
133
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
134
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
135
+ installation_id INTEGER,
136
+ installation_target_type TEXT
137
+ );
138
+ CREATE INDEX IF NOT EXISTS idx_user_github_user_name ON user_github (user_name);
139
+
140
+ -- Table: user_google
141
+ CREATE TABLE IF NOT EXISTS user_google (
142
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
143
+ user_name TEXT REFERENCES "Users"("UserName"),
144
+ google_id TEXT UNIQUE,
145
+ google_email TEXT,
146
+ access_token TEXT,
147
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
148
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
149
+ );
150
+
151
+ -- Seed user (hash-only). Default password is "12345678" for this sample account.
152
+ -- Hash was generated using mbkauthe's "hashPassword(password, username)" function.
153
+ INSERT INTO "Users" ("UserName", "PasswordEnc", "Role", "Active", "HaveMailAccount", "FullName")
154
+ VALUES ('support', 'b8b10c1c9006d8c30ab81c412463c65ff6dae3293d9bfbaf5fd8e275081d0947f000a828004e2fbd3a8f6ef5a35ae3eddd4c57b00ecab376b12e607a16a57459', 'SuperAdmin', 1, 0, 'Support User')
155
+ ON CONFLICT("UserName") DO NOTHING;
package/index.d.ts CHANGED
@@ -50,7 +50,12 @@ declare module 'mbkauthe' {
50
50
  Main_SECRET_TOKEN: string;
51
51
  IS_DEPLOYED: 'true' | 'false' | 'f';
52
52
  DOMAIN: string;
53
- LOGIN_DB: string;
53
+ /** Defaults to 'postgres'. Set to 'sqlite' to use the SQLite backend instead. */
54
+ DB_TYPE?: 'postgres' | 'sqlite';
55
+ /** Required when DB_TYPE is 'postgres' (default). */
56
+ LOGIN_DB?: string;
57
+ /** Required when DB_TYPE is 'sqlite'. Path to the SQLite database file. */
58
+ SQLITE_PATH?: string;
54
59
  MBKAUTH_TWO_FA_ENABLE: 'true' | 'false' | 'f';
55
60
  COOKIE_EXPIRE_TIME?: number;
56
61
  DEVICE_TRUST_DURATION_DAYS?: number;
@@ -339,7 +344,26 @@ declare module 'mbkauthe' {
339
344
  export function getLatestVersion(): Promise<string>;
340
345
 
341
346
  // Exports
342
- export const dblogin: Pool;
347
+ /** A `pg.Pool` when DB_TYPE is 'postgres' (default), or a SqlitePool-shaped adapter when DB_TYPE is 'sqlite'. */
348
+ export const dblogin: Pool | {
349
+ query(text: string | { text: string; values?: any[]; name?: string }, values?: any[]): Promise<{ rows: any[]; rowCount: number }>;
350
+ connect(): Promise<{ query: Function; release: () => void }>;
351
+ end(): Promise<void>;
352
+ };
353
+ export const dbType: 'postgres' | 'sqlite';
354
+ /** SQL dialect helpers for the active backend (see lib/db/dialects/). */
355
+ export const dialect: {
356
+ name: 'postgres' | 'sqlite';
357
+ quoteIdentifier(name: string): string;
358
+ param(index: number): string;
359
+ now(): string;
360
+ boolean(value: any): string;
361
+ supportsReturning: boolean;
362
+ returningClause(columns: string): string;
363
+ limitOffset(options?: { limit?: number; offset?: number }): string;
364
+ /** `null` on SQLite, which has no table-level lock statement. */
365
+ lockTable: ((tableSql: string, mode?: string) => string) | null;
366
+ };
343
367
  export const mbkautheVar: MBKAuthConfig;
344
368
  export const cachedCookieOptions: ReturnType<typeof getCookieOptions>;
345
369
  export const cachedClearCookieOptions: ReturnType<typeof getClearCookieOptions>;
package/index.js CHANGED
@@ -94,7 +94,7 @@ export * from "./lib/middleware/auth.js";
94
94
  export * from "./lib/middleware/index.js";
95
95
  export { validateTokenScope } from "./lib/middleware/scopeValidator.js";
96
96
  export * from "#response.js";
97
- export { dblogin } from "#pool.js";
97
+ export { dblogin, dbType, dialect } from "#pool.js";
98
98
  export { getLatestVersion } from "./lib/routes/misc.js";
99
99
  export * from "./lib/routes/auth.js";
100
100
  export * from "./lib/utils/errors.js";
@@ -7,7 +7,7 @@ const logConfig = createLogger("config");
7
7
 
8
8
  const CONFIG_KEYS = [
9
9
  "APP_NAME", "DEVICE_TRUST_DURATION_DAYS", "Main_SECRET_TOKEN", "SESSION_SECRET_KEY",
10
- "IS_DEPLOYED", "LOGIN_DB", "MBKAUTH_TWO_FA_ENABLE", "COOKIE_EXPIRE_TIME", "DOMAIN", "loginRedirectURL",
10
+ "IS_DEPLOYED", "DB_TYPE", "LOGIN_DB", "SQLITE_PATH", "MBKAUTH_TWO_FA_ENABLE", "COOKIE_EXPIRE_TIME", "DOMAIN", "loginRedirectURL",
11
11
  "GITHUB_LOGIN_ENABLED", "GITHUB_APP_CLIENT_ID", "GITHUB_APP_CLIENT_SECRET", "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET", "GOOGLE_LOGIN_ENABLED", "GOOGLE_CLIENT_ID",
12
12
  "GOOGLE_CLIENT_SECRET", "MAX_SESSIONS_PER_USER"
13
13
  ];
@@ -15,6 +15,8 @@ const CONFIG_KEYS = [
15
15
  const DEFAULT_CONFIG = {
16
16
  DEVICE_TRUST_DURATION_DAYS: 7,
17
17
  IS_DEPLOYED: 'false',
18
+ DB_TYPE: 'postgres',
19
+ SQLITE_PATH: './mbkauthe.sqlite',
18
20
  MBKAUTH_TWO_FA_ENABLE: 'false',
19
21
  COOKIE_EXPIRE_TIME: 2,
20
22
  loginRedirectURL: '/dashboard',
@@ -25,11 +27,13 @@ const DEFAULT_CONFIG = {
25
27
 
26
28
  const BOOLEAN_KEYS = ['GITHUB_LOGIN_ENABLED', 'GOOGLE_LOGIN_ENABLED', 'MBKAUTH_TWO_FA_ENABLE', 'IS_DEPLOYED'];
27
29
  const STRING_KEYS = [
28
- "APP_NAME", "Main_SECRET_TOKEN", "SESSION_SECRET_KEY", "LOGIN_DB", "DOMAIN", "loginRedirectURL",
30
+ "APP_NAME", "Main_SECRET_TOKEN", "SESSION_SECRET_KEY", "DB_TYPE", "LOGIN_DB", "SQLITE_PATH", "DOMAIN", "loginRedirectURL",
29
31
  "GITHUB_APP_CLIENT_ID", "GITHUB_APP_CLIENT_SECRET", "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET",
30
32
  "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"
31
33
  ];
32
- const REQUIRED_KEYS = ["APP_NAME", "Main_SECRET_TOKEN", "SESSION_SECRET_KEY", "IS_DEPLOYED", "LOGIN_DB",
34
+ // LOGIN_DB is required for postgres but not for sqlite (SQLITE_PATH is used instead);
35
+ // enforced conditionally inside validateConfiguration below.
36
+ const REQUIRED_KEYS = ["APP_NAME", "Main_SECRET_TOKEN", "SESSION_SECRET_KEY", "IS_DEPLOYED",
33
37
  "MBKAUTH_TWO_FA_ENABLE", "DOMAIN"];
34
38
 
35
39
  const isPlainObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
@@ -137,6 +141,13 @@ function normalizeAndValidateConfig(config, errors) {
137
141
  config.APP_NAME = config.APP_NAME.toLowerCase();
138
142
  }
139
143
 
144
+ if (hasValue(config.DB_TYPE)) {
145
+ config.DB_TYPE = config.DB_TYPE.toLowerCase();
146
+ if (!["postgres", "sqlite"].includes(config.DB_TYPE)) {
147
+ errors.push("mbkautheVar.DB_TYPE must be either 'postgres' or 'sqlite'");
148
+ }
149
+ }
150
+
140
151
  if (hasValue(config.DOMAIN)) {
141
152
  const domain = config.DOMAIN.toLowerCase().replace(/^\.+/, '');
142
153
  config.DOMAIN = domain;
@@ -203,9 +214,17 @@ function validateConfiguration() {
203
214
  }
204
215
  }
205
216
 
206
- // Validate LOGIN_DB connection string format
207
- if (mbkautheVar.LOGIN_DB && !mbkautheVar.LOGIN_DB.startsWith('postgresql://') && !mbkautheVar.LOGIN_DB.startsWith('postgres://')) {
208
- errors.push("mbkautheVar.LOGIN_DB must be a valid PostgreSQL connection string");
217
+ // Validate the DB connection setting for the selected DB_TYPE
218
+ if (mbkautheVar.DB_TYPE === "sqlite") {
219
+ if (isBlank(mbkautheVar.SQLITE_PATH)) {
220
+ errors.push("mbkautheVar.SQLITE_PATH is required and cannot be empty when DB_TYPE is 'sqlite'");
221
+ }
222
+ } else {
223
+ if (isBlank(mbkautheVar.LOGIN_DB)) {
224
+ errors.push("mbkautheVar.LOGIN_DB is required and cannot be empty when DB_TYPE is 'postgres'");
225
+ } else if (!mbkautheVar.LOGIN_DB.startsWith('postgresql://') && !mbkautheVar.LOGIN_DB.startsWith('postgres://')) {
226
+ errors.push("mbkautheVar.LOGIN_DB must be a valid PostgreSQL connection string");
227
+ }
209
228
  }
210
229
 
211
230
  // If there are validation errors, throw them all at once
@@ -1,4 +1,4 @@
1
- import { dblogin } from "./pool.js";
1
+ import { dblogin, dbType } from "./pool.js";
2
2
  import { readFile } from "fs/promises";
3
3
  import path from "path";
4
4
  import { fileURLToPath } from "url";
@@ -11,8 +11,10 @@ async function main() {
11
11
  const startTime = performance.now();
12
12
 
13
13
  console.log("[mbkauthe] Starting schema creation...");
14
+ console.log(`[mbkauthe] Database type: ${dbType}`);
14
15
 
15
- const schemaPath = path.resolve(__dirname, "../docs/schema/db.sql");
16
+ const schemaFile = dbType === "sqlite" ? "db.sqlite.sql" : "db.sql";
17
+ const schemaPath = path.resolve(__dirname, "../docs/schema", schemaFile);
16
18
 
17
19
  console.log(`[mbkauthe] Schema file: ${schemaPath}`);
18
20
 
@@ -31,6 +33,28 @@ async function main() {
31
33
  `[mbkauthe] Detected approximately ${statementCount} SQL statements`
32
34
  );
33
35
 
36
+ if (dbType === "sqlite") {
37
+ try {
38
+ console.log("[mbkauthe] Applying SQLite schema...");
39
+ const queryStart = performance.now();
40
+
41
+ dblogin.execScript(schemaSql);
42
+
43
+ const queryDuration = (performance.now() - queryStart).toFixed(2);
44
+ console.log(`[mbkauthe] Schema applied successfully in ${queryDuration} ms`);
45
+ } catch (err) {
46
+ console.error("[mbkauthe] Failed to apply schema");
47
+ console.error("Message:", err.message);
48
+ process.exitCode = 1;
49
+ } finally {
50
+ console.log("[mbkauthe] Closing database connection...");
51
+ await dblogin.end();
52
+ const totalDuration = (performance.now() - startTime).toFixed(2);
53
+ console.log(`[mbkauthe] Finished in ${totalDuration} ms`);
54
+ }
55
+ return;
56
+ }
57
+
34
58
  try {
35
59
  console.log("[mbkauthe] Testing database connection...");
36
60
 
@@ -76,6 +76,38 @@ export class AuthRepository extends BaseRepository {
76
76
  }
77
77
 
78
78
  async touchTrustedDevice(deviceTokenHash, username) {
79
+ // SQLite's RETURNING clause can only reference columns of the table
80
+ // being updated, not columns joined in via FROM — so do the UPDATE and
81
+ // the follow-up SELECT as two statements for that dialect.
82
+ if (this.dialect.name === "sqlite") {
83
+ const updated = await this.executeRaw({
84
+ name: "check-trusted-device-update",
85
+ text: `
86
+ UPDATE "TrustedDevices"
87
+ SET "LastUsed" = CURRENT_TIMESTAMP
88
+ WHERE "DeviceToken" = $1
89
+ AND "UserName" = $2
90
+ AND "ExpiresAt" > CURRENT_TIMESTAMP
91
+ RETURNING "UserName", "ExpiresAt"
92
+ `,
93
+ values: [deviceTokenHash, username]
94
+ });
95
+
96
+ const row = updated.rows?.[0];
97
+ if (!row) return null;
98
+
99
+ const userResult = await this.executeRaw({
100
+ name: "check-trusted-device-user",
101
+ text: `SELECT "UserId", "Active", "Role", "AllowedApps" FROM "Users" WHERE "UserName" = $1 AND "Active" = TRUE`,
102
+ values: [row.UserName]
103
+ });
104
+
105
+ const userRow = userResult.rows?.[0];
106
+ if (!userRow) return null;
107
+
108
+ return { ...row, ...userRow };
109
+ }
110
+
79
111
  const query = `
80
112
  UPDATE "TrustedDevices" td
81
113
  SET "LastUsed" = NOW()
@@ -97,6 +129,22 @@ export class AuthRepository extends BaseRepository {
97
129
  }
98
130
 
99
131
  async cleanupAndCountUserSessions(username, queryName = "cleanup-and-count-user-sessions") {
132
+ // SQLite has no writable CTEs (WITH ... AS (DELETE ...) SELECT), so split
133
+ // this into two statements for that dialect instead.
134
+ if (this.dialect.name === "sqlite") {
135
+ await this.executeRaw({
136
+ name: `${queryName}-delete`,
137
+ text: `DELETE FROM "Sessions" WHERE "UserName" = $1 AND expires_at IS NOT NULL AND expires_at <= NOW()`,
138
+ values: [username]
139
+ });
140
+ const result = await this.executeRaw({
141
+ name: `${queryName}-count`,
142
+ text: `SELECT COUNT(*) AS count FROM "Sessions" WHERE "UserName" = $1`,
143
+ values: [username]
144
+ });
145
+ return Number(result.rows?.[0]?.count ?? 0);
146
+ }
147
+
100
148
  const query = `
101
149
  WITH deleted AS (
102
150
  DELETE FROM "Sessions"
@@ -245,7 +293,17 @@ export class AuthRepository extends BaseRepository {
245
293
  }
246
294
 
247
295
  async updateApiTokenLastUsed(tokenId, queryName = null, minIntervalMinutes = 15) {
248
- const query = `
296
+ const query = this.dialect.name === "sqlite"
297
+ ? `
298
+ UPDATE "ApiTokens"
299
+ SET "LastUsed" = CURRENT_TIMESTAMP
300
+ WHERE id = ?
301
+ AND (
302
+ "LastUsed" IS NULL
303
+ OR "LastUsed" < datetime('now', '-' || ? || ' minutes')
304
+ )
305
+ `
306
+ : `
249
307
  UPDATE "ApiTokens"
250
308
  SET "LastUsed" = NOW()
251
309
  WHERE id = $1
@@ -152,6 +152,14 @@ export class BaseRepository {
152
152
  return new this.constructor({ db, dialect: this.dialect });
153
153
  }
154
154
 
155
+ /**
156
+ * Runs `fn(txRepo)` inside BEGIN..COMMIT on a dedicated client.
157
+ * Inside the callback, use ONLY the provided txRepo. Calling the outer
158
+ * repository or the pool from within the callback breaks transactional
159
+ * semantics on Postgres (runs on a different pooled connection) and
160
+ * DEADLOCKS on SQLite (the pool waits for the mutex this transaction
161
+ * holds). See docs/guides/database.md, "SQLite backend notes".
162
+ */
155
163
  async withTransaction(fn) {
156
164
  if (!this.db || typeof this.db.connect !== "function") {
157
165
  return fn(this);
@@ -184,6 +192,11 @@ export class BaseRepository {
184
192
  }
185
193
 
186
194
  async advisoryTransactionLock(lockKey, queryName = "advisory-transaction-lock") {
195
+ // pg_advisory_xact_lock is Postgres-only. SQLite (via better-sqlite3) has
196
+ // no separate connections to contend over: a write transaction already
197
+ // holds an exclusive file-level lock for its duration, so this is a safe no-op.
198
+ if (this.dialect.name !== "postgres") return null;
199
+
187
200
  return this.executeRaw({
188
201
  name: queryName,
189
202
  text: "SELECT pg_advisory_xact_lock(hashtext($1))",
@@ -0,0 +1,25 @@
1
+ const quoteIdentifier = (name) => `"${String(name).replace(/"/g, '""')}"`;
2
+
3
+ export const sqliteDialect = {
4
+ name: "sqlite",
5
+ quoteIdentifier,
6
+ param: () => "?",
7
+ now: () => "CURRENT_TIMESTAMP",
8
+ boolean: (value) => (value ? "1" : "0"),
9
+ // better-sqlite3 bundles SQLite >= 3.35, which supports RETURNING.
10
+ supportsReturning: true,
11
+ returningClause: (columns) => ` RETURNING ${columns}`,
12
+ limitOffset: ({ limit, offset } = {}) => {
13
+ const parts = [];
14
+ if (typeof limit === "number") parts.push(`LIMIT ${limit}`);
15
+ // SQLite requires a LIMIT clause before OFFSET; -1 means "no limit".
16
+ if (typeof offset === "number") {
17
+ if (!parts.length) parts.push("LIMIT -1");
18
+ parts.push(`OFFSET ${offset}`);
19
+ }
20
+ return parts.length ? ` ${parts.join(" ")}` : "";
21
+ },
22
+ // SQLite has no table-level lock statement: writers are already serialized
23
+ // by the file-level lock taken for the duration of a write transaction.
24
+ lockTable: null
25
+ };