@webtypen/webframez-core 0.3.76 → 0.3.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -925,3 +925,16 @@ the text representation; their separate `icon` property controls navigation icon
925
925
  ## DataBuilder model forms
926
926
 
927
927
  DataBuilder supports `@Field()`, parameterized `@Validates()`, server-side `ModelForm` classes and `Route.databuilder()` while retaining the v1 request protocol and legacy controller registrations. See [DataBuilder documentation](docs/databuilder.md).
928
+
929
+ ## Routing and lifecycle performance
930
+
931
+ Route and domain patterns are compiled at registration and reused while matching.
932
+ `Router.init()` resets their caches; runtime registration and legacy direct route
933
+ stores still work. Exact-route precedence, domain ordering, parameters and wildcards
934
+ retain their existing behavior. Public regex builders still produce fresh regexes.
935
+
936
+ Lifecycle hooks skip event normalization when no listeners are registered. Use
937
+ `WebframezHooks.hasListeners(eventName)` before constructing an expensive payload.
938
+ Registered handlers retain their ordered, awaited execution and snapshot behavior.
939
+ The router applies this check before building telemetry payloads. Middleware keeps
940
+ its existing `next`/`reject` callback contract.
@@ -0,0 +1,13 @@
1
+ export type BearerAuthTokens = {
2
+ auth_token: string;
3
+ refresh_token: string;
4
+ auth_expires_at: number;
5
+ refresh_expires_at: number;
6
+ };
7
+
8
+ export function createBearerAuthFetch(options: {
9
+ configuration: (url: URL) => { key: string; refreshUrl: string } | null;
10
+ load: (key: string) => Promise<BearerAuthTokens | null>;
11
+ clear?: (key: string) => Promise<void>;
12
+ save: (key: string, tokens: BearerAuthTokens) => Promise<void>;
13
+ }, originalFetch?: typeof fetch): typeof fetch;
@@ -0,0 +1,67 @@
1
+ /** Browser/native entry point; imports no server or database code. */
2
+ exports.createBearerAuthFetch = function createBearerAuthFetch(options, originalFetch = globalThis.fetch.bind(globalThis)) {
3
+ const pending = new Map();
4
+ const refresh = (configuration, failedToken) => {
5
+ const key = configuration.key;
6
+ const perform = async () => {
7
+ const current = await options.load(key);
8
+ if (!current) return null;
9
+ if (failedToken && current.auth_token !== failedToken) return current;
10
+ let response;
11
+ try {
12
+ response = await originalFetch(configuration.refreshUrl, { method: "POST", credentials: "omit",
13
+ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: current.refresh_token }),
14
+ redirect: "error", signal: typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(15000) : undefined });
15
+ } catch (error) {
16
+ // A timeout may have consumed the rotating token; never replay it automatically.
17
+ if (options.clear) await options.clear(key);
18
+ return null;
19
+ }
20
+ if (!response.ok) {
21
+ if (options.clear) await options.clear(key);
22
+ return null;
23
+ }
24
+ let pair;
25
+ try { pair = await response.json(); }
26
+ catch { if (options.clear) await options.clear(key); return null; }
27
+ if (pair.status !== "success" || typeof pair.auth_token !== "string" || typeof pair.refresh_token !== "string"
28
+ || !Number.isFinite(pair.auth_expires_at) || !Number.isFinite(pair.refresh_expires_at)) {
29
+ if (options.clear) await options.clear(key);
30
+ return null;
31
+ }
32
+ const latest = await options.load(key);
33
+ if (!latest || latest.refresh_token !== current.refresh_token) return latest;
34
+ await options.save(key, pair);
35
+ return pair;
36
+ };
37
+ if (!pending.has(key)) {
38
+ const task = typeof navigator !== "undefined" && navigator.locks
39
+ ? navigator.locks.request(`webframez-bearer:${key}`, perform) : perform();
40
+ pending.set(key, task.finally(() => pending.delete(key)));
41
+ }
42
+ return pending.get(key);
43
+ };
44
+ return async (input, init) => {
45
+ let url;
46
+ try { url = new URL(typeof Request !== "undefined" && input instanceof Request ? input.url : String(input), globalThis.location?.href); }
47
+ catch { return originalFetch(input, init); }
48
+ const configuration = options.configuration(url);
49
+ if (!configuration) return originalFetch(input, init);
50
+ const refreshUrl = new URL(configuration.refreshUrl);
51
+ if (refreshUrl.origin !== url.origin) throw new Error("Bearer refresh must use the request origin.");
52
+ let session = await options.load(configuration.key);
53
+ if (!session || url.href === refreshUrl.href) return originalFetch(input, init);
54
+ if (session.auth_expires_at <= Date.now() + 30000) session = await refresh(configuration, session.auth_token) || session;
55
+ const headers = new Headers(init?.headers || (typeof Request !== "undefined" && input instanceof Request ? input.headers : undefined));
56
+ headers.set("Authorization", `Bearer ${session.auth_token}`);
57
+ const retryInput = typeof Request !== "undefined" && input instanceof Request ? input.clone() : input;
58
+ const response = await originalFetch(input, { ...init, headers, credentials: "omit", redirect: "error" });
59
+ if (response.status !== 401) return response;
60
+ const failure = await response.clone().json().catch(() => null);
61
+ if (failure?.code !== "unauthorized") return response;
62
+ const renewed = await refresh(configuration, session.auth_token);
63
+ if (!renewed) return response;
64
+ headers.set("Authorization", `Bearer ${renewed.auth_token}`);
65
+ return originalFetch(retryInput, { ...init, headers, credentials: "omit", redirect: "error" });
66
+ };
67
+ };
package/dist/Auth/Auth.js CHANGED
@@ -15,7 +15,7 @@ const Model_1 = require("../Database/Model");
15
15
  const ModelAuth_1 = require("./ModelAuth");
16
16
  function mergeOptions(base, config) {
17
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) });
18
+ : Object.assign(Object.assign({}, (base.passwordReset || {})), (config.passwordReset || {})), memberships: Object.assign(Object.assign({}, base.memberships), config.memberships), messages: Object.assign(Object.assign({}, base.messages), config.messages) });
19
19
  }
20
20
  /** Application-wide registry of named browser-authentication scopes. */
21
21
  class AuthFacade {
@@ -0,0 +1,16 @@
1
+ import type { DocumentDatabase } from "../Database/DatabaseAdapter";
2
+ /** Single-use delivery to a separate frontend origin after a server-side SSO callback. */
3
+ export declare class AuthHandoff {
4
+ private readonly options;
5
+ private readonly key;
6
+ constructor(options: {
7
+ secret: string;
8
+ audience: string;
9
+ connection?: string;
10
+ database?: () => Promise<DocumentDatabase>;
11
+ });
12
+ private rows;
13
+ create(payload: object): Promise<string>;
14
+ consume<T = unknown>(code: string): Promise<T | null>;
15
+ cleanup(): Promise<void>;
16
+ }
@@ -0,0 +1,64 @@
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.AuthHandoff = void 0;
13
+ const crypto_1 = require("crypto");
14
+ const DBConnection_1 = require("../Database/DBConnection");
15
+ const AuthSecurity_1 = require("./AuthSecurity");
16
+ /** Single-use delivery to a separate frontend origin after a server-side SSO callback. */
17
+ class AuthHandoff {
18
+ constructor(options) {
19
+ this.options = options;
20
+ if (typeof options.secret !== "string" || options.secret.length < 32)
21
+ throw new Error("Auth handoff requires a strong server secret.");
22
+ if (!options.audience)
23
+ throw new Error("Auth handoff requires an audience.");
24
+ this.key = (0, crypto_1.createHash)("sha256").update(`webframez-handoff:${options.secret}`).digest();
25
+ }
26
+ rows() {
27
+ return __awaiter(this, void 0, void 0, function* () {
28
+ const db = yield (this.options.database ? this.options.database() : DBConnection_1.DBConnection.getDocumentStore(this.options.connection));
29
+ return db.collection("auth_handoffs");
30
+ });
31
+ }
32
+ create(payload) {
33
+ return __awaiter(this, void 0, void 0, function* () {
34
+ const code = (0, AuthSecurity_1.randomAuthToken)(), iv = (0, crypto_1.randomBytes)(12);
35
+ const cipher = (0, crypto_1.createCipheriv)("aes-256-gcm", this.key, iv);
36
+ cipher.setAAD(Buffer.from(this.options.audience));
37
+ const encrypted = Buffer.concat([cipher.update(JSON.stringify(payload), "utf8"), cipher.final()]);
38
+ yield (yield this.rows()).insertOne({ _id: (0, AuthSecurity_1.hashAuthToken)(code), audience: this.options.audience,
39
+ iv: iv.toString("base64"), tag: cipher.getAuthTag().toString("base64"), payload: encrypted.toString("base64"),
40
+ expiresAt: Date.now() + 30000, purgeAt: new Date(Date.now() + 60000), consumedAt: null });
41
+ return code;
42
+ });
43
+ }
44
+ consume(code) {
45
+ return __awaiter(this, void 0, void 0, function* () {
46
+ if (typeof code !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(code))
47
+ return null;
48
+ const row = yield (yield this.rows()).findOneAndUpdate({ _id: (0, AuthSecurity_1.hashAuthToken)(code), audience: this.options.audience,
49
+ expiresAt: { $gt: Date.now() }, consumedAt: null }, { $set: { consumedAt: Date.now(), payload: "" } }, { returnDocument: "before" });
50
+ if (!row)
51
+ return null;
52
+ const decipher = (0, crypto_1.createDecipheriv)("aes-256-gcm", this.key, Buffer.from(row.iv, "base64"));
53
+ decipher.setAAD(Buffer.from(this.options.audience));
54
+ decipher.setAuthTag(Buffer.from(row.tag, "base64"));
55
+ return JSON.parse(Buffer.concat([decipher.update(Buffer.from(row.payload, "base64")), decipher.final()]).toString("utf8"));
56
+ });
57
+ }
58
+ cleanup() {
59
+ return __awaiter(this, void 0, void 0, function* () {
60
+ yield (yield this.rows()).deleteMany({ audience: this.options.audience, expiresAt: { $lte: Date.now() } });
61
+ });
62
+ }
63
+ }
64
+ exports.AuthHandoff = AuthHandoff;
@@ -0,0 +1,39 @@
1
+ import { Model } from "../Database/Model";
2
+ export type AuthMembershipCondition = {
3
+ field: string;
4
+ operator: "=" | "!=";
5
+ value: unknown;
6
+ };
7
+ export type AuthMembershipBinding = {
8
+ user: {
9
+ foreignKey: string;
10
+ localKeys: readonly string[];
11
+ firstAvailable?: boolean;
12
+ };
13
+ resource: {
14
+ foreignKey: string;
15
+ localKeys: readonly string[];
16
+ };
17
+ };
18
+ /** Only server-loaded models and server-configured fields participate in authorization. */
19
+ export type AuthMembershipOptions = {
20
+ model: typeof Model;
21
+ bindings: readonly AuthMembershipBinding[];
22
+ /** Optionally bind this membership to the environment selected by a federated session. */
23
+ sessionEnvironmentKey?: string;
24
+ conditions?: readonly AuthMembershipCondition[];
25
+ userConditions?: readonly AuthMembershipCondition[];
26
+ resourceConditions?: readonly AuthMembershipCondition[];
27
+ };
28
+ export type AuthMembershipContext<TMembership extends Model = Model, TResource extends Model = Model> = {
29
+ membership: TMembership;
30
+ resource: TResource;
31
+ };
32
+ /** Resolves fresh membership records without caching authorization between requests. */
33
+ export declare class AuthMembership {
34
+ private readonly options;
35
+ constructor(options: AuthMembershipOptions);
36
+ private matches;
37
+ matchesEnvironment(resource: Model, environment: string | null): boolean;
38
+ find<TMembership extends Model = Model>(user: Model, resource: Model): Promise<TMembership | null>;
39
+ }
@@ -0,0 +1,62 @@
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.AuthMembership = void 0;
13
+ const Model_1 = require("../Database/Model");
14
+ /** Resolves fresh membership records without caching authorization between requests. */
15
+ class AuthMembership {
16
+ constructor(options) {
17
+ var _a;
18
+ this.options = options;
19
+ if (!options.model || !((_a = options.bindings) === null || _a === void 0 ? void 0 : _a.length))
20
+ throw new Error("Auth membership requires a model and bindings.");
21
+ for (const binding of options.bindings) {
22
+ if (!binding.user.foreignKey || !binding.resource.foreignKey
23
+ || !binding.user.localKeys.length || !binding.resource.localKeys.length) {
24
+ throw new Error("Auth membership requires user and resource reference fields.");
25
+ }
26
+ }
27
+ }
28
+ matches(model, conditions = []) {
29
+ return conditions.every(condition => condition.operator === "="
30
+ ? model[condition.field] === condition.value : model[condition.field] !== condition.value);
31
+ }
32
+ matchesEnvironment(resource, environment) {
33
+ const key = this.options.sessionEnvironmentKey;
34
+ return !environment || !key || String(resource === null || resource === void 0 ? void 0 : resource[key]) === environment;
35
+ }
36
+ find(user, resource) {
37
+ return __awaiter(this, void 0, void 0, function* () {
38
+ if (!(user instanceof Model_1.Model) || !(resource instanceof Model_1.Model)
39
+ || !this.matches(user, this.options.userConditions) || !this.matches(resource, this.options.resourceConditions))
40
+ return null;
41
+ for (const binding of this.options.bindings) {
42
+ let userIds = binding.user.localKeys.map(key => user[key]).filter(value => value !== null && value !== undefined && value !== "");
43
+ if (binding.user.firstAvailable)
44
+ userIds = userIds.slice(0, 1);
45
+ const resourceIds = binding.resource.localKeys.map(key => resource[key]).filter(value => value !== null && value !== undefined && value !== "");
46
+ // Missing references must never turn into an unbounded membership lookup.
47
+ if (!userIds.length || !resourceIds.length)
48
+ continue;
49
+ let query = this.options.model.where(binding.user.foreignKey, "=", { $in: userIds })
50
+ .where(binding.resource.foreignKey, "=", { $in: resourceIds });
51
+ for (const condition of this.options.conditions || []) {
52
+ query = query.where(condition.field, condition.operator, condition.value);
53
+ }
54
+ const membership = yield query.first();
55
+ if (membership)
56
+ return membership;
57
+ }
58
+ return null;
59
+ });
60
+ }
61
+ }
62
+ exports.AuthMembership = AuthMembership;
@@ -1,12 +1,14 @@
1
1
  import { Model } from "../Database/Model";
2
2
  import { Request } from "../Router/Request";
3
3
  import type { Response } from "../Router/Response";
4
+ import { AuthMembershipContext, AuthMembershipOptions } from "./AuthMembership";
4
5
  import { PasswordReset, PasswordResetOptions } from "./PasswordReset";
5
6
  import { AuthSession, SessionAuthOptions } from "./SessionAuth";
6
7
  import { WebAuth, WebAuthError, WebAuthOptions } from "./WebAuth";
7
8
  export type AuthContext<TUser extends Model = Model> = {
8
9
  user: TUser;
9
10
  session: AuthSession;
11
+ memberships?: Record<string, AuthMembershipContext>;
10
12
  };
11
13
  export type AuthSnapshot<TUser = Record<string, unknown>> = {
12
14
  user: TUser;
@@ -19,6 +21,8 @@ export declare class AuthLoginError extends WebAuthError {
19
21
  }
20
22
  export type ModelAuthOptions = Partial<WebAuthOptions> & {
21
23
  model: typeof Model;
24
+ /** Bearer scopes return opaque tokens and never use ambient authentication cookies. */
25
+ transport?: "cookie" | "bearer";
22
26
  key?: string;
23
27
  /** Takes precedence over session.audience; defaults to the configured origin. */
24
28
  audience?: string;
@@ -35,6 +39,8 @@ export type ModelAuthOptions = Partial<WebAuthOptions> & {
35
39
  session?: Partial<Omit<SessionAuthOptions, "issuer" | "isSessionAllowed">>;
36
40
  csrfDisabled?: false;
37
41
  verifyPassword?: (password: string, storedHash: string) => boolean | Promise<boolean>;
42
+ memberships?: Record<string, AuthMembershipOptions>;
43
+ isSessionAllowed?: (session: AuthSession, user: Model) => boolean | Promise<boolean>;
38
44
  isUserAllowed?: (user: Model) => boolean | Promise<boolean>;
39
45
  beforeLogin?: AuthActionHook;
40
46
  afterLogin?: AuthActionHook;
@@ -60,6 +66,8 @@ export declare class AuthScope extends WebAuth {
60
66
  readonly key: string;
61
67
  readonly passwordReset: PasswordReset | null;
62
68
  private readonly model;
69
+ private readonly memberships;
70
+ private readonly contexts;
63
71
  private authPaths;
64
72
  private readonly sessionUsers;
65
73
  private readonly fields;
@@ -77,8 +85,23 @@ export declare class AuthScope extends WebAuth {
77
85
  verifyCredentials(req: Request): Promise<string>;
78
86
  login(req: Request, res: Response, verifyCredentials?: () => Promise<string | null>): Promise<AuthSession>;
79
87
  logout(req: Request, res: Response): Promise<void>;
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<{
90
+ auth_token: string;
91
+ refresh_token: string;
92
+ auth_expires_at: number;
93
+ refresh_expires_at: number;
94
+ session: AuthSession;
95
+ }>;
96
+ logoutBearer(req: Request): Promise<void>;
97
+ /** Explicit Authorization credentials require no cookie CSRF and are never read from query parameters. */
98
+ resolveBearer<TUser extends Model = Model>(req: Request, res?: Response): Promise<AuthContext<TUser> | null>;
80
99
  /** Resolve a fresh validated session and its model; never trusts a client-supplied req.auth. */
81
100
  resolve<TUser extends Model = Model>(req: Request, res?: Response): Promise<AuthContext<TUser> | null>;
101
+ /** For integrations whose authenticated User was established by another trusted transport. */
102
+ findMembership<TMembership extends Model = Model>(name: string, user: Model, resource: Model): Promise<TMembership | null>;
103
+ /** Attaches a fresh membership only to a context authenticated by this scope. */
104
+ authorizeMembership<TMembership extends Model = Model>(req: Request, name: string, resource: Model): Promise<TMembership | null>;
82
105
  resolveCookies<TUser extends Model = Model>(cookies: Record<string, string | undefined>): Promise<AuthContext<TUser> | null>;
83
106
  /** Only explicit public fields are sent to browsers; Model instances remain on the server. */
84
107
  snapshot(auth: AuthContext | null | undefined): AuthSnapshot | null;
@@ -8,6 +8,17 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
8
8
  step((generator = generator.apply(thisArg, _arguments || [])).next());
9
9
  });
10
10
  };
11
+ var __rest = (this && this.__rest) || function (s, e) {
12
+ var t = {};
13
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
14
+ t[p] = s[p];
15
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
16
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
17
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
18
+ t[p[i]] = s[p[i]];
19
+ }
20
+ return t;
21
+ };
11
22
  Object.defineProperty(exports, "__esModule", { value: true });
12
23
  exports.ModelAuth = exports.AuthScope = exports.AuthLoginError = void 0;
13
24
  const bcryptjs_1 = require("bcryptjs");
@@ -15,6 +26,7 @@ const routing_1 = require("../routing");
15
26
  const Config_1 = require("../Config");
16
27
  const Request_1 = require("../Router/Request");
17
28
  const Auth_1 = require("./Auth");
29
+ const AuthMembership_1 = require("./AuthMembership");
18
30
  const PasswordReset_1 = require("./PasswordReset");
19
31
  const AuthSecurity_1 = require("./AuthSecurity");
20
32
  const SessionAuth_1 = require("./SessionAuth");
@@ -79,14 +91,21 @@ class AuthScope extends WebAuth_1.WebAuth {
79
91
  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* () {
80
92
  const id = fields.primaryKey === "_id" ? yield model.objectId(session.subject, { noExceptions: true }) : session.subject;
81
93
  const user = id !== null && id !== undefined ? yield model.where(fields.primaryKey, "=", id).first() : null;
82
- if (!(yield allowed(user)))
94
+ if (!(yield allowed(user)) || (modelOptions.isSessionAllowed && !(yield modelOptions.isSessionAllowed(session, user))))
83
95
  return false;
84
96
  sessionUsers.set(session, user);
85
97
  return true;
86
98
  }) }));
87
99
  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 }));
88
100
  this.modelOptions = modelOptions;
101
+ this.memberships = new Map();
102
+ this.contexts = new WeakSet();
89
103
  this.authPaths = { basePath: "/api/auth", loginPath: "/api/auth/login" };
104
+ for (const [name, options] of Object.entries(modelOptions.memberships || {})) {
105
+ if (!/^[A-Za-z0-9_-]+$/.test(name))
106
+ throw new Error("Invalid auth membership key.");
107
+ this.memberships.set(name, new AuthMembership_1.AuthMembership(options));
108
+ }
90
109
  this.key = key;
91
110
  this.sessionUsers = sessionUsers;
92
111
  this.model = model;
@@ -191,6 +210,82 @@ class AuthScope extends WebAuth_1.WebAuth {
191
210
  yield this.modelOptions.afterLogout(req, user, this);
192
211
  });
193
212
  }
213
+ /** Trusted SSO/server entry point; the subject must already be verified by the caller. */
214
+ establishBearerSession(req, subject, parent = null, environment) {
215
+ return __awaiter(this, void 0, void 0, function* () {
216
+ if (this.modelOptions.transport !== "bearer")
217
+ throw new Error("Bearer transport is not enabled for this auth scope.");
218
+ const id = this.fields.primaryKey === "_id" ? yield this.model.objectId(subject, { noExceptions: true }) : subject;
219
+ const user = id == null ? null : yield this.model.where(this.fields.primaryKey, "=", id).first();
220
+ if (!user || (this.fields.active !== false && user[this.fields.active] === false)
221
+ || (this.modelOptions.isUserAllowed && !(yield this.modelOptions.isUserAllowed(user)))) {
222
+ throw new AuthLoginError("inactive_user", 403, this.message("inactive_user"));
223
+ }
224
+ if (this.modelOptions.beforeLogin) {
225
+ try {
226
+ yield this.modelOptions.beforeLogin(req, user, this);
227
+ }
228
+ catch (error) {
229
+ throw new AuthHookError("login_blocked", error);
230
+ }
231
+ }
232
+ const pair = yield this.sessions.create(subject, parent, environment);
233
+ if (this.modelOptions.trackActivity !== false)
234
+ yield this.sessions.recordLogin(pair.session, (0, AuthSecurity_1.authHeader)(req, "user-agent"));
235
+ if (this.modelOptions.onLogin)
236
+ yield this.modelOptions.onLogin(pair.session, req);
237
+ if (this.modelOptions.afterLogin)
238
+ yield this.modelOptions.afterLogin(req, user, this);
239
+ const { csrf_token } = pair, tokens = __rest(pair, ["csrf_token"]);
240
+ return tokens;
241
+ });
242
+ }
243
+ logoutBearer(req) {
244
+ return __awaiter(this, void 0, void 0, function* () {
245
+ if (req.method !== "POST")
246
+ throw new WebAuth_1.WebAuthError(403, "Logout requires POST.");
247
+ const auth = yield this.resolveBearer(req);
248
+ if (!auth)
249
+ throw new AuthLoginError("unauthorized", 401, this.message("unauthorized"));
250
+ if (this.modelOptions.beforeLogout) {
251
+ try {
252
+ yield this.modelOptions.beforeLogout(req, auth.user, this);
253
+ }
254
+ catch (error) {
255
+ throw new AuthHookError("logout_blocked", error);
256
+ }
257
+ }
258
+ yield this.sessions.revoke(auth.session.id);
259
+ if (this.modelOptions.afterLogout)
260
+ yield this.modelOptions.afterLogout(req, auth.user, this);
261
+ });
262
+ }
263
+ /** Explicit Authorization credentials require no cookie CSRF and are never read from query parameters. */
264
+ resolveBearer(req, res) {
265
+ var _a;
266
+ return __awaiter(this, void 0, void 0, function* () {
267
+ if (this.modelOptions.transport !== "bearer")
268
+ throw new Error("Bearer transport is not enabled for this auth scope.");
269
+ res === null || res === void 0 ? void 0 : res.header("Cache-Control", "no-store");
270
+ Object.defineProperty(req, "auth", { value: null, writable: true, configurable: true, enumerable: false });
271
+ const header = (0, AuthSecurity_1.authHeader)(req, "authorization");
272
+ const token = (_a = /^Bearer ([A-Za-z0-9_-]{1,128}\.[A-Za-z0-9_-]{43})$/i.exec(header)) === null || _a === void 0 ? void 0 : _a[1];
273
+ if (!token)
274
+ return null;
275
+ const session = yield this.sessions.authenticate(token);
276
+ const user = session && this.sessionUsers.get(session);
277
+ if (!session || !user)
278
+ return null;
279
+ if (this.modelOptions.trackActivity !== false)
280
+ yield this.sessions.touch(session);
281
+ const auth = { user, session };
282
+ this.contexts.add(auth);
283
+ if (this.modelOptions.onAuthenticate)
284
+ yield this.modelOptions.onAuthenticate(auth, req);
285
+ req.auth = auth;
286
+ return auth;
287
+ });
288
+ }
194
289
  /** Resolve a fresh validated session and its model; never trusts a client-supplied req.auth. */
195
290
  resolve(req, res) {
196
291
  const _super = Object.create(null, {
@@ -198,6 +293,8 @@ class AuthScope extends WebAuth_1.WebAuth {
198
293
  resume: { get: () => super.resume }
199
294
  });
200
295
  return __awaiter(this, void 0, void 0, function* () {
296
+ if (this.modelOptions.transport === "bearer")
297
+ return this.resolveBearer(req, res);
201
298
  res === null || res === void 0 ? void 0 : res.header("Cache-Control", "no-store");
202
299
  Object.defineProperty(req, "auth", { value: null, writable: true, configurable: true, enumerable: false });
203
300
  const session = (yield _super.authenticate.call(this, req)) || (res ? yield _super.resume.call(this, req, res) : null);
@@ -209,12 +306,44 @@ class AuthScope extends WebAuth_1.WebAuth {
209
306
  if (this.modelOptions.trackActivity !== false)
210
307
  yield this.sessions.touch(session);
211
308
  const auth = { user, session };
309
+ this.contexts.add(auth);
212
310
  if (this.modelOptions.onAuthenticate)
213
311
  yield this.modelOptions.onAuthenticate(auth, req);
214
312
  req.auth = auth;
215
313
  return auth;
216
314
  });
217
315
  }
316
+ /** For integrations whose authenticated User was established by another trusted transport. */
317
+ findMembership(name, user, resource) {
318
+ return __awaiter(this, void 0, void 0, function* () {
319
+ const resolver = this.memberships.get(name);
320
+ if (!resolver)
321
+ throw new Error(`Auth membership "${name}" is not configured.`);
322
+ return resolver.find(user, resource);
323
+ });
324
+ }
325
+ /** Attaches a fresh membership only to a context authenticated by this scope. */
326
+ authorizeMembership(req, name, resource) {
327
+ return __awaiter(this, void 0, void 0, function* () {
328
+ const auth = req.auth;
329
+ if (!auth || !this.contexts.has(auth))
330
+ return null;
331
+ if (auth.memberships)
332
+ delete auth.memberships[name];
333
+ const resolver = this.memberships.get(name);
334
+ if (!resolver)
335
+ throw new Error(`Auth membership "${name}" is not configured.`);
336
+ if (!resolver.matchesEnvironment(resource, auth.session.environment))
337
+ return null;
338
+ const membership = yield resolver.find(auth.user, resource);
339
+ if (!membership)
340
+ return null;
341
+ if (!auth.memberships)
342
+ auth.memberships = Object.create(null);
343
+ auth.memberships[name] = { resource, membership };
344
+ return membership;
345
+ });
346
+ }
218
347
  resolveCookies(cookies) {
219
348
  return __awaiter(this, void 0, void 0, function* () {
220
349
  const req = Object.assign(new Request_1.Request(), { method: "GET", headers: { cookie: `${this.cookies.prefix}access=${encodeURIComponent(cookies[`${this.cookies.prefix}access`] || "")}` } });
@@ -261,10 +390,29 @@ class AuthScope extends WebAuth_1.WebAuth {
261
390
  });
262
391
  }
263
392
  handle(operation, req, res, loginResponse) {
264
- var _a, _b;
393
+ var _a, _b, _c;
265
394
  return __awaiter(this, void 0, void 0, function* () {
266
395
  res.header("Cache-Control", "no-store");
267
396
  try {
397
+ if (this.modelOptions.transport === "bearer") {
398
+ if (operation === "csrf")
399
+ return res.status(405).send({ status: "error", code: "unsupported_operation" });
400
+ if (req.method !== "POST")
401
+ throw new WebAuth_1.WebAuthError(403, "Authentication requires POST.");
402
+ if (operation === "logout") {
403
+ yield this.logoutBearer(req);
404
+ return res.send({ status: "success" });
405
+ }
406
+ if (operation === "refresh") {
407
+ const token = (_a = req.body) === null || _a === void 0 ? void 0 : _a.refresh_token;
408
+ const pair = typeof token === "string" ? yield this.sessions.refresh(token) : null;
409
+ if (!pair)
410
+ throw new AuthLoginError("unauthorized", 401, this.message("unauthorized"));
411
+ return res.send(Object.assign({ status: "success" }, pair));
412
+ }
413
+ const pair = yield this.establishBearerSession(req, yield this.verifyCredentials(req));
414
+ return res.send(Object.assign(Object.assign(Object.assign({}, (loginResponse ? yield loginResponse(req, pair.session) : {})), { status: "success" }), pair));
415
+ }
268
416
  if (operation === "csrf")
269
417
  return res.send({ csrf: this.bootstrap(req, res) });
270
418
  if (operation === "logout") {
@@ -284,7 +432,7 @@ class AuthScope extends WebAuth_1.WebAuth {
284
432
  if (!(error instanceof WebAuth_1.WebAuthError))
285
433
  console.error(error);
286
434
  return res.status(error instanceof WebAuth_1.WebAuthError ? error.status : 500).send({ status: "error", code,
287
- message: error instanceof AuthHookError ? (_b = (_a = this.modelOptions.messages) === null || _a === void 0 ? void 0 : _a[code]) !== null && _b !== void 0 ? _b : error.message : this.message(code) });
435
+ message: error instanceof AuthHookError ? (_c = (_b = this.modelOptions.messages) === null || _b === void 0 ? void 0 : _b[code]) !== null && _c !== void 0 ? _c : error.message : this.message(code) });
288
436
  }
289
437
  });
290
438
  }
@@ -30,6 +30,8 @@ export type SessionAuthOptions = {
30
30
  audience: string;
31
31
  scope?: string;
32
32
  environment?: string;
33
+ /** Allow server-selected environments per session instead of one fixed environment. */
34
+ allowDynamicEnvironment?: boolean;
33
35
  connection?: string;
34
36
  collection?: string;
35
37
  accessTokenSeconds?: number;
@@ -58,7 +60,7 @@ export declare class SessionAuth {
58
60
  private liveFilter;
59
61
  private allowed;
60
62
  private pair;
61
- create(subject: string, parent?: AuthSession["parent"]): Promise<CreatedAuthSession>;
63
+ create(subject: string, parent?: AuthSession["parent"], environment?: string): Promise<CreatedAuthSession>;
62
64
  authenticate(token: string): Promise<AuthSession | null>;
63
65
  /** Trusted-server introspection; never expose this method as an unauthenticated endpoint. */
64
66
  inspect(sessionId: string): Promise<AuthSession | null>;
@@ -23,8 +23,8 @@ class SessionAuth {
23
23
  constructor(options) {
24
24
  var _a;
25
25
  this.options = options;
26
- this.scope = { 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"),
27
- environment: options.environment === undefined ? null : (0, AuthSecurity_1.authText)(options.environment, "environment") };
26
+ 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
+ : { 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
30
  this.idleSeconds = options.idleTimeoutSeconds === undefined || options.idleTimeoutSeconds === false ? false : (0, AuthSecurity_1.authLifetime)(options.idleTimeoutSeconds, 7 * 86400);
@@ -66,16 +66,20 @@ class SessionAuth {
66
66
  return { auth_token: access, refresh_token: refresh, auth_expires_at: record.accessExpiresAt,
67
67
  refresh_expires_at: Math.min(record.expiresAt, record.createdAt + this.sessionSeconds * 1000), session: publicSession(record) };
68
68
  }
69
- create(subject, parent = null) {
69
+ create(subject, parent = null, environment) {
70
+ var _a;
70
71
  return __awaiter(this, void 0, void 0, function* () {
71
72
  (0, AuthSecurity_1.authText)(subject, "subject");
73
+ if (environment !== undefined && (!this.options.allowDynamicEnvironment || this.options.environment !== undefined)) {
74
+ throw new Error("Dynamic session environments are not enabled.");
75
+ }
72
76
  if (parent) {
73
77
  (0, AuthSecurity_1.authText)(parent.issuer, "parent issuer");
74
78
  (0, AuthSecurity_1.authText)(parent.sessionId, "parent session");
75
79
  }
76
80
  const now = Date.now(), id = this.ids().create();
77
81
  const access = `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`, refresh = `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`, csrf = (0, AuthSecurity_1.randomAuthToken)();
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 });
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 });
79
83
  if (!(yield this.allowed(record)))
80
84
  throw new Error("Session is not allowed.");
81
85
  yield (yield this.rows()).insertOne(record);
@@ -118,7 +118,7 @@ class SsoAuthority {
118
118
  yield (yield this.rows()).insertOne({ _id: (0, AuthSecurity_1.hashAuthToken)(code), issuer: this.issuer,
119
119
  audience: client.id, subject: session.subject, environment: request.environment,
120
120
  authoritySessionId: session.id, redirectUri: request.redirectUri, challenge: request.codeChallenge,
121
- expiresAt: Math.min(Date.now() + 60000, session.expiresAt), consumedAt: null });
121
+ expiresAt: Math.min(Date.now() + 60000, session.expiresAt), purgeAt: new Date(Date.now() + 60000), consumedAt: null });
122
122
  const redirect = new URL(request.redirectUri);
123
123
  redirect.searchParams.set("code", code);
124
124
  redirect.searchParams.set("state", request.state);
@@ -160,6 +160,7 @@ class SsoAuthority {
160
160
  const session = client ? yield this.options.sessions.inspect(sessionId) : null;
161
161
  if (!client || !session || !(yield this.options.authorize(session, client, environment)))
162
162
  return null;
163
+ yield this.options.sessions.touch(session);
163
164
  return { issuer: this.issuer, audience: client.id, subject: session.subject, environment,
164
165
  authoritySessionId: session.id, expiresAt: session.expiresAt };
165
166
  });
@@ -236,7 +237,7 @@ class SsoClient {
236
237
  (0, AuthSecurity_1.authText)(environment, "environment");
237
238
  const state = (0, AuthSecurity_1.randomAuthToken)(), binding = (0, AuthSecurity_1.randomAuthToken)(), verifier = (0, AuthSecurity_1.randomAuthToken)();
238
239
  yield (yield this.rows()).insertOne({ _id: (0, AuthSecurity_1.hashAuthToken)(state), issuer: this.issuer, clientId: this.options.clientId,
239
- bindingHash: (0, AuthSecurity_1.hashAuthToken)(binding), verifier, environment, expiresAt: Date.now() + 300000, consumedAt: null });
240
+ bindingHash: (0, AuthSecurity_1.hashAuthToken)(binding), verifier, environment, expiresAt: Date.now() + 300000, purgeAt: new Date(Date.now() + 300000), consumedAt: null });
240
241
  this.cookies.write(res, "sso", binding, 300);
241
242
  res.header("Referrer-Policy", "no-referrer");
242
243
  const url = new URL(this.authorization.href);
@@ -7,3 +7,5 @@ export * from "./SingleSignOn";
7
7
  export * from "./ModelAuth";
8
8
  export * from "./Auth";
9
9
  export * from "./PasswordReset";
10
+ export * from "./AuthMembership";
11
+ export * from "./AuthHandoff";
@@ -23,3 +23,5 @@ __exportStar(require("./SingleSignOn"), exports);
23
23
  __exportStar(require("./ModelAuth"), exports);
24
24
  __exportStar(require("./Auth"), exports);
25
25
  __exportStar(require("./PasswordReset"), exports);
26
+ __exportStar(require("./AuthMembership"), exports);
27
+ __exportStar(require("./AuthHandoff"), exports);
@@ -32,6 +32,7 @@ declare class WebframezHooksFacade {
32
32
  on<TEventName extends WebframezHookEventName>(eventName: TEventName, handler: WebframezHookHandler<TEventName>): () => this;
33
33
  off<TEventName extends WebframezHookEventName>(eventName: TEventName, handler: WebframezHookHandler<TEventName>): this;
34
34
  clear(eventName?: WebframezHookEventName): this;
35
+ hasListeners(eventName: WebframezHookEventName): boolean;
35
36
  emit<TEventName extends WebframezHookEventName>(eventName: TEventName, context: Omit<Partial<WebframezOperationContext>, "operation" | "phase"> & {
36
37
  operationId: string;
37
38
  }): Promise<void>;
@@ -56,8 +56,16 @@ class WebframezHooksFacade {
56
56
  this.handlers = {};
57
57
  return this;
58
58
  }
59
+ hasListeners(eventName) {
60
+ var _a;
61
+ return (((_a = this.handlers[eventName]) === null || _a === void 0 ? void 0 : _a.size) || 0) > 0;
62
+ }
59
63
  emit(eventName, context) {
60
64
  return __awaiter(this, void 0, void 0, function* () {
65
+ const handlers = this.handlers[eventName];
66
+ if (!handlers || handlers.size < 1) {
67
+ return;
68
+ }
61
69
  const parsed = parseEventName(eventName);
62
70
  const attributes = Object.assign({}, (context.attributes || {}));
63
71
  if (context.error && attributes["error.type"] === undefined) {
@@ -67,10 +75,6 @@ class WebframezHooksFacade {
67
75
  type: eventName,
68
76
  context: Object.assign(Object.assign({}, context), { operation: parsed.operation, phase: parsed.phase, timestamp: context.timestamp || new Date(), attributes }),
69
77
  };
70
- const handlers = this.handlers[eventName];
71
- if (!handlers || handlers.size < 1) {
72
- return;
73
- }
74
78
  for (const handler of Array.from(handlers)) {
75
79
  try {
76
80
  yield handler(event);
@@ -65,6 +65,8 @@ declare class RouterFacade {
65
65
  routesDELETE: {
66
66
  [key: string]: RouteObject[];
67
67
  };
68
+ private routeRegexCache;
69
+ private domainRegexCache;
68
70
  /**
69
71
  * Load the application-routes
70
72
  */
@@ -103,6 +105,8 @@ declare class RouterFacade {
103
105
  extractParams(path: string, match: any): any;
104
106
  escapeRegexPart(value: string): string;
105
107
  buildRouteRegex(path: string): RegExp;
108
+ private getRouteRegex;
109
+ private getDomainRegex;
106
110
  normalizeDomainString(value: string): string | null;
107
111
  getRequestHostCandidates(request?: Request): string[];
108
112
  getRouteDomainFilters(routeObj: RouteObject): string[];
@@ -69,6 +69,8 @@ class RouterFacade {
69
69
  * DELETE Route Store
70
70
  */
71
71
  this.routesDELETE = {};
72
+ this.routeRegexCache = new Map();
73
+ this.domainRegexCache = new Map();
72
74
  }
73
75
  /**
74
76
  * Load the application-routes
@@ -88,6 +90,8 @@ class RouterFacade {
88
90
  this.routesPUT = {};
89
91
  this.routesPATCH = {};
90
92
  this.routesDELETE = {};
93
+ this.routeRegexCache.clear();
94
+ this.domainRegexCache.clear();
91
95
  // Load routes
92
96
  if (options.routesFunction) {
93
97
  options.routesFunction();
@@ -139,6 +143,10 @@ class RouterFacade {
139
143
  else {
140
144
  routeConf.component = component;
141
145
  }
146
+ this.getRouteRegex(path);
147
+ for (const domain of this.getRouteDomainFilters(routeConf)) {
148
+ this.getDomainRegex(domain);
149
+ }
142
150
  if (type === "GET") {
143
151
  if (!this.routesGET[path]) {
144
152
  this.routesGET[path] = [];
@@ -273,6 +281,22 @@ class RouterFacade {
273
281
  const regexString = "^" + escaped.replace(/__WFZ_TOKEN_(\d+)__/g, (all, tokenIndex) => placeholders[tokenIndex]) + "$";
274
282
  return new RegExp(regexString);
275
283
  }
284
+ getRouteRegex(path) {
285
+ let regex = this.routeRegexCache.get(path);
286
+ if (!regex) {
287
+ regex = this.buildRouteRegex(path);
288
+ this.routeRegexCache.set(path, regex);
289
+ }
290
+ return regex;
291
+ }
292
+ getDomainRegex(domain) {
293
+ let regex = this.domainRegexCache.get(domain);
294
+ if (!regex) {
295
+ regex = this.buildDomainRegex(domain);
296
+ this.domainRegexCache.set(domain, regex);
297
+ }
298
+ return regex;
299
+ }
276
300
  normalizeDomainString(value) {
277
301
  if (!value || value.trim() === "") {
278
302
  return null;
@@ -342,7 +366,7 @@ class RouterFacade {
342
366
  return new RegExp("^" + regexBody + "$", "i");
343
367
  }
344
368
  matchDomainFilter(hostname, domainFilter) {
345
- const regex = this.buildDomainRegex(domainFilter);
369
+ const regex = this.getDomainRegex(domainFilter);
346
370
  const match = hostname.match(regex);
347
371
  if (!match) {
348
372
  return { matches: false, matchedDomain: null, wildcard: null };
@@ -411,7 +435,7 @@ class RouterFacade {
411
435
  continue;
412
436
  }
413
437
  for (const routeObj of routeCandidates) {
414
- const match = url.match(this.buildRouteRegex(routeObj.path));
438
+ const match = url.match(this.getRouteRegex(routeObj.path));
415
439
  if (!match) {
416
440
  continue;
417
441
  }
@@ -442,14 +466,16 @@ class RouterFacade {
442
466
  : this.mode === "aws-lambda" && options && options.event && options.event.requestContext && options.event.requestContext.http
443
467
  ? options.event.requestContext.http.method
444
468
  : null;
445
- yield WebframezHooks_1.WebframezHooks.emit("http.request.start", {
446
- operationId: httpOperationId,
447
- name: initialMethod || (this.mode === "aws-lambda" ? "lambda" : "request"),
448
- attributes: {
449
- "http.request.method": initialMethod,
450
- "webframez.mode": this.mode,
451
- },
452
- });
469
+ if (WebframezHooks_1.WebframezHooks.hasListeners("http.request.start")) {
470
+ yield WebframezHooks_1.WebframezHooks.emit("http.request.start", {
471
+ operationId: httpOperationId,
472
+ name: initialMethod || (this.mode === "aws-lambda" ? "lambda" : "request"),
473
+ attributes: {
474
+ "http.request.method": initialMethod,
475
+ "webframez.mode": this.mode,
476
+ },
477
+ });
478
+ }
453
479
  let request;
454
480
  let routeOperationId = null;
455
481
  try {
@@ -478,15 +504,17 @@ class RouterFacade {
478
504
  },
479
505
  });
480
506
  const errorResult = this.handleError(fallbackRequest, response, statusCode, e);
481
- yield WebframezHooks_1.WebframezHooks.emit("http.request.error", {
482
- operationId: httpOperationId,
483
- name: `${fallbackRequest.method || "HTTP"} unmatched`,
484
- status: "error",
485
- error: e,
486
- request: fallbackRequest,
487
- response,
488
- attributes: Object.assign(Object.assign({ "http.request.method": fallbackRequest.method || null, "http.response.status_code": statusCode }, requestTelemetryAttributes(fallbackRequest)), { "webframez.mode": this.mode }),
489
- });
507
+ if (WebframezHooks_1.WebframezHooks.hasListeners("http.request.error")) {
508
+ yield WebframezHooks_1.WebframezHooks.emit("http.request.error", {
509
+ operationId: httpOperationId,
510
+ name: `${fallbackRequest.method || "HTTP"} unmatched`,
511
+ status: "error",
512
+ error: e,
513
+ request: fallbackRequest,
514
+ response,
515
+ attributes: Object.assign(Object.assign({ "http.request.method": fallbackRequest.method || null, "http.response.status_code": statusCode }, requestTelemetryAttributes(fallbackRequest)), { "webframez.mode": this.mode }),
516
+ });
517
+ }
490
518
  return errorResult;
491
519
  }
492
520
  const route = this.dissolve(request);
@@ -496,83 +524,95 @@ class RouterFacade {
496
524
  }
497
525
  if (!route) {
498
526
  const notFoundResult = this.handleError(request, response, 404, "Not found '" + request.url + "' ...");
499
- yield WebframezHooks_1.WebframezHooks.emit("http.request.end", {
500
- operationId: httpOperationId,
501
- name: `${request.method} unmatched`,
502
- status: "ok",
503
- request,
504
- response,
505
- attributes: Object.assign(Object.assign({ "http.request.method": request.method, "http.response.status_code": 404 }, requestTelemetryAttributes(request)), { "webframez.mode": this.mode }),
506
- });
527
+ if (WebframezHooks_1.WebframezHooks.hasListeners("http.request.end")) {
528
+ yield WebframezHooks_1.WebframezHooks.emit("http.request.end", {
529
+ operationId: httpOperationId,
530
+ name: `${request.method} unmatched`,
531
+ status: "ok",
532
+ request,
533
+ response,
534
+ attributes: Object.assign(Object.assign({ "http.request.method": request.method, "http.response.status_code": 404 }, requestTelemetryAttributes(request)), { "webframez.mode": this.mode }),
535
+ });
536
+ }
507
537
  return notFoundResult;
508
538
  }
509
539
  if (!(route.component && typeof route.component === "function") && !(route.controller && route.method_name)) {
510
540
  const missingRouteResult = this.handleError(request, response, 404, "Missing route function ...");
511
- yield WebframezHooks_1.WebframezHooks.emit("http.request.end", {
512
- operationId: httpOperationId,
513
- name: `${request.method} ${route.path}`,
514
- status: "ok",
515
- request,
516
- response,
517
- attributes: Object.assign(Object.assign({ "http.request.method": request.method, "http.response.status_code": 404, "url.template": route.path, "webframez.route": route.path }, requestTelemetryAttributes(request)), { "webframez.mode": this.mode }),
518
- });
519
- return missingRouteResult;
520
- }
521
- try {
522
- yield this.handleMiddleware(route, request, response);
523
- if (request.method === "OPTIONS" && !request.skipOptionsForward) {
524
- const optionsResult = this.handleReturn(request, response, "");
541
+ if (WebframezHooks_1.WebframezHooks.hasListeners("http.request.end")) {
525
542
  yield WebframezHooks_1.WebframezHooks.emit("http.request.end", {
526
543
  operationId: httpOperationId,
527
544
  name: `${request.method} ${route.path}`,
528
545
  status: "ok",
529
546
  request,
530
547
  response,
531
- attributes: Object.assign(Object.assign({ "http.request.method": request.method, "http.response.status_code": response.statusCode || 200, "url.template": route.path, "webframez.route": route.path }, requestTelemetryAttributes(request)), { "webframez.mode": this.mode }),
548
+ attributes: Object.assign(Object.assign({ "http.request.method": request.method, "http.response.status_code": 404, "url.template": route.path, "webframez.route": route.path }, requestTelemetryAttributes(request)), { "webframez.mode": this.mode }),
532
549
  });
533
- return optionsResult;
534
550
  }
535
- let result = null;
536
- routeOperationId = WebframezHooks_1.WebframezHooks.createOperationId("route");
537
- yield WebframezHooks_1.WebframezHooks.emit("route.handler.start", {
538
- operationId: routeOperationId,
539
- parentOperationId: httpOperationId,
540
- name: `${request.method} ${route.path}`,
541
- attributes: {
542
- "http.request.method": request.method,
543
- "url.template": route.path,
544
- "webframez.route": route.path,
545
- "webframez.route.handler": route.controller && route.method_name
546
- ? `${route.controller.name || "Controller"}@${route.method_name}`
547
- : "component",
548
- },
549
- });
550
- if (route.controller && route.method_name) {
551
- const controllerInstance = new route.controller();
552
- if (!controllerInstance[route.method_name]) {
553
- const unknownMethodResult = this.handleError(request, response, 404, "Unknown method '" + route.method_name + "'.");
554
- yield WebframezHooks_1.WebframezHooks.emit("route.handler.error", {
555
- operationId: routeOperationId,
556
- parentOperationId: httpOperationId,
557
- name: `${request.method} ${route.path}`,
558
- status: "error",
559
- error: new Error("Unknown route method"),
560
- attributes: {
561
- "http.request.method": request.method,
562
- "http.response.status_code": 404,
563
- "url.template": route.path,
564
- "webframez.route": route.path,
565
- "webframez.route.handler": `${route.controller.name || "Controller"}@${route.method_name}`,
566
- },
567
- });
551
+ return missingRouteResult;
552
+ }
553
+ try {
554
+ yield this.handleMiddleware(route, request, response);
555
+ if (request.method === "OPTIONS" && !request.skipOptionsForward) {
556
+ const optionsResult = this.handleReturn(request, response, "");
557
+ if (WebframezHooks_1.WebframezHooks.hasListeners("http.request.end")) {
568
558
  yield WebframezHooks_1.WebframezHooks.emit("http.request.end", {
569
559
  operationId: httpOperationId,
570
560
  name: `${request.method} ${route.path}`,
571
561
  status: "ok",
572
562
  request,
573
563
  response,
574
- attributes: Object.assign(Object.assign({ "http.request.method": request.method, "http.response.status_code": 404, "url.template": route.path, "webframez.route": route.path }, requestTelemetryAttributes(request)), { "webframez.mode": this.mode }),
564
+ attributes: Object.assign(Object.assign({ "http.request.method": request.method, "http.response.status_code": response.statusCode || 200, "url.template": route.path, "webframez.route": route.path }, requestTelemetryAttributes(request)), { "webframez.mode": this.mode }),
575
565
  });
566
+ }
567
+ return optionsResult;
568
+ }
569
+ let result = null;
570
+ routeOperationId = WebframezHooks_1.WebframezHooks.createOperationId("route");
571
+ if (WebframezHooks_1.WebframezHooks.hasListeners("route.handler.start")) {
572
+ yield WebframezHooks_1.WebframezHooks.emit("route.handler.start", {
573
+ operationId: routeOperationId,
574
+ parentOperationId: httpOperationId,
575
+ name: `${request.method} ${route.path}`,
576
+ attributes: {
577
+ "http.request.method": request.method,
578
+ "url.template": route.path,
579
+ "webframez.route": route.path,
580
+ "webframez.route.handler": route.controller && route.method_name
581
+ ? `${route.controller.name || "Controller"}@${route.method_name}`
582
+ : "component",
583
+ },
584
+ });
585
+ }
586
+ if (route.controller && route.method_name) {
587
+ const controllerInstance = new route.controller();
588
+ if (!controllerInstance[route.method_name]) {
589
+ const unknownMethodResult = this.handleError(request, response, 404, "Unknown method '" + route.method_name + "'.");
590
+ if (WebframezHooks_1.WebframezHooks.hasListeners("route.handler.error")) {
591
+ yield WebframezHooks_1.WebframezHooks.emit("route.handler.error", {
592
+ operationId: routeOperationId,
593
+ parentOperationId: httpOperationId,
594
+ name: `${request.method} ${route.path}`,
595
+ status: "error",
596
+ error: new Error("Unknown route method"),
597
+ attributes: {
598
+ "http.request.method": request.method,
599
+ "http.response.status_code": 404,
600
+ "url.template": route.path,
601
+ "webframez.route": route.path,
602
+ "webframez.route.handler": `${route.controller.name || "Controller"}@${route.method_name}`,
603
+ },
604
+ });
605
+ }
606
+ if (WebframezHooks_1.WebframezHooks.hasListeners("http.request.end")) {
607
+ yield WebframezHooks_1.WebframezHooks.emit("http.request.end", {
608
+ operationId: httpOperationId,
609
+ name: `${request.method} ${route.path}`,
610
+ status: "ok",
611
+ request,
612
+ response,
613
+ attributes: Object.assign(Object.assign({ "http.request.method": request.method, "http.response.status_code": 404, "url.template": route.path, "webframez.route": route.path }, requestTelemetryAttributes(request)), { "webframez.mode": this.mode }),
614
+ });
615
+ }
576
616
  return unknownMethodResult;
577
617
  }
578
618
  result = yield controllerInstance[route.method_name].bind(controllerInstance)(request, response);
@@ -581,40 +621,46 @@ class RouterFacade {
581
621
  result = yield route.component(request, response);
582
622
  }
583
623
  const returnResult = this.handleReturn(request, response, result);
584
- yield WebframezHooks_1.WebframezHooks.emit("route.handler.end", {
585
- operationId: routeOperationId,
586
- parentOperationId: httpOperationId,
587
- name: `${request.method} ${route.path}`,
588
- status: "ok",
589
- attributes: {
590
- "http.request.method": request.method,
591
- "http.response.status_code": response.statusCode || 200,
592
- "url.template": route.path,
593
- "webframez.route": route.path,
594
- },
595
- });
596
- yield WebframezHooks_1.WebframezHooks.emit("http.request.end", {
597
- operationId: httpOperationId,
598
- name: `${request.method} ${route.path}`,
599
- status: "ok",
600
- request,
601
- response,
602
- attributes: Object.assign(Object.assign({ "http.request.method": request.method, "http.response.status_code": response.statusCode || 200, "url.template": route.path, "webframez.route": route.path }, requestTelemetryAttributes(request)), { "webframez.mode": this.mode }),
603
- });
604
- return returnResult;
605
- }
606
- catch (e) {
607
- if (this.isMiddlewareRejectSignal(e)) {
608
- const middlewareErrorResult = this.handleError(request, response, 500, e.reason);
609
- yield WebframezHooks_1.WebframezHooks.emit("http.request.error", {
624
+ if (WebframezHooks_1.WebframezHooks.hasListeners("route.handler.end")) {
625
+ yield WebframezHooks_1.WebframezHooks.emit("route.handler.end", {
626
+ operationId: routeOperationId,
627
+ parentOperationId: httpOperationId,
628
+ name: `${request.method} ${route.path}`,
629
+ status: "ok",
630
+ attributes: {
631
+ "http.request.method": request.method,
632
+ "http.response.status_code": response.statusCode || 200,
633
+ "url.template": route.path,
634
+ "webframez.route": route.path,
635
+ },
636
+ });
637
+ }
638
+ if (WebframezHooks_1.WebframezHooks.hasListeners("http.request.end")) {
639
+ yield WebframezHooks_1.WebframezHooks.emit("http.request.end", {
610
640
  operationId: httpOperationId,
611
641
  name: `${request.method} ${route.path}`,
612
- status: "error",
613
- error: e.reason || e,
642
+ status: "ok",
614
643
  request,
615
644
  response,
616
- attributes: Object.assign(Object.assign({ "http.request.method": request.method, "http.response.status_code": 500, "url.template": route.path, "webframez.route": route.path }, requestTelemetryAttributes(request)), { "webframez.mode": this.mode }),
645
+ attributes: Object.assign(Object.assign({ "http.request.method": request.method, "http.response.status_code": response.statusCode || 200, "url.template": route.path, "webframez.route": route.path }, requestTelemetryAttributes(request)), { "webframez.mode": this.mode }),
617
646
  });
647
+ }
648
+ return returnResult;
649
+ }
650
+ catch (e) {
651
+ if (this.isMiddlewareRejectSignal(e)) {
652
+ const middlewareErrorResult = this.handleError(request, response, 500, e.reason);
653
+ if (WebframezHooks_1.WebframezHooks.hasListeners("http.request.error")) {
654
+ yield WebframezHooks_1.WebframezHooks.emit("http.request.error", {
655
+ operationId: httpOperationId,
656
+ name: `${request.method} ${route.path}`,
657
+ status: "error",
658
+ error: e.reason || e,
659
+ request,
660
+ response,
661
+ attributes: Object.assign(Object.assign({ "http.request.method": request.method, "http.response.status_code": 500, "url.template": route.path, "webframez.route": route.path }, requestTelemetryAttributes(request)), { "webframez.mode": this.mode }),
662
+ });
663
+ }
618
664
  return middlewareErrorResult;
619
665
  }
620
666
  yield ErrorHandler_1.ErrorHandler.report(e, {
@@ -632,29 +678,33 @@ class RouterFacade {
632
678
  });
633
679
  const errorResult = this.handleError(request, response, 500, e);
634
680
  if (routeOperationId) {
635
- yield WebframezHooks_1.WebframezHooks.emit("route.handler.error", {
636
- operationId: routeOperationId,
637
- parentOperationId: httpOperationId,
681
+ if (WebframezHooks_1.WebframezHooks.hasListeners("route.handler.error")) {
682
+ yield WebframezHooks_1.WebframezHooks.emit("route.handler.error", {
683
+ operationId: routeOperationId,
684
+ parentOperationId: httpOperationId,
685
+ name: `${request.method} ${route.path}`,
686
+ status: "error",
687
+ error: e,
688
+ attributes: {
689
+ "http.request.method": request.method,
690
+ "http.response.status_code": 500,
691
+ "url.template": route.path,
692
+ "webframez.route": route.path,
693
+ },
694
+ });
695
+ }
696
+ }
697
+ if (WebframezHooks_1.WebframezHooks.hasListeners("http.request.error")) {
698
+ yield WebframezHooks_1.WebframezHooks.emit("http.request.error", {
699
+ operationId: httpOperationId,
638
700
  name: `${request.method} ${route.path}`,
639
701
  status: "error",
640
702
  error: e,
641
- attributes: {
642
- "http.request.method": request.method,
643
- "http.response.status_code": 500,
644
- "url.template": route.path,
645
- "webframez.route": route.path,
646
- },
703
+ request,
704
+ response,
705
+ attributes: Object.assign(Object.assign({ "http.request.method": request.method, "http.response.status_code": 500, "url.template": route.path, "webframez.route": route.path }, requestTelemetryAttributes(request)), { "webframez.mode": this.mode }),
647
706
  });
648
707
  }
649
- yield WebframezHooks_1.WebframezHooks.emit("http.request.error", {
650
- operationId: httpOperationId,
651
- name: `${request.method} ${route.path}`,
652
- status: "error",
653
- error: e,
654
- request,
655
- response,
656
- attributes: Object.assign(Object.assign({ "http.request.method": request.method, "http.response.status_code": 500, "url.template": route.path, "webframez.route": route.path }, requestTelemetryAttributes(request)), { "webframez.mode": this.mode }),
657
- });
658
708
  return errorResult;
659
709
  }
660
710
  });
@@ -495,3 +495,70 @@ when serializing entire models. Auth snapshots always exclude these fields.
495
495
  user automatically. Existing application plaintext reset links are intentionally
496
496
  not accepted; users must request a new link after migration. Reset pages should
497
497
  use `Referrer-Policy: no-referrer` and avoid response caching.
498
+
499
+ ## Declarative memberships
500
+
501
+ Authentication identifies the user; memberships authorize access to a server-loaded resource.
502
+ Configure named membership models on a scope:
503
+
504
+ ```ts
505
+ const scope = Auth.registerScope("manager", {
506
+ model: User,
507
+ memberships: {
508
+ environment: {
509
+ model: EnvironmentUser,
510
+ bindings: [{
511
+ user: { foreignKey: "_user", localKeys: ["_id"] },
512
+ resource: { foreignKey: "_env", localKeys: ["_id"] },
513
+ }],
514
+ conditions: [{ field: "is_deleted", operator: "!=", value: true }],
515
+ },
516
+ },
517
+ });
518
+
519
+ await scope.resolve(req, res);
520
+ const membership = await scope.authorizeMembership(req, "environment", environment);
521
+ if (!membership) return res.status(403).send({ code: "forbidden" });
522
+ // Actual server-side Models; excluded from browser snapshots:
523
+ req.auth.memberships.environment.membership;
524
+ req.auth.memberships.environment.resource;
525
+ ```
526
+
527
+ Every authorization performs a fresh lookup. Missing IDs and unvalidated request contexts
528
+ fail closed. `userConditions` and `resourceConditions` optionally constrain model status.
529
+ `sessionEnvironmentKey` optionally binds resource authorization to a federated session environment.
530
+ Multiple `bindings` support legacy schemas; `localKeys` can accept local and synchronized
531
+ resource references. Set `user.firstAvailable: true` to prefer the first available user
532
+ reference rather than matching all aliases. `findMembership(name, user, resource)` is the
533
+ trusted-server adapter for integrations that already authenticate through another transport.
534
+ It does not authenticate the supplied user or create a request auth context.
535
+
536
+ ## Bearer scopes for separate API origins
537
+
538
+ Cookie scopes remain the default. An explicitly configured `transport: "bearer"` scope
539
+ uses opaque, hashed Core access/refresh credentials through Authorization instead of cookies.
540
+ `Route.auth()` returns `auth_token`, `refresh_token` and their expiry timestamps. Refresh
541
+ requires POST with `refresh_token`; logout requires POST with the access Authorization header.
542
+ Cookie/query credentials are never accepted by `scope.resolve()` in bearer mode. Cookie CSRF
543
+ behavior remains unchanged; bearer endpoints do not use ambient browser credentials.
544
+
545
+ Use the lightweight `@webtypen/webframez-core/bearer-auth-client` entry point's
546
+ `createBearerAuthFetch()` with application-owned `configuration`, `load`, `save` and `clear`
547
+ callbacks. Configure only trusted API targets and store credentials separately per instance.
548
+ It renews expired access credentials, shares refresh requests within a client, uses Web Locks
549
+ across browser tabs when available, and retries explicit unauthorized responses once.
550
+ It clears credentials after an ambiguous network failure instead of replaying a rotating secret.
551
+ Without Web Locks, cross-tab coordination must be provided by the host application.
552
+
553
+ `establishBearerSession(req, subject, parent?, environment?)` is a trusted server entry point
554
+ following verified SSO; never expose a caller-supplied subject as public login input. Lifecycle
555
+ hooks apply. Set `session.allowDynamicEnvironment: true` to bind individual sessions to a
556
+ server-selected environment; fixed/default session environment behavior stays unchanged.
557
+ `isSessionAllowed(session, user)` can validate a parent SSO session and fails closed on denial.
558
+
559
+ `AuthHandoff` delivers a short-lived one-use code to a separate frontend after the API callback.
560
+ Its payload is encrypted with AES-GCM, its code is hashed, and redemption is atomic. Neither
561
+ access nor refresh credentials need appear in navigation URLs. Configure a strong server secret
562
+ and an instance-specific audience. Create TTL indexes on `auth_handoffs.purgeAt`,
563
+ `auth_sso_transactions.purgeAt` and `auth_sso_codes.purgeAt`, or schedule their cleanup methods.
564
+ SSO introspection records successful central session activity, respecting its absolute expiry.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webtypen/webframez-core",
3
- "version": "0.3.76",
3
+ "version": "0.3.78",
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",
@@ -30,7 +30,9 @@
30
30
  "routing.js",
31
31
  "routing.d.ts",
32
32
  "auth-client.js",
33
- "auth-client.d.ts"
33
+ "auth-client.d.ts",
34
+ "bearer-auth-client.js",
35
+ "bearer-auth-client.d.ts"
34
36
  ],
35
37
  "scripts": {
36
38
  "build": "tsc",