@devopsplaybook.io/common-utils 1.10.1 → 1.11.0-beta.24.d8f9aef
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 +73 -27
- package/dist/src/ConfigBase.d.ts +31 -0
- package/dist/src/ConfigBase.js +58 -16
- package/dist/src/DbUtils.d.ts +20 -3
- package/dist/src/DbUtils.js +93 -2
- package/dist/src/DbUtilsNoTelemetry.d.ts +4 -1
- package/dist/src/DbUtilsNoTelemetry.js +62 -6
- package/dist/src/PostgresDbUtils.d.ts +41 -10
- package/dist/src/PostgresDbUtils.js +357 -304
- package/dist/src/SqlDbUtils.d.ts +12 -4
- package/dist/src/SqlDbUtils.js +76 -30
- package/dist/src/users/Auth.d.ts +11 -1
- package/dist/src/users/Auth.js +160 -44
- package/dist/src/users/User.d.ts +10 -0
- package/dist/src/users/User.js +30 -10
- package/dist/src/users/UserApiToken.d.ts +4 -0
- package/dist/src/users/UserApiToken.js +11 -0
- package/dist/src/users/UsersApiTokensData.d.ts +12 -0
- package/dist/src/users/UsersApiTokensData.js +130 -33
- package/dist/src/users/UsersData.d.ts +20 -1
- package/dist/src/users/UsersData.js +150 -52
- package/dist/src/users/UsersRoutes.js +178 -61
- package/dist/src/users/index.d.ts +8 -0
- package/dist/src/users/index.js +24 -0
- package/package.json +58 -1
- package/.github/workflows/main-build.yml +0 -18
- package/.github/workflows/pr-check.yml +0 -27
- package/.github/workflows/reusable-merge-build.yml +0 -197
- package/.github/workflows/reusable-npm-merge.yml +0 -135
- package/.github/workflows/reusable-npm-pr.yml +0 -183
- package/.github/workflows/reusable-npm-upgrade.yml +0 -92
- package/.github/workflows/reusable-pr-verify.yml +0 -181
- package/AGENTS.md +0 -105
- package/index.ts +0 -18
- package/jest.config.js +0 -17
- package/prettierrc.json +0 -5
- package/src/ConfigBase.spec.ts +0 -108
- package/src/ConfigBase.ts +0 -297
- package/src/DbUtils.spec.ts +0 -23
- package/src/DbUtils.ts +0 -116
- package/src/DbUtilsNoTelemetry.spec.ts +0 -168
- package/src/DbUtilsNoTelemetry.ts +0 -117
- package/src/LLM.spec.ts +0 -303
- package/src/LLM.ts +0 -204
- package/src/Notifications.spec.ts +0 -265
- package/src/Notifications.ts +0 -201
- package/src/OTelContext.spec.ts +0 -58
- package/src/OTelContext.ts +0 -63
- package/src/PostgresDbUtils.spec.ts +0 -153
- package/src/PostgresDbUtils.ts +0 -666
- package/src/SqlDbUtils.spec.ts +0 -108
- package/src/SqlDbUtils.ts +0 -152
- package/src/SystemCommand.spec.ts +0 -18
- package/src/SystemCommand.ts +0 -23
- package/src/Timeout.spec.ts +0 -18
- package/src/Timeout.ts +0 -12
- package/src/users/Auth.spec.ts +0 -268
- package/src/users/Auth.ts +0 -202
- package/src/users/User.ts +0 -75
- package/src/users/UserApiToken.ts +0 -55
- package/src/users/UserPassword.spec.ts +0 -28
- package/src/users/UserPassword.ts +0 -20
- package/src/users/UserSession.ts +0 -9
- package/src/users/UsersApiTokensData.spec.ts +0 -158
- package/src/users/UsersApiTokensData.ts +0 -125
- package/src/users/UsersData.ts +0 -141
- package/src/users/UsersRoutes.ts +0 -374
- package/tsconfig.json +0 -15
- package/tsconfig.spec.json +0 -8
package/dist/src/SqlDbUtils.d.ts
CHANGED
|
@@ -17,7 +17,12 @@ export declare function SqlDbUtilsSetOTel(tracerIn: StandardTracer, loggerIn: St
|
|
|
17
17
|
*
|
|
18
18
|
* Migration files must follow the naming convention `init-NNNN.sql` and are
|
|
19
19
|
* applied in lexicographic order. A `metadata` table tracks which migrations
|
|
20
|
-
* have already been applied so they are idempotent.
|
|
20
|
+
* have already been applied so they are idempotent. Each migration file and
|
|
21
|
+
* its `db_version` row are applied inside a single transaction; a failing
|
|
22
|
+
* migration is rolled back and never recorded.
|
|
23
|
+
*
|
|
24
|
+
* SQLite only supports a single writer: run exactly one instance against a
|
|
25
|
+
* given database file.
|
|
21
26
|
*
|
|
22
27
|
* @param context Parent OTel span.
|
|
23
28
|
* @param config Configuration with `DATA_DIR`.
|
|
@@ -30,11 +35,14 @@ export declare function SqlDbUtilsGetDatabase(): Database.Database;
|
|
|
30
35
|
* Execute a write SQL statement with OTel tracing.
|
|
31
36
|
* @returns Number of rows changed.
|
|
32
37
|
*/
|
|
33
|
-
export declare function SqlDbUtilsExecSQL(context: Span, sql: string, params?: unknown[]): number;
|
|
34
|
-
/**
|
|
38
|
+
export declare function SqlDbUtilsExecSQL(context: Span | undefined, sql: string, params?: unknown[]): number;
|
|
39
|
+
/**
|
|
40
|
+
* Execute an entire SQL file (used for migrations).
|
|
41
|
+
* Migration files must not contain their own transaction control statements.
|
|
42
|
+
*/
|
|
35
43
|
export declare function SqlDbUtilsExecSQLFile(context: Span, filename: string): void;
|
|
36
44
|
/**
|
|
37
45
|
* Execute a read SQL query with OTel tracing.
|
|
38
46
|
* @returns Array of row objects.
|
|
39
47
|
*/
|
|
40
|
-
export declare function SqlDbUtilsQuerySQL(context: Span, sql: string, params?: unknown[], debug?: boolean): any[];
|
|
48
|
+
export declare function SqlDbUtilsQuerySQL(context: Span | undefined, sql: string, params?: unknown[], debug?: boolean): any[];
|
package/dist/src/SqlDbUtils.js
CHANGED
|
@@ -48,6 +48,27 @@ const api_1 = require("@opentelemetry/api");
|
|
|
48
48
|
let database;
|
|
49
49
|
let tracer;
|
|
50
50
|
let logger;
|
|
51
|
+
/**
|
|
52
|
+
* Compiled statements are cached: `better-sqlite3` has no internal cache and
|
|
53
|
+
* `prepare()` is the dominant cost on hot paths. The cache is bounded and is
|
|
54
|
+
* reset by {@link SqlDbUtilsInit} (statements belong to one `Database` handle).
|
|
55
|
+
*/
|
|
56
|
+
const PREPARED_STATEMENT_CACHE_MAX = 100;
|
|
57
|
+
let preparedStatements = new Map();
|
|
58
|
+
function prepareCached(sql) {
|
|
59
|
+
let statement = preparedStatements.get(sql);
|
|
60
|
+
if (!statement) {
|
|
61
|
+
if (preparedStatements.size >= PREPARED_STATEMENT_CACHE_MAX) {
|
|
62
|
+
const oldest = preparedStatements.keys().next().value;
|
|
63
|
+
if (oldest !== undefined) {
|
|
64
|
+
preparedStatements.delete(oldest);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
statement = database.prepare(sql);
|
|
68
|
+
preparedStatements.set(sql, statement);
|
|
69
|
+
}
|
|
70
|
+
return statement;
|
|
71
|
+
}
|
|
51
72
|
/**
|
|
52
73
|
* Injects the OTel tracer and logger instances used by all SQL operations.
|
|
53
74
|
* Must be called once at startup, before {@link SqlDbUtilsInit}.
|
|
@@ -61,7 +82,12 @@ function SqlDbUtilsSetOTel(tracerIn, loggerIn) {
|
|
|
61
82
|
*
|
|
62
83
|
* Migration files must follow the naming convention `init-NNNN.sql` and are
|
|
63
84
|
* applied in lexicographic order. A `metadata` table tracks which migrations
|
|
64
|
-
* have already been applied so they are idempotent.
|
|
85
|
+
* have already been applied so they are idempotent. Each migration file and
|
|
86
|
+
* its `db_version` row are applied inside a single transaction; a failing
|
|
87
|
+
* migration is rolled back and never recorded.
|
|
88
|
+
*
|
|
89
|
+
* SQLite only supports a single writer: run exactly one instance against a
|
|
90
|
+
* given database file.
|
|
65
91
|
*
|
|
66
92
|
* @param context Parent OTel span.
|
|
67
93
|
* @param config Configuration with `DATA_DIR`.
|
|
@@ -69,29 +95,43 @@ function SqlDbUtilsSetOTel(tracerIn, loggerIn) {
|
|
|
69
95
|
*/
|
|
70
96
|
async function SqlDbUtilsInit(context, config, sqlDir) {
|
|
71
97
|
const span = tracer.startSpan("SqlDbUtilsInit", context);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
98
|
+
try {
|
|
99
|
+
await fs.ensureDir(config.DATA_DIR);
|
|
100
|
+
database = new better_sqlite3_1.default(`${config.DATA_DIR}/database.db`);
|
|
101
|
+
preparedStatements = new Map();
|
|
102
|
+
SqlDbUtilsExecSQLFile(span, `${sqlDir}/init-0000.sql`);
|
|
103
|
+
const initFiles = (await fs.readdir(sqlDir)).sort();
|
|
104
|
+
let dbVersionApplied = 0;
|
|
105
|
+
// `metadata.value` has text affinity: a plain MAX(value) is lexicographic and
|
|
106
|
+
// ranks "9" above "10", which re-applies init-0010.sql on every boot.
|
|
107
|
+
const rows = SqlDbUtilsQuerySQL(span, "SELECT MAX(CAST(value AS INTEGER)) as maxVersion FROM metadata WHERE type='db_version'");
|
|
108
|
+
if (rows.length > 0 && rows[0].maxVersion !== null && rows[0].maxVersion !== undefined) {
|
|
109
|
+
dbVersionApplied = Number(rows[0].maxVersion);
|
|
110
|
+
}
|
|
111
|
+
logger.info(`Current DB Version: ${dbVersionApplied}`, span);
|
|
112
|
+
for (const initFile of initFiles) {
|
|
113
|
+
const regex = /init-(\d+)\.sql/g;
|
|
114
|
+
const match = regex.exec(initFile);
|
|
115
|
+
if (match) {
|
|
116
|
+
const dbVersionInitFile = Number(match[1]);
|
|
117
|
+
if (dbVersionInitFile > dbVersionApplied) {
|
|
118
|
+
logger.info(`Loading init file: ${initFile}`, span);
|
|
119
|
+
applyMigration(span, `${sqlDir}/${initFile}`, dbVersionInitFile);
|
|
120
|
+
}
|
|
91
121
|
}
|
|
92
122
|
}
|
|
93
123
|
}
|
|
94
|
-
|
|
124
|
+
finally {
|
|
125
|
+
span.end();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Apply one migration file and record its version atomically. */
|
|
129
|
+
function applyMigration(context, filename, version) {
|
|
130
|
+
const apply = database.transaction(() => {
|
|
131
|
+
SqlDbUtilsExecSQLFile(context, filename);
|
|
132
|
+
SqlDbUtilsExecSQL(context, "INSERT INTO metadata (type, value, dateCreated) VALUES ('db_version',?,?)", [version, new Date().toISOString()]);
|
|
133
|
+
});
|
|
134
|
+
apply();
|
|
95
135
|
}
|
|
96
136
|
/** Returns the underlying `better-sqlite3` Database instance. */
|
|
97
137
|
function SqlDbUtilsGetDatabase() {
|
|
@@ -104,33 +144,38 @@ function SqlDbUtilsGetDatabase() {
|
|
|
104
144
|
function SqlDbUtilsExecSQL(context, sql, params = []) {
|
|
105
145
|
const span = tracer.startSpan("SqlDbUtilsExecSQL", context);
|
|
106
146
|
try {
|
|
107
|
-
const stmt =
|
|
147
|
+
const stmt = prepareCached(sql);
|
|
108
148
|
const result = stmt.run(params);
|
|
109
149
|
span.addEvent(`Impacted Rows: ${result.changes}`);
|
|
110
|
-
span.end();
|
|
111
150
|
return result.changes;
|
|
112
151
|
}
|
|
113
152
|
catch (error) {
|
|
114
153
|
const err = error;
|
|
115
154
|
span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: err.message });
|
|
116
|
-
span.end();
|
|
117
155
|
throw error;
|
|
118
156
|
}
|
|
157
|
+
finally {
|
|
158
|
+
span.end();
|
|
159
|
+
}
|
|
119
160
|
}
|
|
120
|
-
/**
|
|
161
|
+
/**
|
|
162
|
+
* Execute an entire SQL file (used for migrations).
|
|
163
|
+
* Migration files must not contain their own transaction control statements.
|
|
164
|
+
*/
|
|
121
165
|
function SqlDbUtilsExecSQLFile(context, filename) {
|
|
122
166
|
const span = tracer.startSpan("SqlDbUtilsExecSQLFile", context);
|
|
123
167
|
try {
|
|
124
168
|
const sql = fs.readFileSync(filename).toString();
|
|
125
169
|
database.exec(sql);
|
|
126
|
-
span.end();
|
|
127
170
|
}
|
|
128
171
|
catch (error) {
|
|
129
172
|
const err = error;
|
|
130
173
|
span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: err.message });
|
|
131
|
-
span.end();
|
|
132
174
|
throw error;
|
|
133
175
|
}
|
|
176
|
+
finally {
|
|
177
|
+
span.end();
|
|
178
|
+
}
|
|
134
179
|
}
|
|
135
180
|
/**
|
|
136
181
|
* Execute a read SQL query with OTel tracing.
|
|
@@ -142,15 +187,16 @@ function SqlDbUtilsQuerySQL(context, sql, params = [], debug = false) {
|
|
|
142
187
|
console.log(sql);
|
|
143
188
|
}
|
|
144
189
|
try {
|
|
145
|
-
const stmt =
|
|
190
|
+
const stmt = prepareCached(sql);
|
|
146
191
|
const rows = stmt.all(params);
|
|
147
|
-
span.end();
|
|
148
192
|
return rows;
|
|
149
193
|
}
|
|
150
194
|
catch (error) {
|
|
151
195
|
const err = error;
|
|
152
196
|
span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: err.message });
|
|
153
|
-
span.end();
|
|
154
197
|
throw error;
|
|
155
198
|
}
|
|
199
|
+
finally {
|
|
200
|
+
span.end();
|
|
201
|
+
}
|
|
156
202
|
}
|
package/dist/src/users/Auth.d.ts
CHANGED
|
@@ -9,18 +9,28 @@ export interface AuthConfig {
|
|
|
9
9
|
JWT_KEY: string;
|
|
10
10
|
JWT_VALIDITY_DURATION: number;
|
|
11
11
|
DATABASE_TYPE: "sqlite" | "postgres";
|
|
12
|
+
/** Opt-in JWT revocation (requires the `users.tokenVersion` migration). */
|
|
13
|
+
JWT_REVOCATION_ENABLED?: boolean;
|
|
14
|
+
/** Per-user API token cap (defaults to 100 when unset). */
|
|
15
|
+
API_TOKENS_MAX_PER_USER?: number;
|
|
12
16
|
}
|
|
13
17
|
/**
|
|
14
18
|
* Injects the OTel tracer instance used by the auth module.
|
|
15
19
|
* Must be called once at startup, before {@link AuthInit}.
|
|
16
20
|
*/
|
|
17
21
|
export declare function AuthSetOTel(tracerIn: StandardTracer): void;
|
|
22
|
+
/** Whether the opt-in `JWT_REVOCATION_ENABLED` config flag is on. */
|
|
23
|
+
export declare function AuthJwtRevocationEnabled(): boolean;
|
|
24
|
+
/** Configured per-user API token cap (`API_TOKENS_MAX_PER_USER`). */
|
|
25
|
+
export declare function AuthGetApiTokensMaxPerUser(): number;
|
|
18
26
|
/**
|
|
19
27
|
* Initialise the auth module.
|
|
20
28
|
*
|
|
21
29
|
* Registers the full scope set of the host application and loads the JWT
|
|
22
30
|
* signing key from the `metadata` table. When no key is stored yet, a fresh
|
|
23
|
-
* one is generated and persisted.
|
|
31
|
+
* one is generated and persisted. The read/create sequence runs under an
|
|
32
|
+
* advisory lock on Postgres so concurrently booting replicas agree on a
|
|
33
|
+
* single key; SQLite has a single writer (see README).
|
|
24
34
|
*
|
|
25
35
|
* @param context Parent OTel span.
|
|
26
36
|
* @param configIn Server configuration (JWT_KEY is updated in place).
|
package/dist/src/users/Auth.js
CHANGED
|
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.AuthSetOTel = AuthSetOTel;
|
|
37
|
+
exports.AuthJwtRevocationEnabled = AuthJwtRevocationEnabled;
|
|
38
|
+
exports.AuthGetApiTokensMaxPerUser = AuthGetApiTokensMaxPerUser;
|
|
37
39
|
exports.AuthInit = AuthInit;
|
|
38
40
|
exports.AuthGenerateJWT = AuthGenerateJWT;
|
|
39
41
|
exports.AuthMustBeAuthenticated = AuthMustBeAuthenticated;
|
|
@@ -47,6 +49,20 @@ const DbUtils_1 = require("../DbUtils");
|
|
|
47
49
|
const User_1 = require("./User");
|
|
48
50
|
const UsersApiTokensData_1 = require("./UsersApiTokensData");
|
|
49
51
|
const UsersData_1 = require("./UsersData");
|
|
52
|
+
/** Default per-user API token cap when the config field is not set. */
|
|
53
|
+
const DEFAULT_API_TOKENS_MAX_PER_USER = 100;
|
|
54
|
+
/**
|
|
55
|
+
* Rejected non-JWT credentials are remembered for a short time so that a
|
|
56
|
+
* flood of bad tokens does not hit the database twice per request. Only the
|
|
57
|
+
* SHA-256 hash of the presented value is stored, never the plaintext.
|
|
58
|
+
*/
|
|
59
|
+
const REJECTED_CREDENTIALS_MAX = 1024;
|
|
60
|
+
const REJECTED_CREDENTIALS_TTL_MS = 60000;
|
|
61
|
+
const rejectedCredentials = new Map();
|
|
62
|
+
/** `lastUsedAt` is written at most once per hour per token. */
|
|
63
|
+
const LAST_USED_UPDATE_INTERVAL_MS = 60 * 60 * 1000;
|
|
64
|
+
const LAST_USED_TRACKED_MAX = 4096;
|
|
65
|
+
const lastUsedWrites = new Map();
|
|
50
66
|
let tracer;
|
|
51
67
|
let config;
|
|
52
68
|
/**
|
|
@@ -56,12 +72,23 @@ let config;
|
|
|
56
72
|
function AuthSetOTel(tracerIn) {
|
|
57
73
|
tracer = tracerIn;
|
|
58
74
|
}
|
|
75
|
+
/** Whether the opt-in `JWT_REVOCATION_ENABLED` config flag is on. */
|
|
76
|
+
function AuthJwtRevocationEnabled() {
|
|
77
|
+
return (config === null || config === void 0 ? void 0 : config.JWT_REVOCATION_ENABLED) === true;
|
|
78
|
+
}
|
|
79
|
+
/** Configured per-user API token cap (`API_TOKENS_MAX_PER_USER`). */
|
|
80
|
+
function AuthGetApiTokensMaxPerUser() {
|
|
81
|
+
var _a;
|
|
82
|
+
return (_a = config === null || config === void 0 ? void 0 : config.API_TOKENS_MAX_PER_USER) !== null && _a !== void 0 ? _a : DEFAULT_API_TOKENS_MAX_PER_USER;
|
|
83
|
+
}
|
|
59
84
|
/**
|
|
60
85
|
* Initialise the auth module.
|
|
61
86
|
*
|
|
62
87
|
* Registers the full scope set of the host application and loads the JWT
|
|
63
88
|
* signing key from the `metadata` table. When no key is stored yet, a fresh
|
|
64
|
-
* one is generated and persisted.
|
|
89
|
+
* one is generated and persisted. The read/create sequence runs under an
|
|
90
|
+
* advisory lock on Postgres so concurrently booting replicas agree on a
|
|
91
|
+
* single key; SQLite has a single writer (see README).
|
|
65
92
|
*
|
|
66
93
|
* @param context Parent OTel span.
|
|
67
94
|
* @param configIn Server configuration (JWT_KEY is updated in place).
|
|
@@ -71,87 +98,176 @@ async function AuthInit(context, configIn, allScopes = []) {
|
|
|
71
98
|
config = configIn;
|
|
72
99
|
User_1.User.ALL_SCOPES = [...allScopes];
|
|
73
100
|
const span = tracer.startSpan("AuthInit", context);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
101
|
+
try {
|
|
102
|
+
await (0, DbUtils_1.DbUtilsWithLock)("auth_token", async () => {
|
|
103
|
+
const authKeyRaw = await (0, DbUtils_1.DbUtilsQuerySQL)(span, SQL_QUERIES.GET_AUTH_TOKEN);
|
|
104
|
+
if (authKeyRaw.length == 0) {
|
|
105
|
+
configIn.JWT_KEY = (0, uuid_1.v4)();
|
|
106
|
+
await (0, DbUtils_1.DbUtilsExecSQL)(span, SQL_QUERIES.INSERT_AUTH_TOKEN, [
|
|
107
|
+
configIn.JWT_KEY,
|
|
108
|
+
new Date().toISOString(),
|
|
109
|
+
]);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
configIn.JWT_KEY = authKeyRaw[0].value;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
span.end();
|
|
118
|
+
}
|
|
86
119
|
}
|
|
87
120
|
async function AuthGenerateJWT(user) {
|
|
121
|
+
var _a;
|
|
88
122
|
return jwt.sign({
|
|
89
123
|
exp: Math.floor(Date.now() / 1000) + config.JWT_VALIDITY_DURATION,
|
|
90
124
|
userId: user.id,
|
|
91
125
|
userName: user.name,
|
|
92
126
|
role: user.role,
|
|
93
127
|
scopes: user.role === "admin" ? User_1.User.ALL_SCOPES : user.scopes,
|
|
94
|
-
|
|
128
|
+
tokenVersion: (_a = user.tokenVersion) !== null && _a !== void 0 ? _a : 0,
|
|
129
|
+
}, config.JWT_KEY, { algorithm: "HS256" });
|
|
95
130
|
}
|
|
96
131
|
/**
|
|
97
132
|
* Decode credentials from request, caching result on req._jwtPayload to
|
|
98
133
|
* avoid redundant resolution when multiple auth functions are called per
|
|
99
134
|
* request.
|
|
100
135
|
*
|
|
101
|
-
* A `Bearer` credential is first verified as a JWT. When JWT
|
|
102
|
-
* fails, the credential is resolved as a user API token: the
|
|
103
|
-
* SHA-256 hashed and looked up in `users_api_tokens`; on match, a
|
|
104
|
-
* mirroring the owning user's live role and scopes is built (valid
|
|
105
|
-
* the token is revoked).
|
|
136
|
+
* A `Bearer` credential is first verified as a JWT (HS256 only). When JWT
|
|
137
|
+
* verification fails, the credential is resolved as a user API token: the
|
|
138
|
+
* value is SHA-256 hashed and looked up in `users_api_tokens`; on match, a
|
|
139
|
+
* payload mirroring the owning user's live role and scopes is built (valid
|
|
140
|
+
* until the token is revoked or expires).
|
|
141
|
+
*
|
|
142
|
+
* When `JWT_REVOCATION_ENABLED` is on, the live user is re-read after
|
|
143
|
+
* verification and the token is rejected when the user is gone or its
|
|
144
|
+
* `tokenVersion` differs from the claim.
|
|
106
145
|
*/
|
|
107
146
|
async function jwtDecodeCached(req) {
|
|
147
|
+
var _a;
|
|
108
148
|
if (req._jwtPayload) {
|
|
109
149
|
return req._jwtPayload;
|
|
110
150
|
}
|
|
111
|
-
|
|
151
|
+
const authorization = (_a = req.headers) === null || _a === void 0 ? void 0 : _a.authorization;
|
|
152
|
+
if (!authorization) {
|
|
112
153
|
return null;
|
|
113
154
|
}
|
|
155
|
+
const token = authorization.split(" ")[1];
|
|
156
|
+
if (!token) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
let info;
|
|
114
160
|
try {
|
|
115
|
-
|
|
116
|
-
req._jwtPayload = info;
|
|
117
|
-
return info;
|
|
161
|
+
info = jwt.verify(token, config.JWT_KEY, { algorithms: ["HS256"] });
|
|
118
162
|
}
|
|
119
163
|
catch {
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
122
|
-
req._jwtPayload =
|
|
123
|
-
return
|
|
164
|
+
const apiInfo = await resolveApiToken(token);
|
|
165
|
+
if (apiInfo) {
|
|
166
|
+
req._jwtPayload = apiInfo;
|
|
167
|
+
return apiInfo;
|
|
124
168
|
}
|
|
125
169
|
return null;
|
|
126
170
|
}
|
|
171
|
+
if (config.JWT_REVOCATION_ENABLED && !(await isTokenVersionCurrent(info))) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
req._jwtPayload = info;
|
|
175
|
+
return info;
|
|
176
|
+
}
|
|
177
|
+
/** Re-read the user and compare its live `tokenVersion` with the claim. */
|
|
178
|
+
async function isTokenVersionCurrent(info) {
|
|
179
|
+
var _a, _b;
|
|
180
|
+
const span = tracer.startSpan("AuthCheckTokenVersion");
|
|
181
|
+
try {
|
|
182
|
+
const user = await (0, UsersData_1.UsersDataGet)(span, info.userId);
|
|
183
|
+
if (!user) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
return Number((_a = user.tokenVersion) !== null && _a !== void 0 ? _a : 0) === Number((_b = info.tokenVersion) !== null && _b !== void 0 ? _b : 0);
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
span.end();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function isKnownRejectedCredential(tokenHash) {
|
|
193
|
+
const expiresAt = rejectedCredentials.get(tokenHash);
|
|
194
|
+
if (expiresAt === undefined) {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
if (expiresAt <= Date.now()) {
|
|
198
|
+
rejectedCredentials.delete(tokenHash);
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
function rememberRejectedCredential(tokenHash) {
|
|
204
|
+
if (rejectedCredentials.size >= REJECTED_CREDENTIALS_MAX) {
|
|
205
|
+
const oldest = rejectedCredentials.keys().next().value;
|
|
206
|
+
if (oldest !== undefined) {
|
|
207
|
+
rejectedCredentials.delete(oldest);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
rejectedCredentials.set(tokenHash, Date.now() + REJECTED_CREDENTIALS_TTL_MS);
|
|
211
|
+
}
|
|
212
|
+
function shouldWriteLastUsed(tokenId) {
|
|
213
|
+
const lastWrite = lastUsedWrites.get(tokenId);
|
|
214
|
+
const now = Date.now();
|
|
215
|
+
if (lastWrite !== undefined && now - lastWrite < LAST_USED_UPDATE_INTERVAL_MS) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
if (lastUsedWrites.size >= LAST_USED_TRACKED_MAX) {
|
|
219
|
+
const oldest = lastUsedWrites.keys().next().value;
|
|
220
|
+
if (oldest !== undefined) {
|
|
221
|
+
lastUsedWrites.delete(oldest);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
lastUsedWrites.set(tokenId, now);
|
|
225
|
+
return true;
|
|
127
226
|
}
|
|
128
227
|
/**
|
|
129
228
|
* Resolve an API token bearer credential to a user-backed payload.
|
|
130
229
|
* Permissions are read from the user record at resolution time, so
|
|
131
230
|
* role/scope changes apply to existing tokens immediately.
|
|
132
231
|
*/
|
|
133
|
-
async function resolveApiToken(
|
|
134
|
-
const
|
|
135
|
-
if (
|
|
232
|
+
async function resolveApiToken(token) {
|
|
233
|
+
const tokenHash = (0, crypto_1.createHash)("sha256").update(token).digest("hex");
|
|
234
|
+
if (isKnownRejectedCredential(tokenHash)) {
|
|
136
235
|
return null;
|
|
137
236
|
}
|
|
138
237
|
const span = tracer.startSpan("AuthResolveApiToken");
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
238
|
+
try {
|
|
239
|
+
const apiToken = await (0, UsersApiTokensData_1.UsersApiTokensDataGetByTokenHash)(span, tokenHash);
|
|
240
|
+
if (!apiToken) {
|
|
241
|
+
rememberRejectedCredential(tokenHash);
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
if (apiToken.expiresAt && Date.parse(apiToken.expiresAt) <= Date.now()) {
|
|
245
|
+
rememberRejectedCredential(tokenHash);
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
143
248
|
const user = await (0, UsersData_1.UsersDataGet)(span, apiToken.userId);
|
|
144
|
-
if (user) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
249
|
+
if (!user) {
|
|
250
|
+
rememberRejectedCredential(tokenHash);
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
if (shouldWriteLastUsed(apiToken.id)) {
|
|
254
|
+
try {
|
|
255
|
+
await (0, UsersApiTokensData_1.UsersApiTokensDataSetLastUsed)(span, apiToken.id, new Date().toISOString());
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// Best effort: a failed lastUsedAt write never breaks authentication.
|
|
259
|
+
}
|
|
151
260
|
}
|
|
261
|
+
return {
|
|
262
|
+
userId: user.id,
|
|
263
|
+
userName: user.name,
|
|
264
|
+
role: user.role,
|
|
265
|
+
scopes: user.role === "admin" ? [...User_1.User.ALL_SCOPES] : user.scopes,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
span.end();
|
|
152
270
|
}
|
|
153
|
-
span.end();
|
|
154
|
-
return payload;
|
|
155
271
|
}
|
|
156
272
|
async function AuthMustBeAuthenticated(req, res) {
|
|
157
273
|
if (!(await jwtDecodeCached(req))) {
|
|
@@ -199,6 +315,6 @@ async function AuthGetUserSession(req) {
|
|
|
199
315
|
// Written SQLite-first with quoted identifiers (valid for both backends);
|
|
200
316
|
// the DbUtils facade converts `?` placeholders for Postgres.
|
|
201
317
|
const SQL_QUERIES = {
|
|
202
|
-
GET_AUTH_TOKEN: "SELECT value FROM metadata WHERE \"type\" = 'auth_token' LIMIT 1",
|
|
318
|
+
GET_AUTH_TOKEN: "SELECT value FROM metadata WHERE \"type\" = 'auth_token' ORDER BY \"dateCreated\" DESC LIMIT 1",
|
|
203
319
|
INSERT_AUTH_TOKEN: 'INSERT INTO metadata ("type", "value", "dateCreated") VALUES (\'auth_token\', ?, ?)',
|
|
204
320
|
};
|
package/dist/src/users/User.d.ts
CHANGED
|
@@ -13,12 +13,22 @@ export declare class User {
|
|
|
13
13
|
static DEFAULT_SCOPES: UserScope[];
|
|
14
14
|
/** Full scope set of the host application, registered via `AuthInit`. */
|
|
15
15
|
static ALL_SCOPES: UserScope[];
|
|
16
|
+
/**
|
|
17
|
+
* Coerce a stored scope value to an array: JSON string and array columns
|
|
18
|
+
* are both accepted, anything else falls back to the default scopes.
|
|
19
|
+
*/
|
|
20
|
+
static normalizeScopes(value: unknown): UserScope[];
|
|
16
21
|
static fromJson(json: any): User | null;
|
|
17
22
|
id: string;
|
|
18
23
|
name: string;
|
|
19
24
|
passwordEncrypted: string;
|
|
20
25
|
role: UserRole;
|
|
21
26
|
scopes: UserScope[];
|
|
27
|
+
/**
|
|
28
|
+
* Incremented on password/role/scope changes when JWT revocation is
|
|
29
|
+
* enabled; JWTs carry the value they were issued with.
|
|
30
|
+
*/
|
|
31
|
+
tokenVersion: number;
|
|
22
32
|
constructor();
|
|
23
33
|
toJson(): any;
|
|
24
34
|
toTransportJson(): any;
|
package/dist/src/users/User.js
CHANGED
|
@@ -3,7 +3,29 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.User = void 0;
|
|
4
4
|
const uuid_1 = require("uuid");
|
|
5
5
|
class User {
|
|
6
|
+
/**
|
|
7
|
+
* Coerce a stored scope value to an array: JSON string and array columns
|
|
8
|
+
* are both accepted, anything else falls back to the default scopes.
|
|
9
|
+
*/
|
|
10
|
+
static normalizeScopes(value) {
|
|
11
|
+
if (Array.isArray(value)) {
|
|
12
|
+
return [...value];
|
|
13
|
+
}
|
|
14
|
+
if (typeof value === "string") {
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(value);
|
|
17
|
+
if (Array.isArray(parsed)) {
|
|
18
|
+
return parsed;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// fall through to the default scopes
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return [...User.DEFAULT_SCOPES];
|
|
26
|
+
}
|
|
6
27
|
static fromJson(json) {
|
|
28
|
+
var _a;
|
|
7
29
|
if (!json) {
|
|
8
30
|
return null;
|
|
9
31
|
}
|
|
@@ -11,26 +33,23 @@ class User {
|
|
|
11
33
|
if (json.id) {
|
|
12
34
|
user.id = json.id;
|
|
13
35
|
}
|
|
14
|
-
user.id = json.id;
|
|
15
36
|
user.name = json.name;
|
|
16
37
|
user.passwordEncrypted = json.passwordEncrypted;
|
|
17
38
|
user.role = json.role || "user";
|
|
39
|
+
user.tokenVersion = Number((_a = json.tokenVersion) !== null && _a !== void 0 ? _a : 0) || 0;
|
|
18
40
|
if (json.scopes) {
|
|
19
|
-
|
|
20
|
-
user.scopes =
|
|
21
|
-
typeof json.scopes === "string"
|
|
22
|
-
? JSON.parse(json.scopes)
|
|
23
|
-
: json.scopes;
|
|
24
|
-
}
|
|
25
|
-
catch {
|
|
26
|
-
user.scopes = [...User.DEFAULT_SCOPES];
|
|
27
|
-
}
|
|
41
|
+
user.scopes = User.normalizeScopes(json.scopes);
|
|
28
42
|
}
|
|
29
43
|
return user;
|
|
30
44
|
}
|
|
31
45
|
constructor() {
|
|
32
46
|
this.role = "user";
|
|
33
47
|
this.scopes = [...User.DEFAULT_SCOPES];
|
|
48
|
+
/**
|
|
49
|
+
* Incremented on password/role/scope changes when JWT revocation is
|
|
50
|
+
* enabled; JWTs carry the value they were issued with.
|
|
51
|
+
*/
|
|
52
|
+
this.tokenVersion = 0;
|
|
34
53
|
this.id = (0, uuid_1.v4)();
|
|
35
54
|
}
|
|
36
55
|
toJson() {
|
|
@@ -40,6 +59,7 @@ class User {
|
|
|
40
59
|
passwordEncrypted: this.passwordEncrypted,
|
|
41
60
|
role: this.role,
|
|
42
61
|
scopes: this.scopes,
|
|
62
|
+
tokenVersion: this.tokenVersion,
|
|
43
63
|
};
|
|
44
64
|
}
|
|
45
65
|
toTransportJson() {
|
|
@@ -13,6 +13,10 @@ export declare class UserApiToken {
|
|
|
13
13
|
userId: string;
|
|
14
14
|
tokenHash: string;
|
|
15
15
|
dateCreated: string;
|
|
16
|
+
/** Optional ISO expiry (`null` = never expires). */
|
|
17
|
+
expiresAt: string | null;
|
|
18
|
+
/** Last successful authentication with this token (best effort). */
|
|
19
|
+
lastUsedAt: string | null;
|
|
16
20
|
constructor();
|
|
17
21
|
toJson(): any;
|
|
18
22
|
toTransportJson(): any;
|
|
@@ -13,6 +13,7 @@ const uuid_1 = require("uuid");
|
|
|
13
13
|
class UserApiToken {
|
|
14
14
|
//
|
|
15
15
|
static fromJson(json) {
|
|
16
|
+
var _a, _b;
|
|
16
17
|
if (!json) {
|
|
17
18
|
return null;
|
|
18
19
|
}
|
|
@@ -22,9 +23,15 @@ class UserApiToken {
|
|
|
22
23
|
apiToken.userId = json.userId;
|
|
23
24
|
apiToken.tokenHash = json.tokenHash;
|
|
24
25
|
apiToken.dateCreated = json.dateCreated;
|
|
26
|
+
apiToken.expiresAt = (_a = json.expiresAt) !== null && _a !== void 0 ? _a : null;
|
|
27
|
+
apiToken.lastUsedAt = (_b = json.lastUsedAt) !== null && _b !== void 0 ? _b : null;
|
|
25
28
|
return apiToken;
|
|
26
29
|
}
|
|
27
30
|
constructor() {
|
|
31
|
+
/** Optional ISO expiry (`null` = never expires). */
|
|
32
|
+
this.expiresAt = null;
|
|
33
|
+
/** Last successful authentication with this token (best effort). */
|
|
34
|
+
this.lastUsedAt = null;
|
|
28
35
|
this.id = (0, uuid_1.v4)();
|
|
29
36
|
}
|
|
30
37
|
toJson() {
|
|
@@ -34,6 +41,8 @@ class UserApiToken {
|
|
|
34
41
|
userId: this.userId,
|
|
35
42
|
tokenHash: this.tokenHash,
|
|
36
43
|
dateCreated: this.dateCreated,
|
|
44
|
+
expiresAt: this.expiresAt,
|
|
45
|
+
lastUsedAt: this.lastUsedAt,
|
|
37
46
|
};
|
|
38
47
|
}
|
|
39
48
|
// Transport representation: never exposes the token hash.
|
|
@@ -43,6 +52,8 @@ class UserApiToken {
|
|
|
43
52
|
name: this.name,
|
|
44
53
|
userId: this.userId,
|
|
45
54
|
dateCreated: this.dateCreated,
|
|
55
|
+
expiresAt: this.expiresAt,
|
|
56
|
+
lastUsedAt: this.lastUsedAt,
|
|
46
57
|
};
|
|
47
58
|
}
|
|
48
59
|
}
|