@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 +4 -2
- package/README.md +10 -5
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/src/users/Auth.js +52 -7
- package/dist/src/users/UserApiToken.d.ts +19 -0
- package/dist/src/users/UserApiToken.js +52 -0
- package/dist/src/users/UsersApiTokensData.d.ts +14 -0
- package/dist/src/users/UsersApiTokensData.js +94 -0
- package/dist/src/users/UsersRoutes.js +57 -0
- package/index.ts +2 -0
- package/package.json +1 -1
- package/src/users/Auth.spec.ts +268 -0
- package/src/users/Auth.ts +53 -7
- package/src/users/UserApiToken.ts +58 -0
- package/src/users/UsersApiTokensData.spec.ts +158 -0
- package/src/users/UsersApiTokensData.ts +126 -0
- package/src/users/UsersRoutes.ts +83 -0
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
|
-
|
|
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
|
-
|
|
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
|
|
410
|
-
| `AuthGetUserSession` | Returns the `UserSession` decoded from the request
|
|
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
|
-
| `
|
|
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`)
|
|
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);
|
package/dist/src/users/Auth.js
CHANGED
|
@@ -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
|
|
95
|
-
* redundant
|
|
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.
|
|
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
|
|
76
|
-
* redundant
|
|
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
|
+
};
|
package/src/users/UsersRoutes.ts
CHANGED
|
@@ -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
|
}
|