Skip to content

Commit e63aebc

Browse files
authored
Merge pull request #99 from ls1intum/feat/server-side-logout
feat: server-side logout + developer JWT reveal command
2 parents 6ecc04a + 4aa9f2e commit e63aebc

6 files changed

Lines changed: 134 additions & 3 deletions

File tree

extension/package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,18 @@
8282
{
8383
"command": "artemis.clearTrustedDomains",
8484
"title": "Artemis: Clear Trusted Domains"
85+
},
86+
{
87+
"command": "artemis.showJwtToken",
88+
"title": "Artemis: Show JWT Token (Developer)",
89+
"icon": "$(key)"
8590
}
8691
],
8792
"menus": {
8893
"commandPalette": [
8994
{ "command": "artemis.login", "when": "!iris:managedEnvironment" },
90-
{ "command": "artemis.logout", "when": "!iris:managedEnvironment" }
95+
{ "command": "artemis.logout", "when": "!iris:managedEnvironment" },
96+
{ "command": "artemis.showJwtToken", "when": "config.artemis.developerMode" }
9197
]
9298
},
9399
"viewsContainers": {

extension/src/extension/activation/extensionCommands.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { IProviderRegistry } from '../services/ui';
66
import type { TelemetryManager } from '../services/telemetry';
77
import type { ArtemisWebviewProvider, ChatWebviewProvider } from '../provider';
88
import { logger, LogCategory } from '../services/loggingService';
9-
import { processPlantUml, normalizeRelativePath, extractErrorMessage } from '../utils';
9+
import { processPlantUml, normalizeRelativePath, extractErrorMessage, VSCODE_CONFIG } from '../utils';
1010
import { executeReplayCommand } from '../services/telemetry/replay';
1111

1212
// ── Individual command registrations ─────────────────────────────────
@@ -19,11 +19,15 @@ function registerLoginCommand(): vscode.Disposable {
1919

2020
function registerLogoutCommand(
2121
authManager: AuthManager,
22+
artemisApiService: ArtemisApiService,
2223
updateAuthContext: (isAuthenticated: boolean) => Promise<void>,
2324
artemisWebviewProvider: ArtemisWebviewProvider,
2425
): vscode.Disposable {
2526
return vscode.commands.registerCommand('artemis.logout', async () => {
2627
try {
28+
// Best-effort server-side logout before clearing local state.
29+
// Never throws — local cleanup proceeds regardless.
30+
await artemisApiService.logoutFromServer();
2731
await authManager.clear();
2832
await updateAuthContext(false);
2933
vscode.window.showInformationMessage('Successfully logged out of Artemis');
@@ -418,6 +422,50 @@ function registerReplaySessionCommand(globalStorageUri: vscode.Uri): vscode.Disp
418422
});
419423
}
420424

425+
/**
426+
* Developer-only command: copy the current raw JWT to the clipboard for use
427+
* in curl/Postman based server testing. Gated on the `artemis.developerMode`
428+
* setting both at the menu level (commandPalette `when` clause) and at runtime
429+
* (defense-in-depth against direct invocation via `vscode.commands.executeCommand`).
430+
*
431+
* The full token is NEVER shown in the UI or written to logs — only a masked
432+
* preview appears in the notification, and the full value lands in the clipboard.
433+
*/
434+
function registerShowJwtTokenCommand(authManager: AuthManager): vscode.Disposable {
435+
return vscode.commands.registerCommand('artemis.showJwtToken', async () => {
436+
const config = vscode.workspace.getConfiguration(VSCODE_CONFIG.ARTEMIS_SECTION);
437+
const developerMode = config.get<boolean>(VSCODE_CONFIG.DEVELOPER_MODE_KEY, false);
438+
if (!developerMode) {
439+
vscode.window.showErrorMessage(
440+
`Enable '${VSCODE_CONFIG.ARTEMIS_SECTION}.${VSCODE_CONFIG.DEVELOPER_MODE_KEY}' in settings to use this command.`
441+
);
442+
return;
443+
}
444+
445+
const hasToken = await authManager.hasAuthToken();
446+
if (!hasToken) {
447+
vscode.window.showInformationMessage('Not logged in to Artemis — no JWT to show.');
448+
return;
449+
}
450+
451+
const rawJwt = await authManager.getRawJwt();
452+
if (!rawJwt) {
453+
vscode.window.showErrorMessage('Failed to retrieve JWT token from secret storage.');
454+
logger.error('getRawJwt returned undefined despite hasAuthToken=true', LogCategory.AUTH);
455+
return;
456+
}
457+
458+
await vscode.env.clipboard.writeText(rawJwt);
459+
460+
const preview = `${rawJwt.substring(0, 20)}...`;
461+
logger.info(`JWT copied to clipboard via developer command (preview: ${preview})`, LogCategory.AUTH);
462+
463+
vscode.window.showWarningMessage(
464+
`JWT copied to clipboard (${preview}). Do not share, do not commit.`
465+
);
466+
});
467+
}
468+
421469
// ── Aggregate registration ───────────────────────────────────────────
422470

423471
export interface CommandDeps {
@@ -435,7 +483,7 @@ export interface CommandDeps {
435483
export function registerAllCommands(deps: CommandDeps): vscode.Disposable {
436484
return vscode.Disposable.from(
437485
registerLoginCommand(),
438-
registerLogoutCommand(deps.authManager, deps.updateAuthContext, deps.artemisWebviewProvider),
486+
registerLogoutCommand(deps.authManager, deps.artemisApiService, deps.updateAuthContext, deps.artemisWebviewProvider),
439487
registerResetIrisChatCommand(deps.chatWebviewProvider),
440488
registerIrisHealthCheckCommand(deps.authManager, deps.artemisApiService, deps.providerRegistry),
441489
registerWebSocketStatusCommand(deps.artemisWebsocketService),
@@ -445,5 +493,6 @@ export function registerAllCommands(deps: CommandDeps): vscode.Disposable {
445493
registerClearTrustedDomainsCommand(deps.context),
446494
registerStruggleScoreCommand(deps.telemetryManager),
447495
registerReplaySessionCommand(deps.context.globalStorageUri),
496+
registerShowJwtTokenCommand(deps.authManager),
448497
);
449498
}

extension/src/extension/api/artemisApi.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,58 @@ export class ArtemisApiService {
303303
return { success: true };
304304
}
305305

306+
/**
307+
* Inform the Artemis server that the user is logging out.
308+
*
309+
* Best-effort: this method never throws. The calling logout flow
310+
* must always clear local state regardless of the server response,
311+
* so any failure here is logged and swallowed.
312+
*
313+
* Uses a direct fetch instead of `makeRequest()` so a non-2xx
314+
* response does not trigger the shared 401 handler (which would
315+
* re-clear auth and fire the auth-expired callback — both
316+
* pointless and confusing during an intentional logout).
317+
*
318+
* Note: Artemis uses stateless JWTs without server-side blacklisting,
319+
* so this call does not invalidate the token on the server. It merely
320+
* tells Artemis "the user intentionally logged out" (audit logs) and
321+
* returns a Set-Cookie header that clears the jwt cookie in browser
322+
* clients. For this Extension the benefit is protocol symmetry with
323+
* the Artemis webapp.
324+
*/
325+
async logoutFromServer(): Promise<void> {
326+
const headers = await this.authManager.getAuthHeaders();
327+
if (Object.keys(headers).length === 0) {
328+
// Not authenticated — nothing to tell the server.
329+
return;
330+
}
331+
332+
try {
333+
const response = await fetch(`${this.getServerUrl()}${CONFIG.API.ENDPOINTS.LOGOUT}`, {
334+
method: 'POST',
335+
headers: {
336+
'Content-Type': 'application/json',
337+
'User-Agent': getUserAgent(),
338+
...headers,
339+
},
340+
});
341+
if (response.ok) {
342+
logger.info('Server-side logout successful', LogCategory.AUTH);
343+
} else {
344+
logger.warn(
345+
`Server-side logout returned ${response.status}, continuing with local cleanup`,
346+
LogCategory.AUTH
347+
);
348+
}
349+
} catch (err) {
350+
logger.warn(
351+
'Server-side logout failed, continuing with local cleanup',
352+
LogCategory.AUTH,
353+
err
354+
);
355+
}
356+
}
357+
306358
// Check Iris health status (course-scoped)
307359
async checkIrisHealth(courseId: number): Promise<IrisHealthStatus> {
308360
const response = await this.makeRequest(`/api/iris/courses/${courseId}/status`);

extension/src/extension/controller/commands/authCommands.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ export class AuthCommandModule {
5555

5656
private handleLogout = async (_message: WebviewToExtensionMessage): Promise<void> => {
5757
try {
58+
// Best-effort server-side logout before clearing local state.
59+
// Never throws — local cleanup proceeds regardless.
60+
await this.context.artemisApi.logoutFromServer();
5861
await this.context.authManager.clear();
5962
await this.context.updateAuthContext(false);
6063

extension/src/extension/services/auth/authManager.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,26 @@ export class AuthManager {
5757
return this.getStoredToken();
5858
}
5959

60+
/**
61+
* Returns the raw JWT string (without any "jwt=" cookie prefix), suitable
62+
* for use in a `Cookie: jwt=<value>` or `Authorization: Bearer <value>` header.
63+
*
64+
* Intended for developer/debug commands only — normal code paths should use
65+
* `getAuthHeaders()` instead so auth-mode handling stays centralized.
66+
*
67+
* Returns `undefined` if not authenticated.
68+
*/
69+
public async getRawJwt(): Promise<string | undefined> {
70+
const stored = await this.getStoredToken();
71+
if (!stored) {
72+
return undefined;
73+
}
74+
// Desktop mode stores the token as "jwt=<value>" (cookie string).
75+
// Theia mode stores the raw JWT directly. Strip the prefix if present.
76+
const prefix = `${CONFIG.AUTH_COOKIE_NAME}=`;
77+
return stored.startsWith(prefix) ? stored.substring(prefix.length) : stored;
78+
}
79+
6080
/**
6181
* Returns the stored token string.
6282
* In Desktop mode this is a cookie string ("jwt=<token>"),

extension/src/extension/utils/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export const CONFIG = {
1313
API: {
1414
ENDPOINTS: {
1515
AUTHENTICATE: '/api/core/public/authenticate',
16+
LOGOUT: '/api/core/public/logout',
1617
},
1718
},
1819
} as const;

0 commit comments

Comments
 (0)