diff --git a/README.md b/README.md new file mode 100644 index 0000000..a08a98b --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# Workflow Dev + +VS Code extension for uploading and running workflows from a local workspace. + +## Development + +1. Install dependencies and compile: + + ```sh + npm ci + npm run compile + ``` + + Use `npm run watch` instead of `compile` if you want TypeScript to rebuild on save. + +2. Open this repository in VS Code. +3. Open **Run and Debug** and start the **Extension** launch configuration (or press F5). A second VS Code window opens—the **Extension Development Host**, which has this extension loaded. +4. In that window, open a workflow project folder. The repo includes `test-workspace/` as a minimal example. +5. Add a `.vscode/launch.json` in that folder with a `workflow` debug configuration. Two fields are required: + - `api` — base URL of the Workflow API (e.g. `https://milestones-tst.fnwi.uva.nl/`) + - `version` — version name used when uploading and running the workflow (e.g. `testing-1`) + + Then start it from **Run and Debug**. Example configuration: + + ```json + { + "version": "0.2.0", + "configurations": [ + { + "type": "workflow", + "request": "launch", + "name": "Launch workflow", + "api": "https://milestones-tst.fnwi.uva.nl/", + "version": "testing-1" + } + ] + } + ``` + +## Authentication + +The first workflow launch signs in through SURFconext using the authorization-code flow with PKCE. + +- OIDC issuer: `https://connect.test.surfconext.nl/` +- Client ID: `milestones-tst.fnwi.uva.nl` +- Callback: `http://127.0.0.1:53682/callback` +- Scopes: `openid profile` + +The extension temporarily listens on port 53682, opens SURFconext in the system browser, validates the callback, and exchanges the authorization code. It then requests UserInfo for the account display name. + +Access and ID tokens are stored in VS Code SecretStorage. An access token is reused until it is close to expiry, then the extension starts browser sign-in again. Workflow API requests include the access token as a bearer token. + +Port 53682 must be available, and the callback URL must be registered for the client. +These values can be overridden under the `workflow.surfconext` extension settings. + +## Sign out + +Open the **Accounts/Profile** menu in the bottom-left of VS Code, select the account marked **SURFconext**, and choose **Sign Out**. + +This removes the locally stored tokens. It does not end the browser's SURFconext SSO session. + +During extension development, logs are written to the parent VS Code window's **Debug Console** with the `[workflow-dev]` prefix. diff --git a/package-lock.json b/package-lock.json index 4017081..3fd76a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "workflow-dev", - "version": "0.0.1", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "workflow-dev", - "version": "0.0.1", + "version": "0.1.1", "dependencies": { - "@vscode/debugadapter": "^1.68.0" + "@vscode/debugadapter": "^1.68.0", + "openid-client": "^6.8.2" }, "devDependencies": { "@types/mocha": "^10.0.10", @@ -1856,6 +1857,15 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -2145,6 +2155,15 @@ "node": ">=0.10.0" } }, + "node_modules/oauth4webapi": { + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", + "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2169,6 +2188,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openid-client": { + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.2.tgz", + "integrity": "sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==", + "license": "MIT", + "dependencies": { + "jose": "^6.1.3", + "oauth4webapi": "^3.8.4" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", diff --git a/package.json b/package.json index 7d59635..7816bfc 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,48 @@ "activationEvents": [ "onDebugResolve:workflow", "onDebugDynamicConfigurations:workflow", - "onLanguage:yaml" + "onLanguage:yaml", + "onAuthenticationRequest:workflow.surfconext" ], "main": "./out/extension.js", "contributes": { + "authentication": [ + { + "id": "workflow.surfconext", + "label": "SURFconext" + } + ], + "configuration": { + "title": "Workflow authentication", + "properties": { + "workflow.surfconext.authority": { + "type": "string", + "default": "https://connect.test.surfconext.nl/", + "description": "SURFconext OIDC issuer URL. Reload the window after changing this setting." + }, + "workflow.surfconext.clientId": { + "type": "string", + "default": "milestones-tst.fnwi.uva.nl", + "description": "SURFconext OIDC client ID. Reload the window after changing this setting." + }, + "workflow.surfconext.redirectUri": { + "type": "string", + "default": "http://127.0.0.1:53682/callback", + "description": "Registered loopback callback URL. Reload the window after changing this setting." + }, + "workflow.surfconext.scopes": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "openid", + "profile" + ], + "description": "OIDC scopes requested during sign-in. Reload the window after changing this setting." + } + } + }, "debuggers": [ { "type": "workflow", @@ -117,7 +155,8 @@ "typescript": "^5.9.3" }, "dependencies": { - "@vscode/debugadapter": "^1.68.0" + "@vscode/debugadapter": "^1.68.0", + "openid-client": "^6.8.2" }, "extensionDependencies": [ "redhat.vscode-yaml" diff --git a/src/WorkflowDebugSession.ts b/src/WorkflowDebugSession.ts index 40dedd5..43664e3 100644 --- a/src/WorkflowDebugSession.ts +++ b/src/WorkflowDebugSession.ts @@ -2,6 +2,8 @@ import { InitializedEvent, LoggingDebugSession, OutputEvent } from "@vscode/debu import { DebugProtocol } from "@vscode/debugprotocol"; import path from "path"; import * as vscode from 'vscode'; +import { AccessTokenProvider, uploadWorkflowAuthenticated, workflowLaunchOutput } from './workflowApi.js'; +import { logger } from './logger.js'; interface WorkflowLaunchRequestArguments extends DebugProtocol.LaunchRequestArguments { version: string; @@ -11,7 +13,7 @@ interface WorkflowLaunchRequestArguments extends DebugProtocol.LaunchRequestArgu export class WorkflowDebugSession extends LoggingDebugSession { private _configurationDone = false; - public constructor() { + public constructor(private readonly tokenProvider: AccessTokenProvider) { super(); } @@ -24,59 +26,82 @@ export class WorkflowDebugSession extends LoggingDebugSession { protected configurationDoneRequest(response: DebugProtocol.ConfigurationDoneResponse, args: DebugProtocol.ConfigurationDoneArguments): void { super.configurationDoneRequest(response, args); - console.log('configuration done'); + logger.info('Debug session configuration completed.'); this._configurationDone = true; } protected async attachRequest(response: DebugProtocol.AttachResponse, args: WorkflowLaunchRequestArguments) { - console.log('attach request not implemented'); + logger.warn('Attach request is not implemented.'); } protected async launchRequest(launchResponse: DebugProtocol.LaunchResponse, args: WorkflowLaunchRequestArguments, request?: DebugProtocol.Request) { - console.log("launching debug session"); - const fileMap: { [filename: string]: string } = {}; - + logger.info(`Starting workflow launch for version "${args.version}" against ${args.api}.`); + try { + // Gather the workspace files and authenticate concurrently. The upload below still awaits + // the token, so a failed or abandoned login never starts an upload. + const [accessToken, fileMap] = await Promise.all([ + this.tokenProvider.getAccessToken(), + this.collectWorkflowFiles(), + ]); + + const response = await uploadWorkflowAuthenticated( + { api: args.api, version: args.version, files: fileMap }, + this.tokenProvider, + accessToken, + ); + + if (!response.ok) { + const details = (await response.text()).trim(); + logger.warn(`Workflow launch stopped because the API returned HTTP ${response.status}.`); + this.sendErrorResponse(launchResponse, { + id: response.status, + format: `Workflow API rejected the upload (${response.status})${details ? `: ${details}` : '.'}` + }); + return; + } + + this.sendResponse(launchResponse); + logger.info('Workflow upload completed successfully.'); + this.sendEvent(new OutputEvent(workflowLaunchOutput(args.api, args.version))); + } catch (error) { + logger.error('Workflow launch failed.'); + this.sendErrorResponse(launchResponse, { + id: 1001, + format: error instanceof Error ? error.message : 'Could not authenticate or upload the workflow.', + }); + } + } + + private async collectWorkflowFiles(): Promise> { + const fileMap: Record = {}; + // Find all files in the workspace const files = await vscode.workspace.findFiles("**/*.yaml"); - + logger.info(`Found ${files.length} YAML file(s) in the workspace.`); + // Read each file for (const fileUri of files) { try { // Read file content as Uint8Array const content = await vscode.workspace.fs.readFile(fileUri); - + // Convert to string (assuming UTF-8 encoding) const textContent = Buffer.from(content).toString('utf-8'); - + // Get relative path from workspace root const workspaceFolder = vscode.workspace.getWorkspaceFolder(fileUri); - const relativePath = workspaceFolder + const relativePath = workspaceFolder ? path.relative(workspaceFolder.uri.fsPath, fileUri.fsPath) : fileUri.fsPath; - + fileMap[relativePath.replaceAll("\\", "/")] = textContent; } catch (error) { - console.error(`Error reading file ${fileUri.fsPath}:`, error); + logger.error(`Error reading file ${fileUri.fsPath}:`, error); // Continue with other files even if one fails } } - - const response = await fetch(`${args.api}/Versions/${args.version}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(fileMap) - }); - - if (!response.ok) { - this.sendErrorResponse(launchResponse, { - id: response.status, - format: `Error connecting to backend (${response.status}): ${await response.text()}` - }); - } else { - this.sendResponse(launchResponse); - this.sendEvent(new OutputEvent(`Running. View the workflow at https://workflow-dummy-ui.datanose.nl/instances?version=${args.version}&api=${args.api}`)); - } + logger.info(`Prepared ${Object.keys(fileMap).length} workflow file(s) for upload.`); + return fileMap; } -} \ No newline at end of file + +} diff --git a/src/auth/LoopbackCallbackServer.ts b/src/auth/LoopbackCallbackServer.ts new file mode 100644 index 0000000..89c327f --- /dev/null +++ b/src/auth/LoopbackCallbackServer.ts @@ -0,0 +1,71 @@ +import { createServer, Server } from 'node:http'; +import { logger } from '../logger.js'; + +export class LoopbackCallbackServer { + private server: Server | undefined; + private readonly redirectUri: URL; + + public constructor(redirectUri: string) { + this.redirectUri = new URL(redirectUri); + } + + public start(onCallback: (callbackUrl: URL) => void): Promise<{ dispose(): void }> { + if (this.server) { + throw new Error('The SURFconext callback listener is already running.'); + } + + return new Promise((resolve, reject) => { + let handled = false; + const server = createServer((request, response) => { + const callbackUrl = new URL(request.url ?? '/', this.redirectUri); + if (request.method !== 'GET' || callbackUrl.pathname !== this.redirectUri.pathname) { + logger.warn('Rejected an unexpected request to the loopback callback listener.'); + response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + response.end('Not found.'); + return; + } + if (handled) { + logger.warn('Rejected a duplicate loopback callback.'); + response.writeHead(409, { 'Content-Type': 'text/plain; charset=utf-8' }); + response.end('This sign-in callback has already been handled.'); + return; + } + + handled = true; + logger.info('Received the SURFconext loopback callback.'); + response.writeHead(200, { + 'Cache-Control': 'no-store', + 'Content-Type': 'text/plain; charset=utf-8', + }); + response.end('Sign-in returned to VS Code. You can close this browser tab.'); + onCallback(callbackUrl); + }); + + const fail = (error: Error): void => { + logger.error('Could not start the loopback callback listener.'); + this.server = undefined; + reject(error); + }; + server.once('error', fail); + server.listen(this.port(), this.redirectUri.hostname, () => { + server.off('error', fail); + server.on('error', () => undefined); + this.server = server; + logger.info(`Listening for the SURFconext callback at ${this.redirectUri.toString()}.`); + resolve({ + dispose: () => { + if (this.server === server) { + this.server = undefined; + } + server.close(); + logger.info('Stopped the SURFconext callback listener.'); + }, + }); + }); + }); + } + + private port(): number { + return this.redirectUri.port ? Number(this.redirectUri.port) : 80; + } +} diff --git a/src/auth/OidcClient.ts b/src/auth/OidcClient.ts new file mode 100644 index 0000000..0bff75f --- /dev/null +++ b/src/auth/OidcClient.ts @@ -0,0 +1,128 @@ +import * as oidc from 'openid-client'; +import { logger } from '../logger.js'; + +export interface SurfConextConfiguration { + authority: URL; + clientId: string; + redirectUri: string; + scopes: readonly string[]; +} + +export interface AuthorizationRequest { + url: URL; + state: string; + codeVerifier: string; +} + +export interface OidcTokens { + accessToken: string; + idToken?: string; + expiresIn?: number; + subject?: string; + accountLabel?: string; +} + +type Configuration = oidc.Configuration; + +export class SurfConextOidcClient { + private configurationPromise: Promise | undefined; + + public constructor(private readonly settings: SurfConextConfiguration) {} + + public async createAuthorizationRequest(): Promise { + logger.info('Preparing SURFconext authorization request.'); + const configuration = await this.getConfiguration(); + const state = oidc.randomState(); + const codeVerifier = oidc.randomPKCECodeVerifier(); + const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier); + const url = oidc.buildAuthorizationUrl(configuration, { + client_id: this.settings.clientId, + redirect_uri: this.settings.redirectUri, + response_type: 'code', + scope: this.settings.scopes.join(' '), + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }); + + logger.info('SURFconext authorization request prepared with PKCE.'); + return { url, state, codeVerifier }; + } + + public async exchangeAuthorizationCode(callbackUrl: URL, request: AuthorizationRequest): Promise { + logger.info('Exchanging SURFconext authorization code.'); + const configuration = await this.getConfiguration(); + const tokens = await oidc.authorizationCodeGrant( + configuration, + callbackUrl, + { + pkceCodeVerifier: request.codeVerifier, + expectedState: request.state, + idTokenExpected: true, + }, + ); + + logger.info('SURFconext authorization code exchanged successfully.'); + const result = this.toTokens(tokens); + if (result.subject) { + try { + logger.info('Requesting the signed-in account profile from SURFconext UserInfo.'); + const userInfo = await oidc.fetchUserInfo(configuration, result.accessToken, result.subject); + result.accountLabel = accountLabel(userInfo) ?? result.accountLabel; + logger.info('SURFconext UserInfo profile retrieved successfully.'); + } catch { + logger.warn('SURFconext UserInfo request failed; using the ID-token account label.'); + } + } + return result; + } + + private getConfiguration(): Promise { + if (!this.configurationPromise) { + logger.info(`Discovering OIDC metadata from ${this.settings.authority.origin}.`); + this.configurationPromise = oidc.discovery( + this.settings.authority, + this.settings.clientId, + undefined, + oidc.None(), + ).catch(error => { + logger.error('OIDC discovery failed.'); + this.configurationPromise = undefined; + throw error; + }); + void this.configurationPromise.then( + () => logger.info('OIDC discovery completed successfully.'), + () => undefined, + ); + } + return this.configurationPromise; + } + + private toTokens(tokens: oidc.TokenEndpointResponse & oidc.TokenEndpointResponseHelpers): OidcTokens { + const claims = tokens.claims(); + + return { + accessToken: tokens.access_token, + idToken: tokens.id_token, + expiresIn: tokens.expiresIn(), + subject: claims?.sub, + accountLabel: claims ? accountLabel(claims) : undefined, + }; + } +} + +function accountLabel(claims: Record): string | undefined { + const fullName = [stringClaim(claims.given_name), stringClaim(claims.family_name)] + .filter((part): part is string => !!part) + .join(' '); + + return stringClaim(claims.name) + ?? (fullName || undefined) + ?? stringClaim(claims.preferred_username) + ?? stringClaim(claims.email) + ?? stringClaim(claims.eduperson_principal_name); +} + +function stringClaim(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} diff --git a/src/auth/OidcSessionManager.ts b/src/auth/OidcSessionManager.ts new file mode 100644 index 0000000..050ae14 --- /dev/null +++ b/src/auth/OidcSessionManager.ts @@ -0,0 +1,330 @@ +import { + AuthorizationRequest, + OidcTokens, + SurfConextConfiguration, + SurfConextOidcClient, +} from './OidcClient.js'; +import { LoopbackCallbackServer } from './LoopbackCallbackServer.js'; +import { logger } from '../logger.js'; + +const SECRET_KEY = 'workflow.surfconext.tokens'; +const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; +const EXPIRY_SKEW_MS = 60 * 1000; + +export interface SecretStore { + get(key: string): PromiseLike; + store(key: string, value: string): PromiseLike; + delete(key: string): PromiseLike; +} + +export interface AccountDetails { + id: string; + label: string; +} + +export interface StoredCredentials { + clientId: string; + accessToken: string; + idToken: string; + expiresAt: number; + account: AccountDetails; +} + +export interface WorkflowAuthenticationSession { + id: string; + accessToken: string; + account: AccountDetails; + scopes: string[]; +} + +export type CredentialsChanged = ( + previous: StoredCredentials | undefined, + current: StoredCredentials | undefined, +) => void; + +export class AuthenticationError extends Error { + public constructor(message: string) { + super(message); + this.name = 'AuthenticationError'; + } +} + +interface PendingCallback { + request: AuthorizationRequest; + resolve(callbackUrl: URL): void; + reject(error: Error): void; + timer: ReturnType; +} + +export class OidcSessionManager { + private credentials: StoredCredentials | undefined; + private credentialsLoaded = false; + private loginPromise: Promise | undefined; + private pendingCallback: PendingCallback | undefined; + private loginGeneration = 0; + private readonly oidcClient: SurfConextOidcClient; + private readonly callbackServer: LoopbackCallbackServer; + + public constructor( + private readonly secrets: SecretStore, + private readonly openExternal: (url: URL) => Promise, + private readonly configuration: SurfConextConfiguration, + private readonly credentialsChanged: CredentialsChanged = () => undefined, + ) { + this.oidcClient = new SurfConextOidcClient(configuration); + this.callbackServer = new LoopbackCallbackServer(configuration.redirectUri); + } + + public async getSession(interactive: boolean): Promise { + const stored = await this.loadCredentials(); + if (stored && stored.expiresAt > Date.now() + EXPIRY_SKEW_MS) { + logger.info('Reusing the stored SURFconext access token.'); + return this.toSession(stored); + } + + if (stored) { + logger.info('Stored access token is near expiry; removing it before signing in again.'); + await this.replaceCredentials(undefined); + } + + if (!interactive) { + logger.info('No active SURFconext session is available.'); + return undefined; + } + + return this.login(); + } + + private handleCallback(callbackUrl: URL): void { + const pending = this.pendingCallback; + if (!pending) { + logger.warn('Received a callback without an active sign-in request.'); + return; + } + + if (callbackUrl.searchParams.get('state') !== pending.request.state) { + logger.warn('Rejected a SURFconext callback with mismatched state.'); + pending.reject(new AuthenticationError('SURFconext sign-in returned an invalid state. Please try again.')); + return; + } + + pending.resolve(callbackUrl); + logger.info('Accepted the SURFconext callback state.'); + } + + public async invalidateAccessToken(): Promise { + const stored = await this.loadCredentials(); + if (stored) { + logger.info('Invalidating the stored access token after an unauthorized API response.'); + await this.replaceCredentials({ ...stored, expiresAt: 0 }); + } + } + + public async signOut(): Promise { + logger.info('Signing out locally and removing stored SURFconext credentials.'); + this.cancelLogin('SURFconext sign-in was cancelled.'); + await this.replaceCredentials(undefined); + } + + public cancelLogin(message = 'SURFconext sign-in was cancelled.'): void { + this.loginGeneration++; + this.pendingCallback?.reject(new AuthenticationError(message)); + } + + private login(): Promise { + if (this.loginPromise) { + logger.info('Joining the SURFconext sign-in already in progress.'); + return this.loginPromise; + } + + const login = this.performLogin(); + this.loginPromise = login; + void login.finally(() => { + if (this.loginPromise === login) { + this.loginPromise = undefined; + } + }).catch(() => undefined); + return login; + } + + private async performLogin(): Promise { + logger.info('Starting interactive SURFconext sign-in.'); + const generation = this.loginGeneration; + let request: AuthorizationRequest; + try { + request = await this.oidcClient.createAuthorizationRequest(); + } catch { + logger.error('Could not prepare SURFconext sign-in.'); + throw new AuthenticationError('Could not connect to SURFconext. Check your network connection and try again.'); + } + this.ensureLoginActive(generation); + + const callback = this.waitForCallback(request); + let callbackServerHandle: { dispose(): void } | undefined; + try { + callbackServerHandle = await this.callbackServer.start(callbackUrl => this.handleCallback(callbackUrl)); + } catch { + logger.error('Loopback callback listener failed to start.'); + this.cancelLogin(`Could not listen for the SURFconext callback at ${this.configuration.redirectUri}. Close any application using that port and try again.`); + } + if (callbackServerHandle) { + try { + const opened = await this.openExternal(request.url); + if (!opened) { + logger.warn('The system declined to open the SURFconext authorization page.'); + this.cancelLogin('SURFconext sign-in was cancelled before the browser opened.'); + } else { + logger.info('Opened the SURFconext authorization page in the system browser.'); + } + } catch { + logger.error('Could not open the SURFconext authorization page.'); + this.cancelLogin('Could not open the browser for SURFconext sign-in.'); + } + } + + let callbackUrl: URL; + try { + callbackUrl = await callback; + } finally { + callbackServerHandle?.dispose(); + } + let tokens: OidcTokens; + try { + tokens = await this.oidcClient.exchangeAuthorizationCode(callbackUrl, request); + } catch { + logger.error('SURFconext authorization code exchange failed.'); + throw new AuthenticationError('SURFconext could not complete sign-in. Please try again.'); + } + this.ensureLoginActive(generation); + + const credentials = this.createCredentials(tokens); + const committed = await this.commitCredentials(credentials, generation); + logger.info('Interactive SURFconext sign-in completed and credentials were stored.'); + return this.toSession(committed); + } + + private async commitCredentials(credentials: StoredCredentials, generation: number): Promise { + await this.replaceCredentials(credentials); + if (generation !== this.loginGeneration) { + await this.replaceCredentials(undefined); + this.ensureLoginActive(generation); + } + return credentials; + } + + private ensureLoginActive(generation: number): void { + if (generation !== this.loginGeneration) { + throw new AuthenticationError('SURFconext sign-in was cancelled.'); + } + } + + private waitForCallback(request: AuthorizationRequest): Promise { + return new Promise((resolve, reject) => { + const finish = (action: () => void): void => { + if (this.pendingCallback?.request === request) { + clearTimeout(this.pendingCallback.timer); + this.pendingCallback = undefined; + } + action(); + }; + const timer = setTimeout( + () => finish(() => { + logger.warn('SURFconext sign-in timed out while waiting for the callback.'); + reject(new AuthenticationError('SURFconext sign-in timed out after five minutes.')); + }), + DEFAULT_TIMEOUT_MS, + ); + this.pendingCallback = { + request, + timer, + resolve: callbackUrl => finish(() => resolve(callbackUrl)), + reject: error => finish(() => reject(error)), + }; + }); + } + + private createCredentials(tokens: OidcTokens): StoredCredentials { + if (!tokens.accessToken || !tokens.idToken || !tokens.subject) { + throw new AuthenticationError('SURFconext returned an incomplete session. Please try again.'); + } + + return { + clientId: this.configuration.clientId, + accessToken: tokens.accessToken, + idToken: tokens.idToken, + expiresAt: this.expiryFrom(tokens), + account: { + id: tokens.subject, + label: tokens.accountLabel ?? tokens.subject, + }, + }; + } + + private expiryFrom(tokens: OidcTokens): number { + const expiresIn = typeof tokens.expiresIn === 'number' && tokens.expiresIn > 0 ? tokens.expiresIn : 0; + return Date.now() + expiresIn * 1000; + } + + private async loadCredentials(): Promise { + if (this.credentialsLoaded) { + return this.credentials; + } + + this.credentialsLoaded = true; + const serialized = await this.secrets.get(SECRET_KEY); + if (!serialized) { + logger.info('No stored SURFconext credentials were found.'); + return undefined; + } + + try { + const parsed: unknown = JSON.parse(serialized); + if (isStoredCredentials(parsed, this.configuration.clientId)) { + this.credentials = parsed; + logger.info('Restored SURFconext credentials from secure storage.'); + return parsed; + } + } catch { + // Invalid local state is removed below. + } + + await this.secrets.delete(SECRET_KEY); + logger.warn('Removed invalid SURFconext credentials from secure storage.'); + return undefined; + } + + private async replaceCredentials(next: StoredCredentials | undefined): Promise { + const previous = await this.loadCredentials(); + if (next) { + await this.secrets.store(SECRET_KEY, JSON.stringify(next)); + } else { + await this.secrets.delete(SECRET_KEY); + } + this.credentials = next; + this.credentialsLoaded = true; + this.credentialsChanged(previous, next); + } + + public toSession(credentials: StoredCredentials): WorkflowAuthenticationSession { + return { + id: credentials.account.id, + accessToken: credentials.accessToken, + account: credentials.account, + scopes: [...this.configuration.scopes], + }; + } +} + +function isStoredCredentials(value: unknown, clientId: string): value is StoredCredentials { + if (!value || typeof value !== 'object') { + return false; + } + const candidate = value as Partial; + return candidate.clientId === clientId + && typeof candidate.accessToken === 'string' + && typeof candidate.idToken === 'string' + && typeof candidate.expiresAt === 'number' + && !!candidate.account + && typeof candidate.account.id === 'string' + && typeof candidate.account.label === 'string'; +} diff --git a/src/auth/WorkflowAuthenticationProvider.ts b/src/auth/WorkflowAuthenticationProvider.ts new file mode 100644 index 0000000..1c3f1fa --- /dev/null +++ b/src/auth/WorkflowAuthenticationProvider.ts @@ -0,0 +1,95 @@ +import * as vscode from 'vscode'; +import { AccessTokenProvider } from '../workflowApi.js'; +import { logger } from '../logger.js'; +import { + OidcSessionManager, + StoredCredentials, + WorkflowAuthenticationSession, +} from './OidcSessionManager.js'; +import { SurfConextConfiguration } from './OidcClient.js'; + +export const AUTHENTICATION_PROVIDER_ID = 'workflow.surfconext'; +export const AUTHENTICATION_PROVIDER_LABEL = 'SURFconext'; + +export class WorkflowAuthenticationProvider implements vscode.AuthenticationProvider, AccessTokenProvider { + private readonly sessionChangeEmitter = new vscode.EventEmitter(); + private readonly sessionManager: OidcSessionManager; + + public readonly onDidChangeSessions = this.sessionChangeEmitter.event; + + public constructor(secrets: vscode.SecretStorage) { + const settings = vscode.workspace.getConfiguration('workflow.surfconext'); + const configuration: SurfConextConfiguration = { + authority: new URL(settings.get('authority')!), + clientId: settings.get('clientId')!, + redirectUri: settings.get('redirectUri')!, + scopes: settings.get('scopes')!, + }; + this.sessionManager = new OidcSessionManager( + secrets, + url => Promise.resolve(vscode.env.openExternal(vscode.Uri.parse(url.toString()))), + configuration, + (previous, current) => this.emitCredentialsChanged(previous, current), + ); + } + + public async getSessions(_scopes?: readonly string[]): Promise { + logger.info('VS Code requested available SURFconext sessions.'); + const session = await this.sessionManager.getSession(false); + return session ? [this.toVscodeSession(session)] : []; + } + + public async createSession(_scopes: readonly string[]): Promise { + logger.info('VS Code requested a new SURFconext session.'); + const session = await this.sessionManager.getSession(true); + if (!session) { + throw new Error('SURFconext sign-in did not return a session.'); + } + return this.toVscodeSession(session); + } + + public async removeSession(_sessionId: string): Promise { + logger.info('VS Code requested SURFconext sign-out.'); + await this.sessionManager.signOut(); + } + + public async getAccessToken(): Promise { + logger.info('Workflow debugger requested an access token.'); + const session = await this.sessionManager.getSession(true); + if (!session) { + throw new Error('SURFconext sign-in did not return an access token.'); + } + return session.accessToken; + } + + public invalidateAccessToken(): Promise { + return this.sessionManager.invalidateAccessToken(); + } + + public dispose(): void { + this.sessionManager.cancelLogin('SURFconext sign-in was cancelled because the extension stopped.'); + this.sessionChangeEmitter.dispose(); + } + + private emitCredentialsChanged( + previous: StoredCredentials | undefined, + current: StoredCredentials | undefined, + ): void { + const previousSession = previous ? this.toVscodeSession(this.sessionManager.toSession(previous)) : undefined; + const currentSession = current ? this.toVscodeSession(this.sessionManager.toSession(current)) : undefined; + this.sessionChangeEmitter.fire({ + added: !previousSession && currentSession ? [currentSession] : [], + removed: previousSession && !currentSession ? [previousSession] : [], + changed: previousSession && currentSession ? [currentSession] : [], + }); + } + + private toVscodeSession(session: WorkflowAuthenticationSession): vscode.AuthenticationSession { + return { + id: session.id, + accessToken: session.accessToken, + account: session.account, + scopes: session.scopes, + }; + } +} diff --git a/src/extension.ts b/src/extension.ts index d9a5bcf..37ab2bd 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,13 +3,31 @@ import * as vscode from 'vscode'; import { DebugConfiguration, ProviderResult, WorkspaceFolder } from 'vscode'; import { WorkflowDebugSession } from './WorkflowDebugSession.js'; +import { logger } from './logger.js'; +import { + AUTHENTICATION_PROVIDER_ID, + AUTHENTICATION_PROVIDER_LABEL, + WorkflowAuthenticationProvider, +} from './auth/WorkflowAuthenticationProvider.js'; // This method is called when your extension is activated // Your extension is activated the very first time the command is executed export function activate(context: vscode.ExtensionContext) { - console.log("loading extension"); + logger.info('Activating extension.'); - context.subscriptions.push(vscode.debug.registerDebugAdapterDescriptorFactory('workflow', new InlineDebugAdapterFactory())); + const authenticationProvider = new WorkflowAuthenticationProvider(context.secrets); + context.subscriptions.push(authenticationProvider); + context.subscriptions.push(vscode.authentication.registerAuthenticationProvider( + AUTHENTICATION_PROVIDER_ID, + AUTHENTICATION_PROVIDER_LABEL, + authenticationProvider, + { supportsMultipleAccounts: false }, + )); + context.subscriptions.push(vscode.debug.registerDebugAdapterDescriptorFactory( + 'workflow', + new InlineDebugAdapterFactory(authenticationProvider), + )); + logger.info('Authentication provider and workflow debugger registered.'); context.subscriptions.push(vscode.debug.registerDebugConfigurationProvider('workflow', { provideDebugConfigurations(folder: WorkspaceFolder | undefined): ProviderResult { @@ -25,11 +43,15 @@ export function activate(context: vscode.ExtensionContext) { } // This method is called when your extension is deactivated -export function deactivate() {} +export function deactivate() { + logger.info('Deactivating extension.'); +} class InlineDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory { + public constructor(private readonly authenticationProvider: WorkflowAuthenticationProvider) {} + createDebugAdapterDescriptor(_session: vscode.DebugSession): ProviderResult { - return new vscode.DebugAdapterInlineImplementation(new WorkflowDebugSession()); + return new vscode.DebugAdapterInlineImplementation(new WorkflowDebugSession(this.authenticationProvider)); } } diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..166e96e --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,15 @@ +const prefix = '[workflow-dev]'; + +export const logger = { + info(message: string): void { + console.log(`${prefix} ${message}`); + }, + + warn(message: string): void { + console.warn(`${prefix} ${message}`); + }, + + error(message: string, error?: unknown): void { + console.error(`${prefix} ${message}`, ...(error === undefined ? [] : [error])); + }, +}; diff --git a/src/workflowApi.ts b/src/workflowApi.ts new file mode 100644 index 0000000..ca4ac4c --- /dev/null +++ b/src/workflowApi.ts @@ -0,0 +1,69 @@ +import { logger } from './logger.js'; + +export interface AccessTokenProvider { + getAccessToken(): Promise; + invalidateAccessToken(): Promise; +} + +export interface WorkflowUploadRequest { + api: string; + version: string; + files: Record; +} + +export async function uploadWorkflowAuthenticated( + request: WorkflowUploadRequest, + tokenProvider: AccessTokenProvider, + initialAccessToken?: string, +): Promise { + let accessToken = initialAccessToken ?? await tokenProvider.getAccessToken(); + logger.info('Uploading workflow files to the Workflow API.'); + let response = await safelyUploadWorkflow(request, accessToken); + logger.info(`Workflow API responded with HTTP ${response.status}.`); + + if (response.status === 401) { + logger.warn('Workflow API returned 401; signing in again and retrying once.'); + await tokenProvider.invalidateAccessToken(); + accessToken = await tokenProvider.getAccessToken(); + response = await safelyUploadWorkflow(request, accessToken); + logger.info(`Workflow API retry responded with HTTP ${response.status}.`); + } + + return response; +} + +export function workflowLaunchOutput(api: string, version: string): string { + const url = new URL('https://milestones-tst.fnwi.uva.nl/develop'); + url.searchParams.set('version', version); + url.searchParams.set('api', api); + logger.info(`Workflow UI URL prepared: ${url.toString()}`); + return `Running. View the workflow at ${url.toString()}`; +} + +async function safelyUploadWorkflow( + request: WorkflowUploadRequest, + accessToken: string, +): Promise { + try { + return await uploadWorkflow(request, accessToken); + } catch { + logger.error('Workflow API request failed before receiving a response.'); + throw new Error('Could not connect to the Workflow API. Check your network connection and try again.'); + } +} + +async function uploadWorkflow( + request: WorkflowUploadRequest, + accessToken: string, +): Promise { + const api = request.api.replace(/\/+$/, ''); + logger.info(`POST ${api}/Versions/{version}`); + return fetch(`${api}/Versions/${encodeURIComponent(request.version)}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request.files), + }); +}