Skip to content

Commit abf1002

Browse files
Add loopback & device code flows (microsoft#250015)
1 parent dd2ff08 commit abf1002

12 files changed

+934
-35
lines changed

src/vs/base/common/oauth.ts

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ export interface IAuthorizationServerMetadata {
9797
*/
9898
token_endpoint?: string;
9999

100+
/**
101+
* OPTIONAL. URL of the authorization server's device code endpoint.
102+
*/
103+
device_authorization_endpoint?: string;
104+
100105
/**
101106
* OPTIONAL. URL of the authorization server's JWK Set document containing signing keys.
102107
*/
@@ -350,6 +355,70 @@ export interface IAuthorizationTokenErrorResponse {
350355
error_uri?: string;
351356
}
352357

358+
/**
359+
* Response from the device authorization endpoint as per RFC 8628 section 3.2.
360+
*/
361+
export interface IAuthorizationDeviceResponse {
362+
/**
363+
* REQUIRED. The device verification code.
364+
*/
365+
device_code: string;
366+
367+
/**
368+
* REQUIRED. The end-user verification code.
369+
*/
370+
user_code: string;
371+
372+
/**
373+
* REQUIRED. The end-user verification URI on the authorization server.
374+
*/
375+
verification_uri: string;
376+
377+
/**
378+
* OPTIONAL. A verification URI that includes the user_code, designed for non-textual transmission.
379+
*/
380+
verification_uri_complete?: string;
381+
382+
/**
383+
* REQUIRED. The lifetime in seconds of the device_code and user_code.
384+
*/
385+
expires_in: number;
386+
387+
/**
388+
* OPTIONAL. The minimum amount of time in seconds that the client should wait between polling requests.
389+
* If no value is provided, clients must use 5 as the default.
390+
*/
391+
interval?: number;
392+
}
393+
394+
/**
395+
* Error response from the token endpoint when using device authorization grant.
396+
* As defined in RFC 8628 section 3.5.
397+
*/
398+
export interface IAuthorizationDeviceTokenErrorResponse {
399+
/**
400+
* REQUIRED. Error code as specified in OAuth 2.0 or in RFC 8628 section 3.5.
401+
* Standard OAuth 2.0 error codes plus:
402+
* - "authorization_pending": The authorization request is still pending as the end user hasn't completed the user interaction steps
403+
* - "slow_down": A variant of "authorization_pending", polling should continue but interval must be increased by 5 seconds
404+
* - "access_denied": The authorization request was denied
405+
* - "expired_token": The "device_code" has expired and the device authorization session has concluded
406+
*/
407+
error: 'invalid_request' | 'invalid_client' | 'invalid_grant' | 'unauthorized_client' |
408+
'unsupported_grant_type' | 'invalid_scope' | 'authorization_pending' |
409+
'slow_down' | 'access_denied' | 'expired_token' | string;
410+
411+
/**
412+
* OPTIONAL. Human-readable description of the error.
413+
*/
414+
error_description?: string;
415+
416+
/**
417+
* OPTIONAL. URI to a human-readable web page with more information about the error.
418+
*/
419+
error_uri?: string;
420+
}
421+
353422
export interface IAuthorizationJWTClaims {
354423
/**
355424
* REQUIRED. JWT ID. Unique identifier for the token.
@@ -537,6 +606,22 @@ export function isDynamicClientRegistrationResponse(obj: unknown): obj is IAutho
537606
return response.client_id !== undefined && response.client_name !== undefined;
538607
}
539608

609+
export function isAuthorizationDeviceResponse(obj: unknown): obj is IAuthorizationDeviceResponse {
610+
if (typeof obj !== 'object' || obj === null) {
611+
return false;
612+
}
613+
const response = obj as IAuthorizationDeviceResponse;
614+
return response.device_code !== undefined && response.user_code !== undefined && response.verification_uri !== undefined && response.expires_in !== undefined;
615+
}
616+
617+
export function isAuthorizationDeviceTokenErrorResponse(obj: unknown): obj is IAuthorizationDeviceTokenErrorResponse {
618+
if (typeof obj !== 'object' || obj === null) {
619+
return false;
620+
}
621+
const response = obj as IAuthorizationDeviceTokenErrorResponse;
622+
return response.error !== undefined && response.error_description !== undefined;
623+
}
624+
540625
//#endregion
541626

542627
export function getDefaultMetadataForUrl(issuer: URL): IRequiredAuthorizationServerMetadata & IRequiredAuthorizationServerMetadata {
@@ -561,7 +646,15 @@ export function getMetadataWithDefaultValues(metadata: IAuthorizationServerMetad
561646
};
562647
}
563648

564-
export async function fetchDynamicRegistration(registrationEndpoint: string, clientName: string, additionalRedirectUris: string[] = []): Promise<IAuthorizationDynamicClientRegistrationResponse> {
649+
/**
650+
* Default port for the authorization flow. We try to use this port so that
651+
* the redirect URI does not change when running on localhost. This is useful
652+
* for servers that only allow exact matches on the redirect URI. The spec
653+
* says that the port should not matter, but some servers do not follow
654+
* the spec and require an exact match.
655+
*/
656+
export const DEFAULT_AUTH_FLOW_PORT = 33418;
657+
export async function fetchDynamicRegistration(registrationEndpoint: string, clientName: string): Promise<IAuthorizationDynamicClientRegistrationResponse> {
565658
const response = await fetch(registrationEndpoint, {
566659
method: 'POST',
567660
headers: {
@@ -570,12 +663,19 @@ export async function fetchDynamicRegistration(registrationEndpoint: string, cli
570663
body: JSON.stringify({
571664
client_name: clientName,
572665
client_uri: 'https://code.visualstudio.com',
573-
grant_types: ['authorization_code', 'refresh_token'],
666+
grant_types: ['authorization_code', 'refresh_token', 'urn:ietf:params:oauth:grant-type:device_code'],
574667
response_types: ['code'],
575668
redirect_uris: [
576669
'https://insiders.vscode.dev/redirect',
577670
'https://vscode.dev/redirect',
578-
...additionalRedirectUris
671+
'http://localhost/',
672+
'http://127.0.0.1/',
673+
// Added these for any server that might do
674+
// only exact match on the redirect URI even
675+
// though the spec says it should not care
676+
// about the port.
677+
`http://localhost:${DEFAULT_AUTH_FLOW_PORT}/`,
678+
`http://127.0.0.1:${DEFAULT_AUTH_FLOW_PORT}/`
579679
],
580680
token_endpoint_auth_method: 'none'
581681
})

src/vs/base/test/common/oauth.test.ts

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
getDefaultMetadataForUrl,
1111
getMetadataWithDefaultValues,
1212
isAuthorizationAuthorizeResponse,
13+
isAuthorizationDeviceResponse,
14+
isAuthorizationDeviceTokenErrorResponse,
1315
isAuthorizationDynamicClientRegistrationResponse,
1416
isAuthorizationProtectedResourceMetadata,
1517
isAuthorizationServerMetadata,
@@ -18,7 +20,8 @@ import {
1820
parseWWWAuthenticateHeader,
1921
fetchDynamicRegistration,
2022
IAuthorizationJWTClaims,
21-
IAuthorizationServerMetadata
23+
IAuthorizationServerMetadata,
24+
DEFAULT_AUTH_FLOW_PORT
2225
} from '../../common/oauth.js';
2326
import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js';
2427
import { encodeBase64, VSBuffer } from '../../common/buffer.js';
@@ -115,6 +118,81 @@ suite('OAuth', () => {
115118
assert.strictEqual(isDynamicClientRegistrationResponse({ client_name: 'missing-id' }), false);
116119
assert.strictEqual(isDynamicClientRegistrationResponse('not an object'), false);
117120
});
121+
122+
test('isAuthorizationDeviceResponse should correctly identify device authorization response', () => {
123+
// Valid response
124+
assert.strictEqual(isAuthorizationDeviceResponse({
125+
device_code: 'device-code-123',
126+
user_code: 'ABCD-EFGH',
127+
verification_uri: 'https://example.com/verify',
128+
expires_in: 1800
129+
}), true);
130+
131+
// Valid response with optional fields
132+
assert.strictEqual(isAuthorizationDeviceResponse({
133+
device_code: 'device-code-123',
134+
user_code: 'ABCD-EFGH',
135+
verification_uri: 'https://example.com/verify',
136+
verification_uri_complete: 'https://example.com/verify?user_code=ABCD-EFGH',
137+
expires_in: 1800,
138+
interval: 5
139+
}), true);
140+
141+
// Invalid cases
142+
assert.strictEqual(isAuthorizationDeviceResponse(null), false);
143+
assert.strictEqual(isAuthorizationDeviceResponse(undefined), false);
144+
assert.strictEqual(isAuthorizationDeviceResponse({}), false);
145+
assert.strictEqual(isAuthorizationDeviceResponse({ device_code: 'missing-others' }), false);
146+
assert.strictEqual(isAuthorizationDeviceResponse({ user_code: 'missing-others' }), false);
147+
assert.strictEqual(isAuthorizationDeviceResponse({ verification_uri: 'missing-others' }), false);
148+
assert.strictEqual(isAuthorizationDeviceResponse({ expires_in: 1800 }), false);
149+
assert.strictEqual(isAuthorizationDeviceResponse({
150+
device_code: 'device-code-123',
151+
user_code: 'ABCD-EFGH',
152+
verification_uri: 'https://example.com/verify'
153+
// Missing expires_in
154+
}), false);
155+
assert.strictEqual(isAuthorizationDeviceResponse('not an object'), false);
156+
});
157+
158+
test('isAuthorizationDeviceTokenErrorResponse should correctly identify device token error response', () => {
159+
// Valid error response
160+
assert.strictEqual(isAuthorizationDeviceTokenErrorResponse({
161+
error: 'authorization_pending',
162+
error_description: 'The authorization request is still pending'
163+
}), true);
164+
165+
// Valid error response with different error codes
166+
assert.strictEqual(isAuthorizationDeviceTokenErrorResponse({
167+
error: 'slow_down',
168+
error_description: 'Polling too fast'
169+
}), true);
170+
171+
assert.strictEqual(isAuthorizationDeviceTokenErrorResponse({
172+
error: 'access_denied',
173+
error_description: 'The user denied the request'
174+
}), true);
175+
176+
assert.strictEqual(isAuthorizationDeviceTokenErrorResponse({
177+
error: 'expired_token',
178+
error_description: 'The device code has expired'
179+
}), true);
180+
181+
// Valid response with optional error_uri
182+
assert.strictEqual(isAuthorizationDeviceTokenErrorResponse({
183+
error: 'invalid_request',
184+
error_description: 'The request is missing a required parameter',
185+
error_uri: 'https://example.com/error'
186+
}), true);
187+
188+
// Invalid cases
189+
assert.strictEqual(isAuthorizationDeviceTokenErrorResponse(null), false);
190+
assert.strictEqual(isAuthorizationDeviceTokenErrorResponse(undefined), false);
191+
assert.strictEqual(isAuthorizationDeviceTokenErrorResponse({}), false);
192+
assert.strictEqual(isAuthorizationDeviceTokenErrorResponse({ error: 'missing-description' }), false);
193+
assert.strictEqual(isAuthorizationDeviceTokenErrorResponse({ error_description: 'missing-error' }), false);
194+
assert.strictEqual(isAuthorizationDeviceTokenErrorResponse('not an object'), false);
195+
});
118196
});
119197

120198
suite('Utility Functions', () => {
@@ -257,8 +335,7 @@ suite('OAuth', () => {
257335

258336
const result = await fetchDynamicRegistration(
259337
'https://auth.example.com/register',
260-
'Test Client',
261-
['https://custom-redirect.com/callback']
338+
'Test Client'
262339
);
263340

264341
// Verify fetch was called correctly
@@ -272,12 +349,15 @@ suite('OAuth', () => {
272349
const requestBody = JSON.parse(options.body as string);
273350
assert.strictEqual(requestBody.client_name, 'Test Client');
274351
assert.strictEqual(requestBody.client_uri, 'https://code.visualstudio.com');
275-
assert.deepStrictEqual(requestBody.grant_types, ['authorization_code', 'refresh_token']);
352+
assert.deepStrictEqual(requestBody.grant_types, ['authorization_code', 'refresh_token', 'urn:ietf:params:oauth:grant-type:device_code']);
276353
assert.deepStrictEqual(requestBody.response_types, ['code']);
277354
assert.deepStrictEqual(requestBody.redirect_uris, [
278355
'https://insiders.vscode.dev/redirect',
279356
'https://vscode.dev/redirect',
280-
'https://custom-redirect.com/callback'
357+
'http://localhost/',
358+
'http://127.0.0.1/',
359+
`http://localhost:${DEFAULT_AUTH_FLOW_PORT}/`,
360+
`http://127.0.0.1:${DEFAULT_AUTH_FLOW_PORT}/`
281361
]);
282362

283363
// Verify response is processed correctly

src/vs/workbench/api/browser/mainThreadAuthentication.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { IURLService } from '../../../platform/url/common/url.js';
2626
import { DeferredPromise, raceTimeout } from '../../../base/common/async.js';
2727
import { IAuthorizationTokenResponse } from '../../../base/common/oauth.js';
2828
import { IDynamicAuthenticationProviderStorageService } from '../../services/authentication/common/dynamicAuthenticationProviderStorage.js';
29+
import { IClipboardService } from '../../../platform/clipboard/common/clipboardService.js';
2930

3031
export interface AuthenticationInteractiveOptions {
3132
detail?: string;
@@ -94,6 +95,7 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu
9495
@ILogService private readonly logService: ILogService,
9596
@IURLService private readonly urlService: IURLService,
9697
@IDynamicAuthenticationProviderStorageService private readonly dynamicAuthProviderStorageService: IDynamicAuthenticationProviderStorageService,
98+
@IClipboardService private readonly clipboardService: IClipboardService
9799
) {
98100
super();
99101
this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostAuthentication);
@@ -188,6 +190,28 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu
188190
return await deferredPromise.p;
189191
}
190192

193+
$showContinueNotification(message: string): Promise<boolean> {
194+
const yes = nls.localize('yes', "Yes");
195+
const no = nls.localize('no', "No");
196+
const deferredPromise = new DeferredPromise<boolean>();
197+
let result = false;
198+
const handle = this.notificationService.prompt(
199+
Severity.Warning,
200+
message,
201+
[{
202+
label: yes,
203+
run: () => result = true
204+
}, {
205+
label: no,
206+
run: () => result = false
207+
}]);
208+
const disposable = handle.onDidClose(() => {
209+
deferredPromise.complete(result);
210+
disposable.dispose();
211+
});
212+
return deferredPromise.p;
213+
}
214+
191215
async $registerDynamicAuthenticationProvider(id: string, label: string, issuer: UriComponents, clientId: string): Promise<void> {
192216
await this.$registerAuthenticationProvider(id, label, false, [issuer]);
193217
this.dynamicAuthProviderStorageService.storeClientId(id, clientId, label, URI.revive(issuer).toString());
@@ -459,4 +483,30 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu
459483
}
460484

461485
//#endregion
486+
487+
async $showDeviceCodeModal(userCode: string, verificationUri: string): Promise<boolean> {
488+
const { result } = await this.dialogService.prompt({
489+
type: Severity.Info,
490+
message: nls.localize('deviceCodeTitle', "Device Code Authentication"),
491+
detail: nls.localize('deviceCodeDetail', "Your code: {0}\n\nTo complete authentication, navigate to {1} and enter the code above.", userCode, verificationUri),
492+
buttons: [
493+
{
494+
label: nls.localize('copyAndContinue', "Copy & Continue"),
495+
run: () => true
496+
}
497+
],
498+
cancelButton: true
499+
});
500+
501+
if (result) {
502+
// Open verification URI
503+
try {
504+
await this.clipboardService.writeText(userCode);
505+
return await this.openerService.open(URI.parse(verificationUri));
506+
} catch (error) {
507+
this.notificationService.error(nls.localize('failedToOpenUri', "Failed to open {0}", verificationUri));
508+
}
509+
}
510+
return false;
511+
}
462512
}

src/vs/workbench/api/common/extHost.api.impl.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ import { ExtHostNotebookKernels } from './extHostNotebookKernels.js';
7878
import { ExtHostNotebookRenderers } from './extHostNotebookRenderers.js';
7979
import { IExtHostOutputService } from './extHostOutput.js';
8080
import { ExtHostProfileContentHandlers } from './extHostProfileContentHandler.js';
81-
import { ExtHostProgress } from './extHostProgress.js';
81+
import { IExtHostProgress } from './extHostProgress.js';
8282
import { ExtHostQuickDiff } from './extHostQuickDiff.js';
8383
import { createExtHostQuickOpen } from './extHostQuickOpen.js';
8484
import { IExtHostRpcService } from './extHostRpcService.js';
@@ -147,6 +147,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
147147
const extHostSecretState = accessor.get(IExtHostSecretState);
148148
const extHostEditorTabs = accessor.get(IExtHostEditorTabs);
149149
const extHostManagedSockets = accessor.get(IExtHostManagedSockets);
150+
const extHostProgress = accessor.get(IExtHostProgress);
150151
const extHostAuthentication = accessor.get(IExtHostAuthentication);
151152
const extHostLanguageModels = accessor.get(IExtHostLanguageModels);
152153
const extHostMcp = accessor.get(IExtHostMpcService);
@@ -165,6 +166,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
165166
rpcProtocol.set(ExtHostContext.ExtHostTelemetry, extHostTelemetry);
166167
rpcProtocol.set(ExtHostContext.ExtHostEditorTabs, extHostEditorTabs);
167168
rpcProtocol.set(ExtHostContext.ExtHostManagedSockets, extHostManagedSockets);
169+
rpcProtocol.set(ExtHostContext.ExtHostProgress, extHostProgress);
168170
rpcProtocol.set(ExtHostContext.ExtHostAuthentication, extHostAuthentication);
169171
rpcProtocol.set(ExtHostContext.ExtHostChatProvider, extHostLanguageModels);
170172

@@ -204,7 +206,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
204206
const extHostQuickDiff = rpcProtocol.set(ExtHostContext.ExtHostQuickDiff, new ExtHostQuickDiff(rpcProtocol, uriTransformer));
205207
const extHostShare = rpcProtocol.set(ExtHostContext.ExtHostShare, new ExtHostShare(rpcProtocol, uriTransformer));
206208
const extHostComment = rpcProtocol.set(ExtHostContext.ExtHostComments, createExtHostComments(rpcProtocol, extHostCommands, extHostDocuments));
207-
const extHostProgress = rpcProtocol.set(ExtHostContext.ExtHostProgress, new ExtHostProgress(rpcProtocol.getProxy(MainContext.MainThreadProgress)));
208209
const extHostLabelService = rpcProtocol.set(ExtHostContext.ExtHostLabelService, new ExtHostLabelService(rpcProtocol));
209210
const extHostTheming = rpcProtocol.set(ExtHostContext.ExtHostTheming, new ExtHostTheming(rpcProtocol));
210211
const extHostTimeline = rpcProtocol.set(ExtHostContext.ExtHostTimeline, new ExtHostTimeline(rpcProtocol, extHostCommands));

0 commit comments

Comments
 (0)