@webtypen/webframez-core 0.3.78 → 0.3.81
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/dist/Auth/ModelAuth.d.ts +1 -1
- package/dist/Auth/ModelAuth.js +4 -4
- package/dist/Auth/SessionAuth.d.ts +10 -1
- package/dist/Auth/SessionAuth.js +99 -12
- package/dist/Commands/QueueWorkerCommand.d.ts +9 -1
- package/dist/Commands/QueueWorkerCommand.js +113 -28
- package/dist/ConsoleApplication.js +4 -0
- package/docs/authentication.md +22 -0
- package/docs/queue-workers.md +9 -0
- package/package.json +3 -2
package/dist/Auth/ModelAuth.d.ts
CHANGED
|
@@ -86,7 +86,7 @@ export declare class AuthScope extends WebAuth {
|
|
|
86
86
|
login(req: Request, res: Response, verifyCredentials?: () => Promise<string | null>): Promise<AuthSession>;
|
|
87
87
|
logout(req: Request, res: Response): Promise<void>;
|
|
88
88
|
/** Trusted SSO/server entry point; the subject must already be verified by the caller. */
|
|
89
|
-
establishBearerSession(req: Request, subject: string, parent?: AuthSession["parent"], environment?: string): Promise<{
|
|
89
|
+
establishBearerSession(req: Request, subject: string, parent?: AuthSession["parent"], environment?: string, parentExpiresAt?: number): Promise<{
|
|
90
90
|
auth_token: string;
|
|
91
91
|
refresh_token: string;
|
|
92
92
|
auth_expires_at: number;
|
package/dist/Auth/ModelAuth.js
CHANGED
|
@@ -211,7 +211,7 @@ class AuthScope extends WebAuth_1.WebAuth {
|
|
|
211
211
|
});
|
|
212
212
|
}
|
|
213
213
|
/** Trusted SSO/server entry point; the subject must already be verified by the caller. */
|
|
214
|
-
establishBearerSession(req, subject, parent = null, environment) {
|
|
214
|
+
establishBearerSession(req, subject, parent = null, environment, parentExpiresAt) {
|
|
215
215
|
return __awaiter(this, void 0, void 0, function* () {
|
|
216
216
|
if (this.modelOptions.transport !== "bearer")
|
|
217
217
|
throw new Error("Bearer transport is not enabled for this auth scope.");
|
|
@@ -229,14 +229,14 @@ class AuthScope extends WebAuth_1.WebAuth {
|
|
|
229
229
|
throw new AuthHookError("login_blocked", error);
|
|
230
230
|
}
|
|
231
231
|
}
|
|
232
|
-
const pair = yield this.sessions.create(subject, parent, environment);
|
|
233
|
-
if (this.modelOptions.trackActivity !== false)
|
|
232
|
+
const pair = yield this.sessions.create(subject, parent, environment, parentExpiresAt);
|
|
233
|
+
if (!pair.reused && this.modelOptions.trackActivity !== false)
|
|
234
234
|
yield this.sessions.recordLogin(pair.session, (0, AuthSecurity_1.authHeader)(req, "user-agent"));
|
|
235
235
|
if (this.modelOptions.onLogin)
|
|
236
236
|
yield this.modelOptions.onLogin(pair.session, req);
|
|
237
237
|
if (this.modelOptions.afterLogin)
|
|
238
238
|
yield this.modelOptions.afterLogin(req, user, this);
|
|
239
|
-
const { csrf_token } = pair, tokens = __rest(pair, ["csrf_token"]);
|
|
239
|
+
const { csrf_token, reused } = pair, tokens = __rest(pair, ["csrf_token", "reused"]);
|
|
240
240
|
return tokens;
|
|
241
241
|
});
|
|
242
242
|
}
|
|
@@ -24,6 +24,7 @@ export type SessionTokenPair = {
|
|
|
24
24
|
};
|
|
25
25
|
export type CreatedAuthSession = SessionTokenPair & {
|
|
26
26
|
csrf_token: string;
|
|
27
|
+
reused?: boolean;
|
|
27
28
|
};
|
|
28
29
|
export type SessionAuthOptions = {
|
|
29
30
|
issuer: string;
|
|
@@ -32,6 +33,11 @@ export type SessionAuthOptions = {
|
|
|
32
33
|
environment?: string;
|
|
33
34
|
/** Allow server-selected environments per session instead of one fixed environment. */
|
|
34
35
|
allowDynamicEnvironment?: boolean;
|
|
36
|
+
/** Reuse one child per parent, subject and environment. Requires a strong server-held secret. */
|
|
37
|
+
parentSessions?: {
|
|
38
|
+
mode: "new" | "reuse";
|
|
39
|
+
secret?: string;
|
|
40
|
+
};
|
|
35
41
|
connection?: string;
|
|
36
42
|
collection?: string;
|
|
37
43
|
accessTokenSeconds?: number;
|
|
@@ -60,7 +66,10 @@ export declare class SessionAuth {
|
|
|
60
66
|
private liveFilter;
|
|
61
67
|
private allowed;
|
|
62
68
|
private pair;
|
|
63
|
-
|
|
69
|
+
private credentials;
|
|
70
|
+
private reuse;
|
|
71
|
+
private linkedRecord;
|
|
72
|
+
create(subject: string, parent?: AuthSession["parent"], environment?: string, parentExpiresAt?: number): Promise<CreatedAuthSession>;
|
|
64
73
|
authenticate(token: string): Promise<AuthSession | null>;
|
|
65
74
|
/** Trusted-server introspection; never expose this method as an unauthenticated endpoint. */
|
|
66
75
|
inspect(sessionId: string): Promise<AuthSession | null>;
|
package/dist/Auth/SessionAuth.js
CHANGED
|
@@ -10,6 +10,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
10
10
|
};
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.SessionAuth = void 0;
|
|
13
|
+
const crypto_1 = require("crypto");
|
|
13
14
|
const DBConnection_1 = require("../Database/DBConnection");
|
|
14
15
|
const AuthSecurity_1 = require("./AuthSecurity");
|
|
15
16
|
function publicSession(record) {
|
|
@@ -21,13 +22,16 @@ function tokenSessionId(token) {
|
|
|
21
22
|
/** Revocable, hashed, independently addressable device sessions. Legacy UserAuth is unaffected. */
|
|
22
23
|
class SessionAuth {
|
|
23
24
|
constructor(options) {
|
|
24
|
-
var _a;
|
|
25
|
+
var _a, _b;
|
|
25
26
|
this.options = options;
|
|
26
27
|
this.scope = Object.assign({ issuer: (0, AuthSecurity_1.authText)(options.issuer, "issuer"), audience: (0, AuthSecurity_1.authText)(options.audience, "audience"), scope: (0, AuthSecurity_1.authText)((_a = options.scope) !== null && _a !== void 0 ? _a : "main", "scope") }, (options.allowDynamicEnvironment && options.environment === undefined ? {}
|
|
27
28
|
: { environment: options.environment === undefined ? null : (0, AuthSecurity_1.authText)(options.environment, "environment") }));
|
|
28
29
|
this.accessSeconds = (0, AuthSecurity_1.authLifetime)(options.accessTokenSeconds, 15 * 60);
|
|
29
30
|
this.sessionSeconds = (0, AuthSecurity_1.authLifetime)(options.sessionSeconds, 30 * 86400);
|
|
30
31
|
this.idleSeconds = options.idleTimeoutSeconds === undefined || options.idleTimeoutSeconds === false ? false : (0, AuthSecurity_1.authLifetime)(options.idleTimeoutSeconds, 7 * 86400);
|
|
32
|
+
if (((_b = options.parentSessions) === null || _b === void 0 ? void 0 : _b.mode) === "reuse" && (typeof options.parentSessions.secret !== "string" || options.parentSessions.secret.length < 32)) {
|
|
33
|
+
throw new Error("Parent session reuse requires a strong server secret.");
|
|
34
|
+
}
|
|
31
35
|
if (typeof options.isSessionAllowed !== "function")
|
|
32
36
|
throw new Error("SessionAuth requires isSessionAllowed.");
|
|
33
37
|
}
|
|
@@ -66,8 +70,82 @@ class SessionAuth {
|
|
|
66
70
|
return { auth_token: access, refresh_token: refresh, auth_expires_at: record.accessExpiresAt,
|
|
67
71
|
refresh_expires_at: Math.min(record.expiresAt, record.createdAt + this.sessionSeconds * 1000), session: publicSession(record) };
|
|
68
72
|
}
|
|
69
|
-
|
|
73
|
+
credentials(record) {
|
|
74
|
+
var _a, _b, _c;
|
|
75
|
+
if (!record.credentialState || !((_a = this.options.parentSessions) === null || _a === void 0 ? void 0 : _a.secret))
|
|
76
|
+
throw new Error("Linked session credentials are unavailable.");
|
|
77
|
+
const context = JSON.stringify([String(record._id), String(record._subject), record.issuer, record.audience,
|
|
78
|
+
record.scope, record.environment, (_b = record.parent) === null || _b === void 0 ? void 0 : _b.issuer, (_c = record.parent) === null || _c === void 0 ? void 0 : _c.sessionId]);
|
|
79
|
+
const derive = (kind) => (0, crypto_1.createHmac)("sha256", this.options.parentSessions.secret)
|
|
80
|
+
.update(JSON.stringify(["webframez-parent-session-v1", context, kind, record.credentialState[kind]])).digest("base64url");
|
|
81
|
+
return { access: `${record._id}.${derive("access")}`, refresh: `${record._id}.${derive("refresh")}`, csrf: derive("csrf") };
|
|
82
|
+
}
|
|
83
|
+
reuse(record) {
|
|
84
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
85
|
+
if (!(yield this.allowed(record)))
|
|
86
|
+
throw new Error("Session is not allowed.");
|
|
87
|
+
const credentials = this.credentials(record);
|
|
88
|
+
if ((0, AuthSecurity_1.hashAuthToken)(credentials.access) !== record.accessHash || (0, AuthSecurity_1.hashAuthToken)(credentials.refresh) !== record.refreshHash
|
|
89
|
+
|| (0, AuthSecurity_1.hashAuthToken)(credentials.csrf) !== record.csrfHash)
|
|
90
|
+
throw new Error("Linked session credentials have changed.");
|
|
91
|
+
if (record.accessExpiresAt <= Date.now()) {
|
|
92
|
+
yield this.renewAccess(credentials.refresh);
|
|
93
|
+
const current = yield (yield this.rows()).findOne(Object.assign({ _id: record._id }, this.scope));
|
|
94
|
+
if (!current || current.accessExpiresAt <= Date.now())
|
|
95
|
+
throw new Error("Linked session access could not be renewed.");
|
|
96
|
+
return this.reuse(current);
|
|
97
|
+
}
|
|
98
|
+
const session = publicSession(record);
|
|
99
|
+
yield this.touch(session);
|
|
100
|
+
return Object.assign(Object.assign({}, this.pair(record, credentials.access, credentials.refresh)), { session, csrf_token: credentials.csrf, reused: true });
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
linkedRecord(record) {
|
|
70
104
|
var _a;
|
|
105
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
106
|
+
const db = yield (this.options.database ? this.options.database() : DBConnection_1.DBConnection.getDocumentStore(this.options.connection));
|
|
107
|
+
const links = db.collection(`${this.options.collection || "auth_sessions"}_links`);
|
|
108
|
+
const key = (0, AuthSecurity_1.hashAuthToken)(JSON.stringify([record.issuer, record.audience, record.scope, String(record._subject),
|
|
109
|
+
record.environment, record.parent.issuer, record.parent.sessionId]));
|
|
110
|
+
let link;
|
|
111
|
+
try {
|
|
112
|
+
link = yield links.findOneAndUpdate({ _id: key }, { $setOnInsert: Object.assign(Object.assign({}, this.scope), { environment: record.environment, sessionId: record._id, purgeAt: new Date(record.expiresAt) }) }, { upsert: true, returnDocument: "after" });
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (error.code !== 11000)
|
|
116
|
+
throw error;
|
|
117
|
+
link = yield links.findOne({ _id: key });
|
|
118
|
+
}
|
|
119
|
+
if (!link)
|
|
120
|
+
throw new Error("Linked session reference is unavailable.");
|
|
121
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
122
|
+
const candidate = Object.assign(Object.assign({}, record), { _id: link.sessionId, accessExpiresAt: Math.min(record.accessExpiresAt, record.expiresAt) });
|
|
123
|
+
const credentials = this.credentials(candidate);
|
|
124
|
+
Object.assign(candidate, { accessHash: (0, AuthSecurity_1.hashAuthToken)(credentials.access), refreshHash: (0, AuthSecurity_1.hashAuthToken)(credentials.refresh), csrfHash: (0, AuthSecurity_1.hashAuthToken)(credentials.csrf) });
|
|
125
|
+
let stored;
|
|
126
|
+
try {
|
|
127
|
+
stored = yield (yield this.rows()).findOneAndUpdate({ _id: candidate._id }, { $setOnInsert: candidate }, { upsert: true, returnDocument: "after" });
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (error.code !== 11000)
|
|
131
|
+
throw error;
|
|
132
|
+
stored = yield (yield this.rows()).findOne({ _id: candidate._id });
|
|
133
|
+
}
|
|
134
|
+
if (!stored)
|
|
135
|
+
throw new Error("Linked session is unavailable.");
|
|
136
|
+
// A denied parent or membership must fail closed, rather than create another session.
|
|
137
|
+
if (stored.revokedAt === null && stored.expiresAt > Date.now()
|
|
138
|
+
&& stored.createdAt + this.sessionSeconds * 1000 > Date.now()
|
|
139
|
+
&& (this.idleSeconds === false || ((_a = stored.lastActiveAt) !== null && _a !== void 0 ? _a : stored.createdAt) + this.idleSeconds * 1000 > Date.now()))
|
|
140
|
+
return stored;
|
|
141
|
+
link = (yield links.findOneAndUpdate({ _id: key, sessionId: stored._id }, { $set: { sessionId: this.ids().create(), purgeAt: new Date(record.expiresAt) } }, { returnDocument: "after" }))
|
|
142
|
+
|| (yield links.findOne({ _id: key }));
|
|
143
|
+
}
|
|
144
|
+
throw new Error("Linked session could not be established.");
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
create(subject, parent = null, environment, parentExpiresAt) {
|
|
148
|
+
var _a, _b, _c;
|
|
71
149
|
return __awaiter(this, void 0, void 0, function* () {
|
|
72
150
|
(0, AuthSecurity_1.authText)(subject, "subject");
|
|
73
151
|
if (environment !== undefined && (!this.options.allowDynamicEnvironment || this.options.environment !== undefined)) {
|
|
@@ -77,11 +155,21 @@ class SessionAuth {
|
|
|
77
155
|
(0, AuthSecurity_1.authText)(parent.issuer, "parent issuer");
|
|
78
156
|
(0, AuthSecurity_1.authText)(parent.sessionId, "parent session");
|
|
79
157
|
}
|
|
158
|
+
if (parentExpiresAt !== undefined && (!parent || !Number.isFinite(parentExpiresAt) || parentExpiresAt <= Date.now())) {
|
|
159
|
+
throw new Error("Invalid parent session expiry.");
|
|
160
|
+
}
|
|
80
161
|
const now = Date.now(), id = this.ids().create();
|
|
81
162
|
const access = `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`, refresh = `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`, csrf = (0, AuthSecurity_1.randomAuthToken)();
|
|
82
|
-
const record = Object.assign(Object.assign({ _id: id, _subject: this.ids().create(subject) }, this.scope), { environment: environment === undefined ? (_a = this.scope.environment) !== null && _a !== void 0 ? _a : null : (0, AuthSecurity_1.authText)(environment, "environment"), parent: parent ? Object.assign({}, parent) : null, createdAt: now, lastActiveAt: now, expiresAt: now + this.sessionSeconds * 1000, accessExpiresAt: now + Math.min(this.accessSeconds, this.sessionSeconds) * 1000, accessHash: (0, AuthSecurity_1.hashAuthToken)(access), refreshHash: (0, AuthSecurity_1.hashAuthToken)(refresh), csrfHash: (0, AuthSecurity_1.hashAuthToken)(csrf), usedRefreshHashes: [], revokedAt: null });
|
|
163
|
+
const record = Object.assign(Object.assign({ _id: id, _subject: this.ids().create(subject) }, this.scope), { environment: environment === undefined ? (_a = this.scope.environment) !== null && _a !== void 0 ? _a : null : (0, AuthSecurity_1.authText)(environment, "environment"), parent: parent ? Object.assign({}, parent) : null, createdAt: now, lastActiveAt: now, expiresAt: Math.min(now + this.sessionSeconds * 1000, parentExpiresAt !== null && parentExpiresAt !== void 0 ? parentExpiresAt : Infinity), accessExpiresAt: now + Math.min(this.accessSeconds, this.sessionSeconds) * 1000, accessHash: (0, AuthSecurity_1.hashAuthToken)(access), refreshHash: (0, AuthSecurity_1.hashAuthToken)(refresh), csrfHash: (0, AuthSecurity_1.hashAuthToken)(csrf), usedRefreshHashes: [], revokedAt: null });
|
|
83
164
|
if (!(yield this.allowed(record)))
|
|
84
165
|
throw new Error("Session is not allowed.");
|
|
166
|
+
if (parent && ((_b = this.options.parentSessions) === null || _b === void 0 ? void 0 : _b.mode) === "reuse") {
|
|
167
|
+
record.credentialState = { access: (0, AuthSecurity_1.randomAuthToken)(), refresh: (0, AuthSecurity_1.randomAuthToken)(), csrf: (0, AuthSecurity_1.randomAuthToken)() };
|
|
168
|
+
const stored = yield this.linkedRecord(record);
|
|
169
|
+
const pair = yield this.reuse(stored);
|
|
170
|
+
return Object.assign(Object.assign({}, pair), { reused: ((_c = stored.credentialState) === null || _c === void 0 ? void 0 : _c.access) !== record.credentialState.access });
|
|
171
|
+
}
|
|
172
|
+
record.accessExpiresAt = Math.min(record.accessExpiresAt, record.expiresAt);
|
|
85
173
|
yield (yield this.rows()).insertOne(record);
|
|
86
174
|
return Object.assign(Object.assign({}, this.pair(record, access, refresh)), { csrf_token: csrf });
|
|
87
175
|
});
|
|
@@ -117,6 +205,7 @@ class SessionAuth {
|
|
|
117
205
|
}
|
|
118
206
|
/** One winner under concurrency. A previously consumed refresh token revokes its device session. */
|
|
119
207
|
refresh(token) {
|
|
208
|
+
var _a, _b;
|
|
120
209
|
return __awaiter(this, void 0, void 0, function* () {
|
|
121
210
|
const id = tokenSessionId(token);
|
|
122
211
|
if (!id)
|
|
@@ -135,11 +224,10 @@ class SessionAuth {
|
|
|
135
224
|
yield this.revoke(id);
|
|
136
225
|
return null;
|
|
137
226
|
}
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
usedRefreshHashes: [...row.usedRefreshHashes, hash] } }, { returnDocument: "after" });
|
|
227
|
+
const credentialState = row.credentialState ? Object.assign(Object.assign({}, row.credentialState), { access: (0, AuthSecurity_1.randomAuthToken)(), refresh: (0, AuthSecurity_1.randomAuthToken)() }) : undefined;
|
|
228
|
+
const credentials = credentialState ? this.credentials(Object.assign(Object.assign({}, row), { credentialState })) : null;
|
|
229
|
+
const access = (_a = credentials === null || credentials === void 0 ? void 0 : credentials.access) !== null && _a !== void 0 ? _a : `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`, refresh = (_b = credentials === null || credentials === void 0 ? void 0 : credentials.refresh) !== null && _b !== void 0 ? _b : `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`;
|
|
230
|
+
const next = yield rows.findOneAndUpdate(Object.assign(Object.assign(Object.assign({ _id: this.sessionId(id) }, this.scope), { refreshHash: hash }), this.liveFilter()), { $set: Object.assign(Object.assign({}, (credentialState ? { credentialState } : {})), { accessHash: (0, AuthSecurity_1.hashAuthToken)(access), refreshHash: (0, AuthSecurity_1.hashAuthToken)(refresh), accessExpiresAt: Math.min(Date.now() + this.accessSeconds * 1000, row.expiresAt, row.createdAt + this.sessionSeconds * 1000), lastActiveAt: Date.now(), usedRefreshHashes: [...row.usedRefreshHashes, hash] }) }, { returnDocument: "after" });
|
|
143
231
|
if (!next) {
|
|
144
232
|
// Also detect simultaneous replay that lost the compare-and-swap.
|
|
145
233
|
yield rows.updateOne(Object.assign(Object.assign({ _id: this.sessionId(id) }, this.scope), { usedRefreshHashes: hash, revokedAt: null }), { $set: { revokedAt: Date.now() } });
|
|
@@ -168,10 +256,9 @@ class SessionAuth {
|
|
|
168
256
|
const row = yield rows.findOne(filter);
|
|
169
257
|
if (!row || !(yield this.allowed(row)))
|
|
170
258
|
return null;
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
lastActiveAt: now } }, { returnDocument: "after" });
|
|
259
|
+
const credentialState = row.credentialState ? Object.assign(Object.assign({}, row.credentialState), { access: (0, AuthSecurity_1.randomAuthToken)() }) : undefined;
|
|
260
|
+
const access = credentialState ? this.credentials(Object.assign(Object.assign({}, row), { credentialState })).access : `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`, now = Date.now();
|
|
261
|
+
const next = yield rows.findOneAndUpdate(filter, { $set: Object.assign(Object.assign({}, (credentialState ? { credentialState } : {})), { accessHash: (0, AuthSecurity_1.hashAuthToken)(access), accessExpiresAt: Math.min(now + this.accessSeconds * 1000, row.expiresAt, row.createdAt + this.sessionSeconds * 1000), lastActiveAt: now }) }, { returnDocument: "after" });
|
|
175
262
|
const session = next ? yield this.allowed(next) : null;
|
|
176
263
|
return next && session ? Object.assign(Object.assign({}, this.pair(next, access, token)), { session }) : null;
|
|
177
264
|
});
|
|
@@ -11,6 +11,14 @@ export declare class QueueWorkerCommand extends ConsoleCommand {
|
|
|
11
11
|
workerOperationId: string | null;
|
|
12
12
|
workerHadError: boolean;
|
|
13
13
|
lastAutomationCheckAt: any;
|
|
14
|
+
private stopping;
|
|
15
|
+
private automationRun;
|
|
16
|
+
private runtimeState;
|
|
17
|
+
private wakeWaiters;
|
|
18
|
+
private readonly stopSignal;
|
|
19
|
+
get workerInstanceKey(): string;
|
|
20
|
+
requestStop(): void;
|
|
21
|
+
private publishRuntimeState;
|
|
14
22
|
jobsExecuted: number;
|
|
15
23
|
jobsSucceeded: number;
|
|
16
24
|
jobsFailed: number;
|
|
@@ -23,7 +31,7 @@ export declare class QueueWorkerCommand extends ConsoleCommand {
|
|
|
23
31
|
init(): boolean;
|
|
24
32
|
canStart(): boolean;
|
|
25
33
|
random(min: number, max: number): number;
|
|
26
|
-
wait(ms: number): Promise<
|
|
34
|
+
wait(ms: number): Promise<void>;
|
|
27
35
|
waitRun(): Promise<unknown>;
|
|
28
36
|
cancelRun(jobId: any): Promise<unknown>;
|
|
29
37
|
updateWorkerStatus(values: {
|
|
@@ -14,6 +14,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
15
|
exports.QueueWorkerCommand = void 0;
|
|
16
16
|
const fs_1 = __importDefault(require("fs"));
|
|
17
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
17
18
|
const path_1 = __importDefault(require("path"));
|
|
18
19
|
const moment_timezone_1 = __importDefault(require("moment-timezone"));
|
|
19
20
|
const ConsoleCommand_1 = require("./ConsoleCommand");
|
|
@@ -40,12 +41,44 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
|
|
|
40
41
|
this.workerOperationId = null;
|
|
41
42
|
this.workerHadError = false;
|
|
42
43
|
this.lastAutomationCheckAt = null;
|
|
44
|
+
this.stopping = false;
|
|
45
|
+
this.automationRun = null;
|
|
46
|
+
this.runtimeState = {};
|
|
47
|
+
this.wakeWaiters = new Set();
|
|
48
|
+
this.stopSignal = () => this.requestStop();
|
|
43
49
|
// Statistics
|
|
44
50
|
this.jobsExecuted = 0;
|
|
45
51
|
this.jobsSucceeded = 0;
|
|
46
52
|
this.jobsFailed = 0;
|
|
47
53
|
this.lastJob = null;
|
|
48
54
|
}
|
|
55
|
+
get workerInstanceKey() {
|
|
56
|
+
const instance = process.env.WEBFRAMEZ_QUEUE_INSTANCE;
|
|
57
|
+
if (instance && !/^[A-Za-z0-9_.-]+$/.test(instance))
|
|
58
|
+
throw new Error("Invalid queue instance ID");
|
|
59
|
+
return this.workerKey + (instance ? `--${instance}` : "");
|
|
60
|
+
}
|
|
61
|
+
requestStop() {
|
|
62
|
+
this.stopping = true;
|
|
63
|
+
try {
|
|
64
|
+
this.updateWorkerStatus({ status: "draining", status_at: new Date().toISOString() });
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
// A telemetry/storage failure must not turn a graceful signal into a job interruption.
|
|
68
|
+
console.error("Could not publish queue drain status", error);
|
|
69
|
+
}
|
|
70
|
+
for (const wake of this.wakeWaiters)
|
|
71
|
+
wake();
|
|
72
|
+
}
|
|
73
|
+
publishRuntimeState(values) {
|
|
74
|
+
this.runtimeState = Object.assign(Object.assign(Object.assign({}, this.runtimeState), values), { pid: process.pid, worker: this.workerKey });
|
|
75
|
+
const target = process.env.WEBFRAMEZ_QUEUE_STATE_FILE;
|
|
76
|
+
if (!target)
|
|
77
|
+
return;
|
|
78
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
79
|
+
fs_1.default.writeFileSync(temporary, JSON.stringify(this.runtimeState), { mode: 0o600 });
|
|
80
|
+
fs_1.default.renameSync(temporary, target);
|
|
81
|
+
}
|
|
49
82
|
log(message) {
|
|
50
83
|
this.writeln(`[${this.workerKey} | ${(0, moment_timezone_1.default)()
|
|
51
84
|
.tz(this.workerConfig.timezone ? this.workerConfig.timezone : "Europe/Berlin")
|
|
@@ -101,31 +134,37 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
|
|
|
101
134
|
const workerKey = this.getOption("worker");
|
|
102
135
|
if (!workerKey || typeof workerKey !== "string" || workerKey.trim() === "") {
|
|
103
136
|
this.error("You must specify a worker key using --worker=<worker_key>");
|
|
137
|
+
this.workerHadError = true;
|
|
104
138
|
return;
|
|
105
139
|
}
|
|
106
140
|
this.workerConfig = Config_1.Config.get("queue.workers." + workerKey);
|
|
107
141
|
if (!this.workerConfig) {
|
|
108
142
|
this.error(`Worker '${workerKey}' is not defined in the queue configuration.`);
|
|
143
|
+
this.workerHadError = true;
|
|
109
144
|
return;
|
|
110
145
|
}
|
|
111
146
|
if (!this.workerConfig.is_active) {
|
|
112
147
|
this.error(`Worker '${workerKey}' is disabled in the queue configuration.`);
|
|
148
|
+
this.workerHadError = true;
|
|
113
149
|
return;
|
|
114
150
|
}
|
|
115
151
|
this.workerKey = workerKey;
|
|
116
152
|
this.startTime = (0, moment_timezone_1.default)().tz(this.workerConfig.timezone ? this.workerConfig.timezone : "Europe/Berlin");
|
|
117
153
|
if (!this.init()) {
|
|
154
|
+
this.workerHadError = true;
|
|
118
155
|
return;
|
|
119
156
|
}
|
|
157
|
+
process.on("SIGTERM", this.stopSignal);
|
|
158
|
+
process.on("SIGINT", this.stopSignal);
|
|
120
159
|
this.workerOperationId = WebframezHooks_1.WebframezHooks.createOperationId("worker");
|
|
121
|
-
yield WebframezHooks_1.WebframezHooks.emit("queue.worker.start", {
|
|
122
|
-
operationId: this.workerOperationId,
|
|
123
|
-
name: workerKey,
|
|
124
|
-
attributes: {
|
|
125
|
-
"webframez.queue.worker": workerKey,
|
|
126
|
-
},
|
|
127
|
-
});
|
|
128
160
|
try {
|
|
161
|
+
yield WebframezHooks_1.WebframezHooks.emit("queue.worker.start", {
|
|
162
|
+
operationId: this.workerOperationId,
|
|
163
|
+
name: workerKey,
|
|
164
|
+
attributes: {
|
|
165
|
+
"webframez.queue.worker": workerKey,
|
|
166
|
+
},
|
|
167
|
+
});
|
|
129
168
|
this.log("Queue started");
|
|
130
169
|
this.logRegisteredAutomation();
|
|
131
170
|
yield this.run();
|
|
@@ -158,21 +197,33 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
|
|
|
158
197
|
this.error(`Queue run failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
159
198
|
}
|
|
160
199
|
finally {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
200
|
+
this.stopping = true;
|
|
201
|
+
for (const wake of this.wakeWaiters)
|
|
202
|
+
wake();
|
|
203
|
+
yield this.automationRun;
|
|
204
|
+
try {
|
|
205
|
+
if (!this.workerHadError && this.workerOperationId) {
|
|
206
|
+
yield WebframezHooks_1.WebframezHooks.emit("queue.worker.end", {
|
|
207
|
+
operationId: this.workerOperationId,
|
|
208
|
+
name: workerKey,
|
|
209
|
+
status: "ok",
|
|
210
|
+
attributes: {
|
|
211
|
+
"webframez.queue.worker": workerKey,
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
this.log("Queue stopped");
|
|
216
|
+
this.updateWorkerStatus({
|
|
217
|
+
status: "stopped",
|
|
218
|
+
ended_at: new Date().toISOString(),
|
|
219
|
+
current_job: null,
|
|
220
|
+
current_job_number: null,
|
|
169
221
|
});
|
|
170
222
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
});
|
|
223
|
+
finally {
|
|
224
|
+
process.off("SIGTERM", this.stopSignal);
|
|
225
|
+
process.off("SIGINT", this.stopSignal);
|
|
226
|
+
}
|
|
176
227
|
}
|
|
177
228
|
});
|
|
178
229
|
}
|
|
@@ -187,7 +238,7 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
|
|
|
187
238
|
if (!fs_1.default.existsSync((0, FileFunctions_1.storageDir)("queue"))) {
|
|
188
239
|
fs_1.default.mkdirSync((0, FileFunctions_1.storageDir)("queue"), { recursive: true });
|
|
189
240
|
}
|
|
190
|
-
const filePath = (0, FileFunctions_1.storageDir)("queue", `worker_${this.
|
|
241
|
+
const filePath = (0, FileFunctions_1.storageDir)("queue", `worker_${this.workerInstanceKey}.json`);
|
|
191
242
|
let lastLogFile = null;
|
|
192
243
|
if (fs_1.default.existsSync(filePath)) {
|
|
193
244
|
try {
|
|
@@ -207,7 +258,7 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
|
|
|
207
258
|
return true;
|
|
208
259
|
}
|
|
209
260
|
canStart() {
|
|
210
|
-
const filePath = (0, FileFunctions_1.storageDir)("queue", `worker_${this.
|
|
261
|
+
const filePath = (0, FileFunctions_1.storageDir)("queue", `worker_${this.workerInstanceKey}.json`);
|
|
211
262
|
if (!fs_1.default.existsSync(filePath)) {
|
|
212
263
|
return true;
|
|
213
264
|
}
|
|
@@ -242,8 +293,16 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
|
|
|
242
293
|
}
|
|
243
294
|
wait(ms) {
|
|
244
295
|
return __awaiter(this, void 0, void 0, function* () {
|
|
245
|
-
|
|
246
|
-
|
|
296
|
+
if (this.stopping)
|
|
297
|
+
return;
|
|
298
|
+
yield new Promise((resolve) => {
|
|
299
|
+
const done = () => {
|
|
300
|
+
clearTimeout(timer);
|
|
301
|
+
this.wakeWaiters.delete(done);
|
|
302
|
+
resolve();
|
|
303
|
+
};
|
|
304
|
+
const timer = setTimeout(done, ms);
|
|
305
|
+
this.wakeWaiters.add(done);
|
|
247
306
|
});
|
|
248
307
|
});
|
|
249
308
|
}
|
|
@@ -254,6 +313,7 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
|
|
|
254
313
|
status: "waiting",
|
|
255
314
|
status_at: new Date().toISOString(),
|
|
256
315
|
current_job: null,
|
|
316
|
+
current_job_number: null,
|
|
257
317
|
});
|
|
258
318
|
yield this.wait(1000);
|
|
259
319
|
resolve(true);
|
|
@@ -277,7 +337,10 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
|
|
|
277
337
|
});
|
|
278
338
|
}
|
|
279
339
|
updateWorkerStatus(values) {
|
|
280
|
-
|
|
340
|
+
if (this.stopping && values.status !== "stopped")
|
|
341
|
+
values = Object.assign(Object.assign({}, values), { status: "draining" });
|
|
342
|
+
this.publishRuntimeState(values);
|
|
343
|
+
const filePath = (0, FileFunctions_1.storageDir)("queue", `worker_${this.workerInstanceKey}.json`);
|
|
281
344
|
if (!fs_1.default.existsSync(filePath))
|
|
282
345
|
return;
|
|
283
346
|
const json = JSON.parse(fs_1.default.readFileSync(filePath, "utf-8"));
|
|
@@ -378,6 +441,8 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
|
|
|
378
441
|
continue;
|
|
379
442
|
}
|
|
380
443
|
for (let automation of checkAutomation) {
|
|
444
|
+
if (this.checkStop())
|
|
445
|
+
break;
|
|
381
446
|
if (!automation || !automation.jobclass || !automation._execution || !automation._execution.key) {
|
|
382
447
|
continue;
|
|
383
448
|
}
|
|
@@ -391,6 +456,7 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
|
|
|
391
456
|
// Create the automation job
|
|
392
457
|
const now = (0, moment_timezone_1.default)().tz(this.workerConfig.timezone ? this.workerConfig.timezone : "Europe/Berlin");
|
|
393
458
|
const newJob = {
|
|
459
|
+
_id: yield DBConnection_1.DBConnection.objectId(crypto_1.default.createHash("sha256").update("queue-automation:" + automation._execution.key).digest("hex").slice(0, 24)),
|
|
394
460
|
jobclass: automation.jobclass,
|
|
395
461
|
number: yield this.getNextJobNumber(connection),
|
|
396
462
|
is_automated: true,
|
|
@@ -406,7 +472,17 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
|
|
|
406
472
|
executions: [],
|
|
407
473
|
worker: this.workerKey,
|
|
408
474
|
};
|
|
409
|
-
|
|
475
|
+
if (this.checkStop())
|
|
476
|
+
break;
|
|
477
|
+
try {
|
|
478
|
+
yield DBConnection_1.DBConnection.documentStore(connection).collection("queue_jobs").insertOne(newJob);
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
// Concurrent replicas may schedule the same occurrence; the primary key arbitrates.
|
|
482
|
+
const existing = yield DBConnection_1.DBConnection.documentStore(connection).collection("queue_jobs").findOne({ _id: newJob._id });
|
|
483
|
+
if (!existing)
|
|
484
|
+
throw error;
|
|
485
|
+
}
|
|
410
486
|
}
|
|
411
487
|
yield this.wait(5000);
|
|
412
488
|
}
|
|
@@ -436,9 +512,11 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
|
|
|
436
512
|
});
|
|
437
513
|
}
|
|
438
514
|
checkStop() {
|
|
515
|
+
if (this.stopping)
|
|
516
|
+
return true;
|
|
439
517
|
if (!this.workerKey)
|
|
440
518
|
return false;
|
|
441
|
-
const stopFilePath = path_1.default.join((0, FileFunctions_1.storageDir)(), "queue", this.
|
|
519
|
+
const stopFilePath = path_1.default.join((0, FileFunctions_1.storageDir)(), "queue", this.workerInstanceKey + ".stop");
|
|
442
520
|
if (fs_1.default.existsSync(stopFilePath)) {
|
|
443
521
|
if (this.autorestart) {
|
|
444
522
|
fs_1.default.writeFileSync(path_1.default.join((0, FileFunctions_1.storageDir)(), "queue", this.workerKey + ".stop-autorestart"), JSON.stringify({ date: new Date() }));
|
|
@@ -450,7 +528,8 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
|
|
|
450
528
|
run() {
|
|
451
529
|
return __awaiter(this, void 0, void 0, function* () {
|
|
452
530
|
const connection = yield DBConnection_1.DBConnection.getConnection();
|
|
453
|
-
this.
|
|
531
|
+
this.updateWorkerStatus({ status: "waiting", current_job: null });
|
|
532
|
+
this.automationRun = this.runAutomation();
|
|
454
533
|
while (true) {
|
|
455
534
|
const now = (0, moment_timezone_1.default)().tz(this.workerConfig.timezone ? this.workerConfig.timezone : "Europe/Berlin");
|
|
456
535
|
if (this.checkStop()) {
|
|
@@ -486,6 +565,12 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
|
|
|
486
565
|
returnDocument: "after",
|
|
487
566
|
});
|
|
488
567
|
const job = jobUpdate;
|
|
568
|
+
// A signal can arrive while the atomic claim is in flight. Return that claim before executing it.
|
|
569
|
+
if (this.checkStop()) {
|
|
570
|
+
if (job === null || job === void 0 ? void 0 : job._id)
|
|
571
|
+
yield DBConnection_1.DBConnection.documentStore(connection).collection("queue_jobs").updateOne({ _id: job._id, status: "running", worker: this.workerKey }, { $set: { status: "pending", started_at: null } });
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
489
574
|
if (!job || !job.jobclass || !job._id) {
|
|
490
575
|
this.currentJob = null;
|
|
491
576
|
if (job && job._id) {
|
|
@@ -105,6 +105,10 @@ class ConsoleApplication {
|
|
|
105
105
|
if (options && options.onEnd) {
|
|
106
106
|
yield options.onEnd(command.signature);
|
|
107
107
|
}
|
|
108
|
+
// Managed workers must terminate even when a database driver keeps sockets open.
|
|
109
|
+
if (command.signature === "queue:worker" && process.env.WEBFRAMEZ_QUEUE_STATE_FILE) {
|
|
110
|
+
process.exit(commandInstance.workerHadError ? 1 : 0);
|
|
111
|
+
}
|
|
108
112
|
}
|
|
109
113
|
catch (e) {
|
|
110
114
|
yield ErrorHandler_1.ErrorHandler.report(e, {
|
package/docs/authentication.md
CHANGED
|
@@ -562,3 +562,25 @@ access nor refresh credentials need appear in navigation URLs. Configure a stron
|
|
|
562
562
|
and an instance-specific audience. Create TTL indexes on `auth_handoffs.purgeAt`,
|
|
563
563
|
`auth_sso_transactions.purgeAt` and `auth_sso_codes.purgeAt`, or schedule their cleanup methods.
|
|
564
564
|
SSO introspection records successful central session activity, respecting its absolute expiry.
|
|
565
|
+
|
|
566
|
+
### Linked parent session reuse
|
|
567
|
+
|
|
568
|
+
`session.parentSessions: { mode: "reuse", secret: serverSecret }` opts into one local
|
|
569
|
+
session per exact parent issuer/session, user, scope, audience and environment. The default
|
|
570
|
+
`mode: "new"` retains independent session creation. Logins without a parent remain independent.
|
|
571
|
+
The secret must be at least 32 characters and stay stable across all processes of an instance.
|
|
572
|
+
Core stores hashes and independent random credential nonces, never plaintext tokens; a
|
|
573
|
+
domain-separated HMAC derives the current credentials only on the trusted server. Rotation
|
|
574
|
+
updates both hashes and nonces atomically. Reopening returns the current pair without rotating
|
|
575
|
+
refresh or resetting the login timestamp. `auth_sessions_links` uses its primary key for atomic
|
|
576
|
+
creation; add an optional TTL index on `purgeAt` for housekeeping. Session `_id` and `_subject`
|
|
577
|
+
continue to use driver-native IDs. A revoked/expired local child creates a new generation.
|
|
578
|
+
Changing the secret requires revoking existing linked sessions before reauthentication.
|
|
579
|
+
|
|
580
|
+
Trusted callers can pass parent expiry as the fifth argument to
|
|
581
|
+
`Auth.scope().establishBearerSession(request, subject, parent, environment, parentExpiresAt)`
|
|
582
|
+
or fourth argument to `sessions.create`. It caps child expiry. Parent revocation must still be
|
|
583
|
+
checked by `isSessionAllowed` on every authentication/refresh. To enforce logout on the next
|
|
584
|
+
request, do not cache positive introspection results. This requires one authority round trip
|
|
585
|
+
per check and fails closed when the authority is unavailable. No browser push is implied;
|
|
586
|
+
clients detect logout when making their next authenticated request.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Managed queue workers
|
|
2
|
+
|
|
3
|
+
`queue:worker --worker=<key>` handles SIGTERM and SIGINT by stopping new claims and automated scheduling, then finishing the current job and persisting its result. A claim that completes after a stop request is returned to pending without executing its handler. Repeated stop signals remain graceful; SIGKILL is the explicit force operation.
|
|
4
|
+
|
|
5
|
+
The server runtime assigns `WEBFRAMEZ_QUEUE_INSTANCE` for independent status files and `WEBFRAMEZ_QUEUE_STATE_FILE` for readiness/drain telemetry. The worker configuration key still controls routing and job selection. When the managed console finishes its command/application completion hooks, it exits even if a database connection keeps Node sockets open. Run queue:worker directly under the runtime; do not nest queue:start or queue:worker:autorestart.
|
|
6
|
+
|
|
7
|
+
Automatic schedule occurrences have deterministic primary keys, preventing concurrent managed workers from creating the same occurrence twice. Existing pending jobs still use an atomic claim.
|
|
8
|
+
|
|
9
|
+
Graceful draining does not make job side effects transactional. A forcibly killed job may remain running in the database and have partially completed external work. Check its effects before manually recovering/retrying it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webtypen/webframez-core",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.81",
|
|
4
4
|
"description": "The core package of the typescript based webframez (web-framework) by webtypen.de",
|
|
5
5
|
"homepage": "https://webtypen.de/",
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
"auth-client.js",
|
|
33
33
|
"auth-client.d.ts",
|
|
34
34
|
"bearer-auth-client.js",
|
|
35
|
-
"bearer-auth-client.d.ts"
|
|
35
|
+
"bearer-auth-client.d.ts",
|
|
36
|
+
"docs/queue-workers.md"
|
|
36
37
|
],
|
|
37
38
|
"scripts": {
|
|
38
39
|
"build": "tsc",
|