From ee5e4471e2cd5560415f51fd64b023217b09d792 Mon Sep 17 00:00:00 2001 From: Annes Negmel-Din Date: Wed, 1 Jul 2026 11:41:08 +0200 Subject: [PATCH 1/4] feat: add SURFconext authentication support --- README.md | 61 ++++ package-lock.json | 38 ++- package.json | 12 +- src/WorkflowDebugSession.ts | 86 +++-- src/auth/LoopbackCallbackServer.ts | 68 ++++ src/auth/OidcClient.ts | 133 ++++++++ src/auth/OidcSessionManager.ts | 367 +++++++++++++++++++++ src/auth/WorkflowAuthenticationProvider.ts | 86 +++++ src/extension.ts | 30 +- src/logger.ts | 15 + src/workflowApi.ts | 69 ++++ 11 files changed, 925 insertions(+), 40 deletions(-) create mode 100644 README.md create mode 100644 src/auth/LoopbackCallbackServer.ts create mode 100644 src/auth/OidcClient.ts create mode 100644 src/auth/OidcSessionManager.ts create mode 100644 src/auth/WorkflowAuthenticationProvider.ts create mode 100644 src/logger.ts create mode 100644 src/workflowApi.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..07405bb --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# 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: `datanose.local` +- Callback: `http://localhost:3000/callback` +- Scopes: `openid profile` + +The extension temporarily listens on port 3000, opens SURFconext in the system browser, validates the callback, and exchanges the authorization code. It then requests UserInfo for the account display name. + +Access, refresh, and ID tokens are stored in VS Code SecretStorage. An access token is reused until it is close to expiry. If possible, the extension refreshes it; otherwise it starts browser sign-in again. Workflow API requests include the access token as a bearer token. + +Port 3000 must be available, and the callback URL must be registered for the client. + +## 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..394ad58 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,17 @@ "activationEvents": [ "onDebugResolve:workflow", "onDebugDynamicConfigurations:workflow", - "onLanguage:yaml" + "onLanguage:yaml", + "onAuthenticationRequest:workflow.surfconext" ], "main": "./out/extension.js", "contributes": { + "authentication": [ + { + "id": "workflow.surfconext", + "label": "SURFconext" + } + ], "debuggers": [ { "type": "workflow", @@ -117,7 +124,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..a8f10ec 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,81 @@ 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) { + 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}).` + }); + 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..4b2bd44 --- /dev/null +++ b/src/auth/LoopbackCallbackServer.ts @@ -0,0 +1,68 @@ +import { createServer, Server } from 'node:http'; +import { SURFCONEXT_REDIRECT_URI } from './OidcClient.js'; +import { logger } from '../logger.js'; + +export class LoopbackCallbackServer { + private server: Server | undefined; + private readonly redirectUri = new URL(SURFCONEXT_REDIRECT_URI); + + 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 ${SURFCONEXT_REDIRECT_URI}.`); + 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..5b0a49b --- /dev/null +++ b/src/auth/OidcClient.ts @@ -0,0 +1,133 @@ +import * as oidc from 'openid-client'; +import { logger } from '../logger.js'; + +export const SURFCONEXT_AUTHORITY = new URL('https://connect.test.surfconext.nl/'); +export const SURFCONEXT_CLIENT_ID = 'datanose.local'; +export const SURFCONEXT_SCOPES = ['openid', 'profile'] as const; +export const SURFCONEXT_REDIRECT_URI = 'http://localhost:3000/callback'; + +export interface AuthorizationRequest { + url: URL; + state: string; + codeVerifier: string; +} + +export interface OidcTokens { + accessToken: string; + refreshToken?: string; + idToken?: string; + expiresIn?: number; + subject?: string; + accountLabel?: string; +} + +type Configuration = oidc.Configuration; + +export class SurfConextOidcClient { + private configurationPromise: Promise | undefined; + + 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: SURFCONEXT_CLIENT_ID, + redirect_uri: SURFCONEXT_REDIRECT_URI, + response_type: 'code', + scope: SURFCONEXT_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; + } + + public async refresh(refreshToken: string): Promise { + logger.info('Requesting refreshed SURFconext tokens.'); + const tokens = await oidc.refreshTokenGrant(await this.getConfiguration(), refreshToken); + logger.info('SURFconext tokens refreshed successfully.'); + return this.toTokens(tokens); + } + + private getConfiguration(): Promise { + if (!this.configurationPromise) { + logger.info(`Discovering OIDC metadata from ${SURFCONEXT_AUTHORITY.origin}.`); + this.configurationPromise = oidc.discovery( + SURFCONEXT_AUTHORITY, + SURFCONEXT_CLIENT_ID, + 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, + refreshToken: tokens.refresh_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..d4afbc1 --- /dev/null +++ b/src/auth/OidcSessionManager.ts @@ -0,0 +1,367 @@ +import { + AuthorizationRequest, + OidcTokens, + SURFCONEXT_SCOPES, + 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 { + accessToken: string; + refreshToken?: 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 = new SurfConextOidcClient(); + private readonly callbackServer = new LoopbackCallbackServer(); + + public constructor( + private readonly secrets: SecretStore, + private readonly openExternal: (url: URL) => Promise, + private readonly credentialsChanged: CredentialsChanged = () => undefined, + ) {} + + 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?.refreshToken) { + logger.info('Stored access token is near expiry; attempting refresh.'); + const generation = this.loginGeneration; + try { + return this.toSession(await this.refresh(stored, generation)); + } catch { + logger.warn('Stored SURFconext session could not be refreshed; removing it.'); + await this.replaceCredentials(undefined); + } + } else if (stored) { + logger.info('Stored access token expired without a refresh token; removing it.'); + 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 on localhost port 3000. 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 async refresh(stored: StoredCredentials, generation: number): Promise { + let tokens: OidcTokens; + try { + tokens = await this.oidcClient.refresh(stored.refreshToken!); + } catch { + logger.error('SURFconext token refresh failed.'); + throw new AuthenticationError('The saved SURFconext session could not be refreshed.'); + } + this.ensureLoginActive(generation); + + if (!tokens.accessToken) { + throw new AuthenticationError('SURFconext returned an invalid refreshed session.'); + } + + const refreshed: StoredCredentials = { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken ?? stored.refreshToken, + idToken: tokens.idToken ?? stored.idToken, + expiresAt: this.expiryFrom(tokens), + account: tokens.subject + ? { + id: tokens.subject, + label: tokens.accountLabel + ?? (tokens.subject === stored.account.id ? stored.account.label : tokens.subject), + } + : stored.account, + }; + const committed = await this.commitCredentials(refreshed, generation); + logger.info('Stored SURFconext session refreshed.'); + return committed; + } + + private createCredentials(tokens: OidcTokens): StoredCredentials { + if (!tokens.accessToken || !tokens.idToken || !tokens.subject) { + throw new AuthenticationError('SURFconext returned an incomplete session. Please try again.'); + } + + return { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + 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.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: [...SURFCONEXT_SCOPES], + }; + } +} + +function isStoredCredentials(value: unknown): value is StoredCredentials { + if (!value || typeof value !== 'object') { + return false; + } + const candidate = value as Partial; + return typeof candidate.accessToken === 'string' + && typeof candidate.idToken === 'string' + && typeof candidate.expiresAt === 'number' + && !!candidate.account + && typeof candidate.account.id === 'string' + && typeof candidate.account.label === 'string' + && (candidate.refreshToken === undefined || typeof candidate.refreshToken === 'string'); +} diff --git a/src/auth/WorkflowAuthenticationProvider.ts b/src/auth/WorkflowAuthenticationProvider.ts new file mode 100644 index 0000000..cd5ef48 --- /dev/null +++ b/src/auth/WorkflowAuthenticationProvider.ts @@ -0,0 +1,86 @@ +import * as vscode from 'vscode'; +import { AccessTokenProvider } from '../workflowApi.js'; +import { logger } from '../logger.js'; +import { + OidcSessionManager, + StoredCredentials, + WorkflowAuthenticationSession, +} from './OidcSessionManager.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) { + this.sessionManager = new OidcSessionManager( + secrets, + url => Promise.resolve(vscode.env.openExternal(vscode.Uri.parse(url.toString()))), + (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..0fbbcf5 --- /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; refreshing authentication 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), + }); +} From 3360ee74ca9b647638c0772d6757ced11fb14ad2 Mon Sep 17 00:00:00 2001 From: Annes Negmel-Din Date: Wed, 1 Jul 2026 12:34:26 +0200 Subject: [PATCH 2/4] refactor: remove refresh token handling --- README.md | 2 +- src/auth/OidcClient.ts | 9 ------ src/auth/OidcSessionManager.ts | 50 ++-------------------------------- src/workflowApi.ts | 2 +- 4 files changed, 5 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 07405bb..2256b7b 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ The first workflow launch signs in through SURFconext using the authorization-co The extension temporarily listens on port 3000, opens SURFconext in the system browser, validates the callback, and exchanges the authorization code. It then requests UserInfo for the account display name. -Access, refresh, and ID tokens are stored in VS Code SecretStorage. An access token is reused until it is close to expiry. If possible, the extension refreshes it; otherwise it starts browser sign-in again. Workflow API requests include the access token as a bearer token. +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 3000 must be available, and the callback URL must be registered for the client. diff --git a/src/auth/OidcClient.ts b/src/auth/OidcClient.ts index 5b0a49b..05cfc7f 100644 --- a/src/auth/OidcClient.ts +++ b/src/auth/OidcClient.ts @@ -14,7 +14,6 @@ export interface AuthorizationRequest { export interface OidcTokens { accessToken: string; - refreshToken?: string; idToken?: string; expiresIn?: number; subject?: string; @@ -74,13 +73,6 @@ export class SurfConextOidcClient { return result; } - public async refresh(refreshToken: string): Promise { - logger.info('Requesting refreshed SURFconext tokens.'); - const tokens = await oidc.refreshTokenGrant(await this.getConfiguration(), refreshToken); - logger.info('SURFconext tokens refreshed successfully.'); - return this.toTokens(tokens); - } - private getConfiguration(): Promise { if (!this.configurationPromise) { logger.info(`Discovering OIDC metadata from ${SURFCONEXT_AUTHORITY.origin}.`); @@ -107,7 +99,6 @@ export class SurfConextOidcClient { return { accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, idToken: tokens.id_token, expiresIn: tokens.expiresIn(), subject: claims?.sub, diff --git a/src/auth/OidcSessionManager.ts b/src/auth/OidcSessionManager.ts index d4afbc1..7a9c428 100644 --- a/src/auth/OidcSessionManager.ts +++ b/src/auth/OidcSessionManager.ts @@ -24,7 +24,6 @@ export interface AccountDetails { export interface StoredCredentials { accessToken: string; - refreshToken?: string; idToken: string; expiresAt: number; account: AccountDetails; @@ -78,17 +77,8 @@ export class OidcSessionManager { return this.toSession(stored); } - if (stored?.refreshToken) { - logger.info('Stored access token is near expiry; attempting refresh.'); - const generation = this.loginGeneration; - try { - return this.toSession(await this.refresh(stored, generation)); - } catch { - logger.warn('Stored SURFconext session could not be refreshed; removing it.'); - await this.replaceCredentials(undefined); - } - } else if (stored) { - logger.info('Stored access token expired without a refresh token; removing it.'); + if (stored) { + logger.info('Stored access token is near expiry; removing it before signing in again.'); await this.replaceCredentials(undefined); } @@ -248,38 +238,6 @@ export class OidcSessionManager { }); } - private async refresh(stored: StoredCredentials, generation: number): Promise { - let tokens: OidcTokens; - try { - tokens = await this.oidcClient.refresh(stored.refreshToken!); - } catch { - logger.error('SURFconext token refresh failed.'); - throw new AuthenticationError('The saved SURFconext session could not be refreshed.'); - } - this.ensureLoginActive(generation); - - if (!tokens.accessToken) { - throw new AuthenticationError('SURFconext returned an invalid refreshed session.'); - } - - const refreshed: StoredCredentials = { - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken ?? stored.refreshToken, - idToken: tokens.idToken ?? stored.idToken, - expiresAt: this.expiryFrom(tokens), - account: tokens.subject - ? { - id: tokens.subject, - label: tokens.accountLabel - ?? (tokens.subject === stored.account.id ? stored.account.label : tokens.subject), - } - : stored.account, - }; - const committed = await this.commitCredentials(refreshed, generation); - logger.info('Stored SURFconext session refreshed.'); - return committed; - } - private createCredentials(tokens: OidcTokens): StoredCredentials { if (!tokens.accessToken || !tokens.idToken || !tokens.subject) { throw new AuthenticationError('SURFconext returned an incomplete session. Please try again.'); @@ -287,7 +245,6 @@ export class OidcSessionManager { return { accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, idToken: tokens.idToken, expiresAt: this.expiryFrom(tokens), account: { @@ -362,6 +319,5 @@ function isStoredCredentials(value: unknown): value is StoredCredentials { && typeof candidate.expiresAt === 'number' && !!candidate.account && typeof candidate.account.id === 'string' - && typeof candidate.account.label === 'string' - && (candidate.refreshToken === undefined || typeof candidate.refreshToken === 'string'); + && typeof candidate.account.label === 'string'; } diff --git a/src/workflowApi.ts b/src/workflowApi.ts index 0fbbcf5..ca4ac4c 100644 --- a/src/workflowApi.ts +++ b/src/workflowApi.ts @@ -22,7 +22,7 @@ export async function uploadWorkflowAuthenticated( logger.info(`Workflow API responded with HTTP ${response.status}.`); if (response.status === 401) { - logger.warn('Workflow API returned 401; refreshing authentication and retrying once.'); + logger.warn('Workflow API returned 401; signing in again and retrying once.'); await tokenProvider.invalidateAccessToken(); accessToken = await tokenProvider.getAccessToken(); response = await safelyUploadWorkflow(request, accessToken); From a0ae866957c8da00e21060da1fed4ba24748818f Mon Sep 17 00:00:00 2001 From: Annes Negmel-Din Date: Fri, 3 Jul 2026 09:56:28 +0200 Subject: [PATCH 3/4] chore: update SURFconext client ID and callback URL in README and code --- README.md | 8 ++++---- src/auth/OidcClient.ts | 4 ++-- src/auth/OidcSessionManager.ts | 9 +++++++-- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2256b7b..9153330 100644 --- a/README.md +++ b/README.md @@ -42,15 +42,15 @@ VS Code extension for uploading and running workflows from a local workspace. The first workflow launch signs in through SURFconext using the authorization-code flow with PKCE. - OIDC issuer: `https://connect.test.surfconext.nl/` -- Client ID: `datanose.local` -- Callback: `http://localhost:3000/callback` +- Client ID: `milestones-tst.fnwi.uva.nl` +- Callback: `http://127.0.0.1:53682/callback` - Scopes: `openid profile` -The extension temporarily listens on port 3000, opens SURFconext in the system browser, validates the callback, and exchanges the authorization code. It then requests UserInfo for the account display name. +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 3000 must be available, and the callback URL must be registered for the client. +Port 53682 must be available, and the callback URL must be registered for the client. ## Sign out diff --git a/src/auth/OidcClient.ts b/src/auth/OidcClient.ts index 05cfc7f..bf51d2e 100644 --- a/src/auth/OidcClient.ts +++ b/src/auth/OidcClient.ts @@ -2,9 +2,9 @@ import * as oidc from 'openid-client'; import { logger } from '../logger.js'; export const SURFCONEXT_AUTHORITY = new URL('https://connect.test.surfconext.nl/'); -export const SURFCONEXT_CLIENT_ID = 'datanose.local'; +export const SURFCONEXT_CLIENT_ID = 'milestones-tst.fnwi.uva.nl'; export const SURFCONEXT_SCOPES = ['openid', 'profile'] as const; -export const SURFCONEXT_REDIRECT_URI = 'http://localhost:3000/callback'; +export const SURFCONEXT_REDIRECT_URI = 'http://127.0.0.1:53682/callback'; export interface AuthorizationRequest { url: URL; diff --git a/src/auth/OidcSessionManager.ts b/src/auth/OidcSessionManager.ts index 7a9c428..8fb3317 100644 --- a/src/auth/OidcSessionManager.ts +++ b/src/auth/OidcSessionManager.ts @@ -1,6 +1,8 @@ import { AuthorizationRequest, OidcTokens, + SURFCONEXT_CLIENT_ID, + SURFCONEXT_REDIRECT_URI, SURFCONEXT_SCOPES, SurfConextOidcClient, } from './OidcClient.js'; @@ -23,6 +25,7 @@ export interface AccountDetails { } export interface StoredCredentials { + clientId: string; accessToken: string; idToken: string; expiresAt: number; @@ -160,7 +163,7 @@ export class OidcSessionManager { 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 on localhost port 3000. Close any application using that port and try again.'); + this.cancelLogin(`Could not listen for the SURFconext callback at ${SURFCONEXT_REDIRECT_URI}. Close any application using that port and try again.`); } if (callbackServerHandle) { try { @@ -244,6 +247,7 @@ export class OidcSessionManager { } return { + clientId: SURFCONEXT_CLIENT_ID, accessToken: tokens.accessToken, idToken: tokens.idToken, expiresAt: this.expiryFrom(tokens), @@ -314,7 +318,8 @@ function isStoredCredentials(value: unknown): value is StoredCredentials { return false; } const candidate = value as Partial; - return typeof candidate.accessToken === 'string' + return candidate.clientId === SURFCONEXT_CLIENT_ID + && typeof candidate.accessToken === 'string' && typeof candidate.idToken === 'string' && typeof candidate.expiresAt === 'number' && !!candidate.account From 9b23c5227087a2a3d6dd55c3f3e7cd16df2dfc79 Mon Sep 17 00:00:00 2001 From: Annes Negmel-Din Date: Fri, 3 Jul 2026 10:05:10 +0200 Subject: [PATCH 4/4] feat: add configuration options for SURFconext authentication in package.json and update related code --- README.md | 1 + package.json | 31 ++++++++++++++++++++++ src/WorkflowDebugSession.ts | 3 ++- src/auth/LoopbackCallbackServer.ts | 9 ++++--- src/auth/OidcClient.ts | 24 ++++++++++------- src/auth/OidcSessionManager.ts | 26 +++++++++--------- src/auth/WorkflowAuthenticationProvider.ts | 9 +++++++ 7 files changed, 77 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 9153330..a08a98b 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ The extension temporarily listens on port 53682, opens SURFconext in the system 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 diff --git a/package.json b/package.json index 394ad58..7816bfc 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,37 @@ "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", diff --git a/src/WorkflowDebugSession.ts b/src/WorkflowDebugSession.ts index a8f10ec..43664e3 100644 --- a/src/WorkflowDebugSession.ts +++ b/src/WorkflowDebugSession.ts @@ -51,10 +51,11 @@ export class WorkflowDebugSession extends LoggingDebugSession { ); 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}).` + format: `Workflow API rejected the upload (${response.status})${details ? `: ${details}` : '.'}` }); return; } diff --git a/src/auth/LoopbackCallbackServer.ts b/src/auth/LoopbackCallbackServer.ts index 4b2bd44..89c327f 100644 --- a/src/auth/LoopbackCallbackServer.ts +++ b/src/auth/LoopbackCallbackServer.ts @@ -1,10 +1,13 @@ import { createServer, Server } from 'node:http'; -import { SURFCONEXT_REDIRECT_URI } from './OidcClient.js'; import { logger } from '../logger.js'; export class LoopbackCallbackServer { private server: Server | undefined; - private readonly redirectUri = new URL(SURFCONEXT_REDIRECT_URI); + 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) { @@ -48,7 +51,7 @@ export class LoopbackCallbackServer { server.off('error', fail); server.on('error', () => undefined); this.server = server; - logger.info(`Listening for the SURFconext callback at ${SURFCONEXT_REDIRECT_URI}.`); + logger.info(`Listening for the SURFconext callback at ${this.redirectUri.toString()}.`); resolve({ dispose: () => { if (this.server === server) { diff --git a/src/auth/OidcClient.ts b/src/auth/OidcClient.ts index bf51d2e..0bff75f 100644 --- a/src/auth/OidcClient.ts +++ b/src/auth/OidcClient.ts @@ -1,10 +1,12 @@ import * as oidc from 'openid-client'; import { logger } from '../logger.js'; -export const SURFCONEXT_AUTHORITY = new URL('https://connect.test.surfconext.nl/'); -export const SURFCONEXT_CLIENT_ID = 'milestones-tst.fnwi.uva.nl'; -export const SURFCONEXT_SCOPES = ['openid', 'profile'] as const; -export const SURFCONEXT_REDIRECT_URI = 'http://127.0.0.1:53682/callback'; +export interface SurfConextConfiguration { + authority: URL; + clientId: string; + redirectUri: string; + scopes: readonly string[]; +} export interface AuthorizationRequest { url: URL; @@ -25,6 +27,8 @@ 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(); @@ -32,10 +36,10 @@ export class SurfConextOidcClient { const codeVerifier = oidc.randomPKCECodeVerifier(); const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier); const url = oidc.buildAuthorizationUrl(configuration, { - client_id: SURFCONEXT_CLIENT_ID, - redirect_uri: SURFCONEXT_REDIRECT_URI, + client_id: this.settings.clientId, + redirect_uri: this.settings.redirectUri, response_type: 'code', - scope: SURFCONEXT_SCOPES.join(' '), + scope: this.settings.scopes.join(' '), state, code_challenge: codeChallenge, code_challenge_method: 'S256', @@ -75,10 +79,10 @@ export class SurfConextOidcClient { private getConfiguration(): Promise { if (!this.configurationPromise) { - logger.info(`Discovering OIDC metadata from ${SURFCONEXT_AUTHORITY.origin}.`); + logger.info(`Discovering OIDC metadata from ${this.settings.authority.origin}.`); this.configurationPromise = oidc.discovery( - SURFCONEXT_AUTHORITY, - SURFCONEXT_CLIENT_ID, + this.settings.authority, + this.settings.clientId, undefined, oidc.None(), ).catch(error => { diff --git a/src/auth/OidcSessionManager.ts b/src/auth/OidcSessionManager.ts index 8fb3317..050ae14 100644 --- a/src/auth/OidcSessionManager.ts +++ b/src/auth/OidcSessionManager.ts @@ -1,9 +1,7 @@ import { AuthorizationRequest, OidcTokens, - SURFCONEXT_CLIENT_ID, - SURFCONEXT_REDIRECT_URI, - SURFCONEXT_SCOPES, + SurfConextConfiguration, SurfConextOidcClient, } from './OidcClient.js'; import { LoopbackCallbackServer } from './LoopbackCallbackServer.js'; @@ -64,14 +62,18 @@ export class OidcSessionManager { private loginPromise: Promise | undefined; private pendingCallback: PendingCallback | undefined; private loginGeneration = 0; - private readonly oidcClient = new SurfConextOidcClient(); - private readonly callbackServer = new LoopbackCallbackServer(); + 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(); @@ -163,7 +165,7 @@ export class OidcSessionManager { 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 ${SURFCONEXT_REDIRECT_URI}. Close any application using that port and try again.`); + 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 { @@ -247,7 +249,7 @@ export class OidcSessionManager { } return { - clientId: SURFCONEXT_CLIENT_ID, + clientId: this.configuration.clientId, accessToken: tokens.accessToken, idToken: tokens.idToken, expiresAt: this.expiryFrom(tokens), @@ -277,7 +279,7 @@ export class OidcSessionManager { try { const parsed: unknown = JSON.parse(serialized); - if (isStoredCredentials(parsed)) { + if (isStoredCredentials(parsed, this.configuration.clientId)) { this.credentials = parsed; logger.info('Restored SURFconext credentials from secure storage.'); return parsed; @@ -308,17 +310,17 @@ export class OidcSessionManager { id: credentials.account.id, accessToken: credentials.accessToken, account: credentials.account, - scopes: [...SURFCONEXT_SCOPES], + scopes: [...this.configuration.scopes], }; } } -function isStoredCredentials(value: unknown): value is StoredCredentials { +function isStoredCredentials(value: unknown, clientId: string): value is StoredCredentials { if (!value || typeof value !== 'object') { return false; } const candidate = value as Partial; - return candidate.clientId === SURFCONEXT_CLIENT_ID + return candidate.clientId === clientId && typeof candidate.accessToken === 'string' && typeof candidate.idToken === 'string' && typeof candidate.expiresAt === 'number' diff --git a/src/auth/WorkflowAuthenticationProvider.ts b/src/auth/WorkflowAuthenticationProvider.ts index cd5ef48..1c3f1fa 100644 --- a/src/auth/WorkflowAuthenticationProvider.ts +++ b/src/auth/WorkflowAuthenticationProvider.ts @@ -6,6 +6,7 @@ import { StoredCredentials, WorkflowAuthenticationSession, } from './OidcSessionManager.js'; +import { SurfConextConfiguration } from './OidcClient.js'; export const AUTHENTICATION_PROVIDER_ID = 'workflow.surfconext'; export const AUTHENTICATION_PROVIDER_LABEL = 'SURFconext'; @@ -17,9 +18,17 @@ export class WorkflowAuthenticationProvider implements vscode.AuthenticationProv 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), ); }