Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions infra/smalruby-classroom/lambda/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -226,6 +231,13 @@ export async function verifyGoogleIdToken(idToken: string): Promise<string> {
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;
Expand Down
1 change: 1 addition & 0 deletions infra/smalruby-classroom/lib/classroom-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
77 changes: 73 additions & 4 deletions packages/scratch-gui/src/containers/use-teacher-classroom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -206,7 +211,7 @@ const useTeacherClassroom = ({
}
return null;
}
}, []);
}, [mode]);

/**
* Handle a 401 error from a teacher API call.
Expand All @@ -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]);
Expand Down Expand Up @@ -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);
}
Expand All @@ -522,7 +591,7 @@ const useTeacherClassroom = ({
clearInterval(refreshTimerRef.current);
}
};
}, [phase, selectedClassroom, idToken, loadClassroomDetail]);
}, [phase, selectedClassroom, idToken, refreshMembersOnly]);

const handleBackToDashboard = useCallback(() => {
clearError();
Expand Down
Loading