@webtypen/webframez-core 0.3.78 → 0.3.80

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.
@@ -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;
@@ -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
- create(subject: string, parent?: AuthSession["parent"], environment?: string): Promise<CreatedAuthSession>;
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>;
@@ -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
- create(subject, parent = null, environment) {
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 access = `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`, refresh = `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`;
139
- const next = yield rows.findOneAndUpdate(Object.assign(Object.assign(Object.assign({ _id: this.sessionId(id) }, this.scope), { refreshHash: hash }), this.liveFilter()), { $set: { accessHash: (0, AuthSecurity_1.hashAuthToken)(access), refreshHash: (0, AuthSecurity_1.hashAuthToken)(refresh),
140
- accessExpiresAt: Math.min(Date.now() + this.accessSeconds * 1000, row.expiresAt, row.createdAt + this.sessionSeconds * 1000),
141
- lastActiveAt: Date.now(),
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 access = `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`, now = Date.now();
172
- const next = yield rows.findOneAndUpdate(filter, { $set: { accessHash: (0, AuthSecurity_1.hashAuthToken)(access),
173
- accessExpiresAt: Math.min(now + this.accessSeconds * 1000, row.expiresAt, row.createdAt + this.sessionSeconds * 1000),
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
  });
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webtypen/webframez-core",
3
- "version": "0.3.78",
3
+ "version": "0.3.80",
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",