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