Skip to content

Commit 9c51e37

Browse files
authored
fix: save auth state between pd sessions in secure storage (#88)
Signed-off-by: Denis Golovin <[email protected]>
1 parent 9277f21 commit 9c51e37

7 files changed

+135
-122
lines changed

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"test": "vitest run --coverage"
3030
},
3131
"dependencies": {
32-
"@podman-desktop/api": "^1.6.4",
32+
"@podman-desktop/api": "0.0.202403201348-23ebd88",
3333
"@redhat-developer/rhcra-client": "^0.0.1",
3434
"@redhat-developer/rhsm-client": "^0.0.4 ",
3535
"@types/node": "^18.15.11",

src/authentication-service.spec.ts

+87-6
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,37 @@
1616
* SPDX-License-Identifier: Apache-2.0
1717
***********************************************************************/
1818

19-
import { afterEach, expect, beforeEach, test, vi, vitest } from 'vitest';
20-
import { convertToSession } from './authentication-service';
19+
import { beforeEach, expect, test, vi } from 'vitest';
20+
import { RedHatAuthenticationService, convertToSession } from './authentication-service';
21+
import { AuthenticationProvider, ExtensionContext, authentication } from '@podman-desktop/api';
22+
import { TokenSet, Issuer, BaseClient } from 'openid-client';
23+
import { getAuthConfig } from './configuration';
2124

2225
vi.mock('@podman-desktop/api', async () => {
2326
return {
24-
EventEmitter: function() {}
27+
EventEmitter: vi.fn().mockImplementation(() => {
28+
return {
29+
fire: vi.fn(),
30+
};
31+
}),
32+
registry: {
33+
suggestRegistry: vi.fn(),
34+
},
35+
authentication: {
36+
registerAuthenticationProvider: vi.fn(),
37+
onDidChangeSessions: vi.fn(),
38+
getSession: vi.fn(),
39+
},
40+
commands: {
41+
registerCommand: vi.fn(),
42+
},
2543
};
2644
});
2745

46+
beforeEach(() => {
47+
vi.restoreAllMocks();
48+
});
49+
2850
test('An authentication token is converted to a session', () => {
2951
const token = {
3052
account: {
@@ -39,10 +61,69 @@ test('An authentication token is converted to a session', () => {
3961
expiresAt: Date.now() + 777777777,
4062
expiresIn: 777777,
4163
};
42-
const session = convertToSession(token)
64+
const session = convertToSession(token);
4365
expect(session.id).equals(token.sessionId);
4466
expect(session.accessToken).equals(token.accessToken);
4567
expect(session.idToken).equals(token.idToken);
46-
expect(session.account).equals(token.account);;
68+
expect(session.account).equals(token.account);
4769
expect(session.scopes).contain('openid');
48-
});
70+
});
71+
72+
test('Authentication service loads tokens form secret storage during initialization', async () => {
73+
vi.spyOn(Issuer, 'discover').mockImplementation(async (url: string) => {
74+
return {
75+
Client: vi.fn().mockImplementation(() => {
76+
return {
77+
refresh: vi.fn().mockImplementation((refreshToken: string): TokenSet => {
78+
return {
79+
claims: vi.fn().mockImplementation(() => {
80+
return {
81+
sub: 'subscriptionId',
82+
preferred_username: 'username',
83+
};
84+
}),
85+
expired: vi.fn().mockImplementation(() => false),
86+
expires_in: 15 * 60, // in seconds
87+
id_token: 'id_token_string',
88+
access_token: 'access_token_string',
89+
refresh_token: 'refresh_token_string',
90+
session_state: 'session_state_string',
91+
};
92+
}),
93+
authorizationUrl: vi.fn().mockImplementation(() => {}),
94+
callback: vi.fn(),
95+
callbackParams: vi.fn(),
96+
};
97+
}),
98+
} as unknown as Issuer<BaseClient>;
99+
});
100+
101+
let provider: AuthenticationProvider;
102+
vi.mocked(authentication.registerAuthenticationProvider).mockImplementation((_id, _label, ssoProvider) => {
103+
provider = ssoProvider;
104+
return {
105+
dispose: vi.fn(),
106+
};
107+
});
108+
const extensionContext: ExtensionContext = {
109+
secrets: {
110+
get: vi.fn().mockImplementation(() => {
111+
return JSON.stringify([
112+
{
113+
refreshToken: 'refreshTokenString',
114+
scope: 'openid scope1 scope3 scope4',
115+
id: 'uniqueId1',
116+
},
117+
]);
118+
}),
119+
store: vi.fn(),
120+
delete: vi.fn(),
121+
onDidChange: vi.fn(),
122+
},
123+
subscriptions: [],
124+
} as unknown as ExtensionContext;
125+
const service = await RedHatAuthenticationService.build(extensionContext, getAuthConfig());
126+
await service.initialize();
127+
expect(extensionContext.secrets.get).toHaveBeenCalledOnce();
128+
expect((await service.getSessions(['scope1', 'scope3', 'scope4'])).length).toBe(0);
129+
});

src/authentication-service.ts

+20-13
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,11 @@
1919
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
2020
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
2121
*--------------------------------------------------------------------------------------------*/
22-
import { AuthenticationSession, window, EventEmitter, AuthenticationProviderAuthenticationSessionsChangeEvent, env, Uri } from '@podman-desktop/api';
22+
import { AuthenticationSession, window, EventEmitter, AuthenticationProviderAuthenticationSessionsChangeEvent, env, Uri, ExtensionContext, Disposable } from '@podman-desktop/api';
2323
import { ServerResponse } from 'node:http';
2424
import { Client, generators, Issuer, TokenSet } from 'openid-client';
2525
import { createServer, startServer } from './authentication-server';
2626
import { AuthConfig } from './configuration';
27-
import { Keychain } from './keychain';
2827
import Logger from './logger';
2928

3029
interface IToken {
@@ -92,11 +91,11 @@ export class RedHatAuthenticationService {
9291
private _tokens: IToken[] = [];
9392
private _refreshTimeouts: Map<string, NodeJS.Timeout> = new Map<string, NodeJS.Timeout>();
9493
//private _uriHandler: UriEventHandler;
94+
private _disposables: Disposable[] = [];
9595
private client: Client;
96-
private keychain: Keychain;
9796
private config: AuthConfig;
9897

99-
constructor(issuer: Issuer<Client>, config: AuthConfig) {
98+
constructor(issuer: Issuer<Client>, private context: ExtensionContext, config: AuthConfig) {
10099
//this._uriHandler = new UriEventHandler();
101100
//this._disposables.push(vscode.window.registerUriHandler(this._uriHandler));
102101
this.config = config;
@@ -105,19 +104,18 @@ export class RedHatAuthenticationService {
105104
response_types: ['code'],
106105
token_endpoint_auth_method: 'none',
107106
});
108-
this.keychain = new Keychain(config.serviceId);
109107
}
110108

111-
public static async build(config: AuthConfig): Promise<RedHatAuthenticationService> {
109+
public static async build(context: ExtensionContext, config: AuthConfig): Promise<RedHatAuthenticationService> {
112110
Logger.info(`Configuring ${config.serviceId} {auth: ${config.authUrl}, api: ${config.apiUrl}}`);
113111
const issuer = await Issuer.discover(config.authUrl);
114112

115-
const provider = new RedHatAuthenticationService(issuer, config);
113+
const provider = new RedHatAuthenticationService(issuer, context, config);
116114
return provider;
117115
}
118116

119117
public async initialize(): Promise<void> {
120-
const storedData = await this.keychain.getToken();
118+
const storedData = await this.context.secrets.get(this.config.serviceId);
121119
if (storedData) {
122120
try {
123121
const sessions = this.parseStoredData(storedData);
@@ -159,6 +157,10 @@ export class RedHatAuthenticationService {
159157
Logger.info('Failed to initialize stored data');
160158
await this.clearSessions();
161159
}
160+
161+
this._disposables.push(this.context.secrets.onDidChange(() => {
162+
this.checkForUpdates();
163+
}));
162164
}
163165
}
164166

@@ -167,7 +169,7 @@ export class RedHatAuthenticationService {
167169
}
168170

169171
private async storeTokenData(): Promise<void> {
170-
const serializedData: IStoredSession[] = this._tokens.map(token => {
172+
const storedSessions: IStoredSession[] = this._tokens.map(token => {
171173
return {
172174
id: token.sessionId,
173175
refreshToken: token.refreshToken,
@@ -176,13 +178,13 @@ export class RedHatAuthenticationService {
176178
};
177179
});
178180

179-
await this.keychain.setToken(JSON.stringify(serializedData));
181+
await this.context.secrets.store(this.config.serviceId, JSON.stringify(storedSessions));
180182
}
181183

182184
private async checkForUpdates(): Promise<void> {
183185
const added: RedHatAuthenticationSession[] = [];
184186
let removed: RedHatAuthenticationSession[] = [];
185-
const storedData = await this.keychain.getToken();
187+
const storedData = await this.context.secrets.get(this.config.serviceId);
186188
if (storedData) {
187189
try {
188190
const sessions = this.parseStoredData(storedData);
@@ -394,6 +396,11 @@ export class RedHatAuthenticationService {
394396
response.end();
395397
}
396398

399+
public dispose(): void {
400+
this._disposables.forEach(disposable => disposable.dispose());
401+
this._disposables = [];
402+
}
403+
397404
private async setToken(token: IToken, scope: string): Promise<void> {
398405
const existingTokenIndex = this._tokens.findIndex(t => t.sessionId === token.sessionId);
399406
if (existingTokenIndex > -1) {
@@ -546,7 +553,7 @@ export class RedHatAuthenticationService {
546553
session = convertToSession(token);
547554
}
548555
if (this._tokens.length === 0) {
549-
await this.keychain.deleteToken();
556+
await this.context.secrets.delete(this.config.serviceId);
550557
} else {
551558
this.storeTokenData();
552559
}
@@ -556,7 +563,7 @@ export class RedHatAuthenticationService {
556563
public async clearSessions() {
557564
Logger.info('Logging out of all sessions');
558565
this._tokens = [];
559-
await this.keychain.deleteToken();
566+
await this.context.secrets.delete(this.config.serviceId);
560567

561568
this._refreshTimeouts.forEach(timeout => {
562569
clearTimeout(timeout);

src/configuration.ts

+4-10
Original file line numberDiff line numberDiff line change
@@ -41,24 +41,18 @@ const CLIENT_ID = process.env.CLIENT_ID ? process.env.CLIENT_ID : 'podman-deskto
4141
console.log('REDHAT_AUTH_URL: ' + REDHAT_AUTH_URL);
4242
console.log('KAS_API_URL: ' + KAS_API_URL);
4343
console.log('CLIENT_ID: ' + KAS_API_URL);
44-
export async function getAuthConfig(): Promise<AuthConfig> {
44+
45+
export function getAuthConfig(): AuthConfig {
4546
return {
4647
serviceId: 'redhat-account-auth',
4748
authUrl: REDHAT_AUTH_URL,
4849
apiUrl: KAS_API_URL,
4950
clientId: CLIENT_ID,
50-
serverConfig: await getServerConfig(SSO_REDHAT),
51+
serverConfig: getServerConfig(SSO_REDHAT),
5152
};
5253
}
5354

54-
export async function getServerConfig(type: AuthType): Promise<ServerConfig> {
55-
// if (process.env['CHE_WORKSPACE_ID']) {
56-
// return getCheServerConfig(type);
57-
// }
58-
return getLocalServerConfig(type);
59-
}
60-
61-
async function getLocalServerConfig(type: AuthType): Promise<ServerConfig> {
55+
export function getServerConfig(type: AuthType): ServerConfig {
6256
return {
6357
callbackPath: `${type}-callback`,
6458
externalUrl: 'http://localhost',

src/extension.ts

+19-24
Original file line numberDiff line numberDiff line change
@@ -29,24 +29,11 @@ import { restartPodmanMachine, runRpmInstallSubscriptionManager, runSubscription
2929
import { SubscriptionManagerClient } from '@redhat-developer/rhsm-client';
3030
import { isLinux } from './util';
3131

32-
let loginService: RedHatAuthenticationService;
32+
let authenticationServicePromise: Promise<RedHatAuthenticationService>;
3333
let currentSession: extensionApi.AuthenticationSession | undefined;
3434

3535
async function getAuthenticationService() {
36-
if (!loginService) {
37-
const config = await getAuthConfig();
38-
loginService = await RedHatAuthenticationService.build(config);
39-
}
40-
return loginService;
41-
}
42-
43-
let authService: RedHatAuthenticationService;
44-
45-
async function getAuthService() {
46-
if (!authService) {
47-
authService = await getAuthenticationService();
48-
}
49-
return authService;
36+
return authenticationServicePromise;
5037
}
5138

5239
// function to encode file data to base64 encoded string
@@ -209,15 +196,22 @@ async function removeSession(sessionId: string): Promise<void> {
209196
runSubscriptionManagerUnregister()
210197
.catch(console.error); // ignore error in case vm subscription activation failed on login
211198
removeRegistry(); // never fails, even if registry does not exist
212-
const service = await getAuthService();
199+
const service = await getAuthenticationService();
213200
const session = await service.removeSession(sessionId);
214201
onDidChangeSessions.fire({ removed: [session!] });
215202
}
216203

217-
export async function activate(extensionContext: extensionApi.ExtensionContext): Promise<void> {
204+
export async function activate(context: extensionApi.ExtensionContext): Promise<void> {
218205
console.log('starting redhat-authentication extension');
219-
220-
extensionContext.subscriptions.push(extensionApi.registry.suggestRegistry({
206+
if (!authenticationServicePromise) {
207+
authenticationServicePromise = RedHatAuthenticationService.build(context, getAuthConfig())
208+
.then(service => {
209+
context.subscriptions.push(service);
210+
service.initialize();
211+
return service;
212+
});
213+
}
214+
context.subscriptions.push(extensionApi.registry.suggestRegistry({
221215
name: 'Red Hat Container Registry',
222216
icon: fileToBase64(path.resolve(__dirname,'..', 'icon.png')),
223217
url: 'registry.redhat.io',
@@ -228,13 +222,13 @@ export async function activate(extensionContext: extensionApi.ExtensionContext):
228222
'Red Hat SSO', {
229223
onDidChangeSessions: onDidChangeSessions.event,
230224
createSession: async function (scopes: string[]): Promise<extensionApi.AuthenticationSession> {
231-
const service = await getAuthService();
225+
const service = await getAuthenticationService();
232226
const session = await service.createSession(scopes.sort().join(' '));
233227
onDidChangeSessions.fire({ added: [session] });
234228
return session;
235229
},
236230
getSessions: async function (scopes: string[]): Promise<extensionApi.AuthenticationSession[]> {
237-
const service = await getAuthService();
231+
const service = await getAuthenticationService();
238232
return service.getSessions(scopes);
239233
},
240234
removeSession,
@@ -259,7 +253,7 @@ export async function activate(extensionContext: extensionApi.ExtensionContext):
259253

260254
await signIntoRedHatDeveloperAccount(false);
261255

262-
extensionContext.subscriptions.push(providerDisposable);
256+
context.subscriptions.push(providerDisposable);
263257

264258
const SignInCommand = extensionApi.commands.registerCommand('redhat.authentication.signin', async () => {
265259

@@ -309,7 +303,8 @@ export async function activate(extensionContext: extensionApi.ExtensionContext):
309303
});
310304

311305
const SignOutCommand = extensionApi.commands.registerCommand('redhat.authentication.signout', async () => {
312-
loginService.removeSession(currentSession!.id);
306+
const service = await getAuthenticationService();
307+
service.removeSession(currentSession!.id);
313308
onDidChangeSessions.fire({ added: [], removed: [currentSession!], changed: [] });
314309
currentSession = undefined;
315310
});
@@ -320,7 +315,7 @@ export async function activate(extensionContext: extensionApi.ExtensionContext):
320315
);
321316
});
322317

323-
extensionContext.subscriptions.push(SignInCommand, SignOutCommand, SignUpCommand, onDidChangeSessionDisposable);
318+
context.subscriptions.push(SignInCommand, SignOutCommand, SignUpCommand, onDidChangeSessionDisposable);
324319
}
325320

326321
export function deactivate(): void {

0 commit comments

Comments
 (0)