@factorialco/fcode-cli 1.5.0 → 2.0.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/README.md +3 -3
- package/dist/commands/http.d.ts +6 -4
- package/dist/commands/http.js +40 -24
- package/dist/commands/team/push.js +6 -1
- package/dist/core/types.d.ts +28 -3
- package/dist/core/types.js +14 -4
- package/dist/repositories/team.d.ts +3 -1
- package/dist/repositories/team.js +19 -3
- package/dist/services/auth-service.d.ts +3 -0
- package/dist/services/auth-service.js +56 -5
- package/dist/services/fcode-graphql-client.d.ts +10 -0
- package/dist/services/fcode-graphql-client.js +68 -26
- package/dist/services/oauth-browser-service.js +1 -0
- package/dist/services/webhook-auth.d.ts +21 -0
- package/dist/services/webhook-auth.js +77 -0
- package/oclif.manifest.json +236 -236
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ $ npm install -g @factorialco/fcode-cli
|
|
|
20
20
|
$ fcode COMMAND
|
|
21
21
|
running command...
|
|
22
22
|
$ fcode (--version)
|
|
23
|
-
@factorialco/fcode-cli/
|
|
23
|
+
@factorialco/fcode-cli/2.0.0 linux-x64 node-v22.22.3
|
|
24
24
|
$ fcode --help [COMMAND]
|
|
25
25
|
USAGE
|
|
26
26
|
$ fcode COMMAND
|
|
@@ -828,7 +828,7 @@ EXAMPLES
|
|
|
828
828
|
|
|
829
829
|
## `fcode team:push`
|
|
830
830
|
|
|
831
|
-
Update cloud team settings (timezone, error handler, inheritance) from team.json
|
|
831
|
+
Update cloud team settings (timezone, error handler, inheritance, webhook auth) from team.json
|
|
832
832
|
|
|
833
833
|
```
|
|
834
834
|
USAGE
|
|
@@ -838,7 +838,7 @@ FLAGS
|
|
|
838
838
|
-f, --force Force command
|
|
839
839
|
|
|
840
840
|
DESCRIPTION
|
|
841
|
-
Update cloud team settings (timezone, error handler, inheritance) from team.json
|
|
841
|
+
Update cloud team settings (timezone, error handler, inheritance, webhook auth) from team.json
|
|
842
842
|
|
|
843
843
|
EXAMPLES
|
|
844
844
|
$ fcode team:push
|
package/dist/commands/http.d.ts
CHANGED
|
@@ -11,12 +11,14 @@ export default class Http extends Command {
|
|
|
11
11
|
};
|
|
12
12
|
run(): Promise<void>;
|
|
13
13
|
/**
|
|
14
|
-
* Enforces the
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* Enforces the webhook's configured auth, simulating the cloud webhook endpoint: the effective
|
|
15
|
+
* configuration names a variable, whose value is looked up in the workspace variables and compared
|
|
16
|
+
* against the configured header. A missing or malformed header, an undefined variable, a mismatch,
|
|
17
|
+
* or a webhook inheriting a workspace configuration that team.json does not have, is rejected.
|
|
18
|
+
* Returns true when the request may proceed, false after writing a 403.
|
|
18
19
|
*/
|
|
19
20
|
private _checkWebhookAuth;
|
|
21
|
+
private _forbid;
|
|
20
22
|
private _handleProcessExecution;
|
|
21
23
|
private _sendResponse;
|
|
22
24
|
}
|
package/dist/commands/http.js
CHANGED
|
@@ -7,6 +7,9 @@ const process_runner_1 = require("../services/process-runner");
|
|
|
7
7
|
const process_1 = require("../repositories/process");
|
|
8
8
|
const variable_1 = require("../repositories/variable");
|
|
9
9
|
const workspace_1 = require("../config/workspace");
|
|
10
|
+
const team_1 = require("../repositories/team");
|
|
11
|
+
const webhook_auth_1 = require("../services/webhook-auth");
|
|
12
|
+
const types_1 = require("../core/types");
|
|
10
13
|
class Http extends core_1.Command {
|
|
11
14
|
async run() {
|
|
12
15
|
const { flags: { port, logLevel, 'auth-user': authUser, 'auth-password': authPassword, jsonLogs }, } = await this.parse(Http);
|
|
@@ -16,11 +19,17 @@ class Http extends core_1.Command {
|
|
|
16
19
|
: null;
|
|
17
20
|
const { slug: teamSlug } = await new workspace_1.default().getTeamOrigin();
|
|
18
21
|
const processRepository = new process_1.default();
|
|
22
|
+
const teamRepository = new team_1.default();
|
|
23
|
+
if (useBasicAuth) {
|
|
24
|
+
this.warn(`--auth-user/--auth-password protect the whole server through the ${types_1.DEFAULT_WEBHOOK_AUTH_HEADER} header, ` +
|
|
25
|
+
`so a webhook expecting its credential there cannot be exercised at the same time.`);
|
|
26
|
+
}
|
|
19
27
|
const handler = async (req, res) => {
|
|
20
28
|
// CORS headers
|
|
21
29
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
22
30
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
23
|
-
|
|
31
|
+
// Reflected rather than fixed, so a webhook using a custom auth header passes the preflight.
|
|
32
|
+
res.setHeader('Access-Control-Allow-Headers', req.headers['access-control-request-headers'] ?? 'Content-Type, Authorization, Fcode-Async');
|
|
24
33
|
if (req.method === 'OPTIONS') {
|
|
25
34
|
res.writeHead(204);
|
|
26
35
|
res.end();
|
|
@@ -77,7 +86,7 @@ class Http extends core_1.Command {
|
|
|
77
86
|
res.end(JSON.stringify({ success: false, error: `Process slug required in path (e.g. /api/${teamSlug}/webhooks/my-process)` }));
|
|
78
87
|
return;
|
|
79
88
|
}
|
|
80
|
-
if (!(await this._checkWebhookAuth(req, res, processRepository, slug))) {
|
|
89
|
+
if (!(await this._checkWebhookAuth(req, res, processRepository, teamRepository, slug))) {
|
|
81
90
|
return;
|
|
82
91
|
}
|
|
83
92
|
await this._handleProcessExecution(req, res, slug, urlObj, logLevel, jsonLogs);
|
|
@@ -110,12 +119,13 @@ class Http extends core_1.Command {
|
|
|
110
119
|
await new Promise(() => { });
|
|
111
120
|
}
|
|
112
121
|
/**
|
|
113
|
-
* Enforces the
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
122
|
+
* Enforces the webhook's configured auth, simulating the cloud webhook endpoint: the effective
|
|
123
|
+
* configuration names a variable, whose value is looked up in the workspace variables and compared
|
|
124
|
+
* against the configured header. A missing or malformed header, an undefined variable, a mismatch,
|
|
125
|
+
* or a webhook inheriting a workspace configuration that team.json does not have, is rejected.
|
|
126
|
+
* Returns true when the request may proceed, false after writing a 403.
|
|
117
127
|
*/
|
|
118
|
-
async _checkWebhookAuth(req, res, processRepository, slug) {
|
|
128
|
+
async _checkWebhookAuth(req, res, processRepository, teamRepository, slug) {
|
|
119
129
|
let webhook;
|
|
120
130
|
try {
|
|
121
131
|
webhook = (await processRepository.find(slug)).webhook;
|
|
@@ -124,28 +134,34 @@ class Http extends core_1.Command {
|
|
|
124
134
|
// Unknown process: let the execution path report the 404.
|
|
125
135
|
return true;
|
|
126
136
|
}
|
|
127
|
-
|
|
128
|
-
|
|
137
|
+
// Read per request, like the process metadata above, so editing team.json while the server
|
|
138
|
+
// runs takes effect without a restart.
|
|
139
|
+
const teamWebhookAuth = (await teamRepository.find()).webhookAuth;
|
|
140
|
+
let effectiveAuth;
|
|
141
|
+
try {
|
|
142
|
+
effectiveAuth = (0, webhook_auth_1.resolveEffectiveAuth)(webhook, teamWebhookAuth);
|
|
129
143
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
144
|
-
res.end(JSON.stringify({ success: false, error: 'Unauthorized' }));
|
|
144
|
+
catch (error) {
|
|
145
|
+
// Fails closed, and says why: otherwise this reads as a wrong token rather than a local
|
|
146
|
+
// configuration the developer still has to complete.
|
|
147
|
+
this.warn(`Rejecting the ${slug} webhook: ${error.message}`);
|
|
148
|
+
this._forbid(res);
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
const variables = effectiveAuth === null ? [] : await new variable_1.default().findAllForExecution();
|
|
152
|
+
const variableValue = variables.find((variable) => variable.key === effectiveAuth?.variableKey)?.value;
|
|
153
|
+
const result = (0, webhook_auth_1.checkWebhookAuth)(effectiveAuth, req.headers, variableValue);
|
|
154
|
+
if (!result.authorized) {
|
|
155
|
+
this.warn(`Rejecting the ${slug} webhook: ${result.reason}`);
|
|
156
|
+
this._forbid(res);
|
|
145
157
|
return false;
|
|
146
158
|
}
|
|
147
159
|
return true;
|
|
148
160
|
}
|
|
161
|
+
_forbid(res) {
|
|
162
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
163
|
+
res.end(JSON.stringify({ success: false, error: 'Forbidden' }));
|
|
164
|
+
}
|
|
149
165
|
async _handleProcessExecution(req, res, slug, urlObj, logLevel, jsonLogs) {
|
|
150
166
|
if (req.method !== 'GET' && req.method !== 'POST') {
|
|
151
167
|
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
@@ -30,12 +30,17 @@ class TeamPushCommand extends api_client_command_1.default {
|
|
|
30
30
|
const processId = await this._teamRepository.resolveSlugToId(team.errorHandlerConfig.processSlug);
|
|
31
31
|
update.executionErrorHandlerConfig = { processId, tag: team.errorHandlerConfig.tag ?? null };
|
|
32
32
|
}
|
|
33
|
+
// Sent unconditionally, null arm included: team.json is the source of truth, so removing
|
|
34
|
+
// webhookAuth from it must clear the cloud configuration rather than leave it dangling.
|
|
35
|
+
update.webhookAuth = team.webhookAuth
|
|
36
|
+
? { headerName: team.webhookAuth.headerName ?? null, variableKey: team.webhookAuth.variableKey }
|
|
37
|
+
: { variableKey: null };
|
|
33
38
|
await this._fcodeGraphQLClient.pushTeam(update);
|
|
34
39
|
await this.workspaceConfig.setTeamStoredHash(team.contentHash);
|
|
35
40
|
cli_1.default.log(color_1.default.green('✅ team settings pushed'));
|
|
36
41
|
}
|
|
37
42
|
}
|
|
38
|
-
TeamPushCommand.description = 'Update cloud team settings (timezone, error handler, inheritance) from team.json';
|
|
43
|
+
TeamPushCommand.description = 'Update cloud team settings (timezone, error handler, inheritance, webhook auth) from team.json';
|
|
39
44
|
TeamPushCommand.examples = ['$ fcode team:push'];
|
|
40
45
|
TeamPushCommand.flags = types_1.ForceFlag;
|
|
41
46
|
exports.default = TeamPushCommand;
|
package/dist/core/types.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export type Credentials = {
|
|
|
10
10
|
export interface Token {
|
|
11
11
|
access_token: string;
|
|
12
12
|
refresh_token: string;
|
|
13
|
+
expires_at?: number;
|
|
13
14
|
}
|
|
14
15
|
export interface UserInfo {
|
|
15
16
|
groups: string[];
|
|
@@ -98,15 +99,33 @@ export type MergedResource = MergedResourceRecord & {
|
|
|
98
99
|
};
|
|
99
100
|
export type ProgrammingLanguage = 'JAVASCRIPT' | 'PYTHON';
|
|
100
101
|
export declare const programmingLanguageDisplayName: (programmingLanguage: ProgrammingLanguage) => string;
|
|
102
|
+
export type WebhookAuthMode = 'NONE' | 'TEAM' | 'CUSTOM';
|
|
103
|
+
export declare const DEFAULT_WEBHOOK_AUTH_HEADER = "Authorization";
|
|
104
|
+
export interface WebhookAuth {
|
|
105
|
+
/**
|
|
106
|
+
* Header the caller must send the credential in. Omitted when it is the default
|
|
107
|
+
* `Authorization`, whose value must be `Bearer <token>`; any other header takes the raw value.
|
|
108
|
+
*/
|
|
109
|
+
headerName?: string;
|
|
110
|
+
/**
|
|
111
|
+
* Key of the team variable holding the expected value. Only the name travels, never the
|
|
112
|
+
* value, and the variable does not need to exist yet.
|
|
113
|
+
*/
|
|
114
|
+
variableKey: string;
|
|
115
|
+
}
|
|
101
116
|
export interface ProcessWebhook {
|
|
102
117
|
enabled: boolean;
|
|
103
|
-
|
|
104
|
-
|
|
118
|
+
/** Omitted when the webhook is public, so an unprotected webhook stays minimal on disk. */
|
|
119
|
+
authMode?: WebhookAuthMode;
|
|
120
|
+
/** The webhook's own configuration, only meaningful with the CUSTOM mode. */
|
|
121
|
+
auth?: WebhookAuth;
|
|
105
122
|
}
|
|
106
123
|
export type AppRole = 'INSTALL' | 'SETTINGS' | 'USER_FACING_FORM' | 'UNINSTALL' | 'NONE';
|
|
124
|
+
export type FormAuthMode = 'NONE' | 'FACTORIAL';
|
|
107
125
|
export interface ProcessForm {
|
|
108
126
|
enabled: boolean;
|
|
109
127
|
appRole?: AppRole;
|
|
128
|
+
authMode?: FormAuthMode;
|
|
110
129
|
}
|
|
111
130
|
export interface ProcessMetadataFields {
|
|
112
131
|
name: string;
|
|
@@ -208,7 +227,8 @@ export declare class Team extends Resource {
|
|
|
208
227
|
zoneId?: string;
|
|
209
228
|
errorHandlerConfig?: ErrorHandlerConfig;
|
|
210
229
|
parentTeamSlugs: string[];
|
|
211
|
-
|
|
230
|
+
webhookAuth?: WebhookAuth;
|
|
231
|
+
constructor(slug: string, parentTeamSlugs: string[], zoneId?: string, errorHandlerConfig?: ErrorHandlerConfig, webhookAuth?: WebhookAuth);
|
|
212
232
|
get contentHash(): string;
|
|
213
233
|
}
|
|
214
234
|
export interface RawErrorHandlerConfig {
|
|
@@ -220,11 +240,16 @@ export interface RawTeamSettings {
|
|
|
220
240
|
zoneId?: string;
|
|
221
241
|
errorHandlerConfig?: RawErrorHandlerConfig | null;
|
|
222
242
|
parentTeamSlugs: string[];
|
|
243
|
+
webhookAuth?: WebhookAuth | null;
|
|
223
244
|
}
|
|
224
245
|
export interface TeamUpdateInput {
|
|
225
246
|
zoneId?: string;
|
|
226
247
|
parentTeamSlugs?: string[];
|
|
227
248
|
executionErrorHandlerConfig?: RawErrorHandlerConfig | null;
|
|
249
|
+
webhookAuth?: {
|
|
250
|
+
headerName?: string | null;
|
|
251
|
+
variableKey?: string | null;
|
|
252
|
+
} | null;
|
|
228
253
|
}
|
|
229
254
|
export type ResourceMetadata = {
|
|
230
255
|
[key: string]: any;
|
package/dist/core/types.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ServiceAccount = exports.Dependency = exports.DependenciesFlags = exports.TeamNameArg = exports.ResourceSlugArg = exports.ForceFlag = exports.ScriptLibraryNotFound = exports.ProcessNotFound = exports.Team = exports.ProgrammingLanguageManifest = exports.Variable = exports.VersionedScriptLibrary = exports.ScriptLibrary = exports.VersionedProcess = exports.Process = exports.programmingLanguageDisplayName = exports.ResourceStatus = exports.OnConflictResolution = exports.Resource = void 0;
|
|
3
|
+
exports.ServiceAccount = exports.Dependency = exports.DependenciesFlags = exports.TeamNameArg = exports.ResourceSlugArg = exports.ForceFlag = exports.ScriptLibraryNotFound = exports.ProcessNotFound = exports.Team = exports.ProgrammingLanguageManifest = exports.Variable = exports.VersionedScriptLibrary = exports.ScriptLibrary = exports.VersionedProcess = exports.Process = exports.DEFAULT_WEBHOOK_AUTH_HEADER = exports.programmingLanguageDisplayName = exports.ResourceStatus = exports.OnConflictResolution = exports.Resource = void 0;
|
|
4
4
|
const core_1 = require("@oclif/core");
|
|
5
5
|
const crypto = require("node:crypto");
|
|
6
6
|
const canonical_hash_1 = require("../utils/canonical-hash");
|
|
@@ -37,6 +37,8 @@ const programmingLanguageDisplayName = (programmingLanguage) => {
|
|
|
37
37
|
}
|
|
38
38
|
};
|
|
39
39
|
exports.programmingLanguageDisplayName = programmingLanguageDisplayName;
|
|
40
|
+
// The header assumed when none is configured; its value carries a Bearer scheme.
|
|
41
|
+
exports.DEFAULT_WEBHOOK_AUTH_HEADER = 'Authorization';
|
|
40
42
|
class Process extends Resource {
|
|
41
43
|
constructor(id, slug, script, readme, manifest, settings, metadata, resolvingTeamSlug) {
|
|
42
44
|
super();
|
|
@@ -163,19 +165,27 @@ class ProgrammingLanguageManifest extends Resource {
|
|
|
163
165
|
}
|
|
164
166
|
exports.ProgrammingLanguageManifest = ProgrammingLanguageManifest;
|
|
165
167
|
class Team extends Resource {
|
|
166
|
-
constructor(slug, parentTeamSlugs, zoneId, errorHandlerConfig) {
|
|
168
|
+
constructor(slug, parentTeamSlugs, zoneId, errorHandlerConfig, webhookAuth) {
|
|
167
169
|
super();
|
|
168
170
|
this.slug = slug;
|
|
169
171
|
this.parentTeamSlugs = parentTeamSlugs;
|
|
170
172
|
this.zoneId = zoneId;
|
|
171
173
|
this.errorHandlerConfig = errorHandlerConfig;
|
|
174
|
+
this.webhookAuth = webhookAuth;
|
|
172
175
|
}
|
|
173
176
|
get contentHash() {
|
|
174
|
-
|
|
177
|
+
const payload = {
|
|
175
178
|
zoneId: this.zoneId || null,
|
|
176
179
|
errorHandlerConfig: this.errorHandlerConfig || null,
|
|
177
180
|
parentTeamSlugs: [...(this.parentTeamSlugs || [])].sort(),
|
|
178
|
-
}
|
|
181
|
+
};
|
|
182
|
+
// Inserted only when set, so every team.json that predates webhook auth keeps the
|
|
183
|
+
// hash already stored in .fcode and does not report a phantom conflict. A key present
|
|
184
|
+
// with an undefined value would not do: canonicalStringify writes that as null.
|
|
185
|
+
if (this.webhookAuth) {
|
|
186
|
+
payload.webhookAuth = this.webhookAuth;
|
|
187
|
+
}
|
|
188
|
+
return (0, canonical_hash_1.canonicalJsonHash)(payload);
|
|
179
189
|
}
|
|
180
190
|
}
|
|
181
191
|
exports.Team = Team;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { RawTeamSettings, Team } from '../core/types';
|
|
2
2
|
import WorkspaceConfig from '../config/workspace';
|
|
3
3
|
/**
|
|
4
|
-
* Reads/writes the singleton team.json (timezone, error handler, inheritance) at the
|
|
4
|
+
* Reads/writes the singleton team.json (timezone, error handler, inheritance, webhook auth) at the
|
|
5
5
|
* workspace root. Unlike collection resources this is not a ResourceRepositoryBase:
|
|
6
6
|
* there is exactly one team, so there is no slug-keyed collection to manage.
|
|
7
7
|
*
|
|
@@ -19,5 +19,7 @@ export default class TeamRepository {
|
|
|
19
19
|
resolveSlugToId(processSlug: string): Promise<string>;
|
|
20
20
|
resolveIdToSlug(processId: string): Promise<string>;
|
|
21
21
|
private parseErrorHandler;
|
|
22
|
+
/** A configuration without a variable key is meaningless, so it is read as absent. */
|
|
23
|
+
private parseWebhookAuth;
|
|
22
24
|
private toFile;
|
|
23
25
|
}
|
|
@@ -6,7 +6,7 @@ const workspace_1 = require("../config/workspace");
|
|
|
6
6
|
const file_utils_1 = require("../utils/file-utils");
|
|
7
7
|
const TEAM_FILENAME = 'team.json';
|
|
8
8
|
/**
|
|
9
|
-
* Reads/writes the singleton team.json (timezone, error handler, inheritance) at the
|
|
9
|
+
* Reads/writes the singleton team.json (timezone, error handler, inheritance, webhook auth) at the
|
|
10
10
|
* workspace root. Unlike collection resources this is not a ResourceRepositoryBase:
|
|
11
11
|
* there is exactly one team, so there is no slug-keyed collection to manage.
|
|
12
12
|
*
|
|
@@ -29,7 +29,7 @@ class TeamRepository {
|
|
|
29
29
|
}
|
|
30
30
|
catch { }
|
|
31
31
|
const parentTeamSlugs = Array.isArray(raw.parentTeamSlugs) ? raw.parentTeamSlugs : [];
|
|
32
|
-
return new types_1.Team(slug, parentTeamSlugs, raw.zoneId, this.parseErrorHandler(raw.errorHandlerConfig));
|
|
32
|
+
return new types_1.Team(slug, parentTeamSlugs, raw.zoneId, this.parseErrorHandler(raw.errorHandlerConfig), this.parseWebhookAuth(raw.webhookAuth));
|
|
33
33
|
}
|
|
34
34
|
save(team) {
|
|
35
35
|
(0, file_utils_1.outputFileSyncIfChanged)(TEAM_FILENAME, JSON.stringify(this.toFile(team), null, 2));
|
|
@@ -43,7 +43,7 @@ class TeamRepository {
|
|
|
43
43
|
tag: raw.errorHandlerConfig.tag ?? null,
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
|
-
return new types_1.Team(raw.slug, raw.parentTeamSlugs ?? [], raw.zoneId, errorHandlerConfig);
|
|
46
|
+
return new types_1.Team(raw.slug, raw.parentTeamSlugs ?? [], raw.zoneId, errorHandlerConfig, this.parseWebhookAuth(raw.webhookAuth));
|
|
47
47
|
}
|
|
48
48
|
async resolveSlugToId(processSlug) {
|
|
49
49
|
const { processes } = await this.workspaceConfig.getActiveTeam();
|
|
@@ -60,6 +60,18 @@ class TeamRepository {
|
|
|
60
60
|
}
|
|
61
61
|
return { processSlug: raw.processSlug, tag: raw.tag ?? null };
|
|
62
62
|
}
|
|
63
|
+
/** A configuration without a variable key is meaningless, so it is read as absent. */
|
|
64
|
+
parseWebhookAuth(raw) {
|
|
65
|
+
if (!raw || typeof raw.variableKey !== 'string' || raw.variableKey.length === 0) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
const webhookAuth = { variableKey: raw.variableKey };
|
|
69
|
+
// The default header is left out so a pull writes the minimal form.
|
|
70
|
+
if (typeof raw.headerName === 'string' && raw.headerName.length > 0 && raw.headerName !== types_1.DEFAULT_WEBHOOK_AUTH_HEADER) {
|
|
71
|
+
webhookAuth.headerName = raw.headerName;
|
|
72
|
+
}
|
|
73
|
+
return webhookAuth;
|
|
74
|
+
}
|
|
63
75
|
toFile(team) {
|
|
64
76
|
const file = { parentTeamSlugs: team.parentTeamSlugs ?? [] };
|
|
65
77
|
if (team.zoneId !== undefined) {
|
|
@@ -68,6 +80,10 @@ class TeamRepository {
|
|
|
68
80
|
if (team.errorHandlerConfig !== undefined) {
|
|
69
81
|
file.errorHandlerConfig = team.errorHandlerConfig;
|
|
70
82
|
}
|
|
83
|
+
// Last, so adding webhook auth to an existing team.json is a purely additive diff.
|
|
84
|
+
if (team.webhookAuth !== undefined) {
|
|
85
|
+
file.webhookAuth = team.webhookAuth;
|
|
86
|
+
}
|
|
71
87
|
return file;
|
|
72
88
|
}
|
|
73
89
|
}
|
|
@@ -6,6 +6,9 @@ declare const AuthService: {
|
|
|
6
6
|
getUserInfo(authUrl: string, token: Token): Promise<UserInfo>;
|
|
7
7
|
globalConfig: GlobalConfig;
|
|
8
8
|
isAuthenticated(url: string): Promise<boolean>;
|
|
9
|
+
isTokenExpiring(token: Token): boolean;
|
|
10
|
+
refreshToken(authUrl: string, refreshToken: string): Promise<Token>;
|
|
11
|
+
tryRefresh(url: string, { authUrl, token, userInfo }: AuthInfo): Promise<boolean>;
|
|
9
12
|
login({ url, authUrl, email, password, clientId, clientSecret, accessToken }: Credentials): Promise<AuthInfo>;
|
|
10
13
|
logout(url: string): Promise<void>;
|
|
11
14
|
};
|
|
@@ -7,6 +7,15 @@ const global_1 = require("../config/global");
|
|
|
7
7
|
const color_1 = require("@oclif/color");
|
|
8
8
|
const AUTH_URL_PATH = '/protocol/openid-connect';
|
|
9
9
|
const AUTH_CLIENT_ID = 'cli';
|
|
10
|
+
// Refresh this long before the access token actually expires, so a token
|
|
11
|
+
// snapshotted at command init doesn't die mid-command.
|
|
12
|
+
const TOKEN_EXPIRY_MARGIN_SECONDS = 300;
|
|
13
|
+
function withExpiresAt(token) {
|
|
14
|
+
if (typeof token.expires_in === 'number') {
|
|
15
|
+
return { ...token, expires_at: Math.floor(Date.now() / 1000) + token.expires_in };
|
|
16
|
+
}
|
|
17
|
+
return token;
|
|
18
|
+
}
|
|
10
19
|
const AuthService = {
|
|
11
20
|
clientId: process.env.FCODE_CLIENT_ID || AUTH_CLIENT_ID,
|
|
12
21
|
async getToken({ authUrl, email, password, clientId, clientSecret }) {
|
|
@@ -44,7 +53,7 @@ const AuthService = {
|
|
|
44
53
|
});
|
|
45
54
|
const token = await response.json();
|
|
46
55
|
if (response.ok) {
|
|
47
|
-
return token;
|
|
56
|
+
return withExpiresAt(token);
|
|
48
57
|
}
|
|
49
58
|
throw token.error_description;
|
|
50
59
|
},
|
|
@@ -64,14 +73,21 @@ const AuthService = {
|
|
|
64
73
|
globalConfig: new global_1.default(),
|
|
65
74
|
async isAuthenticated(url) {
|
|
66
75
|
try {
|
|
67
|
-
const
|
|
76
|
+
const authInfo = await this.globalConfig.getAuthInfo(url);
|
|
77
|
+
const { authUrl, token } = authInfo;
|
|
68
78
|
try {
|
|
79
|
+
if (this.isTokenExpiring(token)) {
|
|
80
|
+
throw new Error('Access token expired or about to expire');
|
|
81
|
+
}
|
|
69
82
|
await this.getUserInfo(authUrl, token);
|
|
70
83
|
}
|
|
71
84
|
catch (error) {
|
|
72
|
-
await this.
|
|
73
|
-
|
|
74
|
-
|
|
85
|
+
const refreshed = await this.tryRefresh(url, authInfo);
|
|
86
|
+
if (!refreshed) {
|
|
87
|
+
await this.globalConfig.clearAuthInfo(url);
|
|
88
|
+
cli_1.default.log(color_1.default.yellow('🚪 Session expired, please login again'));
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
75
91
|
}
|
|
76
92
|
return true;
|
|
77
93
|
}
|
|
@@ -79,6 +95,41 @@ const AuthService = {
|
|
|
79
95
|
return false;
|
|
80
96
|
}
|
|
81
97
|
},
|
|
98
|
+
isTokenExpiring(token) {
|
|
99
|
+
return token.expires_at !== undefined && Date.now() / 1000 >= token.expires_at - TOKEN_EXPIRY_MARGIN_SECONDS;
|
|
100
|
+
},
|
|
101
|
+
async refreshToken(authUrl, refreshToken) {
|
|
102
|
+
const body = new URLSearchParams({
|
|
103
|
+
client_id: this.clientId,
|
|
104
|
+
grant_type: 'refresh_token',
|
|
105
|
+
refresh_token: refreshToken,
|
|
106
|
+
});
|
|
107
|
+
const response = await (0, node_fetch_1.default)(`${authUrl}${AUTH_URL_PATH}/token`, {
|
|
108
|
+
body: body.toString(),
|
|
109
|
+
headers: {
|
|
110
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
111
|
+
},
|
|
112
|
+
method: 'POST',
|
|
113
|
+
});
|
|
114
|
+
const token = await response.json();
|
|
115
|
+
if (response.ok) {
|
|
116
|
+
return withExpiresAt(token);
|
|
117
|
+
}
|
|
118
|
+
throw token.error_description;
|
|
119
|
+
},
|
|
120
|
+
async tryRefresh(url, { authUrl, token, userInfo }) {
|
|
121
|
+
if (!token.refresh_token) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
const refreshedToken = await this.refreshToken(authUrl, token.refresh_token);
|
|
126
|
+
await this.globalConfig.setAuthInfo(url, { authUrl, token: refreshedToken, userInfo });
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
},
|
|
82
133
|
async login({ url, authUrl, email, password, clientId, clientSecret, accessToken }) {
|
|
83
134
|
const token = accessToken
|
|
84
135
|
? { access_token: accessToken, refresh_token: '' }
|
|
@@ -32,8 +32,18 @@ declare class FcodeGraphQLClient {
|
|
|
32
32
|
removeVariable(id: String | undefined): Promise<Process>;
|
|
33
33
|
programmingLanguageManifest(programmingLanguage: ProgrammingLanguage): Promise<ProgrammingLanguageManifest>;
|
|
34
34
|
private inheritedTeam;
|
|
35
|
+
/**
|
|
36
|
+
* The public mode and the default header name are left out, so a public webhook — or one
|
|
37
|
+
* using the default header — keeps the metadata.json it already had on disk.
|
|
38
|
+
*/
|
|
35
39
|
private mapWebhook;
|
|
36
40
|
mapProcess({ id, slug, name, description, tags, script, readme, manifest, settings, triggers, resolvingTeamSlug }: any): Process;
|
|
41
|
+
/**
|
|
42
|
+
* GraphQL nests the webhook configuration under `settings`, while team.json and the REST API
|
|
43
|
+
* keep it flat. Mapping it explicitly is what keeps that mismatch from silently reading as
|
|
44
|
+
* undefined.
|
|
45
|
+
*/
|
|
46
|
+
mapTeamSettings(team: any): RawTeamSettings;
|
|
37
47
|
team(): Promise<RawTeamSettings>;
|
|
38
48
|
pushTeam(update: TeamUpdateInput): Promise<RawTeamSettings>;
|
|
39
49
|
mapProgrammingLanguageManifest(programmingLanguageManifest: any): ProgrammingLanguageManifest;
|
|
@@ -21,15 +21,10 @@ const PROCESS_FIELDS = `
|
|
|
21
21
|
triggers {
|
|
22
22
|
webhook {
|
|
23
23
|
enabled
|
|
24
|
+
authMode
|
|
24
25
|
auth {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
password
|
|
28
|
-
expression {
|
|
29
|
-
username
|
|
30
|
-
password
|
|
31
|
-
}
|
|
32
|
-
}
|
|
26
|
+
headerName
|
|
27
|
+
variableKey
|
|
33
28
|
}
|
|
34
29
|
}
|
|
35
30
|
}
|
|
@@ -44,6 +39,7 @@ const PROCESS_FIELDS = `
|
|
|
44
39
|
formSettings {
|
|
45
40
|
enabled
|
|
46
41
|
appRole
|
|
42
|
+
authMode
|
|
47
43
|
}
|
|
48
44
|
dependencies {
|
|
49
45
|
scopedToProcess
|
|
@@ -277,6 +273,9 @@ class FcodeGraphQLClient {
|
|
|
277
273
|
if (process.form.appRole) {
|
|
278
274
|
settings.formSettings.appRole = process.form.appRole;
|
|
279
275
|
}
|
|
276
|
+
if (process.form.authMode) {
|
|
277
|
+
settings.formSettings.authMode = process.form.authMode;
|
|
278
|
+
}
|
|
280
279
|
}
|
|
281
280
|
const input = {
|
|
282
281
|
name: process.name,
|
|
@@ -292,11 +291,17 @@ class FcodeGraphQLClient {
|
|
|
292
291
|
}
|
|
293
292
|
if (process.webhook) {
|
|
294
293
|
const webhook = { enabled: process.webhook.enabled };
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
294
|
+
// Forwarded including an explicit NONE, so a webhook can be un-protected from
|
|
295
|
+
// metadata.json; omitting the mode leaves the stored one in place.
|
|
296
|
+
if (process.webhook.authMode !== undefined) {
|
|
297
|
+
webhook.authMode = process.webhook.authMode;
|
|
298
|
+
if (process.webhook.auth) {
|
|
299
|
+
const auth = { variableKey: process.webhook.auth.variableKey };
|
|
300
|
+
if (process.webhook.auth.headerName !== undefined) {
|
|
301
|
+
auth.headerName = process.webhook.auth.headerName;
|
|
302
|
+
}
|
|
303
|
+
webhook.auth = auth;
|
|
304
|
+
}
|
|
300
305
|
}
|
|
301
306
|
input.webhook = webhook;
|
|
302
307
|
}
|
|
@@ -532,23 +537,24 @@ class FcodeGraphQLClient {
|
|
|
532
537
|
inheritedTeam(resolvingTeamSlug) {
|
|
533
538
|
return resolvingTeamSlug && resolvingTeamSlug !== this.teamSlug ? resolvingTeamSlug : undefined;
|
|
534
539
|
}
|
|
540
|
+
/**
|
|
541
|
+
* The public mode and the default header name are left out, so a public webhook — or one
|
|
542
|
+
* using the default header — keeps the metadata.json it already had on disk.
|
|
543
|
+
*/
|
|
535
544
|
mapWebhook(triggers) {
|
|
536
545
|
const webhookTrigger = triggers?.webhook;
|
|
537
546
|
if (!webhookTrigger) {
|
|
538
547
|
return undefined;
|
|
539
548
|
}
|
|
540
549
|
const webhook = { enabled: Boolean(webhookTrigger.enabled) };
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
webhook.
|
|
549
|
-
}
|
|
550
|
-
if (password != null) {
|
|
551
|
-
webhook.password = password;
|
|
550
|
+
if (webhookTrigger.authMode && webhookTrigger.authMode !== 'NONE') {
|
|
551
|
+
webhook.authMode = webhookTrigger.authMode;
|
|
552
|
+
if (webhookTrigger.auth?.variableKey) {
|
|
553
|
+
const auth = { variableKey: webhookTrigger.auth.variableKey };
|
|
554
|
+
if (webhookTrigger.auth.headerName && webhookTrigger.auth.headerName !== types_1.DEFAULT_WEBHOOK_AUTH_HEADER) {
|
|
555
|
+
auth.headerName = webhookTrigger.auth.headerName;
|
|
556
|
+
}
|
|
557
|
+
webhook.auth = auth;
|
|
552
558
|
}
|
|
553
559
|
}
|
|
554
560
|
return webhook;
|
|
@@ -570,9 +576,33 @@ class FcodeGraphQLClient {
|
|
|
570
576
|
if (settings.formSettings.appRole && settings.formSettings.appRole !== 'NONE') {
|
|
571
577
|
metadata.form.appRole = settings.formSettings.appRole;
|
|
572
578
|
}
|
|
579
|
+
if (settings.formSettings.authMode && settings.formSettings.authMode !== 'NONE') {
|
|
580
|
+
metadata.form.authMode = settings.formSettings.authMode;
|
|
581
|
+
}
|
|
573
582
|
}
|
|
574
583
|
return new types_1.Process(id, slug, script, readme, { dependencies }, settings, metadata, this.inheritedTeam(resolvingTeamSlug));
|
|
575
584
|
}
|
|
585
|
+
/**
|
|
586
|
+
* GraphQL nests the webhook configuration under `settings`, while team.json and the REST API
|
|
587
|
+
* keep it flat. Mapping it explicitly is what keeps that mismatch from silently reading as
|
|
588
|
+
* undefined.
|
|
589
|
+
*/
|
|
590
|
+
mapTeamSettings(team) {
|
|
591
|
+
const settings = {
|
|
592
|
+
slug: team.slug,
|
|
593
|
+
zoneId: team.zoneId,
|
|
594
|
+
errorHandlerConfig: team.errorHandlerConfig,
|
|
595
|
+
parentTeamSlugs: team.parentTeamSlugs ?? [],
|
|
596
|
+
};
|
|
597
|
+
const webhookAuth = team.settings?.webhookAuth;
|
|
598
|
+
if (webhookAuth?.variableKey) {
|
|
599
|
+
settings.webhookAuth = { variableKey: webhookAuth.variableKey };
|
|
600
|
+
if (webhookAuth.headerName && webhookAuth.headerName !== types_1.DEFAULT_WEBHOOK_AUTH_HEADER) {
|
|
601
|
+
settings.webhookAuth.headerName = webhookAuth.headerName;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return settings;
|
|
605
|
+
}
|
|
576
606
|
team() {
|
|
577
607
|
return this.client
|
|
578
608
|
.request((0, graphql_request_1.gql) `
|
|
@@ -585,10 +615,16 @@ class FcodeGraphQLClient {
|
|
|
585
615
|
tag
|
|
586
616
|
}
|
|
587
617
|
parentTeamSlugs
|
|
618
|
+
settings {
|
|
619
|
+
webhookAuth {
|
|
620
|
+
headerName
|
|
621
|
+
variableKey
|
|
622
|
+
}
|
|
623
|
+
}
|
|
588
624
|
}
|
|
589
625
|
}
|
|
590
626
|
`, { slug: this.teamSlug })
|
|
591
|
-
.then(({ team }) => team)
|
|
627
|
+
.then(({ team }) => this.mapTeamSettings(team))
|
|
592
628
|
.catch((error) => {
|
|
593
629
|
return Promise.reject(this.cleanError(error));
|
|
594
630
|
});
|
|
@@ -605,10 +641,16 @@ class FcodeGraphQLClient {
|
|
|
605
641
|
tag
|
|
606
642
|
}
|
|
607
643
|
parentTeamSlugs
|
|
644
|
+
settings {
|
|
645
|
+
webhookAuth {
|
|
646
|
+
headerName
|
|
647
|
+
variableKey
|
|
648
|
+
}
|
|
649
|
+
}
|
|
608
650
|
}
|
|
609
651
|
}
|
|
610
652
|
`, { slug: this.teamSlug, update })
|
|
611
|
-
.then(({ updateTeam: team }) => team)
|
|
653
|
+
.then(({ updateTeam: team }) => this.mapTeamSettings(team))
|
|
612
654
|
.catch((error) => {
|
|
613
655
|
return Promise.reject(this.cleanError(error));
|
|
614
656
|
});
|