@friggframework/admin-scripts 2.0.0--canary.517.41839c5.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/LICENSE.md +9 -0
- package/index.js +66 -0
- package/package.json +53 -0
- package/src/adapters/__tests__/aws-scheduler-adapter.test.js +322 -0
- package/src/adapters/__tests__/local-scheduler-adapter.test.js +325 -0
- package/src/adapters/__tests__/scheduler-adapter-factory.test.js +257 -0
- package/src/adapters/__tests__/scheduler-adapter.test.js +103 -0
- package/src/adapters/aws-scheduler-adapter.js +138 -0
- package/src/adapters/local-scheduler-adapter.js +103 -0
- package/src/adapters/scheduler-adapter-factory.js +69 -0
- package/src/adapters/scheduler-adapter.js +64 -0
- package/src/application/__tests__/admin-frigg-commands.test.js +643 -0
- package/src/application/__tests__/admin-script-base.test.js +273 -0
- package/src/application/__tests__/dry-run-http-interceptor.test.js +313 -0
- package/src/application/__tests__/dry-run-repository-wrapper.test.js +257 -0
- package/src/application/__tests__/schedule-management-use-case.test.js +276 -0
- package/src/application/__tests__/script-factory.test.js +381 -0
- package/src/application/__tests__/script-runner.test.js +202 -0
- package/src/application/admin-frigg-commands.js +242 -0
- package/src/application/admin-script-base.js +138 -0
- package/src/application/dry-run-http-interceptor.js +296 -0
- package/src/application/dry-run-repository-wrapper.js +261 -0
- package/src/application/schedule-management-use-case.js +230 -0
- package/src/application/script-factory.js +161 -0
- package/src/application/script-runner.js +254 -0
- package/src/builtins/__tests__/integration-health-check.test.js +598 -0
- package/src/builtins/__tests__/oauth-token-refresh.test.js +344 -0
- package/src/builtins/index.js +28 -0
- package/src/builtins/integration-health-check.js +279 -0
- package/src/builtins/oauth-token-refresh.js +221 -0
- package/src/infrastructure/__tests__/admin-auth-middleware.test.js +148 -0
- package/src/infrastructure/__tests__/admin-script-router.test.js +701 -0
- package/src/infrastructure/admin-auth-middleware.js +49 -0
- package/src/infrastructure/admin-script-router.js +311 -0
- package/src/infrastructure/script-executor-handler.js +75 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
const { AdminScriptBase } = require('../application/admin-script-base');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* OAuth Token Refresh Script
|
|
5
|
+
*
|
|
6
|
+
* Refreshes OAuth tokens for integrations that are near expiry.
|
|
7
|
+
* This helps prevent authentication failures due to expired tokens.
|
|
8
|
+
*/
|
|
9
|
+
class OAuthTokenRefreshScript extends AdminScriptBase {
|
|
10
|
+
static Definition = {
|
|
11
|
+
name: 'oauth-token-refresh',
|
|
12
|
+
version: '1.0.0',
|
|
13
|
+
description: 'Refreshes OAuth tokens for integrations near expiry',
|
|
14
|
+
source: 'BUILTIN',
|
|
15
|
+
|
|
16
|
+
inputSchema: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
integrationIds: {
|
|
20
|
+
type: 'array',
|
|
21
|
+
items: { type: 'string' },
|
|
22
|
+
description: 'Specific integration IDs to refresh (optional, defaults to all)'
|
|
23
|
+
},
|
|
24
|
+
expiryThresholdHours: {
|
|
25
|
+
type: 'number',
|
|
26
|
+
default: 24,
|
|
27
|
+
description: 'Refresh tokens expiring within this many hours'
|
|
28
|
+
},
|
|
29
|
+
dryRun: {
|
|
30
|
+
type: 'boolean',
|
|
31
|
+
default: false,
|
|
32
|
+
description: 'Preview without making changes'
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
outputSchema: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {
|
|
40
|
+
refreshed: { type: 'number' },
|
|
41
|
+
failed: { type: 'number' },
|
|
42
|
+
skipped: { type: 'number' },
|
|
43
|
+
details: { type: 'array' }
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
config: {
|
|
48
|
+
timeout: 600000, // 10 minutes
|
|
49
|
+
maxRetries: 1,
|
|
50
|
+
requiresIntegrationFactory: true, // Needs to call external APIs
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
display: {
|
|
54
|
+
label: 'OAuth Token Refresh',
|
|
55
|
+
description: 'Refresh OAuth tokens before they expire',
|
|
56
|
+
category: 'maintenance',
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
async execute(frigg, params = {}) {
|
|
61
|
+
const {
|
|
62
|
+
integrationIds = null,
|
|
63
|
+
expiryThresholdHours = 24,
|
|
64
|
+
dryRun = false
|
|
65
|
+
} = params;
|
|
66
|
+
|
|
67
|
+
const results = {
|
|
68
|
+
refreshed: 0,
|
|
69
|
+
failed: 0,
|
|
70
|
+
skipped: 0,
|
|
71
|
+
details: []
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
frigg.log('info', 'Starting OAuth token refresh', {
|
|
75
|
+
expiryThresholdHours,
|
|
76
|
+
dryRun,
|
|
77
|
+
specificIds: integrationIds?.length || 'all'
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Get integrations to check
|
|
81
|
+
let integrations;
|
|
82
|
+
if (integrationIds && integrationIds.length > 0) {
|
|
83
|
+
integrations = await Promise.all(
|
|
84
|
+
integrationIds.map(id => frigg.findIntegrationById(id).catch(() => null))
|
|
85
|
+
);
|
|
86
|
+
integrations = integrations.filter(Boolean);
|
|
87
|
+
} else {
|
|
88
|
+
// Get all integrations (this would need to be paginated for large deployments)
|
|
89
|
+
integrations = await this.getAllIntegrations(frigg);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
frigg.log('info', `Found ${integrations.length} integrations to check`);
|
|
93
|
+
|
|
94
|
+
for (const integration of integrations) {
|
|
95
|
+
try {
|
|
96
|
+
const detail = await this.processIntegration(frigg, integration, {
|
|
97
|
+
expiryThresholdHours,
|
|
98
|
+
dryRun
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
results.details.push(detail);
|
|
102
|
+
|
|
103
|
+
if (detail.action === 'refreshed') {
|
|
104
|
+
results.refreshed++;
|
|
105
|
+
} else if (detail.action === 'skipped') {
|
|
106
|
+
results.skipped++;
|
|
107
|
+
} else if (detail.action === 'failed') {
|
|
108
|
+
results.failed++;
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
frigg.log('error', `Error processing integration ${integration.id}`, {
|
|
112
|
+
error: error.message
|
|
113
|
+
});
|
|
114
|
+
results.failed++;
|
|
115
|
+
results.details.push({
|
|
116
|
+
integrationId: integration.id,
|
|
117
|
+
action: 'failed',
|
|
118
|
+
reason: error.message
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
frigg.log('info', 'OAuth token refresh completed', {
|
|
124
|
+
refreshed: results.refreshed,
|
|
125
|
+
failed: results.failed,
|
|
126
|
+
skipped: results.skipped
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return results;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async getAllIntegrations(frigg) {
|
|
133
|
+
// This is a simplified implementation
|
|
134
|
+
// In production, would need pagination for large datasets
|
|
135
|
+
return frigg.listIntegrations({});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async processIntegration(frigg, integration, options) {
|
|
139
|
+
const { expiryThresholdHours, dryRun } = options;
|
|
140
|
+
|
|
141
|
+
// Check prerequisites
|
|
142
|
+
const skipReason = this._checkRefreshPrerequisites(integration, expiryThresholdHours);
|
|
143
|
+
if (skipReason) {
|
|
144
|
+
return this._createResult(integration.id, 'skipped', skipReason);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Handle dry run
|
|
148
|
+
if (dryRun) {
|
|
149
|
+
frigg.log('info', `[DRY RUN] Would refresh token for ${integration.id}`);
|
|
150
|
+
return this._createResult(integration.id, 'skipped', 'Dry run - would have refreshed');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Perform refresh
|
|
154
|
+
return this._performTokenRefresh(frigg, integration);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Check if integration meets prerequisites for token refresh
|
|
159
|
+
* @private
|
|
160
|
+
* @returns {string|null} Skip reason or null if eligible
|
|
161
|
+
*/
|
|
162
|
+
_checkRefreshPrerequisites(integration, expiryThresholdHours) {
|
|
163
|
+
if (!integration.config?.credentials?.access_token) {
|
|
164
|
+
return 'No OAuth credentials found';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const expiresAt = integration.config?.credentials?.expires_at;
|
|
168
|
+
if (!expiresAt) {
|
|
169
|
+
return 'No expiry time found';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const expiryTime = new Date(expiresAt);
|
|
173
|
+
const thresholdTime = new Date(Date.now() + (expiryThresholdHours * 60 * 60 * 1000));
|
|
174
|
+
|
|
175
|
+
if (expiryTime > thresholdTime) {
|
|
176
|
+
return 'Token not near expiry';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Perform the actual token refresh
|
|
184
|
+
* @private
|
|
185
|
+
*/
|
|
186
|
+
async _performTokenRefresh(frigg, integration) {
|
|
187
|
+
const expiresAt = integration.config?.credentials?.expires_at;
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const instance = await frigg.instantiate(integration.id);
|
|
191
|
+
|
|
192
|
+
if (!instance.primary?.api?.refreshAccessToken) {
|
|
193
|
+
return this._createResult(integration.id, 'skipped', 'API does not support token refresh');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
await instance.primary.api.refreshAccessToken();
|
|
197
|
+
frigg.log('info', `Refreshed token for integration ${integration.id}`);
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
integrationId: integration.id,
|
|
201
|
+
action: 'refreshed',
|
|
202
|
+
previousExpiry: expiresAt
|
|
203
|
+
};
|
|
204
|
+
} catch (error) {
|
|
205
|
+
frigg.log('error', `Failed to refresh token for ${integration.id}`, {
|
|
206
|
+
error: error.message
|
|
207
|
+
});
|
|
208
|
+
return this._createResult(integration.id, 'failed', error.message);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Create a result object
|
|
214
|
+
* @private
|
|
215
|
+
*/
|
|
216
|
+
_createResult(integrationId, action, reason) {
|
|
217
|
+
return { integrationId, action, reason };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = { OAuthTokenRefreshScript };
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const { adminAuthMiddleware } = require('../admin-auth-middleware');
|
|
2
|
+
|
|
3
|
+
// Mock the admin script commands
|
|
4
|
+
jest.mock('@friggframework/core/application/commands/admin-script-commands', () => ({
|
|
5
|
+
createAdminScriptCommands: jest.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands');
|
|
9
|
+
|
|
10
|
+
describe('adminAuthMiddleware', () => {
|
|
11
|
+
let mockReq;
|
|
12
|
+
let mockRes;
|
|
13
|
+
let mockNext;
|
|
14
|
+
let mockCommands;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
mockReq = {
|
|
18
|
+
headers: {},
|
|
19
|
+
ip: '127.0.0.1',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
mockRes = {
|
|
23
|
+
status: jest.fn().mockReturnThis(),
|
|
24
|
+
json: jest.fn(),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
mockNext = jest.fn();
|
|
28
|
+
|
|
29
|
+
mockCommands = {
|
|
30
|
+
validateAdminApiKey: jest.fn(),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
createAdminScriptCommands.mockReturnValue(mockCommands);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
jest.clearAllMocks();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('Authorization header validation', () => {
|
|
41
|
+
it('should reject request without Authorization header', async () => {
|
|
42
|
+
await adminAuthMiddleware(mockReq, mockRes, mockNext);
|
|
43
|
+
|
|
44
|
+
expect(mockRes.status).toHaveBeenCalledWith(401);
|
|
45
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
46
|
+
error: 'Missing or invalid Authorization header',
|
|
47
|
+
code: 'MISSING_AUTH',
|
|
48
|
+
});
|
|
49
|
+
expect(mockNext).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should reject request with invalid Authorization format', async () => {
|
|
53
|
+
mockReq.headers.authorization = 'InvalidFormat key123';
|
|
54
|
+
|
|
55
|
+
await adminAuthMiddleware(mockReq, mockRes, mockNext);
|
|
56
|
+
|
|
57
|
+
expect(mockRes.status).toHaveBeenCalledWith(401);
|
|
58
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
59
|
+
error: 'Missing or invalid Authorization header',
|
|
60
|
+
code: 'MISSING_AUTH',
|
|
61
|
+
});
|
|
62
|
+
expect(mockNext).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('API key validation', () => {
|
|
67
|
+
it('should reject request with invalid API key', async () => {
|
|
68
|
+
mockReq.headers.authorization = 'Bearer invalid-key';
|
|
69
|
+
mockCommands.validateAdminApiKey.mockResolvedValue({
|
|
70
|
+
error: 401,
|
|
71
|
+
reason: 'Invalid API key',
|
|
72
|
+
code: 'INVALID_API_KEY',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await adminAuthMiddleware(mockReq, mockRes, mockNext);
|
|
76
|
+
|
|
77
|
+
expect(mockCommands.validateAdminApiKey).toHaveBeenCalledWith('invalid-key');
|
|
78
|
+
expect(mockRes.status).toHaveBeenCalledWith(401);
|
|
79
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
80
|
+
error: 'Invalid API key',
|
|
81
|
+
code: 'INVALID_API_KEY',
|
|
82
|
+
});
|
|
83
|
+
expect(mockNext).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should reject request with expired API key', async () => {
|
|
87
|
+
mockReq.headers.authorization = 'Bearer expired-key';
|
|
88
|
+
mockCommands.validateAdminApiKey.mockResolvedValue({
|
|
89
|
+
error: 401,
|
|
90
|
+
reason: 'API key has expired',
|
|
91
|
+
code: 'EXPIRED_API_KEY',
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await adminAuthMiddleware(mockReq, mockRes, mockNext);
|
|
95
|
+
|
|
96
|
+
expect(mockCommands.validateAdminApiKey).toHaveBeenCalledWith('expired-key');
|
|
97
|
+
expect(mockRes.status).toHaveBeenCalledWith(401);
|
|
98
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
99
|
+
error: 'API key has expired',
|
|
100
|
+
code: 'EXPIRED_API_KEY',
|
|
101
|
+
});
|
|
102
|
+
expect(mockNext).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should accept request with valid API key', async () => {
|
|
106
|
+
const validKey = 'valid-api-key-123';
|
|
107
|
+
mockReq.headers.authorization = `Bearer ${validKey}`;
|
|
108
|
+
mockCommands.validateAdminApiKey.mockResolvedValue({
|
|
109
|
+
valid: true,
|
|
110
|
+
apiKey: {
|
|
111
|
+
id: 'key-id-1',
|
|
112
|
+
name: 'test-key',
|
|
113
|
+
keyLast4: 'e123',
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await adminAuthMiddleware(mockReq, mockRes, mockNext);
|
|
118
|
+
|
|
119
|
+
expect(mockCommands.validateAdminApiKey).toHaveBeenCalledWith(validKey);
|
|
120
|
+
expect(mockReq.adminApiKey).toBeDefined();
|
|
121
|
+
expect(mockReq.adminApiKey.name).toBe('test-key');
|
|
122
|
+
expect(mockReq.adminAudit).toBeDefined();
|
|
123
|
+
expect(mockReq.adminAudit.apiKeyName).toBe('test-key');
|
|
124
|
+
expect(mockReq.adminAudit.apiKeyLast4).toBe('e123');
|
|
125
|
+
expect(mockReq.adminAudit.ipAddress).toBe('127.0.0.1');
|
|
126
|
+
expect(mockNext).toHaveBeenCalled();
|
|
127
|
+
expect(mockRes.status).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('Error handling', () => {
|
|
132
|
+
it('should handle validation errors gracefully', async () => {
|
|
133
|
+
mockReq.headers.authorization = 'Bearer some-key';
|
|
134
|
+
mockCommands.validateAdminApiKey.mockRejectedValue(
|
|
135
|
+
new Error('Database error')
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
await adminAuthMiddleware(mockReq, mockRes, mockNext);
|
|
139
|
+
|
|
140
|
+
expect(mockRes.status).toHaveBeenCalledWith(500);
|
|
141
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
142
|
+
error: 'Authentication failed',
|
|
143
|
+
code: 'AUTH_ERROR',
|
|
144
|
+
});
|
|
145
|
+
expect(mockNext).not.toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
});
|