diff --git a/package-lock.json b/package-lock.json index c23274a..04a4cea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@electron/remote": "^2.1.2", "@microsoft/microsoft-graph-client": "^3.0.2", "electron-store": "^10.0.0", + "isomorphic-fetch": "^3.0.0", "node-fetch": "^3.3.0", "uuid": "^11.0.4" }, @@ -787,6 +788,34 @@ "node": ">=10.19.0" } }, + "node_modules/isomorphic-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", + "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, + "node_modules/isomorphic-fetch/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -1174,6 +1203,11 @@ "node": ">= 8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1235,6 +1269,25 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/when-exit": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.3.tgz", diff --git a/package.json b/package.json index 4487ba7..0cb5de6 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,12 @@ "electron": "^33.2.1" }, "dependencies": { - "@electron/remote": "^2.1.2", - "electron-store": "^10.0.0", - "node-fetch": "^3.3.0", - "uuid": "^11.0.4", "@azure/msal-node": "^1.18.4", - "@microsoft/microsoft-graph-client": "^3.0.2" + "@electron/remote": "^2.1.2", + "@microsoft/microsoft-graph-client": "^3.0.2", + "electron-store": "^10.0.0", + "isomorphic-fetch": "^3.0.0", + "node-fetch": "^3.3.0", + "uuid": "^11.0.4" } } diff --git a/renderer/graph/AuthProvider.js b/renderer/graph/AuthProvider.js new file mode 100644 index 0000000..4fba94e --- /dev/null +++ b/renderer/graph/AuthProvider.js @@ -0,0 +1,140 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +const { PublicClientApplication, InteractionRequiredAuthError } = require('@azure/msal-node'); +const { shell } = require('electron'); + +class AuthProvider { + msalConfig + clientApplication; + account; + cache; + + constructor(msalConfig) { + /** + * Initialize a public client application. For more information, visit: + * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-node/docs/initialize-public-client-application.md + */ + this.msalConfig = msalConfig; + this.clientApplication = new PublicClientApplication(this.msalConfig); + this.cache = this.clientApplication.getTokenCache(); + this.account = null; + } + + async login() { + const authResponse = await this.getToken({ + // If there are scopes that you would like users to consent up front, add them below + // by default, MSAL will add the OIDC scopes to every token request, so we omit those here + scopes: [], + }); + + return this.handleResponse(authResponse); + } + + async logout() { + if (!this.account) return; + + try { + /** + * If you would like to end the session with AAD, use the logout endpoint. You'll need to enable + * the optional token claim 'login_hint' for this to work as expected. For more information, visit: + * https://learn.microsoft.com/azure/active-directory/develop/v2-protocols-oidc#send-a-sign-out-request + */ + if (this.account.idTokenClaims.hasOwnProperty('login_hint')) { + await shell.openExternal(`${this.msalConfig.auth.authority}/oauth2/v2.0/logout?logout_hint=${encodeURIComponent(this.account.idTokenClaims.login_hint)}`); + } + + await this.cache.removeAccount(this.account); + this.account = null; + } catch (error) { + console.log(error); + } + } + + async getToken(tokenRequest) { + let authResponse; + const account = this.account || (await this.getAccount()); + + if (account) { + tokenRequest.account = account; + authResponse = await this.getTokenSilent(tokenRequest); + } else { + authResponse = await this.getTokenInteractive(tokenRequest); + } + + return authResponse || null; + } + + async getTokenSilent(tokenRequest) { + try { + return await this.clientApplication.acquireTokenSilent(tokenRequest); + } catch (error) { + if (error instanceof InteractionRequiredAuthError) { + console.log('Silent token acquisition failed, acquiring token interactive'); + return await this.getTokenInteractive(tokenRequest); + } + + console.log(error); + } + } + + async getTokenInteractive(tokenRequest) { + try { + const openBrowser = async (url) => { + await shell.openExternal(url); + }; + + const authResponse = await this.clientApplication.acquireTokenInteractive({ + ...tokenRequest, + openBrowser, + successTemplate: '

Successfully signed in!

You can close this window now.

', + errorTemplate: '

Oops! Something went wrong

Check the console for more information.

', + }); + + return authResponse; + } catch (error) { + throw error; + } + } + + /** + * Handles the response from a popup or redirect. If response is null, will check if we have any accounts and attempt to sign in. + * @param response + */ + async handleResponse(response) { + if (response !== null) { + this.account = response.account; + } else { + this.account = await this.getAccount(); + } + + return this.account; + } + + /** + * Calls getAllAccounts and determines the correct account to sign into, currently defaults to first account found in cache. + * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-common/docs/Accounts.md + */ + async getAccount() { + const currentAccounts = await this.cache.getAllAccounts(); + + if (!currentAccounts) { + console.log('No accounts detected'); + return null; + } + + if (currentAccounts.length > 1) { + // Add choose account code here + console.log('Multiple accounts detected, need to add choose account code.'); + return currentAccounts[0]; + } else if (currentAccounts.length === 1) { + return currentAccounts[0]; + } else { + return null; + } + } +} + +module.exports = AuthProvider; diff --git a/renderer/graph/authConfig.js b/renderer/graph/authConfig.js new file mode 100644 index 0000000..860b214 --- /dev/null +++ b/renderer/graph/authConfig.js @@ -0,0 +1,48 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +const { LogLevel } = require("@azure/msal-node"); + +/** + * Configuration object to be passed to MSAL instance on creation. + * For a full list of MSAL.js configuration parameters, visit: + * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-node/docs/configuration.md + */ +const AAD_ENDPOINT_HOST = "https://login.microsoftonline.com/"; // include the trailing slash + +const msalConfig = { + auth: { + clientId: "d581ab07-3a21-44d3-84c4-16b06bef6266", + authority: `${AAD_ENDPOINT_HOST}common`, + }, + system: { + loggerOptions: { + loggerCallback(loglevel, message, containsPii) { + console.log(message); + }, + piiLoggingEnabled: false, + logLevel: LogLevel.Verbose, + }, + }, +}; + +/** + * Add here the endpoints and scopes when obtaining an access token for protected web APIs. For more information, see: + * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/resources-and-scopes.md + */ +const GRAPH_ENDPOINT_HOST = "https://graph.microsoft.com/"; // include the trailing slash + +const protectedResources = { + graphMe: { + endpoint: `${GRAPH_ENDPOINT_HOST}v1.0/me`, + scopes: ["User.Read"], + } +}; + + +module.exports = { + msalConfig: msalConfig, + protectedResources: protectedResources, +}; diff --git a/renderer/graph/constants.js b/renderer/graph/constants.js new file mode 100644 index 0000000..c27ba43 --- /dev/null +++ b/renderer/graph/constants.js @@ -0,0 +1,16 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +const IPC_MESSAGES = { + SHOW_WELCOME_MESSAGE: 'SHOW_WELCOME_MESSAGE', + LOGIN: 'LOGIN', + LOGOUT: 'LOGOUT', + GET_PROFILE: 'GET_PROFILE', + SET_PROFILE: 'SET_PROFILE', +} + +module.exports = { + IPC_MESSAGES: IPC_MESSAGES, +} diff --git a/renderer/graph/graph.js b/renderer/graph/graph.js new file mode 100644 index 0000000..4e0f105 --- /dev/null +++ b/renderer/graph/graph.js @@ -0,0 +1,61 @@ +const { Client } = require('@microsoft/microsoft-graph-client'); +const { msalConfig } = require('./authConfig'); +const AuthProvider = require('./AuthProvider'); +require('isomorphic-fetch'); + +class GraphService { + constructor() { + this.authProvider = new AuthProvider(msalConfig); + this.graphClient = null; + } + + async initialize() { + try { + // Get account + await this.authProvider.login(); + + // Get token with OneDrive scopes + const tokenRequest = { + scopes: ['Files.Read', 'Files.Read.All', 'Sites.Read.All'] + }; + + const authResponse = await this.authProvider.getToken(tokenRequest); + + // Initialize Graph client + this.graphClient = Client.init({ + authProvider: (done) => { + done(null, authResponse.accessToken); + } + }); + + return authResponse.accessToken; + } catch (error) { + console.error('Error initializing Graph service:', error); + throw error; + } + } + + async listFolderContents(folderPath) { + if (!this.graphClient) { + await this.initialize(); + } + + try { + // Clean up the path + const cleanPath = folderPath.replace(/^\/+|\/+$/g, ''); + const endpoint = `/me/drive/root:/${cleanPath}:/children`; + + // Get items from the folder + const response = await this.graphClient.api(endpoint) + .select('id,name,size,file,@microsoft.graph.downloadUrl') + .get(); + + return response.value; + } catch (error) { + console.error('Error listing folder contents:', error); + throw error; + } + } +} + +module.exports = new GraphService(); \ No newline at end of file diff --git a/renderer/graphApi.js b/renderer/graph/graphApi.js similarity index 100% rename from renderer/graphApi.js rename to renderer/graph/graphApi.js diff --git a/renderer/renderer.js b/renderer/renderer.js index 86b41a7..0b4dd3e 100644 --- a/renderer/renderer.js +++ b/renderer/renderer.js @@ -1,5 +1,8 @@ const { ipcRenderer, session } = require('electron'); -const graphApi = require('./graphApi'); +const graphApi = require('./graph/graphApi'); + + + // Wait for webview to load const webview = document.getElementById('main-content'); @@ -82,17 +85,23 @@ document.getElementById('sync-button').addEventListener('click', async () => { syncButton.textContent = 'Syncing...'; syncButton.disabled = true; - // Get folder contents using GraphApiClient - console.log('renderer: Getting folder contents:', onedriveSource); - const items = await graphApi.listFolderContents(onedriveSource); - console.log('renderer: Found items:', items.length); + // Get folder contents using GraphService + console.log('Getting folder contents:', onedriveSource); + const graphService = require('./graph/graph'); + + // This will handle authentication and get folder contents + const items = await graphService.listFolderContents(onedriveSource); + console.log('Found items:', items.length); - // Process the items - // TODO: Handle the items as needed showStatus(`Found ${items.length} items`); // Send the items to the main process - ipcRenderer.send('start-sync', { destFolder, onedriveSource , items}); + ipcRenderer.send('start-sync', { + destFolder, + onedriveSource, + items, + accessToken: await graphService.initialize() // Get fresh token for download + }); } catch (error) { console.error('Sync error:', error);