@hotfusion/modeller 0.0.13 → 0.0.16
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 +99 -0
- package/dist/adapters/cipher.js +51 -0
- package/dist/adapters/cipher.js.map +1 -0
- package/dist/connector.js +81 -41
- package/dist/connector.js.map +1 -1
- package/dist/core.js +2 -48
- package/dist/core.js.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/model.js +27 -50
- package/dist/model.js.map +1 -1
- package/dist/oidc/adapter.js +177 -0
- package/dist/oidc/adapter.js.map +1 -0
- package/dist/oidc/adapters/cipher.js +51 -0
- package/dist/oidc/adapters/cipher.js.map +1 -0
- package/dist/oidc/client.js +66 -0
- package/dist/oidc/client.js.map +1 -0
- package/dist/oidc/code.js +37 -0
- package/dist/oidc/code.js.map +1 -0
- package/dist/oidc/default.config.js +200 -0
- package/dist/oidc/default.config.js.map +1 -0
- package/dist/oidc/federation.js +51 -0
- package/dist/oidc/federation.js.map +1 -0
- package/dist/oidc/grant.js +37 -0
- package/dist/oidc/grant.js.map +1 -0
- package/dist/oidc/interaction.js +36 -0
- package/dist/oidc/interaction.js.map +1 -0
- package/dist/oidc/oidc.config.js +79 -0
- package/dist/oidc/oidc.config.js.map +1 -0
- package/dist/oidc/schemas/client.schema.json +62 -0
- package/dist/oidc/schemas/code.schema.json +16 -0
- package/dist/oidc/schemas/grant.schema.json +13 -0
- package/dist/oidc/schemas/interaction.schema.json +26 -0
- package/dist/oidc/schemas/session.schema.json +14 -0
- package/dist/oidc/schemas/token.schema.json +16 -0
- package/dist/oidc/schemas/user.schema.json +44 -0
- package/dist/oidc/session.js +36 -0
- package/dist/oidc/session.js.map +1 -0
- package/dist/oidc/session.token.js +24 -0
- package/dist/oidc/session.token.js.map +1 -0
- package/dist/oidc/token.js +23 -0
- package/dist/oidc/token.js.map +1 -0
- package/dist/oidc/user.js +95 -0
- package/dist/oidc/user.js.map +1 -0
- package/dist/oidc/utils.js +154 -0
- package/dist/oidc/utils.js.map +1 -0
- package/dist/server.js +722 -113
- package/dist/server.js.map +1 -1
- package/dist/types/adapters/cipher.d.ts +12 -0
- package/dist/types/adapters/cipher.d.ts.map +1 -0
- package/dist/types/connector.d.ts +13 -1
- package/dist/types/connector.d.ts.map +1 -1
- package/dist/types/core.d.ts +2 -2
- package/dist/types/core.d.ts.map +1 -1
- package/dist/types/index.d.ts +4 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/model.d.ts +26 -2
- package/dist/types/model.d.ts.map +1 -1
- package/dist/types/oidc/adapter.d.ts +16 -0
- package/dist/types/oidc/adapter.d.ts.map +1 -0
- package/dist/types/oidc/adapters/cipher.d.ts +12 -0
- package/dist/types/oidc/adapters/cipher.d.ts.map +1 -0
- package/dist/types/oidc/client.d.ts +3 -0
- package/dist/types/oidc/client.d.ts.map +1 -0
- package/dist/types/oidc/code.d.ts +3 -0
- package/dist/types/oidc/code.d.ts.map +1 -0
- package/dist/types/oidc/default.config.d.ts +33 -0
- package/dist/types/oidc/default.config.d.ts.map +1 -0
- package/dist/types/oidc/federation.d.ts +3 -0
- package/dist/types/oidc/federation.d.ts.map +1 -0
- package/dist/types/oidc/grant.d.ts +3 -0
- package/dist/types/oidc/grant.d.ts.map +1 -0
- package/dist/types/oidc/interaction.d.ts +3 -0
- package/dist/types/oidc/interaction.d.ts.map +1 -0
- package/dist/types/oidc/oidc.config.d.ts +7 -0
- package/dist/types/oidc/oidc.config.d.ts.map +1 -0
- package/dist/types/oidc/session.d.ts +3 -0
- package/dist/types/oidc/session.d.ts.map +1 -0
- package/dist/types/oidc/session.token.d.ts +3 -0
- package/dist/types/oidc/session.token.d.ts.map +1 -0
- package/dist/types/oidc/token.d.ts +3 -0
- package/dist/types/oidc/token.d.ts.map +1 -0
- package/dist/types/oidc/user.d.ts +3 -0
- package/dist/types/oidc/user.d.ts.map +1 -0
- package/dist/types/oidc/utils.d.ts +56 -0
- package/dist/types/oidc/utils.d.ts.map +1 -0
- package/dist/types/server.d.ts +8 -3
- package/dist/types/server.d.ts.map +1 -1
- package/dist/types/types.d.ts +264 -0
- package/dist/types/utils/bundler.d.ts.map +1 -1
- package/dist/types/utils/display.d.ts +23 -0
- package/dist/types/utils/display.d.ts.map +1 -0
- package/dist/utils/_secret.key +1 -0
- package/dist/utils/bundler.js +48 -8
- package/dist/utils/bundler.js.map +1 -1
- package/dist/utils/display.js +207 -0
- package/dist/utils/display.js.map +1 -0
- package/package.json +28 -4
- package/docs/CORE.md +0 -191
- package/docs/ERRORS.md +0 -90
- package/docs/MODEL.md +0 -296
- package/docs/PATTERNS.md +0 -182
- package/docs/SERVER.md +0 -88
- package/docs/UTILITIES.md +0 -111
package/dist/server.js
CHANGED
|
@@ -1,4 +1,37 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
37
|
};
|
|
@@ -10,11 +43,24 @@ const koa_router_1 = __importDefault(require("koa-router"));
|
|
|
10
43
|
const koa_session_1 = __importDefault(require("koa-session"));
|
|
11
44
|
const bodyparser_1 = __importDefault(require("@koa/bodyparser"));
|
|
12
45
|
const cors_1 = __importDefault(require("@koa/cors"));
|
|
46
|
+
const koa_body_1 = require("koa-body");
|
|
13
47
|
const eventemitter3_1 = require("eventemitter3");
|
|
14
48
|
const jose_1 = require("jose");
|
|
49
|
+
const openidClient = __importStar(require("openid-client"));
|
|
50
|
+
const oidc_provider_1 = __importDefault(require("oidc-provider"));
|
|
15
51
|
const socket_io_1 = require("socket.io");
|
|
16
52
|
const keygen_js_1 = require("./utils/keygen.js");
|
|
17
53
|
const koa_static_1 = __importDefault(require("koa-static"));
|
|
54
|
+
const display_1 = require("./utils/display");
|
|
55
|
+
const bundler_1 = require("./utils/bundler");
|
|
56
|
+
const view_1 = require("./view");
|
|
57
|
+
const core_1 = require("./core");
|
|
58
|
+
const adapter_1 = require("./oidc/adapter");
|
|
59
|
+
const client_1 = require("./oidc/client");
|
|
60
|
+
const user_1 = require("./oidc/user");
|
|
61
|
+
const federation_1 = require("./oidc/federation");
|
|
62
|
+
const token_1 = require("./oidc/token");
|
|
63
|
+
const utils_1 = require("./oidc/utils");
|
|
18
64
|
class Utils {
|
|
19
65
|
static trimPath(path) {
|
|
20
66
|
return '/' + path.split('/').filter((x) => x).join('/');
|
|
@@ -25,12 +71,14 @@ class Server extends eventemitter3_1.EventEmitter {
|
|
|
25
71
|
app;
|
|
26
72
|
router;
|
|
27
73
|
routes = [];
|
|
28
|
-
providers = [];
|
|
29
74
|
config;
|
|
30
75
|
io;
|
|
31
76
|
registeredModels = [];
|
|
32
77
|
extensions = [];
|
|
33
78
|
extensionConfigs = new Map();
|
|
79
|
+
_oidcServers = [];
|
|
80
|
+
_oidcProviders = new Map();
|
|
81
|
+
_staticFolders = [];
|
|
34
82
|
API_OPERATIONS = ['insert', 'update', 'delete', 'get', 'find', 'list'];
|
|
35
83
|
OPERATION_METHODS = {
|
|
36
84
|
insert: 'POST',
|
|
@@ -43,60 +91,54 @@ class Server extends eventemitter3_1.EventEmitter {
|
|
|
43
91
|
constructor(port, config) {
|
|
44
92
|
super();
|
|
45
93
|
this.app = new koa_1.default();
|
|
94
|
+
this.app.proxy = true;
|
|
46
95
|
this.router = new koa_router_1.default();
|
|
47
|
-
this.app.use((0, cors_1.default)());
|
|
48
|
-
this.app.use((0, bodyparser_1.default)());
|
|
49
|
-
this.app.keys = [config?.secret || keygen_js_1.KeyGen.getSystemSecret()];
|
|
50
|
-
this.app.use((0, koa_session_1.default)({
|
|
51
|
-
key: config?.secret || keygen_js_1.KeyGen.getSystemSecret(),
|
|
52
|
-
maxAge: 86400000,
|
|
53
|
-
autoCommit: true,
|
|
54
|
-
overwrite: false,
|
|
55
|
-
httpOnly: true,
|
|
56
|
-
signed: true,
|
|
57
|
-
rolling: false,
|
|
58
|
-
renew: false,
|
|
59
|
-
secure: process.env.NODE_ENV === 'production',
|
|
60
|
-
}, this.app));
|
|
61
96
|
this.port = port;
|
|
62
97
|
this.config = config;
|
|
98
|
+
this.app.on('error', (err, ctx) => {
|
|
99
|
+
if (ctx?.res?.headersSent)
|
|
100
|
+
return;
|
|
101
|
+
display_1.display.error('server', err?.message ?? String(err));
|
|
102
|
+
});
|
|
103
|
+
process.on('unhandledRejection', (reason) => {
|
|
104
|
+
const message = reason instanceof Error
|
|
105
|
+
? reason.stack ?? reason.message
|
|
106
|
+
: typeof reason === 'string'
|
|
107
|
+
? reason
|
|
108
|
+
: JSON.stringify(reason ?? 'Unknown rejection');
|
|
109
|
+
display_1.display.error('server', message);
|
|
110
|
+
});
|
|
63
111
|
}
|
|
64
|
-
// ?? Register model ??????????????????????????????????????????????????????????
|
|
65
112
|
registerModel(id, scope, model) {
|
|
66
113
|
this.registeredModels.push({ id, scope, model });
|
|
67
114
|
return this;
|
|
68
115
|
}
|
|
69
|
-
// ?? Register extension ??????????????????????????????????????????????????????
|
|
70
116
|
registerExtension(extension) {
|
|
71
117
|
this.extensions.push(extension);
|
|
72
118
|
return this;
|
|
73
119
|
}
|
|
74
|
-
|
|
120
|
+
registerOIDC(adapter) {
|
|
121
|
+
this._oidcAdapterClass = adapter;
|
|
122
|
+
return this;
|
|
123
|
+
}
|
|
75
124
|
async setupExtensions() {
|
|
76
|
-
console.log('[Server] Setting up extensions...');
|
|
77
125
|
for (const ext of this.extensions) {
|
|
78
|
-
console.log(`[Server] Setting up extension: ${ext.id}`);
|
|
79
126
|
const extConfig = this.extensionConfigs.get(ext.id) || {};
|
|
80
|
-
console.log(`[Server] Extension ${ext.id} config:`, extConfig);
|
|
81
127
|
await ext.setup(this, extConfig);
|
|
82
|
-
console.log(`[Server] Extension ${ext.id} setup complete`);
|
|
83
128
|
}
|
|
84
|
-
console.log('[Server] All extensions setup complete');
|
|
85
129
|
}
|
|
86
|
-
|
|
87
|
-
mountModelRoutes() {
|
|
130
|
+
async mountModelRoutes() {
|
|
88
131
|
for (const { id, scope, model } of this.registeredModels) {
|
|
89
|
-
this._mountModelRecursive(id, scope, model);
|
|
132
|
+
await this._mountModelRecursive(id, scope, model);
|
|
90
133
|
}
|
|
91
134
|
}
|
|
92
|
-
_mountModelRecursive(id, scope, model) {
|
|
135
|
+
async _mountModelRecursive(id, scope, model) {
|
|
93
136
|
this._mountModel(id, scope, model);
|
|
94
137
|
for (const ext of model.getExtensions?.() ?? []) {
|
|
95
|
-
this._mountModelRecursive(id, `${scope}/${ext.id}`, ext);
|
|
138
|
+
await this._mountModelRecursive(id, `${scope}/${ext.id}`, ext);
|
|
96
139
|
}
|
|
97
140
|
}
|
|
98
141
|
_mountModel(id, scope, model) {
|
|
99
|
-
// ?? CRUD operations ???????????????????????????????????????????????????
|
|
100
142
|
for (const operation of this.API_OPERATIONS) {
|
|
101
143
|
const routePath = `/${id}/${scope}/${operation}`;
|
|
102
144
|
const handler = async (ctx) => {
|
|
@@ -111,8 +153,7 @@ class Server extends eventemitter3_1.EventEmitter {
|
|
|
111
153
|
result = await model.update(key, data, { private: true });
|
|
112
154
|
}
|
|
113
155
|
else if (operation === 'delete') {
|
|
114
|
-
|
|
115
|
-
result = await model.delete(query);
|
|
156
|
+
result = await model.delete(body);
|
|
116
157
|
}
|
|
117
158
|
else if (operation === 'get') {
|
|
118
159
|
result = await model.get(body, { private: true });
|
|
@@ -146,12 +187,11 @@ class Server extends eventemitter3_1.EventEmitter {
|
|
|
146
187
|
else if (operation === 'list')
|
|
147
188
|
this.router.get(routePath, handler);
|
|
148
189
|
}
|
|
149
|
-
// ?? Custom methods ????????????????????????????????????????????????????
|
|
150
190
|
for (const method of model.getMethods?.() ?? []) {
|
|
151
191
|
this.router.post(`/${id}/${scope}/${method}`, async (ctx) => {
|
|
152
192
|
const { _id, ...rest } = ctx.request.body ?? {};
|
|
153
193
|
try {
|
|
154
|
-
const result = await model.call(method, { _id, ...rest });
|
|
194
|
+
const result = await model.call(method, { _id, ...rest }, ctx);
|
|
155
195
|
ctx.status = 200;
|
|
156
196
|
ctx.body = { ok: true, entry: result };
|
|
157
197
|
}
|
|
@@ -161,21 +201,13 @@ class Server extends eventemitter3_1.EventEmitter {
|
|
|
161
201
|
}
|
|
162
202
|
});
|
|
163
203
|
}
|
|
164
|
-
// ?? Upload methods (multipart HTTP) ???????????????????????????????????
|
|
165
204
|
for (const uploadId of model.getUploads?.() ?? []) {
|
|
166
205
|
this.upload(`/${id}/${scope}/${uploadId}`, async (ctx, file, fields) => {
|
|
167
|
-
|
|
168
|
-
return await model.callUpload(uploadId, file, fields);
|
|
169
|
-
}
|
|
170
|
-
catch (err) {
|
|
171
|
-
throw err;
|
|
172
|
-
}
|
|
206
|
+
return model.callUpload(uploadId, file, fields);
|
|
173
207
|
});
|
|
174
208
|
}
|
|
175
|
-
// ?? View routes ??????????????????????????????????????????????????????????????????????
|
|
176
209
|
for (const view of model.getViews?.() ?? []) {
|
|
177
210
|
const routePath = `/${id}/${scope}${view.path}`.replace(/\/$/, '') || '/';
|
|
178
|
-
console.log('[View] Mounting route:', routePath);
|
|
179
211
|
this.router.get(routePath, async (ctx) => {
|
|
180
212
|
try {
|
|
181
213
|
ctx.type = 'text/html';
|
|
@@ -186,8 +218,8 @@ class Server extends eventemitter3_1.EventEmitter {
|
|
|
186
218
|
ctx.body = { ok: false, error: err?.message ?? err };
|
|
187
219
|
}
|
|
188
220
|
});
|
|
221
|
+
display_1.display.addView(`http://localhost:${this.port}${routePath}`);
|
|
189
222
|
}
|
|
190
|
-
// ?? Static folders ????????????????????????????????????????????????????
|
|
191
223
|
for (const [urlPath, { path: folderPath }] of model.getFolders?.() ?? new Map()) {
|
|
192
224
|
const staticMiddleware = (0, koa_static_1.default)(folderPath);
|
|
193
225
|
this.app.use(async (ctx, next) => {
|
|
@@ -198,32 +230,529 @@ class Server extends eventemitter3_1.EventEmitter {
|
|
|
198
230
|
return next();
|
|
199
231
|
});
|
|
200
232
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
233
|
+
}
|
|
234
|
+
// ?? OIDC Setup ????????????????????????????????????????????????????????????
|
|
235
|
+
async _setupOIDC(oidcConfigRaw, mountPath) {
|
|
236
|
+
const secret = this.config?.secret || keygen_js_1.KeyGen.getSystemSecret();
|
|
237
|
+
const port = this.port;
|
|
238
|
+
const adapter = this._oidcAdapterClass ?? adapter_1.OIDCAdapter;
|
|
239
|
+
const baseUrl = process.env.BASE_URL ?? `http://localhost:${port}`;
|
|
240
|
+
const issuer = oidcConfigRaw?.issuer ?? baseUrl;
|
|
241
|
+
const scopes = oidcConfigRaw?.scopes ?? ['openid', 'email', 'profile', 'phone', 'address', 'offline_access'];
|
|
242
|
+
const claims = oidcConfigRaw?.claims ?? {
|
|
243
|
+
openid: ['sub'],
|
|
244
|
+
email: ['email', 'email_verified'],
|
|
245
|
+
phone: ['phone_number', 'phone_number_verified'],
|
|
246
|
+
profile: [
|
|
247
|
+
'name', 'family_name', 'given_name', 'middle_name',
|
|
248
|
+
'nickname', 'preferred_username', 'picture', 'website',
|
|
249
|
+
'gender', 'birthdate', 'zoneinfo', 'locale', 'updated_at',
|
|
250
|
+
],
|
|
251
|
+
address: ['address'],
|
|
252
|
+
};
|
|
253
|
+
const ttl = {
|
|
254
|
+
AccessToken: 60 * 60,
|
|
255
|
+
AuthorizationCode: 10 * 60,
|
|
256
|
+
IdToken: 60 * 60,
|
|
257
|
+
RefreshToken: 14 * 24 * 60 * 60,
|
|
258
|
+
Session: 14 * 24 * 60 * 60,
|
|
259
|
+
Interaction: 60 * 60,
|
|
260
|
+
Grant: 14 * 24 * 60 * 60,
|
|
261
|
+
...(oidcConfigRaw?.ttl ?? {}),
|
|
262
|
+
};
|
|
263
|
+
const users = oidcConfigRaw?.users ?? user_1.UserModel;
|
|
264
|
+
const clientModel = oidcConfigRaw?.clients ?? client_1.ClientModel;
|
|
265
|
+
const federationModel = oidcConfigRaw?.federation ?? federation_1.FederationModel;
|
|
266
|
+
const tokenModel = oidcConfigRaw?.tokens ?? token_1.TokenModel;
|
|
267
|
+
const federation = await federationModel.list({}, { count: 10000 }).catch(() => []);
|
|
268
|
+
const bundler = oidcConfigRaw?.view instanceof bundler_1.Bundler ? oidcConfigRaw.view : null;
|
|
269
|
+
const view = bundler ? new view_1.View(`/${mountPath}/interaction/:uid`, {}, bundler) : null;
|
|
270
|
+
if (bundler) {
|
|
271
|
+
bundler.build().then((result) => { if (view)
|
|
272
|
+
view.result = result; });
|
|
273
|
+
const fs = require('node:fs');
|
|
274
|
+
fs.watch(bundler.path, () => {
|
|
275
|
+
bundler.rebuild().then((result) => { if (view)
|
|
276
|
+
view.result = result; });
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
const findAccount = users?.findAccount
|
|
280
|
+
?? (async (_ctx, id) => {
|
|
281
|
+
const user = await users?.get({ _id: id }, { private: false });
|
|
282
|
+
if (!user)
|
|
283
|
+
return undefined;
|
|
284
|
+
return {
|
|
285
|
+
accountId: user._id.toString(),
|
|
286
|
+
claims: async () => ({
|
|
287
|
+
sub: user._id,
|
|
288
|
+
email: user.email,
|
|
289
|
+
email_verified: user.emailVerified ?? false,
|
|
290
|
+
phone_number: user.phoneNumber,
|
|
291
|
+
phone_number_verified: user.phoneVerified ?? false,
|
|
292
|
+
name: user.name,
|
|
293
|
+
given_name: user.givenName,
|
|
294
|
+
family_name: user.familyName,
|
|
295
|
+
middle_name: user.middleName,
|
|
296
|
+
nickname: user.nickname,
|
|
297
|
+
preferred_username: user.username,
|
|
298
|
+
picture: user.picture,
|
|
299
|
+
website: user.website,
|
|
300
|
+
gender: user.gender,
|
|
301
|
+
birthdate: user.birthdate,
|
|
302
|
+
zoneinfo: user.zoneinfo,
|
|
303
|
+
locale: user.locale,
|
|
304
|
+
address: user.address,
|
|
305
|
+
updated_at: user.updatedAt
|
|
306
|
+
? Math.floor(new Date(user.updatedAt).getTime() / 1000)
|
|
307
|
+
: undefined,
|
|
308
|
+
}),
|
|
309
|
+
};
|
|
310
|
+
});
|
|
311
|
+
const verifyUser = users?.verifyUser
|
|
312
|
+
?? ((username, password) => users?.call('verify', { username, password }));
|
|
313
|
+
const findOrCreateFederatedUser = users?.findOrCreateFederatedUser
|
|
314
|
+
?? (async (email) => {
|
|
315
|
+
let user = await users?.get({ email }).catch(() => null);
|
|
316
|
+
if (!user) {
|
|
317
|
+
user = await users?.insert({
|
|
318
|
+
username: email, email, password: Math.random().toString(36),
|
|
319
|
+
isActive: true, roles: 'user', _sync: true,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
return user;
|
|
323
|
+
});
|
|
324
|
+
// ?? Resolve return_to from config ??
|
|
325
|
+
const redirect = oidcConfigRaw?.redirect;
|
|
326
|
+
if (!redirect)
|
|
327
|
+
throw new Error(`[OIDC] registerOIDCServer('${mountPath}') requires a static redirect`);
|
|
328
|
+
let returnTo;
|
|
329
|
+
if (typeof redirect === 'string' && redirect.startsWith('http')) {
|
|
330
|
+
returnTo = redirect;
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
const model = this.registeredModels.find(m => m.scope === redirect || m.id === redirect);
|
|
334
|
+
if (!model)
|
|
335
|
+
throw new Error(`[OIDC] redirect model '${redirect}' not found`);
|
|
336
|
+
const views = model.model.getViews?.();
|
|
337
|
+
if (!views?.length)
|
|
338
|
+
throw new Error(`[OIDC] redirect model '${redirect}' has no views`);
|
|
339
|
+
returnTo = `${baseUrl}/${model.id}/${model.scope}${views[0].path}`;
|
|
340
|
+
}
|
|
341
|
+
// ?? Seed default client ??????????????????????????????????????????
|
|
342
|
+
try {
|
|
343
|
+
const clientAdapter = new adapter('Client');
|
|
344
|
+
const defaultClient = {
|
|
345
|
+
client_id: `${mountPath}-default`,
|
|
346
|
+
client_secret: 'default-secret',
|
|
347
|
+
redirect_uris: [`${issuer}/${mountPath}/callback`],
|
|
348
|
+
return_to: returnTo,
|
|
349
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
350
|
+
response_types: ['code'],
|
|
351
|
+
token_endpoint_auth_method: 'client_secret_basic',
|
|
352
|
+
scopes: ['openid', 'email', 'profile', 'offline_access'],
|
|
353
|
+
isActive: true,
|
|
354
|
+
};
|
|
355
|
+
await clientAdapter.upsert(defaultClient.client_id, defaultClient, 0);
|
|
356
|
+
display_1.display.log('oidc', `Clients seeded for /${mountPath}`);
|
|
205
357
|
}
|
|
358
|
+
catch (err) {
|
|
359
|
+
display_1.display.error('oidc:seed', JSON.stringify(err));
|
|
360
|
+
throw err;
|
|
361
|
+
}
|
|
362
|
+
// ?? Build JWKS ????????????????????????????????????????????????
|
|
363
|
+
const { generateKeyPair, exportJWK } = await import('jose');
|
|
364
|
+
const fs = require('node:fs');
|
|
365
|
+
const path = require('node:path');
|
|
366
|
+
const JWKS_PATH = path.resolve(process.cwd(), './data/oidc.jwks.json');
|
|
367
|
+
let jwks;
|
|
368
|
+
if (process.env.OIDC_JWKS) {
|
|
369
|
+
jwks = JSON.parse(process.env.OIDC_JWKS);
|
|
370
|
+
}
|
|
371
|
+
else if (fs.existsSync(JWKS_PATH)) {
|
|
372
|
+
jwks = JSON.parse(fs.readFileSync(JWKS_PATH, 'utf-8'));
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
const { privateKey } = await generateKeyPair('RS256', { extractable: true });
|
|
376
|
+
const jwk = await exportJWK(privateKey);
|
|
377
|
+
jwk.use = 'sig';
|
|
378
|
+
jwk.alg = 'RS256';
|
|
379
|
+
jwk.kid = `key-${Date.now()}`;
|
|
380
|
+
jwks = { keys: [jwk] };
|
|
381
|
+
fs.mkdirSync(path.dirname(JWKS_PATH), { recursive: true });
|
|
382
|
+
fs.writeFileSync(JWKS_PATH, JSON.stringify(jwks, null, 2), 'utf-8');
|
|
383
|
+
}
|
|
384
|
+
// ?? Create provider ???????????????????????????????????????????????????
|
|
385
|
+
const provider = new oidc_provider_1.default(issuer, {
|
|
386
|
+
adapter,
|
|
387
|
+
jwks,
|
|
388
|
+
findAccount,
|
|
389
|
+
scopes,
|
|
390
|
+
claims,
|
|
391
|
+
ttl,
|
|
392
|
+
features: {
|
|
393
|
+
devInteractions: { enabled: !view },
|
|
394
|
+
clientCredentials: { enabled: true },
|
|
395
|
+
introspection: { enabled: true },
|
|
396
|
+
revocation: { enabled: true },
|
|
397
|
+
rpInitiatedLogout: { enabled: true },
|
|
398
|
+
},
|
|
399
|
+
issueRefreshToken: async (ctx, client, code) => {
|
|
400
|
+
return client.grantTypeAllowed('refresh_token');
|
|
401
|
+
},
|
|
402
|
+
loadExistingGrant: async (ctx) => {
|
|
403
|
+
if (!ctx.oidc.session?.accountId)
|
|
404
|
+
return undefined;
|
|
405
|
+
const grant = new ctx.oidc.provider.Grant({
|
|
406
|
+
accountId: ctx.oidc.session.accountId,
|
|
407
|
+
clientId: ctx.oidc.client.clientId,
|
|
408
|
+
});
|
|
409
|
+
grant.addOIDCScope('openid email profile offline_access');
|
|
410
|
+
await grant.save();
|
|
411
|
+
return grant;
|
|
412
|
+
},
|
|
413
|
+
extraClientMetadata: {
|
|
414
|
+
properties: ['return_to'],
|
|
415
|
+
},
|
|
416
|
+
pkce: { required: () => true },
|
|
417
|
+
interactions: {
|
|
418
|
+
url: (_ctx, interaction) => `/${mountPath}/interaction/${interaction.uid}`,
|
|
419
|
+
},
|
|
420
|
+
cookies: {
|
|
421
|
+
keys: [process.env.OIDC_COOKIE_SECRET ?? secret],
|
|
422
|
+
short: { secure: false, sameSite: 'lax' },
|
|
423
|
+
long: { secure: false, sameSite: 'lax' },
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
provider.proxy = true;
|
|
427
|
+
provider.on('server_error', (ctx, err) => {
|
|
428
|
+
display_1.display.error('oidc', err?.message ?? String(err));
|
|
429
|
+
});
|
|
430
|
+
// ?? Store local refresh token on grant.success — covers both email/password and federated ??
|
|
431
|
+
provider.on('grant.success', async (ctx) => {
|
|
432
|
+
console.log('grant.success fired', ctx.oidc.entities?.RefreshToken?.jti);
|
|
433
|
+
try {
|
|
434
|
+
const refreshToken = ctx.oidc.entities?.RefreshToken;
|
|
435
|
+
const account = ctx.oidc.entities?.Account;
|
|
436
|
+
const params = ctx.oidc.params;
|
|
437
|
+
if (!refreshToken || !account?.accountId)
|
|
438
|
+
return;
|
|
439
|
+
const existing = await tokenModel.find({
|
|
440
|
+
accountId: account.accountId,
|
|
441
|
+
provider: 'local',
|
|
442
|
+
appId: params.client_id,
|
|
443
|
+
}).then((r) => r?.[0] ?? null).catch(() => null);
|
|
444
|
+
const data = {
|
|
445
|
+
refreshToken: refreshToken.jti,
|
|
446
|
+
scope: refreshToken.scope,
|
|
447
|
+
expiresAt: new Date(Date.now() + ttl.RefreshToken * 1000).toISOString(),
|
|
448
|
+
};
|
|
449
|
+
if (existing) {
|
|
450
|
+
await tokenModel.update({ _id: existing._id }, data);
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
await tokenModel.insert({
|
|
454
|
+
_sync: true,
|
|
455
|
+
accountId: account.accountId,
|
|
456
|
+
provider: 'local',
|
|
457
|
+
appId: params.client_id,
|
|
458
|
+
...data,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
display_1.display.error('oidc:grant', err?.message ?? String(err));
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
// ?? PKCE store — keyed by code_challenge ??
|
|
467
|
+
const pkceStore = new Map();
|
|
468
|
+
// ?? Federation ??????????????????????????????????????????????????????
|
|
469
|
+
const federationClients = new Map();
|
|
470
|
+
for (const fed of federation) {
|
|
471
|
+
try {
|
|
472
|
+
const client = await openidClient.discovery(new URL(fed.issuer), fed.client_id, fed.client_secret);
|
|
473
|
+
federationClients.set(fed.name, { client, config: fed });
|
|
474
|
+
display_1.display.log('oidc', `Federation configured: ${fed.name}`);
|
|
475
|
+
}
|
|
476
|
+
catch (err) {
|
|
477
|
+
display_1.display.error('oidc', `Federation setup failed for ${fed.name}: ${err.message}`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
// ?? Interaction router ???????????????????????????????????????????????
|
|
481
|
+
const interactionRouter = new koa_router_1.default();
|
|
482
|
+
const body = (0, koa_body_1.koaBody)({ text: false, json: true, patchNode: true, patchKoa: true });
|
|
483
|
+
interactionRouter.use(async (ctx, next) => {
|
|
484
|
+
ctx.set('cache-control', 'no-store');
|
|
485
|
+
await next();
|
|
486
|
+
});
|
|
487
|
+
interactionRouter.get(`/${mountPath}/interaction/:uid`, async (ctx) => {
|
|
488
|
+
const details = await provider.interactionDetails(ctx.req, ctx.res);
|
|
489
|
+
ctx.type = 'text/html';
|
|
490
|
+
ctx.body = await view.render({ uid: details.uid, prompt: details.prompt.name, params: details.params });
|
|
491
|
+
});
|
|
492
|
+
interactionRouter.post(`/${mountPath}/interaction/:uid/confirm`, body, async (ctx) => {
|
|
493
|
+
const interactionDetails = await provider.interactionDetails(ctx.req, ctx.res);
|
|
494
|
+
const { prompt: { name, details }, params, session: { accountId } } = interactionDetails;
|
|
495
|
+
if (name !== 'consent') {
|
|
496
|
+
ctx.status = 400;
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
let { grantId } = interactionDetails;
|
|
500
|
+
let grant;
|
|
501
|
+
if (grantId) {
|
|
502
|
+
grant = await provider.Grant.find(grantId);
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
grant = new provider.Grant({ accountId, clientId: params.client_id });
|
|
506
|
+
}
|
|
507
|
+
if (details.missingOIDCScope)
|
|
508
|
+
grant.addOIDCScope(details.missingOIDCScope.join(' '));
|
|
509
|
+
if (details.missingOIDCClaims)
|
|
510
|
+
grant.addOIDCClaims(details.missingOIDCClaims);
|
|
511
|
+
if (details.missingResourceScopes) {
|
|
512
|
+
for (const [indicator, scopeStr] of Object.entries(details.missingResourceScopes))
|
|
513
|
+
grant.addResourceScope(indicator, scopeStr.join(' '));
|
|
514
|
+
}
|
|
515
|
+
grantId = await grant.save();
|
|
516
|
+
const consent = {};
|
|
517
|
+
if (!interactionDetails.grantId)
|
|
518
|
+
consent.grantId = grantId;
|
|
519
|
+
await provider.interactionFinished(ctx.req, ctx.res, { consent }, { mergeWithLastSubmission: true });
|
|
520
|
+
});
|
|
521
|
+
interactionRouter.post(`/${mountPath}/interaction/:uid/login`, body, async (ctx) => {
|
|
522
|
+
let interactionName;
|
|
523
|
+
try {
|
|
524
|
+
const { prompt: { name } } = await provider.interactionDetails(ctx.req, ctx.res);
|
|
525
|
+
interactionName = name;
|
|
526
|
+
}
|
|
527
|
+
catch (err) {
|
|
528
|
+
ctx.redirect(`${issuer}/auth?client_id=${mountPath}-default&redirect_uri=${issuer}/${mountPath}/callback&response_type=code&scope=openid%20email%20profile%20offline_access`);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (!['login', 'consent'].includes(interactionName)) {
|
|
532
|
+
ctx.status = 400;
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
const { email, password } = ctx.request.body ?? {};
|
|
536
|
+
if (!email || !password) {
|
|
537
|
+
ctx.status = 400;
|
|
538
|
+
ctx.body = { error: 'Email and password are required' };
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
try {
|
|
542
|
+
const result = await verifyUser(email, password);
|
|
543
|
+
if (!result?.ok) {
|
|
544
|
+
ctx.status = 401;
|
|
545
|
+
ctx.body = { error: result?.error ?? 'Invalid credentials' };
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const accountId = result.user._id.toString();
|
|
549
|
+
const redirectTo = await provider.interactionResult(ctx.req, ctx.res, {
|
|
550
|
+
login: { accountId },
|
|
551
|
+
});
|
|
552
|
+
ctx.body = { redirectTo };
|
|
553
|
+
}
|
|
554
|
+
catch (err) {
|
|
555
|
+
ctx.status = 400;
|
|
556
|
+
ctx.body = { error: err.message, code: err.code, details: err.details };
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
interactionRouter.post(`/${mountPath}/interaction/:uid/signup`, body, async (ctx) => {
|
|
560
|
+
let interactionName;
|
|
561
|
+
try {
|
|
562
|
+
const { prompt: { name } } = await provider.interactionDetails(ctx.req, ctx.res);
|
|
563
|
+
interactionName = name;
|
|
564
|
+
}
|
|
565
|
+
catch (err) {
|
|
566
|
+
ctx.redirect(`${issuer}/auth?client_id=${mountPath}-default&redirect_uri=${issuer}/${mountPath}/callback&response_type=code&scope=openid%20email%20profile%20offline_access`);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (!['login', 'consent'].includes(interactionName)) {
|
|
570
|
+
ctx.status = 400;
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const { email, password } = ctx.request.body ?? {};
|
|
574
|
+
const validation = await new core_1.Validator({
|
|
575
|
+
type: 'object',
|
|
576
|
+
properties: {
|
|
577
|
+
email: { type: 'string' },
|
|
578
|
+
password: { type: 'string' },
|
|
579
|
+
}
|
|
580
|
+
}).validate({ email, password });
|
|
581
|
+
if (validation) {
|
|
582
|
+
ctx.status = 400;
|
|
583
|
+
ctx.body = { error: 'Validation failed', details: validation };
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const existing = await users?.get({ email }).catch(() => null);
|
|
587
|
+
if (existing) {
|
|
588
|
+
ctx.status = 409;
|
|
589
|
+
ctx.body = { error: 'Email already registered' };
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
const hashedPassword = await (0, utils_1.hashPassword)(password);
|
|
593
|
+
try {
|
|
594
|
+
const user = await users?.insert({
|
|
595
|
+
email,
|
|
596
|
+
password: hashedPassword,
|
|
597
|
+
username: email.split('@').shift(),
|
|
598
|
+
isActive: true,
|
|
599
|
+
roles: 'user',
|
|
600
|
+
emailVerified: false,
|
|
601
|
+
failedLoginAttempts: 0,
|
|
602
|
+
});
|
|
603
|
+
const accountId = user._id.toString();
|
|
604
|
+
const redirectTo = await provider.interactionResult(ctx.req, ctx.res, {
|
|
605
|
+
login: { accountId },
|
|
606
|
+
});
|
|
607
|
+
ctx.body = { redirectTo };
|
|
608
|
+
}
|
|
609
|
+
catch (err) {
|
|
610
|
+
ctx.status = 400;
|
|
611
|
+
ctx.body = { error: err.message, code: err.code, details: err.details };
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
interactionRouter.get(`/${mountPath}/interaction/:uid/abort`, async (ctx) => {
|
|
615
|
+
await provider.interactionFinished(ctx.req, ctx.res, {
|
|
616
|
+
error: 'access_denied', error_description: 'End-User aborted interaction',
|
|
617
|
+
}, { mergeWithLastSubmission: false });
|
|
618
|
+
});
|
|
619
|
+
for (const [name, { client: fedClient, config: fedConfig }] of federationClients) {
|
|
620
|
+
interactionRouter.post(`/${mountPath}/interaction/:uid/federated/${name}`, body, async (ctx) => {
|
|
621
|
+
const { prompt: { name: promptName } } = await provider.interactionDetails(ctx.req, ctx.res);
|
|
622
|
+
if (promptName !== 'login') {
|
|
623
|
+
ctx.status = 400;
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
const code_verifier = openidClient.randomPKCECodeVerifier();
|
|
627
|
+
const BASE_URL = process.env.BASE_URL ?? `http://localhost:${port}`;
|
|
628
|
+
ctx.status = 303;
|
|
629
|
+
ctx.redirect(openidClient.buildAuthorizationUrl(fedClient, {
|
|
630
|
+
redirect_uri: `${BASE_URL}/${mountPath}/interaction/callback/${name}`,
|
|
631
|
+
scope: fedConfig.scopes,
|
|
632
|
+
code_challenge: await openidClient.calculatePKCECodeChallenge(code_verifier),
|
|
633
|
+
code_challenge_method: 'S256',
|
|
634
|
+
state: ctx.params.uid,
|
|
635
|
+
nonce: code_verifier,
|
|
636
|
+
access_type: 'offline', // ask Google for refresh token
|
|
637
|
+
prompt: 'consent', // force re-issue of refresh token
|
|
638
|
+
}));
|
|
639
|
+
});
|
|
640
|
+
interactionRouter.get(`/${mountPath}/interaction/callback/${name}`, async (ctx) => {
|
|
641
|
+
const uid = ctx.query.state;
|
|
642
|
+
// ?? Strip extra params Google appends (iss, authuser, prompt) that break oauth4webapi ??
|
|
643
|
+
const url = new URL(`http://localhost:${port}${ctx.path}`);
|
|
644
|
+
url.searchParams.set('code', ctx.query.code);
|
|
645
|
+
url.searchParams.set('state', ctx.query.state);
|
|
646
|
+
const tokens = await openidClient.authorizationCodeGrant(fedClient, url, { idTokenExpected: true, expectedState: uid });
|
|
647
|
+
const claims = tokens.claims();
|
|
648
|
+
if (!claims?.email)
|
|
649
|
+
throw new Error('No email in federated token claims');
|
|
650
|
+
const user = await findOrCreateFederatedUser(claims.email);
|
|
651
|
+
// ?? Store upstream refresh token in TokenModel ??
|
|
652
|
+
if (tokens.refresh_token) {
|
|
653
|
+
const existing = await tokenModel.find({
|
|
654
|
+
accountId: user._id,
|
|
655
|
+
provider: name,
|
|
656
|
+
appId: fedConfig.client_id,
|
|
657
|
+
}).then((r) => r?.[0] ?? null).catch(() => null);
|
|
658
|
+
if (existing) {
|
|
659
|
+
await tokenModel.update({ _id: existing._id }, { refreshToken: tokens.refresh_token });
|
|
660
|
+
}
|
|
661
|
+
else {
|
|
662
|
+
await tokenModel.insert({
|
|
663
|
+
_sync: true,
|
|
664
|
+
accountId: user._id,
|
|
665
|
+
provider: name,
|
|
666
|
+
appId: fedConfig.client_id,
|
|
667
|
+
refreshToken: tokens.refresh_token,
|
|
668
|
+
scope: fedConfig.scopes,
|
|
669
|
+
expiresAt: null,
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
await provider.interactionFinished(ctx.req, ctx.res, { login: { accountId: user._id } }, { mergeWithLastSubmission: false });
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
const interactionRoutes = interactionRouter.routes();
|
|
677
|
+
provider.use(async (ctx, next) => {
|
|
678
|
+
if (ctx.path.startsWith(`/${mountPath}/interaction`))
|
|
679
|
+
await interactionRoutes(ctx, next);
|
|
680
|
+
else
|
|
681
|
+
await next();
|
|
682
|
+
});
|
|
683
|
+
this.router.post(`/${mountPath}/token`, async (ctx) => {
|
|
684
|
+
const { code, code_verifier } = (ctx.request.body ?? {});
|
|
685
|
+
if (!code) {
|
|
686
|
+
ctx.status = 400;
|
|
687
|
+
ctx.body = { error: 'Missing code' };
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
const tokenRes = await fetch(`${issuer}/token`, {
|
|
691
|
+
method: 'POST',
|
|
692
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
693
|
+
body: new URLSearchParams({
|
|
694
|
+
grant_type: 'authorization_code',
|
|
695
|
+
code,
|
|
696
|
+
redirect_uri: `${issuer}/${mountPath}/callback`,
|
|
697
|
+
client_id: `${mountPath}-default`,
|
|
698
|
+
client_secret: 'default-secret',
|
|
699
|
+
...(code_verifier ? { code_verifier } : {}),
|
|
700
|
+
}).toString(),
|
|
701
|
+
});
|
|
702
|
+
const tokens = await tokenRes.json();
|
|
703
|
+
ctx.body = {
|
|
704
|
+
access_token: tokens.access_token ?? '',
|
|
705
|
+
refresh_token: tokens.refresh_token ?? '',
|
|
706
|
+
id_token: tokens.id_token ?? '',
|
|
707
|
+
return_to: returnTo,
|
|
708
|
+
};
|
|
709
|
+
});
|
|
710
|
+
this.router.get(`/${mountPath}/callback`, async (ctx) => {
|
|
711
|
+
const { code } = ctx.query;
|
|
712
|
+
if (!code) {
|
|
713
|
+
ctx.status = 400;
|
|
714
|
+
ctx.body = { error: 'Missing code' };
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
ctx.type = 'text/html';
|
|
718
|
+
ctx.body = `<!DOCTYPE html><html><body><script>
|
|
719
|
+
(async () => {
|
|
720
|
+
const verifier = sessionStorage.getItem('pkce_verifier');
|
|
721
|
+
sessionStorage.removeItem('pkce_verifier');
|
|
722
|
+
const res = await fetch('/${mountPath}/token', {
|
|
723
|
+
method : 'POST',
|
|
724
|
+
headers : { 'Content-Type': 'application/json' },
|
|
725
|
+
body : JSON.stringify({ code: ${JSON.stringify(code)}, code_verifier: verifier }),
|
|
726
|
+
});
|
|
727
|
+
const data = await res.json();
|
|
728
|
+
const target = new URL(data.return_to, window.location.origin);
|
|
729
|
+
target.searchParams.set('access_token', data.access_token ?? '');
|
|
730
|
+
target.searchParams.set('refresh_token', data.refresh_token ?? '');
|
|
731
|
+
target.searchParams.set('id_token', data.id_token ?? '');
|
|
732
|
+
window.location.href = target.toString();
|
|
733
|
+
})();
|
|
734
|
+
</script></body></html>`;
|
|
735
|
+
});
|
|
736
|
+
provider._hasView = !!view;
|
|
737
|
+
this._oidcProviders.set(mountPath, { provider, federation });
|
|
738
|
+
display_1.display.log('oidc', `Provider ready at ${issuer}`);
|
|
739
|
+
const { randomBytes, createHash } = require('node:crypto');
|
|
740
|
+
const _verifier = randomBytes(32).toString('base64url');
|
|
741
|
+
const _challenge = createHash('sha256').update(_verifier).digest('base64url');
|
|
742
|
+
pkceStore.set(_challenge, _verifier);
|
|
743
|
+
display_1.display.addOIDC(`${issuer}/auth?client_id=${mountPath}-default` +
|
|
744
|
+
`&redirect_uri=${issuer}/${mountPath}/callback` +
|
|
745
|
+
`&response_type=code` +
|
|
746
|
+
`&scope=openid%20email%20profile%20offline_access` +
|
|
747
|
+
`&code_challenge=${_challenge}` +
|
|
748
|
+
`&code_challenge_method=S256`);
|
|
206
749
|
}
|
|
207
750
|
// ?? OIDC Providers ?????????????????????????????????????????????????????
|
|
208
751
|
authPath;
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
this.authPath = path;
|
|
212
|
-
if (this.config.domain)
|
|
213
|
-
path = `${this.config.domain}/${path}`;
|
|
214
|
-
path = path.split('/').filter((x) => x).join('/');
|
|
215
|
-
providers?.forEach?.((provider) => {
|
|
216
|
-
if (!this.providers.find(x => x.clientId === provider.clientId))
|
|
217
|
-
this.providers.push({ ...provider });
|
|
218
|
-
});
|
|
219
|
-
// Store OIDC config for extension
|
|
220
|
-
this.extensionConfigs.set('oidc', {
|
|
221
|
-
providers: providers || [],
|
|
222
|
-
authPath: path,
|
|
223
|
-
});
|
|
752
|
+
registerOIDCServer(path, config) {
|
|
753
|
+
this._oidcServers.push({ path: path.replace(/^\/+|\/+$/g, ''), config: config ?? {} });
|
|
224
754
|
return this;
|
|
225
755
|
}
|
|
226
|
-
// ?? Upload ????????????????????????????????????????????????????????????????
|
|
227
756
|
upload(path, handler) {
|
|
228
757
|
this.routes.push({
|
|
229
758
|
method: 'post',
|
|
@@ -261,7 +790,6 @@ class Server extends eventemitter3_1.EventEmitter {
|
|
|
261
790
|
});
|
|
262
791
|
return this;
|
|
263
792
|
}
|
|
264
|
-
// ?? Stream ????????????????????????????????????????????????????????????????
|
|
265
793
|
_streamHandler;
|
|
266
794
|
_uploadTmp = '/tmp/hotfusion-uploads';
|
|
267
795
|
stream(handler) {
|
|
@@ -298,6 +826,19 @@ class Server extends eventemitter3_1.EventEmitter {
|
|
|
298
826
|
catch { }
|
|
299
827
|
uploads.set(uploadId, { meta: fullMeta, totalChunks, received });
|
|
300
828
|
socket.emit(`upload:started:${uploadId}`);
|
|
829
|
+
if (totalChunks === 0) {
|
|
830
|
+
uploads.delete(uploadId);
|
|
831
|
+
const emptyBuffer = Buffer.alloc(1, ' ');
|
|
832
|
+
Promise.resolve().then(async () => {
|
|
833
|
+
try {
|
|
834
|
+
const result = handler ? await handler(emptyBuffer, { ...fullMeta }) : { ok: true };
|
|
835
|
+
socket.emit(`upload:complete:${uploadId}`, result);
|
|
836
|
+
}
|
|
837
|
+
catch (err) {
|
|
838
|
+
socket.emit(`upload:error:${uploadId}`, err?.message ?? String(err));
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
}
|
|
301
842
|
});
|
|
302
843
|
socket.on('upload:chunk', async ({ uploadId, index, data }) => {
|
|
303
844
|
const upload = uploads.get(uploadId);
|
|
@@ -312,11 +853,13 @@ class Server extends eventemitter3_1.EventEmitter {
|
|
|
312
853
|
socket.emit(`upload:ack:${uploadId}:${index}`);
|
|
313
854
|
if (upload.received.size === upload.totalChunks) {
|
|
314
855
|
const chunks = [];
|
|
315
|
-
for (let i = 0; i < upload.totalChunks; i++)
|
|
856
|
+
for (let i = 0; i < upload.totalChunks; i++)
|
|
316
857
|
chunks.push(await readFile(`${uploadTmp}/${uploadId}/${i}`));
|
|
317
|
-
}
|
|
318
858
|
const buffer = Buffer.concat(chunks);
|
|
319
|
-
|
|
859
|
+
try {
|
|
860
|
+
await rm(`${uploadTmp}/${uploadId}`, { recursive: true, force: true });
|
|
861
|
+
}
|
|
862
|
+
catch { }
|
|
320
863
|
uploads.delete(uploadId);
|
|
321
864
|
let meta = {};
|
|
322
865
|
if (this.listenerCount('uploaded') > 0) {
|
|
@@ -324,8 +867,13 @@ class Server extends eventemitter3_1.EventEmitter {
|
|
|
324
867
|
const results = await Promise.all(listeners.map(listener => new Promise(resolve => listener({ resolve, file: upload.meta, buffer }))));
|
|
325
868
|
meta = Object.assign({}, ...results);
|
|
326
869
|
}
|
|
327
|
-
|
|
328
|
-
|
|
870
|
+
try {
|
|
871
|
+
const result = handler ? await handler(buffer, { ...upload.meta, ...meta }) : { ok: true };
|
|
872
|
+
socket.emit(`upload:complete:${uploadId}`, result);
|
|
873
|
+
}
|
|
874
|
+
catch (err) {
|
|
875
|
+
socket.emit(`upload:error:${uploadId}`, err?.message ?? String(err));
|
|
876
|
+
}
|
|
329
877
|
}
|
|
330
878
|
}
|
|
331
879
|
catch (err) {
|
|
@@ -333,7 +881,6 @@ class Server extends eventemitter3_1.EventEmitter {
|
|
|
333
881
|
}
|
|
334
882
|
});
|
|
335
883
|
}
|
|
336
|
-
// ?? Socket subscriptions ????????????????????????????????????????????????????
|
|
337
884
|
_setupSubscriptions(socket) {
|
|
338
885
|
const socketSubs = new Map();
|
|
339
886
|
const resolveModel = (id, scope) => {
|
|
@@ -384,19 +931,16 @@ class Server extends eventemitter3_1.EventEmitter {
|
|
|
384
931
|
}
|
|
385
932
|
});
|
|
386
933
|
socket.on('disconnect', () => {
|
|
387
|
-
for (const { subscription, listener } of socketSubs.values())
|
|
934
|
+
for (const { subscription, listener } of socketSubs.values())
|
|
388
935
|
subscription.off('publish', listener);
|
|
389
|
-
}
|
|
390
936
|
socketSubs.clear();
|
|
391
937
|
this.registeredModels.forEach(m => m.model.emit('disconnect', socket));
|
|
392
938
|
});
|
|
393
939
|
}
|
|
394
|
-
// ?? Metadata endpoint ??????????????????????????????????????????????????????
|
|
395
940
|
_setupMetadataRoute() {
|
|
396
941
|
this.router.get('/@metadata', async (ctx) => {
|
|
397
942
|
const buildMeta = (id, scope, model) => ({
|
|
398
|
-
id,
|
|
399
|
-
scope,
|
|
943
|
+
id, scope,
|
|
400
944
|
schema: model.getSchema?.(),
|
|
401
945
|
schemes: model.getSchemes?.(),
|
|
402
946
|
operations: this.API_OPERATIONS.map(op => ({
|
|
@@ -411,60 +955,94 @@ class Server extends eventemitter3_1.EventEmitter {
|
|
|
411
955
|
path: `/${id}/${scope}/${m}`,
|
|
412
956
|
url: `http://localhost:${this.port}/${id}/${scope}/${m}`,
|
|
413
957
|
})),
|
|
414
|
-
extensions: (model.getExtensions?.() ?? []).map((ext) => buildMeta(id, `${scope}/${ext.id}`, ext))
|
|
958
|
+
extensions: (model.getExtensions?.() ?? []).map((ext) => buildMeta(id, `${scope}/${ext.id}`, ext))
|
|
415
959
|
});
|
|
416
|
-
ctx.body = {
|
|
960
|
+
ctx.body = {
|
|
961
|
+
api: this.registeredModels.map(m => buildMeta(m.id, m.scope, m.model)),
|
|
962
|
+
oidc: [...this._oidcProviders.entries()].map(([mountPath, { federation }]) => ({
|
|
963
|
+
path: mountPath,
|
|
964
|
+
federation: federation
|
|
965
|
+
.filter((f) => f.isActive !== false)
|
|
966
|
+
.map((f) => ({
|
|
967
|
+
name: f.name,
|
|
968
|
+
issuer: f.issuer,
|
|
969
|
+
scopes: f.scopes,
|
|
970
|
+
url: `/${mountPath}/interaction/:uid/federated/${f.name}`,
|
|
971
|
+
})),
|
|
972
|
+
endpoints: {
|
|
973
|
+
signin: `/${mountPath}/interaction/:uid/login`,
|
|
974
|
+
signup: `/${mountPath}/interaction/:uid/signup`,
|
|
975
|
+
federate: `/${mountPath}/interaction/:uid/federated/:provider`,
|
|
976
|
+
}
|
|
977
|
+
}))
|
|
978
|
+
};
|
|
417
979
|
});
|
|
418
980
|
}
|
|
419
|
-
// ?? Route helper (kept for backward compatibility) ??????????????????????????
|
|
420
981
|
route(route) {
|
|
421
982
|
route.path = Utils.trimPath(route.path);
|
|
422
983
|
this.routes.push(route);
|
|
423
984
|
return this;
|
|
424
985
|
}
|
|
425
|
-
|
|
986
|
+
folder(urlPath, folderPath) {
|
|
987
|
+
const fs = require('node:fs');
|
|
988
|
+
if (!fs.existsSync(folderPath))
|
|
989
|
+
throw new Error(`[Server] folder() path does not exist: ${folderPath}`);
|
|
990
|
+
this._staticFolders.push(urlPath);
|
|
991
|
+
const staticMiddleware = (0, koa_static_1.default)(folderPath);
|
|
992
|
+
this.app.use(async (ctx, next) => {
|
|
993
|
+
if (ctx.path.startsWith(urlPath)) {
|
|
994
|
+
ctx.path = ctx.path.slice(urlPath.length) || '/';
|
|
995
|
+
return staticMiddleware(ctx, next);
|
|
996
|
+
}
|
|
997
|
+
return next();
|
|
998
|
+
});
|
|
999
|
+
return this;
|
|
1000
|
+
}
|
|
426
1001
|
async start() {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
1002
|
+
this.app.use((0, cors_1.default)());
|
|
1003
|
+
this.app.keys = [this.config?.secret || keygen_js_1.KeyGen.getSystemSecret()];
|
|
1004
|
+
for (const { path: oidcPath, config: oidcConfig } of this._oidcServers) {
|
|
1005
|
+
await this._setupOIDC(oidcConfig, oidcPath);
|
|
1006
|
+
}
|
|
1007
|
+
this.app.use((0, bodyparser_1.default)({ enableTypes: ['json'], parsedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'] }));
|
|
1008
|
+
this.app.use((0, koa_session_1.default)({
|
|
1009
|
+
key: this.config?.secret || keygen_js_1.KeyGen.getSystemSecret(),
|
|
1010
|
+
maxAge: 86400000,
|
|
1011
|
+
autoCommit: true,
|
|
1012
|
+
overwrite: false,
|
|
1013
|
+
httpOnly: true,
|
|
1014
|
+
signed: true,
|
|
1015
|
+
rolling: false,
|
|
1016
|
+
renew: false,
|
|
1017
|
+
secure: process.env.NODE_ENV === 'production',
|
|
1018
|
+
}, this.app));
|
|
432
1019
|
this.app.use(async (ctx, next) => {
|
|
433
1020
|
try {
|
|
434
1021
|
await next();
|
|
435
1022
|
}
|
|
436
1023
|
catch (err) {
|
|
1024
|
+
if (ctx.res.headersSent)
|
|
1025
|
+
return;
|
|
437
1026
|
ctx.status = err.status || 500;
|
|
438
|
-
ctx.body = {
|
|
439
|
-
error: err.message || 'Internal error',
|
|
440
|
-
status: err.status
|
|
441
|
-
};
|
|
1027
|
+
ctx.body = { error: err.message || 'Internal error', status: err.status };
|
|
442
1028
|
}
|
|
443
1029
|
});
|
|
444
|
-
|
|
445
|
-
this.mountModelRoutes();
|
|
446
|
-
// Setup extensions FIRST (before any routes)
|
|
447
|
-
console.log('[Server] Before setupExtensions');
|
|
1030
|
+
await this.mountModelRoutes();
|
|
448
1031
|
await this.setupExtensions();
|
|
449
|
-
console.log('[Server] After setupExtensions');
|
|
450
|
-
// Mount metadata endpoint
|
|
451
1032
|
this._setupMetadataRoute();
|
|
452
|
-
// Mount manually registered routes
|
|
453
1033
|
for (let i = 0; i < this.routes.length; i++) {
|
|
454
|
-
|
|
1034
|
+
const { method, path, callback } = this.routes[i] || {};
|
|
455
1035
|
if (method && path && callback) {
|
|
456
1036
|
const handler = async (ctx, next) => {
|
|
457
|
-
|
|
458
|
-
protected: this.routes?.[i]?.protected || false
|
|
459
|
-
};
|
|
1037
|
+
const flags = { protected: this.routes?.[i]?.protected || false };
|
|
460
1038
|
try {
|
|
461
1039
|
if (flags.protected) {
|
|
462
|
-
|
|
1040
|
+
const TOKEN = ctx.headers.authorization;
|
|
463
1041
|
if (!TOKEN)
|
|
464
1042
|
ctx.throw(401, 'Unauthorized');
|
|
465
1043
|
ctx.token = (await (0, jose_1.jwtVerify)(TOKEN.split(' ').pop(), new TextEncoder().encode(Array.isArray(this.app?.keys) ? this.app.keys[0] : undefined))).payload;
|
|
466
1044
|
}
|
|
467
|
-
|
|
1045
|
+
const body = await callback(ctx, next);
|
|
468
1046
|
if (!ctx.body) {
|
|
469
1047
|
ctx.status = 200;
|
|
470
1048
|
ctx.body = body;
|
|
@@ -472,9 +1050,7 @@ class Server extends eventemitter3_1.EventEmitter {
|
|
|
472
1050
|
}
|
|
473
1051
|
catch (err) {
|
|
474
1052
|
ctx.status = 500;
|
|
475
|
-
ctx.body = {
|
|
476
|
-
error: err.message || err.code || err.details || 'Internal error',
|
|
477
|
-
};
|
|
1053
|
+
ctx.body = { error: err.message || err.code || err.details || 'Internal error' };
|
|
478
1054
|
}
|
|
479
1055
|
};
|
|
480
1056
|
if (method === 'get')
|
|
@@ -491,21 +1067,54 @@ class Server extends eventemitter3_1.EventEmitter {
|
|
|
491
1067
|
}
|
|
492
1068
|
this.app.use(this.router.routes());
|
|
493
1069
|
this.app.use(this.router.allowedMethods());
|
|
494
|
-
const
|
|
495
|
-
this.
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
cors: { origin: "*", methods: ["GET", "POST"] },
|
|
1070
|
+
const httpServer = this.app.listen(this.port, '0.0.0.0', async () => {
|
|
1071
|
+
await display_1.display.run(this.port, async () => {
|
|
1072
|
+
this.emit('mounted', this);
|
|
1073
|
+
});
|
|
499
1074
|
});
|
|
500
|
-
|
|
501
|
-
|
|
1075
|
+
// ?? Mount OIDC providers on raw Node server — intercepts before Koa ??????
|
|
1076
|
+
if (this._oidcProviders.size > 0) {
|
|
1077
|
+
const providers = this._oidcProviders;
|
|
1078
|
+
const staticFolders = this._staticFolders;
|
|
1079
|
+
const originalEmit = httpServer.emit.bind(httpServer);
|
|
1080
|
+
httpServer.emit = function (event, ...args) {
|
|
1081
|
+
if (event === 'request') {
|
|
1082
|
+
const [req, res] = args;
|
|
1083
|
+
const url = req.url ?? '';
|
|
1084
|
+
const isStatic = staticFolders.some(p => url === p || url.startsWith(p + '/') || url.startsWith(p + '?'));
|
|
1085
|
+
if (isStatic)
|
|
1086
|
+
return originalEmit(event, ...args);
|
|
1087
|
+
for (const [mountPath, { provider }] of providers) {
|
|
1088
|
+
const hasView = provider._hasView;
|
|
1089
|
+
const oidcPaths = [
|
|
1090
|
+
'/auth', '/.well-known', '/token', '/me', '/jwks',
|
|
1091
|
+
'/session', '/token/introspection', '/token/revocation',
|
|
1092
|
+
'/request',
|
|
1093
|
+
`/${mountPath}/interaction`,
|
|
1094
|
+
...(!hasView ? ['/interaction'] : []),
|
|
1095
|
+
];
|
|
1096
|
+
if (oidcPaths.some((p) => url === p || url.startsWith(p + '?') || url.startsWith(p + '/'))) {
|
|
1097
|
+
if (url === `/${mountPath}/callback` || url.startsWith(`/${mountPath}/callback?`) ||
|
|
1098
|
+
url === `/${mountPath}/token` || url.startsWith(`/${mountPath}/token?`)) {
|
|
1099
|
+
return originalEmit(event, ...args);
|
|
1100
|
+
}
|
|
1101
|
+
provider.callback()(req, res);
|
|
1102
|
+
return true;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return originalEmit(event, ...args);
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
this.io = new socket_io_1.Server(httpServer, {
|
|
1110
|
+
cors: { origin: '*', methods: ['GET', 'POST'] },
|
|
502
1111
|
});
|
|
503
|
-
this.io.
|
|
1112
|
+
this.io.use((socket, next) => { next(); });
|
|
1113
|
+
this.io.on('connection', (socket) => {
|
|
504
1114
|
this._setupStream(socket);
|
|
505
1115
|
this._setupSubscriptions(socket);
|
|
506
1116
|
this.emit('connection', { socket });
|
|
507
1117
|
});
|
|
508
|
-
console.log('[Server] Start complete, listening on port', this.port);
|
|
509
1118
|
return this;
|
|
510
1119
|
}
|
|
511
1120
|
}
|