From c8c7015c2625f949107220b1c7b65364053e60c1 Mon Sep 17 00:00:00 2001 From: Alexander Schiftner Date: Wed, 24 Sep 2025 18:42:19 +0200 Subject: [PATCH 01/15] ShapeDiver auth --- src/hooks/useShapeDiverAuth.ts | 204 +++++++++++++++++++++++++++++++++ src/pages/HomePage.tsx | 23 +++- 2 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useShapeDiverAuth.ts diff --git a/src/hooks/useShapeDiverAuth.ts b/src/hooks/useShapeDiverAuth.ts new file mode 100644 index 0000000..33faf3f --- /dev/null +++ b/src/hooks/useShapeDiverAuth.ts @@ -0,0 +1,204 @@ +import {useCallback, useEffect, useState} from "react"; + +const refreshTokenKey = "shapediver_refresh_token"; +const codeVerifierKey = "shapediver_code_verifier"; +const oauthStateKey = "shapediver_oauth_state"; +const authBaseUrl = "https://dev-wwwcdn.us-east-1.shapediver.com"; +const authEndPoint = `${authBaseUrl}/oauth/authorize`; +const tokenEndPoint = `${authBaseUrl}/oauth/token`; +const clientId = "660310c8-50f4-4f47-bd78-9c7ede8e659b"; + +async function sha256(buffer: Uint8Array): Promise { + return await crypto.subtle.digest("SHA-256", buffer); +} + +function base64UrlEncode(buffer: ArrayBuffer) { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + // see https://developer.mozilla.org/en-US/docs/Glossary/Base64#url_and_filename_safe_base64 + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +function generateRandomString(length: number) { + const charset = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const values = new Uint8Array(length); + crypto.getRandomValues(values); + let result = ""; + for (let i = 0; i < length; i++) { + result += charset[values[i] % charset.length]; + } + return result; +} + +function clearBrowserStorage() { + window.localStorage.removeItem(refreshTokenKey); + window.localStorage.removeItem(codeVerifierKey); + window.localStorage.removeItem(oauthStateKey); +} + +function getRedirectUri() { + return window.location.origin + "/"; +} + +/** + * Hook to manage authentication with ShapeDiver via OAuth2 Authorization Code Flow with PKCE. + * @returns + */ +export default function useShapeDiverAuth() { + // check for error and error description in URL parameters + const params = new URLSearchParams(window.location.search); + const [error, setError] = useState(params.get("error")); + const [errorDescription, setErrorDescription] = useState( + params.get("error_description"), + ); + const [codeData, setCodeData] = useState<{ + code: string; + verifier: string; + } | null>(null); + // check whether local storage has a token stored + const [accessToken, setAccessToken] = useState(); + const [refreshToken, setRefreshToken_] = useState( + window.localStorage.getItem(refreshTokenKey), + ); + const setRefreshToken = useCallback((token: string | null) => { + if (token) { + window.localStorage.setItem(refreshTokenKey, token); + } else { + window.localStorage.removeItem(refreshTokenKey); + } + setRefreshToken_(token); + }, []); + + // if there is an error, clear the local storage + if (error) { + clearBrowserStorage(); + } else { + // check if we got a code and state in the URL parameters + const code = params.get("code"); + const state = params.get("state"); + if (state && code) { + // remove code and state from URL to avoid re-processing + params.delete("code"); + params.delete("state"); + const url = new URL(window.location.href); + url.searchParams.delete("code"); + url.searchParams.delete("state"); + window.history.replaceState({}, document.title, url.toString()); + // verify state + const storedState = window.localStorage.getItem(oauthStateKey); + const storedVerifier = window.localStorage.getItem(codeVerifierKey); + if (storedState === null) { + setError("missing stored state"); + setErrorDescription( + "No stored state found, please initiate the authentication flow again.", + ); + } else if (storedVerifier === null) { + setError("missing stored verifier"); + setErrorDescription( + "No stored code verifier found, please initiate the authentication flow again.", + ); + } else if (state === window.localStorage.getItem(oauthStateKey)) { + // state is valid, now exchange the code for a token + setCodeData({code, verifier: storedVerifier}); + } else { + // state is invalid, clear local storage and return error + setError("state mismatch"); + setErrorDescription( + "The returned state does not match the stored state.", + ); + } + window.localStorage.removeItem(oauthStateKey); + window.localStorage.removeItem(codeVerifierKey); + } + } + + useEffect(() => { + if (codeData) { + const getToken = async () => { + const response = await fetch(tokenEndPoint, { + method: "POST", + body: JSON.stringify({ + grant_type: "authorization_code", + client_id: clientId, + code: codeData.code, + redirect_uri: getRedirectUri(), + code_verifier: codeData.verifier, + }), + headers: { + "Content-Type": "application/json", + }, + }); + if (response.ok) { + const data = (await response.json()) as { + access_token: string; + refresh_token: string; + }; + setAccessToken(data.access_token); + setRefreshToken(data.refresh_token); + } else { + const data = (await response.json()) as { + error?: string; + error_description?: string; + }; + setError(data.error ?? null); + setErrorDescription(data.error_description ?? null); + } + }; + setCodeData(null); + getToken(); + } + }, [codeData]); + + // callback for initiating the authorization flow via the ShapeDiver platform + const initiateShapeDiverAuth = useCallback(async () => { + // reset state + setError(null); + setErrorDescription(null); + setCodeData(null); + setAccessToken(undefined); + setRefreshToken(null); + // clear previous tokens and state + clearBrowserStorage(); + // Create a 64 character random string (from characters a-zA-Z0-9), we call this the secret code verifier. + const codeVerifier = generateRandomString(64); + window.localStorage.setItem(codeVerifierKey, codeVerifier); + // get unix timestamp in seconds + const timestamp = Math.floor(Date.now() / 1000); + // create state + const _state = `${codeVerifier}:${authEndPoint}:${clientId}:${timestamp}`; + const encoder = new TextEncoder(); + const state = base64UrlEncode(await sha256(encoder.encode(_state))); + window.localStorage.setItem(oauthStateKey, state); + const code_challenge = base64UrlEncode( + await sha256(encoder.encode(codeVerifier)), + ); + + // construct the redirection URL + const params = new URLSearchParams(); + params.append("state", state); + params.append("response_type", "code"); + params.append("client_id", clientId); + params.append("code_challenge", code_challenge); + params.append("code_challenge_method", "S256"); + params.append("redirect_uri", getRedirectUri()); + const redirectUrl = `${authEndPoint}?${params.toString()}`; + + // redirect to the authorization endpoint + window.location.href = redirectUrl; + }, []); + + return { + accessToken, + refreshToken, + initiateShapeDiverAuth, + error, + errorDescription, + }; +} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 17b0ed0..870f853 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,5 +1,26 @@ import React from "react"; +import useShapeDiverAuth from "~/hooks/useShapeDiverAuth"; export default function HomePage() { - return <>; + const { + error, + errorDescription, + accessToken, + refreshToken, + initiateShapeDiverAuth, + } = useShapeDiverAuth(); + + return ( + <> + {error &&

Error: {error}

} + {errorDescription &&

Error description: {errorDescription}

} + {accessToken &&

Access Token: {accessToken}

} + {refreshToken &&
Refresh Token: {refreshToken}
} +

+ +

+ + ); } From f9c4618d702cbab8e160a8bbdd4a7ff69f2dbaa7 Mon Sep 17 00:00:00 2001 From: Alexander Schiftner Date: Thu, 25 Sep 2025 09:59:06 +0200 Subject: [PATCH 02/15] autologin using refresh token --- package.json | 1 + pnpm-lock.yaml | 75 ++++++++++++++++++++++++++++++++++ src/hooks/useShapeDiverAuth.ts | 62 ++++++++++++++++++++++++++-- src/pages/HomePage.tsx | 2 +- 4 files changed, 136 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 378a996..6c95892 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "homepage": "./", "dependencies": { + "@shapediver/sdk.platform-api-sdk-v1": "^2.28.6", "globals": "^16.4.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index faa71ad..d6fc440 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false dependencies: + '@shapediver/sdk.platform-api-sdk-v1': + specifier: ^2.28.6 + version: 2.28.6 globals: specifier: ^16.4.0 version: 16.4.0 @@ -2864,6 +2867,32 @@ packages: resolution: {integrity: sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==} dev: false + /@shapediver/api.platform-api-dto-v1@2.28.6: + resolution: {integrity: sha512-vynUfjZz0KTcu9Ae9wekURsaiIm7qNO6CBVt75CMekyZCoCYqQ1MIgMeGrKaSQQgSPY4jKsMv+4xZJ1gl2+BqA==} + dependencies: + '@types/q': 1.5.8 + chargebee-typescript: 2.16.0 + dev: false + + /@shapediver/sdk.geometry-api-sdk-v2@2.9.1: + resolution: {integrity: sha512-D9DBSrCgOtgLCzLyqDnDl08vcCf3e+PSLg1xDlXYvE5Z8JzHfVtgSYT/qoAj/5nCq3+YdFpv62cR09E1fSh7xQ==} + dependencies: + axios: 1.10.0 + transitivePeerDependencies: + - debug + dev: false + + /@shapediver/sdk.platform-api-sdk-v1@2.28.6: + resolution: {integrity: sha512-eUs8V0TQNtZSMs4AdWWpPwUlNS32DoGbJXwOhvevDcgoP+v94JTJcJUXGDH5iSWpJLX+Kgsb4QTn9uZS4t0Nyw==} + dependencies: + '@shapediver/api.platform-api-dto-v1': 2.28.6 + '@shapediver/sdk.geometry-api-sdk-v2': 2.9.1 + axios: 1.12.2 + jwt-decode: 3.1.2 + transitivePeerDependencies: + - debug + dev: false + /@sinclair/typebox@0.24.51: resolution: {integrity: sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==} dev: false @@ -4266,6 +4295,26 @@ packages: engines: {node: '>=4'} dev: false + /axios@1.10.0: + resolution: {integrity: sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==} + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.4 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + dev: false + + /axios@1.12.2: + resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==} + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.4 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + dev: false + /axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -4692,6 +4741,13 @@ packages: engines: {node: '>=12.20'} dev: false + /chargebee-typescript@2.16.0: + resolution: {integrity: sha512-w0xFnZ3GjMeHlne99tb/EXEEGDyORlMtdS1z7Hmz0g+emsANGI8zWrOXp1q5zqW2FWU+XdeguDgj3OCkmraOAA==} + engines: {node: '>=8.0.0'} + dependencies: + q: 1.5.1 + dev: false + /check-types@11.2.3: resolution: {integrity: sha512-+67P1GkJRaxQD6PKK0Et9DhwQB+vGg3PM5+aavopCpZT1lj9jeqfvpgTLAWErNj8qApkkmXlu/Ug74kmhagkXg==} dev: false @@ -6509,6 +6565,17 @@ packages: mime-types: 2.1.35 dev: false + /form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} + engines: {node: '>= 6'} + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + dev: false + /forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -8464,6 +8531,10 @@ packages: object.assign: 4.1.7 object.values: 1.2.1 + /jwt-decode@3.1.2: + resolution: {integrity: sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==} + dev: false + /keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} dependencies: @@ -10077,6 +10148,10 @@ packages: ipaddr.js: 1.9.1 dev: false + /proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + dev: false + /psl@1.15.0: resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} dependencies: diff --git a/src/hooks/useShapeDiverAuth.ts b/src/hooks/useShapeDiverAuth.ts index 33faf3f..efa05bf 100644 --- a/src/hooks/useShapeDiverAuth.ts +++ b/src/hooks/useShapeDiverAuth.ts @@ -1,3 +1,8 @@ +import { + create, + isPBInvalidGrantOAuthResponseError, + isPBInvalidRequestOAuthResponseError, +} from "@shapediver/sdk.platform-api-sdk-v1"; import {useCallback, useEffect, useState} from "react"; const refreshTokenKey = "shapediver_refresh_token"; @@ -47,11 +52,18 @@ function getRedirectUri() { return window.location.origin + "/"; } +interface Props { + /** Log in automatically if a refresh token is available. */ + autoLogin?: boolean; +} + /** * Hook to manage authentication with ShapeDiver via OAuth2 Authorization Code Flow with PKCE. * @returns */ -export default function useShapeDiverAuth() { +export default function useShapeDiverAuth(props?: Props) { + const {autoLogin = false} = props || {}; + // check for error and error description in URL parameters const params = new URLSearchParams(window.location.search); const [error, setError] = useState(params.get("error")); @@ -105,7 +117,7 @@ export default function useShapeDiverAuth() { "No stored code verifier found, please initiate the authentication flow again.", ); } else if (state === window.localStorage.getItem(oauthStateKey)) { - // state is valid, now exchange the code for a token + // state is valid, now exchange the code for a token (handled in useEffect below) setCodeData({code, verifier: storedVerifier}); } else { // state is invalid, clear local storage and return error @@ -119,6 +131,7 @@ export default function useShapeDiverAuth() { } } + // exchange code for token useEffect(() => { if (codeData) { const getToken = async () => { @@ -156,7 +169,49 @@ export default function useShapeDiverAuth() { } }, [codeData]); - // callback for initiating the authorization flow via the ShapeDiver platform + // callback for auth using refresh token + const authUsingRefreshToken = useCallback(async () => { + if (refreshToken) { + // reset state + setError(null); + setErrorDescription(null); + setCodeData(null); + // create SDK + const client = create({clientId, baseUrl: authBaseUrl}); + try { + // TODO: pass refresh token + const data = await client.authorization.refreshToken(); + setAccessToken(data.access_token); + setRefreshToken(data.refresh_token ?? null); + } catch (error) { + if ( + isPBInvalidRequestOAuthResponseError(error) || // <-- thrown if the refresh token is not valid anymore or there is none + isPBInvalidGrantOAuthResponseError(error) // <-- thrown if the refresh token is generally invalid + ) { + setRefreshToken(null); + setError("invalid refresh token"); + setErrorDescription( + "The stored refresh token is invalid, please log in again.", + ); + throw error; + } else { + setRefreshToken(null); + setError("refresh token login failed"); + setErrorDescription( + "The refresh token login failed, please log in again.", + ); + throw error; + } + } + } + }, [refreshToken]); + + // optionally automatically log in + useEffect(() => { + if (autoLogin && refreshToken && !accessToken) authUsingRefreshToken(); + }, [autoLogin, refreshToken, accessToken]); + + // callback for initiating the authorization code flow via the ShapeDiver platform const initiateShapeDiverAuth = useCallback(async () => { // reset state setError(null); @@ -200,5 +255,6 @@ export default function useShapeDiverAuth() { initiateShapeDiverAuth, error, errorDescription, + authUsingRefreshToken, }; } diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 870f853..5572a58 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -8,7 +8,7 @@ export default function HomePage() { accessToken, refreshToken, initiateShapeDiverAuth, - } = useShapeDiverAuth(); + } = useShapeDiverAuth({ autoLogin: true }); return ( <> From 60fd09ad5895b2eba6964ee4c071989711a3abeb Mon Sep 17 00:00:00 2001 From: Alexander Schiftner Date: Thu, 25 Sep 2025 10:55:06 +0200 Subject: [PATCH 03/15] introduce reactive authorization state --- src/hooks/useShapeDiverAuth.ts | 53 ++++++++++++++++++++++++++++++++-- src/pages/HomePage.tsx | 16 ++++++++-- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/hooks/useShapeDiverAuth.ts b/src/hooks/useShapeDiverAuth.ts index efa05bf..371164b 100644 --- a/src/hooks/useShapeDiverAuth.ts +++ b/src/hooks/useShapeDiverAuth.ts @@ -57,11 +57,32 @@ interface Props { autoLogin?: boolean; } +type ShapeDiverAuthStateType = + | "not_authenticated" + | "refresh_token_present" + | "authenticated" + | "authentication_in_progress"; + /** * Hook to manage authentication with ShapeDiver via OAuth2 Authorization Code Flow with PKCE. * @returns */ -export default function useShapeDiverAuth(props?: Props) { +export default function useShapeDiverAuth(props?: Props): { + /** The access token. */ + accessToken: string | undefined; + /** The refresh token. */ + refreshToken: string | null; + /** Callback for initiating authorization code flow via the ShapeDiver platform. */ + initiateShapeDiverAuth: () => Promise; + /** Error type, if any. */ + error: string | null; + /** Error description, if any. */ + errorDescription: string | null; + /** Authenticate using the current refresh token. No need to call this if autoLogin is true. */ + authUsingRefreshToken: () => Promise; + /** Authentication state. */ + authState: ShapeDiverAuthStateType; +} { const {autoLogin = false} = props || {}; // check for error and error description in URL parameters @@ -74,11 +95,12 @@ export default function useShapeDiverAuth(props?: Props) { code: string; verifier: string; } | null>(null); - // check whether local storage has a token stored const [accessToken, setAccessToken] = useState(); + // try to get refresh token from local storage const [refreshToken, setRefreshToken_] = useState( window.localStorage.getItem(refreshTokenKey), ); + const setRefreshToken = useCallback((token: string | null) => { if (token) { window.localStorage.setItem(refreshTokenKey, token); @@ -88,8 +110,15 @@ export default function useShapeDiverAuth(props?: Props) { setRefreshToken_(token); }, []); - // if there is an error, clear the local storage + // determine initial auth state + let authState_: ShapeDiverAuthStateType = refreshToken + ? autoLogin + ? "authentication_in_progress" + : "refresh_token_present" + : "not_authenticated"; + if (error) { + // if there is an error, clear the local storage clearBrowserStorage(); } else { // check if we got a code and state in the URL parameters @@ -119,6 +148,7 @@ export default function useShapeDiverAuth(props?: Props) { } else if (state === window.localStorage.getItem(oauthStateKey)) { // state is valid, now exchange the code for a token (handled in useEffect below) setCodeData({code, verifier: storedVerifier}); + authState_ = "authentication_in_progress"; } else { // state is invalid, clear local storage and return error setError("state mismatch"); @@ -131,6 +161,10 @@ export default function useShapeDiverAuth(props?: Props) { } } + // define auth state + const [authState, setAuthState] = + useState(authState_); + // exchange code for token useEffect(() => { if (codeData) { @@ -155,6 +189,7 @@ export default function useShapeDiverAuth(props?: Props) { }; setAccessToken(data.access_token); setRefreshToken(data.refresh_token); + setAuthState("authenticated"); } else { const data = (await response.json()) as { error?: string; @@ -162,9 +197,13 @@ export default function useShapeDiverAuth(props?: Props) { }; setError(data.error ?? null); setErrorDescription(data.error_description ?? null); + setAccessToken(undefined); + setRefreshToken(null); + setAuthState("not_authenticated"); } }; setCodeData(null); + setAuthState("authentication_in_progress"); getToken(); } }, [codeData]); @@ -180,22 +219,28 @@ export default function useShapeDiverAuth(props?: Props) { const client = create({clientId, baseUrl: authBaseUrl}); try { // TODO: pass refresh token + setAuthState("authentication_in_progress"); const data = await client.authorization.refreshToken(); setAccessToken(data.access_token); setRefreshToken(data.refresh_token ?? null); + setAuthState("authenticated"); } catch (error) { if ( isPBInvalidRequestOAuthResponseError(error) || // <-- thrown if the refresh token is not valid anymore or there is none isPBInvalidGrantOAuthResponseError(error) // <-- thrown if the refresh token is generally invalid ) { + setAccessToken(undefined); setRefreshToken(null); + setAuthState("not_authenticated"); setError("invalid refresh token"); setErrorDescription( "The stored refresh token is invalid, please log in again.", ); throw error; } else { + setAccessToken(undefined); setRefreshToken(null); + setAuthState("not_authenticated"); setError("refresh token login failed"); setErrorDescription( "The refresh token login failed, please log in again.", @@ -219,6 +264,7 @@ export default function useShapeDiverAuth(props?: Props) { setCodeData(null); setAccessToken(undefined); setRefreshToken(null); + setAuthState("authentication_in_progress"); // clear previous tokens and state clearBrowserStorage(); // Create a 64 character random string (from characters a-zA-Z0-9), we call this the secret code verifier. @@ -256,5 +302,6 @@ export default function useShapeDiverAuth(props?: Props) { error, errorDescription, authUsingRefreshToken, + authState, }; } diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 5572a58..6d29074 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -8,18 +8,28 @@ export default function HomePage() { accessToken, refreshToken, initiateShapeDiverAuth, + authUsingRefreshToken, + authState, } = useShapeDiverAuth({ autoLogin: true }); return ( <> {error &&

Error: {error}

} {errorDescription &&

Error description: {errorDescription}

} +

Authentication state: {authState}

{accessToken &&

Access Token: {accessToken}

} {refreshToken &&
Refresh Token: {refreshToken}
}

- + {authState === "not_authenticated" ? ( + + ) : undefined} + {authState === "refresh_token_present" ? ( + + ) : undefined}

); From 495aa29140bca7b0f2f29ce7e1177988595e2635 Mon Sep 17 00:00:00 2001 From: Alexander Schiftner Date: Thu, 25 Sep 2025 19:27:39 +0200 Subject: [PATCH 04/15] basic Stargate functionality (some handlers missing) --- package.json | 6 +- pnpm-lock.yaml | 57 ++++++++-- src/hooks/useShapeDiverAuth.ts | 75 +++++++------ src/hooks/useShapeDiverStargate.ts | 173 +++++++++++++++++++++++++++++ src/pages/HomePage.tsx | 17 ++- 5 files changed, 285 insertions(+), 43 deletions(-) create mode 100644 src/hooks/useShapeDiverStargate.ts diff --git a/package.json b/package.json index 6c95892..7e792ba 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "type": "module", "homepage": "./", "dependencies": { - "@shapediver/sdk.platform-api-sdk-v1": "^2.28.6", + "@shapediver/sdk.geometry-api-sdk-v2": "^2.9.1", + "@shapediver/sdk.platform-api-sdk-v1": "^2.28.7", + "@shapediver/sdk.stargate-sdk-v1": "^1.6.1", "globals": "^16.4.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -83,4 +85,4 @@ "node" ] } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6fc440..c9e81db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,9 +5,15 @@ settings: excludeLinksFromLockfile: false dependencies: + '@shapediver/sdk.geometry-api-sdk-v2': + specifier: ^2.9.1 + version: 2.9.1 '@shapediver/sdk.platform-api-sdk-v1': - specifier: ^2.28.6 - version: 2.28.6 + specifier: ^2.28.7 + version: 2.28.7 + '@shapediver/sdk.stargate-sdk-v1': + specifier: ^1.6.1 + version: 1.6.1 globals: specifier: ^16.4.0 version: 16.4.0 @@ -2867,8 +2873,8 @@ packages: resolution: {integrity: sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==} dev: false - /@shapediver/api.platform-api-dto-v1@2.28.6: - resolution: {integrity: sha512-vynUfjZz0KTcu9Ae9wekURsaiIm7qNO6CBVt75CMekyZCoCYqQ1MIgMeGrKaSQQgSPY4jKsMv+4xZJ1gl2+BqA==} + /@shapediver/api.platform-api-dto-v1@2.28.7: + resolution: {integrity: sha512-uS2a+aV27ZVuB6qneBmKlF3JV9sI4az1ci7uqNWGUxY995AJiclG4Dgkf1cSRL/rQondM9FELhssXqRWQiBLkg==} dependencies: '@types/q': 1.5.8 chargebee-typescript: 2.16.0 @@ -2882,10 +2888,10 @@ packages: - debug dev: false - /@shapediver/sdk.platform-api-sdk-v1@2.28.6: - resolution: {integrity: sha512-eUs8V0TQNtZSMs4AdWWpPwUlNS32DoGbJXwOhvevDcgoP+v94JTJcJUXGDH5iSWpJLX+Kgsb4QTn9uZS4t0Nyw==} + /@shapediver/sdk.platform-api-sdk-v1@2.28.7: + resolution: {integrity: sha512-3F6jm5T5jEjrrSTXgX0hw44e5JVBNOH5i3QFK3ssXLny6Fy8zAIJH2vmNpKgnLfFYd5sXUqTyOts+qW8Q5MHzw==} dependencies: - '@shapediver/api.platform-api-dto-v1': 2.28.6 + '@shapediver/api.platform-api-dto-v1': 2.28.7 '@shapediver/sdk.geometry-api-sdk-v2': 2.9.1 axios: 1.12.2 jwt-decode: 3.1.2 @@ -2893,6 +2899,30 @@ packages: - debug dev: false + /@shapediver/sdk.stargate-sdk-core@1.3.1: + resolution: {integrity: sha512-94Zto7YO7mA1xGPEjMNK6y6eL6iONdFnb7NQYnhq2sz9H06Rs9/RHIXpUjSLOtBbT9L2i1fmUNxharQngG1E6w==} + dependencies: + uuid: 9.0.1 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: false + + /@shapediver/sdk.stargate-sdk-v1@1.6.1: + resolution: {integrity: sha512-qM/kOWmLcXrK6uirZH78D41bbNkh1EYtKvkvSM+z/fIsGKZ9fzINw3R5C62IAlDEeM/m5at5S3SrYv+u4DHKrg==} + dependencies: + '@shapediver/sdk.stargate-sdk-core': 1.3.1 + '@types/node': 20.19.17 + '@types/uuid': 9.0.8 + ajv: 8.17.1 + jsonschema: 1.5.0 + uuid: 9.0.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: false + /@sinclair/typebox@0.24.51: resolution: {integrity: sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==} dev: false @@ -3496,6 +3526,10 @@ packages: resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} dev: false + /@types/uuid@9.0.8: + resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + dev: false + /@types/ws@8.18.1: resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} dependencies: @@ -8522,6 +8556,10 @@ packages: engines: {node: '>=0.10.0'} dev: false + /jsonschema@1.5.0: + resolution: {integrity: sha512-K+A9hhqbn0f3pJX17Q/7H6yQfD/5OXgdrR5UE12gMXCiN9D5Xq2o5mddV2QEcX/bjla99ASsAAQUyMCCRWAEhw==} + dev: false + /jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -11919,6 +11957,11 @@ packages: hasBin: true dev: false + /uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + dev: false + /v8-to-istanbul@8.1.1: resolution: {integrity: sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==} engines: {node: '>=10.12.0'} diff --git a/src/hooks/useShapeDiverAuth.ts b/src/hooks/useShapeDiverAuth.ts index 371164b..9320a7f 100644 --- a/src/hooks/useShapeDiverAuth.ts +++ b/src/hooks/useShapeDiverAuth.ts @@ -2,13 +2,15 @@ import { create, isPBInvalidGrantOAuthResponseError, isPBInvalidRequestOAuthResponseError, + isPBOAuthResponseError, + SdPlatformSdk, } from "@shapediver/sdk.platform-api-sdk-v1"; import {useCallback, useEffect, useState} from "react"; const refreshTokenKey = "shapediver_refresh_token"; const codeVerifierKey = "shapediver_code_verifier"; const oauthStateKey = "shapediver_oauth_state"; -const authBaseUrl = "https://dev-wwwcdn.us-east-1.shapediver.com"; +const authBaseUrl = "https://www.shapediver.com"; const authEndPoint = `${authBaseUrl}/oauth/authorize`; const tokenEndPoint = `${authBaseUrl}/oauth/token`; const clientId = "660310c8-50f4-4f47-bd78-9c7ede8e659b"; @@ -82,6 +84,12 @@ export default function useShapeDiverAuth(props?: Props): { authUsingRefreshToken: () => Promise; /** Authentication state. */ authState: ShapeDiverAuthStateType; + /** The base URL of the ShapeDiver platform. */ + platformBaseUrl: string; + /** The client id used to authenticate. */ + clientId: string; + /** ShapeDiver Platform SDK, authenticated or anonymous depending on auth state. */ + platformSdk: SdPlatformSdk; } { const {autoLogin = false} = props || {}; @@ -96,6 +104,9 @@ export default function useShapeDiverAuth(props?: Props): { verifier: string; } | null>(null); const [accessToken, setAccessToken] = useState(); + const [platformSdk] = useState( + create({clientId, baseUrl: authBaseUrl}), + ); // try to get refresh token from local storage const [refreshToken, setRefreshToken_] = useState( window.localStorage.getItem(refreshTokenKey), @@ -169,34 +180,31 @@ export default function useShapeDiverAuth(props?: Props): { useEffect(() => { if (codeData) { const getToken = async () => { - const response = await fetch(tokenEndPoint, { - method: "POST", - body: JSON.stringify({ - grant_type: "authorization_code", - client_id: clientId, - code: codeData.code, - redirect_uri: getRedirectUri(), - code_verifier: codeData.verifier, - }), - headers: { - "Content-Type": "application/json", - }, - }); - if (response.ok) { - const data = (await response.json()) as { - access_token: string; - refresh_token: string; - }; + try { + const data = + await platformSdk.authorization.authorizationCodePkce( + codeData.code, + codeData.verifier, + getRedirectUri(), + ); setAccessToken(data.access_token); - setRefreshToken(data.refresh_token); + setRefreshToken(data.refresh_token ?? null); setAuthState("authenticated"); - } else { - const data = (await response.json()) as { - error?: string; - error_description?: string; - }; - setError(data.error ?? null); - setErrorDescription(data.error_description ?? null); + } catch (error) { + if (isPBOAuthResponseError(error)) { + setError(error.error ?? null); + setErrorDescription(error.error_description ?? null); + } else { + setError("Unknown token exchange error"); + setErrorDescription( + error && + typeof error === "object" && + "message" in error && + typeof error.message === "string" + ? error.message + : "Unknown token exchange error", + ); + } setAccessToken(undefined); setRefreshToken(null); setAuthState("not_authenticated"); @@ -206,7 +214,7 @@ export default function useShapeDiverAuth(props?: Props): { setAuthState("authentication_in_progress"); getToken(); } - }, [codeData]); + }, [codeData, platformSdk]); // callback for auth using refresh token const authUsingRefreshToken = useCallback(async () => { @@ -215,12 +223,10 @@ export default function useShapeDiverAuth(props?: Props): { setError(null); setErrorDescription(null); setCodeData(null); - // create SDK - const client = create({clientId, baseUrl: authBaseUrl}); try { - // TODO: pass refresh token setAuthState("authentication_in_progress"); - const data = await client.authorization.refreshToken(); + const data = + await platformSdk.authorization.refreshToken(refreshToken); setAccessToken(data.access_token); setRefreshToken(data.refresh_token ?? null); setAuthState("authenticated"); @@ -249,7 +255,7 @@ export default function useShapeDiverAuth(props?: Props): { } } } - }, [refreshToken]); + }, [refreshToken, platformSdk]); // optionally automatically log in useEffect(() => { @@ -303,5 +309,8 @@ export default function useShapeDiverAuth(props?: Props): { errorDescription, authUsingRefreshToken, authState, + platformBaseUrl: authBaseUrl, + clientId, + platformSdk, }; } diff --git a/src/hooks/useShapeDiverStargate.ts b/src/hooks/useShapeDiverStargate.ts new file mode 100644 index 0000000..5d70d66 --- /dev/null +++ b/src/hooks/useShapeDiverStargate.ts @@ -0,0 +1,173 @@ +import { + Configuration, + ResCreateSessionByTicket, + SessionApi, +} from "@shapediver/sdk.geometry-api-sdk-v2"; +import { + SdPlatformModelGetEmbeddableFields, + SdPlatformSdk, +} from "@shapediver/sdk.platform-api-sdk-v1"; +import { + createSdk, + ISdStargateGetSupportedDataReplyDto, + ISdStargatePrepareModelCommandDto, + ISdStargatePrepareModelReplyDto, + ISdStargatePrepareModelResultEnum, + ISdStargateSdk, + ISdStargateStatusReplyDto, + SdStargateGetSupportedDataCommand, + SdStargatePrepareModelCommand, + SdStargateStatusCommand, +} from "@shapediver/sdk.stargate-sdk-v1"; +import {useEffect, useState} from "react"; +import packagejson from "../../package.json"; + +const firstActivity = Math.floor(Date.now() / 1000); +const modelIdSessionMap: { + [key: string]: {config: Configuration; session: ResCreateSessionByTicket}; +} = {}; + +interface Props { + /** The access token to use. */ + accessToken: string | undefined; + /** The platform SDK to use. */ + platformSdk: SdPlatformSdk; + /** Supported data. */ + supportedData: Partial; + /** Handler for command messages for which no handler is registered. */ + serverCommandHandler?: (payload: unknown) => void; + /** + * Handler for connection errors, called if an error message has been + * received from the Stargate server. + */ + connectionErrorHandler?: (msg: string) => void; + /** + * Handler called when the established connection is closed by the Stargate server + * or other external circumstances + */ + disconnectHandler?: (msg: string) => void; +} + +/** + * Hook to manage authentication with ShapeDiver via OAuth2 Authorization Code Flow with PKCE. + * @returns + */ +export default function useShapeDiverStargate(props?: Props): { + /** The platform SDK. In case no access token is present, an unauthenticated SDK will be returned. */ + stargateSdk: ISdStargateSdk | null; +} { + const { + accessToken, + platformSdk, + supportedData, + serverCommandHandler, + connectionErrorHandler, + disconnectHandler, + } = props || {}; + + const [stargateSdk, setStargateSdk] = useState(null); + + useEffect(() => { + const init = async (jwt: string, platformSdk: SdPlatformSdk) => { + // get Stargate endpoint to use + const endpoints = (await platformSdk.stargate.getConfig())?.data + .endpoint; + const endpoint = endpoints + ? endpoints[Object.keys(endpoints)[0]] + : "prod-sg.eu-central-1.shapediver.com"; + // create and configure the SDK + const sdk = await createSdk() + .setBaseUrl(endpoint) + .setServerCommandHandler( + serverCommandHandler ?? + ((payload: unknown) => { + console.log("Received Stargate command:", payload); + }), + ) + .setConnectionErrorHandler( + connectionErrorHandler ?? + ((msg: string) => + console.error(`Stargate connection error: ${msg}`)), + ) + .setDisconnectHandler( + disconnectHandler ?? + ((msg: string) => + console.error(`Stargate disconnected: ${msg}`)), + ) + .build(); + // register the client + await sdk.register( + jwt, + "Stargate Web Client", + packagejson.version, + navigator.platform || "", + window.location.hostname, + "", + ); + // register a handler for the status command + new SdStargateStatusCommand(sdk).registerHandler( + async (): Promise => ({ + firstActivity, + latestActivity: Math.floor(Date.now() / 1000), + }), + ); + // register a handler for the get supported data command + new SdStargateGetSupportedDataCommand(sdk).registerHandler( + async (): Promise => ({ + parameterTypes: [], + typeHints: [], + contentTypes: [], + fileExtensions: [], + ...supportedData, + }), + ); + // register a handler for the prepare model command + new SdStargatePrepareModelCommand(sdk).registerHandler( + async ( + data: ISdStargatePrepareModelCommandDto, + ): Promise => { + // create a session for the model if none exists yet + if (!modelIdSessionMap[data.model.id]) { + const model = ( + await platformSdk.models.get(data.model.id, [ + SdPlatformModelGetEmbeddableFields.BackendSystem, + SdPlatformModelGetEmbeddableFields.Ticket, + SdPlatformModelGetEmbeddableFields.TokenExport, + ]) + ).data; + const config = new Configuration({ + accessToken: model.access_token, + basePath: model.backend_system!.model_view_url, + }); + const session = ( + await new SessionApi(config).createSessionByTicket( + model.ticket!.ticket!, + ) + ).data; + modelIdSessionMap[data.model.id] = {config, session}; + } + + return { + info: { + result: ISdStargatePrepareModelResultEnum.SUCCESS, + }, + }; + }, + ); + // store the SDK in state + setStargateSdk(sdk); + }; + if (accessToken && platformSdk) init(accessToken, platformSdk); + }, [ + accessToken, + platformSdk, + supportedData, + serverCommandHandler, + connectionErrorHandler, + disconnectHandler, + ]); + + return { + stargateSdk, + }; +} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 6d29074..2fca08d 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,5 +1,13 @@ +import { ISdStargateGetSupportedDataReplyDto } from "@shapediver/sdk.stargate-sdk-v1"; import React from "react"; import useShapeDiverAuth from "~/hooks/useShapeDiverAuth"; +import useShapeDiverStargate from "~/hooks/useShapeDiverStargate"; + +const supportedData: Partial = { + contentTypes: ["application/json"], + fileExtensions: ["json"], + parameterTypes: ["File"], +}; export default function HomePage() { const { @@ -10,7 +18,13 @@ export default function HomePage() { initiateShapeDiverAuth, authUsingRefreshToken, authState, + platformSdk, } = useShapeDiverAuth({ autoLogin: true }); + const { stargateSdk } = useShapeDiverStargate({ + accessToken, + platformSdk, + supportedData, + }); return ( <> @@ -18,7 +32,8 @@ export default function HomePage() { {errorDescription &&

Error description: {errorDescription}

}

Authentication state: {authState}

{accessToken &&

Access Token: {accessToken}

} - {refreshToken &&
Refresh Token: {refreshToken}
} + {refreshToken &&

Refresh Token: {refreshToken}

} + {stargateSdk &&

Stargate SDK initialized

}

{authState === "not_authenticated" ? (