diff --git a/jupyterlab-markus-extension/src/__tests__/jupyterlab-markus-extension.test.ts b/jupyterlab-markus-extension/src/__tests__/jupyterlab-markus-extension.test.ts index 4c9a913..871bf5a 100644 --- a/jupyterlab-markus-extension/src/__tests__/jupyterlab-markus-extension.test.ts +++ b/jupyterlab-markus-extension/src/__tests__/jupyterlab-markus-extension.test.ts @@ -42,8 +42,10 @@ import { getNotebookName, getTrustedOrigins, normalizeBaseUrl, - parseMarkusId + parseMarkusId, + submitWithSessionRetry } from '../jupyterlab-markus-extension'; +import { invalidateSession } from '../session'; const mockGetBaseUrl = PageConfig.getBaseUrl as jest.Mock; const mockGetToken = PageConfig.getToken as jest.Mock; @@ -333,20 +335,20 @@ describe('buildSubmitPayload', () => { it('throws when the notebook path is unavailable', () => { const panel = makePanel({ path: '' }); - expect(() => buildSubmitPayload(panel, markus)).toThrow('Could not determine notebook path.'); + expect(() => buildSubmitPayload(panel, markus, 'session-token')).toThrow('Could not determine notebook path.'); }); it('throws when no Jupyter token is available', () => { mockGetToken.mockReturnValue(''); const panel = makePanel({ path: 'demo.ipynb', contentsModelName: 'demo.ipynb' }); - expect(() => buildSubmitPayload(panel, markus)).toThrow('No Jupyter token available.'); + expect(() => buildSubmitPayload(panel, markus, 'session-token')).toThrow('No Jupyter token available.'); }); - it('assembles the full payload from the panel, markus metadata, and PageConfig', () => { + it('assembles the full payload from the panel, markus metadata, PageConfig, and session token', () => { const panel = makePanel({ path: 'nested/demo.ipynb', contentsModelName: 'demo.ipynb' }); - expect(buildSubmitPayload(panel, markus)).toEqual({ + expect(buildSubmitPayload(panel, markus, 'session-token')).toEqual({ notebook_path: 'nested/demo.ipynb', course_id: 1, course: undefined, @@ -355,7 +357,114 @@ describe('buildSubmitPayload', () => { jupyter: { base_url: 'http://localhost:8888/', token: 'test-token' - } + }, + session_token: 'session-token' + }); + }); +}); + +describe('submitWithSessionRetry', () => { + const markus = { + url: 'http://retry.example.com/', + course_id: 1, + assignment_id: 2 + }; + + let mockFetch: jest.Mock; + + beforeEach(() => { + mockGetBaseUrl.mockReset().mockReturnValue('http://localhost:8888/'); + mockGetToken.mockReset().mockReturnValue('test-token'); + mockFetch = jest.fn(); + (global as any).fetch = mockFetch; + invalidateSession(markus); + }); + + function authResponse(sessionToken: string): { ok: true; status: 200; text: () => Promise } { + return { + ok: true, + status: 200, + text: async () => + JSON.stringify({ + status: 'success', + session_token: sessionToken, + expires_at: new Date(Date.now() + 60_000).toISOString() + }) + }; + } + + function submitSuccess(): { ok: true; status: 200; text: () => Promise } { + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ status: 'success', submitted_file: 'demo.ipynb' }) + }; + } + + function submitUnauthorized(): { ok: false; status: 401; text: () => Promise } { + return { + ok: false, + status: 401, + text: async () => JSON.stringify({ status: 'error', message: 'Session expired.', error_class: 'IdentityError' }) + }; + } + + it('authenticates then submits on the happy path', async () => { + const panel = makePanel({ path: 'demo.ipynb', contentsModelName: 'demo.ipynb' }); + mockFetch + .mockResolvedValueOnce(authResponse('sess-1')) + .mockResolvedValueOnce(submitSuccess()); + + const result = await submitWithSessionRetry(panel, markus); + + expect(result.submitted_file).toBe('demo.ipynb'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('re-authenticates and retries exactly once on a 401, succeeding the second time', async () => { + const panel = makePanel({ path: 'demo.ipynb', contentsModelName: 'demo.ipynb' }); + mockFetch + .mockResolvedValueOnce(authResponse('sess-1')) + .mockResolvedValueOnce(submitUnauthorized()) + .mockResolvedValueOnce(authResponse('sess-2')) + .mockResolvedValueOnce(submitSuccess()); + + const result = await submitWithSessionRetry(panel, markus); + + expect(result.submitted_file).toBe('demo.ipynb'); + expect(mockFetch).toHaveBeenCalledTimes(4); + + const secondSubmitBody = JSON.parse((mockFetch.mock.calls[3][1] as RequestInit).body as string); + expect(secondSubmitBody.session_token).toBe('sess-2'); + }); + + it('propagates the error if the retried submit also fails with a 401', async () => { + const panel = makePanel({ path: 'demo.ipynb', contentsModelName: 'demo.ipynb' }); + mockFetch + .mockResolvedValueOnce(authResponse('sess-1')) + .mockResolvedValueOnce(submitUnauthorized()) + .mockResolvedValueOnce(authResponse('sess-2')) + .mockResolvedValueOnce(submitUnauthorized()); + + await expect(submitWithSessionRetry(panel, markus)).rejects.toMatchObject({ + name: 'MarkUsServerError', + status: 401 + }); + expect(mockFetch).toHaveBeenCalledTimes(4); + }); + + it('does not retry on a non-401 failure', async () => { + const panel = makePanel({ path: 'demo.ipynb', contentsModelName: 'demo.ipynb' }); + mockFetch.mockResolvedValueOnce(authResponse('sess-1')).mockResolvedValueOnce({ + ok: false, + status: 403, + text: async () => JSON.stringify({ status: 'error', message: 'Not a student in this course.' }) + }); + + await expect(submitWithSessionRetry(panel, markus)).rejects.toMatchObject({ + name: 'MarkUsServerError', + status: 403 }); + expect(mockFetch).toHaveBeenCalledTimes(2); }); }); diff --git a/jupyterlab-markus-extension/src/__tests__/session.test.ts b/jupyterlab-markus-extension/src/__tests__/session.test.ts new file mode 100644 index 0000000..31def54 --- /dev/null +++ b/jupyterlab-markus-extension/src/__tests__/session.test.ts @@ -0,0 +1,170 @@ +// See the top of jupyterlab-markus-extension.test.ts for why PageConfig is +// mocked rather than imported for real. +jest.mock('@jupyterlab/coreutils', () => ({ + PageConfig: { + getBaseUrl: jest.fn(), + getToken: jest.fn() + } +})); + +import { PageConfig } from '@jupyterlab/coreutils'; + +import { authenticateWithMarkUs, getOrCreateSession, invalidateSession, MarkUsServerError } from '../session'; + +const mockGetBaseUrl = PageConfig.getBaseUrl as jest.Mock; +const mockGetToken = PageConfig.getToken as jest.Mock; + +describe('authenticateWithMarkUs', () => { + const markus = { + url: 'http://localhost:3000/', + course_id: 1, + assignment_id: 2 + }; + + let mockFetch: jest.Mock; + + beforeEach(() => { + mockGetBaseUrl.mockReset().mockReturnValue('http://localhost:8888/'); + mockGetToken.mockReset().mockReturnValue('test-token'); + mockFetch = jest.fn(); + (global as any).fetch = mockFetch; + }); + + it('posts the jupyter base_url/token and returns the parsed session response', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ + status: 'success', + session_token: 'sess-abc', + expires_at: '2026-08-25T12:15:00Z', + markus_user_name: 'c9user' + }) + }); + + const result = await authenticateWithMarkUs(markus); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:3000/jupyter/authenticate', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + jupyter: { base_url: 'http://localhost:8888/', token: 'test-token' } + }) + }) + ); + expect(result).toEqual({ + status: 'success', + session_token: 'sess-abc', + expires_at: '2026-08-25T12:15:00Z', + markus_user_name: 'c9user' + }); + }); + + it('throws a MarkUsServerError carrying the status and server message on failure', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + text: async () => + JSON.stringify({ status: 'error', message: 'Invalid Jupyter token.', error_class: 'IdentityError' }) + }); + + let caught: unknown; + try { + await authenticateWithMarkUs(markus); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(MarkUsServerError); + expect((caught as MarkUsServerError).status).toBe(401); + expect((caught as MarkUsServerError).message).toContain('Invalid Jupyter token.'); + }); + + it('throws when no Jupyter token is available', async () => { + mockGetToken.mockReturnValue(''); + await expect(authenticateWithMarkUs(markus)).rejects.toThrow('No Jupyter token available.'); + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); + +describe('getOrCreateSession / invalidateSession', () => { + // A distinct origin per describe block keeps the module-level session + // cache from leaking state between suites. + const markus = { + url: 'http://session-cache.example.com/', + course_id: 1, + assignment_id: 2 + }; + + let mockFetch: jest.Mock; + + beforeEach(() => { + mockGetBaseUrl.mockReset().mockReturnValue('http://localhost:8888/'); + mockGetToken.mockReset().mockReturnValue('test-token'); + mockFetch = jest.fn(); + (global as any).fetch = mockFetch; + invalidateSession(markus); + }); + + function mockAuthSuccess(sessionToken: string, expiresAt: string): void { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => JSON.stringify({ status: 'success', session_token: sessionToken, expires_at: expiresAt }) + }); + } + + it('authenticates once and reuses the cached token within its TTL', async () => { + mockAuthSuccess('sess-1', new Date(Date.now() + 60_000).toISOString()); + + const first = await getOrCreateSession(markus); + const second = await getOrCreateSession(markus); + + expect(first).toBe('sess-1'); + expect(second).toBe('sess-1'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('re-authenticates once the cached token is within the expiry safety margin', async () => { + mockAuthSuccess('sess-1', new Date(Date.now() + 5_000).toISOString()); + mockAuthSuccess('sess-2', new Date(Date.now() + 60_000).toISOString()); + + const first = await getOrCreateSession(markus); + const second = await getOrCreateSession(markus); + + expect(first).toBe('sess-1'); + expect(second).toBe('sess-2'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('re-authenticates after invalidateSession is called', async () => { + mockAuthSuccess('sess-1', new Date(Date.now() + 60_000).toISOString()); + mockAuthSuccess('sess-2', new Date(Date.now() + 60_000).toISOString()); + + const first = await getOrCreateSession(markus); + invalidateSession(markus); + const second = await getOrCreateSession(markus); + + expect(first).toBe('sess-1'); + expect(second).toBe('sess-2'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('throws when the response is missing session_token or expires_at', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => JSON.stringify({ status: 'success' }) + }); + + await expect(getOrCreateSession(markus)).rejects.toThrow(/missing "session_token" or "expires_at"/); + }); + + it('throws when expires_at cannot be parsed', async () => { + mockAuthSuccess('sess-1', 'not-a-date'); + + await expect(getOrCreateSession(markus)).rejects.toThrow(/invalid "expires_at" value/); + }); +}); diff --git a/jupyterlab-markus-extension/src/jupyterlab-markus-extension.ts b/jupyterlab-markus-extension/src/jupyterlab-markus-extension.ts index b24b343..15bce24 100644 --- a/jupyterlab-markus-extension/src/jupyterlab-markus-extension.ts +++ b/jupyterlab-markus-extension/src/jupyterlab-markus-extension.ts @@ -18,6 +18,14 @@ import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { Widget } from '@lumino/widgets'; +import { + MarkUsServerError, + extractErrorMessage, + getJupyterCredentials, + getOrCreateSession, + invalidateSession +} from './session'; + // This code never actually runs under Node (tsconfig deliberately omits // Node's ambient types to keep the global namespace browser-only) -- // `process.env.NODE_ENV` is a build-time string substituted in by the @@ -33,7 +41,7 @@ const PLUGIN_ID = 'jupyterlab-markus-extension:plugin'; const TRUSTED_ORIGINS_KEY = 'trustedOrigins'; // Creating the Metadata space -interface IMarkUsMetadata { +export interface IMarkUsMetadata { url: string; // course_id refers to Course.id @@ -62,6 +70,8 @@ interface ISubmitPayload { base_url: string; token: string; }; + + session_token: string; } // Creating the submission response space @@ -226,22 +236,17 @@ export function getMarkusMetadata(panel: NotebookPanel): IMarkUsMetadata { } // Compiling the submission payload -export function buildSubmitPayload(panel: NotebookPanel, markus: IMarkUsMetadata): ISubmitPayload { +export function buildSubmitPayload( + panel: NotebookPanel, + markus: IMarkUsMetadata, + sessionToken: string +): ISubmitPayload { const notebookPath = panel.context.path; if (!notebookPath) { throw new Error('Could not determine notebook path.'); } - const jupyterBaseUrl = PageConfig.getBaseUrl(); - const jupyterToken = PageConfig.getToken(); - - if (!jupyterToken) { - throw new Error( - 'No Jupyter token available. This environment may be using cookie/OAuth authentication. Token-based pull may not work.' - ); - } - return { notebook_path: notebookPath, @@ -250,10 +255,9 @@ export function buildSubmitPayload(panel: NotebookPanel, markus: IMarkUsMetadata assignment_id: markus.assignment_id, assignment: markus.assignment, - jupyter: { - base_url: jupyterBaseUrl, - token: jupyterToken - } + jupyter: getJupyterCredentials(), + + session_token: sessionToken }; } @@ -273,7 +277,10 @@ async function submitToServer(payload: ISubmitPayload, markus: IMarkUsMetadata): const text = await response.text(); if (!response.ok) { - throw new Error(`MarkUs server error ${response.status}: ${text}`); + throw new MarkUsServerError( + `MarkUs server error ${response.status}: ${extractErrorMessage(response.status, text)}`, + response.status + ); } try { @@ -286,6 +293,25 @@ async function submitToServer(payload: ISubmitPayload, markus: IMarkUsMetadata): } } +// Submits with a valid session token, re-authenticating and retrying exactly +// once if the session was rejected (expired/tampered/wrong origin/etc). +export async function submitWithSessionRetry(panel: NotebookPanel, markus: IMarkUsMetadata): Promise { + const sessionToken = await getOrCreateSession(markus); + + try { + const payload = buildSubmitPayload(panel, markus, sessionToken); + return await submitToServer(payload, markus); + } catch (error) { + if (!(error instanceof MarkUsServerError) || error.status !== 401) { + throw error; + } + + invalidateSession(markus); + const freshSessionToken = await getOrCreateSession(markus); + return await submitToServer(buildSubmitPayload(panel, markus, freshSessionToken), markus); + } +} + // Confirming the submission is successful async function reportSuccess(result: ISubmitResponse): Promise { let body = result.message || 'Your file has been submitted successfully.'; @@ -383,9 +409,7 @@ async function submitToMarkUs(tracker: INotebookTracker, settings: ISettingRegis return; } - const payload = buildSubmitPayload(panel, markus); - - const result = await submitToServer(payload, markus); + const result = await submitWithSessionRetry(panel, markus); await reportSuccess(result); } catch (error) { diff --git a/jupyterlab-markus-extension/src/session.ts b/jupyterlab-markus-extension/src/session.ts new file mode 100644 index 0000000..d9db8e4 --- /dev/null +++ b/jupyterlab-markus-extension/src/session.ts @@ -0,0 +1,129 @@ +import { PageConfig } from '@jupyterlab/coreutils'; + +import type { IMarkUsMetadata } from './jupyterlab-markus-extension'; + +// An error thrown by a MarkUs HTTP call, carrying the response status so +// callers can distinguish retryable failures (401) from everything else. +export class MarkUsServerError extends Error { + constructor( + message: string, + public readonly status: number + ) { + super(message); + this.name = 'MarkUsServerError'; + } +} + +// The authenticate response +interface ISessionResponse { + status: string; + session_token?: string; + expires_at?: string; + markus_user_name?: string; + message?: string; +} + +// Read a Jupyter base_url/token pair from PageConfig. +export function getJupyterCredentials(): { base_url: string; token: string } { + const jupyterBaseUrl = PageConfig.getBaseUrl(); + const jupyterToken = PageConfig.getToken(); + + if (!jupyterToken) { + throw new Error( + 'No Jupyter token available. This environment may be using cookie/OAuth authentication. Token-based pull may not work.' + ); + } + + return { base_url: jupyterBaseUrl, token: jupyterToken }; +} + +// Extract a human-readable message from a MarkUs error response body, +// which is JSON of the form {status, message, error_class}. +export function extractErrorMessage(status: number, text: string): string { + try { + const parsed = JSON.parse(text); + if (parsed && typeof parsed.message === 'string') { + return parsed.message; + } + } catch { + // Not JSON -- fall through to using the raw text below. + } + + return text || `HTTP ${status}`; +} + +// Authenticate with the MarkUs server to obtain a short-lived session token +export async function authenticateWithMarkUs(markus: IMarkUsMetadata): Promise { + const authUrl = new URL('jupyter/authenticate', markus.url).toString(); + + const response = await fetch(authUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify({ jupyter: getJupyterCredentials() }) + }); + + const text = await response.text(); + + if (!response.ok) { + throw new MarkUsServerError( + `MarkUs server error ${response.status}: ${extractErrorMessage(response.status, text)}`, + response.status + ); + } + + return JSON.parse(text) as ISessionResponse; +} + +// Cache of live session tokens, keyed by MarkUs origin. A single JupyterLab +// install could submit to more than one trusted MarkUs deployment, each needing +// its own session. +interface ISessionCacheEntry { + sessionToken: string; + expiresAt: number; // epoch ms +} + +const sessionCache = new Map(); + +// Don't reuse a token expiring within this margin, to avoid a race where it +// expires mid-request. +const SESSION_EXPIRY_SAFETY_MARGIN_MS = 30_000; + +function getMarkusOrigin(markus: IMarkUsMetadata): string { + return new URL(markus.url).origin; +} + +// Drop any cached session for this MarkUs origin, forcing the next +// getOrCreateSession call to re-authenticate. +export function invalidateSession(markus: IMarkUsMetadata): void { + sessionCache.delete(getMarkusOrigin(markus)); +} + +// Return a live session token for this MarkUs origin, reusing a cached one +// if it isn't close to expiring, otherwise authenticating for a fresh one. +export async function getOrCreateSession(markus: IMarkUsMetadata): Promise { + const origin = getMarkusOrigin(markus); + const cached = sessionCache.get(origin); + + if (cached && cached.expiresAt - SESSION_EXPIRY_SAFETY_MARGIN_MS > Date.now()) { + return cached.sessionToken; + } + + const response = await authenticateWithMarkUs(markus); + + if (!response.session_token || !response.expires_at) { + throw new Error('MarkUs authentication response is missing "session_token" or "expires_at".'); + } + + const expiresAt = Date.parse(response.expires_at); + + if (Number.isNaN(expiresAt)) { + throw new Error(`MarkUs authentication response has an invalid "expires_at" value: "${response.expires_at}".`); + } + + sessionCache.set(origin, { sessionToken: response.session_token, expiresAt }); + + return response.session_token; +}