Skip to content

Commit

Permalink
Begin authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
GreenAppers committed Nov 14, 2024
1 parent ba290d1 commit 995c9c0
Show file tree
Hide file tree
Showing 13 changed files with 946 additions and 23 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"lint": "eslint --ext .ts,.tsx ."
},
"dependencies": {
"@azure/msal-node": "^2.16.1",
"@chakra-ui/icons": "^2.1.1",
"@chakra-ui/react": "^2.8.2",
"@emotion/react": "^11.13.3",
Expand Down Expand Up @@ -55,6 +56,7 @@
"@electron-forge/plugin-webpack": "^7.5.0",
"@electron-forge/publisher-github": "^7.5.0",
"@electron/fuses": "^1.8.0",
"@types/dotenv-webpack": "^7.0.8",
"@types/react": "^18.3.8",
"@types/react-dom": "^18.3.0",
"@types/split2": "^4.2.3",
Expand All @@ -65,6 +67,7 @@
"@vercel/webpack-asset-relocator-loader": "1.7.3",
"babel-loader": "^9.2.1",
"css-loader": "^6.0.0",
"dotenv-webpack": "^8.1.0",
"electron": "32.1.2",
"eslint": "^8.0.1",
"eslint-plugin-import": "^2.25.0",
Expand Down
10 changes: 10 additions & 0 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
Button,
Flex,
IconButton,
Image,
Expand All @@ -9,6 +10,9 @@ import {
TabPanels,
Box,
useColorModeValue,
Avatar,
Spacer,
Tooltip,
} from '@chakra-ui/react'
import React from 'react'
import { Analytics } from './Analytics'
Expand Down Expand Up @@ -41,6 +45,12 @@ function App() {
<Tab>Strongholds</Tab>
<Tab>Waypoints</Tab>
</TabList>
<Spacer />
<Button onClick={() => window.api.loginToMicrosoftAccount()}>
<Tooltip label="Sign in">
<Avatar />
</Tooltip>
</Button>
</Flex>
</Box>
<TabPanels>
Expand Down
16 changes: 16 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { z } from 'zod'
import {
minecraftLoginResponse,
minecraftProfile,
xstsAuthorizeResponse,
} from './utils/auth'

export enum ModLoaderName {
Fabric = 'Fabric',
Expand Down Expand Up @@ -113,6 +118,15 @@ export const fabricVersionDetails = z.object({
),
})

export const gameAccount = z.object({
active: z.boolean(),
profile: minecraftProfile,
userToken: xstsAuthorizeResponse,
xboxliveToken: xstsAuthorizeResponse,
minecraftToken: xstsAuthorizeResponse,
yggdrasilToken: minecraftLoginResponse,
})

export const gameInstall = z.object({
name: z.string(),
path: z.string(),
Expand All @@ -129,6 +143,7 @@ export type MojangVersionManifest = z.infer<typeof mojangVersionManifest>
export type MojangVersionManifests = z.infer<typeof mojangVersionManifests>
export type MojangStringsTemplate = z.infer<typeof mojangStringsTemplate>

export type GameAccount = z.infer<typeof gameAccount>
export type GameInstall = z.infer<typeof gameInstall>

export type StoreSchema = {
Expand All @@ -154,6 +169,7 @@ export const CHANNELS = {
electronStoreGet: 'electron-store-get',
electronStoreSet: 'electron-store-set',
launchGameInstall: 'launch-game-install',
loginToMicrosoftAccount: 'login-to-microsoft-account',
openBrowserWindow: 'open-browser-window',
openFileDialog: 'open-file-dialog',
getLogfilePath: 'get-logfile-path',
Expand Down
17 changes: 6 additions & 11 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import log from 'electron-log/main'

import './index.css'
import { CHANNELS, GameInstall, LAUNCH_CHANNEL, STORE_KEYS } from './constants'
import { newStore } from './store'
import { AuthProvider } from './msal/AuthProvider'
import { newStore, removeGameInstall, updateGameInstall } from './store'
import {
findGameLogFiles,
newGameLogContext,
Expand All @@ -29,6 +30,7 @@ declare const MAIN_WINDOW_WEBPACK_ENTRY: string
declare const MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: string

const clipboardPollInterval = 500
const authProvider = new AuthProvider()
const gameLogContext = newGameLogContext()
const store = newStore()

Expand Down Expand Up @@ -98,29 +100,22 @@ app.on('ready', () => {
CHANNELS.createGameInstall,
async (_event, gameInstall: GameInstall) => {
await setupInstall(gameInstall)
const gameInstalls = store
.get<string, GameInstall[]>(STORE_KEYS.gameInstalls)
.filter((x) => x.uuid !== gameInstall.uuid)
gameInstalls.push({ ...gameInstall })
store.set(STORE_KEYS.gameInstalls, gameInstalls)
updateGameInstall(store, gameInstall)
return gameInstall
}
)
ipcMain.handle(
CHANNELS.deleteGameInstall,
async (_event, gameInstall: GameInstall) => {
const gameInstalls = store
.get<string, GameInstall[]>(STORE_KEYS.gameInstalls)
.filter((x) => x.uuid !== gameInstall.uuid)
store.set(STORE_KEYS.gameInstalls, gameInstalls)
removeGameInstall(store, gameInstall)
return true
}
)
ipcMain.handle(
CHANNELS.launchGameInstall,
async (_event, launchId: string, gameInstall: GameInstall) => {
const channel = LAUNCH_CHANNEL(launchId)
launchInstall(launchId, gameInstall, (update) => {
launchInstall(launchId, gameInstall, authProvider, store, (update) => {
mainWindow?.webContents.send(channel, update)
}).catch((error) => log.error('launchGameInstall error', error))
return true
Expand Down
155 changes: 155 additions & 0 deletions src/msal/AuthProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { app, shell } from 'electron'
import {
PublicClientApplication,
LogLevel,
AccountInfo,
AuthenticationResult,
InteractiveRequest,
InteractionRequiredAuthError,
} from '@azure/msal-node'
import log from 'electron-log/main'
import path from 'path'

import { cachePlugin } from './CachePlugin'
import { CustomLoopbackClient } from './CustomLoopbackClient'

const openBrowser = (url: string) => shell.openExternal(url)

export class AuthProvider {
private clientApplication: PublicClientApplication
private account: AccountInfo | null

constructor() {
this.clientApplication = new PublicClientApplication({
auth: {
authority: 'https://login.microsoftonline.com/consumers/',
clientId: process.env.MICROSOFT_ENTRA_CLIENT_ID ?? '',
},
cache: {
cachePlugin: cachePlugin(
path.join(app.getPath('userData'), 'auth.json')
),
},
system: {
loggerOptions: {
loggerCallback: (level, message) => {
switch (level) {
case LogLevel.Error:
log.error(message)
break
case LogLevel.Warning:
log.warn(message)
break
case LogLevel.Info:
log.info(message)
break
case LogLevel.Verbose:
log.verbose(message)
break
case LogLevel.Trace:
log.debug(message)
break
}
},
logLevel: LogLevel.Info,
piiLoggingEnabled: false,
},
},
})
}

async login(): Promise<AuthenticationResult | null> {
const authResult = await this.getToken()
return this.handleResponse(authResult)
}

async logout(): Promise<void> {
try {
if (!this.account) return
await this.clientApplication.getTokenCache().removeAccount(this.account)
this.account = null
} catch (error) {
console.log(error)
}
}

async getToken(
scopes = ['XboxLive.signin', 'XboxLive.offline_access']
): Promise<AuthenticationResult> {
let authResponse: AuthenticationResult | undefined
const account = this.account || (await this.getAccount())
if (account) {
try {
authResponse = await this.clientApplication.acquireTokenSilent({
account,
scopes,
})
} catch (error) {
if (!(error instanceof InteractionRequiredAuthError)) throw error
}
}
if (!authResponse) {
authResponse = await this.getTokenInteractive({ openBrowser, scopes })
}
this.account = authResponse.account
return authResponse
}

async getTokenInteractive(
tokenRequest: InteractiveRequest
): Promise<AuthenticationResult> {
/**
* A loopback server of your own implementation, which can have custom logic
* such as attempting to listen on a given port if it is available.
*/
const customLoopbackClient = await CustomLoopbackClient.initialize(3874)

const interactiveRequest: InteractiveRequest = {
...tokenRequest,
successTemplate:
'<h1>Successfully signed in!</h1> <p>You can close this window now.</p>',
errorTemplate:
'<h1>Oops! Something went wrong</h1> <p>Check the console for more information.</p>',
loopbackClient: customLoopbackClient, // overrides default loopback client
}

const authResponse = await this.clientApplication.acquireTokenInteractive(
interactiveRequest
)
return authResponse
}

/**
* 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
*/
private async handleResponse(response: AuthenticationResult) {
this.account = response?.account || (await this.getAccount())
return response
}

public currentAccount(): AccountInfo | null {
return this.account
}

private async getAccount(): Promise<AccountInfo | null> {
const cache = this.clientApplication.getTokenCache()
const currentAccounts = await cache.getAllAccounts()

if (currentAccounts === null) {
console.log('No accounts detected')
return null
}

if (currentAccounts.length > 1) {
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
}
}
}
53 changes: 53 additions & 0 deletions src/msal/CachePlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/

import { ICachePlugin, TokenCacheContext } from '@azure/msal-node'
import fs from 'fs'

export const cachePlugin = (CACHE_LOCATION: string): ICachePlugin => {
const beforeCacheAccess = async (cacheContext: TokenCacheContext) => {
return new Promise<void>((resolve, reject) => {
if (fs.existsSync(CACHE_LOCATION)) {
fs.readFile(CACHE_LOCATION, 'utf-8', (err, data) => {
if (err) {
reject()
} else {
cacheContext.tokenCache.deserialize(data)
resolve()
}
})
} else {
fs.writeFile(
CACHE_LOCATION,
cacheContext.tokenCache.serialize(),
(err) => {
if (err) {
reject()
}
}
)
}
})
}

const afterCacheAccess = async (cacheContext: TokenCacheContext) => {
if (cacheContext.cacheHasChanged) {
fs.writeFile(
CACHE_LOCATION,
cacheContext.tokenCache.serialize(),
(err) => {
if (err) {
console.log(err)
}
}
)
}
}

return {
beforeCacheAccess,
afterCacheAccess,
}
}
Loading

0 comments on commit 995c9c0

Please sign in to comment.