From af158abf00960e7987ed8a0a6a96bf3b050bed1c Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 11 Apr 2026 09:13:10 +0900 Subject: [PATCH 1/3] fix: prevent silent reauth in non-teacher/devlogin mode and use lightweight refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes: 1. Skip attemptSilentReauth when mode is not 'teacher' or devlogin is used — prevents Google One Tap from appearing in student mode or when using dev bypass token 2. Auto-refresh (30s) now uses refreshMembersOnly instead of full loadClassroomDetail — only updates the members list without touching selectedClassroom, preserving: - Assignment name input field - Member detail pane (screenshot carousel index, comment input) - Any other UI state in the detail view 3. refreshMembersOnly uses JSON comparison to skip state updates when data hasn't changed, preventing unnecessary re-renders Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/containers/use-teacher-classroom.js | 74 ++++++++++++++++++- 1 file changed, 70 insertions(+), 4 deletions(-) diff --git a/packages/scratch-gui/src/containers/use-teacher-classroom.js b/packages/scratch-gui/src/containers/use-teacher-classroom.js index 303a7f09ccf..063ea541d86 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. @@ -509,11 +514,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 +588,7 @@ const useTeacherClassroom = ({ clearInterval(refreshTimerRef.current); } }; - }, [phase, selectedClassroom, idToken, loadClassroomDetail]); + }, [phase, selectedClassroom, idToken, refreshMembersOnly]); const handleBackToDashboard = useCallback(() => { clearError(); From d619ab2fce4f589214d3a93514f28cd367f7e170 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 11 Apr 2026 19:58:53 +0900 Subject: [PATCH 2/3] fix: clear expired idToken on failed reauth to stop auto-refresh loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When silent reauth fails, the expired idToken remained set, so the 30-second auto-refresh timer kept firing, causing repeated 401 → attemptSilentReauth → One Tap display → cancel cycles. Fix: set idToken to null when reauth fails. This stops the auto-refresh useEffect (condition requires idToken to be truthy) and prevents the One Tap loop. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/scratch-gui/src/containers/use-teacher-classroom.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/scratch-gui/src/containers/use-teacher-classroom.js b/packages/scratch-gui/src/containers/use-teacher-classroom.js index 063ea541d86..f07f4153e6b 100644 --- a/packages/scratch-gui/src/containers/use-teacher-classroom.js +++ b/packages/scratch-gui/src/containers/use-teacher-classroom.js @@ -223,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]); From 64a50857a7d3c9c95bb3742f9b31e49ebd24f140 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 11 Apr 2026 21:56:44 +0900 Subject: [PATCH 3/3] fix: clean up debug logs and add ID_TOKEN_MAX_AGE_SECONDS to Lambda - Remove console.log debug statements from handleTeacher401 and refreshMembersOnly - Add ID_TOKEN_MAX_AGE_SECONDS support to Lambda: custom iat-based token age check for shorter session testing - Pass ID_TOKEN_MAX_AGE_SECONDS env var through CDK stack conditionally Co-Authored-By: Claude Opus 4.6 (1M context) --- infra/smalruby-classroom/lambda/handler.ts | 12 ++++++++++++ infra/smalruby-classroom/lib/classroom-stack.ts | 1 + 2 files changed, 13 insertions(+) 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,