diff --git a/infra/smalruby-classroom/lambda/handler.ts b/infra/smalruby-classroom/lambda/handler.ts index 9ef8e625ae9..dac5c857e6a 100644 --- a/infra/smalruby-classroom/lambda/handler.ts +++ b/infra/smalruby-classroom/lambda/handler.ts @@ -35,6 +35,11 @@ const CLASSROOM_TTL_DAYS = parseInt(process.env.CLASSROOM_TTL_DAYS || '30', 10); const CLASSROOM_TTL_SECONDS = CLASSROOM_TTL_DAYS * 24 * 60 * 60; // Session and membership TTL matches classroom TTL const SESSION_TTL_SECONDS = CLASSROOM_TTL_SECONDS; +// Google ID Token max age override (seconds). Default: undefined (use Google's standard 1-hour). +// Set to e.g. 120 for testing session expiry quickly. +const ID_TOKEN_MAX_AGE_SECONDS = process.env.ID_TOKEN_MAX_AGE_SECONDS + ? parseInt(process.env.ID_TOKEN_MAX_AGE_SECONDS, 10) + : undefined; // Rate limiting for join endpoint (per IP) const JOIN_RATE_LIMIT_WINDOW_SECONDS = parseInt(process.env.JOIN_RATE_LIMIT_WINDOW_SECONDS || '60', 10); const JOIN_RATE_LIMIT_MAX_ATTEMPTS = parseInt(process.env.JOIN_RATE_LIMIT_MAX_ATTEMPTS || '50', 10); @@ -226,6 +231,13 @@ export async function verifyGoogleIdToken(idToken: string): Promise { if (!payload || !payload.sub) { throw new AuthError('Invalid token payload'); } + // Custom max age check: reject tokens older than ID_TOKEN_MAX_AGE_SECONDS + if (ID_TOKEN_MAX_AGE_SECONDS && payload.iat) { + const tokenAge = Math.floor(Date.now() / 1000) - payload.iat; + if (tokenAge > ID_TOKEN_MAX_AGE_SECONDS) { + throw new AuthError(`Token too old: ${tokenAge}s > ${ID_TOKEN_MAX_AGE_SECONDS}s`); + } + } return payload.sub; } catch (err) { if (err instanceof AuthError) throw err; diff --git a/infra/smalruby-classroom/lib/classroom-stack.ts b/infra/smalruby-classroom/lib/classroom-stack.ts index eeff878d0b9..1e9658ee48a 100644 --- a/infra/smalruby-classroom/lib/classroom-stack.ts +++ b/infra/smalruby-classroom/lib/classroom-stack.ts @@ -207,6 +207,7 @@ export class ClassroomStack extends cdk.Stack { JOIN_RATE_LIMIT_WINDOW_SECONDS: process.env.JOIN_RATE_LIMIT_WINDOW_SECONDS || '60', JOIN_RATE_LIMIT_MAX_ATTEMPTS: process.env.JOIN_RATE_LIMIT_MAX_ATTEMPTS || '50', STAGE: stage, + ...(process.env.ID_TOKEN_MAX_AGE_SECONDS ? { ID_TOKEN_MAX_AGE_SECONDS: process.env.ID_TOKEN_MAX_AGE_SECONDS } : {}), }, bundling: { minify: true, diff --git a/packages/scratch-gui/src/containers/use-teacher-classroom.js b/packages/scratch-gui/src/containers/use-teacher-classroom.js index 303a7f09ccf..f07f4153e6b 100644 --- a/packages/scratch-gui/src/containers/use-teacher-classroom.js +++ b/packages/scratch-gui/src/containers/use-teacher-classroom.js @@ -160,6 +160,11 @@ const useTeacherClassroom = ({ // --- Teacher: Silent re-authentication on 401 --- const attemptSilentReauth = useCallback(async () => { + // Skip silent reauth in non-teacher mode or when using devlogin token + if (mode !== 'teacher') return null; + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.get('devlogin')) return null; + try { await loadGoogleIdentity(); const REAUTH_TIMEOUT_MS = 5000; @@ -206,7 +211,7 @@ const useTeacherClassroom = ({ } return null; } - }, []); + }, [mode]); /** * Handle a 401 error from a teacher API call. @@ -218,6 +223,9 @@ const useTeacherClassroom = ({ if (newToken) { return newToken; } + // Clear expired token to stop auto-refresh timer + setIdToken(null); + _cachedTeacherIdToken = null; showSessionExpiredError(); return null; }, [attemptSilentReauth, showSessionExpiredError]); @@ -509,11 +517,72 @@ const useTeacherClassroom = ({ setIsLoading(false); }, [selectedClassroom, clearError, loadClassroomDetail, setIsLoading]); - // Auto-refresh teacher detail + // Lightweight refresh: only update members list without touching + // selectedClassroom or triggering re-render of detail pane + const refreshMembersOnly = useCallback( + async classroomId => { + try { + const [membersData, submissionsData] = await Promise.all([ + classroomAPI.listMembers(idToken, classroomId), + classroomAPI.listSubmissions(idToken, classroomId), + ]); + const subMap = {}; + for (const sub of submissionsData.submissions || []) { + const existing = subMap[sub.memberId]; + if (!existing || sub.submittedAt > existing.submittedAt) { + subMap[sub.memberId] = sub; + } + } + const memberIds = new Set(); + const enriched = (membersData.members || []).map(m => { + memberIds.add(m.memberId); + const sub = subMap[m.memberId]; + return sub + ? { + ...m, + submissionId: sub.submissionId, + submissionStatus: sub.status || 'submitted', + thumbnailUrl: sub.thumbnailUrl || null, + projectUrl: sub.projectUrl || null, + projectName: sub.projectName || null, + screenshotUrls: sub.screenshotUrls || [], + teacherComment: sub.teacherComment || '', + } + : m; + }); + for (const [memberId, sub] of Object.entries(subMap)) { + if (!memberIds.has(memberId)) { + enriched.push({ + memberId, + hasSubmission: true, + submissionId: sub.submissionId, + submissionStatus: sub.status || 'submitted', + submittedAt: sub.submittedAt || null, + thumbnailUrl: sub.thumbnailUrl || null, + projectUrl: sub.projectUrl || null, + projectName: sub.projectName || null, + screenshotUrls: sub.screenshotUrls || [], + teacherComment: sub.teacherComment || '', + left: true, + }); + } + } + setMembers(prev => (JSON.stringify(prev) === JSON.stringify(enriched) ? prev : enriched)); + } catch (err) { + if (err.status === 401) { + await handleTeacher401(); + } + // Silently ignore other refresh errors + } + }, + [idToken, handleTeacher401], + ); + + // Auto-refresh teacher detail (members only — preserves detail pane state) useEffect(() => { if (phase === 'teacher-class-detail' && selectedClassroom && idToken) { refreshTimerRef.current = setInterval(() => { - loadClassroomDetail(selectedClassroom.classroomId); + refreshMembersOnly(selectedClassroom.classroomId); }, REFRESH_INTERVAL_MS); return () => clearInterval(refreshTimerRef.current); } @@ -522,7 +591,7 @@ const useTeacherClassroom = ({ clearInterval(refreshTimerRef.current); } }; - }, [phase, selectedClassroom, idToken, loadClassroomDetail]); + }, [phase, selectedClassroom, idToken, refreshMembersOnly]); const handleBackToDashboard = useCallback(() => { clearError();