diff --git a/exporter/SynthesisFusionAddin/.gitignore b/exporter/SynthesisFusionAddin/.gitignore index eca33a87a8..e7ae5df513 100644 --- a/exporter/SynthesisFusionAddin/.gitignore +++ b/exporter/SynthesisFusionAddin/.gitignore @@ -107,4 +107,5 @@ site-packages # env files **/.env -proto/proto_out \ No newline at end of file +proto/proto_out +.aps_auth diff --git a/fission/.env b/fission/.env new file mode 100644 index 0000000000..f5c2b2b526 --- /dev/null +++ b/fission/.env @@ -0,0 +1,2 @@ +VITE_SYNTHESIS_SERVER_PATH=/ +# VITE_SYNTHESIS_SERVER_PATH=https://synthesis.autodesk.com/ \ No newline at end of file diff --git a/fission/src/Synthesis.tsx b/fission/src/Synthesis.tsx index 65fa57c87d..d0791238e1 100644 --- a/fission/src/Synthesis.tsx +++ b/fission/src/Synthesis.tsx @@ -54,6 +54,7 @@ import World from "@/systems/World.ts" import { AddRobotsModal, AddFieldsModal, SpawningModal } from "@/modals/spawning/SpawningModals.tsx" import ImportMirabufModal from "@/modals/mirabuf/ImportMirabufModal.tsx" import ImportLocalMirabufModal from "@/modals/mirabuf/ImportLocalMirabufModal.tsx" +import APS, { ENDPOINT_SYNTHESIS_CHALLENGE } from "./aps/APS.ts" import ResetAllInputsModal from "./ui/modals/configuring/ResetAllInputsModal.tsx" import Skybox from './ui/components/Skybox.tsx'; @@ -61,12 +62,15 @@ const DEFAULT_MIRA_PATH = "/api/mira/Robots/Team 2471 (2018)_v7.mira" function Synthesis() { const urlParams = new URLSearchParams(document.location.search) - if (urlParams.has("code")) { + const has_code = urlParams.has("code") + if (has_code) { const code = urlParams.get("code") - window.opener?.setAuthCode(code) - window.close() + if (code) { + APS.convertAuthToken(code).then(() => { + document.location.search = "" + }) + } } - const { openModal, closeModal, getActiveModalElement } = useModalManager(initialModals) const { openPanel, closePanel, closeAllPanels, getActivePanelElements } = usePanelManager(initialPanels) const { showTooltip } = useTooltipManager() @@ -81,17 +85,16 @@ function Synthesis() { const modalElement = getActiveModalElement() useEffect(() => { + if (has_code) return + World.InitWorld() let mira_path = DEFAULT_MIRA_PATH - const urlParams = new URLSearchParams(document.location.search) - if (urlParams.has("mira")) { mira_path = `test_mira/${urlParams.get("mira")!}` console.debug(`Selected Mirabuf File: ${mira_path}`) } - console.log(urlParams) const setup = async () => { const info = await MirabufCachingService.CacheRemote(mira_path, MiraType.ROBOT) diff --git a/fission/src/Window.d.ts b/fission/src/Window.d.ts deleted file mode 100644 index 0e1f75341c..0000000000 --- a/fission/src/Window.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Theme } from "@mui/material" - -declare interface Window { - setAuthCode(code: string): void - getTheme(): Theme; -} diff --git a/fission/src/aps/APS.ts b/fission/src/aps/APS.ts index f3a9854fe9..ff2430db36 100644 --- a/fission/src/aps/APS.ts +++ b/fission/src/aps/APS.ts @@ -1,22 +1,25 @@ -import { Random } from "@/util/Random" +import { MainHUD_AddToast } from "@/ui/components/MainHUD" +import { Mutex } from "async-mutex" const APS_AUTH_KEY = "aps_auth" const APS_USER_INFO_KEY = "aps_user_info" export const APS_USER_INFO_UPDATE_EVENT = "aps_user_info_update" -const delay = 1000 -const authCodeTimeout = 200000 - const CLIENT_ID = "GCxaewcLjsYlK8ud7Ka9AKf9dPwMR3e4GlybyfhAK2zvl3tU" -const CHARACTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" -let lastCall = Date.now() +const ENDPOINT_SYNTHESIS_CODE = `${import.meta.env.VITE_SYNTHESIS_SERVER_PATH}api/aps/code` +export const ENDPOINT_SYNTHESIS_CHALLENGE = `${import.meta.env.VITE_SYNTHESIS_SERVER_PATH}api/aps/challenge` + +const ENDPOINT_AUTODESK_AUTHENTICATION_AUTHORIZE = "https://developer.api.autodesk.com/authentication/v2/authorize" +const ENDPOINT_AUTODESK_AUTHENTICATION_TOKEN = "https://developer.api.autodesk.com/authentication/v2/token" +const ENDPOINT_AUTODESK_USERINFO = "https://api.userprofile.autodesk.com/userinfo" interface APSAuth { access_token: string refresh_token: string expires_in: number + expires_at: number token_type: number } @@ -24,22 +27,24 @@ interface APSUserInfo { name: string picture: string givenName: string + email: string } class APS { static authCode: string | undefined = undefined + static requestMutex: Mutex = new Mutex() - static get auth(): APSAuth | undefined { + private static get auth(): APSAuth | undefined { const res = window.localStorage.getItem(APS_AUTH_KEY) try { - return res ? (JSON.parse(res) as APSAuth) : undefined + return res ? JSON.parse(res) : undefined } catch (e) { console.warn(`Failed to parse stored APS auth data: ${e}`) return undefined } } - static set auth(a: APSAuth | undefined) { + private static set auth(a: APSAuth | undefined) { window.localStorage.removeItem(APS_AUTH_KEY) if (a) { window.localStorage.setItem(APS_AUTH_KEY, JSON.stringify(a)) @@ -47,6 +52,46 @@ class APS { this.userInfo = undefined } + /** + * Sets the timestamp at which the access token expires + * + * @param {number} expires_at - When the token expires + */ + static setExpiresAt(expires_at: number) { + if (this.auth) + this.auth.expires_at = expires_at; + } + + /** + * Returns whether the user is signed in + * @returns {boolean} Whether the user is signed in + */ + static isSignedIn(): boolean { + return !!this.getAuth() + } + + /** + * Returns the auth data of the current user. See {@link APSAuth} + * @returns {(APSAuth | undefined)} Auth data of the current user + */ + static getAuth(): APSAuth | undefined { + return this.auth + } + + /** + * Returns the auth data of the current user or prompts them to sign in if they haven't. See {@link APSAuth} and {@link APS#refreshAuthToken} + * @returns {Promise} Promise that resolves to the auth data + */ + static async getAuthOrLogin(): Promise { + const auth = this.auth + if (!auth) return undefined + + if (Date.now() > auth.expires_at) { + await this.refreshAuthToken(auth.refresh_token) + } + return this.auth + } + static get userInfo(): APSUserInfo | undefined { const res = window.localStorage.getItem(APS_USER_INFO_KEY) @@ -67,120 +112,178 @@ class APS { document.dispatchEvent(new Event(APS_USER_INFO_UPDATE_EVENT)) } + /** + * Logs the user out by setting their auth data to undefined. + */ static async logout() { this.auth = undefined } + /** + * Prompts the user to sign in, which will retrieve the auth code. + */ static async requestAuthCode() { - if (Date.now() - lastCall > delay) { - lastCall = Date.now() + await this.requestMutex.runExclusive(async () => { const callbackUrl = import.meta.env.DEV ? `http://localhost:3000${import.meta.env.BASE_URL}` : `https://synthesis.autodesk.com${import.meta.env.BASE_URL}` - const [codeVerifier, codeChallenge] = await this.codeChallenge() - - const dataParams = [ - ["response_type", "code"], - ["client_id", CLIENT_ID], - ["redirect_uri", callbackUrl], - ["scope", "data:read"], - ["nonce", Date.now().toString()], - ["prompt", "login"], - ["code_challenge", codeChallenge], - ["code_challenge_method", "S256"], - ] - const data = dataParams.map(x => `${x[0]}=${encodeURIComponent(x[1])}`).join("&") - - window.open(`https://developer.api.autodesk.com/authentication/v2/authorize?${data}`) - - const searchStart = Date.now() - const func = () => { - if (Date.now() - searchStart > authCodeTimeout) { - console.debug("Auth Code Timeout") - return + try { + const challenge = await this.codeChallenge() + + const params = new URLSearchParams({ + response_type: "code", + client_id: CLIENT_ID, + redirect_uri: callbackUrl, + scope: "data:read", + nonce: Date.now().toString(), + prompt: "login", + code_challenge: challenge, + code_challenge_method: "S256", + }) + + if (APS.userInfo) { + params.append("authoptions", encodeURIComponent(JSON.stringify({ id: APS.userInfo.email }))) } - if (this.authCode) { - const code = this.authCode - this.authCode = undefined + const url = `${ENDPOINT_AUTODESK_AUTHENTICATION_AUTHORIZE}?${params.toString()}` - this.convertAuthToken(code, codeVerifier) - } else { - setTimeout(func, 500) - } + window.open(url, "_self") + } catch (e) { + console.error(e) + MainHUD_AddToast("error", "Error signing in.", "Please try again.") } - func() - } + }) } - static async convertAuthToken(code: string, codeVerifier: string) { - const authUrl = import.meta.env.DEV - ? `http://localhost:3003/api/aps/code/` - : `https://synthesis.autodesk.com/api/aps/code/` - fetch(`${authUrl}?code=${code}&code_verifier=${codeVerifier}`) - .then(x => x.json()) - .then(x => { - this.auth = x.response as APSAuth - }) - .then(() => { - console.log("Preloading user info") + /** + * Refreshes the access token using our refresh token. + * @param {string} refresh_token - The refresh token from our auth data + */ + static async refreshAuthToken(refresh_token: string) { + await this.requestMutex.runExclusive(async () => { + try { + const res = await fetch(ENDPOINT_AUTODESK_AUTHENTICATION_TOKEN, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + grant_type: "refresh_token", + refresh_token: refresh_token, + scope: "data:read", + }), + }) + const json = await res.json() + if (!res.ok) { + MainHUD_AddToast("error", "Error signing in.", json.userMessage) + this.auth = undefined + await this.requestAuthCode() + return + } + json.expires_at = json.expires_in + Date.now() + this.auth = json as APSAuth if (this.auth) { - this.loadUserInfo(this.auth!).then(async () => { - if (APS.userInfo) { - console.info(`Hello, ${APS.userInfo.givenName}`) - } - }) + await this.loadUserInfo(this.auth) + if (APS.userInfo) { + MainHUD_AddToast("info", "ADSK Login", `Hello, ${APS.userInfo.givenName}`) + } } - }) + } catch (e) { + MainHUD_AddToast("error", "Error signing in.", "Please try again.") + this.auth = undefined + await this.requestAuthCode() + } + }) } - static async loadUserInfo(auth: APSAuth) { - console.log("Loading user information") - await fetch("https://api.userprofile.autodesk.com/userinfo", { - method: "GET", - headers: { - Authorization: auth.access_token, - }, - }) - .then(x => x.json()) - .then(x => { - const info: APSUserInfo = { - name: x.name, - givenName: x.given_name, - picture: x.picture, + /** + * Fetches the auth data from Autodesk using the auth code. + * @param {string} code - The auth code + */ + static async convertAuthToken(code: string) { + let retry_login = false + try { + const res = await fetch(`${ENDPOINT_SYNTHESIS_CODE}?code=${code}`) + const json = await res.json() + if (!res.ok) { + MainHUD_AddToast("error", "Error signing in.", json.userMessage) + this.auth = undefined + return + } + const auth_res = json.response as APSAuth + auth_res.expires_at = auth_res.expires_in + Date.now() + this.auth = auth_res + console.log("Preloading user info") + const auth = await this.getAuth() + if (auth) { + await this.loadUserInfo(auth) + if (APS.userInfo) { + MainHUD_AddToast("info", "ADSK Login", `Hello, ${APS.userInfo.givenName}`) } - - this.userInfo = info - }) + } else { + console.error("Couldn't get auth data.") + retry_login = true + } + } catch (e) { + console.error(e) + retry_login = true + } + if (retry_login) { + this.auth = undefined + MainHUD_AddToast("error", "Error signing in.", "Please try again.") + } } - static async codeChallenge() { - const codeVerifier = this.genRandomString(50) - - const msgBuffer = new TextEncoder().encode(codeVerifier) - const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer) - - let str = "" - new Uint8Array(hashBuffer).forEach(x => (str = str + String.fromCharCode(x))) - const codeChallenge = btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") + /** + * Fetches user information using the auth data. See {@link APSAuth} + * @param {APSAuth} auth - The auth data + */ + static async loadUserInfo(auth: APSAuth) { + console.log("Loading user information") + try { + const res = await fetch(ENDPOINT_AUTODESK_USERINFO, { + method: "GET", + headers: { + Authorization: auth.access_token, + }, + }) + const json = await res.json() + if (!res.ok) { + MainHUD_AddToast("error", "Error fetching user data.", json.userMessage) + this.auth = undefined + await this.requestAuthCode() + return + } + const info: APSUserInfo = { + name: json.name, + givenName: json.given_name, + picture: json.picture, + email: json.email, + } - return [codeVerifier, codeChallenge] + this.userInfo = info + } catch (e) { + console.error(e) + MainHUD_AddToast("error", "Error signing in.", "Please try again.") + this.auth = undefined + } } - static genRandomString(len: number): string { - const s: string[] = [] - for (let i = 0; i < len; i++) { - const c = CHARACTERS.charAt(Math.abs(Random() * 10000) % CHARACTERS.length) - s.push(c) + /** + * Fetches the code challenge from our server for requesting the auth code. + */ + static async codeChallenge() { + try { + const res = await fetch(ENDPOINT_SYNTHESIS_CHALLENGE) + const json = await res.json() + return json["challenge"] + } catch (e) { + console.error(e) + MainHUD_AddToast("error", "Error signing in.", "Please try again.") } - - return s.join("") } } -Window.prototype.setAuthCode = (code: string) => { - APS.authCode = code -} - export default APS diff --git a/fission/src/aps/APSDataManagement.ts b/fission/src/aps/APSDataManagement.ts index 0c6c4a6cec..833794583e 100644 --- a/fission/src/aps/APSDataManagement.ts +++ b/fission/src/aps/APSDataManagement.ts @@ -59,7 +59,7 @@ export class Item extends Data { } export async function getHubs(): Promise { - const auth = APS.auth + const auth = APS.getAuth() if (!auth) { return undefined } @@ -88,7 +88,7 @@ export async function getHubs(): Promise { } export async function getProjects(hub: Hub): Promise { - const auth = APS.auth + const auth = APS.getAuth() if (!auth) { return undefined } @@ -121,7 +121,7 @@ export async function getProjects(hub: Hub): Promise { } export async function getFolderData(project: Project, folder: Folder): Promise { - const auth = APS.auth + const auth = APS.getAuth() if (!auth) { return undefined } diff --git a/fission/src/test/aps/APS.test.ts b/fission/src/test/aps/APS.test.ts deleted file mode 100644 index 2721a33633..0000000000 --- a/fission/src/test/aps/APS.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import APS from "@/aps/APS" -import { describe, expect, test } from "vitest" - -describe("APS", () => { - test("Generate Random Strings (10)", () => { - const s = APS.genRandomString(10) - - ;(async () => { - const [v, c] = await APS.codeChallenge() - console.log(`${v}`) - console.log(`${c}`) - })() - - expect(s.length).toBe(10) - const matches = s.match(/^([0-9a-z])*$/i) - expect(matches).toBeDefined() - expect(matches!.length).toBeGreaterThanOrEqual(1) - expect(matches![0]).toBe(s) - }) - - test("Generate Random Strings (50)", () => { - const s = APS.genRandomString(50) - - expect(s.length).toBe(50) - const matches = s.match(/^([0-9a-z])*$/i) - expect(matches).toBeDefined() - expect(matches!.length).toBeGreaterThanOrEqual(1) - expect(matches![0]).toBe(s) - }) - - test("Generate Random Strings (75)", () => { - const s = APS.genRandomString(75) - - expect(s.length).toBe(75) - const matches = s.match(/^([0-9a-z])*$/i) - expect(matches).toBeDefined() - expect(matches!.length).toBeGreaterThanOrEqual(1) - expect(matches![0]).toBe(s) - }) -}) diff --git a/fission/src/ui/components/MainHUD.tsx b/fission/src/ui/components/MainHUD.tsx index 08d3d01fe9..7ab4f1182b 100644 --- a/fission/src/ui/components/MainHUD.tsx +++ b/fission/src/ui/components/MainHUD.tsx @@ -5,14 +5,14 @@ import { BiMenuAltLeft } from "react-icons/bi" import { GrFormClose } from "react-icons/gr" import { GiSteeringWheel } from "react-icons/gi" import { HiDownload } from "react-icons/hi" -import { IoGameController, IoGameControllerOutline, IoPeople } from "react-icons/io5" +import { IoGameControllerOutline, IoPeople, IoRefresh, IoTimer } from "react-icons/io5" import { useModalControlContext } from "@/ui/ModalContext" import { usePanelControlContext } from "@/ui/PanelContext" import { motion } from "framer-motion" import logo from "@/assets/autodesk_logo.png" import { ToastType, useToastContext } from "@/ui/ToastContext" import { Random } from "@/util/Random" -import APS, { APS_USER_INFO_UPDATE_EVENT } from "@/aps/APS" +import APS, { APS_USER_INFO_UPDATE_EVENT, ENDPOINT_SYNTHESIS_CHALLENGE } from "@/aps/APS" import { UserIcon } from "./UserIcon" import World from "@/systems/World" import JOLT from "@/util/loading/JoltSyncLoader" @@ -37,11 +37,7 @@ const MainHUDButton: React.FC = ({ value, icon, onClick, larger }) className={`relative flex flex-row cursor-pointer bg-background w-full m-auto px-2 py-1 text-main-text border-none rounded-md ${larger ? "justify-center" : ""} items-center hover:brightness-105 focus:outline-0 focus-visible:outline-0`} > {larger && icon} - {!larger && ( - - {icon} - - )} + {!larger && {icon}} {value} ) @@ -55,8 +51,6 @@ const variants = { } const MainHUD: React.FC = () => { - // console.debug('Creating MainHUD'); - const { openModal } = useModalControlContext() const { openPanel } = usePanelControlContext() const { addToast } = useToastContext() @@ -128,6 +122,21 @@ const MainHUD: React.FC = () => { onClick={() => openModal("import-local-mirabuf")} /> } onClick={TestGodMode} /> + } + onClick={() => APS.isSignedIn() && APS.refreshAuthToken(APS.getAuth()!.refresh_token)} + /> + } + onClick={() => { + if (APS.isSignedIn()) { + APS.setExpiresAt(Date.now()) + APS.getAuthOrLogin() + } + }} + />
+ +interface ImportMetaEnv { + readonly VITE_SYNTHESIS_SERVER_PATH: string + // more env variables... + } + + interface ImportMeta { + readonly env: ImportMetaEnv + } \ No newline at end of file diff --git a/fission/vite.config.ts b/fission/vite.config.ts index 5f19b30927..ee38e0393c 100644 --- a/fission/vite.config.ts +++ b/fission/vite.config.ts @@ -5,7 +5,7 @@ import glsl from 'vite-plugin-glsl'; const basePath = '/fission/' const serverPort = 3000 -const dockerServerPort = 3003 +const dockerServerPort = 80 // https://vitejs.dev/config/ export default defineConfig({ @@ -53,7 +53,7 @@ export default defineConfig({ secure: false, rewrite: path => path.replace(/^\/api\/mira/, '/Downloadables/Mira'), }, - '/api/auth': { + '/api/aps': { target: `http://localhost:${dockerServerPort}/`, changeOrigin: true, secure: false,