@fluyappgocore/commons-backend 1.0.214 → 1.0.215

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.
@@ -39,12 +39,28 @@ export interface PortalClaims {
39
39
  branchUuid: string;
40
40
  /** Present iff scope === "ticket" */
41
41
  ticketUuid?: string;
42
+ /** Stable token id — used for revocation by jti, kept in claims so
43
+ * the verify path can blacklist a single emission without nuking
44
+ * every token ever issued for the same ticket. */
45
+ jti?: string;
42
46
  iat: number;
43
47
  exp: number;
44
48
  }
45
49
  export interface RequestWithPortal extends Request {
46
50
  portalAuth?: PortalClaims;
47
51
  }
52
+ /**
53
+ * Revokes every ticket-scoped token currently in flight for one
54
+ * ticket. Use this when the customer reports a stolen cookie or
55
+ * when an admin manually closes a session. TTL of the revocation
56
+ * key matches the longest possible JWT lifetime so it cleans itself.
57
+ */
58
+ export declare function revokeTicketTokens(ticketUuid: string, ttlSec?: number): Promise<void>;
59
+ /**
60
+ * Revokes a specific token emission by its `jti`. Useful when only
61
+ * one device should be cut off, not the whole ticket session.
62
+ */
63
+ export declare function revokeTokenByJti(jti: string, ttlSec?: number): Promise<void>;
48
64
  /** Issue a fresh guest token for a visitor who just landed on a branch URL. */
49
65
  export declare function issueGuestToken(input: {
50
66
  entityUuid: string;
@@ -53,11 +69,22 @@ export declare function issueGuestToken(input: {
53
69
  token: string;
54
70
  expiresAt: number;
55
71
  };
56
- /** Upgrade a guest into a ticket holder after POST /tickets succeeds. */
72
+ /**
73
+ * Upgrade a guest into a ticket holder. Optional `ttlSec` lets the
74
+ * caller dial the lifetime per ticket phase:
75
+ *
76
+ * - WAITING / CALLING / ATTENDING → 2h (default)
77
+ * - FINISHED → 7d (NPS, share, print receipt)
78
+ * - CANCELLED / ABANDONED → 30 min (de-prioritised)
79
+ *
80
+ * Renewing on every poll keeps the cookie warm without forcing a
81
+ * "still here" re-auth dance.
82
+ */
57
83
  export declare function issueTicketToken(input: {
58
84
  entityUuid: string;
59
85
  branchUuid: string;
60
86
  ticketUuid: string;
87
+ ttlSec?: number;
61
88
  }): {
62
89
  token: string;
63
90
  expiresAt: number;
@@ -69,7 +96,7 @@ export declare function issueTicketToken(input: {
69
96
  * Rejects anything that isn't a portal-scoped token (aud !== "portal")
70
97
  * so a regular user/agent JWT can NEVER accidentally pass this gate.
71
98
  */
72
- export declare function verifyPortalToken(req: RequestWithPortal, _res: Response, next: NextFunction): void;
99
+ export declare function verifyPortalToken(req: RequestWithPortal, _res: Response, next: NextFunction): Promise<void>;
73
100
  /**
74
101
  * Gate a route by scope.
75
102
  *
@@ -18,17 +18,160 @@ var __importStar = (this && this.__importStar) || function (mod) {
18
18
  __setModuleDefault(result, mod);
19
19
  return result;
20
20
  };
21
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
22
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
23
+ return new (P || (P = Promise))(function (resolve, reject) {
24
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
25
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
26
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
27
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
28
+ });
29
+ };
30
+ var __generator = (this && this.__generator) || function (thisArg, body) {
31
+ var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
32
+ return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
33
+ function verb(n) { return function (v) { return step([n, v]); }; }
34
+ function step(op) {
35
+ if (f) throw new TypeError("Generator is already executing.");
36
+ while (_) try {
37
+ if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
38
+ if (y = 0, t) op = [op[0] & 2, t.value];
39
+ switch (op[0]) {
40
+ case 0: case 1: t = op; break;
41
+ case 4: _.label++; return { value: op[1], done: false };
42
+ case 5: _.label++; y = op[1]; op = [0]; continue;
43
+ case 7: op = _.ops.pop(); _.trys.pop(); continue;
44
+ default:
45
+ if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
46
+ if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
47
+ if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
48
+ if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
49
+ if (t[2]) _.ops.pop();
50
+ _.trys.pop(); continue;
51
+ }
52
+ op = body.call(thisArg, _);
53
+ } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
54
+ if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
55
+ }
56
+ };
21
57
  Object.defineProperty(exports, "__esModule", { value: true });
22
- exports.requireBranchMatch = exports.requireTicketMatch = exports.requireScope = exports.verifyPortalToken = exports.issueTicketToken = exports.issueGuestToken = void 0;
58
+ exports.requireBranchMatch = exports.requireTicketMatch = exports.requireScope = exports.verifyPortalToken = exports.issueTicketToken = exports.issueGuestToken = exports.revokeTokenByJti = exports.revokeTicketTokens = void 0;
23
59
  var jwt = __importStar(require("jsonwebtoken"));
60
+ var redis = __importStar(require("redis"));
24
61
  var exceptions_1 = require("../exceptions");
62
+ var promisify = require("util").promisify;
25
63
  var SECRET = process.env.JWT_KEY || "";
26
64
  var GUEST_TTL_SEC = 30 * 60; // 30 minutes
27
65
  var TICKET_TTL_SEC = 2 * 60 * 60; // 2 hours
66
+ // ─── Revocation backbone ────────────────────────────────────────────
67
+ //
68
+ // JWT is stateless by design — the only way to invalidate a still-fresh
69
+ // token is to keep a server-side blacklist and check it on every verify.
70
+ // We keep two key namespaces in Redis:
71
+ //
72
+ // portal_revoked_jti:<jti> → revoke a single token emission
73
+ // portal_revoked_ticket:<ticketUuid> → revoke ALL ticket-scoped tokens
74
+ // for a given ticket (admin "kill
75
+ // the session" action)
76
+ //
77
+ // The key value is irrelevant; presence == revoked. We TTL the keys so
78
+ // we don't accumulate forever — once the token's exp is in the past
79
+ // the JWT verify alone rejects it, blacklist becomes redundant.
80
+ //
81
+ // Best-effort: if Redis is unreachable, we FAIL OPEN (allow the JWT)
82
+ // instead of locking everyone out during an outage. The signed JWT
83
+ // itself is still authentic. Trade-off documented for security audit.
84
+ var RedisClient = null;
85
+ var existsAsync = null;
86
+ var setAsync = null;
87
+ function getRedis() {
88
+ if (RedisClient)
89
+ return { existsAsync: existsAsync, setAsync: setAsync };
90
+ try {
91
+ RedisClient = redis.createClient({
92
+ host: process.env.REDIS_HOST || "redis",
93
+ port: Number(process.env.REDIS_PORT) || 6379,
94
+ });
95
+ RedisClient.on("error", function (e) {
96
+ // Silent — we fail open. Logging too loud across every microservice.
97
+ if (process.env.DEBUG_PORTAL_AUTH) {
98
+ console.warn("[portalAuth.redis]", e.message);
99
+ }
100
+ });
101
+ existsAsync = promisify(RedisClient.exists).bind(RedisClient);
102
+ setAsync = promisify(RedisClient.set).bind(RedisClient);
103
+ }
104
+ catch (_a) {
105
+ // Redis client init failed → leave nulls, fail open everywhere.
106
+ }
107
+ return { existsAsync: existsAsync, setAsync: setAsync };
108
+ }
28
109
  function randomId() {
29
110
  return (Date.now().toString(36) +
30
111
  Math.random().toString(36).slice(2, 10));
31
112
  }
113
+ /**
114
+ * Revokes every ticket-scoped token currently in flight for one
115
+ * ticket. Use this when the customer reports a stolen cookie or
116
+ * when an admin manually closes a session. TTL of the revocation
117
+ * key matches the longest possible JWT lifetime so it cleans itself.
118
+ */
119
+ function revokeTicketTokens(ticketUuid, ttlSec) {
120
+ if (ttlSec === void 0) { ttlSec = 7 * 24 * 60 * 60; }
121
+ return __awaiter(this, void 0, void 0, function () {
122
+ var setAsync, _a;
123
+ return __generator(this, function (_b) {
124
+ switch (_b.label) {
125
+ case 0:
126
+ setAsync = getRedis().setAsync;
127
+ if (!setAsync || !ticketUuid)
128
+ return [2 /*return*/];
129
+ _b.label = 1;
130
+ case 1:
131
+ _b.trys.push([1, 3, , 4]);
132
+ return [4 /*yield*/, setAsync("portal_revoked_ticket:" + ticketUuid, "1", "EX", ttlSec)];
133
+ case 2:
134
+ _b.sent();
135
+ return [3 /*break*/, 4];
136
+ case 3:
137
+ _a = _b.sent();
138
+ return [3 /*break*/, 4];
139
+ case 4: return [2 /*return*/];
140
+ }
141
+ });
142
+ });
143
+ }
144
+ exports.revokeTicketTokens = revokeTicketTokens;
145
+ /**
146
+ * Revokes a specific token emission by its `jti`. Useful when only
147
+ * one device should be cut off, not the whole ticket session.
148
+ */
149
+ function revokeTokenByJti(jti, ttlSec) {
150
+ if (ttlSec === void 0) { ttlSec = 7 * 24 * 60 * 60; }
151
+ return __awaiter(this, void 0, void 0, function () {
152
+ var setAsync, _a;
153
+ return __generator(this, function (_b) {
154
+ switch (_b.label) {
155
+ case 0:
156
+ setAsync = getRedis().setAsync;
157
+ if (!setAsync || !jti)
158
+ return [2 /*return*/];
159
+ _b.label = 1;
160
+ case 1:
161
+ _b.trys.push([1, 3, , 4]);
162
+ return [4 /*yield*/, setAsync("portal_revoked_jti:" + jti, "1", "EX", ttlSec)];
163
+ case 2:
164
+ _b.sent();
165
+ return [3 /*break*/, 4];
166
+ case 3:
167
+ _a = _b.sent();
168
+ return [3 /*break*/, 4];
169
+ case 4: return [2 /*return*/];
170
+ }
171
+ });
172
+ });
173
+ }
174
+ exports.revokeTokenByJti = revokeTokenByJti;
32
175
  /** Issue a fresh guest token for a visitor who just landed on a branch URL. */
33
176
  function issueGuestToken(input) {
34
177
  var now = Math.floor(Date.now() / 1000);
@@ -39,6 +182,7 @@ function issueGuestToken(input) {
39
182
  scope: "guest",
40
183
  entityUuid: input.entityUuid,
41
184
  branchUuid: input.branchUuid,
185
+ jti: randomId(),
42
186
  iat: now,
43
187
  exp: exp,
44
188
  };
@@ -46,10 +190,21 @@ function issueGuestToken(input) {
46
190
  return { token: token, expiresAt: exp * 1000 };
47
191
  }
48
192
  exports.issueGuestToken = issueGuestToken;
49
- /** Upgrade a guest into a ticket holder after POST /tickets succeeds. */
193
+ /**
194
+ * Upgrade a guest into a ticket holder. Optional `ttlSec` lets the
195
+ * caller dial the lifetime per ticket phase:
196
+ *
197
+ * - WAITING / CALLING / ATTENDING → 2h (default)
198
+ * - FINISHED → 7d (NPS, share, print receipt)
199
+ * - CANCELLED / ABANDONED → 30 min (de-prioritised)
200
+ *
201
+ * Renewing on every poll keeps the cookie warm without forcing a
202
+ * "still here" re-auth dance.
203
+ */
50
204
  function issueTicketToken(input) {
51
205
  var now = Math.floor(Date.now() / 1000);
52
- var exp = now + TICKET_TTL_SEC;
206
+ var ttl = input.ttlSec && input.ttlSec > 0 ? input.ttlSec : TICKET_TTL_SEC;
207
+ var exp = now + ttl;
53
208
  var payload = {
54
209
  sub: "ticket:" + input.ticketUuid,
55
210
  aud: "portal",
@@ -57,6 +212,7 @@ function issueTicketToken(input) {
57
212
  entityUuid: input.entityUuid,
58
213
  branchUuid: input.branchUuid,
59
214
  ticketUuid: input.ticketUuid,
215
+ jti: randomId(),
60
216
  iat: now,
61
217
  exp: exp,
62
218
  };
@@ -73,26 +229,59 @@ exports.issueTicketToken = issueTicketToken;
73
229
  */
74
230
  function verifyPortalToken(req, _res, next) {
75
231
  var _a;
76
- var auth = (_a = req.headers) === null || _a === void 0 ? void 0 : _a.authorization;
77
- if (!auth || !auth.startsWith("Bearer ")) {
78
- return next(new exceptions_1.HttpException(401, "Missing portal token"));
79
- }
80
- var token = auth.slice("Bearer ".length).trim();
81
- try {
82
- var decoded = jwt.verify(token, SECRET, {
83
- algorithms: ["HS256"],
232
+ return __awaiter(this, void 0, void 0, function () {
233
+ var auth, token, decoded, existsAsync, checks, results, _b;
234
+ return __generator(this, function (_c) {
235
+ switch (_c.label) {
236
+ case 0:
237
+ auth = (_a = req.headers) === null || _a === void 0 ? void 0 : _a.authorization;
238
+ if (!auth || !auth.startsWith("Bearer ")) {
239
+ return [2 /*return*/, next(new exceptions_1.HttpException(401, "Missing portal token"))];
240
+ }
241
+ token = auth.slice("Bearer ".length).trim();
242
+ try {
243
+ decoded = jwt.verify(token, SECRET, {
244
+ algorithms: ["HS256"],
245
+ });
246
+ }
247
+ catch (err) {
248
+ return [2 /*return*/, next(new exceptions_1.HttpException(401, (err === null || err === void 0 ? void 0 : err.name) === "TokenExpiredError"
249
+ ? "Portal token expired"
250
+ : "Invalid portal token"))];
251
+ }
252
+ if (decoded.aud !== "portal") {
253
+ return [2 /*return*/, next(new exceptions_1.HttpException(401, "Invalid token audience"))];
254
+ }
255
+ existsAsync = getRedis().existsAsync;
256
+ if (!existsAsync) return [3 /*break*/, 5];
257
+ _c.label = 1;
258
+ case 1:
259
+ _c.trys.push([1, 4, , 5]);
260
+ checks = [];
261
+ if (decoded.jti) {
262
+ checks.push(existsAsync("portal_revoked_jti:" + decoded.jti));
263
+ }
264
+ if (decoded.ticketUuid) {
265
+ checks.push(existsAsync("portal_revoked_ticket:" + decoded.ticketUuid));
266
+ }
267
+ if (!(checks.length > 0)) return [3 /*break*/, 3];
268
+ return [4 /*yield*/, Promise.all(checks)];
269
+ case 2:
270
+ results = _c.sent();
271
+ if (results.some(function (r) { return r === 1; })) {
272
+ return [2 /*return*/, next(new exceptions_1.HttpException(401, "Portal token revoked"))];
273
+ }
274
+ _c.label = 3;
275
+ case 3: return [3 /*break*/, 5];
276
+ case 4:
277
+ _b = _c.sent();
278
+ return [3 /*break*/, 5];
279
+ case 5:
280
+ req.portalAuth = decoded;
281
+ return [2 /*return*/, next()];
282
+ }
84
283
  });
85
- if (decoded.aud !== "portal") {
86
- return next(new exceptions_1.HttpException(401, "Invalid token audience"));
87
- }
88
- req.portalAuth = decoded;
89
- return next();
90
- }
91
- catch (err) {
92
- return next(new exceptions_1.HttpException(401, (err === null || err === void 0 ? void 0 : err.name) === "TokenExpiredError"
93
- ? "Portal token expired"
94
- : "Invalid portal token"));
95
- }
284
+ });
96
285
  }
97
286
  exports.verifyPortalToken = verifyPortalToken;
98
287
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fluyappgocore/commons-backend",
3
- "version": "1.0.214",
3
+ "version": "1.0.215",
4
4
  "description": "",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",