From 303ec08d82cac7e942bfd3daf1be84d57d88c9cb Mon Sep 17 00:00:00 2001 From: oleksandr-shysh Date: Fri, 13 Jun 2025 12:45:49 +0300 Subject: [PATCH] Handle Discord Login in capacitor apps --- package-lock.json | 38 ++++++++++++ package.json | 4 ++ src/client/HostLobbyModal.ts | 4 +- src/client/JoinPrivateLobbyModal.ts | 4 +- src/client/Main.ts | 10 +++- src/client/discord-redirect.html | 32 +++++++++++ src/client/jwt.ts | 89 +++++++++++++++++++++++++++-- src/server/cors.ts | 28 ++++++--- webpack.config.js | 18 ++++++ 9 files changed, 209 insertions(+), 18 deletions(-) create mode 100644 src/client/discord-redirect.html diff --git a/package-lock.json b/package-lock.json index cad573e57..a67f9d556 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "dependencies": { "@aws-sdk/client-redshift-data": "^3.758.0", "@aws-sdk/client-s3": "^3.758.0", + "@capacitor/app": "^7.0.1", + "@capacitor/browser": "^7.0.1", + "@capacitor/core": "^7.3.0", "@datastructures-js/priority-queue": "^6.3.1", "@google-cloud/secret-manager": "^5.6.0", "@opentelemetry/api": "^1.9.0", @@ -101,6 +104,7 @@ "jest-environment-jsdom": "^30.0.0", "lint-staged": "^16.1.2", "mrmime": "^2.0.0", + "os-browserify": "^0.3.0", "postcss": "^8.5.1", "postcss-loader": "^8.1.1", "prettier": "^3.5.3", @@ -2879,6 +2883,33 @@ "dev": true, "license": "MIT" }, + "node_modules/@capacitor/app": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-7.0.1.tgz", + "integrity": "sha512-ArlVZAAla4MwQoKh26x2AaTDOBh5Vhp1VhMKR3RwqZSsZnazKTFGNrPbr9Ez5r1knnEDfApyjwp1uZnXK1WTYQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=7.0.0" + } + }, + "node_modules/@capacitor/browser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/browser/-/browser-7.0.1.tgz", + "integrity": "sha512-N6KEVLw2enTnourQzYJLvAkSds2Ed21zqsvHnSImrVDenzX8fUj032kMt4EdewmxfxiEwRa911BT1VOPBi0fEA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=7.0.0" + } + }, + "node_modules/@capacitor/core": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-7.3.0.tgz", + "integrity": "sha512-t/DdTyBchQ2eAZuCmAARlqQsrEm0WyeNwh5zeRuv+cR6gnAsw+86/EWvJ/em5dTnZyaqEy8vlmOMdWarrUbnuQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -19059,6 +19090,13 @@ "node": ">= 0.8.0" } }, + "node_modules/os-browserify": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", + "integrity": "sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==", + "dev": true, + "license": "MIT" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", diff --git a/package.json b/package.json index bd6af4d44..1a6c43044 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "jest-environment-jsdom": "^30.0.0", "lint-staged": "^16.1.2", "mrmime": "^2.0.0", + "os-browserify": "^0.3.0", "postcss": "^8.5.1", "postcss-loader": "^8.1.1", "prettier": "^3.5.3", @@ -82,6 +83,9 @@ "dependencies": { "@aws-sdk/client-redshift-data": "^3.758.0", "@aws-sdk/client-s3": "^3.758.0", + "@capacitor/app": "^7.0.1", + "@capacitor/browser": "^7.0.1", + "@capacitor/core": "^7.3.0", "@datastructures-js/priority-queue": "^6.3.1", "@google-cloud/secret-manager": "^5.6.0", "@opentelemetry/api": "^1.9.0", diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index b235cc55b..e0564a92e 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -592,6 +592,6 @@ export async function buildGameUrl( const config = await getServerConfigFromClient(); const apiPath = `/api/${path}/${gameID}`; - const baseUrl = process.env.API_BASE_URL || "/"; - return `${baseUrl}${config.workerPath(gameID)}${apiPath}`; + const baseUrl = process.env.API_BASE_URL || ""; + return `${baseUrl}/${config.workerPath(gameID)}${apiPath}`; } diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index 2e7ef76c5..e50d4b0d0 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -1,6 +1,7 @@ import { LitElement, html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; import { translateText } from "../client/Utils"; +import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { GameInfo, GameRecord } from "../core/Schemas"; import { generateID } from "../core/Util"; import "./components/baseComponents/Button"; @@ -171,7 +172,8 @@ export class JoinPrivateLobbyModal extends LitElement { } private async checkActiveLobby(lobbyId: string): Promise { - const url = await buildGameUrl(lobbyId, "game/exists"); + const config = await getServerConfigFromClient(); + const url = `${process.env.API_BASE_URL || ""}/${config.workerPath(lobbyId)}/api/game/${lobbyId}/exists`; const response = await fetch(url, { method: "GET", diff --git a/src/client/Main.ts b/src/client/Main.ts index d1f226918..8ec178ffa 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -30,7 +30,13 @@ import { NewsButton } from "./components/NewsButton"; import "./components/baseComponents/Button"; import { OButton } from "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; -import { discordLogin, getUserMe, isLoggedIn, logOut } from "./jwt"; +import { + discordLogin, + getUserMe, + initializeAuthListener, + isLoggedIn, + logOut, +} from "./jwt"; import "./styles.css"; declare global { @@ -77,6 +83,8 @@ class Client { constructor() {} initialize(): void { + initializeAuthListener(); + const gameVersion = document.getElementById( "game-version", ) as HTMLDivElement; diff --git a/src/client/discord-redirect.html b/src/client/discord-redirect.html new file mode 100644 index 000000000..55540a2ca --- /dev/null +++ b/src/client/discord-redirect.html @@ -0,0 +1,32 @@ + + + + Redirecting... + + +

Please wait while we redirect you back to the application.

+ + + diff --git a/src/client/jwt.ts b/src/client/jwt.ts index 684a737dc..fe04ba2b2 100644 --- a/src/client/jwt.ts +++ b/src/client/jwt.ts @@ -1,3 +1,6 @@ +import { App } from "@capacitor/app"; +import { Browser } from "@capacitor/browser"; +import { Capacitor } from "@capacitor/core"; import { decodeJwt } from "jose"; import { z } from "zod/v4"; import { @@ -8,8 +11,12 @@ import { UserMeResponseSchema, } from "../core/ApiSchemas"; +const isNative = Capacitor.getPlatform() !== "web"; + function getAudience() { - const { hostname } = new URL(window.location.href); + const hostname = + process.env.CAPACITOR_PRODUCTION_HOSTNAME || + new URL(window.location.href).hostname; const domainname = hostname.split(".").slice(-2).join("."); return domainname; } @@ -17,7 +24,10 @@ function getAudience() { function getApiBase() { const domainname = getAudience(); return domainname === "localhost" - ? (localStorage.getItem("apiHost") ?? "http://localhost:8787") + ? (localStorage.getItem("apiHost") ?? + (isNative && process.env.API_BASE_URL)) + ? process.env.API_BASE_URL!.replace("9000", "8787") + : "http://localhost:8787" : `https://api.${domainname}`; } @@ -43,8 +53,22 @@ function getToken(): string | null { return localStorage.getItem("token"); } -export function discordLogin() { - window.location.href = `${getApiBase()}/login/discord?redirect_uri=${window.location.href}`; +export async function discordLogin() { + let redirectUri: string; + + if (isNative) { + redirectUri = `${process.env.API_BASE_URL}/discord-redirect.html`; + } else { + redirectUri = window.location.href.split("#")[0]; + } + + const url = `${getApiBase()}/login/discord?redirect_uri=${encodeURIComponent(redirectUri)}`; + + if (isNative) { + await Browser.open({ url }); + } else { + window.location.href = url; + } } export async function logOut(allSessions: boolean = false) { @@ -101,7 +125,7 @@ function _isLoggedIn(): IsLoggedInResponse { const payload = decodeJwt(token); const { iss, aud, exp, iat } = payload; - if (iss !== getApiBase()) { + if (iss !== getApiBase() && !isNative) { // JWT was not issued by the correct server console.error( 'unexpected "iss" claim value', @@ -158,6 +182,61 @@ function _isLoggedIn(): IsLoggedInResponse { } } +export function initializeAuthListener() { + if (Capacitor.getPlatform() === "web") return; + + App.addListener("appUrlOpen", async (data) => { + try { + const url = new URL(data.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + + if (error) { + console.error("Error from auth provider:", error); + await Browser.close(); + return; + } + + if (code && state) { + const redirectUri = `${process.env.API_BASE_URL}/discord-redirect.html`; + const response = await fetch(`${getApiBase()}/mobile/callback`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code, + state, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + throw new Error( + `Failed to exchange code for token: ${response.statusText}`, + ); + } + + const { token } = await response.json(); + + if (token) { + localStorage.setItem("token", token); + __isLoggedIn = undefined; + await Browser.close(); + window.location.assign(window.location.origin || "/"); + } else { + console.error("No token found in response"); + await Browser.close(); + } + } + } catch (e) { + console.error("Error handling appUrlOpen", e); + await Browser.close(); + } + }); +} + export async function postRefresh(): Promise { try { const token = getToken(); diff --git a/src/server/cors.ts b/src/server/cors.ts index 801c8b77b..4bb1004c0 100644 --- a/src/server/cors.ts +++ b/src/server/cors.ts @@ -6,16 +6,26 @@ import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; const config = getServerConfigFromServer(); const origin = config.origin(); -const allowedOrigins = [ - origin, - "capacitor://openfront.io", - "https://openfront.io", -]; +const allowedOrigins: string[] = [origin]; -if (config.env() === GameEnv.Dev) { - const localIp = getLocalIP(); - if (localIp) { - allowedOrigins.push(`http://${localIp}:9000`); +switch (config.env()) { + case GameEnv.Prod: + allowedOrigins.push("capacitor://openfront.io", "https://openfront.io"); + break; + case GameEnv.Preprod: + allowedOrigins.push("capacitor://openfront.dev", "https://openfront.dev"); + break; + case GameEnv.Dev: { + allowedOrigins.push( + "capacitor://localhost", + "http://localhost", + "http://localhost:8787", + ); + const localIp = getLocalIP(); + if (localIp) { + allowedOrigins.push(`http://${localIp}:9000`); + } + break; } } diff --git a/webpack.config.js b/webpack.config.js index 0f46022ca..3cdf2cc2e 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -124,6 +124,9 @@ export default async (env, argv) => { "node_modules/protobufjs/minimal.js", ), }, + fallback: { + os: path.resolve(__dirname, "node_modules/os-browserify/browser.js"), + }, }, plugins: [ new HtmlWebpackPlugin({ @@ -141,6 +144,21 @@ export default async (env, argv) => { } : false, }), + new HtmlWebpackPlugin({ + template: "./src/client/discord-redirect.html", + filename: "discord-redirect.html", + chunks: [], + minify: isProduction + ? { + collapseWhitespace: true, + removeComments: true, + removeRedundantAttributes: true, + removeScriptTypeAttributes: true, + removeStyleLinkTypeAttributes: true, + useShortDoctype: true, + } + : false, + }), new webpack.DefinePlugin({ "process.env.WEBSOCKET_URL": JSON.stringify( isProduction ? "" : "localhost:3000",