@webtypen/webframez-core 0.3.70 → 0.3.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/agents/webframez_rules.md +53 -0
- package/dist/Api/ApiScope.d.ts +2 -0
- package/dist/Api/ApiScope.js +2 -0
- package/dist/Api/ApiScopeRegistry.js +3 -3
- package/dist/Auth/AuthSecurity.d.ts +11 -0
- package/dist/Auth/AuthSecurity.js +55 -0
- package/dist/Auth/SessionAuth.d.ts +61 -0
- package/dist/Auth/SessionAuth.js +151 -0
- package/dist/Auth/SingleSignOn.d.ts +99 -0
- package/dist/Auth/SingleSignOn.js +287 -0
- package/dist/Auth/WebAuth.d.ts +42 -0
- package/dist/Auth/WebAuth.js +145 -0
- package/dist/Auth/index.d.ts +3 -0
- package/dist/Auth/index.js +3 -0
- package/dist/DataBuilder/DataBuilder.d.ts +32 -108
- package/dist/DataBuilder/DataBuilder.js +122 -411
- package/dist/DataBuilder/DataBuilderController.js +2 -24
- package/dist/DataBuilder/DataBuilderFieldPath.d.ts +1 -0
- package/dist/DataBuilder/DataBuilderFieldPath.js +9 -0
- package/dist/DataBuilder/DataBuilderFieldType.d.ts +52 -0
- package/dist/DataBuilder/DataBuilderFieldType.js +51 -0
- package/dist/DataBuilder/DataBuilderFieldsProcessor.d.ts +36 -0
- package/dist/DataBuilder/DataBuilderFieldsProcessor.js +235 -0
- package/dist/DataBuilder/DataBuilderForms.d.ts +17 -0
- package/dist/DataBuilder/DataBuilderForms.js +87 -0
- package/dist/DataBuilder/DataBuilderFrontend.d.ts +4 -0
- package/dist/DataBuilder/DataBuilderFrontend.js +98 -0
- package/dist/DataBuilder/DataBuilderRoute.d.ts +21 -0
- package/dist/DataBuilder/DataBuilderRoute.js +61 -0
- package/dist/DataBuilder/DataBuilderTypes.d.ts +86 -0
- package/dist/DataBuilder/DataBuilderTypes.js +2 -0
- package/dist/DataBuilder/DataBuilderValidationType.d.ts +10 -0
- package/dist/DataBuilder/DataBuilderValidationType.js +21 -0
- package/dist/DataBuilder/Fields/ApiAutocompleteFieldType.d.ts +5 -0
- package/dist/DataBuilder/Fields/ApiAutocompleteFieldType.js +12 -0
- package/dist/DataBuilder/Fields/ArrayFieldType.d.ts +8 -0
- package/dist/DataBuilder/Fields/ArrayFieldType.js +55 -0
- package/dist/DataBuilder/Fields/BooleanFieldType.d.ts +5 -0
- package/dist/DataBuilder/Fields/BooleanFieldType.js +12 -0
- package/dist/DataBuilder/Fields/ColorFieldType.d.ts +5 -0
- package/dist/DataBuilder/Fields/ColorFieldType.js +12 -0
- package/dist/DataBuilder/Fields/CurrencyFieldType.d.ts +5 -0
- package/dist/DataBuilder/Fields/CurrencyFieldType.js +12 -0
- package/dist/DataBuilder/Fields/CustomFieldType.d.ts +5 -0
- package/dist/DataBuilder/Fields/CustomFieldType.js +12 -0
- package/dist/DataBuilder/Fields/DateFieldType.d.ts +5 -0
- package/dist/DataBuilder/Fields/DateFieldType.js +12 -0
- package/dist/DataBuilder/Fields/DaterangeFieldType.d.ts +5 -0
- package/dist/DataBuilder/Fields/DaterangeFieldType.js +12 -0
- package/dist/DataBuilder/Fields/DatetimeFieldType.d.ts +6 -0
- package/dist/DataBuilder/Fields/DatetimeFieldType.js +22 -0
- package/dist/DataBuilder/Fields/FloatFieldType.d.ts +6 -0
- package/dist/DataBuilder/Fields/FloatFieldType.js +15 -0
- package/dist/DataBuilder/Fields/IntegerFieldType.d.ts +8 -0
- package/dist/DataBuilder/Fields/IntegerFieldType.js +19 -0
- package/dist/DataBuilder/Fields/ModelFieldType.d.ts +5 -0
- package/dist/DataBuilder/Fields/ModelFieldType.js +12 -0
- package/dist/DataBuilder/Fields/NumericFieldType.d.ts +12 -0
- package/dist/DataBuilder/Fields/NumericFieldType.js +53 -0
- package/dist/DataBuilder/Fields/ObjectFieldType.d.ts +8 -0
- package/dist/DataBuilder/Fields/ObjectFieldType.js +45 -0
- package/dist/DataBuilder/Fields/ObjectIdFieldType.d.ts +8 -0
- package/dist/DataBuilder/Fields/ObjectIdFieldType.js +22 -0
- package/dist/DataBuilder/Fields/OptionFieldType.d.ts +5 -0
- package/dist/DataBuilder/Fields/OptionFieldType.js +12 -0
- package/dist/DataBuilder/Fields/PasswordFieldType.d.ts +5 -0
- package/dist/DataBuilder/Fields/PasswordFieldType.js +12 -0
- package/dist/DataBuilder/Fields/SecureFieldType.d.ts +5 -0
- package/dist/DataBuilder/Fields/SecureFieldType.js +12 -0
- package/dist/DataBuilder/Fields/StringFieldType.d.ts +5 -0
- package/dist/DataBuilder/Fields/StringFieldType.js +12 -0
- package/dist/DataBuilder/Fields/TimeFieldType.d.ts +5 -0
- package/dist/DataBuilder/Fields/TimeFieldType.js +12 -0
- package/dist/DataBuilder/Fields/WysiwygFieldType.d.ts +5 -0
- package/dist/DataBuilder/Fields/WysiwygFieldType.js +12 -0
- package/dist/DataBuilder/Fields/index.d.ts +42 -0
- package/dist/DataBuilder/Fields/index.js +70 -0
- package/dist/DataBuilder/LegacyFieldTypeAdapter.d.ts +22 -0
- package/dist/DataBuilder/LegacyFieldTypeAdapter.js +50 -0
- package/dist/DataBuilder/ModelForm.d.ts +23 -0
- package/dist/DataBuilder/ModelForm.js +21 -0
- package/dist/DataBuilder/Validations/EmailValidationType.d.ts +5 -0
- package/dist/DataBuilder/Validations/EmailValidationType.js +16 -0
- package/dist/DataBuilder/Validations/LengthValidationType.d.ts +5 -0
- package/dist/DataBuilder/Validations/LengthValidationType.js +20 -0
- package/dist/DataBuilder/Validations/MaxValidationType.d.ts +5 -0
- package/dist/DataBuilder/Validations/MaxValidationType.js +18 -0
- package/dist/DataBuilder/Validations/MinValidationType.d.ts +5 -0
- package/dist/DataBuilder/Validations/MinValidationType.js +18 -0
- package/dist/DataBuilder/Validations/index.d.ts +6 -0
- package/dist/DataBuilder/Validations/index.js +15 -0
- package/dist/DataBuilder/decorators.d.ts +10 -0
- package/dist/DataBuilder/decorators.js +64 -0
- package/dist/DataBuilder/formScope.d.ts +7 -0
- package/dist/DataBuilder/formScope.js +107 -0
- package/dist/Router/Route.d.ts +3 -0
- package/dist/Router/Route.js +5 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +13 -0
- package/docs/authentication.md +281 -0
- package/docs/databuilder.md +183 -0
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@ This README reflects the current API in this repository and focuses on:
|
|
|
9
9
|
- DataBuilder
|
|
10
10
|
- Console Commands
|
|
11
11
|
- Queue and Jobs
|
|
12
|
+
- Session, browser and cross-instance authentication
|
|
12
13
|
|
|
13
14
|
## Requirements
|
|
14
15
|
|
|
@@ -21,6 +22,16 @@ This README reflects the current API in this repository and focuses on:
|
|
|
21
22
|
npm i @webtypen/webframez-core
|
|
22
23
|
```
|
|
23
24
|
|
|
25
|
+
## Authentication
|
|
26
|
+
|
|
27
|
+
New integrations can use `SessionAuth` for hashed, revocable device sessions,
|
|
28
|
+
`WebAuth` for host-only browser cookies and CSRF protection, and
|
|
29
|
+
`SsoAuthority` / `SsoClient` for first-party cross-instance login with single-use
|
|
30
|
+
codes, PKCE and central session introspection. These APIs are opt-in; existing
|
|
31
|
+
`UserAuth` consumers keep their current behavior.
|
|
32
|
+
|
|
33
|
+
See [Authentication: setup, security model and migration](docs/authentication.md).
|
|
34
|
+
|
|
24
35
|
## Database drivers (from 0.3.69)
|
|
25
36
|
|
|
26
37
|
The core has no MongoDB/BSON dependency. HTTP routing and database-independent
|
|
@@ -68,6 +79,7 @@ Die Regeln beschreiben unter anderem:
|
|
|
68
79
|
- Nutzung von Model.objectId(...) statt eigener ObjectId-Resolver
|
|
69
80
|
- bevorzugte Nutzung der vorhandenen Helper wie StringFunctions, NumericFunctions und DateFunctions
|
|
70
81
|
- Verwendung der Webframez-Abstraktionen fuer Request, Response, Middleware, Storage, Datatables und Routes
|
|
82
|
+
- sinnvolle Leerzeilen nach Imports, zwischen Eigenschaften und Methoden sowie zwischen Methoden
|
|
71
83
|
|
|
72
84
|
Wichtig: AGENTS.md-Dateien haben keine universell standardisierte Import-Funktion. Die praktikable Variante ist deshalb, die Regeldatei aus dem Paket in der AGENTS.md des eigenen Projekts explizit zu referenzieren und projektspezifische Regeln lokal zu ergaenzen.
|
|
73
85
|
|
|
@@ -87,6 +99,7 @@ Insbesondere gilt:
|
|
|
87
99
|
- fuer ObjectId-Konvertierungen Model.objectId(...) oder die passende Model-Instanzmethode verwenden
|
|
88
100
|
- vorhandene Webframez-Helper und Facades bevorzugen statt neue Utilities oder Resolver anzulegen
|
|
89
101
|
- fuer HTTP-, Routing-, Middleware-, Storage- und Datatable-Code die Webframez-APIs und Konventionen beibehalten
|
|
102
|
+
- die Leerzeilenregeln aus dem Abschnitt "Code-Formatierung und Leerzeilen" einhalten
|
|
90
103
|
|
|
91
104
|
Projektspezifische Ergaenzung:
|
|
92
105
|
- dieses Projekt verwendet fuer Admin-Routen zusaetzlich die Middleware "admin-auth"
|
|
@@ -370,6 +383,11 @@ Example: `GET /api/backoffice/current-user`.
|
|
|
370
383
|
|
|
371
384
|
The scope middleware runs before the function. Its return value is passed to the function as `apiRequest.context`. Calling `abort(message, status)` stops execution and returns an error response with that status.
|
|
372
385
|
|
|
386
|
+
For HTTP middleware that must also run on automatic `OPTIONS` responses, set
|
|
387
|
+
`routeMiddleware = ["cors"]` on the scope. These names refer to middleware in the
|
|
388
|
+
application kernel. They run before the route handler; scope `middleware()` still
|
|
389
|
+
runs for function calls and provides their authorization/context (including MCP).
|
|
390
|
+
|
|
373
391
|
### Group ApiScopes
|
|
374
392
|
|
|
375
393
|
Use `ApiScopesGroup` to share middleware across multiple scopes:
|
|
@@ -903,3 +921,7 @@ a penguin glyph fallback). Set `accessibilityLabel` only when the icon adds mean
|
|
|
903
921
|
not already present in adjacent text. Optional `size` is a positive number.
|
|
904
922
|
Content is rendered as text and icons, never HTML. Plain navigation labels retain
|
|
905
923
|
the text representation; their separate `icon` property controls navigation icons.
|
|
924
|
+
|
|
925
|
+
## DataBuilder model forms
|
|
926
|
+
|
|
927
|
+
DataBuilder supports `@Field()`, parameterized `@Validates()`, server-side `ModelForm` classes and `Route.databuilder()` while retaining the v1 request protocol and legacy controller registrations. See [DataBuilder documentation](docs/databuilder.md).
|
|
@@ -15,6 +15,59 @@ Codex soll in Webframez-Projekten bevorzugt bestehende Framework-Abstraktionen n
|
|
|
15
15
|
5. Neue Abstraktionen nur dann erzeugen, wenn die Framework-API die Anforderung nachweislich nicht abbildet.
|
|
16
16
|
6. Controller-, Route-, Middleware-, Datatable- und Storage-Loesungen sollen idiomatisch fuer Webframez sein und nicht wie portierter Express-Code aussehen.
|
|
17
17
|
|
|
18
|
+
## Code-Formatierung und Leerzeilen
|
|
19
|
+
|
|
20
|
+
Diese Regeln gelten fuer neuen und bearbeiteten TypeScript-/JavaScript-Code im Core und in Webframez-Projekten. Verwende sinnvolle Leerzeilen, damit zusammengehoerige Bloecke erkennbar bleiben:
|
|
21
|
+
|
|
22
|
+
1. Nach dem letzten Import folgt genau eine Leerzeile vor der ersten Deklaration bzw. ihrem zugehoerigen Kommentar oder Decorator.
|
|
23
|
+
2. In Klassen folgt genau eine Leerzeile zwischen einem Block von Eigenschaften und dem ersten Konstruktor, Getter, Setter oder der ersten Methode.
|
|
24
|
+
3. Zwischen einzelnen Konstruktoren, Methoden, Gettern und Settern steht genau eine Leerzeile. Zusammengehoerige TypeScript-Overload-Signaturen bleiben mit ihrer Implementierung als Gruppe zusammen.
|
|
25
|
+
4. Zwischen eigenstaendigen Top-Level-Klassen, Funktionen, Interfaces und Typdefinitionen steht genau eine Leerzeile. Zugehoerige Kommentare und Decorators bleiben direkt an der Deklaration.
|
|
26
|
+
5. Zusammengehoerige Eigenschaften, etwa `key` und `type`, bleiben ohne zusaetzliche Leerzeilen zusammen. Innerhalb von Methoden trennen Leerzeilen fachlich unterschiedliche Schritte; nicht jede einzelne Anweisung wird abgesetzt.
|
|
27
|
+
6. Leerzeilen enthalten keine Leerzeichen oder Tabs. Keine mehrfachen Leerzeilen zur kuenstlichen Streckung verwenden. Vorhandene Einrueckungs- und Formatter-Vorgaben beibehalten.
|
|
28
|
+
7. Die Abstaende beim Schreiben bzw. Bearbeiten direkt einhalten und vor Abschluss pruefen. Ein Formatter ersetzt diese Regel nicht, wenn er die erforderlichen Leerzeilen nicht selbst einfuegt. Unbeteiligte Dateien nicht allein fuer eine flaechenweite Formatierung umschreiben.
|
|
29
|
+
|
|
30
|
+
Beispiel fuer den Abstand nach Imports:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { FloatFieldType } from "./FloatFieldType";
|
|
34
|
+
|
|
35
|
+
export class CurrencyFieldType extends FloatFieldType {
|
|
36
|
+
key = "currency";
|
|
37
|
+
type = "currency";
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Beispiel fuer Eigenschaften und Methoden:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { DataBuilderFieldType, DataBuilderFieldContext, DataBuilderValidationContext } from "../DataBuilderFieldType";
|
|
45
|
+
|
|
46
|
+
export class ObjectFieldType extends DataBuilderFieldType {
|
|
47
|
+
key = "object";
|
|
48
|
+
type = "object";
|
|
49
|
+
|
|
50
|
+
missingValue(context: DataBuilderFieldContext): any {
|
|
51
|
+
return context.structured && context.field.schema ? this.convert({}, context) : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async validate(value: any, context: DataBuilderValidationContext) {
|
|
55
|
+
const error = await super.validate(value, context);
|
|
56
|
+
if (error) return error;
|
|
57
|
+
return context.structured && !this.isMissing(value) && (typeof value !== "object" || Array.isArray(value))
|
|
58
|
+
? "Bitte ein Objekt angeben."
|
|
59
|
+
: null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async convert(value: any, context: DataBuilderFieldContext) {
|
|
63
|
+
if (context.structured && context.field.schema) {
|
|
64
|
+
return context.applyChildren(context.field.schema, { ...(context.currentValue || {}) }, context.path);
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
18
71
|
## Imports
|
|
19
72
|
|
|
20
73
|
Bevorzuge immer dieses Muster:
|
package/dist/Api/ApiScope.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export declare class ApiScope {
|
|
|
5
5
|
key: string;
|
|
6
6
|
apiBasePath: string | null;
|
|
7
7
|
mcpEndpoint: string | null;
|
|
8
|
+
/** Kernel middleware for HTTP routes, including automatic OPTIONS responses. */
|
|
9
|
+
routeMiddleware: string[];
|
|
8
10
|
functions: Array<ApiFunctionClass> | (() => Array<ApiFunctionClass>);
|
|
9
11
|
middleware(_req: Request, _res: Response, _abort: ApiScopeMiddlewareAbort): Promise<{
|
|
10
12
|
[key: string]: any;
|
package/dist/Api/ApiScope.js
CHANGED
|
@@ -15,6 +15,8 @@ class ApiScope {
|
|
|
15
15
|
this.key = "unique_scope_key";
|
|
16
16
|
this.apiBasePath = "/api";
|
|
17
17
|
this.mcpEndpoint = null;
|
|
18
|
+
/** Kernel middleware for HTTP routes, including automatic OPTIONS responses. */
|
|
19
|
+
this.routeMiddleware = [];
|
|
18
20
|
this.functions = [];
|
|
19
21
|
}
|
|
20
22
|
middleware(_req, _res, _abort) {
|
|
@@ -71,10 +71,10 @@ class ApiScopeRegistryFacade {
|
|
|
71
71
|
const handler = (req, res) => __awaiter(this, void 0, void 0, function* () {
|
|
72
72
|
return yield this.handleHttpFunction(registration, FunctionClass, req, res);
|
|
73
73
|
});
|
|
74
|
-
this.registerRouteMethod(func.requestMethod, path, handler);
|
|
74
|
+
this.registerRouteMethod(func.requestMethod, path, handler, scope.routeMiddleware);
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
|
-
registerRouteMethod(method, path, handler) {
|
|
77
|
+
registerRouteMethod(method, path, handler, middleware) {
|
|
78
78
|
if (!method) {
|
|
79
79
|
return;
|
|
80
80
|
}
|
|
@@ -83,7 +83,7 @@ class ApiScopeRegistryFacade {
|
|
|
83
83
|
if (!routeMethod || typeof routeMethod !== "function") {
|
|
84
84
|
throw new Error(`ApiFunction request method "${method}" is not supported by Route`);
|
|
85
85
|
}
|
|
86
|
-
routeMethod.bind(Route_1.Route)(path, handler);
|
|
86
|
+
routeMethod.bind(Route_1.Route)(path, handler, { middleware });
|
|
87
87
|
}
|
|
88
88
|
handleHttpFunction(registration, FunctionClass, req, res) {
|
|
89
89
|
return __awaiter(this, void 0, void 0, function* () {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import type { Request } from "../Router/Request";
|
|
3
|
+
/** Internal primitives shared by session, browser and SSO authentication. */
|
|
4
|
+
export declare function randomAuthToken(): string;
|
|
5
|
+
export declare function hashAuthToken(value: string): string;
|
|
6
|
+
export declare function equalAuthToken(left: unknown, right: unknown): boolean;
|
|
7
|
+
export declare function authText(value: unknown, name: string, max?: number): string;
|
|
8
|
+
export declare function authLifetime(value: number | undefined, fallback: number): number;
|
|
9
|
+
export declare function authHeader(req: Pick<Request, "headers">, name: string): string;
|
|
10
|
+
export declare function authCookie(req: Pick<Request, "headers">, name: string): string;
|
|
11
|
+
export declare function authUrl(value: string, allowInsecureLocalhost?: boolean): URL;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.authUrl = exports.authCookie = exports.authHeader = exports.authLifetime = exports.authText = exports.equalAuthToken = exports.hashAuthToken = exports.randomAuthToken = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
/** Internal primitives shared by session, browser and SSO authentication. */
|
|
6
|
+
function randomAuthToken() { return (0, crypto_1.randomBytes)(32).toString("base64url"); }
|
|
7
|
+
exports.randomAuthToken = randomAuthToken;
|
|
8
|
+
function hashAuthToken(value) { return (0, crypto_1.createHash)("sha256").update(value).digest("hex"); }
|
|
9
|
+
exports.hashAuthToken = hashAuthToken;
|
|
10
|
+
function equalAuthToken(left, right) {
|
|
11
|
+
if (typeof left !== "string" || typeof right !== "string" || left.length > 4096 || right.length > 4096)
|
|
12
|
+
return false;
|
|
13
|
+
const a = Buffer.from(left), b = Buffer.from(right);
|
|
14
|
+
return a.length === b.length && (0, crypto_1.timingSafeEqual)(a, b);
|
|
15
|
+
}
|
|
16
|
+
exports.equalAuthToken = equalAuthToken;
|
|
17
|
+
function authText(value, name, max = 512) {
|
|
18
|
+
if (typeof value !== "string" || !value.length || value.length > max || /[\x00-\x20\x7f]/.test(value))
|
|
19
|
+
throw new Error(`Invalid ${name}.`);
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
exports.authText = authText;
|
|
23
|
+
function authLifetime(value, fallback) {
|
|
24
|
+
const result = value === undefined ? fallback : value;
|
|
25
|
+
if (!Number.isSafeInteger(result) || result <= 0)
|
|
26
|
+
throw new Error("Auth lifetimes must be positive integer seconds.");
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
exports.authLifetime = authLifetime;
|
|
30
|
+
function authHeader(req, name) {
|
|
31
|
+
const key = Object.keys(req.headers || {}).find(key => key.toLowerCase() === name.toLowerCase());
|
|
32
|
+
const value = key ? req.headers[key] : undefined;
|
|
33
|
+
return typeof value === "string" ? value : "";
|
|
34
|
+
}
|
|
35
|
+
exports.authHeader = authHeader;
|
|
36
|
+
function authCookie(req, name) {
|
|
37
|
+
const matches = authHeader(req, "cookie").split(";").map(part => part.trim()).filter(part => part.startsWith(`${name}=`));
|
|
38
|
+
if (matches.length !== 1)
|
|
39
|
+
return ""; // Reject ambiguous/shadowed cookies.
|
|
40
|
+
try {
|
|
41
|
+
return decodeURIComponent(matches[0].slice(name.length + 1));
|
|
42
|
+
}
|
|
43
|
+
catch (_a) {
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
exports.authCookie = authCookie;
|
|
48
|
+
function authUrl(value, allowInsecureLocalhost = false) {
|
|
49
|
+
const url = new URL(value);
|
|
50
|
+
const local = allowInsecureLocalhost && url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
51
|
+
if ((!local && url.protocol !== "https:") || url.username || url.password || url.hash)
|
|
52
|
+
throw new Error("Auth URLs require HTTPS and must not contain credentials or fragments.");
|
|
53
|
+
return url;
|
|
54
|
+
}
|
|
55
|
+
exports.authUrl = authUrl;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { DocumentDatabase } from "../Database/DatabaseAdapter";
|
|
2
|
+
export type AuthSession = {
|
|
3
|
+
id: string;
|
|
4
|
+
subject: string;
|
|
5
|
+
issuer: string;
|
|
6
|
+
audience: string;
|
|
7
|
+
environment: string | null;
|
|
8
|
+
createdAt: number;
|
|
9
|
+
expiresAt: number;
|
|
10
|
+
parent: {
|
|
11
|
+
issuer: string;
|
|
12
|
+
sessionId: string;
|
|
13
|
+
} | null;
|
|
14
|
+
};
|
|
15
|
+
export type SessionTokenPair = {
|
|
16
|
+
auth_token: string;
|
|
17
|
+
refresh_token: string;
|
|
18
|
+
auth_expires_at: number;
|
|
19
|
+
refresh_expires_at: number;
|
|
20
|
+
session: AuthSession;
|
|
21
|
+
};
|
|
22
|
+
export type CreatedAuthSession = SessionTokenPair & {
|
|
23
|
+
csrf_token: string;
|
|
24
|
+
};
|
|
25
|
+
export type SessionAuthOptions = {
|
|
26
|
+
issuer: string;
|
|
27
|
+
audience: string;
|
|
28
|
+
environment?: string;
|
|
29
|
+
connection?: string;
|
|
30
|
+
collection?: string;
|
|
31
|
+
accessTokenSeconds?: number;
|
|
32
|
+
sessionSeconds?: number;
|
|
33
|
+
/** Called on creation, authentication and refresh. Check account status and tenant membership here.
|
|
34
|
+
* For federated sessions this can also introspect the parent authority session. Failures fail closed. */
|
|
35
|
+
isSessionAllowed: (session: AuthSession) => boolean | Promise<boolean>;
|
|
36
|
+
/** Dependency injection for a document-capable driver. Atomic findOneAndUpdate is required. */
|
|
37
|
+
database?: () => Promise<DocumentDatabase>;
|
|
38
|
+
};
|
|
39
|
+
/** Revocable, hashed, independently addressable device sessions. Legacy UserAuth is unaffected. */
|
|
40
|
+
export declare class SessionAuth {
|
|
41
|
+
private readonly options;
|
|
42
|
+
private readonly scope;
|
|
43
|
+
private readonly accessSeconds;
|
|
44
|
+
private readonly sessionSeconds;
|
|
45
|
+
constructor(options: SessionAuthOptions);
|
|
46
|
+
private rows;
|
|
47
|
+
private allowed;
|
|
48
|
+
private pair;
|
|
49
|
+
create(subject: string, parent?: AuthSession["parent"]): Promise<CreatedAuthSession>;
|
|
50
|
+
authenticate(token: string): Promise<AuthSession | null>;
|
|
51
|
+
/** Trusted-server introspection; never expose this method as an unauthenticated endpoint. */
|
|
52
|
+
inspect(sessionId: string): Promise<AuthSession | null>;
|
|
53
|
+
verifyCsrf(token: string, csrf: string, kind?: "access" | "refresh"): Promise<boolean>;
|
|
54
|
+
/** One winner under concurrency. A previously consumed refresh token revokes its device session. */
|
|
55
|
+
refresh(token: string): Promise<SessionTokenPair | null>;
|
|
56
|
+
revoke(sessionId: string): Promise<void>;
|
|
57
|
+
revokeAll(subject: string): Promise<void>;
|
|
58
|
+
list(subject: string): Promise<AuthSession[]>;
|
|
59
|
+
/** Run periodically; authorization checks never depend on cleanup scheduling. */
|
|
60
|
+
cleanup(): Promise<void>;
|
|
61
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
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.SessionAuth = void 0;
|
|
13
|
+
const DBConnection_1 = require("../Database/DBConnection");
|
|
14
|
+
const AuthSecurity_1 = require("./AuthSecurity");
|
|
15
|
+
function publicSession(record) {
|
|
16
|
+
return { id: record.id, subject: record.subject, issuer: record.issuer, audience: record.audience,
|
|
17
|
+
environment: record.environment, createdAt: record.createdAt, expiresAt: record.expiresAt, parent: record.parent };
|
|
18
|
+
}
|
|
19
|
+
function tokenSessionId(token) {
|
|
20
|
+
return typeof token === "string" && /^[A-Za-z0-9_-]{43}\.[A-Za-z0-9_-]{43}$/.test(token) ? token.split(".")[0] : null;
|
|
21
|
+
}
|
|
22
|
+
/** Revocable, hashed, independently addressable device sessions. Legacy UserAuth is unaffected. */
|
|
23
|
+
class SessionAuth {
|
|
24
|
+
constructor(options) {
|
|
25
|
+
this.options = options;
|
|
26
|
+
this.scope = { issuer: (0, AuthSecurity_1.authText)(options.issuer, "issuer"), audience: (0, AuthSecurity_1.authText)(options.audience, "audience"),
|
|
27
|
+
environment: options.environment === undefined ? null : (0, AuthSecurity_1.authText)(options.environment, "environment") };
|
|
28
|
+
this.accessSeconds = (0, AuthSecurity_1.authLifetime)(options.accessTokenSeconds, 15 * 60);
|
|
29
|
+
this.sessionSeconds = (0, AuthSecurity_1.authLifetime)(options.sessionSeconds, 30 * 86400);
|
|
30
|
+
if (typeof options.isSessionAllowed !== "function")
|
|
31
|
+
throw new Error("SessionAuth requires isSessionAllowed.");
|
|
32
|
+
}
|
|
33
|
+
rows() {
|
|
34
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
35
|
+
const db = yield (this.options.database ? this.options.database() : DBConnection_1.DBConnection.getDocumentStore(this.options.connection));
|
|
36
|
+
return db.collection(this.options.collection || "auth_sessions");
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
allowed(record) {
|
|
40
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
41
|
+
if (!record || record.revokedAt !== null || !Number.isFinite(record.expiresAt) || record.expiresAt <= Date.now())
|
|
42
|
+
return null;
|
|
43
|
+
const session = publicSession(record);
|
|
44
|
+
return (yield this.options.isSessionAllowed(session)) ? session : null;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
pair(record, access, refresh) {
|
|
48
|
+
return { auth_token: access, refresh_token: refresh, auth_expires_at: record.accessExpiresAt,
|
|
49
|
+
refresh_expires_at: record.expiresAt, session: publicSession(record) };
|
|
50
|
+
}
|
|
51
|
+
create(subject, parent = null) {
|
|
52
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
53
|
+
(0, AuthSecurity_1.authText)(subject, "subject");
|
|
54
|
+
if (parent) {
|
|
55
|
+
(0, AuthSecurity_1.authText)(parent.issuer, "parent issuer");
|
|
56
|
+
(0, AuthSecurity_1.authText)(parent.sessionId, "parent session");
|
|
57
|
+
}
|
|
58
|
+
const now = Date.now(), id = (0, AuthSecurity_1.randomAuthToken)();
|
|
59
|
+
const access = `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`, refresh = `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`, csrf = (0, AuthSecurity_1.randomAuthToken)();
|
|
60
|
+
const record = Object.assign(Object.assign({ _id: id, id, subject }, this.scope), { parent: parent ? Object.assign({}, parent) : null, createdAt: now, expiresAt: now + this.sessionSeconds * 1000, accessExpiresAt: now + Math.min(this.accessSeconds, this.sessionSeconds) * 1000, accessHash: (0, AuthSecurity_1.hashAuthToken)(access), refreshHash: (0, AuthSecurity_1.hashAuthToken)(refresh), csrfHash: (0, AuthSecurity_1.hashAuthToken)(csrf), usedRefreshHashes: [], revokedAt: null });
|
|
61
|
+
if (!(yield this.allowed(record)))
|
|
62
|
+
throw new Error("Session is not allowed.");
|
|
63
|
+
yield (yield this.rows()).insertOne(record);
|
|
64
|
+
return Object.assign(Object.assign({}, this.pair(record, access, refresh)), { csrf_token: csrf });
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
authenticate(token) {
|
|
68
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
69
|
+
const id = tokenSessionId(token);
|
|
70
|
+
if (!id)
|
|
71
|
+
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() } }));
|
|
73
|
+
return this.allowed(row);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/** Trusted-server introspection; never expose this method as an unauthenticated endpoint. */
|
|
77
|
+
inspect(sessionId) {
|
|
78
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
79
|
+
if (typeof sessionId !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(sessionId))
|
|
80
|
+
return null;
|
|
81
|
+
return this.allowed(yield (yield this.rows()).findOne(Object.assign({ _id: sessionId }, this.scope)));
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
verifyCsrf(token, csrf, kind = "access") {
|
|
85
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
86
|
+
const id = tokenSessionId(token);
|
|
87
|
+
if (!id || typeof csrf !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(csrf))
|
|
88
|
+
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() } } : {})));
|
|
90
|
+
return !!(yield this.allowed(row));
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** One winner under concurrency. A previously consumed refresh token revokes its device session. */
|
|
94
|
+
refresh(token) {
|
|
95
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
96
|
+
const id = tokenSessionId(token);
|
|
97
|
+
if (!id)
|
|
98
|
+
return null;
|
|
99
|
+
const rows = yield this.rows(), hash = (0, AuthSecurity_1.hashAuthToken)(token);
|
|
100
|
+
const row = yield rows.findOne(Object.assign({ _id: id }, this.scope));
|
|
101
|
+
if (!(yield this.allowed(row)) || !row)
|
|
102
|
+
return null;
|
|
103
|
+
if (row.refreshHash !== hash) {
|
|
104
|
+
if (row.usedRefreshHashes.includes(hash))
|
|
105
|
+
yield this.revoke(id);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
// Bound history size and retain an absolute expiry instead of extending sessions indefinitely.
|
|
109
|
+
if (row.usedRefreshHashes.length >= 4096) {
|
|
110
|
+
yield this.revoke(id);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
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),
|
|
115
|
+
accessExpiresAt: Math.min(Date.now() + this.accessSeconds * 1000, row.expiresAt),
|
|
116
|
+
usedRefreshHashes: [...row.usedRefreshHashes, hash] } }, { returnDocument: "after" });
|
|
117
|
+
if (!next) {
|
|
118
|
+
// 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() } });
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
return this.pair(next, access, refresh);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
revoke(sessionId) {
|
|
126
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
127
|
+
(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() } });
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
revokeAll(subject) {
|
|
132
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
133
|
+
(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() } });
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
list(subject) {
|
|
138
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
139
|
+
(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();
|
|
141
|
+
return rows.map(publicSession);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/** Run periodically; authorization checks never depend on cleanup scheduling. */
|
|
145
|
+
cleanup() {
|
|
146
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
147
|
+
yield (yield this.rows()).deleteMany(Object.assign(Object.assign({}, this.scope), { expiresAt: { $lte: Date.now() } }));
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
exports.SessionAuth = SessionAuth;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { DocumentDatabase } from "../Database/DatabaseAdapter";
|
|
2
|
+
import type { Request } from "../Router/Request";
|
|
3
|
+
import type { Response } from "../Router/Response";
|
|
4
|
+
import { AuthSession, SessionAuth } from "./SessionAuth";
|
|
5
|
+
import { AuthCookies, WebAuthOptions } from "./WebAuth";
|
|
6
|
+
/** Generate once during trusted instance provisioning; store only secretHash at the authority. */
|
|
7
|
+
export declare function createSsoClientCredentials(): {
|
|
8
|
+
id: string;
|
|
9
|
+
secret: string;
|
|
10
|
+
secretHash: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function readSsoClientCredentials(req: Request): {
|
|
13
|
+
clientId: string;
|
|
14
|
+
clientSecret: string;
|
|
15
|
+
} | null;
|
|
16
|
+
export type SsoClientRegistration = {
|
|
17
|
+
id: string;
|
|
18
|
+
secretHash: string;
|
|
19
|
+
redirectUris: string[];
|
|
20
|
+
};
|
|
21
|
+
export type SsoAuthorizationRequest = {
|
|
22
|
+
clientId: string;
|
|
23
|
+
redirectUri: string;
|
|
24
|
+
environment: string;
|
|
25
|
+
state: string;
|
|
26
|
+
codeChallenge: string;
|
|
27
|
+
codeChallengeMethod: "S256";
|
|
28
|
+
};
|
|
29
|
+
export type SsoCodeExchange = {
|
|
30
|
+
clientId: string;
|
|
31
|
+
clientSecret: string;
|
|
32
|
+
redirectUri: string;
|
|
33
|
+
environment: string;
|
|
34
|
+
code: string;
|
|
35
|
+
codeVerifier: string;
|
|
36
|
+
};
|
|
37
|
+
export type SsoIdentity = {
|
|
38
|
+
issuer: string;
|
|
39
|
+
audience: string;
|
|
40
|
+
subject: string;
|
|
41
|
+
environment: string;
|
|
42
|
+
authoritySessionId: string;
|
|
43
|
+
expiresAt: number;
|
|
44
|
+
};
|
|
45
|
+
export type SsoAuthorityOptions = {
|
|
46
|
+
issuer: string;
|
|
47
|
+
sessions: SessionAuth;
|
|
48
|
+
getClient: (clientId: string) => Promise<SsoClientRegistration | null>;
|
|
49
|
+
/** Required on issuance, exchange and introspection: verify the instance owns the environment AND the user is a member. */
|
|
50
|
+
authorize: (session: AuthSession, client: SsoClientRegistration, environment: string) => Promise<boolean>;
|
|
51
|
+
connection?: string;
|
|
52
|
+
collection?: string;
|
|
53
|
+
database?: () => Promise<DocumentDatabase>;
|
|
54
|
+
allowInsecureLocalhost?: boolean;
|
|
55
|
+
};
|
|
56
|
+
/** First-party, confidential-client SSO code bridge. Not a full OAuth/OIDC provider. */
|
|
57
|
+
export declare class SsoAuthority {
|
|
58
|
+
private readonly options;
|
|
59
|
+
readonly issuer: string;
|
|
60
|
+
constructor(options: SsoAuthorityOptions);
|
|
61
|
+
private rows;
|
|
62
|
+
private client;
|
|
63
|
+
private authenticatedClient;
|
|
64
|
+
/** The caller authenticates the central browser session; its access token never leaves the authority. */
|
|
65
|
+
authorize(accessToken: string, request: SsoAuthorizationRequest): Promise<string | null>;
|
|
66
|
+
/** Server-to-server only; registered client credentials and PKCE are both mandatory. */
|
|
67
|
+
exchange(request: SsoCodeExchange): Promise<SsoIdentity | null>;
|
|
68
|
+
/** Use from instance session policy for central logout/account/membership revocation. */
|
|
69
|
+
introspect(clientId: string, clientSecret: string, sessionId: string, environment: string): Promise<SsoIdentity | null>;
|
|
70
|
+
cleanup(): Promise<void>;
|
|
71
|
+
}
|
|
72
|
+
export type SsoClientOptions = WebAuthOptions & {
|
|
73
|
+
issuer: string;
|
|
74
|
+
clientId: string;
|
|
75
|
+
clientSecret: string;
|
|
76
|
+
authorizationEndpoint: string;
|
|
77
|
+
tokenEndpoint: string;
|
|
78
|
+
introspectionEndpoint: string;
|
|
79
|
+
redirectUri: string;
|
|
80
|
+
connection?: string;
|
|
81
|
+
collection?: string;
|
|
82
|
+
database?: () => Promise<DocumentDatabase>;
|
|
83
|
+
};
|
|
84
|
+
/** Instance-side redirect flow. state + server-held PKCE verifier + host-only browser-binding cookie. */
|
|
85
|
+
export declare class SsoClient {
|
|
86
|
+
private readonly options;
|
|
87
|
+
readonly cookies: AuthCookies;
|
|
88
|
+
readonly issuer: string;
|
|
89
|
+
private readonly authorization;
|
|
90
|
+
private readonly token;
|
|
91
|
+
private readonly introspection;
|
|
92
|
+
constructor(options: SsoClientOptions);
|
|
93
|
+
private rows;
|
|
94
|
+
begin(res: Response, environment: string): Promise<string>;
|
|
95
|
+
private identity;
|
|
96
|
+
complete(req: Request, res: Response): Promise<SsoIdentity | null>;
|
|
97
|
+
introspect(sessionId: string, environment: string): Promise<SsoIdentity | null>;
|
|
98
|
+
cleanup(): Promise<void>;
|
|
99
|
+
}
|