From f89b9150eb9761053edd19fa361ebdcdf9521e0e Mon Sep 17 00:00:00 2001 From: Robert Burnham Date: Sun, 16 Aug 2026 16:09:39 -0500 Subject: [PATCH 1/3] Give the main process one owner for session state (#662) Centralizes logging in/out to a single place, and cleans up some OAuth behaviors/logging concerns. --- lang/dev/lobby.json | 17 +- lang/en/lobby.json | 17 +- src/main/json/file-store.ts | 15 +- src/main/json/model/account.ts | 17 + src/main/main.ts | 5 +- src/main/model/user.ts | 11 + src/main/oauth2/oauth2.ts | 276 ++++++++------- src/main/services/account.service.ts | 108 +++--- src/main/services/auth.service.ts | 226 +++++++++++-- src/main/services/tachyon.service.ts | 22 +- src/main/tachyon/tachyon.handlers.ts | 3 +- src/main/typed-ipc.ts | 8 +- src/preload/preload.ts | 9 +- src/renderer/App.vue | 2 - src/renderer/assets/languages/cs.json | 17 +- src/renderer/assets/languages/de.json | 17 +- src/renderer/assets/languages/dev.json | 17 +- src/renderer/assets/languages/en.json | 17 +- src/renderer/assets/languages/fr.json | 17 +- src/renderer/assets/languages/ru.json | 17 +- src/renderer/assets/languages/zh.json | 17 +- src/renderer/components/navbar/Exit.vue | 9 +- .../components/navbar/ServerStatus.vue | 167 +++++++-- src/renderer/store/me.store.ts | 122 ++++--- src/renderer/store/tachyon.store.ts | 24 +- src/renderer/views/index.vue | 30 +- tests/unit/main/account-store.spec.ts | 197 +++++++++++ tests/unit/main/auth-session.spec.ts | 316 ++++++++++++++++++ tests/unit/main/file-store.spec.ts | 106 ++++++ .../unit/main/main-process-lifecycle.spec.ts | 6 +- tests/unit/main/oauth2-client.spec.ts | 235 +++++++++++++ tests/unit/renderer/auth-projection.spec.ts | 200 +++++++++++ tests/unit/renderer/connection-intent.spec.ts | 106 ++++++ tests/unit/renderer/server-status.spec.ts | 216 ++++++++++++ tests/unit/renderer/server-switch.spec.ts | 40 ++- .../unit/renderer/store-going-offline.spec.ts | 2 + .../shared/preload-api-context-bridge.spec.ts | 10 +- 37 files changed, 2319 insertions(+), 322 deletions(-) create mode 100644 tests/unit/main/account-store.spec.ts create mode 100644 tests/unit/main/auth-session.spec.ts create mode 100644 tests/unit/main/file-store.spec.ts create mode 100644 tests/unit/main/oauth2-client.spec.ts create mode 100644 tests/unit/renderer/auth-projection.spec.ts create mode 100644 tests/unit/renderer/connection-intent.spec.ts create mode 100644 tests/unit/renderer/server-status.spec.ts diff --git a/lang/dev/lobby.json b/lang/dev/lobby.json index b1e09c595..f853ca1f7 100644 --- a/lang/dev/lobby.json +++ b/lang/dev/lobby.json @@ -267,7 +267,22 @@ "playersOnline": "Plyreas Oinnle", "error": "Eorrr", "reconnecting": "Recotnnenicg...", - "offline": "Oniflfe" + "offline": "Oniflfe", + "changeStatus": "Canghe Satuts", + "statusOnline": "Oinnle", + "statusBusy": "Busy", + "statusBusyUnavailable": "The sveerr has no way to show tihs yet", + "goOffline": "Go Oniflfe", + "cancel": "Ceancl", + "connect": "Cnnocet", + "connectTitle": "Cnnocet to the sveerr?", + "connectBody": "You are sgneid in but not cntnoeecd. Cniotcneng bnirgs back paiters, leibbos and mnchaaiktmg.", + "disconnect": "Dsnncoicet", + "disconnectTitle": "Dsnncoicet form the sveerr?", + "disconnectBody": "You wlil laeve any party, lobby or mnchaaiktmg qeuue you are in. You stay sgneid in and can ccnneot agian wehenevr you like.", + "stopReconnecting": "Sotp trinyg", + "stopReconnectingTitle": "Sotp rionentcnecg?", + "stopReconnectingBody": "No fuethrr apmtttes wlil be made uitnl you ccnneot agian yreoulsf." }, "messages": { "message": "Msaesge", diff --git a/lang/en/lobby.json b/lang/en/lobby.json index 26f03b38d..20f59f440 100644 --- a/lang/en/lobby.json +++ b/lang/en/lobby.json @@ -268,7 +268,22 @@ "playersOnline": "Players Online", "error": "Error", "reconnecting": "Reconnecting...", - "offline": "Offline" + "offline": "Offline", + "changeStatus": "Change Status", + "statusOnline": "Online", + "statusBusy": "Busy", + "statusBusyUnavailable": "The server has no way to show this yet", + "goOffline": "Go Offline", + "cancel": "Cancel", + "connect": "Connect", + "connectTitle": "Connect to the server?", + "connectBody": "You are signed in but not connected. Connecting brings back parties, lobbies and matchmaking.", + "disconnect": "Disconnect", + "disconnectTitle": "Disconnect from the server?", + "disconnectBody": "You will leave any party, lobby or matchmaking queue you are in. You stay signed in and can connect again whenever you like.", + "stopReconnecting": "Stop trying", + "stopReconnectingTitle": "Stop reconnecting?", + "stopReconnectingBody": "No further attempts will be made until you connect again yourself." }, "messages": { "message": "Message", diff --git a/src/main/json/file-store.ts b/src/main/json/file-store.ts index b4ef45d39..a146b7e06 100644 --- a/src/main/json/file-store.ts +++ b/src/main/json/file-store.ts @@ -19,6 +19,8 @@ export class FileStore { protected readonly ajv: Ajv; protected readonly validator: ValidateFunction>; + private writeQueue: Promise = Promise.resolve(); + constructor(filePath: string, schema: T, defaultModel?: Static) { this.filePath = filePath; this.schema = schema; @@ -69,7 +71,18 @@ export class FileStore { } } + // Writes are chained and go through a temp file, so overlapping saves can't + // interleave and a crash mid-write can't truncate the existing file. protected async write() { - await fs.promises.writeFile(this.filePath, JSON.stringify(this.model, null, 4)); + const write = this.writeQueue.catch(() => {}).then(() => this.writeModel()); + this.writeQueue = write.catch(() => {}); + + return write; + } + + private async writeModel() { + const tempPath = `${this.filePath}.tmp`; + await fs.promises.writeFile(tempPath, JSON.stringify(this.model, null, 4)); + await fs.promises.rename(tempPath, this.filePath); } } diff --git a/src/main/json/model/account.ts b/src/main/json/model/account.ts index de9b470a3..893cfd6f9 100644 --- a/src/main/json/model/account.ts +++ b/src/main/json/model/account.ts @@ -7,4 +7,21 @@ import { Type } from "@sinclair/typebox"; export const accountSchema = Type.Object({ token: Type.String({ default: "" }), refreshToken: Type.String({ default: "" }), + // Access tokens are opaque to us, so their lifetime has to be recorded here + // rather than read back out of the token. + expiresAt: Type.Number({ default: 0 }), + // Deliberately has no default: absent means the file predates this field and + // we have to work out for ourselves whether the values are encrypted. + encrypted: Type.Optional(Type.Boolean()), + // Who the stored credentials belong to. Kept beside them so the two can't + // drift apart, and so the name is available before any socket exists. Only + // the server can tell us this, so it lands here when user/self arrives. + identity: Type.Optional( + Type.Object({ + userId: Type.String(), + username: Type.String(), + displayName: Type.String(), + countryCode: Type.String({ default: "" }), + }) + ), }); diff --git a/src/main/main.ts b/src/main/main.ts index 9c7d0ee1c..f8d5647de 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -12,7 +12,6 @@ import netFromNode from "node:net"; import { createWindow } from "@main/main-window"; import { settingsService } from "./services/settings.service"; import { infoService } from "./services/info.service"; -import { accountService } from "./services/account.service"; import { logService } from "@main/services/log.service"; import engineService from "./services/engine.service"; import mapsService from "./services/maps.service"; @@ -130,7 +129,7 @@ app.whenReady().then(async () => { setAssetsPath(savedAssetsPath); } await engineService.init(); - await Promise.all([accountService.init(), replaysService.init(), gameService.init(), mapsService.init(), autoUpdaterService.init()]); + await Promise.all([authService.init(), replaysService.init(), gameService.init(), mapsService.init(), autoUpdaterService.init()]); const mainWindow = createWindow(); const webContents = typedWebContents(mainWindow.webContents); @@ -138,7 +137,7 @@ app.whenReady().then(async () => { logService.registerIpcHandlers(); infoService.registerIpcHandlers(); settingsService.registerIpcHandlers(); - authService.registerIpcHandlers(); + authService.registerIpcHandlers(webContents); tachyonService.registerIpcHandlers(webContents); replaysService.registerIpcHandlers(webContents); engineService.registerIpcHandlers(); diff --git a/src/main/model/user.ts b/src/main/model/user.ts index 0ce8246c6..fe5e5e9ad 100644 --- a/src/main/model/user.ts +++ b/src/main/model/user.ts @@ -2,6 +2,17 @@ // // SPDX-License-Identifier: MIT +// What is kept beside the credentials so the signed in account has a name before +// anything has connected. Deliberately not derived from User: it is a stored +// shape, and following changes to the live model would silently reinterpret what +// is already on disk. +export interface StoredIdentity { + userId: string; + username: string; + displayName: string; + countryCode: string; +} + export type User = { userId: string; username: string; diff --git a/src/main/oauth2/oauth2.ts b/src/main/oauth2/oauth2.ts index cb054cd3b..9d0271ac4 100644 --- a/src/main/oauth2/oauth2.ts +++ b/src/main/oauth2/oauth2.ts @@ -5,42 +5,84 @@ import { OAUTH_CLIENT_ID, OAUTH_SCOPE, getOAuthAuthorizationServerURL, getOAuthWellKnownURL } from "@main/config/server"; import { generatePKCE } from "@main/oauth2/pkce"; import RedirectHandler from "@main/oauth2/redirect-handler"; -import { accountService } from "@main/services/account.service"; import { logger } from "@main/utils/logger"; import { shell } from "electron"; -import { stringify } from "node:querystring"; -const log = logger("oauth2-utils"); +const log = logger("oauth2"); -interface TokenResponse { +export interface TokenResponse { token: string; refreshToken: string; expiresIn: number; } +// Carries enough detail for the session owner to decide between retrying and +// dropping the stored credentials. Everything here is safe to log. +export type TokenErrorKind = "network" | "server" | "invalid_grant" | "protocol"; + +export class TokenRequestError extends Error { + readonly kind: TokenErrorKind; + + constructor(kind: TokenErrorKind, message: string) { + super(message); + this.name = "TokenRequestError"; + this.kind = kind; + } +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isOnAuthorizationServer(url: string): boolean { + try { + return new URL(url).origin === getOAuthAuthorizationServerURL(); + } catch { + return false; + } +} + +async function request(url: string, init?: RequestInit): Promise { + try { + return await fetch(url, init); + } catch (error) { + throw new TokenRequestError("network", `Could not reach ${url}: ${describeError(error)}`); + } +} + +async function readJson(response: Response): Promise { + try { + return await response.json(); + } catch { + throw new TokenRequestError("protocol", "Response body is not valid JSON"); + } +} + //TODO cache this response according to HTTP cache headers returned from server export async function fetchAuthorizationServerMetadata(): Promise<{ authorizationEndpoint: string; tokenEndpoint: string; }> { - const response = await fetch(getOAuthWellKnownURL()); - if (response.status !== 200) { - const error = `Failed to fetch OAuth2 authorization server metadata: ${response.status} ${response.statusText}`; - log.error(error); - throw new Error(error); + const response = await request(getOAuthWellKnownURL()); + if (!response.ok) { + throw new TokenRequestError(response.status >= 500 ? "server" : "protocol", `Failed to fetch OAuth2 authorization server metadata: ${response.status} ${response.statusText}`); } - const body = await response.json(); + + const body = (await readJson(response)) as Record; const { authorization_endpoint, token_endpoint, issuer } = body; - if (!authorization_endpoint || !token_endpoint || !issuer) { - const error = "Invalid OAuth2 authorization server metadata"; - log.error(`${error}: ${JSON.stringify(body)}`); - throw new Error(error); + + if (typeof authorization_endpoint !== "string" || typeof token_endpoint !== "string" || typeof issuer !== "string") { + throw new TokenRequestError("protocol", `Invalid OAuth2 authorization server metadata: ${JSON.stringify(body)}`); } if (issuer !== getOAuthAuthorizationServerURL()) { - const error = `Invalid OAuth2 issuer: ${issuer} does not match expected ${getOAuthAuthorizationServerURL()}`; - log.error(error); - throw new Error(error); + throw new TokenRequestError("protocol", `Invalid OAuth2 issuer: ${issuer} does not match expected ${getOAuthAuthorizationServerURL()}`); + } + + // The metadata document decides where we send the user and where we post + // secrets, so neither endpoint may point off the authorization server. + if (!isOnAuthorizationServer(authorization_endpoint) || !isOnAuthorizationServer(token_endpoint)) { + throw new TokenRequestError("protocol", "OAuth2 endpoints do not belong to the authorization server"); } return { @@ -52,138 +94,132 @@ export async function fetchAuthorizationServerMetadata(): Promise<{ // Careful with shell.openExternal. https://benjamin-altpeter.de/shell-openexternal-dangers/ function openInBrowser(url: string) { if (!["https:", "http:"].includes(new URL(url).protocol)) return; - // Additional checks to prevent opening arbitrary URLs - if (!url.startsWith(getOAuthAuthorizationServerURL())) return; + if (!isOnAuthorizationServer(url)) return; shell.openExternal(url); } -function createUrlWithQuerystring(baseUrl: string, params: Record): string { - const queryString = stringify(params); - return `${baseUrl}?${queryString}`; +async function requestToken(tokenEndpoint: string, params: Record): Promise { + // RFC 6749 4.1.3: these go in a form-encoded body. In the query string they + // end up in the server's access logs and in ours. + const response = await request(tokenEndpoint, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(params), + }); + + if (!response.ok) throw await tokenError(response); + + return readJson(response); +} + +async function tokenError(response: Response): Promise { + const body = await response.text().catch(() => ""); + let code: unknown; + let description: unknown; + + try { + const parsed = JSON.parse(body); + code = parsed?.error; + description = parsed?.error_description; + } catch { + // Not every failure comes back as the JSON the spec asks for. + } + + const detail = [code, description].filter(Boolean).join(": ") || response.statusText; + + if (response.status >= 500) { + return new TokenRequestError("server", `Token endpoint failed (${response.status}): ${detail}`); + } + if (code === "invalid_grant") { + return new TokenRequestError("invalid_grant", `Token rejected: ${detail}`); + } + + return new TokenRequestError("protocol", `Token request rejected (${response.status}): ${detail}`); +} + +function parseTokenResponse(body: unknown, currentRefreshToken?: string): TokenResponse { + const { access_token, refresh_token, expires_in } = (body ?? {}) as Record; + + if (typeof access_token !== "string" || !access_token) { + throw new TokenRequestError("protocol", "Token response has no access_token"); + } + + if (typeof expires_in !== "number" || !Number.isFinite(expires_in) || expires_in <= 0) { + throw new TokenRequestError("protocol", `Token response has an unusable expires_in: ${String(expires_in)}`); + } + + const refreshToken = typeof refresh_token === "string" && refresh_token ? refresh_token : currentRefreshToken; + if (!refreshToken) { + throw new TokenRequestError("protocol", "Token response has no refresh_token"); + } + + return { + token: access_token, + refreshToken, + expiresIn: expires_in, + }; } export async function authenticate(): Promise { const { authorizationEndpoint, tokenEndpoint } = await fetchAuthorizationServerMetadata(); const [code_verifier, code_challenge] = generatePKCE(); const redirectHandler = new RedirectHandler(); + try { const redirect_uri = await redirectHandler.start(); + // TODO set state parameter to prevent CSRF attacks // https://www.rfc-editor.org/rfc/rfc6749#section-4.1.1 - const url = createUrlWithQuerystring(authorizationEndpoint, { + const authorizationUrl = new URL(authorizationEndpoint); + authorizationUrl.search = new URLSearchParams({ client_id: OAUTH_CLIENT_ID, scope: OAUTH_SCOPE, response_type: "code", redirect_uri, code_challenge, code_challenge_method: "S256", - }); - openInBrowser(url); + }).toString(); + + openInBrowser(authorizationUrl.toString()); + const callbackUrl = await redirectHandler.waitForCallback(); - log.debug(`Received callback URL: ${callbackUrl}`); const code = callbackUrl.searchParams.get("code"); - if (!code) throw new Error("Call back URL code is not specified"); - - log.debug(`Received OAuth2 code: ${code}`); - const tokenUrl = createUrlWithQuerystring(tokenEndpoint, { - grant_type: "authorization_code", - client_id: OAUTH_CLIENT_ID, - scope: OAUTH_SCOPE, - code, - code_verifier, - redirect_uri, - }); - const tokenResponse = await fetch(tokenUrl, { - method: "POST", - }); - if (tokenResponse.status !== 200) { - const responseText = await tokenResponse.text(); - const error = `Failed to fetch OAuth2 token: ${tokenResponse.status} ${tokenResponse.statusText} ${responseText}`; - log.error(error); - throw new Error(error); - } - // Refresh token is mandatory for this app to work - const body = await tokenResponse.json(); - const { access_token, refresh_token, expires_in } = body; - if (!access_token || !refresh_token) { - const error = "Invalid OAuth2 token response"; - log.error(`${error}: ${JSON.stringify(body)}`); - throw new Error(error); + if (!code) { + const denied = callbackUrl.searchParams.get("error"); + throw new TokenRequestError("protocol", denied ? `Authorization failed: ${denied}` : "Callback URL has no authorization code"); } - return { - token: access_token, - refreshToken: refresh_token, - expiresIn: expires_in, - }; - } catch (error) { - log.error("Error during login"); - throw error; + + log.debug("Exchanging authorization code for tokens"); + + return parseTokenResponse( + await requestToken(tokenEndpoint, { + grant_type: "authorization_code", + client_id: OAUTH_CLIENT_ID, + scope: OAUTH_SCOPE, + code, + code_verifier, + redirect_uri, + }) + ); } finally { redirectHandler.close(); } } -export async function renewAccessToken(): Promise { - log.debug("Renewing access token"); +export async function renewAccessToken(refreshToken: string): Promise { const { tokenEndpoint } = await fetchAuthorizationServerMetadata(); - const refreshToken = await accountService.getRefreshToken(); - if (!refreshToken) { - const error = "No refresh token available"; - log.error(error); - stopTokenRenewer(); - throw new Error(error); - } - const tokenUrl = createUrlWithQuerystring(tokenEndpoint, { - grant_type: "refresh_token", - client_id: OAUTH_CLIENT_ID, - scope: OAUTH_SCOPE, - refresh_token: refreshToken, - }); - const tokenResponse = await fetch(tokenUrl, { - method: "POST", - }); - if (tokenResponse.status !== 200) { - const error = `Failed to renew token, wiping: ${tokenResponse.status} ${tokenResponse.statusText}`; - const responseText = await tokenResponse.text(); - log.error(`${error}: ${responseText}`); - accountService.wipe(); - if (tokenResponse.status === 400) { - log.error(`400 Bad request: ${tokenUrl}`); - } - throw new Error(error); - } - const body = await tokenResponse.json(); - const { access_token, refresh_token, expires_in } = body; - if (!access_token || !expires_in) { - const error = "Invalid OAuth2 token response"; - log.error(`${error}: ${JSON.stringify(body)}`); - throw new Error(error); - } - if (!refresh_token) { - log.info("No new refresh token in token response, keeping the current one."); - } - log.debug("Renewed access token"); - return { - token: access_token, - refreshToken: refresh_token ? refresh_token : refreshToken, - expiresIn: expires_in, - }; -} -let tokenRenewer; -export function startTokenRenewer(interval: number) { - stopTokenRenewer(); - tokenRenewer = setInterval(() => { - renewAccessToken().then((value) => { - accountService.saveRefreshToken(value.refreshToken); - accountService.saveToken(value.token); - log.info("Saved new tokens."); - }); - }, interval); -} + log.debug("Renewing access token"); -export function stopTokenRenewer() { - if (!tokenRenewer) return; - clearInterval(tokenRenewer); + // A server is allowed to keep the current refresh token rather than rotate it. + return parseTokenResponse( + await requestToken(tokenEndpoint, { + grant_type: "refresh_token", + client_id: OAUTH_CLIENT_ID, + scope: OAUTH_SCOPE, + refresh_token: refreshToken, + }), + refreshToken + ); } diff --git a/src/main/services/account.service.ts b/src/main/services/account.service.ts index 3b89c2896..17699b44b 100644 --- a/src/main/services/account.service.ts +++ b/src/main/services/account.service.ts @@ -8,79 +8,105 @@ import { accountSchema } from "@main/json/model/account"; import { logger } from "@main/utils/logger"; import { safeStorage } from "electron"; import path from "path"; +import type { StoredIdentity } from "@main/model/user"; const log = logger("account-service"); const accountStore = new FileStore(path.join(CONFIG_PATH, "account.json"), accountSchema); +export interface StoredTokens { + token: string; + refreshToken: string; + expiresAt: number; +} + async function init() { await accountStore.init(); } -async function saveToken(token: string) { - if (safeStorage.isEncryptionAvailable()) { - token = safeStorage.encryptString(token).toString("base64"); - } else { - log.warn("Encryption is not available, storing token in plain text"); - } - await accountStore.update({ token }); -} +// Whether the values on disk are encrypted is recorded alongside them, because +// safeStorage can stop being available between one run and the next. +function readStoredValue(value: string, label: string): string { + if (!value) return ""; + + const { encrypted } = accountStore.model; + + if (encrypted === false) return value; -async function saveRefreshToken(refreshToken: string) { - if (safeStorage.isEncryptionAvailable()) { - refreshToken = safeStorage.encryptString(refreshToken).toString("base64"); - } else { - log.warn("Encryption is not available, storing refreshToken in plain text"); + if (!safeStorage.isEncryptionAvailable()) { + log.error(`Cannot read stored ${label}, encryption is not available`); + return ""; } - await accountStore.update({ refreshToken }); -} -async function getToken() { - const { token } = await accountStore.model; - if (safeStorage.isEncryptionAvailable() && token) { - try { - return safeStorage.decryptString(Buffer.from(token, "base64")); - } catch (e) { - log.error("Failed to decrypt token, wiping account data", e); - await wipe(); - } + try { + return safeStorage.decryptString(Buffer.from(value, "base64")); + } catch (e) { + // A file written before the flag existed may hold a plain value, from a + // run where encryption wasn't available. + if (encrypted === undefined) return value; + + log.error(`Failed to decrypt stored ${label}`, e); + return ""; } - return token; } -async function getRefreshToken() { - const { refreshToken } = await accountStore.model; - if (safeStorage.isEncryptionAvailable() && refreshToken) { - try { - return safeStorage.decryptString(Buffer.from(refreshToken, "base64")); - } catch (e) { - log.error("Failed to decrypt refreshToken, wiping account data", e); - await wipe(); - } +// The server drops the old refresh token as soon as a renewal succeeds, so the +// pair is written in a single update. Half-applied state locks the user out. +async function saveTokens({ token, refreshToken, expiresAt }: StoredTokens) { + const encrypted = safeStorage.isEncryptionAvailable(); + if (!encrypted) { + log.warn("Encryption is not available, storing tokens in plain text"); } - return refreshToken; -} -async function forgetToken() { + const encode = (value: string) => (encrypted ? safeStorage.encryptString(value).toString("base64") : value); + await accountStore.update({ - token: "", + token: encode(token), + refreshToken: encode(refreshToken), + expiresAt, + encrypted, }); } +function getToken(): string { + return readStoredValue(accountStore.model.token, "token"); +} + +function getRefreshToken(): string { + return readStoredValue(accountStore.model.refreshToken, "refresh token"); +} + +function getExpiresAt(): number { + return accountStore.model.expiresAt; +} + +async function saveIdentity(identity: StoredIdentity) { + await accountStore.update({ identity }); +} + +function getIdentity(): StoredIdentity | undefined { + return accountStore.model.identity; +} + +// Signing out takes the identity with the credentials, so the next sign in +// doesn't start by showing whoever used the client last. async function wipe() { await accountStore.update({ token: "", refreshToken: "", + expiresAt: 0, + identity: undefined, }); } export type Account = typeof accountStore.model; export const accountService = { init, - saveToken, - saveRefreshToken, + saveTokens, getToken, getRefreshToken, + getExpiresAt, + saveIdentity, + getIdentity, wipe, - forgetToken, }; diff --git a/src/main/services/auth.service.ts b/src/main/services/auth.service.ts index 628fdc8e0..4660c23fb 100644 --- a/src/main/services/auth.service.ts +++ b/src/main/services/auth.service.ts @@ -2,41 +2,217 @@ // // SPDX-License-Identifier: MIT -import { authenticate, renewAccessToken, startTokenRenewer, stopTokenRenewer } from "@main/oauth2/oauth2"; +import { authenticate, renewAccessToken, TokenRequestError, TokenResponse } from "@main/oauth2/oauth2"; +import { Signal } from "$/jaz-ts-utils/signal"; +import type { StoredIdentity } from "@main/model/user"; import { accountService } from "@main/services/account.service"; import { logger } from "@main/utils/logger"; -import { ipcMain } from "@main/typed-ipc"; +import { ipcMain, type BarIpcWebContents } from "@main/typed-ipc"; const log = logger("auth-service"); -function registerIpcHandlers() { - ipcMain.handle("auth:login", async () => { - try { - const existingRefreshToken = await accountService.getRefreshToken(); - const { token, refreshToken, expiresIn } = existingRefreshToken ? await renewAccessToken() : await authenticate(); - await accountService.saveToken(token); - await accountService.saveRefreshToken(refreshToken); - startTokenRenewer((expiresIn / 2) * 1000); - } catch (error) { - log.error("Error during login"); - accountService.wipe(); - throw error; - } +export type AuthLossReason = "signed-out" | "expired" | "error"; + +export interface AuthState { + authenticated: boolean; + reason?: AuthLossReason; +} + +const RENEW_AT_FRACTION_OF_LIFETIME = 0.5; +const TRANSIENT_RETRY_MS = 60 * 1000; + +let renewalTimer: NodeJS.Timeout | undefined; +let renewalInFlight: Promise | undefined; +let authenticated = false; + +// Raised whenever the session starts or ends. Kept apart from the IPC wiring so +// that telling the renderer is one subscriber rather than the only way anything +// hears about it. +const onChanged = new Signal(); + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function kindOf(error: unknown): TokenRequestError["kind"] { + return error instanceof TokenRequestError ? error.kind : "protocol"; +} + +function setAuthenticated(next: boolean, reason?: AuthLossReason) { + if (authenticated === next) return; + + authenticated = next; + onChanged.dispatch({ authenticated, reason }); +} + +function stopRenewal() { + if (!renewalTimer) return; + + clearTimeout(renewalTimer); + renewalTimer = undefined; +} + +function scheduleRenewal(delayMs: number) { + stopRenewal(); + renewalTimer = setTimeout(() => void renew(), delayMs); +} + +async function storeTokens({ token, refreshToken, expiresIn }: TokenResponse) { + await accountService.saveTokens({ + token, + refreshToken, + expiresAt: Date.now() + expiresIn * 1000, }); - ipcMain.handle("auth:logout", async () => { - stopTokenRenewer(); - await accountService.forgetToken(); + + scheduleRenewal(expiresIn * 1000 * RENEW_AT_FRACTION_OF_LIFETIME); +} + +async function renew(): Promise { + if (renewalInFlight) return renewalInFlight; + + renewalInFlight = renewOnce().finally(() => { + renewalInFlight = undefined; }); - ipcMain.handle("auth:wipe", async () => { - stopTokenRenewer(); + + return renewalInFlight; +} + +async function renewOnce(): Promise { + const refreshToken = accountService.getRefreshToken(); + if (!refreshToken) { + stopRenewal(); + setAuthenticated(false, "expired"); + return; + } + + try { + await storeTokens(await renewAccessToken(refreshToken)); + setAuthenticated(true); + log.info("Renewed access token"); + } catch (error) { + await onRenewalFailed(error); + } +} + +async function onRenewalFailed(error: unknown) { + const kind = kindOf(error); + log.error(`Token renewal failed (${kind}): ${describeError(error)}`); + + if (kind === "invalid_grant") { + stopRenewal(); await accountService.wipe(); - }); - ipcMain.handle("auth:hasCredentials", async () => { - const refreshToken = await accountService.getRefreshToken(); - return !!refreshToken; - }); + setAuthenticated(false, "expired"); + return; + } + + // Renewal runs at half the token's lifetime, so there is usually still a + // working access token. Keep the session and retry until it really expires. + if (accountService.getExpiresAt() > Date.now()) { + scheduleRenewal(TRANSIENT_RETRY_MS); + return; + } + + stopRenewal(); + setAuthenticated(false, "error"); +} + +async function acquireTokens(interactive: boolean): Promise { + const refreshToken = accountService.getRefreshToken(); + if (!refreshToken) { + if (!interactive) throw new TokenRequestError("invalid_grant", "No stored credentials"); + + return authenticate(); + } + + try { + return await renewAccessToken(refreshToken); + } catch (error) { + if (kindOf(error) !== "invalid_grant") throw error; + + await accountService.wipe(); + if (!interactive) throw error; + + // Without this, the first sign in after the server drops our refresh + // token always fails and the user has to click again. + log.info("Stored refresh token was rejected, falling back to interactive sign in"); + + return authenticate(); + } +} + +async function signIn(interactive: boolean) { + try { + await storeTokens(await acquireTokens(interactive)); + setAuthenticated(true); + log.info("Signed in"); + } catch (error) { + const kind = kindOf(error); + log.error(`Sign in failed (${kind}): ${describeError(error)}`); + + stopRenewal(); + setAuthenticated(false, kind === "invalid_grant" ? "expired" : "error"); + + throw error; + } +} + +// Signing out destroys the stored credentials outright. Keeping the refresh +// token behind the user's back is what made a separate "change account" action +// necessary, and whether to sign in on launch is a setting of its own. +async function signOut() { + stopRenewal(); + await accountService.wipe(); + setAuthenticated(false, "signed-out"); +} + +// Callers get a token that is good right now, or an empty string. Timers don't +// fire while the machine is asleep, so a scheduled renewal is not a guarantee. +async function getAccessToken(): Promise { + if (hasUsableToken()) return accountService.getToken(); + + await renew(); + + return hasUsableToken() ? accountService.getToken() : ""; +} + +function hasUsableToken(): boolean { + return !!accountService.getToken() && accountService.getExpiresAt() > Date.now(); +} + +function state(): AuthState { + return { authenticated }; +} + +// Identity arrives over the socket rather than from the token exchange, so it +// reaches us separately from everything else the session holds. Failing to store +// it costs the name shown before the next connection and nothing more, so it is +// not worth handing back to whoever passed it in. +async function setIdentity(identity: StoredIdentity) { + try { + await accountService.saveIdentity(identity); + } catch (error) { + log.error(`Could not store the account identity: ${describeError(error)}`); + } +} + +async function init() { + await accountService.init(); +} + +function registerIpcHandlers(webContents: BarIpcWebContents) { + onChanged.add((next) => webContents.send("auth:changed", next)); + + ipcMain.handle("auth:login", (_event, interactive) => signIn(interactive ?? true)); + ipcMain.handle("auth:logout", () => signOut()); + ipcMain.handle("auth:hasCredentials", () => !!accountService.getRefreshToken()); + ipcMain.handle("auth:state", () => state()); + ipcMain.handle("auth:identity", () => accountService.getIdentity()); } export const authService = { + init, registerIpcHandlers, + getAccessToken, + setIdentity, + onChanged, }; diff --git a/src/main/services/tachyon.service.ts b/src/main/services/tachyon.service.ts index e6aa78501..794dd4d21 100644 --- a/src/main/services/tachyon.service.ts +++ b/src/main/services/tachyon.service.ts @@ -2,15 +2,30 @@ // // SPDX-License-Identifier: MIT -import { accountService } from "@main/services/account.service"; +import { authService } from "@main/services/auth.service"; import { createTachyonRequestHandlers } from "@main/tachyon/tachyon.handlers"; import { TachyonClient } from "@main/tachyon/tachyon-client"; import { logger } from "@main/utils/logger"; import { ipcMain } from "electron"; +import { TachyonEvent } from "tachyon-protocol"; import { BarIpcWebContents } from "@main/typed-ipc"; const log = logger("tachyon-service"); +// user/self is the only thing that tells us who we are, and it only ever arrives +// over the socket. Keeping it beside the credentials means the name is there on +// the next launch, before anything has connected. +function rememberIdentity(event: TachyonEvent) { + if (event.commandId !== "user/self") return; + + try { + const { userId, username, displayName, countryCode } = event.data.user; + void authService.setIdentity({ userId, username, displayName, countryCode: countryCode ?? "" }); + } catch (error) { + log.error("Could not read the identity out of user/self", error); + } +} + function registerIpcHandlers(webContents: BarIpcWebContents) { const requestHandlers = createTachyonRequestHandlers(webContents); const tachyonClient = new TachyonClient(requestHandlers); @@ -27,7 +42,10 @@ function registerIpcHandlers(webContents: BarIpcWebContents) { tachyonClient.onEvent.add((event) => { log.info(`Received event: ${JSON.stringify(event)}`); + // Forwarded first, so nothing that goes wrong while storing the identity + // can stop the renderer seeing the event. webContents.send("tachyon:event", event); + rememberIdentity(event); }); ipcMain.handle("tachyon:isConnected", () => { @@ -36,7 +54,7 @@ function registerIpcHandlers(webContents: BarIpcWebContents) { ipcMain.handle("tachyon:connect", async () => { if (!tachyonClient.isConnected()) { - const token = await accountService.getToken(); + const token = await authService.getAccessToken(); if (!token) { throw new Error("Not authenticated"); } diff --git a/src/main/tachyon/tachyon.handlers.ts b/src/main/tachyon/tachyon.handlers.ts index f9a425aec..a29a50240 100644 --- a/src/main/tachyon/tachyon.handlers.ts +++ b/src/main/tachyon/tachyon.handlers.ts @@ -41,7 +41,8 @@ function createBattleHandlers(webContents: BarIpcWebContents) { ...defineTachyonRequestHandler( "battle/start", createTypedTachyonRequestHandler<"battle/start">()(async (data: BattleStartRequestData) => { - log.info(`Received battle start request: ${JSON.stringify(data)}`); + // data carries the join password, so it is summarised rather than dumped. + log.info(`Received battle start request for ${data.ip}:${data.port}`); const itemsRequired = !gameContentAPI.isVersionInstalled(data.game.springName) || !mapContentAPI.isVersionInstalled(data.map.springName) || !engineContentAPI.isVersionInstalled(data.engine.version); if (itemsRequired) { diff --git a/src/main/typed-ipc.ts b/src/main/typed-ipc.ts index 540550b57..556920bf1 100644 --- a/src/main/typed-ipc.ts +++ b/src/main/typed-ipc.ts @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: MIT +import type { AuthState } from "@main/services/auth.service"; +import type { StoredIdentity } from "@main/model/user"; import type { BattleWithMetadata } from "@main/game/battle/battle-types"; import type { BattleStartRequestData } from "tachyon-protocol/types"; import type { DownloadInfo } from "@main/content/downloads"; @@ -57,13 +59,15 @@ export type IPCEvents = { "tachyon:connected": () => void; "tachyon:disconnected": () => void; "tachyon:event": (event: TachyonEvent) => void; + "auth:changed": (state: AuthState) => void; }; export type IPCCommands = { "auth:hasCredentials": () => boolean; - "auth:login": () => void; + "auth:identity": () => StoredIdentity | undefined; + "auth:login": (interactive?: boolean) => void; "auth:logout": () => void; - "auth:wipe": () => void; + "auth:state": () => AuthState; "autoUpdater:checkForUpdates": () => boolean; "autoUpdater:downloadUpdate": () => void; "autoUpdater:installUpdates": () => void; diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 84530060d..1298a9b08 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -17,6 +17,8 @@ import { GetCommandData, GetCommandIds, GetCommands } from "tachyon-protocol"; import type { BattleStartRequestData } from "tachyon-protocol/types"; import { MultiplayerLaunchSettings } from "@main/game/game"; import { logLevels } from "@main/services/log.service"; +import { AuthState } from "@main/services/auth.service"; +import { StoredIdentity } from "@main/model/user"; const logApi = { purge: (): Promise => ipcRenderer.invoke("log:purge"), @@ -82,10 +84,13 @@ export type SettingsApi = typeof settingsApi; contextBridge.exposeInMainWorld("settings", settingsApi); const authApi = { - login: (): Promise => ipcRenderer.invoke("auth:login"), + login: (interactive?: boolean): Promise => ipcRenderer.invoke("auth:login", interactive), logout: (): Promise => ipcRenderer.invoke("auth:logout"), - wipe: (): Promise => ipcRenderer.invoke("auth:wipe"), hasCredentials: (): Promise => ipcRenderer.invoke("auth:hasCredentials"), + getState: (): Promise => ipcRenderer.invoke("auth:state"), + getIdentity: (): Promise => ipcRenderer.invoke("auth:identity"), + + onChanged: (callback: (state: AuthState) => void) => ipcRenderer.on("auth:changed", (_event, state) => callback(state)), }; export type AuthApi = typeof authApi; contextBridge.exposeInMainWorld("auth", authApi); diff --git a/src/renderer/App.vue b/src/renderer/App.vue index fd5328c5f..52e916618 100644 --- a/src/renderer/App.vue +++ b/src/renderer/App.vue @@ -97,7 +97,6 @@ import { battleStore } from "@renderer/store/battle.store"; import FullscreenGameModeSelector from "@renderer/components/battle/FullscreenGameModeSelector.vue"; import { useGlobalKeybindings } from "@renderer/composables/useGlobalKeybindings"; import { me } from "@renderer/store/me.store"; -import { auth } from "@renderer/store/me.store"; import { useLogInConfirmation } from "@renderer/composables/useLogInConfirmation"; import { partyStore, PlayersPartyState } from "@renderer/store/party.store"; import accountGroup from "@iconify-icons/mdi/account-group"; @@ -197,7 +196,6 @@ function onInitialSetupDone() { // We do it here and not in index.vue to avoid flashing login page for user before // continuing to overview. if (!settingsStore.devMode) { - auth.playOffline(); router.push("/play"); } diff --git a/src/renderer/assets/languages/cs.json b/src/renderer/assets/languages/cs.json index aea20f419..393368701 100644 --- a/src/renderer/assets/languages/cs.json +++ b/src/renderer/assets/languages/cs.json @@ -2250,7 +2250,22 @@ "playersOnline": null, "error": null, "reconnecting": null, - "offline": null + "offline": null, + "changeStatus": null, + "statusOnline": null, + "statusBusy": null, + "statusBusyUnavailable": null, + "goOffline": null, + "cancel": null, + "connect": null, + "connectTitle": null, + "connectBody": null, + "disconnect": null, + "disconnectTitle": null, + "disconnectBody": null, + "stopReconnecting": null, + "stopReconnectingTitle": null, + "stopReconnectingBody": null }, "messages": { "message": null, diff --git a/src/renderer/assets/languages/de.json b/src/renderer/assets/languages/de.json index da51e06e4..5215224a1 100644 --- a/src/renderer/assets/languages/de.json +++ b/src/renderer/assets/languages/de.json @@ -2121,7 +2121,22 @@ "playersOnline": null, "error": null, "reconnecting": null, - "offline": null + "offline": null, + "changeStatus": null, + "statusOnline": null, + "statusBusy": null, + "statusBusyUnavailable": null, + "goOffline": null, + "cancel": null, + "connect": null, + "connectTitle": null, + "connectBody": null, + "disconnect": null, + "disconnectTitle": null, + "disconnectBody": null, + "stopReconnecting": null, + "stopReconnectingTitle": null, + "stopReconnectingBody": null }, "messages": { "message": null, diff --git a/src/renderer/assets/languages/dev.json b/src/renderer/assets/languages/dev.json index be7fb7859..a04ef68da 100644 --- a/src/renderer/assets/languages/dev.json +++ b/src/renderer/assets/languages/dev.json @@ -267,7 +267,22 @@ "playersOnline": "Plyreas Oinnle", "error": "Eorrr", "reconnecting": "Recotnnenicg...", - "offline": "Oniflfe" + "offline": "Oniflfe", + "changeStatus": "Canghe Satuts", + "statusOnline": "Oinnle", + "statusBusy": "Busy", + "statusBusyUnavailable": "The sveerr has no way to show tihs yet", + "goOffline": "Go Oniflfe", + "cancel": "Ceancl", + "connect": "Cnnocet", + "connectTitle": "Cnnocet to the sveerr?", + "connectBody": "You are sgneid in but not cntnoeecd. Cniotcneng bnirgs back paiters, leibbos and mnchaaiktmg.", + "disconnect": "Dsnncoicet", + "disconnectTitle": "Dsnncoicet form the sveerr?", + "disconnectBody": "You wlil laeve any party, lobby or mnchaaiktmg qeuue you are in. You stay sgneid in and can ccnneot agian wehenevr you like.", + "stopReconnecting": "Sotp trinyg", + "stopReconnectingTitle": "Sotp rionentcnecg?", + "stopReconnectingBody": "No fuethrr apmtttes wlil be made uitnl you ccnneot agian yreoulsf." }, "messages": { "message": "Msaesge", diff --git a/src/renderer/assets/languages/en.json b/src/renderer/assets/languages/en.json index 0d41d5e54..1f347870a 100644 --- a/src/renderer/assets/languages/en.json +++ b/src/renderer/assets/languages/en.json @@ -2098,7 +2098,22 @@ "playersOnline": "Players Online", "error": "Error", "reconnecting": "Reconnecting...", - "offline": "Offline" + "offline": "Offline", + "changeStatus": "Change Status", + "statusOnline": "Online", + "statusBusy": "Busy", + "statusBusyUnavailable": "The server has no way to show this yet", + "goOffline": "Go Offline", + "cancel": "Cancel", + "connect": "Connect", + "connectTitle": "Connect to the server?", + "connectBody": "You are signed in but not connected. Connecting brings back parties, lobbies and matchmaking.", + "disconnect": "Disconnect", + "disconnectTitle": "Disconnect from the server?", + "disconnectBody": "You will leave any party, lobby or matchmaking queue you are in. You stay signed in and can connect again whenever you like.", + "stopReconnecting": "Stop trying", + "stopReconnectingTitle": "Stop reconnecting?", + "stopReconnectingBody": "No further attempts will be made until you connect again yourself." }, "messages": { "message": "Message", diff --git a/src/renderer/assets/languages/fr.json b/src/renderer/assets/languages/fr.json index 3e5566f7f..0d6b7697d 100644 --- a/src/renderer/assets/languages/fr.json +++ b/src/renderer/assets/languages/fr.json @@ -4103,7 +4103,22 @@ "playersOnline": null, "error": null, "reconnecting": null, - "offline": null + "offline": null, + "changeStatus": null, + "statusOnline": null, + "statusBusy": null, + "statusBusyUnavailable": null, + "goOffline": null, + "cancel": null, + "connect": null, + "connectTitle": null, + "connectBody": null, + "disconnect": null, + "disconnectTitle": null, + "disconnectBody": null, + "stopReconnecting": null, + "stopReconnectingTitle": null, + "stopReconnectingBody": null }, "messages": { "message": null, diff --git a/src/renderer/assets/languages/ru.json b/src/renderer/assets/languages/ru.json index 6b630cfc4..b2c891ef6 100644 --- a/src/renderer/assets/languages/ru.json +++ b/src/renderer/assets/languages/ru.json @@ -4072,7 +4072,22 @@ "playersOnline": null, "error": null, "reconnecting": null, - "offline": null + "offline": null, + "changeStatus": null, + "statusOnline": null, + "statusBusy": null, + "statusBusyUnavailable": null, + "goOffline": null, + "cancel": null, + "connect": null, + "connectTitle": null, + "connectBody": null, + "disconnect": null, + "disconnectTitle": null, + "disconnectBody": null, + "stopReconnecting": null, + "stopReconnectingTitle": null, + "stopReconnectingBody": null }, "messages": { "message": null, diff --git a/src/renderer/assets/languages/zh.json b/src/renderer/assets/languages/zh.json index d00e6d3a8..80e3b3a3c 100644 --- a/src/renderer/assets/languages/zh.json +++ b/src/renderer/assets/languages/zh.json @@ -4214,7 +4214,22 @@ "playersOnline": null, "error": null, "reconnecting": null, - "offline": null + "offline": null, + "changeStatus": null, + "statusOnline": null, + "statusBusy": null, + "statusBusyUnavailable": null, + "goOffline": null, + "cancel": null, + "connect": null, + "connectTitle": null, + "connectBody": null, + "disconnect": null, + "disconnectTitle": null, + "disconnectBody": null, + "stopReconnecting": null, + "stopReconnectingTitle": null, + "stopReconnectingBody": null }, "messages": { "message": null, diff --git a/src/renderer/components/navbar/Exit.vue b/src/renderer/components/navbar/Exit.vue index 185a6eb69..7e189ab1c 100644 --- a/src/renderer/components/navbar/Exit.vue +++ b/src/renderer/components/navbar/Exit.vue @@ -42,12 +42,15 @@ async function login() { modal.value?.close(); } +// Closed up front: signing out flips the buttons this menu is showing, so +// leaving it open means watching Logout turn into Login before it disappears. +// Signing in on launch is a setting of its own, so signing out leaves it alone. async function logout() { + modal.value?.close(); + party.onLogout(); - auth.logout(); - settingsStore.loginAutomatically = false; + await auth.logout(); await router.push("/"); - modal.value?.close(); } async function quitToDesktop() { diff --git a/src/renderer/components/navbar/ServerStatus.vue b/src/renderer/components/navbar/ServerStatus.vue index 2fc050d6b..bb1c363ac 100644 --- a/src/renderer/components/navbar/ServerStatus.vue +++ b/src/renderer/components/navbar/ServerStatus.vue @@ -5,20 +5,54 @@ SPDX-License-Identifier: MIT -->