From f5511b39fa7131abdca80244ec3d10df0cc95a57 Mon Sep 17 00:00:00 2001 From: Carlos Eberhardt Date: Wed, 16 Jul 2025 13:45:32 -0500 Subject: [PATCH] feat(auth): always prompt for authorization method (admin or JWT) on every request, persist JWT only, and support all entry points --- src/commands/executeStepZenRequest.ts | 75 +++++++++-------- src/commands/runRequest.ts | 115 ++++++++++++++++++++------ src/extension.ts | 10 +-- src/services/cli.ts | 6 +- src/services/request.ts | 28 +++---- 5 files changed, 149 insertions(+), 85 deletions(-) diff --git a/src/commands/executeStepZenRequest.ts b/src/commands/executeStepZenRequest.ts index 5f97923..bd13db9 100644 --- a/src/commands/executeStepZenRequest.ts +++ b/src/commands/executeStepZenRequest.ts @@ -23,6 +23,7 @@ import { ValidationError, handleError } from "../errors"; * @param options.documentId Optional document ID for persisted document requests * @param options.operationName Optional name of the operation to execute * @param options.varArgs Optional variable arguments (--var, --var-file) + * @param options.auth Optional authorization info (admin or jwt) * @returns Promise that resolves when execution completes */ export async function executeStepZenRequest(options: { @@ -30,8 +31,9 @@ export async function executeStepZenRequest(options: { documentContent?: string; operationName?: string; varArgs?: string[]; + auth?: { type: 'admin' | 'jwt', jwt?: string }; }): Promise { - const { queryText, documentContent, operationName, varArgs = [] } = options; + const { queryText, documentContent, operationName, varArgs = [], auth } = options; // Validate request options using the request service try { @@ -53,15 +55,33 @@ export async function executeStepZenRequest(options: { const cfg = vscode.workspace.getConfiguration("stepzen"); const debugLevel = cfg.get("request.debugLevel", 1); + // Prepare headers based on auth selection + let customHeaders: Record = {}; + let adminKey: string | undefined; + if (auth?.type === 'jwt') { + // Always need the admin key for debug header + adminKey = await services.request.getApiKey(); + customHeaders = { + 'Authorization': `Bearer ${auth.jwt}`, + 'StepZen-Debug-Authorization': `apikey ${adminKey}`, + 'stepzen-debug-level': String(debugLevel), + }; + } else { + // Default: admin key in Authorization + adminKey = await services.request.getApiKey(); + customHeaders = { + 'Authorization': `Apikey ${adminKey}`, + 'stepzen-debug-level': String(debugLevel), + }; + } + // For persisted documents, we need to make an HTTP request directly if (documentContent) { try { // Load endpoint configuration using the request service const endpointConfig = await services.request.loadEndpointConfig(projectRoot); - // Parse variables using the request service const { variables } = services.request.parseVariables(varArgs); - // Show a progress notification await vscode.window.withProgress( { @@ -70,25 +90,23 @@ export async function executeStepZenRequest(options: { cancellable: false }, async () => { - // Execute the persisted document request using the request service + // Execute the persisted document request using the request service, passing custom headers const result = await services.request.executePersistedDocumentRequest( endpointConfig, documentContent, variables, - operationName - ); - + operationName, + customHeaders + ); // Process results const rawDiags = (result.extensions?.stepzen?.diagnostics ?? []) as StepZenDiagnostic[]; services.logger.info("Processing diagnostics for persisted operation..."); const summaries = summariseDiagnostics(rawDiags); publishDiagnostics(summaries, runtimeDiag); - services.logger.info("Persisted document request completed successfully"); await openResultsPanel(result); } ); - return; } catch (err: unknown) { handleError(err); @@ -104,7 +122,6 @@ export async function executeStepZenRequest(options: { // Create a temp file for the query, which we'll need for Terminal mode let tmpFile: string | undefined; - try { // Terminal output mode with debug level 0 if (debugLevel === 0) { @@ -112,32 +129,27 @@ export async function executeStepZenRequest(options: { tmpFile = createTempGraphQLFile(queryText); const term = vscode.window.createTerminal(UI.TERMINAL_NAME); term.show(); - // Build CLI command for terminal const parts = [ "stepzen request", - `--file "${tmpFile}"`, + `--file \"${tmpFile}\"`, ]; - // Add operation name if specified - // Log operation name if (operationName) { - services.logger.info(`Using specified operation: "${operationName}"`); + services.logger.info(`Using specified operation: \"${operationName}\"`); } else { services.logger.debug('No operation name specified, letting StepZen select the default operation'); } - - // Add debug level header - properly escape quotes for shell - parts.push('--header', `"stepzen-debug-level: ${debugLevel}"`); - + // Add custom headers + for (const [key, value] of Object.entries(customHeaders)) { + parts.push('--header', `\"${key}: ${value}\"`); + } // Add variable arguments parts.push(...varArgs); - const cmd = parts.filter(Boolean).join(" "); - services.logger.info(`Executing StepZen request in terminal${operationName ? ` for operation "${operationName}"` : ' (anonymous operation)'}`); + services.logger.info(`Executing StepZen request in terminal${operationName ? ` for operation \"${operationName}\"` : ' (anonymous operation)'}`); services.logger.debug(`Terminal command: ${cmd}`); - term.sendText(`cd "${projectRoot}" && ${cmd}`); - + term.sendText(`cd \"${projectRoot}\" && ${cmd}`); // Cleanup temp file later cleanupLater(tmpFile); } catch (err) { @@ -145,7 +157,6 @@ export async function executeStepZenRequest(options: { } return; } - // JSON result mode with progress notification services.logger.info("Executing StepZen request with CLI service..."); await vscode.window.withProgress( @@ -158,17 +169,15 @@ export async function executeStepZenRequest(options: { try { // Parse variables using the request service const { variables } = services.request.parseVariables(varArgs); - - // Use the CLI service to execute the request - services.logger.info(`Executing StepZen request${operationName ? ` for operation "${operationName}"` : ' (anonymous operation)'} with debug level ${debugLevel}`); - services.logger.debug(`Calling CLI service with request${operationName ? ` for operation "${operationName}"` : ' (no operation specified)'}`); - const stdout = await services.cli.request(queryText, variables, operationName, debugLevel); + // Use the CLI service to execute the request, passing custom headers + services.logger.info(`Executing StepZen request${operationName ? ` for operation \"${operationName}\"` : ' (anonymous operation)'} with debug level ${debugLevel}`); + services.logger.debug(`Calling CLI service with request${operationName ? ` for operation \"${operationName}\"` : ' (no operation specified)'}`); + const stdout = await services.cli.request(queryText, variables, operationName, debugLevel, customHeaders); services.logger.debug("Received response from StepZen CLI service"); - let json: StepZenResponse; try { // Parse the response as JSON - services.logger.debug(`Parsing JSON response${operationName ? ` for operation "${operationName}"` : ''}`); + services.logger.debug(`Parsing JSON response${operationName ? ` for operation \"${operationName}\"` : ''}`); json = JSON.parse(stdout) as StepZenResponse; } catch (parseErr) { throw new ValidationError( @@ -177,15 +186,13 @@ export async function executeStepZenRequest(options: { parseErr ); } - // Process results const rawDiags = (json.extensions?.stepzen?.diagnostics ?? []) as StepZenDiagnostic[]; services.logger.info("Processing diagnostics for file-based request..."); const summaries = summariseDiagnostics(rawDiags); publishDiagnostics(summaries, runtimeDiag); - await openResultsPanel(json); - services.logger.info(`StepZen request completed successfully${operationName ? ` for operation "${operationName}"` : ''}`); + services.logger.info(`StepZen request completed successfully${operationName ? ` for operation \"${operationName}\"` : ''}`); } catch (err) { handleError(err); // Clear any partial results diff --git a/src/commands/runRequest.ts b/src/commands/runRequest.ts index 67b16e0..13f6fcf 100644 --- a/src/commands/runRequest.ts +++ b/src/commands/runRequest.ts @@ -123,6 +123,46 @@ async function collectVariableArgs(query: string, chosenOp?: string): Promise { + // Retrieve last JWT + const lastJwt = context.globalState.get(JWT_STATE_KEY); + + const options = [ + { label: 'Default (Admin Key)', value: 'admin', description: 'Use your StepZen admin API key (default)' }, + { label: 'Bearer Token (JWT)', value: 'jwt', description: 'Use a Bearer JWT for Authorization header' }, + ]; + + const pick = await vscode.window.showQuickPick(options, { + placeHolder: 'Select authorization type for this request', + canPickMany: false, + ignoreFocusOut: true, + }); + if (!pick) {return undefined;} + + if (pick.value === 'jwt') { + const jwt = await vscode.window.showInputBox({ + prompt: 'Enter your Bearer JWT', + value: lastJwt || '', + password: true, + ignoreFocusOut: true, + }); + if (!jwt) {return undefined;} + // Persist JWT + context.globalState.update(JWT_STATE_KEY, jwt); + return { type: 'jwt', jwt }; + } else { + return { type: 'admin' }; + } +} + /* ------------------------------------------------------------- * Common execution function with support for persisted documents * ------------------------------------------------------------*/ @@ -137,7 +177,7 @@ async function collectVariableArgs(query: string, chosenOp?: string): Promise { - const { runGraphQLRequest } = await import("./commands/runRequest.js"); - return runGraphQLRequest(); - }), + safeRegisterCommand(COMMANDS.RUN_REQUEST, () => runGraphQLRequest(context)), safeRegisterCommand(COMMANDS.OPEN_EXPLORER, async () => { const { openQueryExplorer } = await import("./commands/openExplorer.js"); return openQueryExplorer(context); @@ -260,11 +258,11 @@ export async function activate(context: vscode.ExtensionContext) { }), safeRegisterCommand(COMMANDS.RUN_OPERATION, async (...args: unknown[]) => { const { runOperation } = await import("./commands/runRequest.js"); - return runOperation(args[0] as any); + return runOperation(context, args[0] as any); }), safeRegisterCommand(COMMANDS.RUN_PERSISTED, async (...args: unknown[]) => { const { runPersisted } = await import("./commands/runRequest.js"); - return runPersisted(args[0] as string, args[1] as string); + return runPersisted(context, args[0] as string, args[1] as string); }), safeRegisterCommand(COMMANDS.CLEAR_RESULTS, async () => { const { clearResults } = await import("./commands/runRequest.js"); diff --git a/src/services/cli.ts b/src/services/cli.ts index 528c404..ba932d8 100644 --- a/src/services/cli.ts +++ b/src/services/cli.ts @@ -64,10 +64,11 @@ export class StepzenCliService { * @param vars Optional variables to pass to the query * @param operationName Optional name of the operation to execute * @param debugLevel Optional debug level (defaults to 1) + * @param customHeaders Optional custom headers to inject as --header * @returns Promise resolving to the response string * @throws CliError if the operation fails */ - async request(query: string, vars?: object, operationName?: string, debugLevel: number = 1): Promise { + async request(query: string, vars?: object, operationName?: string, debugLevel: number = 1, customHeaders?: Record): Promise { let tmpFile = ''; let varsFile = ''; @@ -111,7 +112,8 @@ export class StepzenCliService { 'request', '--file', tmpFile, ...(operationName ? ['--operation-name', operationName] : []), - '--header', `"stepzen-debug-level: ${debugLevel}"`, + // Add each header as its own --header argument, value wrapped in double quotes + ...(customHeaders ? Object.entries(customHeaders).flatMap(([k, v]) => ['--header', `"${k}: ${v}"`]) : ['--header', `"stepzen-debug-level: ${debugLevel}"`]), ...varArgs ]; diff --git a/src/services/request.ts b/src/services/request.ts index 6366c07..83f9d1f 100644 --- a/src/services/request.ts +++ b/src/services/request.ts @@ -228,7 +228,7 @@ export class RequestService { * @param documentContent The GraphQL document content (used to calculate hash) * @param variables Request variables * @param operationName Optional operation name - * @param debugLevel Debug level for the request + * @param headers Optional custom headers * @returns Promise resolving to StepZen response */ public async executePersistedDocumentRequest( @@ -236,6 +236,7 @@ export class RequestService { documentContent: string, variables: Record, operationName?: string, + headers?: Record, ): Promise { this.logger.info("Making HTTP request to StepZen API for persisted document"); @@ -251,28 +252,25 @@ export class RequestService { return new Promise((resolve, reject) => { const postData = JSON.stringify(requestBody); - - const options = { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(postData), - 'Authorization': endpointConfig.apiKey ? `Apikey ${endpointConfig.apiKey}` : '' - } + // Use custom headers if provided, otherwise default + const requestHeaders = headers ? { ...headers, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) } : { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(postData), + 'Authorization': endpointConfig.apiKey ? `Apikey ${endpointConfig.apiKey}` : '' }; - // debug log the request details - this.logger.debug(`Request details: ${JSON.stringify(options)}`); + this.logger.debug(`Request details: ${JSON.stringify({ headers: requestHeaders })}`); this.logger.debug(`Request body: ${postData}`); this.logger.debug(`Request URL: ${endpointConfig.graphqlUrl}`); - + const options = { + method: 'POST', + headers: requestHeaders + }; const req = https.request(endpointConfig.graphqlUrl, options, (res) => { let responseData = ''; - res.on('data', (chunk) => { responseData += chunk; }); - res.on('end', () => { try { const json = JSON.parse(responseData); @@ -286,7 +284,6 @@ export class RequestService { } }); }); - req.on('error', (err) => { reject(new NetworkError( "Failed to connect to StepZen API", @@ -294,7 +291,6 @@ export class RequestService { err )); }); - req.write(postData); req.end(); });