@onlineapps/service-common 1.0.19 → 1.1.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@onlineapps/service-common",
3
- "version": "1.0.19",
4
- "description": "Common utilities for both infrastructure services and business services",
3
+ "version": "1.1.0",
4
+ "description": "Common utilities for both infrastructure services and business services (JWT auth, Redis/Postgres clients, business errors, runtime config)",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
7
7
  "test": "jest",
@@ -18,6 +18,7 @@
18
18
  "license": "MIT",
19
19
  "dependencies": {
20
20
  "@onlineapps/runtime-config": "1.0.2",
21
+ "jsonwebtoken": "^9.0.3",
21
22
  "nodemailer": "^6.9.8",
22
23
  "redis": "^4.6.0"
23
24
  },
package/src/index.js CHANGED
@@ -45,6 +45,12 @@ const {
45
45
  businessErrorHandler,
46
46
  notFoundHandler
47
47
  } = require('./errors');
48
+ const {
49
+ verifyAccessToken,
50
+ createJwtValidator,
51
+ extractTenantContext,
52
+ ROLES_VERSION_PREFIX
53
+ } = require('./jwt');
48
54
 
49
55
  module.exports = {
50
56
  // Infrastructure readiness utilities (used by both infrastructure and business services)
@@ -85,7 +91,14 @@ module.exports = {
85
91
  isBusinessError,
86
92
  ERROR_TYPES,
87
93
  businessErrorHandler,
88
- notFoundHandler
94
+ notFoundHandler,
95
+
96
+ // JWT validation utilities (shared across services that accept client HTTP/WS requests)
97
+ // See: docs/standards/JWT_AUTH.md
98
+ verifyAccessToken,
99
+ createJwtValidator,
100
+ extractTenantContext,
101
+ ROLES_VERSION_PREFIX
89
102
  };
90
103
 
91
104
 
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ const { verifyAccessToken } = require('./verifyAccessToken');
4
+
5
+ // See: docs/standards/JWT_AUTH.md §9.1 — forced refresh on role change
6
+ const ROLES_VERSION_PREFIX = 'person:roles_version:';
7
+
8
+ /**
9
+ * Create Express middleware that validates JWT Bearer tokens on incoming requests.
10
+ *
11
+ * On success: sets req.auth = { person_uuid, person_id, email, tenants }.
12
+ * On failure: responds with 401 JSON.
13
+ * Paths listed in excludePaths are skipped (transparent pass-through).
14
+ *
15
+ * @param {object} options
16
+ * @param {object} options.logger - Logger with .warn() method (required)
17
+ * @param {string[]} [options.excludePaths] - Paths to skip JWT validation
18
+ * @param {object} [options.redisClient] - Redis client for role version check (optional)
19
+ * @returns {Function} Express middleware (req, res, next)
20
+ */
21
+ function createJwtValidator({ logger, excludePaths, redisClient }) {
22
+ if (!logger || typeof logger.warn !== 'function') {
23
+ throw new Error('[JWT] Logger is required - Expected object with warn() method');
24
+ }
25
+
26
+ const excluded = new Set(excludePaths || []);
27
+
28
+ return async function jwtValidator(req, res, next) {
29
+ if (excluded.has(req.path)) {
30
+ return next();
31
+ }
32
+
33
+ const authHeader = req.headers.authorization;
34
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
35
+ return res.status(401).json({
36
+ error: 'Missing or invalid Authorization header - Expected: Bearer <token>'
37
+ });
38
+ }
39
+
40
+ const token = authHeader.slice(7);
41
+
42
+ try {
43
+ const decoded = verifyAccessToken(token);
44
+
45
+ if (redisClient && redisClient.isOpen && decoded.person_id) {
46
+ try {
47
+ const rolesVersion = await redisClient.get(`${ROLES_VERSION_PREFIX}${decoded.person_id}`);
48
+ if (rolesVersion) {
49
+ const versionTs = parseInt(rolesVersion, 10);
50
+ const tokenIat = decoded.iat * 1000;
51
+ if (!isNaN(versionTs) && tokenIat < versionTs) {
52
+ logger.warn('[JWT] Token stale — role changed after issuance', {
53
+ person_id: decoded.person_id,
54
+ token_iat: new Date(tokenIat).toISOString(),
55
+ roles_changed: new Date(versionTs).toISOString()
56
+ });
57
+ return res.status(401).json({
58
+ error: 'Token stale — your roles have changed, please refresh your access token',
59
+ code: 'TOKEN_STALE'
60
+ });
61
+ }
62
+ }
63
+ } catch (redisErr) {
64
+ logger.warn('[JWT] Redis role version check failed (non-blocking)', { error: redisErr.message });
65
+ }
66
+ }
67
+
68
+ req.auth = {
69
+ person_uuid: decoded.sub,
70
+ person_id: decoded.person_id,
71
+ email: decoded.email,
72
+ tenants: decoded.tenants || []
73
+ };
74
+
75
+ next();
76
+ } catch (err) {
77
+ if (err.name === 'TokenExpiredError') {
78
+ return res.status(401).json({ error: 'Token expired - refresh your access token' });
79
+ }
80
+ if (err.code === 'INVALID_TOKEN_TYPE') {
81
+ return res.status(401).json({ error: 'Invalid token type - Expected access token' });
82
+ }
83
+ logger.warn('[JWT] Token verification failed', { error: err.message });
84
+ return res.status(401).json({ error: 'Invalid token' });
85
+ }
86
+ };
87
+ }
88
+
89
+ module.exports = { createJwtValidator, ROLES_VERSION_PREFIX };
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Extract tenant context from decoded JWT auth data and request headers.
5
+ *
6
+ * Pure function — no Express dependency. Callers (middleware) translate
7
+ * thrown errors into HTTP responses.
8
+ *
9
+ * Rules (per JWT_AUTH.md section 7):
10
+ * - Single tenant: auto-pick, x-tenant-uuid header optional
11
+ * - Multiple tenants: x-tenant-uuid header required
12
+ * - workspace_id: required for direct API calls (x-workspace-id header),
13
+ * optional for workflow submission (workspace is per-step in cookbook)
14
+ *
15
+ * @param {object} auth - req.auth from createJwtValidator: { person_uuid, person_id, email, tenants }
16
+ * @param {object} headers - HTTP request headers (lowercase keys)
17
+ * @param {object} [options] - { requireWorkspace: true } — set false for workflow submission
18
+ * @returns {object} { tenant_id, tenant_uuid, workspace_id, person_id, person_uuid, role }
19
+ * @throws {Error} with .statusCode = 400, 401, or 403
20
+ */
21
+ // See: docs/standards/tenant-context-contract.md
22
+ function extractTenantContext(auth, headers, options) {
23
+ const { requireWorkspace = true } = options || {};
24
+
25
+ if (!auth || !Array.isArray(auth.tenants)) {
26
+ const err = new Error('[TenantContext] Missing auth data - Expected auth object with tenants array');
27
+ err.statusCode = 401;
28
+ throw err;
29
+ }
30
+
31
+ const tenantUuid = headers['x-tenant-uuid'];
32
+ const rawWorkspaceId = headers['x-workspace-id'];
33
+
34
+ let workspaceId = null;
35
+ if (rawWorkspaceId) {
36
+ workspaceId = parseInt(rawWorkspaceId, 10);
37
+ if (isNaN(workspaceId) || workspaceId < 1) {
38
+ const err = new Error(`[TenantContext] Invalid x-workspace-id '${rawWorkspaceId}' - must be a positive integer`);
39
+ err.statusCode = 400;
40
+ throw err;
41
+ }
42
+ } else if (requireWorkspace) {
43
+ const err = new Error('[TenantContext] Missing x-workspace-id header - workspace is required for direct API calls');
44
+ err.statusCode = 400;
45
+ throw err;
46
+ }
47
+
48
+ if (tenantUuid) {
49
+ const membership = auth.tenants.find(t => t.tenant_uuid === tenantUuid);
50
+ if (!membership) {
51
+ const err = new Error('Access denied - No membership for specified tenant');
52
+ err.statusCode = 403;
53
+ throw err;
54
+ }
55
+ return {
56
+ tenant_id: membership.tenant_id,
57
+ tenant_uuid: membership.tenant_uuid,
58
+ workspace_id: workspaceId,
59
+ person_id: auth.person_id,
60
+ person_uuid: auth.person_uuid,
61
+ role: membership.role
62
+ };
63
+ }
64
+
65
+ if (auth.tenants.length === 1) {
66
+ const t = auth.tenants[0];
67
+ return {
68
+ tenant_id: t.tenant_id,
69
+ tenant_uuid: t.tenant_uuid,
70
+ workspace_id: workspaceId,
71
+ person_id: auth.person_id,
72
+ person_uuid: auth.person_uuid,
73
+ role: t.role
74
+ };
75
+ }
76
+
77
+ const err = new Error('Missing tenant context - Provide x-tenant-uuid header (multiple tenants available)');
78
+ err.statusCode = 400;
79
+ throw err;
80
+ }
81
+
82
+ module.exports = { extractTenantContext };
@@ -0,0 +1,12 @@
1
+ 'use strict';
2
+
3
+ const { verifyAccessToken } = require('./verifyAccessToken');
4
+ const { createJwtValidator, ROLES_VERSION_PREFIX } = require('./createJwtValidator');
5
+ const { extractTenantContext } = require('./extractTenantContext');
6
+
7
+ module.exports = {
8
+ verifyAccessToken,
9
+ createJwtValidator,
10
+ extractTenantContext,
11
+ ROLES_VERSION_PREFIX
12
+ };
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+
3
+ const jwt = require('jsonwebtoken');
4
+
5
+ const ISSUER = 'oa-auth';
6
+ const MIN_SECRET_LENGTH = 16;
7
+
8
+ /**
9
+ * Resolve JWT_SECRET from environment. Fail-fast on missing/insecure value.
10
+ * @returns {string}
11
+ */
12
+ function getJwtSecret() {
13
+ const secret = process.env.JWT_SECRET;
14
+ if (!secret || secret.length < MIN_SECRET_LENGTH) {
15
+ throw new Error(
16
+ `[JWT] Missing or insecure JWT_SECRET - Expected env var with at least ${MIN_SECRET_LENGTH} characters`
17
+ );
18
+ }
19
+ return secret;
20
+ }
21
+
22
+ /**
23
+ * Verify and decode an OA Drive access token.
24
+ *
25
+ * Validates: signature (HMAC-SHA256), issuer ('oa-auth'), expiry, type ('access').
26
+ * Returns the full decoded payload on success.
27
+ * Throws on any validation failure — callers decide how to handle.
28
+ *
29
+ * @param {string} token - Raw JWT string (without "Bearer " prefix)
30
+ * @returns {object} Decoded payload: { sub, person_id, email, tenants, type, iss, iat, exp }
31
+ * @throws {Error} TokenExpiredError, JsonWebTokenError, or type mismatch
32
+ */
33
+ function verifyAccessToken(token) {
34
+ if (!token || typeof token !== 'string') {
35
+ throw new Error('[JWT] Token is required - Expected non-empty string');
36
+ }
37
+
38
+ const secret = getJwtSecret();
39
+ const decoded = jwt.verify(token, secret, { issuer: ISSUER });
40
+
41
+ if (decoded.type !== 'access') {
42
+ const err = new Error(`[JWT] Invalid token type - Expected 'access', got '${decoded.type}'`);
43
+ err.code = 'INVALID_TOKEN_TYPE';
44
+ throw err;
45
+ }
46
+
47
+ return decoded;
48
+ }
49
+
50
+ module.exports = { verifyAccessToken, getJwtSecret, ISSUER, MIN_SECRET_LENGTH };
@@ -0,0 +1,170 @@
1
+ 'use strict';
2
+
3
+ const jwt = require('jsonwebtoken');
4
+
5
+ const TEST_SECRET = 'test-jwt-secret-at-least-16-chars';
6
+
7
+ function createValidAccessToken(overrides = {}) {
8
+ const payload = {
9
+ sub: 'u_person-uuid',
10
+ person_id: 42,
11
+ email: 'test@example.com',
12
+ tenants: [{ tenant_uuid: 'a_t1', tenant_id: 1, role: 'OWNER' }],
13
+ type: 'access',
14
+ ...overrides
15
+ };
16
+ return jwt.sign(payload, TEST_SECRET, { issuer: 'oa-auth', expiresIn: '15m' });
17
+ }
18
+
19
+ function mockRes() {
20
+ const res = {
21
+ statusCode: null,
22
+ body: null,
23
+ status(code) { res.statusCode = code; return res; },
24
+ json(data) { res.body = data; return res; }
25
+ };
26
+ return res;
27
+ }
28
+
29
+ describe('createJwtValidator @unit', () => {
30
+ const originalEnv = process.env.JWT_SECRET;
31
+
32
+ beforeEach(() => {
33
+ process.env.JWT_SECRET = TEST_SECRET;
34
+ jest.resetModules();
35
+ });
36
+
37
+ afterEach(() => {
38
+ if (originalEnv !== undefined) {
39
+ process.env.JWT_SECRET = originalEnv;
40
+ } else {
41
+ delete process.env.JWT_SECRET;
42
+ }
43
+ });
44
+
45
+ function loadModule() {
46
+ return require('../../../src/jwt/createJwtValidator');
47
+ }
48
+
49
+ const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() };
50
+
51
+ it('throws if logger is missing', () => {
52
+ const { createJwtValidator } = loadModule();
53
+ expect(() => createJwtValidator({ logger: null })).toThrow('Logger is required');
54
+ });
55
+
56
+ it('returns 401 when Authorization header is missing', () => {
57
+ const { createJwtValidator } = loadModule();
58
+ const middleware = createJwtValidator({ logger });
59
+ const req = { path: '/test', headers: {} };
60
+ const res = mockRes();
61
+ const next = jest.fn();
62
+
63
+ middleware(req, res, next);
64
+
65
+ expect(res.statusCode).toBe(401);
66
+ expect(res.body.error).toContain('Missing or invalid Authorization header');
67
+ expect(next).not.toHaveBeenCalled();
68
+ });
69
+
70
+ it('returns 401 when Authorization header has no Bearer prefix', () => {
71
+ const { createJwtValidator } = loadModule();
72
+ const middleware = createJwtValidator({ logger });
73
+ const req = { path: '/test', headers: { authorization: 'Basic abc123' } };
74
+ const res = mockRes();
75
+ const next = jest.fn();
76
+
77
+ middleware(req, res, next);
78
+
79
+ expect(res.statusCode).toBe(401);
80
+ expect(next).not.toHaveBeenCalled();
81
+ });
82
+
83
+ it('sets req.auth and calls next() for valid token', () => {
84
+ const { createJwtValidator } = loadModule();
85
+ const middleware = createJwtValidator({ logger });
86
+ const token = createValidAccessToken();
87
+ const req = { path: '/test', headers: { authorization: `Bearer ${token}` } };
88
+ const res = mockRes();
89
+ const next = jest.fn();
90
+
91
+ middleware(req, res, next);
92
+
93
+ expect(next).toHaveBeenCalledTimes(1);
94
+ expect(req.auth).toBeDefined();
95
+ expect(req.auth.person_uuid).toBe('u_person-uuid');
96
+ expect(req.auth.person_id).toBe(42);
97
+ expect(req.auth.email).toBe('test@example.com');
98
+ expect(req.auth.tenants).toHaveLength(1);
99
+ });
100
+
101
+ it('returns 401 with refresh instruction for expired token', () => {
102
+ const { createJwtValidator } = loadModule();
103
+ const middleware = createJwtValidator({ logger });
104
+ const token = jwt.sign(
105
+ { sub: 'u_test', person_id: 1, type: 'access' },
106
+ TEST_SECRET,
107
+ { issuer: 'oa-auth', expiresIn: '0s' }
108
+ );
109
+ const req = { path: '/test', headers: { authorization: `Bearer ${token}` } };
110
+ const res = mockRes();
111
+ const next = jest.fn();
112
+
113
+ middleware(req, res, next);
114
+
115
+ expect(res.statusCode).toBe(401);
116
+ expect(res.body.error).toContain('expired');
117
+ expect(res.body.error).toContain('refresh');
118
+ expect(next).not.toHaveBeenCalled();
119
+ });
120
+
121
+ it('returns 401 for refresh token (wrong type)', () => {
122
+ const { createJwtValidator } = loadModule();
123
+ const middleware = createJwtValidator({ logger });
124
+ const token = jwt.sign(
125
+ { sub: 'u_test', person_id: 1, type: 'refresh' },
126
+ TEST_SECRET,
127
+ { issuer: 'oa-auth', expiresIn: '7d' }
128
+ );
129
+ const req = { path: '/test', headers: { authorization: `Bearer ${token}` } };
130
+ const res = mockRes();
131
+ const next = jest.fn();
132
+
133
+ middleware(req, res, next);
134
+
135
+ expect(res.statusCode).toBe(401);
136
+ expect(res.body.error).toContain('Invalid token type');
137
+ expect(next).not.toHaveBeenCalled();
138
+ });
139
+
140
+ it('skips validation for excluded paths', () => {
141
+ const { createJwtValidator } = loadModule();
142
+ const middleware = createJwtValidator({ logger, excludePaths: ['/health', '/public'] });
143
+ const req = { path: '/health', headers: {} };
144
+ const res = mockRes();
145
+ const next = jest.fn();
146
+
147
+ middleware(req, res, next);
148
+
149
+ expect(next).toHaveBeenCalledTimes(1);
150
+ expect(res.statusCode).toBeNull();
151
+ });
152
+
153
+ it('logs warning on invalid token', () => {
154
+ const { createJwtValidator } = loadModule();
155
+ const warnFn = jest.fn();
156
+ const testLogger = { ...logger, warn: warnFn };
157
+ const middleware = createJwtValidator({ logger: testLogger });
158
+ const req = { path: '/test', headers: { authorization: 'Bearer invalid.token.here' } };
159
+ const res = mockRes();
160
+ const next = jest.fn();
161
+
162
+ middleware(req, res, next);
163
+
164
+ expect(warnFn).toHaveBeenCalledWith(
165
+ '[JWT] Token verification failed',
166
+ expect.objectContaining({ error: expect.any(String) })
167
+ );
168
+ expect(res.statusCode).toBe(401);
169
+ });
170
+ });
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ describe('extractTenantContext @unit', () => {
4
+ function loadModule() {
5
+ return require('../../../src/jwt/extractTenantContext');
6
+ }
7
+
8
+ const singleTenantAuth = {
9
+ person_uuid: 'u_person1',
10
+ person_id: 42,
11
+ email: 'test@example.com',
12
+ tenants: [
13
+ { tenant_uuid: 'a_tenant1', tenant_id: 10, role: 'OWNER' }
14
+ ]
15
+ };
16
+
17
+ const multiTenantAuth = {
18
+ person_uuid: 'u_person1',
19
+ person_id: 42,
20
+ email: 'test@example.com',
21
+ tenants: [
22
+ { tenant_uuid: 'a_tenant1', tenant_id: 10, role: 'OWNER' },
23
+ { tenant_uuid: 'a_tenant2', tenant_id: 20, role: 'EDITOR' }
24
+ ]
25
+ };
26
+
27
+ it('throws 400 for single tenant without workspace header (requireWorkspace: true)', () => {
28
+ const { extractTenantContext } = loadModule();
29
+ expect(() => extractTenantContext(singleTenantAuth, {})).toThrow('Missing x-workspace-id');
30
+ });
31
+
32
+ it('auto-picks single tenant without workspace when requireWorkspace: false', () => {
33
+ const { extractTenantContext } = loadModule();
34
+ const ctx = extractTenantContext(singleTenantAuth, {}, { requireWorkspace: false });
35
+
36
+ expect(ctx).toEqual({
37
+ tenant_id: 10,
38
+ tenant_uuid: 'a_tenant1',
39
+ workspace_id: null,
40
+ person_id: 42,
41
+ person_uuid: 'u_person1',
42
+ role: 'OWNER'
43
+ });
44
+ });
45
+
46
+ it('uses workspace from header when provided', () => {
47
+ const { extractTenantContext } = loadModule();
48
+ const ctx = extractTenantContext(singleTenantAuth, { 'x-workspace-id': '5' });
49
+
50
+ expect(ctx.workspace_id).toBe(5);
51
+ });
52
+
53
+ it('picks correct tenant when x-tenant-uuid is provided', () => {
54
+ const { extractTenantContext } = loadModule();
55
+ const ctx = extractTenantContext(multiTenantAuth, { 'x-tenant-uuid': 'a_tenant2', 'x-workspace-id': '1' });
56
+
57
+ expect(ctx.tenant_id).toBe(20);
58
+ expect(ctx.tenant_uuid).toBe('a_tenant2');
59
+ expect(ctx.role).toBe('EDITOR');
60
+ expect(ctx.workspace_id).toBe(1);
61
+ });
62
+
63
+ it('picks correct tenant with custom workspace', () => {
64
+ const { extractTenantContext } = loadModule();
65
+ const ctx = extractTenantContext(multiTenantAuth, {
66
+ 'x-tenant-uuid': 'a_tenant1',
67
+ 'x-workspace-id': '3'
68
+ });
69
+
70
+ expect(ctx.tenant_id).toBe(10);
71
+ expect(ctx.workspace_id).toBe(3);
72
+ });
73
+
74
+ it('throws 400 when multiple tenants and no x-tenant-uuid (requireWorkspace: false)', () => {
75
+ const { extractTenantContext } = loadModule();
76
+
77
+ expect(() => extractTenantContext(multiTenantAuth, {}, { requireWorkspace: false })).toThrow('Missing tenant context');
78
+ try {
79
+ extractTenantContext(multiTenantAuth, {}, { requireWorkspace: false });
80
+ } catch (err) {
81
+ expect(err.statusCode).toBe(400);
82
+ }
83
+ });
84
+
85
+ it('throws 403 when x-tenant-uuid does not match any membership', () => {
86
+ const { extractTenantContext } = loadModule();
87
+
88
+ expect(() => extractTenantContext(singleTenantAuth, { 'x-tenant-uuid': 'a_nonexistent', 'x-workspace-id': '1' }))
89
+ .toThrow('No membership for specified tenant');
90
+ try {
91
+ extractTenantContext(singleTenantAuth, { 'x-tenant-uuid': 'a_nonexistent', 'x-workspace-id': '1' });
92
+ } catch (err) {
93
+ expect(err.statusCode).toBe(403);
94
+ }
95
+ });
96
+
97
+ it('parses workspace header as integer', () => {
98
+ const { extractTenantContext } = loadModule();
99
+ const ctx = extractTenantContext(singleTenantAuth, { 'x-workspace-id': '42' });
100
+
101
+ expect(ctx.workspace_id).toBe(42);
102
+ expect(typeof ctx.workspace_id).toBe('number');
103
+ });
104
+
105
+ it('throws 401 when auth is null', () => {
106
+ const { extractTenantContext } = loadModule();
107
+
108
+ expect(() => extractTenantContext(null, {})).toThrow('Missing auth data');
109
+ try {
110
+ extractTenantContext(null, {});
111
+ } catch (err) {
112
+ expect(err.statusCode).toBe(401);
113
+ }
114
+ });
115
+
116
+ it('throws 401 when auth.tenants is not an array', () => {
117
+ const { extractTenantContext } = loadModule();
118
+
119
+ expect(() => extractTenantContext({ person_id: 1 }, {})).toThrow('Missing auth data');
120
+ });
121
+
122
+ it('single tenant with only x-workspace-id header (no x-tenant-uuid)', () => {
123
+ const { extractTenantContext } = loadModule();
124
+ const ctx = extractTenantContext(singleTenantAuth, { 'x-workspace-id': '7' });
125
+
126
+ expect(ctx.tenant_id).toBe(10);
127
+ expect(ctx.workspace_id).toBe(7);
128
+ });
129
+
130
+ it('multiple tenants with only x-workspace-id header throws 400', () => {
131
+ const { extractTenantContext } = loadModule();
132
+
133
+ expect(() => extractTenantContext(multiTenantAuth, { 'x-workspace-id': '2' }))
134
+ .toThrow('Missing tenant context');
135
+ });
136
+
137
+ it('workspace_id null when requireWorkspace false and no header', () => {
138
+ const { extractTenantContext } = loadModule();
139
+ const ctx = extractTenantContext(singleTenantAuth, {}, { requireWorkspace: false });
140
+ expect(ctx.workspace_id).toBeNull();
141
+ });
142
+
143
+ it('workspace_id from header even when requireWorkspace false', () => {
144
+ const { extractTenantContext } = loadModule();
145
+ const ctx = extractTenantContext(singleTenantAuth, { 'x-workspace-id': '9' }, { requireWorkspace: false });
146
+ expect(ctx.workspace_id).toBe(9);
147
+ });
148
+ });
@@ -0,0 +1,137 @@
1
+ 'use strict';
2
+
3
+ const jwt = require('jsonwebtoken');
4
+
5
+ const TEST_SECRET = 'test-jwt-secret-at-least-16-chars';
6
+ const WRONG_SECRET = 'wrong-secret-also-16-chars-long';
7
+
8
+ function createValidAccessToken(overrides = {}) {
9
+ const payload = {
10
+ sub: 'u_test-person-uuid',
11
+ person_id: 42,
12
+ email: 'test@example.com',
13
+ tenants: [{ tenant_uuid: 'a_tenant1', tenant_id: 1, role: 'OWNER' }],
14
+ type: 'access',
15
+ ...overrides
16
+ };
17
+ return jwt.sign(payload, TEST_SECRET, { issuer: 'oa-auth', expiresIn: '15m' });
18
+ }
19
+
20
+ describe('verifyAccessToken @unit', () => {
21
+ const originalEnv = process.env.JWT_SECRET;
22
+
23
+ beforeEach(() => {
24
+ process.env.JWT_SECRET = TEST_SECRET;
25
+ jest.resetModules();
26
+ });
27
+
28
+ afterEach(() => {
29
+ if (originalEnv !== undefined) {
30
+ process.env.JWT_SECRET = originalEnv;
31
+ } else {
32
+ delete process.env.JWT_SECRET;
33
+ }
34
+ });
35
+
36
+ function loadModule() {
37
+ return require('../../../src/jwt/verifyAccessToken');
38
+ }
39
+
40
+ it('returns decoded payload for valid access token', () => {
41
+ const { verifyAccessToken } = loadModule();
42
+ const token = createValidAccessToken();
43
+ const decoded = verifyAccessToken(token);
44
+
45
+ expect(decoded.sub).toBe('u_test-person-uuid');
46
+ expect(decoded.person_id).toBe(42);
47
+ expect(decoded.email).toBe('test@example.com');
48
+ expect(decoded.tenants).toHaveLength(1);
49
+ expect(decoded.type).toBe('access');
50
+ expect(decoded.iss).toBe('oa-auth');
51
+ });
52
+
53
+ it('throws on expired token', () => {
54
+ const { verifyAccessToken } = loadModule();
55
+ const token = jwt.sign(
56
+ { sub: 'u_test', person_id: 1, type: 'access' },
57
+ TEST_SECRET,
58
+ { issuer: 'oa-auth', expiresIn: '0s' }
59
+ );
60
+
61
+ expect(() => verifyAccessToken(token)).toThrow();
62
+ try {
63
+ verifyAccessToken(token);
64
+ } catch (err) {
65
+ expect(err.name).toBe('TokenExpiredError');
66
+ }
67
+ });
68
+
69
+ it('throws on invalid signature (wrong secret)', () => {
70
+ const { verifyAccessToken } = loadModule();
71
+ const token = jwt.sign(
72
+ { sub: 'u_test', person_id: 1, type: 'access' },
73
+ WRONG_SECRET,
74
+ { issuer: 'oa-auth' }
75
+ );
76
+
77
+ expect(() => verifyAccessToken(token)).toThrow('invalid signature');
78
+ });
79
+
80
+ it('throws when JWT_SECRET is missing', () => {
81
+ delete process.env.JWT_SECRET;
82
+ const { verifyAccessToken } = loadModule();
83
+ const token = createValidAccessToken();
84
+
85
+ expect(() => verifyAccessToken(token)).toThrow('JWT_SECRET');
86
+ });
87
+
88
+ it('throws when JWT_SECRET is too short', () => {
89
+ process.env.JWT_SECRET = 'short';
90
+ const { verifyAccessToken } = loadModule();
91
+ const token = createValidAccessToken();
92
+
93
+ expect(() => verifyAccessToken(token)).toThrow('at least 16 characters');
94
+ });
95
+
96
+ it('throws on refresh token (type != access)', () => {
97
+ const { verifyAccessToken } = loadModule();
98
+ const token = jwt.sign(
99
+ { sub: 'u_test', person_id: 1, type: 'refresh' },
100
+ TEST_SECRET,
101
+ { issuer: 'oa-auth', expiresIn: '7d' }
102
+ );
103
+
104
+ expect(() => verifyAccessToken(token)).toThrow('Invalid token type');
105
+ try {
106
+ verifyAccessToken(token);
107
+ } catch (err) {
108
+ expect(err.code).toBe('INVALID_TOKEN_TYPE');
109
+ }
110
+ });
111
+
112
+ it('throws on wrong issuer', () => {
113
+ const { verifyAccessToken } = loadModule();
114
+ const token = jwt.sign(
115
+ { sub: 'u_test', person_id: 1, type: 'access' },
116
+ TEST_SECRET,
117
+ { issuer: 'wrong-issuer' }
118
+ );
119
+
120
+ expect(() => verifyAccessToken(token)).toThrow();
121
+ });
122
+
123
+ it('throws on empty token', () => {
124
+ const { verifyAccessToken } = loadModule();
125
+
126
+ expect(() => verifyAccessToken('')).toThrow('Token is required');
127
+ expect(() => verifyAccessToken(null)).toThrow('Token is required');
128
+ expect(() => verifyAccessToken(undefined)).toThrow('Token is required');
129
+ });
130
+
131
+ it('throws on non-string token', () => {
132
+ const { verifyAccessToken } = loadModule();
133
+
134
+ expect(() => verifyAccessToken(12345)).toThrow('Token is required');
135
+ expect(() => verifyAccessToken({})).toThrow('Token is required');
136
+ });
137
+ });