Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .github/assets/addnewaccount.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/assets/confirmations.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/assets/selectaccount.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/assets/tradeoffers.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
59 changes: 48 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,61 @@
# Thunder Authenticator

A Steam Desktop authenticator heavily inspired by [Vapor-Authenticator](https://github.com/HilliamT/Vapor-Authenticator). I like the connection Vapors name has to Steam, but since my name is Zeus, Thunder fits me better!
Thunder is a 3rd party desktop authenticator and account manager for Steam. You can use it to generate Steam Guard codes, confirm trades/market transactions, and use the steam website within the app logged in as your selected account without needing to use a mobile phone.

Mostly just wanted to see if I could make a more up to date version of it myself, with some features I thought were lacking there. Vapor unfortunately has breaking bugs and seems abandoned :(
### Table of Contents
- [Features](#features)
- [Download](#download)
- [Showcase images](#showcase-images)
- [Development quickstart](#development-quickstart)

## Development
## First, a word of caution:

### Install Dependencies
> [!WARNING]
> IF YOU HAVE A MOBILE PHONE, USE THE OFFICIAL STEAM MOBILE AUTHENTICATOR INSTEAD. The whole purpose of 2FA is to protect your account, and using a desktop authenticator is inherently less secure than using a mobile one. Only use this if you don't have a phone or have a good reason not to use the official app!

```
$ cd thunder-authenticator
$ yarn install
```
> [!CAUTION]
> IF you lost your config file or forgot your password, go [here](https://store.steampowered.com/twofactor/manage) and click "Remove authenticator" then enter the revocation code that you saved when you first set up the authenticator. This will remove the authenticator from your account, allowing you to set it up again.

Now onto the good stuff

## Features
- Password login, used to encrypt your account data locally
- Generate Steam Guard codes for any number of accounts
- Confirm trades and market transactions
- View the Steam website within the app logged in as your selected account
- Easily import existing accounts from Steam Desktop Authenticator
- Export account information for use in for example trading bots

### Planned features
- Adding tags or notes to accounts for easier organization

Have other feature ideas? Let me know by opening an [issue](https://github.com/ZeusJunior/thunder/issues/new)!

### Use it
## Download
You can download the latest release for your system from the [releases page](https://github.com/ZeusJunior/thunder/releases/latest).

## Showcase images
Selecting an account to use, if there are 5 or more a search box will appear
![Select account](./.github/assets/selectaccount.png)

Adding a new account
![Add account](./.github/assets/addnewaccount.png)

Viewing your confirmations
![Confirmations](./.github/assets/confirmations.png)

Using the Steam website within the app
![Steam website](./.github/assets/tradeoffers.png)

## Development quickstart

```
# development mode
$ cd thunder
$ yarn install

# start dev server
$ yarn dev

# production build
# or build for production
$ yarn build
```
88 changes: 62 additions & 26 deletions main/background.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import path from 'path';
import { app, ipcMain, shell, dialog } from 'electron';
import { app, ipcMain, shell, dialog, IpcMainEvent } from 'electron';
import serve from 'electron-serve';
import { createWindow, getCurrentAccount, getDebugInfo, configFileExists, getAllAccounts, setCurrentAccount, addAccount, accountExists } from './helpers';
import SteamCommunity from 'steamcommunity';
import { addAuthenticator, finalizeAuthenticator, getAuthCode, loginAgain, refreshProfile } from './helpers/steam';
import { createEncryptedStore, initializeStore } from './store';
import { createEncryptedStore, initializeStore, verifyPassword } from './store';
import { Account, Confirmation, IpcHandlers, MaFileData } from './types';
import { readFile } from 'fs/promises';
import { getConfirmationKey, time } from 'steam-totp';
Expand Down Expand Up @@ -123,27 +123,12 @@ app.on('window-all-closed', () => {
ipcMain.on('message', async (event, arg) => {
event.reply('message', `${arg} World!`);
});
ipcMain.on(
'open-new-window',
async (event, { url, external }: { url: string; external: boolean }) => {
if (external) {
await shell.openExternal(url);
return;
}

const newWindow = createWindow('external', {
width: 1200,
height: 800,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
},
});

await newWindow.loadURL(url);
}
);
ipcMain.on('open-browser-github', async () => {
await shell.openExternal('https://github.com/ZeusJunior/thunder');
return;
});

ipcMain.on('open-steam-window', async (event, { url }: { url: string }) => {
function openSteamWindow(event: IpcMainEvent, url: string) {
const account = getCurrentAccount(false);
if (!account) {
throw new Error('No current account set');
Expand All @@ -157,7 +142,16 @@ ipcMain.on('open-steam-window', async (event, { url }: { url: string }) => {
return;
}

const proceed = async () => {
const proceed = async (cookiesRefreshed = false) => {
// Set cookies again if they were refreshed
if (cookiesRefreshed) {
const account = getCurrentAccount(false);
if (!account) {
throw new Error('No current account set');
}
community.setCookies(account.cookies || []);
}

const steamWindow = createWindow('steam', {
width: 1200,
height: 800,
Expand Down Expand Up @@ -189,14 +183,21 @@ ipcMain.on('open-steam-window', async (event, { url }: { url: string }) => {
refreshToken: account.refreshToken,
})
.then(() => {
return proceed();
return proceed(true);
})
.catch(() => {
event.reply('login-required');
});

return;
});
}
ipcMain.on('open-steam-community', async (event) => {
openSteamWindow(event, 'https://steamcommunity.com/');
});

ipcMain.on('open-steam-tradeoffers', async (event) => {
openSteamWindow(event, 'https://steamcommunity.com/my/tradeoffers');
});

handleIpc('debug-info', async () => {
Expand All @@ -216,6 +217,11 @@ handleIpc('config-initialize', async (event, password) => {
return initializeStore(password);
});

ipcMain.on('show-app-data-directory', async () => {
const appDataPath = app.getPath('userData');
shell.showItemInFolder(path.join(appDataPath, 'config.json'));
});

// Account handlers
handleIpc('get-all-accounts', async () => {
return getAllAccounts();
Expand Down Expand Up @@ -263,6 +269,27 @@ handleIpc('get-auth-code', async () => {
return getAuthCode(account.sharedSecret);
});

handleIpc('export-account-secrets', async (event, password: string) => {
try {
const passwordValid = verifyPassword(password);
if (!passwordValid) {
return { error: 'Invalid password' };
}

const account = getCurrentAccount(false);
if (!account) {
return { error: 'No current account set' };
}

return {
sharedSecret: account.sharedSecret,
identitySecret: account.identitySecret,
};
} catch {
return { error: 'Invalid password' };
}
});

handleIpc('show-mafile-dialog', async () => {
const result = await dialog.showOpenDialog({
title: 'Select maFile',
Expand Down Expand Up @@ -353,7 +380,16 @@ handleIpc('get-confirmations', async (event) => {
return resolve([]);
}

const proceed = async () => {
const proceed = async (cookiesRefreshed = false) => {
// Set cookies again if they were refreshed
if (cookiesRefreshed) {
const account = getCurrentAccount(false);
if (!account) {
throw new Error('No current account set');
}
community.setCookies(account.cookies || []);
}

community.getConfirmations(time(), getConfirmationKey(account.identitySecret, time(), 'conf'), async (err, confirmations) => {
if (err) {
return reject(err);
Expand All @@ -377,7 +413,7 @@ handleIpc('get-confirmations', async (event) => {
refreshToken: account.refreshToken,
})
.then(() => {
return proceed();
return proceed(true);
})
.catch(() => {
event.sender.send('login-required');
Expand Down
1 change: 1 addition & 0 deletions main/helpers/create-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export const createWindow = (
state = ensureVisibleOnSomeDisplay(restore());

const win = new BrowserWindow({
autoHideMenuBar: true,
...state,
...options,
webPreferences: {
Expand Down
33 changes: 24 additions & 9 deletions main/helpers/steam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,42 +11,57 @@ export function loginAgain(details: SteamUser.LogOnDetailsNamePass | SteamUser.L
let loggedOn = false;
let cookies: string[] = [];
let newRefreshToken = '';
let hasResolved = false;

const user = new SteamUser({ renewRefreshTokens: true });
user.logOn(details);

const saveAndResolve = () => {
if (hasResolved) return;
hasResolved = true;

const steamId = user.steamID!.getSteamID64();
updateAccount(steamId, {
cookies,
refreshToken: newRefreshToken,
...(newRefreshToken ? { refreshToken: newRefreshToken } : {}),
});
return resolve();
};

const checkReadyAndSetTimeout = () => {
if (loggedOn && cookies.length > 0) {
// Wait up to 1 extra second for refreshToken, then proceed anyway
// It doesn't always fire or possibly after loggedOn and webSession events fire.
// Do still want to try and save the new one as the old one is expired if we get it.
setTimeout(() => {
if (!hasResolved) {
console.log('Proceeding without new refresh token after timeout');
saveAndResolve();
}
}, 1000);
}
};

user.on('error', (err) => {
// TODO: Figure out specific EResult for invalid/expired refresh token?
// TODO: Handle this error better in the UI
// TODO: Handle any errors here better in the UI
console.error('Error re-authenticating:', err);
return reject(new Error(err.message));
});

user.on('loggedOn', () => {
console.log('Re-authenticated successfully for', user.steamID!.getSteamID64());
loggedOn = true;
if (cookies.length > 0 && newRefreshToken) {
return saveAndResolve();
}
checkReadyAndSetTimeout();
});

user.on('webSession', (_sessionID, webSession) => {
console.log('Obtained new web session for', user.steamID!.getSteamID64());
cookies = webSession;
if (loggedOn && newRefreshToken) {
return saveAndResolve();
}
checkReadyAndSetTimeout();
});

user.on('refreshToken', (token) => {
console.log('Obtained new refresh token for', user.steamID!.getSteamID64());
newRefreshToken = token;
if (loggedOn && cookies.length > 0) {
return saveAndResolve();
Expand Down
21 changes: 17 additions & 4 deletions main/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,21 @@ const handler = {
create: (password: string) => invoke('config-create', password),
initialize: (password: string) => invoke('config-initialize', password),
},
openWindow: (url: string, external: boolean) => {
ipcRenderer.send('open-new-window', { url, external });
showAppDataDirectory: () => {
ipcRenderer.send('show-app-data-directory');
},
openSteamWindow: (url: string) => {
ipcRenderer.send('open-steam-window', { url });
openBrowser: {
github: () => {
ipcRenderer.send('open-browser-github');
}
},
openSteamWindow: {
community: () => {
ipcRenderer.send('open-steam-community');
},
tradeOffers: () => {
ipcRenderer.send('open-steam-tradeoffers');
},
},

addAuthenticator: (
Expand All @@ -43,6 +53,9 @@ const handler = {
getAuthCode: () => {
return invoke('get-auth-code');
},
exportAccountSecrets: (password: string) => {
return invoke('export-account-secrets', password);
},

showMaFileDialog: () => {
return invoke('show-mafile-dialog');
Expand Down
26 changes: 26 additions & 0 deletions main/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,30 @@ export function createEncryptedStore(password: string) {
*/
export function getStore() {
return store;
}

/**
* Verify password is correct for the encrypted store
* @param password The encryption password
* @returns boolean indicating if the password is correct
*/
export function verifyPassword(password: string) {
try {
const tempStore = new Store({
name: 'config',
encryptionKey: password,
});

// Verify the store is accessible by trying to read from it
const initialized = tempStore.get('initialized');
if (!initialized) {
// Corrupted config
return false;
}

return true;
} catch (error) {
console.error('Error verifying password:', error);
return false;
}
}
1 change: 1 addition & 0 deletions main/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export interface IpcHandlers {
'finalize-authenticator': (steamId: string, activationCode: string) => Promise<true>;
'login-again': (password: string) => Promise<void>;
'get-auth-code': () => Promise<string>;
'export-account-secrets': (password: string) => Promise<{ identitySecret: string; sharedSecret: string } | { error: string }>;
'show-mafile-dialog': () => Promise<string | null>;
'import-mafile': (filePath: string) => Promise<string>;
'get-confirmations': () => Promise<Confirmation[]>;
Expand Down
7 changes: 2 additions & 5 deletions renderer/components/AccountSelector/AccountList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Image from 'next/image';
import { useEffect, useState } from 'react';
import { useAccount } from '../../context/AccountContext';
import ReloadIcon from '../Icons/Reload';
import { ErrorMessage } from '../ErrorMessage';

export default function AccountList({ onSelect }: { onSelect: (accountId: string) => void }) {
const { accounts, isLoading, loadAccounts } = useAccount();
Expand Down Expand Up @@ -74,11 +75,7 @@ export default function AccountList({ onSelect }: { onSelect: (accountId: string
</div>
)}

{error && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-md">
{error}
</div>
)}
{error && (<ErrorMessage message={error} />)}

{/* Accounts List */}
<div className="space-y-2">
Expand Down
Loading
Loading