diff --git a/.gitignore b/.gitignore index 9f88bb627..4093c641e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ examples/.vscode assets .venv **/*-01*.md +secrets diff --git a/.vscodeignore b/.vscodeignore index 6a7e2b006..862d33866 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -18,3 +18,4 @@ tests coverage dagger dagger.json +secrets diff --git a/package-lock.json b/package-lock.json index c93caeaca..12c6daedb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "got": "^11.8.2", "graphql": "^16.8.0", "jsonc-parser": "^3.2.1", + "jsonwebtoken": "^9.0.2", "lit": "^3.2.1", "octokit": "^4.0.2", "simple-git": "^3.27.0", @@ -63,6 +64,7 @@ "@graphql-codegen/client-preset": "^4.4.0", "@graphql-codegen/typescript": "4.1.0", "@octokit/rest": "^19.0.13", + "@types/jsonwebtoken": "^9.0.7", "@types/node": "^20.17.2", "@types/node-fetch": "^2.6.11", "@types/semver": "^7.5.8", @@ -7017,6 +7019,16 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.7.tgz", + "integrity": "sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", @@ -16303,7 +16315,7 @@ "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", - "dev": true, + "license": "MIT", "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", @@ -16325,7 +16337,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", - "dev": true, "dependencies": { "buffer-equal-constant-time": "1.0.1", "ecdsa-sig-formatter": "1.0.11", @@ -16336,7 +16347,6 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", - "dev": true, "dependencies": { "jwa": "^1.4.1", "safe-buffer": "^5.0.1" @@ -16784,14 +16794,12 @@ "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "dev": true + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "dev": true + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" }, "node_modules/lodash.isequal": { "version": "4.5.0", @@ -16802,26 +16810,22 @@ "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "dev": true + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" }, "node_modules/lodash.isnumber": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "dev": true + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" }, "node_modules/lodash.isstring": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "dev": true + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -16832,8 +16836,7 @@ "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "dev": true + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" }, "node_modules/lodash.pickby": { "version": "4.6.0", diff --git a/package.json b/package.json index 9bee5d5ff..14b3cc33b 100644 --- a/package.json +++ b/package.json @@ -966,16 +966,25 @@ "default": true, "markdownDescription": "If set to `true` enables Stateful Authentication Provider" }, - "runme.aiBaseURL": { - "type": "string", - "default": "http://localhost:8877/api", - "description": "The base URL of the AI service." - }, "runme.app.docsUrl": { "type": "string", "scope": "window", "default": "https://docs.runme.dev", "markdownDescription": "Documentation Base URL" + }, + "runme.app.authTokenPath": { + "type": "string", + "markdownDescription": "Specifies the path to an auth token file to bootstrap a Stateful auth session" + }, + "runme.app.deleteAuthToken": { + "type": "boolean", + "default": true, + "markdownDescription": "If set to `true`, the auth token file will be deleted after the session ends" + }, + "runme.aiBaseURL": { + "type": "string", + "default": "http://localhost:8877/api", + "description": "The base URL of the AI service." } } } @@ -1226,6 +1235,7 @@ "@graphql-codegen/client-preset": "^4.4.0", "@graphql-codegen/typescript": "4.1.0", "@octokit/rest": "^19.0.13", + "@types/jsonwebtoken": "^9.0.7", "@types/node": "^20.17.2", "@types/node-fetch": "^2.6.11", "@types/semver": "^7.5.8", @@ -1307,6 +1317,7 @@ "got": "^11.8.2", "graphql": "^16.8.0", "jsonc-parser": "^3.2.1", + "jsonwebtoken": "^9.0.2", "lit": "^3.2.1", "octokit": "^4.0.2", "simple-git": "^3.27.0", diff --git a/src/extension/extension.ts b/src/extension/extension.ts index 530fe5d14..11cea3be8 100644 --- a/src/extension/extension.ts +++ b/src/extension/extension.ts @@ -18,7 +18,6 @@ import { NotebookUiEvent, Serializer, SyncSchema, FeatureName } from '../types' import { getDocsUrlFor, getForceNewWindowConfig, - getRunmeAppUrl, getServerRunnerVersion, getSessionOutputs, getServerLifecycleIdentity, @@ -467,23 +466,22 @@ export class RunmeExtension { } if (kernel.isFeatureOn(FeatureName.RequireStatefulAuth)) { - const forceLogin = kernel.isFeatureOn(FeatureName.ForceLogin) - context.subscriptions.push(new StatefulAuthProvider(context, uriHandler)) + const statefulAuthProvider = new StatefulAuthProvider(context, uriHandler) + context.subscriptions.push(statefulAuthProvider) + + const session = await getPlatformAuthSession(false, true) + let sessionFromToken = false + if (!session) { + sessionFromToken = await statefulAuthProvider.bootstrapFromToken() + } + + const forceLogin = kernel.isFeatureOn(FeatureName.ForceLogin) || sessionFromToken const silent = forceLogin ? undefined : true getPlatformAuthSession(forceLogin, silent) .then((session) => { if (session) { - const openDashboardStr = 'Open Dashboard' - window - .showInformationMessage('Logged into the Stateful Platform', openDashboardStr) - .then((answer) => { - if (answer === openDashboardStr) { - const dashboardUri = getRunmeAppUrl(['app']) - const uri = Uri.parse(dashboardUri) - env.openExternal(uri) - } - }) + statefulAuthProvider.showLoginNotification() } }) .catch((error) => { @@ -491,7 +489,7 @@ export class RunmeExtension { if (error instanceof Error) { message = error.message } else { - message = String(error) + message = JSON.stringify(error) } // https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/browser/mainThreadAuthentication.ts#L238 diff --git a/src/extension/provider/statefulAuth.ts b/src/extension/provider/statefulAuth.ts index 7ae013636..9c640427e 100644 --- a/src/extension/provider/statefulAuth.ts +++ b/src/extension/provider/statefulAuth.ts @@ -13,30 +13,38 @@ import { AuthenticationSession, AuthenticationProviderAuthenticationSessionsChangeEvent, Event, + workspace, } from 'vscode' -import { v4 as uuid } from 'uuid' +import { v4 as uuidv4 } from 'uuid' import fetch from 'node-fetch' +import jwt, { JwtPayload } from 'jsonwebtoken' -import { getRunmeAppUrl } from '../../utils/configuration' +import { getAuthTokenPath, getDeleteAuthToken, getRunmeAppUrl } from '../../utils/configuration' import { AuthenticationProviders, PLATFORM_USER_SIGNED_IN } from '../../constants' import { RunmeUriHandler } from '../handler/uri' import ContextState from '../contextState' +import getLogger from '../logger' + +const logger = getLogger('StatefulAuthProvider') const AUTH_NAME = 'Stateful' const SESSIONS_SECRET_KEY = `${AuthenticationProviders.Stateful}.sessions` interface TokenInformation { accessToken: string - refreshToken: string expiresIn: number } interface StatefulAuthSession extends AuthenticationSession { - refreshToken: string expiresIn: number isExpired: boolean } +interface DecodedToken extends JwtPayload { + exp?: number + scope?: string +} + // Interface declaration for a PromiseAdapter interface PromiseAdapter { // Function signature of the PromiseAdapter @@ -137,25 +145,8 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable return [session] } - const token = await this.getAccessToken(session.refreshToken) - const { accessToken, refreshToken, expiresIn } = token - - if (accessToken) { - const updatedSession = { - ...session, - accessToken, - refreshToken, - expiresIn: secsToUnixTime(expiresIn), - scopes: scopes, - } - - await this.updateSession(updatedSession) - ContextState.addKey(PLATFORM_USER_SIGNED_IN, true) - return [updatedSession] - } else { - ContextState.addKey(PLATFORM_USER_SIGNED_IN, false) - this.removeSession(session.id) - } + await ContextState.addKey(PLATFORM_USER_SIGNED_IN, false) + await this.removeSession(session.id) } } catch (e) { // Nothing to do @@ -171,7 +162,7 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable */ public async createSession(scopes: string[]): Promise { try { - const { accessToken, refreshToken, expiresIn } = await this.login(scopes) + const { accessToken, expiresIn } = await this.login(scopes) if (!accessToken) { throw new Error('Stateful login failure') @@ -179,10 +170,9 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable const userinfo: { name: string; email: string } = await this.getUserInfo(accessToken) const session: StatefulAuthSession = { - id: uuid(), + id: uuidv4(), expiresIn: secsToUnixTime(expiresIn), accessToken, - refreshToken, account: { label: userinfo.name, id: userinfo.email, @@ -191,8 +181,7 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable isExpired: false, } - ContextState.addKey(PLATFORM_USER_SIGNED_IN, true) - + await ContextState.addKey(PLATFORM_USER_SIGNED_IN, true) await this.persistSessions([session], { added: [session], removed: [], changed: [] }) return session } catch (e) { @@ -201,25 +190,6 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable } } - /** - * Update an existing session - * @param session - */ - private async updateSession(session: StatefulAuthSession): Promise { - const sessions = await this.getAllSessions() - if (!sessions.length) { - return - } - - const sessionIdx = await this.findSessionIndex(sessions, session) - if (sessionIdx < 0) { - return - } - - sessions[sessionIdx] = session - await this.persistSessions(sessions, { added: [], removed: [], changed: [session] }) - } - /** * Remove an existing session * @param sessionId @@ -248,6 +218,99 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable this.#disposables.forEach((d) => d.dispose()) } + public async bootstrapFromToken(): Promise { + try { + const authTokenUri = await this.getAuthTokenUri() + if (!authTokenUri) { + logger.info('No auth token file found, halting bootstrap from token.') + return false + } + const { token, payload } = await this.insecureDecode(authTokenUri) + const session = await this.buildSession(token, payload) + await this.persistSessions([session], { added: [session], removed: [], changed: [] }) + await this.deleteAuthTokenFile(authTokenUri) + return true + } catch (error) { + let message + if (error instanceof Error) { + message = error.message + } else { + message = JSON.stringify(error) + } + logger.error(message) + } + return false + } + + private async getAuthTokenUri(): Promise { + const authTokenPath = getAuthTokenPath() + if (!authTokenPath) { + return + } + + const authTokenUri = Uri.parse(authTokenPath) + const hasTokenFile = await workspace.fs.stat(authTokenUri).then( + () => true, + () => false, + ) + + if (!hasTokenFile) { + return + } + + return authTokenUri + } + + /** + * Decode a JWT token without verifying its signature. + */ + private async insecureDecode(authTokenUri: Uri) { + const bytes = await workspace.fs.readFile(authTokenUri) + if (!bytes?.length) { + throw new Error('Failed to read token file') + } + + const token = new TextDecoder().decode(bytes).trim() + const payload = jwt.decode(token) as DecodedToken + if (!payload) { + throw new Error('Failed to decode JWT token') + } + + return { payload, token } + } + + private async buildSession(token: string, payload: DecodedToken) { + if (!payload.exp || !payload.scope) { + throw new Error('Invalid token format, missing exp or scope') + } + + const { name, email } = await this.getUserInfo(token) + if (!name || !email) { + throw new Error('Failed to get user info from JWT token') + } + + const session: StatefulAuthSession = { + accessToken: token, + expiresIn: secsToUnixTime(payload.exp), + id: uuidv4(), + account: { + label: name, + id: email, + }, + scopes: payload.scope!.split(' '), + isExpired: false, + } + + return session + } + + private async deleteAuthTokenFile(authTokenUri: Uri) { + if (getDeleteAuthToken()) { + logger.info(`Deleting authToken file ${authTokenUri}`) + await workspace.fs.delete(authTokenUri) + } + } + /** * Log in to Stateful */ @@ -259,7 +322,7 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable cancellable: true, }, async (_, token) => { - const nonceId = uuid() + const nonceId = uuidv4() const scopeString = scopes.join(' ') scopes = this.getScopes(scopes) @@ -283,6 +346,7 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable const searchParams = new URLSearchParams({ state: encodeURIComponent(callbackUri.toString(true)), + checkSession: 'true', scope: scopes.join(' '), codeChallengeMethod: 'S256', codeChallenge: codeChallenge, @@ -345,6 +409,9 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable const code = query.get('code') const stateId = query.get('state') + const accessToken = query.get('accessToken') + const expiresIn = query.get('expiresIn') + if (!code) { reject(new Error('No code')) return @@ -366,6 +433,11 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable return } + if (accessToken && expiresIn) { + resolve({ accessToken, expiresIn: Number.parseInt(expiresIn) }) + return + } + const postData = { code, codeVerifier, @@ -379,9 +451,12 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable body: JSON.stringify(postData), }) - const { accessToken, refreshToken, expiresIn } = await response.json() + const json = await response.json() - resolve({ accessToken, refreshToken, expiresIn }) + resolve({ + accessToken: json.accessToken, + expiresIn: json.expiresIn, + }) } /** @@ -395,7 +470,13 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable Authorization: `Bearer ${token}`, }, }) - return await response.json() + + const json = await response.json() + if (!response.ok) { + return Promise.reject(json) + } + + return Promise.resolve(json) } /** @@ -405,9 +486,6 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable private getScopes(scopes: string[] = []): string[] { const modifiedScopes = [...scopes] - if (!modifiedScopes.includes('offline_access')) { - modifiedScopes.push('offline_access') - } if (!modifiedScopes.includes('openid')) { modifiedScopes.push('openid') } @@ -421,30 +499,6 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable return modifiedScopes.sort() } - /** - * Retrieve a new access token by the refresh token - * @param refreshToken - * @param clientId - * @returns - */ - private async getAccessToken(currentRefreshToken: string): Promise { - const postData = { - refreshToken: currentRefreshToken, - } - - const response = await fetch(`${getRunmeAppUrl(['api'])}idp-refresh-token`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(postData), - }) - - const { accessToken, refreshToken, expiresIn } = await response.json() - - return { accessToken, refreshToken, expiresIn } - } - /** * Checks if the token is not expired, considering it invalid one hour before its actual expiration time. * This validation is typically used for refreshing access tokens to avoid race conditions. @@ -480,10 +534,6 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable } } - private async findSessionIndex(sessions: StatefulAuthSession[], session: StatefulAuthSession) { - return sessions.findIndex((s) => s.id === session.id) - } - private async findSessionIndexById(sessions: StatefulAuthSession[], id: string) { return sessions.findIndex((s) => s.id === id) } @@ -522,6 +572,19 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable return { ...session, isExpired: true } } + + showLoginNotification() { + const openDashboardStr = 'Open Dashboard' + window + .showInformationMessage('Logged into the Stateful Platform', openDashboardStr) + .then((answer) => { + if (answer === openDashboardStr) { + const dashboardUri = getRunmeAppUrl(['app']) + const uri = Uri.parse(dashboardUri) + env.openExternal(uri) + } + }) + } } /** diff --git a/src/extension/utils.ts b/src/extension/utils.ts index 5978694a1..399ecb8bd 100644 --- a/src/extension/utils.ts +++ b/src/extension/utils.ts @@ -564,7 +564,7 @@ export function getGithubAuthSession(createIfNone: boolean = true) { } export async function getPlatformAuthSession(createIfNone: boolean = true, silent?: boolean) { - const scopes = ['profile', 'offline_access'] + const scopes = ['profile'] const options: AuthenticationGetSessionOptions = {} if (silent !== undefined) { diff --git a/src/utils/configuration.ts b/src/utils/configuration.ts index 57a348e5a..7a2b46b11 100644 --- a/src/utils/configuration.ts +++ b/src/utils/configuration.ts @@ -91,6 +91,8 @@ const configurationSchema = { loginPrompt: z.boolean().default(true), platformAuth: z.boolean().default(false), docsUrl: z.string().default(DEFAULT_DOCS_URL), + authTokenPath: z.string().optional(), + deleteAuthToken: z.boolean().default(true), }, } @@ -445,6 +447,14 @@ const getDocsUrlFor = (path: string): string => { return `${baseUrl}${path}` } +const getAuthTokenPath = () => { + return getCloudConfigurationValue('authTokenPath', undefined) +} + +const getDeleteAuthToken = () => { + return getCloudConfigurationValue('deleteAuthToken', true) +} + export { enableServerLogs, getActionsOpenViewInEditor, @@ -477,4 +487,6 @@ export { getLoginPrompt, getDocsUrlFor, getDocsUrl, + getAuthTokenPath, + getDeleteAuthToken, } diff --git a/tests/extension/provider/statefulAuth.test.ts b/tests/extension/provider/statefulAuth.test.ts index e64029e5d..5d6f9e282 100644 --- a/tests/extension/provider/statefulAuth.test.ts +++ b/tests/extension/provider/statefulAuth.test.ts @@ -1,7 +1,9 @@ import * as crypto from 'node:crypto' import { expect, vi, beforeEach, describe, it } from 'vitest' -import { Uri, ExtensionContext } from 'vscode' +import { Uri, ExtensionContext, workspace } from 'vscode' +import fetch from 'node-fetch' +import jwt from 'jsonwebtoken' import { StatefulAuthProvider } from '../../../src/extension/provider/statefulAuth' import { RunmeUriHandler } from '../../../src/extension/handler/uri' @@ -9,15 +11,21 @@ import { getRunmeAppUrl } from '../../../src/utils/configuration' vi.mock('vscode') vi.mock('vscode-telemetry') +vi.mock('node-fetch') vi.mock('../../../src/utils/configuration', () => { return { getRunmeAppUrl: vi.fn(), + getDeleteAuthToken: vi.fn(() => true), + getAuthTokenPath: vi.fn(() => '/path/to/auth/token'), } }) const contextFake: ExtensionContext = { extensionUri: Uri.parse('file:///Users/fakeUser/projects/vscode-runme'), + secrets: { + store: vi.fn(), + }, } as any const uriHandlerFake: RunmeUriHandler = {} as any @@ -67,3 +75,85 @@ describe('StatefulAuthProvider#sessionSecretKey', () => { expect(sessionSecretKey).toEqual('stateful.sessions.5d458b91cb755f8e839839dd3d1b4d597bba2c11') }) }) + +describe('StatefulAuthProvider#bootstrapFromToken', () => { + let provider: StatefulAuthProvider + + beforeEach(() => { + vi.mocked(getRunmeAppUrl).mockReturnValue('https://api.stateful.dev/') + provider = new StatefulAuthProvider(contextFake, uriHandlerFake) + }) + + it('returns undefined if no token is provided', async () => { + vi.mocked(workspace.fs.stat).mockRejectedValueOnce({} as any) + const sessionCreated = await provider.bootstrapFromToken() + expect(sessionCreated).toBeFalsy() + }) + + it('returns true if token provided is valid', async () => { + const token = jwt.sign( + { + iss: 'Runme', + aud: 'XXXXXXXXXXXXXXXXXXXXXXXX', + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 60 * 60, + sub: 'XXXXXXXXXXXX', + scope: 'profile email', + }, + 'secret', + ) + + vi.mocked(workspace.fs.stat).mockResolvedValueOnce({} as any) + vi.mocked(workspace.fs.readFile).mockResolvedValueOnce(Buffer.from(token)) + vi.mocked(workspace.fs.delete).mockResolvedValueOnce() + vi.mocked(fetch).mockResolvedValueOnce( + Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + email: 'john@doe.com', + name: 'John Doe', + }), + }) as any, + ) + const spyStore = vi.spyOn(contextFake.secrets, 'store') + const spyDelete = vi.spyOn(workspace.fs, 'delete') + const sessionCreated = await provider.bootstrapFromToken() + + expect(sessionCreated).toBeTruthy() + expect(spyStore).toHaveBeenCalledOnce() + expect(spyDelete).toHaveBeenCalledOnce() + }) + + it('returns true if token provided is invalid', async () => { + const token = jwt.sign( + { + iss: 'Runme', + aud: 'XXXXXXXXXXXXXXXXXXXXXXXX', + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 60 * 60, + sub: 'XXXXXXXXXXXX', + scope: 'profile email', + }, + 'secret', + ) + + vi.mocked(workspace.fs.stat).mockResolvedValueOnce({} as any) + vi.mocked(workspace.fs.readFile).mockResolvedValueOnce(Buffer.from(token)) + + vi.mocked(fetch).mockResolvedValueOnce( + Promise.resolve({ + status: 500, + json: () => Promise.resolve({ status: 'foo', message: 'bar' }), + }) as any, + ) + const spyStore = vi.spyOn(contextFake.secrets, 'store') + const spyDelete = vi.spyOn(workspace.fs, 'delete') + const sessionCreated = await provider.bootstrapFromToken() + + expect(sessionCreated).toBeFalsy() + expect(spyStore).not.toHaveBeenCalledOnce() + expect(spyDelete).not.toHaveBeenCalledOnce() + }) +})