@oondemand/oon-core-back 0.7.6 → 0.7.8

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 CHANGED
@@ -97,3 +97,34 @@ O Core omite todo o corpo de entrada e resposta no `requestLog` para esse métod
97
97
  ## Segredos V1 (0.7.6)
98
98
 
99
99
  `capabilities.secrets.put/status/use/remove` armazena objetos cifrados para Apps single_tenant dedicados. Declare `core.secrets` e `capabilitySettings["core.secrets"]={enabled:true}`. Rotas exigem permissão e sensitiveBody; use `req.accessContext`. Chave provisionada fora do banco, preservada em republicações. Veja o guia distribuído `SECRETS.md` para assinaturas, limites e recadastro explícito.
100
+
101
+
102
+ ### Aceite de convite (C2, SDK 0.7.7)
103
+
104
+ `PUBLIC_APP_URL` deve conter a origem pública HTTPS do aplicativo, sem credenciais,
105
+ query, fragmento ou subcaminho. HTTP é permitido apenas em loopback fora de produção.
106
+ A configuração é validada antes de emitir/rotacionar o convite; o header Host não
107
+ é usado como destino. Novos links usam `/accept-invite#token=...`.
108
+
109
+ O frontend consulta `POST /core/invitations/inspect` com `{ token }`. Somente um
110
+ convite pendente válido permite consultar a existência da identidade. O aceite
111
+ usa `POST /core/invitations/accept` com `{ token, intent, password? }`:
112
+ `authenticate` verifica a senha atual; `register` cria uma identidade com senha
113
+ nova; `session` exige uma sessão válida do destinatário. Nenhum desses caminhos
114
+ redefine a senha de uma identidade existente. Recuperação de senha continua no
115
+ login da plataforma. O retorno após aceite é local e fixo, sem aceitar redirect
116
+ arbitrário. Os endpoints antigos continuam disponíveis até o cutover coordenado.
117
+
118
+ Provedores locais devem fornecer `auth.inspectInvitation({ req, invitation })`,
119
+ retornando `{ accountExists: boolean, sessionUser?: { id, email } | null }`, além de
120
+ `auth.provisionInvitation`. A sessão opcional deve ser validada como identidade
121
+ (assinatura, expiração, versão de credencial e escopo), sem exigir o acesso que
122
+ o convite ainda concederá. Uma sessão inválida retorna `sessionUser: null`.
123
+ O provisionamento recebe também `intent` e deve verificar a prova correspondente,
124
+ retornando `usuario` com `id` e `email` do destinatário. Sem hook de inspeção,
125
+ um provedor local falha fechado e nunca delega identidades administrativas ao
126
+ provedor de aplicativos.
127
+
128
+ Publicar primeiro o suporte da Central (consulta e prova de identidade), depois
129
+ lançar front/back do SDK juntos e atualizar consumidores. O bloqueio de integração
130
+ comercial permanece até C3a/C3b; C2 não executa cutover nem reenvia convites.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oondemand/oon-core-back",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oondemand/oon-platform.git",
@@ -22,9 +22,10 @@ const {
22
22
  recoverPendingInvitation,
23
23
  assertNotLastAdmin,
24
24
  } = require("../core/rbac/storage");
25
- const { provisionInvitation } = require("../core/rbac/identityProvider");
25
+ const { provisionInvitation, inspectInvitation } = require("../core/rbac/identityProvider");
26
26
 
27
27
  const router = express.Router();
28
+ router.use(require("../core/rbac/centralInvitations").createRouter({ inviteUrl }));
28
29
 
29
30
  function requiredRbac() {
30
31
  if (!policy().enabled) {
@@ -36,10 +37,16 @@ function requiredRbac() {
36
37
  }
37
38
 
38
39
  function inviteUrl(req, token) {
39
- const base = String(
40
- process.env.PUBLIC_APP_URL || `${req.protocol || "http"}://${req.get("host") || req.headers.host}`,
41
- ).replace(/\/$/, "");
42
- return `${base}/accept-invite?token=${encodeURIComponent(token)}`;
40
+ let base;
41
+ try {
42
+ base = new URL(String(process.env.PUBLIC_APP_URL || ""));
43
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(base.hostname);
44
+ if ((base.protocol !== "https:" && !(base.protocol === "http:" && local && process.env.NODE_ENV !== "production"))
45
+ || base.username || base.password || base.search || base.hash || base.pathname !== "/") throw new Error();
46
+ } catch {
47
+ throw new GenericError("Configure PUBLIC_APP_URL com a origem HTTPS pública deste aplicativo.", { statusCode: 503, code: "INVITATION_PUBLIC_URL_INVALID" });
48
+ }
49
+ return `${base.origin}/accept-invite#token=${encodeURIComponent(token)}`;
43
50
  }
44
51
 
45
52
  function invitationView(invitation) {
@@ -139,6 +146,7 @@ router.post(
139
146
  asyncHandler(async (req, res) => {
140
147
  requiredRbac();
141
148
  const role = assertAssignable(req.usuario, req.body?.roleCode);
149
+ inviteUrl(req, ""); // Validate configuration before creating or rotating a token.
142
150
  const result = await createInvitation({
143
151
  email: req.body?.email,
144
152
  name: req.body?.name,
@@ -192,6 +200,7 @@ router.post(
192
200
  const previous = await Invitation.findOne({ _id: req.params.id, appCode: appCode() });
193
201
  if (!previous) throw new GenericError("Convite não encontrado.", { statusCode: 404 });
194
202
  assertAssignable(req.usuario, previous.roleCode);
203
+ inviteUrl(req, ""); // Validate configuration before creating or rotating a token.
195
204
  const result = await createInvitation({
196
205
  email: previous.email,
197
206
  name: previous.name,
@@ -277,11 +286,9 @@ router.patch(
277
286
  }),
278
287
  );
279
288
 
280
- router.get(
281
- "/invitations/:token",
282
- asyncHandler(async (req, res) => {
289
+ const inspectHandler = asyncHandler(async (req, res) => {
283
290
  requiredRbac();
284
- const invitation = await findInvitationByToken(req.params.token);
291
+ const invitation = await findInvitationByToken(req.params.token || req.body?.token);
285
292
  if (!invitation || invitation.status !== "pending") {
286
293
  throw new GenericError("Convite inválido, expirado ou já utilizado.", {
287
294
  statusCode: 410,
@@ -289,8 +296,12 @@ router.get(
289
296
  });
290
297
  }
291
298
  res.set("Cache-Control", "no-store");
299
+ const identity = await inspectInvitation({ req, invitation });
300
+ if (typeof identity?.accountExists !== "boolean") throw new GenericError("Resposta inválida do provedor de identidade.", { statusCode: 502 });
292
301
  res.json({
293
302
  invitation: {
303
+ accountExists: identity.accountExists,
304
+ sessionUser: identity.sessionUser || null,
294
305
  email: invitation.email,
295
306
  name: invitation.name,
296
307
  roleCode: invitation.roleCode,
@@ -298,19 +309,23 @@ router.get(
298
309
  expiresAt: invitation.expiresAt,
299
310
  },
300
311
  });
301
- }),
302
- );
312
+ });
313
+ router.get("/invitations/:token", inspectHandler);
314
+ router.post("/invitations/inspect", inspectHandler);
303
315
 
304
- router.post(
305
- "/invitations/:token/accept",
306
- asyncHandler(async (req, res) => {
316
+ const acceptHandler = asyncHandler(async (req, res) => {
307
317
  requiredRbac();
308
318
  const password = String(req.body?.password || "");
309
- if (password.length < 8) {
310
- throw new GenericError("A nova senha deve possuir pelo menos 8 caracteres.", { statusCode: 422 });
319
+ const intent = req.body?.intent;
320
+ if (intent !== undefined && !["register", "authenticate", "session"].includes(intent)) {
321
+ throw new GenericError("Forma de autenticação inválida.", { statusCode: 422 });
322
+ }
323
+ if ((intent === "register" || intent === undefined) && password.length < 8) {
324
+ throw new GenericError("A senha de cadastro deve possuir pelo menos 8 caracteres.", { statusCode: 422 });
311
325
  }
326
+ if (intent !== "session" && !password) throw new GenericError("Informe a senha.", { statusCode: 422 });
312
327
 
313
- const invitation = await findInvitationByToken(req.params.token);
328
+ const invitation = await findInvitationByToken(req.params.token || req.body?.token);
314
329
  if (!invitation || invitation.status !== "pending") {
315
330
  throw new GenericError("Convite inválido, expirado ou já utilizado.", {
316
331
  statusCode: 410,
@@ -324,8 +339,12 @@ router.post(
324
339
  throw new GenericError("O acesso associado ao convite não está mais disponível.", { statusCode: 410 });
325
340
  }
326
341
 
327
- const identity = await provisionInvitation({ req, invitation, password });
342
+ const identity = await provisionInvitation({ req, invitation, password, intent });
328
343
  const providerUser = identity?.usuario || identity?.user || identity;
344
+ if (!providerUser?.email || String(providerUser.email).trim().toLowerCase() !== invitation.email.toLowerCase()
345
+ || !(providerUser.id || providerUser._id)) {
346
+ throw new GenericError("Identidade retornada não corresponde ao convite.", { statusCode: 502, code: "INVITATION_IDENTITY_MISMATCH" });
347
+ }
329
348
  access.identityId = String(providerUser?.id || providerUser?._id || access.identityId || "") || undefined;
330
349
  access.name = providerUser?.nome || providerUser?.name || access.name;
331
350
  access.status = "active";
@@ -347,11 +366,12 @@ router.post(
347
366
 
348
367
  res.set("Cache-Control", "no-store");
349
368
  res.json({
350
- message: "Convite aceito. A nova senha foi definida e o acesso está ativo.",
369
+ message: "Convite aceito. O acesso está ativo.",
351
370
  email: access.email,
352
371
  });
353
- }),
354
- );
372
+ });
373
+ router.post("/invitations/:token/accept", acceptHandler);
374
+ router.post("/invitations/accept", acceptHandler);
355
375
 
356
376
  module.exports = router;
357
377
  module.exports.inviteUrl = inviteUrl;
@@ -137,6 +137,14 @@ async function applyEffectiveAccess(usuario) {
137
137
  rbacMode: "app-commercial", commercialAccess: { ...authority,
138
138
  roleComplete: !local.includes("*") && local.every(p => permissions.includes(p)) } };
139
139
  }
140
+ if (usuario?.canonicalAccess && typeof ctx.config.auth?.verifyToken !== "function") {
141
+ const source = usuario.canonicalAccess;
142
+ const role = roleByCode(source.roleCode);
143
+ if (!source.grantId || source.tenantId !== usuario.tenantId || !role) {
144
+ throw Object.assign(Error("Concessão central inválida."), { statusCode: 403, code: "CENTRAL_ACCESS_INVALID" });
145
+ }
146
+ return { ...usuario, roleCode: role.code, permissions: rolePermissions(role), accessStatus: "active", mustChangePassword: false, rbacMode: "central-invitation" };
147
+ }
140
148
  const access = await resolveUserAccess(usuario);
141
149
  return {
142
150
  ...usuario,
@@ -8,10 +8,11 @@ const registry = require("../registry");
8
8
  const requestLog = async (req, res, next) => {
9
9
  if (req.method === "GET") return next();
10
10
 
11
- const sensitiveBody = await registry.isSensitiveRequest(req.method, req.url || req.originalUrl);
11
+ const invitationRequest = /\/(?:core\/invitations|convites)\//.test(String(req.originalUrl || req.url));
12
+ const sensitiveBody = invitationRequest || await registry.isSensitiveRequest(req.method, req.url || req.originalUrl);
12
13
  const log = new Log({
13
14
  usuario: req.usuario || null,
14
- endpoint: req.originalUrl,
15
+ endpoint: invitationRequest ? String(req.originalUrl).replace(/(\/core\/invitations\/)[^/?]+/, "$1[redacted]").split("?")[0] : req.originalUrl,
15
16
  metodo: req.method,
16
17
  ip: req.ip || req.headers["x-forwarded-for"] || req.connection?.remoteAddress,
17
18
  correlationId: req.correlationId,
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ const express = require("express");
3
+ const ctx = require("../context");
4
+ const auth = require("../middlewares/auth");
5
+ const { asyncHandler } = require("../utils/helpers");
6
+ const GenericError = require("../errors/GenericError");
7
+ const { defaultProviderCall } = require("./identityProvider");
8
+ const { models, appCode, audit } = require("./storage");
9
+ const base = "/integracao/autenticacao-aplicativos/convites/v1";
10
+ function enabled() { return process.env.OON_CENTRAL_INVITATIONS_ENABLED === "true" && typeof ctx.config.auth?.provisionInvitation !== "function" && typeof ctx.config.auth?.verifyToken !== "function" && process.env.OON_RUNTIME_MODE !== "local"; }
11
+ async function cutover() {
12
+ // No import or token recovery. Idempotent state transition touches only old pending invitations.
13
+ const { Invitation } = models();
14
+ const session = await Invitation.db.startSession();
15
+ try { await session.withTransaction(async () => {
16
+ const rows = await Invitation.find({ appCode: appCode(), status: "pending" }).session(session);
17
+ for (const row of rows) {
18
+ const changed = await Invitation.updateOne({ _id: row._id, status: "pending" }, { $set: { status: "revoked", revokedAt: new Date() } }, { session });
19
+ if (changed.modifiedCount) await audit("invitation.cutover.v1", { actor: "system:cutover", target: row.email, context: { invitationId: String(row._id), cutoverId: "central-invitations-v1" }, session });
20
+ }
21
+ }); } finally { await session.endSession(); }
22
+ }
23
+ function createRouter({ inviteUrl }) {
24
+ const router = express.Router();
25
+ router.use((req, _res, next) => enabled() ? next() : next("router"));
26
+ const call = (req, payload) => defaultProviderCall(req, `${base}/command`, payload);
27
+ const management = handler => [auth, asyncHandler(async (req, res) => { await call(req, { command: "list" }); await cutover(); res.set("Cache-Control", "no-store"); await handler(req, res); })];
28
+ function identifier(value) {
29
+ const match = /^central:([a-f\d]{24})$/i.exec(String(value));
30
+ if (!match) throw new GenericError("Convite anterior ao corte. Solicite um novo convite.", { statusCode: 410, code: "INVITATION_LEGACY_DISABLED" });
31
+ return match[1];
32
+ }
33
+ function invitation(row) { return { ...row, id: `central:${row.id}` }; }
34
+ const access = row => ({ id: `central:${row.id}`, email: row.email, name: row.name, roleCode: row.roleCode, status: row.status === "accepted" ? "active" : row.status === "pending" ? "invited" : "revoked", origin: "central", invitation: invitation(row) });
35
+ async function withLink(req, result) {
36
+ if (result.invitation.status && result.invitation.status !== "pending") return { invitation: invitation(result.invitation), access: access(result.invitation) };
37
+ const copied = await call(req, { command: "copy", id: result.invitation.id });
38
+ return { invitation: invitation(copied.invitation), access: access(copied.invitation), inviteUrl: inviteUrl(req, copied.token) };
39
+ }
40
+ router.get("/users", ...management(async (req, res) => {
41
+ const result = await call(req, { command: "list" });
42
+ const canonical = result.invitations.map(access);
43
+ const identities = new Set(canonical.map(row => row.email));
44
+ const local = req.usuario?.commercialAccess ? [] : await models().UserAccess.find({ appCode: appCode(), status: { $ne: "invited" } }).lean();
45
+ res.json({ results: [...canonical, ...local.filter(row => !identities.has(row.email)).map(row => ({ id: String(row._id), email: row.email, name: row.name, roleCode: row.roleCode, status: row.status, origin: "local" }))] });
46
+ }));
47
+ router.post("/users/invitations", ...management(async (req, res) => {
48
+ inviteUrl(req, "");
49
+ const result = await call(req, { command: "create", email: req.body?.email, roleCode: req.body?.roleCode, version: 0 });
50
+ res.status(201).json(await withLink(req, result));
51
+ }));
52
+ router.get("/users/invitations/:id/link", ...management(async (req, res) => res.json(await withLink(req, { invitation: { id: identifier(req.params.id) } }))));
53
+ router.post("/users/invitations/:id/regenerate", ...management(async (req, res) => {
54
+ inviteUrl(req, "");
55
+ res.json(await withLink(req, await call(req, { command: "reissue", id: identifier(req.params.id), version: req.body?.version })));
56
+ }));
57
+ router.delete("/users/invitations/:id", ...management(async (req, res) => res.json(await call(req, { command: "revoke", id: identifier(req.params.id), version: req.body?.version }))));
58
+ router.patch("/users/:id", auth, asyncHandler(async (req, _res, next) => { if (!String(req.params.id).startsWith("central:")) return next("router"); throw new GenericError("Gerencie a concessão na Central; esta lista é uma projeção de leitura.", { statusCode: 409, code: "CENTRAL_ACCESS_COMMAND_REQUIRED" }); }));
59
+ for (const action of ["inspect", "accept", "decline"]) router.post(`/invitations/${action}`, asyncHandler(async (req, res) => {
60
+ if (!String(req.body?.token || "").startsWith("c3.")) throw new GenericError("Convite anterior ao corte. Solicite um novo convite.", { statusCode: 410, code: "INVITATION_LEGACY_DISABLED" });
61
+ res.set("Cache-Control", "no-store");
62
+ if (action === "inspect") return res.json(await defaultProviderCall(req, `${base}/inspect`, { token: req.body.token }));
63
+ const result = await call(req, { command: action, token: req.body.token, version: req.body.version, intent: req.body.intent, password: req.body.password, registration: req.body.registration });
64
+ res.json({ ...result, message: "Convite processado pela Central." });
65
+ }));
66
+ router.all("/invitations/:token/accept", (_req, res) => res.status(410).json({ code: "INVITATION_LEGACY_DISABLED", message: "Solicite um novo convite." }));
67
+ router.get("/invitations/:token", (_req, res) => res.status(410).json({ code: "INVITATION_LEGACY_DISABLED", message: "Solicite um novo convite." }));
68
+ return router;
69
+ }
70
+ module.exports = { enabled, cutover, createRouter };
@@ -10,6 +10,7 @@ const {
10
10
  } = require("../utils/instanceActivation");
11
11
 
12
12
  const IDENTITY_PATHS = Object.freeze({
13
+ inspectInvitation: "/integracao/autenticacao-aplicativos/convites/inspecionar",
13
14
  provisionInvitation: "/integracao/autenticacao-aplicativos/convites/provisionar",
14
15
  changeFirstAccessPassword: "/integracao/autenticacao-aplicativos/primeiro-acesso/senha",
15
16
  });
@@ -25,6 +26,9 @@ async function defaultProviderCall(req, path, payload) {
25
26
 
26
27
  try {
27
28
  const headers = instanceHeaders(instance);
29
+ for (const key of ["idempotency-key", "x-correlation-id", "x-oon-tenant-id"]) {
30
+ if (req?.headers?.[key]) headers[key] = req.headers[key];
31
+ }
28
32
  if (req?.headers?.authorization) headers.Authorization = req.headers.authorization;
29
33
  const response = await providerPost(req, path, payload, headers);
30
34
  return response.data;
@@ -38,14 +42,24 @@ async function defaultProviderCall(req, path, payload) {
38
42
  }
39
43
  }
40
44
 
41
- async function provisionInvitation({ req, invitation, password }) {
45
+ async function inspectInvitation({ req, invitation }) {
46
+ const hook = ctx.config.auth?.inspectInvitation;
47
+ if (typeof hook === "function") return hook({ req, invitation });
48
+ if (typeof ctx.config.auth?.provisionInvitation === "function") {
49
+ throw new GenericError("O provedor local precisa disponibilizar a consulta de convite.", { statusCode: 503, code: "INVITATION_INSPECTION_REQUIRED" });
50
+ }
51
+ return defaultProviderCall(req, IDENTITY_PATHS.inspectInvitation, { email: invitation.email });
52
+ }
53
+
54
+ async function provisionInvitation({ req, invitation, password, intent }) {
42
55
  const hook = ctx.config.auth?.provisionInvitation;
43
- if (typeof hook === "function") return hook({ req, invitation, password });
56
+ if (typeof hook === "function") return hook({ req, invitation, password, intent });
44
57
  return defaultProviderCall(req, IDENTITY_PATHS.provisionInvitation, {
45
58
  email: invitation.email,
46
59
  nome: invitation.name,
47
60
  roleCode: invitation.roleCode,
48
61
  password,
62
+ intent,
49
63
  invitationFingerprint: invitation.tokenFingerprint,
50
64
  });
51
65
  }
@@ -63,6 +77,7 @@ async function changeFirstAccessPassword({ req, user, password }) {
63
77
  module.exports = {
64
78
  IDENTITY_PATHS,
65
79
  defaultProviderCall,
80
+ inspectInvitation,
66
81
  provisionInvitation,
67
82
  changeFirstAccessPassword,
68
83
  };
@@ -105,10 +105,10 @@ function sanitizeSnapshot(value) {
105
105
  return plain;
106
106
  }
107
107
 
108
- async function audit(event, { actor, target, before, after, context } = {}) {
108
+ async function audit(event, { actor, target, before, after, context, session } = {}) {
109
109
  if (mongoose.connection.readyState !== 1) return null;
110
110
  const { Audit } = models();
111
- return Audit.create({
111
+ const data = {
112
112
  appCode: appCode(),
113
113
  event,
114
114
  actor: typeof actor === "string" ? actor : actorKey(actor),
@@ -117,7 +117,8 @@ async function audit(event, { actor, target, before, after, context } = {}) {
117
117
  after: sanitizeSnapshot(after),
118
118
  context,
119
119
  at: new Date(),
120
- });
120
+ };
121
+ return session ? (await Audit.create([data], { session }))[0] : Audit.create(data);
121
122
  }
122
123
 
123
124
  function accessError(message, code = "RBAC_ACCESS_DENIED", statusCode = 403) {