UNPKG

forma-embedded-view-sdk

Version:

The Forma Embedded View SDK is a JavaScript library for creating custom extensions in Autodesk Forma (previously Spacemaker).

461 lines (460 loc) 17.2 kB
import { defineLoginOverlayComponent } from "./login-overlay.js"; const APS_ACCESS_TOKEN_KEY_PREFIX = "aps-access-token"; const TOKEN_REFRESH_LEEWAY_SECONDS = 120; function parseAccessToken(token) { const payload = token.split(".")[1]; if (!payload) return; const decodedPayload = atob(payload); return JSON.parse(decodedPayload); } function shouldRefresh(tokenPayload) { const expiry = tokenPayload.exp; const now = Math.floor(Date.now() / 1000); return expiry < now + TOKEN_REFRESH_LEEWAY_SECONDS; } function isValidTokenClaims(tokenPayload, config) { if (config.clientId !== tokenPayload.client_id) { return false; } return true; } function isCorrectScope(tokenPayload, requiredScope) { if (!tokenPayload) return false; const currentScopes = new Set(tokenPayload.scope ?? []); const combined = new Set([...currentScopes, ...(requiredScope ?? [])]); return currentScopes.size === combined.size; } /** * Higher order function that ensures only one promise is pending at a time * If a promise is already pending, it will return that promise */ const max1Pending = (fn) => { let currentPending; return async (...args) => { if (currentPending !== undefined) { return await currentPending; } try { currentPending = fn(...args); return await currentPending; } finally { currentPending = undefined; } }; }; function b64Uri(r) { return btoa(r).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } export class FormaAuthProvider { #config; #iframeMessenger; #heightObserver; /** @hidden */ constructor(iframeMessenger, heightObserver) { this.#iframeMessenger = iframeMessenger; this.#heightObserver = heightObserver; } get #configOrThrow() { if (!this.#config) throw new Error("Not configured"); return this.#config; } #refreshAccessTokenInternal = max1Pending(async (refreshToken) => { const response = await fetch("https://developer.api.autodesk.com/authentication/v2/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", accept: "application/json", }, body: new URLSearchParams({ client_id: this.#configOrThrow.clientId, grant_type: "refresh_token", refresh_token: refreshToken, }), }); const tokenResponse = (await response.json()); this.#storeTokenResponse(tokenResponse); return tokenResponse.access_token; }); async refreshToken() { const storedToken = this.#getTokenDataFromStorage(); if (!storedToken) { throw new FormaAuthError("Failed to refresh token.", "No token found."); } if (!storedToken?.refreshToken) { throw new FormaAuthError("Failed to refresh token", "No refresh token available"); } return await this.#refreshAccessTokenInternal(storedToken.refreshToken); } #createNonce() { return crypto.randomUUID(); } /** * generate auth url for the popup to autodesk authorization flow */ #createAuthUrl(challenge) { const { clientId, callbackUrl, scopes } = this.#configOrThrow; const nonce = this.#createNonce(); const authSearchParams = new URLSearchParams(); authSearchParams.set("redirect_uri", callbackUrl); authSearchParams.set("client_id", clientId); authSearchParams.set("response_type", "code"); authSearchParams.set("nonce", nonce); authSearchParams.set("code_challenge", challenge); authSearchParams.set("code_challenge_method", "S256"); if (scopes && scopes.length > 0) { authSearchParams.set("scope", scopes.join(" ")); } return `https://developer.api.autodesk.com/authentication/v2/authorize?${authSearchParams.toString()}`; } configure(config) { this.#config = config; } /** * Ensure that the user currently logged in to forma * is the same as the stored token */ async #validateTokenUser(currentToken) { const parsedToken = parseAccessToken(currentToken); if (!parsedToken?.userid) return false; return await this.#iframeMessenger.sendRequest("auth/check-is-logged-in-as", { userId: parsedToken.userid, }); } #getTokenStorageKey() { const searchParams = new URLSearchParams(window.location.search); const extensionId = searchParams.get("extensionId") ?? ""; return `${APS_ACCESS_TOKEN_KEY_PREFIX}-${extensionId}`; } #getTokenDataFromStorage() { const key = this.#getTokenStorageKey(); const raw = localStorage.getItem(key); if (!raw) return; try { return JSON.parse(raw); } catch { return; } } #storeTokenResponse(token) { const key = this.#getTokenStorageKey(); localStorage.setItem(key, JSON.stringify({ accessToken: token.access_token, refreshToken: token.refresh_token, })); } #deleteToken() { localStorage.removeItem(this.#getTokenStorageKey()); } async #acquireTokenSilentInternal() { const storedTokenData = this.#getTokenDataFromStorage(); if (!storedTokenData) return; const currentToken = storedTokenData.accessToken; if (!currentToken) return; const tokenPayload = parseAccessToken(currentToken); const isCurrentFormaUser = await this.#validateTokenUser(currentToken); if (!tokenPayload || !isValidTokenClaims(tokenPayload, this.#configOrThrow) || !isCorrectScope(tokenPayload, this.#configOrThrow.scopes) || !isCurrentFormaUser) { this.#deleteToken(); return; } const refreshToken = storedTokenData.refreshToken; if (refreshToken && shouldRefresh(tokenPayload)) { const newToken = await this.#refreshAccessTokenInternal(refreshToken); if (newToken) { return { accessToken: newToken }; } } return { accessToken: currentToken }; } acquireTokenSilent = max1Pending(this.#acquireTokenSilentInternal.bind(this)); async #createChallengeAndVerifier(length) { length = length || 43; const crypto = window.crypto; const verifier = b64Uri(Array.prototype.map .call(crypto.getRandomValues(new Uint8Array(length)), (n) => String.fromCharCode(n)) .join("")).substring(0, length); const randomArray = new Uint8Array(verifier.length); for (let i = 0; i < verifier.length; i++) { randomArray[i] = verifier.charCodeAt(i); } const digest = await crypto.subtle.digest("SHA-256", randomArray); return { verifier, challenge: b64Uri(String.fromCharCode.apply(null, [...new Uint8Array(digest)])), }; } /** * Monitors the popup window until the callback url * is reached and the code or error is returned. */ #monitorPopupCallbackUrl(popup) { return new Promise((resolve, reject) => { const interval = setInterval(() => { if (!popup || popup.closed) { clearInterval(interval); reject(new FormaAuthError("Popup closed before finishing PKCE flow", "popup_closed")); } let href = ""; try { // Will throw if cross origin, which should be caught and ignored // since we need the interval to keep running while on auth UI. href = popup.location.href; } catch { return; } if (!href || href === "about:blank") { return; } // If we've come this far, we're on the callback // and can clear the interval. clearInterval(interval); // TODO: Can we be redirected back without a code? Maybe poll for that specific param and loop? resolve(this.#parseCallbackUrl(popup.location)); }, 100); }); } #openPopup(url, name) { const popup = window.open(url, name, "width=600,height=600,popup=true"); if (!popup) { throw new Error("Popup blocked"); } return popup; } #parseCallbackUrl(location) { const searchParams = new URLSearchParams(location.search); const error = searchParams.get("error"); if (error) { throw new FormaAuthError("Failed to acquire code from authorization flow", error, searchParams.get("error_description") ?? undefined); } const code = searchParams.get("code"); if (!code) { throw new FormaAuthError("Failed to acquire code from authorization flow", "no_code"); } return { code }; } #fetchToken = async ({ code, verifier, }) => { if (!this.#config) throw new Error("Not configured"); const { clientId, callbackUrl } = this.#config; const response = await fetch("https://developer.api.autodesk.com/authentication/v2/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", accept: "application/json", }, body: new URLSearchParams({ client_id: clientId, grant_type: "authorization_code", code, code_verifier: verifier, redirect_uri: callbackUrl, }), }); if (!response.ok) { const errorResponse = (await response.json()); throw new FormaAuthError("Failed to acquire token from authorization flow", errorResponse.error, errorResponse.error_description ?? undefined); } const tokenResponse = (await response.json()); return tokenResponse; }; async #acquireTokenPopupInternal() { const currentToken = await this.acquireTokenSilent(); if (currentToken) return currentToken; const { challenge, verifier } = await this.#createChallengeAndVerifier(43); const popupUrl = this.#createAuthUrl(challenge); const popup = this.#openPopup(popupUrl, "_blank"); try { const { code } = await this.#monitorPopupCallbackUrl(popup); const token = await this.#fetchToken({ code, verifier }); this.#storeTokenResponse(token); return { accessToken: token.access_token }; } finally { if (popup && !popup.closed) { popup.close(); } } } acquireTokenPopup = max1Pending(this.#acquireTokenPopupInternal.bind(this)); async #acquireTokenOverlayInternal() { const overlayContainer = document.body; const currentToken = await this.acquireTokenSilent(); if (currentToken) return currentToken; return new Promise((resolve, reject) => { defineLoginOverlayComponent(); const overlay = document.createElement("forma-login-overlay"); this.#heightObserver.observe(overlay); const searchParams = new URLSearchParams(window.location.search); const extensionName = searchParams.get("extensionName"); if (extensionName) { overlay.setAttribute("extension-name", extensionName); } overlay.addEventListener("loginclick", () => { this.acquireTokenPopup() .then((result) => { this.#heightObserver.unobserve(overlay); overlay.remove(); resolve(result); }) .catch((err) => { if (err instanceof FormaAuthError && err.errorType === "popup_closed") { // User closed the popup, don't remove overlay, but // let user click the button again. return; } this.#heightObserver.unobserve(overlay); overlay.remove(); // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors reject(err); }); }); overlayContainer.appendChild(overlay); }); } acquireTokenOverlay = max1Pending(this.#acquireTokenOverlayInternal.bind(this)); } export class FormaAuthError extends Error { errorType; errorDescription; constructor(message, errorType, errorDescription) { super(message); this.name = "FormaAuthError"; this.errorType = errorType; this.errorDescription = errorDescription; } } /** * The Forma auth api is used to manage access tokens for a * user if your extension uses any Forma or APS APIs over HTTP. * * The auth api requires your APS app's client id, the scopes you * want the user to have access to, and a login URL. * * @example * ```typescript * Forma.auth.configure({ * clientId: "my-client-id", * callbackUrl: "https://my-extension.com/auth/callback", // we recommend a blank html page here * scope: ["data:read", "data:write"], * }) * * const token = Forma.auth.acquireTokenOverlay() * ``` */ export class AuthApi { #authProvider; /** @hidden */ constructor(authProvider) { this.#authProvider = authProvider; } /** * Configure your extension with the client id, login URL, and scope. * * This should be done before calling any other methods on the auth provider as they will fail without * any configuration. * * @example * ```typescript * Forma.auth.configure({ * clientId: "your-client-id", * callbackUrl: "http://localhost:8080/auth", // this should be a blank html page * scopes: ["data:read", "data:write"], * }) * * // Now you can call acquireTokenSilent, acquireTokenPopup, or acquireTokenOverlay * Forma.auth.acquireTokenPopup().then((result) => { * console.log("Access token:", result.accessToken) * } * ``` */ configure(input) { this.#authProvider.configure(input); } /** * Get the current access token if it is valid and the token's userid, clientId and scope * claims are correct. * * If the current token is expired, but have valid claims, the token will be refreshed. * * @returns object with access token if it is valid, otherwise undefined */ async acquireTokenSilent() { return await this.#authProvider.acquireTokenSilent(); } /** * Acquire an access token by sending the user to the authorization server * in a popup. When the user has authorized the application, the popup will * redirect to the callback URL with a code in the URL. The code will be * exchanged for an access token and stored in localStorage. * * @returns object with access token * * @example * ```typescript * const button = document.getElementById("login-button") * button.addEventListener("click", () => { * Forma.auth.acquireTokenPopup().then((result) => { * console.log("Access token:", result.accessToken) * }) * }) * document.body.appendChild(button) * ``` * * @remarks * This method should only be invoked as part of a user interaction, such as * a button click, to avoid the browser blocking the popup request. */ async acquireTokenPopup() { return await this.#authProvider.acquireTokenPopup(); } /** * Get an access token by first rendering an overlay covering the full embedded * view with a button to open a popup as with {@link acquireTokenPopup}. * * This is a convenience method. * * @returns object with access token * * @example * ```ts * const { accessToken } = await Forma.auth.acquireTokenOverlay() * ``` */ async acquireTokenOverlay() { return await this.#authProvider.acquireTokenOverlay(); } /** * Get a newly refreshed access token if one already exists. * This can be useful if your api takes a long time and the * current token might expire in-flight. * * This function should only be used if the user has already authorized, * and will throw an error if there's no token available. * * @return a newly refreshed token * * @example * ```ts * const newToken = await Forma.auth.refreshToken() * ``` */ async refreshCurrentToken() { const accessToken = await this.#authProvider.refreshToken(); return { accessToken }; } }