@webtypen/webframez-core 0.3.77 → 0.3.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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, parentExpiresAt?: number): 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, parentExpiresAt) {
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, parentExpiresAt);
233
+ if (!pair.reused && 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, reused } = pair, tokens = __rest(pair, ["csrf_token", "reused"]);
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
  }
@@ -24,12 +24,20 @@ export type SessionTokenPair = {
24
24
  };
25
25
  export type CreatedAuthSession = SessionTokenPair & {
26
26
  csrf_token: string;
27
+ reused?: boolean;
27
28
  };
28
29
  export type SessionAuthOptions = {
29
30
  issuer: string;
30
31
  audience: string;
31
32
  scope?: string;
32
33
  environment?: string;
34
+ /** Allow server-selected environments per session instead of one fixed environment. */
35
+ allowDynamicEnvironment?: boolean;
36
+ /** Reuse one child per parent, subject and environment. Requires a strong server-held secret. */
37
+ parentSessions?: {
38
+ mode: "new" | "reuse";
39
+ secret?: string;
40
+ };
33
41
  connection?: string;
34
42
  collection?: string;
35
43
  accessTokenSeconds?: number;
@@ -58,7 +66,10 @@ export declare class SessionAuth {
58
66
  private liveFilter;
59
67
  private allowed;
60
68
  private pair;
61
- create(subject: string, parent?: AuthSession["parent"]): Promise<CreatedAuthSession>;
69
+ private credentials;
70
+ private reuse;
71
+ private linkedRecord;
72
+ create(subject: string, parent?: AuthSession["parent"], environment?: string, parentExpiresAt?: number): Promise<CreatedAuthSession>;
62
73
  authenticate(token: string): Promise<AuthSession | null>;
63
74
  /** Trusted-server introspection; never expose this method as an unauthenticated endpoint. */
64
75
  inspect(sessionId: string): Promise<AuthSession | null>;
@@ -10,6 +10,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.SessionAuth = void 0;
13
+ const crypto_1 = require("crypto");
13
14
  const DBConnection_1 = require("../Database/DBConnection");
14
15
  const AuthSecurity_1 = require("./AuthSecurity");
15
16
  function publicSession(record) {
@@ -21,13 +22,16 @@ function tokenSessionId(token) {
21
22
  /** Revocable, hashed, independently addressable device sessions. Legacy UserAuth is unaffected. */
22
23
  class SessionAuth {
23
24
  constructor(options) {
24
- var _a;
25
+ var _a, _b;
25
26
  this.options = options;
26
- 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") };
27
+ this.scope = Object.assign({ issuer: (0, AuthSecurity_1.authText)(options.issuer, "issuer"), audience: (0, AuthSecurity_1.authText)(options.audience, "audience"), scope: (0, AuthSecurity_1.authText)((_a = options.scope) !== null && _a !== void 0 ? _a : "main", "scope") }, (options.allowDynamicEnvironment && options.environment === undefined ? {}
28
+ : { environment: options.environment === undefined ? null : (0, AuthSecurity_1.authText)(options.environment, "environment") }));
28
29
  this.accessSeconds = (0, AuthSecurity_1.authLifetime)(options.accessTokenSeconds, 15 * 60);
29
30
  this.sessionSeconds = (0, AuthSecurity_1.authLifetime)(options.sessionSeconds, 30 * 86400);
30
31
  this.idleSeconds = options.idleTimeoutSeconds === undefined || options.idleTimeoutSeconds === false ? false : (0, AuthSecurity_1.authLifetime)(options.idleTimeoutSeconds, 7 * 86400);
32
+ if (((_b = options.parentSessions) === null || _b === void 0 ? void 0 : _b.mode) === "reuse" && (typeof options.parentSessions.secret !== "string" || options.parentSessions.secret.length < 32)) {
33
+ throw new Error("Parent session reuse requires a strong server secret.");
34
+ }
31
35
  if (typeof options.isSessionAllowed !== "function")
32
36
  throw new Error("SessionAuth requires isSessionAllowed.");
33
37
  }
@@ -66,18 +70,106 @@ class SessionAuth {
66
70
  return { auth_token: access, refresh_token: refresh, auth_expires_at: record.accessExpiresAt,
67
71
  refresh_expires_at: Math.min(record.expiresAt, record.createdAt + this.sessionSeconds * 1000), session: publicSession(record) };
68
72
  }
69
- create(subject, parent = null) {
73
+ credentials(record) {
74
+ var _a, _b, _c;
75
+ if (!record.credentialState || !((_a = this.options.parentSessions) === null || _a === void 0 ? void 0 : _a.secret))
76
+ throw new Error("Linked session credentials are unavailable.");
77
+ const context = JSON.stringify([String(record._id), String(record._subject), record.issuer, record.audience,
78
+ record.scope, record.environment, (_b = record.parent) === null || _b === void 0 ? void 0 : _b.issuer, (_c = record.parent) === null || _c === void 0 ? void 0 : _c.sessionId]);
79
+ const derive = (kind) => (0, crypto_1.createHmac)("sha256", this.options.parentSessions.secret)
80
+ .update(JSON.stringify(["webframez-parent-session-v1", context, kind, record.credentialState[kind]])).digest("base64url");
81
+ return { access: `${record._id}.${derive("access")}`, refresh: `${record._id}.${derive("refresh")}`, csrf: derive("csrf") };
82
+ }
83
+ reuse(record) {
84
+ return __awaiter(this, void 0, void 0, function* () {
85
+ if (!(yield this.allowed(record)))
86
+ throw new Error("Session is not allowed.");
87
+ const credentials = this.credentials(record);
88
+ if ((0, AuthSecurity_1.hashAuthToken)(credentials.access) !== record.accessHash || (0, AuthSecurity_1.hashAuthToken)(credentials.refresh) !== record.refreshHash
89
+ || (0, AuthSecurity_1.hashAuthToken)(credentials.csrf) !== record.csrfHash)
90
+ throw new Error("Linked session credentials have changed.");
91
+ if (record.accessExpiresAt <= Date.now()) {
92
+ yield this.renewAccess(credentials.refresh);
93
+ const current = yield (yield this.rows()).findOne(Object.assign({ _id: record._id }, this.scope));
94
+ if (!current || current.accessExpiresAt <= Date.now())
95
+ throw new Error("Linked session access could not be renewed.");
96
+ return this.reuse(current);
97
+ }
98
+ const session = publicSession(record);
99
+ yield this.touch(session);
100
+ return Object.assign(Object.assign({}, this.pair(record, credentials.access, credentials.refresh)), { session, csrf_token: credentials.csrf, reused: true });
101
+ });
102
+ }
103
+ linkedRecord(record) {
104
+ var _a;
105
+ return __awaiter(this, void 0, void 0, function* () {
106
+ const db = yield (this.options.database ? this.options.database() : DBConnection_1.DBConnection.getDocumentStore(this.options.connection));
107
+ const links = db.collection(`${this.options.collection || "auth_sessions"}_links`);
108
+ const key = (0, AuthSecurity_1.hashAuthToken)(JSON.stringify([record.issuer, record.audience, record.scope, String(record._subject),
109
+ record.environment, record.parent.issuer, record.parent.sessionId]));
110
+ let link;
111
+ try {
112
+ link = yield links.findOneAndUpdate({ _id: key }, { $setOnInsert: Object.assign(Object.assign({}, this.scope), { environment: record.environment, sessionId: record._id, purgeAt: new Date(record.expiresAt) }) }, { upsert: true, returnDocument: "after" });
113
+ }
114
+ catch (error) {
115
+ if (error.code !== 11000)
116
+ throw error;
117
+ link = yield links.findOne({ _id: key });
118
+ }
119
+ if (!link)
120
+ throw new Error("Linked session reference is unavailable.");
121
+ for (let attempt = 0; attempt < 5; attempt++) {
122
+ const candidate = Object.assign(Object.assign({}, record), { _id: link.sessionId, accessExpiresAt: Math.min(record.accessExpiresAt, record.expiresAt) });
123
+ const credentials = this.credentials(candidate);
124
+ Object.assign(candidate, { accessHash: (0, AuthSecurity_1.hashAuthToken)(credentials.access), refreshHash: (0, AuthSecurity_1.hashAuthToken)(credentials.refresh), csrfHash: (0, AuthSecurity_1.hashAuthToken)(credentials.csrf) });
125
+ let stored;
126
+ try {
127
+ stored = yield (yield this.rows()).findOneAndUpdate({ _id: candidate._id }, { $setOnInsert: candidate }, { upsert: true, returnDocument: "after" });
128
+ }
129
+ catch (error) {
130
+ if (error.code !== 11000)
131
+ throw error;
132
+ stored = yield (yield this.rows()).findOne({ _id: candidate._id });
133
+ }
134
+ if (!stored)
135
+ throw new Error("Linked session is unavailable.");
136
+ // A denied parent or membership must fail closed, rather than create another session.
137
+ if (stored.revokedAt === null && stored.expiresAt > Date.now()
138
+ && stored.createdAt + this.sessionSeconds * 1000 > Date.now()
139
+ && (this.idleSeconds === false || ((_a = stored.lastActiveAt) !== null && _a !== void 0 ? _a : stored.createdAt) + this.idleSeconds * 1000 > Date.now()))
140
+ return stored;
141
+ link = (yield links.findOneAndUpdate({ _id: key, sessionId: stored._id }, { $set: { sessionId: this.ids().create(), purgeAt: new Date(record.expiresAt) } }, { returnDocument: "after" }))
142
+ || (yield links.findOne({ _id: key }));
143
+ }
144
+ throw new Error("Linked session could not be established.");
145
+ });
146
+ }
147
+ create(subject, parent = null, environment, parentExpiresAt) {
148
+ var _a, _b, _c;
70
149
  return __awaiter(this, void 0, void 0, function* () {
71
150
  (0, AuthSecurity_1.authText)(subject, "subject");
151
+ if (environment !== undefined && (!this.options.allowDynamicEnvironment || this.options.environment !== undefined)) {
152
+ throw new Error("Dynamic session environments are not enabled.");
153
+ }
72
154
  if (parent) {
73
155
  (0, AuthSecurity_1.authText)(parent.issuer, "parent issuer");
74
156
  (0, AuthSecurity_1.authText)(parent.sessionId, "parent session");
75
157
  }
158
+ if (parentExpiresAt !== undefined && (!parent || !Number.isFinite(parentExpiresAt) || parentExpiresAt <= Date.now())) {
159
+ throw new Error("Invalid parent session expiry.");
160
+ }
76
161
  const now = Date.now(), id = this.ids().create();
77
162
  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 });
163
+ const record = Object.assign(Object.assign({ _id: id, _subject: this.ids().create(subject) }, this.scope), { environment: environment === undefined ? (_a = this.scope.environment) !== null && _a !== void 0 ? _a : null : (0, AuthSecurity_1.authText)(environment, "environment"), parent: parent ? Object.assign({}, parent) : null, createdAt: now, lastActiveAt: now, expiresAt: Math.min(now + this.sessionSeconds * 1000, parentExpiresAt !== null && parentExpiresAt !== void 0 ? parentExpiresAt : Infinity), accessExpiresAt: now + Math.min(this.accessSeconds, this.sessionSeconds) * 1000, accessHash: (0, AuthSecurity_1.hashAuthToken)(access), refreshHash: (0, AuthSecurity_1.hashAuthToken)(refresh), csrfHash: (0, AuthSecurity_1.hashAuthToken)(csrf), usedRefreshHashes: [], revokedAt: null });
79
164
  if (!(yield this.allowed(record)))
80
165
  throw new Error("Session is not allowed.");
166
+ if (parent && ((_b = this.options.parentSessions) === null || _b === void 0 ? void 0 : _b.mode) === "reuse") {
167
+ record.credentialState = { access: (0, AuthSecurity_1.randomAuthToken)(), refresh: (0, AuthSecurity_1.randomAuthToken)(), csrf: (0, AuthSecurity_1.randomAuthToken)() };
168
+ const stored = yield this.linkedRecord(record);
169
+ const pair = yield this.reuse(stored);
170
+ return Object.assign(Object.assign({}, pair), { reused: ((_c = stored.credentialState) === null || _c === void 0 ? void 0 : _c.access) !== record.credentialState.access });
171
+ }
172
+ record.accessExpiresAt = Math.min(record.accessExpiresAt, record.expiresAt);
81
173
  yield (yield this.rows()).insertOne(record);
82
174
  return Object.assign(Object.assign({}, this.pair(record, access, refresh)), { csrf_token: csrf });
83
175
  });
@@ -113,6 +205,7 @@ class SessionAuth {
113
205
  }
114
206
  /** One winner under concurrency. A previously consumed refresh token revokes its device session. */
115
207
  refresh(token) {
208
+ var _a, _b;
116
209
  return __awaiter(this, void 0, void 0, function* () {
117
210
  const id = tokenSessionId(token);
118
211
  if (!id)
@@ -131,11 +224,10 @@ class SessionAuth {
131
224
  yield this.revoke(id);
132
225
  return null;
133
226
  }
134
- const access = `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`, refresh = `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`;
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(),
138
- usedRefreshHashes: [...row.usedRefreshHashes, hash] } }, { returnDocument: "after" });
227
+ const credentialState = row.credentialState ? Object.assign(Object.assign({}, row.credentialState), { access: (0, AuthSecurity_1.randomAuthToken)(), refresh: (0, AuthSecurity_1.randomAuthToken)() }) : undefined;
228
+ const credentials = credentialState ? this.credentials(Object.assign(Object.assign({}, row), { credentialState })) : null;
229
+ const access = (_a = credentials === null || credentials === void 0 ? void 0 : credentials.access) !== null && _a !== void 0 ? _a : `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`, refresh = (_b = credentials === null || credentials === void 0 ? void 0 : credentials.refresh) !== null && _b !== void 0 ? _b : `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`;
230
+ const next = yield rows.findOneAndUpdate(Object.assign(Object.assign(Object.assign({ _id: this.sessionId(id) }, this.scope), { refreshHash: hash }), this.liveFilter()), { $set: Object.assign(Object.assign({}, (credentialState ? { credentialState } : {})), { accessHash: (0, AuthSecurity_1.hashAuthToken)(access), refreshHash: (0, AuthSecurity_1.hashAuthToken)(refresh), accessExpiresAt: Math.min(Date.now() + this.accessSeconds * 1000, row.expiresAt, row.createdAt + this.sessionSeconds * 1000), lastActiveAt: Date.now(), usedRefreshHashes: [...row.usedRefreshHashes, hash] }) }, { returnDocument: "after" });
139
231
  if (!next) {
140
232
  // Also detect simultaneous replay that lost the compare-and-swap.
141
233
  yield rows.updateOne(Object.assign(Object.assign({ _id: this.sessionId(id) }, this.scope), { usedRefreshHashes: hash, revokedAt: null }), { $set: { revokedAt: Date.now() } });
@@ -164,10 +256,9 @@ class SessionAuth {
164
256
  const row = yield rows.findOne(filter);
165
257
  if (!row || !(yield this.allowed(row)))
166
258
  return null;
167
- const access = `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`, now = Date.now();
168
- const next = yield rows.findOneAndUpdate(filter, { $set: { accessHash: (0, AuthSecurity_1.hashAuthToken)(access),
169
- accessExpiresAt: Math.min(now + this.accessSeconds * 1000, row.expiresAt, row.createdAt + this.sessionSeconds * 1000),
170
- lastActiveAt: now } }, { returnDocument: "after" });
259
+ const credentialState = row.credentialState ? Object.assign(Object.assign({}, row.credentialState), { access: (0, AuthSecurity_1.randomAuthToken)() }) : undefined;
260
+ const access = credentialState ? this.credentials(Object.assign(Object.assign({}, row), { credentialState })).access : `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`, now = Date.now();
261
+ const next = yield rows.findOneAndUpdate(filter, { $set: Object.assign(Object.assign({}, (credentialState ? { credentialState } : {})), { accessHash: (0, AuthSecurity_1.hashAuthToken)(access), accessExpiresAt: Math.min(now + this.accessSeconds * 1000, row.expiresAt, row.createdAt + this.sessionSeconds * 1000), lastActiveAt: now }) }, { returnDocument: "after" });
171
262
  const session = next ? yield this.allowed(next) : null;
172
263
  return next && session ? Object.assign(Object.assign({}, this.pair(next, access, token)), { session }) : null;
173
264
  });
@@ -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);
@@ -495,3 +495,92 @@ 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.
565
+
566
+ ### Linked parent session reuse
567
+
568
+ `session.parentSessions: { mode: "reuse", secret: serverSecret }` opts into one local
569
+ session per exact parent issuer/session, user, scope, audience and environment. The default
570
+ `mode: "new"` retains independent session creation. Logins without a parent remain independent.
571
+ The secret must be at least 32 characters and stay stable across all processes of an instance.
572
+ Core stores hashes and independent random credential nonces, never plaintext tokens; a
573
+ domain-separated HMAC derives the current credentials only on the trusted server. Rotation
574
+ updates both hashes and nonces atomically. Reopening returns the current pair without rotating
575
+ refresh or resetting the login timestamp. `auth_sessions_links` uses its primary key for atomic
576
+ creation; add an optional TTL index on `purgeAt` for housekeeping. Session `_id` and `_subject`
577
+ continue to use driver-native IDs. A revoked/expired local child creates a new generation.
578
+ Changing the secret requires revoking existing linked sessions before reauthentication.
579
+
580
+ Trusted callers can pass parent expiry as the fifth argument to
581
+ `Auth.scope().establishBearerSession(request, subject, parent, environment, parentExpiresAt)`
582
+ or fourth argument to `sessions.create`. It caps child expiry. Parent revocation must still be
583
+ checked by `isSessionAllowed` on every authentication/refresh. To enforce logout on the next
584
+ request, do not cache positive introspection results. This requires one authority round trip
585
+ per check and fails closed when the authority is unavailable. No browser push is implied;
586
+ clients detect logout when making their next authenticated request.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webtypen/webframez-core",
3
- "version": "0.3.77",
3
+ "version": "0.3.80",
4
4
  "description": "The core package of the typescript based webframez (web-framework) by webtypen.de",
5
5
  "homepage": "https://webtypen.de/",
6
6
  "license": "MIT",
@@ -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",