Handle Discord Login in capacitor apps

This commit is contained in:
oleksandr-shysh
2025-06-13 12:45:49 +03:00
parent c5fde1d287
commit 303ec08d82
9 changed files with 209 additions and 18 deletions
+38
View File
@@ -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",
+4
View File
@@ -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",
+2 -2
View File
@@ -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}`;
}
+3 -1
View File
@@ -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<boolean> {
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",
+9 -1
View File
@@ -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;
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<html>
<head>
<title>Redirecting...</title>
</head>
<body>
<p>Please wait while we redirect you back to the application.</p>
<script>
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const error = params.get("error");
const state = params.get("state");
if (code) {
let url = `com.openfront.app://auth?code=${code}`;
if (state) {
url += `&state=${state}`;
}
window.location = url;
} else if (error) {
let url = `com.openfront.app://auth?error=${error}`;
if (state) {
url += `&state=${state}`;
}
window.location = url;
} else {
document.body.innerText =
"No code or error found. You can close this window.";
}
</script>
</body>
</html>
+84 -5
View File
@@ -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<boolean> {
try {
const token = getToken();
+19 -9
View File
@@ -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;
}
}
+18
View File
@@ -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",