diff --git a/package.json b/package.json index ab71683..82af9d4 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@salesforce/sf-plugins-core": "^11.2.4", "@inquirer/select": "^2.4.7", "@inquirer/prompts": "^5.3.8", + "axios": "^1.7.7", "chalk": "^5.3.0", "lwc": "7.1.3", "lwr": "0.14.3", diff --git a/src/commands/lightning/dev/site.ts b/src/commands/lightning/dev/site.ts index d3e3111..a41d4ea 100644 --- a/src/commands/lightning/dev/site.ts +++ b/src/commands/lightning/dev/site.ts @@ -64,8 +64,8 @@ export default class LightningDevSite extends SfCommand { } } - // Pass the org auth token so LWR can make authenticated requests to core - const authToken = org.getConnection().accessToken ?? ''; + // Establish a valid access token for this site + const authToken = await selectedSite.setupAuth(); // Start the dev server await expDev({ diff --git a/src/shared/experience/expSite.ts b/src/shared/experience/expSite.ts index 38ea2b5..233dc7b 100644 --- a/src/shared/experience/expSite.ts +++ b/src/shared/experience/expSite.ts @@ -7,6 +7,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { Org, SfError } from '@salesforce/core'; +import axios from 'axios'; export type SiteMetadata = { bundleName: string; @@ -94,6 +95,28 @@ export class ExperienceSite { return experienceSites; } + /** + * Esablish a valid token for this local development session + * + * @returns sid token for proxied site requests + */ + public async setupAuth(): Promise { + let sidToken = ''; // Default to guest user access only + + // Use environment variable for now if users want to just have guest access only + if (process.env.SITE_GUEST_ACCESS !== 'true') { + try { + const networkId = await this.getNetworkId(); + sidToken = await this.getNewSidToken(networkId); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Failed to establish authentication for site', e); + } + } + + return sidToken; + } + public async isUpdateAvailable(): Promise { const localMetadata = this.getLocalMetadata(); if (!localMetadata) { @@ -225,6 +248,99 @@ export class ExperienceSite { return resourcePath; } + + private async getNetworkId(): Promise { + const conn = this.org.getConnection(); + // Query the Network object for the network with the given site name + const result = await conn.query<{ Id: string }>(`SELECT Id FROM Network WHERE Name = '${this.siteDisplayName}'`); + + const record = result.records[0]; + if (record) { + let networkId = record.Id; + // Subtract the last three characters from the Network ID + networkId = networkId.substring(0, networkId.length - 3); + return networkId; + } else { + throw new Error(`NetworkId for site: '${this.siteDisplayName}' could not be found`); + } + } + + private async getNewSidToken(networkId: string): Promise { + // Get the connection and access token from the org + const conn = this.org.getConnection(); + const orgId = this.org.getOrgId(); + + // Not sure if we need to do this + const orgIdMinus3 = orgId.substring(0, orgId.length - 3); + const accessToken = conn.accessToken; + const instanceUrl = conn.instanceUrl; // Org URL + + // Make the GET request without following redirects + if (accessToken) { + // TODO should we try and refresh auth here? + // await conn.refreshAuth(); + + // Call out to the switcher servlet to establish a session + const switchUrl = `${instanceUrl}/servlet/networks/switch?networkId=${networkId}`; + const cookies = [`sid=${accessToken}`, `oid=${orgIdMinus3}`].join('; ').trim(); + let response = await axios.get(switchUrl, { + headers: { + Cookie: cookies, + }, + withCredentials: true, + maxRedirects: 0, // Prevent axios from following redirects + validateStatus: (status) => status >= 200 && status < 400, // Accept 3xx status codes + }); + + // Extract the Location callback header + const locationHeader = response.headers['location'] as string; + if (locationHeader) { + // Parse the URL to extract the 'sid' parameter + const urlObj = new URL(locationHeader); + const sid = urlObj.searchParams.get('sid') ?? ''; + const cookies2 = ['__Secure-has-sid=1', `sid=${sid}`, `oid=${orgIdMinus3}`].join('; ').trim(); + + // Request the location header to establish our session with the servlet + response = await axios.get(urlObj.toString(), { + headers: { + Cookie: cookies2, + }, + withCredentials: true, + maxRedirects: 0, // Prevent axios from following redirects + validateStatus: (status) => status >= 200 && status < 400, // Accept 3xx status codes + }); + const setCookieHeader = response.headers['set-cookie']; + if (setCookieHeader) { + // Find the 'sid' cookie in the set-cookie header + const sidCookie = setCookieHeader.find((cookieStr: string) => cookieStr.startsWith('sid=')); + if (sidCookie) { + // Extract the sid value from the set-cookie string + const sidMatch = sidCookie.match(/sid=([^;]+)/); + if (sidMatch?.[1]) { + const sidToken = sidMatch[1]; + return sidToken; + } + } + } + } + + // if we can't establish a valid session this way, lets just warn the user and utilize the guest user context for the site + // eslint-disable-next-line no-console + console.warn( + `Warning: could not establish valid auth token for your site '${this.siteDisplayName}'.` + + 'Local Dev proxied requests to your site may fail or return data from the guest user context.' + ); + + return ''; // Site will be guest user access only + } + + // Not sure what scenarios we don't have an access token at all, but lets output a separate message here so we can distinguish these edge cases + // eslint-disable-next-line no-console + console.warn( + 'Warning: sf cli org connection missing accessToken. Local Dev proxied requests to your site may fail or return data from the guest user context.' + ); + return ''; + } } /** diff --git a/yarn.lock b/yarn.lock index bae60f5..cdf33b5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6126,6 +6126,15 @@ axe-core@^4.6.2: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.10.0.tgz#d9e56ab0147278272739a000880196cdfe113b59" integrity sha512-Mr2ZakwQ7XUAjp7pAwQWRhhK8mQQ6JAaNWSjmjxil0R8BPioMtQsTLOolGYkji1rcL++3dCqZA3zWqpT+9Ew6g== +axios@^1.7.7: + version "1.7.7" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.7.tgz#2f554296f9892a72ac8d8e4c5b79c14a91d0a47f" + integrity sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + axobject-query@^3.1.1: version "3.2.4" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-3.2.4.tgz#6dfba930294ea14d7d2fc68b9d007211baedb94c" @@ -8695,6 +8704,11 @@ follow-redirects@^1.0.0: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== +follow-redirects@^1.15.6: + version "1.15.9" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" + integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== + for-each@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" @@ -12991,6 +13005,11 @@ proxy-addr@~2.0.7: forwarded "0.2.0" ipaddr.js "1.9.1" +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + psl@^1.1.33: version "1.9.0" resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7"