@friggframework/core 2.0.0--canary.461.fa770c1.0 → 2.0.0--canary.461.41b7566.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/database/prisma.js +29 -0
- package/handlers/routers/db-migration.js +2 -2
- package/handlers/routers/health.js +2 -2
- package/integrations/integration-router.js +9 -0
- package/package.json +5 -5
- package/user/use-cases/authenticate-user.js +58 -12
- package/user/use-cases/get-user-from-shared-secret.js +124 -0
- package/user/user.js +16 -0
package/database/prisma.js
CHANGED
|
@@ -6,6 +6,32 @@ const { logger } = require('./encryption/logger');
|
|
|
6
6
|
const { Cryptor } = require('../encrypt/Cryptor');
|
|
7
7
|
const config = require('./config');
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Ensures DATABASE_URL is set for MongoDB connections
|
|
11
|
+
* Falls back to MONGO_URI if DATABASE_URL is not set
|
|
12
|
+
* Infrastructure layer concern - maps legacy MONGO_URI to Prisma's expected DATABASE_URL
|
|
13
|
+
*
|
|
14
|
+
* Note: This should only be called when DB_TYPE is 'mongodb'
|
|
15
|
+
*/
|
|
16
|
+
function ensureMongoDbUrl() {
|
|
17
|
+
// If DATABASE_URL is already set, use it
|
|
18
|
+
if (process.env.DATABASE_URL && process.env.DATABASE_URL.trim()) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Fallback to MONGO_URI for backwards compatibility with DocumentDB deployments
|
|
23
|
+
if (process.env.MONGO_URI && process.env.MONGO_URI.trim()) {
|
|
24
|
+
process.env.DATABASE_URL = process.env.MONGO_URI;
|
|
25
|
+
logger.debug('Using MONGO_URI as DATABASE_URL for MongoDB connection');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Neither is set - error
|
|
30
|
+
throw new Error(
|
|
31
|
+
'DATABASE_URL or MONGO_URI environment variable must be set for MongoDB'
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
9
35
|
function getEncryptionConfig() {
|
|
10
36
|
const STAGE = process.env.STAGE || process.env.NODE_ENV || 'development';
|
|
11
37
|
const shouldBypassEncryption = ['dev', 'test', 'local'].includes(STAGE);
|
|
@@ -99,6 +125,8 @@ const prismaClientSingleton = () => {
|
|
|
99
125
|
};
|
|
100
126
|
|
|
101
127
|
if (config.DB_TYPE === 'mongodb') {
|
|
128
|
+
// Ensure DATABASE_URL is set (fallback to MONGO_URI if needed)
|
|
129
|
+
ensureMongoDbUrl();
|
|
102
130
|
PrismaClient = loadPrismaClient('mongodb');
|
|
103
131
|
} else if (config.DB_TYPE === 'postgresql') {
|
|
104
132
|
PrismaClient = loadPrismaClient('postgresql');
|
|
@@ -181,4 +209,5 @@ module.exports = {
|
|
|
181
209
|
connectPrisma,
|
|
182
210
|
disconnectPrisma,
|
|
183
211
|
getEncryptionConfig,
|
|
212
|
+
ensureMongoDbUrl, // Exported for testing
|
|
184
213
|
};
|
|
@@ -61,13 +61,13 @@ const getDatabaseStateUseCase = new GetDatabaseStateViaWorkerUseCase({
|
|
|
61
61
|
* Matches pattern from health.js:72-88
|
|
62
62
|
*/
|
|
63
63
|
const validateApiKey = (req, res, next) => {
|
|
64
|
-
const apiKey = req.headers['x-api-key'];
|
|
64
|
+
const apiKey = req.headers['x-frigg-admin-api-key'];
|
|
65
65
|
|
|
66
66
|
if (!apiKey || apiKey !== process.env.ADMIN_API_KEY) {
|
|
67
67
|
console.error('Unauthorized access attempt to db-migrate endpoint');
|
|
68
68
|
return res.status(401).json({
|
|
69
69
|
status: 'error',
|
|
70
|
-
message: 'Unauthorized',
|
|
70
|
+
message: 'Unauthorized - x-frigg-admin-api-key header required',
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
73
|
|
|
@@ -70,7 +70,7 @@ const checkIntegrationsHealthUseCase = new CheckIntegrationsHealthUseCase({
|
|
|
70
70
|
});
|
|
71
71
|
|
|
72
72
|
const validateApiKey = (req, res, next) => {
|
|
73
|
-
const apiKey = req.headers['x-api-key'];
|
|
73
|
+
const apiKey = req.headers['x-frigg-health-api-key'];
|
|
74
74
|
|
|
75
75
|
if (req.path === '/health') {
|
|
76
76
|
return next();
|
|
@@ -80,7 +80,7 @@ const validateApiKey = (req, res, next) => {
|
|
|
80
80
|
console.error('Unauthorized access attempt to health endpoint');
|
|
81
81
|
return res.status(401).json({
|
|
82
82
|
status: 'error',
|
|
83
|
-
message: 'Unauthorized',
|
|
83
|
+
message: 'Unauthorized - x-frigg-health-api-key header required',
|
|
84
84
|
});
|
|
85
85
|
}
|
|
86
86
|
|
|
@@ -62,6 +62,9 @@ const {
|
|
|
62
62
|
const {
|
|
63
63
|
GetUserFromAdopterJwt,
|
|
64
64
|
} = require('../user/use-cases/get-user-from-adopter-jwt');
|
|
65
|
+
const {
|
|
66
|
+
GetUserFromSharedSecret,
|
|
67
|
+
} = require('../user/use-cases/get-user-from-shared-secret');
|
|
65
68
|
const {
|
|
66
69
|
AuthenticateUser,
|
|
67
70
|
} = require('../user/use-cases/authenticate-user');
|
|
@@ -92,10 +95,16 @@ function createIntegrationRouter() {
|
|
|
92
95
|
userConfig,
|
|
93
96
|
});
|
|
94
97
|
|
|
98
|
+
const getUserFromSharedSecret = new GetUserFromSharedSecret({
|
|
99
|
+
userRepository,
|
|
100
|
+
userConfig,
|
|
101
|
+
});
|
|
102
|
+
|
|
95
103
|
const authenticateUser = new AuthenticateUser({
|
|
96
104
|
getUserFromBearerToken,
|
|
97
105
|
getUserFromXFriggHeaders,
|
|
98
106
|
getUserFromAdopterJwt,
|
|
107
|
+
getUserFromSharedSecret,
|
|
99
108
|
userConfig,
|
|
100
109
|
});
|
|
101
110
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/core",
|
|
3
3
|
"prettier": "@friggframework/prettier-config",
|
|
4
|
-
"version": "2.0.0--canary.461.
|
|
4
|
+
"version": "2.0.0--canary.461.41b7566.0",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
|
|
7
7
|
"@aws-sdk/client-kms": "^3.588.0",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
}
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@friggframework/eslint-config": "2.0.0--canary.461.
|
|
42
|
-
"@friggframework/prettier-config": "2.0.0--canary.461.
|
|
43
|
-
"@friggframework/test": "2.0.0--canary.461.
|
|
41
|
+
"@friggframework/eslint-config": "2.0.0--canary.461.41b7566.0",
|
|
42
|
+
"@friggframework/prettier-config": "2.0.0--canary.461.41b7566.0",
|
|
43
|
+
"@friggframework/test": "2.0.0--canary.461.41b7566.0",
|
|
44
44
|
"@prisma/client": "^6.17.0",
|
|
45
45
|
"@types/lodash": "4.17.15",
|
|
46
46
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
@@ -80,5 +80,5 @@
|
|
|
80
80
|
"publishConfig": {
|
|
81
81
|
"access": "public"
|
|
82
82
|
},
|
|
83
|
-
"gitHead": "
|
|
83
|
+
"gitHead": "41b7566abaa46939705d52e01904022e52cdd06a"
|
|
84
84
|
}
|
|
@@ -2,8 +2,15 @@ const Boom = require('@hapi/boom');
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Use case for authenticating a user using multiple authentication strategies.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
*
|
|
6
|
+
* Supports three authentication modes in priority order:
|
|
7
|
+
* 1. Shared Secret (backend-to-backend with x-frigg-api-key + x-frigg headers)
|
|
8
|
+
* 2. Adopter JWT (custom JWT authentication)
|
|
9
|
+
* 3. Frigg Native Token (bearer token from /user/login)
|
|
10
|
+
*
|
|
11
|
+
* x-frigg-appUserId and x-frigg-appOrgId headers are automatically supported
|
|
12
|
+
* for user identification with any auth mode. When present with JWT or Frigg
|
|
13
|
+
* tokens, they are validated to match the authenticated user.
|
|
7
14
|
*
|
|
8
15
|
* @class AuthenticateUser
|
|
9
16
|
*/
|
|
@@ -14,17 +21,20 @@ class AuthenticateUser {
|
|
|
14
21
|
* @param {import('./get-user-from-bearer-token').GetUserFromBearerToken} params.getUserFromBearerToken - Use case for bearer token auth.
|
|
15
22
|
* @param {import('./get-user-from-x-frigg-headers').GetUserFromXFriggHeaders} params.getUserFromXFriggHeaders - Use case for x-frigg header auth.
|
|
16
23
|
* @param {import('./get-user-from-adopter-jwt').GetUserFromAdopterJwt} params.getUserFromAdopterJwt - Use case for adopter JWT auth.
|
|
24
|
+
* @param {import('./get-user-from-shared-secret').GetUserFromSharedSecret} params.getUserFromSharedSecret - Use case for shared secret auth.
|
|
17
25
|
* @param {Object} params.userConfig - The user config in the app definition.
|
|
18
26
|
*/
|
|
19
27
|
constructor({
|
|
20
28
|
getUserFromBearerToken,
|
|
21
29
|
getUserFromXFriggHeaders,
|
|
22
30
|
getUserFromAdopterJwt,
|
|
31
|
+
getUserFromSharedSecret,
|
|
23
32
|
userConfig,
|
|
24
33
|
}) {
|
|
25
34
|
this.getUserFromBearerToken = getUserFromBearerToken;
|
|
26
35
|
this.getUserFromXFriggHeaders = getUserFromXFriggHeaders;
|
|
27
36
|
this.getUserFromAdopterJwt = getUserFromAdopterJwt;
|
|
37
|
+
this.getUserFromSharedSecret = getUserFromSharedSecret;
|
|
28
38
|
this.userConfig = userConfig;
|
|
29
39
|
}
|
|
30
40
|
|
|
@@ -34,17 +44,20 @@ class AuthenticateUser {
|
|
|
34
44
|
* @param {Object} req - Express request object with headers.
|
|
35
45
|
* @returns {Promise<import('../user').User>} The authenticated user object.
|
|
36
46
|
* @throws {Boom} Unauthorized if no valid authentication provided.
|
|
47
|
+
* @throws {Boom} Forbidden if x-frigg headers don't match authenticated user.
|
|
37
48
|
*/
|
|
38
49
|
async execute(req) {
|
|
39
50
|
const authModes = this.userConfig.authModes || { friggToken: true };
|
|
51
|
+
const appUserId = req.headers['x-frigg-appuserid'];
|
|
52
|
+
const appOrgId = req.headers['x-frigg-apporgid'];
|
|
53
|
+
let user = null;
|
|
40
54
|
|
|
41
|
-
// Priority 1:
|
|
42
|
-
if (authModes.
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return await this.getUserFromXFriggHeaders.execute(
|
|
55
|
+
// Priority 1: Shared Secret (backend-to-backend with API key)
|
|
56
|
+
if (authModes.sharedSecret !== false) {
|
|
57
|
+
const apiKey = req.headers['x-frigg-api-key'];
|
|
58
|
+
if (apiKey) {
|
|
59
|
+
return await this.getUserFromSharedSecret.execute(
|
|
60
|
+
apiKey,
|
|
48
61
|
appUserId,
|
|
49
62
|
appOrgId
|
|
50
63
|
);
|
|
@@ -59,19 +72,52 @@ class AuthenticateUser {
|
|
|
59
72
|
const token = req.headers.authorization.split(' ')[1];
|
|
60
73
|
// Detect JWT format (3 parts separated by dots)
|
|
61
74
|
if (token && token.split('.').length === 3) {
|
|
62
|
-
|
|
75
|
+
user = await this.getUserFromAdopterJwt.execute(token);
|
|
76
|
+
// Validate x-frigg headers match JWT claims if present
|
|
77
|
+
if (appUserId || appOrgId) {
|
|
78
|
+
this.validateUserMatch(user, appUserId, appOrgId);
|
|
79
|
+
}
|
|
80
|
+
return user;
|
|
63
81
|
}
|
|
64
82
|
}
|
|
65
83
|
|
|
66
84
|
// Priority 3: Frigg native token (default)
|
|
67
|
-
if (authModes.friggToken !== false) {
|
|
68
|
-
|
|
85
|
+
if (authModes.friggToken !== false && req.headers.authorization) {
|
|
86
|
+
user = await this.getUserFromBearerToken.execute(
|
|
69
87
|
req.headers.authorization
|
|
70
88
|
);
|
|
89
|
+
// Validate x-frigg headers match token user if present
|
|
90
|
+
if (appUserId || appOrgId) {
|
|
91
|
+
this.validateUserMatch(user, appUserId, appOrgId);
|
|
92
|
+
}
|
|
93
|
+
return user;
|
|
71
94
|
}
|
|
72
95
|
|
|
73
96
|
throw Boom.unauthorized('No valid authentication provided');
|
|
74
97
|
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Validates that x-frigg headers match authenticated user if provided.
|
|
101
|
+
* This ensures that when both authentication (via token/JWT) and
|
|
102
|
+
* x-frigg headers are present, they refer to the same user.
|
|
103
|
+
*
|
|
104
|
+
* @param {import('../user').User} user - The authenticated user
|
|
105
|
+
* @param {string} [appUserId] - The x-frigg-appuserid header value
|
|
106
|
+
* @param {string} [appOrgId] - The x-frigg-apporgid header value
|
|
107
|
+
* @throws {Boom} 403 Forbidden if headers don't match user
|
|
108
|
+
*/
|
|
109
|
+
validateUserMatch(user, appUserId, appOrgId) {
|
|
110
|
+
if (appUserId && user.getAppUserId() !== appUserId) {
|
|
111
|
+
throw Boom.forbidden(
|
|
112
|
+
'x-frigg-appuserid header does not match authenticated user'
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
if (appOrgId && user.getAppOrgId() !== appOrgId) {
|
|
116
|
+
throw Boom.forbidden(
|
|
117
|
+
'x-frigg-apporgid header does not match authenticated user'
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
75
121
|
}
|
|
76
122
|
|
|
77
123
|
module.exports = { AuthenticateUser };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const Boom = require('@hapi/boom');
|
|
2
|
+
const { User } = require('../user');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Use case for authenticating requests with shared secret API key.
|
|
6
|
+
* Used for backend-to-backend communication where the secret proves
|
|
7
|
+
* authenticity, and x-frigg headers identify the user/org.
|
|
8
|
+
*
|
|
9
|
+
* This separates authentication (proving legitimacy via API key) from
|
|
10
|
+
* authorization (identifying user/org via x-frigg headers).
|
|
11
|
+
*
|
|
12
|
+
* @class GetUserFromSharedSecret
|
|
13
|
+
*/
|
|
14
|
+
class GetUserFromSharedSecret {
|
|
15
|
+
/**
|
|
16
|
+
* Creates a new GetUserFromSharedSecret instance.
|
|
17
|
+
* @param {Object} params - Configuration parameters.
|
|
18
|
+
* @param {import('../repositories/user-repository-interface').UserRepositoryInterface} params.userRepository - Repository for user data operations.
|
|
19
|
+
* @param {Object} params.userConfig - The user config in the app definition.
|
|
20
|
+
*/
|
|
21
|
+
constructor({ userRepository, userConfig }) {
|
|
22
|
+
this.userRepository = userRepository;
|
|
23
|
+
this.userConfig = userConfig;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Validates shared secret and extracts user from x-frigg headers.
|
|
28
|
+
* @async
|
|
29
|
+
* @param {string} providedSecret - Secret from x-frigg-api-key header
|
|
30
|
+
* @param {string} [appUserId] - From x-frigg-appuserid header
|
|
31
|
+
* @param {string} [appOrgId] - From x-frigg-apporgid header
|
|
32
|
+
* @returns {Promise<import('../user').User>} The authenticated user object.
|
|
33
|
+
* @throws {Boom} 500 if FRIGG_API_KEY not configured
|
|
34
|
+
* @throws {Boom} 401 if provided secret doesn't match
|
|
35
|
+
* @throws {Boom} 400 if no user identifiers provided or if they refer to different users
|
|
36
|
+
*/
|
|
37
|
+
async execute(providedSecret, appUserId, appOrgId) {
|
|
38
|
+
// Validate secret
|
|
39
|
+
const expectedSecret = process.env.FRIGG_API_KEY;
|
|
40
|
+
if (!expectedSecret) {
|
|
41
|
+
throw Boom.badImplementation(
|
|
42
|
+
'FRIGG_API_KEY environment variable is not configured. ' +
|
|
43
|
+
'Set FRIGG_API_KEY to enable shared secret authentication.'
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!providedSecret || providedSecret !== expectedSecret) {
|
|
48
|
+
throw Boom.unauthorized('Invalid API key');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Require at least one user identifier
|
|
52
|
+
if (!appUserId && !appOrgId) {
|
|
53
|
+
throw Boom.badRequest(
|
|
54
|
+
'At least one of x-frigg-appuserid or x-frigg-apporgid headers is required with shared secret authentication'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Find or create users (reuse logic from GetUserFromXFriggHeaders)
|
|
59
|
+
let individualUserData = null;
|
|
60
|
+
let organizationUserData = null;
|
|
61
|
+
|
|
62
|
+
if (appUserId && this.userConfig.individualUserRequired !== false) {
|
|
63
|
+
individualUserData =
|
|
64
|
+
await this.userRepository.findIndividualUserByAppUserId(
|
|
65
|
+
appUserId
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (appOrgId && this.userConfig.organizationUserRequired) {
|
|
70
|
+
organizationUserData =
|
|
71
|
+
await this.userRepository.findOrganizationUserByAppOrgId(
|
|
72
|
+
appOrgId
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// VALIDATION: If both IDs provided and both users exist, verify they match
|
|
77
|
+
if (
|
|
78
|
+
appUserId &&
|
|
79
|
+
appOrgId &&
|
|
80
|
+
individualUserData &&
|
|
81
|
+
organizationUserData
|
|
82
|
+
) {
|
|
83
|
+
const individualOrgId =
|
|
84
|
+
individualUserData.organizationUser?.toString();
|
|
85
|
+
const expectedOrgId = organizationUserData.id?.toString();
|
|
86
|
+
|
|
87
|
+
if (individualOrgId !== expectedOrgId) {
|
|
88
|
+
throw Boom.badRequest(
|
|
89
|
+
'User ID mismatch: x-frigg-appuserid and x-frigg-apporgid refer to different users. ' +
|
|
90
|
+
'Provide only one identifier or ensure they belong to the same user.'
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Auto-create user if not found
|
|
96
|
+
if (!individualUserData && !organizationUserData) {
|
|
97
|
+
if (appUserId) {
|
|
98
|
+
individualUserData =
|
|
99
|
+
await this.userRepository.createIndividualUser({
|
|
100
|
+
appUserId,
|
|
101
|
+
username: `api-user-${appUserId}`,
|
|
102
|
+
email: `${appUserId}@api.local`,
|
|
103
|
+
});
|
|
104
|
+
} else {
|
|
105
|
+
organizationUserData =
|
|
106
|
+
await this.userRepository.createOrganizationUser({
|
|
107
|
+
appOrgId,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return new User(
|
|
113
|
+
individualUserData,
|
|
114
|
+
organizationUserData,
|
|
115
|
+
this.userConfig.usePassword,
|
|
116
|
+
this.userConfig.primary,
|
|
117
|
+
this.userConfig.individualUserRequired,
|
|
118
|
+
this.userConfig.organizationUserRequired
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { GetUserFromSharedSecret };
|
|
124
|
+
|
package/user/user.js
CHANGED
|
@@ -72,6 +72,22 @@ class User {
|
|
|
72
72
|
getOrganizationUser() {
|
|
73
73
|
return this.organizationUser;
|
|
74
74
|
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Gets the appUserId from the individual user if present.
|
|
78
|
+
* @returns {string|null} The app user ID or null
|
|
79
|
+
*/
|
|
80
|
+
getAppUserId() {
|
|
81
|
+
return this.individualUser?.appUserId || null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Gets the appOrgId from the organization user if present.
|
|
86
|
+
* @returns {string|null} The app organization ID or null
|
|
87
|
+
*/
|
|
88
|
+
getAppOrgId() {
|
|
89
|
+
return this.organizationUser?.appOrgId || null;
|
|
90
|
+
}
|
|
75
91
|
}
|
|
76
92
|
|
|
77
93
|
module.exports = { User };
|