diff --git a/README.md b/README.md index 36a6b92..8894029 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,9 @@ TypeScript using Electron Forge. stories with Titles, Descriptions, and Acceptance Criteria. - Ability to seamlessly write the generated stories back to Azure DevOps as new **Product Backlog Items (PBIs)** linked under a specific Feature. -- **Persistent Settings**: Securely store Azure DevOps credentials, Confluence - tokens, and project configuration locally, and actively check the status of - local GitHub Copilot CLI authentication. +- **Persistent Settings**: Securely store Azure DevOps organization details, + Confluence tokens, and project configuration locally, and actively check the + status of local GitHub Copilot CLI authentication. ## Tech Stack @@ -39,6 +39,7 @@ TypeScript using Electron Forge. - **Navigation:** [React Router Dom](https://reactrouter.com/) - **APIs & Integration**: - `azure-devops-node-api`: For interacting with Azure DevOps REST APIs. + - `@azure/msal-node`: For OAuth 2.0 with PKCE authentication to Azure DevOps. - `@github/copilot-sdk`: For AI-powered generation via GitHub Copilot. - **Confluence REST API**: Utilizing internal fetches for reading Atlassian Cloud content via Basic Auth using API Tokens. @@ -55,8 +56,9 @@ TypeScript using Electron Forge. - **Note for Windows Users**: You must set the `NODE_PATH` (path to Node.js executable) and `COPILOT_SCRIPT_PATH` (path to the Copilot JS script) environment variables for the Copilot client to initialize correctly. -- **Azure DevOps PAT**: A Personal Access Token with "Work Items: Read & Write" - permissions. +- **Azure DevOps Account**: Access to an Azure DevOps organization. The + application uses OAuth 2.0 with PKCE for secure authentication (no PAT + required). - **Confluence API Token**: An Atlassian API Token generated from your profile settings (to be paired with your login email) for basic authentication. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ac07dca..aa1cd63 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -19,8 +19,7 @@ main (Node.js) process from the renderer (Chromium) process. ## Configuration Management -We use `electron-store` to persist user settings (like Azure DevOps PATs) -locally on the machine. +We use `electron-store` to persist user settings locally on the machine. - **Encryption:** Settings are stored in the default Electron user data path. - **IPC Access:** The renderer fetches and saves settings through the @@ -30,8 +29,8 @@ locally on the machine. ### Azure DevOps -- **Library:** `azure-devops-node-api` -- **Method:** Uses Personal Access Tokens (PAT) via the Work Item Tracking API. +- **Libraries:** `azure-devops-node-api`, `@azure/msal-node` +- **Method:** OAuth 2.0 with PKCE via MSAL for secure authentication. - **Scope:** Fetches work item details (ID, Title, Description, Acceptance Criteria), and allows pushing generated test cases back to Azure DevOps as Comments or Child Tasks. diff --git a/package-lock.json b/package-lock.json index 3804ef9..2623a04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.1", "license": "MIT", "dependencies": { + "@azure/msal-node": "^5.1.2", "@fortawesome/fontawesome-free": "^7.2.0", "@github/copilot-sdk": "^0.2.1", "@popperjs/core": "^2.11.8", @@ -51,6 +52,29 @@ "typescript": "^5.9.3" } }, + "node_modules/@azure/msal-common": { + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.4.1.tgz", + "integrity": "sha512-Bl8f+w37xkXsYh7QRkAKCFGYtWMYuOVO7Lv+BxILrvGz3HbIEF22Pt0ugyj0QPOl6NLrHcnNUQ9yeew98P/5iw==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.1.2.tgz", + "integrity": "sha512-DoeSJ9U5KPAIZoHsPywvfEj2MhBniQe0+FSpjLUTdWoIkI999GB5USkW6nNEHnIaLVxROHXvprWA1KzdS1VQ4A==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.4.1", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -4010,6 +4034,12 @@ "node": "*" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -5460,6 +5490,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -9139,6 +9178,28 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/junk": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/junk/-/junk-3.1.0.tgz", @@ -9149,6 +9210,27 @@ "node": ">=8" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9656,6 +9738,42 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -9663,6 +9781,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -12893,7 +13017,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -14967,7 +15090,6 @@ "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, "license": "MIT", "bin": { "uuid": "dist/bin/uuid" diff --git a/package.json b/package.json index 2de6c29..8946c2e 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "typescript": "^5.9.3" }, "dependencies": { + "@azure/msal-node": "^5.1.2", "@fortawesome/fontawesome-free": "^7.2.0", "@github/copilot-sdk": "^0.2.1", "@popperjs/core": "^2.11.8", diff --git a/src/main/index.ts b/src/main/index.ts index c064ae6..a94d613 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -12,7 +12,6 @@ declare const MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: string; type AppSettings = { azureOrg?: string; azureProject?: string; - azurePat?: string; copilotToken?: string; copilotModel?: string; confluenceUrl?: string; @@ -32,7 +31,6 @@ async function initStore() { return store; } -// Global service instances // Global service instances let azureService: AzureDevOpsService | null = null; let confluenceService: any = null; @@ -57,9 +55,9 @@ function trimProperties( obj: Record, ): Record { const out: Record = {}; - for (let key in obj) { - if (obj.hasOwnProperty(key)) { - out[key] = obj[key]?.toString().trim(); + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + out[key] = obj[key]?.toString().trim() || ''; } } return out; @@ -83,19 +81,53 @@ ipcMain.handle('save-settings', async (event, settings: AppSettings) => { }); async function getAzureService() { + const s = await initStore(); + const settings = trimProperties(s.get('settings') || {}) as AppSettings; + const { azureOrg } = settings; + + if (!azureOrg) { + throw new Error( + 'Azure DevOps Organization URL is missing in settings. Please provide it before logging in.', + ); + } + if (!azureService) { - const s = await initStore(); - const { azureOrg, azurePat } = trimProperties( - s.get('settings'), - ) as AppSettings; - if (!azureOrg || !azurePat) { - throw new Error('Azure DevOps settings are missing.'); - } - azureService = new AzureDevOpsService(azureOrg, azurePat); + azureService = new AzureDevOpsService(azureOrg); + } else if (azureService.org !== azureOrg) { + // If org changed, re-initialize + azureService = new AzureDevOpsService(azureOrg); } + return azureService; } +ipcMain.handle('azure-login', async () => { + const service = await getAzureService(); + return service.login(); +}); + +ipcMain.handle('azure-logout', async () => { + if (azureService) { + await azureService.logout(); + azureService = null; + } + return { success: true }; +}); + +ipcMain.handle('get-azure-status', async () => { + try { + const s = await initStore(); + const settings = trimProperties(s.get('settings') || {}) as AppSettings; + if (!settings.azureOrg) return { isAuthenticated: false }; + + const service = await getAzureService(); + const token = await service.getAccessToken(); + return { isAuthenticated: !!token }; + } catch (error) { + return { isAuthenticated: false }; + } +}); + ipcMain.handle('fetch-ticket', async (event, ticketId) => { const service = await getAzureService(); return service.fetchTicket(ticketId); diff --git a/src/main/preload.ts b/src/main/preload.ts index 2b4fddc..26d07c3 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -40,4 +40,7 @@ contextBridge.exposeInMainWorld('electronAPI', { checkCopilotAuth: () => ipcRenderer.invoke('check-copilot-auth'), getVersion: () => ipcRenderer.invoke('get-version'), listCopilotModels: () => ipcRenderer.invoke('list-copilot-models'), + azureLogin: () => ipcRenderer.invoke('azure-login'), + azureLogout: () => ipcRenderer.invoke('azure-logout'), + getAzureStatus: () => ipcRenderer.invoke('get-azure-status'), }); diff --git a/src/main/services/azureDevOpsService.ts b/src/main/services/azureDevOpsService.ts index d891c25..0949bb6 100644 --- a/src/main/services/azureDevOpsService.ts +++ b/src/main/services/azureDevOpsService.ts @@ -1,17 +1,113 @@ import * as azdev from 'azure-devops-node-api'; import { IWorkItemTrackingApi } from 'azure-devops-node-api/WorkItemTrackingApi'; +import { + PublicClientApplication, + Configuration, + AuthenticationResult, + LogLevel, + AccountInfo, + InteractiveRequest, +} from '@azure/msal-node'; +import { shell } from 'electron'; + +const CLIENT_ID = '4e8ba545-31c8-4734-afb2-158ee44a051c'; // Azure DevOps App Registration Client ID +const AUTHORITY = 'https://login.microsoftonline.com/common'; +const SCOPES = ['499b84ac-1321-427f-aa17-267ca6975798/.default']; export class AzureDevOpsService { private witApi: IWorkItemTrackingApi | null = null; + private pca: PublicClientApplication; + private account: AccountInfo | null = null; + + constructor(public org: string) { + const msalConfig: Configuration = { + auth: { + clientId: CLIENT_ID, + authority: AUTHORITY, + }, + system: { + loggerOptions: { + loggerCallback(loglevel, message, containsPii) { + if (!containsPii) console.log(message); + }, + piiLoggingEnabled: false, + logLevel: LogLevel.Info, + }, + }, + }; + this.pca = new PublicClientApplication(msalConfig); + } - constructor( - private org: string, - private pat: string, - ) {} + private async getAccount(): Promise { + if (!this.account) { + const cache = this.pca.getTokenCache(); + const accounts = await cache.getAllAccounts(); + if (accounts.length > 0) { + this.account = accounts[0]; + } + } + return this.account; + } + + async login(): Promise { + const interactiveRequest: InteractiveRequest = { + scopes: SCOPES, + openBrowser: async (url: string) => { + await shell.openExternal(url); + }, + successTemplate: + '

Authentication Successful

You can close this window now.

', + errorTemplate: + '

Authentication Failed

Please check the logs.

', + }; + + try { + const result = await this.pca.acquireTokenInteractive(interactiveRequest); + this.account = result.account; + this.witApi = null; // Reset API client to use new token + return result; + } catch (error) { + console.error('Login failed:', error); + throw error; + } + } + + async logout(): Promise { + const account = await this.getAccount(); + if (account) { + await this.pca.getTokenCache().removeAccount(account); + this.account = null; + this.witApi = null; + } + } + + async getAccessToken(): Promise { + const account = await this.getAccount(); + if (!account) { + throw new Error('No account found. Please log in first.'); + } + + try { + const result = await this.pca.acquireTokenSilent({ + account: account, + scopes: SCOPES, + }); + return result.accessToken; + } catch (error) { + console.warn( + 'Silent token acquisition failed, attempting interactive login...', + error, + ); + const result = await this.login(); + if (!result) throw new Error('Failed to acquire token.'); + return result.accessToken; + } + } private async getApi(): Promise { if (!this.witApi) { - const authHandler = azdev.getPersonalAccessTokenHandler(this.pat); + const token = await this.getAccessToken(); + const authHandler = azdev.getBearerHandler(token); const connection = new azdev.WebApi(this.org, authHandler); this.witApi = await connection.getWorkItemTrackingApi(); } diff --git a/src/renderer/pages/Settings.tsx b/src/renderer/pages/Settings.tsx index a9beb37..878be11 100644 --- a/src/renderer/pages/Settings.tsx +++ b/src/renderer/pages/Settings.tsx @@ -7,7 +7,12 @@ const Settings: React.FC = () => { const navigate = useNavigate(); const [azureOrg, setAzureOrg] = useState(''); const [azureProject, setAzureProject] = useState(''); - const [azurePat, setAzurePat] = useState(''); + const [savedAzureOrg, setSavedAzureOrg] = useState(''); + const [savedAzureProject, setSavedAzureProject] = useState(''); + const [azureAuthStatus, setAzureAuthStatus] = useState({ + isAuthenticated: false, + }); + const [checkingAzure, setCheckingAzure] = useState(false); const [copilotToken, setCopilotToken] = useState(''); const [confluenceUrl, setConfluenceUrl] = useState(''); const [confluenceUser, setConfluenceUser] = useState(''); @@ -25,12 +30,16 @@ const Settings: React.FC = () => { if (settings) { setAzureOrg(settings.azureOrg || ''); setAzureProject(settings.azureProject || ''); - setAzurePat(settings.azurePat || ''); + setSavedAzureOrg(settings.azureOrg || ''); + setSavedAzureProject(settings.azureProject || ''); setCopilotToken(settings.copilotToken || ''); setConfluenceUrl(settings.confluenceUrl || ''); setConfluenceUser(settings.confluenceUser || ''); setConfluenceToken(settings.confluenceToken || ''); } + + const status = await (window as any).electronAPI.getAzureStatus(); + setAzureAuthStatus(status); } catch (error) { console.error('Failed to load settings:', error); } @@ -44,13 +53,14 @@ const Settings: React.FC = () => { await (window as any).electronAPI.saveSettings({ azureOrg: azureOrg, azureProject: azureProject, - azurePat: azurePat, copilotToken: copilotToken, copilotModel: selectedModel, confluenceUrl: confluenceUrl, confluenceUser: confluenceUser, confluenceToken: confluenceToken, }); + setSavedAzureOrg(azureOrg); + setSavedAzureProject(azureProject); setStatusMessage('Settings saved successfully!'); setTimeout(() => setStatusMessage(''), 3000); } catch (error) { @@ -59,6 +69,25 @@ const Settings: React.FC = () => { } }; + const handleAzureLogin = async () => { + try { + setCheckingAzure(true); + await (window as any).electronAPI.azureLogin(); + const status = await (window as any).electronAPI.getAzureStatus(); + setAzureAuthStatus(status); + } catch (error) { + console.error('Azure login failed:', error); + setStatusMessage('Azure login failed. Check console for details.'); + } finally { + setCheckingAzure(false); + } + }; + + const handleAzureLogout = async () => { + await (window as any).electronAPI.azureLogout(); + setAzureAuthStatus({ isAuthenticated: false }); + }; + const handleCheckAuth = async () => { try { setCheckingAuth(true); @@ -122,13 +151,58 @@ const Settings: React.FC = () => { />
- - setAzurePat(e.target.value)} - /> + + {azureAuthStatus.isAuthenticated ? ( +
+ + Authenticated + + +
+ ) : ( + <> + + {(!azureOrg || + !azureProject || + azureOrg !== savedAzureOrg || + azureProject !== savedAzureProject) && ( +
+ + Please enter and Save settings to enable + Sign In. +
+ )} + + )}