Skip to content

Commit beb24f0

Browse files
authored
feat: add device flow to vscode extension (#4189)
1 parent b6d5bcf commit beb24f0

6 files changed

Lines changed: 267 additions & 74 deletions

File tree

vscode/extension/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@
4141
"title": "Sign in to Tobiko Cloud",
4242
"description": "SQLMesh"
4343
},
44+
{
45+
"command": "sqlmesh.signinSpecifyFlow",
46+
"title": "Sign in to Tobiko Cloud (Specify Auth Flow)",
47+
"description": "SQLMesh"
48+
},
4449
{
4550
"command": "sqlmesh.signout",
4651
"title": "Sign out from Tobiko Cloud",

vscode/extension/src/auth/auth.ts

Lines changed: 205 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@ export const AUTH_TYPE = "tobikodata"
1919
export const AUTH_NAME = "Tobiko"
2020

2121
const tokenSchema = z.object({
22-
iss: z.string(),
23-
aud: z.string(),
24-
sub: z.string(),
25-
scope: z.string(),
26-
iat: z.number(),
27-
exp: z.number(),
28-
email: z.string(),
22+
iss: z.string(),
23+
aud: z.string(),
24+
sub: z.string(),
25+
scope: z.string(),
26+
iat: z.number(),
27+
exp: z.number(),
28+
email: z.string(),
2929
})
3030
const statusResponseSchema = z.object({
3131
is_logged_in: z.boolean(),
@@ -39,6 +39,14 @@ const loginUrlResponseSchema = z.object({
3939
verifier_code: z.string(),
4040
})
4141

42+
const deviceCodeResponseSchema = z.object({
43+
device_code: z.string(),
44+
user_code: z.string(),
45+
verification_uri: z.string(),
46+
verification_uri_complete: z.string(),
47+
expires_in: z.number(),
48+
})
49+
4250
export class AuthenticationProviderTobikoCloud
4351
implements AuthenticationProvider
4452
{
@@ -91,8 +99,8 @@ export class AuthenticationProviderTobikoCloud
9199
const session = {
92100
id: token.email,
93101
account: {
94-
id: token.email,
95-
label: "Tobiko",
102+
id: token.sub,
103+
label: token.email,
96104
},
97105
scopes: token.scope.split(" "),
98106
accessToken: "",
@@ -101,6 +109,60 @@ export class AuthenticationProviderTobikoCloud
101109
}
102110

103111
async createSession(): Promise<AuthenticationSession> {
112+
await this.sign_in_oauth_flow()
113+
const status = await this.get_status()
114+
if (isErr(status)) {
115+
throw new Error("Failed to get tcloud auth status")
116+
}
117+
const statusResponse = status.value
118+
if (!statusResponse.is_logged_in) {
119+
throw new Error("Failed to login to tcloud")
120+
}
121+
const token = statusResponse.id_token
122+
const session: AuthenticationSession = {
123+
id: token.email,
124+
account: {
125+
id: token.email,
126+
label: "Tobiko",
127+
},
128+
scopes: token.scope.split(" "),
129+
accessToken: "",
130+
}
131+
this._sessionChangeEmitter.fire({
132+
added: [session],
133+
removed: [],
134+
changed: [],
135+
})
136+
return session
137+
}
138+
139+
async removeSession(): Promise<void> {
140+
// Get current sessions before logging out
141+
const currentSessions = await this.getSessions()
142+
const tcloudBin = await get_tcloud_bin()
143+
const workspacePath = await getProjectRoot()
144+
if (isErr(tcloudBin)) {
145+
throw new Error("Failed to get tcloud bin")
146+
}
147+
const tcloudBinPath = tcloudBin.value
148+
const result = await execAsync(tcloudBinPath, ["auth", "logout"], {
149+
cwd: workspacePath.uri.fsPath,
150+
})
151+
if (result.exitCode !== 0) {
152+
throw new Error("Failed to logout from tcloud")
153+
}
154+
155+
// Emit event with the actual sessions that were removed
156+
if (currentSessions.length > 0) {
157+
this._sessionChangeEmitter.fire({
158+
added: [],
159+
removed: currentSessions,
160+
changed: [],
161+
})
162+
}
163+
}
164+
165+
async sign_in_oauth_flow(): Promise<void> {
104166
const workspacePath = await getProjectRoot()
105167
const tcloudBin = await get_tcloud_bin()
106168
if (isErr(tcloudBin)) {
@@ -117,83 +179,156 @@ export class AuthenticationProviderTobikoCloud
117179
if (result.exitCode !== 0) {
118180
throw new Error("Failed to get tcloud login url")
119181
}
120-
const resultToJson = JSON.parse(result.stdout)
121-
const urlCode = loginUrlResponseSchema.parse(resultToJson)
122-
const url = urlCode.url
123182

124-
const ac = new AbortController()
125-
const timeout = setTimeout(() => ac.abort(), 1000 * 60 * 5)
126-
const backgroundServerForLogin = execAsync(
127-
tcloudBinPath,
128-
["auth", "vscode", "start-server", urlCode.verifier_code],
129-
{
130-
cwd: workspacePath.uri.fsPath,
131-
signal: ac.signal,
183+
try {
184+
const resultToJson = JSON.parse(result.stdout)
185+
const urlCode = loginUrlResponseSchema.parse(resultToJson)
186+
const url = urlCode.url
187+
188+
if (!url) {
189+
throw new Error("Invalid login URL received")
132190
}
133-
)
134191

135-
const messageResult = await window.showInformationMessage(
136-
"Please login to Tobiko Cloud",
137-
{
138-
modal: true,
139-
},
140-
"Sign in with browser",
141-
"Cancel"
142-
)
192+
const ac = new AbortController()
193+
const timeout = setTimeout(() => ac.abort(), 1000 * 60 * 5)
194+
const backgroundServerForLogin = execAsync(
195+
tcloudBinPath,
196+
["auth", "vscode", "start-server", urlCode.verifier_code],
197+
{
198+
cwd: workspacePath.uri.fsPath,
199+
signal: ac.signal,
200+
}
201+
)
143202

144-
if (messageResult === "Sign in with browser") {
145-
await env.openExternal(Uri.parse(url))
146-
}
147-
if (messageResult === "Cancel") {
148-
ac.abort()
149-
throw new Error("Login cancelled")
150-
}
203+
const messageResult = await window.showInformationMessage(
204+
"Please login to Tobiko Cloud",
205+
{
206+
modal: true,
207+
},
208+
"Sign in with browser",
209+
"Cancel"
210+
)
151211

152-
try {
153-
const output = await backgroundServerForLogin
154-
if (output.exitCode !== 0) {
155-
throw new Error(`Failed to start server: ${output.stderr}`)
212+
if (messageResult === "Sign in with browser") {
213+
await env.openExternal(Uri.parse(url))
214+
} else {
215+
// Always abort the server if not proceeding with sign in
216+
ac.abort()
217+
clearTimeout(timeout)
218+
if (messageResult === "Cancel") {
219+
throw new Error("Login cancelled")
220+
}
221+
return
156222
}
157-
} catch (error) {
158-
traceError(`Server error: ${error}`)
159-
throw error
160-
}
161223

162-
clearTimeout(timeout)
163-
164-
const status = await this.get_status()
165-
if (isErr(status)) {
166-
throw new Error("Failed to get tcloud auth status")
167-
}
168-
const statusResponse = status.value
169-
if (!statusResponse.is_logged_in) {
170-
throw new Error("Failed to login to tcloud")
171-
}
172-
const scopes = statusResponse.id_token.scope.split(" ")
173-
const session: AuthenticationSession = {
174-
id: AuthenticationProviderTobikoCloud.id,
175-
account: {
176-
id: AuthenticationProviderTobikoCloud.id,
177-
label: "Tobiko",
178-
},
179-
scopes: scopes,
180-
accessToken: ""
224+
try {
225+
const output = await backgroundServerForLogin
226+
if (output.exitCode !== 0) {
227+
throw new Error(
228+
`Failed to complete authentication: ${output.stderr}`
229+
)
230+
}
231+
// Get updated session and notify about the change
232+
const sessions = await this.getSessions()
233+
if (sessions.length > 0) {
234+
this._sessionChangeEmitter.fire({
235+
added: sessions,
236+
removed: [],
237+
changed: [],
238+
})
239+
}
240+
} catch (error) {
241+
if (error instanceof Error && error.name === "AbortError") {
242+
throw new Error("Authentication timeout or aborted")
243+
}
244+
traceError(`Server error: ${error}`)
245+
throw error
246+
} finally {
247+
clearTimeout(timeout)
248+
}
249+
} catch (error) {
250+
if (error instanceof Error && error.message === "Login cancelled") {
251+
throw error
252+
}
253+
traceError(`Authentication flow error: ${error}`)
254+
throw new Error("Failed to complete authentication flow")
181255
}
182-
return session
183256
}
184257

185-
async removeSession(): Promise<void> {
186-
const tcloudBin = await get_tcloud_bin()
258+
async sign_in_device_flow(): Promise<void> {
187259
const workspacePath = await getProjectRoot()
260+
const tcloudBin = await get_tcloud_bin()
188261
if (isErr(tcloudBin)) {
189262
throw new Error("Failed to get tcloud bin")
190263
}
191264
const tcloudBinPath = tcloudBin.value
192-
const result = await execAsync(tcloudBinPath, ["auth", "logout"], {
193-
cwd: workspacePath.uri.fsPath,
194-
})
265+
const result = await execAsync(
266+
tcloudBinPath,
267+
["auth", "vscode", "device"],
268+
{
269+
cwd: workspacePath.uri.fsPath,
270+
}
271+
)
195272
if (result.exitCode !== 0) {
196-
throw new Error("Failed to logout from tcloud")
273+
throw new Error("Failed to get device code")
274+
}
275+
276+
try {
277+
const resultToJson = JSON.parse(result.stdout)
278+
const deviceCodeResponse = deviceCodeResponseSchema.parse(resultToJson)
279+
280+
const ac = new AbortController()
281+
const timeout = setTimeout(() => ac.abort(), 1000 * 60 * 5)
282+
const waiting = execAsync(
283+
tcloudBinPath,
284+
["auth", "vscode", "poll_device", deviceCodeResponse.device_code],
285+
{
286+
cwd: workspacePath.uri.fsPath,
287+
signal: ac.signal,
288+
}
289+
)
290+
291+
const messageResult = await window.showInformationMessage(
292+
`Confirm the code ${deviceCodeResponse.user_code} at ${deviceCodeResponse.verification_uri}`,
293+
{
294+
modal: true,
295+
},
296+
"Open browser",
297+
"Cancel"
298+
)
299+
300+
if (messageResult === "Open browser") {
301+
await env.openExternal(Uri.parse(deviceCodeResponse.verification_uri_complete))
302+
}
303+
if (messageResult === "Cancel") {
304+
ac.abort()
305+
throw new Error("Login cancelled")
306+
}
307+
308+
try {
309+
const output = await waiting
310+
if (output.exitCode !== 0) {
311+
throw new Error(`Failed to authenticate: ${output.stderr}`)
312+
}
313+
314+
// Get updated session and notify about the change
315+
const sessions = await this.getSessions()
316+
if (sessions.length > 0) {
317+
this._sessionChangeEmitter.fire({
318+
added: sessions,
319+
removed: [],
320+
changed: [],
321+
})
322+
}
323+
} catch (error) {
324+
traceError(`Authentication error: ${error}`)
325+
throw error
326+
} finally {
327+
clearTimeout(timeout)
328+
}
329+
} catch (error) {
330+
traceError(`JSON parsing error: ${error}`)
331+
throw new Error("Failed to parse device code response")
197332
}
198333
}
199334
}
Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import { AuthenticationProviderTobikoCloud } from "../auth/auth"
22
import * as vscode from "vscode"
3+
import { isCodespaces } from "../utilities/isCodespaces"
34

4-
5-
export const signIn = (authenticationProvider: AuthenticationProviderTobikoCloud) => async () => {
6-
await authenticationProvider.createSession()
5+
export const signIn =
6+
(authenticationProvider: AuthenticationProviderTobikoCloud) => async () => {
7+
if (isCodespaces()) {
8+
await authenticationProvider.sign_in_device_flow()
9+
} else {
10+
await authenticationProvider.createSession()
11+
}
712
await vscode.window.showInformationMessage("Signed in successfully")
8-
}
13+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { AuthenticationProviderTobikoCloud } from "../auth/auth"
2+
import { traceInfo } from "../utilities/common/log"
3+
import { window } from "vscode"
4+
5+
export const signInSpecifyFlow = (authenticationProvider: AuthenticationProviderTobikoCloud) => async () => {
6+
traceInfo("Sign in specify flow")
7+
const flowOptions = [
8+
{ label: "OAuth Flow", description: "Sign in using OAuth flow in your browser" },
9+
{ label: "Device Flow", description: "Sign in using a device code" }
10+
]
11+
const selectedFlow = await window.showQuickPick(flowOptions, {
12+
placeHolder: "Select authentication flow method",
13+
ignoreFocusOut: true
14+
})
15+
if (!selectedFlow) {
16+
traceInfo("Sign in cancelled by user")
17+
return
18+
}
19+
if (selectedFlow.label === "OAuth Flow") {
20+
await authenticationProvider.sign_in_oauth_flow()
21+
await authenticationProvider.getSessions()
22+
await window.showInformationMessage("Sign in success")
23+
return
24+
} else if (selectedFlow.label === "Device Flow") {
25+
await authenticationProvider.sign_in_device_flow()
26+
await authenticationProvider.getSessions()
27+
await window.showInformationMessage("Sign in success")
28+
return
29+
} else {
30+
traceInfo("Invalid flow selected")
31+
return
32+
}
33+
}

0 commit comments

Comments
 (0)