@webtypen/webframez-core 0.3.72 → 0.3.74

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,11 @@
1
+ /** Public browser-only entry point for ModelAuth / Route.auth. */
2
+ export type AuthClientOptions = {
3
+ basePath?: string;
4
+ loginPaths?: string[];
5
+ cookieName?: string;
6
+ requiresCsrf?: (url: URL) => boolean;
7
+ };
8
+
9
+ export declare function createAuthFetch(options?: AuthClientOptions, originalFetch?: typeof fetch): typeof fetch;
10
+
11
+ export declare function installAuthForms(options?: AuthClientOptions & { fetch?: typeof fetch }): () => void;
package/auth-client.js ADDED
@@ -0,0 +1,50 @@
1
+ /** Browser-only entry point. No server or database modules are loaded. */
2
+ function csrfCookie(name) {
3
+ const cookieName = name || (window.location.protocol === "https:" ? "__Host-wf_csrf" : "wf_dev_csrf");
4
+ const values = document.cookie.split(";").map(part => part.trim()).filter(part => part.startsWith(`${cookieName}=`));
5
+ return values.length === 1 ? decodeURIComponent(values[0].slice(cookieName.length + 1)) : "";
6
+ }
7
+
8
+ exports.createAuthFetch = function createAuthFetch(options = {}, originalFetch = window.fetch.bind(window)) {
9
+ const base = (options.basePath || "/api/auth").replace(/\/+$/, "");
10
+ const loginPaths = options.loginPaths || [`${base}/login`];
11
+ return async (input, init) => {
12
+ const url = new URL(input instanceof Request ? input.url : String(input), window.location.href);
13
+ const method = String(init?.method || (input instanceof Request ? input.method : "GET")).toUpperCase();
14
+ if (url.origin !== window.location.origin || ["GET", "HEAD", "OPTIONS"].includes(method)
15
+ || (options.requiresCsrf && !options.requiresCsrf(url))) return originalFetch(input, init);
16
+ let token;
17
+ if (loginPaths.includes(url.pathname)) {
18
+ const bootstrap = await originalFetch(`${base}/csrf`, { credentials: "same-origin", cache: "no-store" });
19
+ if (!bootstrap.ok) return bootstrap;
20
+ token = (await bootstrap.json()).csrf;
21
+ } else token = csrfCookie(options.cookieName);
22
+ const headers = new Headers(init?.headers || (input instanceof Request ? input.headers : undefined));
23
+ if (typeof token === "string" && token) headers.set("X-CSRF-Token", token);
24
+ return originalFetch(input, { ...init, headers });
25
+ };
26
+ };
27
+
28
+ exports.installAuthForms = function installAuthForms(options = {}) {
29
+ const loginPaths = options.loginPaths || ["/api/auth/login"];
30
+ const fetch = options.fetch || window.fetch.bind(window);
31
+ const listener = event => {
32
+ const form = event.target;
33
+ if (!(form instanceof HTMLFormElement) || form.method.toLowerCase() !== "post") return;
34
+ const url = new URL(form.action);
35
+ if (url.origin !== window.location.origin || !loginPaths.includes(url.pathname)) return;
36
+ const token = csrfCookie(options.cookieName);
37
+ if (!token) {
38
+ event.preventDefault();
39
+ const submitter = event.submitter;
40
+ void fetch(`${(options.basePath || "/api/auth").replace(/\/+$/, "")}/csrf`, { credentials: "same-origin", cache: "no-store" })
41
+ .then(response => { if (response.ok && csrfCookie(options.cookieName)) form.requestSubmit(submitter); });
42
+ return;
43
+ }
44
+ let field = form.querySelector('input[name="_csrf"]');
45
+ if (!field) { field = document.createElement("input"); field.type = "hidden"; field.name = "_csrf"; form.append(field); }
46
+ field.value = token;
47
+ };
48
+ document.addEventListener("submit", listener, true);
49
+ return () => document.removeEventListener("submit", listener, true);
50
+ };
@@ -0,0 +1,19 @@
1
+ import type { Request } from "../Router/Request";
2
+ import type { Response } from "../Router/Response";
3
+ import { AuthScope, AuthScopeOptions, AuthOperation, AuthRouteOptions } from "./ModelAuth";
4
+ export type AuthConfig = {
5
+ /** Disable automatic main creation; explicitly configured scopes remain available. */
6
+ main?: boolean;
7
+ defaults?: Partial<AuthScopeOptions>;
8
+ scopes?: Record<string, AuthScopeOptions>;
9
+ };
10
+ /** Application-wide registry of named browser-authentication scopes. */
11
+ export declare class AuthFacade {
12
+ private readonly scopes;
13
+ private initialized;
14
+ init(): void;
15
+ scope(key?: string): AuthScope;
16
+ registerScope(key: string, config: Partial<AuthScopeOptions>): AuthScope;
17
+ }
18
+ export declare const Auth: AuthFacade;
19
+ export declare function authRoute(operation: AuthOperation, options: AuthRouteOptions): (req: Request, res: Response) => Promise<Response>;
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.authRoute = exports.Auth = exports.AuthFacade = void 0;
4
+ const Config_1 = require("../Config");
5
+ const Model_1 = require("../Database/Model");
6
+ const ModelAuth_1 = require("./ModelAuth");
7
+ function mergeOptions(base, config) {
8
+ return Object.assign(Object.assign(Object.assign({ model: Model_1.Model }, base), config), { fields: Object.assign(Object.assign({}, base.fields), config.fields), session: Object.assign(Object.assign({}, base.session), config.session), messages: Object.assign(Object.assign({}, base.messages), config.messages) });
9
+ }
10
+ /** Application-wide registry of named browser-authentication scopes. */
11
+ class AuthFacade {
12
+ constructor() {
13
+ this.scopes = new Map();
14
+ this.initialized = false;
15
+ }
16
+ init() {
17
+ if (this.initialized)
18
+ return;
19
+ const config = Config_1.Config.get("auth") || {};
20
+ const configured = new Map();
21
+ for (const [key, options] of Object.entries(config.scopes || {})) {
22
+ configured.set(key, new ModelAuth_1.AuthScope(Object.assign(Object.assign({}, mergeOptions(config.defaults || {}, options)), { key })));
23
+ }
24
+ if (config.main !== false && !configured.has("main")) {
25
+ configured.set("main", new ModelAuth_1.AuthScope(Object.assign(Object.assign({}, mergeOptions(config.defaults || {}, {})), { key: "main" })));
26
+ }
27
+ for (const [key, scope] of configured)
28
+ this.scopes.set(key, scope);
29
+ this.initialized = true;
30
+ }
31
+ scope(key = "main") {
32
+ this.init();
33
+ const scope = this.scopes.get(key);
34
+ if (!scope)
35
+ throw new Error(`Auth scope "${key}" is not registered.`);
36
+ return scope;
37
+ }
38
+ registerScope(key, config) {
39
+ var _a;
40
+ this.init();
41
+ const defaults = Config_1.Config.get("auth.defaults") || {};
42
+ const base = ((_a = this.scopes.get(key)) === null || _a === void 0 ? void 0 : _a.configuration) || defaults;
43
+ const scope = new ModelAuth_1.AuthScope(Object.assign(Object.assign({}, mergeOptions(base, config)), { key }));
44
+ this.scopes.set(key, scope);
45
+ return scope;
46
+ }
47
+ }
48
+ exports.AuthFacade = AuthFacade;
49
+ exports.Auth = new AuthFacade();
50
+ function authRoute(operation, options) {
51
+ return (req, res) => exports.Auth.scope(options.auth || "main").handle(operation, req, res, options.loginResponse);
52
+ }
53
+ exports.authRoute = authRoute;
@@ -0,0 +1,83 @@
1
+ import { Model } from "../Database/Model";
2
+ import { Request } from "../Router/Request";
3
+ import type { Response } from "../Router/Response";
4
+ import { AuthSession, SessionAuthOptions } from "./SessionAuth";
5
+ import { WebAuth, WebAuthError, WebAuthOptions } from "./WebAuth";
6
+ export type AuthContext<TUser extends Model = Model> = {
7
+ user: TUser;
8
+ session: AuthSession;
9
+ };
10
+ export type AuthSnapshot<TUser = Record<string, unknown>> = {
11
+ user: TUser;
12
+ session: AuthSession;
13
+ };
14
+ export type AuthErrorCode = "missing_login" | "invalid_login" | "inactive_user" | "invalid_origin" | "invalid_csrf" | "unauthorized" | "server_error" | "login_blocked" | "logout_blocked";
15
+ export declare class AuthLoginError extends WebAuthError {
16
+ readonly code: AuthErrorCode;
17
+ constructor(code: AuthErrorCode, status: 401 | 403, message: string);
18
+ }
19
+ export type ModelAuthOptions = Partial<WebAuthOptions> & {
20
+ model: typeof Model;
21
+ key?: string;
22
+ /** Takes precedence over session.audience; defaults to the configured origin. */
23
+ audience?: string;
24
+ locale?: "en" | "de";
25
+ /** Record browser metadata and update activity at most once per minute. */
26
+ trackActivity?: boolean;
27
+ fields?: {
28
+ identifier?: string;
29
+ password?: string;
30
+ active?: string | false;
31
+ primaryKey?: string;
32
+ };
33
+ session?: Partial<Omit<SessionAuthOptions, "issuer" | "isSessionAllowed">>;
34
+ csrfDisabled?: false;
35
+ verifyPassword?: (password: string, storedHash: string) => boolean | Promise<boolean>;
36
+ isUserAllowed?: (user: Model) => boolean | Promise<boolean>;
37
+ beforeLogin?: AuthActionHook;
38
+ afterLogin?: AuthActionHook;
39
+ beforeLogout?: AuthActionHook;
40
+ afterLogout?: AuthActionHook;
41
+ onLogin?: (session: AuthSession, req: Request) => void | Promise<void>;
42
+ publicUserFields?: readonly string[];
43
+ onAuthenticate?: (auth: AuthContext, req: Request) => void | Promise<void>;
44
+ messages?: Partial<Record<AuthErrorCode, string>>;
45
+ };
46
+ export type AuthActionHook = (req: Request, user: Model, scope: AuthScope) => void | Promise<void>;
47
+ export type AuthOperation = "csrf" | "login" | "logout" | "refresh";
48
+ export type AuthRouteOptions = Partial<ModelAuthOptions> & {
49
+ auth?: string;
50
+ loginPath?: string;
51
+ loginResponse?: (req: Request, session: AuthSession) => object | Promise<object>;
52
+ middleware?: string[];
53
+ domains?: string[];
54
+ };
55
+ /** Model-backed browser authentication with secure default routes and revocable sessions. */
56
+ export declare class AuthScope extends WebAuth {
57
+ private readonly modelOptions;
58
+ readonly key: string;
59
+ private readonly model;
60
+ private readonly sessionUsers;
61
+ private readonly fields;
62
+ constructor(modelOptions: ModelAuthOptions);
63
+ get configuration(): ModelAuthOptions;
64
+ private message;
65
+ /** Use this when creating users; existing bcrypt hashes are supported without migration. */
66
+ static hashPassword(password: string): Promise<string>;
67
+ verifyCredentials(req: Request): Promise<string>;
68
+ login(req: Request, res: Response, verifyCredentials?: () => Promise<string | null>): Promise<AuthSession>;
69
+ logout(req: Request, res: Response): Promise<void>;
70
+ /** Resolve a fresh validated session and its model; never trusts a client-supplied req.auth. */
71
+ resolve<TUser extends Model = Model>(req: Request): Promise<AuthContext<TUser> | null>;
72
+ resolveCookies<TUser extends Model = Model>(cookies: Record<string, string | undefined>): Promise<AuthContext<TUser> | null>;
73
+ /** Only explicit public fields are sent to browsers; Model instances remain on the server. */
74
+ snapshot(auth: AuthContext | null | undefined): AuthSnapshot | null;
75
+ /** Optional auth for public routes, or required auth for protected controllers. */
76
+ middleware(options?: {
77
+ required?: boolean;
78
+ }): (next: Function, reject: Function, req: Request, res: Response) => Promise<any>;
79
+ handle(operation: AuthOperation, req: Request, res: Response, loginResponse?: AuthRouteOptions["loginResponse"]): Promise<Response>;
80
+ }
81
+ /** Compatibility name for existing integrations. Prefer Auth.scope(). */
82
+ export { AuthScope as ModelAuth };
83
+ export type AuthScopeOptions = ModelAuthOptions;
@@ -0,0 +1,275 @@
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.ModelAuth = exports.AuthScope = exports.AuthLoginError = void 0;
13
+ const bcryptjs_1 = require("bcryptjs");
14
+ const Config_1 = require("../Config");
15
+ const Request_1 = require("../Router/Request");
16
+ const AuthSecurity_1 = require("./AuthSecurity");
17
+ const SessionAuth_1 = require("./SessionAuth");
18
+ const WebAuth_1 = require("./WebAuth");
19
+ class AuthLoginError extends WebAuth_1.WebAuthError {
20
+ constructor(code, status, message) {
21
+ super(status, message);
22
+ this.code = code;
23
+ }
24
+ }
25
+ exports.AuthLoginError = AuthLoginError;
26
+ class AuthHookError extends AuthLoginError {
27
+ constructor(code, error) {
28
+ super(code, error instanceof WebAuth_1.WebAuthError ? error.status : 403, error instanceof Error ? error.message : typeof error === "string" ? error : "Authentication action was blocked.");
29
+ }
30
+ }
31
+ const defaultMessages = {
32
+ en: {
33
+ missing_login: "Please provide your login and password.",
34
+ invalid_login: "Invalid login or password.",
35
+ inactive_user: "This account is inactive.",
36
+ invalid_origin: "The login address does not match the configured website URL.",
37
+ invalid_csrf: "Please reload the page and try logging in again.",
38
+ unauthorized: "Please log in.",
39
+ server_error: "Authentication could not be processed.",
40
+ login_blocked: "Login was blocked.",
41
+ logout_blocked: "Logout was blocked.",
42
+ },
43
+ de: {
44
+ missing_login: "Bitte gib E-Mail-Adresse und Passwort an.",
45
+ invalid_login: "Es wurde kein Benutzer mit dieser E-Mail-/Passwort-Kombination gefunden.",
46
+ inactive_user: "Bitte aktiviere deinen Account über die E-Mail, die wir dir geschickt haben.",
47
+ invalid_origin: "Die Login-Adresse stimmt nicht mit der konfigurierten Website-URL überein.",
48
+ invalid_csrf: "Der Login konnte gerade nicht verarbeitet werden. Bitte lade die Seite neu.",
49
+ unauthorized: "Bitte melde dich an.",
50
+ server_error: "Die Anmeldung konnte gerade nicht verarbeitet werden.",
51
+ login_blocked: "Die Anmeldung wurde abgelehnt.",
52
+ logout_blocked: "Die Abmeldung wurde abgelehnt.",
53
+ },
54
+ };
55
+ // Verify a dummy password hash for unknown accounts as well.
56
+ let dummyHash;
57
+ /** Model-backed browser authentication with secure default routes and revocable sessions. */
58
+ class AuthScope extends WebAuth_1.WebAuth {
59
+ constructor(modelOptions) {
60
+ var _a, _b, _c, _d, _e, _f, _g;
61
+ if (modelOptions.csrfDisabled)
62
+ throw new Error("Browser auth routes require CSRF protection.");
63
+ if (!modelOptions.model)
64
+ throw new Error("Route.auth requires a Model class.");
65
+ const origin = new URL(modelOptions.origin || Config_1.Config.get("auth.origin") || Config_1.Config.get("website.baseUrl") || Config_1.Config.get("application.website.baseUrl") || process.env.WEBSITE_URL || process.env.PUBLIC_BASE_URL || `http://localhost:${process.env.PORT || 3035}`).origin;
66
+ const key = (_a = modelOptions.key) !== null && _a !== void 0 ? _a : "main";
67
+ if (!/^[A-Za-z0-9_-]+$/.test(key))
68
+ throw new Error("Invalid auth scope key.");
69
+ const model = modelOptions.model;
70
+ const fields = Object.assign({ identifier: "email", password: "password", active: "is_active", primaryKey: new model().__primaryKey || "_id" }, modelOptions.fields);
71
+ const allowed = (user) => __awaiter(this, void 0, void 0, function* () {
72
+ return Boolean(user && (fields.active === false || user[fields.active] !== false)
73
+ && (!modelOptions.isUserAllowed || (yield modelOptions.isUserAllowed(user))));
74
+ });
75
+ const sessionUsers = new WeakMap();
76
+ const sessions = new SessionAuth_1.SessionAuth(Object.assign(Object.assign({ accessTokenSeconds: 30 * 86400, sessionSeconds: 30 * 86400 }, modelOptions.session), { issuer: origin, scope: key, audience: (_d = (_b = modelOptions.audience) !== null && _b !== void 0 ? _b : (_c = modelOptions.session) === null || _c === void 0 ? void 0 : _c.audience) !== null && _d !== void 0 ? _d : origin, connection: ((_e = modelOptions.session) === null || _e === void 0 ? void 0 : _e.connection) || new model().__connection, isSessionAllowed: (session) => __awaiter(this, void 0, void 0, function* () {
77
+ const id = fields.primaryKey === "_id" ? yield model.objectId(session.subject, { noExceptions: true }) : session.subject;
78
+ const user = id !== null && id !== undefined ? yield model.where(fields.primaryKey, "=", id).first() : null;
79
+ if (!(yield allowed(user)))
80
+ return false;
81
+ sessionUsers.set(session, user);
82
+ return true;
83
+ }) }));
84
+ super(sessions, Object.assign(Object.assign({}, modelOptions), { origin, cookiePrefix: (_f = modelOptions.cookiePrefix) !== null && _f !== void 0 ? _f : (key === "main" ? undefined : `${origin.startsWith("https:") ? "__Host-wf_" : "wf_dev_"}${key}_`), allowInsecureLocalhost: (_g = modelOptions.allowInsecureLocalhost) !== null && _g !== void 0 ? _g : true }));
85
+ this.modelOptions = modelOptions;
86
+ this.key = key;
87
+ this.sessionUsers = sessionUsers;
88
+ this.model = model;
89
+ this.fields = fields;
90
+ }
91
+ get configuration() {
92
+ return this.modelOptions;
93
+ }
94
+ message(code) {
95
+ var _a, _b;
96
+ return (_b = (_a = this.modelOptions.messages) === null || _a === void 0 ? void 0 : _a[code]) !== null && _b !== void 0 ? _b : defaultMessages[this.modelOptions.locale || "en"][code];
97
+ }
98
+ /** Use this when creating users; existing bcrypt hashes are supported without migration. */
99
+ static hashPassword(password) {
100
+ return (0, bcryptjs_1.hash)(password, 12);
101
+ }
102
+ verifyCredentials(req) {
103
+ var _a, _b;
104
+ return __awaiter(this, void 0, void 0, function* () {
105
+ const identifier = (_a = req.body) === null || _a === void 0 ? void 0 : _a[this.fields.identifier], password = (_b = req.body) === null || _b === void 0 ? void 0 : _b.password;
106
+ if (typeof identifier !== "string" || !identifier.trim() || identifier.length > 320
107
+ || typeof password !== "string" || !password || password.length > 1024) {
108
+ throw new AuthLoginError("missing_login", 401, this.message("missing_login"));
109
+ }
110
+ const user = yield this.model.where(this.fields.identifier, "=", identifier.trim().toLowerCase()).first();
111
+ const stored = user === null || user === void 0 ? void 0 : user[this.fields.password];
112
+ const verify = this.modelOptions.verifyPassword || bcryptjs_1.compare;
113
+ const valid = yield verify(password, typeof stored === "string" ? stored : yield (dummyHash || (dummyHash = (0, bcryptjs_1.hash)("webframez-invalid-account", 12))));
114
+ if (!user || typeof stored !== "string" || !valid)
115
+ throw new AuthLoginError("invalid_login", 401, this.message("invalid_login"));
116
+ if ((this.fields.active !== false && user[this.fields.active] === false)
117
+ || (this.modelOptions.isUserAllowed && !(yield this.modelOptions.isUserAllowed(user)))) {
118
+ throw new AuthLoginError("inactive_user", 403, this.message("inactive_user"));
119
+ }
120
+ const id = user[this.fields.primaryKey];
121
+ if (id === null || id === undefined || id === "")
122
+ throw new Error("Auth user has no primary key.");
123
+ return String(id);
124
+ });
125
+ }
126
+ login(req, res, verifyCredentials = () => this.verifyCredentials(req)) {
127
+ const _super = Object.create(null, {
128
+ login: { get: () => super.login }
129
+ });
130
+ return __awaiter(this, void 0, void 0, function* () {
131
+ let user = null;
132
+ const session = yield _super.login.call(this, req, res, () => __awaiter(this, void 0, void 0, function* () {
133
+ const subject = yield verifyCredentials();
134
+ if (subject && (this.modelOptions.beforeLogin || this.modelOptions.afterLogin)) {
135
+ const id = this.fields.primaryKey === "_id" ? yield this.model.objectId(subject, { noExceptions: true }) : subject;
136
+ user = id == null ? null : yield this.model.where(this.fields.primaryKey, "=", id).first();
137
+ if (!user)
138
+ throw new AuthLoginError("invalid_login", 401, this.message("invalid_login"));
139
+ if (this.modelOptions.beforeLogin) {
140
+ try {
141
+ yield this.modelOptions.beforeLogin(req, user, this);
142
+ }
143
+ catch (error) {
144
+ throw new AuthHookError("login_blocked", error);
145
+ }
146
+ }
147
+ }
148
+ return subject;
149
+ }));
150
+ if (this.modelOptions.trackActivity !== false)
151
+ yield this.sessions.recordLogin(session, (0, AuthSecurity_1.authHeader)(req, "user-agent"));
152
+ if (this.modelOptions.onLogin)
153
+ yield this.modelOptions.onLogin(session, req);
154
+ if (this.modelOptions.afterLogin)
155
+ yield this.modelOptions.afterLogin(req, user, this);
156
+ return session;
157
+ });
158
+ }
159
+ logout(req, res) {
160
+ return __awaiter(this, void 0, void 0, function* () {
161
+ const session = yield this.logoutSession(req);
162
+ const user = this.sessionUsers.get(session);
163
+ if (!user)
164
+ throw new AuthLoginError("unauthorized", 401, this.message("unauthorized"));
165
+ if (this.modelOptions.beforeLogout) {
166
+ try {
167
+ yield this.modelOptions.beforeLogout(req, user, this);
168
+ }
169
+ catch (error) {
170
+ throw new AuthHookError("logout_blocked", error);
171
+ }
172
+ }
173
+ yield this.finishLogout(session, res);
174
+ if (this.modelOptions.afterLogout)
175
+ yield this.modelOptions.afterLogout(req, user, this);
176
+ });
177
+ }
178
+ /** Resolve a fresh validated session and its model; never trusts a client-supplied req.auth. */
179
+ resolve(req) {
180
+ const _super = Object.create(null, {
181
+ authenticate: { get: () => super.authenticate }
182
+ });
183
+ return __awaiter(this, void 0, void 0, function* () {
184
+ Object.defineProperty(req, "auth", { value: null, writable: true, configurable: true, enumerable: false });
185
+ const session = yield _super.authenticate.call(this, req);
186
+ if (!session)
187
+ return null;
188
+ const user = this.sessionUsers.get(session);
189
+ if (!user)
190
+ return null;
191
+ if (this.modelOptions.trackActivity !== false)
192
+ yield this.sessions.touch(session);
193
+ const auth = { user, session };
194
+ if (this.modelOptions.onAuthenticate)
195
+ yield this.modelOptions.onAuthenticate(auth, req);
196
+ req.auth = auth;
197
+ return auth;
198
+ });
199
+ }
200
+ resolveCookies(cookies) {
201
+ return __awaiter(this, void 0, void 0, function* () {
202
+ const req = Object.assign(new Request_1.Request(), { method: "GET", headers: { cookie: `${this.cookies.prefix}access=${encodeURIComponent(cookies[`${this.cookies.prefix}access`] || "")}` } });
203
+ return this.resolve(req);
204
+ });
205
+ }
206
+ /** Only explicit public fields are sent to browsers; Model instances remain on the server. */
207
+ snapshot(auth) {
208
+ var _a;
209
+ if (!auth)
210
+ return null;
211
+ const fields = this.modelOptions.publicUserFields || [this.fields.primaryKey, "email", "name", "firstname", "lastname", "roles"];
212
+ const user = {};
213
+ for (const field of fields) {
214
+ if (field.startsWith("__") || field === this.fields.password || ((_a = auth.user.__hidden) === null || _a === void 0 ? void 0 : _a.includes(field))
215
+ || ["constructor", "prototype"].includes(field))
216
+ continue;
217
+ const value = auth.user[field];
218
+ if (value !== undefined)
219
+ user[field] = value;
220
+ }
221
+ return JSON.parse(JSON.stringify({ user, session: auth.session }));
222
+ }
223
+ /** Optional auth for public routes, or required auth for protected controllers. */
224
+ middleware(options = {}) {
225
+ return (next, reject, req, res) => __awaiter(this, void 0, void 0, function* () {
226
+ try {
227
+ const auth = yield this.resolve(req);
228
+ res.header("Cache-Control", "no-store");
229
+ if (!auth && options.required) {
230
+ res.status(401).send({ status: "error", code: "unauthorized" });
231
+ res.end();
232
+ return reject("Unauthorized");
233
+ }
234
+ return next(true);
235
+ }
236
+ catch (error) {
237
+ if (!(error instanceof WebAuth_1.WebAuthError))
238
+ throw error;
239
+ res.status(error.status).send({ status: "error", code: "invalid_csrf" });
240
+ res.end();
241
+ return reject(error.message);
242
+ }
243
+ });
244
+ }
245
+ handle(operation, req, res, loginResponse) {
246
+ var _a, _b;
247
+ return __awaiter(this, void 0, void 0, function* () {
248
+ res.header("Cache-Control", "no-store");
249
+ try {
250
+ if (operation === "csrf")
251
+ return res.send({ csrf: this.bootstrap(req, res) });
252
+ if (operation === "logout") {
253
+ yield this.logout(req, res);
254
+ return res.send({ status: "success" });
255
+ }
256
+ if (operation === "refresh") {
257
+ yield this.refresh(req, res);
258
+ return res.send({ status: "success" });
259
+ }
260
+ const session = yield this.login(req, res);
261
+ return res.send(Object.assign(Object.assign({}, (loginResponse ? yield loginResponse(req, session) : {})), { status: "success" }));
262
+ }
263
+ catch (error) {
264
+ const code = error instanceof AuthLoginError ? error.code : error instanceof WebAuth_1.WebAuthError
265
+ ? error.message === "Invalid request origin." ? "invalid_origin" : error.status === 401 ? "unauthorized" : "invalid_csrf" : "server_error";
266
+ if (!(error instanceof WebAuth_1.WebAuthError))
267
+ console.error(error);
268
+ return res.status(error instanceof WebAuth_1.WebAuthError ? error.status : 500).send({ status: "error", code,
269
+ 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) });
270
+ }
271
+ });
272
+ }
273
+ }
274
+ exports.AuthScope = AuthScope;
275
+ exports.ModelAuth = AuthScope;
@@ -1,10 +1,13 @@
1
- import type { DocumentDatabase } from "../Database/DatabaseAdapter";
1
+ import type { DatabaseIdAdapter, DocumentDatabase } from "../Database/DatabaseAdapter";
2
2
  export type AuthSession = {
3
3
  id: string;
4
4
  subject: string;
5
5
  issuer: string;
6
6
  audience: string;
7
+ scope: string;
7
8
  environment: string | null;
9
+ userAgent?: string;
10
+ lastActiveAt?: number;
8
11
  createdAt: number;
9
12
  expiresAt: number;
10
13
  parent: {
@@ -25,6 +28,7 @@ export type CreatedAuthSession = SessionTokenPair & {
25
28
  export type SessionAuthOptions = {
26
29
  issuer: string;
27
30
  audience: string;
31
+ scope?: string;
28
32
  environment?: string;
29
33
  connection?: string;
30
34
  collection?: string;
@@ -35,6 +39,8 @@ export type SessionAuthOptions = {
35
39
  isSessionAllowed: (session: AuthSession) => boolean | Promise<boolean>;
36
40
  /** Dependency injection for a document-capable driver. Atomic findOneAndUpdate is required. */
37
41
  database?: () => Promise<DocumentDatabase>;
42
+ /** ID handling for an injected document store; otherwise the selected driver owns IDs. */
43
+ idAdapter?: DatabaseIdAdapter;
38
44
  };
39
45
  /** Revocable, hashed, independently addressable device sessions. Legacy UserAuth is unaffected. */
40
46
  export declare class SessionAuth {
@@ -43,6 +49,8 @@ export declare class SessionAuth {
43
49
  private readonly accessSeconds;
44
50
  private readonly sessionSeconds;
45
51
  constructor(options: SessionAuthOptions);
52
+ private ids;
53
+ private sessionId;
46
54
  private rows;
47
55
  private allowed;
48
56
  private pair;
@@ -53,6 +61,10 @@ export declare class SessionAuth {
53
61
  verifyCsrf(token: string, csrf: string, kind?: "access" | "refresh"): Promise<boolean>;
54
62
  /** One winner under concurrency. A previously consumed refresh token revokes its device session. */
55
63
  refresh(token: string): Promise<SessionTokenPair | null>;
64
+ /** Browser login metadata; arbitrary request data and token hashes are never exposed. */
65
+ recordLogin(session: AuthSession, userAgent: string): Promise<void>;
66
+ /** Persist activity with a one-minute throttle; authorization does not depend on this timestamp. */
67
+ touch(session: AuthSession): Promise<void>;
56
68
  revoke(sessionId: string): Promise<void>;
57
69
  revokeAll(subject: string): Promise<void>;
58
70
  list(subject: string): Promise<AuthSession[]>;
@@ -13,23 +13,32 @@ exports.SessionAuth = void 0;
13
13
  const DBConnection_1 = require("../Database/DBConnection");
14
14
  const AuthSecurity_1 = require("./AuthSecurity");
15
15
  function publicSession(record) {
16
- return { id: record.id, subject: record.subject, issuer: record.issuer, audience: record.audience,
17
- environment: record.environment, createdAt: record.createdAt, expiresAt: record.expiresAt, parent: record.parent };
16
+ return Object.assign(Object.assign({ id: String(record._id), subject: String(record._subject), issuer: record.issuer, audience: record.audience, scope: record.scope, environment: record.environment, createdAt: record.createdAt, expiresAt: record.expiresAt, parent: record.parent }, (record.userAgent !== undefined ? { userAgent: record.userAgent } : {})), (record.lastActiveAt !== undefined ? { lastActiveAt: record.lastActiveAt } : {}));
18
17
  }
19
18
  function tokenSessionId(token) {
20
- return typeof token === "string" && /^[A-Za-z0-9_-]{43}\.[A-Za-z0-9_-]{43}$/.test(token) ? token.split(".")[0] : null;
19
+ return typeof token === "string" && /^[A-Za-z0-9_-]{1,128}\.[A-Za-z0-9_-]{43}$/.test(token) ? token.split(".")[0] : null;
21
20
  }
22
21
  /** Revocable, hashed, independently addressable device sessions. Legacy UserAuth is unaffected. */
23
22
  class SessionAuth {
24
23
  constructor(options) {
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"),
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
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
  if (typeof options.isSessionAllowed !== "function")
31
31
  throw new Error("SessionAuth requires isSessionAllowed.");
32
32
  }
33
+ ids() {
34
+ return this.options.idAdapter || DBConnection_1.DBConnection.getIdAdapter(this.options.connection);
35
+ }
36
+ sessionId(value) {
37
+ const adapter = this.ids();
38
+ const native = adapter.normalize(value);
39
+ // Converted legacy MongoDB sessions keep working with their existing opaque token prefix.
40
+ return native !== null && native !== void 0 ? native : (/^[A-Za-z0-9_-]{43}$/.test(value) ? adapter.normalize((0, AuthSecurity_1.hashAuthToken)(value).slice(0, 24)) : null);
41
+ }
33
42
  rows() {
34
43
  return __awaiter(this, void 0, void 0, function* () {
35
44
  const db = yield (this.options.database ? this.options.database() : DBConnection_1.DBConnection.getDocumentStore(this.options.connection));
@@ -55,9 +64,9 @@ class SessionAuth {
55
64
  (0, AuthSecurity_1.authText)(parent.issuer, "parent issuer");
56
65
  (0, AuthSecurity_1.authText)(parent.sessionId, "parent session");
57
66
  }
58
- const now = Date.now(), id = (0, AuthSecurity_1.randomAuthToken)();
67
+ const now = Date.now(), id = this.ids().create();
59
68
  const access = `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`, refresh = `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`, csrf = (0, AuthSecurity_1.randomAuthToken)();
60
- const record = Object.assign(Object.assign({ _id: id, id, subject }, this.scope), { parent: parent ? Object.assign({}, parent) : null, createdAt: now, expiresAt: now + this.sessionSeconds * 1000, accessExpiresAt: now + Math.min(this.accessSeconds, this.sessionSeconds) * 1000, accessHash: (0, AuthSecurity_1.hashAuthToken)(access), refreshHash: (0, AuthSecurity_1.hashAuthToken)(refresh), csrfHash: (0, AuthSecurity_1.hashAuthToken)(csrf), usedRefreshHashes: [], revokedAt: null });
69
+ const record = Object.assign(Object.assign({ _id: id, _subject: this.ids().create(subject) }, this.scope), { parent: parent ? Object.assign({}, parent) : null, createdAt: now, expiresAt: now + this.sessionSeconds * 1000, accessExpiresAt: now + Math.min(this.accessSeconds, this.sessionSeconds) * 1000, accessHash: (0, AuthSecurity_1.hashAuthToken)(access), refreshHash: (0, AuthSecurity_1.hashAuthToken)(refresh), csrfHash: (0, AuthSecurity_1.hashAuthToken)(csrf), usedRefreshHashes: [], revokedAt: null });
61
70
  if (!(yield this.allowed(record)))
62
71
  throw new Error("Session is not allowed.");
63
72
  yield (yield this.rows()).insertOne(record);
@@ -69,16 +78,16 @@ class SessionAuth {
69
78
  const id = tokenSessionId(token);
70
79
  if (!id)
71
80
  return null;
72
- const row = yield (yield this.rows()).findOne(Object.assign(Object.assign({ _id: id }, this.scope), { accessHash: (0, AuthSecurity_1.hashAuthToken)(token), accessExpiresAt: { $gt: Date.now() } }));
81
+ const row = yield (yield this.rows()).findOne(Object.assign(Object.assign({ _id: this.sessionId(id) }, this.scope), { accessHash: (0, AuthSecurity_1.hashAuthToken)(token), accessExpiresAt: { $gt: Date.now() } }));
73
82
  return this.allowed(row);
74
83
  });
75
84
  }
76
85
  /** Trusted-server introspection; never expose this method as an unauthenticated endpoint. */
77
86
  inspect(sessionId) {
78
87
  return __awaiter(this, void 0, void 0, function* () {
79
- if (typeof sessionId !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(sessionId))
88
+ if (typeof sessionId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(sessionId))
80
89
  return null;
81
- return this.allowed(yield (yield this.rows()).findOne(Object.assign({ _id: sessionId }, this.scope)));
90
+ return this.allowed(yield (yield this.rows()).findOne(Object.assign({ _id: this.sessionId(sessionId) }, this.scope)));
82
91
  });
83
92
  }
84
93
  verifyCsrf(token, csrf, kind = "access") {
@@ -86,7 +95,7 @@ class SessionAuth {
86
95
  const id = tokenSessionId(token);
87
96
  if (!id || typeof csrf !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(csrf))
88
97
  return false;
89
- const row = yield (yield this.rows()).findOne(Object.assign(Object.assign(Object.assign(Object.assign({ _id: id }, this.scope), (kind === "access" ? { accessHash: (0, AuthSecurity_1.hashAuthToken)(token) } : { $or: [{ refreshHash: (0, AuthSecurity_1.hashAuthToken)(token) }, { usedRefreshHashes: (0, AuthSecurity_1.hashAuthToken)(token) }] })), { csrfHash: (0, AuthSecurity_1.hashAuthToken)(csrf) }), (kind === "access" ? { accessExpiresAt: { $gt: Date.now() } } : {})));
98
+ const row = yield (yield this.rows()).findOne(Object.assign(Object.assign(Object.assign(Object.assign({ _id: this.sessionId(id) }, this.scope), (kind === "access" ? { accessHash: (0, AuthSecurity_1.hashAuthToken)(token) } : { $or: [{ refreshHash: (0, AuthSecurity_1.hashAuthToken)(token) }, { usedRefreshHashes: (0, AuthSecurity_1.hashAuthToken)(token) }] })), { csrfHash: (0, AuthSecurity_1.hashAuthToken)(csrf) }), (kind === "access" ? { accessExpiresAt: { $gt: Date.now() } } : {})));
90
99
  return !!(yield this.allowed(row));
91
100
  });
92
101
  }
@@ -97,7 +106,7 @@ class SessionAuth {
97
106
  if (!id)
98
107
  return null;
99
108
  const rows = yield this.rows(), hash = (0, AuthSecurity_1.hashAuthToken)(token);
100
- const row = yield rows.findOne(Object.assign({ _id: id }, this.scope));
109
+ const row = yield rows.findOne(Object.assign({ _id: this.sessionId(id) }, this.scope));
101
110
  if (!(yield this.allowed(row)) || !row)
102
111
  return null;
103
112
  if (row.refreshHash !== hash) {
@@ -111,33 +120,52 @@ class SessionAuth {
111
120
  return null;
112
121
  }
113
122
  const access = `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`, refresh = `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`;
114
- const next = yield rows.findOneAndUpdate(Object.assign(Object.assign({ _id: id }, this.scope), { refreshHash: hash, revokedAt: null, expiresAt: { $gt: Date.now() } }), { $set: { accessHash: (0, AuthSecurity_1.hashAuthToken)(access), refreshHash: (0, AuthSecurity_1.hashAuthToken)(refresh),
123
+ const next = yield rows.findOneAndUpdate(Object.assign(Object.assign({ _id: this.sessionId(id) }, this.scope), { refreshHash: hash, revokedAt: null, expiresAt: { $gt: Date.now() } }), { $set: { accessHash: (0, AuthSecurity_1.hashAuthToken)(access), refreshHash: (0, AuthSecurity_1.hashAuthToken)(refresh),
115
124
  accessExpiresAt: Math.min(Date.now() + this.accessSeconds * 1000, row.expiresAt),
116
125
  usedRefreshHashes: [...row.usedRefreshHashes, hash] } }, { returnDocument: "after" });
117
126
  if (!next) {
118
127
  // Also detect simultaneous replay that lost the compare-and-swap.
119
- yield rows.updateOne(Object.assign(Object.assign({ _id: id }, this.scope), { usedRefreshHashes: hash, revokedAt: null }), { $set: { revokedAt: Date.now() } });
128
+ yield rows.updateOne(Object.assign(Object.assign({ _id: this.sessionId(id) }, this.scope), { usedRefreshHashes: hash, revokedAt: null }), { $set: { revokedAt: Date.now() } });
120
129
  return null;
121
130
  }
122
131
  return this.pair(next, access, refresh);
123
132
  });
124
133
  }
134
+ /** Browser login metadata; arbitrary request data and token hashes are never exposed. */
135
+ recordLogin(session, userAgent) {
136
+ return __awaiter(this, void 0, void 0, function* () {
137
+ const metadata = { userAgent: userAgent.slice(0, 512), lastActiveAt: session.createdAt };
138
+ yield (yield this.rows()).updateOne(Object.assign(Object.assign({ _id: this.sessionId(session.id) }, this.scope), { revokedAt: null }), { $set: metadata });
139
+ Object.assign(session, metadata);
140
+ });
141
+ }
142
+ /** Persist activity with a one-minute throttle; authorization does not depend on this timestamp. */
143
+ touch(session) {
144
+ var _a;
145
+ return __awaiter(this, void 0, void 0, function* () {
146
+ const now = Date.now();
147
+ if (((_a = session.lastActiveAt) !== null && _a !== void 0 ? _a : session.createdAt) >= now - 60000)
148
+ return;
149
+ yield (yield this.rows()).updateOne(Object.assign(Object.assign({ _id: this.sessionId(session.id) }, this.scope), { revokedAt: null, expiresAt: { $gt: now } }), { $set: { lastActiveAt: now } });
150
+ session.lastActiveAt = now;
151
+ });
152
+ }
125
153
  revoke(sessionId) {
126
154
  return __awaiter(this, void 0, void 0, function* () {
127
155
  (0, AuthSecurity_1.authText)(sessionId, "session ID");
128
- yield (yield this.rows()).updateOne(Object.assign(Object.assign({ _id: sessionId }, this.scope), { revokedAt: null }), { $set: { revokedAt: Date.now() } });
156
+ yield (yield this.rows()).updateOne(Object.assign(Object.assign({ _id: this.sessionId(sessionId) }, this.scope), { revokedAt: null }), { $set: { revokedAt: Date.now() } });
129
157
  });
130
158
  }
131
159
  revokeAll(subject) {
132
160
  return __awaiter(this, void 0, void 0, function* () {
133
161
  (0, AuthSecurity_1.authText)(subject, "subject");
134
- yield (yield this.rows()).updateMany(Object.assign(Object.assign({ subject }, this.scope), { revokedAt: null }), { $set: { revokedAt: Date.now() } });
162
+ yield (yield this.rows()).updateMany(Object.assign(Object.assign({ _subject: this.ids().create(subject) }, this.scope), { revokedAt: null }), { $set: { revokedAt: Date.now() } });
135
163
  });
136
164
  }
137
165
  list(subject) {
138
166
  return __awaiter(this, void 0, void 0, function* () {
139
167
  (0, AuthSecurity_1.authText)(subject, "subject");
140
- const rows = yield (yield this.rows()).find(Object.assign(Object.assign({ subject }, this.scope), { revokedAt: null, expiresAt: { $gt: Date.now() } })).toArray();
168
+ const rows = yield (yield this.rows()).find(Object.assign(Object.assign({ _subject: this.ids().create(subject) }, this.scope), { revokedAt: null, expiresAt: { $gt: Date.now() } })).toArray();
141
169
  return rows.map(publicSession);
142
170
  });
143
171
  }
@@ -57,6 +57,10 @@ function readSsoClientCredentials(req) {
57
57
  exports.readSsoClientCredentials = readSsoClientCredentials;
58
58
  function challenge(verifier) { return (0, crypto_1.createHash)("sha256").update(verifier).digest("base64url"); }
59
59
  function verifierValid(value) { return typeof value === "string" && /^[A-Za-z0-9._~-]{43,128}$/.test(value); }
60
+ function sessionIdValid(value) {
61
+ return typeof value === "string" && (/^[a-fA-F0-9]{24}$/.test(value) || /^[0-9]{1,20}$/.test(value)
62
+ || /^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$/.test(value) || opaqueValid(value));
63
+ }
60
64
  function opaqueValid(value) { return typeof value === "string" && /^[A-Za-z0-9_-]{43}$/.test(value); }
61
65
  function redirectAllowed(value, allowLocal = false) {
62
66
  const url = (0, AuthSecurity_1.authUrl)(value, allowLocal);
@@ -245,7 +249,7 @@ class SsoClient {
245
249
  identity(value, environment) {
246
250
  if (!value || value.issuer !== this.issuer || value.audience !== this.options.clientId || value.environment !== environment ||
247
251
  typeof value.subject !== "string" || !value.subject.length || value.subject.length > 512 ||
248
- !opaqueValid(value.authoritySessionId) || !Number.isFinite(value.expiresAt) || value.expiresAt <= Date.now())
252
+ !sessionIdValid(value.authoritySessionId) || !Number.isFinite(value.expiresAt) || value.expiresAt <= Date.now())
249
253
  return null;
250
254
  return { issuer: value.issuer, audience: value.audience, subject: value.subject, environment: value.environment,
251
255
  authoritySessionId: value.authoritySessionId, expiresAt: value.expiresAt };
@@ -272,7 +276,7 @@ class SsoClient {
272
276
  }
273
277
  introspect(sessionId, environment) {
274
278
  return __awaiter(this, void 0, void 0, function* () {
275
- if (!opaqueValid(sessionId))
279
+ if (!sessionIdValid(sessionId))
276
280
  return null;
277
281
  (0, AuthSecurity_1.authText)(environment, "environment");
278
282
  const response = yield postBackchannel(this.introspection, this.options.clientId, this.options.clientSecret, { session_id: sessionId, environment });
@@ -38,5 +38,7 @@ export declare class WebAuth {
38
38
  establishSession(subject: string, res: Response, parent?: AuthSession["parent"]): Promise<AuthSession>;
39
39
  authenticate(req: Request): Promise<AuthSession | null>;
40
40
  refresh(req: Request, res: Response): Promise<AuthSession>;
41
+ protected logoutSession(req: Request): Promise<AuthSession>;
42
+ protected finishLogout(session: AuthSession, res: Response): Promise<void>;
41
43
  logout(req: Request, res: Response): Promise<void>;
42
44
  }
@@ -130,16 +130,29 @@ class WebAuth {
130
130
  return pair.session;
131
131
  });
132
132
  }
133
- logout(req, res) {
133
+ logoutSession(req) {
134
134
  return __awaiter(this, void 0, void 0, function* () {
135
135
  this.post(req);
136
136
  const csrf = this.csrf(req), token = this.cookies.read(req, "refresh");
137
137
  if (!(yield this.sessions.verifyCsrf(token, csrf, "refresh")))
138
138
  throw new WebAuthError(403, "Invalid session CSRF token.");
139
- yield this.sessions.revoke(token.split(".")[0]);
139
+ const session = yield this.sessions.inspect(token.split(".")[0]);
140
+ if (!session)
141
+ throw new WebAuthError(401, "Invalid session.");
142
+ return session;
143
+ });
144
+ }
145
+ finishLogout(session, res) {
146
+ return __awaiter(this, void 0, void 0, function* () {
147
+ yield this.sessions.revoke(session.id);
140
148
  for (const key of ["access", "refresh", "csrf"])
141
149
  this.cookies.write(res, key, "", 0, key !== "csrf");
142
150
  });
143
151
  }
152
+ logout(req, res) {
153
+ return __awaiter(this, void 0, void 0, function* () {
154
+ yield this.finishLogout(yield this.logoutSession(req), res);
155
+ });
156
+ }
144
157
  }
145
158
  exports.WebAuth = WebAuth;
@@ -4,3 +4,5 @@ export * from "./UserPermissions";
4
4
  export * from "./SessionAuth";
5
5
  export * from "./WebAuth";
6
6
  export * from "./SingleSignOn";
7
+ export * from "./ModelAuth";
8
+ export * from "./Auth";
@@ -20,3 +20,5 @@ __exportStar(require("./UserPermissions"), exports);
20
20
  __exportStar(require("./SessionAuth"), exports);
21
21
  __exportStar(require("./WebAuth"), exports);
22
22
  __exportStar(require("./SingleSignOn"), exports);
23
+ __exportStar(require("./ModelAuth"), exports);
24
+ __exportStar(require("./Auth"), exports);
@@ -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.ConsoleApplication = void 0;
13
+ const Auth_1 = require("./Auth/Auth");
13
14
  /// <reference types="node" />
14
15
  const Config_1 = require("./Config");
15
16
  const ConsoleOutputHelper_1 = require("./Commands/ConsoleOutputHelper");
@@ -63,6 +64,7 @@ class ConsoleApplication {
63
64
  Config_1.Config.register(key, options.config[key]);
64
65
  }
65
66
  }
67
+ Auth_1.Auth.init();
66
68
  NotificationService_1.NotificationService.init();
67
69
  if (options && options.errorHandler) {
68
70
  if (Array.isArray(options.errorHandler)) {
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.LambdaApplication = void 0;
4
+ const Auth_1 = require("./Auth/Auth");
4
5
  const Config_1 = require("./Config");
5
6
  const DatatableRegistry_1 = require("./Datatable/DatatableRegistry");
6
7
  const QueueJobsRegisty_1 = require("./Queue/QueueJobsRegisty");
@@ -26,6 +27,7 @@ class LambdaApplication {
26
27
  Config_1.Config.register(key, options.config[key]);
27
28
  }
28
29
  }
30
+ Auth_1.Auth.init();
29
31
  if (options && options.errorHandler) {
30
32
  if (Array.isArray(options.errorHandler)) {
31
33
  ErrorHandler_1.ErrorHandler.setHandlers(options.errorHandler);
@@ -1,7 +1,9 @@
1
1
  /// <reference types="node" />
2
+ import type { AuthContext } from "../Auth/ModelAuth";
2
3
  import { IncomingMessage } from "http";
3
4
  export declare class Request {
4
5
  [key: string]: any;
6
+ auth?: AuthContext | null;
5
7
  method: string;
6
8
  url: string;
7
9
  headers: any;
@@ -1,4 +1,5 @@
1
1
  import { DataBuilderRouteOptions } from "../DataBuilder/DataBuilderRoute";
2
+ import { AuthRouteOptions } from "../Auth/ModelAuth";
2
3
  type RouteRegistrationOptions = {
3
4
  [key: string]: any;
4
5
  };
@@ -28,6 +29,8 @@ export declare class RouteFacade {
28
29
  get(path: string, component: any, options?: RouteRegistrationOptions): void;
29
30
  /** Register the existing DataBuilder POST protocol with request-local configuration. */
30
31
  databuilder(path: string, options?: DataBuilderRouteOptions): void;
32
+ /** Register CSRF, model login, logout and refresh using the browser auth contract. */
33
+ auth(prefix: string, options?: AuthRouteOptions): void;
31
34
  /**
32
35
  * Register a POST-Method
33
36
  *
@@ -1,9 +1,21 @@
1
1
  "use strict";
2
+ var __rest = (this && this.__rest) || function (s, e) {
3
+ var t = {};
4
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
5
+ t[p] = s[p];
6
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
7
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
8
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
9
+ t[p[i]] = s[p[i]];
10
+ }
11
+ return t;
12
+ };
2
13
  Object.defineProperty(exports, "__esModule", { value: true });
3
14
  exports.Route = exports.RouteFacade = void 0;
4
15
  const routing_1 = require("../routing");
5
16
  const Router_1 = require("./Router");
6
17
  const DataBuilderRoute_1 = require("../DataBuilder/DataBuilderRoute");
18
+ const Auth_1 = require("../Auth/Auth");
7
19
  class RouteFacade {
8
20
  constructor() {
9
21
  this.tempGroupPrefix = null;
@@ -69,6 +81,21 @@ class RouteFacade {
69
81
  databuilder(path, options = {}) {
70
82
  this.registerWithGroupContext("POST", path, (0, DataBuilderRoute_1.dataBuilderRoute)(options), { middleware: options.middleware, domains: options.domains });
71
83
  }
84
+ /** Register CSRF, model login, logout and refresh using the browser auth contract. */
85
+ auth(prefix, options = {}) {
86
+ const { auth = "main", loginPath, loginResponse, middleware, domains } = options, overrides = __rest(options, ["auth", "loginPath", "loginResponse", "middleware", "domains"]);
87
+ if (typeof auth !== "string" || !/^[A-Za-z0-9_-]+$/.test(auth))
88
+ throw new Error("Route.auth expects an auth scope key.");
89
+ if (Object.keys(overrides).length) {
90
+ Auth_1.Auth.registerScope(auth, overrides);
91
+ }
92
+ const base = prefix.replace(/\/+$/, "");
93
+ const registration = { middleware: options.middleware, domains: options.domains };
94
+ this.get(`${base}/csrf`, (0, Auth_1.authRoute)("csrf", options), registration);
95
+ this.post(options.loginPath || `${base}/login`, (0, Auth_1.authRoute)("login", options), registration);
96
+ this.post(`${base}/logout`, (0, Auth_1.authRoute)("logout", options), registration);
97
+ this.post(`${base}/refresh`, (0, Auth_1.authRoute)("refresh", options), registration);
98
+ }
72
99
  /**
73
100
  * Register a POST-Method
74
101
  *
@@ -13,6 +13,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
13
13
  };
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.WebApplication = void 0;
16
+ const Auth_1 = require("./Auth/Auth");
16
17
  /// <reference types="node" />
17
18
  const http_1 = __importDefault(require("http"));
18
19
  const Config_1 = require("./Config");
@@ -47,6 +48,7 @@ class WebApplication {
47
48
  Config_1.Config.register(key, options.config[key]);
48
49
  }
49
50
  }
51
+ Auth_1.Auth.init();
50
52
  NotificationService_1.NotificationService.init();
51
53
  if (options && options.errorHandler) {
52
54
  if (Array.isArray(options.errorHandler)) {
@@ -279,3 +279,152 @@ configuration before rolling the new auth flow out to existing applications.
279
279
  - [OWASP Session Management](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)
280
280
  - [OWASP CSRF Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)
281
281
  - [OAuth Security BCP (RFC 9700)](https://www.rfc-editor.org/rfc/rfc9700.html)
282
+
283
+ ## Standard browser login with `Route.auth`
284
+
285
+ ```ts
286
+ import { Model, AuthScope, Route } from "@webtypen/webframez-core";
287
+
288
+ class User extends Model {
289
+ __table = "users";
290
+ __hidden = ["password"];
291
+ }
292
+
293
+ Route.auth("/api/auth", { model: User });
294
+ // GET /api/auth/csrf
295
+ // POST /api/auth/login, /api/auth/logout, /api/auth/refresh
296
+
297
+ // During registration: user.password = await AuthScope.hashPassword(password);
298
+ ```
299
+
300
+ Set `auth.origin` or `website.baseUrl` to the canonical public URL (including the
301
+ correct development port); alternatively pass `origin` explicitly. HTTPS is
302
+ required outside localhost/loopback. Origins are never inferred from untrusted
303
+ Host or forwarding headers. Origin mismatches return `invalid_origin`; token
304
+ mismatches return `invalid_csrf`. Browser CSRF protection cannot be disabled:
305
+ `csrfDisabled` accepts only `false`. Non-browser bearer authentication uses a
306
+ separate adapter.
307
+
308
+ The default model fields are `_id`, `email`, `password` and `is_active`. Identifiers
309
+ are trimmed and lowercased before lookup; store them normalized. Passwords are
310
+ not trimmed. Store a bcrypt hash; existing bcrypt hashes work without migration.
311
+ `fields` configures field names, `verifyPassword` supports other existing hashes,
312
+ and `isUserAllowed` adds account/tenant checks on login and every session access.
313
+ An explicit `false` in `is_active` rejects the account; `fields.active: false`
314
+ disables this field check. Optional `session` options configure storage, scope
315
+ and lifetimes. For `AuthScope`, access and absolute session lifetimes default to
316
+ 30 days; both remain configurable through `session.accessTokenSeconds` and
317
+ `session.sessionSeconds`. The lower-level `SessionAuth` retains its 15-minute
318
+ access-token default.
319
+
320
+ ```ts
321
+ import { createAuthFetch, installAuthForms } from "@webtypen/webframez-core/auth-client";
322
+
323
+ window.fetch = createAuthFetch({ basePath: "/api/auth" });
324
+ installAuthForms({ basePath: "/api/auth" });
325
+ ```
326
+
327
+ This public entry is browser-only and does not import server modules. It fetches
328
+ CSRF before login, sends `X-CSRF-Token` on same-origin mutations, and inserts
329
+ `_csrf` into native login forms. For a basename, supply the mounted `basePath`.
330
+ `loginPaths` supports aliases; `requiresCsrf` narrows mutation paths;
331
+ `cookieName` supports custom cookie prefixes. For a custom Fetch implementation,
332
+ pass it as the second argument to `createAuthFetch`.
333
+
334
+ `Route.auth` selects a shared registered scope with `auth: "main"` (the default).
335
+ Model and other scope options passed to `Route.auth` configure that scope in the
336
+ same `Auth` registry, including partial nested overrides.
337
+ `loginPath` keeps existing URLs, `loginResponse` adds application-specific response
338
+ fields (for example a validated redirect), and `onLogin` / `onAuthenticate` add application-specific callbacks. Browser
339
+ user-agent metadata and last activity are recorded automatically; `trackActivity: false`
340
+ disables tracking. Activity writes are throttled to once per minute. No user object or tokens are serialized by the default routes.
341
+ Route groups retain their middleware and domain restrictions. Login routes must
342
+ be accessible before authentication; apply authentication middleware to protected
343
+ application routes separately.
344
+
345
+
346
+ `audience` is a top-level option in both `Route.auth` and `AuthScope`. It takes
347
+ precedence over the still-supported `session.audience`; the default is the origin.
348
+ Standard error messages are included in English and German (`locale: "de"`);
349
+ `messages` overrides individual entries while retaining the remaining defaults.
350
+ Origin resolution also supports `application.website.baseUrl`, `WEBSITE_URL` and
351
+ `PUBLIC_BASE_URL`; explicit `origin` retains precedence.
352
+
353
+ ```ts
354
+ Route.auth("/api/auth", {
355
+ model: User,
356
+ audience: "simplebis-website",
357
+ locale: "de",
358
+ auth: "main",
359
+ // Optional overrides:
360
+ messages: { invalid_login: "Bitte prüfe deine Zugangsdaten." },
361
+ });
362
+ ```
363
+
364
+ Session metadata stays in the existing session collection. `sessions.list(subject)`
365
+ includes `userAgent` and `lastActiveAt` when available, so applications do not need
366
+ a second model/query or callbacks to maintain the session list. Existing records
367
+ without metadata remain valid; last activity falls back to the creation timestamp.
368
+
369
+
370
+ ## Named auth scopes and lifecycle hooks
371
+
372
+ ```ts
373
+ import { Auth, Config, Route } from "@webtypen/webframez-core";
374
+
375
+ Config.register("auth", {
376
+ scopes: {
377
+ main: { model: User, audience: "website", locale: "de" },
378
+ admin: { model: AdminUser, audience: "administration" },
379
+ },
380
+ });
381
+ Route.auth("/api/auth", {
382
+ auth: "main",
383
+ beforeLogin: async (req, user, scope) => {
384
+ if (!await canLogIn(user, req)) throw new Error("Login denied by policy.");
385
+ },
386
+ afterLogin: async (req, user, scope) => { /* Login has completed. */ },
387
+ beforeLogout: async (req, user, scope) => { /* Throw to prevent logout. */ },
388
+ afterLogout: async (req, user, scope) => { /* Session revoked and cookies cleared. */ },
389
+ });
390
+
391
+ Auth.scope(); // same singleton scope as Auth.scope("main")
392
+ Auth.scope("admin");
393
+ Auth.registerScope("staff", { model: StaffUser });
394
+ ```
395
+
396
+ Applications pass the auth configuration to the regular boot `config` object.
397
+ Web, console and Lambda boot initialize the registry after registering Config.
398
+ An automatic `main` scope exists unless `auth.main: false`; define its model in
399
+ `auth.scopes.main` or in `Route.auth({ model: User })` before using login. Configured
400
+ scopes and scopes registered at runtime remain available when automatic main is
401
+ disabled. Unknown keys fail explicitly. `auth.defaults` supplies shared options.
402
+ Registering an existing key updates its scope; routes resolve the current registry
403
+ entry on each request. `ModelAuth` remains a compatibility alias of `AuthScope`.
404
+
405
+ Each record in the default `auth_sessions` collection contains `scope`. Scope,
406
+ issuer, audience and environment are checked for all session operations. Named
407
+ scopes use separate cookie prefixes by default; configure the browser auth client
408
+ with the selected scope's CSRF cookie name when using a non-main scope.
409
+ The selected driver's ID adapter creates `_id` and converts `_subject`; MongoDB
410
+ stores both as BSON ObjectId. Stored records contain no extra `id` or `subject`.
411
+ Public session metadata still exposes string `id` and `subject` for transport.
412
+ Injected document stores must provide `session.idAdapter` as well as `database`.
413
+
414
+ The four hooks receive `(req, user, scope)` and await async callbacks. Login hooks
415
+ receive the verified user, never a browser-supplied subject. CSRF and origin checks
416
+ happen first. Exceptions in `beforeLogin` return `login_blocked`, and exceptions
417
+ in `beforeLogout` return `logout_blocked`; the response includes the exception's
418
+ message and a 403 status (or a thrown WebAuthError's status). No session/cookie
419
+ mutation or after-hook occurs on a rejected before-hook. After-hooks run after the
420
+ operation completes; their failures do not undo an already completed operation.
421
+ Existing `onLogin(session, req)` and `onAuthenticate(auth, req)` callbacks remain
422
+ supported. Individual `messages` entries can override before-hook error messages.
423
+
424
+ When migrating old MongoDB sessions, convert the former opaque `_id` to ObjectId
425
+ using the first 24 hex characters of SHA-256 of the former ID. Convert `subject`
426
+ to `_subject`, remove duplicate `id`/`subject`, and set the correct `scope`. Keep
427
+ hashes and expiry unchanged. SessionAuth recognizes the former token prefixes,
428
+ so converted sessions retain their existing cookies. Applications should run
429
+ an explicit migration while stopping auth writes, rather than changing schemas
430
+ implicitly during a request.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webtypen/webframez-core",
3
- "version": "0.3.72",
3
+ "version": "0.3.74",
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",
@@ -28,7 +28,9 @@
28
28
  "docs/authentication.md",
29
29
  "docs/databuilder.md",
30
30
  "routing.js",
31
- "routing.d.ts"
31
+ "routing.d.ts",
32
+ "auth-client.js",
33
+ "auth-client.d.ts"
32
34
  ],
33
35
  "scripts": {
34
36
  "build": "tsc",
@@ -47,6 +49,7 @@
47
49
  "typescript": "^4.9.3"
48
50
  },
49
51
  "dependencies": {
52
+ "bcryptjs": "^3.0.3",
50
53
  "busboy": "^1.6.0",
51
54
  "dotenv": "^8.2.0",
52
55
  "extract-zip": "^2.0.1",