Repository: stateful/vscode-runme
Pull request #1488 (2024-07-18 17:38:44)

Platform auth secrets per stage
I believe the reason why we see "false positive" errors when switching stages is that auth tokens aren't valid across stages (staging, prod, etc). The solution is fairly simple by stashing the token into the secrets store under keys that are unique to the stages.

sonarcloud[bot]: ## [![Quality Gate Failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-failed-20px.png 'Quality Gate Failed')](https://sonarcloud.io/dashboard?id=stateful_vscode-runme&pullRequest=1488) **Quality Gate failed**  
Failed conditions  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [1 Security Hotspot](https://sonarcloud.io/project/security_hotspots?id=stateful_vscode-runme&pullRequest=1488&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=stateful_vscode-runme&pullRequest=1488)



Patch:
diff --git a/src/client/components/terminal/index.ts b/src/client/components/terminal/index.ts
index dff09bad..eba93ac9 100644
--- a/src/client/components/terminal/index.ts
+++ b/src/client/components/terminal/index.ts
@@ -376,6 +376,9 @@ export class TerminalView extends LitElement {
   @property({ type: Boolean })
   isSessionOutputsEnabled: boolean = false
 
+  @property({ type: Boolean })
+  isPlatformAuthEnabled: boolean = false
+
   constructor() {
     super()
     this.windowSize = {
@@ -837,7 +840,7 @@ export class TerminalView extends LitElement {
           }}"
         ></copy-button>
         ${when(
-          this.isSessionOutputsEnabled,
+          this.isSessionOutputsEnabled && !this.isPlatformAuthEnabled,
           () => {
             return html`<gist-cell @onGist="${this.#openSessionOutput}"></gist-cell>`
           },
diff --git a/src/client/index.ts b/src/client/index.ts
index 0283a94c..6c0d20fd 100644
--- a/src/client/index.ts
+++ b/src/client/index.ts
@@ -148,6 +148,13 @@ export const activate: ActivationFunction = (context: RendererContext<void>) =>
             )
           }
 
+          if (payload.output.isPlatformAuthEnabled) {
+            terminalElement.setAttribute(
+              'isPlatformAuthEnabled',
+              payload.output.isPlatformAuthEnabled.toString(),
+            )
+          }
+
           element.appendChild(terminalElement)
           break
 
diff --git a/src/extension/cell.ts b/src/extension/cell.ts
index decefa80..6edccf13 100644
--- a/src/extension/cell.ts
+++ b/src/extension/cell.ts
@@ -244,6 +244,7 @@ export class NotebookCellOutputManager {
                 initialRows: terminalRows || terminalConfigurations.rows,
                 enableShareButton: isRunmeAppButtonsEnabled(),
                 isAutoSaveEnabled: isSignedIn ? ContextState.getKey(NOTEBOOK_AUTOSAVE_ON) : false,
+                isPlatformAuthEnabled: isPlatformAuthEnabled(),
                 isSessionOutputsEnabled,
                 ...terminalConfigurations,
               },
diff --git a/src/extension/provider/statefulAuth.ts b/src/extension/provider/statefulAuth.ts
index 8e568eb2..7ae01363 100644
--- a/src/extension/provider/statefulAuth.ts
+++ b/src/extension/provider/statefulAuth.ts
@@ -54,6 +54,11 @@ const passthrough = (value: any, resolve: (value?: any) => void) => resolve(valu
 
 export class StatefulAuthProvider implements AuthenticationProvider, Disposable {
   #disposables: Disposable[] = []
+  // used as compound key in a hash-table; does not contain sensitive data
+  #insensitiveHashedApiUrl: string = crypto
+    .createHash('sha1')
+    .update(getRunmeAppUrl(['api']))
+    .digest('hex')
   #pendingStates: string[] = []
   #codeVerfifiers = new Map<string, string>()
   #scopes = new Map<string, string[]>()
@@ -457,8 +462,12 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable
     return currentTime < oneHourBeforeExpiration
   }
 
+  private get sessionSecretKey() {
+    return `${SESSIONS_SECRET_KEY}.${this.#insensitiveHashedApiUrl}`
+  }
+
   private async getAllSessions(): Promise<StatefulAuthSession[]> {
-    const allSessions = await this.context.secrets.get(SESSIONS_SECRET_KEY)
+    const allSessions = await this.context.secrets.get(this.sessionSecretKey)
     if (!allSessions) {
       return []
     }
@@ -487,7 +496,7 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable
       changed: StatefulAuthSession[]
     },
   ) {
-    await this.context.secrets.store(SESSIONS_SECRET_KEY, JSON.stringify(sessions))
+    await this.context.secrets.store(this.sessionSecretKey, JSON.stringify(sessions))
     this.#onSessionChange.fire(changes)
   }
 
diff --git a/src/types.ts b/src/types.ts
index 5ed5186b..e366bdf9 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -374,6 +374,7 @@ interface Payload {
     enableShareButton: boolean
     isAutoSaveEnabled: boolean
     isSessionOutputsEnabled: boolean
+    isPlatformAuthEnabled: boolean
   }
   [OutputType.github]?: GitHubState
   [OutputType.stdout]: object


Test patch:
diff --git a/tests/extension/provider/statefulAuth.test.ts b/tests/extension/provider/statefulAuth.test.ts
index 3f8c5521..e64029e5 100644
--- a/tests/extension/provider/statefulAuth.test.ts
+++ b/tests/extension/provider/statefulAuth.test.ts
@@ -1,23 +1,33 @@
+import * as crypto from 'node:crypto'
+
 import { expect, vi, beforeEach, describe, it } from 'vitest'
 import { Uri, ExtensionContext } from 'vscode'
 
 import { StatefulAuthProvider } from '../../../src/extension/provider/statefulAuth'
 import { RunmeUriHandler } from '../../../src/extension/handler/uri'
+import { getRunmeAppUrl } from '../../../src/utils/configuration'
 
 vi.mock('vscode')
 vi.mock('vscode-telemetry')
 
+vi.mock('../../../src/utils/configuration', () => {
+  return {
+    getRunmeAppUrl: vi.fn(),
+  }
+})
+
+const contextFake: ExtensionContext = {
+  extensionUri: Uri.parse('file:///Users/fakeUser/projects/vscode-runme'),
+} as any
+
+const uriHandlerFake: RunmeUriHandler = {} as any
+
 describe('StatefulAuthProvider', () => {
   let provider: StatefulAuthProvider
 
   beforeEach(() => {
-    const contextMock: ExtensionContext = {
-      extensionUri: Uri.parse('file:///Users/fakeUser/projects/vscode-runme'),
-    } as any
-
-    const uriHandler: RunmeUriHandler = {} as any
-
-    provider = new StatefulAuthProvider(contextMock, uriHandler)
+    vi.mocked(getRunmeAppUrl).mockReturnValue('https://api.for.platform')
+    provider = new StatefulAuthProvider(contextFake, uriHandlerFake)
   })
 
   it('gets sessions', async () => {
@@ -30,3 +40,30 @@ describe('StatefulAuthProvider', () => {
     expect(sessions).toEqual([])
   })
 })
+
+describe('StatefulAuthProvider#sessionSecretKey', () => {
+  let provider: StatefulAuthProvider
+
+  it('returns a secret key for production', () => {
+    provider = new StatefulAuthProvider(contextFake, uriHandlerFake)
+
+    // access private prop
+    expect((provider as any).sessionSecretKey).toEqual(
+      'stateful.sessions.8e0b4f45d990c8b235d4036020299d4af5c8c4a0',
+    )
+  })
+
+  it('includes a hashed URL of the stage into the secret key', () => {
+    const fakeStagingUrl = 'https://api.staging.for.platform'
+    vi.mocked(getRunmeAppUrl).mockReturnValue(fakeStagingUrl)
+
+    provider = new StatefulAuthProvider(contextFake, uriHandlerFake)
+    const hashed = crypto.createHash('sha1').update(fakeStagingUrl).digest('hex')
+
+    // access private prop
+    const sessionSecretKey = (provider as any).sessionSecretKey
+
+    expect(sessionSecretKey).toContain(hashed)
+    expect(sessionSecretKey).toEqual('stateful.sessions.5d458b91cb755f8e839839dd3d1b4d597bba2c11')
+  })
+})