Skip to content

Commit b1decdb

Browse files
authored
Add Studio Token Auth Flow (#4931)
1 parent 75b9757 commit b1decdb

File tree

13 files changed

+335
-114
lines changed

13 files changed

+335
-114
lines changed

extension/src/__mocks__/vscode.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export enum TreeItemCollapsibleState {
2424
export const Uri = {
2525
file: URI.file,
2626
from: URI.from,
27+
parse: URI.parse,
2728
joinPath: Utils.joinPath
2829
}
2930
export const window = {

extension/src/setup/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,8 @@ export class Setup
424424
() => this.initializeGit(),
425425
(offline: boolean) => this.studio.updateStudioOffline(offline),
426426
() => this.isPythonExtensionUsed(),
427-
() => this.updatePythonEnvironment()
427+
() => this.updatePythonEnvironment(),
428+
() => this.studio.requestStudioTokenAuthentication()
428429
)
429430
this.dispose.track(
430431
this.onDidReceivedWebviewMessage(message =>

extension/src/setup/studio.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import { Event, EventEmitter } from 'vscode'
2+
import fetch from 'node-fetch'
3+
import { STUDIO_URL } from './webview/contract'
24
import { AvailableCommands, InternalCommands } from '../commands/internal'
35
import { getFirstWorkspaceFolder } from '../vscode/workspaceFolders'
46
import { Args, ConfigKey, Flag } from '../cli/dvc/constants'
57
import { ContextKey, setContextValue } from '../vscode/context'
68
import { Disposable } from '../class/dispose'
9+
import { getCallBackUrl, openUrl, waitForUriResponse } from '../vscode/external'
10+
import { Modal } from '../vscode/modal'
711

812
export const isStudioAccessToken = (text?: string): boolean => {
913
if (!text) {
@@ -102,6 +106,80 @@ export class Studio extends Disposable {
102106
)
103107
}
104108

109+
public async requestStudioTokenAuthentication() {
110+
const response = await this.fetchFromStudio(
111+
`${STUDIO_URL}/api/device-login`,
112+
{
113+
client_name: 'VS Code'
114+
}
115+
)
116+
117+
const {
118+
token_uri: tokenUri,
119+
verification_uri: verificationUri,
120+
user_code: userCode,
121+
device_code: deviceCode
122+
} = (await response.json()) as {
123+
token_uri: string
124+
verification_uri: string
125+
user_code: string
126+
device_code: string
127+
}
128+
129+
const callbackUrl = await getCallBackUrl('/studio-complete-auth')
130+
const verificationUrlWithParams = new URL(verificationUri)
131+
132+
verificationUrlWithParams.searchParams.append('redirect_uri', callbackUrl)
133+
verificationUrlWithParams.searchParams.append('code', userCode)
134+
135+
await openUrl(verificationUrlWithParams.toString())
136+
void waitForUriResponse('/studio-complete-auth', () => {
137+
void this.requestStudioToken(deviceCode, tokenUri)
138+
})
139+
}
140+
141+
private fetchFromStudio(reqUri: string, body: Record<string, unknown>) {
142+
return fetch(reqUri, {
143+
body: JSON.stringify(body),
144+
headers: {
145+
'Content-Type': 'application/json'
146+
},
147+
method: 'POST'
148+
})
149+
}
150+
151+
private async fetchStudioToken(deviceCode: string, tokenUri: string) {
152+
const response = await this.fetchFromStudio(tokenUri, {
153+
code: deviceCode
154+
})
155+
156+
if (response.status !== 200) {
157+
const { detail } = (await response.json()) as {
158+
detail: string
159+
}
160+
return Modal.errorWithOptions(
161+
`Unable to get token. Failed with "${detail}"`
162+
)
163+
}
164+
165+
const { access_token: accessToken } = (await response.json()) as {
166+
access_token: string
167+
}
168+
169+
return accessToken
170+
}
171+
172+
private async requestStudioToken(deviceCode: string, tokenUri: string) {
173+
const token = await this.fetchStudioToken(deviceCode, tokenUri)
174+
const cwd = this.getCwd()
175+
176+
if (!token || !cwd) {
177+
return
178+
}
179+
180+
return this.saveStudioAccessTokenInConfig(cwd, token)
181+
}
182+
105183
private async setStudioValues() {
106184
const cwd = this.getCwd()
107185

extension/src/setup/webview/messages.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { commands } from 'vscode'
2-
import { STUDIO_URL, SetupData, SetupData as TSetupData } from './contract'
2+
import { SetupData, SetupData as TSetupData } from './contract'
33
import { Logger } from '../../common/logger'
44
import {
55
MessageFromWebview,
@@ -12,7 +12,6 @@ import {
1212
RegisteredCliCommands,
1313
RegisteredCommands
1414
} from '../../commands/external'
15-
import { openUrl } from '../../vscode/external'
1615
import { autoInstallDvc, autoUpgradeDvc } from '../autoInstall'
1716

1817
export class WebviewMessages {
@@ -21,19 +20,22 @@ export class WebviewMessages {
2120
private readonly updateStudioOffline: (offline: boolean) => Promise<void>
2221
private readonly isPythonExtensionUsed: () => Promise<boolean>
2322
private readonly updatePythonEnv: () => Promise<void>
23+
private readonly requestToken: () => Promise<void>
2424

2525
constructor(
2626
getWebview: () => BaseWebview<TSetupData> | undefined,
2727
initializeGit: () => void,
2828
updateStudioOffline: (shareLive: boolean) => Promise<void>,
2929
isPythonExtensionUsed: () => Promise<boolean>,
30-
updatePythonEnv: () => Promise<void>
30+
updatePythonEnv: () => Promise<void>,
31+
requestStudioToken: () => Promise<void>
3132
) {
3233
this.getWebview = getWebview
3334
this.initializeGit = initializeGit
3435
this.updateStudioOffline = updateStudioOffline
3536
this.isPythonExtensionUsed = isPythonExtensionUsed
3637
this.updatePythonEnv = updatePythonEnv
38+
this.requestToken = requestStudioToken
3739
}
3840

3941
public sendWebviewMessage(data: SetupData) {
@@ -64,10 +66,6 @@ export class WebviewMessages {
6466
return commands.executeCommand(
6567
RegisteredCommands.EXTENSION_SETUP_WORKSPACE
6668
)
67-
case MessageFromWebviewType.OPEN_STUDIO:
68-
return this.openStudio()
69-
case MessageFromWebviewType.OPEN_STUDIO_PROFILE:
70-
return this.openStudioProfile()
7169
case MessageFromWebviewType.SAVE_STUDIO_TOKEN:
7270
return commands.executeCommand(
7371
RegisteredCommands.ADD_STUDIO_ACCESS_TOKEN
@@ -78,6 +76,8 @@ export class WebviewMessages {
7876
)
7977
case MessageFromWebviewType.SET_STUDIO_SHARE_EXPERIMENTS_LIVE:
8078
return this.updateStudioOffline(message.payload)
79+
case MessageFromWebviewType.REQUEST_STUDIO_TOKEN:
80+
return this.requestStudioToken()
8181
case MessageFromWebviewType.OPEN_EXPERIMENTS_WEBVIEW:
8282
return commands.executeCommand(RegisteredCommands.EXPERIMENT_SHOW)
8383
case MessageFromWebviewType.REMOTE_ADD:
@@ -131,11 +131,12 @@ export class WebviewMessages {
131131
return autoInstallDvc(isPythonExtensionUsed)
132132
}
133133

134-
private openStudio() {
135-
return openUrl(STUDIO_URL)
136-
}
137-
138-
private openStudioProfile() {
139-
return openUrl(`${STUDIO_URL}/user/_/profile?section=accessToken`)
134+
private requestStudioToken() {
135+
sendTelemetryEvent(
136+
EventName.VIEWS_SETUP_REQUEST_STUDIO_TOKEN,
137+
undefined,
138+
undefined
139+
)
140+
return this.requestToken()
140141
}
141142
}

extension/src/telemetry/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ export const EventName = Object.assign(
109109
VIEWS_SETUP_FOCUS_CHANGED: 'views.setup.focusChanged',
110110
VIEWS_SETUP_INIT_GIT: 'views.setup.initializeGit',
111111
VIEWS_SETUP_INSTALL_DVC: 'views.setup.installDvc',
112+
VIEWS_SETUP_REQUEST_STUDIO_TOKEN: 'view.setup.requestStudioToken',
112113
VIEWS_SETUP_SHOW_SCM_FOR_COMMIT: 'views.setup.showScmForCommit',
113114
VIEWS_SETUP_UPDATE_PYTHON_ENVIRONMENT:
114115
'views.setup.updatePythonEnvironment',
@@ -323,6 +324,7 @@ export interface IEventNamePropertyMapping {
323324
[EventName.VIEWS_SETUP_CLOSE]: undefined
324325
[EventName.VIEWS_SETUP_CREATED]: undefined
325326
[EventName.VIEWS_SETUP_FOCUS_CHANGED]: undefined
327+
[EventName.VIEWS_SETUP_REQUEST_STUDIO_TOKEN]: undefined
326328
[EventName.VIEWS_SETUP_UPDATE_PYTHON_ENVIRONMENT]: undefined
327329
[EventName.VIEWS_SETUP_SHOW_SCM_FOR_COMMIT]: undefined
328330
[EventName.VIEWS_SETUP_INIT_GIT]: undefined

extension/src/test/suite/setup/index.test.ts

Lines changed: 115 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, it, suite } from 'mocha'
33
import { ensureFileSync, remove } from 'fs-extra'
44
import { expect } from 'chai'
55
import { SinonStub, restore, spy, stub } from 'sinon'
6+
import * as Fetch from 'node-fetch'
67
import {
78
MessageItem,
89
QuickPickItem,
@@ -43,12 +44,15 @@ import { MIN_CLI_VERSION } from '../../../cli/dvc/contract'
4344
import { run } from '../../../setup/runner'
4445
import * as Python from '../../../extensions/python'
4546
import { ContextKey } from '../../../vscode/context'
47+
import * as ExternalUtil from '../../../vscode/external'
4648
import { Setup } from '../../../setup'
47-
import { SetupSection } from '../../../setup/webview/contract'
49+
import { STUDIO_URL, SetupSection } from '../../../setup/webview/contract'
4850
import { getFirstWorkspaceFolder } from '../../../vscode/workspaceFolders'
4951
import { Response } from '../../../vscode/response'
5052
import { DvcConfig } from '../../../cli/dvc/config'
5153
import * as QuickPickUtil from '../../../setup/quickPick'
54+
import { EventName } from '../../../telemetry/constants'
55+
import { Modal } from '../../../vscode/modal'
5256

5357
suite('Setup Test Suite', () => {
5458
const disposable = Disposable.fn()
@@ -850,49 +854,137 @@ suite('Setup Test Suite', () => {
850854
).to.be.calledWithExactly('setContext', 'dvc.cli.incompatible', false)
851855
})
852856

853-
it('should handle a message from the webview to open Studio', async () => {
854-
const { mockOpenExternal, setup, urlOpenedEvent } = buildSetup({
857+
it('should handle a message from the webview to request a token from studio', async () => {
858+
const { setup, mockFetch, studio } = buildSetup({
855859
disposer: disposable
856860
})
857861

862+
const mockConfig = stub(DvcConfig.prototype, 'config')
863+
mockConfig.resolves('')
858864
const webview = await setup.showWebview()
859865
await webview.isReady()
866+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
867+
stub(Setup.prototype as any, 'getCliCompatible').returns(true)
860868

861869
const mockMessageReceived = getMessageReceivedEmitter(webview)
870+
const mockSendTelemetryEvent = stub(Telemetry, 'sendTelemetryEvent')
871+
const mockGetCallbackUrl = stub(ExternalUtil, 'getCallBackUrl')
872+
const mockOpenUrl = stub(ExternalUtil, 'openUrl')
873+
const mockWaitForUriRes = stub(ExternalUtil, 'waitForUriResponse')
874+
const mockStudioRes = {
875+
device_code: 'Yi-NPd9ggvNUDBcam5bP8iivbtLhnqVgM_lSSbilqNw',
876+
token_uri: 'https://studio.iterative.ai/api/device-login/token',
877+
user_code: '40DWMKNA',
878+
verification_uri: 'https://studio.iterative.ai/auth/device-login'
879+
}
880+
const mockCallbackUrl = 'url-to-vscode'
881+
882+
mockFetch.onFirstCall().resolves({
883+
json: () => Promise.resolve(mockStudioRes)
884+
} as Fetch.Response)
885+
mockGetCallbackUrl.onFirstCall().resolves(mockCallbackUrl)
886+
887+
const callbackUriHandlerEvent: Promise<() => unknown> = new Promise(
888+
resolve =>
889+
mockWaitForUriRes.onFirstCall().callsFake((_, onResponse) => {
890+
resolve(onResponse)
891+
})
892+
)
862893

863894
mockMessageReceived.fire({
864-
type: MessageFromWebviewType.OPEN_STUDIO
895+
type: MessageFromWebviewType.REQUEST_STUDIO_TOKEN
865896
})
866897

867-
await urlOpenedEvent
868-
expect(mockOpenExternal).to.be.calledWith(
869-
Uri.parse('https://studio.iterative.ai')
898+
const mockOnStudioResponse = await callbackUriHandlerEvent
899+
900+
expect(mockSendTelemetryEvent).to.be.calledOnce
901+
expect(mockSendTelemetryEvent).to.be.calledWith(
902+
EventName.VIEWS_SETUP_REQUEST_STUDIO_TOKEN,
903+
undefined,
904+
undefined
905+
)
906+
expect(mockFetch).to.be.calledOnce
907+
expect(mockFetch).to.be.calledOnceWithExactly(
908+
`${STUDIO_URL}/api/device-login`,
909+
{
910+
body: JSON.stringify({
911+
client_name: 'VS Code'
912+
}),
913+
headers: {
914+
'Content-Type': 'application/json'
915+
},
916+
method: 'POST'
917+
}
918+
)
919+
expect(mockGetCallbackUrl).to.be.calledOnce
920+
expect(mockGetCallbackUrl).to.be.calledWith('/studio-complete-auth')
921+
expect(mockOpenUrl).to.be.calledOnce
922+
expect(mockOpenUrl).to.be.calledWith(
923+
`${mockStudioRes.verification_uri}?redirect_uri=${mockCallbackUrl}&code=${mockStudioRes.user_code}`
870924
)
871-
}).timeout(WEBVIEW_TEST_TIMEOUT)
872925

873-
it("should handle a message from the webview to open the user's Studio profile", async () => {
874-
const { setup, mockOpenExternal, urlOpenedEvent } = buildSetup({
875-
disposer: disposable
876-
})
926+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
927+
const mockGetCwd = stub(studio as any, 'getCwd')
928+
const mockModalShowError = stub(Modal, 'errorWithOptions')
929+
const mockSaveStudioToken = stub(studio, 'saveStudioAccessTokenInConfig')
930+
mockFetch.onSecondCall().resolves({
931+
json: () =>
932+
Promise.resolve({ detail: 'Request failed for some reason.' }),
933+
status: 500
934+
} as Fetch.Response)
935+
936+
const failedTokenEvent = new Promise(resolve =>
937+
mockGetCwd.onFirstCall().callsFake(() => {
938+
resolve(undefined)
939+
return dvcDemoPath
940+
})
941+
)
877942

878-
const webview = await setup.showWebview()
879-
await webview.isReady()
943+
mockOnStudioResponse()
880944

881-
const mockMessageReceived = getMessageReceivedEmitter(webview)
945+
await failedTokenEvent
882946

883-
mockMessageReceived.fire({
884-
type: MessageFromWebviewType.OPEN_STUDIO_PROFILE
947+
expect(mockFetch).to.be.calledTwice
948+
expect(mockFetch).to.be.calledWithExactly(mockStudioRes.token_uri, {
949+
body: JSON.stringify({
950+
code: mockStudioRes.device_code
951+
}),
952+
headers: {
953+
'Content-Type': 'application/json'
954+
},
955+
method: 'POST'
885956
})
957+
expect(mockModalShowError).to.be.calledOnce
958+
expect(mockModalShowError).to.be.calledWithExactly(
959+
'Unable to get token. Failed with "Request failed for some reason."'
960+
)
961+
expect(mockSaveStudioToken).not.to.be.called
886962

887-
await urlOpenedEvent
888-
expect(mockOpenExternal).to.be.calledWith(
889-
Uri.parse(
890-
'https://studio.iterative.ai/user/_/profile?section=accessToken'
891-
)
963+
const mockToken = 'isat_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
964+
mockFetch.onThirdCall().resolves({
965+
json: () => Promise.resolve({ access_token: mockToken }),
966+
status: 200
967+
} as Fetch.Response)
968+
const tokenEvent = new Promise(resolve =>
969+
mockGetCwd.onSecondCall().callsFake(() => {
970+
resolve(undefined)
971+
return dvcDemoPath
972+
})
973+
)
974+
975+
mockOnStudioResponse()
976+
977+
await tokenEvent
978+
979+
expect(mockFetch).to.be.calledThrice
980+
expect(mockSaveStudioToken).to.be.calledOnce
981+
expect(mockSaveStudioToken).to.be.calledWithExactly(
982+
dvcDemoPath,
983+
mockToken
892984
)
893985
}).timeout(WEBVIEW_TEST_TIMEOUT)
894986

895-
it("should handle a message from the webview to save the user's Studio access token", async () => {
987+
it("should handle a message from the webview to manually save the user's Studio access token", async () => {
896988
const mockToken = 'isat_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
897989

898990
const { setup, mockExecuteCommand, messageSpy } = buildSetup({

0 commit comments

Comments
 (0)