@devopsplaybook.io/common-utils 1.8.0 → 1.9.0

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/AGENTS.md CHANGED
@@ -23,10 +23,12 @@ src/
23
23
  users/ # Auth & user management module set
24
24
  User.ts # User model, roles and application-defined scopes
25
25
  UserSession.ts # Decoded JWT session
26
- Auth.ts # JWT auth (key init, guards, session decode)
26
+ UserApiToken.ts # User API token model (SHA-256 hash only)
27
+ Auth.ts # JWT auth (key init, guards, session decode) + API token resolution
27
28
  UserPassword.ts # bcrypt password hashing/verification
28
29
  UsersData.ts # Users table CRUD (SQLite/Postgres)
29
- UsersRoutes.ts # Standard fastify user management routes
30
+ UsersApiTokensData.ts # users_api_tokens table CRUD (SQLite/Postgres)
31
+ UsersRoutes.ts # Standard fastify user management routes (incl. self-service API tokens)
30
32
  SystemCommand.ts # Promise wrapper around child_process.exec
31
33
  Timeout.ts # Promise wrapper around setTimeout
32
34
  LLM.ts # OpenAI-compatible chat completions client (LLMClient)
package/README.md CHANGED
@@ -404,18 +404,23 @@ fastify.register(new UsersRoutes().getRoutes, { prefix: "/api/users" });
404
404
  | `AuthSetOTel` | Injects the OTel tracer used by the auth module (before `AuthInit`) |
405
405
  | `AuthInit` | Registers app scopes, loads or generates the JWT key from `metadata` |
406
406
  | `AuthGenerateJWT` | Signs a JWT for a user (admins get all scopes) |
407
- | `AuthMustBeAuthenticated` | 403 guard: any valid JWT |
407
+ | `AuthMustBeAuthenticated` | 403 guard: any valid JWT **or user API token** |
408
408
  | `AuthMustBeAdmin` | 403 guard: `role === "admin"` |
409
- | `AuthHasScope` | 403 guard: admin or JWT containing the requested scope |
410
- | `AuthGetUserSession` | Returns the `UserSession` decoded from the request JWT |
409
+ | `AuthHasScope` | 403 guard: admin or credentials containing the requested scope |
410
+ | `AuthGetUserSession` | Returns the `UserSession` decoded from the request credentials |
411
411
  | `User`, `UserRole`, `UserScope` | User model; scopes are application-defined strings |
412
412
  | `UserSession` | Decoded session: `isAuthenticated`, `userId`, `userName`, `role`, `scopes` |
413
+ | `UserApiToken` | API token model (only the SHA-256 hash is persisted) |
413
414
  | `UserPasswordSetPassword` / `UserPasswordCheckPassword` | bcrypt hashing and verification |
414
415
  | `UsersDataSetOTel` | Injects the OTel tracer used by the users data module |
415
416
  | `UsersData*` | Users table CRUD (`Get`, `GetByName`, `List`, `Add`, `UpdateUser`, `UpdatePassword`, `Delete`) |
416
- | `UsersRoutes` | Fastify routes: `GET /status/initialization`, `POST /session`, user CRUD, `PUT /password` |
417
+ | `UsersApiTokensDataSetOTel` | Injects the OTel tracer used by the API tokens data module |
418
+ | `UsersApiTokensData*` | API tokens table CRUD (`Get`, `GetByTokenHash`, `ListByUser`, `Add`, `Delete`, `DeleteByUser`) |
419
+ | `UsersRoutes` | Fastify routes: `GET /status/initialization`, `POST /session`, user CRUD, `PUT /password`, API tokens (`POST/GET /tokens`, `DELETE /tokens/:id`) |
417
420
 
418
- **Requirements**: a `users` table (columns `id`, `name`, `passwordEncrypted`, `role`, `scopes`) and the standard `metadata` table created by `init-0000.sql`. SQL is written SQLite-first; the `DbUtils` facade converts placeholders for Postgres.
421
+ **Requirements**: a `users` table (columns `id`, `name`, `passwordEncrypted`, `role`, `scopes`), the standard `metadata` table created by `init-0000.sql`, and a `users_api_tokens` table (columns `id`, `name`, `userId`, `tokenHash`, `dateCreated`, with a unique index on `tokenHash` and an index on `userId`) for the API token feature. SQL is written SQLite-first; the `DbUtils` facade converts placeholders for Postgres.
422
+
423
+ **API tokens**: users create their own API tokens via `POST /api/users/tokens` (body `{ name }`); the plaintext token is returned exactly once and only its SHA-256 hash is stored. `GET /api/users/tokens` lists the caller's tokens and `DELETE /api/users/tokens/:id` revokes one (owner or admin). Requests authenticated with `Authorization: Bearer <api-token>` resolve to the owning user's live role and scopes on every request, so role/scope changes apply immediately and revocation is instant. Tokens are valid until revoked (no expiry).
419
424
 
420
425
  ---
421
426
 
package/dist/index.d.ts CHANGED
@@ -10,7 +10,9 @@ export * from "./src/SystemCommand";
10
10
  export * from "./src/Timeout";
11
11
  export * from "./src/users/User";
12
12
  export * from "./src/users/UserSession";
13
+ export * from "./src/users/UserApiToken";
13
14
  export * from "./src/users/Auth";
14
15
  export * from "./src/users/UserPassword";
15
16
  export * from "./src/users/UsersData";
17
+ export * from "./src/users/UsersApiTokensData";
16
18
  export * from "./src/users/UsersRoutes";
package/dist/index.js CHANGED
@@ -26,7 +26,9 @@ __exportStar(require("./src/SystemCommand"), exports);
26
26
  __exportStar(require("./src/Timeout"), exports);
27
27
  __exportStar(require("./src/users/User"), exports);
28
28
  __exportStar(require("./src/users/UserSession"), exports);
29
+ __exportStar(require("./src/users/UserApiToken"), exports);
29
30
  __exportStar(require("./src/users/Auth"), exports);
30
31
  __exportStar(require("./src/users/UserPassword"), exports);
31
32
  __exportStar(require("./src/users/UsersData"), exports);
33
+ __exportStar(require("./src/users/UsersApiTokensData"), exports);
32
34
  __exportStar(require("./src/users/UsersRoutes"), exports);
@@ -40,10 +40,13 @@ exports.AuthMustBeAuthenticated = AuthMustBeAuthenticated;
40
40
  exports.AuthMustBeAdmin = AuthMustBeAdmin;
41
41
  exports.AuthHasScope = AuthHasScope;
42
42
  exports.AuthGetUserSession = AuthGetUserSession;
43
+ const crypto_1 = require("crypto");
43
44
  const jwt = __importStar(require("jsonwebtoken"));
44
45
  const uuid_1 = require("uuid");
45
46
  const DbUtils_1 = require("../DbUtils");
46
47
  const User_1 = require("./User");
48
+ const UsersApiTokensData_1 = require("./UsersApiTokensData");
49
+ const UsersData_1 = require("./UsersData");
47
50
  let tracer;
48
51
  let config;
49
52
  /**
@@ -91,11 +94,18 @@ async function AuthGenerateJWT(user) {
91
94
  }, config.JWT_KEY);
92
95
  }
93
96
  /**
94
- * Decode JWT from request, caching result on req._jwtPayload to avoid
95
- * redundant verification when multiple auth functions are called per request.
97
+ * Decode credentials from request, caching result on req._jwtPayload to
98
+ * avoid redundant resolution when multiple auth functions are called per
99
+ * request.
100
+ *
101
+ * A `Bearer` credential is first verified as a JWT. When JWT verification
102
+ * fails, the credential is resolved as a user API token: the value is
103
+ * SHA-256 hashed and looked up in `users_api_tokens`; on match, a payload
104
+ * mirroring the owning user's live role and scopes is built (valid until
105
+ * the token is revoked).
96
106
  */
97
107
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
98
- function jwtDecodeCached(req) {
108
+ async function jwtDecodeCached(req) {
99
109
  if (req._jwtPayload) {
100
110
  return req._jwtPayload;
101
111
  }
@@ -108,22 +118,57 @@ function jwtDecodeCached(req) {
108
118
  return info;
109
119
  }
110
120
  catch {
121
+ const info = await resolveApiToken(req.headers.authorization);
122
+ if (info) {
123
+ req._jwtPayload = info;
124
+ return info;
125
+ }
111
126
  return null;
112
127
  }
113
128
  }
129
+ /**
130
+ * Resolve an API token bearer credential to a user-backed payload.
131
+ * Permissions are read from the user record at resolution time, so
132
+ * role/scope changes apply to existing tokens immediately.
133
+ */
134
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
135
+ async function resolveApiToken(authorizationHeader) {
136
+ const token = authorizationHeader.split(" ")[1];
137
+ if (!token) {
138
+ return null;
139
+ }
140
+ const span = tracer.startSpan("AuthResolveApiToken");
141
+ const tokenHash = (0, crypto_1.createHash)("sha256").update(token).digest("hex");
142
+ const apiToken = await (0, UsersApiTokensData_1.UsersApiTokensDataGetByTokenHash)(span, tokenHash);
143
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
144
+ let payload = null;
145
+ if (apiToken) {
146
+ const user = await (0, UsersData_1.UsersDataGet)(span, apiToken.userId);
147
+ if (user) {
148
+ payload = {
149
+ userId: user.id,
150
+ userName: user.name,
151
+ role: user.role,
152
+ scopes: user.role === "admin" ? [...User_1.User.ALL_SCOPES] : user.scopes,
153
+ };
154
+ }
155
+ }
156
+ span.end();
157
+ return payload;
158
+ }
114
159
  async function AuthMustBeAuthenticated(
115
160
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
116
161
  req,
117
162
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
118
163
  res) {
119
- if (!jwtDecodeCached(req)) {
164
+ if (!(await jwtDecodeCached(req))) {
120
165
  res.status(403).send({ error: "Access Denied" });
121
166
  throw new Error("Access Denied");
122
167
  }
123
168
  }
124
169
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
125
170
  async function AuthMustBeAdmin(req, res) {
126
- const info = jwtDecodeCached(req);
171
+ const info = await jwtDecodeCached(req);
127
172
  if ((info === null || info === void 0 ? void 0 : info.role) === "admin") {
128
173
  return;
129
174
  }
@@ -135,7 +180,7 @@ async function AuthHasScope(
135
180
  req,
136
181
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
137
182
  res, scope) {
138
- const info = jwtDecodeCached(req);
183
+ const info = await jwtDecodeCached(req);
139
184
  if (!info) {
140
185
  res.status(403).send({ error: "Access Denied" });
141
186
  throw new Error("Access Denied");
@@ -153,7 +198,7 @@ res, scope) {
153
198
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
154
199
  async function AuthGetUserSession(req) {
155
200
  const userSession = { isAuthenticated: false };
156
- const info = jwtDecodeCached(req);
201
+ const info = await jwtDecodeCached(req);
157
202
  if (info) {
158
203
  userSession.userId = info.userId;
159
204
  userSession.userName = info.userName;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * User-scoped API token.
3
+ *
4
+ * Only the SHA-256 hash of the token value is ever stored; the plaintext
5
+ * value is generated at creation time and shown to the user exactly once.
6
+ * A token grants the same permissions as the user it belongs to, resolved
7
+ * live on each request (role/scope changes apply immediately).
8
+ */
9
+ export declare class UserApiToken {
10
+ static fromJson(json: any): UserApiToken | null;
11
+ id: string;
12
+ name: string;
13
+ userId: string;
14
+ tokenHash: string;
15
+ dateCreated: string;
16
+ constructor();
17
+ toJson(): any;
18
+ toTransportJson(): any;
19
+ }
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UserApiToken = void 0;
4
+ const uuid_1 = require("uuid");
5
+ /**
6
+ * User-scoped API token.
7
+ *
8
+ * Only the SHA-256 hash of the token value is ever stored; the plaintext
9
+ * value is generated at creation time and shown to the user exactly once.
10
+ * A token grants the same permissions as the user it belongs to, resolved
11
+ * live on each request (role/scope changes apply immediately).
12
+ */
13
+ class UserApiToken {
14
+ //
15
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
+ static fromJson(json) {
17
+ if (!json) {
18
+ return null;
19
+ }
20
+ const apiToken = new UserApiToken();
21
+ apiToken.id = json.id;
22
+ apiToken.name = json.name;
23
+ apiToken.userId = json.userId;
24
+ apiToken.tokenHash = json.tokenHash;
25
+ apiToken.dateCreated = json.dateCreated;
26
+ return apiToken;
27
+ }
28
+ constructor() {
29
+ this.id = (0, uuid_1.v4)();
30
+ }
31
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
+ toJson() {
33
+ return {
34
+ id: this.id,
35
+ name: this.name,
36
+ userId: this.userId,
37
+ tokenHash: this.tokenHash,
38
+ dateCreated: this.dateCreated,
39
+ };
40
+ }
41
+ // Transport representation: never exposes the token hash.
42
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
43
+ toTransportJson() {
44
+ return {
45
+ id: this.id,
46
+ name: this.name,
47
+ userId: this.userId,
48
+ dateCreated: this.dateCreated,
49
+ };
50
+ }
51
+ }
52
+ exports.UserApiToken = UserApiToken;
@@ -0,0 +1,14 @@
1
+ import { StandardTracer } from "@devopsplaybook.io/otel-utils";
2
+ import { Span } from "@opentelemetry/sdk-trace-base";
3
+ import { UserApiToken } from "./UserApiToken";
4
+ /**
5
+ * Injects the OTel tracer instance used by the API tokens data module.
6
+ * Must be called once at startup, before any `UsersApiTokensData*` function.
7
+ */
8
+ export declare function UsersApiTokensDataSetOTel(tracerIn: StandardTracer): void;
9
+ export declare function UsersApiTokensDataGet(context: Span | undefined, id: string): Promise<UserApiToken | null>;
10
+ export declare function UsersApiTokensDataGetByTokenHash(context: Span | undefined, tokenHash: string): Promise<UserApiToken | null>;
11
+ export declare function UsersApiTokensDataListByUser(context: Span | undefined, userId: string): Promise<UserApiToken[]>;
12
+ export declare function UsersApiTokensDataAdd(context: Span | undefined, apiToken: UserApiToken): Promise<void>;
13
+ export declare function UsersApiTokensDataDelete(context: Span | undefined, id: string): Promise<void>;
14
+ export declare function UsersApiTokensDataDeleteByUser(context: Span | undefined, userId: string): Promise<void>;
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UsersApiTokensDataSetOTel = UsersApiTokensDataSetOTel;
4
+ exports.UsersApiTokensDataGet = UsersApiTokensDataGet;
5
+ exports.UsersApiTokensDataGetByTokenHash = UsersApiTokensDataGetByTokenHash;
6
+ exports.UsersApiTokensDataListByUser = UsersApiTokensDataListByUser;
7
+ exports.UsersApiTokensDataAdd = UsersApiTokensDataAdd;
8
+ exports.UsersApiTokensDataDelete = UsersApiTokensDataDelete;
9
+ exports.UsersApiTokensDataDeleteByUser = UsersApiTokensDataDeleteByUser;
10
+ const DbUtils_1 = require("../DbUtils");
11
+ const UserApiToken_1 = require("./UserApiToken");
12
+ let tracer;
13
+ /**
14
+ * Injects the OTel tracer instance used by the API tokens data module.
15
+ * Must be called once at startup, before any `UsersApiTokensData*` function.
16
+ */
17
+ function UsersApiTokensDataSetOTel(tracerIn) {
18
+ tracer = tracerIn;
19
+ }
20
+ async function UsersApiTokensDataGet(context, id) {
21
+ const span = tracer.startSpan("UsersApiTokensDataGet", context);
22
+ const tokensRaw = await (0, DbUtils_1.DbUtilsQuerySQL)(span, SQL_QUERIES.GET_TOKEN_BY_ID, [
23
+ id,
24
+ ]);
25
+ let apiToken = null;
26
+ if (tokensRaw.length > 0) {
27
+ apiToken = fromRaw(tokensRaw[0]);
28
+ }
29
+ span.end();
30
+ return apiToken;
31
+ }
32
+ async function UsersApiTokensDataGetByTokenHash(context, tokenHash) {
33
+ const span = tracer.startSpan("UsersApiTokensDataGetByTokenHash", context);
34
+ const tokensRaw = await (0, DbUtils_1.DbUtilsQuerySQL)(span, SQL_QUERIES.GET_TOKEN_BY_HASH, [tokenHash]);
35
+ let apiToken = null;
36
+ if (tokensRaw.length > 0) {
37
+ apiToken = fromRaw(tokensRaw[0]);
38
+ }
39
+ span.end();
40
+ return apiToken;
41
+ }
42
+ async function UsersApiTokensDataListByUser(context, userId) {
43
+ const span = tracer.startSpan("UsersApiTokensDataListByUser", context);
44
+ const tokensRaw = await (0, DbUtils_1.DbUtilsQuerySQL)(span, SQL_QUERIES.LIST_TOKENS_BY_USER, [userId]);
45
+ const apiTokens = [];
46
+ for (const tokenRaw of tokensRaw) {
47
+ apiTokens.push(fromRaw(tokenRaw));
48
+ }
49
+ span.end();
50
+ return apiTokens;
51
+ }
52
+ async function UsersApiTokensDataAdd(context, apiToken) {
53
+ const span = tracer.startSpan("UsersApiTokensDataAdd", context);
54
+ await (0, DbUtils_1.DbUtilsExecSQL)(span, SQL_QUERIES.INSERT_TOKEN, [
55
+ apiToken.id,
56
+ apiToken.name,
57
+ apiToken.userId,
58
+ apiToken.tokenHash,
59
+ apiToken.dateCreated,
60
+ ]);
61
+ span.end();
62
+ }
63
+ async function UsersApiTokensDataDelete(context, id) {
64
+ const span = tracer.startSpan("UsersApiTokensDataDelete", context);
65
+ await (0, DbUtils_1.DbUtilsExecSQL)(span, SQL_QUERIES.DELETE_TOKEN, [id]);
66
+ span.end();
67
+ }
68
+ async function UsersApiTokensDataDeleteByUser(context, userId) {
69
+ const span = tracer.startSpan("UsersApiTokensDataDeleteByUser", context);
70
+ await (0, DbUtils_1.DbUtilsExecSQL)(span, SQL_QUERIES.DELETE_TOKENS_BY_USER, [userId]);
71
+ span.end();
72
+ }
73
+ // Private Functions
74
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
75
+ function fromRaw(tokenRaw) {
76
+ const apiToken = new UserApiToken_1.UserApiToken();
77
+ apiToken.id = tokenRaw.id;
78
+ apiToken.name = tokenRaw.name;
79
+ apiToken.userId = tokenRaw.userId;
80
+ apiToken.tokenHash = tokenRaw.tokenHash;
81
+ apiToken.dateCreated = tokenRaw.dateCreated;
82
+ return apiToken;
83
+ }
84
+ // SQL
85
+ // Written SQLite-first with quoted identifiers (valid for both backends);
86
+ // the DbUtils facade converts `?` placeholders for Postgres.
87
+ const SQL_QUERIES = {
88
+ GET_TOKEN_BY_ID: 'SELECT * FROM users_api_tokens WHERE "id" = ?',
89
+ GET_TOKEN_BY_HASH: 'SELECT * FROM users_api_tokens WHERE "tokenHash" = ?',
90
+ LIST_TOKENS_BY_USER: 'SELECT * FROM users_api_tokens WHERE "userId" = ?',
91
+ INSERT_TOKEN: 'INSERT INTO users_api_tokens ("id", "name", "userId", "tokenHash", "dateCreated") VALUES (?, ?, ?, ?, ?)',
92
+ DELETE_TOKEN: 'DELETE FROM users_api_tokens WHERE "id" = ?',
93
+ DELETE_TOKENS_BY_USER: 'DELETE FROM users_api_tokens WHERE "userId" = ?',
94
+ };
@@ -1,10 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.UsersRoutes = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const uuid_1 = require("uuid");
4
6
  const Auth_1 = require("./Auth");
5
7
  const User_1 = require("./User");
8
+ const UserApiToken_1 = require("./UserApiToken");
6
9
  const UserPassword_1 = require("./UserPassword");
7
10
  const UsersData_1 = require("./UsersData");
11
+ const UsersApiTokensData_1 = require("./UsersApiTokensData");
8
12
  /**
9
13
  * Retrieves the OTel span attached to the request by the
10
14
  * `@devopsplaybook.io/otel-utils-fastify` hooks.
@@ -198,8 +202,61 @@ class UsersRoutes {
198
202
  }
199
203
  }
200
204
  await (0, UsersData_1.UsersDataDelete)(context, req.params.id);
205
+ await (0, UsersApiTokensData_1.UsersApiTokensDataDeleteByUser)(context, req.params.id);
201
206
  res.status(201).send({});
202
207
  });
208
+ fastify.post("/tokens", async (req, res) => {
209
+ const context = requestSpan(req);
210
+ const userSession = await (0, Auth_1.AuthGetUserSession)(req);
211
+ if (!userSession.isAuthenticated) {
212
+ return res.status(403).send({ error: "Access Denied" });
213
+ }
214
+ if (!req.body || !req.body.name) {
215
+ return res.status(400).send({ error: "Missing: Name" });
216
+ }
217
+ const apiToken = new UserApiToken_1.UserApiToken();
218
+ apiToken.name = req.body.name;
219
+ // isAuthenticated implies userId is set
220
+ apiToken.userId = userSession.userId;
221
+ apiToken.dateCreated = new Date().toISOString();
222
+ const token = (0, uuid_1.v4)();
223
+ apiToken.tokenHash = (0, crypto_1.createHash)("sha256").update(token).digest("hex");
224
+ await (0, UsersApiTokensData_1.UsersApiTokensDataAdd)(context, apiToken);
225
+ // The plaintext token is returned once; only its hash is stored.
226
+ return res.status(201).send({
227
+ token,
228
+ ...apiToken.toTransportJson(),
229
+ });
230
+ });
231
+ fastify.get("/tokens", async (req, res) => {
232
+ const context = requestSpan(req);
233
+ const userSession = await (0, Auth_1.AuthGetUserSession)(req);
234
+ if (!userSession.isAuthenticated) {
235
+ return res.status(403).send({ error: "Access Denied" });
236
+ }
237
+ const apiTokens = await (0, UsersApiTokensData_1.UsersApiTokensDataListByUser)(context, userSession.userId);
238
+ return res
239
+ .status(200)
240
+ .send(apiTokens.map((t) => t.toTransportJson()));
241
+ });
242
+ fastify.delete("/tokens/:id", async (req, res) => {
243
+ const context = requestSpan(req);
244
+ const userSession = await (0, Auth_1.AuthGetUserSession)(req);
245
+ if (!userSession.isAuthenticated) {
246
+ return res.status(403).send({ error: "Access Denied" });
247
+ }
248
+ const apiToken = await (0, UsersApiTokensData_1.UsersApiTokensDataGet)(context, req.params.id);
249
+ if (!apiToken) {
250
+ return res.status(404).send({ error: "API Token Not Found" });
251
+ }
252
+ // Owner can always revoke; admins can revoke any token
253
+ if (apiToken.userId !== userSession.userId &&
254
+ userSession.role !== "admin") {
255
+ return res.status(403).send({ error: "Access Denied" });
256
+ }
257
+ await (0, UsersApiTokensData_1.UsersApiTokensDataDelete)(context, apiToken.id);
258
+ return res.status(201).send({});
259
+ });
203
260
  }
204
261
  }
205
262
  exports.UsersRoutes = UsersRoutes;
package/index.ts CHANGED
@@ -10,7 +10,9 @@ export * from "./src/SystemCommand";
10
10
  export * from "./src/Timeout";
11
11
  export * from "./src/users/User";
12
12
  export * from "./src/users/UserSession";
13
+ export * from "./src/users/UserApiToken";
13
14
  export * from "./src/users/Auth";
14
15
  export * from "./src/users/UserPassword";
15
16
  export * from "./src/users/UsersData";
17
+ export * from "./src/users/UsersApiTokensData";
16
18
  export * from "./src/users/UsersRoutes";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devopsplaybook.io/common-utils",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Shared utility modules for devopsplaybook.io projects (DB, Config, OTel context, auth/users, notifications, LLM, system helpers)",
5
5
  "keywords": [
6
6
  "Open Telemetry",
@@ -0,0 +1,268 @@
1
+ jest.mock("uuid", () => ({
2
+ v4: () => "mock-uuid-1234",
3
+ }));
4
+
5
+ jest.mock("../DbUtils", () => ({
6
+ DbUtilsQuerySQL: jest.fn(),
7
+ DbUtilsExecSQL: jest.fn(),
8
+ }));
9
+
10
+ jest.mock("./UsersApiTokensData", () => ({
11
+ UsersApiTokensDataGetByTokenHash: jest.fn(),
12
+ }));
13
+
14
+ jest.mock("./UsersData", () => ({
15
+ UsersDataGet: jest.fn(),
16
+ }));
17
+
18
+ import { StandardTracer } from "@devopsplaybook.io/otel-utils";
19
+ import { DbUtilsExecSQL, DbUtilsQuerySQL } from "../DbUtils";
20
+ import {
21
+ AuthGenerateJWT,
22
+ AuthGetUserSession,
23
+ AuthHasScope,
24
+ AuthInit,
25
+ AuthMustBeAdmin,
26
+ AuthMustBeAuthenticated,
27
+ AuthSetOTel,
28
+ } from "./Auth";
29
+ import { User } from "./User";
30
+ import { UsersApiTokensDataGetByTokenHash } from "./UsersApiTokensData";
31
+ import { UsersDataGet } from "./UsersData";
32
+
33
+ const mockedGetByHash = UsersApiTokensDataGetByTokenHash as jest.Mock;
34
+ const mockedUsersDataGet = UsersDataGet as jest.Mock;
35
+ const mockedQuery = DbUtilsQuerySQL as jest.Mock;
36
+ const mockedExec = DbUtilsExecSQL as jest.Mock;
37
+
38
+ const mockTracer = {
39
+ startSpan: () => ({ end: () => undefined }),
40
+ } as unknown as StandardTracer;
41
+
42
+ function mockRes() {
43
+ const res = {
44
+ status: jest.fn().mockReturnThis(),
45
+ send: jest.fn(),
46
+ };
47
+ return res;
48
+ }
49
+
50
+ function mockReq(authorization?: string) {
51
+ return {
52
+ headers: authorization ? { authorization } : {},
53
+ };
54
+ }
55
+
56
+ async function expectAccessDenied(guard: Promise<void>, res: ReturnType<typeof mockRes>) {
57
+ await expect(guard).rejects.toThrow("Access Denied");
58
+ expect(res.status).toHaveBeenCalledWith(403);
59
+ expect(res.send).toHaveBeenCalledWith({ error: "Access Denied" });
60
+ }
61
+
62
+ beforeAll(async () => {
63
+ AuthSetOTel(mockTracer);
64
+ const config = {
65
+ JWT_KEY: "",
66
+ JWT_VALIDITY_DURATION: 3600,
67
+ DATABASE_TYPE: "sqlite" as const,
68
+ };
69
+ mockedQuery.mockResolvedValue([]);
70
+ await AuthInit(null as never, config, ["traces", "metrics", "logs"]);
71
+ });
72
+
73
+ beforeEach(() => {
74
+ mockedGetByHash.mockReset();
75
+ mockedUsersDataGet.mockReset();
76
+ });
77
+
78
+ describe("JWT authentication", () => {
79
+ it("should authenticate a valid JWT", async () => {
80
+ const user = new User();
81
+ user.name = "jwt-user";
82
+ user.role = "user";
83
+ user.scopes = ["traces"];
84
+ const jwt = await AuthGenerateJWT(user);
85
+
86
+ const res = mockRes();
87
+ await AuthMustBeAuthenticated(mockReq(`Bearer ${jwt}`), res);
88
+
89
+ expect(res.status).not.toHaveBeenCalled();
90
+ });
91
+
92
+ it("should reject a request without authorization header", async () => {
93
+ const res = mockRes();
94
+ await expectAccessDenied(
95
+ AuthMustBeAuthenticated(mockReq(), res),
96
+ res,
97
+ );
98
+ });
99
+
100
+ it("should reject an invalid JWT", async () => {
101
+ const res = mockRes();
102
+ await expectAccessDenied(
103
+ AuthMustBeAuthenticated(mockReq("Bearer not-a-jwt"), res),
104
+ res,
105
+ );
106
+ expect(mockedGetByHash).toHaveBeenCalledTimes(1);
107
+ });
108
+ });
109
+
110
+ describe("API token authentication", () => {
111
+ it("should authenticate a valid API token and resolve the owning user session", async () => {
112
+ const user = new User();
113
+ user.id = "user-1";
114
+ user.name = "token-user";
115
+ user.role = "user";
116
+ user.scopes = ["traces"];
117
+ const apiToken = new User();
118
+ apiToken.id = "token-1";
119
+ mockedGetByHash.mockResolvedValue(apiToken);
120
+ mockedUsersDataGet.mockResolvedValue(user);
121
+
122
+ const res = mockRes();
123
+ await AuthMustBeAuthenticated(mockReq("Bearer plain-api-token"), res);
124
+ expect(res.status).not.toHaveBeenCalled();
125
+
126
+ const session = await AuthGetUserSession(mockReq("Bearer plain-api-token"));
127
+ expect(session.isAuthenticated).toBe(true);
128
+ expect(session.userId).toBe("user-1");
129
+ expect(session.userName).toBe("token-user");
130
+ expect(session.role).toBe("user");
131
+ expect(session.scopes).toEqual(["traces"]);
132
+ });
133
+
134
+ it("should pass AuthMustBeAdmin for an API token owned by an admin", async () => {
135
+ const admin = new User();
136
+ admin.id = "admin-1";
137
+ admin.name = "token-admin";
138
+ admin.role = "admin";
139
+ mockedGetByHash.mockResolvedValue(new User());
140
+ mockedUsersDataGet.mockResolvedValue(admin);
141
+
142
+ const res = mockRes();
143
+ await AuthMustBeAdmin(mockReq("Bearer admin-api-token"), res);
144
+ expect(res.status).not.toHaveBeenCalled();
145
+
146
+ // Admin tokens get the full scope set, mirroring JWT semantics
147
+ const session = await AuthGetUserSession(mockReq("Bearer admin-api-token"));
148
+ expect(session.scopes).toEqual(["traces", "metrics", "logs"]);
149
+ });
150
+
151
+ it("should fail AuthMustBeAdmin for an API token owned by a non-admin", async () => {
152
+ const user = new User();
153
+ user.id = "user-1";
154
+ user.name = "token-user";
155
+ user.role = "user";
156
+ user.scopes = ["traces"];
157
+ mockedGetByHash.mockResolvedValue(new User());
158
+ mockedUsersDataGet.mockResolvedValue(user);
159
+
160
+ const res = mockRes();
161
+ await expectAccessDenied(
162
+ AuthMustBeAdmin(mockReq("Bearer user-api-token"), res),
163
+ res,
164
+ );
165
+ });
166
+
167
+ it("should pass AuthHasScope when the owning user has the scope", async () => {
168
+ const user = new User();
169
+ user.id = "user-1";
170
+ user.name = "token-user";
171
+ user.role = "user";
172
+ user.scopes = ["traces"];
173
+ mockedGetByHash.mockResolvedValue(new User());
174
+ mockedUsersDataGet.mockResolvedValue(user);
175
+
176
+ const res = mockRes();
177
+ await AuthHasScope(mockReq("Bearer scoped-api-token"), res, "traces");
178
+ expect(res.status).not.toHaveBeenCalled();
179
+ });
180
+
181
+ it("should fail AuthHasScope when the owning user lacks the scope", async () => {
182
+ const user = new User();
183
+ user.id = "user-1";
184
+ user.name = "token-user";
185
+ user.role = "user";
186
+ user.scopes = ["traces"];
187
+ mockedGetByHash.mockResolvedValue(new User());
188
+ mockedUsersDataGet.mockResolvedValue(user);
189
+
190
+ const res = mockRes();
191
+ await expectAccessDenied(
192
+ AuthHasScope(mockReq("Bearer scoped-api-token"), res, "metrics"),
193
+ res,
194
+ );
195
+ });
196
+
197
+ it("should reject an unknown or revoked API token", async () => {
198
+ mockedGetByHash.mockResolvedValue(null);
199
+
200
+ const res = mockRes();
201
+ await expectAccessDenied(
202
+ AuthMustBeAuthenticated(mockReq("Bearer revoked-token"), res),
203
+ res,
204
+ );
205
+ expect(mockedUsersDataGet).not.toHaveBeenCalled();
206
+ });
207
+
208
+ it("should reject an API token whose user no longer exists", async () => {
209
+ mockedGetByHash.mockResolvedValue(new User());
210
+ mockedUsersDataGet.mockResolvedValue(null);
211
+
212
+ const res = mockRes();
213
+ await expectAccessDenied(
214
+ AuthMustBeAuthenticated(mockReq("Bearer orphan-token"), res),
215
+ res,
216
+ );
217
+ });
218
+
219
+ it("should resolve the token only once per request (payload caching)", async () => {
220
+ const user = new User();
221
+ user.id = "user-1";
222
+ user.name = "token-user";
223
+ user.role = "user";
224
+ user.scopes = ["traces"];
225
+ mockedGetByHash.mockResolvedValue(new User());
226
+ mockedUsersDataGet.mockResolvedValue(user);
227
+
228
+ const req = mockReq("Bearer cached-token");
229
+ await AuthMustBeAuthenticated(req, mockRes());
230
+ await AuthHasScope(req, mockRes(), "traces");
231
+ await AuthGetUserSession(req);
232
+
233
+ expect(mockedGetByHash).toHaveBeenCalledTimes(1);
234
+ expect(mockedUsersDataGet).toHaveBeenCalledTimes(1);
235
+ });
236
+ });
237
+
238
+ describe("AuthInit", () => {
239
+ it("should load the existing JWT key from metadata", async () => {
240
+ mockedExec.mockReset();
241
+ mockedQuery.mockReset();
242
+ const config = {
243
+ JWT_KEY: "",
244
+ JWT_VALIDITY_DURATION: 3600,
245
+ DATABASE_TYPE: "sqlite" as const,
246
+ };
247
+ mockedQuery.mockResolvedValue([{ value: "stored-key" }]);
248
+ await AuthInit(null as never, config, ["traces"]);
249
+
250
+ expect(config.JWT_KEY).toBe("stored-key");
251
+ expect(mockedExec).not.toHaveBeenCalled();
252
+ });
253
+
254
+ it("should generate and persist a JWT key when none is stored", async () => {
255
+ mockedExec.mockReset();
256
+ mockedQuery.mockReset();
257
+ const config = {
258
+ JWT_KEY: "",
259
+ JWT_VALIDITY_DURATION: 3600,
260
+ DATABASE_TYPE: "sqlite" as const,
261
+ };
262
+ mockedQuery.mockResolvedValue([]);
263
+ await AuthInit(null as never, config, ["traces"]);
264
+
265
+ expect(config.JWT_KEY).toBe("mock-uuid-1234");
266
+ expect(mockedExec).toHaveBeenCalledTimes(1);
267
+ });
268
+ });
package/src/users/Auth.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from "crypto";
1
2
  import { StandardTracer } from "@devopsplaybook.io/otel-utils";
2
3
  import { Span } from "@opentelemetry/sdk-trace-base";
3
4
  import * as jwt from "jsonwebtoken";
@@ -5,6 +6,8 @@ import { v4 as uuidv4 } from "uuid";
5
6
  import { DbUtilsExecSQL, DbUtilsQuerySQL } from "../DbUtils";
6
7
  import { User, UserScope } from "./User";
7
8
  import { UserSession } from "./UserSession";
9
+ import { UsersApiTokensDataGetByTokenHash } from "./UsersApiTokensData";
10
+ import { UsersDataGet } from "./UsersData";
8
11
 
9
12
  /**
10
13
  * Configuration subset required by the auth module.
@@ -72,11 +75,18 @@ export async function AuthGenerateJWT(user: User): Promise<string> {
72
75
  }
73
76
 
74
77
  /**
75
- * Decode JWT from request, caching result on req._jwtPayload to avoid
76
- * redundant verification when multiple auth functions are called per request.
78
+ * Decode credentials from request, caching result on req._jwtPayload to
79
+ * avoid redundant resolution when multiple auth functions are called per
80
+ * request.
81
+ *
82
+ * A `Bearer` credential is first verified as a JWT. When JWT verification
83
+ * fails, the credential is resolved as a user API token: the value is
84
+ * SHA-256 hashed and looked up in `users_api_tokens`; on match, a payload
85
+ * mirroring the owning user's live role and scopes is built (valid until
86
+ * the token is revoked).
77
87
  */
78
88
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
- function jwtDecodeCached(req: any): any | null {
89
+ async function jwtDecodeCached(req: any): Promise<any | null> {
80
90
  if (req._jwtPayload) {
81
91
  return req._jwtPayload;
82
92
  }
@@ -91,17 +101,53 @@ function jwtDecodeCached(req: any): any | null {
91
101
  req._jwtPayload = info;
92
102
  return info;
93
103
  } catch {
104
+ const info = await resolveApiToken(req.headers.authorization);
105
+ if (info) {
106
+ req._jwtPayload = info;
107
+ return info;
108
+ }
94
109
  return null;
95
110
  }
96
111
  }
97
112
 
113
+ /**
114
+ * Resolve an API token bearer credential to a user-backed payload.
115
+ * Permissions are read from the user record at resolution time, so
116
+ * role/scope changes apply to existing tokens immediately.
117
+ */
118
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
119
+ async function resolveApiToken(authorizationHeader: string): Promise<any | null> {
120
+ const token = authorizationHeader.split(" ")[1];
121
+ if (!token) {
122
+ return null;
123
+ }
124
+ const span = tracer.startSpan("AuthResolveApiToken");
125
+ const tokenHash = createHash("sha256").update(token).digest("hex");
126
+ const apiToken = await UsersApiTokensDataGetByTokenHash(span, tokenHash);
127
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
128
+ let payload: any | null = null;
129
+ if (apiToken) {
130
+ const user = await UsersDataGet(span, apiToken.userId);
131
+ if (user) {
132
+ payload = {
133
+ userId: user.id,
134
+ userName: user.name,
135
+ role: user.role,
136
+ scopes: user.role === "admin" ? [...User.ALL_SCOPES] : user.scopes,
137
+ };
138
+ }
139
+ }
140
+ span.end();
141
+ return payload;
142
+ }
143
+
98
144
  export async function AuthMustBeAuthenticated(
99
145
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
100
146
  req: any,
101
147
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
102
148
  res: any,
103
149
  ): Promise<void> {
104
- if (!jwtDecodeCached(req)) {
150
+ if (!(await jwtDecodeCached(req))) {
105
151
  res.status(403).send({ error: "Access Denied" });
106
152
  throw new Error("Access Denied");
107
153
  }
@@ -109,7 +155,7 @@ export async function AuthMustBeAuthenticated(
109
155
 
110
156
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
111
157
  export async function AuthMustBeAdmin(req: any, res: any): Promise<void> {
112
- const info = jwtDecodeCached(req);
158
+ const info = await jwtDecodeCached(req);
113
159
  if (info?.role === "admin") {
114
160
  return;
115
161
  }
@@ -124,7 +170,7 @@ export async function AuthHasScope(
124
170
  res: any,
125
171
  scope: UserScope,
126
172
  ): Promise<void> {
127
- const info = jwtDecodeCached(req);
173
+ const info = await jwtDecodeCached(req);
128
174
  if (!info) {
129
175
  res.status(403).send({ error: "Access Denied" });
130
176
  throw new Error("Access Denied");
@@ -143,7 +189,7 @@ export async function AuthHasScope(
143
189
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
144
190
  export async function AuthGetUserSession(req: any): Promise<UserSession> {
145
191
  const userSession: UserSession = { isAuthenticated: false };
146
- const info = jwtDecodeCached(req);
192
+ const info = await jwtDecodeCached(req);
147
193
  if (info) {
148
194
  userSession.userId = info.userId;
149
195
  userSession.userName = info.userName;
@@ -0,0 +1,58 @@
1
+ import { v4 as uuidv4 } from "uuid";
2
+
3
+ /**
4
+ * User-scoped API token.
5
+ *
6
+ * Only the SHA-256 hash of the token value is ever stored; the plaintext
7
+ * value is generated at creation time and shown to the user exactly once.
8
+ * A token grants the same permissions as the user it belongs to, resolved
9
+ * live on each request (role/scope changes apply immediately).
10
+ */
11
+ export class UserApiToken {
12
+ //
13
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
+ public static fromJson(json: any): UserApiToken | null {
15
+ if (!json) {
16
+ return null;
17
+ }
18
+ const apiToken = new UserApiToken();
19
+ apiToken.id = json.id;
20
+ apiToken.name = json.name;
21
+ apiToken.userId = json.userId;
22
+ apiToken.tokenHash = json.tokenHash;
23
+ apiToken.dateCreated = json.dateCreated;
24
+ return apiToken;
25
+ }
26
+
27
+ public id: string;
28
+ public name!: string;
29
+ public userId!: string;
30
+ public tokenHash!: string;
31
+ public dateCreated!: string;
32
+
33
+ constructor() {
34
+ this.id = uuidv4();
35
+ }
36
+
37
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
38
+ public toJson(): any {
39
+ return {
40
+ id: this.id,
41
+ name: this.name,
42
+ userId: this.userId,
43
+ tokenHash: this.tokenHash,
44
+ dateCreated: this.dateCreated,
45
+ };
46
+ }
47
+
48
+ // Transport representation: never exposes the token hash.
49
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
50
+ public toTransportJson(): any {
51
+ return {
52
+ id: this.id,
53
+ name: this.name,
54
+ userId: this.userId,
55
+ dateCreated: this.dateCreated,
56
+ };
57
+ }
58
+ }
@@ -0,0 +1,158 @@
1
+ jest.mock("uuid", () => ({
2
+ v4: () => "mock-uuid-1234",
3
+ }));
4
+
5
+ jest.mock("../DbUtils", () => ({
6
+ DbUtilsQuerySQL: jest.fn(),
7
+ DbUtilsExecSQL: jest.fn(),
8
+ }));
9
+
10
+ import { StandardTracer } from "@devopsplaybook.io/otel-utils";
11
+ import { DbUtilsExecSQL, DbUtilsQuerySQL } from "../DbUtils";
12
+ import { UserApiToken } from "./UserApiToken";
13
+ import {
14
+ UsersApiTokensDataAdd,
15
+ UsersApiTokensDataDelete,
16
+ UsersApiTokensDataDeleteByUser,
17
+ UsersApiTokensDataGet,
18
+ UsersApiTokensDataGetByTokenHash,
19
+ UsersApiTokensDataListByUser,
20
+ UsersApiTokensDataSetOTel,
21
+ } from "./UsersApiTokensData";
22
+
23
+ const mockedQuery = DbUtilsQuerySQL as jest.Mock;
24
+ const mockedExec = DbUtilsExecSQL as jest.Mock;
25
+
26
+ const mockTracer = {
27
+ startSpan: () => ({ end: () => undefined }),
28
+ } as unknown as StandardTracer;
29
+
30
+ beforeAll(() => {
31
+ UsersApiTokensDataSetOTel(mockTracer);
32
+ });
33
+
34
+ beforeEach(() => {
35
+ mockedQuery.mockReset();
36
+ mockedExec.mockReset();
37
+ });
38
+
39
+ describe("UsersApiTokensData", () => {
40
+ it("should add a token storing only its hash", async () => {
41
+ const apiToken = new UserApiToken();
42
+ apiToken.name = "CI token";
43
+ apiToken.userId = "user-1";
44
+ apiToken.tokenHash = "abc123hash";
45
+ apiToken.dateCreated = "2026-09-14T00:00:00.000Z";
46
+
47
+ await UsersApiTokensDataAdd(undefined, apiToken);
48
+
49
+ expect(mockedExec).toHaveBeenCalledTimes(1);
50
+ const [, sql, params] = mockedExec.mock.calls[0];
51
+ expect(sql).toContain("INSERT INTO users_api_tokens");
52
+ expect(params).toEqual([
53
+ "mock-uuid-1234",
54
+ "CI token",
55
+ "user-1",
56
+ "abc123hash",
57
+ "2026-09-14T00:00:00.000Z",
58
+ ]);
59
+ expect(sql).toContain("tokenHash");
60
+ });
61
+
62
+ it("should get a token by hash", async () => {
63
+ mockedQuery.mockResolvedValue([
64
+ {
65
+ id: "token-1",
66
+ name: "CI token",
67
+ userId: "user-1",
68
+ tokenHash: "abc123hash",
69
+ dateCreated: "2026-09-14T00:00:00.000Z",
70
+ },
71
+ ]);
72
+
73
+ const apiToken = await UsersApiTokensDataGetByTokenHash(
74
+ undefined,
75
+ "abc123hash",
76
+ );
77
+
78
+ expect(mockedQuery).toHaveBeenCalledTimes(1);
79
+ const [, sql, params] = mockedQuery.mock.calls[0];
80
+ expect(sql).toBe('SELECT * FROM users_api_tokens WHERE "tokenHash" = ?');
81
+ expect(params).toEqual(["abc123hash"]);
82
+ expect(apiToken?.id).toBe("token-1");
83
+ expect(apiToken?.userId).toBe("user-1");
84
+ });
85
+
86
+ it("should return null when no token matches the hash", async () => {
87
+ mockedQuery.mockResolvedValue([]);
88
+
89
+ const apiToken = await UsersApiTokensDataGetByTokenHash(
90
+ undefined,
91
+ "unknown-hash",
92
+ );
93
+
94
+ expect(apiToken).toBeNull();
95
+ });
96
+
97
+ it("should get a token by id", async () => {
98
+ mockedQuery.mockResolvedValue([
99
+ {
100
+ id: "token-1",
101
+ name: "CI token",
102
+ userId: "user-1",
103
+ tokenHash: "abc123hash",
104
+ dateCreated: "2026-09-14T00:00:00.000Z",
105
+ },
106
+ ]);
107
+
108
+ const apiToken = await UsersApiTokensDataGet(undefined, "token-1");
109
+
110
+ const [, sql, params] = mockedQuery.mock.calls[0];
111
+ expect(sql).toBe('SELECT * FROM users_api_tokens WHERE "id" = ?');
112
+ expect(params).toEqual(["token-1"]);
113
+ expect(apiToken?.name).toBe("CI token");
114
+ });
115
+
116
+ it("should list tokens of a user without exposing the hash in transport json", async () => {
117
+ mockedQuery.mockResolvedValue([
118
+ {
119
+ id: "token-1",
120
+ name: "CI token",
121
+ userId: "user-1",
122
+ tokenHash: "abc123hash",
123
+ dateCreated: "2026-09-14T00:00:00.000Z",
124
+ },
125
+ ]);
126
+
127
+ const apiTokens = await UsersApiTokensDataListByUser(undefined, "user-1");
128
+
129
+ const [, sql, params] = mockedQuery.mock.calls[0];
130
+ expect(sql).toBe('SELECT * FROM users_api_tokens WHERE "userId" = ?');
131
+ expect(params).toEqual(["user-1"]);
132
+ expect(apiTokens.length).toBe(1);
133
+ const transport = apiTokens[0].toTransportJson();
134
+ expect(transport).toEqual({
135
+ id: "token-1",
136
+ name: "CI token",
137
+ userId: "user-1",
138
+ dateCreated: "2026-09-14T00:00:00.000Z",
139
+ });
140
+ expect(transport.tokenHash).toBeUndefined();
141
+ });
142
+
143
+ it("should delete a token by id", async () => {
144
+ await UsersApiTokensDataDelete(undefined, "token-1");
145
+
146
+ const [, sql, params] = mockedExec.mock.calls[0];
147
+ expect(sql).toBe('DELETE FROM users_api_tokens WHERE "id" = ?');
148
+ expect(params).toEqual(["token-1"]);
149
+ });
150
+
151
+ it("should delete all tokens of a user", async () => {
152
+ await UsersApiTokensDataDeleteByUser(undefined, "user-1");
153
+
154
+ const [, sql, params] = mockedExec.mock.calls[0];
155
+ expect(sql).toBe('DELETE FROM users_api_tokens WHERE "userId" = ?');
156
+ expect(params).toEqual(["user-1"]);
157
+ });
158
+ });
@@ -0,0 +1,126 @@
1
+ import { StandardTracer } from "@devopsplaybook.io/otel-utils";
2
+ import { Span } from "@opentelemetry/sdk-trace-base";
3
+ import { DbUtilsExecSQL, DbUtilsQuerySQL } from "../DbUtils";
4
+ import { UserApiToken } from "./UserApiToken";
5
+
6
+ let tracer: StandardTracer;
7
+
8
+ /**
9
+ * Injects the OTel tracer instance used by the API tokens data module.
10
+ * Must be called once at startup, before any `UsersApiTokensData*` function.
11
+ */
12
+ export function UsersApiTokensDataSetOTel(tracerIn: StandardTracer): void {
13
+ tracer = tracerIn;
14
+ }
15
+
16
+ export async function UsersApiTokensDataGet(
17
+ context: Span | undefined,
18
+ id: string,
19
+ ): Promise<UserApiToken | null> {
20
+ const span = tracer.startSpan("UsersApiTokensDataGet", context);
21
+ const tokensRaw = await DbUtilsQuerySQL(span, SQL_QUERIES.GET_TOKEN_BY_ID, [
22
+ id,
23
+ ]);
24
+ let apiToken: UserApiToken | null = null;
25
+ if (tokensRaw.length > 0) {
26
+ apiToken = fromRaw(tokensRaw[0]);
27
+ }
28
+ span.end();
29
+ return apiToken;
30
+ }
31
+
32
+ export async function UsersApiTokensDataGetByTokenHash(
33
+ context: Span | undefined,
34
+ tokenHash: string,
35
+ ): Promise<UserApiToken | null> {
36
+ const span = tracer.startSpan("UsersApiTokensDataGetByTokenHash", context);
37
+ const tokensRaw = await DbUtilsQuerySQL(
38
+ span,
39
+ SQL_QUERIES.GET_TOKEN_BY_HASH,
40
+ [tokenHash],
41
+ );
42
+ let apiToken: UserApiToken | null = null;
43
+ if (tokensRaw.length > 0) {
44
+ apiToken = fromRaw(tokensRaw[0]);
45
+ }
46
+ span.end();
47
+ return apiToken;
48
+ }
49
+
50
+ export async function UsersApiTokensDataListByUser(
51
+ context: Span | undefined,
52
+ userId: string,
53
+ ): Promise<UserApiToken[]> {
54
+ const span = tracer.startSpan("UsersApiTokensDataListByUser", context);
55
+ const tokensRaw = await DbUtilsQuerySQL(
56
+ span,
57
+ SQL_QUERIES.LIST_TOKENS_BY_USER,
58
+ [userId],
59
+ );
60
+ const apiTokens: UserApiToken[] = [];
61
+ for (const tokenRaw of tokensRaw) {
62
+ apiTokens.push(fromRaw(tokenRaw));
63
+ }
64
+ span.end();
65
+ return apiTokens;
66
+ }
67
+
68
+ export async function UsersApiTokensDataAdd(
69
+ context: Span | undefined,
70
+ apiToken: UserApiToken,
71
+ ): Promise<void> {
72
+ const span = tracer.startSpan("UsersApiTokensDataAdd", context);
73
+ await DbUtilsExecSQL(span, SQL_QUERIES.INSERT_TOKEN, [
74
+ apiToken.id,
75
+ apiToken.name,
76
+ apiToken.userId,
77
+ apiToken.tokenHash,
78
+ apiToken.dateCreated,
79
+ ]);
80
+ span.end();
81
+ }
82
+
83
+ export async function UsersApiTokensDataDelete(
84
+ context: Span | undefined,
85
+ id: string,
86
+ ): Promise<void> {
87
+ const span = tracer.startSpan("UsersApiTokensDataDelete", context);
88
+ await DbUtilsExecSQL(span, SQL_QUERIES.DELETE_TOKEN, [id]);
89
+ span.end();
90
+ }
91
+
92
+ export async function UsersApiTokensDataDeleteByUser(
93
+ context: Span | undefined,
94
+ userId: string,
95
+ ): Promise<void> {
96
+ const span = tracer.startSpan("UsersApiTokensDataDeleteByUser", context);
97
+ await DbUtilsExecSQL(span, SQL_QUERIES.DELETE_TOKENS_BY_USER, [userId]);
98
+ span.end();
99
+ }
100
+
101
+ // Private Functions
102
+
103
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
104
+ function fromRaw(tokenRaw: any): UserApiToken {
105
+ const apiToken = new UserApiToken();
106
+ apiToken.id = tokenRaw.id;
107
+ apiToken.name = tokenRaw.name;
108
+ apiToken.userId = tokenRaw.userId;
109
+ apiToken.tokenHash = tokenRaw.tokenHash;
110
+ apiToken.dateCreated = tokenRaw.dateCreated;
111
+ return apiToken;
112
+ }
113
+
114
+ // SQL
115
+ // Written SQLite-first with quoted identifiers (valid for both backends);
116
+ // the DbUtils facade converts `?` placeholders for Postgres.
117
+
118
+ const SQL_QUERIES = {
119
+ GET_TOKEN_BY_ID: 'SELECT * FROM users_api_tokens WHERE "id" = ?',
120
+ GET_TOKEN_BY_HASH: 'SELECT * FROM users_api_tokens WHERE "tokenHash" = ?',
121
+ LIST_TOKENS_BY_USER: 'SELECT * FROM users_api_tokens WHERE "userId" = ?',
122
+ INSERT_TOKEN:
123
+ 'INSERT INTO users_api_tokens ("id", "name", "userId", "tokenHash", "dateCreated") VALUES (?, ?, ?, ?, ?)',
124
+ DELETE_TOKEN: 'DELETE FROM users_api_tokens WHERE "id" = ?',
125
+ DELETE_TOKENS_BY_USER: 'DELETE FROM users_api_tokens WHERE "userId" = ?',
126
+ };
@@ -1,7 +1,10 @@
1
+ import { createHash } from "crypto";
1
2
  import { Span } from "@opentelemetry/sdk-trace-base";
2
3
  import { FastifyInstance, RequestGenericInterface } from "fastify";
4
+ import { v4 as uuidv4 } from "uuid";
3
5
  import { AuthGenerateJWT, AuthGetUserSession, AuthMustBeAdmin } from "./Auth";
4
6
  import { User } from "./User";
7
+ import { UserApiToken } from "./UserApiToken";
5
8
  import {
6
9
  UserPasswordCheckPassword,
7
10
  UserPasswordSetPassword,
@@ -15,6 +18,13 @@ import {
15
18
  UsersDataUpdatePassword,
16
19
  UsersDataUpdateUser,
17
20
  } from "./UsersData";
21
+ import {
22
+ UsersApiTokensDataAdd,
23
+ UsersApiTokensDataDelete,
24
+ UsersApiTokensDataDeleteByUser,
25
+ UsersApiTokensDataGet,
26
+ UsersApiTokensDataListByUser,
27
+ } from "./UsersApiTokensData";
18
28
 
19
29
  /**
20
30
  * Retrieves the OTel span attached to the request by the
@@ -286,7 +296,80 @@ export class UsersRoutes {
286
296
  }
287
297
 
288
298
  await UsersDataDelete(context, req.params.id);
299
+ await UsersApiTokensDataDeleteByUser(context, req.params.id);
289
300
  res.status(201).send({});
290
301
  });
302
+
303
+ // ==================== API TOKENS (self-service) ====================
304
+
305
+ interface PostApiToken extends RequestGenericInterface {
306
+ Body: {
307
+ name: string;
308
+ };
309
+ }
310
+ fastify.post<PostApiToken>("/tokens", async (req, res) => {
311
+ const context = requestSpan(req);
312
+ const userSession = await AuthGetUserSession(req);
313
+ if (!userSession.isAuthenticated) {
314
+ return res.status(403).send({ error: "Access Denied" });
315
+ }
316
+ if (!req.body || !req.body.name) {
317
+ return res.status(400).send({ error: "Missing: Name" });
318
+ }
319
+ const apiToken = new UserApiToken();
320
+ apiToken.name = req.body.name;
321
+ // isAuthenticated implies userId is set
322
+ apiToken.userId = userSession.userId as string;
323
+ apiToken.dateCreated = new Date().toISOString();
324
+ const token = uuidv4();
325
+ apiToken.tokenHash = createHash("sha256").update(token).digest("hex");
326
+ await UsersApiTokensDataAdd(context, apiToken);
327
+ // The plaintext token is returned once; only its hash is stored.
328
+ return res.status(201).send({
329
+ token,
330
+ ...apiToken.toTransportJson(),
331
+ });
332
+ });
333
+
334
+ fastify.get("/tokens", async (req, res) => {
335
+ const context = requestSpan(req);
336
+ const userSession = await AuthGetUserSession(req);
337
+ if (!userSession.isAuthenticated) {
338
+ return res.status(403).send({ error: "Access Denied" });
339
+ }
340
+ const apiTokens = await UsersApiTokensDataListByUser(
341
+ context,
342
+ userSession.userId as string,
343
+ );
344
+ return res
345
+ .status(200)
346
+ .send(apiTokens.map((t) => t.toTransportJson()));
347
+ });
348
+
349
+ interface DeleteApiToken extends RequestGenericInterface {
350
+ Params: {
351
+ id: string;
352
+ };
353
+ }
354
+ fastify.delete<DeleteApiToken>("/tokens/:id", async (req, res) => {
355
+ const context = requestSpan(req);
356
+ const userSession = await AuthGetUserSession(req);
357
+ if (!userSession.isAuthenticated) {
358
+ return res.status(403).send({ error: "Access Denied" });
359
+ }
360
+ const apiToken = await UsersApiTokensDataGet(context, req.params.id);
361
+ if (!apiToken) {
362
+ return res.status(404).send({ error: "API Token Not Found" });
363
+ }
364
+ // Owner can always revoke; admins can revoke any token
365
+ if (
366
+ apiToken.userId !== userSession.userId &&
367
+ userSession.role !== "admin"
368
+ ) {
369
+ return res.status(403).send({ error: "Access Denied" });
370
+ }
371
+ await UsersApiTokensDataDelete(context, apiToken.id);
372
+ return res.status(201).send({});
373
+ });
291
374
  }
292
375
  }