Skip to content

Commit 0f9782a

Browse files
authored
fix(auth): allow flow to continue if users copy url #5007
Problem: Clicking anything other than 'open' in the VSC redirect url modal for the auth page would result in cancelling the auth attempt. Solution: Do not expect a specific response from the url modal. Allow the auth flow to continue even if the user hits 'Cancel' or 'Copy'. To cancel the auth flow, the user should click 'Cancel' in the login ui OR leave the login ui.
1 parent d233b70 commit 0f9782a

File tree

6 files changed

+75
-9
lines changed

6 files changed

+75
-9
lines changed

packages/core/src/auth/sso/server.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Socket } from 'net'
1111
import globals from '../../shared/extensionGlobals'
1212
import { Result } from '../../shared/utilities/result'
1313
import { FileSystemCommon } from '../../srcShared/fs'
14+
import { CancellationError } from '../../shared/utilities/timeoutUtils'
1415

1516
export class MissingPortError extends ToolkitError {
1617
constructor() {
@@ -47,6 +48,12 @@ export class AuthError extends ToolkitError {
4748
* back to VSCode
4849
*/
4950
export class AuthSSOServer {
51+
// Last initialized instance of the Auth Server
52+
static #lastInstance: AuthSSOServer | undefined
53+
public static get lastInstance() {
54+
return AuthSSOServer.#lastInstance
55+
}
56+
5057
private baseUrl = `http://127.0.0.1`
5158
private oauthCallback = '/oauth/callback'
5259
private authenticationFlowTimeoutInMs = 600000
@@ -57,7 +64,9 @@ export class AuthSSOServer {
5764
private server: http.Server
5865
private connections: Socket[]
5966

60-
constructor(private readonly state: string) {
67+
private _closed: boolean = false
68+
69+
private constructor(private readonly state: string) {
6170
this.authenticationPromise = new Promise<Result<string>>(resolve => {
6271
this.deferred = { resolve }
6372
})
@@ -99,6 +108,22 @@ export class AuthSSOServer {
99108
})
100109
}
101110

111+
public static init(state: string) {
112+
const lastInstance = AuthSSOServer.#lastInstance
113+
if (lastInstance !== undefined && !lastInstance.closed) {
114+
lastInstance
115+
.close()!
116+
.catch(err =>
117+
getLogger().error('Failed to close already existing auth sever in AuthSSOServer.init(): %s', err)
118+
)
119+
}
120+
121+
getLogger().debug('AuthSSOServer: Initialized new auth server.')
122+
const instance = new AuthSSOServer(state)
123+
AuthSSOServer.#lastInstance = instance
124+
return instance
125+
}
126+
102127
start() {
103128
if (this.server.listening) {
104129
throw new ToolkitError('AuthSSOServer: Server already started')
@@ -127,10 +152,17 @@ export class AuthSSOServer {
127152

128153
close() {
129154
return new Promise<void>((resolve, reject) => {
155+
if (this._closed) {
156+
resolve()
157+
return
158+
}
159+
130160
if (!this.server.listening) {
131161
reject(new ToolkitError('AuthSSOServer: Server not started'))
132162
}
133163

164+
getLogger().debug('AuthSSOServer: Attempting to close server.')
165+
134166
this.connections.forEach(connection => {
135167
connection.destroy()
136168
})
@@ -139,6 +171,8 @@ export class AuthSSOServer {
139171
if (err) {
140172
reject(err)
141173
}
174+
this._closed = true
175+
getLogger().debug('AuthSSOServer: Server closed successfully.')
142176
resolve()
143177
})
144178
})
@@ -152,6 +186,10 @@ export class AuthSSOServer {
152186
return `${this.baseUrl}:${this.getPort()}`
153187
}
154188

189+
public get closed() {
190+
return this._closed
191+
}
192+
155193
public getAddress() {
156194
return this.server.address()
157195
}
@@ -228,6 +266,11 @@ export class AuthSSOServer {
228266
this.deferred?.resolve(Result.err(error))
229267
}
230268

269+
public cancelCurrentFlow() {
270+
getLogger().debug('AuthSSOServer: Cancelling current login flow')
271+
this.deferred?.resolve(Result.err(new CancellationError('user')))
272+
}
273+
231274
public waitForAuthorization(): Promise<Result<string>> {
232275
return Promise.race([
233276
this.authenticationPromise,
@@ -244,7 +287,7 @@ export class AuthSSOServer {
244287
getLogger().warn('AuthSSOServer: Authentication is taking a long time')
245288
}, this.authenticationWarningTimeoutInMs)
246289

247-
void this.authenticationPromise.then(() => {
290+
void this.authenticationPromise.finally(() => {
248291
clearTimeout(warningTimeout)
249292
})
250293
}),

packages/core/src/auth/sso/ssoAccessTokenProvider.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import {
1919
builderIdStartUrl,
2020
openSsoPortalLink,
2121
isDeprecatedAuth,
22-
openSsoUrl,
2322
} from './model'
2423
import { getCache } from './cache'
2524
import { hasProps, hasStringProps, RequiredProps, selectFrom } from '../../shared/utilities/tsUtils'
@@ -436,7 +435,7 @@ class AuthFlowAuthorization extends SsoAccessTokenProvider {
436435
registration: ClientRegistration
437436
): Promise<{ token: SsoToken; registration: ClientRegistration; region: string; startUrl: string }> {
438437
const state = randomUUID()
439-
const authServer = new AuthSSOServer(state)
438+
const authServer = AuthSSOServer.init(state)
440439

441440
try {
442441
await authServer.start()
@@ -457,9 +456,7 @@ class AuthFlowAuthorization extends SsoAccessTokenProvider {
457456
codeChallengeMethod: 'S256',
458457
})
459458

460-
if (!(await openSsoUrl(vscode.Uri.parse(location)))) {
461-
throw new CancellationError('user')
462-
}
459+
await vscode.env.openExternal(vscode.Uri.parse(location))
463460

464461
const authorizationCode = await authServer.waitForAuthorization()
465462
if (authorizationCode.isErr()) {

packages/core/src/login/webview/commonAuthViewProvider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ export class CommonAuthViewProvider implements WebviewViewProvider {
104104
this.webView!.server.storeMetricMetadata({ isReAuth: false })
105105
}
106106
this.webView!.server.emitAuthMetric()
107+
this.webView!.server.cancelAuthFlow()
107108

108109
// Set after emitting. If users use side bar to return to login, this source is correct
109110
// for the next iteration. Otherwise, other sources will be set accordingly by whatever

packages/core/src/login/webview/vue/backend.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { AuthSources } from '../util'
3131
import { AuthEnabledFeatures, AuthError, AuthFlowState, AuthUiClick, TelemetryMetadata, userCancelled } from './types'
3232
import { AuthUtil } from '../../../codewhisperer/util/authUtil'
3333
import { DevSettings } from '../../../shared/settings'
34+
import { AuthSSOServer } from '../../../auth/sso/server'
3435

3536
export abstract class CommonAuthWebview extends VueWebview {
3637
private metricMetadata: TelemetryMetadata = {}
@@ -296,4 +297,8 @@ export abstract class CommonAuthWebview extends VueWebview {
296297
getDefaultStartUrl() {
297298
return DevSettings.instance.get('autofillStartUrl', '')
298299
}
300+
301+
cancelAuthFlow() {
302+
AuthSSOServer.lastInstance?.cancelCurrentFlow()
303+
}
299304
}

packages/core/src/login/webview/vue/login.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,8 @@ export default defineComponent({
514514
void client.emitUiClick('auth_regionSelection')
515515
},
516516
async handleCancelButton() {
517+
void client.cancelAuthFlow()
518+
517519
await client.storeMetricMetadata({ isReAuth: false, result: 'Cancelled' })
518520
void client.emitAuthMetric()
519521
void client.emitUiClick('auth_cancelButton')

packages/core/src/test/credentials/sso/server.test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import {
1313
} from '../../../auth/sso/server'
1414
import request from '../../../common/request'
1515
import { URLSearchParams } from 'url'
16-
import { ToolkitError } from '../../../shared/errors'
16+
import { isUserCancelledError, ToolkitError } from '../../../shared/errors'
17+
import { sleep } from '../../../shared/utilities/timeoutUtils'
1718

1819
describe('AuthSSOServer', function () {
1920
const code = 'zfhgaiufgsbdfigsdfg'
@@ -24,7 +25,7 @@ describe('AuthSSOServer', function () {
2425
let server: AuthSSOServer
2526

2627
beforeEach(async function () {
27-
server = new AuthSSOServer(state)
28+
server = AuthSSOServer.init(state)
2829
await server.start()
2930
})
3031

@@ -122,4 +123,21 @@ describe('AuthSSOServer', function () {
122123
}
123124
assert.fail('Expected address 127.0.0.1')
124125
})
126+
127+
it('can be cancelled while waiting for auth', async function () {
128+
const promise = server.waitForAuthorization().catch(e => {
129+
return e
130+
})
131+
server.cancelCurrentFlow()
132+
133+
const err = await promise
134+
assert.ok(isUserCancelledError(err.inner), 'CancellationError not thrown.')
135+
})
136+
137+
it('initializes and closes instances', async function () {
138+
const newServer = AuthSSOServer.init('1234')
139+
assert.equal(AuthSSOServer.lastInstance, newServer)
140+
await sleep(100)
141+
assert.ok(server.closed)
142+
})
125143
})

0 commit comments

Comments
 (0)