@webtypen/webframez-core 0.3.77 → 0.3.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bearer-auth-client.d.ts +13 -0
- package/bearer-auth-client.js +67 -0
- package/dist/Auth/Auth.js +1 -1
- package/dist/Auth/AuthHandoff.d.ts +16 -0
- package/dist/Auth/AuthHandoff.js +64 -0
- package/dist/Auth/AuthMembership.d.ts +39 -0
- package/dist/Auth/AuthMembership.js +62 -0
- package/dist/Auth/ModelAuth.d.ts +23 -0
- package/dist/Auth/ModelAuth.js +151 -3
- package/dist/Auth/SessionAuth.d.ts +12 -1
- package/dist/Auth/SessionAuth.js +105 -14
- package/dist/Auth/SingleSignOn.js +3 -2
- package/dist/Auth/index.d.ts +2 -0
- package/dist/Auth/index.js +2 -0
- package/docs/authentication.md +89 -0
- package/package.json +4 -2
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type BearerAuthTokens = {
|
|
2
|
+
auth_token: string;
|
|
3
|
+
refresh_token: string;
|
|
4
|
+
auth_expires_at: number;
|
|
5
|
+
refresh_expires_at: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function createBearerAuthFetch(options: {
|
|
9
|
+
configuration: (url: URL) => { key: string; refreshUrl: string } | null;
|
|
10
|
+
load: (key: string) => Promise<BearerAuthTokens | null>;
|
|
11
|
+
clear?: (key: string) => Promise<void>;
|
|
12
|
+
save: (key: string, tokens: BearerAuthTokens) => Promise<void>;
|
|
13
|
+
}, originalFetch?: typeof fetch): typeof fetch;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** Browser/native entry point; imports no server or database code. */
|
|
2
|
+
exports.createBearerAuthFetch = function createBearerAuthFetch(options, originalFetch = globalThis.fetch.bind(globalThis)) {
|
|
3
|
+
const pending = new Map();
|
|
4
|
+
const refresh = (configuration, failedToken) => {
|
|
5
|
+
const key = configuration.key;
|
|
6
|
+
const perform = async () => {
|
|
7
|
+
const current = await options.load(key);
|
|
8
|
+
if (!current) return null;
|
|
9
|
+
if (failedToken && current.auth_token !== failedToken) return current;
|
|
10
|
+
let response;
|
|
11
|
+
try {
|
|
12
|
+
response = await originalFetch(configuration.refreshUrl, { method: "POST", credentials: "omit",
|
|
13
|
+
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: current.refresh_token }),
|
|
14
|
+
redirect: "error", signal: typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(15000) : undefined });
|
|
15
|
+
} catch (error) {
|
|
16
|
+
// A timeout may have consumed the rotating token; never replay it automatically.
|
|
17
|
+
if (options.clear) await options.clear(key);
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
if (!response.ok) {
|
|
21
|
+
if (options.clear) await options.clear(key);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
let pair;
|
|
25
|
+
try { pair = await response.json(); }
|
|
26
|
+
catch { if (options.clear) await options.clear(key); return null; }
|
|
27
|
+
if (pair.status !== "success" || typeof pair.auth_token !== "string" || typeof pair.refresh_token !== "string"
|
|
28
|
+
|| !Number.isFinite(pair.auth_expires_at) || !Number.isFinite(pair.refresh_expires_at)) {
|
|
29
|
+
if (options.clear) await options.clear(key);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const latest = await options.load(key);
|
|
33
|
+
if (!latest || latest.refresh_token !== current.refresh_token) return latest;
|
|
34
|
+
await options.save(key, pair);
|
|
35
|
+
return pair;
|
|
36
|
+
};
|
|
37
|
+
if (!pending.has(key)) {
|
|
38
|
+
const task = typeof navigator !== "undefined" && navigator.locks
|
|
39
|
+
? navigator.locks.request(`webframez-bearer:${key}`, perform) : perform();
|
|
40
|
+
pending.set(key, task.finally(() => pending.delete(key)));
|
|
41
|
+
}
|
|
42
|
+
return pending.get(key);
|
|
43
|
+
};
|
|
44
|
+
return async (input, init) => {
|
|
45
|
+
let url;
|
|
46
|
+
try { url = new URL(typeof Request !== "undefined" && input instanceof Request ? input.url : String(input), globalThis.location?.href); }
|
|
47
|
+
catch { return originalFetch(input, init); }
|
|
48
|
+
const configuration = options.configuration(url);
|
|
49
|
+
if (!configuration) return originalFetch(input, init);
|
|
50
|
+
const refreshUrl = new URL(configuration.refreshUrl);
|
|
51
|
+
if (refreshUrl.origin !== url.origin) throw new Error("Bearer refresh must use the request origin.");
|
|
52
|
+
let session = await options.load(configuration.key);
|
|
53
|
+
if (!session || url.href === refreshUrl.href) return originalFetch(input, init);
|
|
54
|
+
if (session.auth_expires_at <= Date.now() + 30000) session = await refresh(configuration, session.auth_token) || session;
|
|
55
|
+
const headers = new Headers(init?.headers || (typeof Request !== "undefined" && input instanceof Request ? input.headers : undefined));
|
|
56
|
+
headers.set("Authorization", `Bearer ${session.auth_token}`);
|
|
57
|
+
const retryInput = typeof Request !== "undefined" && input instanceof Request ? input.clone() : input;
|
|
58
|
+
const response = await originalFetch(input, { ...init, headers, credentials: "omit", redirect: "error" });
|
|
59
|
+
if (response.status !== 401) return response;
|
|
60
|
+
const failure = await response.clone().json().catch(() => null);
|
|
61
|
+
if (failure?.code !== "unauthorized") return response;
|
|
62
|
+
const renewed = await refresh(configuration, session.auth_token);
|
|
63
|
+
if (!renewed) return response;
|
|
64
|
+
headers.set("Authorization", `Bearer ${renewed.auth_token}`);
|
|
65
|
+
return originalFetch(retryInput, { ...init, headers, credentials: "omit", redirect: "error" });
|
|
66
|
+
};
|
|
67
|
+
};
|
package/dist/Auth/Auth.js
CHANGED
|
@@ -15,7 +15,7 @@ const Model_1 = require("../Database/Model");
|
|
|
15
15
|
const ModelAuth_1 = require("./ModelAuth");
|
|
16
16
|
function mergeOptions(base, config) {
|
|
17
17
|
return Object.assign(Object.assign(Object.assign({ model: Model_1.Model }, base), config), { fields: Object.assign(Object.assign({}, base.fields), config.fields), session: Object.assign(Object.assign({}, base.session), config.session), passwordReset: config.passwordReset === false ? false : config.passwordReset === undefined && base.passwordReset === false ? false
|
|
18
|
-
: Object.assign(Object.assign({}, (base.passwordReset || {})), (config.passwordReset || {})), messages: Object.assign(Object.assign({}, base.messages), config.messages) });
|
|
18
|
+
: Object.assign(Object.assign({}, (base.passwordReset || {})), (config.passwordReset || {})), memberships: Object.assign(Object.assign({}, base.memberships), config.memberships), messages: Object.assign(Object.assign({}, base.messages), config.messages) });
|
|
19
19
|
}
|
|
20
20
|
/** Application-wide registry of named browser-authentication scopes. */
|
|
21
21
|
class AuthFacade {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { DocumentDatabase } from "../Database/DatabaseAdapter";
|
|
2
|
+
/** Single-use delivery to a separate frontend origin after a server-side SSO callback. */
|
|
3
|
+
export declare class AuthHandoff {
|
|
4
|
+
private readonly options;
|
|
5
|
+
private readonly key;
|
|
6
|
+
constructor(options: {
|
|
7
|
+
secret: string;
|
|
8
|
+
audience: string;
|
|
9
|
+
connection?: string;
|
|
10
|
+
database?: () => Promise<DocumentDatabase>;
|
|
11
|
+
});
|
|
12
|
+
private rows;
|
|
13
|
+
create(payload: object): Promise<string>;
|
|
14
|
+
consume<T = unknown>(code: string): Promise<T | null>;
|
|
15
|
+
cleanup(): Promise<void>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.AuthHandoff = void 0;
|
|
13
|
+
const crypto_1 = require("crypto");
|
|
14
|
+
const DBConnection_1 = require("../Database/DBConnection");
|
|
15
|
+
const AuthSecurity_1 = require("./AuthSecurity");
|
|
16
|
+
/** Single-use delivery to a separate frontend origin after a server-side SSO callback. */
|
|
17
|
+
class AuthHandoff {
|
|
18
|
+
constructor(options) {
|
|
19
|
+
this.options = options;
|
|
20
|
+
if (typeof options.secret !== "string" || options.secret.length < 32)
|
|
21
|
+
throw new Error("Auth handoff requires a strong server secret.");
|
|
22
|
+
if (!options.audience)
|
|
23
|
+
throw new Error("Auth handoff requires an audience.");
|
|
24
|
+
this.key = (0, crypto_1.createHash)("sha256").update(`webframez-handoff:${options.secret}`).digest();
|
|
25
|
+
}
|
|
26
|
+
rows() {
|
|
27
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
28
|
+
const db = yield (this.options.database ? this.options.database() : DBConnection_1.DBConnection.getDocumentStore(this.options.connection));
|
|
29
|
+
return db.collection("auth_handoffs");
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
create(payload) {
|
|
33
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
34
|
+
const code = (0, AuthSecurity_1.randomAuthToken)(), iv = (0, crypto_1.randomBytes)(12);
|
|
35
|
+
const cipher = (0, crypto_1.createCipheriv)("aes-256-gcm", this.key, iv);
|
|
36
|
+
cipher.setAAD(Buffer.from(this.options.audience));
|
|
37
|
+
const encrypted = Buffer.concat([cipher.update(JSON.stringify(payload), "utf8"), cipher.final()]);
|
|
38
|
+
yield (yield this.rows()).insertOne({ _id: (0, AuthSecurity_1.hashAuthToken)(code), audience: this.options.audience,
|
|
39
|
+
iv: iv.toString("base64"), tag: cipher.getAuthTag().toString("base64"), payload: encrypted.toString("base64"),
|
|
40
|
+
expiresAt: Date.now() + 30000, purgeAt: new Date(Date.now() + 60000), consumedAt: null });
|
|
41
|
+
return code;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
consume(code) {
|
|
45
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
46
|
+
if (typeof code !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(code))
|
|
47
|
+
return null;
|
|
48
|
+
const row = yield (yield this.rows()).findOneAndUpdate({ _id: (0, AuthSecurity_1.hashAuthToken)(code), audience: this.options.audience,
|
|
49
|
+
expiresAt: { $gt: Date.now() }, consumedAt: null }, { $set: { consumedAt: Date.now(), payload: "" } }, { returnDocument: "before" });
|
|
50
|
+
if (!row)
|
|
51
|
+
return null;
|
|
52
|
+
const decipher = (0, crypto_1.createDecipheriv)("aes-256-gcm", this.key, Buffer.from(row.iv, "base64"));
|
|
53
|
+
decipher.setAAD(Buffer.from(this.options.audience));
|
|
54
|
+
decipher.setAuthTag(Buffer.from(row.tag, "base64"));
|
|
55
|
+
return JSON.parse(Buffer.concat([decipher.update(Buffer.from(row.payload, "base64")), decipher.final()]).toString("utf8"));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
cleanup() {
|
|
59
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
60
|
+
yield (yield this.rows()).deleteMany({ audience: this.options.audience, expiresAt: { $lte: Date.now() } });
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
exports.AuthHandoff = AuthHandoff;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Model } from "../Database/Model";
|
|
2
|
+
export type AuthMembershipCondition = {
|
|
3
|
+
field: string;
|
|
4
|
+
operator: "=" | "!=";
|
|
5
|
+
value: unknown;
|
|
6
|
+
};
|
|
7
|
+
export type AuthMembershipBinding = {
|
|
8
|
+
user: {
|
|
9
|
+
foreignKey: string;
|
|
10
|
+
localKeys: readonly string[];
|
|
11
|
+
firstAvailable?: boolean;
|
|
12
|
+
};
|
|
13
|
+
resource: {
|
|
14
|
+
foreignKey: string;
|
|
15
|
+
localKeys: readonly string[];
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
/** Only server-loaded models and server-configured fields participate in authorization. */
|
|
19
|
+
export type AuthMembershipOptions = {
|
|
20
|
+
model: typeof Model;
|
|
21
|
+
bindings: readonly AuthMembershipBinding[];
|
|
22
|
+
/** Optionally bind this membership to the environment selected by a federated session. */
|
|
23
|
+
sessionEnvironmentKey?: string;
|
|
24
|
+
conditions?: readonly AuthMembershipCondition[];
|
|
25
|
+
userConditions?: readonly AuthMembershipCondition[];
|
|
26
|
+
resourceConditions?: readonly AuthMembershipCondition[];
|
|
27
|
+
};
|
|
28
|
+
export type AuthMembershipContext<TMembership extends Model = Model, TResource extends Model = Model> = {
|
|
29
|
+
membership: TMembership;
|
|
30
|
+
resource: TResource;
|
|
31
|
+
};
|
|
32
|
+
/** Resolves fresh membership records without caching authorization between requests. */
|
|
33
|
+
export declare class AuthMembership {
|
|
34
|
+
private readonly options;
|
|
35
|
+
constructor(options: AuthMembershipOptions);
|
|
36
|
+
private matches;
|
|
37
|
+
matchesEnvironment(resource: Model, environment: string | null): boolean;
|
|
38
|
+
find<TMembership extends Model = Model>(user: Model, resource: Model): Promise<TMembership | null>;
|
|
39
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.AuthMembership = void 0;
|
|
13
|
+
const Model_1 = require("../Database/Model");
|
|
14
|
+
/** Resolves fresh membership records without caching authorization between requests. */
|
|
15
|
+
class AuthMembership {
|
|
16
|
+
constructor(options) {
|
|
17
|
+
var _a;
|
|
18
|
+
this.options = options;
|
|
19
|
+
if (!options.model || !((_a = options.bindings) === null || _a === void 0 ? void 0 : _a.length))
|
|
20
|
+
throw new Error("Auth membership requires a model and bindings.");
|
|
21
|
+
for (const binding of options.bindings) {
|
|
22
|
+
if (!binding.user.foreignKey || !binding.resource.foreignKey
|
|
23
|
+
|| !binding.user.localKeys.length || !binding.resource.localKeys.length) {
|
|
24
|
+
throw new Error("Auth membership requires user and resource reference fields.");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
matches(model, conditions = []) {
|
|
29
|
+
return conditions.every(condition => condition.operator === "="
|
|
30
|
+
? model[condition.field] === condition.value : model[condition.field] !== condition.value);
|
|
31
|
+
}
|
|
32
|
+
matchesEnvironment(resource, environment) {
|
|
33
|
+
const key = this.options.sessionEnvironmentKey;
|
|
34
|
+
return !environment || !key || String(resource === null || resource === void 0 ? void 0 : resource[key]) === environment;
|
|
35
|
+
}
|
|
36
|
+
find(user, resource) {
|
|
37
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
38
|
+
if (!(user instanceof Model_1.Model) || !(resource instanceof Model_1.Model)
|
|
39
|
+
|| !this.matches(user, this.options.userConditions) || !this.matches(resource, this.options.resourceConditions))
|
|
40
|
+
return null;
|
|
41
|
+
for (const binding of this.options.bindings) {
|
|
42
|
+
let userIds = binding.user.localKeys.map(key => user[key]).filter(value => value !== null && value !== undefined && value !== "");
|
|
43
|
+
if (binding.user.firstAvailable)
|
|
44
|
+
userIds = userIds.slice(0, 1);
|
|
45
|
+
const resourceIds = binding.resource.localKeys.map(key => resource[key]).filter(value => value !== null && value !== undefined && value !== "");
|
|
46
|
+
// Missing references must never turn into an unbounded membership lookup.
|
|
47
|
+
if (!userIds.length || !resourceIds.length)
|
|
48
|
+
continue;
|
|
49
|
+
let query = this.options.model.where(binding.user.foreignKey, "=", { $in: userIds })
|
|
50
|
+
.where(binding.resource.foreignKey, "=", { $in: resourceIds });
|
|
51
|
+
for (const condition of this.options.conditions || []) {
|
|
52
|
+
query = query.where(condition.field, condition.operator, condition.value);
|
|
53
|
+
}
|
|
54
|
+
const membership = yield query.first();
|
|
55
|
+
if (membership)
|
|
56
|
+
return membership;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
exports.AuthMembership = AuthMembership;
|
package/dist/Auth/ModelAuth.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { Model } from "../Database/Model";
|
|
2
2
|
import { Request } from "../Router/Request";
|
|
3
3
|
import type { Response } from "../Router/Response";
|
|
4
|
+
import { AuthMembershipContext, AuthMembershipOptions } from "./AuthMembership";
|
|
4
5
|
import { PasswordReset, PasswordResetOptions } from "./PasswordReset";
|
|
5
6
|
import { AuthSession, SessionAuthOptions } from "./SessionAuth";
|
|
6
7
|
import { WebAuth, WebAuthError, WebAuthOptions } from "./WebAuth";
|
|
7
8
|
export type AuthContext<TUser extends Model = Model> = {
|
|
8
9
|
user: TUser;
|
|
9
10
|
session: AuthSession;
|
|
11
|
+
memberships?: Record<string, AuthMembershipContext>;
|
|
10
12
|
};
|
|
11
13
|
export type AuthSnapshot<TUser = Record<string, unknown>> = {
|
|
12
14
|
user: TUser;
|
|
@@ -19,6 +21,8 @@ export declare class AuthLoginError extends WebAuthError {
|
|
|
19
21
|
}
|
|
20
22
|
export type ModelAuthOptions = Partial<WebAuthOptions> & {
|
|
21
23
|
model: typeof Model;
|
|
24
|
+
/** Bearer scopes return opaque tokens and never use ambient authentication cookies. */
|
|
25
|
+
transport?: "cookie" | "bearer";
|
|
22
26
|
key?: string;
|
|
23
27
|
/** Takes precedence over session.audience; defaults to the configured origin. */
|
|
24
28
|
audience?: string;
|
|
@@ -35,6 +39,8 @@ export type ModelAuthOptions = Partial<WebAuthOptions> & {
|
|
|
35
39
|
session?: Partial<Omit<SessionAuthOptions, "issuer" | "isSessionAllowed">>;
|
|
36
40
|
csrfDisabled?: false;
|
|
37
41
|
verifyPassword?: (password: string, storedHash: string) => boolean | Promise<boolean>;
|
|
42
|
+
memberships?: Record<string, AuthMembershipOptions>;
|
|
43
|
+
isSessionAllowed?: (session: AuthSession, user: Model) => boolean | Promise<boolean>;
|
|
38
44
|
isUserAllowed?: (user: Model) => boolean | Promise<boolean>;
|
|
39
45
|
beforeLogin?: AuthActionHook;
|
|
40
46
|
afterLogin?: AuthActionHook;
|
|
@@ -60,6 +66,8 @@ export declare class AuthScope extends WebAuth {
|
|
|
60
66
|
readonly key: string;
|
|
61
67
|
readonly passwordReset: PasswordReset | null;
|
|
62
68
|
private readonly model;
|
|
69
|
+
private readonly memberships;
|
|
70
|
+
private readonly contexts;
|
|
63
71
|
private authPaths;
|
|
64
72
|
private readonly sessionUsers;
|
|
65
73
|
private readonly fields;
|
|
@@ -77,8 +85,23 @@ export declare class AuthScope extends WebAuth {
|
|
|
77
85
|
verifyCredentials(req: Request): Promise<string>;
|
|
78
86
|
login(req: Request, res: Response, verifyCredentials?: () => Promise<string | null>): Promise<AuthSession>;
|
|
79
87
|
logout(req: Request, res: Response): Promise<void>;
|
|
88
|
+
/** Trusted SSO/server entry point; the subject must already be verified by the caller. */
|
|
89
|
+
establishBearerSession(req: Request, subject: string, parent?: AuthSession["parent"], environment?: string, parentExpiresAt?: number): Promise<{
|
|
90
|
+
auth_token: string;
|
|
91
|
+
refresh_token: string;
|
|
92
|
+
auth_expires_at: number;
|
|
93
|
+
refresh_expires_at: number;
|
|
94
|
+
session: AuthSession;
|
|
95
|
+
}>;
|
|
96
|
+
logoutBearer(req: Request): Promise<void>;
|
|
97
|
+
/** Explicit Authorization credentials require no cookie CSRF and are never read from query parameters. */
|
|
98
|
+
resolveBearer<TUser extends Model = Model>(req: Request, res?: Response): Promise<AuthContext<TUser> | null>;
|
|
80
99
|
/** Resolve a fresh validated session and its model; never trusts a client-supplied req.auth. */
|
|
81
100
|
resolve<TUser extends Model = Model>(req: Request, res?: Response): Promise<AuthContext<TUser> | null>;
|
|
101
|
+
/** For integrations whose authenticated User was established by another trusted transport. */
|
|
102
|
+
findMembership<TMembership extends Model = Model>(name: string, user: Model, resource: Model): Promise<TMembership | null>;
|
|
103
|
+
/** Attaches a fresh membership only to a context authenticated by this scope. */
|
|
104
|
+
authorizeMembership<TMembership extends Model = Model>(req: Request, name: string, resource: Model): Promise<TMembership | null>;
|
|
82
105
|
resolveCookies<TUser extends Model = Model>(cookies: Record<string, string | undefined>): Promise<AuthContext<TUser> | null>;
|
|
83
106
|
/** Only explicit public fields are sent to browsers; Model instances remain on the server. */
|
|
84
107
|
snapshot(auth: AuthContext | null | undefined): AuthSnapshot | null;
|
package/dist/Auth/ModelAuth.js
CHANGED
|
@@ -8,6 +8,17 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
8
8
|
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
9
|
});
|
|
10
10
|
};
|
|
11
|
+
var __rest = (this && this.__rest) || function (s, e) {
|
|
12
|
+
var t = {};
|
|
13
|
+
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
|
|
14
|
+
t[p] = s[p];
|
|
15
|
+
if (s != null && typeof Object.getOwnPropertySymbols === "function")
|
|
16
|
+
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
|
|
17
|
+
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
|
|
18
|
+
t[p[i]] = s[p[i]];
|
|
19
|
+
}
|
|
20
|
+
return t;
|
|
21
|
+
};
|
|
11
22
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
23
|
exports.ModelAuth = exports.AuthScope = exports.AuthLoginError = void 0;
|
|
13
24
|
const bcryptjs_1 = require("bcryptjs");
|
|
@@ -15,6 +26,7 @@ const routing_1 = require("../routing");
|
|
|
15
26
|
const Config_1 = require("../Config");
|
|
16
27
|
const Request_1 = require("../Router/Request");
|
|
17
28
|
const Auth_1 = require("./Auth");
|
|
29
|
+
const AuthMembership_1 = require("./AuthMembership");
|
|
18
30
|
const PasswordReset_1 = require("./PasswordReset");
|
|
19
31
|
const AuthSecurity_1 = require("./AuthSecurity");
|
|
20
32
|
const SessionAuth_1 = require("./SessionAuth");
|
|
@@ -79,14 +91,21 @@ class AuthScope extends WebAuth_1.WebAuth {
|
|
|
79
91
|
const sessions = new SessionAuth_1.SessionAuth(Object.assign(Object.assign({ accessTokenSeconds: 15 * 60, sessionSeconds: 30 * 86400, idleTimeoutSeconds: 7 * 86400 }, modelOptions.session), { issuer: origin, scope: key, audience: (_d = (_b = modelOptions.audience) !== null && _b !== void 0 ? _b : (_c = modelOptions.session) === null || _c === void 0 ? void 0 : _c.audience) !== null && _d !== void 0 ? _d : origin, connection: ((_e = modelOptions.session) === null || _e === void 0 ? void 0 : _e.connection) || new model().__connection, isSessionAllowed: (session) => __awaiter(this, void 0, void 0, function* () {
|
|
80
92
|
const id = fields.primaryKey === "_id" ? yield model.objectId(session.subject, { noExceptions: true }) : session.subject;
|
|
81
93
|
const user = id !== null && id !== undefined ? yield model.where(fields.primaryKey, "=", id).first() : null;
|
|
82
|
-
if (!(yield allowed(user)))
|
|
94
|
+
if (!(yield allowed(user)) || (modelOptions.isSessionAllowed && !(yield modelOptions.isSessionAllowed(session, user))))
|
|
83
95
|
return false;
|
|
84
96
|
sessionUsers.set(session, user);
|
|
85
97
|
return true;
|
|
86
98
|
}) }));
|
|
87
99
|
super(sessions, Object.assign(Object.assign({}, modelOptions), { origin, cookiePrefix: (_f = modelOptions.cookiePrefix) !== null && _f !== void 0 ? _f : (key === "main" ? undefined : `${origin.startsWith("https:") ? "__Host-wf_" : "wf_dev_"}${key}_`), allowInsecureLocalhost: (_g = modelOptions.allowInsecureLocalhost) !== null && _g !== void 0 ? _g : true }));
|
|
88
100
|
this.modelOptions = modelOptions;
|
|
101
|
+
this.memberships = new Map();
|
|
102
|
+
this.contexts = new WeakSet();
|
|
89
103
|
this.authPaths = { basePath: "/api/auth", loginPath: "/api/auth/login" };
|
|
104
|
+
for (const [name, options] of Object.entries(modelOptions.memberships || {})) {
|
|
105
|
+
if (!/^[A-Za-z0-9_-]+$/.test(name))
|
|
106
|
+
throw new Error("Invalid auth membership key.");
|
|
107
|
+
this.memberships.set(name, new AuthMembership_1.AuthMembership(options));
|
|
108
|
+
}
|
|
90
109
|
this.key = key;
|
|
91
110
|
this.sessionUsers = sessionUsers;
|
|
92
111
|
this.model = model;
|
|
@@ -191,6 +210,82 @@ class AuthScope extends WebAuth_1.WebAuth {
|
|
|
191
210
|
yield this.modelOptions.afterLogout(req, user, this);
|
|
192
211
|
});
|
|
193
212
|
}
|
|
213
|
+
/** Trusted SSO/server entry point; the subject must already be verified by the caller. */
|
|
214
|
+
establishBearerSession(req, subject, parent = null, environment, parentExpiresAt) {
|
|
215
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
216
|
+
if (this.modelOptions.transport !== "bearer")
|
|
217
|
+
throw new Error("Bearer transport is not enabled for this auth scope.");
|
|
218
|
+
const id = this.fields.primaryKey === "_id" ? yield this.model.objectId(subject, { noExceptions: true }) : subject;
|
|
219
|
+
const user = id == null ? null : yield this.model.where(this.fields.primaryKey, "=", id).first();
|
|
220
|
+
if (!user || (this.fields.active !== false && user[this.fields.active] === false)
|
|
221
|
+
|| (this.modelOptions.isUserAllowed && !(yield this.modelOptions.isUserAllowed(user)))) {
|
|
222
|
+
throw new AuthLoginError("inactive_user", 403, this.message("inactive_user"));
|
|
223
|
+
}
|
|
224
|
+
if (this.modelOptions.beforeLogin) {
|
|
225
|
+
try {
|
|
226
|
+
yield this.modelOptions.beforeLogin(req, user, this);
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
throw new AuthHookError("login_blocked", error);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const pair = yield this.sessions.create(subject, parent, environment, parentExpiresAt);
|
|
233
|
+
if (!pair.reused && this.modelOptions.trackActivity !== false)
|
|
234
|
+
yield this.sessions.recordLogin(pair.session, (0, AuthSecurity_1.authHeader)(req, "user-agent"));
|
|
235
|
+
if (this.modelOptions.onLogin)
|
|
236
|
+
yield this.modelOptions.onLogin(pair.session, req);
|
|
237
|
+
if (this.modelOptions.afterLogin)
|
|
238
|
+
yield this.modelOptions.afterLogin(req, user, this);
|
|
239
|
+
const { csrf_token, reused } = pair, tokens = __rest(pair, ["csrf_token", "reused"]);
|
|
240
|
+
return tokens;
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
logoutBearer(req) {
|
|
244
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
245
|
+
if (req.method !== "POST")
|
|
246
|
+
throw new WebAuth_1.WebAuthError(403, "Logout requires POST.");
|
|
247
|
+
const auth = yield this.resolveBearer(req);
|
|
248
|
+
if (!auth)
|
|
249
|
+
throw new AuthLoginError("unauthorized", 401, this.message("unauthorized"));
|
|
250
|
+
if (this.modelOptions.beforeLogout) {
|
|
251
|
+
try {
|
|
252
|
+
yield this.modelOptions.beforeLogout(req, auth.user, this);
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
throw new AuthHookError("logout_blocked", error);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
yield this.sessions.revoke(auth.session.id);
|
|
259
|
+
if (this.modelOptions.afterLogout)
|
|
260
|
+
yield this.modelOptions.afterLogout(req, auth.user, this);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
/** Explicit Authorization credentials require no cookie CSRF and are never read from query parameters. */
|
|
264
|
+
resolveBearer(req, res) {
|
|
265
|
+
var _a;
|
|
266
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
267
|
+
if (this.modelOptions.transport !== "bearer")
|
|
268
|
+
throw new Error("Bearer transport is not enabled for this auth scope.");
|
|
269
|
+
res === null || res === void 0 ? void 0 : res.header("Cache-Control", "no-store");
|
|
270
|
+
Object.defineProperty(req, "auth", { value: null, writable: true, configurable: true, enumerable: false });
|
|
271
|
+
const header = (0, AuthSecurity_1.authHeader)(req, "authorization");
|
|
272
|
+
const token = (_a = /^Bearer ([A-Za-z0-9_-]{1,128}\.[A-Za-z0-9_-]{43})$/i.exec(header)) === null || _a === void 0 ? void 0 : _a[1];
|
|
273
|
+
if (!token)
|
|
274
|
+
return null;
|
|
275
|
+
const session = yield this.sessions.authenticate(token);
|
|
276
|
+
const user = session && this.sessionUsers.get(session);
|
|
277
|
+
if (!session || !user)
|
|
278
|
+
return null;
|
|
279
|
+
if (this.modelOptions.trackActivity !== false)
|
|
280
|
+
yield this.sessions.touch(session);
|
|
281
|
+
const auth = { user, session };
|
|
282
|
+
this.contexts.add(auth);
|
|
283
|
+
if (this.modelOptions.onAuthenticate)
|
|
284
|
+
yield this.modelOptions.onAuthenticate(auth, req);
|
|
285
|
+
req.auth = auth;
|
|
286
|
+
return auth;
|
|
287
|
+
});
|
|
288
|
+
}
|
|
194
289
|
/** Resolve a fresh validated session and its model; never trusts a client-supplied req.auth. */
|
|
195
290
|
resolve(req, res) {
|
|
196
291
|
const _super = Object.create(null, {
|
|
@@ -198,6 +293,8 @@ class AuthScope extends WebAuth_1.WebAuth {
|
|
|
198
293
|
resume: { get: () => super.resume }
|
|
199
294
|
});
|
|
200
295
|
return __awaiter(this, void 0, void 0, function* () {
|
|
296
|
+
if (this.modelOptions.transport === "bearer")
|
|
297
|
+
return this.resolveBearer(req, res);
|
|
201
298
|
res === null || res === void 0 ? void 0 : res.header("Cache-Control", "no-store");
|
|
202
299
|
Object.defineProperty(req, "auth", { value: null, writable: true, configurable: true, enumerable: false });
|
|
203
300
|
const session = (yield _super.authenticate.call(this, req)) || (res ? yield _super.resume.call(this, req, res) : null);
|
|
@@ -209,12 +306,44 @@ class AuthScope extends WebAuth_1.WebAuth {
|
|
|
209
306
|
if (this.modelOptions.trackActivity !== false)
|
|
210
307
|
yield this.sessions.touch(session);
|
|
211
308
|
const auth = { user, session };
|
|
309
|
+
this.contexts.add(auth);
|
|
212
310
|
if (this.modelOptions.onAuthenticate)
|
|
213
311
|
yield this.modelOptions.onAuthenticate(auth, req);
|
|
214
312
|
req.auth = auth;
|
|
215
313
|
return auth;
|
|
216
314
|
});
|
|
217
315
|
}
|
|
316
|
+
/** For integrations whose authenticated User was established by another trusted transport. */
|
|
317
|
+
findMembership(name, user, resource) {
|
|
318
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
319
|
+
const resolver = this.memberships.get(name);
|
|
320
|
+
if (!resolver)
|
|
321
|
+
throw new Error(`Auth membership "${name}" is not configured.`);
|
|
322
|
+
return resolver.find(user, resource);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
/** Attaches a fresh membership only to a context authenticated by this scope. */
|
|
326
|
+
authorizeMembership(req, name, resource) {
|
|
327
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
328
|
+
const auth = req.auth;
|
|
329
|
+
if (!auth || !this.contexts.has(auth))
|
|
330
|
+
return null;
|
|
331
|
+
if (auth.memberships)
|
|
332
|
+
delete auth.memberships[name];
|
|
333
|
+
const resolver = this.memberships.get(name);
|
|
334
|
+
if (!resolver)
|
|
335
|
+
throw new Error(`Auth membership "${name}" is not configured.`);
|
|
336
|
+
if (!resolver.matchesEnvironment(resource, auth.session.environment))
|
|
337
|
+
return null;
|
|
338
|
+
const membership = yield resolver.find(auth.user, resource);
|
|
339
|
+
if (!membership)
|
|
340
|
+
return null;
|
|
341
|
+
if (!auth.memberships)
|
|
342
|
+
auth.memberships = Object.create(null);
|
|
343
|
+
auth.memberships[name] = { resource, membership };
|
|
344
|
+
return membership;
|
|
345
|
+
});
|
|
346
|
+
}
|
|
218
347
|
resolveCookies(cookies) {
|
|
219
348
|
return __awaiter(this, void 0, void 0, function* () {
|
|
220
349
|
const req = Object.assign(new Request_1.Request(), { method: "GET", headers: { cookie: `${this.cookies.prefix}access=${encodeURIComponent(cookies[`${this.cookies.prefix}access`] || "")}` } });
|
|
@@ -261,10 +390,29 @@ class AuthScope extends WebAuth_1.WebAuth {
|
|
|
261
390
|
});
|
|
262
391
|
}
|
|
263
392
|
handle(operation, req, res, loginResponse) {
|
|
264
|
-
var _a, _b;
|
|
393
|
+
var _a, _b, _c;
|
|
265
394
|
return __awaiter(this, void 0, void 0, function* () {
|
|
266
395
|
res.header("Cache-Control", "no-store");
|
|
267
396
|
try {
|
|
397
|
+
if (this.modelOptions.transport === "bearer") {
|
|
398
|
+
if (operation === "csrf")
|
|
399
|
+
return res.status(405).send({ status: "error", code: "unsupported_operation" });
|
|
400
|
+
if (req.method !== "POST")
|
|
401
|
+
throw new WebAuth_1.WebAuthError(403, "Authentication requires POST.");
|
|
402
|
+
if (operation === "logout") {
|
|
403
|
+
yield this.logoutBearer(req);
|
|
404
|
+
return res.send({ status: "success" });
|
|
405
|
+
}
|
|
406
|
+
if (operation === "refresh") {
|
|
407
|
+
const token = (_a = req.body) === null || _a === void 0 ? void 0 : _a.refresh_token;
|
|
408
|
+
const pair = typeof token === "string" ? yield this.sessions.refresh(token) : null;
|
|
409
|
+
if (!pair)
|
|
410
|
+
throw new AuthLoginError("unauthorized", 401, this.message("unauthorized"));
|
|
411
|
+
return res.send(Object.assign({ status: "success" }, pair));
|
|
412
|
+
}
|
|
413
|
+
const pair = yield this.establishBearerSession(req, yield this.verifyCredentials(req));
|
|
414
|
+
return res.send(Object.assign(Object.assign(Object.assign({}, (loginResponse ? yield loginResponse(req, pair.session) : {})), { status: "success" }), pair));
|
|
415
|
+
}
|
|
268
416
|
if (operation === "csrf")
|
|
269
417
|
return res.send({ csrf: this.bootstrap(req, res) });
|
|
270
418
|
if (operation === "logout") {
|
|
@@ -284,7 +432,7 @@ class AuthScope extends WebAuth_1.WebAuth {
|
|
|
284
432
|
if (!(error instanceof WebAuth_1.WebAuthError))
|
|
285
433
|
console.error(error);
|
|
286
434
|
return res.status(error instanceof WebAuth_1.WebAuthError ? error.status : 500).send({ status: "error", code,
|
|
287
|
-
message: error instanceof AuthHookError ? (
|
|
435
|
+
message: error instanceof AuthHookError ? (_c = (_b = this.modelOptions.messages) === null || _b === void 0 ? void 0 : _b[code]) !== null && _c !== void 0 ? _c : error.message : this.message(code) });
|
|
288
436
|
}
|
|
289
437
|
});
|
|
290
438
|
}
|
|
@@ -24,12 +24,20 @@ export type SessionTokenPair = {
|
|
|
24
24
|
};
|
|
25
25
|
export type CreatedAuthSession = SessionTokenPair & {
|
|
26
26
|
csrf_token: string;
|
|
27
|
+
reused?: boolean;
|
|
27
28
|
};
|
|
28
29
|
export type SessionAuthOptions = {
|
|
29
30
|
issuer: string;
|
|
30
31
|
audience: string;
|
|
31
32
|
scope?: string;
|
|
32
33
|
environment?: string;
|
|
34
|
+
/** Allow server-selected environments per session instead of one fixed environment. */
|
|
35
|
+
allowDynamicEnvironment?: boolean;
|
|
36
|
+
/** Reuse one child per parent, subject and environment. Requires a strong server-held secret. */
|
|
37
|
+
parentSessions?: {
|
|
38
|
+
mode: "new" | "reuse";
|
|
39
|
+
secret?: string;
|
|
40
|
+
};
|
|
33
41
|
connection?: string;
|
|
34
42
|
collection?: string;
|
|
35
43
|
accessTokenSeconds?: number;
|
|
@@ -58,7 +66,10 @@ export declare class SessionAuth {
|
|
|
58
66
|
private liveFilter;
|
|
59
67
|
private allowed;
|
|
60
68
|
private pair;
|
|
61
|
-
|
|
69
|
+
private credentials;
|
|
70
|
+
private reuse;
|
|
71
|
+
private linkedRecord;
|
|
72
|
+
create(subject: string, parent?: AuthSession["parent"], environment?: string, parentExpiresAt?: number): Promise<CreatedAuthSession>;
|
|
62
73
|
authenticate(token: string): Promise<AuthSession | null>;
|
|
63
74
|
/** Trusted-server introspection; never expose this method as an unauthenticated endpoint. */
|
|
64
75
|
inspect(sessionId: string): Promise<AuthSession | null>;
|
package/dist/Auth/SessionAuth.js
CHANGED
|
@@ -10,6 +10,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
10
10
|
};
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.SessionAuth = void 0;
|
|
13
|
+
const crypto_1 = require("crypto");
|
|
13
14
|
const DBConnection_1 = require("../Database/DBConnection");
|
|
14
15
|
const AuthSecurity_1 = require("./AuthSecurity");
|
|
15
16
|
function publicSession(record) {
|
|
@@ -21,13 +22,16 @@ function tokenSessionId(token) {
|
|
|
21
22
|
/** Revocable, hashed, independently addressable device sessions. Legacy UserAuth is unaffected. */
|
|
22
23
|
class SessionAuth {
|
|
23
24
|
constructor(options) {
|
|
24
|
-
var _a;
|
|
25
|
+
var _a, _b;
|
|
25
26
|
this.options = options;
|
|
26
|
-
this.scope = { issuer: (0, AuthSecurity_1.authText)(options.issuer, "issuer"), audience: (0, AuthSecurity_1.authText)(options.audience, "audience"), scope: (0, AuthSecurity_1.authText)((_a = options.scope) !== null && _a !== void 0 ? _a : "main", "scope"),
|
|
27
|
-
environment: options.environment === undefined ? null : (0, AuthSecurity_1.authText)(options.environment, "environment") };
|
|
27
|
+
this.scope = Object.assign({ issuer: (0, AuthSecurity_1.authText)(options.issuer, "issuer"), audience: (0, AuthSecurity_1.authText)(options.audience, "audience"), scope: (0, AuthSecurity_1.authText)((_a = options.scope) !== null && _a !== void 0 ? _a : "main", "scope") }, (options.allowDynamicEnvironment && options.environment === undefined ? {}
|
|
28
|
+
: { environment: options.environment === undefined ? null : (0, AuthSecurity_1.authText)(options.environment, "environment") }));
|
|
28
29
|
this.accessSeconds = (0, AuthSecurity_1.authLifetime)(options.accessTokenSeconds, 15 * 60);
|
|
29
30
|
this.sessionSeconds = (0, AuthSecurity_1.authLifetime)(options.sessionSeconds, 30 * 86400);
|
|
30
31
|
this.idleSeconds = options.idleTimeoutSeconds === undefined || options.idleTimeoutSeconds === false ? false : (0, AuthSecurity_1.authLifetime)(options.idleTimeoutSeconds, 7 * 86400);
|
|
32
|
+
if (((_b = options.parentSessions) === null || _b === void 0 ? void 0 : _b.mode) === "reuse" && (typeof options.parentSessions.secret !== "string" || options.parentSessions.secret.length < 32)) {
|
|
33
|
+
throw new Error("Parent session reuse requires a strong server secret.");
|
|
34
|
+
}
|
|
31
35
|
if (typeof options.isSessionAllowed !== "function")
|
|
32
36
|
throw new Error("SessionAuth requires isSessionAllowed.");
|
|
33
37
|
}
|
|
@@ -66,18 +70,106 @@ class SessionAuth {
|
|
|
66
70
|
return { auth_token: access, refresh_token: refresh, auth_expires_at: record.accessExpiresAt,
|
|
67
71
|
refresh_expires_at: Math.min(record.expiresAt, record.createdAt + this.sessionSeconds * 1000), session: publicSession(record) };
|
|
68
72
|
}
|
|
69
|
-
|
|
73
|
+
credentials(record) {
|
|
74
|
+
var _a, _b, _c;
|
|
75
|
+
if (!record.credentialState || !((_a = this.options.parentSessions) === null || _a === void 0 ? void 0 : _a.secret))
|
|
76
|
+
throw new Error("Linked session credentials are unavailable.");
|
|
77
|
+
const context = JSON.stringify([String(record._id), String(record._subject), record.issuer, record.audience,
|
|
78
|
+
record.scope, record.environment, (_b = record.parent) === null || _b === void 0 ? void 0 : _b.issuer, (_c = record.parent) === null || _c === void 0 ? void 0 : _c.sessionId]);
|
|
79
|
+
const derive = (kind) => (0, crypto_1.createHmac)("sha256", this.options.parentSessions.secret)
|
|
80
|
+
.update(JSON.stringify(["webframez-parent-session-v1", context, kind, record.credentialState[kind]])).digest("base64url");
|
|
81
|
+
return { access: `${record._id}.${derive("access")}`, refresh: `${record._id}.${derive("refresh")}`, csrf: derive("csrf") };
|
|
82
|
+
}
|
|
83
|
+
reuse(record) {
|
|
84
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
85
|
+
if (!(yield this.allowed(record)))
|
|
86
|
+
throw new Error("Session is not allowed.");
|
|
87
|
+
const credentials = this.credentials(record);
|
|
88
|
+
if ((0, AuthSecurity_1.hashAuthToken)(credentials.access) !== record.accessHash || (0, AuthSecurity_1.hashAuthToken)(credentials.refresh) !== record.refreshHash
|
|
89
|
+
|| (0, AuthSecurity_1.hashAuthToken)(credentials.csrf) !== record.csrfHash)
|
|
90
|
+
throw new Error("Linked session credentials have changed.");
|
|
91
|
+
if (record.accessExpiresAt <= Date.now()) {
|
|
92
|
+
yield this.renewAccess(credentials.refresh);
|
|
93
|
+
const current = yield (yield this.rows()).findOne(Object.assign({ _id: record._id }, this.scope));
|
|
94
|
+
if (!current || current.accessExpiresAt <= Date.now())
|
|
95
|
+
throw new Error("Linked session access could not be renewed.");
|
|
96
|
+
return this.reuse(current);
|
|
97
|
+
}
|
|
98
|
+
const session = publicSession(record);
|
|
99
|
+
yield this.touch(session);
|
|
100
|
+
return Object.assign(Object.assign({}, this.pair(record, credentials.access, credentials.refresh)), { session, csrf_token: credentials.csrf, reused: true });
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
linkedRecord(record) {
|
|
104
|
+
var _a;
|
|
105
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
106
|
+
const db = yield (this.options.database ? this.options.database() : DBConnection_1.DBConnection.getDocumentStore(this.options.connection));
|
|
107
|
+
const links = db.collection(`${this.options.collection || "auth_sessions"}_links`);
|
|
108
|
+
const key = (0, AuthSecurity_1.hashAuthToken)(JSON.stringify([record.issuer, record.audience, record.scope, String(record._subject),
|
|
109
|
+
record.environment, record.parent.issuer, record.parent.sessionId]));
|
|
110
|
+
let link;
|
|
111
|
+
try {
|
|
112
|
+
link = yield links.findOneAndUpdate({ _id: key }, { $setOnInsert: Object.assign(Object.assign({}, this.scope), { environment: record.environment, sessionId: record._id, purgeAt: new Date(record.expiresAt) }) }, { upsert: true, returnDocument: "after" });
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (error.code !== 11000)
|
|
116
|
+
throw error;
|
|
117
|
+
link = yield links.findOne({ _id: key });
|
|
118
|
+
}
|
|
119
|
+
if (!link)
|
|
120
|
+
throw new Error("Linked session reference is unavailable.");
|
|
121
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
122
|
+
const candidate = Object.assign(Object.assign({}, record), { _id: link.sessionId, accessExpiresAt: Math.min(record.accessExpiresAt, record.expiresAt) });
|
|
123
|
+
const credentials = this.credentials(candidate);
|
|
124
|
+
Object.assign(candidate, { accessHash: (0, AuthSecurity_1.hashAuthToken)(credentials.access), refreshHash: (0, AuthSecurity_1.hashAuthToken)(credentials.refresh), csrfHash: (0, AuthSecurity_1.hashAuthToken)(credentials.csrf) });
|
|
125
|
+
let stored;
|
|
126
|
+
try {
|
|
127
|
+
stored = yield (yield this.rows()).findOneAndUpdate({ _id: candidate._id }, { $setOnInsert: candidate }, { upsert: true, returnDocument: "after" });
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (error.code !== 11000)
|
|
131
|
+
throw error;
|
|
132
|
+
stored = yield (yield this.rows()).findOne({ _id: candidate._id });
|
|
133
|
+
}
|
|
134
|
+
if (!stored)
|
|
135
|
+
throw new Error("Linked session is unavailable.");
|
|
136
|
+
// A denied parent or membership must fail closed, rather than create another session.
|
|
137
|
+
if (stored.revokedAt === null && stored.expiresAt > Date.now()
|
|
138
|
+
&& stored.createdAt + this.sessionSeconds * 1000 > Date.now()
|
|
139
|
+
&& (this.idleSeconds === false || ((_a = stored.lastActiveAt) !== null && _a !== void 0 ? _a : stored.createdAt) + this.idleSeconds * 1000 > Date.now()))
|
|
140
|
+
return stored;
|
|
141
|
+
link = (yield links.findOneAndUpdate({ _id: key, sessionId: stored._id }, { $set: { sessionId: this.ids().create(), purgeAt: new Date(record.expiresAt) } }, { returnDocument: "after" }))
|
|
142
|
+
|| (yield links.findOne({ _id: key }));
|
|
143
|
+
}
|
|
144
|
+
throw new Error("Linked session could not be established.");
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
create(subject, parent = null, environment, parentExpiresAt) {
|
|
148
|
+
var _a, _b, _c;
|
|
70
149
|
return __awaiter(this, void 0, void 0, function* () {
|
|
71
150
|
(0, AuthSecurity_1.authText)(subject, "subject");
|
|
151
|
+
if (environment !== undefined && (!this.options.allowDynamicEnvironment || this.options.environment !== undefined)) {
|
|
152
|
+
throw new Error("Dynamic session environments are not enabled.");
|
|
153
|
+
}
|
|
72
154
|
if (parent) {
|
|
73
155
|
(0, AuthSecurity_1.authText)(parent.issuer, "parent issuer");
|
|
74
156
|
(0, AuthSecurity_1.authText)(parent.sessionId, "parent session");
|
|
75
157
|
}
|
|
158
|
+
if (parentExpiresAt !== undefined && (!parent || !Number.isFinite(parentExpiresAt) || parentExpiresAt <= Date.now())) {
|
|
159
|
+
throw new Error("Invalid parent session expiry.");
|
|
160
|
+
}
|
|
76
161
|
const now = Date.now(), id = this.ids().create();
|
|
77
162
|
const access = `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`, refresh = `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`, csrf = (0, AuthSecurity_1.randomAuthToken)();
|
|
78
|
-
const record = Object.assign(Object.assign({ _id: id, _subject: this.ids().create(subject) }, this.scope), { parent: parent ? Object.assign({}, parent) : null, createdAt: now, lastActiveAt: now, expiresAt: now + this.sessionSeconds * 1000, accessExpiresAt: now + Math.min(this.accessSeconds, this.sessionSeconds) * 1000, accessHash: (0, AuthSecurity_1.hashAuthToken)(access), refreshHash: (0, AuthSecurity_1.hashAuthToken)(refresh), csrfHash: (0, AuthSecurity_1.hashAuthToken)(csrf), usedRefreshHashes: [], revokedAt: null });
|
|
163
|
+
const record = Object.assign(Object.assign({ _id: id, _subject: this.ids().create(subject) }, this.scope), { environment: environment === undefined ? (_a = this.scope.environment) !== null && _a !== void 0 ? _a : null : (0, AuthSecurity_1.authText)(environment, "environment"), parent: parent ? Object.assign({}, parent) : null, createdAt: now, lastActiveAt: now, expiresAt: Math.min(now + this.sessionSeconds * 1000, parentExpiresAt !== null && parentExpiresAt !== void 0 ? parentExpiresAt : Infinity), accessExpiresAt: now + Math.min(this.accessSeconds, this.sessionSeconds) * 1000, accessHash: (0, AuthSecurity_1.hashAuthToken)(access), refreshHash: (0, AuthSecurity_1.hashAuthToken)(refresh), csrfHash: (0, AuthSecurity_1.hashAuthToken)(csrf), usedRefreshHashes: [], revokedAt: null });
|
|
79
164
|
if (!(yield this.allowed(record)))
|
|
80
165
|
throw new Error("Session is not allowed.");
|
|
166
|
+
if (parent && ((_b = this.options.parentSessions) === null || _b === void 0 ? void 0 : _b.mode) === "reuse") {
|
|
167
|
+
record.credentialState = { access: (0, AuthSecurity_1.randomAuthToken)(), refresh: (0, AuthSecurity_1.randomAuthToken)(), csrf: (0, AuthSecurity_1.randomAuthToken)() };
|
|
168
|
+
const stored = yield this.linkedRecord(record);
|
|
169
|
+
const pair = yield this.reuse(stored);
|
|
170
|
+
return Object.assign(Object.assign({}, pair), { reused: ((_c = stored.credentialState) === null || _c === void 0 ? void 0 : _c.access) !== record.credentialState.access });
|
|
171
|
+
}
|
|
172
|
+
record.accessExpiresAt = Math.min(record.accessExpiresAt, record.expiresAt);
|
|
81
173
|
yield (yield this.rows()).insertOne(record);
|
|
82
174
|
return Object.assign(Object.assign({}, this.pair(record, access, refresh)), { csrf_token: csrf });
|
|
83
175
|
});
|
|
@@ -113,6 +205,7 @@ class SessionAuth {
|
|
|
113
205
|
}
|
|
114
206
|
/** One winner under concurrency. A previously consumed refresh token revokes its device session. */
|
|
115
207
|
refresh(token) {
|
|
208
|
+
var _a, _b;
|
|
116
209
|
return __awaiter(this, void 0, void 0, function* () {
|
|
117
210
|
const id = tokenSessionId(token);
|
|
118
211
|
if (!id)
|
|
@@ -131,11 +224,10 @@ class SessionAuth {
|
|
|
131
224
|
yield this.revoke(id);
|
|
132
225
|
return null;
|
|
133
226
|
}
|
|
134
|
-
const
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
usedRefreshHashes: [...row.usedRefreshHashes, hash] } }, { returnDocument: "after" });
|
|
227
|
+
const credentialState = row.credentialState ? Object.assign(Object.assign({}, row.credentialState), { access: (0, AuthSecurity_1.randomAuthToken)(), refresh: (0, AuthSecurity_1.randomAuthToken)() }) : undefined;
|
|
228
|
+
const credentials = credentialState ? this.credentials(Object.assign(Object.assign({}, row), { credentialState })) : null;
|
|
229
|
+
const access = (_a = credentials === null || credentials === void 0 ? void 0 : credentials.access) !== null && _a !== void 0 ? _a : `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`, refresh = (_b = credentials === null || credentials === void 0 ? void 0 : credentials.refresh) !== null && _b !== void 0 ? _b : `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`;
|
|
230
|
+
const next = yield rows.findOneAndUpdate(Object.assign(Object.assign(Object.assign({ _id: this.sessionId(id) }, this.scope), { refreshHash: hash }), this.liveFilter()), { $set: Object.assign(Object.assign({}, (credentialState ? { credentialState } : {})), { accessHash: (0, AuthSecurity_1.hashAuthToken)(access), refreshHash: (0, AuthSecurity_1.hashAuthToken)(refresh), accessExpiresAt: Math.min(Date.now() + this.accessSeconds * 1000, row.expiresAt, row.createdAt + this.sessionSeconds * 1000), lastActiveAt: Date.now(), usedRefreshHashes: [...row.usedRefreshHashes, hash] }) }, { returnDocument: "after" });
|
|
139
231
|
if (!next) {
|
|
140
232
|
// Also detect simultaneous replay that lost the compare-and-swap.
|
|
141
233
|
yield rows.updateOne(Object.assign(Object.assign({ _id: this.sessionId(id) }, this.scope), { usedRefreshHashes: hash, revokedAt: null }), { $set: { revokedAt: Date.now() } });
|
|
@@ -164,10 +256,9 @@ class SessionAuth {
|
|
|
164
256
|
const row = yield rows.findOne(filter);
|
|
165
257
|
if (!row || !(yield this.allowed(row)))
|
|
166
258
|
return null;
|
|
167
|
-
const
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
lastActiveAt: now } }, { returnDocument: "after" });
|
|
259
|
+
const credentialState = row.credentialState ? Object.assign(Object.assign({}, row.credentialState), { access: (0, AuthSecurity_1.randomAuthToken)() }) : undefined;
|
|
260
|
+
const access = credentialState ? this.credentials(Object.assign(Object.assign({}, row), { credentialState })).access : `${id}.${(0, AuthSecurity_1.randomAuthToken)()}`, now = Date.now();
|
|
261
|
+
const next = yield rows.findOneAndUpdate(filter, { $set: Object.assign(Object.assign({}, (credentialState ? { credentialState } : {})), { accessHash: (0, AuthSecurity_1.hashAuthToken)(access), accessExpiresAt: Math.min(now + this.accessSeconds * 1000, row.expiresAt, row.createdAt + this.sessionSeconds * 1000), lastActiveAt: now }) }, { returnDocument: "after" });
|
|
171
262
|
const session = next ? yield this.allowed(next) : null;
|
|
172
263
|
return next && session ? Object.assign(Object.assign({}, this.pair(next, access, token)), { session }) : null;
|
|
173
264
|
});
|
|
@@ -118,7 +118,7 @@ class SsoAuthority {
|
|
|
118
118
|
yield (yield this.rows()).insertOne({ _id: (0, AuthSecurity_1.hashAuthToken)(code), issuer: this.issuer,
|
|
119
119
|
audience: client.id, subject: session.subject, environment: request.environment,
|
|
120
120
|
authoritySessionId: session.id, redirectUri: request.redirectUri, challenge: request.codeChallenge,
|
|
121
|
-
expiresAt: Math.min(Date.now() + 60000, session.expiresAt), consumedAt: null });
|
|
121
|
+
expiresAt: Math.min(Date.now() + 60000, session.expiresAt), purgeAt: new Date(Date.now() + 60000), consumedAt: null });
|
|
122
122
|
const redirect = new URL(request.redirectUri);
|
|
123
123
|
redirect.searchParams.set("code", code);
|
|
124
124
|
redirect.searchParams.set("state", request.state);
|
|
@@ -160,6 +160,7 @@ class SsoAuthority {
|
|
|
160
160
|
const session = client ? yield this.options.sessions.inspect(sessionId) : null;
|
|
161
161
|
if (!client || !session || !(yield this.options.authorize(session, client, environment)))
|
|
162
162
|
return null;
|
|
163
|
+
yield this.options.sessions.touch(session);
|
|
163
164
|
return { issuer: this.issuer, audience: client.id, subject: session.subject, environment,
|
|
164
165
|
authoritySessionId: session.id, expiresAt: session.expiresAt };
|
|
165
166
|
});
|
|
@@ -236,7 +237,7 @@ class SsoClient {
|
|
|
236
237
|
(0, AuthSecurity_1.authText)(environment, "environment");
|
|
237
238
|
const state = (0, AuthSecurity_1.randomAuthToken)(), binding = (0, AuthSecurity_1.randomAuthToken)(), verifier = (0, AuthSecurity_1.randomAuthToken)();
|
|
238
239
|
yield (yield this.rows()).insertOne({ _id: (0, AuthSecurity_1.hashAuthToken)(state), issuer: this.issuer, clientId: this.options.clientId,
|
|
239
|
-
bindingHash: (0, AuthSecurity_1.hashAuthToken)(binding), verifier, environment, expiresAt: Date.now() + 300000, consumedAt: null });
|
|
240
|
+
bindingHash: (0, AuthSecurity_1.hashAuthToken)(binding), verifier, environment, expiresAt: Date.now() + 300000, purgeAt: new Date(Date.now() + 300000), consumedAt: null });
|
|
240
241
|
this.cookies.write(res, "sso", binding, 300);
|
|
241
242
|
res.header("Referrer-Policy", "no-referrer");
|
|
242
243
|
const url = new URL(this.authorization.href);
|
package/dist/Auth/index.d.ts
CHANGED
package/dist/Auth/index.js
CHANGED
|
@@ -23,3 +23,5 @@ __exportStar(require("./SingleSignOn"), exports);
|
|
|
23
23
|
__exportStar(require("./ModelAuth"), exports);
|
|
24
24
|
__exportStar(require("./Auth"), exports);
|
|
25
25
|
__exportStar(require("./PasswordReset"), exports);
|
|
26
|
+
__exportStar(require("./AuthMembership"), exports);
|
|
27
|
+
__exportStar(require("./AuthHandoff"), exports);
|
package/docs/authentication.md
CHANGED
|
@@ -495,3 +495,92 @@ when serializing entire models. Auth snapshots always exclude these fields.
|
|
|
495
495
|
user automatically. Existing application plaintext reset links are intentionally
|
|
496
496
|
not accepted; users must request a new link after migration. Reset pages should
|
|
497
497
|
use `Referrer-Policy: no-referrer` and avoid response caching.
|
|
498
|
+
|
|
499
|
+
## Declarative memberships
|
|
500
|
+
|
|
501
|
+
Authentication identifies the user; memberships authorize access to a server-loaded resource.
|
|
502
|
+
Configure named membership models on a scope:
|
|
503
|
+
|
|
504
|
+
```ts
|
|
505
|
+
const scope = Auth.registerScope("manager", {
|
|
506
|
+
model: User,
|
|
507
|
+
memberships: {
|
|
508
|
+
environment: {
|
|
509
|
+
model: EnvironmentUser,
|
|
510
|
+
bindings: [{
|
|
511
|
+
user: { foreignKey: "_user", localKeys: ["_id"] },
|
|
512
|
+
resource: { foreignKey: "_env", localKeys: ["_id"] },
|
|
513
|
+
}],
|
|
514
|
+
conditions: [{ field: "is_deleted", operator: "!=", value: true }],
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
await scope.resolve(req, res);
|
|
520
|
+
const membership = await scope.authorizeMembership(req, "environment", environment);
|
|
521
|
+
if (!membership) return res.status(403).send({ code: "forbidden" });
|
|
522
|
+
// Actual server-side Models; excluded from browser snapshots:
|
|
523
|
+
req.auth.memberships.environment.membership;
|
|
524
|
+
req.auth.memberships.environment.resource;
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
Every authorization performs a fresh lookup. Missing IDs and unvalidated request contexts
|
|
528
|
+
fail closed. `userConditions` and `resourceConditions` optionally constrain model status.
|
|
529
|
+
`sessionEnvironmentKey` optionally binds resource authorization to a federated session environment.
|
|
530
|
+
Multiple `bindings` support legacy schemas; `localKeys` can accept local and synchronized
|
|
531
|
+
resource references. Set `user.firstAvailable: true` to prefer the first available user
|
|
532
|
+
reference rather than matching all aliases. `findMembership(name, user, resource)` is the
|
|
533
|
+
trusted-server adapter for integrations that already authenticate through another transport.
|
|
534
|
+
It does not authenticate the supplied user or create a request auth context.
|
|
535
|
+
|
|
536
|
+
## Bearer scopes for separate API origins
|
|
537
|
+
|
|
538
|
+
Cookie scopes remain the default. An explicitly configured `transport: "bearer"` scope
|
|
539
|
+
uses opaque, hashed Core access/refresh credentials through Authorization instead of cookies.
|
|
540
|
+
`Route.auth()` returns `auth_token`, `refresh_token` and their expiry timestamps. Refresh
|
|
541
|
+
requires POST with `refresh_token`; logout requires POST with the access Authorization header.
|
|
542
|
+
Cookie/query credentials are never accepted by `scope.resolve()` in bearer mode. Cookie CSRF
|
|
543
|
+
behavior remains unchanged; bearer endpoints do not use ambient browser credentials.
|
|
544
|
+
|
|
545
|
+
Use the lightweight `@webtypen/webframez-core/bearer-auth-client` entry point's
|
|
546
|
+
`createBearerAuthFetch()` with application-owned `configuration`, `load`, `save` and `clear`
|
|
547
|
+
callbacks. Configure only trusted API targets and store credentials separately per instance.
|
|
548
|
+
It renews expired access credentials, shares refresh requests within a client, uses Web Locks
|
|
549
|
+
across browser tabs when available, and retries explicit unauthorized responses once.
|
|
550
|
+
It clears credentials after an ambiguous network failure instead of replaying a rotating secret.
|
|
551
|
+
Without Web Locks, cross-tab coordination must be provided by the host application.
|
|
552
|
+
|
|
553
|
+
`establishBearerSession(req, subject, parent?, environment?)` is a trusted server entry point
|
|
554
|
+
following verified SSO; never expose a caller-supplied subject as public login input. Lifecycle
|
|
555
|
+
hooks apply. Set `session.allowDynamicEnvironment: true` to bind individual sessions to a
|
|
556
|
+
server-selected environment; fixed/default session environment behavior stays unchanged.
|
|
557
|
+
`isSessionAllowed(session, user)` can validate a parent SSO session and fails closed on denial.
|
|
558
|
+
|
|
559
|
+
`AuthHandoff` delivers a short-lived one-use code to a separate frontend after the API callback.
|
|
560
|
+
Its payload is encrypted with AES-GCM, its code is hashed, and redemption is atomic. Neither
|
|
561
|
+
access nor refresh credentials need appear in navigation URLs. Configure a strong server secret
|
|
562
|
+
and an instance-specific audience. Create TTL indexes on `auth_handoffs.purgeAt`,
|
|
563
|
+
`auth_sso_transactions.purgeAt` and `auth_sso_codes.purgeAt`, or schedule their cleanup methods.
|
|
564
|
+
SSO introspection records successful central session activity, respecting its absolute expiry.
|
|
565
|
+
|
|
566
|
+
### Linked parent session reuse
|
|
567
|
+
|
|
568
|
+
`session.parentSessions: { mode: "reuse", secret: serverSecret }` opts into one local
|
|
569
|
+
session per exact parent issuer/session, user, scope, audience and environment. The default
|
|
570
|
+
`mode: "new"` retains independent session creation. Logins without a parent remain independent.
|
|
571
|
+
The secret must be at least 32 characters and stay stable across all processes of an instance.
|
|
572
|
+
Core stores hashes and independent random credential nonces, never plaintext tokens; a
|
|
573
|
+
domain-separated HMAC derives the current credentials only on the trusted server. Rotation
|
|
574
|
+
updates both hashes and nonces atomically. Reopening returns the current pair without rotating
|
|
575
|
+
refresh or resetting the login timestamp. `auth_sessions_links` uses its primary key for atomic
|
|
576
|
+
creation; add an optional TTL index on `purgeAt` for housekeeping. Session `_id` and `_subject`
|
|
577
|
+
continue to use driver-native IDs. A revoked/expired local child creates a new generation.
|
|
578
|
+
Changing the secret requires revoking existing linked sessions before reauthentication.
|
|
579
|
+
|
|
580
|
+
Trusted callers can pass parent expiry as the fifth argument to
|
|
581
|
+
`Auth.scope().establishBearerSession(request, subject, parent, environment, parentExpiresAt)`
|
|
582
|
+
or fourth argument to `sessions.create`. It caps child expiry. Parent revocation must still be
|
|
583
|
+
checked by `isSessionAllowed` on every authentication/refresh. To enforce logout on the next
|
|
584
|
+
request, do not cache positive introspection results. This requires one authority round trip
|
|
585
|
+
per check and fails closed when the authority is unavailable. No browser push is implied;
|
|
586
|
+
clients detect logout when making their next authenticated request.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webtypen/webframez-core",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.80",
|
|
4
4
|
"description": "The core package of the typescript based webframez (web-framework) by webtypen.de",
|
|
5
5
|
"homepage": "https://webtypen.de/",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
"routing.js",
|
|
31
31
|
"routing.d.ts",
|
|
32
32
|
"auth-client.js",
|
|
33
|
-
"auth-client.d.ts"
|
|
33
|
+
"auth-client.d.ts",
|
|
34
|
+
"bearer-auth-client.js",
|
|
35
|
+
"bearer-auth-client.d.ts"
|
|
34
36
|
],
|
|
35
37
|
"scripts": {
|
|
36
38
|
"build": "tsc",
|