forma-embedded-view-sdk
Version:
The Forma Embedded View SDK is a JavaScript library for creating custom extensions in Autodesk Forma Site Design (previously Spacemaker).
461 lines (460 loc) • 17.3 kB
JavaScript
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 Site Design auth api is used to manage access tokens for a
* user if your extension uses any Forma Site Design 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 };
}
}