mbkauthe 4.1.4 → 4.2.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/docs/api.md CHANGED
@@ -41,11 +41,15 @@ Authorization: Bearer <your_api_token>
41
41
  - **Stateless:** Validates against the `ApiTokens` table on every request.
42
42
  - **Expiration:** Tokens can have an optional expiration date.
43
43
  - **Permissions:** API tokens inherit the permissions of the user who created them.
44
+ - **Scopes:** Tokens have a scope (`read-only` or `write`) that controls which HTTP methods are allowed:
45
+ - `read-only`: Only GET, HEAD, and OPTIONS requests (safe, read-only operations)
46
+ - `write`: All HTTP methods (GET, POST, PUT, DELETE, PATCH, etc.)
44
47
  - **Usage Tracking:** The system updates the `LastUsed` timestamp on every successful request.
45
48
 
46
49
  **Errors:**
47
50
  - `401 Unauthorized` (Code 1005: `INVALID_AUTH_TOKEN`): Token is malformed or not found.
48
51
  - `401 Unauthorized` (Code 1006: `API_TOKEN_EXPIRED`): Token exists but has passed its expiration date.
52
+ - `403 Forbidden` (Code 1007: `TOKEN_SCOPE_INSUFFICIENT`): Token scope doesn't allow this HTTP method.
49
53
 
50
54
  **Example Usage:**
51
55
 
@@ -424,6 +428,184 @@ fetch('/mbkauthe/api/logout', {
424
428
 
425
429
  ### Multi-Account Endpoints
426
430
 
431
+ ---
432
+
433
+ #### Token Management Endpoints
434
+
435
+ ##### `POST /mbkauthe/api/token`
436
+
437
+ Create a new API token for the authenticated user.
438
+
439
+ **Rate Limit:** 10 requests per minute
440
+
441
+ **Authentication:** Session required (cookie or Bearer token)
442
+
443
+ **Request Body:**
444
+ ```json
445
+ {
446
+ "name": "string (required, 1-255 chars, friendly name for the token)",
447
+ "expiresDays": "number (optional, 1-365, default 90)",
448
+ "scope": "string (optional, 'read-only' or 'write', default 'read-only')",
449
+ "allowedApps": "array (optional, app names or ['*'] for all apps)"
450
+ }
451
+ ```
452
+
453
+ **Token Scopes:**
454
+ - `read-only`: Allows only read operations (GET, HEAD, OPTIONS methods)
455
+ - `write`: Allows all operations (GET, POST, PUT, DELETE, PATCH, etc.)
456
+
457
+ **Token Application Access:**
458
+ - Omit `allowedApps`: Token inherits from user's allowed apps
459
+ - `["app1", "app2"]`: Token restricted to specific apps (must be subset of user's apps for non-SuperAdmin)
460
+ - `["*"]`: All user's apps (non-SuperAdmin) or all system apps (SuperAdmin only)
461
+ - **SuperAdmin bypass**: SuperAdmin tokens work on any app regardless of `allowedApps` configuration
462
+
463
+ **Success Response (201 Created):**
464
+ ```json
465
+ {
466
+ "success": true,
467
+ "token": "mbk_7f83a92b1dc4e5a6f89b012c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e",
468
+ "tokenId": 42,
469
+ "prefix": "mbk_7f83a92",
470
+ "name": "My API Token",
471
+ "scope": "read-only",
472
+ "allowedApps": ["App1", "App2"],
473
+ "expiresAt": "2025-04-27T12:34:56.000Z",
474
+ "createdAt": "2025-01-27T12:34:56.000Z",
475
+ "message": "Token created successfully. Save it now - it won't be shown again."
476
+ }
477
+ ```
478
+
479
+ **Error Responses:**
480
+
481
+ | Status Code | Error Code | Message |
482
+ |------------|------------|---------|
483
+ | 400 | MISSING_REQUIRED_FIELD | Token name is required (1-255 characters) |
484
+ | 400 | MISSING_REQUIRED_FIELD | expiresDays must be between 1 and 365 |
485
+ | 400 | MISSING_REQUIRED_FIELD | Invalid scope. Available scopes: read-only, write |
486
+ | 400 | MISSING_REQUIRED_FIELD | allowedApps must be an array |
487
+ | 401 | SESSION_NOT_FOUND | Not authenticated |
488
+ | 403 | INSUFFICIENT_PERMISSIONS | Only SuperAdmin can create tokens with '*' (all apps) access |
489
+ | 403 | INSUFFICIENT_PERMISSIONS | You don't have access to app 'X' |
490
+ | 500 | INTERNAL_SERVER_ERROR | Internal Server Error |
491
+
492
+ **Example Requests:**
493
+
494
+ *Create a read-only token for specific apps:*
495
+ ```javascript
496
+ const response = await fetch('/mbkauthe/api/token', {
497
+ method: 'POST',
498
+ headers: {
499
+ 'Content-Type': 'application/json',
500
+ 'Authorization': 'Bearer mbk_existing_token...' // or use session cookie
501
+ },
502
+ body: JSON.stringify({
503
+ name: 'CI/CD Pipeline Token',
504
+ expiresDays: 30,
505
+ scope: 'read-only',
506
+ allowedApps: ['App1', 'App2']
507
+ })
508
+ });
509
+ ```
510
+
511
+ *Create a write token with inherited app access:*
512
+ ```javascript
513
+ const response = await fetch('/mbkauthe/api/token', {
514
+ method: 'POST',
515
+ headers: { 'Content-Type': 'application/json' },
516
+ body: JSON.stringify({
517
+ name: 'Admin API Token',
518
+ scope: 'write'
519
+ // allowedApps omitted - inherits from user
520
+ })
521
+ });
522
+ ```
523
+
524
+ ---
525
+
526
+ ##### `GET /mbkauthe/api/tokens`
527
+
528
+ List all API tokens for the authenticated user (token value not included).
529
+
530
+ **Rate Limit:** 10 requests per minute
531
+
532
+ **Authentication:** Session required (cookie or Bearer token)
533
+
534
+ **Success Response (200 OK):**
535
+ ```json
536
+ {
537
+ "success": true,
538
+ "tokens": [
539
+ {
540
+ "id": 42,
541
+ "name": "CI/CD Pipeline Token",
542
+ "prefix": "mbk_7f83a92",
543
+ "scope": "read-only",
544
+ "allowedApps": ["App1", "App2"],
545
+ "lastUsed": "2025-01-27T10:15:30.000Z",
546
+ "createdAt": "2025-01-27T12:34:56.000Z",
547
+ "expiresAt": "2025-04-27T12:34:56.000Z",
548
+ "expired": false
549
+ },
550
+ {
551
+ "id": 43,
552
+ "name": "Admin API Token",
553
+ "prefix": "mbk_a1b2c3d",
554
+ "scope": "write",
555
+ "allowedApps": null,
556
+ "lastUsed": null,
557
+ "createdAt": "2025-01-26T08:20:15.000Z",
558
+ "expiresAt": null,
559
+ "expired": false
560
+ }
561
+ ],
562
+ "count": 2
563
+ }
564
+ ```
565
+
566
+ **Note:** `allowedApps: null` means the token inherits from user's allowed apps.
567
+
568
+ **Error Responses:**
569
+
570
+ | Status Code | Error Code | Message |
571
+ |------------|------------|---------|
572
+ | 401 | SESSION_NOT_FOUND | Not authenticated |
573
+ | 500 | INTERNAL_SERVER_ERROR | Internal Server Error |
574
+
575
+ ---
576
+
577
+ ##### `DELETE /mbkauthe/api/token/:id`
578
+
579
+ Revoke (delete) an API token by its ID.
580
+
581
+ **Rate Limit:** 10 requests per minute
582
+
583
+ **Authentication:** Session required (cookie or Bearer token)
584
+
585
+ **URL Parameters:**
586
+ - `id`: Token ID (integer)
587
+
588
+ **Success Response (200 OK):**
589
+ ```json
590
+ {
591
+ "success": true,
592
+ "message": "Token revoked successfully"
593
+ }
594
+ ```
595
+
596
+ **Error Responses:**
597
+
598
+ | Status Code | Error Code | Message |
599
+ |------------|------------|---------|
600
+ | 400 | MISSING_REQUIRED_FIELD | Invalid token ID |
601
+ | 401 | SESSION_NOT_FOUND | Not authenticated |
602
+ | 404 | SESSION_NOT_FOUND | Token not found or not owned by you |
603
+ | 500 | INTERNAL_SERVER_ERROR | Internal Server Error |
604
+
605
+ ---
606
+
607
+ ### Multi-Account Endpoints
608
+
427
609
  #### `GET /mbkauthe/accounts`
428
610
 
429
611
  Renders the account switching page, allowing users to switch between remembered accounts on the device.
package/docs/db.md CHANGED
@@ -78,6 +78,8 @@ CREATE TABLE "ApiTokens" (
78
78
  "Name" VARCHAR(255) NOT NULL, -- User-provided friendly name
79
79
  "TokenHash" VARCHAR(128) NOT NULL UNIQUE, -- Hashed access token (SHA-256)
80
80
  "Prefix" VARCHAR(32) NOT NULL, -- First few chars of token for ID
81
+ "Scope" VARCHAR(20) DEFAULT 'read-only', -- Token scope: 'read-only' or 'write'
82
+ "AllowedApps" JSONB DEFAULT NULL, -- Apps this token can access
81
83
  "LastUsed" TIMESTAMP WITH TIME ZONE,
82
84
  "CreatedAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
83
85
  "ExpiresAt" TIMESTAMP WITH TIME ZONE -- Optional expiration
@@ -87,6 +89,38 @@ CREATE INDEX IF NOT EXISTS idx_apitokens_tokenhash ON "ApiTokens" ("TokenHash");
87
89
  CREATE INDEX IF NOT EXISTS idx_apitokens_username ON "ApiTokens" ("UserName");
88
90
  ```
89
91
 
92
+ **Token Permissions (JSONB):**
93
+
94
+ The `Permissions` column stores both scope and allowed apps in a single JSONB structure for optimal performance:
95
+
96
+ ```json
97
+ {
98
+ "scope": "read-only" | "write",
99
+ "allowedApps": null | ["app1", "app2"] | ["*"] | []
100
+ }
101
+ ```
102
+
103
+ **Scope Values:**
104
+ - `read-only`: Allows only safe, read-only HTTP methods (GET, HEAD, OPTIONS)
105
+ - `write`: Allows all HTTP methods (GET, POST, PUT, DELETE, PATCH, etc.)
106
+
107
+ **AllowedApps Values:**
108
+ - `null` (default): Token inherits allowed apps from user's `AllowedApps` in Users table
109
+ - `["app1", "app2"]`: Token is restricted to specific apps (must be subset of user's apps)
110
+ - `["*"]`: Token has access to all user's apps (for non-SuperAdmin) or all apps in system (SuperAdmin only)
111
+ - `[]` (empty array): Token has no app access (effectively disabled)
112
+
113
+ **Note**: SuperAdmin users bypass all app permission checks, so their tokens work on any app regardless of the `allowedApps` value.
114
+
115
+ **Security:**
116
+ - Tokens are stored as SHA-256 hashes (never plain text)
117
+ - Only the prefix (first 11 characters) is stored for identification
118
+ - The full token is only shown once during creation
119
+ - Scope enforcement is applied at the middleware level before route processing
120
+ - App access is validated against both token restrictions and user permissions
121
+ - **SuperAdmin exception**: SuperAdmin users bypass app permission checks - their tokens work on any app regardless of `allowedApps` configuration
122
+ - Wildcard `["*"]` for non-SuperAdmin means "all apps the user has access to", not "all apps in system"
123
+
90
124
  ## How It Works
91
125
 
92
126
  ### Login Flow (GitHub/Google)
package/docs/db.sql CHANGED
@@ -137,13 +137,29 @@ INSERT INTO "Users" ("UserName", "Password", "Role", "Active", "HaveMailAccount"
137
137
  CREATE TABLE "ApiTokens" (
138
138
  "id" SERIAL PRIMARY KEY,
139
139
  "UserName" VARCHAR(50) NOT NULL REFERENCES "Users"("UserName") ON DELETE CASCADE,
140
- "Name" VARCHAR(255) NOT NULL, -- User-provided friendly name
141
- "TokenHash" VARCHAR(128) NOT NULL UNIQUE, -- Hashed access token
142
- "Prefix" VARCHAR(32) NOT NULL, -- First few chars of token for ID
140
+ "Name" VARCHAR(255) NOT NULL,
141
+ "TokenHash" VARCHAR(128) NOT NULL UNIQUE,
142
+ "Prefix" VARCHAR(32) NOT NULL,
143
+ "Permissions" JSONB NOT NULL DEFAULT '{"scope":"read-only","allowedApps":null}'::jsonb,
143
144
  "LastUsed" TIMESTAMP WITH TIME ZONE,
144
145
  "CreatedAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
145
- "ExpiresAt" TIMESTAMP WITH TIME ZONE -- Optional expiration
146
+ "ExpiresAt" TIMESTAMP WITH TIME ZONE
146
147
  );
147
148
 
149
+ -- Basic indexes
148
150
  CREATE INDEX IF NOT EXISTS idx_apitokens_tokenhash ON "ApiTokens" ("TokenHash");
149
151
  CREATE INDEX IF NOT EXISTS idx_apitokens_username ON "ApiTokens" ("UserName");
152
+
153
+ -- Performance indexes
154
+ CREATE INDEX IF NOT EXISTS idx_apitokens_tokenhash_expires ON "ApiTokens" ("TokenHash", "ExpiresAt") WHERE "ExpiresAt" IS NOT NULL;
155
+ CREATE INDEX IF NOT EXISTS idx_apitokens_username_created ON "ApiTokens" ("UserName", "CreatedAt" DESC);
156
+ CREATE INDEX IF NOT EXISTS idx_apitokens_expires ON "ApiTokens" ("ExpiresAt") WHERE "ExpiresAt" IS NOT NULL;
157
+
158
+ -- JSONB indexes for fast permission queries
159
+ CREATE INDEX IF NOT EXISTS idx_apitokens_permissions_gin ON "ApiTokens" USING GIN ("Permissions");
160
+ CREATE INDEX IF NOT EXISTS idx_apitokens_permissions_scope ON "ApiTokens" (("Permissions"->>'scope'));
161
+
162
+ -- Data integrity constraints
163
+ ALTER TABLE "ApiTokens" ADD CONSTRAINT chk_apitokens_permissions_scope CHECK ("Permissions"->>'scope' IN ('read-only', 'write'));
164
+ ALTER TABLE "ApiTokens" ADD CONSTRAINT chk_apitokens_name_not_empty CHECK (LENGTH(TRIM("Name")) > 0);
165
+ ALTER TABLE "ApiTokens" ADD CONSTRAINT chk_apitokens_expires_future CHECK ("ExpiresAt" IS NULL OR "ExpiresAt" > "CreatedAt");
@@ -174,6 +174,16 @@ The provided API token is invalid.
174
174
  #### `1006 - API_TOKEN_EXPIRED`
175
175
  The provided API token has expired.
176
176
 
177
+ #### `1007 - TOKEN_SCOPE_INSUFFICIENT`
178
+ Token scope doesn't allow the requested operation.
179
+ ```javascript
180
+ {
181
+ errorCode: 1007,
182
+ message: "This API token doesn't have permission for this operation.",
183
+ hint: "Use a token with 'write' scope or create a new one with appropriate permissions"
184
+ }
185
+ ```
186
+
177
187
  ### Rate Limiting (1100-1199)
178
188
 
179
189
  #### `1101 - RATE_LIMIT_EXCEEDED`
package/index.d.ts CHANGED
@@ -25,6 +25,7 @@ declare global {
25
25
  role: 'SuperAdmin' | 'NormalUser' | 'Guest';
26
26
  sessionId?: string;
27
27
  allowedApps?: string[];
28
+ tokenScope?: 'read-only' | 'write' | null;
28
29
  };
29
30
  preAuthUser?: {
30
31
  id: number;
@@ -146,6 +147,26 @@ declare module 'mbkauthe' {
146
147
  updated_at: Date;
147
148
  }
148
149
 
150
+ // API Token Types
151
+ export type TokenScope = 'read-only' | 'write';
152
+
153
+ export interface TokenPermissions {
154
+ scope: TokenScope;
155
+ allowedApps: string[] | null;
156
+ }
157
+
158
+ export interface ApiToken {
159
+ id: number;
160
+ UserName: string;
161
+ Name: string;
162
+ TokenHash: string;
163
+ Prefix: string;
164
+ Permissions: TokenPermissions;
165
+ LastUsed?: Date;
166
+ CreatedAt: Date;
167
+ ExpiresAt?: Date;
168
+ }
169
+
149
170
  // API Response Types
150
171
  export interface LoginResponse {
151
172
  success: boolean;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * API Token Scope Configuration
3
+ * Defines available scopes and methods for token-based access control
4
+ */
5
+
6
+ // Available scopes
7
+ export const TOKEN_SCOPES = {
8
+ 'read-only': {
9
+ name: 'Read Only',
10
+ description: 'Allows only read operations (GET, HEAD, OPTIONS)',
11
+ allowedMethods: ['GET', 'HEAD', 'OPTIONS']
12
+ },
13
+ 'write': {
14
+ name: 'Write (Full Access)',
15
+ description: 'Allows all operations (GET, POST, PUT, DELETE, PATCH, etc.)',
16
+ allowedMethods: '*' // All methods
17
+ }
18
+ };
19
+
20
+ export const DEFAULT_SCOPE = 'read-only';
21
+
22
+ /**
23
+ * Check if a token scope allows the given HTTP method
24
+ * @param {string} scope - Token scope ('read-only' or 'write')
25
+ * @param {string} method - HTTP method (GET, POST, etc.)
26
+ * @returns {boolean}
27
+ */
28
+ export function canAccessMethod(scope, method) {
29
+ if (!scope || !TOKEN_SCOPES[scope]) return false;
30
+
31
+ const scopeConfig = TOKEN_SCOPES[scope];
32
+
33
+ // Full access scope
34
+ if (scopeConfig.allowedMethods === '*') return true;
35
+
36
+ // Check if method is in allowed list
37
+ return scopeConfig.allowedMethods.includes(method.toUpperCase());
38
+ }
39
+
40
+ /**
41
+ * Validate if a scope is valid
42
+ * @param {string} scope - Scope to validate
43
+ * @returns {boolean}
44
+ */
45
+ export function isValidScope(scope) {
46
+ return TOKEN_SCOPES.hasOwnProperty(scope);
47
+ }
48
+
49
+ /**
50
+ * Get all available scopes with descriptions
51
+ * @returns {Object}
52
+ */
53
+ export function getAvailableScopes() {
54
+ return Object.entries(TOKEN_SCOPES).map(([key, value]) => ({
55
+ scope: key,
56
+ name: value.name,
57
+ description: value.description
58
+ }));
59
+ }
@@ -4,6 +4,7 @@ import { renderError } from "../utils/response.js";
4
4
  import { clearSessionCookies, cachedCookieOptions, readAccountListFromCookie } from "../config/cookies.js";
5
5
  import { ErrorCodes, createErrorResponse } from "../utils/errors.js";
6
6
  import { hashApiToken } from "../config/security.js";
7
+ import { canAccessMethod } from "../config/tokenScopes.js";
7
8
 
8
9
  /**
9
10
  * Validates a Bearer token (API Token or Session UUID)
@@ -21,7 +22,8 @@ async function validateTokenAuthentication(req) {
21
22
  if (token.startsWith('mbk_')) {
22
23
  const tokenHash = hashApiToken(token);
23
24
  const tokenQuery = `
24
- SELECT t.id, t."UserName", t."ExpiresAt", u.id as uid, u."Active", u."Role", u."AllowedApps", u."FullName"
25
+ SELECT t.id, t."UserName", t."ExpiresAt", t."Permissions",
26
+ u.id as uid, u."Active", u."Role", u."AllowedApps" as user_allowed_apps, u."FullName"
25
27
  FROM "ApiTokens" t
26
28
  JOIN "Users" u ON t."UserName" = u."UserName"
27
29
  WHERE t."TokenHash" = $1 LIMIT 1
@@ -29,24 +31,37 @@ async function validateTokenAuthentication(req) {
29
31
  const tokenResult = await dblogin.query({ name: 'validate-api-token', text: tokenQuery, values: [tokenHash] });
30
32
 
31
33
  if (tokenResult.rows.length === 0) return { error: 'INVALID_TOKEN' };
32
-
34
+
33
35
  const row = tokenResult.rows[0];
34
36
  if (row.ExpiresAt && new Date(row.ExpiresAt) <= new Date()) return { error: 'TOKEN_EXPIRED' };
35
37
 
38
+ // Parse permissions from JSONB
39
+ const permissions = row.Permissions || { scope: 'read-only', allowedApps: null };
40
+ const tokenScope = permissions.scope || 'read-only';
41
+ const tokenAllowedApps = permissions.allowedApps;
42
+
43
+ // Determine allowed apps: token-specific takes precedence over user's apps
44
+ let allowedApps = row.user_allowed_apps;
45
+ if (tokenAllowedApps !== null) {
46
+ allowedApps = tokenAllowedApps;
47
+ }
48
+
36
49
  // Update usage
37
50
  dblogin.query({
38
- text: 'UPDATE "ApiTokens" SET "LastUsed" = NOW() WHERE id = $1',
39
- values: [row.id]
51
+ text: 'UPDATE "ApiTokens" SET "LastUsed" = NOW() WHERE id = $1',
52
+ values: [row.id]
40
53
  }).catch(e => console.error('[mbkauthe] Failed to update token usage:', e));
41
54
 
42
55
  return {
43
- id: row.uid,
44
- username: row.UserName,
45
- fullname: row.FullName,
46
- role: row.Role,
47
- sessionId: 'api-token-session',
48
- allowedApps: row.AllowedApps,
49
- active: row.Active
56
+ id: row.uid,
57
+ username: row.UserName,
58
+ fullname: row.FullName,
59
+ role: row.Role,
60
+ sessionId: 'api-token-session',
61
+ allowedApps: allowedApps,
62
+ userAllowedApps: row.user_allowed_apps, // Pass user apps for wildcard validation
63
+ active: row.Active,
64
+ tokenScope: tokenScope
50
65
  };
51
66
  }
52
67
 
@@ -58,37 +73,72 @@ async function validateSession(req, res, next) {
58
73
  if (req.headers.authorization) {
59
74
  try {
60
75
  const tokenUser = await validateTokenAuthentication(req);
61
-
76
+
62
77
  if (tokenUser && !tokenUser.error) {
63
- if (!tokenUser.active) {
64
- return res.status(401).json(createErrorResponse(401, ErrorCodes.ACCOUNT_INACTIVE));
65
- }
66
-
67
- if (tokenUser.role !== "SuperAdmin") {
68
- const allowedApps = tokenUser.allowedApps;
69
- const hasAllowedApps = Array.isArray(allowedApps) && allowedApps.length > 0;
70
- if (!hasAllowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase())) {
78
+ if (!tokenUser.active) {
79
+ return res.status(401).json(createErrorResponse(401, ErrorCodes.ACCOUNT_INACTIVE));
80
+ }
81
+
82
+ // SuperAdmin bypasses app permission checks
83
+ if (tokenUser.role !== "SuperAdmin") {
84
+ // API tokens must respect their app restrictions for non-SuperAdmin users
85
+ const allowedApps = tokenUser.allowedApps;
86
+ const userAllowedApps = tokenUser.userAllowedApps;
87
+
88
+ // allowedApps should always be an array (never null at this point)
89
+ // If token had null allowedApps, it was already replaced with user's apps in validateTokenAuthentication
90
+ if (!Array.isArray(allowedApps) || allowedApps.length === 0) {
91
+ return res.status(401).json(createErrorResponse(401, ErrorCodes.APP_NOT_AUTHORIZED));
92
+ }
93
+
94
+ // Check if token has access to current app
95
+ const hasWildcard = allowedApps.includes('*');
96
+ const hasSpecificApp = allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase());
97
+
98
+ // If wildcard, check against user's allowed apps (wildcard means "all user's apps", not "all apps")
99
+ if (hasWildcard) {
100
+ const userHasApp = userAllowedApps && Array.isArray(userAllowedApps) &&
101
+ userAllowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase());
102
+ if (!userHasApp) {
71
103
  return res.status(401).json(createErrorResponse(401, ErrorCodes.APP_NOT_AUTHORIZED));
72
104
  }
73
- }
74
-
75
- // Populate session for downstream
76
- req.session.user = {
77
- id: tokenUser.id,
78
- username: tokenUser.username,
79
- fullname: tokenUser.fullname,
80
- role: tokenUser.role,
81
- sessionId: tokenUser.sessionId,
82
- allowedApps: tokenUser.allowedApps,
83
- };
84
- req.userRole = tokenUser.role;
85
- return next();
105
+ } else if (!hasSpecificApp) {
106
+ return res.status(401).json(createErrorResponse(401, ErrorCodes.APP_NOT_AUTHORIZED));
107
+ }
108
+ }
109
+
110
+ // Populate session for downstream
111
+ req.session.user = {
112
+ id: tokenUser.id,
113
+ username: tokenUser.username,
114
+ fullname: tokenUser.fullname,
115
+ role: tokenUser.role,
116
+ sessionId: tokenUser.sessionId,
117
+ allowedApps: tokenUser.allowedApps,
118
+ tokenScope: tokenUser.tokenScope || null, // Add scope for token-based auth
119
+ };
120
+ req.userRole = tokenUser.role;
121
+
122
+ // Validate token scope for API token requests
123
+ if (tokenUser.tokenScope) {
124
+ const requestMethod = req.method;
125
+ if (!canAccessMethod(tokenUser.tokenScope, requestMethod)) {
126
+ return res.status(403).json(createErrorResponse(403, ErrorCodes.TOKEN_SCOPE_INSUFFICIENT, {
127
+ message: `Token scope '${tokenUser.tokenScope}' does not allow ${requestMethod} requests`,
128
+ tokenScope: tokenUser.tokenScope,
129
+ requestedMethod: requestMethod,
130
+ hint: 'Use a token with write scope for write operations'
131
+ }));
132
+ }
133
+ }
134
+
135
+ return next();
86
136
  }
87
-
137
+
88
138
  // Token provided but invalid (or null if format incorrect)
89
139
  let errorCode = ErrorCodes.INVALID_AUTH_TOKEN;
90
140
  if (tokenUser && tokenUser.error === 'TOKEN_EXPIRED') {
91
- errorCode = ErrorCodes.API_TOKEN_EXPIRED;
141
+ errorCode = ErrorCodes.API_TOKEN_EXPIRED;
92
142
  }
93
143
  return res.status(401).json(createErrorResponse(401, errorCode));
94
144
 
@@ -102,7 +152,7 @@ async function validateSession(req, res, next) {
102
152
  if (!req.session.user) {
103
153
  console.log("[mbkauthe] User not authenticated");
104
154
  console.log("[mbkauthe]: ", req.session.user);
105
- return renderError(res, {
155
+ return renderError(res, req, {
106
156
  code: 401,
107
157
  error: "Not Logged In",
108
158
  message: "You Are Not Logged In. Please Log In To Continue.",
@@ -365,7 +415,7 @@ export async function reloadSessionUser(req, res) {
365
415
 
366
416
  // Sync cookies for client UI (non-httpOnly)
367
417
  try {
368
- res.cookie('username', req.session.user.username, { ...cachedCookieOptions, httpOnly: false });
418
+ res.cookie('username', req.session.user.username, { ...cachedCookieOptions, httpOnly: false });
369
419
  res.cookie('fullName', req.session.user.fullname || req.session.user.username, { ...cachedCookieOptions, httpOnly: false });
370
420
  res.cookie('sessionId', req.session.user.sessionId, cachedCookieOptions);
371
421
  } catch (cookieErr) {
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Scope Validation Middleware
3
+ * Validates HTTP methods against token scopes
4
+ */
5
+
6
+ import { canAccessMethod } from '../config/tokenScopes.js';
7
+ import { ErrorCodes, createErrorResponse } from '../utils/errors.js';
8
+
9
+ /**
10
+ * Middleware to validate that the token's scope allows the request method
11
+ * Only applies to API token authentication (not session cookies)
12
+ */
13
+ export function validateTokenScope(req, res, next) {
14
+ // Only validate for API token requests (not cookie-based sessions)
15
+ // Check if this request was authenticated via API token
16
+ if (req.session?.user?.sessionId === 'api-token-session' && req.session?.user?.tokenScope) {
17
+ const tokenScope = req.session.user.tokenScope;
18
+ const requestMethod = req.method;
19
+
20
+ // Check if scope allows this HTTP method
21
+ if (!canAccessMethod(tokenScope, requestMethod)) {
22
+ return res.status(403).json(createErrorResponse(403, ErrorCodes.TOKEN_SCOPE_INSUFFICIENT, {
23
+ message: `Token scope '${tokenScope}' does not allow ${requestMethod} requests`,
24
+ tokenScope,
25
+ requestedMethod: requestMethod,
26
+ hint: 'Use a token with write scope for write operations'
27
+ }));
28
+ }
29
+ }
30
+
31
+ next();
32
+ }
@@ -186,6 +186,12 @@ router.get('/test', validateSession, LoginLimit, async (req, res) => {
186
186
  }
187
187
  });
188
188
 
189
+ router.post('/test', validateSession, LoginLimit, async (req, res) => {
190
+ if (req.session?.user) {
191
+ return res.json({ success: true, message: "You are logged in" });
192
+ }
193
+ });
194
+
189
195
  // API: check current session validity (JSON) — minimal response
190
196
  router.get('/api/checkSession', LoginLimit, async (req, res) => {
191
197
  try {
@@ -34,6 +34,7 @@ export const ErrorCodes = {
34
34
  INVALID_TOKEN_FORMAT: 1004,
35
35
  INVALID_AUTH_TOKEN: 1005,
36
36
  API_TOKEN_EXPIRED: 1006,
37
+ TOKEN_SCOPE_INSUFFICIENT: 1007,
37
38
 
38
39
  // Rate limiting (1100-1199)
39
40
  RATE_LIMIT_EXCEEDED: 1101,
@@ -75,7 +76,7 @@ export const ErrorMessages = {
75
76
  [ErrorCodes.APP_NOT_AUTHORIZED]: {
76
77
  message: "Not authorized for this application",
77
78
  userMessage: "You don't have permission to access this application.",
78
- hint: "Contact your administrator if you believe this is an error"
79
+ hint: "This token may not be authorized for the requested app."
79
80
  },
80
81
 
81
82
  // 2FA
@@ -160,6 +161,11 @@ export const ErrorMessages = {
160
161
  userMessage: "The provided API token has expired.",
161
162
  hint: "Please generate a new API token"
162
163
  },
164
+ [ErrorCodes.TOKEN_SCOPE_INSUFFICIENT]: {
165
+ message: "Token scope insufficient",
166
+ userMessage: "This API token doesn't have permission for this operation.",
167
+ hint: "Use a token with 'write' scope or create a new one with appropriate permissions"
168
+ },
163
169
 
164
170
  // Rate Limiting
165
171
  [ErrorCodes.RATE_LIMIT_EXCEEDED]: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mbkauthe",
3
- "version": "4.1.4",
3
+ "version": "4.2.0",
4
4
  "description": "MBKTech's reusable authentication system for Node.js applications.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -13,104 +13,6 @@
13
13
  <span class="logo-comp">mbktech</span></span>
14
14
  </a>
15
15
 
16
- <div class="header-actions">
17
- {{#if userLoggedIn}}
18
- <div class="profile-menu">
19
- <button class="profile-trigger" type="button" aria-expanded="false" aria-haspopup="true">
20
- <img src="/mbkauthe/user/profilepic?u={{username}}" alt="Profile" class="profile-avatar">
21
- </button>
22
- <div class="profile-dropdown" role="menu">
23
- <a class="profile-item" role="menuitem" title="{{username}}">
24
- <span>@{{username}}</span>
25
- </a>
26
- <a class="profile-item" href="https://portal.mbktech.org/user/settings" role="menuitem"
27
- title="Settings">
28
- <i class="fas fa-cog"></i>
29
- <span>Settings</span>
30
- </a>
31
- <a class="profile-item" href="/mbkauthe/accounts" role="menuitem"
32
- title="Switch or Add another Account">
33
- <i class="fa fa-user-group"></i>
34
- <span>Switch account</span>
35
- </a>
36
- <button class="profile-item" type="button" data-action="logout" role="menuitem" title="Logout">
37
- <i class="fas fa-sign-out-alt"></i>
38
- <span>Logout</span>
39
- </button>
40
- </div>
41
- </div>
42
- {{else}}
43
- <a class="btn-login" style="text-decoration: none;" href="/mbkauthe/login"><i
44
- class="fas fa-sign-in-alt"></i> Login</a>
45
- {{/if}}
46
- </div>
16
+ {{> profilemenu}}
47
17
  </div>
48
- </header>
49
-
50
- <script>
51
- (() => {
52
- const trigger = document.querySelector('header .profile-trigger');
53
- const dropdown = document.querySelector('header .profile-dropdown');
54
-
55
- if (!trigger || !dropdown) {
56
- return;
57
- }
58
-
59
- const closeMenu = () => {
60
- dropdown.classList.remove('open');
61
- trigger.setAttribute('aria-expanded', 'false');
62
- };
63
-
64
- trigger.addEventListener('click', (event) => {
65
- event.stopPropagation();
66
- const isOpen = dropdown.classList.toggle('open');
67
- trigger.setAttribute('aria-expanded', String(isOpen));
68
- });
69
-
70
- document.addEventListener('click', (event) => {
71
- if (!dropdown.contains(event.target) && !trigger.contains(event.target)) {
72
- closeMenu();
73
- }
74
- });
75
-
76
- document.addEventListener('keydown', (event) => {
77
- if (event.key === 'Escape') {
78
- closeMenu();
79
- }
80
- });
81
-
82
- const logoutButton = dropdown.querySelector('[data-action="logout"]');
83
- if (logoutButton) {
84
- logoutButton.addEventListener('click', async () => {
85
- const originalLabel = logoutButton.textContent;
86
- logoutButton.disabled = true;
87
- logoutButton.textContent = 'Logging out...';
88
-
89
- try {
90
- const response = await fetch('/mbkauthe/api/logout', {
91
- method: 'POST',
92
- headers: {
93
- 'Content-Type': 'application/json'
94
- },
95
- credentials: 'include'
96
- });
97
-
98
- const result = await response.json().catch(() => ({}));
99
-
100
- if (response.ok) {
101
- window.location.reload();
102
- } else {
103
- alert(result.message || 'Logout failed. Please try again.');
104
- }
105
- } catch (error) {
106
- console.error('[mbkauthe] Error during logout:', error);
107
- alert('Logout failed. Please try again.');
108
- } finally {
109
- logoutButton.disabled = false;
110
- logoutButton.textContent = originalLabel;
111
- closeMenu();
112
- }
113
- });
114
- }
115
- })();
116
- </script>
18
+ </header>
@@ -0,0 +1,187 @@
1
+ <div class="header-actions">
2
+ {{#if userLoggedIn}}
3
+ <div class="profile-menu">
4
+ <button class="profile-trigger" type="button" aria-expanded="false" aria-haspopup="true">
5
+ <img src="/mbkauthe/user/profilepic?u={{username}}" alt="Profile" class="profile-avatar">
6
+ </button>
7
+ <div class="profile-dropdown" role="menu">
8
+ <a class="profile-item" role="menuitem" title="{{username}}">
9
+ <span>@{{username}}</span>
10
+ </a>
11
+ <a class="profile-item" href="https://portal.mbktech.org/user/settings" role="menuitem" title="Settings">
12
+ <i class="fas fa-cog"></i>
13
+ <span>Settings</span>
14
+ </a>
15
+ <a class="profile-item" href="/mbkauthe/accounts" role="menuitem" title="Switch or Add another Account">
16
+ <i class="fa fa-user-group"></i>
17
+ <span>Switch account</span>
18
+ </a>
19
+ <button class="profile-item" type="button" data-action="logout" role="menuitem" title="Logout">
20
+ <i class="fas fa-sign-out-alt"></i>
21
+ <span>Logout</span>
22
+ </button>
23
+ </div>
24
+ </div>
25
+ {{else}}
26
+ <a class="btn-login" style="text-decoration: none;" href="/mbkauthe/login"><i class="fas fa-sign-in-alt"></i>
27
+ Login</a>
28
+ {{/if}}
29
+ </div>
30
+ <style>
31
+ .header-actions {
32
+ display: flex;
33
+ align-items: center;
34
+ gap: 0.75rem;
35
+ margin-left: auto;
36
+ }
37
+
38
+ .profile-menu {
39
+ position: relative;
40
+ }
41
+
42
+ .profile-trigger {
43
+ width: 45px;
44
+ height: 45px;
45
+ border-radius: 999px;
46
+ border: 1px solid rgba(255, 255, 255, 0.12);
47
+ background: rgba(255, 255, 255, 0.04);
48
+ display: flex;
49
+ align-items: center;
50
+ justify-content: center;
51
+ cursor: pointer;
52
+ transition: var(--transition);
53
+ }
54
+
55
+ .profile-trigger:hover {
56
+ border-color: var(--accent);
57
+ box-shadow: 0 10px 30px rgba(0, 184, 148, 0.2);
58
+ }
59
+
60
+ .profile-trigger:focus-visible {
61
+ outline: 2px solid var(--accent);
62
+ outline-offset: 2px;
63
+ }
64
+
65
+ .profile-avatar {
66
+ width: 40px;
67
+ height: 40px;
68
+ border-radius: 999px;
69
+ object-fit: cover;
70
+ background: var(--dark);
71
+ }
72
+
73
+ .profile-dropdown {
74
+ position: absolute;
75
+ right: 0;
76
+ top: calc(100% + 0.5rem);
77
+ background: var(--darker);
78
+ border: 1px solid rgba(255, 255, 255, 0.08);
79
+ border-radius: var(--border-radius);
80
+ min-width: 190px;
81
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
82
+ padding: 0;
83
+ opacity: 0;
84
+ visibility: hidden;
85
+ transform: translateY(-6px);
86
+ transition: var(--transition);
87
+ z-index: 1000;
88
+ }
89
+
90
+ .profile-dropdown.open {
91
+ opacity: 1;
92
+ visibility: visible;
93
+ transform: translateY(0);
94
+ }
95
+
96
+ .profile-item {
97
+ width: 100%;
98
+ display: block;
99
+ padding: 0.85rem 1rem;
100
+ color: var(--text);
101
+ background: transparent;
102
+ border: none;
103
+ text-decoration: none;
104
+ text-align: left;
105
+ font-weight: 600;
106
+ letter-spacing: 0.01em;
107
+ cursor: pointer;
108
+ transition: var(--transition);
109
+ }
110
+
111
+ .profile-item:hover {
112
+ background: rgba(255, 255, 255, 0.05);
113
+ color: var(--accent);
114
+ }
115
+
116
+ .profile-item:disabled {
117
+ opacity: 0.6;
118
+ cursor: not-allowed;
119
+ }
120
+ </style>
121
+ <script>
122
+ (() => {
123
+ const trigger = document.querySelector('header .profile-trigger');
124
+ const dropdown = document.querySelector('header .profile-dropdown');
125
+
126
+ if (!trigger || !dropdown) {
127
+ return;
128
+ }
129
+
130
+ const closeMenu = () => {
131
+ dropdown.classList.remove('open');
132
+ trigger.setAttribute('aria-expanded', 'false');
133
+ };
134
+
135
+ trigger.addEventListener('click', (event) => {
136
+ event.stopPropagation();
137
+ const isOpen = dropdown.classList.toggle('open');
138
+ trigger.setAttribute('aria-expanded', String(isOpen));
139
+ });
140
+
141
+ document.addEventListener('click', (event) => {
142
+ if (!dropdown.contains(event.target) && !trigger.contains(event.target)) {
143
+ closeMenu();
144
+ }
145
+ });
146
+
147
+ document.addEventListener('keydown', (event) => {
148
+ if (event.key === 'Escape') {
149
+ closeMenu();
150
+ }
151
+ });
152
+
153
+ const logoutButton = dropdown.querySelector('[data-action="logout"]');
154
+ if (logoutButton) {
155
+ logoutButton.addEventListener('click', async () => {
156
+ const originalLabel = logoutButton.textContent;
157
+ logoutButton.disabled = true;
158
+ logoutButton.textContent = 'Logging out...';
159
+
160
+ try {
161
+ const response = await fetch('/mbkauthe/api/logout', {
162
+ method: 'POST',
163
+ headers: {
164
+ 'Content-Type': 'application/json'
165
+ },
166
+ credentials: 'include'
167
+ });
168
+
169
+ const result = await response.json().catch(() => ({}));
170
+
171
+ if (response.ok) {
172
+ window.location.reload();
173
+ } else {
174
+ alert(result.message || 'Logout failed. Please try again.');
175
+ }
176
+ } catch (error) {
177
+ console.error('[mbkauthe] Error during logout:', error);
178
+ alert('Logout failed. Please try again.');
179
+ } finally {
180
+ logoutButton.disabled = false;
181
+ logoutButton.textContent = originalLabel;
182
+ closeMenu();
183
+ }
184
+ });
185
+ }
186
+ })();
187
+ </script>
@@ -53,96 +53,6 @@
53
53
  margin: 0 auto;
54
54
  }
55
55
 
56
- .header-actions {
57
- display: flex;
58
- align-items: center;
59
- gap: 0.75rem;
60
- margin-left: auto;
61
- }
62
-
63
- .profile-menu {
64
- position: relative;
65
- }
66
-
67
- .profile-trigger {
68
- width: 45px;
69
- height: 45px;
70
- border-radius: 999px;
71
- border: 1px solid rgba(255, 255, 255, 0.12);
72
- background: rgba(255, 255, 255, 0.04);
73
- display: flex;
74
- align-items: center;
75
- justify-content: center;
76
- cursor: pointer;
77
- transition: var(--transition);
78
- }
79
-
80
- .profile-trigger:hover {
81
- border-color: var(--accent);
82
- box-shadow: 0 10px 30px rgba(0, 184, 148, 0.2);
83
- }
84
-
85
- .profile-trigger:focus-visible {
86
- outline: 2px solid var(--accent);
87
- outline-offset: 2px;
88
- }
89
-
90
- .profile-avatar {
91
- width: 40px;
92
- height: 40px;
93
- border-radius: 999px;
94
- object-fit: cover;
95
- background: var(--dark);
96
- }
97
-
98
- .profile-dropdown {
99
- position: absolute;
100
- right: 0;
101
- top: calc(100% + 0.5rem);
102
- background: var(--darker);
103
- border: 1px solid rgba(255, 255, 255, 0.08);
104
- border-radius: var(--border-radius);
105
- min-width: 190px;
106
- box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
107
- padding: 0;
108
- opacity: 0;
109
- visibility: hidden;
110
- transform: translateY(-6px);
111
- transition: var(--transition);
112
- z-index: 1000;
113
- }
114
-
115
- .profile-dropdown.open {
116
- opacity: 1;
117
- visibility: visible;
118
- transform: translateY(0);
119
- }
120
-
121
- .profile-item {
122
- width: 100%;
123
- display: block;
124
- padding: 0.85rem 1rem;
125
- color: var(--text);
126
- background: transparent;
127
- border: none;
128
- text-decoration: none;
129
- text-align: left;
130
- font-weight: 600;
131
- letter-spacing: 0.01em;
132
- cursor: pointer;
133
- transition: var(--transition);
134
- }
135
-
136
- .profile-item:hover {
137
- background: rgba(255, 255, 255, 0.05);
138
- color: var(--accent);
139
- }
140
-
141
- .profile-item:disabled {
142
- opacity: 0.6;
143
- cursor: not-allowed;
144
- }
145
-
146
56
  .logo {
147
57
  display: flex;
148
58
  align-items: center;