@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.
- package/auth-client.d.ts +11 -0
- package/auth-client.js +50 -0
- package/dist/Auth/Auth.d.ts +19 -0
- package/dist/Auth/Auth.js +53 -0
- package/dist/Auth/ModelAuth.d.ts +83 -0
- package/dist/Auth/ModelAuth.js +275 -0
- package/dist/Auth/SessionAuth.d.ts +13 -1
- package/dist/Auth/SessionAuth.js +44 -16
- package/dist/Auth/SingleSignOn.js +6 -2
- package/dist/Auth/WebAuth.d.ts +2 -0
- package/dist/Auth/WebAuth.js +15 -2
- package/dist/Auth/index.d.ts +2 -0
- package/dist/Auth/index.js +2 -0
- package/dist/ConsoleApplication.js +2 -0
- package/dist/LambdaApplication.js +2 -0
- package/dist/Router/Request.d.ts +2 -0
- package/dist/Router/Route.d.ts +3 -0
- package/dist/Router/Route.js +27 -0
- package/dist/WebApplication.js +2 -0
- package/docs/authentication.md +149 -0
- package/package.json +5 -2
package/auth-client.d.ts
ADDED
|
@@ -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[]>;
|
package/dist/Auth/SessionAuth.js
CHANGED
|
@@ -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.
|
|
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_-]{
|
|
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 = (
|
|
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,
|
|
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_-]{
|
|
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
|
-
!
|
|
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 (!
|
|
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 });
|
package/dist/Auth/WebAuth.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/Auth/WebAuth.js
CHANGED
|
@@ -130,16 +130,29 @@ class WebAuth {
|
|
|
130
130
|
return pair.session;
|
|
131
131
|
});
|
|
132
132
|
}
|
|
133
|
-
|
|
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.
|
|
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;
|
package/dist/Auth/index.d.ts
CHANGED
package/dist/Auth/index.js
CHANGED
|
@@ -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);
|
package/dist/Router/Request.d.ts
CHANGED
package/dist/Router/Route.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/Router/Route.js
CHANGED
|
@@ -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
|
*
|
package/dist/WebApplication.js
CHANGED
|
@@ -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)) {
|
package/docs/authentication.md
CHANGED
|
@@ -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.
|
|
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",
|