diff --git a/src/app/scene.js b/src/app/scene.js index 65e40cd7e..b98a28914 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -19,6 +19,7 @@ import { describeError } from './errors.js'; export async function createApplicationScene({ requestServices, googleApiKey, + googleTokens = null, cesiumToken, credits, MapController = MapStackController, @@ -59,11 +60,12 @@ export async function createApplicationScene({ registerDataCredits(viewer, credits); configureCreditKeyboardAccess(document); loaderStatus.textContent = - googleApiKey || cesiumToken + googleApiKey || googleTokens || cesiumToken ? 'Loading Google 3D Tiles...' : 'Loading the keyless globe...'; const photoreal = await loadPhotorealisticTileset(Cesium, { googleApiKey, + googleTokens, cesiumToken, }); const tileset = photoreal.tileset; diff --git a/src/mapStartup.test.mjs b/src/mapStartup.test.mjs index 521c1842b..7921fc2f6 100644 --- a/src/mapStartup.test.mjs +++ b/src/mapStartup.test.mjs @@ -15,7 +15,16 @@ function fakeCesium(outcomes = []) { return { calls, Ion: { defaultAccessToken: undefined }, - GoogleMaps: { defaultApiKey: undefined }, + GoogleMaps: { + defaultApiKey: undefined, + mapTilesApiEndpoint: 'https://tile.googleapis.com/', + getDefaultCredit: () => undefined, + }, + Resource: class { + constructor(options) { + Object.assign(this, options); + } + }, async createGooglePhotorealistic3DTileset(options) { calls.push({ options, googleKey: options.key }); return next(); @@ -29,6 +38,7 @@ function fakeCesium(outcomes = []) { async fromUrl(resource, options) { calls.push({ options, + resource, ionToken: resource.accessToken, assetId: resource.assetId, }); @@ -140,3 +150,69 @@ test('independent source configurations never mutate shared SDK credentials', as assert.equal(Cesium.Ion.defaultAccessToken, 'untouched-ion'); assert.equal(Cesium.GoogleMaps.defaultApiKey, 'untouched-google'); }); + +function tokenSource(...issued) { + const requests = []; + return { + requests, + async token(options = {}) { + requests.push(options); + return issued.shift() ?? null; + }, + }; +} + +test('without a key, server tokens load Google 3D directly', async () => { + const tileset = { id: 'token' }; + const Cesium = fakeCesium([tileset]); + const result = await loadPhotorealisticTileset(Cesium, { + googleTokens: tokenSource('short-lived'), + cesiumToken: 'ion-secret', + }); + assert.equal(result.tileset, tileset); + assert.equal(result.route, 'google-token'); + const { resource, options } = Cesium.calls[0]; + assert.equal( + resource.url, + 'https://tile.googleapis.com/v1/3dtiles/root.json', + ); + assert.equal(resource.headers.Authorization, 'Bearer short-lived'); + assert.equal(resource.queryParameters, undefined); + assert.equal(options.enableCollision, true); +}); + +test('a tile refused for an expired token retries once with a renewed one', async () => { + const Cesium = fakeCesium([{ id: 'token' }]); + const tokens = tokenSource('first', 'second'); + await loadPhotorealisticTileset(Cesium, { googleTokens: tokens }); + const { resource } = Cesium.calls[0]; + const tile = { headers: { ...resource.headers } }; + assert.equal(await resource.retryCallback(tile, { statusCode: 401 }), true); + assert.equal(tile.headers.Authorization, 'Bearer second'); + assert.deepEqual(tokens.requests.at(-1), { replacing: 'first' }); + assert.equal(resource.retryAttempts, 1); + assert.equal(await resource.retryCallback(tile, { statusCode: 500 }), false); +}); + +test('a key takes precedence over server tokens', async () => { + const Cesium = fakeCesium([{ id: 'direct' }]); + const tokens = tokenSource('short-lived'); + const result = await loadPhotorealisticTileset(Cesium, { + googleApiKey: 'google-secret', + googleTokens: tokens, + }); + assert.equal(result.route, 'google-direct'); + assert.equal(tokens.requests.length, 0); +}); + +test('a server without tokens falls back to ion', async () => { + const tileset = { id: 'ion' }; + const Cesium = fakeCesium([tileset]); + const result = await loadPhotorealisticTileset(Cesium, { + googleTokens: tokenSource(), + cesiumToken: 'ion-secret', + }); + assert.equal(result.route, 'google-ion'); + assert.equal(result.errors.length, 1); + assert.equal(Cesium.calls.length, 1); +}); diff --git a/src/maps/google3d.js b/src/maps/google3d.js index bb45aa800..aab45ea35 100644 --- a/src/maps/google3d.js +++ b/src/maps/google3d.js @@ -16,30 +16,43 @@ export function selectMapStartupRoute({ /** * Load Google Photorealistic 3D Tiles through direct Google access when - * configured, otherwise through Cesium ion's hosted Google asset. If the - * direct request fails and an ion token is available, ion is the recovery path. + * configured, otherwise through Cesium ion's hosted Google asset. Direct + * access uses a browser key, or else short-lived tokens from the app's + * server (see googleTokens.js). If direct access fails and an ion token is + * available, ion is the recovery path. * * @param {object} Cesium - * @param {{googleApiKey?: string, cesiumToken?: string}} credentials - * @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-ion'|'osm', errors: Error[]}>} + * @param {{googleApiKey?: string, cesiumToken?: string, googleTokens?: {token: Function}|null}} credentials + * @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-token'|'google-ion'|'osm', errors: Error[]}>} */ export async function loadPhotorealisticTileset( Cesium, - { googleApiKey = '', cesiumToken = '' } = {}, + { googleApiKey = '', cesiumToken = '', googleTokens = null } = {}, ) { const googleKey = clean(googleApiKey); const ionToken = clean(cesiumToken); const errors = []; const attempts = []; - if (googleKey) attempts.push({ route: 'google-direct', googleKey }); - if (ionToken) attempts.push({ route: 'google-ion', googleKey: undefined }); + if (googleKey) + attempts.push({ + route: 'google-direct', + create: () => createGoogleDirectTileset(Cesium, googleKey), + }); + else if (googleTokens) + attempts.push({ + route: 'google-token', + create: () => createGoogleTokenTileset(Cesium, googleTokens), + }); + if (ionToken) + attempts.push({ + route: 'google-ion', + create: () => createGoogleIonTileset(Cesium, ionToken), + }); for (const attempt of attempts) { try { - const tileset = attempt.googleKey - ? await createGoogleDirectTileset(Cesium, attempt.googleKey) - : await createGoogleIonTileset(Cesium, ionToken); + const tileset = await attempt.create(); return { tileset, route: attempt.route, errors }; } catch (error) { errors.push(error instanceof Error ? error : new Error(String(error))); @@ -60,6 +73,39 @@ export function createGoogleDirectTileset(Cesium, key) { ); } +/** + * Google 3D with short-lived tokens instead of a key. Every tile request + * carries the token as a header; a tile refused for an expired token gets + * the renewed one and is tried once more. + */ +export async function createGoogleTokenTileset(Cesium, tokens) { + const token = await tokens.token(); + if (!token) throw new Error('Google 3D tokens are not offered'); + const credit = Cesium.GoogleMaps.getDefaultCredit?.(); + const resource = new Cesium.Resource({ + url: `${Cesium.GoogleMaps.mapTilesApiEndpoint}v1/3dtiles/root.json`, + headers: { Authorization: `Bearer ${token}` }, + credits: credit ? [credit] : undefined, + retryAttempts: 1, + async retryCallback(failed, error) { + if (![401, 403].includes(error?.statusCode)) return false; + const used = failed.headers.Authorization?.replace(/^Bearer /, ''); + const renewed = await tokens.token({ replacing: used }); + if (!renewed || renewed === used) return false; + failed.headers.Authorization = `Bearer ${renewed}`; + return true; + }, + }); + // The settings Cesium's Google helper applies to the key route. + return Cesium.Cesium3DTileset.fromUrl(resource, { + cacheBytes: 1536 * 1024 * 1024, + maximumCacheOverflowBytes: 1024 * 1024 * 1024, + enableCollision: true, + // Tiles keep drawing their own texture while draped weather loads. + asynchronouslyLoadImagery: true, + }); +} + export async function createGoogleIonTileset( Cesium, accessToken, diff --git a/src/maps/googleTokens.js b/src/maps/googleTokens.js new file mode 100644 index 000000000..7896c8f5c --- /dev/null +++ b/src/maps/googleTokens.js @@ -0,0 +1,62 @@ +/** + * Short-lived Google access tokens the app's server may offer in place of a + * browser key. A server that offers them answers GET GOOGLE_TOKEN_PATH with + * `{ accessToken, expiresAt }` (expiresAt in epoch milliseconds); any other + * answer means it does not, and the app carries on without them. + */ +export const GOOGLE_TOKEN_PATH = '/api/google/tiles-token'; + +/** Renew a token this long before it expires. */ +const RENEW_EARLY_MS = 60_000; + +function parseToken(data, now) { + const accessToken = data?.accessToken; + const expiresAt = Number(data?.expiresAt); + if (typeof accessToken !== 'string' || accessToken.length === 0) return null; + if (!Number.isFinite(expiresAt) || expiresAt <= now) return null; + return { accessToken, expiresAt }; +} + +/** + * A source of tokens from the app's server. `token()` resolves the current + * token, renewing it shortly before it expires; `token({ replacing })` + * renews only if `replacing` is still the current token, so many tile + * requests rejected for the same expired token renew it once. Resolves null + * when the server does not offer tokens. + */ +export function createGoogleTokenSource({ + fetchImpl = (...args) => fetch(...args), + url = GOOGLE_TOKEN_PATH, + now = () => Date.now(), +} = {}) { + let current = null; + let pending = null; + + async function request() { + try { + const response = await fetchImpl(url, { + headers: { Accept: 'application/json' }, + cache: 'no-store', + }); + if (!response.ok) return null; + return parseToken(await response.json(), now()); + } catch { + return null; + } + } + + return { + async token({ replacing } = {}) { + const usable = + current && + current.expiresAt - RENEW_EARLY_MS > now() && + (replacing === undefined || current.accessToken !== replacing); + if (usable) return current.accessToken; + pending ??= request().finally(() => { + pending = null; + }); + current = await pending; + return current?.accessToken ?? null; + }, + }; +} diff --git a/src/maps/googleTokens.test.mjs b/src/maps/googleTokens.test.mjs new file mode 100644 index 000000000..87b7cf8dc --- /dev/null +++ b/src/maps/googleTokens.test.mjs @@ -0,0 +1,64 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createGoogleTokenSource, GOOGLE_TOKEN_PATH } from './googleTokens.js'; + +function server(answers) { + const calls = []; + return { + calls, + fetchImpl: async (url) => { + calls.push(url); + const answer = answers.shift(); + if (answer instanceof Error) throw answer; + return answer; + }, + }; +} + +const offer = (accessToken, expiresAt) => + Response.json({ accessToken, expiresAt }); + +test('a token is reused until shortly before it expires', async () => { + let now = 1_000_000; + const { calls, fetchImpl } = server([ + offer('first', now + 900_000), + offer('second', now + 1_800_000), + ]); + const tokens = createGoogleTokenSource({ fetchImpl, now: () => now }); + assert.equal(await tokens.token(), 'first'); + assert.equal(await tokens.token(), 'first'); + assert.deepEqual(calls, [GOOGLE_TOKEN_PATH]); + now += 850_000; + assert.equal(await tokens.token(), 'second'); + assert.equal(calls.length, 2); +}); + +test('requests refused for the same token renew it once', async () => { + const now = 1_000_000; + const { calls, fetchImpl } = server([ + offer('first', now + 900_000), + offer('second', now + 900_000), + ]); + const tokens = createGoogleTokenSource({ fetchImpl, now: () => now }); + await tokens.token(); + const renewed = await Promise.all([ + tokens.token({ replacing: 'first' }), + tokens.token({ replacing: 'first' }), + ]); + assert.deepEqual(renewed, ['second', 'second']); + assert.equal(await tokens.token({ replacing: 'first' }), 'second'); + assert.equal(calls.length, 2); +}); + +test('a server that does not offer tokens yields none', async () => { + for (const answer of [ + new Response('Not found', { status: 404 }), + new Response('', { status: 200 }), + Response.json({ accessToken: '', expiresAt: Date.now() + 60_000 }), + Response.json({ accessToken: 'stale', expiresAt: 1 }), + new TypeError('offline'), + ]) { + const { fetchImpl } = server([answer]); + assert.equal(await createGoogleTokenSource({ fetchImpl }).token(), null); + } +}); diff --git a/src/standalone/application.js b/src/standalone/application.js index cbdf9cb7f..5dd1b9641 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -6,6 +6,7 @@ import { createStandaloneScene } from './scene.js'; import { createStandaloneControls } from './controls.js'; import { createStandaloneData } from './data.js'; import { createStandaloneTools } from './tools.js'; +import { createGoogleTokenSource } from '../maps/googleTokens.js'; // The existing controls and layer catalog contain page-scoped state. let constructed = false; @@ -39,6 +40,9 @@ export function createStandaloneApplication({ const scene = await createStandaloneScene({ ...context, googleApiKey, + // Without a key, Google 3D can still load with tokens from the + // app's server when it offers them. + googleTokens: googleApiKey ? null : createGoogleTokenSource(), cesiumToken, loaderStatus, });