@webtypen/webframez-core 0.3.74 → 0.3.75

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/auth-client.js CHANGED
@@ -8,6 +8,7 @@ function csrfCookie(name) {
8
8
  exports.createAuthFetch = function createAuthFetch(options = {}, originalFetch = window.fetch.bind(window)) {
9
9
  const base = (options.basePath || "/api/auth").replace(/\/+$/, "");
10
10
  const loginPaths = options.loginPaths || [`${base}/login`];
11
+ let refreshing;
11
12
  return async (input, init) => {
12
13
  const url = new URL(input instanceof Request ? input.url : String(input), window.location.href);
13
14
  const method = String(init?.method || (input instanceof Request ? input.method : "GET")).toUpperCase();
@@ -21,7 +22,23 @@ exports.createAuthFetch = function createAuthFetch(options = {}, originalFetch =
21
22
  } else token = csrfCookie(options.cookieName);
22
23
  const headers = new Headers(init?.headers || (input instanceof Request ? input.headers : undefined));
23
24
  if (typeof token === "string" && token) headers.set("X-CSRF-Token", token);
24
- return originalFetch(input, { ...init, headers });
25
+ const retryInput = input instanceof Request ? input.clone() : input;
26
+ const response = await originalFetch(input, { ...init, headers });
27
+ if (loginPaths.includes(url.pathname) || url.pathname.startsWith(`${base}/`)
28
+ || ![401, 403].includes(response.status)) return response;
29
+ const failure = await response.clone().json().catch(() => null);
30
+ if (!["unauthorized", "invalid_csrf"].includes(failure?.code)) return response;
31
+ if (!refreshing) {
32
+ const refreshHeaders = new Headers();
33
+ const csrf = csrfCookie(options.cookieName);
34
+ if (csrf) refreshHeaders.set("X-CSRF-Token", csrf);
35
+ refreshing = originalFetch(`${base}/refresh`, { method: "POST", credentials: "same-origin", headers: refreshHeaders })
36
+ .then(result => result.ok, () => false).finally(() => { refreshing = undefined; });
37
+ }
38
+ if (!await refreshing) return response;
39
+ const nextCsrf = csrfCookie(options.cookieName);
40
+ if (nextCsrf) headers.set("X-CSRF-Token", nextCsrf);
41
+ return originalFetch(retryInput, { ...init, headers });
25
42
  };
26
43
  };
27
44
 
@@ -1,3 +1,4 @@
1
+ import { Model } from "../Database/Model";
1
2
  import type { Request } from "../Router/Request";
2
3
  import type { Response } from "../Router/Response";
3
4
  import { AuthScope, AuthScopeOptions, AuthOperation, AuthRouteOptions } from "./ModelAuth";
@@ -13,6 +14,7 @@ export declare class AuthFacade {
13
14
  private initialized;
14
15
  init(): void;
15
16
  scope(key?: string): AuthScope;
17
+ revokeUserSessions(model: typeof Model, subject: string): Promise<void>;
16
18
  registerScope(key: string, config: Partial<AuthScopeOptions>): AuthScope;
17
19
  }
18
20
  export declare const Auth: AuthFacade;
package/dist/Auth/Auth.js CHANGED
@@ -1,11 +1,21 @@
1
1
  "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
2
11
  Object.defineProperty(exports, "__esModule", { value: true });
3
12
  exports.authRoute = exports.Auth = exports.AuthFacade = void 0;
4
13
  const Config_1 = require("../Config");
5
14
  const Model_1 = require("../Database/Model");
6
15
  const ModelAuth_1 = require("./ModelAuth");
7
16
  function mergeOptions(base, config) {
8
- return Object.assign(Object.assign(Object.assign({ model: Model_1.Model }, base), config), { fields: Object.assign(Object.assign({}, base.fields), config.fields), session: Object.assign(Object.assign({}, base.session), config.session), messages: Object.assign(Object.assign({}, base.messages), config.messages) });
17
+ return Object.assign(Object.assign(Object.assign({ model: Model_1.Model }, base), config), { fields: Object.assign(Object.assign({}, base.fields), config.fields), session: Object.assign(Object.assign({}, base.session), config.session), passwordReset: config.passwordReset === false ? false : config.passwordReset === undefined && base.passwordReset === false ? false
18
+ : Object.assign(Object.assign({}, (base.passwordReset || {})), (config.passwordReset || {})), messages: Object.assign(Object.assign({}, base.messages), config.messages) });
9
19
  }
10
20
  /** Application-wide registry of named browser-authentication scopes. */
11
21
  class AuthFacade {
@@ -35,6 +45,15 @@ class AuthFacade {
35
45
  throw new Error(`Auth scope "${key}" is not registered.`);
36
46
  return scope;
37
47
  }
48
+ revokeUserSessions(model, subject) {
49
+ return __awaiter(this, void 0, void 0, function* () {
50
+ this.init();
51
+ for (const scope of this.scopes.values()) {
52
+ if (scope.configuration.model === model)
53
+ yield scope.sessions.revokeAll(subject);
54
+ }
55
+ });
56
+ }
38
57
  registerScope(key, config) {
39
58
  var _a;
40
59
  this.init();
@@ -1,6 +1,7 @@
1
1
  import { Model } from "../Database/Model";
2
2
  import { Request } from "../Router/Request";
3
3
  import type { Response } from "../Router/Response";
4
+ import { PasswordReset, PasswordResetOptions } from "./PasswordReset";
4
5
  import { AuthSession, SessionAuthOptions } from "./SessionAuth";
5
6
  import { WebAuth, WebAuthError, WebAuthOptions } from "./WebAuth";
6
7
  export type AuthContext<TUser extends Model = Model> = {
@@ -24,6 +25,7 @@ export type ModelAuthOptions = Partial<WebAuthOptions> & {
24
25
  locale?: "en" | "de";
25
26
  /** Record browser metadata and update activity at most once per minute. */
26
27
  trackActivity?: boolean;
28
+ passwordReset?: PasswordResetOptions | false;
27
29
  fields?: {
28
30
  identifier?: string;
29
31
  password?: string;
@@ -56,10 +58,18 @@ export type AuthRouteOptions = Partial<ModelAuthOptions> & {
56
58
  export declare class AuthScope extends WebAuth {
57
59
  private readonly modelOptions;
58
60
  readonly key: string;
61
+ readonly passwordReset: PasswordReset | null;
59
62
  private readonly model;
63
+ private authPaths;
60
64
  private readonly sessionUsers;
61
65
  private readonly fields;
62
66
  constructor(modelOptions: ModelAuthOptions);
67
+ configureRoutes(basePath: string, loginPath?: string): void;
68
+ get browserConfiguration(): {
69
+ basePath: string;
70
+ loginPaths: string[];
71
+ cookieName: string;
72
+ };
63
73
  get configuration(): ModelAuthOptions;
64
74
  private message;
65
75
  /** Use this when creating users; existing bcrypt hashes are supported without migration. */
@@ -68,7 +78,7 @@ export declare class AuthScope extends WebAuth {
68
78
  login(req: Request, res: Response, verifyCredentials?: () => Promise<string | null>): Promise<AuthSession>;
69
79
  logout(req: Request, res: Response): Promise<void>;
70
80
  /** Resolve a fresh validated session and its model; never trusts a client-supplied req.auth. */
71
- resolve<TUser extends Model = Model>(req: Request): Promise<AuthContext<TUser> | null>;
81
+ resolve<TUser extends Model = Model>(req: Request, res?: Response): Promise<AuthContext<TUser> | null>;
72
82
  resolveCookies<TUser extends Model = Model>(cookies: Record<string, string | undefined>): Promise<AuthContext<TUser> | null>;
73
83
  /** Only explicit public fields are sent to browsers; Model instances remain on the server. */
74
84
  snapshot(auth: AuthContext | null | undefined): AuthSnapshot | null;
@@ -11,8 +11,11 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.ModelAuth = exports.AuthScope = exports.AuthLoginError = void 0;
13
13
  const bcryptjs_1 = require("bcryptjs");
14
+ const routing_1 = require("../routing");
14
15
  const Config_1 = require("../Config");
15
16
  const Request_1 = require("../Router/Request");
17
+ const Auth_1 = require("./Auth");
18
+ const PasswordReset_1 = require("./PasswordReset");
16
19
  const AuthSecurity_1 = require("./AuthSecurity");
17
20
  const SessionAuth_1 = require("./SessionAuth");
18
21
  const WebAuth_1 = require("./WebAuth");
@@ -57,7 +60,7 @@ let dummyHash;
57
60
  /** Model-backed browser authentication with secure default routes and revocable sessions. */
58
61
  class AuthScope extends WebAuth_1.WebAuth {
59
62
  constructor(modelOptions) {
60
- var _a, _b, _c, _d, _e, _f, _g;
63
+ var _a, _b, _c, _d, _e, _f, _g, _h;
61
64
  if (modelOptions.csrfDisabled)
62
65
  throw new Error("Browser auth routes require CSRF protection.");
63
66
  if (!modelOptions.model)
@@ -73,7 +76,7 @@ class AuthScope extends WebAuth_1.WebAuth {
73
76
  && (!modelOptions.isUserAllowed || (yield modelOptions.isUserAllowed(user))));
74
77
  });
75
78
  const sessionUsers = new WeakMap();
76
- const sessions = new SessionAuth_1.SessionAuth(Object.assign(Object.assign({ accessTokenSeconds: 30 * 86400, sessionSeconds: 30 * 86400 }, modelOptions.session), { issuer: origin, scope: key, audience: (_d = (_b = modelOptions.audience) !== null && _b !== void 0 ? _b : (_c = modelOptions.session) === null || _c === void 0 ? void 0 : _c.audience) !== null && _d !== void 0 ? _d : origin, connection: ((_e = modelOptions.session) === null || _e === void 0 ? void 0 : _e.connection) || new model().__connection, isSessionAllowed: (session) => __awaiter(this, void 0, void 0, function* () {
79
+ const sessions = new SessionAuth_1.SessionAuth(Object.assign(Object.assign({ accessTokenSeconds: 15 * 60, sessionSeconds: 30 * 86400, idleTimeoutSeconds: 7 * 86400 }, modelOptions.session), { issuer: origin, scope: key, audience: (_d = (_b = modelOptions.audience) !== null && _b !== void 0 ? _b : (_c = modelOptions.session) === null || _c === void 0 ? void 0 : _c.audience) !== null && _d !== void 0 ? _d : origin, connection: ((_e = modelOptions.session) === null || _e === void 0 ? void 0 : _e.connection) || new model().__connection, isSessionAllowed: (session) => __awaiter(this, void 0, void 0, function* () {
77
80
  const id = fields.primaryKey === "_id" ? yield model.objectId(session.subject, { noExceptions: true }) : session.subject;
78
81
  const user = id !== null && id !== undefined ? yield model.where(fields.primaryKey, "=", id).first() : null;
79
82
  if (!(yield allowed(user)))
@@ -83,10 +86,23 @@ class AuthScope extends WebAuth_1.WebAuth {
83
86
  }) }));
84
87
  super(sessions, Object.assign(Object.assign({}, modelOptions), { origin, cookiePrefix: (_f = modelOptions.cookiePrefix) !== null && _f !== void 0 ? _f : (key === "main" ? undefined : `${origin.startsWith("https:") ? "__Host-wf_" : "wf_dev_"}${key}_`), allowInsecureLocalhost: (_g = modelOptions.allowInsecureLocalhost) !== null && _g !== void 0 ? _g : true }));
85
88
  this.modelOptions = modelOptions;
89
+ this.authPaths = { basePath: "/api/auth", loginPath: "/api/auth/login" };
86
90
  this.key = key;
87
91
  this.sessionUsers = sessionUsers;
88
92
  this.model = model;
89
93
  this.fields = fields;
94
+ this.passwordReset = modelOptions.passwordReset === false ? null : new PasswordReset_1.PasswordReset(model, key, fields, modelOptions.passwordReset || {}, (user) => __awaiter(this, void 0, void 0, function* () { return allowed(user); }), (user, mode) => __awaiter(this, void 0, void 0, function* () {
95
+ const subject = String(user[fields.primaryKey]);
96
+ yield this.sessions.revokeAll(subject);
97
+ if (mode === "all")
98
+ yield Auth_1.Auth.revokeUserSessions(model, subject);
99
+ }), (_h = modelOptions.session) === null || _h === void 0 ? void 0 : _h.database);
100
+ }
101
+ configureRoutes(basePath, loginPath) {
102
+ this.authPaths = { basePath, loginPath: loginPath || `${basePath}/login` };
103
+ }
104
+ get browserConfiguration() {
105
+ return { basePath: (0, routing_1.appPath)(this.authPaths.basePath), loginPaths: [(0, routing_1.appPath)(this.authPaths.loginPath)], cookieName: this.cookies.prefix + "csrf" };
90
106
  }
91
107
  get configuration() {
92
108
  return this.modelOptions;
@@ -176,13 +192,15 @@ class AuthScope extends WebAuth_1.WebAuth {
176
192
  });
177
193
  }
178
194
  /** Resolve a fresh validated session and its model; never trusts a client-supplied req.auth. */
179
- resolve(req) {
195
+ resolve(req, res) {
180
196
  const _super = Object.create(null, {
181
- authenticate: { get: () => super.authenticate }
197
+ authenticate: { get: () => super.authenticate },
198
+ resume: { get: () => super.resume }
182
199
  });
183
200
  return __awaiter(this, void 0, void 0, function* () {
201
+ res === null || res === void 0 ? void 0 : res.header("Cache-Control", "no-store");
184
202
  Object.defineProperty(req, "auth", { value: null, writable: true, configurable: true, enumerable: false });
185
- const session = yield _super.authenticate.call(this, req);
203
+ const session = (yield _super.authenticate.call(this, req)) || (res ? yield _super.resume.call(this, req, res) : null);
186
204
  if (!session)
187
205
  return null;
188
206
  const user = this.sessionUsers.get(session);
@@ -212,7 +230,7 @@ class AuthScope extends WebAuth_1.WebAuth {
212
230
  const user = {};
213
231
  for (const field of fields) {
214
232
  if (field.startsWith("__") || field === this.fields.password || ((_a = auth.user.__hidden) === null || _a === void 0 ? void 0 : _a.includes(field))
215
- || ["constructor", "prototype"].includes(field))
233
+ || field.startsWith("auth_reset_") || ["constructor", "prototype"].includes(field))
216
234
  continue;
217
235
  const value = auth.user[field];
218
236
  if (value !== undefined)
@@ -224,7 +242,7 @@ class AuthScope extends WebAuth_1.WebAuth {
224
242
  middleware(options = {}) {
225
243
  return (next, reject, req, res) => __awaiter(this, void 0, void 0, function* () {
226
244
  try {
227
- const auth = yield this.resolve(req);
245
+ const auth = yield this.resolve(req, res);
228
246
  res.header("Cache-Control", "no-store");
229
247
  if (!auth && options.required) {
230
248
  res.status(401).send({ status: "error", code: "unauthorized" });
@@ -0,0 +1,40 @@
1
+ import type { DocumentDatabase } from "../Database/DatabaseAdapter";
2
+ import { Model } from "../Database/Model";
3
+ export type PasswordResetOptions = {
4
+ tokenSeconds?: number;
5
+ cooldownSeconds?: number;
6
+ /** Revoke registered scopes using the same model, only this scope, or leave sessions intact. */
7
+ revokeSessions?: "all" | "scope" | false;
8
+ };
9
+ export type PasswordResetDelivery = (user: Model, token: string, expiresAt: number) => void | Promise<void>;
10
+ /** Framework adapter: the password change and token consumption are one conditional database update. */
11
+ export declare class PasswordReset {
12
+ private readonly model;
13
+ private readonly key;
14
+ private readonly fields;
15
+ private readonly options;
16
+ private readonly allowed;
17
+ private readonly invalidate;
18
+ private readonly database?;
19
+ readonly tokenSeconds: number;
20
+ readonly cooldownSeconds: number;
21
+ readonly revokeSessions: "all" | "scope" | false;
22
+ readonly hiddenFields: string[];
23
+ private readonly hashField;
24
+ private readonly expiresField;
25
+ private readonly requestedField;
26
+ constructor(model: typeof Model, key: string, fields: {
27
+ identifier: string;
28
+ password: string;
29
+ primaryKey: string;
30
+ active: string | false;
31
+ }, options: PasswordResetOptions, allowed: (user: Model) => boolean | Promise<boolean>, invalidate: (user: Model, mode: "all" | "scope") => Promise<void>, database?: (() => Promise<DocumentDatabase>) | undefined);
32
+ private rows;
33
+ private digest;
34
+ private userForToken;
35
+ /** No account-existence result is returned. Deliver the raw token only through a trusted server callback. */
36
+ request(identifier: string, deliver: PasswordResetDelivery): Promise<void>;
37
+ /** A non-consuming check for the reset form; reset() always checks again atomically. */
38
+ validate(token: string): Promise<Model | null>;
39
+ reset(token: string, password: string): Promise<Model | null>;
40
+ }
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.PasswordReset = void 0;
13
+ const bcryptjs_1 = require("bcryptjs");
14
+ const DBConnection_1 = require("../Database/DBConnection");
15
+ const AuthSecurity_1 = require("./AuthSecurity");
16
+ /** Framework adapter: the password change and token consumption are one conditional database update. */
17
+ class PasswordReset {
18
+ constructor(model, key, fields, options, allowed, invalidate, database) {
19
+ var _a;
20
+ this.model = model;
21
+ this.key = key;
22
+ this.fields = fields;
23
+ this.options = options;
24
+ this.allowed = allowed;
25
+ this.invalidate = invalidate;
26
+ this.database = database;
27
+ this.tokenSeconds = (0, AuthSecurity_1.authLifetime)(options.tokenSeconds, 15 * 60);
28
+ this.cooldownSeconds = (0, AuthSecurity_1.authLifetime)(options.cooldownSeconds, 3 * 60);
29
+ this.revokeSessions = (_a = options.revokeSessions) !== null && _a !== void 0 ? _a : "all";
30
+ if (!["all", "scope", false].includes(this.revokeSessions))
31
+ throw new Error("Invalid password reset session policy.");
32
+ const prefix = `auth_reset_${key}_`;
33
+ this.hashField = prefix + "hash";
34
+ this.expiresField = prefix + "expires_at";
35
+ this.requestedField = prefix + "requested_at";
36
+ this.hiddenFields = [this.hashField, this.expiresField, this.requestedField];
37
+ }
38
+ rows() {
39
+ return __awaiter(this, void 0, void 0, function* () {
40
+ const instance = new this.model();
41
+ const db = yield (this.database ? this.database() : DBConnection_1.DBConnection.getDocumentStore(instance.__connection));
42
+ return db.collection(instance.__table);
43
+ });
44
+ }
45
+ digest(token, user) {
46
+ // Bind reset tokens to the scope and current password: a later password change invalidates them.
47
+ return (0, AuthSecurity_1.hashAuthToken)(`${this.key}\0${token}\0${user[this.fields.password]}`);
48
+ }
49
+ userForToken(token) {
50
+ return __awaiter(this, void 0, void 0, function* () {
51
+ if (typeof token !== "string" || !/^[A-Za-z0-9_-]{1,128}\.[A-Za-z0-9_-]{43}$/.test(token))
52
+ return null;
53
+ const subject = token.split(".")[0];
54
+ const id = this.fields.primaryKey === "_id" ? yield this.model.objectId(subject, { noExceptions: true }) : subject;
55
+ if (id == null)
56
+ return null;
57
+ const user = yield this.model.where(this.fields.primaryKey, "=", id).first();
58
+ if (!user || typeof user[this.fields.password] !== "string" || !(yield this.allowed(user)))
59
+ return null;
60
+ return user;
61
+ });
62
+ }
63
+ /** No account-existence result is returned. Deliver the raw token only through a trusted server callback. */
64
+ request(identifier, deliver) {
65
+ return __awaiter(this, void 0, void 0, function* () {
66
+ if (typeof identifier !== "string" || !identifier.trim() || identifier.length > 320)
67
+ return;
68
+ const user = yield this.model.where(this.fields.identifier, "=", identifier.trim().toLowerCase()).first();
69
+ if (!user || typeof user[this.fields.password] !== "string" || !(yield this.allowed(user)))
70
+ return;
71
+ const now = Date.now(), expiresAt = now + this.tokenSeconds * 1000;
72
+ const token = `${user[this.fields.primaryKey]}.${(0, AuthSecurity_1.randomAuthToken)()}`;
73
+ const filter = { [this.fields.primaryKey]: user[this.fields.primaryKey],
74
+ [this.fields.password]: user[this.fields.password],
75
+ $or: [{ [this.requestedField]: { $exists: false } }, { [this.requestedField]: { $lte: now - this.cooldownSeconds * 1000 } }] };
76
+ const updated = yield (yield this.rows()).findOneAndUpdate(filter, { $set: { [this.hashField]: this.digest(token, user), [this.expiresField]: expiresAt, [this.requestedField]: now } }, { returnDocument: "after" });
77
+ if (updated)
78
+ yield deliver(user, token, expiresAt);
79
+ });
80
+ }
81
+ /** A non-consuming check for the reset form; reset() always checks again atomically. */
82
+ validate(token) {
83
+ return __awaiter(this, void 0, void 0, function* () {
84
+ const user = yield this.userForToken(token);
85
+ if (!user)
86
+ return null;
87
+ const row = yield (yield this.rows()).findOne({ [this.fields.primaryKey]: user[this.fields.primaryKey],
88
+ [this.fields.password]: user[this.fields.password], [this.hashField]: this.digest(token, user), [this.expiresField]: { $gt: Date.now() } });
89
+ return row ? user : null;
90
+ });
91
+ }
92
+ reset(token, password) {
93
+ return __awaiter(this, void 0, void 0, function* () {
94
+ if (typeof password !== "string" || !password || password.length > 1024)
95
+ throw new Error("Invalid password.");
96
+ const user = yield this.validate(token);
97
+ if (!user)
98
+ return null;
99
+ const stored = user[this.fields.password];
100
+ const nextPassword = yield (0, bcryptjs_1.hash)(password, 12);
101
+ const row = yield (yield this.rows()).findOneAndUpdate(Object.assign({ [this.fields.primaryKey]: user[this.fields.primaryKey], [this.fields.password]: stored, [this.hashField]: this.digest(token, user), [this.expiresField]: { $gt: Date.now() } }, (this.fields.active === false ? {} : { [this.fields.active]: { $ne: false } })), { $set: { [this.fields.password]: nextPassword }, $unset: { [this.hashField]: "", [this.expiresField]: "" } }, { returnDocument: "after" });
102
+ if (!row)
103
+ return null;
104
+ user[this.fields.password] = nextPassword;
105
+ if (this.revokeSessions !== false)
106
+ yield this.invalidate(user, this.revokeSessions);
107
+ return user;
108
+ });
109
+ }
110
+ }
111
+ exports.PasswordReset = PasswordReset;
@@ -34,6 +34,8 @@ export type SessionAuthOptions = {
34
34
  collection?: string;
35
35
  accessTokenSeconds?: number;
36
36
  sessionSeconds?: number;
37
+ /** Maximum inactivity; false disables the idle limit. */
38
+ idleTimeoutSeconds?: number | false;
37
39
  /** Called on creation, authentication and refresh. Check account status and tenant membership here.
38
40
  * For federated sessions this can also introspect the parent authority session. Failures fail closed. */
39
41
  isSessionAllowed: (session: AuthSession) => boolean | Promise<boolean>;
@@ -48,10 +50,12 @@ export declare class SessionAuth {
48
50
  private readonly scope;
49
51
  private readonly accessSeconds;
50
52
  private readonly sessionSeconds;
53
+ private readonly idleSeconds;
51
54
  constructor(options: SessionAuthOptions);
52
55
  private ids;
53
56
  private sessionId;
54
57
  private rows;
58
+ private liveFilter;
55
59
  private allowed;
56
60
  private pair;
57
61
  create(subject: string, parent?: AuthSession["parent"]): Promise<CreatedAuthSession>;
@@ -61,6 +65,8 @@ export declare class SessionAuth {
61
65
  verifyCsrf(token: string, csrf: string, kind?: "access" | "refresh"): Promise<boolean>;
62
66
  /** One winner under concurrency. A previously consumed refresh token revokes its device session. */
63
67
  refresh(token: string): Promise<SessionTokenPair | null>;
68
+ /** Trusted browser GET resumption: renew access without consuming or replaying refresh rotation. */
69
+ renewAccess(token: string): Promise<SessionTokenPair | null>;
64
70
  /** Browser login metadata; arbitrary request data and token hashes are never exposed. */
65
71
  recordLogin(session: AuthSession, userAgent: string): Promise<void>;
66
72
  /** Persist activity with a one-minute throttle; authorization does not depend on this timestamp. */
@@ -27,6 +27,7 @@ class SessionAuth {
27
27
  environment: options.environment === undefined ? null : (0, AuthSecurity_1.authText)(options.environment, "environment") };
28
28
  this.accessSeconds = (0, AuthSecurity_1.authLifetime)(options.accessTokenSeconds, 15 * 60);
29
29
  this.sessionSeconds = (0, AuthSecurity_1.authLifetime)(options.sessionSeconds, 30 * 86400);
30
+ this.idleSeconds = options.idleTimeoutSeconds === undefined || options.idleTimeoutSeconds === false ? false : (0, AuthSecurity_1.authLifetime)(options.idleTimeoutSeconds, 7 * 86400);
30
31
  if (typeof options.isSessionAllowed !== "function")
31
32
  throw new Error("SessionAuth requires isSessionAllowed.");
32
33
  }
@@ -45,9 +46,17 @@ class SessionAuth {
45
46
  return db.collection(this.options.collection || "auth_sessions");
46
47
  });
47
48
  }
49
+ liveFilter(now = Date.now()) {
50
+ return Object.assign({ revokedAt: null, expiresAt: { $gt: now }, createdAt: { $gt: now - this.sessionSeconds * 1000 } }, (this.idleSeconds === false ? {} : { $and: [{ $or: [
51
+ { lastActiveAt: { $gt: now - this.idleSeconds * 1000 } },
52
+ { lastActiveAt: { $exists: false }, createdAt: { $gt: now - this.idleSeconds * 1000 } },
53
+ ] }] }));
54
+ }
48
55
  allowed(record) {
56
+ var _a;
49
57
  return __awaiter(this, void 0, void 0, function* () {
50
- if (!record || record.revokedAt !== null || !Number.isFinite(record.expiresAt) || record.expiresAt <= Date.now())
58
+ if (!record || record.revokedAt !== null || !Number.isFinite(record.expiresAt) || Math.min(record.expiresAt, record.createdAt + this.sessionSeconds * 1000) <= Date.now()
59
+ || (this.idleSeconds !== false && ((_a = record.lastActiveAt) !== null && _a !== void 0 ? _a : record.createdAt) + this.idleSeconds * 1000 <= Date.now()))
51
60
  return null;
52
61
  const session = publicSession(record);
53
62
  return (yield this.options.isSessionAllowed(session)) ? session : null;
@@ -55,7 +64,7 @@ class SessionAuth {
55
64
  }
56
65
  pair(record, access, refresh) {
57
66
  return { auth_token: access, refresh_token: refresh, auth_expires_at: record.accessExpiresAt,
58
- refresh_expires_at: record.expiresAt, session: publicSession(record) };
67
+ refresh_expires_at: Math.min(record.expiresAt, record.createdAt + this.sessionSeconds * 1000), session: publicSession(record) };
59
68
  }
60
69
  create(subject, parent = null) {
61
70
  return __awaiter(this, void 0, void 0, function* () {
@@ -66,7 +75,7 @@ class SessionAuth {
66
75
  }
67
76
  const now = Date.now(), id = this.ids().create();
68
77
  const access = `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`, refresh = `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`, csrf = (0, AuthSecurity_1.randomAuthToken)();
69
- const record = Object.assign(Object.assign({ _id: id, _subject: this.ids().create(subject) }, this.scope), { parent: parent ? Object.assign({}, parent) : null, createdAt: 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 });
78
+ const record = Object.assign(Object.assign({ _id: id, _subject: this.ids().create(subject) }, this.scope), { 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 });
70
79
  if (!(yield this.allowed(record)))
71
80
  throw new Error("Session is not allowed.");
72
81
  yield (yield this.rows()).insertOne(record);
@@ -78,8 +87,11 @@ class SessionAuth {
78
87
  const id = tokenSessionId(token);
79
88
  if (!id)
80
89
  return null;
81
- const row = yield (yield this.rows()).findOne(Object.assign(Object.assign({ _id: this.sessionId(id) }, this.scope), { accessHash: (0, AuthSecurity_1.hashAuthToken)(token), accessExpiresAt: { $gt: Date.now() } }));
82
- return this.allowed(row);
90
+ const row = yield (yield this.rows()).findOne(Object.assign(Object.assign(Object.assign({ _id: this.sessionId(id) }, this.scope), this.liveFilter()), { accessHash: (0, AuthSecurity_1.hashAuthToken)(token), accessExpiresAt: { $gt: Date.now() } }));
91
+ const session = yield this.allowed(row);
92
+ if (session && this.idleSeconds !== false)
93
+ yield this.touch(session);
94
+ return session;
83
95
  });
84
96
  }
85
97
  /** Trusted-server introspection; never expose this method as an unauthenticated endpoint. */
@@ -120,8 +132,9 @@ class SessionAuth {
120
132
  return null;
121
133
  }
122
134
  const access = `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`, refresh = `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`;
123
- const next = yield rows.findOneAndUpdate(Object.assign(Object.assign({ _id: this.sessionId(id) }, this.scope), { refreshHash: hash, revokedAt: null, expiresAt: { $gt: Date.now() } }), { $set: { accessHash: (0, AuthSecurity_1.hashAuthToken)(access), refreshHash: (0, AuthSecurity_1.hashAuthToken)(refresh),
124
- accessExpiresAt: Math.min(Date.now() + this.accessSeconds * 1000, row.expiresAt),
135
+ 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),
136
+ accessExpiresAt: Math.min(Date.now() + this.accessSeconds * 1000, row.expiresAt, row.createdAt + this.sessionSeconds * 1000),
137
+ lastActiveAt: Date.now(),
125
138
  usedRefreshHashes: [...row.usedRefreshHashes, hash] } }, { returnDocument: "after" });
126
139
  if (!next) {
127
140
  // Also detect simultaneous replay that lost the compare-and-swap.
@@ -131,6 +144,25 @@ class SessionAuth {
131
144
  return this.pair(next, access, refresh);
132
145
  });
133
146
  }
147
+ /** Trusted browser GET resumption: renew access without consuming or replaying refresh rotation. */
148
+ renewAccess(token) {
149
+ return __awaiter(this, void 0, void 0, function* () {
150
+ const id = tokenSessionId(token);
151
+ if (!id)
152
+ return null;
153
+ const rows = yield this.rows();
154
+ const filter = Object.assign(Object.assign(Object.assign({ _id: this.sessionId(id) }, this.scope), { refreshHash: (0, AuthSecurity_1.hashAuthToken)(token) }), this.liveFilter());
155
+ const row = yield rows.findOne(filter);
156
+ if (!row || !(yield this.allowed(row)))
157
+ return null;
158
+ const access = `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`, now = Date.now();
159
+ const next = yield rows.findOneAndUpdate(filter, { $set: { accessHash: (0, AuthSecurity_1.hashAuthToken)(access),
160
+ accessExpiresAt: Math.min(now + this.accessSeconds * 1000, row.expiresAt, row.createdAt + this.sessionSeconds * 1000),
161
+ lastActiveAt: now } }, { returnDocument: "after" });
162
+ const session = next ? yield this.allowed(next) : null;
163
+ return next && session ? Object.assign(Object.assign({}, this.pair(next, access, token)), { session }) : null;
164
+ });
165
+ }
134
166
  /** Browser login metadata; arbitrary request data and token hashes are never exposed. */
135
167
  recordLogin(session, userAgent) {
136
168
  return __awaiter(this, void 0, void 0, function* () {
@@ -144,9 +176,9 @@ class SessionAuth {
144
176
  var _a;
145
177
  return __awaiter(this, void 0, void 0, function* () {
146
178
  const now = Date.now();
147
- if (((_a = session.lastActiveAt) !== null && _a !== void 0 ? _a : session.createdAt) >= now - 60000)
179
+ if (((_a = session.lastActiveAt) !== null && _a !== void 0 ? _a : session.createdAt) >= now - (this.idleSeconds === false ? 60000 : Math.min(60000, this.idleSeconds * 250)))
148
180
  return;
149
- yield (yield this.rows()).updateOne(Object.assign(Object.assign({ _id: this.sessionId(session.id) }, this.scope), { revokedAt: null, expiresAt: { $gt: now } }), { $set: { lastActiveAt: now } });
181
+ yield (yield this.rows()).updateOne(Object.assign(Object.assign({ _id: this.sessionId(session.id) }, this.scope), this.liveFilter(now)), { $max: { lastActiveAt: now } });
150
182
  session.lastActiveAt = now;
151
183
  });
152
184
  }
@@ -165,14 +197,21 @@ class SessionAuth {
165
197
  list(subject) {
166
198
  return __awaiter(this, void 0, void 0, function* () {
167
199
  (0, AuthSecurity_1.authText)(subject, "subject");
168
- const rows = yield (yield this.rows()).find(Object.assign(Object.assign({ _subject: this.ids().create(subject) }, this.scope), { revokedAt: null, expiresAt: { $gt: Date.now() } })).toArray();
200
+ const rows = yield (yield this.rows()).find(Object.assign(Object.assign({ _subject: this.ids().create(subject) }, this.scope), this.liveFilter())).toArray();
169
201
  return rows.map(publicSession);
170
202
  });
171
203
  }
172
204
  /** Run periodically; authorization checks never depend on cleanup scheduling. */
173
205
  cleanup() {
174
206
  return __awaiter(this, void 0, void 0, function* () {
175
- yield (yield this.rows()).deleteMany(Object.assign(Object.assign({}, this.scope), { expiresAt: { $lte: Date.now() } }));
207
+ const now = Date.now();
208
+ yield (yield this.rows()).deleteMany(Object.assign(Object.assign({}, this.scope), { $or: [
209
+ { expiresAt: { $lte: now } }, { createdAt: { $lte: now - this.sessionSeconds * 1000 } },
210
+ ...(this.idleSeconds === false ? [] : [
211
+ { lastActiveAt: { $lte: now - this.idleSeconds * 1000 } },
212
+ { lastActiveAt: { $exists: false }, createdAt: { $lte: now - this.idleSeconds * 1000 } },
213
+ ]),
214
+ ] }));
176
215
  });
177
216
  }
178
217
  }
@@ -32,6 +32,8 @@ export declare class WebAuth {
32
32
  /** GET /auth/csrf, fetched from this origin. Also protects login before a user session exists. */
33
33
  bootstrap(req: Request, res: Response): string;
34
34
  private writePair;
35
+ /** GET/HEAD only. A current refresh secret and matching session-bound CSRF cookie are required. */
36
+ resume(req: Request, res: Response): Promise<AuthSession | null>;
35
37
  /** Credential verification is supplied by the app, after CSRF checks. Never accepts a user ID from the browser. */
36
38
  login(req: Request, res: Response, verifyCredentials: () => Promise<string | null>): Promise<AuthSession>;
37
39
  /** Trusted-server entry point after a validated SSO callback (not a public login endpoint). */
@@ -86,6 +86,24 @@ class WebAuth {
86
86
  this.cookies.write(res, "access", pair.auth_token, (pair.auth_expires_at - Date.now()) / 1000);
87
87
  this.cookies.write(res, "refresh", pair.refresh_token, (pair.refresh_expires_at - Date.now()) / 1000);
88
88
  }
89
+ /** GET/HEAD only. A current refresh secret and matching session-bound CSRF cookie are required. */
90
+ resume(req, res) {
91
+ return __awaiter(this, void 0, void 0, function* () {
92
+ if (!["GET", "HEAD"].includes(req.method))
93
+ return null;
94
+ const token = this.cookies.read(req, "refresh"), csrf = this.cookies.read(req, "csrf");
95
+ if (!(yield this.sessions.verifyCsrf(token, csrf, "refresh")))
96
+ return null;
97
+ const pair = yield this.sessions.renewAccess(token);
98
+ if (!pair)
99
+ return null;
100
+ this.writePair(res, pair);
101
+ const name = this.cookies.prefix + "access";
102
+ const cookies = (0, AuthSecurity_1.authHeader)(req, "cookie").split(";").map(value => value.trim()).filter(value => value && !value.startsWith(`${name}=`));
103
+ req.headers = Object.assign(Object.assign({}, req.headers), { cookie: [...cookies, `${name}=${encodeURIComponent(pair.auth_token)}`].join("; ") });
104
+ return pair.session;
105
+ });
106
+ }
89
107
  /** Credential verification is supplied by the app, after CSRF checks. Never accepts a user ID from the browser. */
90
108
  login(req, res, verifyCredentials) {
91
109
  return __awaiter(this, void 0, void 0, function* () {
@@ -6,3 +6,4 @@ export * from "./WebAuth";
6
6
  export * from "./SingleSignOn";
7
7
  export * from "./ModelAuth";
8
8
  export * from "./Auth";
9
+ export * from "./PasswordReset";
@@ -22,3 +22,4 @@ __exportStar(require("./WebAuth"), exports);
22
22
  __exportStar(require("./SingleSignOn"), exports);
23
23
  __exportStar(require("./ModelAuth"), exports);
24
24
  __exportStar(require("./Auth"), exports);
25
+ __exportStar(require("./PasswordReset"), exports);
@@ -90,6 +90,7 @@ class RouteFacade {
90
90
  Auth_1.Auth.registerScope(auth, overrides);
91
91
  }
92
92
  const base = prefix.replace(/\/+$/, "");
93
+ Auth_1.Auth.scope(auth).configureRoutes(base, loginPath);
93
94
  const registration = { middleware: options.middleware, domains: options.domains };
94
95
  this.get(`${base}/csrf`, (0, Auth_1.authRoute)("csrf", options), registration);
95
96
  this.post(options.loginPath || `${base}/login`, (0, Auth_1.authRoute)("login", options), registration);
@@ -312,7 +312,7 @@ not trimmed. Store a bcrypt hash; existing bcrypt hashes work without migration.
312
312
  and `isUserAllowed` adds account/tenant checks on login and every session access.
313
313
  An explicit `false` in `is_active` rejects the account; `fields.active: false`
314
314
  disables this field check. Optional `session` options configure storage, scope
315
- and lifetimes. For `AuthScope`, access and absolute session lifetimes default to
315
+ and lifetimes. For `AuthScope`, access lifetime defaults to 15 minutes and absolute session lifetime to
316
316
  30 days; both remain configurable through `session.accessTokenSeconds` and
317
317
  `session.sessionSeconds`. The lower-level `SessionAuth` retains its 15-minute
318
318
  access-token default.
@@ -428,3 +428,68 @@ hashes and expiry unchanged. SessionAuth recognizes the former token prefixes,
428
428
  so converted sessions retain their existing cookies. Applications should run
429
429
  an explicit migration while stopping auth writes, rather than changing schemas
430
430
  implicitly during a request.
431
+
432
+
433
+ ## Scope-configured lifetimes and password recovery
434
+
435
+ ```ts
436
+ Config.register("auth", {
437
+ scopes: {
438
+ main: {
439
+ model: User,
440
+ session: {
441
+ accessTokenSeconds: 15 * 60,
442
+ sessionSeconds: 30 * 86400,
443
+ idleTimeoutSeconds: 7 * 86400, // false explicitly disables inactivity expiry.
444
+ },
445
+ passwordReset: {
446
+ tokenSeconds: 15 * 60,
447
+ cooldownSeconds: 3 * 60,
448
+ revokeSessions: "all", // "scope" or false are explicit alternatives.
449
+ },
450
+ },
451
+ },
452
+ });
453
+
454
+ await Auth.scope().passwordReset?.request(email, async (user, token, expiresAt) => {
455
+ // Build a link from your configured website URL, never from request Host headers.
456
+ await sendResetEmail(user, token, expiresAt);
457
+ });
458
+ const user = await Auth.scope().passwordReset?.validate(token); // Does not consume it.
459
+ const changedUser = await Auth.scope().passwordReset?.reset(token, newPassword);
460
+ // null means invalid, expired, consumed, wrong scope, inactive account, or changed password.
461
+ ```
462
+
463
+ Scope defaults are a 15-minute access lifetime, a 30-day absolute lifetime and a
464
+ seven-day inactivity limit. `session.idleTimeoutSeconds: false` disables only the
465
+ inactivity limit. All deadlines are enforced on access, refresh, CSRF, introspection
466
+ and listing. Existing records also respect newly shortened configured limits.
467
+ Successful authentication updates activity with throttled writes; introspection
468
+ and CSRF verification alone do not extend inactivity. Absolute expiry never slides.
469
+
470
+ `scope.resolve(req, res)` resumes an expired access token for GET/HEAD if the current
471
+ refresh secret and matching session-bound CSRF cookie are valid. It issues a new
472
+ access token and retains the refresh secret, so parallel page requests do not
473
+ trigger refresh replay revocation. This trusted read resumption does not authorize
474
+ unsafe requests, revive expired sessions, or extend absolute expiry. Explicit
475
+ POST refresh rotates both secrets and retains replay protection. Middleware uses
476
+ this resumption automatically when a response is available. Browser `createAuthFetch`
477
+ refreshes rejected authentication/CSRF requests once, then repeats the rejected
478
+ request once; login failures and permission errors are never retried.
479
+
480
+ Reset tokens have 256-bit random secrets; only a SHA-256 digest bound to the scope
481
+ and current password is stored. Per-account request cooldown is an atomic conditional
482
+ update. Successful reset consumes the token and writes the new password in the same
483
+ atomic database operation. Other password changes invalidate outstanding links.
484
+ The callback is a trusted server delivery integration; raw reset tokens must not be
485
+ returned to browsers by the request endpoint. Password validation remains the app's
486
+ responsibility. Disable recovery explicitly with `passwordReset: false`.
487
+
488
+ The fields `auth_reset_<scope>_hash`, `_expires_at` and `_requested_at` are stored
489
+ on the model's document collection and must be included in the model's `__hidden`
490
+ when serializing entire models. Auth snapshots always exclude these fields.
491
+ `revokeSessions: "all"` revokes registered scopes using the same model class;
492
+ `"scope"` limits revocation to the initiating scope. Recovery does not log in the
493
+ user automatically. Existing application plaintext reset links are intentionally
494
+ not accepted; users must request a new link after migration. Reset pages should
495
+ use `Referrer-Policy: no-referrer` and avoid response caching.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webtypen/webframez-core",
3
- "version": "0.3.74",
3
+ "version": "0.3.75",
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",