Skip to content

Commit 74a3f54

Browse files
Add a command for removing a registration (microsoft#249765)
* Add a command for removing a registration And move code that needs to be moved into its own service to get this to work * fix tests
1 parent 87d1648 commit 74a3f54

File tree

7 files changed

+345
-43
lines changed

7 files changed

+345
-43
lines changed

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

Lines changed: 14 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,9 @@ import { CancellationError } from '../../../base/common/errors.js';
2323
import { ILogService } from '../../../platform/log/common/log.js';
2424
import { ExtensionHostKind } from '../../services/extensions/common/extensionHostKind.js';
2525
import { IURLService } from '../../../platform/url/common/url.js';
26-
import { DeferredPromise, Queue, raceTimeout } from '../../../base/common/async.js';
27-
import { ISecretStorageService } from '../../../platform/secrets/common/secrets.js';
28-
import { IAuthorizationTokenResponse, isAuthorizationTokenResponse } from '../../../base/common/oauth.js';
29-
import { IStorageService, StorageScope, StorageTarget } from '../../../platform/storage/common/storage.js';
26+
import { DeferredPromise, raceTimeout } from '../../../base/common/async.js';
27+
import { IAuthorizationTokenResponse } from '../../../base/common/oauth.js';
28+
import { IDynamicAuthenticationProviderStorageService } from '../../services/authentication/common/dynamicAuthenticationProviderStorage.js';
3029

3130
export interface AuthenticationInteractiveOptions {
3231
detail?: string;
@@ -94,8 +93,7 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu
9493
@IOpenerService private readonly openerService: IOpenerService,
9594
@ILogService private readonly logService: ILogService,
9695
@IURLService private readonly urlService: IURLService,
97-
@ISecretStorageService private readonly secretStorageService: ISecretStorageService,
98-
@IStorageService private readonly storageService: IStorageService,
96+
@IDynamicAuthenticationProviderStorageService private readonly dynamicAuthProviderStorageService: IDynamicAuthenticationProviderStorageService,
9997
) {
10098
super();
10199
this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostAuthentication);
@@ -107,33 +105,24 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu
107105
const providerInfo = this.authenticationService.getProvider(e.providerId);
108106
this._proxy.$onDidChangeAuthenticationSessions(providerInfo.id, providerInfo.label, e.extensionIds);
109107
}));
108+
109+
// Listen for dynamic authentication provider token changes
110+
this._register(this.dynamicAuthProviderStorageService.onDidChangeTokens(e => {
111+
this._proxy.$onDidChangeDynamicAuthProviderTokens(e.authProviderId, e.clientId, e.tokens);
112+
}));
113+
110114
this._register(authenticationService.registerAuthenticationProviderHostDelegate({
111115
// Prefer Node.js extension hosts when they're available. No CORS issues etc.
112116
priority: extHostContext.extensionHostKind === ExtensionHostKind.LocalWebWorker ? 0 : 1,
113117
create: async (serverMetadata) => {
114-
const clientId = storageService.get(`dynamicAuthClientId/${serverMetadata.issuer}`, StorageScope.APPLICATION, undefined);
118+
const clientId = this.dynamicAuthProviderStorageService.getClientId(serverMetadata.issuer);
115119
let initialTokens: (IAuthorizationTokenResponse & { created_at: number })[] | undefined = undefined;
116120
if (clientId) {
117-
initialTokens = await this._getSessionsForDynamicAuthProvider(serverMetadata.issuer, clientId);
121+
initialTokens = await this.dynamicAuthProviderStorageService.getSessionsForDynamicAuthProvider(serverMetadata.issuer, clientId);
118122
}
119123
return this._proxy.$registerDynamicAuthProvider(serverMetadata, clientId, initialTokens);
120124
}
121125
}));
122-
const queue = new Queue<void>();
123-
this._register(this.secretStorageService.onDidChangeSecret(async key => {
124-
let payload: { isDynamicAuthProvider: boolean; authProviderId: string; clientId: string } | undefined;
125-
try {
126-
payload = JSON.parse(key);
127-
} catch (error) {
128-
// Ignore errors... must not be a dynamic auth provider
129-
}
130-
if (payload?.isDynamicAuthProvider) {
131-
void queue.queue(async () => {
132-
const tokens = await this._getSessionsForDynamicAuthProvider(payload.authProviderId, payload.clientId);
133-
this._proxy.$onDidChangeDynamicAuthProviderTokens(payload.authProviderId, payload.clientId, tokens);
134-
});
135-
}
136-
}));
137126
}
138127

139128
async $registerAuthenticationProvider(id: string, label: string, supportsMultipleAccounts: boolean, supportedIssuers: UriComponents[] = []): Promise<void> {
@@ -197,29 +186,11 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu
197186

198187
async $registerDynamicAuthenticationProvider(id: string, label: string, issuer: UriComponents, clientId: string): Promise<void> {
199188
await this.$registerAuthenticationProvider(id, label, false, [issuer]);
200-
this.storageService.store(`dynamicAuthClientId/${id}`, clientId, StorageScope.APPLICATION, StorageTarget.MACHINE);
201-
}
202-
203-
private async _getSessionsForDynamicAuthProvider(authProviderId: string, clientId: string): Promise<(IAuthorizationTokenResponse & { created_at: number })[] | undefined> {
204-
const key = JSON.stringify({ isDynamicAuthProvider: true, authProviderId, clientId });
205-
const value = await this.secretStorageService.get(key);
206-
if (value) {
207-
const parsed = JSON.parse(value);
208-
if (!Array.isArray(parsed) || !parsed.every((t) => typeof t.created_at === 'number' && isAuthorizationTokenResponse(t))) {
209-
this.logService.error(`Invalid session data for ${authProviderId} (${clientId}) in secret storage:`, parsed);
210-
this.secretStorageService.delete(key);
211-
return undefined;
212-
}
213-
return parsed;
214-
}
215-
return undefined;
189+
this.dynamicAuthProviderStorageService.storeClientId(id, clientId, label, URI.revive(issuer).toString());
216190
}
217191

218192
async $setSessionsForDynamicAuthProvider(authProviderId: string, clientId: string, sessions: (IAuthorizationTokenResponse & { created_at: number })[]): Promise<void> {
219-
const key = JSON.stringify({ isDynamicAuthProvider: true, authProviderId, clientId });
220-
const value = JSON.stringify(sessions);
221-
await this.secretStorageService.set(key, value);
222-
this.logService.trace(`Set session data for ${authProviderId} (${clientId}) in secret storage:`, sessions);
193+
await this.dynamicAuthProviderStorageService.setSessionsForDynamicAuthProvider(authProviderId, clientId, sessions);
223194
}
224195

225196
private async loginPrompt(provider: IAuthenticationProvider, extensionName: string, recreatingSession: boolean, options?: AuthenticationInteractiveOptions): Promise<boolean> {

src/vs/workbench/api/test/browser/extHostAuthentication.integrationTest.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ import { IUserActivityService, UserActivityService } from '../../../services/use
4141
import { ExtHostUrls } from '../../common/extHostUrls.js';
4242
import { ISecretStorageService } from '../../../../platform/secrets/common/secrets.js';
4343
import { TestSecretStorageService } from '../../../../platform/secrets/test/common/testSecretStorageService.js';
44+
import { IDynamicAuthenticationProviderStorageService } from '../../../services/authentication/common/dynamicAuthenticationProviderStorage.js';
45+
import { DynamicAuthenticationProviderStorageService } from '../../../services/authentication/browser/dynamicAuthenticationProviderStorageService.js';
4446

4547
class AuthQuickPick {
4648
private listener: ((e: IQuickPickDidAcceptEvent) => any) | undefined;
@@ -119,6 +121,7 @@ suite('ExtHostAuthentication', () => {
119121
instantiationService.stub(IDialogService, new TestDialogService({ confirmed: true }));
120122
instantiationService.stub(IStorageService, new TestStorageService());
121123
instantiationService.stub(ISecretStorageService, new TestSecretStorageService());
124+
instantiationService.stub(IDynamicAuthenticationProviderStorageService, instantiationService.createInstance(DynamicAuthenticationProviderStorageService));
122125
instantiationService.stub(IQuickInputService, new AuthTestQuickInputService());
123126
instantiationService.stub(IExtensionService, new TestExtensionService());
124127

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { localize, localize2 } from '../../../../../nls.js';
7+
import { Action2 } from '../../../../../platform/actions/common/actions.js';
8+
import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js';
9+
import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js';
10+
import { IDynamicAuthenticationProviderStorageService, DynamicAuthenticationProviderInfo } from '../../../../services/authentication/common/dynamicAuthenticationProviderStorage.js';
11+
import { IAuthenticationService } from '../../../../services/authentication/common/authentication.js';
12+
import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';
13+
14+
interface IDynamicProviderQuickPickItem extends IQuickPickItem {
15+
provider: DynamicAuthenticationProviderInfo;
16+
}
17+
18+
export class RemoveDynamicAuthenticationProvidersAction extends Action2 {
19+
20+
static readonly ID = 'workbench.action.removeDynamicAuthenticationProviders';
21+
22+
constructor() {
23+
super({
24+
id: RemoveDynamicAuthenticationProvidersAction.ID,
25+
title: localize2('removeDynamicAuthProviders', 'Remove Dynamic Authentication Providers'),
26+
category: localize2('authenticationCategory', 'Authentication'),
27+
f1: true
28+
});
29+
}
30+
31+
async run(accessor: ServicesAccessor): Promise<void> {
32+
const quickInputService = accessor.get(IQuickInputService);
33+
const dynamicAuthStorageService = accessor.get(IDynamicAuthenticationProviderStorageService);
34+
const authenticationService = accessor.get(IAuthenticationService);
35+
const dialogService = accessor.get(IDialogService);
36+
37+
const interactedProviders = dynamicAuthStorageService.getInteractedProviders();
38+
39+
if (interactedProviders.length === 0) {
40+
await dialogService.info(
41+
localize('noDynamicProviders', 'No dynamic authentication providers'),
42+
localize('noDynamicProvidersDetail', 'No dynamic authentication providers have been used yet.')
43+
);
44+
return;
45+
}
46+
47+
const items: IDynamicProviderQuickPickItem[] = interactedProviders.map(provider => ({
48+
label: provider.label,
49+
description: localize('clientId', 'Client ID: {0}', provider.clientId),
50+
provider
51+
}));
52+
53+
const selected = await quickInputService.pick(items, {
54+
placeHolder: localize('selectProviderToRemove', 'Select a dynamic authentication provider to remove'),
55+
canPickMany: true
56+
});
57+
58+
if (!selected || selected.length === 0) {
59+
return;
60+
}
61+
62+
// Confirm deletion
63+
const providerNames = selected.map(item => item.provider.label).join(', ');
64+
const message = selected.length === 1
65+
? localize('confirmDeleteSingleProvider', 'Are you sure you want to remove the dynamic authentication provider "{0}"?', providerNames)
66+
: localize('confirmDeleteMultipleProviders', 'Are you sure you want to remove {0} dynamic authentication providers: {1}?', selected.length, providerNames);
67+
68+
const result = await dialogService.confirm({
69+
message,
70+
detail: localize('confirmDeleteDetail', 'This will remove all stored authentication data for the selected provider(s). You will need to re-authenticate if you use these providers again.'),
71+
primaryButton: localize('remove', 'Remove'),
72+
type: 'warning'
73+
});
74+
75+
if (!result.confirmed) {
76+
return;
77+
}
78+
79+
// Remove the selected providers
80+
for (const item of selected) {
81+
const providerId = item.provider.providerId;
82+
83+
// Unregister from authentication service if still registered
84+
if (authenticationService.isAuthenticationProviderRegistered(providerId)) {
85+
authenticationService.unregisterAuthenticationProvider(providerId);
86+
}
87+
88+
// Remove from dynamic storage service
89+
await dynamicAuthStorageService.removeDynamicProvider(providerId);
90+
}
91+
}
92+
}

src/vs/workbench/contrib/authentication/browser/authentication.contribution.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { ManageAccountPreferencesForExtensionAction } from './actions/manageAcco
2121
import { IAuthenticationUsageService } from '../../../services/authentication/browser/authenticationUsageService.js';
2222
import { ManageAccountPreferencesForMcpServerAction } from './actions/manageAccountPreferencesForMcpServerAction.js';
2323
import { ManageTrustedMcpServersForAccountAction } from './actions/manageTrustedMcpServersForAccountAction.js';
24+
import { RemoveDynamicAuthenticationProvidersAction } from './actions/manageDynamicAuthenticationProvidersAction.js';
2425

2526
const codeExchangeProxyCommand = CommandsRegistry.registerCommand('workbench.getCodeExchangeProxyEndpoints', function (accessor, _) {
2627
const environmentService = accessor.get(IBrowserWorkbenchEnvironmentService);
@@ -123,6 +124,7 @@ class AuthenticationContribution extends Disposable implements IWorkbenchContrib
123124
this._register(registerAction2(ManageAccountPreferencesForExtensionAction));
124125
this._register(registerAction2(ManageTrustedMcpServersForAccountAction));
125126
this._register(registerAction2(ManageAccountPreferencesForMcpServerAction));
127+
this._register(registerAction2(RemoveDynamicAuthenticationProvidersAction));
126128
}
127129

128130
private _clearPlaceholderMenuItem(): void {
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
7+
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
8+
import { IDynamicAuthenticationProviderStorageService, DynamicAuthenticationProviderInfo, DynamicAuthenticationProviderTokensChangeEvent } from '../common/dynamicAuthenticationProviderStorage.js';
9+
import { ISecretStorageService } from '../../../../platform/secrets/common/secrets.js';
10+
import { IAuthorizationTokenResponse, isAuthorizationTokenResponse } from '../../../../base/common/oauth.js';
11+
import { ILogService } from '../../../../platform/log/common/log.js';
12+
import { Emitter, Event } from '../../../../base/common/event.js';
13+
import { Disposable } from '../../../../base/common/lifecycle.js';
14+
import { Queue } from '../../../../base/common/async.js';
15+
16+
export class DynamicAuthenticationProviderStorageService extends Disposable implements IDynamicAuthenticationProviderStorageService {
17+
declare readonly _serviceBrand: undefined;
18+
19+
private static readonly PROVIDERS_STORAGE_KEY = 'dynamicAuthProviders';
20+
21+
private readonly _onDidChangeTokens = this._register(new Emitter<DynamicAuthenticationProviderTokensChangeEvent>());
22+
readonly onDidChangeTokens: Event<DynamicAuthenticationProviderTokensChangeEvent> = this._onDidChangeTokens.event;
23+
24+
constructor(
25+
@IStorageService private readonly storageService: IStorageService,
26+
@ISecretStorageService private readonly secretStorageService: ISecretStorageService,
27+
@ILogService private readonly logService: ILogService
28+
) {
29+
super();
30+
31+
// Listen for secret storage changes and emit events for dynamic auth provider token changes
32+
const queue = new Queue<void>();
33+
this._register(this.secretStorageService.onDidChangeSecret(async (key: string) => {
34+
let payload: { isDynamicAuthProvider: boolean; authProviderId: string; clientId: string } | undefined;
35+
try {
36+
payload = JSON.parse(key);
37+
} catch (error) {
38+
// Ignore errors... must not be a dynamic auth provider
39+
}
40+
if (payload?.isDynamicAuthProvider) {
41+
void queue.queue(async () => {
42+
const tokens = await this.getSessionsForDynamicAuthProvider(payload.authProviderId, payload.clientId);
43+
this._onDidChangeTokens.fire({
44+
authProviderId: payload.authProviderId,
45+
clientId: payload.clientId,
46+
tokens
47+
});
48+
});
49+
}
50+
}));
51+
}
52+
53+
getClientId(providerId: string): string | undefined {
54+
const providers = this._getStoredProviders();
55+
const provider = providers.find(p => p.providerId === providerId);
56+
return provider?.clientId;
57+
}
58+
59+
storeClientId(providerId: string, clientId: string, label?: string, issuer?: string): void {
60+
// Store provider information in single location
61+
this._trackProvider(providerId, clientId, label, issuer);
62+
}
63+
64+
private _trackProvider(providerId: string, clientId: string, label?: string, issuer?: string): void {
65+
const providers = this._getStoredProviders();
66+
67+
// Check if provider already exists
68+
const existingProviderIndex = providers.findIndex(p => p.providerId === providerId);
69+
if (existingProviderIndex === -1) {
70+
// Add new provider with provided or default info
71+
const newProvider: DynamicAuthenticationProviderInfo = {
72+
providerId,
73+
label: label || providerId, // Use provided label or providerId as default
74+
issuer: issuer || providerId, // Use provided issuer or providerId as default
75+
clientId
76+
};
77+
providers.push(newProvider);
78+
this._storeProviders(providers);
79+
} else {
80+
const existingProvider = providers[existingProviderIndex];
81+
// Create new provider object with updated info
82+
const updatedProvider: DynamicAuthenticationProviderInfo = {
83+
providerId,
84+
label: label || existingProvider.label,
85+
issuer: issuer || existingProvider.issuer,
86+
clientId
87+
};
88+
providers[existingProviderIndex] = updatedProvider;
89+
this._storeProviders(providers);
90+
}
91+
}
92+
93+
private _getStoredProviders(): DynamicAuthenticationProviderInfo[] {
94+
const stored = this.storageService.get(DynamicAuthenticationProviderStorageService.PROVIDERS_STORAGE_KEY, StorageScope.APPLICATION, '[]');
95+
try {
96+
return JSON.parse(stored);
97+
} catch {
98+
return [];
99+
}
100+
}
101+
102+
private _storeProviders(providers: DynamicAuthenticationProviderInfo[]): void {
103+
this.storageService.store(
104+
DynamicAuthenticationProviderStorageService.PROVIDERS_STORAGE_KEY,
105+
JSON.stringify(providers),
106+
StorageScope.APPLICATION,
107+
StorageTarget.MACHINE
108+
);
109+
}
110+
111+
getInteractedProviders(): ReadonlyArray<DynamicAuthenticationProviderInfo> {
112+
return this._getStoredProviders();
113+
}
114+
115+
async removeDynamicProvider(providerId: string): Promise<void> {
116+
// Get provider info before removal for secret cleanup
117+
const providers = this._getStoredProviders();
118+
const providerInfo = providers.find(p => p.providerId === providerId);
119+
120+
// Remove from stored providers
121+
const filteredProviders = providers.filter(p => p.providerId !== providerId);
122+
this._storeProviders(filteredProviders);
123+
124+
// Remove sessions from secret storage if we have the provider info
125+
if (providerInfo) {
126+
const secretKey = JSON.stringify({ isDynamicAuthProvider: true, authProviderId: providerId, clientId: providerInfo.clientId });
127+
await this.secretStorageService.delete(secretKey);
128+
}
129+
}
130+
131+
async getSessionsForDynamicAuthProvider(authProviderId: string, clientId: string): Promise<(IAuthorizationTokenResponse & { created_at: number })[] | undefined> {
132+
const key = JSON.stringify({ isDynamicAuthProvider: true, authProviderId, clientId });
133+
const value = await this.secretStorageService.get(key);
134+
if (value) {
135+
const parsed = JSON.parse(value);
136+
if (!Array.isArray(parsed) || !parsed.every((t) => typeof t.created_at === 'number' && isAuthorizationTokenResponse(t))) {
137+
this.logService.error(`Invalid session data for ${authProviderId} (${clientId}) in secret storage:`, parsed);
138+
await this.secretStorageService.delete(key);
139+
return undefined;
140+
}
141+
return parsed;
142+
}
143+
return undefined;
144+
}
145+
146+
async setSessionsForDynamicAuthProvider(authProviderId: string, clientId: string, sessions: (IAuthorizationTokenResponse & { created_at: number })[]): Promise<void> {
147+
const key = JSON.stringify({ isDynamicAuthProvider: true, authProviderId, clientId });
148+
const value = JSON.stringify(sessions);
149+
await this.secretStorageService.set(key, value);
150+
this.logService.trace(`Set session data for ${authProviderId} (${clientId}) in secret storage:`, sessions);
151+
}
152+
}
153+
154+
registerSingleton(IDynamicAuthenticationProviderStorageService, DynamicAuthenticationProviderStorageService, InstantiationType.Delayed);

0 commit comments

Comments
 (0)